Главная/Observability и диагностика/Урок

13.6. Таблица диагностики

Это справочный материал. Прочитайте его один раз целиком, чтобы знать состав, и возвращайтесь по мере необходимости.

Формат каждой записи одинаков: симптом → вероятная причина → проверка → исправление. Проверка обязательна: она отличает диагностику от угадывания.

Цели

После этого материала вы сможете:

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

Предварительные знания

Разделы 04, 07, 08 и уроки 13.113.5.


Методика: пять вопросов

Прежде чем открывать таблицу, ответьте на пять вопросов. Каждый сужает область.

text
1. Container вообще создан?
       docker ps -a | grep ИМЯ
       ├── нет  → проблема при создании: логи daemon
       └── да   ↓

2. Он работает или завершился?
       docker inspect ИМЯ --format '{{.State.Status}} {{.State.ExitCode}}'
       ├── exited → раздел «Запуск и остановка», смотреть код
       └── running ↓

3. Приложение что-нибудь вывело?
       docker logs ИМЯ
       ├── трассировка → проблема в приложении, не в Docker
       ├── пусто       → буферизация или драйвер логов
       └── штатный вывод ↓

4. Проверка здоровья проходит?
       docker inspect ИМЯ --format '{{json .State.Health}}'
       ├── unhealthy → смотреть Health.Log: там вывод проверки
       └── healthy или отсутствует ↓

5. Изнутри работает?
       docker exec ИМЯ curl -s localhost:ПОРТ
       ├── работает    → проблема в сети или публикации порта
       └── не работает → проблема в приложении

Пять команд отделяют пять разных областей. Дальше — таблица соответствующего раздела.

Главное правило: не переходите к шагу N+1, не выполнив шаг N. Порядок выбран так, что каждый следующий шаг имеет смысл только при определённом ответе на предыдущий.


1. Запуск и остановка

#СимптомВероятная причинаПроверкаИсправление
1.1docker run завершается ошибкой, container не созданОшибка на стороне daemon: монтирование, сеть, местоsudo journalctl -u docker.service --since '5 min ago' -p errПо сообщению daemon
1.2Код выхода 137, OOMKilled: trueИсчерпан лимит памятиdocker inspect ИМЯ --format '{{.State.OOMKilled}}'Поднять --memory или найти причину роста (13.3)
1.3Код 137, OOMKilled: falsedocker kill, таймаут stop или OOM на hostdocker events --filter container=ИМЯ; sudo dmesg -T | grep -i killedЗависит от источника
1.4Код 143PID 1 умер от SIGTERM — обычно из-за --initНормаОбработать сигнал и выйти с кодом 0
1.5Код 126Файл найден, но не исполняемыйdocker run --rm ОБРАЗ ls -l /путьchmod +x в Dockerfile
1.6Код 127Команда не найденаdocker run --rm ОБРАЗ which КОМАНДАПроверить PATH и наличие в образе
1.7Ошибка exec format errorАрхитектура образа не совпадает с hostdocker inspect ОБРАЗ --format '{{.Architecture}}'--platform или пересобрать
1.8Restart loop: перезапуск каждые N секундПроцесс падает сразу после стартаdocker logs --tail 50 ИМЯ; docker inspect --format '{{.RestartCount}}'По содержимому логов
1.9Container running, но приложение не отвечаетПроцесс жив, но зависdocker top ИМЯ; sidecar с py-spy dumpПо стеку процесса
1.10docker stop длится 10 секунд и завершается кодом 137Приложение не обрабатывает SIGTERMdocker logs — есть ли сообщение о завершенииУстановить обработчик (6.7)
1.11Приложение получает SIGTERM, но не завершаетсяSIGTERM уходит оболочке, а не приложениюdocker inspect --format '{{json .Config.Entrypoint}}'Форма exec: CMD ["python", "app.py"]
1.12Container завершается сразу с кодом 0Главный процесс отработал и вышелdocker logs ИМЯПроцесс должен работать в переднем плане

Развёрнуто: restart loop

Симптом одинаков, причин три. Различает их время до падения:

Время жизниПричинаПроверка
Меньше секундыОшибка команды или отсутствие файлаdocker logs; код выхода 126/127
1–10 секундОшибка инициализации: конфигурация, подключение к БДdocker logs --tail 50
Стабильные 30–60 секундПровал проверки здоровья или OOMdocker inspect --format '{{json .State.Health}}'; OOMKilled

Третий случай самый коварный: интервал кажется «периодическим», и его связывают с задачей по расписанию, а не с проверкой здоровья.

bash
docker inspect ИМЯ --format '{{.RestartCount}} перезапусков'
docker events --since 30m --filter container=ИМЯ --format '{{.Time}} {{.Action}}'

Вывод events даёт интервалы. Стабильный интервал — признак таймаута, а не случайного сбоя.


2. Логи

