voidlearn
 

2.3Контейнер в работе

Проследишь, как контейнер общается с соседями, хранит данные и получает CPU и память. Проверишь остановку по сигналу и набор ограничений запуска, чтобы сервис переживал завершение без лишних прав.

Что сможешь объяснить после этой главы
  • Почему в дефолтной сети контейнеры не находят друг друга по имени, а в своей находят, и кто такой 127.0.0.11.
  • Что происходит с пакетом при -p 8080:8080, зачем нужен docker-proxy и почему опубликованный порт проходит мимо файрвола хоста.
  • Чем named volume отличается от bind mount и tmpfs и что с правами, когда USER 65532 пишет в каталог хоста.
  • Какие файлы cgroup пишут --memory, --cpus, --pids-limit и как отличить OOM от обычной остановки.
  • Почему docker stop здесь ждёт три секунды, а не десять, и как настроить graceful shutdown.
  • Что даёт --cap-drop ALL, --read-only, no-new-privileges и почему монтированный docker.sock равен root на хосте.

Прогоны сняты на Docker Desktop 4.91 (Docker Engine 29.8.0, containerd 2.3.4, ядро ВМ 7.0.12-linuxkit, cgroup v2, снапшоттер overlayfs), сервис написан на Go 1.27.1 и собран в distroless. Все контейнеры работают на ядре общей ВМ Docker Desktop, поэтому часть поведения (числа в /proc, docker-proxy, тайм-аут остановки) на сервере другая. Где это важно, рядом стоит прогон в docker:29.8.0-dind: полноценный dockerd 29.8 без настроек изображает Linux-сервер.

Дефолтный мост против своей сети

У контейнера свой сетевой namespace с интерфейсами, маршрутами и netfilter. Наружу он выходит через veth-пару: eth0 внутри контейнера, второй конец воткнут в мост на хосте. Контейнер без --network попадает в сеть bridge по умолчанию, то есть в мост docker0:

$ docker run -d --name dv-c3d-a dv-c3d-tools sleep infinity     # сеть bridge по умолчанию
$ docker exec dv-c3d-a ip -br link show eth0
eth0@if4498      UP             e6:6c:7f:5f:eb:20 <BROADCAST,MULTICAST,UP,LOWER_UP>

Суффикс @if4498 называет индекс пары. Второй конец лежит в namespace ВМ, и заглянуть туда можно только на чтение, через nsenter в namespace процесса 1:

# в сетевом namespace ВМ, только чтение
$ ip -o link show master docker0 | grep '^4498:'
4498: vethb328d3f@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 65535 qdisc noqueue master docker0 ...

Под индексом 4498 висит vethb328d3f, подключённый к docker0, а его @if11 указывает обратно на eth0 контейнера. Все контейнеры сети сидят на одном мосту и через него общаются.

По имени контейнеры дефолтной сети друг друга не находят: встроенного DNS там нет, запрос уходит резолверу хоста, а тот имён контейнеров не знает.

$ docker run --rm dv-c3d-tools getent hosts dv-c3d-a; echo "exit=$?"
exit=2
$ docker run --rm dv-c3d-tools cat /etc/resolv.conf
…
nameserver 192.168.65.7
…
# Based on host file: '/etc/resolv.conf' (legacy)
…

В пользовательской сети (её создаёт docker network create, такую же compose заводит под каждый проект) появляется встроенный DNS Docker на 127.0.0.11, и имена работают:

$ docker network create dv-c3d-net
$ docker run -d --name dv-c3d-b --network dv-c3d-net dv-c3d-tools sleep infinity
$ docker run --rm --network dv-c3d-net dv-c3d-tools cat /etc/resolv.conf
…
nameserver 127.0.0.11
options ndots:0
…
# Based on host file: '/etc/resolv.conf' (internal resolver)
# ExtServers: [host(192.168.65.7)]
…
$ docker run --rm --network dv-c3d-net dv-c3d-tools getent hosts dv-c3d-b
172.23.0.2      dv-c3d-b

На 127.0.0.11:53 слушает резолвер Docker, открытый в namespace контейнера. Свои имена он разрешает сам, остальное пересылает серверам из ExtServers. Дефолтный мост так не умеет (там были устаревшие --link), и документация Docker называет его наследием.

Контейнеры «не видят» друг друга

Два сервиса, запущенные docker run без общей сети, попадают в дефолтный bridge, и приложение не находит соседа по имени. Лечится общей пользовательской сетью или compose, а не адресом. Разные сети между собой не связаны: чтобы контейнер достучался в чужую сеть, его подключают и к ней (docker network connect).

Публикация порта: DNAT и docker-proxy

