Módulo 5: Gitops With Argocd
8. Proyecto: un cambio en Git, reflejado solo
Descripción
Este proyecto cierra el módulo con la prueba que todas las lecciones anteriores prepararon: un cambio real, subido a Git con git push, reflejado en andes-cargo-cluster sin que nadie ejecute kubectl apply para nada que no sea observar. No es un cambio de juguete —vas a subir las réplicas de andes-cargo-status-api de 3 a 5, un cambio de negocio real, del tipo que un equipo de verdad haría un martes cualquiera—, y vas a medir, con timestamps reales, cuánto tarda en converger. Como bono, este proyecto documenta un hallazgo real que apareció sin planearlo mientras se escribía esta lección: el HorizontalPodAutoscaler del Módulo 3 y el replicas que declara Git terminan compitiendo por el mismo campo — y la corrección de producción real que resuelve esa tensión, verificada con evidencia literal.
Conexión con el módulo
Este proyecto usa, sin ningún cambio, las siete lecciones anteriores: Gitea (lección 3) como repositorio, ArgoCD (lección 4) como operador, el Application ya sincronizado (lecciones 5-6), y la distinción precisa entre escalado y RollingUpdate (lección 7) que hace posible describir, con exactitud, qué mecanismo estás viendo aquí.
Paso 1 — Confirma el estado de partida
kubectl get pods -n andes-cargo -l app=andes-cargo-status-api
kubectl get deployment andes-cargo-status-api -n andes-cargo -o jsonpath='{.spec.replicas} desired, {.status.readyReplicas} ready{"\n"}'
Qué esperar (literal, ejecutado — nombres de Pod con sufijo hash son tu valor variable; el conteo de tres réplicas es literal en este punto del módulo, heredado de la lección 6):
NAME READY STATUS RESTARTS AGE
andes-cargo-status-api-548966dd97-bpqjq 1/1 Running 0 76m
andes-cargo-status-api-548966dd97-ls2bc 1/1 Running 0 76m
andes-cargo-status-api-548966dd97-z7bpd 1/1 Running 0 41s
3 desired, 3 ready
grep -n "replicas:" deployment.yaml
10: replicas: 3
Tres réplicas, en el clúster y en el repositorio — el punto de partida exacto de este proyecto.
Paso 2 — Cambia deployment.yaml, localmente, y confirma el diff
sed -i '' 's/replicas: 3/replicas: 5/' deployment.yaml
git diff
Qué esperar (literal, ejecutado):
diff --git a/deployment.yaml b/deployment.yaml
index 423cef4..9086693 100644
--- a/deployment.yaml
+++ b/deployment.yaml
@@ -7,7 +7,7 @@ metadata:
labels:
app: andes-cargo-status-api
spec:
- replicas: 3
+ replicas: 5
strategy:
type: RollingUpdate
rollingUpdate:
Una línea. Nada más cambió — ni la imagen, ni el strategy, ni ningún otro campo del Pod template. Como confirmó la lección 7, esto es, con precisión, un evento de escalado — el ReplicaSet existente va a crecer, sin que se cree ninguno nuevo.
Paso 3 — git commit, git push — y nada más
git add deployment.yaml
git commit -m "Scale andes-cargo-status-api from 3 to 5 replicas"
git push origin main
Qué esperar (literal, ejecutado — el hash del commit es tu valor variable):
To http://localhost:3000/andes-cargo/andes-cargo-k8s.git
820515f..cd0b5ca main -> main
Esto es todo lo que vas a ejecutar contra el clúster. A partir de aquí, ningún comando de los pasos siguientes cambia nada — todos observan.
Paso 4 — Observa la convergencia, con timestamps reales
for i in $(seq 1 18); do
ts=$(date +%H:%M:%S)
sync=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.status}')
revision=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.revision}' | cut -c1-7)
replicas=$(kubectl get deployment andes-cargo-status-api -n andes-cargo -o jsonpath='{.spec.replicas}')
echo "$ts sync=$sync revision=$revision spec.replicas=$replicas"
if [ "$replicas" = "5" ]; then break; fi
sleep 10
done
Qué esperar (literal, ejecutado — los timestamps exactos son tu valor variable; el patrón —varios ciclos sin cambio, después convergencia— es lo que confirma el mecanismo de la lección 2):
15:54:13 sync=Synced revision=820515f spec.replicas=3
15:54:23 sync=Synced revision=820515f spec.replicas=3
15:54:33 sync=Synced revision=820515f spec.replicas=3
15:54:43 sync=Synced revision=820515f spec.replicas=3
15:54:53 sync=Synced revision=820515f spec.replicas=3
15:55:03 sync=Synced revision=820515f spec.replicas=3
15:55:13 sync=Synced revision=820515f spec.replicas=3
15:55:24 sync=Synced revision=820515f spec.replicas=3
15:55:34 sync=Synced revision=820515f spec.replicas=3
15:55:44 sync=Synced revision=820515f spec.replicas=3
15:55:54 sync=Synced revision=cd0b5ca spec.replicas=5
Aproximadamente cien segundos entre el git push del Paso 3 y la convergencia detectada — dentro del rango que predijo la lección 2 (el intervalo de sondeo de ArgoCD, por defecto, de unos pocos minutos). Ni un solo kubectl apply, kubectl scale ni kubectl edit corrió en ningún momento de esta espera — el número pasó de 3 a 5 porque ArgoCD lo empujó, no porque nadie lo forzara.
Paso 5 — Confirma los Pods, en dos momentos
Unos segundos después de que revision cambiara a cd0b5ca:
kubectl get pods -n andes-cargo -l app=andes-cargo-status-api -o wide
Qué esperar (literal, ejecutado — todavía en transición, un Pod terminando):
NAME READY STATUS RESTARTS AGE IP NODE
andes-cargo-status-api-548966dd97-8w8tx 1/1 Running 0 15s 10.244.1.17 andes-cargo-cluster-worker
andes-cargo-status-api-548966dd97-9nh96 1/1 Running 0 15s 10.244.1.18 andes-cargo-cluster-worker
andes-cargo-status-api-548966dd97-bjcqx 1/1 Terminating 0 91s 10.244.1.16 andes-cargo-cluster-worker
andes-cargo-status-api-548966dd97-bpqjq 1/1 Running 0 79m 10.244.1.2 andes-cargo-cluster-worker
andes-cargo-status-api-548966dd97-hqlbt 1/1 Running 0 15s 10.244.2.10 andes-cargo-cluster-worker2
andes-cargo-status-api-548966dd97-ls2bc 1/1 Running 0 79m 10.244.2.2 andes-cargo-cluster-worker2
Y, unos segundos después, ya estable:
kubectl get all -n andes-cargo
Qué esperar (literal, ejecutado — nombres de Pod son tu valor variable; el patrón de cinco réplicas es literal):
NAME READY STATUS RESTARTS AGE
pod/andes-cargo-status-api-548966dd97-br69f 1/1 Running 0 42s
pod/andes-cargo-status-api-548966dd97-g4xhv 1/1 Running 0 42s
pod/andes-cargo-status-api-548966dd97-hqlbt 1/1 Running 0 70s
pod/andes-cargo-status-api-548966dd97-ls2bc 1/1 Running 0 79m
pod/andes-cargo-status-api-548966dd97-wtv4s 1/1 Running 0 42s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/status-api-service ClusterIP 10.96.239.125 <none> 80/TCP 79m
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/andes-cargo-status-api 5/5 5 5 79m
NAME DESIRED CURRENT READY AGE
replicaset.apps/andes-cargo-status-api-548966dd97 5 5 5 79m
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
horizontalpodautoscaler.autoscaling/andes-cargo-status-api-hpa Deployment/andes-cargo-status-api cpu: 1%/50% 2 6 5 79m
5/5, un solo ReplicaSet (548966dd97, el mismo hash de siempre — confirmando, otra vez, que esto fue escalado, no un RollingUpdate de versión, exactamente como predijo la lección 7). Confirma también desde el ángulo de ArgoCD:
argocd app get andes-cargo-status-api
Qué esperar (literal, ejecutado):
Sync Status: Synced to main (cd0b5ca)
Health Status: Healthy
GROUP KIND NAMESPACE NAME STATUS HEALTH HOOK MESSAGE
Namespace andes-cargo andes-cargo Synced Running namespace/andes-cargo unchanged
networking.k8s.io NetworkPolicy andes-cargo default-deny-ingress Synced networkpolicy.networking.k8s.io/default-deny-ingress unchanged
networking.k8s.io NetworkPolicy andes-cargo allow-from-ingress-nginx Synced networkpolicy.networking.k8s.io/allow-from-ingress-nginx unchanged
Secret andes-cargo andes-cargo-status-api-secrets Synced secret/andes-cargo-status-api-secrets configured
ConfigMap andes-cargo andes-cargo-status-api-config Synced configmap/andes-cargo-status-api-config unchanged
Service andes-cargo status-api-service Synced Healthy service/status-api-service unchanged
apps Deployment andes-cargo andes-cargo-status-api Synced Healthy deployment.apps/andes-cargo-status-api configured
autoscaling HorizontalPodAutoscaler andes-cargo andes-cargo-status-api-hpa Synced Healthy horizontalpodautoscaler.autoscaling/andes-cargo-status-api-hpa unchanged
networking.k8s.io Ingress andes-cargo status-api-ingress Synced Healthy ingress.networking.k8s.io/status-api-ingress unchanged
argoproj.io Application argocd andes-cargo-status-api Synced application.argoproj.io/andes-cargo-status-api configured
Sync to cd0b5ca — exactamente el commit del Paso 3. Synced, Healthy, cinco réplicas, cero kubectl apply. Esta es la prueba central de todo el módulo.
LO QUE ACABA DE PASAR
Tú Gitea ArgoCD andes-cargo-cluster
│ │ │ │
│──git push (cd0b5ca)──▶│ │ │
│ │◀── poll (~100s) ──────│ │
│ │────commit cd0b5ca────▶│ │
│ │ │──apply replicas: 5────▶│
│ │ │◀───5/5 Ready───────────│
│ │ │ │
│ Nunca corriste: kubectl apply, kubectl scale, kubectl edit │
Paso 6 — El hallazgo real: el HPA y Git compiten por el mismo campo
Todo lo anterior es la prueba que este proyecto prometía. Lo que sigue es un descubrimiento real, no planeado, que apareció mientras se escribía esta lección — y que vale la pena documentar completo, porque es exactamente el tipo de problema que un equipo real encuentra la primera semana de usar GitOps junto a autoscaling.
Unos minutos después de la convergencia del Paso 5, sin que nadie tocara nada:
kubectl get deployment andes-cargo-status-api -n andes-cargo -o jsonpath='{.spec.replicas}{"\n"}'
kubectl get hpa -n andes-cargo
argocd app get andes-cargo-status-api --refresh | head -12
Qué esperar (literal, ejecutado — sorpresa: el número volvió a bajar, y ArgoCD lo marca):
2
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS AGE
andes-cargo-status-api-hpa Deployment/andes-cargo-status-api cpu: 1%/50% 2 6 2 94m
Sync Status: OutOfSync from main (cd0b5ca)
Health Status: Healthy
OutOfSync. El HorizontalPodAutoscaler del Módulo 3 (andes-cargo-status-api-hpa, con minReplicas: 2) hizo exactamente lo que fue diseñado para hacer: con la CPU real al 1%, muy por debajo del objetivo del 50%, redujo las réplicas hacia el mínimo permitido — escribiendo directamente spec.replicas: 2 en el Deployment, sin pasar por Git. deployment.yaml, en Gitea, sigue diciendo replicas: 5 — Git y el clúster real, ahora, dicen cosas distintas, y ArgoCD lo detecta con precisión (esa es, exactamente, su función: comparar).
Con selfHeal: true activo desde la lección 5, la pregunta obvia es: ¿por qué ArgoCD no revierte esto a 5, como sí hizo con el kubectl scale manual de esa misma lección? La respuesta está en los propios registros del controlador:
kubectl logs -n argocd argocd-application-controller-0 --tail=200 | grep -i "skipping auto-sync"
Qué esperar (literal, ejecutado):
{"msg":"Skipping auto-sync: already attempted sync to [cd0b5cadbb43cfdf8c6aaa9c46a7fcd0dd8a63eb] with timeout 0s (retrying in 3m41.370372326s)", ...}
ArgoCD sí intentó una corrección automática apenas detectó la diferencia — pero, después de ese primer intento, aplica un período de enfriamiento antes de volver a intentarlo contra la misma revisión. Sin este mecanismo, selfHeal y el HPA entrarían en una pelea infinita: ArgoCD fuerza 5, el HPA lo baja de nuevo a 2 en su próximo ciclo (cada pocos segundos), ArgoCD lo vuelve a forzar, indefinidamente — el tipo exacto de oscilación que ningún sistema de producción debería tolerar. El enfriamiento evita esa pelea, a costa de dejar el Application en OutOfSync de forma indefinida mientras el HPA siga activo y la CPU se mantenga baja.
LA PELEA QUE selfHeal NO LIBRA INDEFINIDAMENTE
Git: replicas = 5 HPA controller: cpu 1%/50%,
(sin cambios) baja a replicas = 2
│ │
└──── ArgoCD compara, detecta diferencia ────┘
│
▼
Primer intento: fuerza replicas = 5 ✓
│
▼
HPA vuelve a bajarlo a 2, segundos después
│
▼
ArgoCD: "ya intenté sync contra esta revisión,
reintento en ~3-4 minutos" — NO pelea sin fin
Paso 7 — La corrección real: decirle a ArgoCD que ese campo no es suyo
La solución de producción real, documentada por el propio proyecto, no es desactivar selfHeal ni bajar la frecuencia de sondeo — es decirle a ArgoCD, explícitamente, que ese campo específico no es de su competencia. ignoreDifferences hace exactamente eso:
# application.yaml (fragmento agregado)
spec:
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
- RespectIgnoreDifferences=true
ignoreDifferences:
- group: apps
kind: Deployment
jsonPointers:
- /spec/replicas
ignoreDifferences excluye /spec/replicas del cálculo de Sync Status — ArgoCD deja de considerar ese campo al decidir si el Deployment está sincronizado o no. RespectIgnoreDifferences=true, dentro de syncOptions, es el complemento necesario: sin esta bandera, una sincronización real seguiría aplicando el valor completo de Git (incluido replicas: 5) cada vez que corriera, sobrescribiendo lo que el HPA hubiera decidido — la bandera le dice a la operación de sincronización misma que respete el valor que ya está en el clúster para ese campo, no solo que lo ignore al comparar.
git add application.yaml
git commit -m "Ignore spec.replicas on the Deployment: the HorizontalPodAutoscaler owns it, not Git"
git push origin main
argocd app sync andes-cargo-status-api
Qué esperar (literal, ejecutado — el hash es tu valor variable):
To http://localhost:3000/andes-cargo/andes-cargo-k8s.git
cd0b5ca..db09bbe main -> main
Sync Status: Synced to main (db09bbe)
Health Status: Healthy
...
apps Deployment andes-cargo andes-cargo-status-api Synced Healthy deployment.apps/andes-cargo-status-api unchanged
unchanged — a pesar de que el Deployment real seguía teniendo 2 réplicas (el valor que dejó el HPA) y Git sigue declarando 5. ArgoCD ya no considera eso una diferencia. Confírmalo con la misma prueba de continuidad que hizo el Paso 6, esta vez durante dos minutos completos:
for i in $(seq 1 10); do
ts=$(date +%H:%M:%S)
sync=$(kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.status}')
replicas=$(kubectl get deployment andes-cargo-status-api -n andes-cargo -o jsonpath='{.spec.replicas}')
echo "$ts sync=$sync spec.replicas=$replicas"
sleep 12
done
Qué esperar (literal, ejecutado):
16:15:27 sync=Synced spec.replicas=2
16:15:39 sync=Synced spec.replicas=2
16:15:51 sync=Synced spec.replicas=2
16:16:03 sync=Synced spec.replicas=2
16:16:15 sync=Synced spec.replicas=2
16:16:27 sync=Synced spec.replicas=2
16:16:39 sync=Synced spec.replicas=2
16:16:51 sync=Synced spec.replicas=2
16:17:03 sync=Synced spec.replicas=2
16:17:15 sync=Synced spec.replicas=2
Synced, de forma estable, con el HPA libre de mover replicas a donde la CPU real lo pida —2 en este momento—, sin que ArgoCD lo marque nunca más como una diferencia. Este es el estado correcto de producción: Git sigue siendo la fuente de verdad para qué corre (la imagen, la configuración, las probes), y el HPA es la fuente de verdad para cuántas réplicas, en tiempo real — dos autoridades distintas, cada una dueña de su propio campo, sin pelear entre sí.
Checklist final del módulo
- Gitea corriendo dentro del clúster, con el repositorio
andes-cargo/andes-cargo-k8scomo única fuente de verdad de nueve manifiestos (lección 3). - ArgoCD
v3.5.1corriendo dentro del clúster, sincronizando ese repositorio de forma automática (lección 4). - Un cambio real (
replicas: 3 → 5) subido congit pushy reflejado en el clúster sin un solokubectl apply, medido con timestamps reales (~100 segundos de convergencia) — la prueba central de este módulo (Pasos 1-5 de este proyecto). -
selfHeal: truecorrigiendo un cambio manual en segundos (lección 5) y respetando, conignoreDifferences, el campo que le pertenece a otro controlador (Paso 6-7 de este proyecto) — las dos caras de la misma propiedad de GitOps, aplicadas con criterio.
Errores comunes
Concluir que el hallazgo del Paso 6 significa que "GitOps y autoscaling son incompatibles" (de generalización apresurada, el error más importante de esta lección). Qué pasa: alguien, al ver OutOfSync sin razón obvia, concluye que ArgoCD y HorizontalPodAutoscaler no deberían usarse juntos. Cómo detectarlo: si tu conclusión es "hay que elegir entre GitOps o autoscaling". Cómo corregirlo: el Paso 7 demuestra la solución estándar y documentada oficialmente (ignoreDifferences + RespectIgnoreDifferences=true) — es un patrón conocido, no un defecto de ninguna de las dos herramientas. Cualquier equipo que use ArgoCD junto a un HorizontalPodAutoscaler en producción real configura esto desde el principio.
Aplicar ignoreDifferences sin RespectIgnoreDifferences=true y esperar el mismo resultado (de YAML incompleto). Qué pasa: alguien agrega solo el bloque ignoreDifferences, sin la opción de sincronización complementaria. Cómo detectarlo: si una sincronización manual (argocd app sync) sigue sobrescribiendo el valor que el HPA había puesto. Cómo corregirlo: ignoreDifferences por sí solo afecta únicamente el cálculo de diferencias (qué cuenta como OutOfSync); sin RespectIgnoreDifferences=true, una sincronización real todavía aplica el manifiesto completo de Git, incluido el campo "ignorado". Las dos piezas trabajan juntas, no una en lugar de la otra.
No entender por qué selfHeal no peleó infinitamente antes de la corrección (de mecanismo, ya explicado con el log literal del Paso 6). Qué pasa: alguien espera ver a ArgoCD forzando replicas: 5 cada pocos segundos, en una pelea visible contra el HPA. Cómo detectarlo: si, al revisar los eventos del namespace durante el período OutOfSync, esperabas ver docenas de eventos ScalingReplicaSet alternando 2 y 5. Cómo corregirlo: el mensaje Skipping auto-sync: already attempted sync ... retrying in Nm del propio controlador confirma que ArgoCD intenta la corrección una vez, y después espera un período de enfriamiento antes de reintentar contra la misma revisión — un mecanismo de protección exactamente contra el escenario de este proyecto, documentado en el propio comportamiento del controlador, no en la documentación de más alto nivel.
Ejercicios
Ejercicio 1 — Reconstruye la secuencia completa de memoria. Sin volver a esta lección, enumera, en orden, los cinco eventos que ocurrieron entre el git push del Paso 3 y el estado final Synced/Healthy con ignoreDifferences activo.
Ver solución
(1) git push sube replicas: 5 a Gitea. (2) ArgoCD sondea el repositorio (~100 segundos después) y detecta el cambio. (3) ArgoCD aplica replicas: 5 al Deployment, sin ningún kubectl apply manual — convergencia confirmada. (4) Minutos después, el HorizontalPodAutoscaler, con CPU real baja, reduce replicas a 2 directamente en el clúster, sin pasar por Git — ArgoCD lo detecta como OutOfSync, pero no pelea indefinidamente (enfriamiento del controlador). (5) Se agrega ignoreDifferences + RespectIgnoreDifferences=true al Application, vía otro git push — el Sync Status vuelve a Synced de forma estable, con el HPA libre de mover replicas sin que ArgoCD lo marque nunca más.
Ejercicio 2 — Explica ignoreDifferences sin usar la palabra "ignorar". En una frase, sin usar la palabra "ignorar" ni "ignora", explica qué hace ignoreDifferences: [{group: apps, kind: Deployment, jsonPointers: [/spec/replicas]}].
Ver solución
Una explicación razonable: "Le dice a ArgoCD que el campo replicas de este Deployment en particular pertenece a otro controlador (el HorizontalPodAutoscaler), así que no debe contarlo como una diferencia entre Git y el clúster real, ni sobrescribirlo durante una sincronización."
Ejercicio 3 — Diseña la prueba de que la corrección funciona, sin copiar el Paso 7. Sin mirar el Paso 7 de nuevo, describe un experimento —con comandos concretos— que demuestre que ignoreDifferences está funcionando correctamente para este Application.
Ver solución
Una respuesta razonable: forzar al HPA a cambiar replicas de forma visible (por ejemplo, generando carga real con kubectl run load-generator, como en el Módulo 3, para que suba por encima de 5, o esperando a que la CPU baje y el HPA lo reduzca de forma natural, como hizo este proyecto) y, mientras eso ocurre, correr kubectl get application andes-cargo-status-api -n argocd -o jsonpath='{.status.sync.status}' en un bucle durante varios minutos. Si el resultado se mantiene en Synced durante todo el experimento, sin importar qué valor tenga replicas en cada momento, la corrección funciona — exactamente la prueba de dos minutos que hizo el Paso 7 de esta lección.
Resumen y siguiente paso
Este proyecto demostró, con evidencia literal y timestamps reales, la promesa central de todo el módulo: un cambio de negocio real (replicas: 3 → 5), subido con git push, reflejado en andes-cargo-cluster en aproximadamente cien segundos, sin un solo kubectl apply. También documentó, completo, un hallazgo real no planeado —la tensión entre selfHeal de ArgoCD y el HorizontalPodAutoscaler del Módulo 3 por el mismo campo replicas— y su corrección de producción real (ignoreDifferences + RespectIgnoreDifferences=true), verificada con una prueba de estabilidad de dos minutos. andes-cargo-status-api es, desde hoy, un servicio cuyo qué corre vive en Git (imagen, configuración, probes, NetworkPolicy) y cuyo cuánto corre vive en tiempo real, en el HorizontalPodAutoscaler — dos fuentes de verdad, cada una dueña de su propio campo, coexistiendo sin pelear.
Antes de avanzar deberías poder: reproducir, en tu propio clúster, el git push sin kubectl apply de este proyecto; explicar por qué selfHeal no peleó infinitamente contra el HPA; y escribir de memoria el bloque ignoreDifferences que resuelve esa tensión.
Siguiente módulo: seguridad de runtime, admission control y escaneo de imagen. El Módulo 6 toma el mismo repositorio andes-cargo-k8s que ArgoCD sincroniza y agrega la capa que decide, antes de que cualquier objeto llegue a etcd, si tiene permitido existir — OPA Gatekeeper y Kyverno, dos motores de políticas distintos, ambos corridos de verdad contra andes-cargo-cluster.
Recursos
- Argo CD — Diffing, Application-Level Configuration — documentación oficial de
ignoreDifferences, fuente exacta de la corrección de este proyecto. - Argo CD — Sync Options, Respect Ignore Difference on Sync — documentación de
RespectIgnoreDifferences=true. - Kubernetes — Horizontal Pod Autoscaling — referencia del controlador que compite por
spec.replicasen este proyecto. kubernetes-and-eks-in-production-guide(NIEVA), Módulo 3, lección 7-8 — el origen delHorizontalPodAutoscalerque este proyecto retoma con un hallazgo nuevo.