#СимптомВероятная причинаПроверкаИсправление
2.1docker logs пуст, приложение работаетБуферизация stdoutdocker inspect --format '{{json .Config.Env}}' | grep -o PYTHONUNBUFFEREDPYTHONUNBUFFERED=1
2.2Логи появляются с задержкой в минутыТо жеТо жеТо же
2.3При падении последние строки отсутствуютБуфер не сброшенВоспроизвести с PYTHONUNBUFFERED=1Тот же
2.4docker logs не работает вовсеДрайвер none или удалённый без кэшаdocker inspect --format '{{.HostConfig.LogConfig.Type}}'Сменить драйвер или включить dual logging
2.5Диск заполнен, docker system df показывает малоЛоги без ротацииsudo du -sh /var/lib/docker/containers/*/ | sort -h | tailmax-size и max-file (13.1)
2.6Агрегатор получает битые записи JSONСтрока длиннее 16 КиБ разрезанаДлина строк в выводе; attrs.partial_id в файле логаОграничить длину полей в приложении
2.7Приложение зависает под нагрузкой, стек в writeБлокирующий режим драйвера логовdocker inspect --format '{{json .HostConfig.LogConfig.Config}}'mode=non-blocking
2.8Часть строк пропадаетnon-blocking с малым буферомСравнить счётчик приложения с docker logs | wc -lУвеличить max-buffer-size

3. Сеть

#СимптомВероятная причинаПроверкаИсправление
3.1Порт опубликован, снаружи не отвечаетПриложение слушает 127.0.0.1docker exec ИМЯ ss -tlnp или sidecarСлушать 0.0.0.0
3.2connection refused между сервисамиЦелевой сервис ещё не запущенdocker ps; depends_on с условиемcondition: service_healthy (9.3)
3.3Имя сервиса не разрешаетсяСеть bridge по умолчанию вместо пользовательскойdocker inspect --format '{{json .NetworkSettings.Networks}}'Пользовательская сеть (8.4)
3.4localhost внутри container'а не находит соседаlocalhost — это сам containerПроверить обращение по имени сервисаИспользовать имя сервиса
3.5Порт занят на hostДругой процесс слушает егоsudo ss -tlnp | grep :ПОРТСменить порт публикации
3.6Работает с --network host, не работает без негоПриложение привязано к 127.0.0.1Как 3.1Как 3.1, а не переход на host
3.7DNS не работает внутри container'аПроблема 127.0.0.11 или настроек daemondocker exec ИМЯ cat /etc/resolv.confПроверить dns в daemon.json
3.8Соединение к внешнему миру не устанавливаетсяПравила брандмауэра hostdocker exec ИМЯ python -c "import socket; socket.create_connection(('1.1.1.1',443),3)"Правила iptables/nftables
3.9Медленное разрешение имёнПоиск по search-доменамdocker exec ИМЯ cat /etc/resolv.conf; замер времениПолное имя с точкой на конце
3.10Сервисы видят друг друга, снаружи недоступныСеть internal: truedocker network inspect ИМЯ --format '{{.Internal}}'Так задумано; публиковать через прокси
3.11После пересоздания сети container'ы потеряли связьContainer'ы остались в старой сетиdocker inspect --format '{{json .NetworkSettings.Networks}}'Пересоздать container'ы

Развёрнуто: сервис недоступен

Лестница из семи шагов (8.6), от процесса к внешнему миру:

text
1. Процесс жив?              docker top ИМЯ
2. Слушает порт?             sidecar: ss -tlnp
3. На 0.0.0.0, не 127.0.0.1? там же
4. Отвечает изнутри?         docker exec ИМЯ curl -s localhost:ПОРТ
5. Отвечает из соседа?       sidecar --network container: → curl
6. Порт опубликован?         docker port ИМЯ
7. Отвечает с host?          curl localhost:ОПУБЛИКОВАННЫЙ

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


4. Storage

#СимптомВероятная причинаПроверкаИсправление
4.1Данные исчезли после пересоздания container'аЗапись в файловую систему container'аdocker diff ИМЯVolume для данных (7.2)
4.2Смонтированный каталог пустBind mount закрыл содержимое образаdocker inspect --format '{{json .Mounts}}'Bind mount всегда затеняет
4.3Volume не заполнился данными образаАвтозаполнение произошло однаждыdocker volume inspect; создан ли volume ранееПересоздать volume
4.4Permission denied при записи в volumeНесовпадение UIDdocker exec ИМЯ id; ls -ln каталогаСогласовать UID (7.5)
4.5Permission denied при верных правах UnixМетки SELinuxgetenforce; ls -ZСуффикс :z или :Z (12.5)
4.6Слой container'а растётПриложение пишет мимо volumedocker ps -s; docker diff ИМЯНаправить запись в volume
4.7Диск заполнен, system df показывает немногоЛоги (см. 2.5) или build cachedocker system df; du каталога DockerПо категории (13.4)
4.8Удалили образ — место не освободилосьОбщие слои используются другимиdocker system df -v: UNIQUE SIZEОжидаемо; смотреть UNIQUE SIZE
4.9RECLAIMABLE нулевой при множестве образовИх удерживают остановленные container'ыdocker ps -aСначала container prune
4.10Данные пропали после prunedocker volume prune или --volumesВосстановления нетРезервные копии (7.6)

5. Ресурсы

#СимптомВероятная причинаПроверкаИсправление
5.1Высокая задержка при низкой загрузке CPUThrottling по квоте CFSdocker exec ИМЯ cat /sys/fs/cgroup/cpu.statПоднять --cpus или сгладить всплески
5.2MEM USAGE близок к лимиту, приложение в порядкеPage cache в учёте cgroupСравнить anon из memory.stat с RSSНорма (13.2)
5.3Память растёт монотонноУтечка или фрагментацияСравнить anon с tracemallocПо результату сравнения
5.4can't start new threadИсчерпан --pids-limitdocker exec ИМЯ cat /sys/fs/cgroup/pids.current /sys/fs/cgroup/pids.maxПоднять лимит или сократить потоки
5.5Too many open filesИсчерпан RLIMIT_NOFILEdocker exec ИМЯ sh -c 'ls /proc/1/fd | wc -l'--ulimit nofile; искать утечку
5.6os.cpu_count() возвращает число ядер hostPython не видит --cpusdocker exec ИМЯ python -c "import os; print(os.cpu_count())"Читать cpu.max (6.13)
5.7Приложение медленное только под нагрузкойThrottling (5.1) или блокирующий драйвер логов (2.7)Обе проверкиПо результату
5.8OOM убил процесс, container продолжает работатьУбит не PID 1sudo dmesg -T | grep -i 'killed process'Имя процесса — только в журнале ядра

6. Сборка

#СимптомВероятная причинаПроверкаИсправление
6.1Кэш не срабатывает при неизменном кодеИзменился слой вышеdocker build --progress=plain; порядок COPYЗависимости копировать до кода (5.5)
6.2Сборка долгая, контекст большойНет .dockerignoreСтрока transferring context в выводе.dockerignore (5.3)
6.3Секрет виден в готовом образеARG или ENV вместо secret mountdocker history --no-trunc ОБРАЗRUN --mount=type=secret (12.6)
6.4COPY не находит файлФайл вне контекста сборкиПуть относительно контекста, не DockerfileПеренести или сменить контекст
6.5Стадия multi-stage не выполняетсяBuildKit пропускает недостижимые от цели--target; граф зависимостейОжидаемо; явная зависимость
6.6Образ больше ожидаемогоКэш пакетов остался в слоеdocker history ОБРАЗОчистка в той же инструкции RUN
6.7Build cache занял десятки гигабайтНет GCdocker system dfbuilder.gc в daemon.json (13.4)
6.8Сборка воспроизводится по-разномуБазовый образ по тегу, не по digestdocker inspect ОБРАЗ --format '{{index .RepoDigests 0}}'Закрепить digest (11.6)
6.9pip install каждый раз скачивает зановоНет cache mountВывод сборкиRUN --mount=type=cache,target=/root/.cache/pip

Быстрый справочник команд

ЗадачаКоманда
Состояние и код выходаdocker inspect ИМЯ --format '{{.State.Status}} {{.State.ExitCode}} {{.State.OOMKilled}}'
Вывод проверки здоровьяdocker inspect ИМЯ --format '{{json .State.Health}}' | python3 -m json.tool
Последовательность событийdocker events --since 30m --until 0s --filter container=ИМЯ --format '{{.Time}} {{.Action}}'
Что записал containerdocker diff ИМЯ
Размер writable layerdocker ps -s
Файл из образа без shelldocker cp ИМЯ:/путь - | tar -xO
Сеть из соседаdocker run --rm --network container:ИМЯ nicolaka/netshoot ss -tlnp
Утилита host в сети целиsudo nsenter -t $(docker inspect ИМЯ --format '{{.State.Pid}}') -n -- ss -tlnp
Throttling CPUdocker exec ИМЯ cat /sys/fs/cgroup/cpu.stat
Память по составуdocker exec ИМЯ cat /sys/fs/cgroup/memory.stat | head -5
Логи daemonsudo journalctl -u docker.service --since '10 min ago' -p err
След OOM в ядреsudo dmesg -T | grep -i 'killed process'
Расход диска по категориямdocker system df -v
Логи, не видные в system dfsudo du -sh /var/lib/docker/containers/*/ | sort -h | tail

