voidlearn
 

3.3Выкатка и упаковка

Разберёшь, как Deployment раскладывает обновление по ReplicaSet и как PDB, HPA, Job и CronJob меняют поведение кластера. Потренируешься считать шаги rollout и замечать сброс values при Helm upgrade.

Что сможешь объяснить после этой главы
  • Откуда в имени пода 559f6c778d и почему после rollout undo номер ревизии растёт.
  • Сколько подов контроллер поднимет и погасит на каждом шаге выкатки.
  • Почему kubectl drain может ждать бесконечно.
  • Почему память Go-сервиса плохой сигнал для HPA.
  • Что CronJob делает с пропущенным запуском при Forbid.
  • Почему helm upgrade --set без -f сбрасывает prod-values.

Прогоны: kind с Kubernetes 1.37.0 без записи в кластер, kubectl 1.37.0 с Kustomize 5.8.1, Helm 4.3.0, helm-diff 3.15.13, Go 1.27.1. Поведение контроллеров сверено с исходниками 1.37.

Ревизии Deployment живут в ReplicaSet-ах

Готовый пример в кластере есть: Deployment CoreDNS, его ставит kubeadm.

$ kubectl get rs -n kube-system -L pod-template-hash
NAME                 DESIRED   CURRENT   READY   AGE   POD-TEMPLATE-HASH
coredns-559f6c778d   2         2         2       64m   559f6c778d
$ kubectl get rs coredns-559f6c778d -n kube-system -o yaml | sed -n '/^  annotations:/,/^  creationTimestamp/p'
  annotations:
    deployment.kubernetes.io/desired-replicas: "2"
    deployment.kubernetes.io/max-replicas: "3"
    deployment.kubernetes.io/revision: "1"
  creationTimestamp: "2026-09-17T07:22:13Z"
$ kubectl rollout history deployment/coredns -n kube-system
deployment.apps/coredns
REVISION  CHANGE-CAUSE
1         <none>

Хеш 559f6c778d контроллер считает FNV-32a от spec.template (ComputeHash в pkg/controller/controller_utils.go) и пишет в имя ReplicaSet, метку pod-template-hash и селектор: метка k8s-app: kube-dns общая у всех ревизий, а по хешу ReplicaSet отличает свои поды. CHANGE-CAUSE берётся из аннотации kubernetes.io/change-cause, её ставит разве что пайплайн.

Новая ревизия появляется только при смене шаблона пода. replicas, strategy и minReadySeconds лежат вне его, что видно в kubectl diff без записи в кластер:

# coredns-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: coredns
  namespace: kube-system
spec:
  minReadySeconds: 10
  strategy:
    rollingUpdate:
      maxUnavailable: 0
$ kubectl diff -f coredns-patch.yaml
…
-  generation: 1
+  generation: 2
…
 spec:
+  minReadySeconds: 10
…
-      maxUnavailable: 1
+      maxUnavailable: 0
     type: RollingUpdate
   template:

С --server-side тот же diff падает с conflict with "kubeadm", успев напечатать «keeps changing»: конфликт владельца поля приходит с кодом 409, как конфликт версии, и kubectl делает четыре попытки.

kubectl rollout undo работает на стороне kubectl (polymorphichelpers/rollback.go): берёт шаблон ReplicaSet нужной ревизии, убирает метку pod-template-hash и патчем кладёт в Deployment. Дальше идёт обычный rolling update: контроллер находит этот ReplicaSet и поднимает его по тем же maxSurge и maxUnavailable. Номер ему достаётся максимальный плюс один, прежний уходит в аннотацию deployment.kubernetes.io/revision-history (SetNewReplicaSetAnnotations): после отката с 3 на 1 в истории останутся 2 и 3. У CoreDNS откатываться некуда:

$ kubectl rollout undo deployment/coredns -n kube-system --dry-run=server
error: no rollout history found for deployment "coredns"
$ kubectl rollout undo deployment/coredns -n kube-system --to-revision=1 --dry-run=server
deployment.apps/coredns skipped rollback (current template already matches revision 1) (server dry run)

Старые ReplicaSet с нулём реплик хранятся до revisionHistoryLimit (по умолчанию 10), лишние удаляет cleanupDeployment, когда выкатка закончена.

Шаг выкатки: maxSurge, maxUnavailable, minReadySeconds