Внутри одной сети контейнеры общаются напрямую, а -p нужен, чтобы попасть в контейнер снаружи. Docker добавляет в таблицу nat правило DNAT: порт хоста превращается в адрес контейнера (сам механизм разобран в главе 1.3 статьи «Linux и ОС»). На чистом Linux-сервере (dind, dockerd 29.8 без настроек) правила такие:

$ docker run -d -p 18080:8080          app     # наружу на всех адресах
$ docker run -d -p 127.0.0.1:18081:8080 app    # только на localhost
$ iptables -t nat -S | grep DNAT
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 18080 -j DNAT --to-destination 172.18.0.2:8080
-A DOCKER -d 127.0.0.1/32 ! -i docker0 -p tcp -m tcp --dport 18081 -j DNAT --to-destination 172.18.0.3:8080

Пакет на 18080 получает в PREROUTING адрес 172.18.0.2:8080 и через FORWARD уходит к контейнеру. Правило для -p 127.0.0.1:18081:8080 добавляет условие -d 127.0.0.1/32: такой порт открыт только с самого хоста, а -p 8080:8080 выставляет сервис на все адреса сервера.

Рядом с DNAT работает docker-proxy, по процессу на каждый опубликованный порт и адрес: у -p 18080:8080 их два, на 0.0.0.0 и на ::. Он нужен соединениям хоста с самим собой: в цепочке OUTPUT правило стоит с условием ! -d 127.0.0.0/8, и loopback через DNAT не идёт. Такое соединение принимает docker-proxy и открывает своё до контейнера, поэтому приложение видит адрес моста, а не клиента.

Linux-хост, dockerd 29.8 с настройками по умолчанию: -p 18080:8080 сеть eth0 SYN от клиента dst хост:18080 PREROUTING, nat цепочка DOCKER: DNAT → 172.18.0.2:8080 FORWARD, filter DOCKER-USER, потом DOCKER ACCEPT tcp dpt:8080 INPUT правила хоста, ufw: адрес назначения уже не свой, пакет с DNAT сюда не попадает процесс на самом хосте localhost:18080 OUTPUT, nat ! -d 127.0.0.0/8 loopback без DNAT docker-proxy слушает 0.0.0.0:18080 и открывает соединение docker0 мост 172.18.0.1 veth-пара контейнер 172.18.0.2:8080 видит адрес клиента видит адрес 172.18.0.1 клиент не виден Таблицу nat проходит только первый пакет соединения, дальше адреса подменяет conntrack. Docker Desktop 4.91: userland-proxy выключен, поэтому и loopback внутри ВМ идёт через DNAT, а docker-proxy нет. Порт на самом Mac слушает com.docker.backend и пересылает соединение в ВМ. -p 127.0.0.1:18080:8080 добавляет к DNAT условие -d 127.0.0.1: из сети этот порт недоступен.
Опубликованный порт. Соединение из сети получает новый адрес назначения ещё до выбора маршрута и идёт через FORWARD, мимо INPUT с правилами файрвола. Соединение с localhost самого хоста принимает docker-proxy, и приложение видит адрес моста вместо клиента.
Почему на Docker Desktop нет docker-proxy

В ВМ Docker Desktop процессов docker-proxy нет: в её daemon.json стоит "userland-proxy": false. Правило в OUTPUT там без исключения 127.0.0.0/8, на docker0 включён route_localnet, и loopback внутри ВМ тоже идёт через DNAT. Порт на Mac слушает процесс com.docker.backend. На Linux-сервере userland-proxy по умолчанию включён.

localhost, host.docker.internal и сеть host

localhost в контейнере ведёт в его собственный loopback, а не на хост. До сервиса на хосте достают по имени host.docker.internal, и Docker Desktop разрешает его сам:

# на Mac поднят сервер на 127.0.0.1:18090
$ docker run --rm dv-c3d-tools sh -c 'curl -sS -m2 localhost:18090/; curl -sS host.docker.internal:18090/'
curl: (7) Failed to connect to localhost port 18090 after 0 ms: Could not connect to server
hello from the mac                                       # а через host.docker.internal есть

Голый dockerd на Linux-сервере этого имени не знает. Его добавляют флагом --add-host=host.docker.internal:host-gateway (в compose extra_hosts), а host-gateway превращается в адрес моста по умолчанию docker0, в какой бы сети ни был контейнер. Через этот адрес видны сервисы хоста на 0.0.0.0, а на 127.0.0.1 нет:

# Linux-сервер: сервис хоста слушает 0.0.0.0:18091 и 127.0.0.1:18090
$ docker run --rm --add-host=host.docker.internal:host-gateway alpine sh -c '\
    wget -qO- -T2 host.docker.internal:18091/; wget -qO- -T2 host.docker.internal:18090/'
hello from linux-host, remote 172.18.0.4:43880                        # 0.0.0.0 виден
wget: can't connect to remote host (172.18.0.1): Connection refused   # 127.0.0.1 нет

