Kubernetes в продакшне: 18 месяцев, 47 инцидентов и одно просветление
Прежде чем начать, хочу сказать одну важную вещь: я люблю Kubernetes. Искренне. Как любят сложного человека — за глубину, за то, что никогда не знаешь чего ожидать, за то, что каждый день чему-то учишься. И одновременно хочется иногда взять его и... ну, вы понимаете.
Восемнадцать месяцев в продакшне с k8s. Сорок семь инцидентов в PagerDuty. Из них тридцать один — "это мы сами виноваты". Остальные шестнадцать — "это k8s виноват, но мы неправильно его настроили". Итого: сорок семь раз мы были виноваты сами. Добро пожаловать в правду.
Начало: эйфория
Мы переехали на k8s с bare-metal + ansible + systemd. По тем временам это был большой шаг. Наконец-то: декларативная конфигурация, автомасштабирование, rolling updates без даунтайма, самолечение. Красота!
Первая неделя прошла в написании манифестов. Я писал их как поэт — вдохновенно, не жалея строк. YAML цвёл. Deployments, Services, ConfigMaps, Secrets, Ingress — всё было прекрасно.
apiVersion: apps/v1
kind: Deployment
metadata:
name: php-app
spec:
replicas: 3
template:
spec:
containers:
- name: php-fpm
image: myapp:latest # ← вот здесь уже первая мина
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
image: myapp:latest — это первая из ошибок, которая позже стоила нам часа даунтайма. Тег latest — это зло. Это не версия, это "бог знает что". При следующем деплое k8s может подтянуть другой образ на разные ноды, и у вас будет кластер, где половина подов — старая версия, а половина — новая, и они несовместимы. Всегда используйте immutable теги: git sha, semver, timestamp. Всегда.
Инцидент #7: OOMKiller приходит ночью
Наше PHP приложение — CI4 с тяжёлой бизнес-логикой. PHP-FPM воркеры потребляют память по-разному в зависимости от endpoint'а. Лёгкий API — 40MB. Тяжёлый отчёт — 380MB. Мы выставили limits.memory: 512Mi и думали, что этого хватит.
Ночью запустился cron-джоб, который генерировал ежемесячные отчёты. Каждый воркер под отчёт — ~380MB. PHP-FPM с 8 воркерами = 3GB. Лимит пода — 512MB.
OOMKilled
Exit Code: 137
Под убит. k8s перезапускает. Новый под опять запускает отчёт. Опять OOMKilled. Restart loop. Кейс называется CrashLoopBackOff, и выглядит он в логах примерно так:
pod/php-app-7d9f8c-xk2p9 0/1 CrashLoopBackOff 14 47m
Четырнадцать рестартов за 47 минут. k8s упорно пытался поднять под, PHP упорно пытался сожрать память, OOMKiller упорно его убивал. Это было похоже на зомби-апокалипсис в миниатюре.
Решение: разделение воркеров
Мы разделили PHP-FPM на два пула:
; /etc/php-fpm.d/www.conf — общий пул
[www]
pm = dynamic
pm.max_children = 20
pm.max_requests = 500
; /etc/php-fpm.d/heavy.conf — пул для тяжёлых операций
[heavy]
pm = ondemand
pm.max_children = 4
pm.max_requests = 50
pm.process_idle_timeout = 10s
И создали отдельный Deployment для heavy-операций с увеличенными лимитами:
# heavy-deployment.yaml
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2000m"
Nginx роутит по prefix:
location /api/reports/ {
fastcgi_pass heavy-php-fpm:9001;
}
location / {
fastcgi_pass www-php-fpm:9000;
}
Элегантно? Не очень. Работает? Да.
Инцидент #19: Liveness probe убивает прод
Это был шедевр. Мы настроили liveness probe — k8s регулярно проверяет, жив ли под, и если нет — убивает и перезапускает:
livenessProbe:
httpGet:
path: /health
port: 80
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
Endpoint /health возвращал 200 и JSON со статусами всех зависимостей: Redis, MySQL, очередь. Казалось бы — отлично.
И вот в один прекрасный день MySQL реплика начала лагать (проблема с дисками). Запросы к реплике стали занимать 15-20 секунд. Наш /health endpoint проверял реплику, таймаут probe — 5 секунд. Итог:
Liveness probe failed: Get "http://10.244.2.5/health": context deadline exceeded
k8s решил, что под нездоров. Убил его. Поднял новый. Новый тоже упёрся в лагающую реплику. Тоже убит. В какой-то момент k8s убивал поды быстрее, чем они успевали принять трафик.
Мы устроили собственноручный DDoS на наш прод с помощью liveness probe. Это надо уметь.
Решение:
Разделили liveness и readiness:
# Liveness: только "жив ли процесс"
livenessProbe:
httpGet:
path: /health/live # Проверяет только PHP-FPM ping
port: 80
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 2
failureThreshold: 5
# Readiness: "готов ли принимать трафик"
readinessProbe:
httpGet:
path: /health/ready # Проверяет все зависимости
port: 80
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
// CI4 Controller
class HealthController extends BaseController
{
public function live(): ResponseInterface
{
// Только базовая проверка процесса
return $this->response->setJSON(['status' => 'ok']);
}
public function ready(): ResponseInterface
{
$checks = [
'database' => $this->checkDatabase(),
'redis' => $this->checkRedis(),
'queue' => $this->checkQueue(),
];
$healthy = !in_array(false, $checks, true);
return $this->response
->setStatusCode($healthy ? 200 : 503)
->setJSON([
'status' => $healthy ? 'ready' : 'degraded',
'checks' => $checks,
]);
}
}
Теперь при проблемах с репликой поды переставали получать трафик (readiness failed), но не убивались (liveness — ok). Через 20 минут реплика восстановилась, readiness снова позеленела, трафик вернулся. Без единой 500-й ошибки.
Инцидент #31: HPA и смерть от масштабирования
Horizontal Pod Autoscaler — прекрасная вещь. Трафик растёт — поды добавляются. Трафик падает — поды убираются. Автоматически. Без участия человека.
Кроме случаев, когда всё идёт не так.
Мы настроили HPA по CPU:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: php-app-hpa
spec:
minReplicas: 3
maxReplicas: 30
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Ждём нагрузочного теста. Трафик растёт. HPA видит CPU 85% → добавляет поды. Новые поды стартуют, прогревают OPcache, пока прогреваются — CPU у них 90% → HPA добавляет ещё поды. Те тоже прогреваются → CPU опять высокий → ещё поды.
Через 3 минуты у нас было 28 подов из максимальных 30. Они все одновременно прогревали OPcache, коннектились к MySQL, коннектились к Redis. MySQL задыхался от 28×20 = 560 новых соединений. Redis cluster не успевал. Прод лёг под весом собственного масштабирования.
Решение:
Добавить cooldown и stabilization:
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2 # Не более 2 подов за раз
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300
Использовать custom metrics (RPS), а не CPU:
metrics:
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "300"
Добавить PHP-FPM warming в startup probe:
startupProbe:
httpGet:
path: /warmup
port: 80
failureThreshold: 30
periodSeconds: 5
Endpoint /warmup прогревал OPcache и пул соединений. Пока startupProbe не вернула 200 — под не получал трафик и не учитывался в метриках HPA.
Просветление
После 18 месяцев и 47 инцидентов я пришёл к простому выводу: Kubernetes не упрощает жизнь. Он переносит сложность из "как запустить" в "как правильно настроить". И это совершенно другой уровень сложности — более тонкий, более коварный, и требующий понимания системы на глубоком уровне.
Ключевые правила, которые я вколотил в стену рядом с рабочим столом:
Никогда
image:latest— только иммутабельные тегиLiveness ≠ Readiness — это разные вещи с разной ценой ошибки
Resource limits — это не потолок, это граница жизни и смерти пода
HPA с CPU — ловушка для PHP. Используйте RPS или custom metrics
Один под не значит один процесс — PHP-FPM это N воркеров
Chaos engineering — убивайте поды намеренно, пока это не сделает production
И последнее: k8s — это не серебряная пуля. Это мощный инструмент с огромным количеством движущихся частей. Уважайте его, изучайте, читайте исходники при необходимости. И обязательно найдите коммьюнити — на ithub.uno, на форумах, на конференциях. Потому что некоторые грабли лучше подбирать чужим лбом.
До следующего CrashLoopBackOff. 🤕
Recommended Comments