Главная/Проверка знаний/Проверка знаний

Debugging challenges

Двадцать сломанных конфигураций. Для каждой дан симптом — то, что видит пользователь, — и файлы, воспроизводящие проблему. Задача: найти причину и починить.

Это главный тренажёр курса и 15 % итогового балла. Причина такого веса одна: умение собрать работающую конфигурацию и умение починить сломанную — разные навыки, и второй в работе нужнее.

Как работать

ШагЧто делать
1Прочитать симптом. Не читать разбор
2Сформулировать две-три гипотезы до первой команды
3Проверять гипотезы командами, а не рассуждением
4Починить и убедиться, что симптом исчез
5Только теперь открыть разбор и сверить

Правило. Challenge засчитывается, если решён до открытия разбора. Подсмотренный разбор даёт знание конкретной поломки, но не навык расследования — а проверяется именно он.

Раздел «Методика» открывайте, если застряли более чем на 20 минут: он даёт направление, а не ответ.

Целевой результат: не менее 15 из 20 самостоятельно.

Docker на машине, где готовился курс, не установлен. Конфигурации написаны так, чтобы воспроизводить описанный симптом; вывод команд — реконструкция по механизмам из соответствующих уроков, а не запись сеанса (README).


1. Container немедленно завершается

Симптом. docker run -d возвращает идентификатор, но через секунду container'а нет в docker ps.

dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY worker.py .
CMD ["python", "worker.py"]
python
# worker.py
import time
print("обработчик запущен")
Методика

Первый вопрос: завершился с ошибкой или сделал свою работу и вышел? Их различает код возврата.

bash
docker ps -a --filter name=worker --format '{{.Status}}'
docker inspect worker --format '{{.State.ExitCode}}'
Разбор

Причина. Скрипт печатает строку и заканчивается. Container живёт ровно столько, сколько живёт процесс с PID 1 — это не «машина», которую можно «оставить работать».

text
Exited (0) 2 seconds ago
0

Код 0 — ключ. Ошибки не было; работа сделана.

Исправление — дать процессу работу:

python
import signal
import sys
import time

running = True


def on_term(signum, frame):
    global running
    running = False


signal.signal(signal.SIGTERM, on_term)
print("обработчик запущен", flush=True)
while running:
    time.sleep(1)
print("обработчик остановлен", flush=True)

Чего делать не следует. Добавить sleep infinity или tail -f /dev/null, чтобы «не падало». Container будет жив, работа не сделана, и никто об этом не узнает (урок 19.4).

Разбор: урок 4.1.


2. Python output не появляется в logs

Симптом. Приложение работает, отвечает на запросы, но docker logs пуст. После docker stop строки появляются все сразу.

dockerfile
FROM python:3.13-slim
COPY app.py .
CMD ["python", "app.py"]
Методика

Строки появляются при завершении. Значит, они где-то накапливались. Где именно и почему именно при перенаправлении?

Разбор

Причина. Python буферизует stdout блоками, когда он не терминал. В container'е вывод идёт в канал, а не в терминал, поэтому строки копятся в буфере до его заполнения или до завершения процесса.

Проверка гипотезы:

bash
docker run --rm -e PYTHONUNBUFFERED=1 образ

Если строки появились сразу — причина найдена.

Исправление в Dockerfile:

dockerfile
ENV PYTHONUNBUFFERED=1

Почему не flush=True в каждом print. Работает, но не покрывает вывод библиотек и logging. Переменная окружения действует на всё.

Чего это стоит. Буферизация ускоряет вывод; её отключение — сознательный размен производительности на наблюдаемость. Для сервиса размен верный: лог, появляющийся через час, бесполезен.

Разбор: урок 6.6.


3. Port опубликован, приложение недоступно

Симптом. docker run -p 8080:8000 отработал, docker ps показывает 0.0.0.0:8080->8000/tcp, но curl localhost:8080 даёт Connection reset by peer.

Методика

Публикация порта и наличие слушателя — разные вещи. Проверьте изнутри container'а, что именно слушает и на каком порту.

bash
docker exec app python -c "
import socket
for p in (8000, 8080):
    s = socket.socket(); s.settimeout(1)
    print(p, 'открыт' if s.connect_ex(('127.0.0.1', p)) == 0 else 'закрыт')"