Проценты округляет ResolveFenceposts: maxSurge вверх, maxUnavailable вниз. У CoreDNS 25 % от двух реплик дают 0,5, вверх это 1, отсюда max-replicas: "3". Если оба ушли в ноль, контроллер берёт maxUnavailable за 1, а явные нули в обоих полях не пропустит валидация. За проход rolloutRolling (pkg/controller/deployment/rolling.go) делает одно из двух:

  • добавляет поды новому ReplicaSet, если сумма spec.replicas всех ReplicaSet меньше replicas + maxSurge, но не больше недостающего до replicas;
  • убирает поды у старых, если доступных больше replicas − maxUnavailable; ещё не доступные новые поды вычитаются из запаса, неготовые старые гасятся первыми.

Доступным под становится, пробыв Ready minReadySeconds. Шаги ниже посчитаны по этим формулам в предположении, что новые поды становятся доступными одновременно.

replicas: 4 · maxSurge 25% → 1 (вверх) · maxUnavailable 25% → 1 (вниз) старая ревизия, Ready новая, ещё не Ready новая, доступна предел: 5 подов всего, не меньше 3 доступных 1 старт: старый ReplicaSet держит 4 пода v1 v1 v1 v1 всего 4 · доступно 4 2 новый ReplicaSet +1: всего 5 = 4 + maxSurge v1 v1 v1 v1 v2 всего 5 · доступно 4 3 старый −1: доступно 3 = 4 − maxUnavailable v1 v1 v1 v2 всего 4 · доступно 3 4 новый +1; гасить старые больше нельзя v1 v1 v1 v2 v2 всего 5 · доступно 3 ждёт Ready 5 оба новых доступны: старый −2 v1 v2 v2 всего 3 · доступно 3 6 новый +2, до четырёх подов v1 v2 v2 v2 v2 всего 5 · доступно 3 ждёт Ready 7 новые доступны: старый −1, выкатка закончена v2 v2 v2 v2 всего 4 · доступно 4 готово
Шаги выкатки при четырёх репликах. Контроллер чередует два действия: добавляет поды новому ReplicaSet, пока всего меньше пяти, и убирает старые, пока доступных больше трёх. Неготовые новые поды блокируют второе действие.

progressDeadlineSeconds отсчитывается от последнего прогресса, а не от начала выкатки: DeploymentTimedOut сравнивает время с lastUpdateTime условия Progressing, а syncRolloutStatus (progress.go) обновляет его при каждом продвижении. Выкатка, застрявшая в состоянии 4, через 600 секунд получит Progressing=False с причиной ProgressDeadlineExceeded, и на этом всё.

Поды в Terminating в расчёт не входят: старый под с долгим terminationGracePeriodSeconds живёт сверх replicas + maxSurge, в 1.37 их число видно в status.terminatingReplicas. Recreate поднимает новые поды, только когда старые завершились полностью.

PodDisruptionBudget и drain

PodDisruptionBudget ограничивает только eviction API, подресурс pods/eviction. Через него выселяет поды kubectl drain. Rolling update удаляет поды через ReplicaSet, а kubectl delete pod и упавший узел обходятся без eviction: бюджет их не останавливает, но потери учитывает.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
spec:
  maxUnavailable: 1                        # проценты округляются вверх
  unhealthyPodEvictionPolicy: AlwaysAllow
  selector:
    matchLabels: { app: api }

Ожидаемое число подов бюджет берёт из spec.replicas владельца, а при целом minAvailable считает сами поды. EvictionREST.Create (pkg/registry/core/pod/storage/eviction.go) выселяет без проверки поды в Pending, Succeeded, Failed и уже удаляемые. Неготовый под при AlwaysAllow удаляется сразу, при умолчании IfHealthyBudget только если currentHealthy ≥ desiredHealthy. При disruptionsAllowed == 0 ответ 429 «Cannot evict pod as it would violate the pod's disruption budget.», иначе счётчик уменьшается и под удаляется с обычным grace period.

kubectl drain ставит cordon и выселяет поды параллельно (staging/src/k8s.io/kubectl/pkg/drain/drain.go). На 429 он пишет (will retry after 5s) и повторяет до --timeout, по умолчанию бесконечного (0). Прочие ошибки drain не повторяет.

Одна реплика и minAvailable: 1

Такой бюджет запрещает любое выселение, и drain узла не закончится: документация это допускает. Для нескольких реплик удобнее maxUnavailable: 1 и AlwaysAllow, чтобы узел не держали поды в CrashLoopBackOff.

HPA изнутри

