Модуль: DevOps · Уровень: Middle+/Senior

TL;DR#

  • Pod — минимальная единица деплоя (1+ контейнеров с общими network namespace и volumes). Поды эфемерны и заменяемы.
  • Deployment управляет ReplicaSet’ами, обеспечивает rolling update и self-healing. Service даёт стабильный виртуальный IP/DNS и L4-балансировку поверх меняющихся подов. Ingress — L7-роутинг (host/path) снаружи.
  • ConfigMap — некритичная конфигурация, Secret — чувствительные данные (base64, не шифрование по умолчанию). Монтируются как env или файлы.
  • Probes: liveness (перезапуск зависшего), readiness (убрать из балансировки, не убивая), startup (для медленного старта, отключает остальные пока не пройдёт).
  • requests — гарантированный минимум для планировщика, limits — потолок. CPU limit троттлит, memory limit → OOMKill.
  • Главная Go-проблема: GOMAXPROCS не видит cgroup-лимит CPU — по умолчанию берёт число ядер ноды, что вызывает throttling. Решение: automaxprocs или Go 1.25+ (cgroup-aware runtime). Аналогично GOMEMLIMIT под memory limit.
  • Graceful shutdown: SIGTERM → дренаж readiness → дослужить запросы → exit до истечения terminationGracePeriodSeconds. preStop хук для синхронизации с балансировщиком.

Теория#

Базовые объекты#

Pod — один или несколько контейнеров, разделяющих сетевой namespace (один IP, общий localhost) и volumes. В norm-практике 1 контейнер на под + sidecar’ы (логи, прокси). Под не переживает перезапуск ноды — его пересоздаёт контроллер.

ReplicaSet — поддерживает N идентичных реплик. Напрямую почти не создают.

Deployment — декларативное управление ReplicaSet’ами: rolling update, rollback, масштабирование, self-healing.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 3
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api }
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: registry/api:1.4.2
          ports: [{ containerPort: 8080 }]
          env:
            - name: GOMEMLIMIT
              valueFrom:
                resourceFieldRef: { resource: limits.memory }
          resources:
            requests: { cpu: "250m", memory: "128Mi" }
            limits:   { cpu: "1",    memory: "256Mi" }
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10
          startupProbe:
            httpGet: { path: /healthz, port: 8080 }
            failureThreshold: 30
            periodSeconds: 2
          lifecycle:
            preStop:
              exec: { command: ["sleep", "5"] }

Service — стабильная точка доступа поверх подов (по label selector), L4-балансировка через kube-proxy/iptables/IPVS. Типы:

  • ClusterIP — внутренний виртуальный IP (дефолт).
  • NodePort — порт на каждой ноде.
  • LoadBalancer — внешний LB облака.
  • Headless (clusterIP: None) — DNS отдаёт IP всех подов напрямую (для StatefulSet, клиентской балансировки, gRPC).

Ingress — L7 (HTTP/HTTPS) роутинг по host/path, TLS-терминация. Нужен Ingress Controller (nginx, traefik). Современная замена — Gateway API.

ConfigMap и Secret#

apiVersion: v1
kind: ConfigMap
metadata: { name: app-config }
data:
  LOG_LEVEL: "info"
  config.yaml: |
    timeout: 30s
---
apiVersion: v1
kind: Secret
metadata: { name: app-secret }
type: Opaque
data:
  DB_PASSWORD: c2VjcmV0   # base64, НЕ шифрование

Подключение:

envFrom:
  - configMapRef: { name: app-config }
  - secretRef:    { name: app-secret }
volumeMounts:
  - { name: cfg, mountPath: /etc/app, readOnly: true }
  • Secret в etcd по умолчанию только base64, не шифрование. Включайте encryption-at-rest для etcd и/или внешние хранилища (Vault, External Secrets Operator, cloud KMS).
  • env vs volume: значения через env фиксируются на старте пода — обновление ConfigMap не подхватится без рестарта. Смонтированные файлы обновляются автоматически (с задержкой), но приложение должно их перечитывать (watch/SIGHUP).

Probes#

ProbeЧто делает при провалеЗачем
livenessперезапускает контейнервытащить из дедлока/зависания
readinessубирает под из endpoints Service (трафик не идёт), под живётвременная неготовность: прогрев кэша, потеря БД, дренаж при shutdown
startupне убивает, пока не превышен failureThreshold; до прохождения liveness/readiness не запускаютсязащита медленно стартующих приложений от преждевременного liveness-kill

Критично разделять /healthz (liveness — процесс жив) и /readyz (readiness — готов принимать трафик). Ошибка: liveness, проверяющий БД — при кратком сбое БД k8s начнёт каскадно рестартовать все поды (crash loop), хотя приложение в порядке. Зависимости проверяйте в readiness.

