Kubernetes: подготовка проекта и развёртывание

Как довести приложение до состояния, в котором его можно запускать в Kubernetes, и как поднять отказоустойчивый кластер k3s силами mitdev.

Читать этот документ имеет смысл, только если вы прочли HA.md и сознательно решили, что схема «репликация + watchdog» вам не подходит. Kubernetes — не улучшенная версия трёх серверов с nginx. Это другой набор компромиссов.


Содержание


Нужен ли вам Kubernetes

Честные вопросы. Если на большинство ответ «нет» — вернитесь к HA.md.

Kubernetes даёт: декларативные деплои, самовосстановление подов, rolling-update без простоя, единый способ описывать любые сервисы, экосистему операторов.

Kubernetes берёт: новый слой сети (CNI), новый слой хранилища (CSI/PV), собственный DNS, собственный класс аварий (etcd, kubelet, cgroups), и требование понимать всё это до того, как оно сломается.


Что ставит mitdev

Модуль kubernetes разворачивает k3s — сертифицированную CNCF облегчённую дистрибуцию Kubernetes от Rancher. Один бинарник, встроенный containerd, без зависимости от Docker. Плюс kubectl и helm.

Три режима, выбираются в install.sh:

Режим Что делает Когда
single один самостоятельный сервер стенд, небольшой прод
init первый сервер HA-кластера, поднимает embedded etcd первый из 3/5
join присоединяется к существующему кластеру как сервер второй и далее

HA-кластер использует embedded etcd (не внешний). Для кворума нужно нечётное число серверов: 3 переживают отказ одного, 5 — двух.


Часть I. Подготовка проекта

Kubernetes не чинит плохо написанные приложения — он делает их проблемы заметнее и чаще. Приложение, которое переживёт кластер, должно уметь: умирать в любой момент, стартовать в любой момент, существовать в нескольких копиях.

Двенадцать факторов, но честно

Из 12-factor для Kubernetes критичны шесть. Ниже — только они, с проверками.

Шаг 1. Приложение без состояния

Под может быть убит и пересоздан на другом узле в любую секунду. Всё, что пережило бы перезапуск процесса на одном сервере, но не переживёт переезд на другой, — это состояние, которое надо вынести.

Что искать в коде:

Плохо Куда переносить
Сессии в памяти процесса Redis
Загруженные файлы в ./uploads S3 / MinIO
Кэш в локальном файле Redis / memcached
SQLite на локальном диске PostgreSQL
Cron внутри приложения CronJob кластера
Долгие задачи в фоновом потоке очередь (RabbitMQ) + отдельный worker
Sticky sessions на балансировщике JWT / сессии в Redis

Проверка: запустите две копии локально на разных портах и погоняйте через них трафик поочерёдно. Если что-то ломается — вы нашли состояние.

PORT=3000 npm start &
PORT=3001 npm start &
# логинимся через :3000, делаем запрос через :3001 — сессия жива?

Если в приложении неизбежен локальный диск (кэш сборки, временные файлы) — это должен быть emptyDir, содержимое которого не жалко потерять.

Шаг 2. Конфигурация из окружения

Никаких config.production.json, зашитых в образ. Один образ — все окружения.

// плохо: конфиг выбирается по NODE_ENV, файлы лежат в образе
const config = require(`./config.${process.env.NODE_ENV}.json`);

// хорошо: всё из окружения, с валидацией при старте
const config = {
  databaseUrl: required('DATABASE_URL'),
  redisUrl:    required('REDIS_URL'),
  port:        Number(process.env.PORT ?? 3000),
  logLevel:    process.env.LOG_LEVEL ?? 'info',
};

function required(name) {
  const v = process.env[name];
  if (!v) throw new Error(`Не задана обязательная переменная ${name}`);
  return v;
}

Валидируйте конфиг при старте и падайте, если чего-то нет. Под в CrashLoopBackOff с внятной ошибкой в логах диагностируется за минуту. Приложение, которое стартовало с databaseUrl: undefined и падает через час под нагрузкой, — за день.

Несекретное → ConfigMap. Секретное → Secret (см. ниже).