Практическое упражнение

Задание. Соберите инструмент, проходящий методику из пяти вопросов автоматически.

Требования:

  1. Реализовать пять шагов методики в правильном порядке, с остановкой на первом отрицательном ответе.
  2. Для каждого исхода выдавать раздел таблицы и конкретную следующую команду.
  3. Проверить инструмент на четырёх разных неисправностях.
  4. Показать, что одинаковый симптом (код 137) приводит к разным диагнозам.
  5. Показать, что инструмент не даёт ложного диагноза на исправном сервисе.
  6. Различать «проверка не выполнена» и «проверка пройдена».

Подсказки

Подсказка 1

Шаг 5 требует выполнить запрос изнутри container'а. В образе может не быть curl — используйте Python или sidecar.

Подсказка 2

Для пункта 4 нужны два container'а с кодом 137: один убитый по OOM, другой — docker kill.

Подсказка 3

Инструмент должен останавливаться на первом отрицательном ответе — иначе он выдаст несколько противоречивых диагнозов.

Решение

Показать решение
bash
mkdir -p /tmp/diaglab && cd /tmp/diaglab

# ─── Исправный сервис ─────────────────────────────────────────────────
cat > good.py <<'PY'
"""Исправный сервис: отвечает на / и на /health."""
import http.server, json, os, threading, time

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        body = json.dumps({"ок": True, "pid": os.getpid()}, ensure_ascii=False).encode()
        self.send_response(200)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
    def log_message(self, *a): pass

