devops-курс Все курсы

✻ Урок 4.9 · Тема 4: Docker и Compose

Отладка контейнеров и уборка диска

⏱ 3.5 ч

Зачем это нужно

Контейнер упал в три часа ночи. У тебя нет ни привычной оболочки внутри, ни времени гадать: есть код выхода (число, с которым программа сообщает системе, как завершилась: 0 «всё хорошо», остальное «что-то не так»), логи (всё, что программа успела напечатать) и docker inspect (команда, которая показывает всё, что Docker знает о контейнере). Вторая типичная беда любого Docker-хоста: диск заполнился логами и старыми образами, а сервис умер с ошибкой no space left on device («на устройстве не осталось места»). Это как переполненный шкаф: новую вещь положить некуда, хотя дверца исправна.

На собеседовании «контейнер в Exited, твои действия» и «диск полный, а du показывает мало» спрашивают почти всегда. На работе ту же лестницу вопросов ты пройдёшь и в Kubernetes: там будут kubectl describe (команда Kubernetes, аналог docker inspect, тема 5) и OOMKilled (контейнер убит ядром за превышение лимита памяти, разберём в теории), но логика та же.

В этом уроке ты воспроизведёшь настоящие поломки (нехватка памяти, занятый порт, DNS-ошибка, перезапуски по кругу, раздутые логи) и научишься по короткому порядку действий находить причину, а не перезапускать «на удачу».

Шаг проекта: в ~/notes появляется Makefile с целями build, up, down, logs, ps, test, clean, и типовые команды из уроков 4.5-4.8 больше не набираются руками. Makefile это файл, в котором ты один раз записываешь команды под короткими именами, а потом запускаешь их словом make build: как кнопки быстрого набора в телефоне. build собирает образ, up и down поднимают и гасят стек.

Что нужно знать

  • Урок 1.4: процессы и сигналы: что такое процесс и PID, сигнал (короткое сообщение процессу от системы) SIGTERM («заверши работу») и SIGKILL («умри сейчас»), код выхода 128 + номер сигнала.
  • Урок 1.5: диск, память, CPU: df и du, OOM-killer (механизм ядра, которое убивает процесс, когда память кончилась), cgroup (механизм ядра, задающий процессу потолок памяти и CPU, то есть процессора), демонстрационный эндпоинт /leak (адрес приложения, который намеренно занимает память).
  • Урок 1.6: основы Bash: Makefile (файл с именованными наборами команд), TAB в рецептах (строки с командами начинаются с символа табуляции), переменные, test_app.py.
  • Урок 4.2: Dockerfile: образ, слои, пользователь 10001, CMD в exec-форме и shell-форме.
  • Урок 4.3: тома и сети: именованные тома и пользовательские сети.
  • Урок 4.5: Compose и PostgreSQL: стек notes + db, том pgdata (именованный том Docker, где PostgreSQL хранит данные, чтобы они пережили пересоздание контейнера).
  • Урок 4.6: nginx и TLS в Compose: сервис proxy, ротация логов (ограничение размера файлов логов, чтобы они не заполнили диск) x-logging.
  • Урок 4.7: образы и реестр: образ notes:0.4.0, multi-stage Dockerfile.

Картина целиком

Диагностика контейнера похожа на приём у врача скорой помощи. Врач не начинает с операции. Он идёт по порядку: как выглядит пациент (жив ли), что он сам скажет (жалобы), что показывают анализы (подробные данные), можно ли его осмотреть изнутри, как работают органы под нагрузкой и не в порядке ли условия вокруг (палата, питание). Так и тут: у каждой ступени свой вопрос и своя команда.

Контейнер для этой задачи это обычный процесс Linux, которому ядро дало три вещи: свой взгляд на файлы и сеть, лимит ресурсов и канал для сообщений (stdout: стандартный поток вывода, куда программа «говорит» обычные сообщения). Сломаться он может ровно тремя путями: процесс завершился сам (код выхода), ядро его убило (сигнал, чаще всего за память) или ему не хватило чего-то снаружи (порт, имя, место на диске).

flowchart TB
    S1["1. Состояние: docker ps -a<br>жив? какой код выхода?"] --> S2["2. Логи: docker logs --tail 50<br>что процесс успел сказать?"]
    S2 --> S3["3. Причина: docker inspect<br>OOMKilled? перезапуски? команда? тома?"]
    S3 --> S4["4. Внутри: docker exec или отладочный контейнер<br>что видит сам процесс: файлы, сеть, env?"]
    S4 --> S5["5. Ресурсы: docker stats<br>CPU, память, сеть под нагрузкой"]
    S5 --> S6["6. Хост: docker system df, df -h<br>хватает ли места, не забиты ли логи?"]
    S6 -.-> D["/var/lib/docker/containers/&lt;id&gt;/&lt;id&gt;-json.log<br>логи контейнера растут без ротации"]

За урок ты пройдёшь каждую ступень на живых поломках, затем научишься убирать диск так, чтобы не потерять данные, и в конце оформишь всё в Makefile.

Теория

Что мы диагностируем: процесс, код выхода и сигнал

Слова «контейнер упал» ничего не говорят. Упасть может только процесс внутри, а у процесса есть точная «причина смерти». Без этого понятия нельзя прочитать ни одну диагностику.

У каждого рабочего, уходящего со смены, есть запись в журнале: «ушёл сам, всё сделано» (0), «ушёл с жалобой» (не ноль) или «выведен охраной» (сигнал). Код выхода это запись в журнале смерти процесса.

Устроено это так.

  1. Процесс (process) это запущенная программа. У каждого есть номер PID. В контейнере главный процесс, тот, что запустила команда CMD, получает PID 1 (первый в своём контейнере). Когда он завершается, контейнер останавливается: контейнер живёт ровно столько, сколько живёт его PID 1.
  2. Код выхода (exit code) это число от 0 до 255, которое процесс отдаёт системе при завершении. 0 значит «всё хорошо», всё остальное «что-то не так».
  3. Сигнал (signal) это короткое сообщение процессу от ядра или от другого процесса. Два нужны всегда: SIGTERM (номер 15, «пожалуйста, заверши работу», процесс может успеть прибраться) и SIGKILL (номер 9, «умри немедленно», перехватить нельзя). Подробнее об этом в уроке 1.4.
  4. Если процесс убит сигналом, код выхода равен 128 + номер сигнала. Это правило оболочки, оно же используется Docker.

Посмотрим на примере. SIGKILL это сигнал 9, значит код выхода 128 + 9 = 137. SIGTERM это 15, значит 128 + 15 = 143. Если ты видишь 137, знай: процесс не «сломался», его убили. Осталось выяснить кто.

Прикинь сам: Процесс убит сигналом SIGKILL (номер 9). Чему равен код выхода?

137: код выхода равен 128 плюс номер сигнала, то есть 128 + 9.

Осторожно, частое заблуждение: «Код 0 это всегда хорошо». Для сервиса нет: он должен работать вечно, и Exited (0) у веб-приложения значит, что оно тихо завершилось и это странно.

Главное: «упал контейнер» значит «завершился главный процесс»; код выхода (128 плюс номер сигнала) показывает, как именно.

Дальше посмотрим, из чего состоит контейнер как процесс.

Проверь понимание: почему после docker stop контейнер остановился, хотя ты «не убивал» его?

Ответ

Docker послал главному процессу (PID 1) сигнал SIGTERM. Процесс получил его, завершился, и раз контейнер живёт, пока живёт PID 1, остановился и контейнер.

Контейнер глазами диагноста: три «стены» и слой записи

Все команды урока становятся понятнее, если помнить, что контейнера как отдельной «маленькой машины» нет. Есть обычный процесс, которому ядро Linux выдало три вещи. Тогда понятно, почему у контейнера нет своих логов на диске, почему он умирает вместе с процессом и откуда берутся лимиты.

Гостиничный номер для гостя. В номере свои стены (изоляция: гость видит только свою мебель), счётчик на воду и свет (лимиты) и журнал обслуживания у администратора (всё, что гость просит, записывается). Гость живёт, пока идёт его проживание, а потом номер убирают. Аналогия перестаёт работать в том, что в гостинице номер физически отдельный, а контейнеры делят одно и то же ядро хоста: поэтому взломанный контейнер опаснее, чем отдельная виртуальная машина.

Устроено это так.

  1. Изоляция (namespaces). Ядро показывает процессу свой набор файлов, свою сеть и свой список процессов. Внутри контейнера твой python app.py думает, что он PID 1 и что в системе больше никого нет. С хоста тот же процесс виден под другим, обычным номером.
  2. Лимиты (cgroups). Ядро считает, сколько памяти и процессора занимает группа процессов, и может не дать больше заданного. Это та самая «счётчик на воду»: превысил потолок памяти, и ядро убивает процесс (OOMKilled).
  3. Файлы из слоёв и слой записи. Образ только для чтения. Поверх него Docker кладёт тонкий слой, куда контейнер пишет свои изменения (слой записи, writable layer). Когда контейнер удаляют, этот слой удаляется вместе с ним. Тома (volumes) живут отдельно и остаются, поэтому данные базы хранят в томе.
  4. Вывод и логи. Всё, что процесс печатает в stdout и stderr, Docker записывает в файл на хосте. Это единственный «журнал», за который отвечает платформа.

Вот как это выглядит на деле. Ты зашёл в контейнер notes через docker exec, создал файл /tmp/x и вышел. Пока контейнер работает, файл на месте: слой записи цел. Ты выполнил docker rm -f notes и создал контейнер заново из того же образа: файла нет. Данные из тома /data при этом на месте: том подключён снаружи. Отсюда практическое правило: всё, что должно пережить контейнер, лежит в томе, всё остальное считается одноразовым.

Прикинь сам: Ты создал файл /tmp/x внутри контейнера и пересоздал его командой up --force-recreate. Сколько файлов /tmp/x останется?

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

Осторожно, частое заблуждение: что контейнер это «лёгкая виртуальная машина». ВМ запускает своё ядро, контейнер использует ядро хоста. Поэтому внутри контейнера нельзя поменять версию ядра, а нехватка памяти у контейнера проявляется как убийство процесса ядром хоста.

Главное: контейнер это обычный процесс с изоляцией, лимитами и тонким слоем записи, а не маленькая виртуальная машина.

Теперь научимся идти к причине по порядку.

Проверь понимание: ты поправил конфиг прямо внутри работающего контейнера, а после docker compose up -d --force-recreate правка пропала. Почему?

Ответ

Правка лежала в слое записи контейнера, а при пересоздании контейнер удаляется вместе с ним и создаётся заново из неизменного образа. Чтобы правка жила, нужно изменить файл на хосте и смонтировать его в контейнер (bind mount) или изменить образ.

Лестница диагностики и статусы в docker ps -a

Первая ошибка новичка: сразу лезть в конфиги. Порядок действий экономит время: каждая ступень дешёвая и отсекает половину причин.

Как проверка автомобиля, который не заводится: сначала горит ли приборная панель (жив ли), потом что написал бортовой компьютер (логи), потом диагностика в сервисе (inspect), и только потом разбирать двигатель.

Устроено это так.

Ступень Команда Вопрос
1. Состояние docker ps -a жив ли контейнер, какой код выхода
2. Логи docker logs --tail 50 <имя> что он успел сказать перед смертью
3. Причина docker inspect <имя> OOM, перезапуски, команда, смонтированные тома
4. Внутри docker exec -it <имя> sh что видит процесс: файлы, сеть, переменные
5. Ресурсы docker stats --no-stream CPU, память, сеть
6. Хост docker system df, df -h не кончилось ли место

Ключ -a в docker ps -a значит «all»: показывать и остановленные контейнеры (без него видны только работающие). В колонке STATUS бывают такие значения:

  • Up 2 seconds (health: starting): работает, проверка здоровья (HEALTHCHECK из урока 4.2) ещё не ответила; затем (healthy) или (unhealthy).
  • Exited (137) 3 seconds ago: остановился, в скобках код выхода.
  • Restarting (3) 2 seconds ago: падает и его снова поднимает политика перезапуска, в скобках код последнего падения.
  • Created: контейнер создан, но процесс не стартовал (например, не нашлась команда или занят порт).

Разобранный пример (реальный вывод, когда контейнер падает по кругу):

NAMES     STATUS
loop      Restarting (3) 2 seconds ago

Слово Restarting говорит, что кто-то перезапускает контейнер снова и снова, а (3) что процесс каждый раз завершается с кодом 3. Уже из этих двух слов ясно: смотреть надо логи и код 3, а не «почему Docker не работает».

Прикинь сам: docker ps показывает пусто. Сколько контейнеров на хосте точно нет?

Неизвестно: без -a остановленные контейнеры не видны. Проверь docker ps -a.

Осторожно: статус Up не значит «сервис работает». Процесс жив, но приложение может отдавать 500. Поэтому рядом смотрят health.

Главное: идём по лестнице снизу вверх по цене: состояние, логи, причина (inspect), внутри (exec), ресурсы (stats), хост.

Теперь научимся читать сами сообщения об ошибках.

Проверь понимание: docker ps (без -a) показывает пустую таблицу. Значит ли это, что контейнера нет?

Ответ

Нет. Без -a остановленные контейнеры не показываются. Контейнер мог упасть. Проверь docker ps -a.

Как читать сообщение об ошибке Docker: справа налево

Сообщения Docker длинные и страшные, но почти всегда устроены одинаково: в начале стоит «кто сообщает», в конце причина. Если читать слева направо, теряешься в идентификаторах. Если справа налево, причина находится за секунды.