Шаг 3. Корректное завершение

При обновлении, масштабировании вниз или вытеснении Kubernetes посылает поду SIGTERM, ждёт terminationGracePeriodSeconds (по умолчанию 30 с) и убивает SIGKILL.

Приложение, игнорирующее SIGTERM, будет обрывать запросы на каждом деплое.

const server = app.listen(config.port);

let shuttingDown = false;

async function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  logger.info({ signal }, 'получен сигнал завершения');

  // 1. Перестать проходить readiness — kube снимет под с эндпоинтов Service.
  //    Даём ему время: трафик перестаёт приходить до закрытия сервера.
  await sleep(5000);

  // 2. Не принимать новые соединения, дождаться текущих.
  await new Promise((resolve) => server.close(resolve));

  // 3. Закрыть внешние ресурсы.
  await Promise.all([db.end(), redis.quit(), queue.close()]);

  logger.info('завершение штатное');
  process.exit(0);
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT',  () => shutdown('SIGINT'));

Пятисекундная пауза перед server.close() — не суеверие. Между отправкой SIGTERM и удалением пода из эндпоинтов Service проходит время; без паузы вы закроете сервер раньше, чем kube-proxy перестанет слать в него трафик, и часть запросов получит connection refused.

Если grace period не хватает (длинные запросы, обработка загрузок), поднимите его в манифесте:

spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60

Шаг 4. Проверки живости и готовности

Три разные пробы отвечают на три разных вопроса.

Проба Вопрос Провал →
startupProbe «Он ещё запускается или уже завис?» перезапуск, но с большим терпением на старте
readinessProbe «Можно слать трафик?» под снимается с Service, не перезапускается
livenessProbe «Он ещё жив или надо убить?» перезапуск контейнера

Шаблон mitdev по умолчанию использует tcpSocket — «порт открыт». Это работает для любого приложения, но не отличает «слушает» от «работает». Для реального прода добавьте HTTP-эндпоинты.

// /healthz — я жив? Никаких внешних зависимостей!
app.get('/healthz', (req, res) => res.status(200).send('ok'));

// /readyz — я готов принимать трафик?
app.get('/readyz', async (req, res) => {
  if (shuttingDown) return res.status(503).send('shutting down');
  try {
    await db.query('SELECT 1');
    await redis.ping();
    res.status(200).send('ready');
  } catch (err) {
    res.status(503).send('not ready');
  }
});

Критично: livenessProbe не должна проверять базу данных. Если БД упала, liveness провалится на всех подах сразу, Kubernetes перезапустит их все, подключения к БД устроят шторм при восстановлении, и вы получите каскадный отказ из-за проблемы, которую приложение могло переждать.

Liveness отвечает только на вопрос «этот процесс завис?».

startupProbe:
  httpGet: { path: /healthz, port: 3000 }
  failureThreshold: 30
  periodSeconds: 2          # до 60 секунд на старт, потом включается liveness

readinessProbe:
  httpGet: { path: /readyz, port: 3000 }
  periodSeconds: 5
  failureThreshold: 2

livenessProbe:
  httpGet: { path: /healthz, port: 3000 }
  periodSeconds: 20
  failureThreshold: 3

Шаг 5. Dockerfile, пригодный для кластера

Требования: маленький, без root, с корректной обработкой сигналов, с детерминированной сборкой.

# --- сборка ------------------------------------------------------------------
FROM node:24-alpine AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci                       # ci, не install: детерминированно по lock-файлу

COPY . .
RUN npm run build && npm prune --omit=dev

# --- рантайм -----------------------------------------------------------------
FROM node:24-alpine
WORKDIR /app

# tini как PID 1: пробрасывает сигналы и жнёт зомби-процессы.
RUN apk add --no-cache tini

# Непривилегированный пользователь.
RUN addgroup -g 10001 app && adduser -u 10001 -G app -s /bin/sh -D app

COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist         ./dist
COPY --from=build --chown=app:app /app/package.json ./

USER 10001
EXPOSE 3000
ENV NODE_ENV=production

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "dist/server.js"]