print(f"сервис готов, pid={os.getpid()}", flush=True)
threading.Thread(
    target=http.server.ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever,
    daemon=True).start()
while True:
    time.sleep(1)
PY

# ─── Сервис, слушающий только localhost ───────────────────────────────
cat > loopback.py <<'PY'
"""Ошибка 3.1: привязка к 127.0.0.1 вместо 0.0.0.0."""
import http.server, os, threading, time

class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200); self.end_headers(); self.wfile.write(b"ok")
    def log_message(self, *a): pass

print(f"слушаю 127.0.0.1:8000, pid={os.getpid()}", flush=True)
threading.Thread(
    target=http.server.ThreadingHTTPServer(("127.0.0.1", 8000), H).serve_forever,
    daemon=True).start()
while True:
    time.sleep(1)
PY

# ─── Сервис, падающий при старте ──────────────────────────────────────
cat > crashing.py <<'PY'
"""Ошибка 1.8: падение при инициализации."""
import sys, time
print("инициализация...", flush=True)
time.sleep(1)
print("ОШИБКА: не удалось подключиться к базе данных", file=sys.stderr, flush=True)
sys.exit(1)
PY

# ─── Потребитель памяти ───────────────────────────────────────────────
cat > hungry.py <<'PY'
"""Ошибка 1.2: исчерпание лимита памяти."""
import time
blocks = []
n = 0
while True:
    blocks.append(bytearray(4 * 1024 * 1024))
    blocks[-1][::4096] = b"\x01" * (len(blocks[-1]) // 4096)
    n += 4
    print(f"выделено {n} МиБ", flush=True)
    time.sleep(0.15)
PY

# ─── Инструмент диагностики по методике ───────────────────────────────
cat > triage.sh <<'SH'
#!/usr/bin/env bash
# Проходит методику из пяти вопросов, останавливаясь на первом
# отрицательном ответе. Выдаёт раздел таблицы и следующую команду.
set -uo pipefail

TARGET="${1:?укажите container}"
PORT="${2:-8000}"

verdict() {
    printf '\n    ДИАГНОЗ:  %s\n' "$1"
    printf '    РАЗДЕЛ:   %s\n' "$2"
    printf '    ДАЛЬШЕ:   %s\n\n' "$3"
}

printf '\n  Диагностика: %s\n\n' "$TARGET"

# ── Шаг 1: создан ли container ────────────────────────────────────────
if ! docker inspect "$TARGET" > /dev/null 2>&1; then
    printf '    [1] container создан?      НЕТ\n'
    verdict "container не создан — ошибка на стороне daemon" \
            "1. Запуск и остановка, запись 1.1" \
            "sudo journalctl -u docker.service --since '5 min ago' -p err"
    exit 1
fi
printf '    [1] container создан?      да\n'

# ── Шаг 2: работает или завершился ────────────────────────────────────
read -r status code oom restarts <<EOF
$(docker inspect "$TARGET" --format '{{.State.Status}} {{.State.ExitCode}} {{.State.OOMKilled}} {{.RestartCount}}')
EOF
printf '    [2] статус?                %s (код %s, перезапусков %s)\n' "$status" "$code" "$restarts"

if [ "$status" != "running" ]; then
    if [ "$oom" = "true" ]; then
        verdict "убит по нехватке памяти в cgroup" \
                "1.2 и раздел 5. Ресурсы" \
                "docker exec/логи: найти причину роста; поднять --memory"
        exit 2
    fi
    case "$code" in
        137)
            stops="$(docker events --since 30m --until 0s --filter "container=$TARGET" \
                --format '{{.Action}}' 2>/dev/null | grep -c '^stop$' || echo 0)"
            if [ "${stops:-0}" -gt 0 ]; then
                verdict "не завершился по SIGTERM за отведённое время" \
                        "1.10 и 1.11" \
                        "проверить обработчик SIGTERM и форму exec в CMD"
            else
                verdict "SIGKILL извне; возможен OOM на уровне host" \
                        "1.3" \
                        "sudo dmesg -T | grep -i 'killed process'"
            fi
            exit 2 ;;
        143) verdict "штатное завершение по SIGTERM" "1.4" "норма"; exit 0 ;;
        126) verdict "файл найден, но не исполняемый" "1.5" \
                     "docker run --rm ОБРАЗ ls -l /путь"; exit 2 ;;
        127) verdict "команда не найдена" "1.6" \
                     "docker run --rm ОБРАЗ which КОМАНДА"; exit 2 ;;
        0)   verdict "процесс отработал и вышел" "1.12" \
                     "главный процесс должен работать в переднем плане"; exit 0 ;;
        *)   verdict "завершился с кодом $code — ошибка приложения" \
                     "1. Запуск и остановка" \
                     "docker logs --tail 50 $TARGET"; exit 2 ;;
    esac