Разбор

Причина, случай А. Приложение слушает не тот порт, который опубликован. -p 8080:8000 означает «порт 8080 хоста ведёт на порт 8000 container'а». Если приложение слушает 5000, публикация ведёт в пустоту.

Причина, случай Б. Приложение слушает 127.0.0.1 — см. challenge 4.

Как различить. Внутри container'а:

bash
docker exec app python -c "
import socket
s = socket.socket(); s.settimeout(1)
print('8000 изнутри:', s.connect_ex(('127.0.0.1', 8000)))"
  • соединение проходит, снаружи нет → случай Б;
  • не проходит и изнутри → случай А или приложение не запустилось.

Существенно. EXPOSE в Dockerfile ничего не публикует. Это документация: она сообщает, какой порт предполагается, и не создаёт ни одного правила.

Разбор: урок 8.3.


4. Приложение слушает только 127.0.0.1

Симптом. docker exec app curl localhost:8000 отвечает. curl localhost:8080 с хоста — нет.

python
uvicorn.run(app, host="127.0.0.1", port=8000)
Методика

Изнутри работает, снаружи нет. Что означает 127.0.0.1 внутри container'а?

Разбор

Причина. Каждый container имеет собственный network namespace (урок 17.3). 127.0.0.1 внутри — это петлевой интерфейс самого container'а, а не хоста.

Опубликованный порт направляет трафик на адрес container'а в сети Docker. Приложение, слушающее только петлю, этот трафик не видит.

Исправление:

python
uvicorn.run(app, host="0.0.0.0", port=8000)

Возражение «это небезопасно» и ответ на него. 0.0.0.0 внутри container'а означает «все интерфейсы container'а», а их немного. Наружу сервис попадает только через публикацию порта — и вот её как раз стоит ограничивать:

bash
docker run -p 127.0.0.1:8080:8000 образ

Так порт доступен с хоста и недоступен из сети.

Разбор: урок 8.3.


5. Container не видит другой service

Симптом. docker run двух container'ов; из первого ping second не разрешается.

bash
docker run -d --name first образ
docker run -d --name second образ
docker exec first getent hosts second   # пусто
Методика

Проверьте, в какой сети находятся оба:

bash
docker inspect first --format '{{json .NetworkSettings.Networks}}'
Разбор

Причина. Оба container'а попали в сеть по умолчанию bridge. В ней нет разрешения имён — только адреса.

Встроенный DNS работает в пользовательских сетях, а сеть по умолчанию сохранена ради совместимости и такого разрешения не даёт.

Исправление:

bash
docker network create app-net
docker run -d --name first --network app-net образ
docker run -d --name second --network app-net образ
docker exec first getent hosts second
text
172.18.0.3      second

В Compose это происходит само. Compose создаёт пользовательскую сеть для проекта, поэтому симптом встречается только при ручном docker run.

Разбор: урок 8.4.


6. localhost используется неправильно

Симптом. Приложение в Compose не подключается к базе: connection to server at "localhost" (127.0.0.1), port 5432 failed. При этом docker compose exec api getent hosts db отвечает.

yaml
environment:
  DATABASE_URL: postgresql://app@localhost:5432/appdb
Методика

Разрешение имён работает, а подключение нет. Значит, имя правильное, а адрес — нет. К чему относится localhost в этом контексте?

Разбор

Причина. localhost внутри container'а api указывает на сам api. База работает в другом container'е, у которого свой network namespace и свой localhost.

Исправление — обращаться по имени сервиса:

yaml
DATABASE_URL: postgresql://app@db:5432/appdb

Три места, где эта ошибка повторяется:

ГдеНеверноВерно
Строка подключенияlocalhost:5432db:5432
Проверка готовности изнутриcurl localhost:8000вернотот же container
Обращение к хостуlocalhosthost.docker.internal

Вторая строка не ошибка: healthcheck выполняется внутри того же container'а, и localhost там указывает куда надо. Различать эти случаи и есть суть.

Разбор: урок 8.4.


7. Volume имеет неверные permissions

Симптом. Приложение запущено от USER 10001 и падает при записи в том: PermissionError: [Errno 13] Permission denied: '/data/state.json'.