Письмо, которое переслали несколько раз: сверху «Fwd: Fwd: Fwd:», а суть в самом низу. Каждый, кто пересылал, добавлял свою строку сверху. Аналогия перестаёт работать в том, что у Docker «пересылающих» обычно три, и знать их полезно.

Устроено это так.

  1. Команда docker (клиент) просит демона dockerd (фоновую службу Docker) что-то сделать. Если демон отказал, клиент печатает Error response from daemon: и дальше текст демона.
  2. Демон, в свою очередь, просит исполнителя runc запустить процесс. Ошибка оттуда попадает в текст фрагментами вроде OCI runtime create failed, exec: или failed to set up container networking.
  3. В самом конце стоит первоисточник: сообщение ядра, файловой системы или сети (permission denied, executable file not found in $PATH, Bind for ... failed: port is already allocated, no such host).
  4. Середину из идентификаторов (6047ed...), путей и номеров можно пропускать: они нужны для поиска в журнале, а не для понимания.

Теперь на числах. Из урока: docker: Error response from daemon: ... Bind for 127.0.0.1:8080 failed: port is already allocated. Читаем с конца. port is already allocated: порт уже занят. Строка перед ней Bind for 127.0.0.1:8080 failed: какой именно порт и адрес. Error response from daemon: отказал сам демон, а не ты ошибся в синтаксисе. Вывод готов: надо искать, кто держит 8080, а не перечитывать команду.

Второй пример: exec: "nosuchcmd": executable file not found in $PATH. Причина not found in $PATH (программы нет среди каталогов, где система ищет команды), а в кавычках название команды. Вместе с кодом выхода 127 из таблицы выше это опечатка или команда, которой нет в образе.

Прикинь сам: В сообщении Docker три слоя текста и 40 символов идентификатора. Какая часть показывает причину?

Последняя: после последнего двоеточия стоит первоисточник (port is already allocated, permission denied). Середину можно пропускать.

Осторожно, частое заблуждение: «Error response from daemon значит, что Docker сломан». Нет, это просто «ответ демона на твой запрос»: запрос был выполним, но что-то помешало. Если сломан сам демон, ты увидишь Cannot connect to the Docker daemon at unix:///var/run/docker.sock: клиент не достучался до службы.

Главное: сообщение читают с конца: причина стоит последней, а Error response from daemon лишь говорит, кто сообщает.

Следующая улика после текста ошибки это код выхода.

Проверь понимание: Cannot connect to the Docker daemon и port is already allocated. Какая из них говорит о проблеме с самой службой Docker?

Ответ

Первая: клиент вообще не смог соединиться с демоном (служба не запущена или нет прав на сокет). Вторая пришла от работающего демона: он получил запрос, но порт занят.

Коды выхода: таблица и как их получать

Код выхода это самая дешёвая улика: он показывает класс проблемы ещё до чтения логов.

Запомни таблицу:

Код Смысл Откуда берётся
0 штатное завершение для сервиса это тоже странно: он должен работать вечно
1 ошибка приложения необработанное исключение, читай логи
125 сам docker run не смог запуститься неверный флаг, порт занят, ошибка демона
126 команда найдена, но не исполняется нет права x, это не программа
127 команда не найдена опечатка в CMD, нет бинарника в образе
137 128 + 9: SIGKILL OOM-killer, docker kill, docker stop не дождался
143 128 + 15: SIGTERM процесс убит сигналом SIGTERM, а не завершился сам

Важная тонкость про 143. Наше приложение с урока 1.4 перехватывает SIGTERM, печатает shutting down, аккуратно завершается и выходит с кодом 0, а не 143. 143 увидишь у программы, которая не перехватывает SIGTERM и просто умирает от него.

Разберём пример. Ниже реальные результаты трёх «ошибок запуска». В примере alpine:3.22 это маленький образ Linux, nosuchcmd несуществующая команда, /etc/hostname это обычный текстовый файл, не программа:

$ docker run --name c127 alpine:3.22 nosuchcmd; echo "exit=$?"
docker: Error response from daemon: ... exec: "nosuchcmd": executable file not found in $PATH
exit=127
$ docker run --name c126 alpine:3.22 /etc/hostname; echo "exit=$?"
docker: Error response from daemon: ... exec: "/etc/hostname": permission denied
exit=126

$? это код выхода последней команды. docker run передаёт наружу код своей проблемы: 127 «не найдено», 126 «нельзя исполнить». Оба контейнера при этом остались в статусе Created: процесс так и не стартовал.

Прикинь сам: docker run alpine:3.22 python app.py вернул 127. Сколько программ python в этом образе?

Ни одной: код 127 значит «команда не найдена».

Осторожно, частое заблуждение: «137 это всегда нехватка памяти». Нет. 137 значит только «убит SIGKILL». Кто послал сигнал, покажет следующая тема.

Главное: 1 это ошибка приложения, 125 ошибка docker run, 126 не исполняется, 127 не найдено, 137 SIGKILL, 143 SIGTERM.

Теперь посмотрим, где процесс оставляет свои сообщения.

Проверь понимание: docker run alpine:3.22 python app.py вернул 127. Что случилось?

Ответ

В образе alpine:3.22 нет программы python, поэтому «команда не найдена». Нужен образ с Python или другая команда.

Логи: что такое stdout и почему docker logs бывает пуст

У контейнера нет окна, куда приложение могло бы «показать» сообщение. Нужен единый канал, откуда платформа заберёт всё, что программа сказала.

Радиопереговоры на стройке. Каждый рабочий говорит в рацию, а диспетчер (Docker) записывает всё на ленту. Кто написал записку в кармане (лог в файл внутри контейнера), тот диспетчеру ничего не сообщил.

Устроено это так.

  1. У любого процесса есть два стандартных выходных потока: stdout (обычные сообщения) и stderr (сообщения об ошибках). Терминал показывает оба.
  2. Docker подключается к обоим потокам главного процесса и пишет всё в файл на хосте. По умолчанию это драйвер логов json-file: каждая строка оборачивается в JSON с временем и именем потока.
  3. docker logs <имя> читает этот файл. Полезные ключи: --tail 50 (последние 50 строк), --since 10m (за последние 10 минут), -f (следить в реальном времени), -t (добавить время).

Посмотрим на примере. Так выглядит одна строка в файле (реальная, из контейнера, напечатавшего «строка лога для проверки»):

{"log":"строка лога для проверки\n","stream":"stdout","time":"2026-09-30T12:16:32.044359379Z"}

Поле log это сама строка, stream откуда она пришла, time время. Отсюда видно, почему файл лога больше, чем текст: к каждой строке добавляется около 60 байт обвязки.

Поэтому «Заметки» пишут в консоль, а не в файл: их логи автоматически попадают в docker logs.

Прикинь сам: docker logs пуст, а контейнер Exited (1). Сколько причин у пустого лога?

Две основные: процесс упал раньше первой строки или пишет логи в файл, а не в stdout.

Осторожно, частое заблуждение: «docker logs пуст, значит приложение молчит». Не всегда: либо процесс погиб раньше первой строки (например, его убило ядро за память), либо пишет не в stdout, а в файл внутри контейнера.

Главное: Docker читает только stdout и stderr главного процесса, поэтому приложение должно писать в консоль.

Дальше узнаем, где Docker хранит всё остальное о контейнере.

Проверь понимание: docker logs пуст, а контейнер в статусе Exited (1). Какие две причины самые вероятные?

Ответ

Первая: процесс упал раньше, чем успел что-то напечатать, например, не смог стартовать. Тогда смотри код выхода и запускай образ с другой командой: docker run --rm -it --entrypoint sh <образ>. Вторая: приложение пишет логи в файл, а не в stdout. Тогда читай файл через docker exec или docker cp, а в образе перенаправь лог в stdout.

docker inspect: паспорт контейнера

Всё, что Docker знает о контейнере (состояние, лимиты, команда, тома, причина смерти), лежит в одном JSON. Логи рассказывают о том, что сказало приложение, а inspect о том, что с ним сделала платформа.

docker inspect <имя> печатает JSON на несколько сотен строк. Читать всё не нужно. Достают отдельные поля шаблоном --format (-f): шаблон на языке Go, где .State.ExitCode значит «поле ExitCode внутри блока State». Другой способ: конвейер в jq, утилиту для разбора JSON (пример: docker inspect имя | jq '.[0].State').

Поля, которые нужны чаще всего:

Поле Что значит
.State.Status running, exited, restarting и т. п.
.State.ExitCode код выхода последнего завершения
.State.OOMKilled true, если убило ядро за превышение лимита памяти
.RestartCount сколько раз политика перезапускала контейнер
.HostConfig.Memory лимит памяти в байтах (0 значит «без лимита»)
.LogPath путь к файлу логов на хосте
.Mounts какие тома и каталоги подключены
.Config.Cmd, .Config.User команда запуска и пользователь

Вот как это выглядит на деле. Блок State реального контейнера, который убили за память (значения сокращены):

{
  "Status": "exited",
  "OOMKilled": true,
  "ExitCode": 137,
  "StartedAt": "2026-09-30T12:14:13.82010026Z",
  "FinishedAt": "2026-09-30T12:14:16.168988845Z"
}

Читаем так: остановился (exited), код 137, а OOMKilled: true отвечает на главный вопрос «кто убил»: ядро, за превышение лимита. По времени видно, что контейнер прожил около трёх секунд.

Прикинь сам: HostConfig.Memory равно 0. Сколько мегабайт памяти разрешено контейнеру?

Сколько есть на хосте: 0 значит «лимита нет».

Осторожно, частое заблуждение: «OOMKilled: false значит, что память ни при чём». Не совсем: после автоматического перезапуска Docker обнуляет это поле (мы увидим это на практике). Проверяй ещё RestartCount и события.

Главное: inspect показывает состояние, код выхода, OOMKilled, число перезапусков, лимиты и тома; поле достают шаблоном --format.

Иногда причину видно только изнутри.

Проверь понимание: что покажет HostConfig.Memory, если контейнер запущен без флага -m?

Ответ

0. Это значит «лимита нет»: контейнер может занять сколько угодно памяти хоста.

docker exec, отладочный контейнер и лимиты имён

Иногда причину видно только изнутри: есть ли файл, резолвится ли имя, слушает ли порт.

Логи это рассказ пациента, exec это осмотр врачом. А если пациента нельзя «вскрыть» (в образе нет даже оболочки), врач приходит со своими инструментами и работает рядом.

Устроено это так.

  1. docker exec <имя> <команда> запускает второй процесс в тех же «стенах» (пространствах имён, namespaces) и с теми же лимитами (cgroup), что и главный. Ключи -it подключают клавиатуру и терминал, для интерактивной оболочки: docker exec -it имя sh.
  2. Наш образ на основе python:3.13-slim минимален: нет curl и нет ps. Зато есть Python, им можно проверять сеть.
  3. Когда внутри нет вообще ничего (distroless-образы), запускают отладочный контейнер: чужой образ с нужными инструментами, подключённый к сети (--network container:<имя>) или к списку процессов (--pid container:<имя>) целевого контейнера.

Разобранный пример (реальный). Проверяем здоровье изнутри без curl:

$ docker exec web1 python -c "import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:8080/healthz').read())"
b'ok'
$ docker exec web1 ps
sh: 1: ps: not found
$ docker run --rm --pid container:web1 alpine:3.22 ps
PID   USER     TIME  COMMAND
    1 10001     0:00 python app.py
   29 root      0:00 ps

b'ok' это ответ /healthz (буква b значит «байты»). ps в образе «Заметок» нет, но отладочный контейнер alpine:3.22 с ключом --pid container:web1 видит процессы «Заметок»: python app.py под пользователем 10001 и с PID 1. Это пример двух правил сразу: главный процесс это PID 1, а работает он не от root (урок 4.2).

Прикинь сам: В образе «Заметок» нет ps и curl. Сколько способов осмотреть контейнер изнутри остаётся?

Как минимум два: Python внутри (docker exec ... python -c) и отладочный контейнер с --pid container:....

Осторожно, частое заблуждение: «Зашёл в контейнер и поправил файл, значит починил». Изменения слоя записи пропадут при пересоздании контейнера. Чини образ и конфигурацию, а не сам контейнер.

Главное: exec запускает второй процесс в тех же стенах, а отладочный контейнер приносит нужные инструменты и делит среду.

Теперь про самую частую причину тихой смерти: память.

Проверь понимание: зачем отладочному контейнеру ключ --network container:web1, если можно просто запустить curl из хоста?

Ответ

Так ты проверяешь именно то, что видит приложение: он делит с web1 одну сетевую среду, а порт может быть не опубликован наружу. 127.0.0.1 в отладочном контейнере это тот же 127.0.0.1, что у приложения.

Память: лимит cgroup, swap и OOMKilled

Лимит памяти защищает соседей: один жадный контейнер не должен съесть весь хост. Но и сам контейнер при превышении лимита умирает мгновенно и без предупреждения. Эту смерть надо уметь распознавать.

Номер в гостинице с ограничением «не более 64 человек». Как только в номер зашёл 65-й, охрана (ядро) без разговоров выводит кого-то из жильцов. Про остальные номера в гостинице (память хоста) охрана в этот момент не думает.

Устроено это так.

  1. Флаг -m 64m (или mem_limit в Compose) записывает в cgroup контейнера (группу управления ресурсами из урока 1.5) лимит 64 МБ.
  2. Когда процессы группы запрашивают больше, ядро сначала пробует выгрузить лишнее в swap (область подкачки на диске), если она разрешена.
  3. Если освободить нечего, включается OOM-killer: ядро отправляет SIGKILL самому «тяжёлому» процессу группы. Код выхода 137, поле OOMKilled становится true.
  4. Процесс не успевает ничего написать: SIGKILL не перехватывается. Поэтому в docker logs причины смерти нет, она есть только в inspect, в docker events и в журнале ядра (sudo dmesg).

