Kubernetes: подготовка проекта и развёртывание
Как довести приложение до состояния, в котором его можно запускать в Kubernetes, и как поднять отказоустойчивый кластер k3s силами mitdev.
Читать этот документ имеет смысл, только если вы прочли HA.md и сознательно решили, что схема «репликация + watchdog» вам не подходит. Kubernetes — не улучшенная версия трёх серверов с nginx. Это другой набор компромиссов.
Содержание
- Нужен ли вам Kubernetes
- Что ставит mitdev
- Часть I. Подготовка проекта
- Двенадцать факторов, но честно
- Шаг 1. Приложение без состояния
- Шаг 2. Конфигурация из окружения
- Шаг 3. Корректное завершение
- Шаг 4. Проверки живости и готовности
- Шаг 5. Dockerfile, пригодный для кластера
- Шаг 6. Логи в stdout
- Шаг 7. Миграции как отдельный шаг
- Шаг 8. Ресурсы: requests и limits
- Чек-лист готовности
- Часть II. Кластер
- Часть III. Развёртывание
- Часть IV. Эксплуатация
- Миграция с PM2 на Kubernetes
- Устранение неполадок
Нужен ли вам Kubernetes
Честные вопросы. Если на большинство ответ «нет» — вернитесь к HA.md.
- У вас больше пяти-шести самостоятельных сервисов, которые надо катить независимо друг от друга?
- Разные команды деплоят в одну инфраструктуру и мешают друг другу?
- Нагрузка меняется в разы в течение суток, и вы хотите автоскейлинг?
- Вы уже упёрлись в потолок «nginx + PM2 + три сервера» и можете назвать, во что именно?
- Есть человек, который в 3 часа ночи разберётся, почему под в
CrashLoopBackOffиз-за OOM в init-контейнере?
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"]
Три частые ошибки:
CMD npm start. npm не пробрасываетSIGTERMдочернему процессу. Приложение никогда не увидит сигнал, всегда получитSIGKILLчерез 30 с, и каждый деплой будет рвать соединения. Запускайтеnodeнапрямую.- Работа от root.
USER 10001обязателен. Многие кластеры имеютPodSecurityStandards, запрещающие root, и ваш под просто не стартует. 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-киллером до того, как сборщик мусора решит поработать.
Чек-лист готовности
- Две копии приложения работают параллельно без общего локального диска
- Сессии, загрузки, кэш — во внешних хранилищах
- Вся конфигурация — из переменных окружения, валидируется при старте
-
SIGTERMобрабатывается, соединения дренируются, есть пауза перед закрытием -
/healthzне трогает БД;/readyzтрогает - Dockerfile: multi-stage,
USERне root,tini,nodeнапрямую (неnpm) - Логи — однострочный JSON в stdout
- Миграции — отдельный Job, обратно совместимые
-
requestsзаданы;limits.memoryсогласован с--max-old-space-size - Образ проверен локально:
/healthz, graceful stop, non-root
Часть 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 |
Что делает команда:
- Рендерит манифест в
/var/lib/mitdev/k8s/<имя>.yaml kubectl apply- Ждёт
rollout statusдо 180 секунд - Если задан домен: Ingress (Traefik) или конфиг nginx → NodePort
- Предлагает выпустить SSL через certbot
Не используйте тег latest. imagePullPolicy: Always в шаблоне заставит
каждый под тянуть образ заново, и вы получите разные версии на разных узлах,
если между pull'ами кто-то запушил новый latest. Тегируйте по версии или по
хешу коммита.
Что генерируется
Шаблон templates/kubernetes/app.yaml.template создаёт Deployment + Service
типа NodePort:
strategy: RollingUpdateсmaxUnavailable: 0, maxSurge: 1— новая реплика поднимается и проходит readiness до остановки старой. Простоя нет, но на время выкладки нужен запас ресурсов на одну реплику.podAntiAffinity— разнесение по узлам.requests: cpu 50m, memory 64Mi;limits: memory 512Mi.- Пробы
tcpSocket— readiness через 5 с, liveness через 15 с. - Метка
managed-by: mitdev— по ней работаютk8s statusиk8s delete-all.
Этого достаточно для старта, но для прода вы захотите свои значения: 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). Делать только после того, как убедились,
что большинство мертво навсегда.
Что дальше
- HA.md — кластеры БД, которые остаются снаружи Kubernetes
- MESH.md — приватная сеть под кластером
- DEPLOY.md — рецепты развёртывания без Kubernetes
- COMMANDS.md — справочник
mitdev k8s