Local Kubernetes Dev — Part 6: Containerizing your service — writing a Dockerfile
Production-готовый Dockerfile для сервиса на FastAPI: multi-stage сборка, кэширование слоёв, лёгкие базовые образы, непривилегированный пользователь — и загрузка образа в кластер k3d.
Кластер dev уже работает (см. главу 5). Но Kubernetes не умеет запускать ваш Python-код напрямую — он запускает контейнеры. Контейнер — это упакованное приложение вместе со всем, что ему нужно для работы: интерпретатором, библиотеками, файлами. Рецепт сборки такого образа описывается в файле Dockerfile. В этой главе мы напишем production-готовый Dockerfile для нашего myapp (HTTP API на Python 3.12 + FastAPI, порт 8080, зависит от PostgreSQL), научимся делать его маленьким, быстрым в сборке и безопасным, а затем загрузим образ в кластер k3d.
Анатомия Dockerfile на примере myapp (FastAPI)
Dockerfile — это последовательность инструкций, каждая из которых описывает один шаг сборки. Самые частые:
FROM— базовый образ, от которого мы отталкиваемся (например, готовый образ с Python).WORKDIR— рабочая директория внутри образа.COPY— копирование файлов с вашей машины в образ.RUN— выполнение команды во время сборки (установка пакетов и т.п.).ENV— переменные окружения.EXPOSE— документирование порта, который слушает приложение.USER— под каким пользователем запускать процесс.CMD— команда, которая выполняется при запуске контейнера.
Начнём с простого, «наивного» варианта — он работает, но не идеален. Допустим, структура проекта такая: код лежит в каталоге app/ (внутри app/main.py с объектом FastAPI), а зависимости перечислены в requirements.txt.
Чтобы пример был воспроизводим с самого начала, вот минимальный myapp. Сам app/main.py (полную версию проб с реальной проверкой PostgreSQL мы разберём в главе 13):
1# app/main.py
2from fastapi import FastAPI
3
4app = FastAPI()
5
6
7@app.get("/")
8def root():
9 return {"service": "myapp"}
10
11
12@app.get("/healthz") # liveness: процесс жив
13def healthz():
14 return {"status": "ok"}
15
16
17@app.get("/ready") # readiness: готов принимать трафик (в главе 13 добавим проверку БД)
18def ready():
19 return {"status": "ready"}1# requirements.txt
2fastapi
3uvicorn[standard]Теперь Dockerfile. Обратите внимание: WORKDIR /code + COPY ./app ./app кладут код в /code/app — этот путь нам ещё понадобится в Tiltfile (глава 8).
1FROM python:3.12-slim
2
3WORKDIR /code
4
5# Полезные переменные окружения для Python в контейнере
6ENV PYTHONDONTWRITEBYTECODE=1 \
7 PYTHONUNBUFFERED=1 \
8 PIP_NO_CACHE_DIR=1
9
10COPY requirements.txt .
11RUN pip install --no-cache-dir -r requirements.txt
12
13COPY ./app ./app
14
15EXPOSE 8080
16
17CMD ["fastapi", "run", "app/main.py", "--port", "8080"]Разберём ключевые моменты.
fastapi run вместо голого uvicorn. Официальный гайд FastAPI по контейнерам рекомендует именно команду fastapi run — под капотом она поднимает тот же Uvicorn, но с разумными настройками для прода (FastAPI in Containers). Локально для hot reload вы запускаете uvicorn --reload, но в образе для кластера --reload использовать нельзя: это лишний оверхед и потенциальные утечки, режим предназначен только для разработки. (Быстрый цикл «правка кода → перезапуск» в кластере мы организуем иначе — через Tilt, см. главу 8.)
Exec-форма CMD. Обратите внимание, что команда записана массивом строк (["fastapi", "run", ...]), а не одной строкой. Это критично: при exec-форме процесс приложения становится PID 1 и напрямую получает системные сигналы. Если написать shell-форму (CMD fastapi run app/main.py), команду обернёт /bin/sh, сигнал SIGTERM от Kubernetes не дойдёт до приложения, и graceful shutdown (а вместе с ним и lifespan-хуки FastAPI) сломается — под будет убит жёстко по таймауту.
Переменные окружения. PYTHONDONTWRITEBYTECODE=1 отключает запись .pyc-файлов, PYTHONUNBUFFERED=1 заставляет логи сразу попадать в stdout (иначе они застревают в буфере и вы не видите их в kubectl logs), а PIP_NO_CACHE_DIR=1 не даёт pip раздувать образ кэшем.
Если ваш сервис будет работать за TLS-прокси (а в кластере почти наверняка будет — через Ingress, см. главу 11), добавьте флаг --proxy-headers, чтобы FastAPI корректно читал X-Forwarded-*:
1CMD ["fastapi", "run", "app/main.py", "--proxy-headers", "--port", "8080"]Про количество воркеров: классическая связка — Gunicorn как менеджер процессов и Uvicorn как воркеры, число воркеров обычно прикидывают по эвристике (2 * CPU) + 1. Но в Kubernetes часто проще запускать по одному воркеру на под и масштабировать репликами — так нагрузкой управляет сам кластер. Для локальной разработки это вообще не вопрос: одного процесса достаточно.
Многоступенчатая сборка (multi-stage): отделяем сборку от рантайма
Наивный образ выше тащит в финал всё подряд: компиляторы, dev-заголовки, кэши pip. Для рантайма это балласт. Multi-stage build решает проблему: в одном Dockerfile можно написать несколько секций FROM, каждая из которых — отдельная стадия сборки, и в финальный образ скопировать только готовые артефакты (Multi-stage builds — Docker Docs).
Стадии можно именовать (FROM ... AS build), а копировать между ними — через COPY --from:
1# --- Стадия 1: сборка зависимостей ---
2FROM python:3.12-slim AS build
3
4ENV PYTHONDONTWRITEBYTECODE=1 \
5 PYTHONUNBUFFERED=1 \
6 PIP_NO_CACHE_DIR=1
7
8WORKDIR /code
9
10# Ставим зависимости в изолированное venv, чтобы легко перенести его целиком
11RUN python -m venv /opt/venv
12ENV PATH="/opt/venv/bin:$PATH"
13
14COPY requirements.txt .
15RUN pip install --no-cache-dir -r requirements.txt
16
17# --- Стадия 2: рантайм ---
18FROM python:3.12-slim
19
20ENV PYTHONDONTWRITEBYTECODE=1 \
21 PYTHONUNBUFFERED=1 \
22 PATH="/opt/venv/bin:$PATH"
23
24WORKDIR /code
25
26# Копируем ТОЛЬКО готовое venv из стадии build — без pip-кэшей и компиляторов
27COPY /opt/venv /opt/venv
28COPY ./app ./app
29
30EXPOSE 8080
31
32CMD ["fastapi", "run", "app/main.py", "--port", "8080"]Идея простая: всё «грязное» (установка пакетов, возможная компиляция) происходит на стадии build, а в рантайм-образ переезжает только каталог /opt/venv с уже собранными библиотеками. Документация Docker отмечает, что COPY --from умеет тянуть файлы не только из своих стадий, но и из внешних образов (например, COPY --from=nginx:latest ...).
Насколько это уменьшает образ — зависит от того, сколько у вас build-зависимостей. Если пакеты ставятся из готовых wheel-файлов, выигрыш скромный; если что-то компилируется из исходников — экономия может быть существенной (порядка десятков процентов и больше). Точную цифру обещать нельзя, но направление всегда одно: финальный образ становится меньше и чище.
Полезный приём для отладки: остановить сборку на конкретной стадии и посмотреть, что внутри.
1docker build --target build -t myapp:builder .(myapp:builder здесь — временный отладочный тег, чтобы не путать промежуточную стадию с нашим основным образом myapp:dev.)
Кэширование слоёв: почему порядок инструкций важен
Каждая инструкция Dockerfile создаёт слой (layer). Docker кэширует слои и при повторной сборке переиспользует те, что не изменились. Ключевое правило: если изменился какой-то слой, инвалидируются и все слои после него — как формулирует документация Docker, «если слой меняется, затрагиваются и все идущие за ним слои» (Docker build cache).
Отсюда вытекает главный принцип: редко меняющееся ставим раньше, часто меняющееся — позже. Чаще всего вы правите код приложения, а не список зависимостей. Поэтому зависимости нужно установить до копирования кода. Именно так мы и сделали выше:
1COPY requirements.txt .
2RUN pip install --no-cache-dir -r requirements.txt # тяжёлый слой, меняется редко
3COPY ./app ./app # лёгкий слой, меняется частоГайд FastAPI прямо описывает этот трюк: «сначала копируем файл с зависимостями отдельно, а не весь код целиком». Установка зависимостей «может занять минуты», а из кэша — «секунды в худшем случае». Если же сделать наоборот — поставить COPY . . до установки зависимостей, — то любая правка одной строчки в коде будет инвалидировать слой с кодом, а вслед за ним и слой pip install, и Docker будет переустанавливать все библиотеки заново при каждой сборке. Это самый частый антипаттерн.
Ещё одно правило кэширования касается системных пакетов: команды apt-get update и apt-get install всегда объединяйте в один RUN. Иначе закэшированный устаревший apt-get update приведёт к установке протухших версий:
1RUN apt-get update && apt-get install -y --no-install-recommends \
2 libpq5 \
3 && rm -rf /var/lib/apt/lists/*Лёгкие базовые образы (slim/alpine/distroless) и их подводные камни
От выбора базового образа зависит и размер, и совместимость, и безопасность. Три популярных варианта для Python:
slim (debian-slim). Урезанный Debian с glibc и пакетным менеджером apt. Лучшая совместимость: подавляющее большинство Python-пакетов имеют готовые бинарные wheel-файлы под glibc, так что ничего не компилируется. Это разумный выбор по умолчанию — именно его мы и использовали (python:3.12-slim).
alpine. Очень маленький образ (сам Alpine — порядка нескольких мегабайт, Docker best practices). Но есть нюанс: Alpine использует musl libc вместо glibc. Под musl нет стандартных manylinux wheel-файлов, поэтому pip часто компилирует пакеты из исходников — это медленно и хрупко, особенно для научных библиотек. Плюс исторические особенности работы DNS в musl. Вывод: берите Alpine для Python только если уверены, что все ваши зависимости с ним дружат; в остальных случаях slim практичнее.
distroless. Образы от Google, которые содержат «только ваше приложение и его рантайм-зависимости» и принципиально не содержат «пакетных менеджеров, оболочек (shell)» (GoogleContainerTools/distroless). Меньше пакетов — меньше потенциальных уязвимостей (CVE). Это отличный выбор для безопасного прод-рантайма, но почти всегда в паре с multi-stage: собираете зависимости на обычном slim, а копируете в distroless.
1# Стадия build — как раньше, на python:3.12-slim
2FROM python:3.12-slim AS build
3# ... установка зависимостей в /opt/venv ...
4
5# Рантайм на distroless
6FROM gcr.io/distroless/python3-debian12
7WORKDIR /code
8COPY /opt/venv /opt/venv
9COPY ./app ./app
10ENV PATH="/opt/venv/bin:$PATH"
11EXPOSE 8080
12# В distroless нет shell — только exec-форма!
13ENTRYPOINT ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]Подводный камень distroless: внутри нет shell, поэтому docker exec -it ... sh не сработает, и CMD/ENTRYPOINT обязаны быть в exec-форме. Для отладки используйте :debug-варианты образа (в них есть busybox-shell) или kubectl debug (см. главу 12). Для первого знакомства и локальной разработки я рекомендую остаться на slim — отлаживать проще. Distroless разумно подключить позже, готовя образ к проду (см. главу 13).
.dockerignore, непривилегированный пользователь, EXPOSE
.dockerignore. Когда вы запускаете docker build, Docker сначала отправляет в сборщик весь «build context» — содержимое текущего каталога. Файл .dockerignore исключает из него лишнее; работает он по тем же правилам, что и .gitignore (Docker best practices). Без него вы рискуете раздуть контекст и, что хуже, случайно затащить в образ .git, локальное виртуальное окружение или секреты вроде .env.
1.git
2.gitignore
3__pycache__/
4*.pyc
5.venv/
6venv/
7.env
8.pytest_cache/
9.mypy_cache/
10*.md
11Dockerfile
12.dockerignoreНепривилегированный пользователь (USER). По умолчанию процесс в контейнере работает от root — это нарушает принцип наименьших привилегий. Документация Docker советует: «если сервис может работать без привилегий, используйте USER». Создаём обычного пользователя с явным UID и переключаемся на него перед запуском:
1RUN adduser --disabled-password --uid 10001 appuser
2USER appuserВ Kubernetes это потом дополняется настройкой securityContext.runAsNonRoot: true в манифесте пода (см. главу 7 и главу 13) — образ и кластер усиливают друг друга.
EXPOSE. Важно понимать: EXPOSE 8080 не публикует порт наружу. Это только метаданные — документация о том, какой порт слушает приложение (Docker best practices). Реальная публикация делается флагом -p в docker run или объектами Service/Ingress в Kubernetes (см. главу 11). FastAPI-гайд вообще обходится без EXPOSE, но оставить его полезно как подсказку читателю Dockerfile.
Хорошая идея — добавить HEALTHCHECK, чтобы Docker знал, жив ли сервис (в Kubernetes для этого есть отдельные probe, но локально HEALTHCHECK удобен). Важная деталь: в python:3.12-slim (и тем более в distroless) нет curl, поэтому проверку делаем самим Python, который точно есть в образе:
1HEALTHCHECK \
2 CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/healthz').getcode()==200 else 1)"]Локальная проверка образа и загрузка в k3d registry
Сначала собираем и проверяем образ локально, обычным Docker:
1docker build -t myapp:dev .
2docker run --rm -p 8000:8080 myapp:devТеперь сервис доступен на http://localhost:8000 (порт 8000 на хосте проброшен на 8080 внутри контейнера). Заодно полезно посмотреть размер и слои:
1docker images myapp:dev
2docker history myapp:devГлавная ловушка новичков
Кажется логичным: я собрал образ через docker build, значит, k3d его увидит. Нет. Ноды k3d работают на собственном containerd, изолированном от вашего Docker daemon (oneuptime: Docker images with k3d). Образ, лежащий в Docker, кластеру просто не виден, и под уйдёт в ImagePullBackOff — Kubernetes будет безуспешно пытаться скачать образ из удалённого registry. Есть два пути доставить образ в кластер.
Путь 1: прямой импорт (k3d image import). Самый быстрый способ для разовой проверки — закинуть уже собранный образ прямо в ноды кластера (Importing images — k3d):
1docker build -t myapp:dev .
2k3d image import myapp:dev -c devМожно импортировать сразу несколько образов или загрузить из tar-архива:
1k3d image import myapp:dev myworker:dev -c dev
2docker save myapp:dev -o myapp.tar && k3d image import myapp.tar -c devВ манифесте при этом обязательно укажите imagePullPolicy: IfNotPresent — иначе Kubernetes всё равно полезет тянуть образ из сети и не найдёт его:
1image: myapp:dev
2imagePullPolicy: IfNotPresentПуть 2: встроенный registry. Для постоянной работы удобнее локальный registry — приватный «склад образов» внутри k3d. Тогда цикл привычный: build → push → кластер сам тянет образ (Using Image Registries — k3d). Наш канонический registry k3d-registry.localhost:5000 мы уже подняли вместе с кластером dev в главе 5 — пересоздавать ничего не нужно.
Теперь собираем, тегируем под адрес registry и пушим с хоста по адресу k3d-registry.localhost:5000:
1docker build -t k3d-registry.localhost:5000/myapp:dev .
2docker push k3d-registry.localhost:5000/myapp:devУдобство встроенного registry k3d в том, что одно и то же имя k3d-registry.localhost:5000 работает и с хоста (для push), и изнутри кластера (для pull). Поэтому в манифесте главы 7 образ указывается ровно тем же адресом:
1image: k3d-registry.localhost:5000/myapp:devЕсли вы создавали registry без явного порта (--registry-create k3d-registry.localhost), k3d назначит случайный порт — узнать его можно через docker ps:
1docker ps -f name=k3d-registry.localhostПочему *.localhost просто работает
Здесь работает то, что имена вида *.localhost (как наш k3d-registry.localhost) на многих системах резолвятся в 127.0.0.1 автоматически. Детали этого резолва и что делать, если он не сработал из коробки, разобраны в главе 11.
На практике руками всё это делать почти не придётся: в следующих главах Tilt возьмёт сборку, пуш и обновление пода на себя (см. главу 8). Но понимать, что происходит под капотом, важно — когда что-то пойдёт не так, вы будете знать, где искать.
Итак, у нас есть аккуратный, маленький и безопасный образ myapp, и он лежит в кластере. Дальше — научим Kubernetes его запускать: переходим к манифестам.
Источники
- FastAPI in Containers - Docker (официальный гайд)
- Multi-stage builds — Docker Docs
- Docker build cache — Docker Docs
- Building best practices — Docker Docs
- GoogleContainerTools/distroless (официальный репозиторий)
- Using Image Registries — k3d (официальная документация)
- Importing images — k3d (официальная документация)
- How to Use Docker Images with k3d (k3s in Docker) — oneuptime