Теперь на числах. Флаг -m 64m по умолчанию разрешает и ещё столько же swap (всего 128 МБ), если swap на машине есть. Поэтому единственный запрос на 100 МБ мог бы «пролезть». Чтобы поведение не зависело от машины, добавляют --memory-swap 64m (общий предел памяти вместе со swap равен лимиту, то есть swap не используется). Запрос /leak?mb=100 («держи 100 МБ», урок 1.5) при лимите 64 МБ даст:

[ 2775.517744] python invoked oom-killer: gfp_mask=0xcc0(GFP_KERNEL), order=0, oom_score_adj=0
[ 2775.517882] oom-kill:constraint=CONSTRAINT_MEMCG,...,task=python,pid=157205,uid=10001
[ 2775.517914] Memory cgroup out of memory: Killed process 157205 (python) total-vm:210136kB, anon-rss:64464kB, file-rss:8828kB, shmem-rss:0kB, UID:10001

Это реальные строки журнала ядра (dmesg), из них убрана только длинная часть с идентификаторами. Читаем: oom-killer вызван; constraint=CONSTRAINT_MEMCG значит «убийство по лимиту cgroup, а не из-за нехватки памяти всего хоста»; anon-rss:64464kB это ровно 63 МБ реально занятой памяти в момент смерти, то есть упёрлись в потолок 64 МБ (64 × 1024 = 65 536 КБ минус немного).

flowchart LR
    A["процесс просит больше -m 64m"] --> B["ядро пробует выгрузить лишнее в swap"]
    B -->|"освободить нечего"| C["OOM-killer шлёт SIGKILL<br>самому тяжёлому процессу"]
    C --> D["код 137, OOMKilled: true<br>в docker logs пусто, причина в inspect и dmesg"]

Прикинь сам: -m 64m, процесс занял 70 МБ, на хосте свободно 6 ГБ. Что произойдёт?

Ядро убьёт процесс: SIGKILL, код 137, OOMKilled: true. Свободная память хоста значения не имеет, важен лимит cgroup.

Осторожно, частое заблуждение: «Свободной памяти на хосте много, значит OOM невозможен». Возможен: лимит контейнера и память хоста это разные величины. И ещё: «политика restart починила проблему». Она только скрывает её. Контейнер поднялся, но RestartCount растёт, а на самой странице приложение отвечает урывками.

Главное: при превышении лимита ядро убивает процесс без предупреждения, в docker logs это не видно, видно в inspect и dmesg.

Чтобы увидеть приближение к лимиту, смотрим stats.

Проверь понимание: контейнер получил -m 64m и в нём процесс занял 70 МБ. На хосте свободно 6 ГБ. Что произойдёт?

Ответ

Ядро убьёт процесс (SIGKILL, код 137, OOMKilled: true), потому что лимит cgroup контейнера превышен. То, что хост свободен, не важно: лимит проверяется отдельно для каждой группы.

docker stats: ресурсы под нагрузкой

inspect говорит, какой лимит задан, но не говорит, сколько контейнер использует прямо сейчас. Чтобы увидеть, что контейнер подбирается к потолку памяти или грузит процессор, нужна «приборная панель» в реальном времени.

Приборная панель автомобиля: скорость, обороты, температура. По одной стрелке диагноз не поставить, но по трём вместе видно, что машина перегревается ещё до поломки. Аналогия перестаёт работать в том, что у панели есть красная зона, а Docker подсвечивает только проценты: красной зоной придётся считать самому.

Устроено это так.

  1. docker stats раз в секунду опрашивает cgroup каждого контейнера и печатает строку с потреблением. Ключ --no-stream печатает один снимок и выходит (удобно для скриптов).
  2. Колонка CPU %: доля процессорного времени. 100% это одно ядро полностью; на многоядерной машине значение может быть и 250%.
  3. Колонка MEM USAGE / LIMIT: занято и предел. Если лимит не задан, вместо него общая память хоста, а не настоящий потолок.
  4. Колонка MEM %: доля от предела. Значение около 100% при заданном -m значит, что OOMKilled близко.
  5. Колонки NET I/O и BLOCK I/O: сколько данных прошло по сети и по диску с момента старта контейнера (сумма, а не скорость). PIDS: число процессов и потоков внутри.

Разберём пример. Допустим, строка такая: notes 0.3% 61.2MiB / 64MiB 95.6% 1.2kB / 800B ... 4. Читаем: процессор почти не нагружен, а память занята на 95,6% от лимита 64 МиБ. Это тревожный признак: при следующем росте ядро убьёт процесс. Значение в процентах важнее абсолютных: 61 МиБ без лимита не проблема, а под лимитом 64 МиБ проблема.

Прикинь сам: 61.2MiB / 64MiB: сколько процентов лимита занято?

Около 95,6%: OOMKilled близко.

Осторожно, частое заблуждение: «Высокий CPU значит, что приложение зависло». Нет: может просто обрабатываться запрос. Смотри динамику: если процессор на 100% уже минуту без запросов, нужно искать цикл. И второе: единицы измерения, MiB (мебибайты, 1024 × 1024 байт) чуть больше MB (миллионов байт), поэтому 64 MiB это примерно 67 МБ.

Главное: docker stats показывает потребление сейчас; без лимита в LIMIT стоит вся память хоста, а не потолок контейнера.

Теперь поговорим об остановке контейнера.

Проверь понимание: лимит памяти не задан, а в колонке LIMIT написано 15.6GiB. Что это за число?

Ответ

Это вся память хоста (или виртуальной машины Docker), а не ограничение контейнера. Без флага -m потолка у контейнера нет, а stats показывает память хоста как верхнюю границу.

Остановка: docker stop, PID 1 и код 137

«Деплой висит десять секунд, потом код 137» это классическая проблема. Она объясняется одним правилом про PID 1.

Устроено это так.

  1. docker stop шлёт главному процессу SIGTERM и ждёт 10 секунд (можно изменить ключом -t).
  2. Если процесс за это время завершился, всё хорошо. Иначе Docker шлёт SIGKILL, и код выхода получается 137.
  3. Особенность PID 1: ядро не применяет к нему действия по умолчанию. Обычная программа умирает от SIGTERM, а PID 1 нет, пока программа сама не установила обработчик сигнала. «Заметки» обработчик установили в уроке 1.4, поэтому останавливаются быстро.
  4. Форма записи CMD (урок 4.2). Exec-форма CMD ["python", "app.py"] запускает Python напрямую, он получает PID 1 и сигнал. Shell-форма CMD python app.py запускает /bin/sh -c "python app.py": PID 1 это оболочка, и она сигнал Python не передаёт.
  5. Выход для программ без обработчика: ключ --init (или init: true в Compose). Docker запускает крошечный init-процесс, он становится PID 1, получает SIGTERM и пересылает его настоящему процессу.

Посмотрим на примере. Реальные замеры (time показывает, сколько ждал docker stop):

Запуск Время docker stop Код выхода
notes:0.4.0, exec-форма 0.22 с 0
sh -c 'python app.py; true' (shell-форма) 10.15 с 137
alpine:3.22 sleep 300 10.12 с 137
--init alpine:3.22 sleep 300 0.17 с 143
docker kill для любого контейнера сразу 137

В строке про shell-форму ; true добавлено, чтобы оболочка не подменила себя процессом python (некоторые оболочки так делают для последней команды). Читаем: «Заметки» обрабатывают SIGTERM и завершаются с 0; sleep без обработчика игнорирует SIGTERM как PID 1, ждёт десять секунд и получает 137; с --init SIGTERM доходит до sleep, тот умирает от сигнала, и код 143.

sequenceDiagram
    participant D as docker stop
    participant P as PID 1 (приложение)
    D->>P: SIGTERM
    alt обработчик есть (exec-форма или --init)
        P-->>D: завершился, код 0 или 143, около 0,2 с
    else обработчика нет (PID 1 игнорирует)
        Note over D,P: ждём 10 секунд
        D->>P: SIGKILL
        P-->>D: код 137
    end

Прикинь сам: docker stop ждёт 10 секунд, а процесс не реагирует на SIGTERM. Сколько секунд пройдёт до SIGKILL и какой будет код?

10 секунд, потом SIGKILL и код 137.

Осторожно, частое заблуждение: «docker stop это то же самое, что docker kill». Нет: stop вежливый, kill сразу SIGKILL.

Главное: PID 1 не умирает от SIGTERM по умолчанию, поэтому нужна exec-форма CMD или --init.

Теперь про сеть и порты.

Проверь понимание: docker stop работает 10 секунд и заканчивается кодом 137, OOMKilled: false. Что делать?

Ответ

Сигнал SIGTERM не дошёл до приложения или тот его игнорирует, и Docker добил процесс через 10 секунд. Проверь форму CMD (нужна exec-форма), добавь обработчик SIGTERM или запуск с --init. Увеличивать таймаут stop_grace_period не лечит причину.

Сеть и порты: port is already allocated и no such host

Две самые частые «ошибки запуска»: порт уже занят и имя не находится. У обеих настоящая причина видна в тексте сообщения, если уметь его читать.

Порт это номер «двери» на машине (0-65535); одну дверь может занимать только одна программа. Ключ -p 127.0.0.1:8080:8080 говорит: «порт 8080 хоста, только с этой машины (127.0.0.1), перенаправь на порт 8080 контейнера». Если 8080 на хосте уже занят (другим контейнером или программой), docker run завершается кодом 125 и печатает port is already allocated.

Второе: имена. Внутри пользовательской сети Docker есть свой DNS (урок 4.3), который по имени сервиса (db, notes) возвращает адрес контейнера. Если такого имени в сети нет, ты получишь no such host (для образа в реестре) или Name or service not known (внутри приложения).

Вот как это выглядит на деле. Реальные сообщения:

docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint web2 (6047ed...): Bind for 127.0.0.1:8080 failed: port is already allocated

Error response from daemon: failed to resolve reference "registry.example.invalid/notes:0.4.0": failed to do request: Head "https://registry.example.invalid/v2/notes/manifests/0.4.0": dial tcp: lookup registry.example.invalid on 192.168.65.7:53: no such host

Первое: Bind for 127.0.0.1:8080 failed («не смог занять адрес и порт»), причина после последнего двоеточия. Кто занял, находится так: docker ps --filter publish=8080. Второе: lookup ... no such host это ответ DNS-сервера 192.168.65.7:53 (адрес у тебя другой) «такого имени я не знаю». Опечатка в имени реестра или нет доступа к DNS. Слово denied в другой ошибке (error from registry: denied) значит другое: имя найдено, но реестр отказал в доступе.

Внутри приложения: «Заметки» v4 с STORE=postgres и несуществующим хостом db пишут failed to resolve host 'db': [Errno -2] Name or service not known, отдают /readyz с кодом 503 и /notes с 500. Контейнер при этом Up, а работать не может: это как раз тот случай, где статус Up вводит в заблуждение.

Прикинь сам: -p 80:80 ответил port is already allocated. Сколько программ могут держать порт 80 одновременно?

Одна. Нужно найти, кто занял порт: docker ps --filter publish=80 и ss -ltnp.

Осторожно: ошибки no such host и connection refused (уроки 2.2 и 2.5): в первом случае имя не превратилось в адрес, во втором адрес есть, но по нему никто не слушает.

Главное: port is already allocated значит занят порт, no such host значит имя не превратилось в адрес, это разные классы проблем.

Теперь научимся проверять здоровье сервиса.

Проверь понимание: docker run -p 80:80 nginx:1.30 ответил port is already allocated. С чего начнёшь?

Ответ

Найду, кто держит порт 80: docker ps --filter publish=80 (контейнер) и sudo ss -ltnp 'sport = :80' (программа на хосте, урок 2.2). Потом остановлю его или выберу другой внешний порт, например -p 8088:80.

Healthcheck: как Docker решает, что сервис здоров

Статус Up говорит только «процесс жив». Приложение при этом может не отвечать: завис, потерял соединение с базой, не закончило запуск. Без проверки здоровья Docker и Compose не отличат «жив и работает» от «жив, но бесполезен».

Врач на утреннем обходе измеряет пульс, а не только проверяет, что пациент лежит в палате. Аналогия перестаёт работать в том, что врач ничего не чинит, а Docker сам по статусу unhealthy контейнер не перезапускает (это делает оркестратор или ты).

Устроено это так.

  1. В образе или в compose.yml описана команда проверки (урок 4.2): например, запрос к /healthz. Код выхода 0 значит «здоров», 1 «нездоров».
  2. Первые start_period секунд провалы не считаются, контейнеру дают разогнаться. Статус в это время starting.
  3. Каждые interval секунд Docker запускает проверку внутри контейнера и ждёт не дольше timeout.
  4. После retries неудач подряд статус становится unhealthy. Одна успешная проверка возвращает healthy.
  5. Результаты последних проверок лежат в docker inspect в блоке .State.Health: там статус, счётчик неудач и журнал с выводом команды.

Теперь на числах. Контейнер показывает Up 2 minutes (unhealthy). Ты выполняешь docker inspect --format '{{json .State.Health}}' имя и видишь в журнале проверок вывод команды, например connection refused. Значит, процесс жив, но порт приложения никто не слушает: смотри логи приложения и правильный ли порт указан в проверке. Если же в журнале timeout, приложение отвечает слишком медленно.