Контроллер (pkg/controller/podautoscaler/horizontal.go) раз в 15 секунд берёт наибольшее из предложений по метрикам. Для Resource (GetResourceReplicas):

  1. потребление подов с метриками делится на сумму их requests, отношение к цели даёт ratio;
  2. при 0,9 ≤ ratio ≤ 1,1 число реплик не меняется; порог задаёт флаг --horizontal-pod-autoscaler-tolerance, а с 1.37 стабильно поле tolerance в behavior.scaleUp и scaleDown;
  3. поды без метрик при росте считаются нулём, при уменьшении полным request (или целью, если она выше 100 %), неготовые поды для CPU при росте тоже нулём; если поправка меняет направление, масштаба нет;
  4. результат: ceil(ratio × число учтённых подов).

Если часть метрик недоступна, реплики HPA не убирает. Рекомендации контроллер хранит в памяти и сглаживает: при уменьшении берёт наибольшую за окно stabilizationWindowSeconds (300 секунд), при росте наименьшую (окно 0). Что подставит API, видно на серверном dry-run HPA с одной политикой уменьшения { type: Pods, value: 1, periodSeconds: 60 }:

$ kubectl apply --dry-run=server -f hpa.yaml -o jsonpath='{.spec.behavior.scaleUp}{"\n"}{.spec.behavior.scaleDown}{"\n"}'
{"policies":[{"periodSeconds":15,"type":"Pods","value":4},{"periodSeconds":15,"type":"Percent","value":100}],"selectPolicy":"Max","stabilizationWindowSeconds":0}
{"policies":[{"periodSeconds":60,"type":"Pods","value":1}],"selectPolicy":"Max"}

Окно уменьшения API оставил пустым, 300 секунд подставит контроллер. Без behavior API не заполняет ничего, и рост за проход ограничен max(2 × текущее, 4). Справка kubectl explain hpa.spec.behavior пишет «4 pods per 60 seconds», а в коде, в dry-run и на kubernetes.io период 15 секунд. explain hpa.spec.minReplicas называет нулевой минимум alpha, но кластер принял minReplicas: 0 с метрикой External: gate HPAScaleToZero в 1.37 в beta и включён.

ИсточникAPIТип в HPAЧто важно
metrics-server 0.9metrics.k8s.ioResource, ContainerResourceCPU и память от kubelet; память в виде working set: memory.current − inactive_file
prometheus-adaptercustom.metrics.k8s.io, external.metrics.k8s.ioPods, Object, ExternalPromQL в метрики; последний релиз 0.12.0, май 2024
KEDA 2.20external.metrics.k8s.ioHPA создаёт сам из ScaledObject0 ↔ 1 масштабирует оператор, 1 ↔ N обычный HPA; сервер внешних метрик в кластере может быть только один (FAQ KEDA)

Job: попытки, индексы, уборка

backoffLimit (по умолчанию 6) считает упавшие поды, а при restartPolicy: OnFailure и перезапуски контейнеров. Новый под после падения создаётся с задержкой от 10 секунд с удвоением. Документация пишет «capped at six minutes», в коде 1.37 потолок MaxJobPodFailureBackOff = 10 * time.Minute (pkg/controller/job/job_controller.go). activeDeadlineSeconds сильнее backoffLimit. Разные причины падений разводят политика падений и индексы:

apiVersion: batch/v1
kind: Job
metadata:
  name: reindex
spec:
  completions: 8
  parallelism: 4
  completionMode: Indexed          # номер части в JOB_COMPLETION_INDEX
  backoffLimitPerIndex: 2          # у каждого индекса свои попытки
  maxFailedIndexes: 3              # больше трёх упавших - Job провалена
  ttlSecondsAfterFinished: 3600
  podFailurePolicy:
    rules:
      - action: FailIndex          # код 42: повтор не поможет
        onExitCodes: { containerName: reindex, operator: In, values: [42] }
      - action: Ignore             # вытеснение с узла не считать
        onPodConditions: [{ type: DisruptionTarget }]
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: reindex
          image: registry.local/api:1.4.2
          args: ["reindex", "--shard=$(JOB_COMPLETION_INDEX)"]
$ kubectl apply --dry-run=server -f job.yaml -o jsonpath='{.spec.backoffLimit} {.spec.podReplacementPolicy} {.spec.completionMode}{"\n"}'
2147483647 Failed Indexed

С backoffLimitPerIndex общий лимит становится MaxInt32, с podFailurePolicy замена пода создаётся после его полного завершения. Справка для обеих требует restartPolicy: Never, но API 1.37 проверяет это только у podFailurePolicy. ttlSecondsAfterFinished удаляет законченную Job вместе с подами. Миграции отдельной Job разобраны в главе 4.3 статьи «CI/CD».

CronJob: расписание, пропуски, конкуренция