requests / limits и QoS#

  • requests — то, что планировщик резервирует для пода; используется для bin-packing на ноды.
  • limits — жёсткий потолок. CPU limit → throttling (CFS quota, под не убивают, но тормозят). Memory limit → OOMKill при превышении.
  • QoS-классы: Guaranteed (requests==limits для всех ресурсов), Burstable (есть requests < limits), BestEffort (ничего не задано). При нехватке памяти на ноде первыми эвиктятся BestEffort, потом Burstable.

Споры по CPU limit: многие убирают CPU limit (оставляя только request), чтобы избежать throttling, но это снижает изоляцию. Memory limit ставить обязательно.

HPA#

Horizontal Pod Autoscaler масштабирует реплики по метрикам.

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api }
spec:
  scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: api }
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target: { type: Utilization, averageUtilization: 70 }
  • Целевой % считается от requests, не от limits. Поэтому корректный CPU request критичен для HPA.
  • Нужен metrics-server (для resource-метрик) или Prometheus Adapter / KEDA (для custom-метрик: RPS, длина очереди).
  • GOMAXPROCS-проблема прямо бьёт по HPA: если рантайм троттлится из-за неверного GOMAXPROCS, метрики CPU искажаются и автоскейл работает неправильно.

Rolling update#

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 0   # не уменьшать доступную ёмкость
    maxSurge: 1         # можно поднять +1 под сверх replicas

Поды заменяются по одному: поднимается новый, ждём readiness, гасим старый. maxUnavailable: 0 + maxSurge гарантирует отсутствие просадки ёмкости. Откат: kubectl rollout undo deployment/api.

Graceful shutdown (важнейшее для backend на Go)#

Последовательность при удалении пода:

  1. Под помечается Terminating, удаляется из endpoints Service (асинхронно).
  2. Выполняется preStop хук (если есть).
  3. Контейнеру шлётся SIGTERM.
  4. Ждём до terminationGracePeriodSeconds (дефолт 30s).
  5. Если жив — SIGKILL.

Гонка: удаление из endpoints и SIGTERM происходят параллельно. Балансировщик может ещё слать трафик в под, который уже получил SIGTERM. Решение — preStop: sleep 5–10s: даём времени iptables/endpoint-контроллеру обновиться ДО того, как начнём останавливать сервер.

Go-реализация:

func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux}
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()
    <-ctx.Done() // получили SIGTERM

    // снимаем readiness, чтобы новый трафик не шёл
    atomic.StoreInt32(&ready, 0)

    // дослуживаем активные запросы
    shCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(shCtx); err != nil {
        log.Printf("graceful shutdown failed: %v", err)
    }
}

Важно: srv.Shutdown дожидается завершения in-flight запросов, но не прерывает долгие. Таймаут shutdown должен быть < terminationGracePeriodSeconds, иначе SIGKILL прервёт на середине.

GOMAXPROCS, GOMEMLIMIT и cgroups#

Проблема: Go runtime по умолчанию ставит GOMAXPROCS = число логических CPU ноды (через /proc), игнорируя cgroup CPU quota. На ноде с 64 ядрами и лимитом limits.cpu: 2 Go создаст 64 P, runtime-scheduler будет раскидывать горутины на 64 потока, CFS начнёт жёстко throttle’ить → высокая latency, скачки p99, неэффективный GC.

Решения:

  • go.uber.org/automaxprocsimport _ "go.uber.org/automaxprocs", читает cgroup quota и выставляет GOMAXPROCS = floor(quota).
  • Go 1.25+ — рантайм стал cgroup-aware и сам учитывает CPU quota (но проверяйте версию; automaxprocs остаётся надёжным фолбэком).
  • Вручную: env GOMAXPROCS из downward API.

GOMEMLIMIT (Go 1.19+) — soft memory limit для GC. Без него Go может вырасти до memory limit и получить OOMKill раньше, чем GC решит собрать мусор. Выставляйте GOMEMLIMIT в ~80–90% от limits.memory:

env:
  - name: GOMEMLIMIT
    value: "230MiB"   # при limits.memory: 256Mi

GOMEMLIMIT — soft limit: GC станет агрессивнее у границы, но не гарантирует, что процесс не превысит лимит (может крутить GC в死, “GC death spiral”, если памяти реально не хватает). Комбинируйте с реалистичным memory limit.

Подводные камни / gotchas#

  • Liveness проверяет внешние зависимости → каскадные рестарты при сбое БД. Зависимости — только в readiness.
  • Нет preStop sleep → 502/connection reset во время деплоя из-за гонки endpoints vs SIGTERM.
  • GOMAXPROCS = ядра ноды → CPU throttling, рост p99. Ставьте automaxprocs.
  • Нет GOMEMLIMIT → OOMKill вместо GC. Симптом: под рестартует с reason OOMKilled, exit code 137.
  • env из ConfigMap не обновляется → меняете ConfigMap, под не видит до рестарта. Используйте volume + watch или сделайте rollout.
  • CPU limit ставит throttling даже без превышения среднего — CFS quota применяется в окнах 100ms, burst-нагрузка троттлится. Многие убирают CPU limit.
  • HPA не масштабирует → не выставлен resource request (HPA считает % от request) или нет metrics-server.
  • Shutdown timeout > terminationGracePeriodSeconds → SIGKILL обрывает graceful shutdown. Держите запас.
  • Secret в git как plain YAML → утечка. Используйте SOPS/sealed-secrets/External Secrets.
  • imagePullPolicy: Always с тегом latest vs кэш — невоспроизводимость. Пинуйте теги/digest.
  • Под не реагирует на SIGTERM — приложение запущено через shell-обёртку (не PID 1) или не ловит сигнал. Exec-форма ENTRYPOINT + signal.NotifyContext.