stateDiagram-v2
    [*] --> starting: старт, идёт start_period
    starting --> healthy: проверка успешна
    starting --> unhealthy: retries провалов подряд
    healthy --> unhealthy: retries провалов подряд
    unhealthy --> healthy: одна успешная проверка

Прикинь сам: interval 10 с и retries 3. Через сколько секунд сбоев статус станет unhealthy?

Примерно через 30 секунд (3 провала по 10 с), если нет start_period.

Осторожно, частое заблуждение: «unhealthy значит, что контейнер упал». Нет: он работает, просто проверка не проходит. Вторая ошибка: проверка, которая всегда зелёная (например, exit 0): такой healthcheck ничего не проверяет и только создаёт ложное спокойствие.

Главное: Up значит только «процесс жив», а healthy значит, что проверка проходит; unhealthy не значит «упал».

Теперь про перезапуски.

Проверь понимание: контейнер в статусе (health: starting) уже пять минут, хотя start_period 30 секунд. Что это значит?

Ответ

Статус starting держится, пока нет ни одной успешной проверки и после start_period не набрано retries провалов подряд. Пять минут без решения значит, что провалы копятся медленно: проверки идут редко (большой interval), каждая долго ждёт ответа (большой timeout) или задано много retries. Смотри .State.Health.Log в docker inspect: там видно, когда и с каким результатом проходили проверки.

Перезапуски: политика restart и цикл падений

Docker умеет сам поднимать упавший контейнер. Это удобно, но легко прячет проблему.

Флаг --restart (или restart: в Compose) задаёт политику: no (по умолчанию, не перезапускать), on-failure (только при ненулевом коде), always и unless-stopped (всегда, кроме случая, когда ты сам остановил). Между попытками Docker выдерживает растущую паузу. Каждая попытка увеличивает RestartCount в inspect.

Разберём пример. Реальный контейнер, который печатает boot, потом fatal: config missing и выходит с кодом 3:

NAMES     STATUS
loop      Restarting (3) 2 seconds ago
restarts=6 exit=3 status=restarting

Через шесть секунд счётчик уже 6. Формула диагностики: Restarting плюс маленький код (1-3) это падает само приложение, читай логи; Restarting (137) и растущий счётчик это часто память; логи при этом пусты.

Прикинь сам: RestartCount равен 6 и растёт. Сколько раз контейнер падал сам?

Минимум 6 раз: цикл падений, Restarting с маленьким кодом значит, что падает само приложение.

Осторожно, частое заблуждение: «Контейнер Up, значит всё в порядке». Посмотри на время в статусе: Up 2 seconds через минуту после старта означает, что он падает и поднимается.

Главное: политика restart прячет проблему: смотри счётчик, код выхода и время Up.

Теперь поговорим о диске.

Проверь понимание: как по одному запуску docker inspect -f '{{.RestartCount}}' понять, что контейнер нестабилен?

Ответ

Если число больше нуля и растёт при повторных запусках команды, контейнер перезапускают по кругу. Смотри код выхода, логи до падения (docker logs) и события.

Куда уходит диск Docker

Диск это единственный ресурс, который кончается тихо и вдруг, и тогда падает всё сразу: сборки, pull, запись в базу.

Кладовая ресторана. Продукты (образы), недоеденные блюда на столах (остановленные контейнеры), ценные заготовки (тома), мусорные пакеты (логи). Уборщик с лозунгом «выкинь всё не нужное» рискует выбросить заготовки.

Docker хранит всё в /var/lib/docker. Главные потребители:

  • образы (images): теги, которые ты давно не используешь, и образы без имени (в выводе <untagged>, в старых версиях <none>), которые остаются после сборок без -t. Слои образов общие: два образа с одной базой делят её на диске.
  • контейнеры: у каждого свой слой записи; остановленный контейнер продолжает занимать место.
  • тома (volumes): данные, самое ценное.
  • кэш сборки (build cache): промежуточные результаты docker build.
  • логи: файл /var/lib/docker/containers/<id>/<id>-json.log. Драйвер json-file без ротации не ограничивает его размер, и главное: docker system df логи не учитывает.

Посмотрим на примере. Реальный вывод docker system df (числа у тебя будут другие):

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          3         2         269.3MB   2.198MB (0%)
Containers      2         1         20.98MB   20.98MB (99%)
Local Volumes   1         0         28B       28B (100%)
Build Cache     15        0         141.4MB   40.96kB

Колонки: TOTAL сколько объектов, ACTIVE сколько из них сейчас используется, SIZE общий размер, RECLAIMABLE сколько можно освободить. Читаем: контейнеров 2, работает 1, второй остановленный весит около 21 МБ и удаляется без потерь (99%). Но обрати внимание на строку про тома: 28B (100%) тоже «можно освободить», хотя это единственные данные. RECLAIMABLE для томов означает лишь «сейчас к ним не подключён ни один контейнер», а не «они не нужны».

Логи и ротация. Ротация ограничивает размер: --log-opt max-size=1m --log-opt max-file=3 значит «храни не более трёх файлов по 1 МБ, старые затирай». Для контейнеров в Compose это ключ logging (урок 4.6), для всех новых контейнеров сразу это файл /etc/docker/daemon.json (ключ log-opts, потом sudo systemctl restart docker; перезапуск останавливает все контейнеры, если не включён live-restore, поэтому на проде делай его в окно обслуживания). Опции действуют только на контейнеры, созданные после этого: старые надо пересоздать.

Уборка по уровням риска.

Команда Что удаляет Риск
docker container prune остановленные контейнеры низкий
docker image prune образы без имени (<untagged>) низкий
docker builder prune кэш сборки низкий
docker image prune -a все образы, которые не использует ни один контейнер средний: придётся скачивать заново
docker system prune контейнеры, сети, образы без имени, кэш средний
docker system prune -a --volumes всё выше плюс все неиспользуемые образы и анонимные тома средний-высокий
docker volume prune -a все неиспользуемые тома, в том числе именованные высокий: потеря данных БД
docker compose down -v контейнеры стека и его тома высокий

Про тома поведение зависит от версии. В Docker 29 (проверено) docker volume prune и docker system prune --volumes удаляют только анонимные тома (те, что создались без имени), а именованный pgdata остаётся. Удаляют именованные volume prune -a и compose down -v. В старых версиях (до 23) --volumes удалял и именованные. Не полагайся на память, смотри docker volume ls до и после.

Прикинь сам: df -h показывает 100%, а docker system df почти пуст. Сколько мегабайт логов он учитывает?

Ни одного: docker system df не считает логи. Смотри du по /var/lib/docker/containers.

Осторожно, частое заблуждение: «du по /var/lib/docker покажет всё». Расходятся df (сколько занято файловой системой) и du (сколько занимают видимые файлы) в трёх случаях: файл удалили, но его держит открытым процесс (место освободится только после закрытия, lsof +L1 покажет (deleted)), кончились inodes (df -i, урок 1.5), или данные лежат на другом разделе.

Главное: место уходит на образы, контейнеры, тома, кэш и логи; чистить нужно от безопасного к опасному, а тома не трогать.

Чтобы не набирать длинные команды, соберём Makefile.

Ошибка no space left on device. Она бывает в трёх местах: при docker pull (write .../tmp/GetImageBlob...: no space left on device), при docker build (на шаге RUN) и внутри приложения (dd: error writing '/data/f': No space left on device). Во всех случаях сначала df -h, потом docker system df, потом размер логов.

Проверь понимание: df -h показывает /var на 100%, а docker system df почти пуст. Куда смотреть?

Ответ

