惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
The Register - Security
The Register - Security
月光博客
月光博客
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
The GitHub Blog
The GitHub Blog
博客园 - 司徒正美
罗磊的独立博客
U
Unit 42
S
SegmentFault 最新的问题
Y
Y Combinator Blog
博客园_首页
Hugging Face - Blog
Hugging Face - Blog
J
Java Code Geeks
Schneier on Security
Schneier on Security
Know Your Adversary
Know Your Adversary
C
Check Point Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Simon Willison's Weblog
Simon Willison's Weblog
V
Vulnerabilities – Threatpost
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
阮一峰的网络日志
阮一峰的网络日志
The Hacker News
The Hacker News
博客园 - 叶小钗
C
Cybersecurity and Infrastructure Security Agency CISA
Spread Privacy
Spread Privacy
L
LINUX DO - 热门话题
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Latest news
Latest news
L
Lohrmann on Cybersecurity
A
About on SuperTechFans
L
LangChain Blog
Stack Overflow Blog
Stack Overflow Blog
S
Securelist
A
Arctic Wolf
D
Darknet – Hacking Tools, Hacker News & Cyber Security
T
Threatpost
Scott Helme
Scott Helme
博客园 - 聂微东
博客园 - 【当耐特】
T
Tenable Blog
I
Intezer
D
DataBreaches.Net
B
Blog RSS Feed
Security Latest
Security Latest
C
Cisco Blogs
T
Tor Project blog
N
Netflix TechBlog - Medium

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как мы поймали drift в Kubernetes и зачем после этого перешли на GitOps
casssuzy · 2026-05-04 · via Все публикации подряд на Хабре

Это был не ночной инцидент и не релиз под давлением. Обычный рабочий день, плановая выкладка во второй половине дня, когда нагрузка уже ниже пика. Сервис не самый маленький: несколько 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 применяет его внутри кластера.

Схема перехода от push-деплоя к GitOps pull-модели

Схема перехода от 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-состоянии, которое существует только потому, что кто-то однажды быстро поправил его руками и забыл.