Сеть host (--network host) снимает сетевую изоляцию: контейнер живёт в сетевом namespace хоста, NAT и публикация не нужны. На Docker Desktop хостом для контейнера служит ВМ, а не Mac:

$ docker run --rm --network host dv-c3d-tools ip -br addr | grep -E '^(eth0|lo) '
lo   UNKNOWN   127.0.0.1/8 ::1/128
eth0 UP        192.168.65.3/24 ...          # адрес ВМ, не Mac

С Mac по localhost порт такого контейнера не виден: настройка host networking в Desktop выключена по умолчанию. По документации её включают в настройках с версии 4.34, работает она только на L4, а опубликованные порты в этом режиме отбрасываются. На Linux host означает сеть хоста без оговорок; про hostNetwork рассказано в статье «Kubernetes».

Тома, bind mount и tmpfs

Записываемый слой умирает вместе с контейнером, поэтому данные, которые должны пережить пересоздание или пишутся интенсивно, выносят из него. Вариантов три:

  • Named volume (-v pgdata:/var/lib/postgresql) лежит в /var/lib/docker/volumes/<имя>/_data и переживает пересоздание. Пустой том при первом монтировании Docker заполняет тем, что лежало в этом каталоге образа; bind mount и tmpfs так не делают.
  • Bind mount (-v $(pwd):/src) кладёт внутрь каталог хоста.
  • tmpfs (--tmpfs /tmp) держит данные в памяти, пока жив контейнер.

Тип монтирования и опции видны в docker inspect, а изнутри через findmnt:

$ docker run -d --name store -v "$PWD/data:/data:ro" \
    --tmpfs /cache:size=16m --mount type=tmpfs,dst=/scratch,tmpfs-size=8m dv-c3d-tools sleep infinity
$ docker exec store findmnt -lo TARGET,FSTYPE,OPTIONS | grep -E '/(cache|scratch|data) '
/cache              tmpfs    rw,nosuid,nodev,noexec,relatime,size=16384k
/data               virtiofs ro,nosuid,nodev,relatime,...   # bind mount проброшен через VirtioFS
/scratch            tmpfs    rw,nosuid,nodev,noexec,relatime,size=8192k

С :ro запись падает с «Read-only file system»: так дёшево отдать контейнеру конфиг, не давая его испортить. tmpfs по умолчанию монтируется с nosuid,nodev,noexec, и бинарь из него не запустится (Permission denied), пока не добавишь опцию exec. size задаёт потолок, а не резерв: данные tmpfs лежат в памяти и засчитываются в лимит cgroup.

Больнее всего на bind mount права и uid. Процесс пишет под своим uid, а каталог на хосте принадлежит хостовому пользователю. На Linux процесс с USER 65532 (distroless nonroot) не пишет в каталог root, пока каталог не отдан этому uid:

# Linux-сервер, /srv/data принадлежит root
$ docker run --rm --user 65532 -v /srv/data:/data alpine sh -c 'echo hi > /data/x'
sh: can't create /data/x: Permission denied
$ chown 65532:65532 /srv/data      # теперь тот же запуск пишет

На Docker Desktop тот же процесс пишет без вопросов: общий доступ к файлам через VirtioFS подменяет владельца. Внутри каталог показан как root, а на Mac файлы достаются твоему пользователю (uid 501). Для разработки удобно, но на сервере тот же compose упадёт на правах. Чинят это chown каталога под нужный uid или named volume.

# Docker Desktop, VirtioFS
$ docker run --rm --user 65532 -v "$PWD/data:/data" dv-c3d-tools sh -c 'echo hi > /data/f; ls -ln /data'
-rw-r--r-- 1 65532 0 3 ... f          # внутри владелец 65532
$ ls -ln data
-rw-r--r--  1 501  20  3 ... f          # на Mac владелец твой пользователь

Слой записи контейнера

Всё, что контейнер пишет мимо томов и tmpfs, попадает в его записываемый слой, тонкий верхний слой поверх образа (OverlayFS и copy-up разобраны в главе 2.1). С Engine 29.0 новые установки по умолчанию хранят образы в containerd image store, и слой виден в docker inspect как снапшот overlayfs, а изнутри как upperdir:

$ docker inspect -f '{{json .GraphDriver}} {{json .Storage}}' store
null {"RootFS":{"Snapshot":{"Name":"overlayfs"}}}
$ docker exec store sh -c 'findmnt -no OPTIONS / | tr , "\n" | grep upperdir'
upperdir=/var/lib/desktop-containerd/daemon/io.containerd.snapshotter.v1.overlayfs/snapshots/8445/fs