docker system df не считает логи. Смотри sudo du -h /var/lib/docker/containers/*/*-json.log | sort -h | tail. Если и там пусто, ищи удалённые, но открытые файлы (sudo lsof +L1), inodes (df -i) и другие каталоги на этом разделе.

Makefile: кнопки быстрого набора для команд

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

Кнопки быстрого набора в телефоне: «Мама», «Работа». Нажал и не набираешь номер. Аналогия перестаёт работать в том, что у make есть зависимости: цель может требовать, чтобы сначала выполнилась другая.

Устроено это так.

  1. Файл называется Makefile и лежит в каталоге проекта. Команду make имя ты запускаешь в этом каталоге.
  2. Файл состоит из правил. Правило: цель (имя), двоеточие, затем строки рецепта (команды), которые выполнятся в оболочке. Каждая строка рецепта обязана начинаться с символа табуляции (TAB), а не с пробелов: иначе ошибка missing separator.
  3. Если после двоеточия перечислены другие цели, make сначала выполнит их: up: build значит «перед up собери образ».
  4. Строка .PHONY: build up down говорит, что эти имена это команды, а не файлы. Без неё, если в каталоге появится файл с именем build, make build решит, что «цель уже готова», и ничего не сделает.
  5. Переменные вида IMAGE := notes:0.4.0 пишут один раз и подставляют как $(IMAGE): номер версии меняется в одном месте.

Вот как это выглядит на деле. Минимальный пример для понимания (полный Makefile проекта ты соберёшь в задании 6; отступ в рецепте это TAB):

IMAGE := notes:0.4.0

.PHONY: build up
build:
	docker build -t $(IMAGE) .
up: build
	docker compose up -d

Строка build: объявляет цель, под ней команда сборки с подставленным $(IMAGE). Строка up: build говорит: make up сначала вызовет build, затем выполнит docker compose up -d. Итого одна команда make up собирает образ и поднимает стек.

Прикинь сам: В Makefile 10 целей, а в .PHONY указаны не все. Что случится, если в каталоге появится файл build?

make build ответит «up to date» и ничего не сделает: файл с таким именем считается результатом.

Осторожно: пробелы вместо TAB: редактор молча заменил табуляцию, и make отвечает *** missing separator. Stop.. Симптом: текст правила выглядит правильно, но не запускается. Ещё путают цель с именем файла: для команд добавляют .PHONY. И третье: make не «умный», он просто запускает те же команды, что ты набрал бы руками. Если команда в рецепте неверна, make её не исправит.

Главное: make запускает рецепты (с табуляцией!) и зависимости, а .PHONY говорит, что цели это команды, а не файлы.

Остался последний набор следов.

Проверь понимание: make up отвечает Makefile:5: *** missing separator. Stop.. Что проверишь в пятой строке?

Ответ

Что строка рецепта начинается с символа табуляции, а не с пробелов. Это самая частая причина такой ошибки: редактор заменил TAB на пробелы. Включи в редакторе показ невидимых символов или используй команду cat -A Makefile: табуляция показывается как ^I.

Следы убийства: docker events и журнал ядра

Когда контейнер убило ядро, в docker logs об этом ни слова: процесс не успел ничего сказать. Но следы остались в двух местах, и знать их нужно, чтобы не гадать, кто виноват.

Камеры видеонаблюдения и журнал охраны. Охранник (ядро) записывает, кого и за что вывел из здания. Администратор здания (Docker) записывает, какие двери открывались и закрывались. Чтобы понять картину, смотришь оба журнала. Аналогия перестаёт работать в том, что журнал Docker хранится недолго (только поток в реальном времени), а журнал ядра живёт до перезагрузки или до переполнения кольцевого буфера.

Устроено это так.

  1. docker events печатает поток событий демона: create, start, die, oom, kill, stop, destroy. Каждое событие с временем, типом и именем контейнера. Команда работает в реальном времени: запусти её в одном окне, а поломку воспроизводи в другом. Ключ --since 10m показывает события за последние десять минут.
  2. Событие oom означает, что ядро сообщило Docker об убийстве по лимиту памяти. Событие die с атрибутом exitCode=137 идёт следом.
  3. Журнал ядра читается командой sudo dmesg -T (ключ -T переводит время в человеческий формат). Искать строки со словами oom-killer и Killed process: в них указан процесс, его размер и то, что убийство произошло по лимиту cgroup (CONSTRAINT_MEMCG).
  4. Сопоставляешь: время die в docker events совпало со временем Killed process в dmesg, значит причина смерти установлена.

Теперь на числах. Контейнер падает раз в несколько минут с кодом 137. docker logs пуст. Ты запускаешь docker events --since 1h --filter event=oom и видишь события oom для контейнера notes в 12:14 и 12:19. Затем sudo dmesg -T | grep -i 'killed process' показывает строки с тем же временем: убит python, занявший около 64 МБ. Вывод: лимит памяти слишком мал или в приложении утечка. Чинишь причину (поднимаешь лимит после оценки реальной потребности или исправляешь утечку), а не перезапускаешь контейнер.

Прикинь сам: ExitCode 137, OOMKilled: false, в логе пусто. Сколько возможных убийц у такого контейнера?

Как минимум два: docker stop или kill (событие kill) и OOM после перезапуска. Смотри docker events и dmesg.

Осторожно, частое заблуждение: «Нет событий oom, значит память ни при чём». Поток событий Docker не хранится, он показывает только то, что произошло, пока демон работал и пока ты смотрел. Для прошлого есть RestartCount в inspect и журнал ядра. И второе: dmesg на хосте Docker Desktop (Mac, Windows) находится внутри виртуальной машины, а не на самом компьютере.

Главное: docker events и dmesg -T сопоставляют по времени: событие die и строка Killed process называют убийцу.

Проверь понимание: в docker logs пусто, ExitCode равен 137, OOMKilled: false. Какие два шага сделаешь, прежде чем винить память?

Ответ

Первый: проверить, не завершил ли контейнер docker stop или docker kill (событие kill в docker events, и время остановки до 10 секунд говорит о недошедшем SIGTERM). Второй: посмотреть dmesg на ядерные сообщения oom-killer, потому что OOMKilled после перезапуска обнуляется. Только если нет ни того, ни другого, искать сигнал от внешней системы (оркестратор, скрипт, системный менеджер).

Практика

Все команды выполняются на твоём Docker-хосте (Ubuntu из урока 4.1). Образ notes:0.4.0 из уроков 4.2 и 4.7 должен существовать: проверь docker image ls notes. Если его нет, собери в ~/notes: docker build -t notes:0.4.0 ..

Задание 1. OOM: ловим убийство за память

Цель: отличить OOM-kill от других причин кода 137 и увидеть его в inspect, событиях и журнале ядра.

Предскажи: контейнер notes:0.4.0 с лимитом 64 МБ просят удержать 100 МБ через /leak?mb=100. Что вернёт curl, какой будет код выхода и значение OOMKilled?

Ответ

curl получит обрыв соединения (Empty reply from server), процесс умрёт с кодом 137, OOMKilled будет true.

Шаги

  1. Останови стек Compose, если он занимает 8080 (docker compose down в ~/notes). Запусти контейнер только с файловым хранилищем и лимитом. Разбор команды: -d в фоне, --name t-oom имя, -m 64m лимит памяти 64 МБ, --memory-swap 64m общий предел памяти вместе со swap (значит swap не используется), -p 127.0.0.1:8081:8080 порт 8081 хоста только с этой машины на порт 8080 контейнера:
docker run -d --name t-oom -m 64m --memory-swap 64m -p 127.0.0.1:8081:8080 notes:0.4.0
  1. Проверь, что контейнер живой, и посмотри потребление. docker stats --no-stream печатает один снимок ресурсов (без ключа обновляется бесконечно):
sleep 2
docker stats --no-stream t-oom
curl -s http://127.0.0.1:8081/healthz; echo
  1. Вызови демонстрационный эндпоинт (в реальном сервисе его бы не было). -sS значит «без прогресс-бара, но ошибки показать»:
curl -sS "http://127.0.0.1:8081/leak?mb=100"
  1. Найди причину. Разбор: --filter name=t-oom оставляет строки с этим именем, --format задаёт вид; в inspect -f поля из таблицы теории:
docker ps -a --filter name=t-oom --format '{{.Status}}'
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} mem={{.HostConfig.Memory}}' t-oom
docker logs --tail 5 t-oom

Что должно получиться (реальный прогон; идентификаторы и время у тебя другие)

CONTAINER ID   NAME      CPU %     MEM USAGE / LIMIT   MEM %     NET I/O       BLOCK I/O   PIDS
c571c174645a   t-oom     0.01%     14.88MiB / 64MiB    23.25%    736B / 126B   0B / 0B     1
ok
curl: (52) Empty reply from server
Exited (137) 1 second ago
exit=137 oom=true mem=67108864
2026-09-30 12:14:33,560 INFO started host=0.0.0.0 port=8080

Как читать вывод: в stats до удара процесс занимал 14.88 МБ из 64 (MEM % 23). curl: (52) Empty reply from server значит: соединение принято и оборвано без ответа, то есть процесс умер, не успев ответить. Exited (137) плюс oom=true это диагноз «убито за память». mem=67108864 это 64 × 1024 × 1024 байт, то есть ровно 64 МБ. В docker logs только строка старта: приложение не успело написать ничего про смерть, причины в его логах нет, а в inspect есть.

  1. Спроси ядро и Docker. docker events показывает события демона; --since 2m за последние 2 минуты, фильтры оставляют только смерть и OOM. sudo dmesg это журнал ядра (на Ubuntu без sudo нельзя):
docker events --since 2m --until 0s --filter container=t-oom --filter event=oom --filter event=die
sudo dmesg | grep -i -E "oom|killed process" | tail -5
2026-09-30T12:14:36.493623882Z container oom c571c174645a... (image=notes:0.4.0, name=t-oom)
2026-09-30T12:14:36.662828048Z container die c571c174645a... (execDuration=3, exitCode=137, image=notes:0.4.0, name=t-oom)
[ 2775.517914] Memory cgroup out of memory: Killed process 157205 (python) total-vm:210136kB, anon-rss:64464kB, ...

Событие oom и следом die с exitCode=137 подтверждают причину независимо от inspect.

  1. Теперь добавь политику перезапуска и увидь, как она скрывает проблему. Ключ --restart unless-stopped описан в теории:
docker rm -f t-oom
docker run -d --name t-oom2 -m 64m --memory-swap 64m --restart unless-stopped -p 127.0.0.1:8082:8080 notes:0.4.0
sleep 2
curl -sS "http://127.0.0.1:8082/leak?mb=100"
sleep 3
docker ps --filter name=t-oom2 --format 'table {{.Names}}\t{{.Status}}'
docker inspect -f 'oom={{.State.OOMKilled}} restarts={{.RestartCount}} status={{.State.Status}}' t-oom2
curl: (52) Empty reply from server
NAMES     STATUS
t-oom2    Up 2 seconds (health: starting)
oom=false restarts=1 status=running

Как читать вывод: контейнер снова Up, но Up 2 seconds спустя пару секунд после запроса говорит, что он только что перезапущен. restarts=1. А oom=false: Docker сбросил поле для новой жизни контейнера. Поэтому при разборе инцидента с restart-политикой не ограничивайся OOMKilled, смотри RestartCount и docker events. Повтори curl, счётчик станет 2. В docker logs t-oom2 будет строка started на каждый запуск, но не будет ни слова о причине.

Объясни себе

  • Почему в логах нет причины смерти, а в inspect, events и dmesg есть?
  • Что в реальном инциденте делать первым: поднять лимит или искать утечку? (Подсказка: смотри docker stats во времени, растёт ли память без нагрузки.)
  • Почему без --memory-swap 64m запрос мог вернуть leaked total 100 MB?

Типичные ошибки

  • leaked total 100 MB и контейнер жив: на машине включён swap, а -m 64m разрешает ещё 64 МБ swap. Добавь --memory-swap 64m, пересоздай контейнер (лимиты меняются только при пересоздании). Так у меня и получилось в первом прогоне на Docker Desktop.
  • curl: (7) Failed to connect to 127.0.0.1 port 8081: контейнер уже упал или порт не опубликован. Смотри docker ps -a.
  • Bind for 127.0.0.1:8081 failed: port is already allocated: порт занят старым t-oom. Удали: docker rm -f t-oom.
  • WARNING: Your kernel does not support memory limit capabilities: на старом ядре лимиты не включены. Проверь docker info и версию cgroup.

Уборка: docker rm -f t-oom t-oom2.

Задание 2. Коды выхода, остановка, порт и DNS

Цель: вызвать каждую типичную ошибку запуска и по тексту понять причину.

Предскажи: какой код выхода вернёт docker run alpine:3.22 nosuchcmd, и в каком статусе останется контейнер?

Ответ

Код 127 («команда не найдена»), контейнер в статусе Created: процесс так и не стартовал.

Шаги

  1. Три ошибки запуска ($? печатает код выхода последней команды, ; разделяет команды):
docker run --foo alpine:3.22 true; echo "exit=$?"
docker run --name c127 alpine:3.22 nosuchcmd; echo "exit=$?"
docker run --name c126 alpine:3.22 /etc/hostname; echo "exit=$?"
docker run --name c1 notes:0.4.0 python -c 'import nosuchmod'; echo "exit=$?"
docker ps -a --format 'table {{.Names}}\t{{.Command}}\t{{.Status}}'

Что должно получиться (реальный вывод; длинные хвосты сообщений сокращены)

unknown flag: --foo
...
exit=125
docker: Error response from daemon: ... exec: "nosuchcmd": executable file not found in $PATH
exit=127
docker: Error response from daemon: ... exec: "/etc/hostname": permission denied
exit=126
Traceback (most recent call last):
  File "<string>", line 1, in <module>
    import nosuchmod
ModuleNotFoundError: No module named 'nosuchmod'
exit=1
NAMES     COMMAND                  STATUS
c1        "python -c 'import n…"   Exited (1) Less than a second ago
c126      "/etc/hostname"          Created
c127      "nosuchcmd"              Created

Как читать вывод: 125, потому что ошибся сам docker run (флаг); 127 и 126 потому что не нашлась или не запустилась команда, при этом контейнеры Created; 1 потому что программа стартовала и упала на собственной ошибке (Python показывает Traceback, читай последнюю строку). Это четыре разных класса проблемы за четыре секунды.

Получил странный код выхода и не знаешь, что он значит? Вставь нейросети команду docker run и код, она подскажет класс проблемы. Проверь ответ по таблице кодов в уроке: нейросеть любит путать 125, 126 и 127.

  1. Остановка. Сравни docker stop для приложения, которое умеет SIGTERM, и для sleep. time покажет длительность:
docker run -d --name s1 notes:0.4.0
time docker stop s1
docker inspect -f 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}}' s1
docker run -d --name s3 alpine:3.22 sleep 300
time docker stop s3
docker run -d --init --name s4 alpine:3.22 sleep 300
time docker stop s4
docker inspect -f 'exit={{.State.ExitCode}}' s3 s4

Результат совпадает с таблицей из теории: s1 0.22 с и код 0 (в его логах shutting down и stopped), s3 10.12 с и код 137, s4 0.17 с и код 143. Значения real у тебя немного другие, но 10 секунд у s3 должны быть.

  1. Порт занят:
docker run -d --name web1 -p 127.0.0.1:8080:8080 notes:0.4.0
docker run -d --name web2 -p 127.0.0.1:8080:8080 notes:0.4.0; echo "exit=$?"
docker ps --filter publish=8080 --format '{{.Names}}'
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint web2 (6047ed...): Bind for 127.0.0.1:8080 failed: port is already allocated
exit=125
web1

Причина после последнего двоеточия, а виновника назвала третья команда: web1.

  1. Имя не находится:
docker pull registry.example.invalid/notes:0.4.0; echo "exit=$?"
Error response from daemon: failed to resolve reference "registry.example.invalid/notes:0.4.0": failed to do request: Head "https://registry.example.invalid/v2/notes/manifests/0.4.0": dial tcp: lookup registry.example.invalid on 192.168.65.7:53: no such host
exit=1

(Домен .invalid зарезервирован для примеров и никогда не существует.) Адрес DNS-сервера 192.168.65.7 у тебя другой.

  1. Приложение без базы: «Заметки» v4 с STORE=postgres и хостом db, которого нет в сети:
docker run -d --name c-pg -e STORE=postgres -e DATABASE_URL=postgresql://notes:secret@db:5432/notes -p 127.0.0.1:8083:8080 notes:0.4.0
sleep 4
docker ps -a --filter name=c-pg --format '{{.Status}}'
curl -s -i http://127.0.0.1:8083/readyz | head -1
docker logs c-pg 2>&1 | tail -3
Up 4 seconds (health: starting)
HTTP/1.1 503 Service Unavailable
2026-09-30 12:15:31,111 WARNING база пока недоступна: failed to resolve host 'db': [Errno -2] Name or service not known
2026-09-30 12:15:31,111 INFO started host=0.0.0.0 port=8080
2026-09-30 12:15:34,853 ERROR ошибка хранилища: failed to resolve host 'db': [Errno -2] Name or service not known

Как читать вывод: контейнер Up, но /readyz отвечает 503, а причина видна только в логах: имя db не резолвится, потому что контейнеры не в одной пользовательской сети (или базы нет). Статус Up ничего не гарантировал.

Если не понимаешь, почему /readyz отвечает 503, вставь нейросети docker logs --tail 50 (без секретов). Проверь ответ командой docker inspect: нейросеть может увидеть причину там, где её нет.

  1. Цикл падений:
docker run -d --name loop --restart unless-stopped alpine:3.22 sh -c 'echo "boot"; echo "fatal: config missing" >&2; exit 3'
sleep 6
docker ps -a --filter name=loop --format 'table {{.Names}}\t{{.Status}}'
docker inspect -f 'restarts={{.RestartCount}} exit={{.State.ExitCode}} status={{.State.Status}}' loop
docker logs loop
NAMES     STATUS
loop      Restarting (3) 2 seconds ago
restarts=6 exit=3 status=restarting
boot
boot
boot
boot
boot
boot
fatal: config missing
fatal: config missing
fatal: config missing
fatal: config missing
fatal: config missing
fatal: config missing

sh -c '...' запускает оболочку с командой из кавычек; >&2 отправляет сообщение в stderr; exit 3 завершает с кодом 3. Порядок строк в логе (сначала все boot) объясняется тем, что docker logs показывает потоки stdout и stderr по очереди. Число повторов у тебя зависит от времени.

Объясни себе

  • Чем Created отличается от Exited?
  • Почему Up не гарантирует, что приложение готово? Что рядом смотреть? (health и /readyz.)
  • Что бы ты увидел, если бы у loop была политика no?

Типичные ошибки

  • Conflict. The container name "/web1" is already in use: имя занято, контейнер остался с прошлого раза. docker rm -f web1.
  • cannot attach stdin to a TTY-enabled container because stdin is not a terminal: команду с -it запустили не из терминала (скрипт). Убери -t или запускай из терминала.
  • time: command not found в некоторых оболочках: выполни bash -c 'time docker stop s3'.

Уборка: docker rm -f c1 c126 c127 s1 s3 s4 web1 web2 c-pg loop.

Задание 3. Логи, которые съедают диск

Цель: найти лог-файл контейнера, увидеть его рост и ограничить его.

Предскажи: контейнер печатает 200 тысяч строк. Где лежит их файл на хосте и сколько он весит примерно? Изменится ли размер после docker restart?

Ответ

Файл /var/lib/docker/containers/<id>/<id>-json.log. Каждая строка оборачивается в JSON (см. пример в теории), поэтому файл заметно больше самого текста: десятки мегабайт, а не единицы. После restart тот же файл продолжает расти: лог живёт, пока контейнер не удалён.

Шаги

  1. Контейнер без ротации. Команда внутри: yes "текст" бесконечно печатает строку, head -n 200000 оставляет первые 200 тысяч, sleep 300 держит контейнер живым. Драйвер логов проверяет docker info --format '{{.LoggingDriver}}' (должен быть json-file). sudo ls -lh показывает размер файла (без sudo каталог недоступен):
docker run -d --name t-log1 alpine:3.22 sh -c 'yes "строка лога для проверки" | head -n 200000; sleep 300'
sleep 4
sudo ls -lh "$(docker inspect -f '{{.LogPath}}' t-log1)"
  1. Контейнер с ротацией: максимум 3 файла по 1 МБ. dirname берёт из пути каталог, чтобы показать все файлы:
docker run -d --name t-log2 \
  --log-opt max-size=1m --log-opt max-file=3 \
  alpine:3.22 sh -c 'yes "строка лога для проверки" | head -n 200000; sleep 300'
sleep 4
sudo ls -lh "$(dirname "$(docker inspect -f '{{.LogPath}}' t-log2)")"
  1. Найди самые тяжёлые логи и сравни, сколько строк вернёт docker logs (wc -l считает строки):
sudo du -h /var/lib/docker/containers/*/*-json.log | sort -h | tail -5
docker logs t-log1 | wc -l
docker logs t-log2 | wc -l
docker system df

