Модуль 03 — Dockerfile

Рецепт образа, который собирается одинаково на любой машине и за секунды после правки кода

Прогресс курса Модуль 3 из 9

Что вы освоите в этом модуле

01

Приложение

Весь модуль мы упаковываем одно учебное приложение — веб-сервис на Flask, который отвечает JSON с версией, именем машины и номером пользователя, от которого работает:

import os
import signal
import socket
import sys

from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return {"app": "notes", "version": os.environ.get("APP_VERSION", "dev"),
            "host": socket.gethostname(), "user": os.getuid()}


if __name__ == "__main__":
    signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
    app.run(host="0.0.0.0", port=8000)
# правка
# ещё правка
flask==3.1.2

Строка с signal — не украшение: зачем приложению в контейнере обрабатывать SIGTERM, разберём в разделе 06. Порт 8000 — внутри контейнера; какой порт будет на хосте, решает тот, кто запускает.

02

Первый Dockerfile

Dockerfile — текстовый файл с инструкциями: от какого образа начать, что скопировать, что выполнить, что запускать. Самый прямолинейный вариант:

FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD python app.py
ИнструкцияЧто делает
FROM образБазовый образ — начало файловой системы. Здесь Debian с Python 3.13
WORKDIR /appРабочий каталог для следующих инструкций и для запущенного контейнера; создаётся, если его нет
COPY откуда кудаСкопировать файлы из контекста сборки (о нём — раздел 05) в образ
RUN командаВыполнить команду при сборке; результат становится новым слоем
CMD командаЧто запускать при старте контейнера. При сборке не выполняется

Сборка: -f — какой файл (по умолчанию Dockerfile), -t — имя и тег образа, точка в конце — контекст, каталог с файлами. Чтобы вывод был компактным, оставляем только строки шагов:

$ time docker build --progress=plain -f Dockerfile.bad -t notes:bad . 2>&1 | grep -E "^#[0-9]+ \[|CACHED|DONE [0-9.]+s$|naming to" | grep -v -E "internal|load build|metadata" | grep -E 'RUN|COPY|naming'
#7 [3/4] COPY . .
#8 [4/4] RUN pip install --no-cache-dir -r requirements.txt
#9 naming to docker.io/library/notes:bad done

real	0m4,770s
user	0m0,117s
sys	0m0,090s
03

Кэш слоёв и порядок инструкций

Каждая инструкция Dockerfile даёт слой, и Docker кэширует слои. Перед выполнением шага он проверяет: тот же ли предыдущий слой, та же ли инструкция, те же ли копируемые файлы. Если да — берёт слой из кэша. Как только один шаг изменился, все следующие выполняются заново.

Поправим одну строку в app.py и соберём снова:

$ echo "# правка" >> app.py
$ time docker build --progress=plain -f Dockerfile.bad -t notes:bad . 2>&1 | grep -E "^#[0-9]+ \[|CACHED|DONE [0-9.]+s$|naming to" | grep -v -E "internal|load build|metadata" | grep -E 'RUN|COPY|CACHED|naming'
#6 CACHED
#7 [3/4] COPY . .
#8 [4/4] RUN pip install --no-cache-dir -r requirements.txt
#9 naming to docker.io/library/notes:bad done

real	0m4,539s
user	0m0,109s
sys	0m0,080s

Из кэша взят только шаг WORKDIR — строка CACHED. COPY . . скопировал изменившийся файл — и pip install поехал заново, хотя зависимости не менялись. Здесь это 5 секунд, в реальном проекте с сотней пакетов — минуты на каждую правку и лишняя нагрузка на PyPI.

Правило: сначала то, что меняется редко, потом то, что меняется часто. Зависимости меняются редко — копируем сначала только их список и ставим, код — в самом конце:

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
RUN useradd --system --no-create-home app
USER app
EXPOSE 8000
CMD ["python", "app.py"]
$ docker build --progress=plain -t notes:1 . 2>&1 | grep -E "^#[0-9]+ \[|CACHED|DONE [0-9.]+s$|naming to" | grep -v -E "internal|load build|metadata" | grep -E '^#[0-9]+ \[|naming'
#4 [1/6] FROM docker.io/library/python:3.13-slim
#5 [2/6] WORKDIR /app
#7 [3/6] COPY requirements.txt .
#8 [4/6] RUN pip install --no-cache-dir -r requirements.txt
#9 [5/6] COPY app.py .
#10 [6/6] RUN useradd --system --no-create-home app
#11 naming to docker.io/library/notes:1 done