Что в него попало, показывает docker diff: A добавлено, C изменено, D удалено (удаление файла нижнего слоя записывается whiteout-файлом). Размер слоя виден в docker ps -s, а virtual прибавляет к нему образ:

$ docker exec store sh -c 'mkdir -p /var/log/app; head -c 50000000 /dev/urandom > /var/log/app/app.log; rm /etc/debian_version'
$ docker diff store
…
C /var/log
A /var/log/app
A /var/log/app/app.log
C /etc
D /etc/debian_version
…
$ docker ps -s --filter name=store --format '{{.Names}} {{.Size}}'
store 50MB (virtual 295MB)

Данные и логи в этом слое держать плохо: он умирает вместе с контейнером (пересоздали под при деплое, и данных нет), правка большого файла через copy-up копирует его целиком, а контейнер молча распухает. Логи, которые Docker забирает со stdout, лежат не в слое, и docker ps -s их не считает:

# контейнер написал 300000 строк в stdout; du запускается в ВМ
$ docker ps -s --filter name=chatty --format '{{.Names}} {{.Size}}'
chatty 0B (virtual 20.8MB)                    # слой пуст
$ du -h $(docker inspect -f '{{.LogPath}}' chatty)
27.3M   /var/lib/docker/containers/<id>/<id>-json.log   # а лог на диске растёт

Compose глубже

Возьмём стек из базы, миграции и сервиса: база должна стать готовой, миграция отработать до конца, и только потом стартует сервис.

services:
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_PASSWORD: ${PG_PASSWORD:?set PG_PASSWORD in .env}
      POSTGRES_DB: app
    tmpfs: [/var/lib/postgresql]          # данные в памяти, тома не заводим
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 2s
      retries: 15

  migrate:
    build: ../app
    command: ["migrate"]
    environment: { DB_DSN: "postgres://postgres:${PG_PASSWORD}@db:5432/app" }
    depends_on:
      db: { condition: service_healthy }

  api:
    build: ../app
    env_file: app.env                     # переменные внутрь контейнера
    ports: ["127.0.0.1:${API_PORT:-18082}:8080"]
    depends_on:
      db:      { condition: service_healthy }
      migrate: { condition: service_completed_successfully }
    stop_grace_period: 20s

У depends_on три условия. service_started ждёт только запуска контейнера, service_healthy ждёт успешного healthcheck (у Postgres это pg_isready), а service_completed_successfully ждёт выхода с кодом 0 и подходит разовым задачам вроде миграции. На up -d порядок выстраивается сам:

docker compose up -d: условия depends_on db migrate api Created Started health: starting pg_isready каждые 2 с healthy Created ждёт db: condition: service_healthy Started Exited (0) Created ждёт db healthy и migrate: service_completed_successfully Started migrate: Exited (1) → api остаётся Created, compose up завершается с кодом 1: service "migrate" didn't complete successfully: exit 1
depends_on с условиями. Compose создаёт все контейнеры сразу, но запускает зависимый, только когда у зависимости выполнено условие: база отвечает на healthcheck, миграция вышла с нулевым кодом. Если миграция упала, api так и не стартует.

Интерполяция ${VAR} подставляет значения в сам compose-файл до запуска: ${API_PORT:-18082} берёт переменную или значение по умолчанию, ${PG_PASSWORD:?...} валит запуск, если переменная пуста. Значения приходят из окружения shell и файла .env. А env_file кладёт переменные внутрь контейнера, и в интерполяции их не видно: .env настраивает compose, env_file настраивает процесс. Итог подстановки и слияния печатает docker compose config, и с пустым PG_PASSWORD он падает ещё до запуска (required variable PG_PASSWORD is missing a value).

Слияние файлов. Без -f Compose читает compose.yaml и поверх него compose.override.yaml, если тот есть; несколько -f сливаются в порядке перечисления. image и command заменяются целиком, ports дополняются, environment сливается по имени переменной. Сервис с profiles: [tools] обычный up не запускает, нужен --profile tools: так прячут разовые и отладочные контейнеры.

compose down не трогает сервисы из профилей

Если поднят сервис из профиля, обычный docker compose down его оставит, а сеть не удалит: «Network ... Resource is still in use». Сносить надо с тем же профилем, docker compose --profile tools down. Контейнеры compose run ведут себя так же.

docker compose watch нужен для разработки в контейнере. Правила задаются в блоке develop.watch: action: sync копирует изменённый файл в работающий контейнер, action: rebuild пересобирает образ, когда без сборки не обойтись (исходник, go.mod). В distroless удаление через sync не работает: compose зовёт внутри rm, которого там нет (exec: "rm": executable file not found in $PATH), а добавление и правка проходят.

Лимиты ресурсов в cgroup v2

Флаги ресурсов docker run превращаются в файлы cgroup v2, и контейнер видит их в /sys/fs/cgroup:

