Перейти к основному содержимому

Ubuntu 24.04: настройка Docker-хоста для production

·2016 слов·10 минут
Олег Казанин
Автор
Олег Казанин
Строю полезную инфраструктуру на Open Source стеке. Документирую грабли, чтобы вы на них не наступали.
Оглавление
Linux Hardening - Эта статья — часть серии.
Часть 4: Ты уже здесь

Ubuntu 24.04: настройка Docker-хоста для production
#

Статья предполагает что базовый hardening уже сделан по статье: SSH на нестандартном порту, UFW включён, fail2ban настроен. Если нет - сначала туда.

В этой статье - Docker и всё что нужно чтобы контейнеры не стали дырой в безопасности хоста.

Что получишь на выходе:

  • Docker установлен из официального репозитория (не snap)
  • daemon.json настроен безопасно - без userns-remap и icc:false по умолчанию
  • UFW и Docker работают вместе
  • systemd-resolved и DNS в контейнерах - рабочее решение
  • docker.sock - понимание риска и контроль доступа
  • Контейнеры изолированы по сетям, ограничены по ресурсам
  • Образы проверяются на уязвимости

Исходные данные
#

  • Ubuntu 24.04 LTS с выполненным базовым hardening
  • Минимум 2GB RAM, 20GB диска
  • Пользователь с sudo

Проверь версию:

lsb_release -a
Distributor ID: Ubuntu
Description:    Ubuntu 24.04.4 LTS
Release:        24.04
Codename:       noble
ВАЖНО!

Все команды выполняются с sudo если не указано иное. Если работаешь от root - sudo можно опустить.

Установка Docker
#

ОЧЕНЬ ВАЖНО!

Не используй snap версию в production!

В Ubuntu можно поставить Docker через snap (sudo snap install docker).

Snap-версия Docker работает в собственном confinement-окружении со своими путями (/var/snap/docker/), хуже интегрируется с системным iptables/UFW, и обновляется по графику snap, а не когда нужно тебе. Используй официальный репозиторий Docker - там стандартный путь /var/lib/docker, предсказуемое поведение, полный контроль над версией.

Проверь что snap-версии нет:

snap list | grep docker

Если есть - удали:

sudo snap remove docker

Удаление старых версий
#

sudo apt remove -y docker docker-engine docker.io containerd runc

Установка из официального репозитория
#

Зависимости

sudo apt install -y ca-certificates curl gnupg lsb-release

Создать директорию для ключей

sudo install -m 0755 -d /etc/apt/keyrings

Добавить GPG ключ Docker

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
  sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

Добавить репозиторий

echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null

Обновить индекс и установить

sudo apt update && sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Проверь версию:

docker --version

Ожидаем:

# Docker version 29.x.x, build xxxxxxx

Проверь daemon:

sudo systemctl status docker

Ожидаем:

● docker.service - Docker Application Container Engine
     Loaded: loaded (/usr/lib/systemd/system/docker.service; enabled; preset: enabled)
     Active: active (running) since Wed 2026-07-29 MSK; 3h 38min ago
TriggeredBy: ● docker.socket
       Docs: https://docs.docker.com
   Main PID: 182611 (dockerd)
      Tasks: 8
     Memory: 23.4M (peak: 24.0M)
        CPU: 2.661s
     CGroup: /system.slice/docker.service
             └─182611 /usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock

Добавление пользователя в группу docker
#

sudo usermod -aG docker $USER

Выйди и зайди снова (Выполни newgrp docker для текущей сессии без перелогина). Проверь:

docker run --rm hello-world

docker.sock - главный риск
#

Прежде чем настраивать daemon - разберёмся с самым опасным местом.

/var/run/docker.sock - Unix сокет Docker daemon. Любой процесс с доступом к нему имеет полный root на хосте. Через один вызов API можно смонтировать корень хоста и получить туда доступ:

# Пример атаки - одна команда и ты root на хосте
docker run --rm -v /:/host alpine chroot /host

Правила работы с docker.sock:

ВАЖНО

Никогда не монтируй docker.sock в контейнер без явной необходимости.

Если сервис требует его (Portainer, Watchtower, Traefik с автообнаружением) - этот сервис имеет root-доступ к хосту.

Проверь права на сокет:

ls -la /var/run/docker.sock
srw-rw---- 1 root docker 0 Jul 29 11:38 /var/run/docker.sock

