Главная/О курсе и репозитории/Документ

Проверенные факты

Утверждения курса, проверенные фактическим запуском на реальном 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

bash
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

bash
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отдельная история

bash
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'ена хостевывод
pid40265326744026531836свой
net40265326764026531840свой
mnt40265326714026531841свой
uts40265326724026531838свой
ipc40265326734026531839свой
cgroup40265326754026531835свой
user40265318374026531837общий с хостом
time40265327374026531834не хостовый

Два разных container'а (alpine и python:3.13-slim) дали один и тот же time:[4026532737].

Частично опровергнуто. Курс утверждал: «Docker создаёт шесть namespaces из восьми; user и time остаются общими» — подразумевая «общими с хостом».

Верно то, что своих у container'а шесть. Неверно про time: он не хостовый, но и не свой — он общий для всех container'ов. То есть изоляции времени container не получает, но и хостовый namespace не использует.

Формулировка исправлена во всех затронутых файлах.


4. Коды возврата после docker stop — матрица

bash
docker run -d --name vfyx ОБРАЗ python /СЦЕНАРИЙ
docker stop vfyx
docker inspect vfyx --format '{{.State.ExitCode}}'
СценарийPID 1Время остановкиКод
Обработчик SIGTERM + sys.exit(0)python0 с0
Без обработчикаpython10 с137
Обработчик + sys.exit(143)python0 с143
Без обработчика + --initdocker-init0 с143
Обработчик + exit(0) + --initdocker-init0 с0

Настоящий uvicorn с FastAPI как PID 1:

text
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
143PID 1 умер от SIGTERM. На практике — при --init, когда tini пересылает сигнал потомку и завершается с 128+15; либо приложение явно вышло с 143

Практический вывод остаётся прежним и даже усиливается: код возврата непригоден как признак мягкого завершения. Различать нужно по времени (доли секунды против ровно grace period) и по записям в журнале.


5. Copy-up копирует файл целиком

bash
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 ничего не публикует

bash
docker image inspect vfyexp --format '{{json .Config.ExposedPorts}}'   # {"8000/tcp":{}}
docker port vfye                                                       # пусто

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


7. Удалённый файл извлекается из слоя

dockerfile
RUN echo SECRET-STRING-42 > /secret && rm /secret
bash
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

bash
docker run --name проба alpine
text
Error response from daemon: Invalid container name (проба),
only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed
bash
docker build -t проба .
text
ERROR: failed to build: invalid tag "проба": invalid reference format

Новый факт, в курсе отсутствовавший. Демон отвергает кириллицу в именах container'ов, образов и тегов. Все примеры курса, использовавшие русские имена, не работали бы.

Исправлено; в справочники добавлено предупреждение.


9. fastapi run ломает машинно разбираемый лог

bash
docker run -d --name x ОБРАЗ    # ENTRYPOINT ["fastapi", "run", ...]
docker logs x
text
 ⚡️ 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 run7 не-JSON строк из 10. Тот же образ с python -m uvicorn0 из 9.

Опровергнуто. Курс утверждал в README примеров: «Structured logging — каждая строка валидный JSON» и «все строки — JSON, включая строки uvicorn». В container'е это неверно.

Две причины, и вторая неочевидна:

  1. fastapi run печатает декоративный баннер с эмодзи до импорта приложения.
  2. Он перенастраивает логирование 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-stack73 MB
fastapi-basic, production-fastapi66 MB
pytest-container, python-cli45 MB
flask-basic44 MB
worker-redis43 MB
hello-python, local-registry41 MB

Первая редакция этой записи была неверной, и это стоит записать. Я измерил docker image inspect --format '{{.Size}}', получил 66 MB и записал: «оговорка про 200 MB опровергнута». Число верное, величина — не та.

Под containerd image store (умолчание Docker 29) две команды возвращают разные величины:

КомандаВеличинаfastapi-basic
docker images --format '{{.Size}}'DISK USAGE293 MB
docker image inspect --format '{{.Size}}'CONTENT SIZE68.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. Уязвимости приходят из базового образа, а не из кода

bash
trivy image --severity HIGH,CRITICAL ОБРАЗ
syft ОБРАЗ -o syft-json
ОбразHIGH/CRITICAL
production-fastapi23
hello-python23
python-cli23