$ docker run --rm --memory 256m --memory-swap 256m --cpus 1.5 --pids-limit 64 dv-c3d-tools \
    sh -c 'cd /sys/fs/cgroup && grep -H . memory.max memory.swap.max cpu.max pids.max; nproc'
memory.max:268435456          # --memory 256m
memory.swap.max:0             # --memory-swap 256m: своп сверх памяти = 0
cpu.max:150000 100000         # --cpus 1.5: 150 мс на каждые 100 мс
pids.max:64                   # --pids-limit 64
4                             # ядер по-прежнему видно 4

Квота 150000 100000 в cpu.max означает не «полтора ядра», а 150 мс процессорного времени на каждые 100 мс реального (как на это отвечает рантайм Go, разобрано в главе 2.1). --memory-swap задаёт память плюс своп: равный --memory своп запрещает, а без него Docker разрешает свопа ещё столько же, и OOM наступает позже. --pids-limit ограничивает число процессов и потоков. Go-сервису его дают с запасом: с --pids-limit 4 рантайм на 4 ядрах падает на старте (fatal error: newosproc, errno 11).

Лимиты меняются на живом контейнере: docker update --cpus 3 --memory 512m --memory-swap 512m перезаписывает те же файлы, а без --memory-swap команда откажет, если своп уже задан меньше новой памяти. Потребление показывает docker stats. Когда процесс упирается в memory.max и освободить нечего, его убивает OOM killer cgroup: docker inspect ставит OOMKilled, код выхода 137 (128 + 9, SIGKILL). Кроме кучи, в лимит идут page cache и tmpfs: файл на 200 МиБ в --tmpfs съедает 200 МиБ из memory.max.

Остановка и перезапуск

docker stop шлёт контейнеру SIGTERM, ждёт тайм-аут и добивает SIGKILL, если тот не вышел. Приложение ловит SIGTERM и доигрывает текущие запросы (в Go signal.NotifyContext и srv.Shutdown, статья «Веб-сервисы» раздела Go). Ловить должен PID 1: к нему ядро не применяет действие сигнала по умолчанию, и без обработчика SIGTERM просто теряется (глава 2.1). Go-бинарь без signal.NotifyContext сигнал не теряет: рантайм ставит обработчик сам, и процесс сразу выходит с кодом 143, обрывая запросы.

По документации Docker тайм-аут по умолчанию 10 секунд, но на этой машине без -t он равен трём:

$ docker run -d -e SHUTDOWN_DELAY=5s app; docker stop <id>   # приложение тянет 5 с
# ... вернулось через 3.1 с, exit 137: не успел, добит SIGKILL

Приложение и пользовательский ~/.docker/daemon.json тут ни при чём. В Docker Engine 29.7 появилась опция демона default-stop-timeout, и Docker Desktop прописывает её в daemon.json своей ВМ. Конфиг видно через namespace процесса dockerd:

# в ВМ, только чтение конфига dockerd
$ grep stop-timeout /proc/$(pgrep dockerd)/root/run/config/docker/daemon.json
  "default-stop-timeout": 3,

Три секунды выставил Docker Desktop. На чистом Linux-сервере (dind без настроек) тайм-аут десять секунд, и тот же контейнер успевает слить запросы и выйти с кодом 0.

Не полагайся на дефолтный тайм-аут, задай его сам

Тайм-аут по умолчанию зависит от окружения: 3 с на Docker Desktop, 10 с на голом Linux-сервере, 30 с в Kubernetes. Задавай его явно, с запасом над временем слива: --stop-timeout, stop_grace_period в compose, terminationGracePeriodSeconds в поде. С --stop-timeout 10 тот же сервис на Desktop укладывается в свои 5 секунд и выходит с кодом 0.

Сигнал остановки задаёт STOPSIGNAL или --stop-signal: обычно это SIGTERM, а Postgres 18 просит SIGINT. Процессу без обработчиков помогает --init: первым встаёт крошечный init (tini), который пересылает сигнал потомку и пожинает зомби. Shell-обёртку он не чинит, сигнал достанется shell, а не приложению. Разница на процессе без обработчика:

$ docker run -d --name a1        dv-c3d-tools sleep infinity; docker stop a1   # 3.1 с, exit 137 (SIGKILL)
$ docker run -d --name b1 --init dv-c3d-tools sleep infinity; docker stop b1   # 0.1 с, exit 143 (SIGTERM)

Restart policies решают, что делать с вышедшим контейнером: no (по умолчанию), on-failure[:N] (при ненулевом коде, до N раз), always, unless-stopped (всегда, кроме ручной остановки). При повторных падениях задержка перезапуска удваивается со 100 мс (0,16 → 0,26 → 0,46 → 0,85 → 1,67 → 3,25 с в прогоне) до потолка в минуту и сбрасывается, если контейнер прожил 10 секунд. После перезапуска демона поднимаются always и упавшие on-failure, а unless-stopped только если его не останавливали руками.

