Безопасность Docker: лучшие практики
По умолчанию Docker настроен на удобство, а не на безопасность: процесс в контейнере запускается от root, сокет демона доступен любому, кто входит в группу docker, базовый образ не проверяется на уязвимости, а файловая система контейнера пишется свободно. Ничего из этого не ломает работу приложения — пока сервер не скомпрометируют. Ниже — практический чек-лист того, что стоит поправить в первую очередь, без теории про модели угроз.
Содержание
Не запускайте процесс в контейнере от root
Если в Dockerfile нет директивы USER, процесс внутри контейнера работает от root — и это не мелочь. Root в контейнере с примонтированным томом видит и меняет файлы хоста с правами root, даже если сам контейнер изолирован через namespaces. Побег из контейнера (через уязвимость в ядре или неудачную конфигурацию) от root даёт атакующему root на хосте, от непривилегированного пользователя — куда меньше.
Минимальная правка Dockerfile:
FROM node:20-slim
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --chown=app:app . .
RUN npm ci --omit=dev
USER app
CMD ["node", "server.js"]
Для образов, где нет своего useradd (например, минималистичные base-образы), пользователя можно задать по UID без создания записи в /etc/passwd:
USER 10001:10001
Если поправить Dockerfile нельзя (чужой образ), root можно ограничить на уровне запуска:
docker run --user 1000:1000 -d myimage
services:
app:
image: myimage
user: "1000:1000"
Проверить, от кого реально работает контейнер, можно так:
docker exec my_container id
Если видите uid=0(root) — контейнер не защищён этим пунктом, даже если в Dockerfile была директива USER, но образ переопределили флагом при запуске.
docker.sock внутри контейнера — это root на хосте
Монтирование /var/run/docker.sock внутрь контейнера (частый паттерн для Portainer, CI-раннеров, watchtower-подобных утилит) даёт этому контейнеру возможность управлять Docker-демоном хоста — создавать новые контейнеры с любыми привилегиями, монтировать любые пути хоста, читать чужие переменные окружения через docker inspect. По факту это равносильно root-доступу к серверу, только через промежуточный API.
# так делать не стоит без крайней необходимости
services:
ci-runner:
image: my-ci-runner
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Если контейнеру действительно нужно управлять другими контейнерами (CI, оркестрация), варианты по убыванию риска:
- Docker socket proxy — прослойка вроде
tecnativa/docker-socket-proxy, которая пропускает только разрешённые эндпоинты API (например, только чтение статусов, без создания контейнеров). - Rootless Docker — демон работает от непривилегированного пользователя, и утечка сокета не даёт root на хосте.
- Отдельная нода для сборки — вынести CI-раннеры на отдельный сервер, где компрометация не затронет продакшен-контейнеры.
Если выбора нет и сокет монтировать приходится — хотя бы не давайте контейнеру дополнительно privileged: true и не открывайте наружу порты этого контейнера без аутентификации.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSСканируйте образы на уязвимости до деплоя
Базовый образ тянет за собой сотни системных пакетов, и часть из них почти гарантированно содержит известные CVE на момент сборки. Проверка занимает секунды и должна быть частью пайплайна, а не разовой акцией.
Docker Scout встроен в современный Docker CLI:
docker scout cves myimage:latest
docker scout quickview myimage:latest
Trivy — отдельная утилита, работает и без демона Docker, удобна для CI:
trivy image --severity HIGH,CRITICAL myimage:latest
В GitHub Actions проверка образа перед публикацией выглядит примерно так:
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: "myimage:${{ github.sha }}"
severity: "CRITICAL,HIGH"
exit-code: "1"
exit-code: "1" останавливает деплой, если найдены критичные уязвимости — так проверка не превращается в отчёт, который никто не читает. На старте не обязательно блокировать релиз по каждой находке: многие CVE в базовых образах не эксплуатируемы в контексте конкретного приложения. Разумный первый шаг — блокировать только CRITICAL, остальное собирать в отчёт и разбирать по расписанию.
Не храните секреты в переменных окружения открытым текстом
environment: в docker-compose и флаг -e — самый частый способ передать пароль в контейнер, и самый ненадёжный: значение видно через docker inspect, попадает в ps aux на момент запуска и оседает в логах мониторинга. Это отдельная большая тема с конкретными механизмами (Docker secrets, bind-mount файлов с правами 600, интеграция с Vault) — подробный разбор с примерами для Swarm и обычного compose есть в статье Docker secrets: управление паролями. Здесь важно одно правило: если секрет виден в выводе docker inspect my_container --format='{{json .Config.Env}}' — он не защищён, вне зависимости от того, насколько сложный пароль вы придумали.
Read-only файловая система там, где приложению не нужно писать
Большинству процессов внутри контейнера не нужно менять файлы образа во время работы — им нужен только каталог для временных файлов, логов или кэша. Запрет записи в остальную файловую систему не даёт эксплойту закрепиться, подменить бинарник или дописать вредоносный код в рантайме.
docker run --read-only --tmpfs /tmp -d myimage
В compose с явным указанием, куда разрешена запись:
services:
app:
image: myimage
read_only: true
tmpfs:
- /tmp
volumes:
- app_data:/app/data
Каталоги, куда приложению реально нужна запись (логи, кэш, аплоады), оставляйте как обычные volume или bind-mount — read_only: true их не затрагивает. Проверить, что приложение вообще переживёт такой режим, стоит на стейджинге: некоторые фреймворки без предупреждения пишут временные файлы не в /tmp, а куда-то в /app или /var/cache, и это вскроется только логами вида EROFS: read-only file system.
Обновляйте базовые образы регулярно
Образ, собранный полгода назад, содержит все уязвимости, найденные в его пакетах за эти полгода — даже если код приложения не менялся ни разу. latest в теге базового образа не гарантирует автоматического обновления: тег фиксируется в момент сборки и дальше не меняется, пока вы не пересоберёте образ заново.
Практика, которая работает без лишней автоматизации:
- Пересобирайте образы на регулярной основе (еженедельно для внешних сервисов, ежемесячно — для внутренних), даже если код не менялся:
docker build --no-cache -t myapp:$(date +%Y%m%d) . - Используйте минимальные базовые образы (
alpine,slim, distroless) — меньше пакетов, меньше поверхность для CVE. - Пиньте базовый образ по digest в критичных сервисах, а обновление делайте осознанным PR, а не случайной подменой тега:
FROM node:20-slim@sha256:abcd1234...
- Инструменты вроде Renovate или Dependabot умеют сами открывать PR при выходе новой версии базового образа — это снимает ручной мониторинг, но финальное решение обновлять всё равно должно приниматься осознанно, а не автомёрджем на проде без проверки.
Автоматические обновления запущенных контейнеров (watchtower и аналоги) на проде — спорная практика: они экономят время, но могут выкатить breaking change среди ночи без возможности откатиться быстро. Разумнее держать пересборку образов регулярной, а обновление запущенных сервисов — контролируемым релизом, отдельным от настройки docker-compose для продакшена.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать VPSНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
С чего начать, если ничего из списка ещё не сделано?
С двух пунктов, которые дают больше всего защиты за меньше всего усилий: добавить USER в Dockerfile и убрать монтирование docker.sock там, где оно не обязательно. Остальное можно внедрять постепенно.
Обязательно ли использовать и Docker Scout, и Trivy?
Нет, любого из них достаточно. Docker Scout удобнее, если вы уже в экосистеме Docker Desktop/CLI, Trivy — если нужен инструмент без привязки к демону Docker, например для сканирования в CI на чистом раннере.
--read-only ломает контейнер — что делать?
Значит, приложению нужна запись куда-то ещё, кроме /tmp. Найдите каталог по логу ошибки read-only file system и добавьте под него отдельный volume или tmpfs, не отключая read-only целиком.
Rootless Docker — это то же самое, что запуск контейнера не от root?
Нет, это разные уровни. USER в Dockerfile меняет, от кого работает процесс внутри контейнера. Rootless-режим меняет, от кого работает сам демон Docker на хосте — это защищает от эскалации через сам демон, а не только через приложение внутри контейнера.
Нужно ли делать всё это сразу на одном сервере?
Нет, это чек-лист, а не требование внедрить всё разом. Каждый пункт снижает конкретный риск независимо от остальных — можно внедрять по одному, начиная с самых дешёвых по времени.