Одинаковое число у всех трёх — потому что все находки в пакетах 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'а делят сетевой namespacenet:[4026533202] у обоих
PID namespace у каждого свой по умолчанию4026533267 против 4026533270
runAsNonRoot отвергает образ с именем пользователяCreateContainerConfigError: container has runAsNonRoot and image will run as root
Классы QoS по requests/limitsGuaranteed, Burstable
Secret — base64, не шифрование0J7Rh9C1…ОченьСекретно42 обычным base64 -d
Pod принадлежит ReplicaSet, а не DeploymentownerReferences[0].kind = ReplicaSet

14. NetworkPolicy: утверждение курса требует оговорки

bash
# до политики
wget -qO- http://target        # <!DOCTYPE html> — связь есть
kubectl apply -f deny-all.yaml
# после
wget -qO- http://target        # пусто — связь закрыта

Уточнено. Курс утверждал: «NetworkPolicy не действует без поддержки плагином сети; правило может быть создано и молча не работать».

На kindnet политика подействовала: трафик закрылся. То есть плагин по умолчанию в kind NetworkPolicy поддерживает.

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

Это важнее исходного утверждения: полезен не факт, а способ его установить для своего кластера.


15. ReadWriteOnce: механизм отказа не тот, что был описан

text
класс хранения: 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 — все шесть:

#СимптомФакт
01Container завершается самкод 0
02Логи пусты при работающем процессестрок 0
03Изнутри отвечает, снаружи нетизнутри 200, снаружи нет ответа
04Остановка занимает grace period11 с, обработчик не сработал
05Приложение стартует раньше базы4 отказа подключения
06Отказ в правах на томеPermission denied

Замечание. Скрипт пришлось исправить: имена container'ов были кириллическими — см. запись 8.


17. Конвейер: три состояния работают

Прогон ci-pipeline с установленными trivy и syft:

