Как мы поймали drift в Kubernetes и зачем после этого перешли на GitOps

Страницы:  1

Ответить
 

Professor Seleznov


Это был не ночной инцидент и не релиз под давлением. Обычный рабочий день, плановая выкладка во второй половине дня, когда нагрузка уже ниже пика. Сервис не самый маленький: несколько Deployment’ов, PostgreSQL, PgBouncer, Redis, фоновые workers, отдельные CronJob’ы, Helm chart с values под окружения. Деплой тогда был устроен просто: GitLab CI прогонял тесты, собирал образ, пушил его в registry и последним шагом выполнял helm upgrade.
Схема была примерно такой:
deploy:prod:
stage: deploy
image: alpine/helm:3.14.0
script:
- helm upgrade --install users-api ./helm/users-api
--namespace users
--values ./helm/users-api/values-prod.yaml
--set image.tag=${CI_COMMIT_SHA}
--atomic
--timeout 5m
only:
- main
На бумаге всё выглядело нормально. --atomic должен был откатить релиз, если Helm не дождётся успешного состояния. У CI был kubeconfig в защищённых переменных. До Kubernetes API могли достучаться только приватные раннеры. Конфигурация приложения лежала в values. Не идеально, но вполне типовая схема, на которой живёт много команд.
Важный нюанс: --atomic не является полноценным production rollback-механизмом. Он работает в рамках того, что Helm считает неуспешным upgrade. Если Kubernetes успел посчитать rollout успешным, а деградация проявилась позже на уровне бизнес-метрик, пайплайн уже будет зелёным.
Так и получилось, пайплайн прошёл зелёным. Образ собрался, чарт применился, появился новый ReplicaSet. Через пару минут загорелись алерты: выросла доля 5xx, ускорилось выгорание SLO-бюджета, readiness у части подов не проходил. Снаружи это выглядело как регресс в новой версии. Мы быстро решили откатиться на предыдущий тег образа, который до этого работал несколько месяцев.
И тут всё стало неприятным: старая версия тоже не поднялась. Точнее, она даже не смогла нормально стартовать. Pod уходил в CrashLoopBackOff, в логах была ошибка подключения к базе. Приложение падало на этапе запуска, когда собирало строку подключения к базе данных.
Логи были примерно такими:
failed to initialize storage:
pq: password authentication failed for user "users_app"
or
dial tcp: lookup pg-users.prod.svc.cluster.local: no such host
На первой итерации мы проверили всё, что обычно проверяешь в такой ситуации: жив ли endpoint базы, есть ли за ним реальные адреса подов, не менялся ли Secret, совпадает ли набор переменных окружения в pod’е с тем, что описано в Helm values, не появился ли новый отказ от admission-контроллера, не сломался ли pull образа, не упал ли sidecar. Постепенно версия с «битым релизом» начала рассыпаться. Образы были нормальными. Secret не ротировался. Проблема сходилась к конфигурации подключения.
В Git в values-prod.yaml лежало одно значение, а в живом кластере до релиза было другое значение DB_HOST. На первый взгляд отличие не выглядело критичным, но по факту сервис ходил не в тот endpoint, который был описан в репозитории.
DB_HOST у нас попадал в pod через ConfigMap users-api-env: Helm рендерил этот ConfigMap из values-prod.yaml, а Deployment подключал его через envFrom. Когда-то давно, во время отдельной проблемы с PgBouncer, этот ConfigMap изменили руками прямо в production namespace через kubectl edit. В Git изменение не попало и частью Helm release не стало. В итоге live object в кластере и desired state из репозитория разошлись.
Получается, что наш production несколько месяцев работал на состоянии, которого не существовало в Git.
А новый релиз пересоздал pod’ы. Они взяли конфигурацию из chart’а, то есть из Git, и получили «правильный» с точки зрения репозитория DB_HOST. Только этот DB_HOST уже давно не был рабочим для реального production, и поэтому новая версия упала. А rollback image tag не помог, потому что проблема была не в образе. Старая версия приложения точно так же стартовала с конфигурацией из Git и точно так же падала.
В этот момент у нас наконец появился нормальный корень проблемы, а не просто формулировка «релиз сломал сервис». Сам релиз ничего не сломал. Он просто проявил рассинхрон между ожидаемым состоянием из Git и реальным состоянием в кластере.
Что мы сделали во время инцидента
Первой задачей было не «внедрить GitOps», а вернуть сервис. Мы зафиксировали текущее состояние кластера, сравнили его с Git и приняли неприятное, но правильное решение: не править ConfigMap руками второй раз, а внести фактическую продовую конфигурацию в репозиторий и прогнать выкладку уже через существующий пайплайн.
Да, это заняло на несколько минут больше, чем ручное редактирование объекта в кластере. Но мы уже видели цену такой правки. Повторить тот же паттерн во время разбора было бы странно.
Мы сделали маленький MR в infra repo:
env:
- DB_HOST: "pgbouncer.users.svc.cluster.local"
+ DB_HOST: "pgbouncer-primary.users.svc.cluster.local"
Перед merge мы отдельно убедились, что этот service name существует в production namespace, что у него есть живые endpoints и что он действительно ведёт к нужному PgBouncer-пулу. После merge pipeline применил chart, pod’ы пересоздались и readiness начал проходить, а ошибки подключения к базе исчезли.
После этого мы не ограничились статусом Deployment’а. Мы проверили health endpoint, несколько пользовательских сценариев, ошибки на уровне работы с базой, пул соединений, задержки запросов к PostgreSQL и фоновые workers. Это важный момент: Kubernetes readiness говорит только о том, что pod готов принимать трафик по тем критериям, которые вы сами описали. Он не доказывает, что бизнес-сценарии живы. У нас readiness был завязан на HTTP endpoint, который проверял базовый запуск и подключение к базе, но он не проверял несколько важных внешних зависимостей. После инцидента мы это тоже поправили, но не стали превращать readiness в полноценный synthetic test. Слишком тяжёлая readiness-проверка сама становится причиной нестабильности.
Когда сервис вернулся, мы сделали postmortem. И там стало видно, что проблема не сводится к одному ConfigMap.
У нас было сразу несколько слабых мест.
CI имел прямой доступ к production-кластеру. Kubeconfig лежал в GitLab CI variables. Да, переменные были защищёнными и скрытыми. Да, доступ был только у production job’ы. Но по сути это всё равно был ключ от кластера, который позволял внешней системе менять состояние production.
Production API server был доступен для runner’ов. Мы прикрывали это сетевыми ограничениями, но сама модель требовала входящего доступа к Kubernetes API из CI-среды.
Git не был источником правды. Он был источником того, что мы думали о production. Реальность могла отличаться, и мы узнавали об этом только при перезапуске pod’ов или во время аварии.
У нас не было постоянной проверки drift. Никто автоматически не сравнивал live-состояние с тем, что лежит в репозитории. То есть расхождение могло жить сколько угодно долго.
И главное: rollback был неполным. Мы откатывали image tag, но не откатывали состояние окружения. В этом инциденте состояние окружения и было проблемой.
Почему мы выбрали GitOps, а не просто «запретили ручные правки»
После разбора инцидента можно было пойти простым путём: написать правило в духе «не править production руками», забрать доступы у части людей, добавить пункт в runbook. Это полезно, но не решает системную проблему. Люди всё равно будут делать ручные правки, если это самый быстрый способ восстановить сервис. Особенно ночью, когда горит бизнес-метрика.
Нам нужен был механизм, который меняет не только поведение инженеров, но и свойства системы.
Мы сформулировали цель так: production должен сходиться к состоянию из Git автоматически и постоянно. Это как раз ядро GitOps: декларативное описание состояния, версионирование, автоматическое получение желаемого состояния агентом и continuous reconciliation.
Мы выбрали Argo CD. Но не потому, что он единственный правильный вариант. Flux тоже мог бы закрыть эту задачу. Но для нашей команды Argo CD был проще организационно: UI, понятные статусы Synced / OutOfSync, видимый diff, нормальная интеграция с Helm и Kustomize. И нам было важно, чтобы не только platform-инженеры, но и backend-разработчики могли открыть приложение и увидеть: вот что лежит в Git, вот что реально находится в кластере, вот где оно разъехалось.
Формально доступ был ограничен: переменные protected и masked, job запускалась только для production. Но суть от этого не менялась: во внешней CI-системе лежал credential, с помощью которого можно было менять production-кластер.
Новая модель стала такой: GitLab CI больше не деплоит в Kubernetes. Он собирает образ, прогоняет проверки, пушит образ в registry и обновляет infra repo. Например, меняет tag в values:
image:
repository: registry.example.com/users-api
tag: "9f4c2a1"
Дальше Argo CD, работающий внутри кластера, сам забирает изменения из Git и применяет их. Важный практический плюс: пайплайн больше не обязан иметь прямой доступ ни к Kubernetes API, ни к Argo CD API. CI/CD в такой модели просто фиксирует новое желаемое состояние в Git, а deployment выполняет кластерный контроллер.
Минимально приложение выглядело примерно так:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: users-api
namespace: argocd
spec:
project: production
source:
repoURL: https://git.example.com/platform/prod-manifests.git
targetRevision: main
path: services/users-api
helm:
valueFiles:
- values-prod.yaml
destination:
server: https://kubernetes.default.svc
namespace: users
syncPolicy:
automated:
prune: false
selfHeal: false
Обратите внимание: сначала мы не включили prune и selfHeal.
Это важная деталь. Очень хочется сразу поставить красивый финальный конфиг:
syncPolicy:
automated:
prune: true
selfHeal: true
Но если включить это в грязном кластере, можно быстро удалить то, что внезапно оказалось «нужным», хотя в Git его нет. Не потому что Argo CD опасный, а потому что кластер уже накопил устную историю.
Мы сначала запустили Argo CD в режиме наблюдения. Он показывал OutOfSync, но не пытался всё исправить автоматически. Это дало нам список расхождений. Часть была ожидаемой: runtime annotations, поля status, вещи, которыми управляли операторы. Часть была настоящим мусором: старые ConfigMap, забытые Service, RoleBinding’и после экспериментов, временные Ingress annotations, которые давно надо было удалить или перенести в Git.
Самым полезным оказался не сам список, а разговор вокруг него. Каждое расхождение пришлось классифицировать: это легитимное состояние, которое надо описать в Git; это мусор, который надо удалить; это поле, которым должен управлять не GitOps, а другой контроллер; это ручная правка, которую нужно превратить в нормальную заявку на изменение.
Только после этого мы включили auto-sync для части сервисов, потом self-heal, и уже позже — prune. Не на всём продакшене сразу, а по группам приложений.
Финальный вариант для сервиса стал ближе к такому:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: users-api
namespace: argocd
spec:
project: production
source:
repoURL: https://git.example.com/platform/prod-manifests.git
targetRevision: main
path: services/users-api
helm:
valueFiles:
- values-prod.yaml
destination:
server: https://kubernetes.default.svc
namespace: users
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
- ApplyOutOfSyncOnly=true
selfHeal для нас был принципиален. Если кто-то меняет ConfigMap руками, это изменение не должно жить полгода. Оно должно либо стать нормальным коммитом, либо исчезнуть. Когда Argo CD видит расхождение между желаемыми манифестами в Git и реальным состоянием кластера, он может снова синхронизировать приложение. А selfHeal как раз отвечает за то, чтобы вернуть кластер к состоянию, описанному в репозитории.
prune мы включали осторожнее. Он нужен, чтобы удалённые из Git ресурсы удалялись из кластера, иначе Git не описывает полное состояние: в проде могут оставаться старые ConfigMap, Service, RoleBinding или другие объекты, о которых репозиторий уже ничего не знает. Но prune может больно ударить, если у вас в Git не попало что-то реально используемое. Поэтому перед включением prune мы отдельно прошлись по ресурсам в namespace и проверили кто за что отвечает: что создаёт Helm, что создаёт оператор, что создаётся вручную, что вообще больше не используется.
Что пришлось поменять вокруг GitOps
Сам по себе Argo CD не делает систему зрелой. Он просто начинает честно показывать, где у вас беспорядок.
Первое, что мы убрали, — kubeconfig из GitLab CI variables. CI больше не должен был иметь права применять манифесты в production. Для сборки образов ему хватало доступа к registry. Для обновления версии приложения — права на коммит в инфраструктурный репозиторий через отдельного бота с ограниченными правами. Это важное разделение: CI производит артефакт, Git фиксирует желаемое состояние, Argo CD применяет его внутри кластера.
pic
Схема перехода от push-деплоя к GitOps pull-модели
Второе изменение — RBAC для Argo CD. Было бы глупо забрать широкие права у CI и выдать такие же широкие права GitOps-контроллеру без ограничений. Мы разделили приложения по Argo CD Projects: production-сервисы отдельно, platform-компоненты отдельно, системные namespaces отдельно. Для обычного application project запретили cluster-scoped ресурсы, которые ему не нужны. Приложению не надо создавать ClusterRole, MutatingWebhookConfiguration или CRD. Если сервису действительно требуется что-то на весь кластер, это должен быть отдельный разговор и отдельный репозиторий/platform-процесс.
Примерно так выглядела наша идея project-level ограничений:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: production-apps
namespace: argocd
spec:
sourceRepos:
- https://git.example.com/platform/prod-manifests.git
destinations:
- namespace: users
server: https://kubernetes.default.svc
- namespace: billing
server: https://kubernetes.default.svc
clusterResourceWhitelist: []
namespaceResourceWhitelist:
- group: ""
kind: ConfigMap
- group: ""
kind: Secret
- group: ""
kind: Service
- group: apps
kind: Deployment
- group: networking.k8s.io
kind: Ingress
Это не универсальный шаблон. В реальности список ресурсов зависит от того, что именно вы деплоите. Но принцип важен: GitOps-контроллер не должен автоматически становиться cluster-admin «потому что так проще».
Третье изменение — секреты. До инцидента мы и так не складывали реальные секреты в Git, но в Helm values всё равно попадались скользкие места: строки подключения без паролей, имена секретов, иногда слишком подробные параметры внешних систем. Мы отдельно проговорили границу: Git может хранить декларацию зависимости от секрета, но не сам секрет в открытом виде.
Kubernetes Secret — это объект для хранения чувствительных данных вроде паролей и токенов, но это не означает, что его YAML безопасно коммитить как есть. В Kubernetes часто приходится работать с base64-представлением данных, и base64 — это кодирование, как вам известно, а не криптографическая защита.
Мы выбрали External Secrets Operator. В Git остался ExternalSecret, который описывает, какой секрет нужен приложению и из какого внешнего хранилища его взять. Само значение лежит во внешнем хранилище секретов. External Secrets Operator синхронизирует секреты из внешних API в Kubernetes Secrets.
Примерно так:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: users-api-db
namespace: users
spec:
refreshInterval: 1h
secretStoreRef:
name: prod-secrets
kind: ClusterSecretStore
target:
name: users-api-db
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: prod/users-api/db
property: password
Смысл не в том, что External Secrets «безопаснее» сам по себе. Смысл в разделении ответственности. Git описывает, что сервису нужен секрет. Хранилище секрета отвечает за значение, ротацию, аудит доступа. Kubernetes получает только итоговый Secret, нужный приложению во время работы.
Четвёртое изменение — аварийная процедура. Нельзя просто сказать людям: «Теперь любые ручные правки запрещены». В production бывают ситуации, когда надо действовать быстро. Но мы изменили форму таких действий.
Теперь аварийное изменение должно либо сразу идти через Git, либо, если это совсем пожар, ручная правка фиксируется как временное исключение с обязательным последующим коммитом. При включённом self-heal ручная правка всё равно будет перетёрта, поэтому для редких случаев у нас есть понятная процедура: временно остановить auto-sync для конкретного приложения, сделать изменение, восстановить сервис, тут же оформить изменение в Git и вернуть auto-sync. Это нисколько не best practices и не паттерн, но это честный аварийный путь, который не оставляет кластер в неизвестном состоянии.
Пятое изменение — гигиена расхождений. После включения GitOps у нас сначала появилось много шума. Некоторые ресурсы были OutOfSync, хотя фактически проблем не было. Например, часть annotations добавлялась admission controller’ом, status обновлялся Kubernetes’ом, HPA управлял количеством реплик. Если на это смотреть без фильтра, команда быстро перестаёт доверять статусам.
Мы не стали просто игнорировать всё подряд. Это опасно: слишком широкий ignore превращает GitOps в декоративный dashboard. Вместо этого мы разобрали ownership по полям. Если replicas управляет HPA, значит не надо делать вид, что Git владеет этим значением. Если аннотацию добавляет конкретный контроллер, это можно описать через diff customization. Если поле меняется руками — это не ignore, это проблема.
Что изменилось после перехода
Самое заметное изменение было не в UI Argo CD и не в пайплайн. Изменился способ разговора о production.
До этого во время инцидента часто звучали вопросы вроде: «А что сейчас реально в кластере?», «А это точно применилось?», «А кто менял ConfigMap?», «А почему в Git одно, а в namespace другое?». После внедрения GitOps эти вопросы не исчезли полностью, но стали намного проще. Если приложение Synced, мы понимаем, что live state соответствует Git в рамках настроенных правил сравнения. Если OutOfSync, мы видим diff.
Второе изменение — rollback стал более предсказуемым. Мы больше не откатываем production из памяти. Если сломался tag образа, мы возвращаем предыдущий tag в Git. Если сломалась конфигурация, мы revert’им конфигурационный коммит. А если изменение было комплексным, мы видим набор файлов, который надо откатить. Это всё ещё не отменяет сложных случаев с базой данных, очередями и внешними контрактами. GitOps не делает миграции данных обратимыми. Но он убирает очень неприятный класс аварий, где приложение откатили, а окружение осталось в неизвестном состоянии.
Третье изменение — ручные правки перестали быть невидимыми. Это, наверное, главный эффект. Раньше изменение ConfigMap напрямую в кластере могло пережить несколько релизов и стать частью production-фольклора. Теперь такая правка либо быстро становится OutOfSync, либо автоматически перетирается self-heal’ом. Это не всем понравилось сразу. Особенно тем, кто привык чинить production напрямую. Но через пару инцидентов стало понятно, что GitOps не мешает чинить. Он мешает забывать, что именно мы починили.
Четвёртое изменение — мы перестали давать CI лишнюю власть и это сильно упростило разговор с безопасниками. Раньше компрометация CI означала потенциальный доступ к production-кластеру. После перехода атакующему уже недостаточно получить deploy job с kubeconfig, потому что kubeconfig там больше нет. Да, остаются другие риски: можно попытаться протащить вредное изменение в infra repo, можно атаковать registry, можно скомпрометировать Argo CD. Но это уже другая модель защиты: защита основной ветки, ревью кода, подписанные коммиты или хотя бы обязательные апрувы, проверка образов, политики допуска в кластер, RBAC для Argo CD. Поверхность атаки стала понятнее и уже.
И пятое — мы начали лучше видеть, где GitOps не должен быть единственным механизмом. Например, схему миграции данных мы не стали бездумно запихивать в sync hooks. Для простых миграций это может работать, но для тяжёлых изменений данных лучше отдельная стратегия: обратно совместимые миграции, expand/contract, feature flags, контроль времени выполнения, отдельные jobs, хорошая наблюдаемость. GitOps хорошо применяет декларативное состояние Kubernetes. Но если вы меняете данные внутри PostgreSQL, это уже не просто YAML.
Самый полезный вывод из этого инцидента оказался довольно неприятным: наш production сломался не в момент релиза. Он сломался за полгода до него, когда ручная правка ConfigMap не попала в Git. Просто тогда это выглядело как быстрое решение проблемы. Релиз только заставил систему пересобраться и проявил старый долг.
После этого я стал иначе смотреть на фразу «Git — единый источник истины». Это не лозунг и не архитектурный слайд. Это проверяемое свойство. Можно взять namespace, удалить управляемые ресурсы и восстановить их из Git? Можно понять, почему live-состояние отличается от desired? Можно откатить изменение через revert, а не через набор ручных команд из Slack? Можно убрать у CI доступ к Kubernetes API и при этом продолжить деплоить?
Если нет, то Git у нас пока не источник правды, а просто место, где лежит часть правды.
GitOps для нас стал не способом сделать деплой моднее. Он стал способом перестать жить на production-состоянии, которое существует только потому, что кто-то однажды быстро поправил его руками и забыл.-Источник
 
Loading...
Error