Все виглядає добре, поки ви не перезавантажите систему. Тоді «простий» стек Docker Compose перетворюється на місце події: контейнери стартують у неправильному порядку, томи ще не змонтовані, мережі відсутні, і ваша база даних працює, а додаток упевнений, що всесвіт усе ще впав.
Compose чудово описує додаток. systemd чудово стежить за тим, щоб машина поведінувалася як машина. Якщо поєднати їх правильно, ви припиняєте писати зав’язаний із часом shell-спагетті на старті, яке працює лише коли ніхто не дивиться.
Що ми вирішуємо (і що ні)
Йдеться про надійний запуск стеків, описаних у Compose, після перезавантаження з використанням systemd. «Надійно» означає:
- Стек стартує під час завантаження без ручного втручання.
- Він не починає роботу занадто рано (до готовності дисків, мережі чи Docker).
- Він коректно завершується, щоб не пошкодити стані сервіси.
- Ви можете швидко діагностувати збої за допомогою інструментів, що вже є на хості.
Це не про перетворення Compose на Kubernetes. Compose не стає планувальником, самовідновним менеджером кластера або оркестратором для багатьох вузлів. Якщо вам це потрібно — ви вже це знаєте й маєте шрами.
Також: ми не робимо “@reboot sleep 30 && docker compose up -d”. Це не інженерія. Це ритуал.
Факти та історія, які справді мають значення
Трохи контексту допомагає, бо половина «проблем Compose + systemd» — це насправді «я припускав, що стару поведінку все ще можна використовувати». Ось конкретні факти з операційними наслідками:
- Compose походить від Fig (2013), інструменту на Python. Це спадщина пояснює, чому деякі люди досі вважають, що «Compose — це Python-інструмент» і поводяться з ним як зі скриптом, а не як з інструментом життєвого циклу.
- Docker рано ввів політики перезапуску (
restart: always,unless-stopped). Ці політики реалізує docker-демон, а не systemd, тому вони поводяться інакше під час порядку завершення. - systemd став мейнстрімом у великих дистрибутивах у середині 2010-х. До того init-скрипти були «best effort». Якщо ви копіюєте керівництва з тієї епохи, ви успадковуєте їхню випадковість.
- Compose V2 — це плагін Docker CLI (
docker compose), а не старий Python-бінарникdocker-compose. Юніти, які жорстко прописують старий шлях, ламаються після оновлень. - Ім’я юніта Docker відрізняється в дистрибутивах (взаємодія
docker.serviceіdocker.socket). Правильний порядок вимагає явності щодо того, від чого ви залежите. - “depends_on” ніколи не означало «чекати готовності». Це порядок старту, а не готовність. Healthchecks плюс логіка очікування (або логіка повторних спроб у додатку) лишаються важливими.
- journald logging — це не функція Docker; це рішення хоста щодо логування. Якщо ви не інтегруєте логи в шлях логування хоста, ви будете дебажити проблеми завантаження в сліпу.
- Завершення системи — це інша всесвітність, ніж завантаження. Якщо ви не управляєте таймаутами на зупинку, ваша база даних може отримати SIGKILL як злочинець.
- Rootless Docker — це реальні операції зараз, і це змінює розташування сокетів, як встановлюються юніти і хто володіє життєвим циклом. Юніти, написані для rootful Docker, тихо проваляться під rootless.
Цитата, яку варто мати на стіні, бо вона описує 90% збоїв контейнерів під час завантаження:
Werner Vogels (парафраз): «Усе виходить з ладу; проектуйте так, щоб відмови були очікувані, а відновлення — автоматичне.»
Принципи для стеків, стійких до перезавантаження
1) Оберіть одного супервізора: systemd або Docker restart policies
Не змушуйте їх змагатися. Можна використовувати обох, але треба розуміти наслідок: systemd контролює команду Compose, тоді як Docker контролює контейнери. Якщо systemd вважає сервіс «завершеним», а Docker сам перезапускає контейнери, ви можете отримати вводячі в оману сигнали стану та заплутані перезапуски.
Мій вибір для однохостового випадку з невеликою кількістю стеків:
- Використовуйте systemd для старту стеку під час завантаження і для зупинки під час завершення.
- Використовуйте Docker restart policies всередині Compose для перезапусків контейнерів під час рантайму (крахи, транзієнтні збої).
Таке поєднання робить життєвий цикл завантаження/зупинки явним і одночасно дає стійкість під час виконання.
2) Зробіть порядок реальним: диски, мережа, Docker, потім Compose
«After=docker.service» необхідний, але часто недостатній. Якщо ваш стек залежить від змонтованої файлової системи (NFS, iSCSI, зашифрований диск, імпорт набору ZFS), ви повинні також вказати цей порядок. Інакше ваші контейнери стартують з порожніми директоріями й створять новий стан не там, де треба — ось як о 2-й ночі ви дізнаєтесь, чому використовується SQLite у /var/lib.
3) Не плутайте «запущено» з «готово»
systemd може сказати, що сервіс запустився. Docker може сказати, що контейнер працює. Жоден із них не скаже, що Postgres завершив відновлення після збою або що ваш додаток застосував міграції, якщо ви це не підключили додатково.
Ось тут healthchecks, повторні спроби і таймаути перестають бути академічними і починають зменшувати кількість пейджів.
4) Зробіть stateful-сервіси нудними
Нудно означає: стабільні шляхи, явні маунти, явні таймаути зупинки і відсутність несподіваних оновлень під час перезавантаження. «Круті» підходи — це те, як ви болісно дізнаєтеся, що бази даних не люблять раптовий SIGKILL.
Короткий жарт №1: Якщо ваш стек завантажується лише коли ви пошепки говорите «тільки цього разу», ваш сервер набув емоційної залежності, а не автоматизації.
Проєктування правильного systemd-юніта для Compose
Який вигляд має «правильний» юніт
Хороший unit-файл робить чотири речі:
- Впорядковує себе після передумов (Docker, маунти, network-online.target якщо потрібно).
- Запускає стек ідемпотентно.
- Зупиняє стек коректно в межах реалістичного таймауту.
- Виставляє логи і стани відмов там, де їх бачать ваші звичні інструменти.
Семантика unit-ів, яка важлива в проді
Ось ручки, що вирішують, чи будете ви пити каву вранці чи читати постмортем:
- Type=oneshot + RemainAfterExit=yes: systemd виконує команду для підняття стеку, а потім вважає сервіс «активним», не прив’язуючи процес. Це відображає реальність: контейнери тримає Docker, а не процес Compose.
- ExecStart/ExecStop: Використовуйте
docker compose up -dдля старту іdocker compose downабоstopдля зупинки. Вибирайте залежно від того, чи хочете ви видаляти мережі/томи. - TimeoutStartSec/TimeoutStopSec: Дайте достатньо часу на завантаження образів (старт) і на злив даних бази (зупинка). Значення за замовчуванням — це не моральний вирок; це просто дефолти.
- WorkingDirectory: Встановіть його. Compose обчислює відносні шляхи, env-файли і назви проєктів на основі робочої директорії. Залишати її неявною — шлях до випадкових помилок.
- EnvironmentFile: Добре для конфігурації на хості, що не в репозиторії. Також зручний спосіб не зберігати секрети в unit-файлах (але це не заміна повноцінному секретному сховищу).
- RequiresMountsFor=: Недооцінена і відмінна опція. Вона змушує systemd чекати, поки шлях не буде змонтовано, перед стартом.
- After=network-online.target: Лише якщо вам дійсно потрібно. Це може сповільнити завантаження, якщо мережа нестабільна. Використовуйте тільки тоді, коли залежите від віддалених ресурсів.
Яку команду Compose використовувати: up, start, down, stop
Ось як я рекомендую зіставляти:
- Start:
docker compose up -d --remove-orphans(видаляє забуті контейнери зі старих конфігів; уникає «примарних» сервісів). - Stop (дружній до stateful):
docker compose stop(зберігає мережі і визначені контейнери; швидший рестарт). - Stop (чистий стан):
docker compose down(видаляє контейнери і мережі; використовуйте, коли хочете пересоздати під час старту).
Для більшості продакшн-стеків: стартуйте з up -d, зупиняйте з stop. Використовуйте down, коли маєте вагому причину (наприклад, immutable infrastructure) і впевнені, що томи зовнішні/персистентні.
Логування: оберіть journald або Docker logs, але будьте цілеспрямовані
Під час проблем старту ви хочете одну точку правди. Якщо ваша організація вже використовує systemd journal, зробіть так, щоб unit пише корисні повідомлення: додавайте --log-level там, де підтримується, і переконайтесь, що помилки повертають ненульовий код.
Окремо вирішіть, куди йдуть stdout/stderr контейнерів. Дефолтний json-file драйвер Docker — нормальний вибір, поки ним не стає; тоді ви виявите використання диску «весело». Якщо ви використовуєте journald як Docker log driver, отримуєте централізований хостовий пошук через journalctl. Якщо лишаєте json-file, налаштуйте ротацію.
Конкретні шаблони unit-файлів (rootful і rootless)
Шаблон A: rootful Docker, oneshot unit, чистий порядок
Це шаблон, який я найчастіше розгортаю на одному хості. Простий, передбачуваний і не робить вигляд, що Compose — це демон.
cr0x@server:~$ sudo tee /etc/systemd/system/compose@.service > /dev/null <<'EOF'
[Unit]
Description=Docker Compose stack (%i)
Requires=docker.service
After=docker.service
Wants=network-online.target
After=network-online.target
# If your stack uses persistent data on a specific mount, uncomment and set:
# RequiresMountsFor=/srv/%i
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/%i
EnvironmentFile=-/srv/%i/.env
# Pull is optional; use it when you can tolerate boot-time pulls.
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose stop
ExecStopPost=/usr/bin/docker compose rm -f
TimeoutStartSec=300
TimeoutStopSec=180
[Install]
WantedBy=multi-user.target
EOF
Нотатки, які вам мають бути важливі:
compose@.service— це template unit. Ви можете запуститиcompose@myapp, і він використовуватиме/srv/myapp.EnvironmentFile=-робить файл опційним. Якщо його нема, юніт все одно запуститься.ExecStopPost rm -fвидаляє зупинені контейнери, щоб майбутнійupне успадкував дивний стан. Якщо ви хочете зберегти контейнери — видаліть цей рядок.
Шаблон B: rootful Docker, «down on stop» для immutable стеків
Якщо ви трактуєте хост як «cattle» (або як нудного улюбленця), можливо, ви захочете down, щоб кожен старт пересоздавав контейнери. Переконайтесь, що томи — реальні томи, а не анонімні дефолти.
cr0x@server:~$ sudo tee /etc/systemd/system/compose-immutable@.service > /dev/null <<'EOF'
[Unit]
Description=Immutable Docker Compose stack (%i)
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/%i
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down --remove-orphans
TimeoutStartSec=300
TimeoutStopSec=240
[Install]
WantedBy=multi-user.target
EOF
Будьте чесні: якщо ваша база даних використовує bind mount у /srv/%i/data і цей шлях ще не змонтований, down вас не врятує. Він просто швидше пересоздасть невірний стан.
Шаблон C: rootless Docker + user systemd units
Rootless Docker привабливий із погляду безпеки. Він також достатньо відрізняється, щоб карати «копіювати-вставити». Сокет і сервіс живуть у сесії користувача, і юніти слід встановлювати як користувацькі сервіси.
cr0x@server:~$ mkdir -p ~/.config/systemd/user
cr0x@server:~$ tee ~/.config/systemd/user/compose@.service > /dev/null <<'EOF'
[Unit]
Description=User Docker Compose stack (%i)
After=default.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=%h/stacks/%i
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose stop
TimeoutStartSec=300
TimeoutStopSec=180
[Install]
WantedBy=default.target
EOF
І ви маєте ввімкнути lingering, якщо очікуєте, що сервіс стартуватиме при завантаженні без інтерактивного логіну:
cr0x@server:~$ sudo loginctl enable-linger cr0x
Якщо ви забудете lingering, усе працює у вашому терміналі, але провалюється після перезавантаження. Це не таємниця; це невідповідність життєвих циклів.
Практичні завдання: команди, виводи та рішення
Це не «приємно знати». Це речі, які ви реально запускаєте, коли хтось каже: «Після перезавантаження не повернулося». Кожне завдання включає команду, типовий вивід, що це означає і яке рішення приймати.
Завдання 1: Підтвердити Compose V2 чи застарілий бінарник
cr0x@server:~$ docker compose version
Docker Compose version v2.24.6
Значення: Compose доступний як плагін Docker CLI.
Рішення: Пишіть unit-и, які викликають /usr/bin/docker compose. Не жорстко прописуйте docker-compose, якщо ви не перевірили, що він існує і керується.
Завдання 2: Перевірити, що демон Docker запущений і не деградований
cr0x@server:~$ systemctl status docker --no-pager
● docker.service - Docker Application Container Engine
Loaded: loaded (/lib/systemd/system/docker.service; enabled; vendor preset: enabled)
Active: active (running) since Tue 2026-01-03 09:12:41 UTC; 2min 10s ago
TriggeredBy: ● docker.socket
Docs: man:docker(1)
Значення: Docker працює і активується через сокет.
Рішення: Якщо Docker inactive або failed, спочатку виправте Docker. Compose-юніти, які залежать від Docker, зазнають каскадного провалу.
Завдання 3: Перевірити, чи ваш Compose-юніт увімкнений і який таргет його хоче
cr0x@server:~$ systemctl is-enabled compose@myapp.service
enabled
Значення: Він має стартувати при завантаженні, коли досягнуто відповідного таргету.
Рішення: Якщо він disabled, увімкніть його. Якщо static, ви написали unit без секції [Install].
Завдання 4: Подивитись, чи systemd вважає Compose-сервіс активним
cr0x@server:~$ systemctl status compose@myapp.service --no-pager
● compose@myapp.service - Docker Compose stack (myapp)
Loaded: loaded (/etc/systemd/system/compose@.service; enabled; preset: enabled)
Active: active (exited) since Tue 2026-01-03 09:13:04 UTC; 1min 40s ago
Process: 2214 ExecStart=/usr/bin/docker compose up -d --remove-orphans (code=exited, status=0/SUCCESS)
Значення: Дія «старта» Compose пройшла успішно; контейнери тепер має керувати Docker.
Рішення: Якщо failed, подивіться журнали юніта і виправте негайну помилку (відсутній env, відсутній compose-файл, проблеми з правами).
Завдання 5: Прочитати логи юніта за останній старт системи
cr0x@server:~$ journalctl -u compose@myapp.service -b --no-pager
Jan 03 09:13:03 server systemd[1]: Starting Docker Compose stack (myapp)...
Jan 03 09:13:04 server docker[2214]: [+] Running 3/3
Jan 03 09:13:04 server docker[2214]: ✔ Network myapp_default Created
Jan 03 09:13:04 server docker[2214]: ✔ Container myapp-db-1 Started
Jan 03 09:13:04 server docker[2214]: ✔ Container myapp-api-1 Started
Jan 03 09:13:04 server systemd[1]: Started Docker Compose stack (myapp).
Значення: Це джерело правди, чи старт відбувся під час завантаження.
Рішення: Якщо логи показують відсутні файли або помилки маунту, виправте залежності/порядок. Якщо логи показують успіх, але додаток падує — проблема всередині контейнерів або їх залежностей (готовність, мережа, відновлення БД).
Завдання 6: Підтвердити, що контейнери існують і відповідають проєкту Compose
cr0x@server:~$ docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES STATUS PORTS
myapp-api-1 Up 1 minute (healthy) 0.0.0.0:8080->8080/tcp
myapp-db-1 Up 1 minute 5432/tcp
Значення: Контейнери присутні; стан здоров’я видно для сервісів з healthchecks.
Рішення: Якщо контейнери відсутні — Compose не виконався або виконався в неправильній директорії. Якщо контейнери рестартують — перевірте логи і навантаження на ресурси.
Завдання 7: Дослідити, чому контейнер перезапускається (останні коди виходу, OOM, health)
cr0x@server:~$ docker inspect myapp-api-1 --format '{{.State.Status}} {{.State.ExitCode}} OOM={{.State.OOMKilled}} Health={{if .State.Health}}{{.State.Health.Status}}{{end}}'
running 0 OOM=false Health=unhealthy
Значення: Він запущений, але unhealthy; ймовірно залежність не готова або додаток неправильно налаштовано.
Рішення: Не перезапускайте сліпо. Перевірте логи і доступність залежностей. Якщо OOM=true — налаштуйте ліміти пам’яті або потужності хоста.
Завдання 8: Перевірити розв’язання compose-файлу і середовище в точній WorkingDirectory
cr0x@server:~$ cd /srv/myapp
cr0x@server:~$ /usr/bin/docker compose config
name: myapp
services:
api:
image: registry.local/myapp-api:1.9.2
environment:
DB_HOST: db
db:
image: postgres:16
Значення: Compose може розпарсити і відрендерити фінальну конфігурацію.
Рішення: Якщо це падає — systemd теж впаде. Виправте синтаксис, відсутні змінні оточення, відсутні файли або неправильний WorkingDirectory.
Завдання 9: Підтвердити, що шлях персистентних даних змонтовано перед запуском Compose
cr0x@server:~$ findmnt /srv/myapp
TARGET SOURCE FSTYPE OPTIONS
/srv/myapp tank/appdata/myapp zfs rw,xattr,noacl
Значення: Директорія даних стеку — це реальний маунт (тут ZFS dataset).
Рішення: Якщо findmnt нічого не показує — ви пишете в корінь файлової системи. Додайте RequiresMountsFor=/srv/myapp в unit і виправте порядок маунтів/імпорту.
Завдання 10: Перевірити порядок завантаження та граф залежностей
cr0x@server:~$ systemctl list-dependencies compose@myapp.service --no-pager
compose@myapp.service
● ├─docker.service
● ├─network-online.target
● └─multi-user.target
Значення: systemd не запускатиме Compose, поки ці юніти не будуть задоволені.
Рішення: Якщо ваш маунт не в списку — ви покладаєтесь на таймінг. Додайте RequiresMountsFor або явні mount-юніти.
Завдання 11: Час, коли завантаження повільне: critical chain
cr0x@server:~$ systemd-analyze critical-chain compose@myapp.service
compose@myapp.service +1.820s
└─docker.service +1.301s
└─network-online.target +1.005s
└─NetworkManager-wait-online.service +1.002s
Значення: Ваш стек Compose не найповільніший; система чекає на network-online.
Рішення: Якщо стек не потребує network-online, приберіть його. Інакше виправте provider для network-online (наприклад, налаштування wait-online сервісу).
Завдання 12: Підтвердити поведінку при зупинці і Timeout
cr0x@server:~$ systemctl show compose@myapp.service -p TimeoutStopUSec -p ExecStop
TimeoutStopUSec=3min
ExecStop={ path=/usr/bin/docker ; argv[]=/usr/bin/docker compose stop ; ignore_errors=no ; start_time=[n/a] ; stop_time=[n/a] ; pid=0 ; code=(null) ; status=0/0 }
Значення: systemd дозволить 3 хвилини на коректну зупинку.
Рішення: Якщо у вас бази даних, 10 секунд — це комедія. Збільшіть TimeoutStopSec і налаштуйте Compose stop_grace_period, якщо потрібно.
Завдання 13: Перевірити драйвер логів Docker і ротацію (щоб уникнути сюрпризів із заповненим диском під час завантаження)
cr0x@server:~$ docker info --format '{{.LoggingDriver}}'
json-file
Значення: Контейнери логують у json-файли за замовчуванням.
Рішення: Переконайтесь, що /etc/docker/daemon.json налаштовує ротацію, або розгляньте journald, якщо це підходить вашій операційній моделі. Повний диск під час завантаження може не дозволити Docker запуститись взагалі.
Завдання 14: Виявити, чи маєте rootless Docker, коли думали, що ні
cr0x@server:~$ docker context show
default
cr0x@server:~$ systemctl --user status docker --no-pager
Unit docker.service could not be found.
Значення: Ймовірно rootful Docker (або юзер-сервіс не встановлено). Rootless зазвичай має користувацький docker service і інший шлях сокета.
Рішення: Узгодьте розташування юніта (system vs user) з тим, як запускається Docker. Невірні припущення викликають «працює в шеллі, падає при завантаженні».
Плейбук швидкої діагностики
Коли стек не повернувся після перезавантаження, у вас немає часу на інтерпретативний танець. Ось найшвидший шлях до вузького місця.
Спочатку: доведіть, чи systemd виконав команду старту
- Перевірте стан юніта:
systemctl status compose@X.service - Перевірте логи останнього старту:
journalctl -u compose@X.service -b
Якщо юніт ніколи не запускався — ви в зоні enablement/Install/target. Якщо він запускався і впав — виправте повідомлену помилку перед тим, як торкатися контейнерів.
По-друге: доведіть, чи Docker був готовий і залишився живим
systemctl status dockerjournalctl -u docker -b— дивіться помилки драйверів сховища, повний диск, проблеми з правами, цикли падіння демона.
Якщо Docker нездоровий — Compose не має значення. Спочатку виправте сховище Docker, диск або конфігурацію.
По-третє: доведіть, чи були доступні передумови (маунти, мережа, секрети)
findmnt /srv/Xабо відповідні шляхи.systemctl list-dependencies compose@X.serviceщоб побачити, чи вимагаються маунти.- Перевірте наявність і права env-файлів, compose-файлів і директорій для bind mount.
По-четверте: якщо все «запустилось», переслідуйте готовність і помилки на рівні додатка
docker psі статуси health.docker logs --tail=200для проблемних контейнерів.docker inspectдля кодів виходу, OOMKilled, health-помилок.
По-п’яте: ізолюйте обмеження ресурсів
- Тиск CPU/пам’яті:
docker stats --no-stream,free -h. - Тиск диска:
df -h,docker system df. - Повільні маунти:
systemd-analyze critical-chainі логи mount-юнітів.
Короткий жарт №2: «Працювало після ще одного ребута» — це не фікс; це ігровий автомат з кращим брендингом.
Поширені помилки: симптом → корінь проблеми → виправлення
Це секція, яку ви побажаєте прочитати перед викликом на інцидент.
1) Симптом: Unit каже «active (exited)», але контейнери відсутні
- Корінь проблеми: Неправильний
WorkingDirectory, тому Compose запустився в порожній директорії і створив новий проєкт десь ще (або нічого не зробив). - Виправлення: Встановіть
WorkingDirectory=/srv/myapp(або еквівалент). Запустітьdocker compose configз тієї директорії для валідації. Розгляньте--project-name, якщо потрібно, але зазвичай іменування за директорією підходить.
2) Симптом: Контейнери стартують, але додаток одразу після завантаження не може підключитися до бази
- Корінь проблеми: Ви покладалися на
depends_onдля готовності. DB контейнер запущений, але ще не приймає з’єднань (відновлення після збою, fsck, відтворення WAL, розблокування шифрування). - Виправлення: Додайте healthcheck до контейнера бази даних і реалізуйте повторні спроби/бекоф у додатку. Якщо потрібно загородити старт, зробіть невеликий init/wait крок у entrypoint додатку, а не в systemd.
3) Симптом: Після перезавантаження директорія даних порожня або «скинута»
- Корінь проблеми: Маунт не був готовий; контейнер створив нову директорію у кореневій ФС і ініціалізував свіжі дані. Пізніше маунт з’являється і ховає невірні дані.
- Виправлення: Додайте
RequiresMountsFor=/srv/myapp(або точний шлях даних) до юніта. Для ZFS переконайтесь, що імпорт відбувається раніше. Для зашифрованих дисків переконайтесь, що розблокувальні юніти передують Docker/Compose.
4) Симптом: Compose-юніт падає з «Cannot connect to the Docker daemon» під час старту
- Корінь проблеми: Ваш юніт виконується раніше, ніж Docker socket/демон готовий, або Docker повільний через перевірки сховища.
- Виправлення: Переконайтесь у
Requires=docker.serviceіAfter=docker.service. Якщо Docker активується через socket, все одно залежіть від сервісу. Розгляньте збільшенняTimeoutStartSecдля вашого Compose-юніта, якщо Docker старує повільно.
5) Симптом: Завершення висить довго, потім контейнери вбивають
- Корінь проблеми: Надто короткі таймаути зупинки, або ваш юніт зовсім не зупиняє стек, лишаючи Docker робити це пізно при завершенні.
- Виправлення: Додайте
ExecStop=docker compose stopі реалістичнийTimeoutStopSec. Для stateful сервісів встановіть Composestop_grace_periodі уникайтеdown, якщо хочете швидший рестарт без пересоздання.
6) Симптом: Юніт працює вручну, але на старті відсутні змінні оточення
- Корінь проблеми: Ви використали змінні в shell-профілі або покладалися на інтерактивне середовище. systemd сервіси не завантажують ваші shell RC-файли.
- Виправлення: Використовуйте
EnvironmentFile=в юніті або вбудуйте змінні в Composeenv_file. Перевірте за допомогоюsystemctl showіdocker compose config.
7) Симптом: Завантаження повільне через нескінченне очікування network-online
- Корінь проблеми: Ви додали
network-online.targetз звички, але стек його не потребує, або сервіс очікування мережі неправильно налаштовано. - Виправлення: Видаліть залежність від network-online, якщо вона не потрібна. Якщо потрібна — виправте wait-online сервіс чи конфігурацію network manager.
8) Симптом: Rootless Compose стек не стартує після перезавантаження
- Корінь проблеми: Користувацький сервіс не стартує при завантаженні, бо lingering не ввімкнено, або юніт встановлено як system unit, коли Docker — rootless.
- Виправлення: Встановіть як user unit і виконайте
loginctl enable-linger USER. Перевірте за допомогоюsystemctl --user is-enabledі перегляньте журнали користувача.
Три міні-історії з корпоративного світу
Міні-історія 1: Інцидент через хибне припущення
Середня компанія запускала клієнтський API на одному потужному VM. Нічого хитрого: стек Compose з API-контейнером, Redis і Postgres. Вони поставили restart: always для всього і назвали це «high availability». Це працювало місяцями — от як з’являється хибна впевненість.
Після планового оновлення ядра VM перезавантажився. Docker піднявся. Контейнери піднялись. API технічно був доступний. Але він повертав 500 протягом приблизно десяти хвилин, потім сам відновився. На черзі був on-call, який побачив це, пожалів плечима і пішов далі. «Транзієнтно».
Через тиждень ще один ребут стався — під час пікового навантаження. Логіка міграцій API виконалась на старті, припустивши, що база доступна одразу, і сильно впала. Політика перезапуску контейнера старанно перезапускала його з інтервалом, дроблячи лог і утримуючи сервіс вниз. Postgres був у порядку; він просто відтворював WAL на повільнішому ніж зазвичай диску після некоректного завершення.
Хибне припущення було тонким: вони вірили, що «контейнер запущений» означає «залежність готова». Також вірили, що Docker restart policy достатній для порядку старту. Обидва припущення поширені і обидва помилкові в характерний спосіб, який проявляється під час завантаження або відновлення.
Виправлення було нудним і ефективним: systemd стартував стек після маунтів і Docker, додали healthchecks для Postgres і Redis, а API навчили повторювати підключення з бекофом перед виконанням міграцій. Наступний ребут пройшов без пригод — найкращий результат.
Міні-історія 2: Оптимізація, що відбилася боком
Інша команда хотіла прискорити завантаження. На одному хості в них було дюжина Compose-проєктів (внутрішні тулзи, дашборди, невеликі сервіси). Хтось помітив, що очікування network-online.target додає секунди, тож його видалили з усіх unit-ів і оголосили перемогу.
Завантаження стало швидшим. Потім почалися дивні збої: контейнер метрик не міг резолвити DNS під час старту і закешував помилку. Сервіс перевірки ліцензії один раз спробував звернутись до зовнішнього ендпоінту, провалився і вимкнувся до ручного рестарту. Реверс-проксі стартував без можливості резолвити upstream і видавав дефолтні сторінки помилок.
У постмортемі команда виявила неприємну річ: ці сервіси ніколи не були стійкі до тимчасової відсутності мережі. Очікування network-online маскувало крихкість додатків. Видалення зробило цю крихкість видимою, і «оптимізація» перетворила швидкість старту в нестабільність сервісів.
Рішення було тонким. Вони повернули network-online лише для стеків, яким це дійсно потрібно, і виправили найгірші кейси, щоб повторювати мережеві операції. Час завантаження трохи покращився, надійність — значно, і команда навчилась, що економія секунд на старті — дорога, коли потім доводиться лагодити сервіси.
Міні-історія 3: Нудна, але правильна практика, що врятувала ситуацію
Фінансово орієнтована компанія запускала невеликий Compose-стек для звітного пайплайна: планувальник, воркер і база даних. Хост використовував зашифроване сховище і виділений dataset для даних БД. Їхній systemd-юніт мав один додатковий рядок у порівнянні з іншими: RequiresMountsFor=/srv/reporting.
Одного ранку після непильного ребута розблокування шифру тривало довше, бо залежний сервіс повторював отримання ключа. Система була «вгору», але dataset не був змонтований, коли Docker старував. Без порядку DB-контейнер ініціалізував би свіжу базу в не змонтованій директорії на кореневому диску.
Але юніт не стартував. systemd чекав. Docker був готовий, мережа була в порядку, але маунт відсутній, тож Compose-сервіс залишився в черзі. Коли dataset змонтувався, стек стартував нормально. Нема «поділу мозку» директорій. Нема фантомної свіжої бази. Нема драм з відновленням.
Коли потім вони аудитили логи завантаження, єдиним артефактом була затримка старту. Та затримка — фіча: вона запобігла прихованому розходженню даних. Найкраща робота з надійності часто виглядає як «нічого не сталося», і це єдине прийнятне естетичне явище в проді.
Контрольні списки / поетапний план
Покроковий план: чисто мігрувати Compose-стек на systemd
- Нормалізуйте місце розташування стеку: помістіть кожен проєкт у стабільну директорію (наприклад,
/srv/myapp). Вирішіть, чи потрібні вам template units (compose@.service) або юніти на стек. - Зробіть стан явним: переконайтесь, що бази даних використовують іменовані томи або bind mount в контрольований шлях. Уникайте анонімних томів для критичних даних.
- Валідуйте конфіг Compose детерміністично: запускайте
docker compose configз передбачуваної директорії. Виправте попередження й відсутні змінні. - Напишіть unit:
WorkingDirectory=встановлено на директорію стеку.Requires=docker.service,After=docker.service.RequiresMountsFor=для персистентних шляхів на виділених маунтах.ExecStart=docker compose up -d --remove-orphansExecStop=docker compose stop(абоdown, якщо дійсно треба).- Реалістичні таймаути.
- Перезавантажте systemd:
systemctl daemon-reload. - Увімкніть юніт:
systemctl enable --now compose@myapp.service. - Тестуйте поведінку при перезапуску без реального ребута: зупиніть Docker, запустіть Docker, переконайтесь у поведінці юніта. Потім зробіть реальний ребут у вікні техобслуговування і дивіться
journalctl. - Інструментуйте готовність: додайте healthchecks для критичних залежностей; переконайтесь, що додатки повторюють підключення при відмові. Не змушуйте systemd виконувати рівень додатка.
- Встановіть політику логування: виберіть драйвер логів і ротацію. Переконайтесь, що використання диска не вибухне.
- Документуйте контракт операцій: що означає «start», «stop», «upgrade» для цього стеку, включно з очікуваннями щодо безпеки даних.
Операційний чекліст: перед тим як називати «надійним»
- Юніт успішно стартує на холодному завантаженні (не лише на «теплому» рестарті).
- Юніт чекає потрібних маунтів; директорії даних не створюються на неправильній файловій системі.
- Контейнери БД мають реалістичні періоди grace для зупинки; при завершенні їх не SIGKILL-ять рoutinely.
- Логи старту видимі в
journalctl -u, а логи контейнерів зберігаються/ротуються. - Видалення сервісу з Compose не лишає його працюючим назавжди (використовуйте
--remove-orphans). - Сценарії катастрофи протестовано: Docker не стартує, диск повний, маунт відсутній, мережа відсутня. Система падає голосно, а не тихо.
FAQ
1) Чи варто використовувати Docker restart policies, якщо у мене є systemd-юніти?
Так, але на різних рівнях. systemd має управляти «запуском стеку при завантаженні» і «зупинкою при завершенні». Docker restart policies обробляють крахи контейнерів під час рантайму. Тримайте їх узгодженими і уникайте ілюзії подвійного нагляду.
2) Чи має systemd-сервіс бути Type=simple і запускати «docker compose up» без -d?
Зазвичай ні. Запуск без -d прив’язує здоров’я сервісу до довготривалого клієнтського процесу. Це може працювати, але крихке: логи, поведінка TTY і крах клієнта можуть збити systemd з пантелику. Type=oneshot + up -d чистіший для більшості хостів.
3) Чи достатньо depends_on для контролю порядку старту?
Воно контролює порядок старту, а не готовність. Якщо вам потрібна готовність — використовуйте healthchecks і логіку повторних спроб. Розглядайте готовність як відповідальність додатку, а не як магію оркестратора.
4) Чи має ExecStop використовувати «docker compose down» чи «stop»?
stop безпечніший для stateful стеків і швидший для рестарту. down підходить, коли ви хочете пересоздати контейнери і впевнені, що персистентні дані на іменованих томах/маунтах не зникнуть.
5) Як переконатися, що стек не стартує раніше за імпортовані ZFS dataset або зашифровані томи?
Використовуйте RequiresMountsFor=, вказавши шлях, який потрібен стеку (наприклад, /srv/myapp або /var/lib/myapp). Це змусить systemd чекати маунту. Також переконайтесь, що сам маунт налаштовано так, щоб з’являтися перед multi-user.
6) Чому юніт каже «active (exited)» — хіба це не помилка?
Це правильно для oneshot-юніта з RemainAfterExit=yes. Завдання юніта — виконати команду старту. Docker тримає контейнери. Якщо ви хочете, щоб systemd відслідковував процес, потрібен інший патерн.
7) Як чисто запускати кілька стеків?
Використовуйте template unit (compose@.service) і конвенцію директорій, наприклад /srv/<stack>. Кожен стек вмикається окремо: systemctl enable --now compose@stackname. Це уникає одного монолітного юніта, що намагається робити все і провалюється неоднозначно.
8) Як правильно працювати з секретами?
Принаймні тримайте секрети поза unit-файлами і поза Git. Використовуйте EnvironmentFile= з відповідними правами або Compose secrets на основі файлів. Якщо у вас є секретний менеджер — інтегруйте його в рантайм контейнера. Не прикидайтеся, що systemd — це сейф для секретів.
9) А що щодо Podman Compose або quadlets?
Podman має нативну інтеграцію з systemd через quadlets, і це дійсний вибір. Але ця стаття саме про Docker Compose. Якщо ви на Podman — використовуйте його нативні патерни замість емулювання Docker.
10) Як уникнути того, щоб boot тягнув образи і зависав назавжди?
Не тягніть образи при завантаженні, якщо ви цього не маєте на увазі. Тримайте образи попередньо затягнутими через окрему задачу оновлення або workflow обслуговування. Якщо все ж тягнете під час boot — збільшіть TimeoutStartSec і прийміть компроміс.
Висновок: наступні правильні кроки
Якщо хочете, щоб стек Compose поводився після перезавантаження, перестаньте ставитися до завантаження як до містицизму і почніть ставитися до нього як до управління залежностями. systemd відмінно розставляє порядок і керує життєвим циклом. Compose відмінно декларує стек. Разом вони — нудні у найкращому сенсі.
Наступні кроки, що швидко окупаються:
- Напишіть template unit з
WorkingDirectory,After/RequiresіRequiresMountsFor. - Увімкніть його для кожного стеку і перевірте через
journalctl -bпісля контрольованого ребута. - Додайте healthchecks і повторні спроби там, де «запущено» ≠ «готово».
- Встановіть таймаути зупинки, що поважають ваші stateful сервіси.
Ваше майбутнє «я» все одно іноді отримає пейдж. Але це будуть реальні проблеми, а не те, що контейнери прибігли до старту швидше за диски.