fi

# ── Шаг 3: что вывело приложение ──────────────────────────────────────
loglines="$(docker logs "$TARGET" 2>&1 | grep -c . || echo 0)"
printf '    [3] строк в логах?         %s\n' "$loglines"
if [ "${loglines:-0}" -eq 0 ]; then
    unbuf="$(docker inspect "$TARGET" --format '{{range .Config.Env}}{{println .}}{{end}}' \
        | grep -c PYTHONUNBUFFERED || echo 0)"
    drv="$(docker inspect "$TARGET" --format '{{.HostConfig.LogConfig.Type}}')"
    if [ "${unbuf:-0}" -eq 0 ]; then
        verdict "логи пусты: вероятна буферизация stdout" "2.1" \
                "перезапустить с PYTHONUNBUFFERED=1"
    else
        verdict "логи пусты при заданном PYTHONUNBUFFERED; драйвер: $drv" "2.4" \
                "docker inspect --format '{{.HostConfig.LogConfig.Type}}'"
    fi
    exit 1
fi
if docker logs "$TARGET" 2>&1 | grep -qE 'Traceback|Error|ОШИБКА|Exception'; then
    printf '        в логах есть признаки ошибки:\n'
    docker logs "$TARGET" 2>&1 | grep -E 'Traceback|Error|ОШИБКА|Exception' \
        | tail -2 | sed 's/^/          /'
fi

# ── Шаг 4: проверка здоровья ──────────────────────────────────────────
health="$(docker inspect "$TARGET" --format \
    '{{if .State.Health}}{{.State.Health.Status}}{{else}}нет{{end}}')"
printf '    [4] проверка здоровья?     %s\n' "$health"
if [ "$health" = "unhealthy" ]; then
    printf '        последний вывод проверки:\n'
    docker inspect "$TARGET" --format '{{json .State.Health.Log}}' \
        | python3 -c "
import json, sys
log = json.load(sys.stdin) or []
if log:
    e = log[-1]
    print('          код=' + str(e['ExitCode']) + ' ' + e['Output'].strip()[:60])
" 2>/dev/null
    verdict "проверка здоровья не проходит" "1.8 и 3. Сеть" \
            "причина — в выводе выше; проверить зависимости сервиса"
    exit 2
fi

# ── Шаг 5: отвечает ли изнутри ────────────────────────────────────────
inside="$(docker exec "$TARGET" python -c "
import socket, sys
s = socket.socket(); s.settimeout(3)
try:
    s.connect(('127.0.0.1', $PORT)); print('да')
except OSError as e:
    print('нет:' + (e.strerror or str(e)))
finally:
    s.close()
" 2>/dev/null)"
if [ -z "$inside" ]; then
    printf '    [5] отвечает изнутри?      ПРОВЕРКА НЕ ВЫПОЛНЕНА (нет python в образе)\n'
    verdict "шаг 5 не выполнен — инструмент недоступен в образе" \
            "13.5. Глубокая диагностика" \
            "sidecar: docker run --rm --network container:$TARGET ОБРАЗ ..."
    exit 1
fi
printf '    [5] отвечает изнутри?      %s\n' "$inside"

case "$inside" in
    да)
        # Отвечает внутри — проверяем снаружи
        published="$(docker port "$TARGET" "$PORT" 2>/dev/null | head -1)"
        if [ -z "$published" ]; then
            verdict "работает внутри, порт не опубликован" "3. Сеть" \
                    "docker run -p ХОСТ:$PORT ... или ports в compose.yaml"
            exit 1
        fi
        hostport="${published##*:}"
        outside="$(python3 -c "
import socket
s = socket.socket(); s.settimeout(3)
try:
    s.connect(('127.0.0.1', $hostport)); print('да')
except OSError as e:
    print('нет:' + (e.strerror or str(e)))
finally:
    s.close()
