Частина посилань на цій сторінці партнерські: якщо ви зареєструєтесь через них, сайт отримає комісію, для вас ціна не змінюється. На ціни й рекомендації це не впливає.

Минулого тижня на головну Hacker News вийшов пост під назвою “Docker is weird”: 880 поінтів, 369 коментарів. Автор пише власний container runtime і дізнався, що контейнерів як сутності ядро не має, образи це просто папки, а runc потребує C-модуля, бо в Go немає придатного fork. Добре написано, і все це правда.

Цей пост друга половина тієї ж історії. Не про те, чому Docker дивний усередині, а про те, де ця дивність вилазить на невеликому продакшн-сервері о другій ночі. Сім поведінок, кожну відтворено нижче на живій машині з Docker 28.3.1, з реальним виводом, а не з пам’яті. Якщо спершу потрібне залізо під таку машину, ми порівняли варіанти в найкращому VPS для самохостингу; сам стек описаний у Caddy і Docker у продакшні.

1. Політиці перезапуску байдуже, що контейнер unhealthy

Саме ця коштує найбільше простою, бо виглядає так, ніби ви підстрахувалися двічі: healthcheck і restart: always.

docker run -d --name dw1 --restart always \
  --health-cmd 'exit 1' --health-interval 2s --health-retries 1 \
  alpine:3 sleep 600
sleep 12
docker inspect -f 'status={{ .State.Status }} health={{ .State.Health.Status }} failing={{ .State.Health.FailingStreak }} restarts={{ .RestartCount }}' dw1
status=running health=unhealthy failing=5 restarts=0

П’ять провалених перевірок підряд, контейнер відкрито позначений як unhealthy, і нуль перезапусків. Політика перезапуску реагує на завершення головного процесу. Healthcheck лише пише статус. У чистому Docker ніщо не з’єднує одне з другим.

Тобто застосунок, який заклинив свій пул воркерів, тримає порт відкритим і перестав відповідати, залишиться точно там, де був, із майже зеленим рядком docker ps, де Up 3 days (unhealthy) стоїть у колонці, яку ніхто не читає. Swarm і Kubernetes реагують на здоров’я. Compose на одній машині ні.

Що робити замість цього: зробити так, щоб процес завершувався, коли він зламаний. Якщо застосунок цього не вміє, винесіть перевірку за межі Docker. Systemd-таймер, який пінгує ендпоінт і робить docker restart, це п’ять рядків, і він справді працює:

curl -fsS --max-time 5 http://127.0.0.1:3000/health >/dev/null || docker restart app

А healthcheck використовуйте за призначенням, у якому він дійсно сильний: depends_on: condition: service_healthy, щоб база була готова до того, як застосунок під’єднається.

2. Опублікований порт проходить просто повз ufw

Ви налаштували ufw. ufw status каже, що дозволені тільки 22, 80 і 443. Потім ви публікуєте порт у Compose, щоб глянути на дашборд, і цей порт стоїть у відкритому інтернеті.

Ось чому, на тій самій машині:

docker run -d --name pub -p 8099:80 nginx:alpine
iptables -t nat -S | grep 8099
iptables -S FORWARD | head -8
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8099 -j DNAT --to-destination 172.17.0.5:80

-P FORWARD DROP
-A FORWARD -j DOCKER-USER
-A FORWARD -j DOCKER-FORWARD
-A FORWARD -j ufw-before-logging-forward
-A FORWARD -j ufw-before-forward
...

Прочитайте порядок. Трафік на опублікований порт переписується через DNAT ще до будь-якого вашого рішення про фільтрацію, а далі віддається в DOCKER-USER і DOCKER-FORWARD раніше, ніж буде досягнутий перший ланцюжок ufw. Docker його приймає, пакет форвардиться, і ланцюжки ufw нижче його ніколи не бачать. На цій машині DOCKER-USER такий самий, як на більшості машин:

-N DOCKER-USER

Порожній. Цей ланцюжок існує саме для того, щоб ви мали куди покласти свої правила так, щоб Docker їх не перетер, і майже ніхто ним не користується.

