
Запуск контейнера от root с полным доступом к хостовой файловой системе - одна из самых распространённых ошибок в Docker-окружениях. Эта статья описывает конкретные меры: non-root пользователь, read-only filesystem, ограничение capabilities, сканирование образов через Trivy и изоляция сетей.
Среда: Ubuntu 22.04 LTS, Docker 26.1, containerd 1.7.
Non-root пользователь внутри контейнера
По умолчанию процессы внутри контейнера запускаются от root (UID 0). Если приложение скомпрометировано и злоумышленник выйдет из контейнера, он окажется root на хосте. Это нужно устранить на уровне Dockerfile.
FROM node:20-alpine
# Создаём непривилегированного пользователя
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -D appuser
WORKDIR /app
# Копируем файлы и меняем владельца
COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production
COPY --chown=appuser:appgroup . .
# Переключаемся на непривилегированного пользователя
USER appuser
EXPOSE 3000
CMD ["node", "server.js"]
Проверяем, от кого запущен процесс:
docker exec <container_id> whoami
# appuser
docker exec <container_id> id
# uid=1001(appuser) gid=1001(appgroup) groups=1001(appgroup)
Если образ уже собран без non-root пользователя, можно указать пользователя при запуске:
docker run --user 1001:1001 nginx:1.26

Read-only filesystem
Большинству приложений не нужна запись в файловую систему контейнера. Монтируем её как read-only и явно разрешаем запись только в нужные директории:
docker run -d \
--name my_app \
--read-only \
--tmpfs /tmp:rw,size=50m \
--tmpfs /var/run:rw,size=10m \
-v /app/logs:/app/logs:rw \
my_image:1.0
Флаг --read-only делает корневую файловую систему контейнера read-only. --tmpfs монтирует tmpfs в оперативную память для директорий, куда нужна запись (временные файлы, сокеты). Постоянные данные монтируются через -v.
В Docker Compose:
services:
app:
image: my_app:1.0
read_only: true
tmpfs:
- /tmp:size=50m
- /var/run:size=10m
volumes:
- ./logs:/app/logsОграничение Linux capabilities
Docker по умолчанию предоставляет контейнеру ~14 capabilities из 40+ существующих в Linux. Большинство приложений не нуждаются даже в них.
Хорошая практика - сбросить все capabilities и добавить только необходимые:
docker run -d \
--cap-drop ALL \
--cap-add NET_BIND_SERVICE \
--name nginx_secure \
nginx:1.26
NET_BIND_SERVICE нужен только для прослушивания портов ниже 1024. Если ваше приложение слушает на порту выше 1024, можно убрать и его.
В Docker Compose:
services:
nginx:
image: nginx:1.26
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
Проверяем, какие capabilities есть у контейнера:
docker inspect <container_id> | grep -A 10 "CapAdd\|CapDrop"
# Или через утилиту capsh внутри контейнера (если доступна)
docker exec <container_id> cat /proc/1/status | grep Cap

Сканирование образов через Trivy
Trivy - опенсорсный сканер уязвимостей от Aqua Security. Находит CVE в пакетах ОС, библиотеках языков программирования и конфигурационных файлах.
Установка Trivy
# Через apt (Ubuntu/Debian)
sudo apt install wget apt-transport-https gnupg -y
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | \
gpg --dearmor | sudo tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | \
sudo tee /etc/apt/sources.list.d/trivy.list
sudo apt update && sudo apt install trivy -y
trivy --version
# Version: 0.52.2Сканирование образа
# Сканируем образ до запуска
trivy image nginx:1.26
# Только CRITICAL и HIGH уязвимости
trivy image --severity CRITICAL,HIGH nginx:1.26
# Сканируем запущенный контейнер
trivy image --input <(docker save my_app:1.0)
# Вывод в JSON для CI/CD
trivy image --format json --output trivy-results.json nginx:1.26
Пример вывода:
nginx:1.26 (debian 12.6)
Total: 23 (CRITICAL: 2, HIGH: 8, MEDIUM: 13)
Интеграция в GitLab CI:
trivy_scan:
stage: security
image: aquasec/trivy:0.52.2
script:
- trivy image --exit-code 1 --severity CRITICAL $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
--exit-code 1 прерывает пайплайн при обнаружении CRITICAL-уязвимостей.
Изоляция сетей Docker
По умолчанию все контейнеры попадают в сеть bridge и могут общаться друг с другом без ограничений. Для изоляции создаём отдельные сети и подключаем к ним только те контейнеры, которым нужна связь.
# Создаём изолированные сети
docker network create --internal db_net
docker network create frontend_net
# запускаем БД только в db_net (без доступа к интернету - флаг --internal)
docker run -d \
--name postgres \
--network db_net \
postgres:16
# Приложение подключается к обеим сетям: к БД и к фронтенду
docker run -d \
--name app \
--network db_net \
my_app:1.0
docker network connect frontend_net app
# Nginx - только фронтенд сеть
docker run -d \
--name nginx \
--network frontend_net \
-p 80:80 \
nginx:1.26
В Docker Compose изоляция сетей выглядит так:
services:
nginx:
networks: [frontend]
app:
networks: [frontend, backend]
db:
networks: [backend]
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true
Флаг internal: true запрещает контейнерам в этой сети выходить в интернет. База данных изолирована полностью.
Дополнительные меры
No new privileges - запрещает процессу внутри контейнера повышать привилегии через setuid/setgid бинарники:
docker run --security-opt no-new-privileges:true nginx:1.26
Seccomp-профили ограничивают системные вызовы. Docker применяет профиль по умолчанию (~300 разрешённых из 400+), но можно усилить:
docker run --security-opt seccomp=/path/to/profile.json nginx:1.26

Обновляйте базовые образы. Большинство уязвимостей, которые находит Trivy, закрыты в более новых версиях пакетов. FROM node:20-alpine более безопасен, чем FROM node:20-bullseye - Alpine содержит значительно меньше пакетов.
Итог
Безопасность Docker-контейнеров строится на нескольких независимых уровнях. Non-root пользователь снижает последствия компрометации приложения. Read-only filesystem исключает изменение бинарных файлов через уязвимость. Ограничение capabilities убирает ненужные привилегии ядра. Trivy находит известные CVE до деплоя. Изоляция сетей ограничивает lateral movement при взломе одного контейнера. Ни одна из мер не абсолютна, но их совокупность создаёт реальный барьер.