Контейнер - это не волшебная коробка. За каждым docker run стоит целая архитектура ядра Linux, которую многие принимают как данность. Я провёл несколько лет, разбирая production-инциденты, связанные с недостаточной изоляцией контейнеров, и могу сказать честно: базовые настройки Docker - это лишь отправная точка. Настоящая безопасность начинается там, где заканчивается документация для новичков.

Почему стандартных namespaces недостаточно

Когда контейнер стартует, Docker создаёт набор пространств имён: PID, NET, MNT, UTS, IPC и USER. Звучит солидно, но что происходит на практике? Контейнер по умолчанию работает от root внутри своего namespace, и этот root имеет прямое соответствие с root хостовой системы. Один неверный mount, одна уязвимость в приложении - и атакующий получает доступ к хосту.

Я настраиваю кастомные user namespaces через файл /etc/docker/daemon.json:

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 приложения:

json
 
 
{
  "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"
    }
  ]
}

Запуск контейнера с этим профилем:

bash
 
 
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 раньше, чем место на диске. Проверяю командой:

bash
 
 
df -i /var/lib/docker/overlay2

Проблема вторая - copy-up операции. Когда контейнер модифицирует файл из нижнего слоя, весь файл копируется в upper слой. Для больших файлов это создаёт серьёзные задержки. Я стараюсь выносить такие файлы в отдельные volume:

bash
 
 
docker run -v /data/logs:/app/logs myapp:latest

Третий нюанс - metacopy. Начиная с ядра 4.19, overlay поддерживает metacopy=on, копируя только метаданные при изменении атрибутов файла. Включается через опцию монтирования, но требует внимательного тестирования на совместимость.

Cgroups: управление ресурсами без компромиссов

Контейнер без лимитов - это бомба замедленного действия. Один процесс с утечкой памяти способен положить весь хост. Cgroups v2 предоставляет элегантную модель управления ресурсами, и я использую её на полную мощность.

Базовые лимиты задаю при запуске:

bash
 
 
docker run --memory=512m --memory-swap=512m --cpus=1.5 --pids-limit=100 myapp:latest

Но настоящая гибкость появляется при работе с cgroup напрямую. После запуска контейнера можно найти его cgroup:

bash
 
 
CONTAINER_ID=$(docker inspect --format='{{.Id}}' mycontainer)
cat /sys/fs/cgroup/docker/$CONTAINER_ID/memory.max

Интересная техника - memory.high вместо memory.max. Жёсткий лимит убивает процесс при превышении. Memory.high создаёт давление, заставляя процесс отдавать память, но не убивает его мгновенно:

bash
 
 
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:

yaml
 
 
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:

yaml
 
 
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: hardened
handler: runc
scheduling:
  nodeSelector:
    security: hardened

Мониторинг и отладка изолированных контейнеров

Жёсткая изоляция создаёт новые вызовы при диагностике проблем. Несколько техник, которые выручали меня неоднократно:

Для отладки seccomp-блокировок использую strace с флагом -f внутри контейнера (если он доступен) или анализирую audit log на хосте:

bash
 
 
ausearch -m seccomp --start today

Для анализа cgroup давления проверяю файлы pressure в cgroup:

bash
 
 
cat /sys/fs/cgroup/docker/$CONTAINER_ID/memory.pressure

Вывод показывает, сколько времени процессы провели в ожидании из-за нехватки ресурсов - бесценная метрика для capacity planning.

При проблемах с overlay использую команду:

bash
 
 
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. Угрозы эволюционируют, и защита должна эволюционировать вместе с ними. Главное помнить: безопасность по умолчанию - это миф. Настоящая защита требует понимания механизмов на уровне ядра и готовности инвестировать время в их правильную настройку.