" 2>/dev/null)"
        printf '    [6] отвечает с host?       %s\n' "$outside"
        if [ "$outside" = "да" ]; then
            verdict "сервис работает и доступен снаружи" "—" "проблем не обнаружено"
            exit 0
        fi
        verdict "работает внутри, недоступен снаружи" "3.1 и 3.6" \
                "проверить привязку: слушает ли 0.0.0.0, а не 127.0.0.1"
        exit 1 ;;
    *)
        listen="$(docker exec "$TARGET" python -c "
import socket
s = socket.socket(); s.settimeout(2)
try:
    s.bind(('0.0.0.0', $PORT)); print('порт свободен — приложение НЕ слушает')
except OSError:
    print('порт занят — приложение слушает, но не на 0.0.0.0')
finally:
    s.close()
" 2>/dev/null)"
        printf '        уточнение: %s\n' "$listen"
        case "$listen" in
            *"не на 0.0.0.0"*)
                verdict "приложение привязано к 127.0.0.1" "3.1" \
                        "слушать 0.0.0.0 вместо 127.0.0.1" ;;
            *)
                verdict "приложение не слушает порт $PORT" "1.9 и 3. Сеть" \
                        "docker top $TARGET; проверить, что процесс дошёл до listen" ;;
        esac
        exit 1 ;;
esac
SH
chmod +x triage.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка: четыре неисправности и один исправный ═══\n'
docker run -d --name t-good -e PYTHONUNBUFFERED=1 -p 18100:8000 \
    -v "$PWD/good.py:/a.py:ro" python:3.13-slim python /a.py > /dev/null
docker run -d --name t-loopback -e PYTHONUNBUFFERED=1 -p 18101:8000 \
    -v "$PWD/loopback.py:/a.py:ro" python:3.13-slim python /a.py > /dev/null
docker run -d --name t-crash -e PYTHONUNBUFFERED=1 \
    -v "$PWD/crashing.py:/a.py:ro" python:3.13-slim python /a.py > /dev/null
docker run -d --name t-oom --memory 64m --memory-swap 64m -e PYTHONUNBUFFERED=1 \
    -v "$PWD/hungry.py:/a.py:ro" python:3.13-slim python /a.py > /dev/null
docker run -d --name t-killed python:3.13-slim sleep 300 > /dev/null
sleep 3 && docker kill t-killed > /dev/null
sleep 12
printf '    пять container'"'"'ов созданы\n'

printf '\n═══ Требования 1-3: методика на четырёх неисправностях ═══\n'

printf '\n  ── упавший при старте ──'
./triage.sh t-crash; rc_crash=$?
printf '  ── исчерпал память ──'
./triage.sh t-oom; rc_oom=$?
printf '  ── привязан к 127.0.0.1 ──'
./triage.sh t-loopback; rc_loop=$?
printf '  ── убит командой kill ──'
./triage.sh t-killed; rc_kill=$?

d_crash="$(./triage.sh t-crash | grep 'ДИАГНОЗ' | sed 's/.*ДИАГНОЗ: *//')"
d_oom="$(./triage.sh t-oom | grep 'ДИАГНОЗ' | sed 's/.*ДИАГНОЗ: *//')"
d_loop="$(./triage.sh t-loopback | grep 'ДИАГНОЗ' | sed 's/.*ДИАГНОЗ: *//')"
d_kill="$(./triage.sh t-killed | grep 'ДИАГНОЗ' | sed 's/.*ДИАГНОЗ: *//')"

uniq_count="$(printf '%s\n%s\n%s\n%s\n' "$d_crash" "$d_oom" "$d_loop" "$d_kill" \
    | sort -u | grep -c .)"
printf '\n    различных диагнозов на четыре неисправности: %s\n' "$uniq_count"
[ "$uniq_count" -eq 4 ] \
    && ok "каждая неисправность получила свой диагноз и раздел таблицы" \
    || bad "диагнозов: $uniq_count из 4"

printf '\n═══ Требование 4: один код 137 — разные диагнозы ═══\n'
printf '    %-14s %-6s %-11s %s\n' "container" "код" "OOMKilled" "диагноз"
printf '    %s\n' "──────────────────────────────────────────────────────────────────────────"
for c in t-oom t-killed; do
    info="$(docker inspect "$c" --format '{{.State.ExitCode}}|{{.State.OOMKilled}}')"
    d="$(./triage.sh "$c" | grep 'ДИАГНОЗ' | sed 's/.*ДИАГНОЗ: *//')"
    printf '    %-14s %-6s %-11s %s\n' "$c" "${info%%|*}" "${info##*|}" "${d:0:44}"
done
[ "$d_oom" != "$d_kill" ] \
    && ok "одинаковый код 137 разведён на два диагноза" \
    || bad "диагнозы совпали"