Проверь кто в группе docker:

getent group docker

Только доверенные пользователи должны быть в этой группе - фактически это равно правам root.

Проверь какие контейнеры монтируют сокет:

docker ps -q | 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

Hardening 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 + лимиты - без них логи контейнеров забивают /var/lib/docker/containers/ за дни на активном сервисе.

live-restore: true - контейнеры продолжают работать пока daemon перезапускается.

userland-proxy: false - iptables вместо userland proxy, быстрее, меньше процессов.

no-new-privileges: true - контейнеры не поднимают привилегии через setuid/setgid бинарники.

storage-driver: overlay2 - зафиксирован явно, хотя на Ubuntu 24.04 он и так используется по умолчанию.

Почему нет userns-remap
#

Root в контейнере мапится на непривилегированный UID на хосте (например 100000). Звучит безопасно, но ломает bind mounts: файлы на хосте принадлежат UID 1000, контейнер видит UID 100000 и получает Permission denied. Каждый сервис с записью на хост требует chown -R 100000:100000, и пересчёт нужен для каждого нового сервиса.

ВАЖНО

Включай только если осознанно готов управлять UID маппингом для каждого тома.

Почему нет icc:false
#

Глобальный icc: false ломает общение контейнеров внутри одного docker-compose стека - Compose создаёт свою bridge сеть, и без icc контейнеры в ней не видят друг друга без явных --link.

Правильная изоляция - через отдельные сети Docker, не через глобальный флаг daemon. Раздел ниже.

Примени:

sudo systemctl restart docker

Проверь:

docker info | grep -E "Storage Driver|Logging Driver|live-restore"

sysctl для Docker-хоста
#

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

# Для Java приложений (Elasticsearch и подобных) в контейнерах
vm.max_map_count = 262144
sudo sysctl -p /etc/sysctl.d/99-docker.conf

systemd-resolved и DNS в контейнерах
#

Это самая частая проблема на Ubuntu специфично. Ubuntu 24.04 использует systemd-resolved - DNS резолвер слушает на 127.0.0.53. Контейнер не может достать этот адрес - 127.0.0.53 это loopback хоста, контейнер находится в своём network namespace и видит свой собственный loopback, не хостовый.

Симптом:

docker run --rm alpine nslookup google.com
# ;; connection timed out; no servers could be reached

Решение 1 (быстрое, для отдельных контейнеров):

docker run --dns 8.8.8.8 --dns 8.8.4.4 alpine nslookup google.com

Решение 2 (для всех контейнеров через daemon.json):

sudo nano /etc/docker/daemon.json

Добавь:

{
  "dns": ["8.8.8.8", "8.8.4.4"]
}
sudo systemctl restart docker

Решение 3 (если нужен внутренний DNS, не публичный):

Узнай реальный upstream DNS на хосте:

resolvectl status | grep "DNS Server"

Используй именно его IP (не 127.0.0.53) в daemon.json.

Docker Compose поддерживает то же самое на уровне сервиса:

services:
  app:
    dns:
      - 8.8.8.8
      - 8.8.4.4

UFW + Docker интеграция
#

Та же проблема что и на любом дистрибутиве - Docker пишет свои iptables правила напрямую, обходя UFW.

Подход 1 (рекомендуемый): порты только на localhost

# Плохо - обходит UFW
docker run -p 80:80 nginx

# Хорошо
docker run -p 127.0.0.1:80:80 nginx

Reverse proxy на хосте слушает 0.0.0.0:443, UFW разрешает только 443, контейнеры недоступны напрямую.

Подход 2: DOCKER-USER chain

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

-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
sudo ufw reload

Для большинства случаев достаточно подхода 1.

Сетевая изоляция контейнеров
#

Вместо глобального icc: false - отдельные сети под каждое приложение:

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

Контейнеры из разных сетей не видят друг друга. В docker-compose сети создаются автоматически для каждого стека - это и есть рабочая изоляция.

Безопасность образов
#

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

Перед деплоем:

trivy image --exit-code 1 --severity CRITICAL,HIGH nginx:alpine

Правило: CRITICAL уязвимости - образ не идёт в production.

Выбор образов
#

# Большой attack surface
FROM ubuntu:24.04

# Минимальный
FROM alpine:3.20

# Ещё меньше - нет shell, нет пакетного менеджера
FROM gcr.io/distroless/static-debian12

Конкретные теги, не latest:

docker pull nginx:1.27-alpine

Изоляция контейнеров
#

Capabilities
#

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE nginx

Read-only filesystem
#

docker run --read-only --tmpfs /tmp --tmpfs /var/run nginx

Seccomp
#

Docker применяет дефолтный seccomp профиль автоматически:

docker info | grep seccomp

Никогда не использовать --privileged
#

# НИКОГДА в production - отключает всю изоляцию
docker run --privileged nginx

Ресурсные ограничения
#

# Memory с soft limit
docker run -m 512m --memory-reservation 256m nginx

# CPU
docker run --cpus="0.5" nginx

# PID limit - защита от fork bomb
docker run --pids-limit 200 nginx

Проверка OOM:

dmesg | grep -i "oom\|killed process"
docker inspect CONTAINER --format='{{.State.OOMKilled}}'

Docker Compose с hardening
#

services:
  web:
    image: nginx:1.27-alpine
    container_name: web
    restart: unless-stopped

    ports:
      - "127.0.0.1:8080:80"

    dns:
      - 8.8.8.8
      - 8.8.4.4

    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"

    networks:
      - backend

  app:
    image: myapp:1.2.3
    container_name: app
    restart: unless-stopped

    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

volumes:
  db_data:

secrets:
  db_password:
    file: ./secrets/db_password.txt

internal: true для backend - база данных без доступа к интернету.

Регулярные задачи обслуживания
#

# Что занимает место
docker system df

# Очистка неиспользуемого
docker system prune -f

# Полная очистка включая неиспользуемые образы
docker system prune -a -f

# Volumes - ОСТОРОЖНО, удаляет данные безвозвратно
docker volume prune -f

Cron:

sudo crontab -e
0 4 * * 0 docker system prune -f >> /var/log/docker-prune.log 2>&1

Метрики:

docker stats --no-stream

Аудит через auditd
#

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 docker
sudo systemctl enable auditd
sudo systemctl restart auditd
sudo ausearch -k docker | tail -20

Troubleshooting
#

DNS не работает в контейнере
#

См. раздел “systemd-resolved и DNS в контейнерах” выше - это специфика Ubuntu, самая частая проблема на этом дистрибутиве.


Docker обходит UFW
#

Причина: Docker пишет напрямую в iptables.

Решение: 127.0.0.1:port:port или DOCKER-USER chain.


Permission denied на bind mount
#

Причина: UID процесса в контейнере не совпадает с владельцем файлов на хосте.

docker run --rm IMAGE id
sudo chown -R UID:GID /path/to/mount

OOM killer убивает контейнер
#

dmesg | grep -i "oom\|killed process"
docker inspect CONTAINER --format='{{.State.OOMKilled}}'

Увеличить memory limit или оптимизировать приложение.


newgrp docker не сработал после usermod
#

Симптом: docker: permission denied несмотря на usermod -aG docker $USER.

Причина: Группа применяется только к новым сессиям. Текущая сессия не знает о изменении.

Решение:

newgrp docker

Или просто выйти и зайти заново по SSH.


Диск забит логами
#

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 (уже настроено).

Финальная проверка
#

sudo systemctl status docker
docker info | grep -E "Storage Driver|Logging Driver|live-restore"
docker run --rm alpine nslookup google.com
ss -tulnp | grep docker
docker system df

Отличия от Debian 12
#

АспектDebian 12Ubuntu 24.04
Snap версия DockerНе существуетСуществует, избегать
DNS resolverОбычно прямой /etc/resolv.confsystemd-resolved (127.0.0.53), требует явной настройки DNS в Docker
Группа dockernewgrp docker работаетnewgrp docker работает, та же команда
UFWНужно устанавливатьПредустановлен
GPG ключ репозитория Dockerdownload.docker.com/linux/debian/gpgdownload.docker.com/linux/ubuntu/gpg

Что дальше
#

Docker-хост готов к production нагрузке.

  • Reverse proxy - Traefik или nginx
  • Мониторинг - Prometheus + cAdvisor + Grafana
  • Централизованные логи - Loki + Promtail
  • Секреты - HashiCorp Vault или Docker Secrets

Стек этой статьи: Ubuntu 24.04 LTS (Noble Numbat) · Docker CE · UFW · systemd-resolved · auditd · Trivy · docker-compose

Linux Hardening - Эта статья — часть серии.
Часть 4: Ты уже здесь

Статьи по теме