Три виправлення, за спаданням користі:

  • Прив’язати до localhost. -p 127.0.0.1:8099:80 замість -p 8099:80, а далі діставатися через SSH-тунель. Це правильний дефолт для будь-якого адмін-інтерфейсу.
  • Взагалі не публікувати. Поставте контейнер у мережу реверс-проксі і дайте проксі ходити до нього за іменем на внутрішній порт. Нічого у стеку за нашим Caddy не публікує порт.
  • Написати правила в DOCKER-USER, якщо порт справді має бути публічним і обмеженим: iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.0/24 -p tcp --dport 8099 -j DROP.

Якщо ви щойно прийняли чужу машину, це пункт номер один у перевірці, а решта тієї перевірки є в аудиті успадкованого сервера.

3. Логи не ротуються, поки ви це не додасте

Типовий драйвер логування це json-file, а типова конфігурація це ніщо:

docker inspect -f '{{ json .HostConfig.LogConfig }}' dw2
{"Type":"json-file","Config":{}}

Ні max-size, ні max-file. Файл у /var/lib/docker/containers/<id>/<id>-json.log росте, поки диск не заповниться. Як швидко? Один контейнер, що друкує короткий рядок у циклі, залишений на двадцять секунд:

docker run -d --name chat alpine:3 sh -c \
  'i=0; while [ $i -lt 200000 ]; do echo "line $i some payload here"; i=$((i+1)); done; sleep 60'
# через двадцять секунд
du -sh /var/lib/docker/containers/9ee39f60*/
19.0M	/var/lib/docker/containers/9ee39f60.../

19 МБ за двадцять секунд від одного контейнера, який пише один короткий рядок за ітерацію. Застосунок, що логує кожен запит на рівні debug і залишений сам на довгі вихідні, це заповнена коренева файлова система, а заповнена коренева файлова система на одній машині означає, що база перестає приймати записи, проксі не може писати свій access-лог, а поновлення TLS падає.

Налаштуйте глобально, один раз, до того як знадобиться:

{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3" }
}

Це йде в /etc/docker/daemon.json, далі systemctl restart docker. Діє на контейнери, створені після рестарту, а не на наявні, тож перестворіть їх. На сервіс у Compose:

    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Тридцять мегабайтів на контейнер, з межею, і docker logs далі працює.

4. PID 1 це оболонка, і ваш SIGTERM не доходить нікуди

Два контейнери, той самий sleep, зупинені однаково:

docker run -d --name dw2 alpine:3 sh -c 'trap "echo caught; exit 0" TERM; sleep 600'
time docker stop dw2

docker run -d --name dw3 --init alpine:3 sleep 600
time docker stop dw3
dw2    10.11s
dw3     0.09s

Десять секунд проти дев’яти сотих. І зверніть увагу: у повільного є trap на TERM. Оболонки відкладають trap, поки не завершиться команда на передньому плані, тож поки працює sleep, обробник нічого не дає. docker stop надсилає SIGTERM у PID 1, вичікує період відстрочки, а потім надсилає SIGKILL.

Дев’яносто дев’ять разів зі ста це лише прикра затримка. Сотий раз це втрата даних, бо SIGKILL отримує ваша база, споживач черги чи процес, який пише файл, замість послідовності завершення. А ще кожен деплой стає на десять секунд довшим на контейнер, що добре відчувається в пайплайні rsync і restart.

Три виходи, усі дешеві:

  • Exec-форма в Dockerfile: CMD ["nginx", "-g", "daemon off;"], і ніколи CMD nginx -g 'daemon off;'.
  • exec останнім рядком entrypoint-скрипта, щоб ваш процес замінив оболонку, а не був її дитиною.
  • init: true у Compose, що ставить крихітний init у PID 1, передає сигнали і прибирає зомбі.

Далі перевірте, бо це саме той випадок, коли всі впевнені, що вже це зробили: docker exec <name> ps -o pid,comm і подивіться, що насправді стоїть у PID 1.

5. DNS для контейнерів існує лише в мережах, які створили ви

Той самий рушій, два контейнери, два різні резолвери. У типовому bridge:

nameserver 192.168.178.1
nameserver 8.8.8.8

У мережі, створеній через docker network create:

nameserver 127.0.0.11

127.0.0.11 це вбудований DNS-сервер Docker, і саме він робить так, що postgres://db:5432 працює. У типовому bridge його немає: туди копіюється /etc/resolv.conf хоста, і імена контейнерів не резолвляться ні в що. Ось чому приклади з docker run із чийогось блогу не знаходять один одного, а ті самі образи в Compose знаходять: Compose створює вам мережу проєкту.