Три частые ошибки:

  1. CMD npm start. npm не пробрасывает SIGTERM дочернему процессу. Приложение никогда не увидит сигнал, всегда получит SIGKILL через 30 с, и каждый деплой будет рвать соединения. Запускайте node напрямую.
  2. Работа от root. USER 10001 обязателен. Многие кластеры имеют PodSecurityStandards, запрещающие root, и ваш под просто не стартует.
  3. FROM node:24 вместо node:24-alpine — 1.1 ГБ против 150 МБ. Каждый pull на каждом узле при каждом деплое.

Проверьте образ до кластера:

docker build -t myapp:test .
docker run --rm -p 3000:3000 --env-file .env myapp:test &
curl -f localhost:3000/healthz            # проба отвечает
docker stop $(docker ps -lq)              # завершился < 30 с и без ошибок?
docker run --rm myapp:test id             # uid=10001, не 0

Шаг 6. Логи в stdout

Никаких файлов, никакой ротации внутри контейнера. Пишите структурированный JSON в stdout; сбором занимается кластер.

// pino: JSON в stdout, без транспортов и файлов
const logger = require('pino')({
  level: config.logLevel,
  formatters: { level: (label) => ({ level: label }) },
});

logger.info({ userId, orderId, durationMs }, 'заказ создан');

Одна строка — одно событие. Многострочные стектрейсы ломают почти любой парсер логов; сериализуйте ошибку в поле.

Шаг 7. Миграции как отдельный шаг

Не запускайте миграции при старте приложения. Три реплики стартуют одновременно и три раза попытаются накатить одну миграцию.

Правильно — отдельный Job, выполняемый до обновления Deployment:

apiVersion: batch/v1
kind: Job
metadata:
  name: myapp-migrate-20260709
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: ghcr.io/org/myapp:v1.4.0    # ТОТ ЖЕ образ, другая команда
          command: ["node", "dist/migrate.js"]
          envFrom:
            - secretRef: { name: myapp-secrets }
sudo mitdev k8s apply migrate-job.yaml
sudo k3s kubectl wait --for=condition=complete job/myapp-migrate-20260709 --timeout=300s
sudo mitdev k8s deploy myapp ghcr.io/org/myapp:v1.4.0 3000 app.example.com

Миграции должны быть обратно совместимыми: во время rolling-update старые и новые поды работают одновременно с одной схемой. Удаление колонки — это две выкладки: сначала код перестаёт её читать, потом (следующим релизом) колонка удаляется.

Шаг 8. Ресурсы: requests и limits

requests — сколько гарантированно выделить, по этому числу планировщик выбирает узел. limits — потолок.

resources:
  requests:
    cpu: 100m           # 0.1 ядра
    memory: 128Mi
  limits:
    memory: 512Mi       # превышение → OOMKilled

Не ставьте limits.cpu, если не уверены. CPU-лимит реализован через CFS-квоты: под троттлится даже когда на узле полно свободного CPU, и вы получаете необъяснимые всплески задержки. Ограничивайте память (её превышение опасно для соседей), CPU — регулируйте через requests.

limits.memory для Node.js должен быть согласован с heap:

env:
  - name: NODE_OPTIONS
    value: "--max-old-space-size=384"    # ~75% от limits.memory 512Mi

Иначе V8 будет думать, что памяти много, разрастётся, и cgroup убьёт процесс OOM-киллером до того, как сборщик мусора решит поработать.

Чек-лист готовности


Часть II. Кластер

Одиночный сервер

sudo ./install.sh      # модуль Kubernetes → режим «single»
sudo mitdev k8s status
  Kubernetes (k3s)
  Версия    v1.31.4+k3s1
  Служба    активна
  Helm      v3.16.3
  Ingress   через nginx на хосте
  Узлы      srv-1 Ready
  Поды      12 всего, 12 Running

Отказоустойчивости на уровне узла тут нет — но есть на уровне пода: упавший контейнер перезапускается, обновление идёт rolling-update без простоя. Для стенда и небольшого прода этого часто достаточно.

