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 и открывает своё до
контейнера, поэтому приложение видит адрес моста, а не клиента.
В ВМ 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 порядок выстраивается сам:
Интерполяция ${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 на сокете не
спасает.
Вопросы
4nat. Он срабатывает
в 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».
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.
: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 от ручного убийства или таймаута.