«СмартЛогистик» — это платформа для управления доставками, которая обрабатывает маршруты, отслеживает грузы в реальном времени и оптимизирует логистические цепочки. Инфраструктура: 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-ноды (preemptible instances в Yandex Cloud), сэкономив 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, оказалось, что план устарел на 4 месяца и половина шагов не работает.
Теперь мы проводим 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-решения, не экономьте на мониторинге и помните: самые дорогие уроки — те, которые вы учите в продакшене.
Оставить комментарий