printf '\n═══ Требование 5: исправный сервис ═══\n'
./triage.sh t-good; rc_good=$?
d_good="$(./triage.sh t-good | grep 'ДИАГНОЗ' | sed 's/.*ДИАГНОЗ: *//')"
printf '    код возврата: %s\n' "$rc_good"
case "$d_good" in
    *"работает и доступен"*) ok "на исправном сервисе ложного диагноза нет (код $rc_good)" ;;
    *) bad "ложный диагноз: $d_good" ;;
esac

printf '\n═══ Требование 6: непроверенное отличается от пройденного ═══\n'
docker run -d --name t-noshell --entrypoint sleep alpine:3.21 300 > /dev/null
sleep 2
noshell_out="$(./triage.sh t-noshell 2>&1)"
echo "$noshell_out" | grep -E '\[5\]|ДИАГНОЗ' | sed 's/^/    /'
case "$noshell_out" in
    *"ПРОВЕРКА НЕ ВЫПОЛНЕНА"*)
        ok "шаг, который не удалось выполнить, помечен явно, а не засчитан" ;;
    *) bad "невыполненный шаг не отмечен" ;;
esac
docker rm -f t-noshell > /dev/null 2>&1

printf '\n═══ Проверка остановки на первом отрицательном ответе ═══\n'
steps_crash="$(./triage.sh t-crash | grep -c '^    \[[0-9]\]')"
steps_good="$(./triage.sh t-good | grep -c '^    \[[0-9]\]')"
printf '    шагов пройдено на упавшем: %s\n' "$steps_crash"
printf '    шагов пройдено на исправном: %s\n' "$steps_good"
[ "$steps_crash" -lt "$steps_good" ] \
    && ok "инструмент останавливается на первом отрицательном ответе" \
    || bad "шагов: $steps_crash и $steps_good"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"

docker rm -f t-good t-loopback t-crash t-oom t-killed > /dev/null 2>&1
cd /tmp && rm -rf /tmp/diaglab
exit "$fail"

Ожидаемый вывод:

text
═══ Подготовка: четыре неисправности и один исправный ═══
    пять container'ов созданы

═══ Требования 1-3: методика на четырёх неисправностях ═══

  ── упавший при старте ──
  Диагностика: t-crash

    [1] container создан?      да
    [2] статус?                exited (код 1, перезапусков 0)

    ДИАГНОЗ:  завершился с кодом 1 — ошибка приложения
    РАЗДЕЛ:   1. Запуск и остановка
    ДАЛЬШЕ:   docker logs --tail 50 t-crash

  ── исчерпал память ──
  Диагностика: t-oom

    [1] container создан?      да
    [2] статус?                exited (код 137, перезапусков 0)

    ДИАГНОЗ:  убит по нехватке памяти в cgroup
    РАЗДЕЛ:   1.2 и раздел 5. Ресурсы
    ДАЛЬШЕ:   docker exec/логи: найти причину роста; поднять --memory

  ── привязан к 127.0.0.1 ──
  Диагностика: t-loopback

    [1] container создан?      да
    [2] статус?                running (код 0, перезапусков 0)
    [3] строк в логах?         1
    [4] проверка здоровья?     нет
    [5] отвечает изнутри?      да
    [6] отвечает с host?       нет:Connection refused

    ДИАГНОЗ:  работает внутри, недоступен снаружи
    РАЗДЕЛ:   3.1 и 3.6
    ДАЛЬШЕ:   проверить привязку: слушает ли 0.0.0.0, а не 127.0.0.1

  ── убит командой kill ──
  Диагностика: t-killed

    [1] container создан?      да
    [2] статус?                exited (код 137, перезапусков 0)

    ДИАГНОЗ:  SIGKILL извне; возможен OOM на уровне host
    РАЗДЕЛ:   1.3
    ДАЛЬШЕ:   sudo dmesg -T | grep -i 'killed process'

    различных диагнозов на четыре неисправности: 4
  ✓ каждая неисправность получила свой диагноз и раздел таблицы

═══ Требование 4: один код 137 — разные диагнозы ═══
    container      код    OOMKilled   диагноз
    ──────────────────────────────────────────────────────────────────────────
    t-oom          137    true        убит по нехватке памяти в cgroup
    t-killed       137    false       SIGKILL извне; возможен OOM на уровне ho
  ✓ одинаковый код 137 разведён на два диагноза

═══ Требование 5: исправный сервис ═══
  Диагностика: t-good

    [1] container создан?      да
    [2] статус?                running (код 0, перезапусков 0)
    [3] строк в логах?         1
    [4] проверка здоровья?     нет
    [5] отвечает изнутри?      да
    [6] отвечает с host?       да

    ДИАГНОЗ:  сервис работает и доступен снаружи
    РАЗДЕЛ:   —
    ДАЛЬШЕ:   проблем не обнаружено

    код возврата: 0
  ✓ на исправном сервисе ложного диагноза нет (код 0)