text
линтер           пройдено
тесты            пройдено
сборка           пройдено
проверка образа  пройдено
сканирование     ПРОВАЛЕНО (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. Абсолютные пути машины автора

bash
grep -rn "/home/<автор>" --include=*.md .

37 вхождений в 11 файлах — в основном cd /home/.../resources/examples/… в блоках команд. Такую команду нельзя выполнить у себя.

Найдено при разборе блоков «Ожидаемый вывод», не глазами. Исправлено на пути относительно корня курса; в валидатор добавлено правило.

Правило пришлось сузить: первая редакция ловила любой /home/* и дала 8 ложных срабатываний на /home/appuser — законном пути внутри container'а. Ищется теперь только фактический домашний каталог.


19. GraphDriver исчез — но курс это уже покрывал

console
$ 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 написан с запасной ветвью:

bash
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/upperdirdocker 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: команды чек-листа не давали заявленного вывода

console
$ 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 тестов — все проходят.

python
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

console
$ 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

console
$ 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 run2015
python -m uvicorn app.main:app150

Тест test_uvicorn_logs_are_json запускал python -m uvicorn напрямую и проходил, тогда как образ шёл через fastapi run.

3. Требование 4 не выполнялось совсем. Функция load(), отвергающая опечатку в имени переменной, была написана, покрыта тестом и не вызывалась: create_app создавал Settings().

console
# было: сервис стартует, опечатка проигнорирована (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 вместо «тесты прошли»
2pytest вместо python -m pytestModuleNotFoundError: No module named 'app'
3ps -o args= -p 1OCI runtime exec failed — в slim нет ps
4CMD ["fastapi", "run", …]баннер с эмодзи в выводе требования 5
5Проверка логов через grep '^{'не могла провалиться: не-JSON строки отбрасывались до подсчёта

Пятое — самое неприятное: проверка требования «все строки JSON» сама отфильтровывала строки, которые должна была найти. Заменена на подсчёт всех строк:

text
═══ Требование 6: JSON-логи, включая Uvicorn ═══
  строк: 10, не JSON: 0

После правок упражнение проходит целиком; блок «Ожидаемый вывод» заменён на фактический.


27. make lock в примере multistage-uv не работал

console
$ 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 lockmake buildmake 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 не читал никто.

console
$ 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.

console
$ 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 прогоняла тесты, которым нужна база.

text
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. Проверено, что защита действует не на все сервисы:

console
$ 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 повис на проверке опечатки в имени переменной — и это оказалось сообщением, а не помехой:

console
$ 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, так что больше копий нет.

После правки:

console
$ 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.

json
{"совпало": 250, "разошлось": 305, "ошибка": 37,
 "пропущено": 21, "требует_root": 56, "интерактив": 8}

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

КорзинаШт.Почему это не дефект
зависит от предыдущего блока81образ или файл не создан: предыдущий блок пропущен как sudo/разрушительный
различаются только числа54строки совпадают, если не смотреть на числа
нет команды на стенде44hadolint, trivy, syft и подобные
вывод конкретной машины32df, nproc, hostname, docker version
вывод целиком нестабилен18сверять нечего
формат вывода BuildKit7различие версий
реестр недоступен1сеть
остаток68прочитан целиком

Из 68 остатка дефектами оказались шесть; остальные — каскад от пропущенных блоков, ширина колонок и переменные оболочки, не переживающие границу блока (прогонщик выполняет каждый блок отдельным bash -c).

Правило подтвердилось на цифрах: расхождение — ещё не дефект. 305 расхождений дали 6 дефектов, то есть 2 %. Но эти 2 % включают два неверных утверждения о безопасности, которые нельзя было найти иначе.

Разборщик тоже проверяется: 52-triage.py --self-test прогоняет десять заведомых случаев. Корзина без сверки — та же подгонка ожидания под факт, поэтому из каждой печатается образец.


31. Два неверных утверждения о безопасности

--cap-add=SYS_ADMIN не включает mount.

console
$ 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 не защищает привилегированные порты.

console
$ 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. Причина найдена:

console
$ 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, и это давало правдоподобный ноль

console
$ 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:

console
$ 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 подставляет значение базового образа

console
$ 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 нет вовсе. Источник:

console
$ docker run --rm python:3.13-slim env | grep PYTHON_VERSION
PYTHON_VERSION=3.13.14

ARG до FROM в стадию действительно не попадает — но имя совпало с ENV базового образа, и подставился он. Пустая строка получилась бы на alpine, где такой переменной нет: поведение зависит от базы.

Там же исправлено обратное утверждение про приоритет: «ENV перекрывает ARG» верно только при порядке ARGENV. Измерено три порядка:

ПорядокЧто видит RUNЧто в образе
ENVARGзначение --build-argзначение ENV
ARGENVзначение 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.py8 собрано, 2 иллюстрации, 0 отказов
Флаги Docker против установленного Docker71-flags.py752 употребления, 2 дефекта
Блоки без «Ожидаемого вывода»73-silent-blocks.py279 выполнено, 76 с ненулевым кодом → 6 дефектов
Блоки с «Ожидаемым выводом»51-run-blocks.pyсм. запись 30

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

Найдено 7 дефектов, из них три системных.


36. docker pull принимает ровно один образ

console
$ docker pull -q python:3.13-slim alpine:3.21
docker: 'docker pull' requires 1 argument

Опровергнуто, и это первая команда, которую читатель выполняет. Многообразный docker pull стоял в блоке «Подготовка» девяти файлов exercises.md и ещё в двух уроках — одиннадцать мест. Ни одно не работало.

Заменено на цикл:

bash
for img in python:3.13-slim alpine:3.21; do docker pull -q "$img"; done

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


37. .NetworkSettings.IPAddress в Docker 29 нет

console
$ docker inspect insp --format '{{.NetworkSettings.IPAddress}}'
template parsing error: template: :1:18: executing "" at <.NetworkSettings.IPAddress>:
map has no entry for key "IPAddress"

Опровергнуто. Адрес переехал внутрь каждой сети:

console
$ 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, а заменяет его

console
$ 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'ы машины

bash
docker ps -aq | xargs -r docker rm -f

Эта строка стояла в разделе «Очистка после раздела» десяти файлов exercises.md. Она удаляет не «созданное разделом», а всё, что есть на машине.

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

Дефект тем неприятнее, что курс сам предупреждает об этом классе команд — урок 3.4, раздел «Опасные варианты очистки», где docker system prune -a --volumes назван самым опасным. Собственная уборка делала ровно то же самое, только адресно к container'ам.

Исправлено во всех десяти местах: удаляется только созданное из образов этого раздела.

bash
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

Эти утверждения остаются помеченными как непроверенные.


Как воспроизвести

bash
./verify/10-facts.sh; echo "код: $?"

Коды возврата: 0 все подтвердились, 1 есть опровергнутые, 3 Docker недоступен.


Навигация

Опись проверяемого
Скрипт проверки утверждений
Состояние курса
Главное оглавление

Markdown на GitHub ↗