✻ Урок 4.9 · Тема 4: Docker и Compose
Отладка контейнеров и уборка диска
Содержание урока
Зачем это нужно
Контейнер упал в три часа ночи. У тебя нет ни привычной оболочки внутри, ни времени гадать: есть код выхода (число, с которым программа сообщает системе, как завершилась: 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-stageDockerfile.
Картина целиком
Диагностика контейнера похожа на приём у врача скорой помощи. Врач не начинает с операции. Он идёт по порядку: как выглядит пациент (жив ли), что он сам скажет (жалобы), что показывают анализы (подробные данные), можно ли его осмотреть изнутри, как работают органы под нагрузкой и не в порядке ли условия вокруг (палата, питание). Так и тут: у каждой ступени свой вопрос и своя команда.
Контейнер для этой задачи это обычный процесс 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/<id>/<id>-json.log<br>логи контейнера растут без ротации"]
За урок ты пройдёшь каждую ступень на живых поломках, затем научишься убирать диск так, чтобы не потерять данные, и в конце оформишь всё в Makefile.
Теория
Что мы диагностируем: процесс, код выхода и сигнал
Слова «контейнер упал» ничего не говорят. Упасть может только процесс внутри, а у процесса есть точная «причина смерти». Без этого понятия нельзя прочитать ни одну диагностику.
У каждого рабочего, уходящего со смены, есть запись в журнале: «ушёл сам, всё сделано» (0), «ушёл с жалобой» (не ноль) или «выведен охраной» (сигнал). Код выхода это запись в журнале смерти процесса.
Устроено это так.
- Процесс (process) это запущенная программа. У каждого есть номер PID. В контейнере главный процесс, тот, что запустила команда
CMD, получает PID 1 (первый в своём контейнере). Когда он завершается, контейнер останавливается: контейнер живёт ровно столько, сколько живёт его PID 1. - Код выхода (exit code) это число от 0 до 255, которое процесс отдаёт системе при завершении. 0 значит «всё хорошо», всё остальное «что-то не так».
- Сигнал (signal) это короткое сообщение процессу от ядра или от другого процесса. Два нужны всегда: SIGTERM (номер 15, «пожалуйста, заверши работу», процесс может успеть прибраться) и SIGKILL (номер 9, «умри немедленно», перехватить нельзя). Подробнее об этом в уроке 1.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 выдало три вещи. Тогда понятно, почему у контейнера нет своих логов на диске, почему он умирает вместе с процессом и откуда берутся лимиты.
Гостиничный номер для гостя. В номере свои стены (изоляция: гость видит только свою мебель), счётчик на воду и свет (лимиты) и журнал обслуживания у администратора (всё, что гость просит, записывается). Гость живёт, пока идёт его проживание, а потом номер убирают. Аналогия перестаёт работать в том, что в гостинице номер физически отдельный, а контейнеры делят одно и то же ядро хоста: поэтому взломанный контейнер опаснее, чем отдельная виртуальная машина.
Устроено это так.
- Изоляция (namespaces). Ядро показывает процессу свой набор файлов, свою сеть и свой список процессов. Внутри контейнера твой
python app.pyдумает, что он PID 1 и что в системе больше никого нет. С хоста тот же процесс виден под другим, обычным номером. - Лимиты (cgroups). Ядро считает, сколько памяти и процессора занимает группа процессов, и может не дать больше заданного. Это та самая «счётчик на воду»: превысил потолок памяти, и ядро убивает процесс (OOMKilled).
- Файлы из слоёв и слой записи. Образ только для чтения. Поверх него Docker кладёт тонкий слой, куда контейнер пишет свои изменения (слой записи, writable layer). Когда контейнер удаляют, этот слой удаляется вместе с ним. Тома (volumes) живут отдельно и остаются, поэтому данные базы хранят в томе.
- Вывод и логи. Всё, что процесс печатает в 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 «пересылающих» обычно три, и знать их полезно.
Устроено это так.
- Команда
docker(клиент) просит демонаdockerd(фоновую службу Docker) что-то сделать. Если демон отказал, клиент печатаетError response from daemon:и дальше текст демона. - Демон, в свою очередь, просит исполнителя
runcзапустить процесс. Ошибка оттуда попадает в текст фрагментами вродеOCI runtime create failed,exec:илиfailed to set up container networking. - В самом конце стоит первоисточник: сообщение ядра, файловой системы или сети (
permission denied,executable file not found in $PATH,Bind for ... failed: port is already allocated,no such host). - Середину из идентификаторов (
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) записывает всё на ленту. Кто написал записку в кармане (лог в файл внутри контейнера), тот диспетчеру ничего не сообщил.
Устроено это так.
- У любого процесса есть два стандартных выходных потока: stdout (обычные сообщения) и stderr (сообщения об ошибках). Терминал показывает оба.
- Docker подключается к обоим потокам главного процесса и пишет всё в файл на хосте. По умолчанию это драйвер логов
json-file: каждая строка оборачивается в JSON с временем и именем потока. 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 это осмотр врачом. А если пациента нельзя «вскрыть» (в образе нет даже оболочки), врач приходит со своими инструментами и работает рядом.
Устроено это так.
docker exec <имя> <команда>запускает второй процесс в тех же «стенах» (пространствах имён, namespaces) и с теми же лимитами (cgroup), что и главный. Ключи-itподключают клавиатуру и терминал, для интерактивной оболочки:docker exec -it имя sh.- Наш образ на основе
python:3.13-slimминимален: нетcurlи нетps. Зато есть Python, им можно проверять сеть. - Когда внутри нет вообще ничего (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-й, охрана (ядро) без разговоров выводит кого-то из жильцов. Про остальные номера в гостинице (память хоста) охрана в этот момент не думает.
Устроено это так.
- Флаг
-m 64m(илиmem_limitв Compose) записывает в cgroup контейнера (группу управления ресурсами из урока 1.5) лимит 64 МБ. - Когда процессы группы запрашивают больше, ядро сначала пробует выгрузить лишнее в swap (область подкачки на диске), если она разрешена.
- Если освободить нечего, включается OOM-killer: ядро отправляет SIGKILL самому «тяжёлому» процессу группы. Код выхода 137, поле
OOMKilledстановитсяtrue. - Процесс не успевает ничего написать: 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 подсвечивает только проценты: красной зоной придётся считать самому.
Устроено это так.
docker statsраз в секунду опрашивает cgroup каждого контейнера и печатает строку с потреблением. Ключ--no-streamпечатает один снимок и выходит (удобно для скриптов).- Колонка
CPU %: доля процессорного времени. 100% это одно ядро полностью; на многоядерной машине значение может быть и 250%. - Колонка
MEM USAGE / LIMIT: занято и предел. Если лимит не задан, вместо него общая память хоста, а не настоящий потолок. - Колонка
MEM %: доля от предела. Значение около 100% при заданном-mзначит, что OOMKilled близко. - Колонки
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.
Устроено это так.
docker stopшлёт главному процессу SIGTERM и ждёт 10 секунд (можно изменить ключом-t).- Если процесс за это время завершился, всё хорошо. Иначе Docker шлёт SIGKILL, и код выхода получается 137.
- Особенность PID 1: ядро не применяет к нему действия по умолчанию. Обычная программа умирает от SIGTERM, а PID 1 нет, пока программа сама не установила обработчик сигнала. «Заметки» обработчик установили в уроке 1.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 не передаёт. - Выход для программ без обработчика: ключ
--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 контейнер не перезапускает (это делает оркестратор или ты).
Устроено это так.
- В образе или в
compose.ymlописана команда проверки (урок 4.2): например, запрос к/healthz. Код выхода 0 значит «здоров», 1 «нездоров». - Первые
start_periodсекунд провалы не считаются, контейнеру дают разогнаться. Статус в это времяstarting. - Каждые
intervalсекунд Docker запускает проверку внутри контейнера и ждёт не дольшеtimeout. - После
retriesнеудач подряд статус становитсяunhealthy. Одна успешная проверка возвращаетhealthy. - Результаты последних проверок лежат в
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: одна успешная проверка
Прикинь сам:
interval10 с иretries3. Через сколько секунд сбоев статус станетunhealthy?
Примерно через 30 секунд (3 провала по 10 с), если нет start_period.
Осторожно, частое заблуждение: «unhealthy значит, что контейнер упал». Нет: он работает, просто проверка не проходит. Вторая ошибка: проверка, которая всегда зелёная (например, exit 0): такой healthcheck ничего не проверяет и только создаёт ложное спокойствие.
Главное:
Upзначит только «процесс жив», аhealthyзначит, что проверка проходит;unhealthyне значит «упал».
Теперь про перезапуски.
Проверь понимание: контейнер в статусе
(health: starting)уже пять минут, хотяstart_period30 секунд. Что это значит?
Ответ
Статус 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 есть зависимости: цель может требовать, чтобы сначала выполнилась другая.
Устроено это так.
- Файл называется
Makefileи лежит в каталоге проекта. Командуmake имяты запускаешь в этом каталоге. - Файл состоит из правил. Правило: цель (имя), двоеточие, затем строки рецепта (команды), которые выполнятся в оболочке. Каждая строка рецепта обязана начинаться с символа табуляции (TAB), а не с пробелов: иначе ошибка
missing separator. - Если после двоеточия перечислены другие цели,
makeсначала выполнит их:up: buildзначит «передupсобери образ». - Строка
.PHONY: build up downговорит, что эти имена это команды, а не файлы. Без неё, если в каталоге появится файл с именемbuild,make buildрешит, что «цель уже готова», и ничего не сделает. - Переменные вида
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 хранится недолго (только поток в реальном времени), а журнал ядра живёт до перезагрузки или до переполнения кольцевого буфера.
Устроено это так.
docker eventsпечатает поток событий демона:create,start,die,oom,kill,stop,destroy. Каждое событие с временем, типом и именем контейнера. Команда работает в реальном времени: запусти её в одном окне, а поломку воспроизводи в другом. Ключ--since 10mпоказывает события за последние десять минут.- Событие
oomозначает, что ядро сообщило Docker об убийстве по лимиту памяти. Событиеdieс атрибутомexitCode=137идёт следом. - Журнал ядра читается командой
sudo dmesg -T(ключ-Tпереводит время в человеческий формат). Искать строки со словамиoom-killerиKilled process: в них указан процесс, его размер и то, что убийство произошло по лимиту cgroup (CONSTRAINT_MEMCG). - Сопоставляешь: время
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 МБ. Вывод: лимит памяти слишком мал или в приложении утечка. Чинишь причину (поднимаешь лимит после оценки реальной потребности или исправляешь утечку), а не перезапускаешь контейнер.
Прикинь сам:
ExitCode137,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.
Шаги
- Останови стек 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
- Проверь, что контейнер живой, и посмотри потребление.
docker stats --no-streamпечатает один снимок ресурсов (без ключа обновляется бесконечно):
sleep 2
docker stats --no-stream t-oom
curl -s http://127.0.0.1:8081/healthz; echo
- Вызови демонстрационный эндпоинт (в реальном сервисе его бы не было).
-sSзначит «без прогресс-бара, но ошибки показать»:
curl -sS "http://127.0.0.1:8081/leak?mb=100"
- Найди причину. Разбор:
--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 есть.
- Спроси ядро и 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.
- Теперь добавь политику перезапуска и увидь, как она скрывает проблему. Ключ
--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: процесс так и не стартовал.
Шаги
- Три ошибки запуска (
$?печатает код выхода последней команды,;разделяет команды):
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.
- Остановка. Сравни
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 должны быть.
- Порт занят:
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.
- Имя не находится:
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 у тебя другой.
- Приложение без базы:
«Заметки»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: нейросеть может увидеть причину там, где её нет.
- Цикл падений:
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 тот же файл продолжает расти: лог живёт, пока контейнер не удалён.
Шаги
- Контейнер без ротации. Команда внутри:
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)"
- Контейнер с ротацией: максимум 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)")"
- Найди самые тяжёлые логи и сравни, сколько строк вернёт
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).
- Что ломает или помогает при аварии. Перезапуск:
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 тысяч строк ещё раз.
- Удаление файла руками на живом контейнере (
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 образ тоже оставит.
Шаги
- Смотрим, что занимает место.
system df -v(verbose) раскладывает по объектам,head -30берёт первые 30 строк:
docker system df
docker system df -v | head -30
- Создай мусор. Сначала том с «данными» (его мы пытаемся не потерять) и остановленный контейнер, записавший 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'
- Теперь образ без имени: соберём после правки без ключа
-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 это «удалить».
- Убери только безопасное. Ключ
-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 остались на месте.
- Настоящий риск это тома. Проверим на учебном томе с меткой, чтобы не задеть твои (
--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, если стек в этот момент остановлен: неиспользуемым считается любой том, к которому не подключён ни один контейнер (даже остановленный).
- Ошибка, которую ты увидишь при попытке удалить образ:
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.
Шаги
- Открой
~/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 даёт образу имя по проекту и сервису); слои общие, поэтому вторая сборка почти мгновенная.
- Проверь:
make test
make lint
make build
make up
make ps
make clean
docker volume ls
- Зафиксируй в 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) и запусти.
Симптом
Один из трёх:
- Место на диске тает, а
docker system dfпоказывает почти пустые контейнеры. Виноват кто-то, кого этот отчёт не считает. docker system dfпоказывает сотни мегабайт в образах и остановленных контейнерах, почти всё в колонкеRECLAIMABLE. На настоящем сервере с таким мусоромdocker buildиdocker pullзаканчивались быno space left on device.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-stageDockerfileурока 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.6lintвызывает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.