Что должно получиться (реальный прогон, идентификаторы сокращены до <id>)

-rw-r-----    1 root     root       22.1M Sep 30 12:16 /var/lib/docker/containers/<id>/<id>-json.log

-rw-r-----    1 root     root      156.2K Sep 30 12:16 <id2>-json.log
-rw-r-----    1 root     root      976.6K Sep 30 12:16 <id2>-json.log.1
-rw-r-----    1 root     root      976.6K Sep 30 12:16 <id2>-json.log.2
...
160.0K	/var/lib/docker/containers/<id2>/<id2>-json.log
22.1M	/var/lib/docker/containers/<id>/<id>-json.log
200000
18653

Каталог второго контейнера содержит ещё служебные файлы (config.v2.json, hostconfig.json): они не логи.

Как читать вывод: 200 тысяч коротких строк дали 22.1 МБ, потому что каждая «обёрнута» примерно в 110 байт JSON (мой прогноз «2-3 МБ» был неверным, реальность больше). У первого контейнера файл один и растёт без предела. У второго три файла: текущий json.log и два архивных .1, .2 по 976.6 КБ (это 1 МБ в единицах, которые печатает ls). Всё лишнее стёрто: docker logs t-log2 вернул 18 653 строки из 200 000. Это цена ротации: старые записи навсегда пропадают, поэтому важные логи нужно отправлять в систему сбора (тема 8).

  1. Что ломает или помогает при аварии. Перезапуск:
docker restart t-log1
sleep 4
sudo ls -lh "$(docker inspect -f '{{.LogPath}}' t-log1)"
-rw-r-----    1 root     root       44.2M Sep 30 12:16 <id>-json.log

Размер удвоился: файл не пересоздан, а продолжен, а команда контейнера напечатала свои 200 тысяч строк ещё раз.

  1. Удаление файла руками на живом контейнере (rm не освобождает место):
LP=$(docker inspect -f '{{.LogPath}}' t-log1)
sudo rm "$LP"
sudo lsof +L1 | head -3
COMMAND PID USER FD   TYPE DEVICE SIZE/OFF NLINK  NODE NAME
dockerd  26 root 30w   REG  254,1 46332544     0 26902 /var/lib/docker/containers/<id>/<id>-json.log (deleted)

lsof +L1 показывает открытые файлы с числом ссылок меньше 1, то есть удалённые. Файл (deleted), а процесс dockerd всё ещё держит его открытым и пишет в него, поэтому место занято. Освободит его только перезапуск контейнера или демона. Правильный «срочный» способ: обнулить, не удаляя:

sudo truncate -s 0 "$(docker inspect -f '{{.LogPath}}' t-log2)"

truncate -s 0 делает размер нулевым, а файл остаётся тем же, который Docker держит открытым.

Объясни себе

  • Почему docker logs работает и после ротации, но видит только оставшиеся файлы?
  • Как включить ту же ротацию для всех новых контейнеров? (Подсказка: /etc/docker/daemon.json, ключ log-opts, нужен sudo systemctl restart docker (останавливает контейнеры без live-restore); действует только на новые контейнеры.)
  • Почему docker system df не показал эти 22 МБ?

Типичные ошибки

  • ls: cannot access '/var/lib/docker/containers/...': Permission denied: каталог принадлежит root. Используй sudo.
  • unknown log opt 'max-size' for journald log driver: драйвер логов не json-file. Проверь docker info --format '{{.LoggingDriver}}'.
  • du: cannot access '/var/lib/docker/containers/*/*-json.log': No such file or directory: контейнеров нет или ты запустил du без sudo: оболочка не раскрыла маску в каталоге root.

Уборка: docker rm -f t-log1 t-log2.

Задание 4. Безопасная уборка диска

Цель: освободить место, не потеряв тома с данными.

Предскажи: какая из команд docker image prune, docker image prune -a, docker system prune --volumes удалит образ notes:0.4.0, если запущен только стек PostgreSQL, а notes остановлен и его контейнера нет?

Ответ

Только image prune -a: он удаляет все образы, которые не использует ни один контейнер. image prune без -a не тронет: у образа есть имя, он не «без имени». system prune --volumes без -a образ тоже оставит.

Шаги

  1. Смотрим, что занимает место. system df -v (verbose) раскладывает по объектам, head -30 берёт первые 30 строк:
docker system df
docker system df -v | head -30
  1. Создай мусор. Сначала том с «данными» (его мы пытаемся не потерять) и остановленный контейнер, записавший 20 МБ (dd копирует случайные байты в файл):
docker volume create pgdata-lab
docker run --rm -v pgdata-lab:/data alpine:3.22 sh -c 'echo "важная заметка" > /data/notes.txt'
docker run --name old1 alpine:3.22 sh -c 'dd if=/dev/urandom of=/big bs=1M count=20 2>/dev/null'
  1. Теперь образ без имени: соберём после правки без ключа -t (так делает CI, когда не задан тег). Правку app.py потом откатываем:
cd ~/notes
echo "# правка" >> app.py
docker build . >/dev/null 2>&1
sed -i '$ d' app.py          # убрать добавленную строку (на macOS: sed -i '' '$ d' app.py)
docker image ls -a
docker images --filter dangling=true
IMAGE              ID             DISK USAGE   CONTENT SIZE   EXTRA
alpine:3.22        5291449c3df7       13.4MB         4.21MB
notes:0.4.0        23216c74e72f        254MB         54.7MB
python:3.13-slim   7c61056e61ac        204MB         45.5MB
<untagged>         1ab3d8aa7390        254MB         54.7MB

Как читать вывод: <untagged> это образ без имени, наш мусор. Обычный docker image ls в Docker 29 такие образы скрывает, поэтому нужен ключ -a или фильтр dangling=true. Разберём sed -i '$ d': -i правит файл на месте, $ это «последняя строка», d это «удалить».

  1. Убери только безопасное. Ключ -f значит «не переспрашивать»:
docker container prune -f
docker image prune -f
docker builder prune -f
docker system df
docker volume ls
Deleted Containers:
1f0c68160f8cd0e529ed3d2269300e55a2826dd3e6eef0714c994aeacd697cae

Total reclaimed space: 20.98MB
Deleted Images:
untagged: sha256:1ab3d8aa7390...
deleted: sha256:1ab3d8aa7390...
deleted: sha256:5c26c526d9c6...
deleted: sha256:2a2c9e94c87b...

Total reclaimed space: 44.9kB

Как читать вывод: container prune освободил 20.98 МБ (контейнер old1). А image prune освободил лишь 44.9 КБ, хотя образ весил 254 МБ по строке DISK USAGE. Дело в общих слоях: почти все слои этого образа те же, что у notes:0.4.0, они остались, удалились только уникальные 44.9 КБ. Поэтому «размер образа» и «сколько освободится» это разные числа. Тома docker volume ls остались на месте.

  1. Настоящий риск это тома. Проверим на учебном томе с меткой, чтобы не задеть твои (--label добавляет метку, --filter label=... ограничивает уборку ими):
docker volume create --label lesson=4.9 scratch-data
docker run --rm -v scratch-data:/d alpine:3.22 sh -c 'echo hi > /d/f'
docker volume prune -f
docker volume ls
docker volume prune -a -f --filter label=lesson=4.9
docker volume ls
Total reclaimed space: 0B
DRIVER    VOLUME NAME
local     pgdata-lab
local     scratch-data
Deleted Volumes:
scratch-data

Total reclaimed space: 3B
DRIVER    VOLUME NAME
local     pgdata-lab

Как читать вывод: обычный volume prune не тронул именованные тома (0B). Тома удаляет только -a. Мы ограничили его фильтром по метке, и удалился один scratch-data. Без фильтра docker volume prune -a удалил бы и pgdata-lab, и твой настоящий notes_pgdata, если стек в этот момент остановлен: неиспользуемым считается любой том, к которому не подключён ни один контейнер (даже остановленный).

  1. Ошибка, которую ты увидишь при попытке удалить образ:
docker run --name old2 alpine:3.22 true
docker rmi alpine:3.22
Error response from daemon: conflict: unable to delete alpine:3.22 (must be forced) - container 1f0c68160f8c is using its referenced image 5291449c3df7

Остановленный контейнер держит образ: удали его (docker rm old2), тогда образ удалится.

Что должно получиться: после шага 4 docker system df показывает Containers 0 и в колонке RECLAIMABLE близко к нулю. Пример реального вывода после всей уборки (числа у тебя другие):

TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          3         1         269.3MB   15.61MB (5%)
Containers      1         1         4.096kB   0B (0%)
Local Volumes   1         0         28B       28B (100%)
Build Cache     12        0         141.4MB   0B

Объясни себе

  • Почему в колонке Local Volumes нельзя ориентироваться на RECLAIMABLE?
  • Чем docker system prune -a хуже последовательности из трёх команд выше?
  • Как часто и чем ты бы чистил CI-runner, а как прод?

Типичные ошибки

  • Total reclaimed space: 0B: всё нужное используется или удалять нечего. Проверь docker image ls -a.
  • conflict: unable to delete ... (must be forced) - container ... is using its referenced image: см. шаг 6.
  • no space left on device во время самой сборки: чисти по ступеням выше, а если пусто, ищи логи (задание 3).
  • Error response from daemon: remove pgdata-lab: volume is in use: том подключён к контейнеру. Сначала удали контейнер.

Уборка: docker rm -f old1 old2; docker volume rm pgdata-lab.

Задание 5. no space left on device: вживую

Цель: увидеть саму ошибку и понять, в каком месте она возникает, не заполняя настоящий диск.

Предскажи: что произойдёт, если процесс в контейнере попробует записать 10 МБ на файловую систему размером 2 МБ?

Ответ

Запись остановится на 2 МБ с ошибкой No space left on device; остальной хост это не затронет.

Шаги

--tmpfs /data:rw,size=2m подключает в контейнер маленький временный диск в памяти на 2 МБ по пути /data. dd пишет в него 10 блоков по 1 МБ:

docker run --rm --tmpfs /data:rw,size=2m alpine:3.22 sh -c 'dd if=/dev/zero of=/data/f bs=1M count=10; df -h /data'
dd: error writing '/data/f': No space left on device
3+0 records in
2+0 records out
2097152 bytes (2.0MB) copied, 0.000705 seconds, 2.8GB/s
Filesystem                Size      Used Available Use% Mounted on
tmpfs                     2.0M      2.0M         0         0 100% /data

Как читать вывод: 2+0 records out это два полных блока, третий не поместился. Use% 100%, Available 0. Тот же текст ошибки приложение получит, когда в разделе /var/lib/docker кончится место, только тогда пострадают все контейнеры сразу. Для docker pull на переполненном разделе я получил (в проверочном стенде Docker был на разделе размером 120 МБ, поэтому путь в сообщении другой):

write /mnt/small/root/tmp/GetImageBlob2810688906: no space left on device

У тебя вместо /mnt/small/root будет /var/lib/docker. Это временный файл, куда Docker скачивает слой, и он не поместился.

Типичные ошибки

  • dd: error writing ...: No space left on device при работе приложения: место кончилось у самой файловой системы (том, /data), а не у хоста. Смотри docker exec имя df -h.
  • В Docker Desktop диск виртуальной машины отдельный, df -h на хосте его не покажет: смотри docker system df и настройки Docker Desktop.

Задание 6. Шаг проекта: Makefile для Docker

Цель: добавить в ~/notes/Makefile цели для сборки и запуска, чтобы команды из уроков 4.5-4.8 запускались одним словом.

Состояние после урока: Makefile с целями run, test, lint (остаются из 1.6) и новыми build, up, down, logs, ps, clean. Целей infra-* пока нет.

Предскажи: что сделает make clean, если в нём есть docker compose down без -v, и потеряется ли БД?

Ответ

Контейнеры и сеть удалятся, том pgdata останется: без -v том compose down не трогает. БД не потеряется. Поэтому в clean мы осознанно не пишем -v.

