Ночь, на сервере обновили ядро, машина ушла в перезагрузку и вернулась. Утром часть сервисов молчит: контейнеры, запущенные руками через docker run, так и не поднялись, ведь никто не сказал им делать это самим. Ситуация до обидного знакомая, и лечится она одной строкой в команде запуска или, для требовательных проектов, аккуратным юнитом systemd. Ниже разбор четырёх встроенных режимов перезапуска, честный список их ограничений, два готовых юнита и лимиты ресурсов, без которых один прожорливый сосед способен уронить весь сервер.
Четыре режима перезапуска и их поведение после перезагрузки
У Docker четыре встроенных режима перезапуска, и разница между ними проявляется в самый неудобный момент: ночью, после сбоя службы Docker или рестарта сервера. Задаётся режим флагом --restart при запуске контейнера или строкой restart в описании стека Compose.
Режимов четыре, и у каждого свой характер:
- no оставляет контейнер в покое: упал, значит лежит, после перезагрузки сам не поднимется;
- on-failure перезапускает процесс только при ненулевом коде выхода, суффикс с числом ограничивает попытки, например on-failure:5;
- always поднимает контейнер после любого падения и после любого старта службы, даже если перед этим его остановили руками;
- unless-stopped ведёт себя скромнее: помнит ручную остановку и не поднимает контейнер после перезагрузки без спроса.
Посмотреть действующий режим любого контейнера можно инспектом: docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' имя ответит словом no, on-failure, always или unless-stopped. Пример запуска с флагом и строка для Compose выглядят так:
docker run -d --restart=unless-stopped --name app nginx:stable-alpine
services:
app:
image: nginx:stable-alpine
restart: unless-stopped
Самая коварная пара это always и unless-stopped. Отличие в одном: always игнорирует ручную остановку после рестарта службы. Администратор вечером гасит контейнер, утром сервер перезагружают, и контейнер снова работает, как ни в чём не бывало. Об этом спрашивают на собеседованиях по контейнерам, а на практике эффект незапланированного воскрешения доставал не одного дежурного. Режим unless-stopped лишён этого эффекта и потому стал разумным выбором по умолчанию для типовых служб.
У работающего контейнера режим меняется на лету командой docker update --restart=unless-stopped имя. Это спасает, когда контейнер давно запущен, а флаг при запуске забыли: пересоздавать ничего не нужно.
Ограничения встроенных режимов перезапуска в живом проекте
Встроенные режимы честно делают одну работу: поднимают процесс после падения. Всё, что сложнее, им не по силам. Контейнер может зависнуть без выхода из процесса, и перезапускать становится нечего: служба молчит, а посетители видят ошибку. Порядок старта не контролируется, приложению нужна готовая база, а флаг перезапуска ждать не умеет. Образ сам не обновится, устаревшая версия будет послушно подниматься снова и снова. Лимиты ресурсов и уведомления о сбоях тоже вне компетенции простого флага.
Типовой пример из практики: контейнер приложения стартует раньше соседнего контейнера базы, обращается к пустому адресу и падает с ошибкой соединения. Флаг on-failure честно перезапустит его пять раз, потратит попытки впустую и ляжет. Систематное решение выглядит иначе: порядок задают зависимости юнитов, а живучесть остаётся на restart policy внутри стека.
Отсюда простое правило выбора. Для одиночного сервиса, который достаточно просто держать живым, хватает unless-stopped. Как только появляются порядок запуска, потребность обновлять образ перед стартом, лимиты ресурсов или дисциплина журналов, дело переходит к systemd с его юнитами, зависимостями и таймерами.
Ещё один бытовой сценарий: сервер с дюжиной разрозненных контейнеров, запущенных в разное время разными людьми. Через месяц никто не помнит, что и зачем крутится, а перезагрузка превращается в восстановление по памяти. Единый юнит или один файл стека решают вопрос каталога служб сами по себе.
Файл юнита systemd для одного контейнера
Юнит для одиночного контейнера живёт в каталоге /etc/systemd/system и описывает полный цикл: подготовку, запуск, остановку и перезапуск. Ниже готовый пример для статичного сайта.
[Unit]
Description=Статичный сайт в контейнере
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
ExecStartPre=-/usr/bin/docker rm -f site
ExecStartPre=/usr/bin/docker pull nginx:stable-alpine
ExecStart=/usr/bin/docker run --name site -p 8080:80 --memory=256m --cpus=0.5 -v /srv/site:/usr/share/nginx/html:ro nginx:stable-alpine
ExecStop=/usr/bin/docker stop -t 10 site
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Чтение файла сверху вниз. Секция Unit выстраивает порядок: контейнер требует поднятой службы Docker и сети, строки Requires и After не дадут ему стартовать раньше времени. Первая строка ExecStartPre со знаком минуса впереди убирает застрявший контейнер прошлой сессии, минус означает, что ошибка не фатальна, часто контейнера и нет. Вторая подтягивает свежий образ, поэтому каждый перезапуск несёт обновление. Строка ExecStart запускает контейнер в переднем режиме, docker run висит, пока живёт контейнер, и systemd считает это работой службы. Флаги --memory и --cpus задают потолок ресурсов прямо в команде запуска. Остановка идёт через docker stop с десятью секундами на завершение. Наконец строки Restart и RestartSec диктуют перезапуск при сбое с пятисекундной паузой между попытками.
Включение юнита занимает две команды:
sudo systemctl daemon-reload
sudo systemctl enable --now site
Состояние смотрится командой systemctl status site, журнал наблюдается вживую через journalctl -u site -f. После перезагрузки сервера systemd сам поднимет юнит по цели multi-user.target, и ручной запуск уходит в прошлое.
Отдельное предупреждение о смешивании механизмов. Если контейнеру задать флаг --restart=always, а юниту добавить собственный Restart, два уровня начнут спорить, кто главный, и поведение станет непредсказуемым. В схеме с юнитом флаг у контейнера опускают, всеми перезапусками управляет systemd.
Юнит systemd для целого стека на Docker Compose
Для стека из нескольких служб юнит выглядит иначе, и причина в поведении команды docker compose up -d: она отрабатывает и выходит. Для таких случаев у systemd есть тип oneshot в паре с параметром RemainAfterExit.
[Unit]
Description=Продакшн стек проекта
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
WorkingDirectory=/opt/project
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
Смысл приёма в том, что юнит отрабатывает команду и остаётся в статусе active (exited). Ключ WorkingDirectory указывает на каталог с файлом описания стека, без него Compose не найдёт конфигурацию. Остановка через compose down убирает контейнеры и сети, тома остаются на месте. Перезапусками отдельных служб внутри стека управляют их собственные режимы restart из описания, юнит отвечает за старт всего набора целиком.
Нюанс типа oneshot: systemd не применяет к нему автоперезапуски, и это правильно. Юнит лишь даёт стартовую команду, а живучесть отдельных контейнеров обеспечивают режимы restart из файла стека. Получается двухуровневая схема: systemd владеет стеком, стек владеет службами.
Выбор между stop и down в строке ExecStop стоит делать осознанно. Команда docker compose stop лишь останавливает службы, сети остаются на месте. Вариант с down убирает и контейнеры, и сети, тома в любом случае остаются. Флаг --volumes к down приписывать не стоит: он снесёт тома вместе с базой.
Ограничение памяти и процессора на уровне контейнера
Лимиты ресурсов это страховка, которая в спокойные дни незаметна и спасает в день аварии. Механика простая: ядро Linux через контрольные группы выделяет контейнеру потолок памяти и долю процессора, выход за границы срезается.
docker run -d --name app --memory=512m --memory-swap=512m --cpus=1.5 --pids-limit=200 nginx:stable-alpine
В описании стека те же лимиты выглядят компактнее.
services:
app:
image: nginx:stable-alpine
mem_limit: 512m
cpus: 1.5
pids_limit: 200
restart: unless-stopped
Пара --memory и --memory-swap с равными значениями запрещает контейнеру уходить в своп, на VPS без файла подкачки это равноценно обычному лимиту. Значение cpus в полтора ядра означает долю от всех ядер, а не привязку к конкретным. Лимит --pids-limit защищает от бесконечного плодования процессов, классической реакции сломанного приложения.
Проверить фактический потолок работающего контейнера можно инспектом: команда docker inspect --format '{{.HostConfig.Memory}}' app выведет байты, ноль означает, что лимита нет вовсе.
Зачем всё это, видно в день аварии. Без лимитов память кончается у всего сервера, и ядро принудительно завершает самый прожорливый процесс, нередко это чужая MariaDB. С лимитами страдает сам нарушитель: его процесс завершается принудительно, в статусе контейнера появляется пометка OOMKilled, код выхода 137, соседние службы продолжают работать. Расходы всех контейнеров в реальном времени показывает docker stats, она же подсказывает, где лимит задран слишком высоко. Лимиты не бесплатны: заниженный потолок для сервисов со сборкой мусора оборачивается частыми перезапусками, поэтому после настройки стоит несколько дней последить за docker stats и поднять планку при необходимости.
Соотношение лимитов systemd и лимитов Docker
У аккуратного администратора возникает вопрос: зачем нужны свои MemoryMax и CPUQuota в юните, если systemd умеет ограничивать службы. Ответ прячется в устройстве контрольных групп. Команда docker run, запущенная из юнита, лишь просит службу Docker поднять контейнер, а сам контейнер живёт в собственной области под крылом docker.service. Свойства MemoryMax в юните site ограничат короткоживущий клиентский процесс, до контейнера они не дотянутся.
Поэтому рабочая схема выглядит так: потолки контейнеров задаются флагами Docker или строками Compose, а на уровне systemd при желании ограничивают весь движок целиком. Свойства MemoryMax и CPUQuota на службе docker.service станут глобальным потолком для всех контейнеров разом, инструмент грубый, но честный. В связке с Podman карта иная: контейнер, запущенный в rootless режиме из сгенерированного юнита, остаётся в группе своего юнита, и MemoryMax работает напрямую. Это одна из причин, по которой переезжающие с Docker хвалят интеграцию Podman с systemd.
Журналы и обновление контейнера под контролем systemd
После переезда контейнера под юнит наблюдение сводится к двум командам. Systemctl status site показывает состояние юнита и последние строки, journalctl -u site -f ведёт журнал вживую. Внутренний журнал контейнера по-прежнему доступен через docker logs, стандартные потоки приложения пишет движок в свои файлы. Для полноты картины лимиты роста этих файлов настраивают в daemon.json, иначе дисковая авария рано или поздно придёт со стороны журналов.
Обновление образа в такой схеме перестаёт быть событием. Достаточно команды systemctl restart site: юнит остановит контейнер, подтянет свежий образ из строки ExecStartPre и запустит его снова, пауза займёт считаные секунды. Перезагрузка сервера, отказ контейнера, плановое обновление, всё крутится без ручного вмешательства, а журнал остаётся единым источником правды.
Для обновлений без единой секунды простоя одного сервера уже мало, там нужен кластер с несколькими копиями сервиса, и это территория Swarm и Kubernetes.
Быстрый разбор вчерашнего сбоя делается фильтром по времени: journalctl -u site --since yesterday выведет ровно последние сутки жизни юнита, а слова вроде yesterday и выражения вида -1h системный журнал понимает из коробки. Но на уровне одной машины связка systemd с контейнерами закрывает почти все бытовые сценарии.
Выбор между флагом и юнитом на практике выглядит мирно. Типовому сервису достаточно режима unless-stopped, он закрывает и падения, и перезагрузки. Юнит с лимитами нужен там, где требуется дисциплина: порядок старта, обновление перед запуском, потолок ресурсов и единый журнал. Оба инструмента дружат с Compose, стек переписывать не придётся, а сервер перестанет зависеть от чьей-то памяти.