Вопросы на собеседовании#

В: Разница между liveness и readiness probe? О: Liveness при провале перезапускает контейнер (лечит зависание/дедлок). Readiness при провале убирает под из endpoints Service — трафик не идёт, но под не убивают (временная неготовность, прогрев, дренаж). Внешние зависимости проверяют только в readiness, иначе сбой БД вызовет каскадные рестарты.

В: Что происходит при kubectl delete pod или rolling update, на уровне сигналов? О: Под → Terminating, параллельно удаляется из endpoints; выполняется preStop; контейнер получает SIGTERM; ждём terminationGracePeriodSeconds; если жив — SIGKILL. Приложение должно по SIGTERM снять readiness и сделать graceful shutdown активных запросов до истечения grace period.

В: Почему Go-сервис в k8s тормозит, хотя CPU не утилизирован полностью? О: GOMAXPROCS взят по числу ядер ноды, а не по cgroup CPU quota. Runtime раскидывает горутины на много потоков, CFS троттлит до квоты → латентность растёт. Лечится automaxprocs или Go 1.25+ cgroup-aware рантаймом.

В: В чём разница requests и limits, и что такое QoS? О: requests — резерв для планировщика (bin-packing, основа для HPA). limits — потолок: CPU → throttling, memory → OOMKill. QoS: Guaranteed (requests==limits), Burstable (requests<limits), BestEffort (ничего). При нехватке памяти эвиктятся сначала BestEffort.

В: Как избежать OOMKill у Go-сервиса? О: Выставить GOMEMLIMIT (~80–90% от limits.memory), чтобы GC становился агрессивнее у границы и не давал процессу дорасти до лимита. Плюс реалистичный memory limit и профилирование heap.

В: Зачем preStop хук с sleep? О: Из-за гонки: удаление пода из endpoints и доставка SIGTERM происходят асинхронно. preStop sleep даёт балансировщику время перестать слать трафик до начала остановки сервера, исключая 502/RST во время деплоя.

В: ConfigMap через env vs через volume? О: env фиксируется на старте — обновление ConfigMap требует рестарта пода. Volume-файлы обновляются автоматически (с задержкой), но приложение должно их перечитывать. Для динамической перезагрузки — volume + fsnotify/SIGHUP.

В: Как работает HPA и от чего считается целевая нагрузка? О: HPA масштабирует реплики по метрикам; для CPU считает Utilization как % от requests (не limits). Нужен metrics-server (resource-метрики) или Prometheus Adapter/KEDA (custom: RPS, длина очереди). Без корректного request HPA работает неправильно.

В: Чем Service отличается от Ingress? О: Service — L4 (TCP/UDP), стабильный ClusterIP + балансировка по подам через kube-proxy. Ingress — L7 (HTTP), роутинг по host/path и TLS-терминация, требует Ingress Controller. Service для внутренней связности, Ingress для внешнего HTTP-входа.

На что копают на senior+#

  • Глубокое понимание cgroups v1/v2 и как Go runtime взаимодействует с CFS quota; что именно делает automaxprocs и почему дробный CPU limit (например 1.5) округляется вниз.
  • Endpoint propagation delay: как именно EndpointSlice-контроллер и kube-proxy обновляют iptables, почему preStop sleep — это про устранение гонки, а не “магическая задержка”.
  • GC tuning под контейнер: связка GOMEMLIMIT + GOGC, риск GC death spiral, soft vs hard memory limit, как читать runtime/metrics и профилировать в проде (pprof endpoint, continuous profiling).
  • PodDisruptionBudget для защиты от одновременной потери реплик при drain ноды/upgrade; как он взаимодействует с rolling update.
  • Topology spread / anti-affinity для распределения реплик по зонам и нодам.
  • StatefulSet vs Deployment: стабильные сетевые идентичности и ordered shutdown для stateful Go-сервисов (например с локальным кэшем/leader election).
  • Leader election (через Lease API / client-go leaderelection) для синглтон-воркеров в нескольких репликах.
  • Service mesh (Envoy/Istio sidecar) и нюанс порядка запуска/остановки sidecar относительно приложения при graceful shutdown.
  • Resource overcommit и noisy neighbors: как requests/limits и QoS влияют на стабильность p99 под нагрузкой соседей по ноде.