Контейнер - это не волшебная коробка. За каждым docker run стоит целая архитектура ядра Linux, которую многие принимают как данность. Я провёл несколько лет, разбирая production-инциденты, связанные с недостаточной изоляцией контейнеров, и могу сказать честно: базовые настройки Docker - это лишь отправная точка. Настоящая безопасность начинается там, где заканчивается документация для новичков.
Почему стандартных namespaces недостаточно
Когда контейнер стартует, Docker создаёт набор пространств имён: PID, NET, MNT, UTS, IPC и USER. Звучит солидно, но что происходит на практике? Контейнер по умолчанию работает от root внутри своего namespace, и этот root имеет прямое соответствие с root хостовой системы. Один неверный mount, одна уязвимость в приложении - и атакующий получает доступ к хосту.
Я настраиваю кастомные user namespaces через файл /etc/docker/daemon.json:
{
"userns-remap": "dockremap:dockremap"
}
После этого создаю mapping в /etc/subuid и /etc/subgid:
dockremap:100000:65536
Теперь root внутри контейнера (UID 0) маппится на непривилегированный UID 100000 на хосте. Даже если злоумышленник вырвется из контейнера, он окажется пользователем без реальных полномочий. Эта простая настройка закрыла мне несколько потенциальных векторов атак в реальных деплойментах.
Seccomp: фильтрация системных вызовов как искусство
Ядро Linux предоставляет около 400 системных вызовов. Типичному веб-приложению нужно от силы 50-70. Остальные - потенциальные точки входа для эксплойтов. Seccomp позволяет ограничить контейнер только необходимым минимумом syscalls.
Docker использует дефолтный seccomp-профиль, блокирующий около 44 опасных вызовов. Но я всегда создаю кастомные профили под конкретные сервисы. Вот фрагмент профиля для Node.js приложения:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{
"names": ["read", "write", "open", "close", "stat", "fstat", "mmap", "mprotect", "brk", "rt_sigaction", "rt_sigprocmask", "ioctl", "access", "pipe", "select", "sched_yield", "mremap", "munmap", "dup", "dup2", "socket", "connect", "accept", "sendto", "recvfrom", "bind", "listen", "epoll_create", "epoll_wait", "epoll_ctl", "clock_gettime", "futex", "exit_group"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Запуск контейнера с этим профилем:
docker run --security-opt seccomp=/path/to/profile.json myapp:latest
Принцип whitelist вместо blacklist - работает железно. Если приложение пытается вызвать что-то за пределами списка, вызов возвращает ошибку EPERM. Да, потребуется время на тестирование и отладку. Но после нескольких итераций получаешь контейнер, который физически не способен выполнить подозрительные операции.
OverlayFS: тонкости многослойной файловой системы
Каждый образ Docker - это стопка read-only слоёв с тонким writable слоем сверху. OverlayFS склеивает их в единое представление. Механизм элегантный, но скрывает подводные камни.
Проблема первая - inode exhaustion. Каждый файл в overlay потребляет inode на нижележащей файловой системе. При большом количестве контейнеров и активной работе с файлами можно исчерпать inode раньше, чем место на диске. Проверяю командой:
df -i /var/lib/docker/overlay2
Проблема вторая - copy-up операции. Когда контейнер модифицирует файл из нижнего слоя, весь файл копируется в upper слой. Для больших файлов это создаёт серьёзные задержки. Я стараюсь выносить такие файлы в отдельные volume:
docker run -v /data/logs:/app/logs myapp:latest
Третий нюанс - metacopy. Начиная с ядра 4.19, overlay поддерживает metacopy=on, копируя только метаданные при изменении атрибутов файла. Включается через опцию монтирования, но требует внимательного тестирования на совместимость.
Cgroups: управление ресурсами без компромиссов
Контейнер без лимитов - это бомба замедленного действия. Один процесс с утечкой памяти способен положить весь хост. Cgroups v2 предоставляет элегантную модель управления ресурсами, и я использую её на полную мощность.
Базовые лимиты задаю при запуске:
docker run --memory=512m --memory-swap=512m --cpus=1.5 --pids-limit=100 myapp:latest
Но настоящая гибкость появляется при работе с cgroup напрямую. После запуска контейнера можно найти его cgroup:
CONTAINER_ID=$(docker inspect --format='{{.Id}}' mycontainer)
cat /sys/fs/cgroup/docker/$CONTAINER_ID/memory.max
Интересная техника - memory.high вместо memory.max. Жёсткий лимит убивает процесс при превышении. Memory.high создаёт давление, заставляя процесс отдавать память, но не убивает его мгновенно:
echo "400M" > /sys/fs/cgroup/docker/$CONTAINER_ID/memory.high
echo "512M" > /sys/fs/cgroup/docker/$CONTAINER_ID/memory.max
Такой подход даёт приложению шанс на graceful degradation вместо внезапного OOM-kill.
Интеграция с Kubernetes: от теории к production
Kubernetes добавляет собственный слой абстракции, но все описанные механизмы работают и здесь. SecurityContext в Pod spec - точка входа для настройки изоляции.
Типичная конфигурация для production workload:
apiVersion: v1
kind: Pod
metadata:
name: secure-app
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
fsGroup: 2000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: myapp:latest
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL
resources:
limits:
memory: "512Mi"
cpu: "1"
requests:
memory: "256Mi"
cpu: "500m"
Обратите внимание на несколько ключевых моментов. RunAsNonRoot запрещает запуск от root даже внутри контейнера. ReadOnlyRootFilesystem блокирует запись в корневую файловую систему - приложение должно писать только в явно примонтированные volume. Capabilities drop ALL удаляет все Linux capabilities, делая контейнер максимально ограниченным.
Для более строгого seccomp использую кастомные профили через аннотации или RuntimeClass:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: hardened
handler: runc
scheduling:
nodeSelector:
security: hardened
Мониторинг и отладка изолированных контейнеров
Жёсткая изоляция создаёт новые вызовы при диагностике проблем. Несколько техник, которые выручали меня неоднократно:
Для отладки seccomp-блокировок использую strace с флагом -f внутри контейнера (если он доступен) или анализирую audit log на хосте:
ausearch -m seccomp --start today
Для анализа cgroup давления проверяю файлы pressure в cgroup:
cat /sys/fs/cgroup/docker/$CONTAINER_ID/memory.pressure
Вывод показывает, сколько времени процессы провели в ожидании из-за нехватки ресурсов - бесценная метрика для capacity planning.
При проблемах с overlay использую команду:
docker system df -v
Она показывает реальное потребление места каждым слоем и контейнером.
Практические рекомендации из боевого опыта
За годы работы я выработал несколько принципов, которые редко встречаются в официальной документации:
Никогда не доверяйте образам из публичных registry без проверки. Сканируйте на уязвимости, проверяйте Dockerfile, анализируйте слои. Инструменты типа Trivy или Grype должны быть частью CI/CD pipeline.
Используйте multi-stage builds для минимизации attack surface. Финальный образ должен содержать только runtime и артефакты приложения, никаких компиляторов и средств разработки.
Включайте user namespaces на уровне демона Docker, а не отдельных контейнеров. Это обеспечивает консистентную изоляцию для всего хоста.
Тестируйте seccomp профили в permissive режиме (SCMP_ACT_LOG вместо SCMP_ACT_ERRNO) перед переводом в enforcing. Соберите статистику по используемым syscalls, потом ужесточите политику.
Регулярно проверяйте состояние cgroups и overlay через системы мониторинга. Prometheus с node_exporter предоставляет метрики по cgroup, которые помогают предотвратить проблемы до их возникновения.
Контейнерная изоляция - это не конечная точка, а непрерывный процесс. Ядро Linux развивается, появляются новые возможности вроде io_uring, которые требуют пересмотра seccomp политик. Kubernetes добавляет механизмы типа Pod Security Standards. Угрозы эволюционируют, и защита должна эволюционировать вместе с ними. Главное помнить: безопасность по умолчанию - это миф. Настоящая защита требует понимания механизмов на уровне ядра и готовности инвестировать время в их правильную настройку.