HA-кластер из трёх серверов

Предпосылка: приватная сеть

Соберите mesh (MESH.md) до установки k3s. Трафик etcd и flannel между серверами не должен ходить по публичному интернету.

Проверьте, что подсеть mesh не пересекается с k3s: поды живут в 10.42.0.0/16, сервисы в 10.43.0.0/16. mitdev net create предупредит, но лучше сразу взять 10.100.0.0/24.

Первый сервер

# srv-1
sudo ./install.sh      # Kubernetes → режим «init»

Поднимается embedded etcd, узел становится первым сервером кластера.

sudo mitdev k8s token
  Токен присоединения к кластеру
  URL:   https://10.100.0.1:6443
  Токен: K10a3f…::server:8b2c…

Второй и третий серверы

# srv-2, затем srv-3
sudo ./install.sh      # Kubernetes → режим «join»
                       # K8S_SERVER_URL=https://10.100.0.1:6443
                       # K8S_TOKEN=K10a3f…

Присоединяйте по одному, дожидаясь Ready. Кластер из двух серверов временно теряет кворум etcd, если поспешить.

sudo mitdev k8s nodes
NAME    STATUS   ROLES                       AGE   VERSION
srv-1   Ready    control-plane,etcd,master   12m   v1.31.4+k3s1
srv-2   Ready    control-plane,etcd,master   4m    v1.31.4+k3s1
srv-3   Ready    control-plane,etcd,master   1m    v1.31.4+k3s1

Что даёт кворум

Серверов Кворум etcd Переживает отказ
1 1
2 2 0 (хуже одного!)
3 2 1
4 3 1 (не лучше трёх)
5 3 2

Только нечётные числа. Кластер из двух серверов менее надёжен, чем из одного: отказ любого лишает кворума, и API становится read-only.

Разнесение реплик по узлам

Шаблон mitdev включает podAntiAffinity с preferredDuringSchedulingIgnoredDuringExecution: планировщик старается разложить реплики одного приложения по разным узлам, но не откажется от размещения, если узлов не хватает.

affinity:
  podAntiAffinity:
    preferredDuringSchedulingIgnoredDuringExecution:
      - weight: 100
        podAffinityTerm:
          topologyKey: kubernetes.io/hostname
          labelSelector:
            matchLabels: { app: myapp }

Отказ узла уносит максимум одну реплику. Если вам нужна гарантия (лучше не запуститься, чем встать вдвоём на один узел) — замените на requiredDuringSchedulingIgnoredDuringExecution в своём манифесте.

Сосуществование с nginx

k3s по умолчанию тащит Traefik, который занимает порты 80 и 443. Если на хосте уже стоит nginx, они подерутся.

mitdev решает это автоматически: при установленном модуле nginx k3s ставится с отключённым Traefik. Приложения публикуются через nginx, который проксирует на NodePort сервиса. Выпуск SSL остаётся за certbot.

sudo mitdev k8s status | grep Ingress
#   Ingress   через nginx на хосте

Если nginx нет — Traefik остаётся, и k8s deploy создаёт обычный Ingress.

nginx на хосте Traefik в кластере
SSL certbot, как для обычных сайтов нужен cert-manager
Конфиг /etc/nginx/sites-available/k8s-<имя>.conf ресурс Ingress
Маршрут домен → NodePort → Service → под домен → Ingress → Service → под
Знакомо да нужно учить

Часть III. Развёртывание

mitdev k8s deploy

sudo mitdev k8s deploy <имя> <образ> [порт] [домен] [реплики]
# полная форма
sudo mitdev k8s deploy myapp ghcr.io/org/myapp:v1.4.0 3000 app.example.com 3

# без публикации наружу (только внутри кластера)
sudo mitdev k8s deploy worker ghcr.io/org/worker:v1.4.0 8080

# интерактивно — спросит всё
sudo mitdev k8s deploy
Аргумент Обязателен По умолчанию Требования
имя да RFC 1123: строчные, цифры, дефис
образ да полная ссылка с тегом
порт нет спросит, 3000 порт внутри контейнера
домен нет пусто = без публикации
реплики нет 2