Логи контейнера

Приложение в контейнере пишет логи в stdout и stderr, а не в файл, и Docker подхватывает их драйвером логов. Такой контракт: контейнер эфемерный, его файловая система пропадёт, а stdout забирает платформа. По умолчанию и на Docker Desktop, и на голом Linux стоит драйвер json-file:

$ docker info --format '{{.LoggingDriver}}'
json-file
$ docker inspect -f '{{.LogPath}}' logs
/var/lib/docker/containers/<id>/<id>-json.log
$ cat $(docker inspect -f '{{.LogPath}}' logs)
{"log":"log line 1 of 2\n","stream":"stdout","time":"2026-09-16T23:07:51.728511884Z"}

В каждой строке JSON с текстом, потоком и временем; этот файл читает docker logs, в том числе с --since и --timestamps. У json-file по умолчанию нет ротации (max-size не задан), и журнал растёт без предела, как тот лог на 27 МиБ из раздела про слой записи. Ротацию включают опциями драйвера:

$ docker run -d --name rot --log-opt max-size=1k --log-opt max-file=3 app
# ... контейнер насыпал логов
$ ls $(dirname $(docker inspect -f '{{.LogPath}}' rot)) | grep json
<id>-json.log
<id>-json.log.1
<id>-json.log.2                              # три файла, старые выбрасываются

max-size ограничивает размер файла, max-file число файлов; ставить их лучше глобально в daemon.json. Встроенный драйвер local ротирует из коробки (100 МиБ: пять файлов по 20 МиБ, со сжатием), и документация Docker рекомендует его, а json-file оставляет дефолтом ради обратной совместимости. В Kubernetes логами и ротацией занимается kubelet, статья «Kubernetes».

Безопасность запуска

По умолчанию контейнер стартует от root, но не всемогущего: права root ядро делит на capabilities (глава 1.2 статьи «Linux и ОС»), и Docker оставляет из них небольшой набор:

$ docker run --rm dv-c3d-tools sh -c 'grep CapEff /proc/self/status; capsh --decode=00000000a80425fb'
CapEff: 00000000a80425fb
0x...=cap_chown,cap_dac_override,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,
      cap_net_bind_service,cap_net_raw,cap_sys_chroot,cap_mknod,cap_audit_write,cap_setfcap

Четырнадцать capabilities из 41, которые знает ядро 7.0. Набор урезают дальше: снимают всё и возвращают нужное, например NET_BIND_SERVICE (порт ниже 1024 в Docker, впрочем, доступен и без неё, глава 2.1):

$ docker run --rm --cap-drop ALL --cap-add NET_BIND_SERVICE dv-c3d-tools sh -c 'grep CapEff /proc/self/status; chown nobody /tmp'
CapEff: 0000000000000400                      # одна capability, бит 10
chown: changing ownership of '/tmp': Operation not permitted   # CAP_CHOWN уже нет

С непривилегированным пользователем (--user 65532) действующий набор (CapEff) и вовсе пуст. Следующий шаг: неизменяемый корень и tmpfs для записи:

$ docker run --rm --read-only --tmpfs /tmp dv-c3d-tools sh -c 'touch /etc/x; touch /tmp/x && echo tmp-ok'
touch: cannot touch '/etc/x': Read-only file system
tmp-ok                                        # /tmp пишется, остальное нет

Флаг no-new-privileges запрещает набирать привилегии сверх имеющихся, и setuid-бит перестаёт поднимать euid. Тестовый setuid-бинарь без флага получает euid 0, с флагом остаётся при своём:

$ docker run --rm --user 65534 dv-c3d-tools euid
uid=65534 euid=0                              # setuid сработал
$ docker run --rm --user 65534 --security-opt no-new-privileges dv-c3d-tools euid
uid=65534 euid=65534                          # euid не поднялся

Ещё один рубеж, seccomp. Профиль Docker по умолчанию устроен как белый список: вызов вне списка получает ошибку EPERM. unshare в профиле разрешён только при CAP_SYS_ADMIN, поэтому под дефолтным профилем создать namespace нельзя, а без профиля можно:

$ docker run --rm dv-c3d-tools sh -c 'grep Seccomp: /proc/self/status; unshare --user --map-root-user id'
Seccomp: 2                                    # фильтр включён
unshare: unshare failed: Operation not permitted
$ docker run --rm --security-opt seccomp=unconfined dv-c3d-tools unshare --user --map-root-user id
uid=0(root) gid=0(root) groups=0(root)        # без профиля прошло