Шаги

  1. Открой ~/notes/Makefile и приведи его к такому виду (в рецептах строго TAB, не пробелы, урок 1.6). Первая часть до .PHONY это твой прежний файл из 1.6 плюс две переменные:
# Цели проекта "Заметки". Запуск: make <цель>
PYTHON ?= python3
PORT   ?= 8080
# файл данных для make run: в /tmp, чтобы не нужны были права на /var/lib/notes
NOTES_DATA ?= /tmp/notes-dev.txt

# Версия образа: та же, что тег релиза (урок 4.7)
VERSION := 0.4.0
IMAGE   := notes:$(VERSION)

.PHONY: run test lint build up down logs ps clean

# Запуск приложения локально, без контейнера
run:
	PORT=$(PORT) NOTES_DATA=$(NOTES_DATA) $(PYTHON) app.py

# Юнит-тесты
test:
	$(PYTHON) -m unittest -v

# Проверка синтаксиса (как в уроке 1.6)
lint:
	$(PYTHON) -m py_compile app.py test_app.py
	@echo "lint: синтаксис в порядке"

# Сборка образа с закреплённым тегом
build:
	docker build -t $(IMAGE) .

# Поднять стек Compose (notes, db, proxy) в фоне
up:
	docker compose up -d

# Остановить стек; тома с данными не трогаем
down:
	docker compose down

# Последние 50 строк логов и дальше в реальном времени
logs:
	docker compose logs --tail=50 -f

# Состояние сервисов и проверок здоровья
ps:
	docker compose ps

# Безопасная уборка: остановленные контейнеры, образы без имени, кэш сборки; тома не удаляются
clean:
	docker compose down --remove-orphans
	docker container prune -f
	docker image prune -f
	docker builder prune -f

Разбор новых частей. VERSION := 0.4.0 это переменная, := вычисляет её один раз; IMAGE := notes:$(VERSION) собирает из неё имя образа, поэтому версию меняют в одном месте. .PHONY перечисляет имена-действия: без него make решил бы, что build это файл, и, найдя в каталоге файл или каталог с таким именем, ответил бы make: 'build' is up to date. и ничего не запустил. Каждая строка с отступом-TAB это команда оболочки. docker compose down --remove-orphans дополнительно удаляет контейнеры, которых больше нет в compose.yml. Цели prune разобраны в задании 4: -f не переспрашивает. В clean нет -v и нет volume prune: тома с данными эта цель не тронет никогда. Цель build собирает образ notes:0.4.0, а compose up собирает для стека свой образ с именем notes-notes (Compose даёт образу имя по проекту и сервису); слои общие, поэтому вторая сборка почти мгновенная.

  1. Проверь:
make test
make lint
make build
make up
make ps
make clean
docker volume ls
  1. Зафиксируй в git:
git add Makefile
git commit -m "Makefile: цели docker (build, up, down, logs, ps, clean)"

Что должно получиться (реальный прогон; время и идентификаторы у тебя другие)

make test:

python3 -m unittest -v
test_empty_body_is_400 (test_app.NotesTest.test_empty_body_is_400) ... ok
test_healthz (test_app.NotesTest.test_healthz) ... ok
test_method_not_allowed (test_app.NotesTest.test_method_not_allowed) ... ok
test_not_found (test_app.NotesTest.test_not_found) ... ok
test_post_and_get_note (test_app.NotesTest.test_post_and_get_note) ... ok
test_root (test_app.NotesTest.test_root) ... ok

----------------------------------------------------------------------
Ran 6 tests in 0.658s

OK

make lint:

python3 -m py_compile app.py test_app.py
lint: синтаксис в порядке

make up (в терминале строки про сеть и том рисуются анимацией, у тебя они компактнее):

 Network notes-net Created
 Volume notes_pgdata Created
 Container notes-db-1 Created
 Container notes-notes-1 Created
 Container notes-proxy-1 Created
 Container notes-db-1 Started
 Container notes-db-1 Healthy
 Container notes-notes-1 Started
 Container notes-proxy-1 Started

make ps:

docker compose ps
NAME            IMAGE         COMMAND                  SERVICE   CREATED          STATUS                    PORTS
notes-db-1      postgres:18   "docker-entrypoint.s…"   db        21 seconds ago   Up 20 seconds (healthy)   5432/tcp
notes-notes-1   notes-notes   "python app.py"          notes     21 seconds ago   Up 15 seconds (healthy)   8080/tcp
notes-proxy-1   nginx:1.30    "/docker-entrypoint.…"   proxy     21 seconds ago   Up 15 seconds             0.0.0.0:80->80/tcp, [::]:80->80/tcp, 0.0.0.0:443->443/tcp, [::]:443->443/tcp

make clean (показаны последние строки, самое главное для нас в конце) и docker volume ls:

 Container notes-db-1 Stopped
 Container notes-db-1 Removed
 Network notes-net Removed
docker container prune -f
Total reclaimed space: 0B
docker image prune -f
Total reclaimed space: 0B
docker builder prune -f
...
Total:	45.91MB
DRIVER    VOLUME NAME
local     notes_pgdata

Как читать вывод: make печатает каждую команду перед выполнением (строка python3 -m unittest -v), кроме тех, что начинаются с @ (как @echo). make test прогнал 6 тестов из урока 1.6. В make ps смотри колонку STATUS: (healthy) значит проверка здоровья прошла; у proxy её нет, поэтому просто Up. Порт 5432/tcp у базы без стрелки: наружу он не опубликован, БД доступна только внутри сети notes-net. После make clean том notes_pgdata на месте, а docker builder prune -f очистил кэш сборки (45.91MB). Число тестов зависит от твоей версии test_app.py.

Объясни себе

  • Почему clean не содержит docker system prune -a --volumes и docker volume prune -a?
  • Зачем .PHONY? Что случится, если в каталоге появится файл build?
  • Почему VERSION вынесена в переменную, а не вписана в каждую цель?

Типичные ошибки

  • Makefile:12: *** missing separator. Stop.: в рецепте пробелы вместо TAB. Замени отступ на TAB.
  • make: *** No rule to make target 'clean'. Stop.: цель не добавлена или опечатка в имени.
  • docker: 'compose' is not a docker command: не установлен плагин Compose v2 (урок 4.1).
  • make: 'build' is up to date.: нет .PHONY и есть файл build.
  • make: *** [Makefile:40: logs] Error 130: это не поломка: ты нажал Ctrl+C, чтобы выйти из make logs.

Сломай и почини

Запусти скрипт и не читай его (это твоя тренировка диагностики). Скрипт создаёт всё в Docker и метит меткой break=4.9; реальный диск он не заполняет, объём «мусора» несколько сотен мегабайт. Запуск без sudo, от своего пользователя:

curl -fsSL -o /tmp/break-4.9.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/4.9/break.sh
bash /tmp/break-4.9.sh 1

Вместо 1 можно указать 2 или 3. Когда закончишь, bash /tmp/break-4.9.sh fix вернёт всё как было (безопасен при повторном запуске). Если не хочешь спойлеров, выбери номер случайно (shuf -i 1-3 -n 1) и запусти.

Симптом

Один из трёх:

  1. Место на диске тает, а docker system df показывает почти пустые контейнеры. Виноват кто-то, кого этот отчёт не считает.
  2. docker system df показывает сотни мегабайт в образах и остановленных контейнерах, почти всё в колонке RECLAIMABLE. На настоящем сервере с таким мусором docker build и docker pull заканчивались бы no space left on device.
  3. notes-api то отвечает на curl -sS --max-time 3 http://127.0.0.1:8090/healthz, то нет, docker ps показывает подозрительно свежее время работы, а docker logs чист.

Гипотезы

  • Диск забит логами контейнера без ротации.
  • Диск забит старыми образами, кэшем сборки, остановленными контейнерами.
  • Контейнер убивается по памяти (OOM-kill) и его перезапускает политика restart.

Проверки

По ступеням лестницы из теории. Выполни те, что подходят к твоему симптому:

df -h /var/lib/docker
df -i /var/lib/docker
docker system df
docker image ls -a
sudo du -h /var/lib/docker/containers/*/*-json.log | sort -h | tail -3
docker ps -a --format 'table {{.Names}}\t{{.Status}}'
docker inspect -f '{{.Name}} oom={{.State.OOMKilled}} restarts={{.RestartCount}} mem={{.HostConfig.Memory}}' $(docker ps -aq)
docker events --since 5m --filter event=oom

Исправление

Разбор трёх сценариев

1. Логи без ротации. docker system df мало (строка Containers около 4 КБ), а sudo du -h /var/lib/docker/containers/*/*-json.log показывает один файл на 445 МБ. Это контейнер notes-worker: он печатает миллионы строк с ошибкой. Починка: пересоздать контейнер с --log-opt max-size=10m --log-opt max-file=3 (в Compose ключ logging, как в 4.6) или срочно обнулить файл sudo truncate -s 0 <LogPath> (не rm, задание 3). Постоянно: log-opts в /etc/docker/daemon.json. Ещё стоит найти, почему контейнер пишет так много (цикл ошибок): ротация иначе только скроет проблему.

2. Мусор. docker system df показывает в Images около 600 МБ (RECLAIMABLE 97%), docker image ls -a четыре образа <untagged>, docker ps -a три остановленных контейнера notes-old-*. Починка: docker container prune -f, docker image prune -f, docker builder prune -f, при необходимости docker image prune -a (понимая, что образы придётся скачать заново). Тома не трогать. Профилактика: чистка по расписанию, не хранить сотни тегов в CI.

3. OOM-killed. docker inspect покажет растущий restarts (oom после перезапуска может быть false, как в задании 1), docker events --filter event=oom покажет события, в docker logs notes-api только строки started, а docker inspect -f '{{.HostConfig.Memory}}' notes-api даст 50331648 (48 МБ). Кто-то вызывает /leak: это контейнер notes-probe, он раз в 2 секунды просит удержать ещё 10 МБ. Причина: рост памяти выше лимита, в реальной жизни это утечка. Починка: найти источник роста памяти в приложении; если лимит просто мал, поднять его осознанно, ориентируясь на docker stats. Не лечить отключением лимита.

Общий вывод: сначала измеряй (df, system df, inspect), потом удаляй.

ИИ в помощь

Нейросеть хорошо переводит ошибки Docker и подсказывает, где искать причину, но не видит твой хост. Общие правила: ИИ-помощник.

Задача: разобрать сообщение об ошибке.

Вот ошибка Docker и команда, которую я запускал: <вставь без секретов>.
Прочитай сообщение справа налево, назови причину и три шага проверки по порядку (ps -a, logs, inspect).
Не предлагай удалить тома или образы.

Проверь ответ: выполни шаги по лестнице диагностики и сверь причину с логами. Типичная ошибка нейросети: советовать docker system prune -a --volumes при любом сбое.

Задача: понять причину падения по коду выхода.

Контейнер завершился с кодом 137, docker logs пуст, OOMKilled: false. Вот docker inspect (без секретов): <вставь>.
Какие три причины возможны и как отличить их по docker events и dmesg?

Проверь ответ: проверь docker events и sudo dmesg -T. Типичная ошибка нейросети: считать 137 всегда нехваткой памяти.

Задача: составить безопасный план уборки диска.

Вот вывод docker system df и df -h: <вставь>.
Предложи порядок уборки от безопасных команд к опасным и отметь, что может удалить данные.

Проверь ответ: сверь с таблицей рисков уборки в уроке. Типичная ошибка нейросети: включить volume prune -a в первый же шаг.

Словарик урока

Термин Простыми словами
Код выхода (exit code) число, которое процесс отдаёт при завершении: 0 значит успех, остальное ошибка; при смерти от сигнала равен 128 + номер сигнала
Сигнал (signal) короткое сообщение процессу; SIGTERM (15) просит завершиться, SIGKILL (9) убивает немедленно
PID 1 главный процесс контейнера; когда он завершается, останавливается и контейнер
stdout, stderr два стандартных потока вывода программы: обычные сообщения и ошибки; их собирает Docker
Драйвер логов json-file способ хранения логов по умолчанию: строки в JSON-файле на хосте
Ротация логов ограничение размера логов: старые файлы удаляются, хранится не более max-file по max-size
docker inspect команда, печатающая JSON со всеми данными контейнера или образа
--format (-f) шаблон Go для выбора полей из inspect
docker exec запуск ещё одного процесса внутри работающего контейнера
Отладочный контейнер вспомогательный контейнер с инструментами, подключённый к сети или процессам целевого (--network container:, --pid container:)
cgroup механизм ядра, ограничивающий ресурсы группы процессов (память, CPU)
Лимит памяти (-m) предел оперативной памяти контейнера; при превышении процесс убивает ядро
swap область подкачки на диске, куда ядро выгружает память; --memory-swap задаёт общий предел памяти вместе с ней
OOM-killer, OOMKilled механизм ядра, убивающий процесс при нехватке памяти; поле OOMKilled: true отмечает такую смерть
docker events поток событий демона: старт, смерть (die), нехватка памяти (oom)
Политика restart правило автоматического перезапуска упавшего контейнера (no, on-failure, always, unless-stopped)
RestartCount сколько раз контейнер перезапускали автоматически
--init запуск крошечного init-процесса как PID 1, который передаёт сигналы настоящему процессу
Exec-форма и shell-форма CMD ["python","app.py"] запускает программу напрямую, python app.py через оболочку sh -c, которая сигналы не передаёт
port is already allocated ошибка: порт хоста уже занят другим контейнером или программой
no such host ошибка DNS: имя не превратилось в адрес
Образ без имени (<untagged>, dangling) образ, у которого нет тега; остаётся после сборок без -t
Слой записи слой контейнера для изменений файлов; у остановленного контейнера остаётся на диске
Кэш сборки промежуточные результаты docker build; убирается docker builder prune
docker system df отчёт о месте под образы, контейнеры, тома и кэш; логи не считает
RECLAIMABLE сколько можно освободить; для томов вводит в заблуждение
prune группа команд уборки неиспользуемых объектов; тома удаляет только volume prune -a и compose down -v
Анонимный том том, созданный без имени; в отличие от именованного, удаляется обычным prune --volumes
no space left on device ошибка: на файловой системе не осталось места (или inodes)
lsof +L1 список открытых файлов, которые уже удалены; они держат место
truncate -s 0 обнулить файл, не удаляя его
Цель и рецепт (make) Цель это имя команды в Makefile, рецепт это строки под ней, которые начинаются с TAB
Namespaces, слой записи Изоляция файлов, сети и процессов контейнера; слой записи хранит его изменения и удаляется вместе с ним
docker stats Показывает CPU, память, сеть и диск контейнеров в реальном времени
.State.Health Блок в docker inspect с результатами проверок healthcheck
Error response from daemon Начало ответа демона Docker: настоящая причина стоит в конце сообщения
dmesg Журнал ядра Linux: там видны убийства за память (oom-killer)
Makefile, .PHONY файл с рецептами для make; .PHONY помечает цели, которые не являются файлами

