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. Шаги ниже посчитаны по этим формулам в
предположении, что новые поды становятся доступными одновременно.
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 не повторяет.
Такой бюджет запрещает любое выселение, и drain узла не закончится: документация это допускает. Для нескольких
реплик удобнее maxUnavailable: 1 и AlwaysAllow, чтобы узел не держали поды в CrashLoopBackOff.
HPA изнутри
Контроллер (pkg/controller/podautoscaler/horizontal.go) раз в 15 секунд берёт наибольшее из
предложений по метрикам. Для Resource (GetResourceReplicas):
- потребление подов с метриками делится на сумму их requests, отношение к цели даёт
ratio; - при
0,9 ≤ ratio ≤ 1,1число реплик не меняется; порог задаёт флаг--horizontal-pod-autoscaler-tolerance, а с 1.37 стабильно полеtoleranceвbehavior.scaleUpиscaleDown; - поды без метрик при росте считаются нулём, при уменьшении полным request (или целью, если она выше 100 %), неготовые поды для CPU при росте тоже нулём; если поправка меняет направление, масштаба нет;
- результат:
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.9 | metrics.k8s.io | Resource, ContainerResource | CPU и память от kubelet; память в виде working set: memory.current − inactive_file |
| prometheus-adapter | custom.metrics.k8s.io, external.metrics.k8s.io | Pods, Object, External | PromQL в метрики; последний релиз 0.12.0, май 2024 |
| KEDA 2.20 | external.metrics.k8s.io | HPA создаёт сам из ScaledObject | 0 ↔ 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".
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 после удаления остаются.
Если новая версия не становится 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.3 | Kustomize 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-ом |
| Где уместен | распространяемый чарт, сторонние системы | свои сервисы с небольшой разницей окружений |
Вопросы
4disruptionsAllowed равен нулю, а 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 продолжит сам |
Флаг удаляет поды без бюджета: сервис с одной репликой получит простой, сервис с неготовыми подами может
потерять последние живые. В скриптах обслуживания 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 признаёт, что активную Job контроллер может не увидеть. Под
внутри Job тоже иногда стартует дважды (глава 4.3 статьи «CI/CD»). Задачу делают идемпотентной: отчёт за
дату перезаписывается, рассылки защищены ключом или блокировкой в базе.
Арифметика контроллера
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.memory даёт ratio выше, и HPA растёт без нагрузки, как с CPU
в главе 3.1. Подбором requests и цели это не лечится: память не падает с числом реплик.
--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.yaml | 1 | 6 | api:1.4.2 |
upgrade --set image.tag=1.4.3 | 2 | 2 | api:1.4.3 |
upgrade без -f и --set | 3 | 2 | api:1.4.3 |
upgrade -f values-prod.yaml --set image.tag=1.4.3 | 4 | 6 | api: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.4 | 5 | 6 | api: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.
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 под согласованное окно или провести обслуживание с принятым риском простоя. Защиту нельзя обходить молча: она отражает требование доступности.