Era um dia de trabalho normal, com uma implantação planejada no final da tarde, quando a carga já estava abaixo do pico. O serviço não era pequeno: vários Deployments, PostgreSQL, PgBouncer, Redis, workers em segundo plano, CronJobs separados, um gráfico Helm com valores para os ambientes. A implantação era simples: o GitLab CI executava testes, criava uma imagem, a enviava para o registro e, como último passo, executava helm upgrade.
A estrutura era mais ou menos assim:
yamldeploy: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
No papel, tudo parecia normal. --atomic deveria reverter a versão se o Helm não esperasse um estado bem-sucedido. O CI tinha um kubeconfig em variáveis protegidas. Apenas os runners privados podiam acessar a API do Kubernetes. A configuração do aplicativo estava em values. Não era ideal, mas era um esquema bastante típico que muitas equipes usavam.
Um detalhe importante: --atomic não é um mecanismo completo de rollback de produção. Ele funciona dentro do que o Helm considera uma atualização malsucedida. Se o Kubernetes conseguiu considerar a implantação bem-sucedida, mas a degradação apareceu mais tarde no nível das métricas de negócios, o pipeline já estará verde.
E foi o que aconteceu, o pipeline passou verde. A imagem foi construída, o gráfico foi aplicado, um novo ReplicaSet apareceu. Em alguns minutos, os alertas dispararam: a porcentagem de 5xx aumentou, a queima do orçamento SLO acelerou, a prontidão de alguns pods não passou. Por fora, parecia uma regressão na nova versão. Rapidamente decidimos reverter para a tag de imagem anterior, que havia funcionado por vários meses.
E então tudo ficou desagradável: a versão antiga também não subiu. Ou melhor, ela nem conseguiu iniciar normalmente. O Pod entrou em CrashLoopBackOff, nos logs havia um erro de conexão com o banco de dados. O aplicativo falhou na fase de inicialização, quando estava montando a string de conexão com o banco de dados.
Os logs eram mais ou menos assim:
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
Na primeira iteração, verificamos tudo o que você normalmente verifica em tal situação: se o endpoint do banco de dados está ativo, se há endereços reais de pods atrás dele, se o Secret mudou, se o conjunto de variáveis de ambiente no pod corresponde ao que está descrito nos valores do Helm, se uma nova rejeição do admission controller apareceu, se o pull da imagem quebrou, se o sidecar caiu. Gradualmente, a versão com o "release quebrado" começou a desmoronar. As imagens estavam normais. O Secret não foi rotacionado. O problema se resumia à configuração da conexão.
No Git, em values-prod.yaml, havia um valor, e no cluster ativo antes do lançamento havia outro valor DB_HOST. À primeira vista, a diferença não parecia crítica, mas, na verdade, o serviço não estava indo para o endpoint que estava descrito no repositório. DB_HOST entrava no pod através do ConfigMap users-api-env: o Helm renderizava este ConfigMap de values-prod.yaml, e o Deployment o conectava através de envFrom. Há muito tempo, durante um problema separado com o PgBouncer, este ConfigMap foi alterado manualmente diretamente no namespace de produção através de kubectl edit. A alteração não entrou no Git e não se tornou parte do Helm release. Como resultado, o objeto ativo no cluster e o estado desejado do repositório divergiram.
Acontece que nossa produção funcionou por vários meses em um estado que não existia no Git.
E o novo release recriou os pods. Eles pegaram a configuração do gráfico, ou seja, do Git, e obtiveram o DB_HOST "correto" do ponto de vista do repositório. Só que este DB_HOST já não funcionava para a produção real, e por isso a nova versão falhou. E o rollback da tag da imagem não ajudou, porque o problema não estava na imagem. A versão antiga do aplicativo também começou com a configuração do Git e também falhou.
A essa altura, finalmente tínhamos a raiz normal do problema, e não apenas a formulação "o release quebrou o serviço". O próprio release não quebrou nada. Ele simplesmente revelou a dessincronização entre o estado esperado do Git e o estado real no cluster.
O que fizemos durante o incidente
A primeira tarefa não era "implementar GitOps", mas restaurar o serviço. Fixamos o estado atual do cluster, comparamos com o Git e tomamos a decisão desagradável, mas correta: não editar o ConfigMap manualmente pela segunda vez, mas inserir a configuração real de produção no repositório e executar a implantação através do pipeline existente.
Sim, isso levou alguns minutos a mais do que editar manualmente o objeto no cluster. Mas já tínhamos visto o preço dessa edição. Repetir o mesmo padrão durante a análise seria estranho.
Fizemos um pequeno MR no repositório infra:
diffenv: - DB_HOST: "pgbouncer.users.svc.cluster.local" + DB_HOST: "pgbouncer-primary.users.svc.cluster.local"
Antes do merge, certificamo-nos separadamente de que esse nome de serviço existia no namespace de produção, que ele tinha endpoints ativos e que realmente levava ao pool PgBouncer correto. Após o merge, o pipeline aplicou o gráfico, os pods foram recriados e a prontidão começou a passar, e os erros de conexão com o banco de dados desapareceram.
Depois disso, não nos limitamos ao status do Deployment. Verificamos o endpoint de saúde, vários cenários do usuário, erros no nível do trabalho com o banco de dados, o pool de conexões, os atrasos nas solicitações ao PostgreSQL e os workers em segundo plano. Este é um ponto importante: a prontidão do Kubernetes só diz que o pod está pronto para aceitar tráfego de acordo com os critérios que você mesmo descreveu. Isso não prova que os cenários de negócios estão ativos. Tínhamos a prontidão vinculada ao endpoint HTTP, que verificava o lançamento básico e a conexão com o banco de dados, mas não verificava várias dependências externas importantes. Depois do incidente, também corrigimos isso, mas não transformamos a prontidão em um teste sintético completo. Uma verificação de prontidão muito pesada se torna a causa da instabilidade.
Quando o serviço foi restaurado, fizemos uma análise post-mortem. E lá ficou claro que o problema não se resumia a um ConfigMap.
Tínhamos vários pontos fracos de uma vez.
- O CI tinha acesso direto ao cluster de produção. O Kubeconfig estava nas variáveis do GitLab CI. Sim, as variáveis eram protegidas e ocultas. Sim, o acesso era apenas para o job de produção. Mas, na verdade, ainda era a chave do cluster, que permitia que um sistema externo alterasse o estado da produção.
- O servidor de API de produção estava acessível aos runners. Cobrimos isso com restrições de rede, mas o próprio modelo exigia acesso de entrada à API do Kubernetes do ambiente CI.
- O Git não era a fonte da verdade. Era a fonte do que pensávamos sobre a produção. A realidade poderia ser diferente, e só descobrimos isso ao reiniciar os pods ou durante um acidente.
- Não tínhamos uma verificação constante de drift. Ninguém comparava automaticamente o estado ativo com o que estava no repositório. Ou seja, a divergência poderia viver por quanto tempo fosse.
- E o principal: o rollback foi incompleto. Revertemos a tag da imagem, mas não revertemos o estado do ambiente. Neste incidente, o estado do ambiente era o problema.
Por que escolhemos GitOps e não apenas "proibimos edições manuais"
Após a análise do incidente, era possível seguir o caminho simples: escrever uma regra como "não editar a produção manualmente", remover o acesso de algumas pessoas, adicionar um item ao runbook. Isso é útil, mas não resolve o problema do sistema. As pessoas ainda farão edições manuais se essa for a maneira mais rápida de restaurar o serviço. Especialmente à noite, quando a métrica de negócios está pegando fogo.
Precisávamos de um mecanismo que mudasse não apenas o comportamento dos engenheiros, mas também as propriedades do sistema.
Formulamos o objetivo da seguinte forma: a produção deve convergir para o estado do Git automaticamente e constantemente. Esta é a essência do GitOps: uma descrição declarativa do estado, versionamento, obtenção automática do estado desejado pelo agente e reconciliação contínua.
Escolhemos o Argo CD. Mas não porque seja a única opção correta. Flux também poderia ter resolvido essa tarefa. Mas para nossa equipe, o Argo CD era mais simples organizacionalmente: UI, status Synced/OutOfSync claros, diff visível, boa integração com Helm e Kustomize. E era importante para nós que não apenas os engenheiros de plataforma, mas também os desenvolvedores de back-end pudessem abrir o aplicativo e ver: aqui está o que está no Git, aqui está o que realmente está no cluster, aqui é onde ele se separou.
Formalmente, o acesso era limitado: as variáveis protegidas e mascaradas, o job era executado apenas para produção. Mas a essência disso não mudou: no sistema CI externo, havia uma credencial com a qual era possível alterar o cluster de produção.
O novo modelo ficou assim: o GitLab CI não implanta mais no Kubernetes. Ele cria a imagem, executa as verificações, envia a imagem para o registro e atualiza o repositório de infraestrutura. Por exemplo, altera a tag nos valores:
yamlimage: repository: registry.example.com/users-api tag: "9f4c2a1"
Em seguida, o Argo CD, que funciona dentro do cluster, pega as alterações do Git e as aplica. Uma vantagem prática importante: o pipeline não é mais obrigado a ter acesso direto à API do Kubernetes ou à API do Argo CD. CI/CD nesse modelo simplesmente corrige o novo estado desejado no Git, e a implantação é executada pelo controlador do cluster.
No mínimo, o aplicativo parecia mais ou menos assim:
yamlapiVersion: 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
Observe: no início, não incluímos prune e selfHeal.
Este é um detalhe importante. Você realmente quer colocar a configuração final bonita imediatamente:
yamlsyncPolicy: automated: prune: true selfHeal: true
Mas se você incluir isso em um cluster sujo, poderá remover rapidamente o que de repente se tornou "necessário", embora não esteja no Git. Não porque o Argo CD seja perigoso, mas porque o cluster já acumulou uma história oral.
Primeiro, executamos o Argo CD no modo de observação. Ele mostrava OutOfSync, mas não tentava corrigir tudo automaticamente. Isso nos deu uma lista de discrepâncias. Parte era esperada: anotações de tempo de execução, campos de status, coisas que eram gerenciadas por operadores. Parte era lixo real: ConfigMaps antigos, Service esquecidos, RoleBindings após experimentos, anotações Ingress temporárias que precisavam ser removidas ou transferidas para o Git há muito tempo.
A coisa mais útil não foi a lista em si, mas a conversa em torno dela. Cada discrepância teve que ser classificada: este é um estado legítimo que precisa ser descrito no Git; este é lixo que precisa ser removido; este é um campo que não deve ser gerenciado pelo GitOps, mas por outro controlador; esta é uma edição manual que precisa ser transformada em uma solicitação de alteração normal.
Apenas depois disso, ativamos a sincronização automática para parte dos serviços, depois a autocura e, mais tarde, a poda. Não em toda a produção de uma vez, mas em grupos de aplicativos.
A versão final do serviço ficou mais próxima disso:
yamlapiVersion: 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 era fundamental para nós. Se alguém alterar o ConfigMap manualmente, essa alteração não deve viver por seis meses. Ele deve se tornar um commit normal ou desaparecer. Quando o Argo CD vê uma discrepância entre os manifestos desejados no Git e o estado real do cluster, ele pode sincronizar o aplicativo novamente. E selfHeal é responsável por retornar o cluster ao estado descrito no repositório.
Incluímos prune com cautela. Ele é necessário para que os recursos removidos do Git sejam removidos do cluster, caso contrário, o Git não descreve o estado completo: ConfigMaps, Service, RoleBindings ou outros objetos antigos sobre os quais o repositório não sabe mais nada podem permanecer na produção. Mas prune pode doer se você não tiver algo realmente usado no Git. Portanto, antes de ativar a poda, percorremos separadamente os recursos no namespace e verificamos quem é responsável por quê: o que o Helm cria, o que o operador cria, o que é criado manualmente, o que não é mais usado.
O que teve que ser mudado em torno do GitOps
O Argo CD por si só não torna o sistema maduro. Ele apenas começa a mostrar honestamente onde você tem bagunça.
A primeira coisa que removemos foi o kubeconfig das variáveis do GitLab CI. O CI não deveria mais ter o direito de aplicar manifestos na produção. Para construir as imagens, ele tinha acesso suficiente ao registro. Para atualizar a versão do aplicativo - o direito de cometer no repositório de infraestrutura através de um bot separado com direitos limitados. Esta é uma separação importante: o CI produz o artefato, o Git corrige o estado desejado, o Argo CD o aplica dentro do cluster.
O esquema de transição de implantação por push para o modelo pull GitOps
A segunda mudança foi o RBAC para o Argo CD. Seria tolo tirar amplos direitos do CI e dar os mesmos amplos direitos ao controlador GitOps sem restrições. Dividimos os aplicativos por Projetos Argo CD: serviços de produção separados, componentes de plataforma separados, namespaces do sistema separados. Para um projeto de aplicativo normal, proibimos recursos de cluster que ele não precisa. O aplicativo não precisa criar ClusterRole, MutatingWebhookConfiguration ou CRD. Se o serviço realmente precisar de algo para todo o cluster, essa deve ser uma conversa separada e um repositório/processo de plataforma separado.
Mais ou menos assim era nossa ideia de restrições no nível do projeto:
yamlapiVersion: 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
Este não é um modelo universal. Na realidade, a lista de recursos depende do que você está implantando. Mas o princípio é importante: o controlador GitOps não deve se tornar automaticamente um cluster-admin "porque é mais fácil".
A terceira mudança - segredos. Antes do incidente, também não colocávamos segredos reais no Git, mas em Helm values, ainda havia pontos escorregadios: strings de conexão sem senhas, nomes de segredos, às vezes parâmetros muito detalhados de sistemas externos. Conversamos separadamente sobre o limite: o Git pode armazenar a declaração da dependência do segredo, mas não o próprio segredo em formato aberto.
Kubernetes Secret é um objeto para armazenar dados confidenciais como senhas e tokens, mas isso não significa que seu YAML seja seguro para cometer como está. No Kubernetes, você geralmente tem que trabalhar com a representação base64 dos dados, e base64 é codificação, como você sabe, e não proteção criptográfica.
Escolhemos o External Secrets Operator. No Git, o ExternalSecret permaneceu, que descreve qual segredo o aplicativo precisa e de qual armazenamento externo obtê-lo. O próprio valor está no armazenamento externo de segredos. O External Secrets Operator sincroniza segredos das APIs externas para Kubernetes Secrets.
Mais ou menos assim:
yamlapiVersion: 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
O ponto não é que os External Secrets sejam "mais seguros" por si só. O ponto é a divisão de responsabilidades. O Git descreve que o serviço precisa de um segredo. O armazenamento secreto é responsável pelo valor, rotação, auditoria de acesso. O Kubernetes recebe apenas o Secret final necessário para o aplicativo durante a operação.
A quarta mudança - procedimento de emergência. Não se pode simplesmente dizer às pessoas: "Agora, quaisquer edições manuais são proibidas". Na produção, há situações em que você precisa agir rapidamente. Mas mudamos a forma dessas ações.
Agora, uma alteração de emergência deve ir imediatamente através do Git ou, se for um incêndio, a edição manual é corrigida como uma exceção temporária com um commit subsequente obrigatório. Com a autocura ativada, a edição manual ainda será apagada, portanto, para casos raros, temos um procedimento claro: interromper temporariamente a sincronização automática para um aplicativo específico, fazer uma alteração, restaurar o serviço, emitir imediatamente uma alteração no Git e retornar a sincronização automática. Isso não é de forma alguma as melhores práticas e não é um padrão, mas é um caminho de emergência honesto que não deixa o cluster em um estado desconhecido.
A quinta mudança - higiene de discrepâncias. Após a ativação do GitOps, primeiro tivemos muito barulho. Alguns recursos estavam OutOfSync, embora na verdade não houvesse problemas. Por exemplo, parte das anotações foi adicionada pelo admission controller, o status foi atualizado pelo Kubernetes, o HPA gerenciou o número de réplicas. Se você olhar para isso sem um filtro, a equipe rapidamente para de confiar nos status.
Não começamos a simplesmente ignorar tudo. Isso é perigoso: um ignore muito amplo transforma o GitOps em um painel decorativo. Em vez disso, dividimos a propriedade por campos. Se as réplicas são gerenciadas pelo HPA, então você não precisa fingir que o Git possui esse valor. Se a anotação for adicionada por um controlador específico, isso pode ser descrito através da personalização diff. Se o campo for alterado manualmente, este não é um ignore, este é um problema.
O que mudou após a transição
A mudança mais notável não foi na interface do usuário do Argo CD e não no pipeline. A maneira de falar sobre produção mudou.
Antes disso, durante o incidente, perguntas como: "O que está realmente no cluster agora?", "Isso realmente se aplicou?", "Quem alterou o ConfigMap?", "Por que há uma coisa no Git e outra no namespace?" eram frequentemente ouvidas. Após a implementação do GitOps, essas perguntas não desapareceram completamente, mas se tornaram muito mais simples. Se o aplicativo estiver Synced, entendemos que o estado ativo corresponde ao Git dentro das regras de comparação configuradas. Se OutOfSync, vemos o diff.
A segunda mudança - o rollback se tornou mais previsível. Não revertemos mais a produção da memória. Se a tag da imagem quebrar, retornamos a tag anterior no Git. Se a configuração quebrar, revertemos o commit de configuração. E se a alteração fosse complexa, vemos o conjunto de arquivos que precisam ser revertidos. Isso ainda não cancela casos complexos com o banco de dados, filas e contratos externos. GitOps não torna as migrações de dados reversíveis. Mas remove uma classe muito desagradável de acidentes, onde o aplicativo foi revertido, e o ambiente permaneceu em um estado desconhecido.
A terceira mudança - as edições manuais pararam de ser invisíveis. Este é provavelmente o principal efeito. Antes, alterar o ConfigMap diretamente no cluster poderia sobreviver a vários lançamentos e se tornar parte do folclore da produção. Agora, essa edição ou se torna rapidamente OutOfSync ou é automaticamente apagada pela autocura. Nem todos gostaram disso imediatamente. Especialmente aqueles que estão acostumados a consertar a produção diretamente. Mas depois de alguns incidentes, ficou claro que o GitOps não interfere na correção. Ele impede que esqueçamos o que exatamente consertamos.
A quarta mudança - paramos de dar ao CI poder extra e isso simplificou muito a conversa com os seguranças. Antes, a comprometimento do CI significava potencial acesso ao cluster de produção. Após a transição, não é mais suficiente para o invasor obter um job de implantação com kubeconfig, porque não há mais kubeconfig lá. Sim, outros riscos permanecem: você pode tentar arrastar uma alteração maliciosa para o repositório de infraestrutura, pode atacar o registro, pode comprometer o Argo CD. Mas este já é outro modelo de proteção: proteção do branch principal, revisão de código, commits assinados ou pelo menos aprovações obrigatórias, verificação de imagens, políticas de acesso ao cluster, RBAC para Argo CD. A superfície de ataque tornou-se mais clara e menor.
E a quinta - começamos a ver melhor onde o GitOps não deve ser o único mecanismo. Por exemplo, não começamos a enfiar cegamente o esquema de migração de dados em sync hooks. Para migrações simples, isso pode funcionar, mas para alterações pesadas de dados, uma estratégia separada é melhor: migrações compatíveis com versões anteriores, expand/contract, feature flags, controle de tempo de execução, jobs separados, boa observabilidade. GitOps aplica bem o estado declarativo do Kubernetes. Mas se você estiver alterando dados dentro do PostgreSQL, isso não é apenas YAML.
A conclusão mais útil deste incidente foi bastante desagradável: nossa produção quebrou não no momento do lançamento. Ele quebrou seis meses antes, quando a edição manual do ConfigMap não entrou no Git. Só que então parecia uma solução rápida para o problema. O lançamento só forçou o sistema a se reconstruir e revelou uma dívida antiga.
Depois disso, comecei a olhar para a frase "Git é a única fonte da verdade" de forma diferente. Este não é um slogan e não um slide arquitetônico. Esta é uma propriedade verificável. Você pode pegar um namespace, remover recursos gerenciados e restaurá-los do Git? Você pode entender por que o estado ativo difere do desejado? Você pode reverter a alteração através da reversão, e não através de um conjunto de comandos manuais do Slack? Você pode remover o acesso da CI à API do Kubernetes e, ao mesmo tempo, continuar a implantar?
Se não, então o Git para nós ainda não é a fonte da verdade, mas apenas um lugar onde parte da verdade está.
GitOps para nós não se tornou uma maneira de tornar a implantação mais moderna. Tornou-se uma maneira de parar de viver em um estado de produção que existe apenas porque alguém um dia o corrigiu rapidamente com as mãos e se esqueceu.