Теперь та же правка кода:

$ echo "# ещё правка" >> app.py
$ time docker build --progress=plain -t notes:1 . 2>&1 | grep -E "^#[0-9]+ \[|CACHED|DONE [0-9.]+s$|naming to" | grep -v -E "internal|load build|metadata" | grep -E 'CACHED|COPY app|naming'
#6 CACHED
#7 CACHED
#8 CACHED
#9 [5/6] COPY app.py .
#11 naming to docker.io/library/notes:1 done

real	0m0,676s
user	0m0,112s
sys	0m0,079s

Шаги до установки зависимостей включительно взяты из кэша, заново выполнено только копирование app.py — пересборка за доли секунды. Тот же приём работает для любого языка: package.json и npm ci до COPY . ., go.mod и go mod download до исходников.

--no-cache-dir у pip — чтобы кэш загрузок pip не оставался в слое образа и не занимал место. По той же причине в Debian-образах после apt-get install в той же инструкции RUN удаляют /var/lib/apt/lists/*: удаление в следующей инструкции уже не уменьшит образ, файлы останутся в предыдущем слое.
04

Запуск и история образа

$ docker run -d --name notes -p 8000:8000 notes:1
3f9c0c71a91ec6a786db19758800df7e20eac06f80c0e59cee1cb45420333dfc
$ curl -s http://localhost:8000/
{"app":"notes","host":"3f9c0c71a91e","user":999,"version":"dev"}
$ docker logs notes 2>&1 | head -2
 * Serving Flask app 'app'
 * Debug mode: off

"user": 999 — приложение работает не от root, об этом раздел 08. Предупреждение Flask справедливо: встроенный сервер — для разработки; в модуле 07 мы заменим его на gunicorn.

История образа показывает слои снизу вверх и их размер. Каждая инструкция Dockerfile — строка; слои базового образа ниже обрезаны:

$ docker history notes:1 --format "table {{.CreatedBy}}\t{{.Size}}" | head -11
CREATED BY                                      SIZE
CMD ["python" "app.py"]                         0B
EXPOSE map[8000/tcp:{}]                         0B
USER app                                        0B
RUN |1 APP_VERSION=dev /bin/sh -c useradd --…   4.29kB
ENV APP_VERSION=dev                             0B
ARG APP_VERSION=dev                             0B
COPY app.py . # buildkit                        429B
RUN /bin/sh -c pip install --no-cache-dir -r…   13.1MB
COPY requirements.txt . # buildkit              13B
WORKDIR /app                                    0B
$ docker image ls notes
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
notes        1         59daa7e2ba9f   2 seconds ago   131MB
notes        bad       caf31980f646   8 seconds ago   131MB

Основной объём — базовый образ python:3.13-slim (118 МБ), наши слои добавили около 13 МБ, почти всё — Flask с зависимостями.

05

Контекст сборки и .dockerignore

Точка в docker build … . — контекст: каталог, файлы которого доступны инструкциям COPY. Сборщик берёт из контекста то, что просят инструкции, и при COPY . . это всё подряд: каталог .git, журналы, архивы, файлы .env с паролями. Они попадают в образ, увеличивают его и могут утечь вместе с ним.

Опыт: в каталоге проекта лежат .git на 200 МБ и журнал на 50 МБ, Dockerfile копирует всё:

$ cat Dockerfile; du -sh .
FROM alpine:3.22
COPY . /src
251M	.
$ docker build --progress=plain -t ctx . 2>&1 | grep 'transferring context:' | tail -1
#5 transferring context: 262.21MB 3.8s done
$ docker image ls ctx
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
ctx          latest    96f1bef61d28   3 seconds ago   270MB
$ printf ".git\n*.log\n" > .dockerignore
$ docker build --progress=plain -t ctx . 2>&1 | grep 'transferring context:' | tail -1
#4 transferring context: 141B 0.0s done
$ docker image ls ctx
REPOSITORY   TAG       IMAGE ID       CREATED                  SIZE
ctx          latest    76c606180810   Less than a second ago   8.3MB

.dockerignore рядом с Dockerfile перечисляет, что не отдавать сборщику, — синтаксис как у .gitignore. Образ уменьшился с 270 МБ до 8 МБ — остался почти один alpine. Для учебного приложения файл такой:

.git
*.log
Dockerfile*
.dockerignore
big.bin
Добавьте в .dockerignore файлы с секретами — .env, ключи, *.pem — даже если сейчас COPY . . не используется. Завтра его кто-нибудь допишет, и пароль от базы окажется в образе, который выложат в реестр.
06

CMD: exec- и shell-форма, сигналы

У CMD (и ENTRYPOINT) две формы записи:

Разница проявляется при остановке. docker stop шлёт SIGTERM процессу PID 1. В shell-форме его получает sh, который сигнал приложению не передаёт:

$ docker run -d --name bad notes:bad
1ee1d73004811452e46d5632fef3a67b4a0d9c5911365b52ce63ea97dc9b5cfd
$ docker top bad -o pid,args
PID                 COMMAND
1634201             /bin/sh -c python app.py
1634226             python app.py
$ time docker stop bad
bad

real	0m10,167s
user	0m0,010s
sys	0m0,010s
$ docker rm slow bad
slow
bad

Через 10 секунд Docker убил обоих. Приложение не успело закрыть соединения, дописать файлы, завершить транзакции. С exec-формой то же приложение останавливается сразу (раздел 04 — notes:1):

$ time docker stop notes
notes

real	0m0,295s
user	0m0,018s
sys	0m0,004s
$ docker rm notes
notes

Но exec-формы мало. Приложение как PID 1 должно само обрабатывать SIGTERM: ядро не применяет к PID 1 действие сигнала по умолчанию (модуль 02). Python сам SIGTERM не обрабатывает — программа без обработчика не останавливается и в exec-форме:

import time

print("работаю", flush=True)
time.sleep(300)
$ docker build -q -f Dockerfile.slow -t slow . >/dev/null
$ docker run -d --name slow slow
e37a6fcd43157bcda2bba0b57280b15007bb03e6efb923285ba848495f515b4a
$ time docker stop slow
slow

real	0m10,146s
user	0m0,012s
sys	0m0,008s
$ docker inspect -f "{{.State.ExitCode}}" slow
137

Отсюда строка с signal.signal(signal.SIGTERM, …) в app.py. Серверы приложений — gunicorn, uvicorn, nginx, PostgreSQL — обрабатывают SIGTERM сами. Для своей программы без обработчика есть docker run --init (модуль 02) — маленький init-процесс, передающий сигналы.

ВариантPID 1docker stop
CMD python app.pysh10 секунд, SIGKILL
CMD ["python", "slow.py"], нет обработчикаpython10 секунд, SIGKILL
CMD ["python", "app.py"], есть обработчикpythonСразу, штатно
Любой вариант с docker run --initdocker-initСразу, если приложение завершается по SIGTERM по умолчанию
07

ENTRYPOINT и CMD вместе

Аргументы после имени образа в docker run заменяют CMD целиком. Если в образе задан ENTRYPOINT, то CMD становится его аргументами по умолчанию. Так делают образы-утилиты:

import sys

print(f"Привет, {' '.join(sys.argv[1:])}!")
FROM python:3.13-slim
COPY greet.py /
ENTRYPOINT ["python", "/greet.py"]
CMD ["мир"]
$ docker build -q -f Dockerfile.greet -t greet . >/dev/null
$ docker run --rm greet
Привет, мир!
$ docker run --rm greet Анна и Борис
Привет, Анна и Борис!
$ docker run --rm --entrypoint python greet --version
Python 3.13.17

ENTRYPOINT — что запускать всегда, CMD — аргументы, которые удобно менять. Заменить сам ENTRYPOINT можно только явно, параметром --entrypoint. Для сервиса, как наше приложение, хватает одного CMD: тогда docker run notes:1 id выполнит id вместо сервера, что удобно для проверок.

08

ARG, ENV и USER

ARG — параметр сборки, задаётся --build-arg и существует только во время сборки. ENV — переменная окружения, которая остаётся в образе и видна приложению. Связка ARG APP_VERSION → ENV APP_VERSION=$APP_VERSION из нашего Dockerfile «впекает» версию в образ:

$ docker build -q --build-arg APP_VERSION=1.0.0 -t notes:1.0.0 . >/dev/null
$ docker run --rm notes:1.0.0 env | grep APP_VERSION
APP_VERSION=1.0.0
$ docker run --rm -e APP_VERSION=override notes:1.0.0 env | grep APP_VERSION
APP_VERSION=override
$ docker image inspect -f "{{json .Config.Env}}" notes:1.0.0
["PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","GPG_KEY=7169605F62C751356D054A26A821E680E5FA6305","PYTHON_VERSION=3.13.17","PYTHON_SHA256=6020bd10aff3bf4821be16f088ebb1591beb7b811dcd8fab1da8f7c22a7b27e7","APP_VERSION=1.0.0"]

-e при запуске перекрывает ENV образа. Переменные образа видны любому, у кого есть образ, — как и значения ARG, они сохраняются в истории (строка RUN |1 APP_VERSION=dev в разделе 04). Поэтому пароли и токены не передают ни через ENV, ни через ARG — способ для секретов сборки в модуле 08.

По умолчанию процесс в контейнере работает от root. Если в приложении найдут уязвимость, атакующий получит root в контейнере — и всего шаг до хоста, если контейнер запущен с лишними правами или смонтирован сокет Docker. USER переключает на обычного пользователя, которого мы создали инструкцией RUN useradd:

$ docker run --rm notes:bad id
uid=0(root) gid=0(root) groups=0(root)
$ docker run --rm notes:1 id
uid=999(app) gid=999(app) groups=999(app)

USER ставят после установки пакетов: pip install и apt-get требуют root. Всё, что приложению нужно записывать, должно принадлежать этому пользователю — иначе Permission denied (с томами это всплывёт в модуле 04).

09

EXPOSE и теги

EXPOSE 8000 ничего не открывает — это документация: «приложение слушает 8000». Её использует docker run -P, публикуя все такие порты на случайные свободные порты хоста:

$ docker run -d --name notes -P notes:1
270b9e96ae2479c0fb0d846165102c4d7ef627be654ee63ba99a3093135a3794
$ docker port notes
8000/tcp -> 0.0.0.0:32769
8000/tcp -> [::]:32769
$ docker rm -f notes
notes

Один образ может иметь несколько тегов: docker tag не копирует образ, а добавляет имя — у notes:1.0 и notes:1.0.0 один IMAGE ID:

$ docker tag notes:1.0.0 notes:1.0
$ docker image ls notes
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
notes        1.0       408ecb46fc04   12 seconds ago   131MB
notes        1.0.0     408ecb46fc04   12 seconds ago   131MB
notes        1         59daa7e2ba9f   40 seconds ago   131MB
notes        bad       caf31980f646   46 seconds ago   131MB

Принятая схема — семантические версии с «плавающими» тегами: 1.0.0 не меняется никогда, 1.0 и 1 переставляют на последний выпуск ветки. Пользователь выбирает, насколько он готов к обновлениям: python:3.13-slim получит новый выпуск 3.13, python:3.13.17-slim — нет.

Ключевые выводы модуля

Запомните главное

  • Порядок инструкций — от редко меняющегося к часто меняющемуся: список зависимостей и их установка до COPY кода
  • .dockerignore обязателен: .git, журналы, .env и ключи не должны попасть в контекст
  • CMD и ENTRYPOINT — в exec-форме, приложение обрабатывает SIGTERM; иначе --init
  • ENTRYPOINT — что запускать, CMD — аргументы по умолчанию
  • ARG — при сборке, ENV — в образе; секреты — ни туда, ни туда
  • USER — не root; тег версии не меняется, «плавающие» теги переставляются
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 02: Образы и контейнеры Все модули Модуль 04: Тома и данные