Всё это включается флагами, приложение переписывать не нужно. Дальше идёт rootless-режим: демон и контейнеры работают в user namespace от обычного пользователя.

Монтированный docker.sock равен root на хосте

С -v /var/run/docker.sock:/var/run/docker.sock контейнер получает API демона, который работает от root. Через сокет он запускает соседа с --privileged и -v /:/host и пишет в корень хоста, так что урезанные права исходного контейнера ничего не значат, и :ro на сокете не спасает.

Вопросы

4
Суть: публикация порта держится на DNAT в таблице nat. Он срабатывает в PREROUTING, и пакет к контейнеру идёт транзитом через FORWARD, мимо INPUT с правилами ufw, поэтому ufw deny его не видит. Фильтровать такой трафик надо в цепочке DOCKER-USER.

Почему ufw не работает

ufw пишет правила в INPUT и OUTPUT, а до них пакет не доходит. Закроем вход политикой DROP, как это делает ufw: сервис в сети host закрылся, а опубликованный порт по-прежнему открыт из сети:

$ iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
$ iptables -A INPUT -i lo -j ACCEPT
$ iptables -P INPUT DROP                        # default deny, как ufw
$ curl -sS -m2 <server>:18091/     # сервис в сети host: его ловит INPUT
curl: (28) Connection timed out after 2001 milliseconds
$ curl -sS -m2 <server>:15432/     # порт контейнера с -p идёт мимо INPUT
hello from <container>

Где фильтровать правильно

Для своих правил Docker держит в FORWARD цепочку DOCKER-USER, и пакет проходит её до правил Docker. Порт там сверяют через модуль conntrack, потому что DNAT уже переписал адрес:

$ iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 15432 -j DROP
$ curl -sS -m2 <server>:15432/                  # из сети закрыт
curl: (28) Connection timed out after 2004 milliseconds
$ wget -qO- -T2 localhost:15432/                # с самого хоста работает
hello from <container>
Не публикуй лишнего

Лучше вовсе не открывать порт наружу: внутри одной сети контейнеры видят друг друга, и порт базы «чтобы посмотреть» публиковать незачем. Нужен локально, вешай на loopback (-p 127.0.0.1:5432:5432). DOCKER-USER остаётся вторым рубежом.

Суть: проверь три вещи. Приложение ловит SIGTERM и делает graceful shutdown; оно работает как PID 1 (exec-форма ENTRYPOINT), иначе сигнал теряется; тайм-аут остановки больше времени слива и задан явно, потому что дефолт в каждом окружении свой.

Три места, где рвётся

Приложение должно поймать SIGTERM и доиграть текущие запросы (в Go signal.NotifyContext плюс srv.Shutdown(ctx)). При shell-форме ENTRYPOINT PID 1 достаётся /bin/sh, который сигнал не пересылает (глава 2.1), поэтому нужна exec-форма ENTRYPOINT ["/app"]; --init тут не спасёт, сигнал получит shell. А тайм-аут зависит от окружения: документация обещает 10 секунд, но Docker Desktop ставит в своей ВМ default-stop-timeout: 3, и без -t контейнер добивают через три:

$ docker run -d --name drain -e SHUTDOWN_DELAY=5s app; docker stop drain   # сливает 5 с
# ... вернулось через 3.1 с, exit 137: не успел, SIGKILL

На голом Linux тот же сервис успевает за свои 5 с (там тайм-аут 10 с), в Kubernetes дают 30 с. Задавай тайм-аут явно и с запасом: --stop-timeout, stop_grace_period в compose, terminationGracePeriodSeconds в поде; с --stop-timeout 10 сервис выходит с кодом 0. Сигнал задаёт STOPSIGNAL (Postgres просит SIGINT). Как по шагам останавливается под, разобрано в статье «Kubernetes».

Суть: 137 означает 128 + 9, то есть SIGKILL, а его шлёт и OOM killer, и docker stop по тайм-ауту. Различают их по флагу OOMKilled и событию oom. В лимит памяти, кроме кучи, входят page cache файлов и tmpfs.

137 бывает от двух причин

Код 137 значит «процесс убит сигналом 9». Его посылает OOM killer cgroup, когда память упёрлась в memory.max, и тот же сигнал прилетает от docker stop по тайм-ауту. Отличает их флаг:

# добит по тайм-ауту stop
$ docker inspect -f 'exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' drain
exit=137 OOMKilled=false
# убит OOM killer
$ docker inspect -f 'exit={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' hog
exit=137 OOMKilled=true

Надёжнее поток событий: у OOM перед die стоит oom, а у обычной остановки kill с сигналом 15, потом с 9.

$ docker events --filter container=hog --format '{{.Action}}'
oom
die

Почему heap маленький, а OOM есть

