Рецепт образа, который собирается одинаково на любой машине и за секунды после правки кода
docker build.dockerignoreCMD и ENTRYPOINT, exec- и shell-формой — и понимать, как это влияет на остановкуARG и ENV и запускать приложение не от rootВесь модуль мы упаковываем одно учебное приложение — веб-сервис на 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 — внутри контейнера; какой порт будет на хосте, решает тот, кто запускает.
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
Каждая инструкция 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/*: удаление в следующей инструкции уже не уменьшит образ, файлы останутся в предыдущем слое.$ 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 с зависимостями.
Точка в 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 . . не используется. Завтра его кто-нибудь допишет, и пароль от базы окажется в образе, который выложат в реестр.У CMD (и ENTRYPOINT) две формы записи:
CMD ["python", "app.py"]. Docker запускает программу напрямую, она становится PID 1.CMD python app.py. Docker запускает /bin/sh -c "python app.py", PID 1 — оболочка, а приложение — её потомок.Разница проявляется при остановке. 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 1 | docker stop |
|---|---|---|
CMD python app.py | sh | 10 секунд, SIGKILL |
CMD ["python", "slow.py"], нет обработчика | python | 10 секунд, SIGKILL |
CMD ["python", "app.py"], есть обработчик | python | Сразу, штатно |
Любой вариант с docker run --init | docker-init | Сразу, если приложение завершается по SIGTERM по умолчанию |
Аргументы после имени образа в 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 вместо сервера, что удобно для проверок.
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).
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; иначе --initENTRYPOINT — что запускать, CMD — аргументы по умолчаниюARG — при сборке, ENV — в образе; секреты — ни туда, ни тудаUSER — не root; тег версии не меняется, «плавающие» теги переставляются