Gestionar deployments en Kubernetes con kubectl apply funciona hasta que dejas de saber qué está corriendo realmente en el cluster. El historial de cambios vive en la cabeza de quien ejecutó el comando, los rollbacks son manuales, y el estado declarado en los manifiestos empieza a divergir del estado real. GitOps resuelve ese problema invirtiendo el modelo: Git es la fuente de verdad, y el cluster se reconcilia continuamente hacia ella.
Esta es la arquitectura que usamos en producción sobre GKE Autopilot, con ArgoCD como motor de sincronización.
La infraestructura base: dos clusters, una fleet
El setup corre sobre dos clusters GKE Autopilot conectados mediante GKE Fleet — el plano de control unificado de GCP para gestionar múltiples clusters como una sola unidad. ArgoCD está instalado únicamente en el cluster principal y desde ahí gestiona ambos. El cluster secundario no tiene ArgoCD propio: recibe sus deployments desde el principal a través de la Fleet.
Elegir Autopilot sobre Standard elimina la gestión de node pools y simplifica la operación — no hay que dimensionar nodos ni gestionar escalado de infraestructura manualmente. El trade-off es menos control sobre la configuración de los nodos, pero para este tipo de carga es un intercambio que vale la pena.
ArgoCD se gestiona a sí mismo
ArgoCD está instalado a partir de los manifiestos oficiales del repositorio de GitHub del proyecto, no vía Helm. Los manifiestos viven en GitLab, en un repositorio de platform apps, y ArgoCD los usa para desplegarse a sí mismo en el cluster principal.
Esto no es un detalle menor: significa que cualquier actualización de ArgoCD sigue el mismo flujo GitOps que el resto de las aplicaciones. Nadie ejecuta kubectl apply manualmente para actualizar el controlador. Se hace un commit al repo, ArgoCD detecta el cambio y se actualiza a sí mismo. El historial de versiones del propio controlador está en Git, auditado y reversible como cualquier otro componente.
El manifiesto que hace posible este loop es una Application que apunta al propio repositorio de platform apps:
# platform-apps/argocd/application.yaml
# ArgoCD Application that manages ArgoCD itself
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argocd
namespace: argocd
spec:
project: default
source:
repoURL: git@gitlab.com:platform/platform-apps.git
targetRevision: main
path: argocd/manifests
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
syncOptions:
- ServerSideApply=true Platform apps: un repo, todos los operadores
Además de ArgoCD, el repo de platform apps contiene los manifiestos de los operadores del cluster:
- External Secrets Operator (ESO) — inyección de secretos desde GCP Secret Manager.
- KEDA — escalado basado en eventos.
- External DNS — gestión automática de registros DNS.
- Cert Manager — emisión y renovación de certificados TLS.
Cada uno tiene su propio ApplicationSet configurado para desplegarse en todos los clusters de la Fleet. La lógica es simple: estos operadores son infraestructura transversal. No tiene sentido gestionarlos app por app — se declaran una vez y se propagan a todos los entornos.
La convención -k8s: onboarding sin configuración manual
Aquí está la pieza que más impacto tiene en el día a día. Cada aplicación tiene dos repositorios en GitLab: el repositorio del código fuente y un repositorio de manifiestos de Kubernetes. Este último sigue siempre la misma convención de nombre: el nombre de la aplicación con el sufijo -k8s.
El ApplicationSet usa ese sufijo como selector. Cualquier repositorio que termine en -k8s es automáticamente detectado y desplegado por ArgoCD:
# platform-apps/applicationsets/apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: apps
namespace: argocd
spec:
generators:
- scmProvider:
gitlab:
group: applications
allBranches: false
# The convention: any repo ending in -k8s is auto-discovered
filters:
- repositoryMatch: ".*-k8s$"
template:
metadata:
name: '{{repository}}'
spec:
project: default
source:
repoURL: '{{url}}'
targetRevision: HEAD
path: '.'
destination:
server: https://kubernetes.default.svc
# Namespace inferred from the repo name (without the -k8s suffix)
namespace: '{{ index .repository | replace "-k8s" "" }}'
syncPolicy:
automated:
prune: true El resultado práctico: para incorporar una nueva aplicación al cluster, basta con crear el repositorio de manifiestos con la convención correcta. ArgoCD se encarga del resto sin ninguna configuración adicional.
Esto elimina el paso que en muchos setups requiere que alguien con acceso a ArgoCD registre manualmente la nueva aplicación. El onboarding es declarativo y está gobernado por la convención del nombre del repositorio.
Secretos: Secret Manager, IAM conditions y Workload Identity
Los secretos de todas las aplicaciones viven en un proyecto GCP dedicado exclusivamente a ese propósito, separado de los proyectos donde corren los clusters. ESO los inyecta en el cluster en tiempo de ejecución usando Workload Identity — sin credenciales estáticas, sin JSON keys.
El control de acceso está resuelto con IAM conditions sobre el naming convention de los secretos. Por ejemplo, todos los secretos que comienzan con external-dns-* pueden ser accedidos únicamente por la service account de External DNS:
# Manifest in the app's -k8s repo
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: external-dns
namespace: external-dns
spec:
refreshInterval: 1h
secretStoreRef:
name: gcp-secret-store
kind: ClusterSecretStore
target:
name: external-dns-credentials
data:
- secretKey: credentials.json
remoteRef:
# Naming convention: external-dns-* prefix is the access boundary
key: external-dns-gcp-sa-key La condición IAM se define a nivel de proyecto y restringe el acceso por prefijo del nombre del secreto:
# IAM binding scoped by name prefix
# Only secrets matching external-dns-* are accessible to this SA
gcloud projects add-iam-policy-binding secrets-project \
--member="serviceAccount:[email protected]" \
--role="roles/secretmanager.secretAccessor" \
--condition='expression=resource.name.startsWith("projects/_/secrets/external-dns-"),title=external-dns-only,description=Restrict access to external-dns secrets' Cada aplicación tiene acceso solo al subconjunto de secretos que le corresponde, filtrado por prefijo. Este esquema evita el problema habitual de dar acceso demasiado amplio a Secret Manager. No se necesita gestionar permisos por secreto individual — la convención de nombres hace el trabajo.
Sync: auto-sync sin self-heal
ArgoCD está configurado con auto-sync activado, pero con self-heal desactivado deliberadamente.
Self-heal revierte automáticamente cualquier cambio manual en el cluster que se desvíe de lo declarado en Git. En teoría es deseable. En la práctica, cuando alguien necesita aplicar un cambio temporal para depurar un problema en producción — modificar una variable de entorno, ajustar un réplica count, inyectar un flag — self-heal lo deshace inmediatamente y genera conflictos en el peor momento posible.
syncPolicy:
automated:
prune: true
# selfHeal is intentionally off
# Manual changes are allowed for debugging in production
# selfHeal: false
syncOptions:
- CreateNamespace=true
- PrunePropagationPolicy=foreground La decisión de desactivarlo es un trade-off consciente: se acepta que puede existir drift temporal en el cluster a cambio de poder intervenir manualmente cuando la situación lo requiere. El drift se resuelve con el siguiente commit al repositorio de manifiestos.
El flujo completo: de un commit a producción
El ciclo de deployment funciona así:
- El desarrollador hace push al repositorio del código fuente.
- GitLab CI compila la imagen y la sube a Artifact Registry.
- El pipeline descarga el repositorio de manifiestos (
-k8s), actualiza el tag de imagen usando Kustomize, y hace commit del cambio. - ArgoCD detecta el nuevo commit en el repositorio de manifiestos y sincroniza el cluster hacia el nuevo estado declarado.
El job de GitLab CI que materializa los pasos 2 y 3 es relativamente sencillo:
# .gitlab-ci.yml in the application source repo
deploy:
stage: deploy
image: gcr.io/google.com/cloudsdktool/cloud-sdk:slim
before_script:
- apt-get update && apt-get install -y kustomize git
script:
# Build image tag from the commit SHA
- IMAGE_TAG="${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
# Clone the matching manifests repo (convention: <app>-k8s)
- git clone "https://oauth2:${MANIFESTS_TOKEN}@gitlab.com/applications/${CI_PROJECT_NAME}-k8s.git"
- cd "${CI_PROJECT_NAME}-k8s"
# Update only the image tag — Kustomize handles the rest
- kustomize edit set image "app=${IMAGE_TAG}"
# Commit and push. ArgoCD picks it up automatically.
- git config user.email "[email protected]"
- git config user.name "GitLab CI"
- git commit -am "ci: bump image to ${CI_COMMIT_SHORT_SHA}"
- git push origin main
only:
- main No hay push directo al cluster desde el pipeline. El pipeline solo actualiza Git. ArgoCD hace el pull y aplica los cambios. Esta separación es la esencia del modelo GitOps: el cluster nunca recibe instrucciones directas del sistema de CI, solo reconcilia contra su fuente de verdad.
Lo que sigue
El rollback y la estrategia de disaster recovery son los pendientes activos de esta arquitectura — áreas en las que todavía se está trabajando. Son lo suficientemente complejos como para merecer su propia entrada, con las decisiones y trade-offs que implican.
No comments yet