Проверенные факты
Утверждения курса, проверенные фактическим запуском на реальном Docker.
Стенд: Ubuntu 24.04.3, ядро 6.8.0-88-generic, Docker Engine 29.7.1, runc 1.3.6, cgroup v2, драйвер overlay2.
Дата проверки: 2026-08-04.
Каждая запись содержит команду, фактический вывод и вердикт. Там, где вердикт «опровергнуто», указано, что именно исправлено в курсе.
1. docker run — это два запроса API
docker create --name vfy1 alpine sleep 60
docker inspect vfy1 --format '{{.State.Status}} {{.State.Pid}}' # created 0
docker start vfy1
docker inspect vfy1 --format '{{.State.Status}} {{.State.Pid}}' # running 99140
Подтверждено. Между create и start container существует, Pid равен нулю.
2. runc завершается; родитель процесса — shim
docker run -d --name vfy2 alpine sleep 60
pgrep -c runc # 0
pid=$(docker inspect vfy2 --format '{{.State.Pid}}')
ps -o comm= -p "$(ps -o ppid= -p $pid)" # containerd-shim
Подтверждено. При работающем container'е процессов runc — ноль; родитель — containerd-shim.
3. Namespaces: шесть своих, user общий с хостом, time — отдельная история
docker run --rm alpine sh -c 'for n in pid net mnt uts ipc user time cgroup; do
printf "%s %s\n" "$n" "$(readlink /proc/self/ns/$n)"; done'
for n in pid net mnt uts ipc user time cgroup; do
printf "%s %s\n" "$n" "$(readlink /proc/self/ns/$n)"; done
| namespace | в container'е | на хосте | вывод |
|---|---|---|---|
| pid | 4026532674 | 4026531836 | свой |
| net | 4026532676 | 4026531840 | свой |
| mnt | 4026532671 | 4026531841 | свой |
| uts | 4026532672 | 4026531838 | свой |
| ipc | 4026532673 | 4026531839 | свой |
| cgroup | 4026532675 | 4026531835 | свой |
| user | 4026531837 | 4026531837 | общий с хостом |
| time | 4026532737 | 4026531834 | не хостовый |
Два разных container'а (alpine и python:3.13-slim) дали один и тот же time:[4026532737].
Частично опровергнуто. Курс утверждал: «Docker создаёт шесть namespaces из восьми; user и time остаются общими» — подразумевая «общими с хостом».
Верно то, что своих у container'а шесть. Неверно про time: он не хостовый, но и не свой — он общий для всех container'ов. То есть изоляции времени container не получает, но и хостовый namespace не использует.
Формулировка исправлена во всех затронутых файлах.
4. Коды возврата после docker stop — матрица
docker run -d --name vfyx ОБРАЗ python /СЦЕНАРИЙ
docker stop vfyx
docker inspect vfyx --format '{{.State.ExitCode}}'
| Сценарий | PID 1 | Время остановки | Код |
|---|---|---|---|
Обработчик SIGTERM + sys.exit(0) | python | 0 с | 0 |
| Без обработчика | python | 10 с | 137 |
Обработчик + sys.exit(143) | python | 0 с | 143 |
Без обработчика + --init | docker-init | 0 с | 143 |
Обработчик + exit(0) + --init | docker-init | 0 с | 0 |
Настоящий uvicorn с FastAPI как PID 1:
PID 1 в container: python
сервис отвечает: 200
время остановки: 1 с
КОД ВОЗВРАТА: 0
лог: INFO: Application shutdown complete.
Опровергнуто. Курс утверждал в нескольких местах: «143 — обычно штатный результат docker stop» и «143 — приложение получило SIGTERM и завершилось по действию по умолчанию, без обработчика».
Оба утверждения неверны, и второе противоречит собственному уроку курса о PID 1:
Для PID 1 ядро не применяет действие по умолчанию. Обработчика нет — сигнал игнорируется.
Отсюда: PID 1 без обработчика не может умереть от SIGTERM. Он доживает до SIGKILL и даёт 137, что и показал замер (10 секунд, код 137).
Верные утверждения
| Код | Когда |
|---|---|
| 0 | Приложение обработало SIGTERM и завершилось само. Обычный случай для правильно написанного сервиса |
| 137 | Обработчика нет: PID 1 проигнорировал SIGTERM, ядро убило его по истечении grace period |
| 143 | PID 1 умер от SIGTERM. На практике — при --init, когда tini пересылает сигнал потомку и завершается с 128+15; либо приложение явно вышло с 143 |
Практический вывод остаётся прежним и даже усиливается: код возврата непригоден как признак мягкого завершения. Различать нужно по времени (доли секунды против ровно grace period) и по записям в журнале.
5. Copy-up копирует файл целиком
docker run --name vfysz alpine sh -c 'dd if=/dev/zero of=/f bs=1M count=8'
docker inspect --size vfysz --format 'SizeRw={{.SizeRw}}' # SizeRw=8392704
Подтверждено. 8 МиБ записано — записываемый слой вырос на 8 392 704 байта.
Замечание по методике: docker inspect без флага --size возвращает <nil> для SizeRw. В курсе команда приведена с флагом.
6. EXPOSE ничего не публикует
docker image inspect vfyexp --format '{{json .Config.ExposedPorts}}' # {"8000/tcp":{}}
docker port vfye # пусто
Подтверждено. Запись в метаданных образа есть, публикаций — ноль.
7. Удалённый файл извлекается из слоя
RUN echo SECRET-STRING-42 > /secret && rm /secret
docker run --rm vfysec ls /secret # No such file or directory
docker save vfysec -o img.tar && tar -xf img.tar -C unpacked
grep -ra 'SECRET-STRING-42' unpacked/ # найдено
Подтверждено. Файла в готовом образе нет, содержимое извлекается из слоя.
8. Имена container'ов и тегов — только ASCII
docker run --name проба alpine
Error response from daemon: Invalid container name (проба),
only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed
docker build -t проба .
ERROR: failed to build: invalid tag "проба": invalid reference format
Новый факт, в курсе отсутствовавший. Демон отвергает кириллицу в именах container'ов, образов и тегов. Все примеры курса, использовавшие русские имена, не работали бы.
Исправлено; в справочники добавлено предупреждение.
9. fastapi run ломает машинно разбираемый лог
docker run -d --name x ОБРАЗ # ENTRYPOINT ["fastapi", "run", ...]
docker logs x
⚡️ Starting FastAPI in production mode
🐍 Using import string: app.main:app
🌐 Server started at http://0.0.0.0:8000
Documentation at http://0.0.0.0:8000/docs
Logs:
INFO: Started server process [1]
INFO: 127.0.0.1:50536 - "GET /healthz HTTP/1.1" 200 OK
{"ts": "...", "level": "info", "logger": "eventapi", ...}
Замер: fastapi run — 7 не-JSON строк из 10. Тот же образ с python -m uvicorn — 0 из 9.
Опровергнуто. Курс утверждал в README примеров: «Structured logging — каждая строка валидный JSON» и «все строки — JSON, включая строки uvicorn». В container'е это неверно.
Две причины, и вторая неочевидна:
fastapi runпечатает декоративный баннер с эмодзи до импорта приложения.- Он перенастраивает логирование uvicorn после импорта, отменяя программную настройку. Поэтому строки
INFO: Started server processостаются в формате uvicorn — хотя при запуске черезpython -m uvicornони становятся JSON.
Второй пункт объясняет, почему локальный замер этого не показал: локально запускался python -m uvicorn, а образ использует fastapi run.
Что исправлено. Точка входа в примерах заменена на python -m uvicorn; после пересборки оба дают 0 не-JSON строк. В уроке 6.10 добавлен разбор размена: fastapi run официально рекомендован и удобен человеку, но несовместим со сборщиком логов.
10. Размеры образов измерены
| Образ | Размер |
|---|---|
compose-stack | 73 MB |
fastapi-basic, production-fastapi | 66 MB |
pytest-container, python-cli | 45 MB |
flask-basic | 44 MB |
worker-redis | 43 MB |
hello-python, local-registry | 41 MB |
Первая редакция этой записи была неверной, и это стоит записать. Я измерил docker image inspect --format '{{.Size}}', получил 66 MB и записал: «оговорка про 200 MB опровергнута». Число верное, величина — не та.
Под containerd image store (умолчание Docker 29) две команды возвращают разные величины:
| Команда | Величина | fastapi-basic |
|---|---|---|
docker images --format '{{.Size}}' | DISK USAGE | 293 MB |
docker image inspect --format '{{.Size}}' | CONTENT SIZE | 68.5 MB |
docker save \| wc -c даёт те же 69 MB — то есть inspect считает сжатые blob'ы, а не занятое место.
Ориентир «менее 200 MB» из старых руководств — про занятое место, и по нему эталонное решение проекта 2 не проходит: 293 MB. Более того, порог недостижим в принципе: python:3.13-slim сам занимает 178 MB, оставляя приложению 22 MB. Отказ от fastapi[standard] даёт 247 MB — измерено, но порога всё равно не хватает.
Исправлено: в урок 3.4 добавлен разбор двух чисел; ориентир проекта 2 переписан на «меньше 350 MB по DISK USAGE»; в рубрике, production-checklist и системе оценивания указано, какой командой мерить.
Все образы запускаются от 10001:10001.
11. Уязвимости приходят из базового образа, а не из кода
trivy image --severity HIGH,CRITICAL ОБРАЗ
syft ОБРАЗ -o syft-json
| Образ | HIGH/CRITICAL |
|---|---|
production-fastapi | 23 |
hello-python | 23 |
python-cli | 23 |
Одинаковое число у всех трёх — потому что все находки в пакетах Debian базового образа: bsdutils, gzip, libacl1, libblkid1. Ни одной в коде приложения или в пакетах Python.
Состав по syft для production-fastapi: 145 пакетов — 87 deb, 45 python, 13 binary.
Подтверждено, и с числами. Курс утверждал: «состав из requirements.txt не видит системных пакетов базового образа, а уязвимости чаще находят именно там». Здесь все 23 находки — в тех самых 87 deb-пакетах, которых нет в requirements.txt.
12. Что сборка примеров показала сразу
| Пример | Итог |
|---|---|
| 9 из 12 | Собираются без правок |
ci-pipeline | Падал: digest базового образа был выдуманным. Заменён на настоящий |
multistage-uv | Требует make lock — задокументировано в его README |
debug-scenarios | Общего Dockerfile нет: по одному на каждый из 6 сценариев |
13. Kubernetes: проверено на настоящем кластере
Кластер kind v1.36.1 из трёх узлов (control-plane + 2 worker), плагин сети kindnet.
| Утверждение | Результат |
|---|---|
| Container'ы pod'а делят сетевой namespace | ✓ net:[4026533202] у обоих |
| PID namespace у каждого свой по умолчанию | ✓ 4026533267 против 4026533270 |
runAsNonRoot отвергает образ с именем пользователя | ✓ CreateContainerConfigError: container has runAsNonRoot and image will run as root |
Классы QoS по requests/limits | ✓ Guaranteed, Burstable |
| Secret — base64, не шифрование | ✓ 0J7Rh9C1… → ОченьСекретно42 обычным base64 -d |
| Pod принадлежит ReplicaSet, а не Deployment | ✓ ownerReferences[0].kind = ReplicaSet |
14. NetworkPolicy: утверждение курса требует оговорки
# до политики
wget -qO- http://target # <!DOCTYPE html> — связь есть
kubectl apply -f deny-all.yaml
# после
wget -qO- http://target # пусто — связь закрыта
Уточнено. Курс утверждал: «NetworkPolicy не действует без поддержки плагином сети; правило может быть создано и молча не работать».
На kindnet политика подействовала: трафик закрылся. То есть плагин по умолчанию в kind NetworkPolicy поддерживает.
Утверждение верно как предупреждение о возможном режиме отказа, но неверно как описание обычного положения дел. Формулировка исправлена: вместо «не действует» — «может не действовать, и это нужно проверять», с приведённой командой проверки.
Это важнее исходного утверждения: полезен не факт, а способ его установить для своего кластера.
15. ReadWriteOnce: механизм отказа не тот, что был описан
класс хранения: standard (rancher.io/local-path, WaitForFirstConsumer)
first → Running на vfy-worker; PV получил nodeAffinity → vfy-worker
second → принудительно на vfy-worker2 → Pending
FailedScheduling: 0/3 nodes are available:
1 node(s) didn't match PersistentVolume's node affinity
Подтверждено по сути, уточнено по механизму. Курс утверждал: «второй pod не стартует — Multi-Attach error».
Фактически отказ наступает раньше и с другим сообщением. Режимов три, и они зависят от класса хранения:
| Хранилище | Что происходит | Когда обнаруживается |
|---|---|---|
| Узловое (local-path, hostPath) | FailedScheduling: не совпал nodeAffinity тома | При планировании — pod даже не назначается |
| Сетевое (EBS, Cinder) | Multi-Attach error: том уже подключён к другому узлу | При подключении — pod назначен, но не стартует |
| Тот же узел | Оба pod'а работают, два процесса пишут в один файл | Не обнаруживается вовсе |
Третья строка — та самая, которую курс называл опаснейшей, и она подтвердилась случайно: первая редакция проверки навесила метку после создания pod'ов, оба попали на один узел и оба запустились. Ровно то, о чём предупреждает урок.
16. Сценарии отладки воспроизводятся
resources/examples/debug-scenarios/check.sh — все шесть:
| # | Симптом | Факт |
|---|---|---|
| 01 | Container завершается сам | код 0 |
| 02 | Логи пусты при работающем процессе | строк 0 |
| 03 | Изнутри отвечает, снаружи нет | изнутри 200, снаружи нет ответа |
| 04 | Остановка занимает grace period | 11 с, обработчик не сработал |
| 05 | Приложение стартует раньше базы | 4 отказа подключения |
| 06 | Отказ в правах на томе | Permission denied |
Замечание. Скрипт пришлось исправить: имена container'ов были кириллическими — см. запись 8.
17. Конвейер: три состояния работают
Прогон ci-pipeline с установленными trivy и syft:
линтер пройдено
тесты пройдено
сборка пройдено
проверка образа пройдено
сканирование ПРОВАЛЕНО (1)
состав (SBOM) пройдено
пройдено: 5, провалено: 1, не проверено: 0
Подтверждено главное свойство: когда инструменты есть, не проверено: 0. Когда их не было — не проверено: 4. Именно это различение и является смыслом устройства.
Провал сканирования правилен: 23 HIGH/CRITICAL в python:3.13-slim — настоящие.
Три дефекта конвейера, найденные этим прогоном
| Дефект | Почему появился |
|---|---|
Тест «нет docker» подставлял PATH=/usr/bin:/bin | Сломался в день, когда docker поставили в /usr/bin |
Пустой PATH во второй редакции | Убрал заодно python3 и sh, нужные самим скриптам |
| «Нет оболочки» считалось провалом | У образов на базе *-slim она есть по устройству |
| Кэш из registry валил всю сборку | Кэш — ускорение, а не условие сборки |
Правильная имитация отсутствия инструмента — каталог со ссылками на все утилиты, кроме проверяемой. Две неверные редакции до этого показывают, насколько легко подменить «инструмента нет» на «системы нет».
18. Абсолютные пути машины автора
grep -rn "/home/<автор>" --include=*.md .
37 вхождений в 11 файлах — в основном cd /home/.../resources/examples/… в блоках команд. Такую команду нельзя выполнить у себя.
Найдено при разборе блоков «Ожидаемый вывод», не глазами. Исправлено на пути относительно корня курса; в валидатор добавлено правило.
Правило пришлось сузить: первая редакция ловила любой /home/* и дала 8 ложных срабатываний на /home/appuser — законном пути внутри container'а. Ищется теперь только фактический домашний каталог.
19. GraphDriver исчез — но курс это уже покрывал
$ docker info --format '{{.Driver}}'
overlayfs
$ docker info | grep driver-type
driver-type: io.containerd.snapshotter.v1
$ docker inspect имя --format '{{.GraphDriver.Name}}'
template parsing error: map has no entry for key "GraphDriver"
На Docker 29.7.1 ключа GraphDriver в docker inspect нет: образы хранит containerd, а не драйвер демона.
Это НЕ находка, и важно сказать прямо. Разбирая расхождение, я сперва счёл его дефектом курса и уже вписал сюда как таковой. Проверка показала обратное: урок 17.5 содержит раздел «Что меняется при containerd image store» с таблицей сравнения, а скрипт урока 3.2 написан с запасной ветвью:
UPPER="$(docker inspect cow-demo --format '{{.GraphDriver.Data.UpperDir}}')"
echo "upperdir: ${UPPER:-(недоступен при containerd image store)}"
if [ -n "$UPPER" ]; then
...
Расхождение с «Ожидаемым выводом» возникло потому, что блок показывает ветвь для классического overlay2, а машина пошла по второй. Курс верен; неверна была моя первая трактовка.
Что прогон всё же добавил — подтверждение, что запасная ветвь срабатывает, и точные замены:
| Задача | Команда | Проверено |
|---|---|---|
| Что записано в слой | docker diff имя | A /f |
| Размер записываемого слоя | docker inspect --size имя --format '{{.SizeRw}}' | 8192 |
Настоящие lowerdir/upperdir | docker exec имя grep -m1 overlay /proc/self/mountinfo | /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/540/fs |
Путь снимка стоит запомнить: /var/lib/docker/overlay2/ → /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/.
Урок из самого этого эпизода: расхождение ожидаемого с фактическим — это ещё не дефект. Половина расхождений в калибровочном прогоне оказалась sudo-блоками и различиями формата BuildKit между версиями. Разбирать нужно каждое, а не считать все ошибками.
20. Прогон блоков «Ожидаемый вывод»
Написан verify/51-run-blocks.py: извлекает пары «команда → ожидаемый вывод», выполняет во временном каталоге и сверяет.
Первый прогон был бессмысленным: прогонщик запускался без группы docker, и каждая команда получала permission denied. Массовые расхождения ничего не значили. Это тот же класс ошибки, что и остальные шесть моих сломанных проверок — проверка, дающая правдоподобный неверный результат.
Устройство прогонщика продиктовано тремя ограничениями:
| Ограничение | Решение |
|---|---|
| Блоки внутри урока зависимы | Файл прогоняется целиком, в одном рабочем каталоге |
| Часть вывода нестабильна (время, адреса, digest) | Такие строки исключаются из сверки, а не подгоняются |
| Часть команд разрушительна | Не выполняются никогда, отмечаются отдельно |
Калибровочный прогон на четырёх файлах: 11 совпало, 22 разошлось. Разбор расхождений дал одну настоящую находку (запись 19), остальные — команды под sudo и различия в формате вывода BuildKit между версиями.
Полный прогон 402 блоков не выполнен. Прогонщик готов и откалиброван; на прогон и разбор требуется отдельная сессия.
21. Проект 1: команды чек-листа не давали заявленного вывода
$ docker run --rm -i logstat < sample.jsonl
usage: logstat [-h] [--group-by ПОЛЕ] [--top N] ...
$ docker run --rm -i logstat --top 3 < sample.jsonl
status записей доля
─────────────────────────
200 2 66.7%
Опровергнуто. CHECKLIST.md и TASK.md проекта 1 в одиннадцати командах читали стандартный ввод без аргументов. Образ задаёт ENTRYPOINT ["logstat"] и CMD ["--help"]: без аргументов печатается справка, stdin не читается.
Из-за этого же не срабатывала проверка кода 2: LOGSTAT_TOP=ноль давал 0, потому что --help завершается до разбора конфигурации.
Показательно, что рабочий пример python-cli делает это правильно — там во всех командах с stdin есть аргумент. Расхождение возникло только в проекте.
Исправлено: во все одиннадцать команд добавлен аргумент, в CHECKLIST.md добавлена оговорка о причине. После правки чек-лист даёт 20 подтверждений из 20.
22. RedisQueue: код, не выполнявшийся ни разу
Redis 8 в container'е, 6 тестов — все проходят.
async def test_task_moves_to_processing_list(q):
"""Задача не теряется между извлечением и подтверждением."""
await q.push({"id": 42})
item = await q.pop(timeout=2)
assert item == {"id": 42}
assert await q.size() == 0 # из очереди ушла
assert await q._r.llen("vfy:queue:processing") == 1 # но НЕ потеряна
Подтверждено. Главное свойство BLMOVE — задача перемещается в список «в работе» атомарно — работает. Проверены также порядок FIFO, истечение времени ожидания на пустой очереди и удаление при подтверждении.
Утверждение курса о BLMOVE было взято из документации и помечено как непроверенное. Теперь проверено; пометка снята.
23. .dockerignore исключал то, что копирует стадия test
$ docker build --target test -t fastapi-basic .
- CopyIgnoredFile: Attempting to Copy file "tests" that is excluded by .dockerignore (line 23)
ERROR: failed to compute cache key: "/tests": not found
Опровергнуто, и это оказалось системным. .dockerignore — один фильтр на всю сборку; стадии его не переопределяют. Пять рабочих примеров из шести исключали tests (а два — ещё и requirements-dev.txt) и потому никогда не собирали свою стадию test:
| Пример | До | После |
|---|---|---|
compose-stack | отказ | 9 пройдено |
fastapi-basic | отказ | 6 пройдено |
production-fastapi | отказ | 63 пройдено |
python-cli | отказ | 9 пройдено |
ci-pipeline | отказ (нет git) | пройдено |
pytest-container | собиралась | собирается |
Собирались только стадии runtime — оттого дефект и дожил до прогона.
Тот же дефект нашёлся в четырёх местах текста: уроки 5.5 (упражнение), 6.3, 6.8, 6.10 и SOLUTION.md проекта 2. В трёх из них «Ожидаемый вывод» содержал строку «тесты прошли», которую получить было нельзя.
Исключение ничего и не давало: ни одна стадия runtime в курсе не использует COPY . . — все копируют только app/. Проверено: после правки тестов в итоговых образах по-прежнему нет.
В урок 5.1 добавлен раздел с воспроизведением. Заодно выяснилось, что число в предупреждении — строка Dockerfile, а не .dockerignore: при tests на первой строке .dockerignore сообщение говорит «line 8», и восьмая строка — это COPY tests/ ./tests/.
24. pytest и python -m pytest — разные sys.path
$ docker build --target test -t compose-stack .
tests/test_stack.py:11: in <module>
from app.settings import Settings, read_secret
E ModuleNotFoundError: No module named 'app'
Опровергнуто. Всплыло сразу после исправления №23 — в двух примерах и в упражнении урока 6.10. Консольная команда pytest не кладёт текущий каталог в sys.path; python -m pytest кладёт. Примеры, использовавшие python -m pytest (production-fastapi), собирались сразу.
Курс этого различия не объяснял: в уроке 6.12 обходной путь — sys.path.insert прямо в тесте. В исправленные места добавлен комментарий с причиной.
25. Проект 2: три дефекта, видимых только из образа
Чек-лист прогнан целиком: verify/61-project2.sh, 27 подтверждений из 27. Образ собирается из блоков SOLUTION.md — проверяется ровно то, что напечатано в курсе.
До правок не выполнялись три требования из одиннадцати:
1. Стадия test не собиралась — .dockerignore, см. №23.
2. Точка входа fastapi run отменяла настройку логирования. Требование 5 («каждая строка — объект JSON»):
| Точка входа | Строк | Не JSON |
|---|---|---|
fastapi run | 20 | 15 |
python -m uvicorn app.main:app | 15 | 0 |
Тест test_uvicorn_logs_are_json запускал python -m uvicorn напрямую и проходил, тогда как образ шёл через fastapi run.
3. Требование 4 не выполнялось совсем. Функция load(), отвергающая опечатку в имени переменной, была написана, покрыта тестом и не вызывалась: create_app создавал Settings().
# было: сервис стартует, опечатка проигнорирована (killed by timeout)
$ timeout 20 docker run --rm -e EVENTAPI_LOG_LEVE=info eventapi; echo "код: $?"
код: 124
# стало
$ docker run --rm -e EVENTAPI_LOG_LEVE=info eventapi; echo "код: $?"
app.settings.UnknownSettingError: неизвестные переменные окружения: EVENTAPI_LOG_LEVE
код: 1
Добавлен тест test_application_itself_rejects_typo, входящий в приложение той же дверью, что и точка входа. Проверено, что он падает на старой редакции (1 failed, 63 passed) и проходит на новой (64 passed, покрытие 96 %).
Общее у всех трёх: тест проверял тот путь, который сам и вызывал. Образ входил в код иначе.
26. Упражнение урока 6.10: пять дефектов в одном блоке
Прогон упражнения целиком дал пять расхождений с напечатанным «Ожидаемым выводом»:
| № | Что | Как проявилось |
|---|---|---|
| 1 | .dockerignore исключал tests | "/tests": not found вместо «тесты прошли» |
| 2 | pytest вместо python -m pytest | ModuleNotFoundError: No module named 'app' |
| 3 | ps -o args= -p 1 | OCI runtime exec failed — в slim нет ps |
| 4 | CMD ["fastapi", "run", …] | баннер с эмодзи в выводе требования 5 |
| 5 | Проверка логов через grep '^{' | не могла провалиться: не-JSON строки отбрасывались до подсчёта |
Пятое — самое неприятное: проверка требования «все строки JSON» сама отфильтровывала строки, которые должна была найти. Заменена на подсчёт всех строк:
═══ Требование 6: JSON-логи, включая Uvicorn ═══
строк: 10, не JSON: 0
После правок упражнение проходит целиком; блок «Ожидаемый вывод» заменён на фактический.
27. make lock в примере multistage-uv не работал
$ make lock
error: Failed to discover managed Python installations
Caused by: Failed to find any common binaries to determine libc from:
/bin/sh, /usr/bin/env, /bin/dash, /bin/ls
Опровергнуто. Образ ghcr.io/astral-sh/uv:0.12.0 содержит только бинарник uv — ни оболочки, ни libc, по которой uv определяет платформу. В Dockerfile это работает (COPY --from=… кладёт бинарник в python:3.13-slim), а в Makefile тот же образ запускался отдельно.
Без uv.lock не собирался и сам пример: uv sync --locked требует lock-файла намеренно.
Исправлено: uv ставится внутрь python:3.13-slim и запускается там же. Заодно добавлены --user "$(id -u):$(id -g)" и HOME=/tmp — иначе uv.lock создавался с владельцем root (урок 6.7).
Проверено полностью: make lock → make build → make run печатает результат, образ 190 MB на диске / 45 MB контента; make compare показывает 190 MB против 206 MB у сборки на pip.
Ещё один дефект нашёлся тут же: цель compare фильтровала вывод как grep '^ multistage-uv', опираясь на два ведущих пробела в --format. Docker их не печатает, grep не находил ничего, и make завершался с ошибкой.
28. Проект 3: стек не поднимался вовсе
Чек-лист прогнан целиком: verify/62-project3.sh, 35 подтверждений из 35, включая десять запусков подряд. Стек собирается из блоков SOLUTION.md проектов 2 и 3.
Первый запуск дал шесть дефектов; три из них не давали стеку стартовать.
1. PGPASSWORD_FILE не читал никто.
$ docker compose up -d
✔ Container eventstack-db-1 Healthy
✘ Container eventstack-migrate-1 Error
$ docker compose logs migrate | tail -1
миграции: база недоступна за 30.0 с: connection failed: connection to server
at "172.21.0.2", port 5432 failed: fe_sendauth: no password supplied
libpq знает PGPASSWORD и PGPASSFILE (файл формата .pgpass), но не PGPASSWORD_FILE. Соглашение «<ИМЯ>_FILE» реализует entrypoint образа postgres — для себя. Клиент обязан читать файл сам.
Это самая поучительная находка всей проверки: соглашение одного образа было принято за механизм платформы. Добавлен app/secrets.py с export_pgpassword(), вызываемый из всех трёх точек входа.
2. Settings не знала о EVENTAPI_DATABASE_URL и EVENTAPI_REDIS_URL.
$ docker compose logs api | tail -1
app.settings.UnknownSettingError: неизвестные переменные окружения:
EVENTAPI_DATABASE_URL, EVENTAPI_REDIS_URL; известны: host, log_level, ...
Отказ правильный: строгая проверка имён из проекта 2 сработала как задумано. Ошибка была в том, что настройку добавили в compose.yaml, а в модель — нет. В решение добавлен шаг 4a: что именно меняется в settings.py и main.py.
3. Стадия test прогоняла тесты, которым нужна база.
ERROR: Coverage failure: total of 20 is less than fail-under=75
FAIL Required test coverage of 75% not reached. Total coverage: 20.32%
У стадии сборки нет доступа к сети Compose — тесты пропускались, и покрытие складывалось из одних пропусков. Ворота перенесены в сервис tests, где база есть.
Там же нашлось, что --cov=app считает и файлы проекта 2 (40 % и провал порога), а у сервиса tests не было EVENTAPI_REQUIRE_DB=1 — то есть недоступная база дала бы «33 skipped» и зелёный прогон. После правок: 80 %, порог 75 % достигнут.
Уточнение про internal: true. Проверено, что защита действует не на все сервисы:
$ docker compose exec worker python -c "socket.create_connection(('1.1.1.1', 443))"
socket.gaierror: [Errno -3] Temporary failure in name resolution
$ docker compose exec api python -c "socket.create_connection(('1.1.1.1', 443))"
api: выход есть
internal — свойство сети, а не сервиса: api состоит и в frontend, и выход у него законный. Формулировка в чек-листе уточнена.
29. Тот же дефект нашёлся в примере production-fastapi
Прогон verify/30-behaviour.sh повис на проверке опечатки в имени переменной — и это оказалось сообщением, а не помехой:
$ timeout 20 docker run --rm -e EVENTAPI_LOG_LEVE=info production-fastapi
{"ts": "...", "logger": "uvicorn.error", "message": "Application startup complete."}
код: 124 ← убит по таймауту: сервис работал
Опровергнуто. app/main.py:78 содержал settings = settings or Settings() — ровно тот же дефект, что в эталонном решении проекта 2 (запись 25). Функция load(), отвергающая неизвестные имена, была написана, покрыта тестом test_unknown_variable_is_rejected и не вызывалась приложением.
Совпадение не случайно: пример и решение писались по одному образцу, и ошибка размножилась вместе с ним. Проверены остальные примеры — production-fastapi единственный, где есть check_env, так что больше копий нет.
После правки:
$ docker run --rm -e EVENTAPI_LOG_LEVE=info production-fastapi; echo "код: $?"
app.settings.UnknownSettingError: неизвестные переменные окружения: EVENTAPI_LOG_LEVE
код: 1
Добавлен test_application_itself_rejects_typo (64 теста, покрытие 96 %). В сам прогонщик добавлен timeout: проверка, способная повиснуть, не отличается от непройденной — без него отказ выглядел бы как зависший стенд.
Заодно в прогонщике исправлены две собственные ошибки: он искал в stderr слова «пропущено|прочитано», которых пример не печатает (там «всего уникальных слов: N»), и запускал python-cli без аргумента — то есть упирался в CMD ["--help"], как чек-лист проекта 1 (запись 21). После правок: 13 подтверждений из 13.
30. Полный прогон блоков «Ожидаемый вывод»
Прогнаны все блоки курса, где за командой следует блок вывода. Прогонщик — 51-run-blocks.py, разборщик расхождений — 52-triage.py.
{"совпало": 250, "разошлось": 305, "ошибка": 37,
"пропущено": 21, "требует_root": 56, "интерактив": 8}
Разбирать 305 расхождений глазами по одному нельзя — и не нужно: у большинства причина видна из самого вывода. Разборщик раскладывает их по причинам и печатает остаток, который читается целиком.
| Корзина | Шт. | Почему это не дефект |
|---|---|---|
| зависит от предыдущего блока | 81 | образ или файл не создан: предыдущий блок пропущен как sudo/разрушительный |
| различаются только числа | 54 | строки совпадают, если не смотреть на числа |
| нет команды на стенде | 44 | hadolint, trivy, syft и подобные |
| вывод конкретной машины | 32 | df, nproc, hostname, docker version |
| вывод целиком нестабилен | 18 | сверять нечего |
| формат вывода BuildKit | 7 | различие версий |
| реестр недоступен | 1 | сеть |
| остаток | 68 | прочитан целиком |
Из 68 остатка дефектами оказались шесть; остальные — каскад от пропущенных блоков, ширина колонок и переменные оболочки, не переживающие границу блока (прогонщик выполняет каждый блок отдельным bash -c).
Правило подтвердилось на цифрах: расхождение — ещё не дефект. 305 расхождений дали 6 дефектов, то есть 2 %. Но эти 2 % включают два неверных утверждения о безопасности, которые нельзя было найти иначе.
Разборщик тоже проверяется: 52-triage.py --self-test прогоняет десять заведомых случаев. Корзина без сверки — та же подгонка ожидания под факт, поэтому из каждой печатается образец.
31. Два неверных утверждения о безопасности
--cap-add=SYS_ADMIN не включает mount.
$ docker run --rm --cap-add=SYS_ADMIN alpine sh -c 'mount -t tmpfs none /mnt/test'
mount: mounting none on /mnt/test failed: Permission denied
$ docker run --rm --cap-add=SYS_ADMIN --security-opt apparmor=unconfined alpine \
sh -c 'mount -t tmpfs none /mnt/test && echo смонтировано'
смонтировано
Урок утверждал «Заработало. Одна capability изменила результат». На Ubuntu операцию запрещает AppArmor независимо от capabilities. Переписано: ограничения складываются, снятие одного ничего не гарантирует.
--cap-drop=NET_BIND_SERVICE не защищает привилегированные порты.
$ docker run --rm --cap-drop=NET_BIND_SERVICE python:3.13-slim python -c "
import socket; socket.socket().bind(('0.0.0.0', 80)); print('порт 80 занят успешно')"
порт 80 занят успешно
Урок ожидал PermissionError. Причина найдена:
$ sysctl net.ipv4.ip_unprivileged_port_start # хост
net.ipv4.ip_unprivileged_port_start = 1024
$ docker run --rm alpine sysctl net.ipv4.ip_unprivileged_port_start # container
net.ipv4.ip_unprivileged_port_start = 0
Docker опускает границу привилегированных портов до нуля, и capability становится ни на что не влияющей. С --sysctl net.ipv4.ip_unprivileged_port_start=1024 заявленный PermissionError появляется. Проверено и следствие: порт 80 занимается под uid 10001 с --cap-drop=ALL.
Это опаснее первого: читатель мог бы счесть, что отбор capability что-то защищает.
32. ps нет в python:*-slim, и это давало правдоподобный ноль
$ docker exec flask sh -c 'ps -o args= | grep -c "[g]unicorn"'
sh: 1: ps: not found
0
Опровергнуто. Команда печатает 0 — не ошибку, а неверное число. Через /proc тот же container даёт 3.
Найдено в семи местах: уроки 6.9 (четыре блока), 6.10, 6.11, 11 (упражнение), 5.4 и 4.5. В alpine ps есть, но busybox не знает -p:
$ docker exec var-pid ps -o args= -p 1
ps: unrecognized option: p
Везде заменено на чтение /proc. В шаблоне подсчёта используется [g]unicorn: иначе команда находит саму себя — её текст тоже лежит в /proc/PID/cmdline.
Заодно уточнилось поведение --init: zombie с PPID 1 он собирает, а оставленного самим приложением (PPID 7) — нет. Три прогона подряд дают один и тот же результат, так что это свойство, а не случайность. Урок обещал ноль zombie; теперь показывает один и объясняет, чей он.
33. ARG до FROM подставляет значение базового образа
$ docker build --no-cache -f Dockerfile.scope . 2>&1 | grep 'объявления'
#5 0.288 БЕЗ объявления: [3.13.14]
#6 0.384 ПОСЛЕ объявления: [3.13]
Опровергнуто. Урок обещал пустую строку: «переменная вне области видимости стадии». Подставилось 3.13.14 — значение, которого в Dockerfile нет вовсе. Источник:
$ docker run --rm python:3.13-slim env | grep PYTHON_VERSION
PYTHON_VERSION=3.13.14
ARG до FROM в стадию действительно не попадает — но имя совпало с ENV базового образа, и подставился он. Пустая строка получилась бы на alpine, где такой переменной нет: поведение зависит от базы.
Там же исправлено обратное утверждение про приоритет: «ENV перекрывает ARG» верно только при порядке ARG → ENV. Измерено три порядка:
| Порядок | Что видит RUN | Что в образе |
|---|---|---|
ENV → ARG | значение --build-arg | значение ENV |
ARG → ENV | значение ENV | значение ENV |
только ARG | значение --build-arg | ничего |
Побеждает объявленная последней.
34. Мелочи, найденные тем же прогоном
tail на ошибке docker run ловит подсказку, а не ошибку. Docker 29 печатает ошибку, пустую строку и Run 'docker run --help' for more information. Четыре блока с 2>&1 | tail -1|-2 показывали ожидаемый текст ошибки, а давали подсказку. Заменено на head -1. Заодно исправлен текст: docker: conflicting options: cannot specify both --restart and --rm — сообщение клиента, без Error response from daemon.
Устаревшие числа. python:3.13-slim пересобран на Debian trixie: 9 шагов истории вместо 12, 178 MB вместо 126 MB, база 87.4 MB вместо 126 MB. Обновлено в шести местах; добавлена оговорка, что смотреть нужно на доли.
RUN chown -R стоит 22 MB, а не 17.
Строки формата. docker images --format ' {{...}}' отбрасывает ведущие пробелы — два блока ожидали их в выводе.
35. Сплошная проверка разделов 01–04
Разделы 01–04 (32 файла, 650 bash-блоков, 10 dockerfile-блоков) проверены четырьмя способами вдобавок к прогону блоков «Ожидаемый вывод»:
| Проверка | Инструмент | Результат |
|---|---|---|
Сборка каждого dockerfile-блока | 72-dockerfiles.py | 8 собрано, 2 иллюстрации, 0 отказов |
| Флаги Docker против установленного Docker | 71-flags.py | 752 употребления, 2 дефекта |
| Блоки без «Ожидаемого вывода» | 73-silent-blocks.py | 279 выполнено, 76 с ненулевым кодом → 6 дефектов |
| Блоки с «Ожидаемым выводом» | 51-run-blocks.py | см. запись 30 |
Третья проверка — новая по смыслу: прогонщик 51 такие блоки пропускает, сверять не с чем. Но выполнить их можно, и критерий есть — код возврата.
Найдено 7 дефектов, из них три системных.
36. docker pull принимает ровно один образ
$ docker pull -q python:3.13-slim alpine:3.21
docker: 'docker pull' requires 1 argument
Опровергнуто, и это первая команда, которую читатель выполняет. Многообразный docker pull стоял в блоке «Подготовка» девяти файлов exercises.md и ещё в двух уроках — одиннадцать мест. Ни одно не работало.
Заменено на цикл:
for img in python:3.13-slim alpine:3.21; do docker pull -q "$img"; done
Дефект пережил все предыдущие проверки, потому что за такими блоками нет «Ожидаемого вывода»: сверять было не с чем, и прогонщик их пропускал.
37. .NetworkSettings.IPAddress в Docker 29 нет
$ docker inspect insp --format '{{.NetworkSettings.IPAddress}}'
template parsing error: template: :1:18: executing "" at <.NetworkSettings.IPAddress>:
map has no entry for key "IPAddress"
Опровергнуто. Адрес переехал внутрь каждой сети:
$ docker inspect insp --format '{{range $net, $conf := .NetworkSettings.Networks}}{{$net}}={{$conf.IPAddress}}{{end}}'
bridge=172.17.0.5
Идиома распространённая, поэтому исправлена во всех трёх местах: урок 4.3, урок 13.2 и шпаргалка по CLI.
Там же нашлось то же самое для container'а: docker inspect КОНТЕЙНЕР --format '{{json .GraphDriver.Data}}' даёт отказ шаблонизатора, а не пустоту. Урок 3.2 объяснял оговорку про containerd для образов, но следующая же команда — для container'а — оговорки не имела. Добавлена проверка {{if .GraphDriver}} и показано, что writable layer виден через docker ps -s независимо от хранилища.
38. docker run ОБРАЗ --version не дополняет Cmd, а заменяет его
$ docker run --rm python:3.13-slim --version
exec: "--version": executable file not found in $PATH
Опровергнуто. Урок 3.1 писал: «Аргумент --version был передан команде python3 из конфигурации» — и приводил вывод Python 3.13.9. Аргументы Cmd не дополняют, а заменяют: без ENTRYPOINT дописывать некуда. Утверждение противоречило уроку 5.4 того же курса.
Переписано на три команды, показывающие и правило, и ловушку: python --version работает (замена), --version отказывает (аргумент стал командой).
39. Мелочи, найденные сплошной проверкой
Выдуманный digest. sha256:9f8e7d6c5b4a3928… использовался в восьми файлах, в том числе в FROM и в compose.yaml, — то есть в блоках, которые читатель копирует. Собрать их нельзя: failed to resolve source metadata. Заменён на настоящий digest python:3.13-slim, проверенный запуском. Пиннинг по digest тем и хорош, что настоящее значение не устареет.
--keep-storage переименован. В Docker 29 это --reserved-space; старое имя принимается с предупреждением об устаревании. Исправлено в пяти местах.
docker builder prune спрашивает подтверждение. Блоки без -f в неинтерактивном прогоне зависают. Там, где команда должна выполниться, добавлен -f; в «безопасной последовательности» подтверждение оставлено намеренно и описано.
Плейсхолдер в исполняемом блоке. docker start -a <container-id> в уроке 1.4 — первом, где читатель что-то запускает, — даёт syntax error near unexpected token 'newline': < в оболочке означает перенаправление. Заменено на рабочий вариант с сохранением идентификатора в переменную.
40. Уборка после раздела удаляла все container'ы машины
docker ps -aq | xargs -r docker rm -f
Эта строка стояла в разделе «Очистка после раздела» десяти файлов exercises.md. Она удаляет не «созданное разделом», а всё, что есть на машине.
Найдено дорого: при прогоне блоков она снесла кластер kind, поднятый для проверки раздела 18. Кластер был мой, но у читателя на его месте окажется база коллеги или рабочий стенд.
Дефект тем неприятнее, что курс сам предупреждает об этом классе команд — урок 3.4, раздел «Опасные варианты очистки», где docker system prune -a --volumes назван самым опасным. Собственная уборка делала ровно то же самое, только адресно к container'ам.
Исправлено во всех десяти местах: удаляется только созданное из образов этого раздела.
for img in python:3.13-slim alpine:3.21; do
docker ps -aq --filter "ancestor=$img" | xargs -r docker rm -f
done
У прогонщика была парная дыра. Список запрещённых команд блокировал docker rm -f $(docker ps -aq), но не форму через xargs — поэтому блок и выполнился. Закрыто в обоих прогонщиках, с самопроверкой на пяти случаях.
Самопроверка сразу же поймала мою ошибку в самой самопроверке: я записал, что отфильтрованная форма --filter "ancestor=..." должна блокироваться. Не должна — она безопасна, и блокировка означала бы, что правильную уборку курса прогонщик пропускает. Ожидание исправлено, а не регулярное выражение.
Из этого следует правило, которого раньше не было явно: прогонщик, выполняющий чужие команды, обязан иметь список запрещённого с самопроверкой. Без неё дыра видна только по последствиям.
Что осталось непроверяемым на этом стенде
| Что | Почему |
|---|---|
| GPU и привязка к версии драйвера | Нет GPU |
Multi-Attach error на сетевом хранилище | В kind только узловое хранилище; узловой режим отказа проверен |
| Побочные каналы | Требуют условий и оборудования |
| Поведение при других драйверах хранения | Только overlay2 |
Эти утверждения остаются помеченными как непроверенные.
Как воспроизвести
./verify/10-facts.sh; echo "код: $?"
Коды возврата: 0 все подтвердились, 1 есть опровергнутые, 3 Docker недоступен.
Навигация
Опись проверяемого
Скрипт проверки утверждений
Состояние курса
Главное оглавление