GitOps con Argo CD en Kubernetes: por que dejamos de aplicar manifiestos a mano desde el pipeline de CI
Durante bastante tiempo nuestro despliegue a Kubernetes fue un paso mas del pipeline de CI: el job de turno hacia build, corria los tests, y al final ejecutaba kubectl apply -f contra el cluster con las credenciales guardadas como secreto en el sistema de CI. Funcionaba, hasta que alguien entraba al cluster con kubectl edit para arreglar un incidente a las tres de la manana, ese cambio quedaba aplicado directamente sobre los recursos vivos, y ninguna ejecucion posterior del pipeline volvia a tocar ese recurso porque el proximo commit no lo modificaba. El cluster y el repositorio empezaban a divergir, y no habia forma automatica de saber cuanto.
Ese es el problema estructural del modelo push-based de CI/CD: el pipeline empuja cambios hacia el cluster cuando algo en el repositorio cambia, pero nada vigila el cluster el resto del tiempo. Si el estado vivo se modifica por fuera del pipeline -a mano, por un operador que escala un deployment, por otra herramienta que toca el mismo namespace-, esa desviacion se queda ahi hasta que alguien la nota o hasta que un cambio no relacionado en el mismo recurso la sobrescribe sin que nadie se entere de que tambien revirtio el parche de emergencia.
Pull-based en vez de push-based
Argo CD invierte la direccion del flujo. En vez de que el pipeline de CI empuje manifiestos hacia el cluster, un controlador que vive dentro del propio cluster consulta periodicamente un repositorio git, compara lo que encuentra ahi contra el estado real de los recursos, y aplica la diferencia. El pipeline de CI deja de tener credenciales de escritura sobre el cluster de produccion; su unico trabajo es construir la imagen, correr los tests, y actualizar el repositorio de manifiestos con la referencia a la imagen nueva. Quien de verdad aplica los cambios es el controlador dentro del cluster, no un job externo.
La pieza central es el recurso Application, que declara de donde viene el estado deseado y a que namespace del cluster corresponde:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: catalogo-service
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/tuurt/catalogo-manifests.git
targetRevision: main
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: catalogo
syncPolicy:
automated:
prune: true
selfHeal: true
selfHeal: true es la diferencia real frente a un pipeline tradicional: si alguien modifica el recurso a mano dentro del cluster, en el siguiente ciclo de reconciliacion Argo CD detecta que el estado vivo ya no coincide con lo declarado en git y revierte el cambio manual, sin que nadie tenga que pedirselo. prune: true hace lo simetrico con las eliminaciones: si un manifiesto se borra del repositorio, el recurso correspondiente se elimina del cluster en vez de quedar huerfano.
Deteccion de drift como efecto de como funciona, no como caracteristica aparte
La deteccion de drift no es una funcionalidad que Argo CD implementa aparte del proceso de sincronizacion, es una consecuencia directa de como reconcilia: en cada ciclo, calcula el diff entre el manifiesto en git y el estado vivo del recurso usando la misma logica que usaria para decidir que aplicar. Ese diff es visible en la interfaz y por linea de comandos incluso cuando selfHeal esta desactivado y Argo CD no corrige nada automaticamente:
argocd app diff catalogo-service
Con selfHeal en false, ese comando es una herramienta de auditoria: muestra exactamente que cambio en el cluster desde la ultima sincronizacion sin revertir nada, util en entornos donde se prefiere que un humano revise el drift antes de decidir si corregirlo o si el cambio manual en realidad deberia incorporarse al repositorio como un commit nuevo. Con selfHeal en true, el mismo diff es lo que dispara la correccion automatica. Es el mismo mecanismo con una decision distinta sobre que hacer con el resultado, no dos features separadas.
El repositorio de manifiestos separado del repositorio de codigo
Una decision que tomamos temprano fue separar el repositorio de manifiestos de Kubernetes del repositorio de codigo de la aplicacion. El pipeline de CI del repositorio de codigo, al terminar de construir una imagen, hace un commit automatico en el repositorio de manifiestos actualizando el tag de la imagen:
# Ultimo paso del pipeline de CI, despues de publicar la imagen
git clone https://github.com/tuurt/catalogo-manifests.git
cd catalogo-manifests
yq -i ".spec.template.spec.containers[0].image = \"registry.tuurt.com/catalogo:$IMAGE_TAG\"" \
overlays/production/deployment-patch.yaml
git commit -am "Actualiza catalogo-service a $IMAGE_TAG"
git push
La separacion evita un problema concreto: si el manifiesto vive en el mismo repositorio que el codigo, cada commit de aplicacion -incluyendo cambios que no tocan nada del deployment, como un ajuste de un test- pasa a formar parte del historial que Argo CD vigila, y el diff de auditoria se llena de ruido que no tiene que ver con cambios de infraestructura. Con el repositorio separado, el historial de manifiestos contiene exclusivamente cambios que de verdad modifican lo que corre en el cluster, lo cual importa el dia que hay que revisar ese historial para entender cuando cambio algo especifico.
Lo que no funciono: sync waves sin dependencias explicitas
En el Application de un servicio con varios componentes -el deployment, un job de migracion de base de datos, un configmap que ambos leen- probamos inicialmente dejar que Argo CD aplicara todo en el orden en que aparecia en el directorio de manifiestos, asumiendo que el orden alfabetico de los archivos coincidiria mas o menos con el orden logico de aplicacion. No fue asi: el deployment se aplico antes que el job de migracion en mas de una sincronizacion, y el pod arranco contra un esquema de base de datos que todavia no tenia la migracion aplicada, con el resultado previsible de que fallara el arranque hasta que Argo CD reintentara despues de que el job terminara.
La correccion fue usar sync waves explicitas mediante anotaciones, en vez de depender del orden implicito de los archivos:
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1"
El job de migracion queda en la ola -1 y el deployment en la ola por defecto, que es 0. Argo CD espera a que todos los recursos de una ola terminen -en el caso de un Job, a que complete, no solo a que se cree- antes de aplicar la siguiente. El orden dejo de depender de una convencion implicita de nombres de archivo y paso a estar declarado donde cualquiera que lea el manifiesto lo puede ver.
Que cambio con este modelo
El pipeline de CI ya no necesita credenciales de escritura sobre el cluster de produccion, solo credenciales de escritura sobre el repositorio de manifiestos, lo cual reduce la superficie de lo que un pipeline comprometido podria hacer directamente contra produccion. El drift entre lo declarado y lo real dejo de ser una pregunta sin respuesta clara y paso a ser un diff que se puede consultar en cualquier momento, se corrija automaticamente o no. Y el repositorio de manifiestos, al quedar separado del codigo, funciona como el registro real de que se desplego en el cluster y cuando, sin depender de reconstruir esa historia a partir de logs de un pipeline de CI que rota y se pierde con el tiempo.