Лимит cgroup считает не одну кучу Go. В memory.current входит page cache файлов, которые процесс пишет (грязные страницы ядро не выбросит, пока не сбросит на диск), и tmpfs, который целиком лежит в памяти. Сервис, который пишет большой файл или держит данные в --tmpfs, получает OOMKilled при скромной куче. Механизм разобран в статье «Linux и ОС», главы 1.2 и 1.4, про GOMEMLIMIT в главе 2.1. Большие данные держи вне контейнера и не складывай в tmpfs.

Суть: через сокет доступен весь API демона, а демон работает от root. Кто пишет в сокет, тот запускает привилегированный контейнер с корнем хоста внутри; урезанные права и :ro не помогают. Если Docker всё же нужен, ставят socket-proxy с белым списком или rootless-демон.

Почему это полный доступ

Через API контейнер запускает соседа с --privileged и корнем хоста (-v /:/host), делает chroot и пишет в файловую систему хоста от root. Права исходного контейнера тут ни при чём: права нового задаёт тот, кто дёргает API. В песочнице (внутри dind) не-root контейнер так дописал файл в /root «хоста»:

$ docker run --rm --user 65532 --group-add <docker-gid> -v /var/run/docker.sock:/var/run/docker.sock ... \
    sh -c 'id; docker run --rm --privileged -v /:/host alpine chroot /host sh -c "id; echo pwned > /root/x"'
uid=65532 ...        # исходный не root
uid=0(root) ...      # новый: root на хосте

:ro на сокете ничего не меняет: read-only касается файла-сокета, а запросы к API идут через него как раньше.

Что делать вместо этого

  • Не пробрасывать сокет. Обычно Docker из контейнера не нужен: CI-раннеру хватает сборщика без демона (статья «CI/CD»), приложению он ни к чему.
  • Socket-proxy. Если доступ нужен (мониторинг, автообновление), перед сокетом ставят прокси, который пропускает только нужные запросы и режет POST на создание контейнеров.
  • Rootless-демон. Демон и контейнеры работают от обычного пользователя, и пробой через сокет упирается в его права.
Сокет в разработке ≠ сокет в проде

Локально смонтировать docker.sock в контейнер с инструментом нормально, а в проде на общем хосте тот же приём отдаёт root любому, кто попал в контейнер. В Kubernetes у пода по умолчанию нет доступа к сокету рантайма, и docker-in-docker туда без нужды не тащат.

Что стоит запомнить

  • Имя контейнера разрешается в пользовательской Docker-сети, а не обязательно в сети по умолчанию.
  • Named volume, bind mount и tmpfs имеют разный срок жизни и владельца данных.
  • docker stop сначала посылает сигнал завершения, затем после таймаута принудительно убивает процесс.

Частые ошибки

  • Публикация порта меняет границу доступности сервиса.
  • Монтирование docker.sock фактически даёт широкие права на управление хостом.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Прочитай остановку контейнера

Приложение завершает graceful shutdown за 8 секунд. Что произойдёт при такой команде?

docker stop --time 3 api
Ответ и разбор

Docker пошлёт контейнеру SIGTERM и будет ждать до трёх секунд. Если приложение ещё завершает запросы, по истечении таймаута Docker отправит SIGKILL и остановит его принудительно. Таймаут должен покрывать остановку приложения и укладываться в бюджет оркестратора.

Код-ревью

2. Сократи права runtime-контейнера

Найди лишние права и риск для хоста в запуске контейнера.

docker run --privileged -v /var/run/docker.sock:/var/run/docker.sock api:latest
Ответ и разбор

--privileged снимает большую часть ограничений, а доступ к docker.sock даёт возможность создавать привилегированные контейнеры и управлять хостом. Удали оба доступа, если приложению они не нужны; запускай от непривилегированного пользователя, сбрось capabilities и добавь только конкретные необходимые монтирования.

Применение

3. Выбери место для данных

Сервис пишет временный кэш и загруженные пользователями файлы. Что разместить в tmpfs, что в named volume, а что в bind mount для локальной разработки?

Ответ и разбор

Временный кэш можно хранить в tmpfs, если допустима потеря при остановке и расход ограничен. Постоянные пользовательские файлы - в named volume или внешнем хранилище с требуемым сроком жизни. Bind mount удобен для исходников при разработке, но привязывает путь к хосту и его правам.

Применение

4. Найди причину OOM

Контейнер завершился, а приложение не успело записать stack trace. Какие данные соберёшь снаружи контейнера, чтобы проверить OOM-гипотезу?

Ответ и разбор

Проверь exit code и поле OOMKilled через docker inspect, лимит памяти контейнера и события cgroup/ядра на хосте. Сопоставь это со временем остановки и метриками процесса. Код 137 означает SIGKILL и сам по себе не отличает OOM от ручного убийства или таймаута.