Це також означає, що імена ваших сервісів не є стабільним API. Старий --link прибрали, а типовий bridge дає лише IP-адреси, що веде до наступного пункту.

6. Compose видає вам іншу підмережу, ніж та, яку ви внесли у whitelist

docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.19.0.0/16

docker compose down
docker network create decoy1 && docker network create decoy2
docker compose up -d
docker network inspect app_default -f '{{ (index .IPAM.Config 0).Subnet }}'
# 172.21.0.0/16

docker compose down видаляє мережу проєкту. Наступний up створює нову і бере перший вільний блок, який не той, що був у вас. Двох сторонніх мереж, створених між цими командами, вистачило, щоб проєкт переїхав з 172.19 на 172.21.

Усе, що ви прибили до старої підмережі, тепер тихо неправильне: рядок у pg_hba.conf, allowlist у застосунку, правило фаєрвола, API, що порівнює X-Forwarded-For з CIDR. Поломка приходить як “connection refused from unknown host” після рутинного редеплою, тобто в найгірший можливий момент, щоб сідати читати pg_hba.conf.

Тож або перестаньте прибивати підмережі і ходіть за іменами контейнерів у власній мережі, або оголосіть підмережу так, щоб вона не могла зсунутися:

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24

7. OOM-кіл не виглядає як падіння

Дайте контейнеру 32 МБ і дозвольте йому алокувати:

docker run --rm -m 32m alpine:3 sh -c 'a=""; while true; do a="$a$(head -c 1000000 /dev/zero | tr "\0" x)"; done'
echo $?
137

Код виходу 137 це 128 + 9: SIGKILL. Не ваш код, не виняток, без стектрейсу, нічого в логу застосунку. Cgroup досяг свого ліміту, і ядро вбило найбільший процес усередині.

І саме тут пост з Hacker News і цей сходяться. Ядро вбиває не “контейнер”, воно вбиває звичайний процес, який бачить увесь час:

hp=$(docker inspect -f '{{ .State.Pid }}' dw4)
ps -o pid,cmd -p $hp
grep NSpid /proc/$hp/status
    PID CMD
1085777 sleep 600
NSpid:	1085777	1

Один процес, два номери: 1085777 на хості, 1 усередині. “Контейнери не існують” це не провокація, це саме те, на що ви дивитеся, коли розбираєте OOM-кіл. PID 1 контейнера стоїть у таблиці процесів вашого хоста, і саме там ви його знаходите, коли в docker logs нічого немає, бо процес так і не встиг написати рядок.

Отже, коли контейнер зникає без пояснень: спершу читайте код виходу (docker inspect -f '{{ .State.ExitCode }} {{ .State.OOMKilled }}'), потім dmesg -T | grep -i oom, а потім ставте ліміт памʼяті на кожен сервіс, щоб ядро вбивало саме той контейнер, що зле поводився, а не те, що випадково працювало на вашому хості.

Чекліст

Усе вище, як діф до стека, який ви вже тримаєте:

services:
  app:
    image: app:1.4.2          # не :latest
    init: true                # PID 1 передає сигнали
    restart: unless-stopped
    mem_limit: 512m           # ядро вбиває це, а не вашу базу
    ports:
      - "127.0.0.1:3000:3000" # ніколи голий порт хоста
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 30s
    depends_on:
      db:
        condition: service_healthy

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24   # щоб редеплой її не зсунув

Плюс дві речі поза цим файлом: max-size у /etc/docker/daemon.json для всього, що ви запускаєте руками, і зовнішній вотчдог під статус здоров’я, на який Docker не реагує.

Нічого екзотичного тут немає. Це все типова поведінка, задокументована, відтворювана за двадцять секунд кожна, і кожен пункт списку цього місяця вже поклав комусь невеликий продакшн-сервер. Повторюється це тому, що дефолти налаштовані під ноутбук, де лог-файл на 19 МБ і зупинка на десять секунд не коштують нічого.

Якщо потрібен решта стека, якому належать ці налаштування: повний конфіг машини, що віддає цю сторінку, є в Caddy і Docker у продакшні, а бік вимірювання в власній аналітиці на Umami.