Что делает команда:

  1. Рендерит манифест в /var/lib/mitdev/k8s/<имя>.yaml
  2. kubectl apply
  3. Ждёт rollout status до 180 секунд
  4. Если задан домен: Ingress (Traefik) или конфиг nginx → NodePort
  5. Предлагает выпустить SSL через certbot

Не используйте тег latest. imagePullPolicy: Always в шаблоне заставит каждый под тянуть образ заново, и вы получите разные версии на разных узлах, если между pull'ами кто-то запушил новый latest. Тегируйте по версии или по хешу коммита.

Что генерируется

Шаблон templates/kubernetes/app.yaml.template создаёт Deployment + Service типа NodePort:

Этого достаточно для старта, но для прода вы захотите свои значения: HTTP-пробы вместо TCP, свои ресурсы, envFrom с секретами, terminationGracePeriodSeconds. Тогда — свои манифесты.

Свои манифесты

sudo mitdev k8s apply ./k8s/production.yaml

Это тонкая обёртка над k3s kubectl apply -f. Держите манифесты в репозитории рядом с кодом; mitdev k8s deploy оставьте для быстрых выкладок и стендов.

Сохраните метку managed-by: mitdev, если хотите, чтобы приложение попадало в mitdev k8s status и удалялось через k8s delete.

Секреты

Не кладите пароли в манифест и не коммитьте их.

sudo k3s kubectl create secret generic myapp-secrets \
  --from-literal=DATABASE_URL='postgresql://app:[email protected]:5432/app?sslmode=require' \
  --from-literal=REDIS_URL='redis://10.100.0.1:6379'
envFrom:
  - secretRef:  { name: myapp-secrets }
  - configMapRef: { name: myapp-config }

Помните: Secret в Kubernetes — это base64, а не шифрование. Любой с доступом к API и правами на чтение секретов в namespace прочитает их открытым текстом. В k3s секреты по умолчанию лежат в etcd незашифрованными. Для серьёзных требований — включайте encryption at rest или берите внешнее хранилище (Vault, SOPS).

Базы данных: снаружи кластера

Соблазн велик, но: не запускайте PostgreSQL в Kubernetes, пока у вас нет человека, который умеет чинить PostgreSQL в Kubernetes.

Причина не в том, что это невозможно (операторы вроде CloudNativePG работают хорошо). Причина в том, что вы складываете два независимых источника аварий: проблемы БД и проблемы кластера. Отладка «под с базой не поднялся, потому что PV не примонтировался, потому что CSI-драйвер потерял узел» — это не то, чем стоит заниматься в 3 часа ночи.

Правильная схема: кластер k3s для приложений, кластер PostgreSQL из HA.md — на отдельных серверах, связанные приватной сетью.

   ┌─── k3s: srv-1, srv-2, srv-3 ────┐
   │  приложения, воркеры, cron      │
   └────────────┬────────────────────┘
                │ WireGuard mesh
   ┌────────────┴────────────────────┐
   │  db-1 (primary), db-2, db-3     │
   │  pgcluster + watchdog + VIP     │
   └─────────────────────────────────┘

Приложение в кластере подключается к 10.100.0.100:5432 (VIP) — ровно так же, как подключалось бы с обычного сервера. Failover БД для кластера прозрачен.

Если БД всё же должна жить в кластере — используйте зрелый оператор (CloudNativePG, Zalando), а не голый StatefulSet с PersistentVolumeClaim.


Часть IV. Эксплуатация

sudo mitdev k8s status              # версия, узлы, поды, приложения mitdev
sudo mitdev k8s nodes               # kubectl get nodes -o wide
sudo mitdev k8s pods                # все namespace
sudo mitdev k8s pods kube-system    # конкретный namespace
sudo mitdev k8s services            # kubectl get svc -A
sudo mitdev k8s logs myapp          # хвост 100 строк, follow, по метке app=
sudo mitdev k8s token               # URL и токен для join

Удаление, от точечного к разрушительному:

sudo mitdev k8s delete myapp        # deploy+svc+ingress приложения, конфиг nginx
sudo mitdev k8s delete-all          # все приложения с меткой managed-by=mitdev
sudo mitdev k8s delete-cluster      # k3s целиком: все поды, все данные

delete-cluster эквивалентна mitdev remove kubernetes — сносит k3s, Helm, kubeconfig и правила файрвола. Необратимо.

Для всего, чего нет в обёртке, — обычный kubectl:

sudo k3s kubectl get events -A --sort-by=.lastTimestamp | tail -30
sudo k3s kubectl describe pod myapp-7d9f8c-x2k4l
sudo k3s kubectl rollout undo deploy/myapp          # откат на предыдущую ревизию
sudo k3s kubectl scale deploy/myapp --replicas=5
sudo k3s kubectl top nodes                          # если metrics-server стоит

Удобно завести алиас:

echo 'alias kubectl="sudo k3s kubectl"' >> ~/.bashrc

Миграция с PM2 на Kubernetes

Переезжайте по частям, а не одним прыжком.

1. Приведите приложение в порядок, оставаясь на PM2. Все шаги части I, кроме Dockerfile, работают и под PM2: вынесите состояние, переведите конфиг на env, добавьте SIGTERM и /healthz. Проверьте pm2 scale myapp 3 — три процесса работают?

2. Соберите образ и погоняйте под Docker Compose. Так вы отловите проблемы образа отдельно от проблем кластера.

3. Поднимите k3s одним узлом рядом. Выложите приложение, направьте на него 5% трафика через nginx upstream с весами.

upstream myapp {
    server 127.0.0.1:3000 weight=19;    # PM2
    server 127.0.0.1:31234 weight=1;    # NodePort k3s
}

4. Наблюдайте сутки. Сравните задержки, ошибки, память.

5. Переключите весь трафик, оставив PM2 в резерве неделю.

6. Добавьте второй и третий сервер в кластер.

На каждом шаге у вас есть путь назад. Одномоментная миграция такого пути не оставляет.


Устранение неполадок

Под в CrashLoopBackOff

sudo k3s kubectl describe pod <под>          # секция Events внизу — читать первым
sudo k3s kubectl logs <под> --previous       # логи упавшего экземпляра

Частые причины: не задана обязательная переменная окружения (валидируйте конфиг и пишите внятную ошибку); OOMKilled (смотрите Last State в describe); образ запускается от root в кластере с PSS.

Под в Pending

sudo k3s kubectl describe pod <под> | tail -20

Insufficient cpu/memory — узлы заняты, requests слишком велики. node(s) didn't match pod anti-affinity — реплик больше, чем узлов, и у вас required вместо preferred.

ImagePullBackOff

Приватный реестр без секрета:

sudo k3s kubectl create secret docker-registry ghcr \
  --docker-server=ghcr.io --docker-username=<user> --docker-password=<token>
spec:
  imagePullSecrets:
    - name: ghcr

Traefik и nginx дерутся за 80/443

sudo ss -tlnp | grep -E ':(80|443)'

Если nginx поставили после k3s, Traefik не был отключён:

sudo k3s kubectl -n kube-system delete helmchart traefik
sudo k3s kubectl -n kube-system delete svc traefik
sudo systemctl restart nginx

Узел NotReady после перезагрузки

sudo systemctl status k3s
sudo journalctl -u k3s -n 50

Если k3s стартовал раньше WireGuard, он не смог достучаться до etcd-пиров. Проверьте mitdev net status; после восстановления сети systemctl restart k3s.

Кворум etcd потерян

Симптом: kubectl отвечает, но любая запись висит.

sudo k3s kubectl get nodes     # два из трёх NotReady?

Восстанавливайте узлы. Если это невозможно, у k3s есть аварийный режим:

sudo systemctl stop k3s
sudo k3s server --cluster-reset      # ОДИН узел становится единственным
sudo systemctl start k3s

Это разрушает кластер до одного узла. Остальные придётся присоединять заново (k3s-uninstall.sh, потом join). Делать только после того, как убедились, что большинство мертво навсегда.


Что дальше