═══ Требование 6: непроверенное отличается от пройденного ═══
    [5] отвечает изнутри?      ПРОВЕРКА НЕ ВЫПОЛНЕНА (нет python в образе)
    ДИАГНОЗ:  шаг 5 не выполнен — инструмент недоступен в образе
  ✓ шаг, который не удалось выполнить, помечен явно, а не засчитан

═══ Проверка остановки на первом отрицательном ответе ═══
    шагов пройдено на упавшем: 2
    шагов пройдено на исправном: 6
  ✓ инструмент останавливается на первом отрицательном ответе

═══ ИТОГ ═══
  все требования выполнены

Все требования выполнены.

Строки требования 4 — суть методики. Код 137 в обоих случаях, OOMKilled различается — и диагнозы получаются разные. Без второго поля оба случая слились бы в один бесполезный вывод «убит сигналом».

Три решения, определяющие качество.

Инструмент останавливается на первом отрицательном ответе. Соблазн — выполнить все проверки и выдать полный отчёт. Но у упавшего container'а шаги 3–5 бессмысленны: логи есть, здоровья нет, exec невозможен. Полный отчёт выдал бы три «проблемы» вместо одной причины. Числа в конце — 2 шага против 6 — подтверждают, что остановка работает.

Каждый диагноз называет раздел таблицы, а не только причину. «Убит по нехватке памяти» — это ответ, но не следующее действие. Ссылка на запись 1.2 и раздел 5 даёт продолжение: там перечислены проверки, различающие утечку, фрагментацию и кэш. Инструмент передаёт эстафету справочнику, а не пытается заменить его.

Шаг 5 различает три исхода, а не два. «Отвечает» и «не отвечает» — недостаточно: третий случай, когда проверку не удалось выполнить (в образе нет python), при бинарной логике был бы засчитан как отказ и дал бы ложный диагноз о неработающем приложении. Отдельная ветка выводит на урок 13.5 с sidecar.

Чего решение не делает. Проверено четыре неисправности из тридцати в таблице; разделы «Storage» и «Сборка» инструментом не покрыты — они требуют контекста, которого у автоматической проверки нет (какие данные важны, какой размер образа ожидался). Шаг 5 использует python из образа приложения; для образов без него нужен sidecar, и решение об этом сообщает, но не реализует. Диагноз «возможен OOM на уровне host» не подтверждается: для этого нужен dmesg и права root. Наконец, инструмент не различает случай, когда причин несколько, — он находит первую и останавливается, что для диагностики правильно, но не полно.

Как пользоваться таблицей

СитуацияДействие
Симптом знакомНайти запись, выполнить проверку из колонки «Проверка»
Симптом неясенПройти методику из пяти вопросов; она укажет раздел
Проверка подтвердила причинуПрименить исправление, повторить проверку
Проверка не подтвердилаВернуться к методике — гипотеза была неверна
Причин может быть несколькоИсправлять по одной, проверяя после каждой

Последняя строка важна: одновременное исправление трёх вещей не даёт знания о том, какая из них была причиной.

Контрольные вопросы

  1. Пять вопросов методики — назовите по порядку и объясните, почему порядок именно такой.
  2. Код 137 встречается в трёх записях таблицы. Чем они различаются?
  3. docker logs пуст. Две возможные причины и как их различить?
  4. Приложение отвечает изнутри container'а, но не снаружи. Первая гипотеза?
  5. Диск заполнен, docker system df показывает немного. Где искать?
  6. Restart loop со стабильным интервалом 40 секунд. О чём это говорит?
  7. Почему не следует исправлять несколько вещей одновременно?

Краткое резюме

  1. Методика из пяти вопросов сужает область до перехода к таблице.
  2. Не переходить к следующему шагу, не выполнив предыдущий.
  3. Одинаковый симптом имеет разные причины: код 137 — три, пустые логи — две.
  4. Container не создан — искать в логах daemon, а не в docker logs.
  5. Стабильный интервал restart loop указывает на таймаут, а не на случайный сбой.
  6. docker logs при unhealthy не содержит причины — она в .State.Health.Log.
  7. Работает изнутри, не работает снаружи — почти всегда привязка к 127.0.0.1.
  8. Расхождение docker system df и размера каталога — это логи.
  9. Проверка обязательна: без неё исправление становится угадыванием.
  10. Исправлять по одной причине за раз, проверяя после каждой.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: troubleshootinghttps://docs.docker.com/engine/daemon/troubleshoot/Диагностика daemon
Docker: container logshttps://docs.docker.com/engine/logging/Драйверы и их поведение
Docker: networking troubleshootinghttps://docs.docker.com/engine/network/Сетевая модель
Docker: resource constraintshttps://docs.docker.com/engine/containers/resource_constraints/Лимиты и их проявления
Docker: prunehttps://docs.docker.com/engine/manage-resources/pruning/Очистка по категориям
Linux: exit codes и сигналыhttps://man7.org/linux/man-pages/man7/signal.7.htmlСоответствие 128 + N

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление

Markdown на GitHub ↗