Debian 12: настройка Docker-хоста для production#
Статья предполагает что базовая настройка уже выполнена: SSH на порту 2222, UFW включён, fail2ban настроен, sysctl применён.
Если нет - сначала:
Здесь ставим Docker и закрываем типичные дыры: daemon без избыточных привилегий, UFW который не обходится Docker, изоляция контейнеров по сетям. Плюс разбираем docker.sock - самое опасное место на Docker-хосте.
Исходные данные#
- Debian 12 с выполненным базовым hardening
- Минимум 2GB RAM, 20GB диска
Проверь версию:
lsb_release -aDistributor ID: Debian
Description: Debian GNU/Linux 12 (bookworm)
Release: 12
Codename: bookwormВсе команды выполняются с sudo если не указано иное.
Установка Docker#
Удаление старых версий#
Если был установлен Docker из репозиториев Debian - удали. Версия там устаревшая:
sudo apt remove -y docker docker-engine docker.io containerd runc docker-compose docker-doc podman-dockerУстановка из официального репозитория#
Команды установки актуальны на момент написания статьи. Docker меняет процедуру добавления репозитория - перед установкой сверяйся с официальной документацией: docs.docker.com/engine/install/debian
# Установить зависимости
sudo apt update
sudo apt install -y ca-certificates curl
# Создать директорию для ключей
sudo install -m 0755 -d /etc/apt/keyrings
# Добавить GPG ключ Docker
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Добавить репозиторий
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
# Установить Docker
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-pluginПроверь версию:
docker --versionDocker version 29.x.x, build xxxxxxxПроверь что daemon запущен:
sudo systemctl status dockerДобавление пользователя в группу docker#
sudo usermod -aG docker $USER
# Активировать группу без перелогина
newgrp dockerВажно: членство в группе docker эквивалентно sudo без пароля - через docker.sock можно получить полный root на хосте. Добавляй только пользователей которым полностью доверяешь. Подробнее - в разделе про docker.sock ниже.
docker run --rm hello-worldОжидаемый вывод: Hello from Docker!
docker.sock - главный риск#
Прежде чем настраивать daemon - разберёмся с самым опасным местом.
/var/run/docker.sock - Unix сокет Docker daemon. Любой процесс с доступом к нему имеет полный root на хосте. Не “почти root” - именно root. Через один вызов API можно запустить контейнер с bind mount /:/host и получить доступ ко всей файловой системе хоста:
# Пример атаки - одна команда и ты root на хосте (выполнение не требуется)
docker run --rm -v /:/host alpine chroot /hostПроверь кто имеет доступ к сокету:
# Права на сокет
ls -la /var/run/docker.socksrw-rw---- 1 root docker 0 May 22 10:00 /var/run/docker.sockПроверь кто в группе docker:
getent group dockerТолько пользователи которым доверяешь полный root должны быть в этой группе.
Настройка daemon.json#
Создай файл:
sudo nano /etc/docker/daemon.json{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
},
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"storage-driver": "overlay2",
"default-ulimits": {
"nofile": {
"Name": "nofile",
"Hard": 65536,
"Soft": 65536
}
}
}Что и почему:
log-driver: json-file + max-size/max-file - ограничение логов контейнеров. Без этого /var/lib/docker/containers/ забивает весь диск за несколько дней на активном сервисе. 50MB × 3 файла = 150MB максимум на контейнер.
live-restore: true - контейнеры продолжают работать пока daemon перезапускается. Без этого systemctl restart docker роняет все контейнеры.
userland-proxy: false - iptables вместо userland proxy для проброса портов. Быстрее и меньше процессов.
no-new-privileges: true - контейнеры не могут поднять привилегии через setuid/setgid бинарники.
storage-driver: overlay2 - современный и быстрый драйвер. На Debian 12 используется по умолчанию, но лучше зафиксировать явно.
Почему нет userns-remap#
userns-remap - user namespace remapping. Root в контейнере (UID 0) маппится на непривилегированный UID на хосте (например 100000). Звучит безопасно.
На практике: ломает все bind mounts - файлы на хосте принадлежат UID 1000, а контейнер видит UID 100000 и получает Permission denied. Redis, PostgreSQL, nginx - у каждого свой UID, каждый нужно пересчитывать вручную через chown -R 100000:100000. Для production с несколькими сервисами это постоянная головная боль. Включай только если чётко понимаешь все последствия и готов управлять UID маппингом вручную.
Почему нет icc:false#
icc: false (inter-container communication) - глобальный запрет общения между контейнерами на уровне daemon.
Проблема: Docker Compose создаёт изолированную bridge сеть для каждого стека. Контейнеры одного стека (app + postgres + redis) общаются через эту сеть. С icc: false на уровне daemon они перестают видеть друг друга - стек перестаёт работать. Правильный подход - изоляция через сети Docker Compose. Описано ниже.
Примени конфиг:
sudo systemctl restart dockerПроверь что daemon запустился с нужными параметрами:
docker info | grep -E "Storage Driver|Logging Driver|Live Restore" Storage Driver: overlay2
Logging Driver: json-file
Live Restore Enabled: trueSysctl для Docker-хоста#
Docker требует дополнительных параметров ядра помимо базового hardening:
sudo nano /etc/sysctl.d/99-docker.conf# IP forwarding - обязательно для работы сети контейнеров
# Docker включает это сам при старте, фиксируем явно чтобы не зависеть от порядка сервисов
net.ipv4.ip_forward = 1
# IPv6 forwarding - только если используешь IPv6 в контейнерах
# net.ipv6.conf.all.forwarding = 1
# Лимиты inotify - для контейнеров которые следят за файлами
fs.inotify.max_user_instances = 512
fs.inotify.max_user_watches = 524288
# Для Elasticsearch и других Java приложений в контейнерах
vm.max_map_count = 262144Примени:
sudo sysctl -p /etc/sysctl.d/99-docker.confПроверь forwarding:
sudo sysctl net.ipv4.ip_forward
# net.ipv4.ip_forward = 1UFW + Docker#
Этот раздел - справочный. Конкретные настройки применяются при разворачивании каждого сервиса.
Docker создаёт свои iptables правила и обходит UFW. Порт опубликованный через -p 80:80 становится доступен снаружи даже если UFW его не разрешал. Это не баг - это архитектурное решение Docker.
Два подхода к решению:
Подход 1 - рекомендуемый: порты на localhost#
Публикуй порты только на localhost, снаружи открывай через reverse proxy (nginx, Traefik):
# Плохо - порт открыт на всех интерфейсах, обходит UFW
docker run -p 80:80 nginx
# Хорошо - порт только на localhost
docker run -p 127.0.0.1:80:80 nginxВ docker-compose.yml:
ports:
- "127.0.0.1:8080:80"Reverse proxy слушает на 0.0.0.0:443 и проксирует на 127.0.0.1:8080. UFW разрешает только 443. Docker UFW не обходит - нечего обходить.
Для большинства production сетапов достаточно этого подхода - он проще и надёжнее.
Подход 2: DOCKER-USER chain#
Docker оставляет пустую цепочку DOCKER-USER для пользовательских правил. Правила в ней применяются до правил Docker. Нужен когда по каким-то причинам нельзя использовать reverse proxy:
sudo nano /etc/ufw/after.rulesДобавь в конец файла:
# BEGIN UFW AND DOCKER
*filter
:DOCKER-USER - [0:0]
# Разрешить трафик из локальных сетей
-A DOCKER-USER -s 10.0.0.0/8 -j RETURN
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN
-A DOCKER-USER -s 192.168.0.0/16 -j RETURN
# Применить правила UFW к трафику идущему в контейнеры
-A DOCKER-USER -j ufw-user-forward
# Блокировать новые соединения снаружи к частным подсетям
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j DROP -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j RETURN
COMMIT
# END UFW AND DOCKERПерезагрузи UFW:
sudo ufw reloadСетевая изоляция контейнеров#
Этот раздел - справочный. Сети создаются при разворачивании каждого сервиса.
Вместо глобального icc: false - изоляция через сети.
По умолчанию все контейнеры запущенные без явной сети попадают в bridge сеть и видят друг друга. Создавай отдельную сеть для каждого приложения:
# Создать изолированную сеть для приложения
docker network create --driver bridge app_network
# Контейнеры видят только соседей по той же сети
docker run -d --name app --network app_network myapp
docker run -d --name db --network app_network postgresКонтейнеры из разных сетей не могут общаться - это и есть правильная изоляция без глобального icc: false.
В docker-compose.yml сети создаются автоматически для каждого стека. Явно задавай сети если нужна дополнительная изоляция внутри стека.
Безопасность образов#
Сканирование уязвимостей - Trivy#
# Добавить репозиторий Trivy
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 \
$(lsb_release -sc) main" | \
sudo tee /etc/apt/sources.list.d/trivy.list > /dev/null
sudo apt update
sudo apt install -y trivyПроверь образ:
trivy image nginx:alpineВывод:
admin@vps01:~$ trivy image nginx:alpine
2026-06-09T12:22:41+03:00 INFO [vulndb] Need to update DB
2026-06-09T12:22:41+03:00 INFO [vulndb] Downloading vulnerability DB...
2026-06-09T12:22:41+03:00 INFO [vulndb] Downloading artifact... repo="mirror.gcr.io/aquasec/trivy-db:2"
95.58 MiB / 95.58 MiB [---------------------------------------------------------------------] 100.00% 763.08 KiB p/s 2m8s
2026-06-09T12:24:52+03:00 INFO [vulndb] Artifact successfully downloaded repo="mirror.gcr.io/aquasec/trivy-db:2"
2026-06-09T12:24:52+03:00 INFO [vuln] Vulnerability scanning is enabled
2026-06-09T12:24:52+03:00 INFO [secret] Secret scanning is enabled
2026-06-09T12:24:52+03:00 INFO [secret] If your scanning is slow, please try '--scanners vuln' to disable secret scanning
2026-06-09T12:24:52+03:00 INFO [secret] Please see https://trivy.dev/docs/v0.71/guide/scanner/secret#recommendation for faster secret detection
2026-06-09T12:25:11+03:00 INFO Detected OS family="alpine" version="3.23.4"
2026-06-09T12:25:11+03:00 INFO [alpine] Detecting vulnerabilities... os_version="3.23" repository="3.23" pkg_num=71
2026-06-09T12:25:11+03:00 INFO Number of language-specific files num=0
Report Summary
┌──────────────────────────────┬────────┬─────────────────┬─────────┐
│ Target │ Type │ Vulnerabilities │ Secrets │
├──────────────────────────────┼────────┼─────────────────┼─────────┤
│ nginx:alpine (alpine 3.23.4) │ alpine │ 1 │ - │
└──────────────────────────────┴────────┴─────────────────┴─────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)
nginx:alpine (alpine 3.23.4)
Total: 1 (UNKNOWN: 0, LOW: 0, MEDIUM: 0, HIGH: 1, CRITICAL: 0)
┌─────────┬───────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────┐
│ Library │ Vulnerability │ Severity │ Status │ Installed Version │ Fixed Version │ Title │
├─────────┼───────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────┤
│ libxml2 │ CVE-2026-6732 │ HIGH │ fixed │ 2.13.9-r0 │ 2.13.9-r1 │ libxml2: libxml2: Denial of Service via crafted │
│ │ │ │ │ │ │ XSD-validated document │
│ │ │ │ │ │ │ https://avd.aquasec.com/nvd/cve-2026-6732 │
└─────────┴───────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────┘Status
fixed- исправление уже есть, обнови образ Statusaffected- исправления нет, принимай решение осознанно
Правило для production: образы с CRITICAL уязвимостями не идут в production. HIGH - анализировать и принимать решение.
Сканировать перед каждым деплоем:
# Выйти с ненулевым кодом если есть CRITICAL/HIGH - удобно для CI
trivy image --exit-code 1 --severity CRITICAL,HIGH nginx:alpineАудит событий Docker#
Auditd фиксирует все обращения к Docker на уровне ядра: запуск контейнеров, изменения конфигурации, доступ к docker.sock. Когда что-то пойдёт не так - это первое место где искать что произошло и когда.
sudo apt install -y auditdСоздай правила:
sudo nano /etc/audit/rules.d/docker.rules-w /usr/bin/docker -p rwxa -k docker
-w /var/lib/docker -p rwxa -k docker
-w /etc/docker -p rwxa -k docker
-w /var/run/docker.sock -p rwxa -k dockersudo systemctl enable auditd
sudo systemctl restart auditdПросматривай события:
sudo ausearch -k docker | tail -20Правила выбора образов#
Этот раздел - справочный. Применяй при выборе базового образа для своих Dockerfile.
Alpine-based или distroless - меньше attack surface:
# 200MB лишних пакетов и уязвимостей
FROM ubuntu:24.04
# 5MB, минимум уязвимостей
FROM alpine:3.20
# Ещё лучше - distroless (нет shell, нет пакетного менеджера)
FROM gcr.io/distroless/static-debian12Всегда указывай конкретный тег - никогда latest:
# Плохо - непредсказуемо, сломается при следующем релизе образа
docker pull nginx:latest
# Хорошо - предсказуемо
docker pull nginx:1.27-alpineИзоляция контейнеров#
Этот раздел - справочный.
Capabilities#
Контейнер по умолчанию имеет набор Linux capabilities которые ему не нужны. Убирай всё лишнее:
# Убрать все capabilities, добавить только нужные
docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginxМинимальный набор для большинства веб-сервисов: NET_BIND_SERVICE (порты < 1024). Если сервис не биндит привилегированные порты - вообще ничего добавлять не нужно, только --cap-drop=ALL.
Read-only filesystem#
# Контейнер не может писать в FS кроме явно указанных точек
docker run --read-only --tmpfs /tmp --tmpfs /var/run nginxВ docker-compose.yml:
read_only: true
tmpfs:
- /tmp
- /var/runНе все образы поддерживают read-only без дополнительной настройки - смотри документацию образа.
Seccomp#
Docker применяет seccomp профиль по умолчанию - блокирует ~44 опасных syscall. Проверь что профиль применяется:
docker info | grep seccomp
# Security Options: ... seccompНе запускай контейнеры с –privileged#
# НИКОГДА в production
docker run --privileged nginx--privileged даёт контейнеру доступ ко всем устройствам хоста и отключает все механизмы изоляции. Если видишь --privileged в чужом docker-compose - это красный флаг.
Ресурсные ограничения#
Этот раздел - справочный.
Без ограничений один контейнер может съесть всю память или CPU хоста и положить остальные сервисы.
OOM (Out Of Memory) killer - механизм ядра Linux: когда процесс превышает лимит памяти, ядро принудительно его завершает. Код завершения контейнера 137 (128 + сигнал 9) означает что сработал OOM killer, а не штатное завершение.
# Максимум 512MB RAM - если превысит, OOM (Out Of Memory) killer завершит контейнер
docker run -m 512m --memory-reservation 256m nginx
# Максимум 0.5 ядра
docker run --cpus="0.5" nginx
# Защита от fork bomb - контейнер не создает бесконечное количество процессов
docker run --pids-limit 200 nginxЕсли контейнер неожиданно останавливается - первым делом проверяй OOM (Out Of Memory):
dmesg | grep -i "oom\|killed process"
docker inspect CONTAINER --format='{{.State.OOMKilled}}'Docker Compose с настройками безопасности#
Этот раздел - справочный.
Шаблон docker-compose.yml для production: сети, лимиты, изоляция:
services:
web:
image: nginx:1.27-alpine
container_name: web
restart: unless-stopped
# Порт только на localhost - reverse proxy снаружи
ports:
- "127.0.0.1:8080:80"
# Ресурсные ограничения
deploy:
resources:
limits:
cpus: "0.5"
memory: 256M
reservations:
memory: 128M
# Изоляция
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
security_opt:
- no-new-privileges:true
pids_limit: 100
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
- frontend
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
volumes:
- db_data:/var/lib/postgresql/data
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- DAC_OVERRIDE
- CHOWN
security_opt:
- no-new-privileges:true
pids_limit: 200
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
# Только backend сеть - не доступен снаружи
networks:
- backend
app:
image: myapp:1.2.3
container_name: app
restart: unless-stopped
# Подключена к обеим сетям - мост между web и db
networks:
- frontend
- backend
depends_on:
- db
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 100
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # backend сеть без доступа к интернету
volumes:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txtinternal: true для backend сети - контейнеры в ней не имеют доступа к внешним сетям. База данных не должна ходить в интернет.
Регулярные задачи#
Этот раздел - справочный.
Проверка контейнеров с docker.sock#
Проверь запущенные контейнеры у которых docker.sock смонтирован:
# Контейнеры с docker.sock = контейнеры с root-доступом к хосту
docker ps --format '{{.Names}}' | xargs -I {} docker inspect {} \
--format '{{.Name}}: {{range .Mounts}}{{if eq .Source "/var/run/docker.sock"}}DOCKER_SOCK_MOUNTED{{end}}{{end}}' \
2>/dev/null | grep DOCKER_SOCK_MOUNTEDЗапускай после добавления каждого нового сервиса. Пустой вывод - норма.
Никогда не монтируй docker.sock в контейнер без явной необходимости. Если сервис требует docker.sock (Portainer, Watchtower, Traefik с автообнаружением) - осознавай что это сервис с root-доступом к хосту.
Очистка#
Docker накапливает мусор: остановленные контейнеры, неиспользуемые образы, dangling volumes:
# Показать что занимает место
docker system df
# Удалить всё неиспользуемое (остановленные контейнеры, dangling образы, неиспользуемые сети)
docker system prune -f
# Удалить всё включая неиспользуемые образы
docker system prune -a -f
# Удалить неиспользуемые volumes (ОСТОРОЖНО - данные удалятся безвозвратно)
docker volume prune -fАвтоматизируй через cron:
sudo crontab -e# Еженедельная очистка - каждое воскресенье в 4:00
0 4 * * 0 docker system prune -f >> /var/log/docker-prune.log 2>&1Мониторинг ресурсов#
# Статистика всех запущенных контейнеров в реальном времени
docker stats
# Один снимок без потока
docker stats --no-stream
# Конкретный контейнер
docker stats web --no-streamTroubleshooting#
Контейнер не видит DNS#
Симптом: curl: Could not resolve host: example.com внутри контейнера.
Причина: DNS не передан контейнеру.
Решение: Добавь DNS в daemon.json:
{
"dns": ["8.8.8.8", "8.8.4.4"]
}sudo systemctl restart dockerDocker обходит UFW#
Симптом: Порт опубликованный через -p 80:80 доступен снаружи несмотря на UFW deny.
Причина: Docker пишет напрямую в iptables, обходя UFW.
Решение: Использовать 127.0.0.1:80:80 вместо 80:80 - подход 1 из раздела UFW + Docker выше.
Permission denied на bind mount#
Симптом: Контейнер падает с Permission denied при обращении к примонтированной директории.
Причина: UID процесса в контейнере не совпадает с владельцем файлов на хосте.
Решение: Проверь под каким UID работает процесс в контейнере:
docker run --rm IMAGE idУстанови правильного владельца на хосте:
sudo chown -R UID:GID /path/to/mountOOM killer убивает контейнер#
Симптом: Контейнер неожиданно останавливается, docker ps показывает Exited (137).
Причина: Превышен memory limit, ядро завершило процесс.
Диагностика:
# Проверить OOM события
dmesg | grep -i "oom\|killed process"
# Статус контейнера
docker inspect CONTAINER --format='{{.State.OOMKilled}}'Решение: Увеличить memory limit или оптимизировать приложение.
Диск забит логами контейнеров#
Симптом: df -h показывает 100% на /var/lib/docker.
Диагностика:
# Найти самые большие лог файлы
sudo find /var/lib/docker/containers/ -name "*.log" -exec du -sh {} \; | sort -rh | head -10Экстренное решение:
# Обрезать лог конкретного контейнера (не удаляет файл)
sudo truncate -s 0 /var/lib/docker/containers/CONTAINER_ID/CONTAINER_ID-json.logПостоянное решение: max-size и max-file в daemon.json - уже настроено выше.
Финальная проверка#
# Статус daemon
sudo systemctl status docker
# Конфигурация
docker info | grep -E "Storage Driver|Logging Driver|Live Restore|no-new-privileges"
# Открытые порты
ss -tulnp | grep docker
# Размер данных Docker
docker system df
# Контейнеры с docker.sock
docker ps -q | xargs docker inspect --format '{{.Name}}: {{range .Mounts}}{{if eq .Source "/var/run/docker.sock"}}SOCK{{end}}{{end}}' 2>/dev/null | grep SOCKЧто дальше#
Docker-хост готов к production нагрузке. Следующий шаг - разворачиваем сервисы поверх этой базы.
- Peertube на VPS - следующая статья серии: собственный видеохостинг на этом Docker-хосте
- Reverse proxy - Traefik или nginx перед контейнерами
- Мониторинг - Prometheus + cAdvisor + Grafana
- Централизованные логи - Loki + Promtail
Стек этой статьи: Debian 12 (Bookworm) · Docker CE · UFW · auditd · Trivy · docker-compose







