Каждый разработчик рано или поздно упирается в одну и ту же стену. Вы пишете код на своей машине, он работает безупречно, вы выкатываете его на стенд — и он падает с загадочной ошибкой о недостающей системной зависимости или другой версии библиотеки. Классическая проблема «у меня работает» преследовала разработку десятилетиями, и Docker не просто решил её — он сделал решение настолько простым, что не пользоваться им уже нет оправданий.
Но Docker решает только задачу упаковки. После того как приложение упаковано в контейнер, его всё ещё нужно запустить в продакшене — возможно, на нескольких серверах, с балансировкой нагрузки, выкладками без простоя, проверками состояния и автоматическим восстановлением. Вот здесь и появляется Kubernetes. И вот здесь большинство разработчиков теряются в сложности, потому что Kubernetes вводит целый новый словарь абстракций, на усвоение которого нужно время.
Это руководство отсекает шум. В нём — чем контейнеры являются на самом деле, как писать эффективные и безопасные Dockerfile, какие понятия Kubernetes нужно знать для выкладки настоящих приложений, в чём разница между Docker Compose и Kubernetes для локальной разработки и, главное, когда за каждым из этих инструментов стоит тянуться, а когда лучше оставить его в покое.
Чем контейнеры являются на самом деле
Контейнер — это не облегчённая виртуальная машина. Это самое распространённое заблуждение, и оно ведёт к неверным мысленным моделям. Виртуальная машина запускает полноценную гостевую операционную систему поверх гипервизора, со своим ядром, своим распределением памяти и своими драйверами устройств. Контейнер разделяет ядро хоста и работает как изолированный пользовательский процесс. Изоляцию обеспечивают возможности ядра Linux: пространства имён — для изоляции процессов, cgroups — для ограничения ресурсов, оверлейные файловые системы — для эффективных слоёв образа.
Это различие важно, потому что объясняет поведение, которое вы будете наблюдать. Контейнеры стартуют за миллисекунды, потому что загружать ядро не нужно. Они занимают меньше памяти, потому что нет дублирующего ядра и лишних системных процессов. Но это же означает, что контейнер, запущенный на Linux, не может использовать версию ядра, отличную от хостовой, а контейнеру Windows нужен хост Windows. В macOS Docker Desktop именно поэтому запускает Linux-контейнеры внутри лёгкой виртуальной машины.
Образ — это шаблон только для чтения: снимок файловой системы плюс метаданные. Контейнер — это запущенный экземпляр образа со слоем для записи поверх. Образ можно собрать один раз и запустить из него десятки контейнеров. Это базовая единица работы в мире Docker, и ясное понимание этого делает всё остальное проще.
Как писать Dockerfile
Dockerfile — это рецепт сборки образа. Каждая инструкция создаёт новый слой, и слои кешируются. Значит, порядок инструкций напрямую влияет на скорость сборки, размер образа и безопасность. Вот принципы, которые важнее всего в реальных проектах.
Упорядочивайте слои по частоте изменений
Docker кеширует каждый слой после сборки. Если слой не изменился с прошлой сборки, Docker берёт его из кеша. Значит, инструкции, которые меняются редко, надо ставить выше, а те, что меняются часто, — ниже. Системные зависимости (apt-get, apk add) не меняются почти никогда. Зависимости приложения (npm install, pip install) меняются при обновлении lock-файла. Исходный код приложения меняется с каждым коммитом.
# Плохо: исходный код раньше зависимостей
FROM node:20-alpine
WORKDIR /app
COPY . . # сбрасывает кеш для всего, что ниже
RUN npm ci # выполняется при каждой сборке, даже если package.json не менялся
EXPOSE 3000
CMD ["node", "dist/index.js"]
# Хорошо: стабильные слои идут первыми
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # берётся из кеша, если package.json не менялся
COPY . . # только правка исходников сбрасывает этот слой
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/index.js"]Разница драматическая. Плохой Dockerfile пересобирает все зависимости при каждом коммите. Хороший пересобирает их только при изменении lock-файла, а это обычно случается раз на пул-реквест, а не на каждый коммит. В проекте на Node.js с пятьюстами зависимостями это экономит порядка двух минут на сборку.
Многоэтапные сборки
Многоэтапные сборки позволяют одним Dockerfile и собрать приложение, и получить минимальный образ для выполнения. На этапе сборки есть компиляторы, dev-зависимости и инструменты. Этап выполнения копирует только результат компиляции. Так продакшен-образы остаются маленькими, а поверхность атаки — узкой.
# Build stage
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage — начинаем с чистой минимальной базы
FROM node:20-alpine AS runner
WORKDIR /app
# Только то, что нужно для запуска
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/package.json ./
COPY --from=builder /build/node_modules ./node_modules
EXPOSE 3000
USER node
CMD ["node", "dist/index.js"]Этап выполнения не включает компилятор TypeScript, исходники и dev-зависимости. Для типичного приложения это уменьшает образ с 800 МБ до менее чем 200 МБ. Конструкция COPY --from=builder — ключевая идея: она берёт файлы из предыдущего этапа, не перенося его слои.
Запуск не от root
По умолчанию контейнеры работают от root. Это риск: если злоумышленник использует уязвимость в приложении, он получает root внутри контейнера. Исправление — одна строка в Dockerfile, переключающая на непривилегированного пользователя. Большинство базовых образов поставляется с пользователем node или nobody именно для этого.
Помимо безопасности, многоэтапные сборки и правильный порядок слоёв ускоряют конвейер CI/CD. Каждая минута, сэкономленная на сборке образа, — это минута, которую разработчики не ждут выкладки. В команде из десяти человек, выкатывающей пять раз в день, экономия двух минут на сборку возвращает более шестидесяти рабочих часов в год.
Основы Kubernetes
Kubernetes — это оркестратор контейнеров. Он берёт кластер машин (узлов), планирует контейнеры на них, поддерживает их работоспособность, управляет сетью и предоставляет декларативный API для описания желаемого состояния системы. Вы говорите Kubernetes, чего хотите — три реплики API-сервера, открытый порт 8080, стратегию обновления по очереди, — и он это обеспечивает.
Кривая обучения реальна, потому что Kubernetes вводит многоуровневый набор абстракций. Три из них, с которыми вы будете иметь дело чаще всего, — это Pod, Deployment и Service.
Pod
Pod — наименьшая развёртываемая единица в Kubernetes. Он представляет один или несколько контейнеров, которые разделяют сетевое пространство имён и тома хранения. На практике большинство Pod'ов запускает один контейнер. Шаблоны с sidecar (основной контейнер плюс контейнер логирования или прокси) используют многоконтейнерные Pod'ы, но в повседневной выкладке приложений на Pod приходится один контейнер.
Создавать Pod'ы напрямую приходится редко. Pod'ы эфемерны: их могут завершить и перепланировать в любой момент. Если вы создали Pod вручную и узел, на котором он работает, выходит из строя, Pod теряется навсегда. Вот здесь и нужны Deployment'ы.
Deployment
Deployment управляет набором одинаковых Pod'ов (ReplicaSet). Он берёт на себя обновления по очереди, масштабирование, самовосстановление и откаты. Это ресурс, которым вы будете выкатывать приложения без состояния.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
labels:
app: api-server
spec:
replicas: 3
selector:
matchLabels:
app: api-server
template:
metadata:
labels:
app: api-server
spec:
containers:
- name: api
image: myregistry/api-server:v1.2.3
ports:
- containerPort: 3000
resources:
requests:
cpu: 250m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
httpGet:
path: /healthz
port: 3000
readinessProbe:
httpGet:
path: /ready
port: 3000Этот Deployment объявляет три реплики API-сервера. Kubernetes проследит, чтобы всегда работали три Pod'а. Если Pod падает, Kubernetes создаёт замену. При обновлении по очереди (смена тега образа) Kubernetes заменяет Pod'ы один за другим, не допуская простоя. Проба livenessProbe говорит Kubernetes, когда Pod жив; readinessProbe — когда он готов принимать трафик.
Service
У Pod'ов динамические IP-адреса. Каждый раз при пересоздании Pod получает новый адрес. Service даёт стабильную сетевую точку, которая балансирует нагрузку по Pod'ам, подходящим под его селектор. Именно так другие части системы находят ваше приложение и общаются с ним.
apiVersion: v1
kind: Service
metadata:
name: api-server
spec:
selector:
app: api-server
ports:
- port: 80
targetPort: 3000
type: ClusterIPЭтот Service отображает порт 80 на стабильном внутрикластерном адресе на порт 3000 у Pod'ов с меткой app: api-server. Другие сервисы внутри кластера обращаются к нему по DNS-имени api-server. Для внешнего трафика поверх Service использовали бы LoadBalancer или Ingress.
Kubernetes — это не платформа, на которую вы выкладываете. Это платформа, которой вы описываете выкладку. Разница между императивным и декларативным — самый важный сдвиг в мышлении, который можно совершить.
Локальная разработка: Docker Compose против Kubernetes
Самая частая ошибка команд — считать, что раз Kubernetes используется в продакшене, он нужен и для локальной разработки. Docker Compose и Kubernetes служат разным целям, и неверный выбор для локальной работы создаёт лишнее трение.
Docker Compose создан для локальной разработки. Он работает на одной машине, поднимает контейнеры за секунды и использует простой формат YAML, который прямо отображается на то, что вам нужно: веб-сервер, база данных, экземпляр Redis и, возможно, обработчик очереди. Вы описываете сервисы, и docker compose up приводит всё в рабочее состояние: логи идут в терминал, порты проброшены на localhost, горячая перезагрузка работает из коробки.
version: "3.8"
services:
api:
build: .
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
- DATABASE_URL=postgres://user:pass@db:5432/app
depends_on:
- db
db:
image: postgres:16-alpine
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:Этот файл Compose даёт рабочую среду разработки с горячей перезагрузкой, локальную базу данных с постоянным хранилищем и корректную сеть между сервисами. Он занимает около тридцати строк YAML и поднимается меньше чем за десять секунд.
Minikube, Kind и k3s умеют запускать Kubernetes локально, но добавляют заметные накладные расходы. Им нужно больше памяти, они дольше стартуют и приносят сложность (ingress-контроллеры, сервисные сетки, классы хранения), которая просто не нужна, когда вы правите одну функцию. Локальный Kubernetes полезен, чтобы проверить специфическое поведение самого Kubernetes — политики вытеснения Pod'ов, горизонтальное автомасштабирование, собственные описания ресурсов, — но он не замена Compose в повседневной разработке.
- Для локальной разработки берите Docker Compose. Он быстрый, простой и прямо отображается на контейнеры, которые вы запускаете.
- Kubernetes (через Minikube или Kind) берите для интеграционных проверок, когда продакшен опирается на возможности Kubernetes — ConfigMap, Secret, собственные контроллеры.
- Удалённый кластер разработки нужен, только если требуется доступ к GPU, специализированное оборудование или общий стенд, точно повторяющий продакшен.
- Не поднимайте два кластера Kubernetes локально только потому, что у вас две среды. Compose решает это одним флагом --profile.
- Если команда тратит на отладку конфигураций Kubernetes больше времени, чем на код приложения, вы зашли слишком далеко. Вернитесь к Compose и добавляйте сложность лишь тогда, когда боль от её отсутствия превысит боль от её поддержки.
Частые ловушки и как их обойти
Даже когда понятия усвоены, некоторые ошибки повторяются из команды в команду. Вот те, что стоит запомнить, чтобы пропустить двухдневные разборы.
Хаос с тегами образов — самая распространённая продакшен-проблема. Тег latest в Deployment означает, что вы не знаете, какая версия работает на каком узле. Kubernetes тянет образ, только если его нет на узле, так что latest на одном узле может оказаться другой версией, чем latest на другом. Всегда используйте семантические версии или SHA коммита. Ещё лучше — полностью квалифицированный digest образа: только он гарантированно неизменен.
Запросы и лимиты ресурсов часто опускают или ставят наугад. Без запросов Kubernetes не может осмысленно планировать Pod'ы, и узлы оказываются перегружены. Без лимитов утечка памяти в одном Pod'е способна обрушить соседние Pod'ы на том же узле. Задавайте и то и другое, а настраивать их по фактическому потреблению помогает Vertical Pod Autoscaler в режиме рекомендаций.
ConfigMap и Secret монтируются как переменные окружения или файлы. Переменные окружения удобны, но любое изменение вступает в силу только после перезапуска Pod'а. Файловые монтирования могут обновляться без перезапуска (Pod читает новое содержимое при обращении к файлу), но многие приложения кешируют конфигурацию при старте. Знайте, какой вариант использует ваше приложение, и стройте подход к конфигурации соответственно.
Постоянные тома в Kubernetes — не волшебство. PersistentVolumeClaim запрашивает хранилище, но нижележащий класс хранения нужно настроить под вашего облачного провайдера. Классы по умолчанию могут использовать сетевое хранилище с иными характеристиками производительности, чем локальные SSD. Если производительность базы данных важна, проверьте свой класс хранения до выхода в продакшен.
Логирование и отладка в Kubernetes сложнее, чем на одном сервере. Pod'ы эфемерны, поэтому логи исчезают вместе с удалённым Pod'ом. Для живого потока годится kubectl logs --tail=50 -f имя-пода, но для продакшена нужно централизованное решение (Loki, Elasticsearch или облачный сервис логирования). Так же и с kubectl exec -it имя-пода -- sh: зайти внутрь работающего контейнера полезно, но помните, что любые изменения внутри него теряются при перезапуске.
А Kubernetes вам точно нужен?
Этот вопрос никто не любит задавать: Kubernetes хорошо смотрится в резюме и сигналит об операционной зрелости. Но Kubernetes решает конкретную задачу — запуск нескольких контейнеризованных сервисов на нескольких машинах с автоматическим восстановлением, масштабированием и выкладкой по очереди. Если у вас один-два сервиса на одном сервере, Kubernetes — это перебор.
Вот прямая рамка для решения. Если вы выкатываете одно приложение, обслуживающее меньше десяти тысяч запросов в секунду, один сервер с Docker Compose в продакшене (да, для многих нагрузок Compose там работает прекрасно) послужит вам хорошо. Добавьте обратный прокси вроде Caddy или Nginx, настройте автоматические резервные копии — и у вас продакшен-система, которую один разработчик способен понять и поддерживать целиком.
Переходите на Kubernetes, когда сервисов несколько и их нужно выкатывать независимо; когда нужно масштабировать по сервисам (API нужны десять реплик, а обработчику — две); когда выкладка без простоя становится рутинной операцией; или когда в команде есть хотя бы один человек, чья основная зона ответственности — инфраструктура. До выполнения этих условий операционная стоимость Kubernetes — и в управлении кластером, и в нагрузке на голову разработчика — остаётся чистым минусом.
Многим командам выгодна середина. Docker Compose для локальной разработки и управляемая контейнерная платформа вроде AWS App Runner, Google Cloud Run или Fly.io для продакшена. Такие платформы дают выкладку контейнеров, автоматический HTTPS и масштабирование без необходимости управлять управляющим слоем Kubernetes. Вы получаете большую часть выгод контейнеризации без кривой обучения Kubernetes.
Лучшая инфраструктурная стратегия — та, что позволяет команде выпускать функции. Docker и Kubernetes — инструменты, а не идентичность. Берите их, когда они помогают, и обходитесь без них, когда нет.