Прямой ответ
Контейнер — не виртуалка: все контейнеры делят ядро хоста, и изоляция — namespaces + cgroups + capabilities/seccomp, а не гипервизор; отсюда практическая модель защиты: считать, что RCE в приложении даёт атакующему shell в контейнере, и сделать этот shell бесполезным. Чек-лист, закрывающий 90% риска: non-root пользователь в образе (USER app — escape из-под root в контейнере на порядок ближе к root на хосте), read-only файловая система (--read-only + tmpfs на /tmp — записать вебшелл/закрепиться некуда), сброшенные capabilities (--cap-drop=ALL + точечно вернуть нужное — дефолтный набор из 14 capability позволяет больше, чем нужно веб-приложению), без новых привилегий (--security-opt=no-new-privileges — setuid-эскалация отключена), лимиты ресурсов (--memory, --cpus — контейнер не съест хост и не убьёт соседей), секреты не в ENV образа (env виден в docker inspect и утекает в логи — секреты) и сканирование образов в CI (trivy/grype по слоям — CVE в базовом образе — аудит зависимостей). И граница честности: для запуска чужого недоверенного кода контейнера мало (нужны gVisor/Kata/VM) — hardening выше защищает от последствий взлома своего приложения, не от намеренного escape-кода.
Пользователь, ФС и capabilities
USER app: в Dockerfile после установки зависимостей — RUN useradd -u 10001 app && chown -R app /app + USER app (root-контейнер при пробое приложения даёт запись в системные пути и больший набор эксплойтов ядра; 10001+ — за пределами системных uid, меньше коллизий с mount'ами хоста). Read-only: docker run --read-only --tmpfs /tmp:size=100m — приложение с временными файлами работает, персистентно записать ничего нельзя; volumes — только осознанные (данные), не «потому что без них не стартует» (обычно не хватает как раз /tmp). Capabilities: ядро дробит root-права на ~40 мандатов, Docker по умолчанию даёт 14 (включая CHOWN, KILL, NET_BIND_SERVICE...); --cap-drop=ALL и --cap-add=NET_BIND_SERVICE (если нужен порт <1024 — хотя лучше слушать 8080 и терминировать на прокси — nginx) — принцип минимума. seccomp — фильтр syscall'ов (дефолтный профиль Docker блокирует ~50 опасных вызовов — не отключать --security-opt seccomp=unconfined без причины); no-new-privileges — запрещает setuid/capability-повышение при exec. Проверка текущего состояния — docker inspect (User, CapDrop, SecurityOpt) и линтеры образов (docker-bench-security для хоста, trivy config для Dockerfile — «USER отсутствует» ловится автоматически).
Сеть, ресурсы, секреты
Сеть: контейнеру наружу нужно редко (API партнёров — да, входящие — только через проброшенный порт прокси); --network=none для фоновых обработчиков без сети, internal-сети compose без шлюза для внутренних сервисов (сети); БД не публикует порт на 0.0.0.0 (только внутренняя сеть — файрвол хоста как второй слой). Ресурсы: --memory=512m --memory-swap=512m (swap отключён — предсказуемый OOM внутри контейнера вместо расползания на хост), --cpus=1.5, --pids-limit=256 (fork-бомба упирается в лимит), --restart=on-failure с оглядкой (бесконечный рестарт падающего контейнера — шторм; лимит попыток или алерт). Секреты: ENV в образе/рантайме — худшее место (docker inspect, /proc, утечка в стектрейсах); варианты по возрастанию зрелости: docker secrets (файлы в tmpfs — swarm/compose), монтирование Vault-агента/SOPS-файла (хранилища), env только на старте процесса из доверенного источника (systemd EnvironmentFile с правами 600 — компромисс для простых стеков). Образы: базовый — минимальный и поддерживаемый (slim/distroless — меньше CVE и поверхность; distroless — даже shell'а нет, закрепиться негде — отладка через ephemeral-контейнеры), тег по дайджесту/версии (python:3.12-slim, не latest), сборка из lock-файла с хешами (supply chain — аудит), скан в CI блокирует критичные CVE (multistage-сборка заодно убирает инструменты сборки из рантайма).
Хост и границы честности
Контейнеры сильны ровно настолько, насколько хост: ядро обновляется (escape-уязвимости ядра — общие для всех контейнеров — обновления), docker.sock не монтируется в контейнеры (доступ к сокету = root на хосте без эксплойтов — «удобный» монтаж сокета в CI-раннеры/панели — классическая дыра; если API Docker нужен — прокси с ACL), rootless Docker/user namespaces — uid 0 в контейнере маппится в непривилегированный uid хоста (дешёвое усиление, если дистрибутив поддерживает), доступ к docker-демону — только через sudo/группу с пониманием (группа docker эквивалентна root на хосте). Границы модели: контейнер защищает от ошибок и взлома своего кода, не от намеренного противника с кодом внутри (аренда исполнения чужих задач — только gVisor/Kata/Firecracker/VM — отдельный класс изоляции); мультиарендные платформы на голом Docker — известная ошибка архитектуры. И наблюдательность: аудит-события ядра/контейнеров (falco-подобные — «в контейнере появился shell/сетевое сканирование» — алерт), логи хоста в journald (настройка), инвентарь запущенного (docker ps с лимитами и пользователями — часть регулярного обхода).
Практический сценарий: hardening сервиса за вечер
Исходник: FastAPI-сервис в контейнере «как получилось» — root, writable, 340 МБ образ на python:3.12 (полный), секреты в docker run -e, БД с портом 5432 наружу «для отладки». Прогон: (1) trivy image — 18 критичных CVE в базовом слое — переход на python:3.12-slim + multistage (итог 90 МБ, критичных 0); (2) Dockerfile: useradd app, chown, USER app; compose: read_only + tmpfs /tmp, cap_drop ALL, no-new-privileges, memory 512m, pids-limit; (3) секреты — в EnvironmentFile systemd на хосте с 600 (compose env_file с правами), БД — только внутренняя сеть, публикация порта убрана (отладка — через docker exec/туннель — ssh-подход); (4) проверки: приложение стартует и отвечает (healthcheck compose — дисциплина); попытка записи в /app из приложения — EROFS (read-only подтверждён); docker exec -u 0 ... извне работает (хост-доступ — отдельная граница), но из приложения эскалации нет (no-new-privileges); (5) CI: trivy в пайплайне блокирует merge с критичными CVE, docker-bench-security — в еженедельный обход хоста. Время — вечер; эффект: взлом приложения теперь не даёт ни записи, ни root, ни данных БД наружу — стоимость атаки выросла на порядок ценой десятка строк конфигурации.
FAQ
Distroless-образы — почему не все так делают? Цена — отладка: в образе нет shell/утилит (kubectl debug/ephemeral-контейнеры решают, но требуют привычки), сложные рантаймы (нативные зависимости, subprocess-вызовы утилит) ломаются; для типового веб-сервиса — оправданы, для «приложение вызывает ffmpeg/graphicsmagick» — slim + аккуратность.
Достаточно ли non-root, если приложение слушает 8080? Non-root + 8080 — норма (порты <1024 — через прокси или NET_BIND_SERVICE capability); «root нужен, потому что приложение пишет в /root или /etc» — лечится переносом путей (HOME/конфиги в /app), не отказом от non-root.
Сканировать образы или зависимости? Оба слоя: trivy image ловит CVE в системных пакетах/базе (openssl, libc — то, что не видит pip-audit), pip-audit/trivy fs — прикладные зависимости; запускать оба в CI — они дополняют картину supply chain.
Что даёт rootless Docker и когда его включать? Демон и контейнеры работают от обычного пользователя — compromise демона/escape теряет root на хосте; включать на новых хостах по умолчанию (поддержка в современных дистрибутивах зрелая); ограничения — порты <1024 и часть сетевых режимов (rootless-сети через slirp4netns/pasta), для типового стека с прокси — незаметны.
Источники: документация Docker Engine Security, OWASP Docker Cheat Sheet, Docker build best practices — ссылки в шапке.