✻ Урок 4.2 · Тема 4: Docker и Compose
Dockerfile: собираем образ «Заметок»
Содержание урока
Зачем это нужно
На сервере «Заметки» работали как systemd-сервис: код в /opt/notes/, данные в /var/lib/notes/, свой пользователь notes, настройки в /etc/notes/notes.env. Чтобы запустить то же самое на другой машине, нужно повторить десяток шагов руками: поставить Python (язык, на котором написано приложение), создать пользователя, разложить файлы, выдать права. Через месяц никто не вспомнит, в каком порядке это делалось.
Образ (image) собирает всё это один раз по письменному рецепту, файлу Dockerfile, и после этого запускается одинаково на ноутбуке, в CI и в Kubernetes. Образ - это «заморозка» всего, что нужно программе: система, Python и твой код в одном неизменяемом наборе файлов (подробно разберём ниже). CI (continuous integration) - это робот, который при каждом коммите сам собирает и проверяет проект, ты настраивал его в уроке 3.3. Kubernetes - программа, которая запускает контейнеры на многих серверах сразу и сама перезапускает упавшие; ты начнёшь с неё в уроке 5.1, пока достаточно знать, что она берёт готовый образ. Рецепт лежит в git рядом с кодом, его можно прочитать, проверить и повторить.
На работе Dockerfile ревьюят так же, как код: коллега читает твой файл до слияния и пишет замечания (ревью, review, ты делал это в уроке 3.2). Три самые частые претензии: сборка медленная (после любой правки качаются все пакеты), образ весит гигабайт, сервис работает под root. Этот урок про то, как не получить ни одну из трёх.
Шаг проекта: в ~/notes появляются три файла, которые ты напишешь сам: Dockerfile (рецепт), .dockerignore (список того, что не нужно отправлять на сборку: как «не клади это в посылку») и requirements.txt (список пакетов, которые нужны приложению, по строке на пакет). Из них собирается образ notes:0.3.0. Приложение слушает 0.0.0.0:8080 внутри контейнера (адрес «все сетевые входы», зачем он нужен, разберём ниже) и работает под пользователем с uid 10001 (это просто номер, по которому система проверяет права; выбран «непохожим» на номера живых людей, которые начинаются с 1000). Версия кода остаётся v3.
Что нужно знать
- Урок 4.1: контейнеры: контейнер это процесс с ограничениями, а не виртуальная машина; Docker Engine установлен, порты 80, 443 и 8080 на хосте свободны (сервисы
nginxиnotesостановлены). - Урок 1.3: пользователи и права: что такое uid (числовой номер пользователя), владелец каталога,
chown, почему сервис не должен работать от root. - Урок 1.4: процессы и сигналы: PID, сигналы SIGTERM и SIGKILL, коды выхода. Всё это нужно в разделе про остановку контейнера.
- Урок 2.1: адреса и маршруты и урок 2.2: порты: адрес
127.0.0.1(петля, loopback), адрес0.0.0.0, что значит «слушать порт». - Урок 3.5: релизы: версии вида
0.3.0(semver) и git-теги: версия образа совпадёт с тегом.
Картина целиком
Представь, что ты открываешь пекарню и хочешь, чтобы булочки получались одинаковыми в любом филиале. Ты пишешь рецепт: возьми такую-то муку, замеси, положи в печь, выставь такую температуру. Любой повар по рецепту получает то же самое. Так и Dockerfile: это рецепт сборки образа.
Готовая заморозка, которую можно отвезти в филиал, это образ. Из одной заморозки можно разогреть сколько угодно порций: каждая разогретая порция это контейнер. Заморозка не меняется, а разогретую порцию можно съесть (удалить), и заморозка останется целой.
Есть нюанс. Рецепт пишется по шагам, и после каждого шага повар делает снимок результата. Если завтра ты изменишь только последний шаг (например, добавку в конце), повару не нужно начинать с муки: он берёт снимок после предпоследнего шага и делает только последний. Эти снимки называются слоями (layers), и по ним работает кэш сборки: Docker запоминает готовые снимки и, если шаг не менялся, берёт результат из памяти вместо повторной работы. Без кэша любая мелкая правка означала бы сборку с нуля.
flowchart LR
D["Dockerfile<br>рецепт, текст"] -->|"docker build"| I["Образ notes:0.3.0<br>заморозка, файлы"]
I -->|"docker run"| C["Контейнер notes<br>запущенный процесс"]
Рецепт превращается в образ, образ в контейнер. Каждая строка рецепта даёт слой образа, если меняет файлы:
| Инструкция | Что получается |
|---|---|
FROM python:3.13-slim |
слой 1: готовая система с Python (чужой, скачивается) |
WORKDIR /app |
слой 2: каталог /app |
RUN groupadd ... |
слой 3: пользователь notes, каталог /data |
COPY requirements.txt |
слой 4: список зависимостей |
RUN pip install ... |
слой 5: установленные пакеты |
COPY app.py . |
слой 6: твой код |
ENV, USER, CMD |
метаданные: как запускать (файлов не добавляют) |
За урок ты разберёшь каждую часть этой схемы. Что такое слои и как кэш экономит минуты. Почему в образ нельзя класть секреты. Почему внутри контейнера приложение должно слушать 0.0.0.0, а не 127.0.0.1. Почему docker stop бывает быстрым, а бывает висит десять секунд. И как сделать так, чтобы сервис работал не от root и сам сообщал Docker, что он здоров.
Теория
Dockerfile, образ и контейнер: что из чего получается
Без рецепта образ пришлось бы собирать руками: запустить пустую систему, зайти в неё, поставить Python, скопировать файлы, сохранить. Через полгода никто не помнит, что именно было сделано, а повторить нельзя. Dockerfile превращает эту работу в текстовый файл: его хранят в git, ревьюят, запускают в CI.
Рецепт, заготовка, порция. Dockerfile это рецепт, образ это заготовка в морозилке, контейнер это разогретая порция. Где аналогия перестаёт работать: порцию из контейнера можно «вернуть в морозилку» командой docker commit, но так делать не надо, потому что рецепта уже не останется, а без него образ не повторить.
Файл Dockerfile состоит из инструкций (instruction): каждая строка это слово-команда заглавными буквами и её аргументы. Команда docker build читает файл сверху вниз и выполняет инструкции по очереди. Результат это образ: неизменяемый набор файлов плюс метаданные (какую команду запускать, какие переменные задать, от какого пользователя). Образ можно только создать или удалить, исправить нельзя. Команда docker run берёт образ и запускает из него контейнер: обычный процесс Linux (это ты доказал в уроке 4.1), которому ядро показывает файлы образа как корневую файловую систему.
Инструкции, которые понадобятся нам в этом уроке:
| Инструкция | Что делает | Меняет файлы образа? |
|---|---|---|
FROM |
берёт готовый образ как основу | да (это первый слой) |
WORKDIR |
задаёт рабочий каталог, создаёт его | да |
COPY |
копирует файлы с твоей машины в образ | да |
RUN |
выполняет команду во время сборки | да |
ENV |
задаёт переменные окружения | нет, метаданные |
EXPOSE |
записывает, какой порт слушает приложение | нет, метаданные |
USER |
задаёт пользователя, под которым пойдёт процесс | нет, метаданные |
HEALTHCHECK |
задаёт команду проверки здоровья | нет, метаданные |
CMD |
задаёт команду запуска контейнера | нет, метаданные |
Переменная окружения (environment variable) это пара «имя=значение», которую операционная система передаёт программе при запуске. «Заметки» читают из них HOST, PORT и NOTES_DATA (ты настраивал их в notes.env в уроке 1.8).
Разбор на примере: FROM. Первая инструкция почти всегда FROM python:3.13-slim. Строка читается так: «возьми готовый образ python с тегом 3.13-slim». Образы лежат в реестре (registry), это сервер-хранилище образов, по умолчанию Docker Hub. Имя состоит из репозитория (python) и тега (tag, 3.13-slim) через двоеточие. Тег это метка версии. Здесь она значит: Python 3.13 и вариант slim (облегчённый: в нём есть Python и минимум системных программ, а компиляторов, curl и ps нет). Слово slim и определяет размер: обычный python:3.13 весит 1,6 ГБ, а python:3.13-slim 204 МБ на диске. Тег latest мы не используем нигде: он означает «последний на момент скачивания», и завтра тот же тег даст другую систему. Поэтому база закреплена версией.
Прикинь сам: В первом Dockerfile «Заметок» (задание 3) девять инструкций. Сколько из них работают с файлами образа, если метаданные (
ENV,EXPOSE,CMD) файлов не меняют?
Шесть: FROM, WORKDIR, RUN mkdir, COPY, RUN pip, COPY. Остальные три (ENV, EXPOSE, CMD) только записывают, как запускать, поэтому их слои весят 0 байт.
Осторожно, частое заблуждение: что образ и контейнер это одно и то же. Образ это файл-шаблон, он не «работает». Контейнер это процесс, который из него запущен. Удаление контейнера (docker rm) не удаляет образ, а удаление образа (docker image rm) невозможно, пока от него остался хоть один контейнер.
Главное: Dockerfile это рецепт, образ собранный из него неизменяемый набор файлов с метаданными, контейнер запущенный из образа процесс.
Рецепт пишется по шагам, и каждый шаг запоминается. Посмотрим, как это ускоряет сборку.
Проверь понимание: ты запустил из образа три контейнера. Сколько копий образа лежит на диске?
Ответ
Одна. Слои образа неизменяемы и общие: каждый контейнер получает только свой тонкий записываемый слой сверху. Поэтому десять контейнеров из одного образа занимают места немногим больше, чем один.
Образ, слои и кэш
Сборка образа бывает долгой: скачать базу, поставить пакеты, скопировать код. Если после каждой правки одной строки ждать всё заново, работать невозможно. Слои и кэш позволяют пересобирать только то, что изменилось.
Слоёный торт, который печётся снизу вверх. Испёк коржи и крем, поверх положил вишню. Завтра клиент просит другую вишню: снимаешь только верхний слой и кладёшь новый, а коржи не трогаешь. Но если попросили другой корж внизу, придётся разобрать всё, что лежит выше. Аналогия ломается в одном: слой образа нельзя «выковырять» снизу и оставить остальное. Порядок жёсткий.
Каждая инструкция, которая меняет файлы (FROM, WORKDIR, COPY, RUN), создаёт слой (layer): набор файлов, которые добавились или изменились относительно предыдущего слоя. Инструкции ENV, EXPOSE, USER, HEALTHCHECK, CMD меняют только метаданные, и их слои весят 0 байт. Сборщик BuildKit (встроен в Docker Engine, включён по умолчанию) запоминает каждый слой. При следующей сборке для каждой инструкции он задаёт вопрос: «я уже выполнял такую же инструкцию на таком же слое ниже?» Для COPY он дополнительно сравнивает содержимое копируемых файлов. Если ответ «да», слой берётся из кэша, и в выводе появляется пометка CACHED. Если «нет», инструкция выполняется, а все слои ниже пересобираются тоже, даже если их инструкции не менялись: они стоят на другом фундаменте.
Посмотрим на примере. Возьмём такой фрагмент:
COPY requirements.txt . # слой A
RUN pip install -r requirements.txt # слой B (качает пакеты, самый медленный)
COPY app.py . # слой C
Ты меняешь строку в app.py и запускаешь сборку. BuildKit идёт сверху. Слой A: requirements.txt не менялся, кэш. Слой B: инструкция та же, слой A тот же, кэш, пакеты не скачиваются. Слой C: файл app.py изменился, выполняем заново. Итого сборка занимает доли секунды.
Теперь поменяем порядок: сначала COPY . . (копируем весь каталог, включая app.py), потом RUN pip install. Ты меняешь app.py. Первый слой видит другой файл и пересобирается. Слой с pip install стоит ниже, значит тоже пересобирается, и пакеты качаются заново. Для «Заметок» с пустым requirements.txt это секунда, а для реального сервиса с сотней пакетов это пять минут на каждую правку одной строки. Отсюда главное правило порядка: то, что меняется редко (список зависимостей), пишется выше, то, что меняется часто (твой код), ниже.
Прикинь сам: Для реального сервиса
pip installзанимает 5 минут, а ты меняешь одну строку вapp.py12 раз за день. Сколько минут уйдёт на установку пакетов, еслиCOPY requirements.txtиRUN pip installстоят вышеCOPY app.py?
Почти ноль: слои с пакетами берутся из кэша. Если поставить COPY . . раньше установки, будет 12 × 5 = 60 минут ожидания.
Осторожно, частое заблуждение: что CACHED значит «ничего не изменилось вообще». Нет: кэш работает по каждому слою отдельно. В примере выше три из четырёх слоёв могут быть CACHED, а один выполняться. Ещё путают «изменилась инструкция» и «изменились файлы»: COPY app.py . пересоберётся, даже если текст самой инструкции тот же, потому что изменился файл.
Главное: кэш работает по слоям сверху вниз: после первого изменившегося слоя пересобирается всё, что ниже, поэтому редко меняющееся пишут выше.
Теперь посмотрим, кто на самом деле выполняет сборку.
Проверь понимание: в Dockerfile сначала
COPY requirements.txt, потомRUN pip install, потомCOPY app.py. Ты добавил новую зависимость вrequirements.txt. Что пересоберётся?
Ответ
Всё, начиная со слоя с COPY requirements.txt: файл изменился, значит сам слой, pip install (он стоит ниже) и COPY app.py (тоже ниже) выполнятся заново. Кэшем останутся только слои выше: FROM и WORKDIR.
Что происходит при docker build: клиент, демон, реестр
Когда ты впервые набираешь docker build, непонятно, кто что делает: откуда берётся Python, куда уходят твои файлы, где лежит результат. Без этой картины ошибки вроде Cannot connect to the Docker daemon или pull access denied выглядят как магия.
Заказ в пекарне. Ты (клиент) отдаёшь заказ на кассе, повар на кухне (демон) по нему работает, муку и дрожжи он берёт на оптовом складе (реестр). Где аналогия не работает: у Docker касса и кухня могут стоять в разных городах, и связь между ними идёт по сети или через файл-сокет.
В Docker три участника.
- Клиент (client) - команда
docker, которую ты вводишь. Сама она почти ничего не делает: разбирает твою команду и отправляет запрос. - Демон (daemon,
dockerd) - фоновая программа, которая постоянно работает на машине и выполняет работу: собирает образы, запускает контейнеры. Клиент общается с ним через сокет (socket): специальный файл/var/run/docker.sock, в который можно «постучаться» как в окошко. Поэтому доступ к этому файлу равен доступу к Docker, отсюда и требование состоять в группеdocker. - Реестр (registry) - сервер с готовыми образами. По умолчанию Docker Hub (
docker.io).
sequenceDiagram
participant U as Ты
participant C as Клиент docker
participant D as Демон dockerd
participant R as Реестр Docker Hub
U->>C: docker build -t notes:0.3.0 .
C->>D: 1. упаковал контекст и отправил
D->>D: 2. FROM python:3.13-slim: слоя нет на диске
D->>R: запрос слоёв базы
R-->>D: 3. отдал слои базы
D->>D: 4. выполнил RUN и COPY по очереди, снял слои
D-->>U: образ notes:0.3.0 лежит на диске демона
Вот как это выглядит на деле. Первая сборка идёт долго, потому что на шаге 3 скачиваются слои python:3.13-slim (около 50 МБ по сети). Вторая сборка быстрая: база уже лежит на диске демона, и в выводе у неё пометка CACHED. Образ notes:0.3.0 после сборки хранится у демона локально. В реестр он не попадает, пока ты сам не выполнишь docker push (это урок 4.7).
Прикинь сам: База
python:3.13-slimвесит около 50 МБ по сети. Сколько мегабайт скачает вторая сборка того же Dockerfile на той же машине?
Ничего: слои базы уже лежат у демона, в выводе будет CACHED. В реестр образ попадёт только после docker push.
Осторожно, частое заблуждение: что docker build «собирает на сервере Docker Hub». Нет: собирает твой локальный демон, а реестр лишь выдаёт чужие базовые слои.
Главное:
docker buildсобирает локальный демон, а реестр лишь отдаёт чужие базовые слои.
Демон получает от клиента файлы для сборки. Решим, какие именно.
Проверь понимание: ты остановил демон (
sudo systemctl stop docker) и набралdocker ps. Что напишет клиент и почему?
Ответ
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? Клиент на месте, а принять запрос некому: «кухня закрыта». Лечится запуском демона: sudo systemctl start docker.
Контекст сборки и .dockerignore
Команда docker build запускается не там, где живёт Docker Engine: клиент (docker) и демон (dockerd, постоянно работающая программа, которая делает всю работу) общаются через сокет, и демон может быть даже на другой машине. Поэтому демон не может просто «зайти» в твой каталог: файлы для COPY ему нужно передать. Что передавать, а что оставить дома, решает .dockerignore.
Ты отправляешь посылку в мастерскую, чтобы там собрали изделие по чертежу. В посылку кладёшь только то, что нужно для работы. Личные документы, которые лежали на столе рядом, в коробку класть не надо, а то они уедут вместе с изделием.
Последний аргумент docker build . (точка) называется контекстом сборки (build context): это каталог, который клиент упаковывает и отправляет демону целиком. Инструкция COPY видит только его: файл вне контекста скопировать нельзя, даже если он лежит на соседнем каталоге. Файл .dockerignore (лежит в корне контекста) перечисляет пути, которые в контекст не попадают: по одному шаблону в строке, *.md значит «все файлы с расширением .md». Без него в контекст уедет всё: история git (.git), локальные пароли (.env), каталоги с данными.
Теперь на числах. В проекте «Заметки» есть .git, versions/ с восемью версиями app.py, break/ со скриптами поломок, README. В контейнере они не нужны. Размер контекста виден в строке transferring context: ... done в начале вывода сборки. На реальном проекте разница в сотни мегабайт, и она видна в строке transferring context: ... done в выводе сборки. Второе применение важнее скорости: то, что попало в контекст и было скопировано, остаётся в образе. Если COPY . . захватил .env с паролем, пароль теперь внутри образа.
Прикинь сам: В каталоге проекта лежат
.gitна 80 МБ,versions/иbreak/, а нужен толькоapp.pyна 20 КБ. Что попадёт в контекст без.dockerignore?
Всё: клиент упаковывает каталог целиком и шлёт демону. Лишние 80 МБ уедут при каждой сборке, а COPY . . положит их ещё и в образ.
Осторожно, частое заблуждение: что .dockerignore это то же самое, что .gitignore. Они похожи по синтаксису, но независимы: файл, которого нет в .gitignore, всё равно можно исключить из образа, и наоборот. Второй вариант ошибки: исключить из контекста файл, который нужен для COPY. Тогда сборка упадёт с "/app.py": not found. Ты сам это увидишь в разделе «Сломай и почини».
Главное: контекст сборки это каталог, который клиент целиком отправляет демону;
.dockerignoreоставляет лишнее дома и не пускает секреты в образ.
Всё, что попало в контекст и в COPY, остаётся в образе. Это особенно опасно для паролей.
Проверь понимание: в
.dockerignoreесть строка*.md, а в DockerfileCOPY README.md /app/. Что произойдёт?
Ответ
Сборка упадёт с ошибкой "/README.md": not found. Файл лежит на диске, но исключён из контекста, поэтому демон его не получил и COPY его не видит.
Секреты и неизменяемость слоёв
Самая опасная ошибка новичка: положить в образ пароль, а потом «удалить». Образы выкладывают в реестры и раздают коллегам, и всё, что в них лежит, доступно любому, у кого образ есть.
Слой неизменяем. Инструкция RUN rm .env не стирает файл из предыдущего слоя: она создаёт новый слой, в котором записано «файла .env больше нет». Образ это стопка слоёв, и предыдущий слой с файлом остаётся в стопке целым. Файловая система контейнера показывает верхнюю картину, поэтому внутри контейнера файла не видно, но образ хранит его в слое. Достать секрет можно распаковкой слоёв (docker save выгружает образ в один архив, tar его раскрывает), а команда docker history (история образа: по строке на каждый слой) показывает, какая инструкция что добавила, включая строку с COPY .env.
Пример. Три строки: COPY .env /app/.env, RUN rm /app/.env, дальше всё остальное. Слой 1: файл есть. Слой 2: пометка «удалён». Итоговый образ весит столько же, сколько с файлом, а не меньше, потому что слой 1 никуда не делся. Правильно: .env в .dockerignore (файл вообще не попадает в контекст), а настройки и пароли передаются при запуске контейнера (docker run -e ... или --env-file, подробнее в уроке 4.5) и не в сборке. Продвинутые способы (секреты BuildKit) разберём в уроке 4.8.
Прикинь сам:
COPY .env /app/.envвесит 1 КБ, потомRUN rm /app/.env. Сколько килобайт занимает файл в итоговом образе и виден ли он в контейнере?
Файл по-прежнему занимает 1 КБ в слое 1 образа, а в контейнере его не видно: слой 2 только помечает его удалённым. Достать пароль можно через docker save и tar.
Осторожно, частое заблуждение: что переменная ARG (значение, передаваемое при сборке) безопаснее ENV. Нет: значения ARG тоже видны в docker history. Сборочные аргументы годятся для версий, но не для паролей.
Главное: слой неизменяем: удалить секрет «потом» нельзя, поэтому пароль не кладут в образ, а передают при запуске контейнера.
Следующая особенность контейнера касается сети: почему приложение должно слушать 0.0.0.0.
Проверь понимание: пароль попал в образ, ты пересобрал образ без него и удалил старый локально. Пароль в безопасности?
Ответ
Нет, если старый образ успели выложить в реестр или отдать кому-то: копии живут там. Пароль надо считать скомпрометированным и сменить. Пересборка и удаление локальной копии закрывают только твою машину.
Сеть контейнера: почему нужен HOST=0.0.0.0
В уроке 2.1 ты видел, что один и тот же сервис может слушать 127.0.0.1 (только свою машину) или 0.0.0.0 (все сетевые адреса машины). Контейнер это отдельная «машина» со своей сетью (namespace net из урока 4.1), и у неё свой 127.0.0.1. Поэтому та настройка, которая на сервере была безопасной, в контейнере делает сервис недоступным.
Квартира с домофоном. Внутри квартиры (петля, 127.0.0.1) можно позвать соседа по комнате, но с улицы тебя не услышат. Чтобы гость с улицы дозвонился, нужен звонок, который слышно у двери подъезда: это 0.0.0.0. Сам по себе номер квартиры (порт) без звонка не поможет.
Контейнер получает виртуальную сетевую карту с собственным адресом (например, 172.17.0.2) и отдельную петлю. Хост пользоваться адресом контейнера напрямую не должен, поэтому порт публикуют: флаг -p 8080:8080 у docker run значит «пакеты, пришедшие на порт 8080 хоста, передай на порт 8080 контейнера». Docker при этом принимает соединение на хосте и пересылает его на адрес контейнера (не на его петлю). Приложение получит запрос только если слушает сетевую карту контейнера, то есть 0.0.0.0, а не 127.0.0.1.
flowchart TD
subgraph H["Хост"]
CURL["curl localhost:8080"] --> HP["порт 8080 хоста"]
end
HP -->|"-p 8080:8080"| ETH
subgraph K["Контейнер notes"]
ETH["eth0 172.17.0.2<br>сюда приходит пакет"]
LO["lo 127.0.0.1 (петля)<br>HOST=127.0.0.1 слушает только здесь"]
ALL["HOST=0.0.0.0<br>слушает на всех адресах, в том числе на eth0"]
ETH -.->|"до петли не доходит"| LO
ETH ==> ALL
end
Пакет с хоста приходит на eth0 контейнера. Если приложение слушает только 127.0.0.1, оно его не получит. При 0.0.0.0 приложение слушает и на eth0, поэтому запрос доходит.
Разберём пример. Мы запустили контейнер с -e HOST=127.0.0.1, порт опубликовали (docker port показал 8080/tcp -> 0.0.0.0:...), в логах приложения стоит started host=127.0.0.1 port=8080, контейнер в статусе Up. А curl с хоста получил curl: (56) Recv failure: Connection reset by peer. Соединение принято на хосте, переслано в контейнер, там его никто не ждёт на сетевой карте, и оно оборвано. Симптом путает: «контейнер работает, порт открыт, логи чистые, а не отвечает». Первая проверка в такой ситуации: на каком адресе слушает приложение. Команда docker exec контейнер команда запускает команду внутри уже работающего контейнера (как зайти в комнату и спросить), а env печатает его переменные окружения; плюс смотришь логи (то, что приложение печатает о себе).
Отдельно про EXPOSE 8080 в Dockerfile. Эта инструкция ничего не публикует и не открывает. Это запись для человека и для инструментов: «приложение внутри слушает 8080». Публикует только -p.
Прикинь сам:
docker run -p 8080:8080 notes:0.3.0, а приложение слушает127.0.0.1:8080внутри контейнера. Что вернётcurl localhost:8080с хоста?
Ошибку соединения: Docker пересылает пакет на адрес контейнера (172.17.0.2), а приложение слушает только свою петлю. С HOST=0.0.0.0 заработает.
Осторожно, частое заблуждение: что EXPOSE открывает порт. Нет, порт открывает -p. Ещё думают, что 0.0.0.0 в контейнере опасен так же, как на сервере в интернете. Он открывает порт для всех сетей, к которым подключён контейнер, а наружу выпускает только то, что ты опубликовал через -p.
Главное: внутри контейнера приложение должно слушать
0.0.0.0, а наружу порт открывает только-p, а неEXPOSE.
Приложение доступно. Но куда оно пишет данные?
Проверь понимание: ты запустил
docker run -d -p 8080:8080 notes:0.3.0, а в образе нет ниENV HOST=0.0.0.0, ни-e. По умолчанию приложение слушает127.0.0.1. Откроется ли оно с хоста?
Ответ
Нет: HOST по умолчанию 127.0.0.1 (это видно в коде os.environ.get("HOST", "127.0.0.1")), и приложение слушает только петлю контейнера. Поэтому в Dockerfile стоит ENV HOST=0.0.0.0.
Данные внутри контейнера: слой для записи
Образ неизменяем, а «Заметкам» нужно писать файл. Docker решает это так: сверху слоёв образа добавляется тонкий слой для записи (writable layer), и всё, что процесс пишет, попадает туда.
Слои образа доступны только для чтения. Когда контейнер создаётся, к ним добавляется пустой слой, привязанный к этому контейнеру. Чтение идёт сверху вниз, запись только в верхний слой. При docker rm этот слой удаляется вместе с контейнером. Сам образ не меняется.
Пример. Мы запустили контейнер, записали заметку ({"id": 1}), проверили cat /data/notes.txt: строка на месте. Затем docker rm -f notes и запуск нового контейнера из того же образа. Запрос GET /notes вернул []: заметки нет, она жила в слое старого контейнера. Как сохранить данные, мы разберём в уроке 4.3 (тома). А пока сам каталог /data должен существовать в образе, иначе запись упадёт с [Errno 2] No such file or directory: '/data/notes.txt' (мы видели и этот случай в прогоне). Приложение mkdir не делает, каталог создаёт Dockerfile.
Прикинь сам: Ты записал в «Заметки» 5 заметок, выполнил
docker rm -f notesи запустил новый контейнер из того же образа. Сколько заметок вернётGET /notes?
Ноль: []. Заметки лежали в слое записи старого контейнера, а при docker rm он удаляется. Сохраняют данные тома (урок 4.3).
Осторожно, частое заблуждение: что данные в контейнере живут «в образе» и переживут пересоздание. Образ не пишется никогда. Данные живут в слое контейнера и умирают вместе с ним.
Главное: данные в слое контейнера живут, пока жив контейнер, а образ не меняется никогда.
Данные разобрали. Теперь о том, как контейнер запускается и как его остановить без потерь.
Проверь понимание: ты остановил контейнер командой
docker stop, но не удалял его. Заметки на месте?
Ответ
Да. Остановленный контейнер сохраняет свой слой записи, docker start вернёт его с данными. Данные пропадают при docker rm.
CMD, PID 1 и остановка контейнера
Контейнеру нужно знать, какой процесс запускать. Это задаёт CMD. А как именно он записан, определяет, услышит ли приложение просьбу «остановись»: от этого зависит, потеряются ли запросы и данные при каждой выкатке.
Директору нужно сказать сотруднику «заканчивай и уходи». Если сотрудник сидит в отделе напрямую, он услышит. Если между вами стоит секретарь, которая не передаёт устные просьбы дальше, сотрудник не узнает, а директор через десять минут просто отключит свет. Секретарь это /bin/sh, свет это SIGKILL.
Первый процесс, который запускается в контейнере, получает номер PID 1. Ядро относится к нему особо: сигналы, для которых у процесса нет обработчика, для PID 1 игнорируются (иначе случайный сигнал убил бы «главный» процесс без разбора). Команда docker stop делает две вещи: шлёт процессу с PID 1 сигнал SIGTERM (вежливая просьба завершиться, урок 1.4), ждёт 10 секунд, затем шлёт SIGKILL (принудительное убийство, которое поймать нельзя). У CMD две формы записи:
- exec-форма
CMD ["python", "app.py"]: список в квадратных скобках. Docker запускаетpythonнапрямую, и он получает PID 1. SIGTERM приходит сразу Python-приложению; - shell-форма
CMD python app.py: обычная строка. Docker запускает/bin/sh -c "python app.py". Процессshполучает PID 1, а Python становится его дочерним процессом. SIGTERM приходит вsh, у него нет обработчика, для PID 1 сигнал игнорируется, и до Python он не доходит.
К CMD есть парная инструкция ENTRYPOINT. ENTRYPOINT задаёт программу, которая запускается всегда, а CMD даёт аргументы по умолчанию к ней. docker run образ другая-команда заменяет CMD, а ENTRYPOINT заменяется только флагом --entrypoint. Для приложения вроде «Заметок» достаточно одного CMD.
flowchart TD
subgraph E["exec-форма: CMD [python, app.py]"]
E1["docker stop → SIGTERM"] --> E2["python app.py (PID 1)<br>ловит сигнал, пишет shutting down,<br>доделывает запросы, выходит с кодом 0"]
end
subgraph SH["shell-форма: CMD python app.py"]
S1["docker stop → SIGTERM"] --> S2["/bin/sh -c python app.py (PID 1)<br>сигнал дальше не передаёт"]
S2 --> S3["python app.py (PID 7)<br>ничего не знает"]
S3 --> S4["через 10 с SIGKILL, код 137"]
end
В exec-форме сигнал получает само приложение и завершается за доли секунды. В shell-форме его принимает sh и теряет, и Docker добивает Python через 10 секунд.
Посмотрим на примере. В нашем прогоне с exec-формой docker stop занял 0,63 секунды. В логе после остановки: INFO shutting down, INFO stopped, код выхода 0. С shell-формой: docker top показал два процесса, /bin/sh -c python app.py (PID 141307) и python app.py (PID 141322, потомок), docker stop занял 10,159 секунды, в логе нет ни shutting down, ни stopped, код выхода 137. Расшифруем 137: сигнал 9 (SIGKILL) плюс 128 (так оболочка кодирует «убит сигналом»), то есть 128 + 9 = 137. Этот код ты уже видел в уроке 4.1 при нехватке памяти.
Заметка про реальные ситуации. Для Kubernetes картина та же: он тоже шлёт SIGTERM и через период ожидания SIGKILL (урок 5.7). Запросы, которые сервис не успел закончить, при SIGKILL теряются.
Прикинь сам:
docker stopс exec-формой занял 0,63 с, с shell-формой 10,16 с. Во сколько раз второй дольше и почему?
Примерно в 16 раз. SIGTERM получил /bin/sh (PID 1), Python его не увидел, и Docker через 10 секунд послал SIGKILL (код 137).
Осторожно, частое заблуждение: что exec-форма сама по себе гарантирует красивую остановку. Она лишь доставляет сигнал. Приложение должно его обработать: сервер «Заметок» обрабатывает SIGTERM (это сделано в уроке 1.4), поэтому останавливается за секунду. Программа без обработчика в роли PID 1 проживёт все 10 секунд и в exec-форме.
Главное:
CMDпиши в exec-форме, чтобы SIGTERM дошёл до приложения, а приложение должно уметь его обработать.
Процесс в контейнере по умолчанию работает от root. Исправим это.
Проверь понимание: почему нельзя просто увеличить таймаут (
docker stop -t 60) вместо перехода на exec-форму?
Ответ
Таймаут только задерживает SIGKILL. Приложение не получило SIGTERM, поэтому всё равно не начало завершаться, а docker stop будет висеть уже 60 секунд. Причина в форме CMD, её и надо исправлять.
Не root: USER и владелец каталога
Процесс в контейнере по умолчанию работает от root (uid 0). Это тот же root ядра, что на хосте, ограниченный namespaces (у контейнера свой взгляд на процессы, сеть и файлы, урок 4.1) и capabilities (вместо «всё или ничего» ядро делит права root на мелкие части, и контейнер получает урезанный набор). Если в приложении найдут дыру или ошибётся конфигурация (например, смонтирован docker.sock или запущено с --privileged), root в контейнере превращается в root на хосте. Принцип наименьших привилегий: процесс получает ровно столько прав, сколько нужно для работы.
В офисе у уборщика есть ключ только от кабинетов, которые он убирает, а не мастер-ключ от всего здания. Если ключ потеряют, ущерб ограничен.
Пользователь в Linux определяется числом uid (и группа числом gid). Имя notes это удобная надпись, ядро смотрит только на числа. Мы договорились в курсе про uid и gid 10001 для «Заметок». Ставим их в три шага, и порядок обязателен:
- Пока ты ещё root (по умолчанию все
RUNидут от root), создаёшь группу и пользователя (groupadd,useradd) и каталог данных с владельцем 10001 (mkdir,chown). - Инструкцией
USER 10001:10001говоришь: «все следующие инструкции и сам контейнер работают от этого пользователя». - Больше ничего от root не выполняется.
Если поставить USER раньше chown или вообще не выдать права на /data, обычный пользователь не сможет ни создать файл в каталоге, принадлежащем root, ни поменять владельца.
Вот как это выглядит на деле. В прогоне мы добавили только USER 10001:10001, а RUN mkdir /data оставили без chown. Контейнер стартует и логи чистые (started host=0.0.0.0 port=8080), docker exec notes id показывает uid=10001 gid=10001 groups=10001. Но ls -ld /data (показать сам каталог, владельца и права, как в уроке 1.3) показывает drwxr-xr-x 2 root root: владелец root, права на запись только у него. Запись заметки возвращает {"error": "storage"} с кодом 500, /readyz отвечает 503 not ready (проверка этого эндпоинта в v3: можно ли писать в каталог файла), а в логах PermissionError-подобная строка [Errno 13] Permission denied: '/data/notes.txt'. После правильного порядка: ls -ld /data даёт drwxr-xr-x 1 notes notes 4096 ..., запись возвращает {"id": 1}.
Про флаги useradd: --system (системный пользователь, не для входа людей), --no-create-home (домашний каталог не нужен), --shell /usr/sbin/nologin (интерактивный вход запрещён, оболочки нет).
Прикинь сам: Каталог
/dataсоздан от root, а процесс идёт от uid 10001. Что получится при записи/data/notes.txtи какой командой чинится?
Permission denied: у процесса нет прав на каталог root. Исправление: chown 10001:10001 /data до инструкции USER.
Осторожно, частое заблуждение: что достаточно указать USER и всё заработает. Нет: USER меняет, кто запускает процесс, но не права на уже существующие каталоги. Ещё путают: «chmod 777 /data быстрее». Так работает, но открывает каталог всем, и это красный флаг на любом ревью.
Главное: процесс запускают не от root: сначала создают пользователя и выдают права на каталог данных, потом ставят
USER.
При создании пользователя сборка печатает предупреждение. Разберёмся, что оно значит.
Проверь понимание:
USER 10001:10001стоит в Dockerfile раньше, чемRUN mkdir /data. Что произойдёт при сборке?
Ответ
mkdir тоже пойдёт от пользователя 10001, а он не может создавать каталоги в /. Сборка упадёт с mkdir: cannot create directory '/data': Permission denied. Поэтому создание каталога и chown выполняются до USER.
Предупреждение useradd про SYS_UID_MAX
При сборке из задания 3 в выводе появится строка с warning. Новичок видит слово «предупреждение», пугается и начинает искать ошибку. Здесь её нет, и полезно понять почему.
Команда useradd создаёт пользователя. В Debian (на нём построен python:3.13-slim) номера пользователей поделены на диапазоны, записанные в файле /etc/login.defs: системные пользователи (службы вроде www-data, не люди) получают номера от SYS_UID_MIN до SYS_UID_MAX, по умолчанию 100-999, обычные люди получают номера от 1000. Ты просишь --system (я служба) и одновременно --uid 10001 (номер вне диапазона служб). useradd замечает несоответствие и сообщает об этом:
useradd warning: notes's uid 10001 outside of the SYS_UID_MIN 100 and SYS_UID_MAX 999 range.
Числа 100 и 999 у тебя могут отличаться, смысл тот же: «номер не из диапазона служб, я всё равно его создам». Это именно предупреждение, а не ошибка: код выхода 0, сборка идёт дальше, пользователь создан.
Почему это безопасно. Диапазоны нужны людям для порядка, ядро на них не смотрит: права проверяются по самому числу uid. Мы нарочно взяли 10001: он не пересекается ни с людьми (от 1000), ни со службами (до 999), а Kubernetes в теме 5 с runAsNonRoot: true требует числовой uid, не равный 0. Большой номер удобен тем, что не пересекается с uid хоста.
Что делать не нужно. Не убирай --system и не ставь uid 999, чтобы «заглушить» строку: договорённость курса 10001, и дальше уроки на неё опираются. Не правь /etc/login.defs и не подавляй вывод (2>/dev/null): в слое это лишняя правка, а настоящую ошибку в другой день ты тоже не увидишь. Единственное, что стоит проверить: строка называется warning, а не error, и итоговый docker build завершился без красного текста.
Прикинь сам:
useradd --systemс uid 10001 печатаетwarning. Сборка остановится и создан ли пользователь?
Нет, не остановится: код выхода 0, пользователь создан. Номер просто выходит за диапазон «служебных» 100-999.
Осторожно, частое заблуждение: что предупреждение значит «пользователь не создался». Он создан: проверь docker run --rm notes:0.3.0 id notes (после сборки).
Главное:
warningпро диапазон uid не ошибка: пользователь создан, а правила курса (uid 10001) менять не надо.
Процесс жив, но как Docker узнает, что он здоров?
Проверь понимание: чем отличается
useradd warning: ... outside of the ... rangeотuseradd: UID 10001 is not unique?
Ответ
Первое это предупреждение: номер свободен, но «нестандартный», пользователь создан. Второе это ошибка: номер уже занят другим пользователем, useradd останавливается, сборка падает.
HEALTHCHECK: как Docker узнаёт, что сервис здоров
Статус Up у контейнера значит только «процесс жив». Приложение может быть живо, но зависнуть или не слушать порт. Проверка здоровья (health check) даёт Docker способ спросить у самого приложения, всё ли в порядке.
Инструкция HEALTHCHECK задаёт команду. Docker периодически запускает её внутри контейнера. Код выхода 0 значит «здоров» (healthy), 1 значит «нездоров» (unhealthy). Параметры: --interval=10s (как часто проверять), --timeout=3s (сколько ждать ответа), --start-period=5s (время на запуск, за это время неудачи не считаются), --retries=3 (после скольких неудач подряд статус станет unhealthy). Пока проверок не было, статус starting. В slim-образе нет curl, поэтому проверку пишем на Python из стандартной библиотеки: python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/healthz')". Функция urlopen бросает исключение, если сервер не ответил или вернул ошибку, и Python завершается с кодом 1.
Теперь на числах. Через 16 секунд после старта docker ps показал Up 16 seconds (healthy). Проверки шли в 12:12:52 и 12:13:02, обе с ExitCode: 0. Затем мы запустили контейнер с -e PORT=9090: приложение слушает 9090, а проверка стучится в 8080. Через 40 секунд статус Up 40 seconds (unhealthy), FailingStreak: 4, а в Output каждой пробы длинный текст ConnectionRefusedError: [Errno 111] Connection refused. Здесь проверка внутри контейнера использует 127.0.0.1, это правильно: она выполняется в том же сетевом пространстве, что и приложение.
Прикинь сам:
--interval=10s --retries=3. Через сколько секунд после неудачных проверок статус станетunhealthy, если запуск уже закончился?
Примерно через 30 секунд: три проверки подряд по 10 секунд. Учти и --timeout каждой проверки.
Осторожно, частое заблуждение: что Docker перезапустит unhealthy контейнер. Нет: обычный docker run только показывает статус. Действовать на него могут оркестраторы (Kubernetes использует собственные пробы, урок 5.7) и Compose (условие depends_on: condition: service_healthy, урок 4.5).
Главное: HEALTHCHECK показывает статус
healthyилиunhealthy, но обычный Docker контейнер сам не перезапускает.
Образ готов к запуску. Осталось его правильно назвать.
Проверь понимание: контейнер в статусе
Up, аdocker psпишет(unhealthy). Что проверишь первым?
Ответ
Вывод проб (docker inspect печатает всё, что Docker знает о контейнере, в виде JSON, а --format достаёт из него одно поле; подробно разберём в уроке 4.9): docker inspect --format '{{json .State.Health}}' notes. Он покажет код выхода и текст ошибки последних проверок. Чаще всего проверка бьёт не в тот порт или приложение не слушает нужный адрес.
Тег образа и версия
Образ нужно уметь однозначно назвать: «вот тот, который в проде», «вот тот, на который откатываемся». Название образа это имя:тег.
Как устроено и пример. Мы назовём образ notes:0.3.0: имя notes, тег 0.3.0 (semver из урока 3.5: мажорная, минорная, патч-версия). Такая же версия у git-тега v0.3.0: по образу в проде всегда можно найти коммит. Тег изменяем: можно собрать другой образ и назвать его тем же тегом (при пересборке в задании 2 мы делаем именно так, старый образ остаётся без тега). Поэтому в проде, где нужна абсолютная воспроизводимость, используют digest: notes@sha256:..., хэш содержимого образа, который однозначно указывает на один образ (урок 4.7).
Прикинь сам: Образ собран как
notes:0.3.0, а git-тегv0.3.0. Что даёт совпадение версий и чем всё равно рискует тег?
По версии образа в проде находят коммит. Но тег можно переприсвоить другому образу, поэтому для абсолютной точности нужен digest sha256:....
Осторожно, частое заблуждение: что latest это «самая свежая и безопасная». Это обычное слово. Оно достаётся тому образу, которому его присвоили последним.
Главное: тег образа совпадает с версией приложения и git-тегом, а тег
latestв курсе не используется.
Тег есть. А какой базовый образ взять в FROM?
Проверь понимание: зачем закреплять базу как
python:3.13-slim, а неpython:slim?
Ответ
Без версии сборка завтра может внезапно взять Python 3.14 и сломать приложение. Закреплённая минорная версия делает сборку воспроизводимой; патч-обновления внутри 3.13 приходят сами, что для безопасности хорошо.
Из чего выбрать базовый образ
FROM определяет всё: размер, набор программ внутри, скорость обновлений безопасности. Выбор базы это первое решение в Dockerfile, и от него зависят все следующие.
Для Python есть три привычных семейства. Обычный python:3.13 построен на полной Debian с компиляторами и сотнями утилит: удобно для отладки, но образ весит больше гигабайта, а лишние программы это лишняя поверхность для уязвимостей. python:3.13-slim это Debian без всего лишнего: Python есть, curl, ps и компиляторов нет. python:3.13-alpine построен на Alpine Linux, где вместо системной библиотеки glibc стоит musl: образ совсем маленький, но часть готовых Python-пакетов под musl приходится собирать из исходников, и это долго и хрупко. Есть ещё «distroless» (образы без оболочки и пакетного менеджера): совсем минимальные, но в них нельзя зайти через docker exec ... sh, поэтому для первого знакомства они неудобны.
Разберём пример. Мы взяли slim, потому что это компромисс: образ около 200 МБ на диске, есть обычная Debian-система с привычным поведением, а готовые пакеты с PyPI ставятся без сборки. Цена компромисса видна в задании 3: в образе нет curl, и проверку здоровья мы пишем на Python. Если бы проверять пришлось чем-то, чего нет в образе, пришлось бы ставить это отдельным слоем (apt-get install), увеличивая образ и поверхность атаки.
Прикинь сам:
python:3.13весит 1,6 ГБ,python:3.13-slim204 МБ. Во сколько раз slim легче?
Примерно в 8 раз (1600 делим на 204). Цена: в slim нет curl, ps и компиляторов, поэтому проверку здоровья пишут на Python.
Осторожно, частое заблуждение: что «меньше значит лучше всегда». Alpine меньше, но проблемы совместимости с musl стоят дороже, чем 100 МБ диска. Начинай с slim и меняй базу, только когда есть конкретная причина.
Главное: для начала берут
slim: компромисс между размером и привычным поведением, а базу меняют, когда есть причина.
Дальше пишем шаги сборки: COPY, ADD и RUN.
Проверь понимание: почему в образе
slimнетcurl, и почему это считается плюсом?
Ответ
Каждая лишняя программа это дополнительный код, в котором могут найти уязвимость, и лишние мегабайты в каждом слое. Если в образе нет curl, атакующему, попавшему в контейнер, труднее скачать своё. Нужный инструмент добавляют явно, когда без него не обойтись.
COPY, ADD и RUN: как правильно писать шаги сборки
Три инструкции делают почти всю работу образа, и у каждой есть ловушка, которая стоит новичку часов.
COPY источник назначение копирует файлы из контекста сборки в образ. Есть похожая ADD, которая умеет ещё распаковывать архивы и качать по URL, но эта «магия» делает сборку непредсказуемой, поэтому по умолчанию используем COPY. RUN команда выполняет команду при сборке и сохраняет изменения файлов в новый слой. Каждая RUN это отдельный слой, поэтому связанные действия объединяют через && в одну: так мы создали пользователя и каталог одним слоем. Временные файлы, созданные в одном RUN и удалённые в следующем, остаются в слое первого (тот же принцип неизменяемости, что с секретами). Удалять мусор нужно в той же RUN, где он создан.
Отдельно про WORKDIR: он заменяет привычные cd в RUN. Каждая RUN стартует заново в рабочем каталоге, поэтому RUN cd /app не «запоминается» для следующей инструкции, а WORKDIR /app да.
Посмотрим на примере. Строка RUN pip install --no-cache-dir -r requirements.txt содержит два приёма. -r requirements.txt берёт пакеты из файла, который скопирован отдельным, более ранним COPY: благодаря этому слой с установкой зависит только от списка, а не от кода. --no-cache-dir говорит pip не хранить скачанные архивы: они нужны только во время установки, а в образе занимали бы место.
Прикинь сам: В одной
RUNскачали архив 200 МБ, в следующей удалили. Сколько весит образ по сравнению с образом без архива?
На 200 МБ больше: слой первой RUN хранит архив, а вторая только помечает его удалённым. Скачивать, распаковывать и удалять надо в одной RUN.
Осторожно, частое заблуждение: что RUN cd /app && ... и WORKDIR это одно и то же: cd действует только внутри этой RUN. Ещё путают точку в COPY requirements.txt .: это не «текущий каталог хоста», а текущий каталог внутри образа, то есть /app после WORKDIR.
Главное:
COPYпо умолчанию,ADDтолько если нужна его магия;WORKDIRвместоcd; временные файлы удаляют в той жеRUN.
Для установки пакетов нужен файл со списком зависимостей.
Проверь понимание: в одной
RUNты скачал архив на 200 МБ и распаковал его, а в следующейRUNудалил архив. Сколько весит образ?
Ответ
Всё так же с архивом: слой первой RUN содержит архив, второй слой лишь помечает его удалённым. Правильно скачивать, распаковывать и удалять в одной RUN.
Зависимости: requirements.txt и pip
Приложение редко пишется целиком с нуля: ему нужны чужие готовые библиотеки (например, драйвер для общения с базой данных, он появится в уроке 4.4). Такие библиотеки называют зависимостями (dependencies): твой код без них не запустится. Без списка зависимостей новый человек (или Docker) не узнает, что ставить, и приложение упадёт на первой строке import.
Список покупок к рецепту: «мука 500 г, яйца 3 шт.». Повару не нужно угадывать, что докупить. Где аналогия ломается: в списке для программ указывают не только название, но и версию (==1.2.3), иначе завтра в магазине будет «другая мука».
pip - программа, которая скачивает пакеты Python с сервера PyPI и ставит их. Команда pip install -r requirements.txt читает файл (по пакету на строку, например psycopg==3.2.3) и ставит всё из него. Строка с # в начале - комментарий, pip её пропускает. Поэтому файл, состоящий из одного комментария, как в нашем задании, допустим: пакетов ноль, pip install завершится успешно и ничего не скачает.
Вот как это выглядит на деле. Файл requirements.txt копируется в образ отдельной инструкцией раньше, чем код. Тогда слой pip install зависит только от списка. Правишь app.py: список тот же, слой в кэше, пакеты не качаются. Добавляешь пакет в список: пересобирается слой установки и всё ниже. Это тот же принцип «редкое выше, частое ниже», что и в разделе про кэш.
Прикинь сам: В
requirements.txtодна строкаpsycopg, а завтра выходит новая мажорная версия. Что станет с новой сборкой?
Она поставит новую версию с другим поведением, и образ перестанет быть повторяемым. Поэтому пишут psycopg==3.2.3.
Осторожно, частое заблуждение: что requirements.txt нужен только «большим» проектам. Он нужен всегда, когда есть хоть одна внешняя библиотека, а пустой файл заранее закладывает правильный порядок слоёв.
Главное: список зависимостей копируют в образ отдельно и раньше кода, а версии закрепляют через
==.
Слои складываются в одну картину. Как именно?
Проверь понимание: почему в
requirements.txtпишутpsycopg==3.2.3, а не простоpsycopg?
Ответ
Без версии pip поставит самую свежую на момент сборки. Через полгода та же сборка принесёт другую версию с другим поведением, и образ перестанет быть повторяемым. Подробнее в разделе про воспроизводимость ниже.
Файловая система образа: как слои складываются в одну
Контейнеру нужно видеть один каталог /, хотя образ это стопка слоёв, а сверху ещё слой для записи. Кто-то должен склеить их в одну картину.
Это делает драйвер хранилища; в современном Docker на Linux он называется overlay2 и работает на возможностях ядра (overlayfs). Он показывает процессу объединённый вид: для каждого пути берётся версия из самого верхнего слоя, где путь есть. Если файл лежит в нескольких слоях, побеждает верхний. Если файл удалён, в верхнем слое остаётся специальная пометка «здесь ничего нет», и нижние слои по этому пути не видны, хотя физически лежат на диске. Запись в существующий файл нижнего слоя работает по схеме copy-on-write (копирование при записи): файл целиком копируется в верхний слой контейнера, и правится уже копия. Образ при этом не меняется.
Теперь на числах. Так объясняются размеры в docker history из задания 2: слой COPY app.py . весит 20,5 килобайта (только твой файл), а слои базы десятки мегабайт (там Debian). Полный размер образа это сумма слоёв. Два разных образа на одной базе python:3.13-slim хранят слои базы на диске один раз.
Прикинь сам:
chown -Rпо каталогу в 300 МБ из нижнего слоя. Насколько вырастет образ?
Примерно на 300 МБ: смена владельца копирует каждый файл в новый слой. Поэтому chown делают только для каталога данных.
Осторожно, частое заблуждение: что слой это «копия всей системы». Слой хранит только разницу с предыдущим. Поэтому небольшая RUN почти ничего не весит, а инструкция, которая трогает много файлов (например, chown -R по большому каталогу), создаёт слой размером со все эти файлы.
Главное: драйвер overlay2 показывает процессу объединённый вид слоёв, где верхний слой перекрывает нижние.
Теперь научимся настраивать образ при сборке и запуске.
Проверь понимание: зачем
chown 10001:10001 /dataделается по одному каталогу, а неchown -Rпо всему образу?
Ответ
Смена владельца файла из нижнего слоя копирует файл в новый слой. chown -R по всей системе продублировал бы сотни мегабайт и раздул бы образ. Права выдают только тем каталогам, куда процесс должен писать.
ENV и ARG: настройки при сборке и при запуске
Одному образу нужны разные настройки: порт, путь к данным, версия. Если вшить их в код, для каждого окружения придётся собирать свой образ. Переменные позволяют собрать образ один раз и настраивать при запуске.
Заводская табличка на приборе и ручка настройки на его панели. Табличка (ARG) записана при изготовлении и нужна только на заводе. Ручка (ENV) стоит в положении «по умолчанию», и хозяин может повернуть её при включении.
ENV имя=значение записывает переменную окружения в метаданные образа. Она доступна и при сборке (следующим инструкциям), и в запущенном контейнере. Её можно переопределить флагом docker run -e имя=значение: значение из -e побеждает значение из образа. ARG имя=значение живёт только во время сборки и передаётся флагом docker build --build-arg имя=значение; в работающем контейнере её нет. Но значение ARG остаётся в истории образа, поэтому секреты через него передавать нельзя.
Разберём пример. В нашем Dockerfile ENV HOST=0.0.0.0 PORT=8080 NOTES_DATA=/data/notes.txt задаёт значения по умолчанию. В задании 1 мы запустили контейнер с -e HOST=127.0.0.1, и приложение прочитало 127.0.0.1: -e перебил ENV. APP_VERSION мы в образ не вшивали: без -e приложение покажет vdev, а с -e APP_VERSION=0.3.0 покажет v0.3.0.
Прикинь сам: В образе
ENV PORT=8080, а запуск с-e PORT=9090, при этомHEALTHCHECKстучится в 8080. Какой статус получит контейнер?
unhealthy: приложение слушает 9090, а проверка вшита в образ и бьёт в 8080. -e перекрывает ENV только для приложения.
Осторожно, частое заблуждение: что ENV это способ хранить пароли. Значение ENV видно любому, кто выполнит docker inspect на образе или контейнере. Пароли передают при запуске и не пишут в Dockerfile.
Главное:
ENVзадаёт значение по умолчанию и переопределяется-e,ARGживёт только при сборке, а пароли ни там, ни там не хранят.
Закрепим всё, что влияет на повторяемость сборки.
Проверь понимание: в образе
ENV PORT=8080, а контейнер запущен с-e PORT=9090. Какой порт увидит приложение?
Ответ
- Значение из
docker run -eперекрывает значение из образа. Поэтому HEALTHCHECK, который стучится в 8080, начнёт падать: он вшит в образ и про новый порт не знает (это ты увидишь в задании 3).
Воспроизводимость: почему «у меня собирается» не аргумент
Смысл образа в том, чтобы получить одно и то же везде. Но сборка зависит от внешнего мира: база обновляется, пакеты на PyPI выходят новыми версиями. Если ничего не закрепить, образ, собранный сегодня и через месяц из одного Dockerfile, будет разным, и ошибка «на CI работает, у меня нет» вернётся.
Воспроизводимость даёт закрепление версий на трёх уровнях. База: python:3.13-slim вместо python:latest (для абсолютной точности добавляют digest, как в строке FROM ...@sha256:... из вывода сборки). Пакеты: requirements.txt с точными версиями (пакет==1.2.3), а не «любая свежая». Сам образ: тег с версией приложения, совпадающей с git-тегом. Кэш сборки на воспроизводимость не влияет, он лишь ускоряет: чистая сборка (docker build --no-cache) должна давать рабочий образ так же, как сборка из кэша.
Посмотрим на примере. У «Заметок» v3 нет внешних пакетов, поэтому воспроизводимость упирается в базу. В выводе сборки в шаге FROM виден полный digest базового образа: именно он указывает на конкретное содержимое, а тег 3.13-slim может со временем указать на обновлённую сборку. Когда в уроке 4.4 появятся пакеты для работы с базой данных, их версии закрепим в requirements.txt.
Прикинь сам: Какие три уровня нужно закрепить, чтобы образ из января и образ из марта совпадали?
База (python:3.13-slim вместо latest, а для точности digest), пакеты (== в requirements.txt) и тег самого образа с версией приложения.
Осторожно, частое заблуждение: что достаточно закрепить только базу. Пакеты тоже обновляются: незакреплённый pip install requests через год поставит другую версию с другим поведением.
Главное: воспроизводимость даёт закрепление версий базы, пакетов и образа.
Остался последний вопрос о запуске: как соотносятся ENTRYPOINT и CMD.
Проверь понимание: образ, собранный в январе, работает, а такой же Dockerfile в марте даёт ошибку импорта. Что искать?
Ответ
Незакреплённые версии: либо новая версия пакета из requirements.txt без ==, либо обновившаяся база под тем же тегом. Сравни docker history и pip list в старом и новом образах.
ENTRYPOINT и CMD: кто главный при запуске
Иногда образ должен быть «программой» (например, docker run образ --help передаёт флаг программе), а иногда «системой», в которой можно запустить что угодно. Для этого команда запуска разделена на две инструкции.
Кофемашина. ENTRYPOINT это то, что она делает всегда: варит кофе. CMD это рецепт по умолчанию: эспрессо. Нажав другую кнопку, ты заменяешь рецепт, но машина остаётся кофемашиной.
Итоговая команда контейнера это ENTRYPOINT плюс CMD. Аргументы после имени образа в docker run заменяют CMD целиком. ENTRYPOINT заменяется только флагом --entrypoint. Если ENTRYPOINT не задан, CMD сам является командой, и docker run образ sh запустит sh вместо приложения. Обе инструкции пишут в exec-форме, по причинам из раздела про PID 1.
Вот как это выглядит на деле. У «Заметок» только CMD ["python", "app.py"]. Поэтому docker run --rm notes:0.3.0 python -c "print(1)" выполнит python -c "print(1)" вместо запуска сервера, а docker run --rm notes:0.3.0 ls /app покажет файлы и завершится. Это удобно для отладки. Будь у образа ENTRYPOINT ["python"] и CMD ["app.py"], то docker run образ -c "print(1)" тоже работало бы, но запустить в нём ls без --entrypoint не получилось бы.
Прикинь сам: У образа только
CMD ["python", "app.py"]. Что сделаетdocker run --rm notes:0.3.0 ls /app?
Покажет файлы каталога и завершится: аргументы заменяют CMD, сервер не запустится. Это удобно для отладки.
Осторожно, частое заблуждение: что CMD выполняется при сборке. Нет, при сборке работает RUN, а CMD только записывает, что запускать при старте контейнера.
Главное: аргументы после имени образа заменяют
CMD, аENTRYPOINTзаменяется только флагом--entrypoint.
Проверь понимание: ты запустил
docker run notes:0.3.0 sleep 5. Что произойдёт с приложением?
Ответ
Приложение не запустится: sleep 5 заменил CMD, и контейнер просто подождёт 5 секунд и завершится.
Практика
Все задания идут в ~/notes (репозиторий проекта, приложение v3). Docker Engine из урока 4.1 работает, docker run hello-world проходит. Вывод в уроке получен на Docker 29.6.2 и образе python:3.13-slim: ID, размеры, время и адреса у тебя будут другими.
Задание 1. Первый Dockerfile
Цель: собрать образ «Заметок» и запустить его.
Предскажи: мы запустим контейнер с -p 8080:8080 и намеренно передадим -e HOST=127.0.0.1. Ответит ли приложение с хоста, если контейнер в статусе Up и в логах написано, что сервер запущен? Ответ проверишь в шаге 5.
Ответ
Не ответит. Для контейнера 127.0.0.1 это его собственная петля (loopback), а проброшенный порт приходит на сетевую карту контейнера. Слушать нужно 0.0.0.0.
Шаги:
-
Проверь, что каталог на месте, и подготовь
requirements.txt. У «Заметок» v3 внешних зависимостей нет (всё из стандартной библиотеки Python), но файл нужен, чтобы порядок слоёв был правильным уже сейчас: в уроке 4.4 появитсяpsycopg.cd ~/notes ls app.py echo "# зависимости приложения (пока только стандартная библиотека)" > requirements.txtРазбор:
cd ~/notesпереходит в каталог проекта (~это твой домашний каталог).ls app.pyтолько проверяет, что файл есть: если его нет, команда напишетNo such file or directory, и дальше идти рано.echo "..." > requirements.txtзаписывает строку в файл (>создаёт файл или перезаписывает). Строка начинается с#: вrequirements.txtэто комментарий,pipего пропустит.Убедись, что
app.pyэто v3:curl -s localhost:8080мы запустим позже, а пока сравниgrep -c 'HTTP/1.1' app.py: число должно быть больше нуля (HTTP/1.1 появился в v3). -
Создай
.dockerignore:cat > .dockerignore <<'IGN' .git .github k8s helm infra monitoring docs versions break .env __pycache__ *.md IGNРазбор:
cat > файл <<'IGN'это «here-документ»: оболочка читает все строки до словаIGNиcatзаписывает их в файл. Кавычки вокруг'IGN'отключают подстановки ($), это защищает от неожиданных замен. Это тот же приём, что ты использовал в уроке 1.8. Значение строк: каждая это путь или шаблон, который не попадёт в контекст сборки. Каталоги, которых у тебя ещё нет (k8s,helm,infra,monitoring,docs), в списке про запас: они появятся в следующих темах, и в образ им попадать незачем..envв списке, чтобы будущий файл с паролями не уехал в образ.*.mdисключает README и заметки. -
Создай
Dockerfile(именно с таким именем, без расширения):# База закреплена по минорной версии, latest не используем FROM python:3.13-slim # Рабочий каталог: инструкции ниже выполняются в нём, каталог создаётся сам WORKDIR /app # Каталог данных (пока с владельцем root, в задании 3 это изменится) RUN mkdir /data # Сначала зависимости: этот слой меняется редко и живёт в кэше COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # Потом код: меняется часто, пересобирается только он COPY app.py . # Конфигурация по умолчанию: слушаем все интерфейсы контейнера ENV HOST=0.0.0.0 PORT=8080 NOTES_DATA=/data/notes.txt # Документация для человека, порт не публикует EXPOSE 8080 # exec-форма: python получает PID 1 и сигналы CMD ["python", "app.py"]Построчно.
FROM python:3.13-slim: база, Debian с Python 3.13.WORKDIR /app: дальше.вCOPYи рабочий каталог процесса это/app.RUN mkdir /data: создаёт каталог, куда приложение будет писать заметки.COPY requirements.txt .: копирует файл из контекста в текущий каталог образа (/app, точка в конце).RUN pip install --no-cache-dir -r requirements.txt:pip(установщик пакетов Python) ставит пакеты из списка;-r файлзначит «список пакетов из файла»,--no-cache-dirне оставляет в образе кэш скачанных файлов и делает образ меньше.COPY app.py .: код приложения.ENV ...: три переменные, которые читает приложение;NOTES_DATAуказывает путь к файлу заметок в каталоге/data.EXPOSE 8080: запись «слушаем 8080».CMD [...]: команда запуска в exec-форме. -
Собери образ и посмотри на него:
docker build -t notes:0.3.0 . docker image ls notesРазбор:
docker buildсобирает образ по Dockerfile.-t notes:0.3.0(tag) даёт образу имя и тег. Последний аргумент.это контекст сборки, текущий каталог.docker image ls notesпоказывает образы с именемnotes. -
Сначала запусти «неправильный» контейнер, чтобы увидеть симптом (порт 8080 должен быть свободен):
docker run -d --name notes -p 8080:8080 -e HOST=127.0.0.1 notes:0.3.0 curl -sS localhost:8080/healthz; echo "код curl: $?" docker logs notes docker rm -f notesРазбор.
docker run -dзапускает контейнер в фоне (detached) и печатает его длинный ID.--name notesдаёт имя, чтобы не работать с ID.-p 8080:8080публикует порт:порт-хоста:порт-контейнера.-e HOST=127.0.0.1(environment) переопределяет переменную изENV.curl -sS:-sтихий режим без индикатора загрузки,-Sвсё же показывает ошибку, если она случилась.$?это код выхода последней команды.docker logs notesпоказывает то, что приложение написало в stderr/stdout.docker rm -f notesудаляет контейнер,-f(force) даже работающий. -
Теперь запусти как надо и проверь:
docker run -d --name notes -p 8080:8080 notes:0.3.0 curl -s localhost:8080/healthz; echo curl -s -X POST localhost:8080/notes -d '{"text":"из контейнера"}'; echoРазбор:
-X POST -d '...'отправляет POST с телом (это ты делал в уроке 2.4);echoпосле;просто переводит строку, потому что сервер не добавляет перевод строки в конце.
Что должно получиться:
Шаг 4, docker image ls notes:
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
notes:0.3.0 21ce81ece17f 215MB 46.8MB
Шаг 5:
d52493d1fb6b16a9834551953a908559d88f2a39ef4ce7b2732cb29632ba3a25
curl: (56) Recv failure: Connection reset by peer
код curl: 56
2026-09-30 12:12:18,070 INFO started host=127.0.0.1 port=8080
notes
Шаг 6:
d70689b6e4cfbb24911e39c8b40b63c5bb9a16593d1796d3be32d602402b6dd9
ok
{"id": 1}
Как читать вывод:
docker image ls:IMAGEимя и тег,IDкороткий идентификатор (у тебя другой).DISK USAGEсколько образ занимает на диске в распакованном виде,CONTENT SIZEсколько весят его сжатые слои в реестре. Мой Mac с процессором arm64, у тебя, скорее всего, amd64: числа будут отличаться в пределах десятков мегабайт. Ориентир: порядка 200 МБ на диске (в основном это база).- Шаг 5:
Connection reset by peerзначит, что соединение принято и сброшено с той стороны. Логи при этом показываютhost=127.0.0.1: приложение запущено, но слушает не там. Иногда вместо (56) видятcurl: (52) Empty reply from server: причина та же. Последняя строкаnotesэто ответdocker rm -f. - Шаг 6:
okиз/healthz,{"id": 1}это ответ на POST (в JSON после двоеточия есть пробел, так уjson.dumpsпо умолчанию).
Не понимаешь ошибку
Permission deniedпри записи в/data? Дай нейросети свой Dockerfile и вывод команды, но сам проверьls -ldn /data: она любит советоватьchmod 777, а это не лечение.
Объясни себе:
- Почему
EXPOSE 8080не заменяет-p 8080:8080? - Что произойдёт с заметкой после
docker rm -f notes? Проверь: удали контейнер, запусти снова тем жеdocker runи сделайcurl -s localhost:8080/notes; echo. Ответ[]значит, что заметка пропала: данные жили в слое контейнера. Как это исправить, тема урока 4.3.
Типичные ошибки:
failed to compute cache key: failed to calculate checksum of ref ...: "/requirements.txt": not found: файла нет в контексте сборки или он исключён в.dockerignore. Проверьlsи содержимое.dockerignore.docker: Error response from daemon: Conflict. The container name "/notes" is already in use by container "26d1be9e...". You have to remove (or rename) that container to be able to reuse that name.: контейнер с таким именем уже есть (даже остановленный).docker rm -f notes.Bind for 0.0.0.0:8080 failed: port is already allocated: порт 8080 хоста занят. Либо остался старый контейнер (docker ps), либо работает хостовыйnotes.service: он должен быть остановлен в уроке 4.1,sudo systemctl disable --now notes. (В Docker Desktop текст обрамлён словамиfailed to set up container networking: driver failed programming external connectivity on endpoint ..., смысл тот же.)Cannot connect to the Docker daemon at unix:///var/run/docker.sock: демон не запущен или нет прав.sudo systemctl start docker, проверь группуdocker(урок 4.1).
Задание 2. Кэш слоёв и docker history
Цель: увидеть, как порядок инструкций определяет скорость пересборки.
Предскажи: ты добавишь комментарий в app.py и пересоберёшь образ. Сколько шагов будет CACHED, а сколько выполнится заново?
Ответ
Из кэша возьмутся все шаги до COPY app.py: WORKDIR, RUN mkdir, COPY requirements.txt, RUN pip. Заново выполнится только COPY app.py .. Инструкции ниже (ENV, EXPOSE, CMD) меняют лишь метаданные и создаются мгновенно.
Шаги:
-
Пересобери без изменений и посмотри пометки
CACHED:docker build --progress=plain -t notes:0.3.0 . 2>&1 | grep -E 'CACHED|^#[0-9]+ \['Разбор.
--progress=plainвключает простой построчный вывод вместо анимации: без него в терминале BuildKit перерисовывает экран, иgrepнечего искать.2>&1направляет поток ошибок (stderr) туда же, куда обычный вывод (stdout): BuildKit пишет прогресс в stderr, а|(пайп) передаёт по цепочке только stdout, так что без этогоgrepничего не увидит.grep -E '...'отбирает строки по регулярному выражению:CACHEDэто буквальное слово,|внутри кавычек значит «или»,^#[0-9]+ \[это строки, которые начинаются с#, потом число (номер шага сборки), пробел и[(скобку экранируем обратным слешем, потому что в регулярках[особая). -
Измени код и собери снова:
echo "# правка для проверки кэша" >> app.py docker build --progress=plain -t notes:0.3.0 . 2>&1 | grep -E 'CACHED|^#[0-9]+ \['>>дописывает строку в конец файла (в отличие от>, которое перезаписывает). -
Убери правку и проверь, что файл вернулся к исходному:
sed -i '$ d' app.py tail -2 app.pyРазбор:
sed -iправит файл на месте, адрес$означает «последняя строка», командаdудаляет её.tail -2показывает две последние строки. -
Посмотри слои образа:
docker history notes:0.3.0
Что должно получиться:
Шаг 1 (все слои из кэша; порядок строк может отличаться, потому что BuildKit выполняет независимые шаги параллельно, а строка #N CACHED относится к шагу с тем же номером #N):
#1 [2/6] WORKDIR /app
#1 CACHED
#2 [1/6] FROM docker.io/library/python:3.13-slim@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b
#7 [4/6] COPY requirements.txt .
#7 CACHED
#8 [5/6] RUN pip install --no-cache-dir -r requirements.txt
#8 CACHED
#9 [3/6] RUN mkdir /data
#9 CACHED
#10 [6/6] COPY app.py .
#10 CACHED
Шаг 2 (после правки app.py: у последнего шага нет пометки):
#1 [2/6] WORKDIR /app
#1 CACHED
#2 [1/6] FROM docker.io/library/python:3.13-slim@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b
#7 [4/6] COPY requirements.txt .
#7 CACHED
#8 [3/6] RUN mkdir /data
#8 CACHED
#9 [5/6] RUN pip install --no-cache-dir -r requirements.txt
#9 CACHED
#10 [6/6] COPY app.py .
Шаг 4:
IMAGE CREATED CREATED BY SIZE COMMENT
86b824173d3f 2 seconds ago CMD ["python" "app.py"] 0B buildkit.dockerfile.v0
<missing> 2 seconds ago EXPOSE [8080/tcp] 0B buildkit.dockerfile.v0
<missing> 2 seconds ago ENV HOST=0.0.0.0 PORT=8080 NOTES_DATA=/data/… 0B buildkit.dockerfile.v0
<missing> 2 seconds ago COPY app.py . # buildkit 20.5kB buildkit.dockerfile.v0
<missing> 3 seconds ago RUN /bin/sh -c pip install --no-cache-dir -r… 9.92MB buildkit.dockerfile.v0
<missing> 4 seconds ago COPY requirements.txt . # buildkit 12.3kB buildkit.dockerfile.v0
<missing> 4 seconds ago RUN /bin/sh -c mkdir /data # buildkit 8.19kB buildkit.dockerfile.v0
<missing> About a minute ago WORKDIR /app 8.19kB buildkit.dockerfile.v0
и ниже ещё строки самого базового образа (в них слои по 110 МБ и 43,7 МБ).
Как читать вывод:
- Число
[3/6]это номер шага и общее число шагов. Шаг[1/6]этоFROM, аinternalстроки (загрузка Dockerfile, контекста) служебные. В нашем Dockerfile 6 шагов, потому чтоENV,EXPOSEиCMDслоёв файлов не создают. На Docker Desktop (Mac, Windows) у меня первые два шага были подписаны[1/5]и[2/5]: это особенность отображения, на Linux все шаги идут из6. CACHEDпод номером шага: этот слой взят из кэша, сборщик его не выполнял. Во втором прогоне у#10 [6/6] COPY app.py .пометки нет: он выполнялся заново, потому что файл изменился. Всё выше остаётся в кэше, потому что мы поставили код ниже зависимостей.- В
docker historyстроки идут сверху вниз от последней инструкции к первой.SIZEпоказывает, сколько файлов добавил слой:ENV,EXPOSE,CMDвесят0B(метаданные), твойCOPY app.pyвесит 20,5 килобайта. Тяжёлые строки это слои базового образа (<missing>в колонкеIMAGEнормально: для слоёв, полученных при сборке, у BuildKit нет отдельных ID).
Объясни себе:
- Что было бы, если бы
COPY . .стоял вышеpip install? (Подсказка: вспомни разбор в теории про порядок.) - Почему
ENV,EXPOSEиCMDвdocker historyвесят 0 байт?
Типичные ошибки:
- Кэш не срабатывает вообще, хотя ты ничего не менял: в контекст попал изменчивый файл (лог,
__pycache__), иCOPY . .каждый раз видит новый состав. Добавь его в.dockerignore. - В выводе нет слова
CACHED: без--progress=plainBuildKit рисует анимацию. Добавь флаг, как в командах выше. sed: -e expression #1, char 2: extra characters after command: кавычки вокруг$ dпотеряны или заменены. Скопируй команду точно, с одинарными кавычками. (На macOSsed -iтребует пустой аргумент:sed -i '' '$ d' app.py. В Ubuntu, где ты работаешь, это не нужно.)
Задание 3. Не root: USER и владелец /data
Цель: запустить сервис под uid 10001 и получить каталог данных, доступный этому пользователю.
Предскажи: сейчас docker exec notes id покажет uid=0(root). А сможет ли приложение писать в /data, если добавить только USER 10001:10001 и оставить RUN mkdir /data как есть?
Ответ
Нет. Каталог /data создан от root и принадлежит root, у обычного пользователя нет прав на запись. Контейнер запустится, но запись заметки вернёт 500. Владельца нужно сменить до USER, пока ты ещё root.
Шаги:
-
Проверь текущего пользователя (контейнер
notesиз задания 1 должен работать, если нет, запусти его зановоdocker run -d --name notes -p 8080:8080 notes:0.3.0):docker exec notes id docker exec notes ls -ld /dataРазбор:
docker exec контейнер командазапускает команду внутри работающего контейнера.idпоказывает uid, gid и группы.ls -ld /dataпоказывает сам каталог (а не его содержимое), владельца и права. -
Замени в
DockerfileстрокуRUN mkdir /data(и комментарий над ней) на создание пользователя и каталога, а передEXPOSEдобавьUSER:# Пользователь и каталог данных создаются до USER, пока мы ещё root. # Слой стоит выше кода: он не меняется, значит живёт в кэше. RUN groupadd --system --gid 10001 notes \ && useradd --system --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin notes \ && mkdir /data && chown 10001:10001 /data# Дальше и сам контейнер работает не от root USER 10001:10001Разбор. Обратный слеш
\в конце строки переносит команду на следующую: это однаRUN.&&выполняет следующую команду, только если предыдущая закончилась успешно (однаRUNэто один слой).groupadd --system --gid 10001 notesсоздаёт группуnotesс номером 10001.useraddсоздаёт пользователяnotes(флаги разобраны в теории).chown 10001:10001 /dataделает владельцем каталога пользователя и группу с номерами 10001 (chownты использовал в уроке 1.3). ИнструкцияUSER 10001:10001использует числа, а не имя: так безопаснее для Kubernetes, который проверяет только числовой uid.Сборка этого шага напечатает строку вроде
useradd warning: notes's uid 10001 outside of the SYS_UID_MIN 100 and SYS_UID_MAX 999 range.(в выводе BuildKit перед ней стоит номер шага, например#7 0.312). Это не ошибка:useraddлишь замечает, что--systemи номер 10001 не сходятся с диапазоном системных uid. Пользователь создан, сборка продолжается, делать ничего не нужно (подробно в разделе «Предупреждение useradd про SYS_UID_MAX» выше). -
Добавь
HEALTHCHECKпередCMD(Python-однострочник,curlв slim-образе нет):HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/healthz')"Разбор.
python -c "код"выполняет строку кода, не файл.urllib.request.urlopen(url)делает HTTP-запрос из стандартной библиотеки и бросает исключение при ошибке соединения или коде 4xx/5xx, тогда Python завершается кодом 1. Успешный ответ: код 0. Адрес127.0.0.1здесь верный: проверка выполняется внутри контейнера. -
Пересобери и перезапусти:
docker build -t notes:0.3.0 . docker rm -f notes docker run -d --name notes -p 8080:8080 notes:0.3.0 docker exec notes id curl -s -X POST localhost:8080/notes -d '{"text":"под uid 10001"}'; echo docker exec notes ls -ld /data -
Подожди 15 секунд и посмотри статус:
docker ps --filter name=notes --format 'table {{.Names}}\t{{.Status}}'Разбор:
--filter name=notesоставляет только контейнеры сnotesв имени.--format 'table ...'задаёт колонки:{{.Names}}и{{.Status}}это шаблоны Docker (поля контейнера),\tэто табуляция между колонками.
Что должно получиться:
Шаг 1 (до правки):
uid=0(root) gid=0(root) groups=0(root)
drwxr-xr-x 1 root root 4096 Sep 30 12:16 /data
После шагов 4 и 5:
uid=10001(notes) gid=10001(notes) groups=10001(notes)
{"id": 1}
drwxr-xr-x 1 notes notes 4096 Sep 30 12:16 /data
NAMES STATUS
notes Up 16 seconds (healthy)
Как читать вывод:
uid=10001(notes) gid=10001(notes) groups=10001(notes): процесс работает от пользователяnotes(число 10001, имя из/etc/passwdобраза). Если бы мы поставилиUSERбезuseradd, было бы простоuid=10001 gid=10001 groups=10001, без имён: работает и так, но имена делают вывод читаемым.drwxr-xr-x 1 notes notes ... /data: первоеdзначит каталог,rwxr-xr-xправа, дальше владелец и группа:notes notes. Пока в этих колонкахroot root, записать что-то может только root.Up 16 seconds (healthy): контейнер работает 16 секунд и последняя проба прошла. В первые 5 секунд (--start-period) вместо этого стояло бы(health: starting).
Если
docker historyили вывод сборки выглядит непонятно, вставь его нейросети и попроси объяснить каждый слой. Сверь ответ со своим Dockerfile: нейросеть часто путает слой с метаданными (0 байт) и слой с файлами.
Для эксперимента можно сделать первую ошибку намеренно: оставь RUN mkdir /data без chown, но добавь USER. Запись вернёт {"error": "storage"} 500, curl -s localhost:8080/readyz вернёт not ready с кодом 503, а docker logs notes покажет ERROR ошибка хранилища: [Errno 13] Permission denied: '/data/notes.txt'. Это ровно то, что ты будешь чинить в разделе «Сломай и почини».
Объясни себе:
- Почему
USERстоит послеRUN mkdirиchown, а не до? - Чем
HEALTHCHECKполезнее, чем «контейнер в статусе Up»?
Типичные ошибки:
PermissionError: [Errno 13] Permission denied: '/data'(в v3 приложение ловит её и пишет в логошибка хранилища: [Errno 13] Permission denied: '/data/notes.txt', а клиенту отдаёт 500): каталог принадлежит root илиUSERстоит раньшеchown. Поменяй порядок.- Статус
Up ... (unhealthy): проверка не проходит, чаще всего приложение слушает не тот порт (например, запущено с-e PORT=9090, а проба стучится в 8080).docker inspect --format '{{json .State.Health}}' notesпокажет код выхода и текст ошибки последних проб. useradd warning: ... outside of the SYS_UID_MIN 100 and SYS_UID_MAX 999 range: не ошибка, а предупреждение, игнорируй (см. теорию выше). Ошибка выглядит иначе: сборка остановилась и команда вернула ненулевой код.useradd: UID 10001 is not unique: такой uid уже занят в базовом образе (в проверенномpython:3.13-slim10001 свободен, но на другой базе так бывает). Найди конфликт в/etc/passwdобраза; договорённость курса это 10001.
Задание 4. Сигналы: exec-форма против shell-формы
Цель: измерить, как форма CMD влияет на остановку.
Предскажи: сколько секунд займёт docker stop при exec-форме, а сколько при shell-форме?
Ответ
Exec-форма: меньше секунды, потому что приложение получает SIGTERM и завершается само. Shell-форма: около 10 секунд, затем SIGKILL. Если бы приложение SIGTERM не обрабатывало, оно бы прожило таймаут и в exec-форме: у PID 1 нет обработчика по умолчанию. Сервер «Заметок» обрабатывает SIGTERM, это сделано в уроке 1.4.
Шаги:
-
Замерь остановку текущего контейнера:
time docker stop notes docker logs notes 2>&1 | tail -3 docker inspect --format '{{.State.ExitCode}}' notesРазбор:
time командазапускает команду и в конце печатает, сколько она заняла (realэто реальное время по часам).docker stopпечатает имя остановленного контейнера.2>&1 | tail -3берёт последние три строки лога (приложение пишет логи в stderr).docker inspect --formatдостаёт из описания контейнера одно поле, код выхода. -
Временно собери вариант с shell-формой (образ с другим тегом, чтобы не трогать основной):
sed 's|^CMD \["python", "app.py"\]|CMD python app.py|' Dockerfile > /tmp/Dockerfile.shell docker build -f /tmp/Dockerfile.shell -t notes:shell . docker run -d --name notes-shell notes:shell docker top notes-shell time docker stop notes-shell docker logs notes-shell 2>&1 | tail -3 docker inspect --format '{{.State.ExitCode}}' notes-shellРазбор
sed: команда подстановкиs|что|чем|. Разделителем здесь|, а не привычный/, чтобы не путаться с символами в шаблоне.^в начале шаблона привязывает поиск к началу строки,\[и\]это буквальные квадратные скобки (без слеша скобки означали бы набор символов). Итог: строкаCMD ["python", "app.py"]заменяется наCMD python app.py, всё остальное файла остаётся, результат записывается в/tmp/Dockerfile.shell.docker build -f файлберёт Dockerfile под другим именем, точка в конце по-прежнему контекст.docker topпоказывает процессы внутри контейнера, не заходя в него. Обычныйpsв slim-образе не установлен. -
Убери за собой:
docker rm notes notes-shell docker image rm notes:shell rm /tmp/Dockerfile.shell
Что должно получиться:
Шаг 1:
notes
real 0m0.630s
user 0m0.012s
sys 0m0.008s
2026-09-30 12:17:42,071 INFO started host=0.0.0.0 port=8080
2026-09-30 12:17:43,079 INFO shutting down
2026-09-30 12:17:43,578 INFO stopped
0
Шаг 2 (docker build в конце добавит предупреждение о форме CMD, см. ниже):
1 warning found (use docker --debug to expand):
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 23)
301d248e400134968951ee08ab7640c4f6d8df1c12012fa817e85e80c4dd9aae
UID PID PPID C STIME TTY TIME CMD
10001 141307 141284 0 12:13 ? 00:00:00 /bin/sh -c python app.py
10001 141322 141307 1 12:13 ? 00:00:00 python app.py
notes-shell
real 0m10.159s
user 0m0.010s
sys 0m0.012s
2026-09-30 12:13:16,841 INFO started host=0.0.0.0 port=8080
137
(номер строки в предупреждении зависит от того, как выглядит твой Dockerfile.)
Как читать вывод:
- Первая строка
notesэто имя, которое печатаетdocker stop.real 0m0.630s: остановка заняла меньше секунды. В логеshutting downиstopped: приложение услышало SIGTERM и завершилось само, код выхода0. - Предупреждение
JSONArgsRecommended: встроенная проверка BuildKit сама заметила, чтоCMDв shell-форме, и напомнила про сигналы. Это подсказка, а не ошибка. docker top: два процесса./bin/sh -c python app.pyэто родитель (егоPID141307 совпадает сPPIDвторой строки),python app.pyего ребёнок. PID 1 внутри контейнера уsh, поэтому сигнал получаетsh. Номера PID у тебя будут другие: их видит ядро хоста.real 0m10.159s: Docker ждал полный таймаут. В логе нет ниshutting down, ниstopped(приложение сигнал не получило), код выхода137= 128 + 9, процесс убит SIGKILL.
Объясни себе:
- Что именно получил процесс с PID 1 в shell-форме и почему приложение об этом не узнало?
- К чему приводит SIGKILL при остановке в Kubernetes (шаг ко второй теме курса, подробнее в уроке 5.7)?
Типичные ошибки:
OCI runtime exec failed: exec failed: unable to start container process: exec: "ps": executable file not found in $PATH: в slim-образе нетps. Используйdocker top <контейнер>.sed: -e expression #1, char ...: unterminated s command: сломались кавычки или не экранированы скобки. Скопируй команду точно.docker stopв shell-форме заканчивается сразу: значит, в Dockerfile осталась exec-форма,sedничего не заменил. Открой/tmp/Dockerfile.shellи проверь строкуCMD.
Задание 5. Шаг проекта: образ notes:0.3.0 и тег v0.3.0
Цель: зафиксировать Dockerfile в репозитории и выпустить версию 0.3.0.
Шаги:
-
Финальный
Dockerfileдолжен выглядеть так (порядок: редкое выше, частое ниже):# База закреплена по минорной версии FROM python:3.13-slim WORKDIR /app # Пользователь и каталог данных создаются до USER; слой не меняется и живёт в кэше RUN groupadd --system --gid 10001 notes \ && useradd --system --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin notes \ && mkdir /data && chown 10001:10001 /data # Сначала зависимости, потом код COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app.py . ENV HOST=0.0.0.0 PORT=8080 NOTES_DATA=/data/notes.txt # С этой строки процесс работает не от root USER 10001:10001 EXPOSE 8080 # Проверка здоровья: python есть в образе, curl нет HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \ CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8080/healthz')" # exec-форма: приложение получает SIGTERM напрямую CMD ["python", "app.py"]В Dockerfile из задания 3 слой с пользователем стоял после
COPY app.py. Здесь он перенесён выше, кWORKDIR: он не зависит от кода, а значит, кэш его сохранит при правкахapp.py. Поведение образа то же самое. -
Собери, проверь и посмотри размер:
docker build -t notes:0.3.0 . docker rm -f notes docker run --rm -d --name notes -p 8080:8080 -e APP_VERSION=0.3.0 notes:0.3.0 sleep 3; curl -s localhost:8080/; docker stop notes docker image ls notes:0.3.0Разбор:
--rmудалит контейнер сразу после остановки, поэтому отдельныйdocker rmне нужен.-e APP_VERSION=0.3.0передаёт версию, которую приложение покажет на главной странице (без неё будетNotes service vdev).sleep 3даёт приложению время стартовать. -
Закоммить и поставь тег, как в уроке 3.5:
git switch -c feat/dockerfile git add Dockerfile .dockerignore requirements.txt git commit -m "Добавить Dockerfile и .dockerignore" git switch main && git merge --no-ff feat/dockerfile git tag -a v0.3.0 -m "Образ notes:0.3.0" git push origin main v0.3.0Разбор:
git switch -c имясоздаёт ветку и переходит в неё.git addвыбирает файлы для коммита,git commit -mфиксирует.git merge --no-ffвливает ветку отдельным коммитом слияния.git tag -a v0.3.0 -m "..."ставит аннотированный тег на текущий коммит.git push origin main v0.3.0отправляет и ветку, и тег.
Что должно получиться:
Notes service v0.3.0
notes
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
notes:0.3.0 2401c7eb01c5 215MB 46.8MB
Как читать вывод: Notes service v0.3.0 это ответ главной страницы: версия пришла из -e APP_VERSION. Слово notes в конце строки после ответа печатает docker stop. Таблица та же, что в задании 1: ID и размер у тебя другие.
Объясни себе:
- Почему версия образа
0.3.0совпадает с git-тегомv0.3.0? - Что в проекте пока не решено? Данные лежат в слое контейнера и пропадут вместе с ним (это делает урок 4.3). Версия приложения задаётся флагом при запуске, а не в образе (позже её положат в конфигурацию Compose).
Типичные ошибки:
error: pathspec 'main' did not match any file(s) known to git: ветка называется иначе.git branch --show-current.! [remote rejected] main -> main (protected branch hook declined): main защищена, как настроено в уроке 3.2. Открой PR изfeat/dockerfileи поставь тег после слияния.Cannot connect to the Docker daemon at unix:///var/run/docker.sock: демон не запущен или нет прав.sudo systemctl start docker, проверь группуdocker.
Сломай и почини
Скрипт создаёт неисправность в отдельном каталоге ~/notes-break-4.2 и контейнере notes-break: твой ~/notes, образ notes:0.3.0 и контейнер notes он не трогает. Нужен Docker и файл ~/notes/app.py из задания 1. Порт 8080 должен быть свободен: останови свой контейнер notes (docker rm -f notes).
Скачай скрипт и запусти сценарий (без sudo; скрипт не читай, иначе пропадёт смысл упражнения):
curl -fsSL -o /tmp/break-4.2.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/4.2/break.sh
bash /tmp/break-4.2.sh 1
Разбор: curl -fsSL -o файл URL скачивает файл: -f даёт ошибку на коде 4xx/5xx вместо сохранения страницы ошибки, -s тихий режим, -S покажет ошибку, -L идёт по перенаправлениям, -o сохраняет в файл.
Сценарии 1, 2 и 3 отличаются симптомом. Сначала пройди по порядку. После каждого исправления запусти bash /tmp/break-4.2.sh fix: он удалит контейнер, образ и каталог сценария. Запустить сценарий повторно безопасно.
Симптом
Один из трёх:
- Сценарий 1: в каталоге
~/notes-break-4.2командаdocker build -t notes:break .падает с ошибкой"/app.py": not foundна строке сCOPY app.py .. - Сценарий 2: контейнер
notes-breakв статусеUp,docker logs notes-breakпоказываетstarted, аcurl -sS http://localhost:8080/healthzс хоста возвращаетcurl: (56) Recv failure: Connection reset by peer. - Сценарий 3: контейнер
Up,/healthzотвечаетok, ноcurl -sS -X POST http://localhost:8080/notes -d '{"text":"проба"}'возвращает{"error": "storage"},/readyzотвечаетnot ready(503), а в логахPermission denied.
Гипотезы
Для каждого симптома запиши по две гипотезы и одну проверку, которая их различает. Например, для второго: приложение слушает 127.0.0.1 внутри контейнера, либо порт не опубликован через -p. Проверка: docker port notes-break покажет, опубликован ли порт, а docker exec notes-break env покажет HOST.
Проверки
docker build --progress=plain -t notes:test . 2>&1 | tail -20 # какой шаг упал (в каталоге сценария 1)
docker port notes-break # опубликован ли порт
docker exec notes-break env | grep -E 'HOST|PORT' # какой адрес слушаем
docker exec notes-break id # под кем работаем
docker exec notes-break ls -ld /data # владелец каталога
docker logs notes-break # что говорит приложение
cat ~/notes-break-4.2/.dockerignore # что исключено из контекста
Разбор: tail -20 берёт последние 20 строк. grep -E 'HOST|PORT' оставляет строки с HOST или PORT. Остальное ты уже использовал в заданиях 1-3.
Исправление
Разбор сценариев
1. "/app.py": not found (полностью: failed to compute cache key: failed to calculate checksum of ref ...: "/app.py": not found). Причина: app.py перечислен в .dockerignore, поэтому в контекст сборки не попал, и COPY его не видит. Проверка: cat .dockerignore и ls: файл на диске есть, но исключён. Исправление: убрать строку app.py из .dockerignore, собрать снова.
2. Контейнер работает, с хоста не открывается. Причина: ENV HOST=127.0.0.1 в Dockerfile (или -e HOST=127.0.0.1 при запуске): приложение слушает только петлю контейнера. Проверка: docker exec notes-break env | grep HOST покажет HOST=127.0.0.1, а docker port при этом покажет опубликованный порт. Исправление: ENV HOST=0.0.0.0, пересборка, пересоздание контейнера. EXPOSE тут ни при чём.
3. Permission denied: '/data/notes.txt'. Причина: каталог /data создан от root, а процесс работает под uid 10001 (нет chown до USER). Проверка: docker exec notes-break ls -ld /data покажет root root, docker exec notes-break id покажет uid=10001. Исправление: mkdir и chown 10001:10001 до USER. Если каталог смонтирован снаружи (том), владельца нужно менять у тома: подробно в уроке 4.3.
ИИ в помощь
Нейросеть хорошо объясняет инструкции Dockerfile и находит ошибки в порядке слоёв, но версии образов и пути она подставляет по общему шаблону. Общие правила: ИИ-помощник.
Задача: проверить порядок слоёв в своём Dockerfile.
Я учу Docker. Вот мой Dockerfile для Python-приложения на stdlib:
<вставь Dockerfile целиком>.
Объясни по строкам, что делает каждая инструкция и какие из них создают слои.
Скажи, что пересоберётся, если я поменяю только app.py, и что, если поменяю requirements.txt.
Если порядок слоёв неудачный, предложи лучший и объясни почему.
Проверь ответ: сверь с разделом «Образ, слои и кэш»: редко меняющееся выше, часто меняющееся ниже. Подтверди сам: пересобери с docker build и посмотри, где появляется CACHED. Типичная ошибка нейросети: не заметить COPY . . до pip install, из-за которого кэш пакетов не работает.
Задача: найти секреты и лишние файлы в образе.
Вот мой Dockerfile и список файлов проекта: <вставь Dockerfile и вывод ls -a>.
Найди всё, что не должно попасть в образ: секреты, .git, лишние каталоги.
Составь .dockerignore и объясни каждую строку. Не меняй сам Dockerfile.
Проверь ответ: проверь, что в .dockerignore нет файлов, нужных COPY: иначе сборка упадёт с not found. Типичная ошибка нейросети: предлагать RUN rm .env после COPY .env. В уроке показано, почему это не убирает секрет из образа.
Задача: понять, почему контейнер долго останавливается.
Мой контейнер останавливается `docker stop` дольше 10 секунд, код выхода 137.
Вот Dockerfile: <вставь CMD и ENTRYPOINT>. Вот вывод docker top <имя-контейнера>: <вставь>.
Объясни, получает ли приложение SIGTERM, и как это исправить.
Проверь ответ: сравни форму CMD с таблицей exec и shell из урока. Типичная ошибка: советовать docker stop -t 60, который лишь откладывает SIGKILL и причину не убирает.
Словарик урока
| Термин | Простыми словами |
|---|---|
| образ (image) | неизменяемый набор файлов и настроек, из которого запускают контейнеры |
| Dockerfile | текстовый рецепт сборки образа: инструкции по порядку |
| инструкция | строка Dockerfile вида FROM, RUN, COPY |
| слой (layer) | набор файлов, добавленных одной инструкцией; образ это стопка слоёв |
| кэш сборки | запомненные слои, которые не нужно строить заново, если ничего не изменилось |
| BuildKit | сборщик образов, встроенный в Docker Engine |
| реестр (registry) | сервер-хранилище образов (Docker Hub, ghcr.io) |
| тег (tag) | метка версии образа после двоеточия: python:3.13-slim |
| digest | хэш содержимого образа, точно указывает на один образ |
| slim | облегчённый вариант базового образа без лишних программ |
| контекст сборки | каталог, который docker build отправляет демону; COPY видит только его |
.dockerignore |
список того, что не попадает в контекст сборки |
| слой для записи | тонкий слой сверху образа, куда пишет контейнер; исчезает вместе с ним |
| PID 1 | первый процесс контейнера, он получает сигналы от docker stop |
| exec-форма | CMD ["python","app.py"]: процесс запускается напрямую |
| shell-форма | CMD python app.py: процесс запускается через /bin/sh -c |
| SIGTERM / SIGKILL | просьба завершиться и принудительное завершение |
| код 137 | 128 + 9: процесс убит сигналом SIGKILL |
| публикация порта | флаг -p: пересылка порта хоста в порт контейнера |
loopback (127.0.0.1) |
адрес «только эта машина», у контейнера свой |
| uid / gid | числовые номера пользователя и группы, по ним ядро проверяет права |
| HEALTHCHECK | команда, по которой Docker проверяет, что приложение здорово |
| переменная окружения | пара «имя=значение», которую программа читает при запуске |
клиент / демон (dockerd) |
docker принимает твою команду, демон в фоне выполняет работу |
сокет (docker.sock) |
файл, через который клиент обращается к демону |
| зависимость | чужая библиотека, без которой код не запустится |
requirements.txt |
список зависимостей Python, по пакету на строку |
pip |
программа, которая скачивает и ставит пакеты Python |
| CI | робот, который при каждом коммите собирает и проверяет проект |
SYS_UID_MAX |
верхняя граница номеров системных пользователей в Debian (999); useradd предупреждает, если номер вне диапазона |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой «часто» задают почти на каждом собеседовании по теме урока: начни с них. Короткие вопросы с пометкой «на скорость» тренируй на время: ответ за 30 секунд.
1. [junior] [часто] Чем COPY отличается от ADD?
Ответ
COPY просто копирует файлы из контекста сборки в образ. ADD умеет больше: распаковывает локальные tar-архивы и может скачивать файлы по URL. Из-за такого неочевидного поведения по умолчанию беру COPY, а ADD только когда нужна автоматическая распаковка локального архива. Скачивание по URL лучше делать через RUN curl с проверкой контрольной суммы и удалением лишнего в том же слое.
Что хотят услышать: COPY по умолчанию, ADD распаковывает архивы и качает по URL, предсказуемость, проверка загружаемого.
Красный флаг: «Без разницы, использую ADD везде».
2. [junior] [часто] Чем CMD отличается от ENTRYPOINT?
Ответ
ENTRYPOINT это программа, которая запускается всегда, CMD это аргументы по умолчанию к ней или команда по умолчанию, если ENTRYPOINT нет. docker run образ команда заменяет CMD, а ENTRYPOINT заменяется только флагом --entrypoint. Для обычного приложения хватает одного CMD, ENTRYPOINT берут для образов-утилит.
Что хотят услышать: что CMD перезаписывается аргументами docker run, ENTRYPOINT нет; комбинация «ENTRYPOINT плюс CMD как аргументы по умолчанию».
Красный флаг: «это одно и то же, просто два способа».
3. [middle] [часто] Сборка занимает 5 минут после любой правки одной строки кода. Что смотришь?
Ответ
Порядок слоёв: скорее всего, COPY . . стоит до установки зависимостей, и каждая правка кода инвалидирует слой с pip install, зависимости качаются заново. Разделяю: сначала COPY requirements.txt и RUN pip install, потом код. Смотрю вывод --progress=plain: какие шаги помечены CACHED. Проверяю .dockerignore: изменчивые файлы в контексте (логи, .git) тоже ломают кэш при COPY . ..
Что хотят услышать: правило «редко меняющееся выше», инвалидация всех слоёв ниже изменённого, .dockerignore, кэш-mount для pip как продвинутая мера (кэш скачанных пакетов между сборками).
Красный флаг: «добавлю --no-cache, чтобы было надёжнее»: это отключает кэш и делает сборку ещё медленнее.
4. [middle] docker stop висит 10 секунд, потом контейнер убивается. Почему и как чинить?
Ответ
Скорее всего, приложение не получает SIGTERM: CMD в shell-форме, PID 1 это /bin/sh, он сигнал дочернему процессу не передаёт. Проверяю docker top: если видно /bin/sh -c ..., перехожу на exec-форму CMD ["python","app.py"]. Если процесс уже PID 1, но всё равно не реагирует, смотрю код: у PID 1 нет обработчика по умолчанию, приложение должно само ловить SIGTERM. Признак принудительного убийства: код выхода 137 (128 + 9, SIGKILL).
Что хотят услышать: exec против shell, PID 1, таймаут 10 секунд и SIGKILL, код 137, --init или tini как запасной вариант (маленькая программа, которая становится PID 1 и передаёт сигналы дальше), exec в entrypoint-скриптах.
Красный флаг: «увеличу таймаут -t 60» без поиска причины.
5. [junior] [на скорость] Зачем нужен .dockerignore?
Ответ
Он исключает файлы из контекста сборки, то есть из того, что клиент отправляет демону. Это ускоряет docker build (не гоняется .git и данные), делает кэш стабильнее и, главное, не даёт секретам вроде .env попасть в слои образа через COPY . ..
Что хотят услышать: контекст отправляется демону целиком, .git и .env, влияние на кэш COPY . ..
Красный флаг: «чтобы образ был меньше» и ничего про секреты.
6. [middle] В образ случайно попал пароль. Ты удалил его следующей командой RUN rm. Что не так?
Ответ
Файл остаётся в предыдущем слое: образ это стопка неизменяемых слоёв, а RUN rm добавляет ещё один слой с пометкой «файл удалён». Любой, кто получил образ, достанет секрет распаковкой слоёв (docker save и tar); строку с COPY видно в docker history. Пароль считаю скомпрометированным и меняю, образ пересобираю без него (.dockerignore), а секреты передаю при запуске, а не при сборке.
Что хотят услышать: слои неизменяемы, ротация секрета, .dockerignore, секреты BuildKit (--mount=type=secret), что ARG для секретов тоже виден в истории.
Красный флаг: «удалю образ локально, и всё» без ротации.
7. [junior] Почему контейнер не стоит запускать от root?
Ответ
Root в контейнере это тот же root ядра хоста, ограниченный namespaces и capabilities (наборами отдельных прав). Уязвимость приложения или ошибка конфигурации (смонтированный docker.sock, --privileged) даёт атакующему root на хосте. Поэтому в образе USER с непривилегированным uid, а каталоги данных отдают этому uid через chown.
Что хотят услышать: принцип наименьших привилегий, USER 10001, chown до USER, дальше --cap-drop, --read-only, runAsNonRoot в Kubernetes.
Красный флаг: «в контейнере всё равно изолировано, root там безопасен».
8. [middle] Контейнер запущен, docker logs чистые, а с хоста порт не открывается. Твои шаги?
Ответ
Проверяю docker port и docker ps: опубликован ли порт (-p), какой именно. Потом docker exec ... env и логи: на каком адресе слушает приложение. Если 127.0.0.1 внутри контейнера, нужно 0.0.0.0: соединение с хоста приходит на сетевую карту контейнера, а не на его петлю. Типичный признак: curl: (56) Recv failure: Connection reset by peer. Дальше проверяю firewall хоста и занятость порта.
Что хотят услышать: loopback контейнера отличается от хоста, EXPOSE не публикует, ss -tlnp внутри и снаружи.
Красный флаг: «пересоздам контейнер, вдруг поможет».
9. [middle] В контейнере Permission denied при записи в каталог данных. Причины?
Ответ
Приложение работает под uid 10001, а владелец каталога root: либо mkdir выполнен без chown, либо каталог смонтирован извне (том или каталог хоста) с другим владельцем. Смотрю docker exec ... id и ls -ld /data, сверяю числа. Чиню: chown 10001:10001 в Dockerfile до USER, либо владелец тома на хосте.
Что хотят услышать: сверка uid процесса и владельца каталога, различие между слоем образа и монтируемым томом, fsGroup в Kubernetes (настройка группы для смонтированных томов).
Красный флаг: chmod 777 или запуск от root ради «починки».
10. [junior] Чем плох тег latest?
Ответ
Это обычный тег без гарантий: он указывает на образ, которому его присвоили последним, и завтра тот же тег даст другой код. Сборка и откат становятся невоспроизводимыми. В проде используют конкретную версию, а для полной неизменности digest.
Что хотят услышать: воспроизводимость, откат на предыдущую версию, тег совпадает с semver, digest @sha256:... как неизменяемая ссылка.
Красный флаг: «latest это всегда самая свежая и безопасная версия».
11. [middle] Как уменьшить образ?
Ответ
Взять slim или distroless (образ без оболочки и лишних программ) базу, не ставить лишнего, использовать --no-cache-dir для pip, .dockerignore, объединять RUN с очисткой в одном слое, применить multi-stage сборку (в финальный образ попадает только результат, а сборочные инструменты остаются в промежуточном). Размер смотрю через docker history, чтобы найти тяжёлые слои. Multi-stage разберём в уроке 4.7.
Что хотят услышать: удаление в том же слое, где создано (иначе слой с файлом остаётся), multi-stage, выбор базы с оглядкой на совместимость (musl у Alpine), проверка через docker history.
Красный флаг: «просто удалю файлы в конце Dockerfile»: слой с ними уже в образе.
12. [junior] [на скорость] Что происходит с данными, которые приложение пишет внутри контейнера?
Ответ
Они попадают в тонкий слой для записи, привязанный к контейнеру. Пока контейнер существует (даже остановленный), данные на месте. docker rm удаляет слой вместе с данными, а образ не меняется. Для данных, которые должны жить дольше контейнера, используют тома (volumes).
Что хотят услышать: слои образа только для чтения, слой контейнера, тома или bind mount для постоянных данных.
Красный флаг: «данные сохранятся в образе».
13. [junior] Чем ARG отличается от ENV в Dockerfile?
Ответ
ARG существует только во время сборки, значение передаётся через --build-arg. ENV задаёт переменную окружения, которая остаётся в образе и доступна работающему контейнеру. Значения обоих видны в истории образа (docker history, docker inspect), поэтому ни то ни другое не подходит для секретов, для них использую секретные mount’ы BuildKit (RUN --mount=type=secret). ARG удобен для версий и параметров сборки, ENV для настроек рантайма.
Что хотят услышать: время сборки против рантайма, оба видны в образе, секреты не передавать.
Красный флаг: Передавать пароль через ARG, потому что «он не попадает в контейнер».
14. [junior] Чем RUN отличается от CMD?
Ответ
RUN выполняется при сборке образа, результат фиксируется в новом слое (установка пакетов, компиляция). CMD не выполняется при сборке, это команда по умолчанию, которая запускается при старте контейнера и которую можно переопределить аргументами docker run. Поэтому apt-get install идёт в RUN, а запуск приложения в CMD или ENTRYPOINT. Лишние RUN дают лишние слои, связанные команды объединяю через &&.
Что хотят услышать: сборка против старта, RUN создаёт слой, CMD переопределяется.
Красный флаг: Запускать приложение через RUN и удивляться, что контейнер сразу завершается.
Проверено на версиях
Проверено на Mac (Apple Silicon, процессор arm64) в Docker Desktop, реальным прогоном:
- Docker Engine и клиент: 29.6.2; BuildKit встроен; плагин buildx 0.35.0.
- Базовый образ
python:3.13-slim, внутри Python 3.13.15 (Debian 13 trixie); приложение «Заметки» v3 изproject/notes/versions/v3.py. - Проверены все задания 1-5: сборка, кэш (
CACHED),docker history, недоступность приHOST=127.0.0.1, запись под uid 10001,HEALTHCHECK(healthyиunhealthyпри неверном порте), остановка exec-формой (0,63 с, код 0) и shell-формой (10,16 с, код 137). - Скрипт
project/notes/break/4.2/break.sh: проверенshellcheckбез замечаний и прогнан на стенде, сценарии 1, 2, 3 иfix, повторные запуски. - Не проверялось: работа на Ubuntu 24.04 и 26.04 с Docker Engine из apt (порт-конфликт даёт текст
Bind for 0.0.0.0:8080 failed, в прогоне на Docker Desktop он выглядел иначе), командыgit switch ... pushиз задания 5 (репозиторий ученика), архитектура amd64 (числа размеров у тебя будут отличаться),.dockerignoreс каталогамиk8s,helm,infra(в проекте их ещё нет).
Итог урока: ты умеешь
- написать Dockerfile для Python-сервиса с закреплённой базой и exec-формой
CMD - расположить слои так, чтобы правка кода не пересобирала зависимости, и доказать это по
CACHED - исключить лишнее и секреты из контекста через
.dockerignoreи объяснить, почемуRUN rmсекрет не удаляет - объяснить, зачем в контейнере
HOST=0.0.0.0, и отличить это отEXPOSEи-p - запустить сервис под uid 10001 и выдать ему доступ к каталогу данных до
USER - замерить
docker stopи объяснить разницу exec- и shell-формы по PID 1 и коду 137 - добавить
HEALTHCHECKна стандартной библиотеке Python и прочитать статус вdocker ps - диагностировать три поломки:
"/app.py": not found, недоступный порт,Permission deniedв/data
Дальше: Урок 4.3: Тома и сети Docker
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.