yaml
services:
  app:
    user: "10001:10001"
    volumes:
      - appdata:/data
volumes:
  appdata:
Методика

Кто владелец каталога внутри container'а?

bash
docker compose exec app ls -ldn /data
Разбор

Причина. Пустой именованный том при первом подключении получает владельца и права из образа — точнее, из того каталога, поверх которого он подключён. Если в образе /data не существовал или принадлежал root, том достаётся root, а процесс работает от 10001.

text
drwxr-xr-x 2 0 0 4096 Aug  4 12:00 /data

Владелец 0 — это root.

Исправление в образе — создать каталог заранее с нужным владельцем:

dockerfile
RUN mkdir -p /data && chown 10001:10001 /data
USER 10001:10001
VOLUME ["/data"]

Чего делать не следует. chmod 777 на томе. Работает, и любой процесс получает полный доступ к данным.

Тонкость. Права копируются из образа только при первом подключении пустого тома. Уже созданный том сохраняет свои права, и пересборка образа их не изменит — нужно docker volume rm.

Разбор: урок 7.5.


8. Bind mount скрывает файлы image

Симптом. Образ собран, docker run без томов работает. С -v $(pwd):/app приложение падает: ModuleNotFoundError: No module named 'app'.

Методика

Сравните содержимое каталога с монтированием и без:

bash
docker run --rm образ ls /app
docker run --rm -v "$PWD:/app" образ ls /app
Разбор

Причина. Монтирование накрывает каталог целиком. Файлы образа по этому пути становятся недоступны — они никуда не делись, но их не видно, как книгу под скатертью.

text
# без монтирования
app  requirements.txt  venv

# с монтированием
app.py  README.md

Каталог хоста не содержит того, что было в образе.

Три исправления, и выбор между ними — по задаче:

СпособКогда
Монтировать подкаталог, а не корень-v "$PWD/app:/app/app"
Ставить зависимости вне монтируемого пути/opt/venv вместо /app/venv
Анонимный том поверх монтирования-v /app/venv — оставляет содержимое образа

Второй способ обычно лучший: он устраняет саму причину, а не последствие.

Разбор: урок 7.3.


9. Dependency layer постоянно пересобирается

Симптом. Любое изменение кода — и сборка идёт три минуты вместо десяти секунд. Кэш «не работает».

dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install -r requirements.txt
CMD ["python", "-m", "app"]
Методика

Смотрите вывод сборки: где заканчиваются строки CACHED и начинается выполнение. Первая невыполненная инструкция — та, которая сбросила кэш.

Разбор

Причина. COPY . . копирует всё, включая исходники. Любое изменение любого файла меняет контрольную сумму слоя, и весь дальнейший кэш недействителен — в том числе pip install.

Исправление — разделить копирование:

dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
COPY app/ ./app/
CMD ["python", "-m", "app"]

Теперь изменение кода сбрасывает только последние два слоя.

Проверка исправления по времени, а не по числу CACHED:

bash
docker build -t проба . && touch app/main.py && time docker build -t проба .

Секунды — работает. Минуты — нет.

Вторая, менее заметная причина того же симптома — отсутствие .dockerignore. Тогда в контекст попадают .git, __pycache__, каталоги окружений, и COPY видит изменения даже там, где вы ничего не трогали.

Разбор: урок 5.5.


10. Image слишком большой

Симптом. Образ Python-сервиса весит 1.2 GB. Ожидалось около 200 MB.

dockerfile
FROM python:3.13
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y build-essential
RUN pip install -r requirements.txt
CMD ["python", "-m", "app"]
Методика

Найдите, какие слои занимают место:

bash
docker history образ --human --format 'table {{.Size}}\t{{.CreatedBy}}' | head -15
Разбор

Четыре причины, и все четыре здесь присутствуют.