CronJob заменяет пару .service и .timer из главы 1.4 статьи «Linux и ОС». Задачи запускает контроллер в kube-controller-manager, а пропущенные слоты ищет от status.lastScheduleTime.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: report
spec:
  schedule: "0 3 * * *"
  timeZone: Europe/Moscow
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      backoffLimit: 2
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: report
              image: registry.local/api:1.4.2
              args: ["report", "daily"]
$ kubectl apply --dry-run=server -f cronjob.yaml -o jsonpath='{.spec.concurrencyPolicy} {.spec.successfulJobsHistoryLimit} {.spec.failedJobsHistoryLimit}{"\n"}'
Forbid 3 1
$ kubectl apply --dry-run=server -f cronjob-badtz.yaml
The CronJob "report" is invalid: spec.timeZone: Invalid value: "MSK": unknown time zone MSK

С TZ или CRON_TZ в schedule новый объект не пройдёт валидацию, а без timeZone расписание считается в часовом поясе процесса kube-controller-manager. Запускать ли Job, решает syncCronJob (pkg/controller/cronjob/cronjob_controllerv2.go):

  • Слот. Берётся последний наступивший слот после lastScheduleTime: сколько бы их ни пропало за простой или suspend, Job будет одна.
  • Дедлайн. Слот старше startingDeadlineSeconds пропускается, и пропуски ищутся только в этом окне.
  • Конкуренция. Allow запускает поверх активной Job, Forbid пропускает слот с событием JobAlreadyActive, Replace удаляет активную Job.
  • Имя. report-<время слота в минутах Unix>: второй Job на слот не будет и после перезапуска контроллера.

При сотне с лишним пропусков документация обещает не запускать Job. В 1.37 контроллер пишет событие TooManyMissedTimes и всё равно создаёт Job, так и в тесте "prev ran but done, long overdue, not past deadline, A".

schedule: "*/10 * * * *", каждая задача работает 14 минут 03:00 03:10 03:20 03:30 Allow по умолчанию задача 03:00 задача 03:10 задача 03:20 03:30 Forbid без дедлайна задача 03:00 пропуск слот 03:10, старт в 03:14 пропуск слот 03:20 пропуск Forbid startingDeadlineSeconds: 120 задача 03:00 пропуск, в 03:14 уже поздно задача 03:20 пропуск Replace старую удаляет 03:00, удалена 03:10, удалена 03:20, удалена 03:30
Один CronJob, три политики. Задача длиннее интервала. Allow запускает всё по расписанию, задачи идут параллельно. Forbid откладывает слот до конца предыдущей задачи, если к тому времени он не старше startingDeadlineSeconds. Replace удаляет незаконченную задачу, и ни одна не доходит до конца.

StatefulSet: когда он действительно нужен

StatefulSet нужен программам, которые сами хранят данные или адресуются по имени реплики (Postgres, брокер, NATS с JetStream), а не Go-сервису без состояния. Deployment с общим PVC тут не годится: диск ReadWriteOnce подключается к одному узлу, и новый под на другом узле ждёт с событием FailedAttachVolume «Volume is already used by pod(s) …» (reportMultiAttachError в attach/detach-контроллере).

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: nats
spec:
  serviceName: nats              # headless Service: nats-0.nats.<ns>.svc
  replicas: 3
  selector:
    matchLabels: { app: nats }
  template:
    metadata:
      labels: { app: nats }
    spec:
      containers:
        - name: nats
          image: nats:2.12
          args: ["--jetstream", "--store_dir=/data"]
          volumeMounts:
            - { name: data, mountPath: /data }
  volumeClaimTemplates:          # у каждого пода свой PVC: data-nats-0, data-nats-1…
    - metadata:
        name: data
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests: { storage: 10Gi }
$ kubectl apply --dry-run=server -f sts.yaml -o jsonpath='{.spec.podManagementPolicy}{"\n"}{.spec.updateStrategy}{"\n"}{.spec.persistentVolumeClaimRetentionPolicy}{"\n"}'
OrderedReady
{"rollingUpdate":{"maxUnavailable":1,"partition":0},"type":"RollingUpdate"}
{"whenDeleted":"Retain","whenScaled":"Retain"}

OrderedReady создаёт поды от 0 вверх, дожидаясь готовности предыдущего, обновление идёт с наибольшего номера, partition: 2 обновит только nats-2. maxUnavailable в 1.37 включён (beta), но при OrderedReady может не действовать. PVC после удаления остаются.

Откат шаблона не лечит застрявший StatefulSet

