Позвольте рассказать о «СмартЛогистик» — нашей платформе для управления доставками. Она не только строит маршруты и отслеживает грузы в реальном времени, но и помогает нам здорово оптимизировать логистические цепочки. Что же у неё «под капотом»? Это managed-кластер Kubernetes на 28 нод в Yandex Cloud. Мы используем 15 микросервисов, написанных на Go и Python, а также PostgreSQL, Redis и Kafka. Обычно мы держим среднесуточно 3000 RPS, но по утрам к нам часто прилетают пики, доходящие до 8000 RPS.
Kubernetes в продакшене: 10 уроков первого года
Контекст проекта
Урок 1: Resource Limits — не роскошь, а страховка
Как сейчас помню, в первый же месяц, когда мы только выкатили сервис обработки маршрутов, случился казус: мы забыли проставить resource limits. Пока нагрузка была обычной, всё шло как по маслу. Но вот на пике сервис просто взял и сожрал всю память ноды. Kubelet, конечно, не растерялся и прибил его через OOMKill. Но вот что неприятно: пока это происходило, сервис успел утащить за собой ещё три пода, которые, к несчастью, жили на той же ноде.
С тех пор правило железное: ни один под не уезжает в продакшен без limits.
apiVersion: apps/v1
kind: Deployment
metadata:
name: route-optimizer
namespace: logistics
spec:
replicas: 3
selector:
matchLabels:
app: route-optimizer
template:
metadata:
labels:
app: route-optimizer
spec:
containers:
- name: route-optimizer
image: registry.smartlogistic.ru/route-optimizer:v2.4.1
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "1"
memory: 512Mi
Практические правила, которые мы выработали: requests = типичное потребление (P50), limits = пиковое потребление (P99) с запасом 20%. CPU limits лучше ставить с осторожностью — throttling может быть хуже, чем отсутствие лимита. Для memory limits всё наоборот — ставьте всегда, OOMKill непредсказуем.
Чтобы держать это под контролем, навесили LimitRange на уровень namespace:
apiVersion: v1
kind: LimitRange
metadata:
name: default-limits
namespace: logistics
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 256Mi
defaultRequest:
cpu: 100m
memory: 128Mi
max:
cpu: "4"
memory: 4GiУрок 2: HPA требует тонкой настройки
Horizontal Pod Autoscaler — мощный инструмент, но дефолтная настройка по CPU редко работает хорошо. Наш сервис отслеживания грузов был IO-bound: CPU оставался на 20%, а запросы копились в очереди. HPA не масштабировал, потому что смотрел только на CPU.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: tracking-service-hpa
namespace: logistics
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: tracking-service
minReplicas: 3
maxReplicas: 20
metrics:
# Кастомная метрика — количество запросов в секунду на под
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "100"
# CPU как дополнительный сигнал
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Pods
value: 4
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # 5 минут стабилизации перед scale down
policies:
- type: Percent
value: 25
periodSeconds: 120
Ключевой момент — behavior. Без stabilizationWindow HPA начинает «пилить»: масштабирует вверх, нагрузка падает, масштабирует вниз, нагрузка растёт. Scale down должен быть медленным и осторожным.
Урок 3: Pod Disruption Budgets спасают от простоя
Поды в Kubernetes, как известно, постоянно в движении. Они переезжают, когда мы обновляем ноды, когда срабатывает кластерный автоскейлинг или когда отбираются spot-инстансы. Так вот, без PDB наш кластер запросто может прихлопнуть сразу все реплики сервиса, именно в тот момент, когда он обновляет какую-то ноду.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: tracking-service-pdb
namespace: logistics
spec:
minAvailable: 2 # или maxUnavailable: 1
selector:
matchLabels:
app: tracking-service
Мы усвоили это после инцидента: обновление Kubernetes-нод убило все 3 реплики API-шлюза одновременно, потому что все они оказались на двух нодах, попавших под обновление. 4 минуты полного простоя. С PDB drain ноды ждёт, пока новые поды поднимутся на других нодах.
Урок 4: Секреты через HashiCorp Vault, не через Kubernetes Secrets
Знаете, Kubernetes Secrets хранятся в etcd в виде base64. И тут очень важно понимать: это не полноценное шифрование, а всего лишь кодировка. Любой, у кого есть доступ к кластеру, сможет их прочитать без какого-либо труда. А для нашей логистической платформы, которая ежедневно работает с конфиденциальными данными клиентов и API-ключами перевозчиков, такое положение дел попросту неприемлемо и недопустимо.
Поэтому подключили HashiCorp Vault с Vault Agent Injector:
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-integration
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "payment-integration"
vault.hashicorp.com/agent-inject-secret-db-creds: "secret/data/logistics/db"
vault.hashicorp.com/agent-inject-template-db-creds: |
{{- with secret "secret/data/logistics/db" -}}
DB_HOST={{ .Data.data.host }}
DB_PORT={{ .Data.data.port }}
DB_USER={{ .Data.data.username }}
DB_PASSWORD={{ .Data.data.password }}
{{- end -}}
spec:
serviceAccountName: payment-integration
containers:
- name: app
image: registry.smartlogistic.ru/payment-integration:v1.8.0
command: ["sh", "-c", "source /vault/secrets/db-creds && ./app"]
Vault автоматически ротирует credentials каждые 24 часа. Приложение получает новые секреты через перемонтированный файл без рестарта пода. Audit log показывает, кто и когда запрашивал секреты.
Урок 5: Network Policies — zero trust внутри кластера
Что важно помнить: по умолчанию в Kubernetes абсолютно любой под может свободно стучаться к любому другому. А это означает, что если вдруг удастся скомпрометировать всего один сервис, то доступ мгновенно будет открыт ко всем остальным нашим компонентам.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: tracking-service-netpol
namespace: logistics
spec:
podSelector:
matchLabels:
app: tracking-service
policyTypes:
- Ingress
- Egress
ingress:
# Принимаем трафик только от API gateway и мониторинга
- from:
- podSelector:
matchLabels:
app: api-gateway
- podSelector:
matchLabels:
app: prometheus
ports:
- port: 8080
protocol: TCP
- port: 9090 # метрики
protocol: TCP
egress:
# Разрешаем только PostgreSQL, Redis и Kafka
- to:
- podSelector:
matchLabels:
app: postgresql
ports:
- port: 5432
- to:
- podSelector:
matchLabels:
app: redis
ports:
- port: 6379
- to:
- podSelector:
matchLabels:
app: kafka
ports:
- port: 9092
# DNS
- to:
- namespaceSelector: {}
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- port: 53
protocol: UDP
Не забывайте про DNS в egress. Мы потратили 2 часа на дебаг, пока не поняли, что заблокировали DNS-резолвинг.
Урок 6: Init Containers для управления зависимостями
Мы заметили одну особенность: наш сервис поднимается быстрее, чем его зависимости. Когда мы разворачивали кластер с нуля, он постоянно падал. А причина была проста: PostgreSQL ещё попросту не успевал прийти в полную готовность.
spec:
initContainers:
# Ждём готовности PostgreSQL
- name: wait-for-postgres
image: busybox:1.36
command:
- sh
- -c
- |
until nc -z postgresql.logistics.svc.cluster.local 5432; do
echo "Waiting for PostgreSQL..."
sleep 2
done
# Выполняем миграции БД
- name: run-migrations
image: registry.smartlogistic.ru/tracking-service:v3.1.0
command: ["./migrate", "up"]
envFrom:
- secretRef:
name: tracking-db-credentials
containers:
- name: tracking-service
image: registry.smartlogistic.ru/tracking-service:v3.1.0
Init containers гарантируют, что основной контейнер стартует только после выполнения всех подготовительных шагов. Миграции БД в init container — особенно удобно: если миграция упала, под не стартует, rollout останавливается, и мы сразу видим проблему.
Урок 7: Liveness vs Readiness — не путайте их
Самый частый промах новичков — сделать liveness и readiness probes одинаковыми. Под нагрузкой это выливается в каскадные рестарты.
spec:
containers:
- name: api-gateway
ports:
- containerPort: 8080
# Readiness: «могу ли я принимать трафик?»
# Если не ready — под исключается из Service,
# но НЕ перезапускается
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
successThreshold: 1
# Liveness: «жив ли процесс вообще?»
# Если не alive — под ПЕРЕЗАПУСКАЕТСЯ
livenessProbe:
httpGet:
path: /healthz/alive
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 5 # высокий порог — не перезапускаем при временных проблемах
# Startup probe для медленного старта
startupProbe:
httpGet:
path: /healthz/alive
port: 8080
failureThreshold: 30
periodSeconds: 5 # 30 * 5 = 150 секунд на старт
Наш Go-хендлер для health checks:
func (s *Server) healthAlive(w http.ResponseWriter, r *http.Request) {
// Liveness: проверяем только что процесс жив
// НЕ проверяем зависимости — иначе падение БД
// вызовет рестарт всех подов
w.WriteHeader(http.StatusOK)
w.Write([]byte("alive"))
}
func (s *Server) healthReady(w http.ResponseWriter, r *http.Request) {
// Readiness: проверяем зависимости
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "db: %v", err)
return
}
if err := s.redis.Ping(ctx).Err(); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, "redis: %v", err)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
}
Правило: liveness probe никогда не должен проверять внешние зависимости. Иначе падение базы данных убьёт все поды приложения, хотя сами процессы работают нормально.
Урок 8: Spot-инстансы экономят 60%, но требуют подготовки
Мы решили перевести наши Stateless-сервисы на spot-ноды, которые в Yandex Cloud называются preemptible-инстансами. И знаете что? Мы смогли сократить наши затраты на вычисления аж на 62%! Однако, стоит сразу сказать, просто так это не работает — нам пришлось как следует подготовиться:
- Все сервисы обрабатывают graceful shutdown за 30 секунд (Kubernetes даёт SIGTERM перед SIGKILL).
- PDB гарантирует, что минимум 50% реплик всегда доступны.
- Pod Topology Spread распределяет поды по зонам доступности.
- On-demand ноды для stateful workloads (PostgreSQL, Kafka, Redis).
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: tracking-service
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-type
operator: In
values:
- spot
tolerations:
- key: "cloud.google.com/gke-preemptible"
operator: "Equal"
value: "true"
effect: "NoSchedule"Урок 9: GitOps с ArgoCD — единственный путь в продакшен
Мы запретили kubectl apply руками в продакшене. Все изменения инфраструктуры идут через Git:
# Структура репозитория
infra/
base/
tracking-service/
deployment.yaml
service.yaml
hpa.yaml
netpol.yaml
kustomization.yaml
overlays/
staging/
kustomization.yaml
patches/
production/
kustomization.yaml
patches/
resource-limits.yaml
replicas.yaml
ArgoCD следит за Git-репозиторием и автоматически синхронизирует состояние кластера. Любое расхождение видно на дашборде. Если кто-то вручную изменит что-то в кластере — ArgoCD откатит назад.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: tracking-service
namespace: argocd
spec:
project: logistics
source:
repoURL: https://git.smartlogistic.ru/infra.git
path: infra/overlays/production
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: logistics
syncPolicy:
automated:
prune: true # удалять ресурсы, которых нет в Git
selfHeal: true # откатывать ручные изменения
syncOptions:
- CreateNamespace=true
retry:
limit: 3
backoff:
duration: 5s
factor: 2
maxDuration: 3m0s
Результат: полная аудируемость (кто, когда, что изменил — видно в git log), мгновенный откат (git revert), одинаковый процесс для staging и production.
Урок 10: Disaster Recovery — проверяйте, а не планируйте
Да, конечно, план аварийного восстановления у нас был. Очень красивый, надо сказать, документ в Confluence. Но вот когда кластер реально лёг из-за серьёзной проблемы с etcd, тогда-то и выяснилось: наш план протух аж на четыре месяца, и, к сожалению, добрая половина шагов, описанных в нём, уже просто не работала.
Теперь DR-учения проводим каждый месяц:
- Velero для бэкапа ресурсов Kubernetes и persistent volumes. Бэкапы каждые 6 часов, хранение 30 дней.
- Тест восстановления — раз в месяц поднимаем кластер из бэкапа в отдельном namespace и проверяем работоспособность.
- Chaos Engineering — используем Litmus Chaos для имитации отказов: убийство подов, отключение нод, инъекция задержек сети.
# Velero: ежедневный бэкап namespace logistics
apiVersion: velero.io/v1
kind: Schedule
metadata:
name: logistics-daily-backup
namespace: velero
spec:
schedule: "0 */6 * * *"
template:
includedNamespaces:
- logistics
includedResources:
- deployments
- services
- configmaps
- secrets
- persistentvolumeclaims
storageLocation: yandex-s3
ttl: 720h # 30 дней
snapshotVolumes: true
После внедрения ежемесячных учений время восстановления кластера сократилось с «неизвестно» до 22 минут. И, что важнее, команда уверена в процессе и не паникует при реальных инцидентах.
Итоги первого года
Kubernetes — не серебряная пуля. Это мощная штука, но она требует и экспертизы, и дисциплины. За год работы с кластером «СмартЛогистик» мы вышли на такие цифры:
- 99.95% uptime — при SLA 99.9%. Три инцидента за год, максимальный простой — 8 минут.
- Сокращение расходов на инфраструктуру на 45% — за счёт spot-инстансов, автоскейлинга и правильного ресурсного планирования.
- Время деплоя 4 минуты — от коммита до продакшена через ArgoCD.
- DR recovery за 22 минуты — проверенный и отработанный процесс.
Мой личный совет, если вы только-только начинаете своё знакомство с Kubernetes: обязательно берите managed-решение. Ни в коем случае не жадничайте на мониторинге. И всегда держите в голове одну очень важную мысль: самые дорогие уроки — это те, что приходится учить прямо в продакшене.

Оставить комментарий