ПричинаСколько даётИсправление
python:3.13 вместо -slim~700 MBpython:3.13-slim
build-essential остался в образе~200 MBMulti-stage
Кэш apt не удалён~40 MBrm -rf /var/lib/apt/lists/* в той же RUN
COPY . . без .dockerignoreзависит.dockerignore

Исправление:

dockerfile
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS deps
RUN python -m venv /opt/venv
ENV PATH=/opt/venv/bin:$PATH
RUN apt-get update && apt-get install -y --no-install-recommends build-essential \
    && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install -r requirements.txt

FROM python:3.13-slim AS runtime
ENV PATH=/opt/venv/bin:$PATH
WORKDIR /app
COPY --from=deps /opt/venv /opt/venv
COPY app/ ./app/
USER 10001:10001
CMD ["python", "-m", "app"]

Существенно про третью строку. Удаление кэша следующей инструкцией не помогает: файлы остаются в предыдущем слое. Слои только добавляются; удаление в новом слое лишь скрывает файл (урок 3.2).

Разбор: урок 3.4, урок 5.7.


11. CMD и ENTRYPOINT конфликтуют

Симптом. docker run образ --help вместо справки приложения печатает справку интерпретатора Python.

dockerfile
CMD ["python", "-m", "app"]
Методика

Что происходит с аргументами docker run, когда задан только CMD? А когда задан ENTRYPOINT?

Разбор

Причина. Аргументы docker run заменяют CMD целиком. Команда превращается в --help, а поскольку образ основан на python, выполняется python --help... либо, в зависимости от базового образа, вообще ничего.

Что заданоdocker run образ --top 3 выполнит
Только CMD--top 3 (замена)
Только ENTRYPOINTENTRYPOINT --top 3 (добавление)
ОбаENTRYPOINT --top 3 (CMD заменён)

Исправление:

dockerfile
ENTRYPOINT ["python", "-m", "app"]
CMD ["--help"]

Теперь docker run образ печатает справку, а docker run образ --top 3 передаёт аргументы приложению.

Второй симптом того же семейства — shell-форма:

dockerfile
ENTRYPOINT python -m app     # неверно

Она разворачивается в /bin/sh -c "python -m app", аргументы docker run вообще теряются, а PID 1 становится оболочкой (challenge 12).

Разбор: урок 5.4.


12. SIGTERM не обрабатывается

Симптом. docker stop занимает ровно 10 секунд. Обработчик сигнала в коде есть и при запуске скрипта напрямую работает.

dockerfile
CMD python app.py
Методика

Ровно 10 секунд — это grace period по умолчанию. Значит, сигнал не был обработан и пришёл SIGKILL. Кто получил SIGTERM?

bash
docker exec app ps -eo pid,ppid,cmd
Разбор

Причина. Shell-форма CMD разворачивается в /bin/sh -c "python app.py". PID 1 — оболочка, Python — её потомок.

text
  PID  PPID CMD
    1     0 /bin/sh -c python app.py
    7     1 python app.py

docker stop посылает SIGTERM процессу с PID 1, то есть sh. Она не пересылает сигнал потомкам. Через 10 секунд приходит SIGKILL, убивающий всё разом.

Исправление — exec-форма:

dockerfile
CMD ["python", "app.py"]
text
  PID  PPID CMD
    1     0 python app.py

Как отличить эту причину от «обработчика нет вовсе». Только проверкой PID 1: при отсутствии обработчика PID 1 был бы самим Python. Симптом одинаков — 10 секунд.

Второй источник того же. Если точка входа — скрипт-обёртка, вызывающий приложение, нужен exec:

bash
#!/bin/sh
exec python app.py     # без exec оболочка останется PID 1

Разбор: урок 4.4, урок 4.5.


13. Container получает exit code 137

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

Методика

Код 137 = 128 + 9. Кто послал сигнал 9, и почему приложение не смогло об этом сообщить?

bash
docker inspect app --format '{{.State.ExitCode}} {{.State.OOMKilled}}'
Разбор

Причина. Превышено ограничение памяти cgroup; ядро завершило процесс через OOM killer. SIGKILL не перехватывается — обработчика для него не существует в принципе, поэтому приложение физически не могло ничего записать.

text
137 true

Почему в логах приложения пусто. Запись делает ядро:

bash
dmesg | grep -i 'killed process'
docker events --filter 'event=oom' --since 24h

Как предсказать заранее — счётчики давления памяти растут задолго до отказа:

bash
docker exec app cat /sys/fs/cgroup/memory.events
text
low 0
high 0
max 4213
oom 12
oom_kill 3

Ненулевой max означает, что процесс уже упирался в предел.

Три гипотезы, и различить их можно только измерением:

ГипотезаПризнак
Утечка памятиПотребление растёт монотонно между перезапусками
Лимит заниженПотребление выходит на плато выше лимита
Всплеск на редком запросеСтабильно, скачок перед отказом

Поднять лимит, не различив их, — обычная ошибка: при утечке это лишь отодвигает отказ.

Разбор: урок 11.4, урок 17.4.


14. Healthcheck постоянно failing

Симптом. docker ps показывает (unhealthy), но curl к сервису с хоста отвечает 200.

dockerfile
HEALTHCHECK CMD curl -f http://localhost:8000/health
Методика

Healthcheck выполняется внутри container'а. Проверьте, что там происходит:

bash
docker inspect app --format '{{json .State.Health}}' | python3 -m json.tool
Разбор

Причина, случай А — curl нет в образе.

text
{
    "Status": "unhealthy",
    "Log": [{"ExitCode": 127, "Output": "OCI runtime exec failed: exec: \"curl\": executable file not found"}]
}

Код 127 — «команда не найдена». В python:*-slim и в alpine curl отсутствует.

Исправление без установки лишнего пакета:

dockerfile
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
    CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/health').status==200 else 1)"]

Причина, случай Б — нет start-period. Приложение стартует 20 секунд, проверки идут с первой, три подряд неудачи помечают container нездоровым ещё до того, как он успел запуститься.

Причина, случай В — проверка зависит от базы. Тогда отказ базы делает нездоровым приложение, и оркестратор перезапускает его — хотя перезапуск ничего не чинит (урок 11.3).

Как различить. Поле Log[].ExitCode: 127 — нет команды, 1 — проверка вернула ошибку, 124 — истёк timeout.

Разбор: урок 5.8.


15. Compose запускает приложение до готовности БД

Симптом. docker compose upapi падает с Connection refused, перезапускается, на третий раз стартует. На машине разработчика проблема «не воспроизводится».

yaml
services:
  api:
    depends_on:
      - db
  db:
    image: postgres:17-alpine
Методика

Что именно гарантирует depends_on в форме списка? Прочитайте формулировку буквально.

Разбор

Причина. depends_on в форме списка ждёт запуска container'а, а не готовности процесса внутри. PostgreSQL после старта container'а несколько секунд инициализирует кластер и соединения не принимает.

Плавающий характер — следствие: на прогретой машине база успевает, на холодной нет.

Исправление:

yaml
services:
  api:
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 5s
      timeout: 3s
      retries: 10
      start_period: 10s

Чего это не решает. Отказ базы в работе, а не при старте. depends_on действует один раз; повторы подключения в коде действуют всегда. Правильный ответ — и то и другое, а в Kubernetes останется только второе (урок 18.3).

Проверка исправления. Один успешный запуск ничего не доказывает:

bash
for i in $(seq 10); do
  docker compose down -v >/dev/null 2>&1
  docker compose up -d >/dev/null 2>&1
  sleep 15
  docker compose ps api --format '{{.Status}}'
done

Разбор: урок 9.4.


16. DNS resolution не работает

Симптом. Внутри container'а не разрешаются внешние имена: pip install падает с Temporary failure in name resolution. При этом ping 1.1.1.1 работает.

Методика

Адреса доступны, имена нет — значит, сеть работает, а разрешение имён нет. Проверьте, что видит container:

bash
docker exec app cat /etc/resolv.conf
Разбор

Причина, случай А — сеть объявлена internal.

yaml
networks:
  backend:
    internal: true

internal: true отрезает выход наружу. Внутренние имена разрешаются, внешние — нет. Это не ошибка, если так и задумано: контейнеры с данными не должны ходить в интернет.

Причина, случай Б — DNS хоста недоступен из сети Docker. Типично, когда /etc/resolv.conf хоста указывает на 127.0.0.53 (systemd-resolved): внутри container'а этот адрес указывает на сам container.

Docker обычно подменяет такой адрес, но при нестандартной настройке подмена не срабатывает.

Проверка:

bash
docker exec app cat /etc/resolv.conf
docker run --rm --dns 1.1.1.1 образ getent hosts pypi.org

Если со вторым явно заданным DNS работает — причина в разрешении, а не в сети.

Исправление для случая Б:

yaml
services:
  app:
    dns: [1.1.1.1, 8.8.8.8]

Как различить А и Б. Проверить internal у сети:

bash
docker network inspect имя --format '{{.Internal}}'

Разбор: урок 8.4, урок 8.6.


17. Disk заполнен Docker data

Симптом. На хосте кончилось место. df -h показывает 100 % на разделе с /var/lib/docker.

Методика

Место занимают четыре разных вещи, и очищаются они по-разному:

bash
docker system df -v | head -20
Разбор

Четыре источника:

ЧтоТипичная причина
ОбразыКаждая сборка оставляет предыдущую версию без тега
ТомаАнонимные тома от удалённых container'ов
Кэш сборкиРастёт неограниченно
Логи container'овДрайвер по умолчанию не ротирует

Четвёртый пункт находят реже прочих, потому что docker system df его не показывает:

bash
du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -5

Исправление логов — ротация:

yaml
logging:
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

Или в /etc/docker/daemon.json для всех container'ов сразу.

Очистка — по возрастанию опасности:

bash
docker builder prune                    # кэш сборки: безопасно
docker image prune                      # образы без тегов: почти безопасно
docker volume prune                     # тома без container'ов: ОПАСНО, это данные
docker system prune -a                  # всё, включая нужные образы

Чего не делать в эксплуатации. docker system prune -a удаляет образы, на которых можно было бы откатиться. Место освободится, а откат станет невозможен.

Разбор: урок 13.4.


18. Старые images занимают место

Симптом. docker images показывает десятки строк <none>:<none> общим объёмом в несколько десятков гигабайт.

Методика

Что такое <none>:<none> и откуда они берутся? Сравните с выводом docker images -a.

Разбор

Причина. Это образы, потерявшие тег. Сборка с тем же тегом переносит его на новый образ; старый остаётся без имени и не удаляется.

bash
docker build -t app:latest .   # первый образ получает тег
docker build -t app:latest .   # тег переходит на новый; первый становится <none>

Один docker build в день — тридцать безымянных образов в месяц.

Исправление, часть 1 — очистка:

bash
docker image prune                    # только безымянные
docker image prune -a --filter 'until=168h'   # старше недели

Исправление, часть 2 — не создавать проблему. Неизменяемые теги вместо перезаписываемых:

bash
docker build -t "app:g$(git rev-parse --short HEAD)" .

Тогда каждый образ сохраняет имя, и удалять их можно осознанно — по возрасту или по номеру сборки.

Побочная польза. Тот же приём делает возможным откат: перезаписываемый тег latest не позволяет вернуться к предыдущему образу, потому что под этим именем его уже нет (урок 14.4).

Разбор: урок 3.4.


19. Secret попал в image layer

Симптом. Аудит нашёл ключ доступа в опубликованном образе. В Dockerfile он удаляется.

dockerfile
COPY id_rsa /root/.ssh/id_rsa
RUN git clone git@github.com:org/private.git
RUN rm -rf /root/.ssh
Методика

Слои неизменяемы и только добавляются. Что делает rm в новом слое с файлом из предыдущего?

bash
docker history --no-trunc образ | grep -i ssh
Разбор

Причина. Файловая система образа — стек слоёв. Удаление в новом слое создаёт запись «этого файла здесь нет», но содержимое остаётся в нижнем слое и извлекается:

bash
docker save образ -o образ.tar
tar -xf образ.tar -C распакованное
grep -rl 'BEGIN OPENSSH PRIVATE KEY' распакованное/

Исправление — секрет, не попадающий в слои:

dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=ssh_key,target=/root/.ssh/id_rsa \
    git clone git@github.com:org/private.git
bash
docker build --secret id=ssh_key,src=$HOME/.ssh/id_rsa .

Секрет доступен во время выполнения инструкции и в слой не записывается.

Второй способ — multi-stage: клонировать в стадии сборки и копировать в итоговый образ только результат. Слой с ключом останется в промежуточной стадии, которая в образ не входит.

Что делать с уже опубликованным образом. Считать ключ скомпрометированным и отозвать его. Удаление образа из registry не помогает: он мог быть загружен кем угодно.

Разбор: урок 12.6.


20. Container запущен с избыточными privileges

Симптом. Ревью нашло privileged: true в конфигурации. Автор объясняет: «без него не работает монтирование».

yaml
services:
  backup:
    image: backup:1
    privileged: true
    volumes:
      - /:/host
Методика

Выясните, какая конкретная возможность нужна, вместо того чтобы выдавать все. Проверить можно перебором.

Разбор

Что даёт privileged: true:

Что снимаетсяПоследствие
Профиль seccompДоступны все системные вызовы
AppArmor или SELinuxОграничения путей сняты
Ограничение capabilitiesВозвращаются все 41
Скрытие устройствДоступен весь /dev

Вместе с - /:/host это эквивалентно правам root на хосте (урок 19.2).

Исправление, шаг 1 — выдать точную возможность:

yaml
services:
  backup:
    cap_drop: [ALL]
    cap_add: [DAC_READ_SEARCH]    # чтение любых файлов, без записи
    volumes:
      - /:/host:ro                # только для чтения
    read_only: true

Как найти нужную возможность. Перебором от пустого набора: запускать с cap_drop: [ALL] и добавлять по одной, пока не заработает. Обратный порядок — снимать по одной от полного набора — обычно оставляет лишнее.

Исправление, шаг 2 — усомниться в задаче. Резервное копирование всей файловой системы хоста из container'а — сомнительный приём сам по себе. Обычно правильнее запускать копирование на хосте, а container использовать только для выгрузки в хранилище.

Три конфигурации, эквивалентные root на хосте:

ЧтоПочему
privileged: trueСнимает всё
Монтирование /var/run/docker.sockПозволяет создать любой container
cap_add: [SYS_ADMIN]Монтирование и значительная часть операций root

Вторая опаснее прочих тем, что выглядит безобидно: «просто файл».

Разбор: урок 12.4.


Сводная таблица

СимптомРазделКлюч к разгадке
1Container сразу завершается04Код возврата 0
2Логи пусты до остановки06Буферизация при перенаправлении
3Порт опубликован, ответа нет08Проверить изнутри
4Работает внутри, не снаружи08127.0.0.1 в своём namespace
5Имя не разрешается08Сеть по умолчанию без DNS
6localhost не туда08Свой namespace у каждого
7Отказ в правах на томе07Владелец из образа при первом подключении
8Монтирование скрыло файлы07Накрывает каталог целиком
9Кэш сборки не работает05COPY . . до установки
10Образ слишком большой03, 05Четыре причины сразу
11Аргументы не доходят05CMD заменяется, ENTRYPOINT нет
12Остановка ровно 10 секунд04PID 1 — оболочка
13Код возврата 13711, 17Ядро, а не приложение
14Healthcheck красный05, 11Код 127 в журнале проверок
15Старт раньше базы09depends_on ждёт запуска
16Имена не разрешаются наружу08internal или DNS хоста
17Кончилось место13Логи не показываются в system df
18Образы <none>03, 14Перезаписываемый тег
19Секрет в образе12Слои только добавляются
20Избыточные привилегии12, 19Точная возможность вместо всех

Как считать результат

Решено самостоятельноОценка
18–20Отлично
15–17Проходной результат
10–14Вернуться к разделам по темам нерешённых
менее 10Пройти разделы 04, 05, 07, 08 заново

Засчитывается только решённое до открытия разбора. Считать иначе — обманывать себя, а вся система проверки курса держится ровно на этом (описание системы).

Что общего у двадцати задач

Три вещи, заметные только когда решены все.

Симптом почти никогда не указывает на причину. «Порт недоступен» может быть про приложение, про адрес привязки, про сеть или про публикацию. Половина работы — сузить область, и делается это командами, а не размышлением.

У восьми задач из двадцати причин больше одной. Задачи 3, 10, 14, 16 имеют по нескольку независимых причин с одинаковым симптомом; в задаче 10 их четыре, и все присутствуют одновременно. Найти одну и остановиться — типичная ошибка.

Проверять нужно то, что видит ядро, а не то, что говорит приложение. Задачи 13 и 7 не разрешаются чтением логов приложения вовсе: запись делает ядро, и смотреть надо dmesg, docker events, /sys/fs/cgroup (урок 13.5).


Навигация

Вернуться к системе проверки
Checkpoints
Grading rubric
Диагностическая таблица
Главное оглавление

Markdown на GitHub ↗