Если новая версия не становится Ready, выкатка при OrderedReady стоит, и возврат старого шаблона её не сдвинет: контроллер ждёт готовности сломанного пода. Документация (раздел «Forced rollback») требует после отката удалить руками поды, запущенные с плохой конфигурацией.

Helm: чарт, values, шаблоны

helm create api создаёт Chart.yaml (version чарта и appVersion приложения), values.yaml и templates/ с типовыми объектами. Шаблоны пишутся на text/template с функциями Sprig. Файлы с подчёркиванием в начале имени движок в манифесты не рендерит (pkg/engine/engine.go), там определяют шаблоны для include: api.image собирает репозиторий и тег, по умолчанию appVersion. Чарт урезан до Deployment и хука:

# values.yaml
replicaCount: 2
image:
  repository: registry.local/api
  tag: ""              # пусто: берётся appVersion
resources:
  requests: { cpu: 100m, memory: 128Mi }
  limits: { memory: 256Mi }
tolerations:
  - { key: pool, value: general, effect: NoSchedule }

# values-prod.yaml
replicaCount: 6
resources:
  limits: { memory: 512Mi }
tolerations:
  - { key: pool, value: prod, effect: NoSchedule }
# templates/deployment.yaml, фрагмент
  replicas: {{ .Values.replicaCount }}
      containers:
        - name: api
          image: {{ include "api.image" . }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
$ helm template api ./api -f api/values-prod.yaml --set image.tag=1.4.3 --show-only templates/deployment.yaml
…
  replicas: 6
…
          image: registry.local/api:1.4.3
          resources:
            limits:
              memory: 512Mi
            requests:
              cpu: 100m
              memory: 128Mi
      tolerations:
        - effect: NoSchedule
          key: pool
          value: prod
$ helm template api ./api --set replicaCount=8 -f api/values-prod.yaml --show-only templates/deployment.yaml | grep replicas
  replicas: 8

Словари сливаются вглубь: prod заменил limits.memory, requests остались. Списки заменяются целиком: от общего tolerations ничего не осталось. --set resources.limits=null убирает ключ из вывода. Порядок жёсткий, позиция флага не важна (MergeValues в pkg/cli/values/options.go): файлы -f по очереди, затем --set-json, --set, --set-string, --set-file, --set-literal, и всё это поверх values.yaml чарта. Поэтому --set перед -f победил. Что ловят helm lint, helm template и серверный dry-run, разобрано в главе 4.3 статьи «CI/CD».

Релиз, история и хуки

Каждая ревизия лежит в Secret пространства имён релиза (pkg/storage/driver/secrets.go): sh.helm.release.v1.<релиз>.v<ревизия>, тип helm.sh/release.v1, метки name, owner=helm, status, version. В data.release лежит JSON релиза в gzip и base64, а API добавляет ещё один base64. Ниже Secret, который драйвер Helm 4.3.0 записал в поддельный клиент (secret-last.json, как его отдал бы API):

$ jq -r '.data.release' secret-last.json | base64 -d | base64 -d | gunzip \
    | jq -c '{name, version, status: .info.status, chart: .chart.metadata.version, apply_method}'
{"name":"api","version":5,"status":"deployed","chart":"0.4.0","apply_method":"ssa"}

На кластере вместо файла будет kubectl get secret sh.helm.release.v1.api.v5 -o jsonpath='{.data.release}'. Внутри чарт целиком, values пользователя (config), манифест, хуки и статус, а Secret ограничен 1 МиБ (MaxSecretSize). Ревизий хранится 10 (--history-max), последняя успешная не удаляется, helm rollback создаёт следующую ревизию «Rollback to N».

--wait в Helm 4 принимает стратегию: без флага hookOnly (ждать только хуки), голый --wait означает watcher (готовность объектов через kstatus), есть legacy в стиле Helm 3. --atomic на helm upgrade печатает «Flag --atomic has been deprecated, use --rollback-on-failure instead».

Job миграции в чарте помечена helm.sh/hook: pre-install,pre-upgrade, hook-weight: "-5" и hook-delete-policy: before-hook-creation,hook-succeeded. Серверный dry-run печатает её отдельно:

$ helm upgrade --install api ./api -n default -f api/values-prod.yaml --dry-run=server
Release "api" does not exist. Installing it now.
…
STATUS: pending-install
REVISION: 1
DESCRIPTION: Dry run complete
TEST SUITE: None
HOOKS:
---
# Source: api/templates/job-migrate.yaml
…
MANIFEST:
---
# Source: api/templates/deployment.yaml
…

pkg/action/hooks.go сортирует хуки по весу, при равенстве по имени, удаляет прежний объект по before-hook-creation (это умолчание), создаёт новый и ждёт завершения Job в пределах --timeout, 5 минут. Упавший хук проваливает релиз. Хуки не входят в манифест релиза: helm uninstall их не удалит, откат не вернёт (документация Helm). Почему миграция в хуке компромисс, разобрано в главе 4.3 статьи «CI/CD». Объекты, пропавшие из чарта, helm upgrade удаляет, кроме помеченных helm.sh/resource-policy: keep.

helm diff остаётся плагином. Из git-репозитория Helm 4 ставит его только с --verify=false («plugin source does not support verification»), подписанный архив релиза проходит проверку. Плагин сравнивает рендер с манифестом последней ревизии из Secret и ручную правку в кластере не увидит, живые объекты сравнивает --three-way-merge. --detailed-exitcode даёт ненулевой код при разнице, для CI.

Kustomize: base, overlays, патчи

Kustomize не шаблонизирует: он берёт валидный YAML из base и накладывает преобразования overlay. kubectl kustomize печатает результат, kubectl apply -k применяет.

# base/kustomization.yaml
resources:
  - deployment.yaml
configMapGenerator:
  - name: api-config
    literals:
      - LOG_LEVEL=info

# overlays/prod/resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      containers:
        - name: api
          resources:
            limits: { memory: 512Mi }
# overlays/prod/kustomization.yaml
namespace: shop-prod
resources:
  - ../../base
images:
  - name: registry.local/api
    digest: sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4
replicas:
  - name: api
    count: 6
configMapGenerator:
  - name: api-config
    behavior: merge
    literals:
      - LOG_LEVEL=warn
patches:
  - path: resources.yaml          # strategic merge
  - target: { kind: Deployment, name: api }
    patch: |-                     # JSON 6902
      - op: add
        path: /spec/template/spec/priorityClassName
        value: business-critical
$ kubectl kustomize base | grep -E 'name: api-config|LOG_LEVEL'
  LOG_LEVEL: info
  name: api-config-hf678c7m2b
            name: api-config-hf678c7m2b
$ kubectl kustomize overlays/prod
…
  name: api-config-dfk4bdbtkk
  namespace: shop-prod
…
  replicas: 6
…
            name: api-config-dfk4bdbtkk
        image: registry.local/api@sha256:e9646e3580e8557fc7a6609baf5b8585a9bfcd652268c0cb5fff1ded9d7e77f4
…
      priorityClassName: business-critical

Хеш содержимого в имени ConfigMap меняет шаблон пода при правке конфига, и начинается выкатка, как с checksum/config из главы 3.1. Старые ConfigMap остаются: kubectl apply без --prune лишнее не удаляет.

Helm или Kustomize

Helm 4.3Kustomize 5.8 в kubectl
Модельшаблоны, из которых получается YAMLвалидный YAML плюс патчи и преобразования
Параметрыvalues любой формы, условия и циклытолько то, что умеют трансформеры и патчи
Состояниерелиз с историей в Secret, helm rollbackнет, история в git, откат коммитом
Удаление лишнегопри upgrade, кроме resource-policy: keep--prune или prune в Argo CD и Flux
Ожидание и хуки--wait, --rollback-on-failure, хукинет, ждут kubectl rollout status
Чужие компонентычарты из репозиториев и OCIупаковки нет; --enable-helm рендерит чарт Helm-ом
Где уместенраспространяемый чарт, сторонние системысвои сервисы с небольшой разницей окружений

Вопросы

4
Суть: у бюджета этого пода disruptionsAllowed равен нулю, а drain повторяет выселение раз в 5 секунд без ограничения по времени. Ищут бюджет и причину нуля; удалять поды мимо бюджета стоит в последнюю очередь.

Где смотреть

kubectl get pdb -n <ns> покажет ноль в столбце ALLOWED DISRUPTIONS, -o yaml даст currentHealthy, desiredHealthy, expectedPods и условие DisruptionAllowed. Два бюджета на один под дают не 429, а 500, и на таком поде drain завершится с ошибкой.

Частые причины

Что видноПочему нольЧто сделать
expectedPods: 1, minAvailable: 1одна реплика, любое выселение нарушает бюджетдве реплики и maxUnavailable: 1
currentHealthy < desiredHealthy, поды в CrashLoopBackOffпри IfHealthyBudget неготовый под выселяется, только если приложение целоunhealthyPodEvictionPolicy: AlwaysAllow
после первого выселения замена в Pendingместа нет, здоровых стало меньшеосвободить или добавить узел, drain продолжит сам
--disable-eviction отключает защиту

Флаг удаляет поды без бюджета: сервис с одной репликой получит простой, сервис с неготовыми подами может потерять последние живые. В скриптах обслуживания drain запускают с явным --timeout, иначе зависший drain никто не остановит.

Суть: при Forbid слот, пропущенный из-за активной задачи, запускается сразу после её окончания, если он не старше startingDeadlineSeconds. После простоя контроллер запускает одну Job за самый поздний слот, тоже только внутри дедлайна.

Поздний старт

В 03:10 предыдущая Job работает, контроллер пишет JobAlreadyActive и не трогает lastScheduleTime. В 03:14 Job завершается, её изменение ставит CronJob в очередь (updateJob), поиск от 03:00 находит неисполненный слот 03:10, активных Job нет, и Job за 03:10 создаётся в 03:14. С дедлайном 120 секунд этот слот уже вне окна, следующей будет Job за 03:20.

Пропуск после простоя

Очереди слотов нет. Отчёт на 03:00 с дедлайном 600 секунд запустится, если контроллер ожил до 03:10, и не запустится после. Без дедлайна он запустится при любом опоздании, но один раз, даже после трёх суток простоя.

$ kubectl get cronjob report -o jsonpath='{.status.lastScheduleTime} {.status.lastSuccessfulTime}{"\n"}'
$ kubectl events --for cronjob/report
$ kubectl get jobs -o custom-columns='NAME:.metadata.name,SLOT:.metadata.annotations.batch\.kubernetes\.io/cronjob-scheduled-timestamp'

Алерт строят на времени последнего успеха (kube_cronjob_status_last_successful_time в kube-state-metrics): несостоявшийся запуск ошибок не даёт.

Forbid не гарантирует одну копию

Комментарий в исходнике у проверки Forbid признаёт, что активную Job контроллер может не увидеть. Под внутри Job тоже иногда стартует дважды (глава 4.3 статьи «CI/CD»). Задачу делают идемпотентной: отчёт за дату перезаписывается, рассылки защищены ключом или блокировкой в базе.

Суть: HPA рассчитывает, что метрика на под падает, когда подов больше. Память Go-сервиса от числа реплик почти не зависит: пулы, кэши и цель кучи есть на каждом поде. Если потребление выше цели больше чем на 10 %, реплики растут до максимума, а повода их убрать формула не находит.

Арифметика контроллера

requests.memory: 256Mi, цель 70 %, то есть 179,2 МиБ. Прогретый под держит 200 МиБ при любой нагрузке, ratio = 1,116. Контроллер хочет ceil(4 × 1,116) = 5, у пяти подов память та же, и ряд идёт 4, 5, 6, 7, 8, 9, 11, 13, 15, 17, 19, 20. Уменьшение наступит, когда ratio опустится ниже 0,9 на все 300 секунд окна. При 190 МиБ (ratio 1,06) не будет и роста.

Что показал прогон

Go 1.27.1, контейнер с --memory=512m. Программа держит 300 МиБ, отпускает их, вызывает runtime.GC() и раз в 15 секунд печатает memory.current, working set (минус inactive_file) и метрики рантайма:

    0s after GC       memory.current=306Mi  working_set=306Mi  heap_objects=0Mi    heap_free=300Mi  heap_released=2Mi
  120s idle           memory.current=306Mi  working_set=306Mi  heap_objects=0Mi    heap_free=300Mi  heap_released=2Mi
  135s idle           memory.current=208Mi  working_set=208Mi  heap_objects=0Mi    heap_free=202Mi  heap_released=100Mi
  150s idle           memory.current=7Mi    working_set=7Mi    heap_objects=0Mi    heap_free=0Mi    heap_released=302Mi

Объекты собраны сразу, а для cgroup и metrics-server память занята ещё две минуты. Цель возврата памяти (gcPaceScavenger) пересчитывается в конце цикла GC по куче на момент разметки, когда мёртвые 300 МиБ ещё не выметены. Без аллокаций следующий цикл запускается через forcegcperiod, 2 минуты. Под нагрузкой GC чаще, но куча между циклами растёт до цели, при GOGC=100 примерно вдвое больше живой.

Как настроить

Масштабируют по тому, что делится между репликами (CPU, RPS на под, лаг консьюмера), а память задают размером пода: requests, лимит и GOMEMLIMIT (глава 3.1).

Утилизация считается от requests

Та же память при меньших requests.memory даёт ratio выше, и HPA растёт без нагрузки, как с CPU в главе 3.1. Подбором requests и цели это не лечится: память не падает с числом реплик.

Суть: values прошлой ревизии Helm подставляет, только если новых values нет совсем. Один --set, и values собираются заново из флагов, prod-файл забыт. Надёжно передавать полный набор -f каждый раз; --reuse-values опасен при смене версии чарта.

Что показал прогон

Helm 4.3.0 запущен как библиотека: action.Install и action.Upgrade, релизы драйвер Secret пишет в поддельный клиент, объекты никуда не применяются. Чарт 0.4.0 добавляет ключ probe.path, который шаблон читает как .Values.probe.path.

КомандаРевизияreplicasОбраз
install -f values-prod.yaml16api:1.4.2
upgrade --set image.tag=1.4.322api:1.4.3
upgrade без -f и --set32api:1.4.3
upgrade -f values-prod.yaml --set image.tag=1.4.346api:1.4.3
чарт 0.4.0, --reuse-values --set image.tag=1.4.4ошибка nil pointer evaluating interface {}.path
чарт 0.4.0, --reset-then-reuse-values --set image.tag=1.4.456api:1.4.4

Почему так

В reuseValues (pkg/action/upgrade.go) --reset-values берёт только новые флаги. --reuse-values сливает их с config прошлой ревизии и подменяет values.yaml нового чарта значениями старого, поэтому ключ probe пропал. --reset-then-reuse-values оставляет values.yaml нового чарта и кладёт сверху старый config и новые флаги. Без флагов старый config копируется, только когда новых values нет: так ревизия 3 унаследовала тег от ревизии 2. Что лежит в config, покажет helm get values api, полный итог --all.

Ревизия 3 выглядит безопасной

helm upgrade без флагов сохранил values, и легко решить, что Helm их всегда помнит. Следующий --set опровергнет это в prod. Перед выкаткой запускают helm diff upgrade: он показал бы replicas: 6 → 2 до записи в кластер.

Что стоит запомнить

  • RollingUpdate ограничивается maxSurge и maxUnavailable.
  • PDB ограничивает добровольные disruptions, но не защищает от любого сбоя узла.
  • CronJob с concurrencyPolicy Forbid пропускает запуск, пока прошлый Job ещё активен.

Частые ошибки

  • Рост памяти сам по себе не означает, что HPA должен масштабировать Pod.
  • Перед upgrade сверь итоговые values: --set не подключает файл конфигурации автоматически.

Самопроверка

Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.

Чтение кода

1. Посчитай предел rollout

Deployment имеет 4 реплики, maxSurge: 1 и maxUnavailable: 1. Сколько Pod контроллер может держать во время обновления и сколько готовых допускается временно потерять?

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 1
Ответ и разбор

Контроллер может держать до 5 не завершающихся Pod: четыре желаемых плюс surge 1; доступных должно оставаться не меньше трёх. Фактическое число Pod и расход ресурсов могут временно превысить пять, если старые Pod ещё завершаются дольше grace period. Здесь параметры заданы целыми числами, поэтому округления процентов нет.

Код-ревью

2. Сохрани values релиза

CI обновляет Helm release командой. Настройки кластера лежат в deploy/release.yaml. Что упущено?

helm upgrade api ./chart --set image.tag=42
Ответ и разбор

Команда не передаёт values-файл, поэтому Helm использует defaults chart и указанный image tag. Добавь -f deploy/release.yaml, затем сравни отрендеренный манифест с текущим релизом до применения.

Применение

3. Разбери CronJob с Forbid

CronJob запускается каждые 5 минут. Предыдущий Job ещё работает на следующем слоте, а concurrencyPolicy равен Forbid. Что сделает контроллер и что проверить, если отчёт пропущен?

Ответ и разбор

Новый Job не запускается, пока предыдущий активен; слот пропускается, а не ставится в очередь. Проверь длительность Job, расписание, startingDeadlineSeconds и статус предыдущего запуска. Если работу нужно наверстывать, выбери другую политику и обеспечь идемпотентность.

Применение

4. Пойми, почему drain ждёт

Обслуживание узла остановилось: у Deployment один Pod, а PDB задан как minAvailable: 1. Почему Pod нельзя добровольно выселить и какие варианты обсудить?

Ответ и разбор

Удаление единственного Pod нарушит PDB, поэтому добровольное выселение блокируется. Можно заранее поднять число реплик и дождаться готовности второй, изменить PDB под согласованное окно или провести обслуживание с принятым риском простоя. Защиту нельзя обходить молча: она отражает требование доступности.