Вопросы с собеседований

Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой «часто» задают почти на каждом собеседовании по теме урока: начни с них. Короткие вопросы с пометкой «на скорость» тренируй на время: ответ за 30 секунд.

1. [junior] [часто] Какими командами ты отлаживаешь контейнер?

Ответ

Статус смотрю через docker ps -a, логи через docker logs --tail 100 -f <имя>. docker inspect <имя> показывает код выхода, флаг OOMKilled, здоровье, тома и сети. Внутрь работающего захожу через docker exec -it <имя> sh, потребление смотрю в docker stats. Если контейнер падает сразу, запускаю образ с другой командой: docker run --rm -it --entrypoint sh <образ>. Начинаю всегда с логов и кода выхода.

Что хотят услышать: ps -a, logs, inspect, exec, stats, запуск с другим entrypoint, порядок: статус, код выхода, логи.

Красный флаг: «Пересоздам контейнер и посмотрю, поможет ли»; не смотрит логи и код выхода.

2. [junior] [часто] Контейнер в статусе Exited. С чего начнёшь?

Ответ

docker ps -a, смотрю код выхода. Потом docker logs --tail 100, потом docker inspect (OOMKilled, команда, монтирования). Если логов нет, запускаю образ с --entrypoint sh (подменяю команду запуска на оболочку) и повторяю команду руками.

Что хотят услышать: порядок «код, логи, inspect, exec», знание кодов 1, 127, 137, 143.

Красный флаг: «перезапущу и посмотрю, поднимется ли».

3. [junior] [часто] Что значит код выхода 137 и чем он отличается от 143?

Ответ

137 это 128 плюс 9, SIGKILL. 143 это 128 плюс 15, SIGTERM. 137 бывает от OOM, от docker stop, который не дождался процесса, и от kill -9. Различаю по State.OOMKilled в inspect и по docker events. 143 значит, что процесс умер от SIGTERM, не перехватив его; программа с обработчиком SIGTERM завершится кодом 0.

Что хотят услышать: формула 128 + сигнал, проверка OOMKilled, связь с обработкой SIGTERM.

Красный флаг: «137 это всегда нехватка памяти».

4. [junior] [на скорость] Диск на Docker-хосте заполнен. Что смотришь и что можно чистить без риска?

Ответ

df -h, docker system df, размер логов контейнеров (system df их не считает). Безопасно: container prune, image prune, builder prune. Тома (volume prune -a, compose down -v) не трогаю, пока не проверю, чьи там данные.

Что хотят услышать: измерять до удаления, уровни риска, тома с данными, логи вне system df.

Красный флаг: docker system prune -a --volumes «на всякий случай».

5. [middle] Прод отвечает 502, за nginx стоит контейнер приложения. Твои действия?

Ответ

502 значит, что прокси (nginx, который принимает запросы и передаёт приложению) не получил нормального ответа от приложения. Смотрю docker ps: жив ли контейнер и healthy ли. Если перезапускается, читаю logs и inspect (OOM, RestartCount). Проверяю из контейнера nginx, что имя notes:8080 резолвится и порт отвечает. Проверяю, не поменялся ли IP контейнера при пересоздании (кэш nginx, урок 4.6). Затем ищу, что менялось в последнем релизе.

Что хотят услышать: цепочка клиент, nginx, upstream; логи обоих; проверка DNS и порта из соседнего контейнера; откат как быстрая мера.

Красный флаг: сразу править конфиг nginx, не посмотрев состояние приложения.

6. [middle] df показывает диск 100%, а du по /var/lib/docker даёт заметно меньше. Причины?

Ответ

Удалённые, но открытые процессом файлы (lsof +L1: например, лог, удалённый через rm у работающего контейнера), исчерпанные inodes (df -i), резерв файловой системы под root, другой раздел (логи или данные на отдельном диске), слои overlay, которые du считает иначе. Начинаю с df -i и lsof +L1, потом с docker system df.

Что хотят услышать: inodes, deleted-but-open, отличие df от du (урок 1.5).

Красный флаг: «du врёт, перезагружу сервер».

7. [middle] Контейнер периодически перезапускается, docker logs чист. Что это может быть?

Ответ

OOM-kill: ядро убивает мгновенно, и приложение ничего не пишет. Смотрю RestartCount и OOMKilled в inspect (после перезапуска флаг может быть false, поэтому смотрю ещё docker events и dmesg | grep -i oom на хосте), docker stats для динамики памяти. Дальше либо утечка, либо мал лимит.

Что хотят услышать: OOMKilled, dmesg, отличие лимита cgroup от свободной памяти хоста, restart-политика маскирует проблему.

Красный флаг: «увеличу лимит до 8 ГБ и всё».

8. [junior] [на скорость] Как понять, что приложение в контейнере пишет логи не туда?

Ответ

Если docker logs пуст, а внутри есть файл лога (docker exec ... ls /var/log), приложение пишет в файл, а не в stdout. Для контейнеров правильно писать в stdout и stderr, а сборкой и хранением логов занимается платформа.

Что хотят услышать: stdout/stderr как контракт контейнера, связь с драйвером логов.

Красный флаг: «настрою logrotate внутри контейнера».

9. [middle] Сборка образа падает с no space left on device. Что делаешь?

Ответ

Смотрю df -h и docker system df. Чищу кэш сборки и образы без имени (builder prune, image prune). Ищу логи-гиганты в /var/lib/docker/containers. Если это CI-runner (машина, на которой CI запускает сборки), настраиваю чистку по расписанию и лимит размера кэша.

Что хотят услышать: уровни чистки, логи как частый виновник, профилактика, а не разовая уборка.

Красный флаг: удалить /var/lib/docker руками.

10. [middle] После docker compose down -v и очистки пропала база. Что произошло и как избежать?

Ответ

Том pgdata удалили: ключ -v у compose down или docker volume prune -a. В современном Docker обычный system prune --volumes именованные тома не трогает, но в старых версиях (до 23) удалял, и любой том без подключённого контейнера считается неиспользуемым. Избегаю: не использую -v и volume prune -a на живых данных, делаю резервные копии БД (pg_dump, дамп в файл), перед уборкой смотрю docker volume ls.

Что хотят услышать: правило «том без контейнера считается неиспользуемым», бэкап отдельно от тома, осторожность с prune.

Красный флаг: «том это тоже кэш, его можно чистить».

11. [middle] Деплой новой версии: docker stop каждый раз висит 10 секунд, потом код 137. Почему?

Ответ

Приложение не получает SIGTERM: команда в shell-форме (CMD python app.py), сигнал достаётся оболочке, а не процессу, либо процесс его игнорирует (PID 1 без обработчика сигнал не обрабатывает). Через таймаут Docker шлёт SIGKILL, отсюда 137 и обрыв запросов. Чиню: exec-форма CMD ["python","app.py"], обработчик SIGTERM, при необходимости --init.

Что хотят услышать: PID 1 и сигналы (урок 1.4), exec-форма против shell-формы, graceful shutdown (штатное завершение с доделыванием запросов).

Красный флаг: «увеличу stop_grace_period и забуду».

12. [middle] docker system df показывает, что места хватает, а диск заполнен. Что проверишь?

Ответ

docker system df не считает логи контейнеров. Смотрю sudo du -h /var/lib/docker/containers/*/*-json.log | sort -h. Если там гигабайты, включаю ротацию (max-size, max-file) и пересоздаю контейнер; срочно обнуляю файл через truncate -s 0, а не rm, чтобы место освободилось.

Что хотят услышать: логи вне system df, ротация, разница truncate и rm у открытого файла.

Красный флаг: «docker system prune -a всё решит».

13. [middle] В контейнере нет shell (distroless), docker exec ... sh не работает. Как отлаживать?

Ответ

Сначала получаю максимум снаружи: docker logs, docker inspect, docker top, docker cp для копирования файлов. Если нужны сетевые и системные утилиты, запускаю отладочный контейнер рядом и подключаю его к пространствам имён целевого: docker run --rm -it --network container:имя --pid container:имя nicolaka/netshoot. Он видит тот же сетевой стек и процессы. В образ с приложением отладочные инструменты не добавляю, это расширяет поверхность атаки.

Что хотят услышать: docker logs/inspect/cp, отладочный контейнер с --network container:, не раздувать прод-образ.

Красный флаг: Пересобрать прод-образ с shell и отладочными утилитами «навсегда».

14. [middle] Диск растёт из-за логов контейнеров. Как ограничить их размер?

Ответ

Драйвер логов по умолчанию json-file сам ротацию не делает, файл растёт, пока не кончится место. Ограничиваю так: docker run --log-opt max-size=10m --log-opt max-file=3, в Compose это logging: {driver: json-file, options: {max-size: "10m", max-file: "3"}}. Глобально задаю log-opts в /etc/docker/daemon.json и перезапускаю демон. Настройка по умолчанию касается только новых контейнеров, старые нужно пересоздать. Размер проверяю: docker inspect --format '{{.LogPath}}' имя и ls -lh по этому пути.

Что хотят услышать: json-file без ротации, max-size/max-file, daemon.json только для новых контейнеров.

Красный флаг: Обрезать файл логов руками каждый раз вместо настройки ротации.

Проверено на версиях

Проверено в изолированном Docker внутри контейнера docker:dind (Docker Engine 29.8.1, хранилище образов containerd, ядро Linux виртуальной машины Docker Desktop на Mac с ARM, cgroup v2; поэтому размеры и dmesg из этой среды):

  • Docker Engine: 29.8.1, Docker Compose: v5.5.1. Прогнаны все задания 1-4 и 6 и три сценария скрипта поломок (включая повторный запуск и двойной fix).
  • Образ «Заметок»: notes:0.4.0, собран из project/notes/versions/v4.py по multi-stage Dockerfile урока 4.7 (psycopg[binary]>=3.2,<4).
  • Python в образе: 3.13.15 (python:3.13-slim), PostgreSQL 18.6 (postgres:18), nginx 1.30 (nginx:1.30), Alpine 3.22 (alpine:3.22).
  • make test, make lint: GNU Make 4.4.1 и Python 3.14.7 (Alpine); make build, make up, make ps, make clean прогнаны на полном стеке notes + db + proxy (самоподписанный сертификат из урока 4.6).
  • Задание 5: pull при нехватке места воспроизведён во вспомогательном Docker на разделе 120 МБ; путь в сообщении отличается от стандартного /var/lib/docker.
  • Не прогонялось: Ubuntu 24.04 и 26.04 с Docker из apt (в стенде другое ядро, sudo не использовался, вывод dmesg может отличаться), Docker Engine со старым хранилищем overlay2 (там образ без имени называется <none>), Compose-вариант ротации логов (logging, синтаксис проверен на конфиге compose.yml и docker compose up).
  • Найдено при проверке и исправлено в уроке: python в Makefile (в Ubuntu только python3), несуществующая цель ruff check . из requirements-dev.txt (в 1.6 lint вызывает py_compile), ошибочный вывод Ran 9 tests, notes:0.4.0 в docker compose ps (Compose называет образ notes-notes), 143 у docker stop (приложение выходит с кодом 0), размер лога 2-3 МБ (на деле 22 МБ), поведение prune --volumes в Docker 29.

Итог урока: ты умеешь

  • умею по docker ps -a и коду выхода (0, 1, 125, 126, 127, 137, 143) определить класс проблемы
  • умею отличить OOM-kill от других причин 137 через docker inspect, docker events и dmesg
  • умею достать из inspect нужные поля шаблоном --format
  • умею зайти в контейнер через exec или отладочный контейнер в его сети и списке процессов
  • умею объяснить, почему docker stop висит 10 секунд и заканчивается кодом 137
  • умею по тексту ошибки понять, занят порт (port is already allocated) или не найдено имя (no such host)
  • умею найти лог-файл контейнера, включить ротацию max-size и max-file и срочно обнулить файл через truncate
  • умею читать docker system df и чистить диск по уровням риска, не задев тома
  • умею собрать Makefile с целями build, up, down, logs, ps, test, clean

Дальше: Тема 5: Kubernetes и Helm

Проверь себя

Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.

Тест работает с включённым JavaScript.

тема 4 урок 4.9 3.5 ч курс 0/0 ← → уроки