Los despliegues modernos en producción incluyen tres capas distintas: aprovisionamiento de infraestructura, empaquetado de aplicaciones y entrega continua. Este artículo reúne las tres con Terraform, Helm y ArgoCD, desde los fundamentos hasta consideraciones arquitectónicas, y muestra cómo se integran en un pipeline de GitOps coherente tanto para una API REST de Node.js como para un agente Agno en Python (un framework de agentes de IA basado en FastAPI).
Cada sección avanza por cinco niveles de experiencia para que puedas leer con la profundidad que prefieras.
El modelo GitOps: ¿por qué tres herramientas?
Antes de entrar en detalle, veamos qué problema resuelve cada herramienta:
| Herramienta | Problema que resuelve | Dónde se ejecuta |
|---|---|---|
| Terraform | ”¿Qué infraestructura existe?” | Runner de CI / máquina del desarrollador |
| Helm | ”¿Cómo se configura esta aplicación para cada entorno?” | Repositorio de Git |
| ArgoCD | ”¿El clúster coincide con lo que indica Git?” | Dentro del clúster |
GitOps es el modelo que las une. Git es la única fuente de verdad. Cada cambio —una nueva etiqueta de imagen, un ajuste de escalado o un cambio de configuración— entra al sistema como un commit. Un reconciliador automatizado compara continuamente lo que Git indica que debería existir con lo que realmente se ejecuta en el clúster y corrige cualquier desviación.
Los dos modelos de despliegue en la práctica:
- Basado en push: el pipeline de CI ejecuta
kubectl applydespués del merge. CI necesita credenciales del clúster. - Basado en pull: un agente dentro del clúster consulta Git y aplica los cambios. Ningún sistema externo necesita acceso al clúster.
ArgoCD utiliza el modelo pull. El agente dentro del clúster hace el trabajo, lo que significa que tu pipeline de CI nunca necesita un kubeconfig.
Flujo de datos: el panorama completo
┌─────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER (Terraform) │
│ │
│ Developer ──git push──► CI Runner │
│ │ │
│ terraform plan │
│ terraform apply │
│ │ │
│ ┌────────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ VPC + EKS RDS (PG) IAM/IRSA │
│ │ │
│ outputs ─────────┼──────────────► │
│ (cluster_endpoint, role_arns, rds_endpoint) │
└───────────────────────────────┼─────────────────────────── ┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ APPLICATION LAYER (Helm) │
│ │
│ k8s-manifests repo (Git) │
│ ├── charts/ │
│ │ ├── node-api/ ← Helm chart │
│ │ │ ├── Chart.yaml │
│ │ │ ├── values.yaml ← defaults │
│ │ │ └── templates/ ← K8s manifest templates │
│ │ └── agno-agent/ ← Helm chart │
│ ├── environments/ │
│ │ ├── staging/values.yaml ← env overrides │
│ │ └── production/values.yaml │
│ └── apps/ │
│ ├── root.yaml ← ArgoCD bootstrap │
│ ├── node-api.yaml ← ArgoCD Application CRD │
│ └── agno-agent.yaml ← ArgoCD Application CRD │
└───────────────────────────────┬─────────────────────────── ┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ DELIVERY LAYER (ArgoCD, inside EKS) │
│ │
│ ArgoCD polls Git every 3m (or via webhook) │
│ │ │
│ ├─ helm template → renders manifests │
│ ├─ diff rendered vs live cluster state │
│ │ │
│ ├─ OutOfSync detected │
│ │ │ │
│ │ ├── PreSync hooks (db-migrate Job) │
│ │ ├── Apply Deployment (rolling update) │
│ │ └── PostSync hooks (smoke test) │
│ │ │
│ └─ Status: Synced / Healthy │
└─────────────────────────────────────────────────────────────┘
Flujo del pipeline de CI: del push del desarrollador al pod en ejecución
Developer
│
│ git push origin main (Node.js API source repo)
▼
GitHub Actions
├─ 1. Run tests (npm test)
├─ 2. Authenticate to AWS via OIDC (no long-lived secrets)
├─ 3. docker build → push to ECR
│ tag: acme/node-api:abc1234 (git SHA)
└─ 4. Checkout k8s-manifests repo
yq -i ".image.tag = \"abc1234\"" environments/production/values.yaml
git commit + git push
k8s-manifests repo
│ (now has new image tag in production values)
▼
ArgoCD controller (in-cluster, polling every 3 min)
│ Detects: image.tag 2.1.0 → abc1234 → OutOfSync
│
├─ Renders Helm chart with new values
├─ Runs PreSync hook: db-migrate Job (waits for completion)
├─ kubectl apply Deployment (RollingUpdate: maxUnavailable=0)
│ K8s pulls acme/node-api:abc1234 from ECR
│ New pods → readinessProbe /health → 200 OK → Ready
│ Old pods → Terminating
└─ PostSync hooks: smoke test, Slack notification
Cluster
└─ 3 running pods of node-api:abc1234
behind Service → Ingress → ALB → Internet
1. Terraform: infraestructura como código
Básico — ¿Qué es?
Terraform te permite describir tu infraestructura en la nube mediante código en lugar de navegar por interfaces gráficas. Escribes archivos que dicen “quiero un clúster de Kubernetes con 3 nodos worker y una base de datos PostgreSQL” y Terraform lo hace realidad. Si lo ejecutas de nuevo, solo cambia lo que sea diferente: es idempotente.
Piensa en él como un plano: el plano no cambia cada vez que lo consultas, pero puedes versionarlo, revisarlo y volver a una versión anterior.
Tres elementos que administra Terraform:
- Providers: plugins que se comunican con las APIs de la nube (AWS, GCP, Azure)
- Recursos: objetos individuales de la nube (VPC, EC2, RDS, bucket de S3)
- Estado: un archivo JSON que registra lo que Terraform creó la última vez
Intermedio — Estructura y comandos principales
infra/
├── main.tf # Provider config and resource declarations
├── variables.tf # Input variable declarations with types and validation
├── outputs.tf # Values that other configs can consume
├── terraform.tfvars # Actual variable values (gitignore secrets!)
└── modules/
├── vpc/ # Reusable VPC component
├── eks/ # Reusable EKS component
└── rds/ # Reusable RDS component
Comandos esenciales:
terraform init # Download providers and modules
terraform validate # Check HCL syntax (no API calls)
terraform plan # Preview changes — always review before applying
terraform apply # Execute the plan
terraform output # Print output values
terraform workspace new staging # Create named state slice
terraform workspace select prod # Switch environment context
terraform destroy # Tear down (never run in prod without deliberation)
Avanzado — Estado remoto, módulos e IRSA
Nunca uses estado local en producción. Guarda el estado en S3 con bloqueo mediante DynamoDB para que varios ingenieros puedan trabajar de forma segura:
# main.tf
terraform {
required_version = ">= 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
backend "s3" {
bucket = "acme-terraform-state"
key = "production/eks/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
provider "aws" {
region = var.region
default_tags {
tags = {
ManagedBy = "terraform"
Environment = var.environment
Project = "acme-api"
}
}
}
Las variables con validación evitan que los errores de escritura lleguen a terraform apply:
# variables.tf
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "db_password" {
description = "Master password for RDS"
type = string
sensitive = true # never printed in plan output
}
VPC + EKS con módulos de la comunidad (se encargan de más de 200 líneas de código repetitivo):
locals {
cluster_name = "acme-${var.environment}"
}
data "aws_availability_zones" "available" { state = "available" }
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.5"
name = "${local.cluster_name}-vpc"
cidr = "10.0.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # one NAT per AZ for HA
enable_dns_hostnames = true
# Required tags for EKS subnet auto-discovery
private_subnet_tags = {
"kubernetes.io/cluster/${local.cluster_name}" = "shared"
"kubernetes.io/role/internal-elb" = "1"
}
public_subnet_tags = {
"kubernetes.io/cluster/${local.cluster_name}" = "shared"
"kubernetes.io/role/elb" = "1"
}
}
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 20.8"
cluster_name = local.cluster_name
cluster_version = "1.30"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
enable_irsa = true # pods assume IAM roles without node-level credentials
eks_managed_node_groups = {
general = {
instance_types = var.node_instance_types
min_size = local.node_min
max_size = local.node_max
desired_size = 3
disk_size = 50
}
}
}
IRSA (IAM Roles for Service Accounts): los pods asumen roles de IAM directamente, sin credenciales en variables de entorno ni Secrets:
module "node_api_irsa" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "~> 5.39"
role_name = "${local.cluster_name}-node-api"
oidc_providers = {
main = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["apps:node-api"] # namespace:serviceaccount
}
}
role_policy_arns = {
s3 = aws_iam_policy.node_api_s3.arn
}
}
Experto — Estrategia de workspaces, detección de desviaciones y operaciones de estado
Los workspaces crean secciones de estado independientes dentro de una misma configuración:
locals {
is_prod = terraform.workspace == "prod"
node_min = local.is_prod ? 3 : 1
node_max = local.is_prod ? 20 : 5
db_class = local.is_prod ? "db.r6g.large" : "db.t3.micro"
}
terraform workspace new staging
terraform workspace select staging
terraform apply -var-file=environments/staging.tfvars
Precaución para expertos: los workspaces comparten las mismas credenciales del provider. Si producción necesita una cuenta de AWS separada (recomendado para limitar el alcance de los fallos), usa configuraciones de backend y archivos de estado independientes, no solo workspaces.
Importar recursos existentes al estado (cuando tienes infraestructura creada manualmente):
# Import an existing RDS instance into state without recreating it
terraform import aws_db_instance.main acme-prod
Operaciones delicadas sobre el estado cuando algo sale mal:
terraform state list # list all managed resources
terraform state show aws_db_instance.main # inspect a single resource
terraform state mv aws_s3_bucket.old aws_s3_bucket.new # rename without destroy
terraform state rm aws_db_instance.main # stop tracking without deleting
Legendario — La decisión filosófica de diseño
La mayor apuesta arquitectónica de Terraform es la reconciliación convergente por encima de los scripts imperativos. En lugar de escribir scripts de shell que digan “crea X, luego crea Y y después configura Z”, declaras el estado objetivo y dejas que la herramienta calcule la ruta más corta para alcanzarlo.
Esto crea un registro de auditoría inmutable: terraform plan muestra las diferencias entre el estado deseado y el real. Cada terraform apply es una transición de estado registrada, no un procedimiento con efectos secundarios. Combina esto con estado remoto y tendrás un sistema de coordinación distribuido: varios ingenieros pueden proponer planes simultáneamente, pero solo uno puede aplicarlos a la vez (bloqueo de DynamoDB).
La implicación para los equipos en crecimiento: a medida que crece tu infraestructura, la inversión adecuada no es más HCL, sino una mejor composición de módulos. Cada módulo debe poder probarse de forma independiente (con terratest), versionarse por separado y publicarse en un registro privado. La configuración raíz compone los módulos de la misma manera que el código de una aplicación compone bibliotecas: con versiones fijadas, no copiando y pegando.
El límite de escalabilidad conocido: la fase de refresh de Terraform lee cada recurso administrado desde las APIs de la nube en cada plan. Con más de 1,000 recursos en un solo archivo de estado, esto se convierte en un cuello de botella (planes de 3–5 minutos). La solución es dividir los archivos de estado por dominio (redes, cómputo, bases de datos, IAM) y usar fuentes de datos entre estados.
2. Helm: empaquetado de la aplicación
Básico — ¿Qué es?
Helm es un gestor de paquetes para Kubernetes, como npm para Node.js o pip para Python. En lugar de mantener archivos YAML sin procesar para cada entorno, escribes plantillas con variables. Helm renderiza las plantillas con los valores adecuados para cada entorno y aplica todo al clúster.
Un chart de Helm es una carpeta con:
Chart.yaml— metadatos (nombre, versión)values.yaml— configuración predeterminadatemplates/— manifiestos de Kubernetes con marcadores como{{ .Values.something }}
Una release de Helm es una instancia desplegada de un chart. Puedes tener node-api-staging y node-api-prod a partir del mismo chart con distintos valores.
Intermedio — Estructura del chart y plantillas principales
node-api/
├── Chart.yaml
├── values.yaml
├── .helmignore
└── templates/
├── _helpers.tpl # Named templates (reusable partials)
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── hpa.yaml # Horizontal Pod Autoscaler
├── serviceaccount.yaml
├── configmap.yaml
└── hooks/
└── db-migrate.yaml # Pre-upgrade migration Job
# Chart.yaml
apiVersion: v2
name: node-api
description: ACME Node.js REST API
type: application
version: 0.5.0 # chart version — bump when chart structure changes
appVersion: "2.1.0" # application version — CI sets this from image tag
# values.yaml — defaults for all environments
replicaCount: 3
image:
repository: 123456789.dkr.ecr.us-east-1.amazonaws.com/acme/node-api
tag: "2.1.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 3000
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
cpu: "1000m"
memory: "512Mi"
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 70
serviceAccount:
create: true
annotations:
eks.amazonaws.com/role-arn: "" # set per environment via IRSA
env:
NODE_ENV: production
PORT: "3000"
LOG_LEVEL: info
envSecret:
DB_URL: "" # injected from a Kubernetes Secret
probes:
readiness:
path: /health
initialDelaySeconds: 10
periodSeconds: 5
liveness:
path: /health
initialDelaySeconds: 30
periodSeconds: 10
Comandos principales de instalación y actualización:
# Install (first time)
helm install node-api ./charts/node-api \
--namespace apps \
--create-namespace \
--values environments/production/values.yaml \
--set image.tag=2.1.0
# Upgrade (idempotent in CI — use --install flag)
helm upgrade --install node-api ./charts/node-api \
--namespace apps \
--values environments/production/values.yaml \
--set image.tag=2.2.0 \
--wait \
--timeout 5m
# Rollback to the previous revision
helm rollback node-api 0 # 0 = previous revision
# View full release history
helm history node-api -n apps
Avanzado — Plantillas, helpers y hooks
_helpers.tpl define plantillas con nombre que se reutilizan en todos los manifiestos:
{{/*
Fully qualified name (Release.Name-chart.Name, max 63 chars).
*/}}
{{- define "node-api.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{/* Common labels for all resources */}}
{{- define "node-api.labels" -}}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version }}
app.kubernetes.io/name: {{ include "node-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Values.image.tag | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/* Selector labels — used by Service and Deployment */}}
{{- define "node-api.selectorLabels" -}}
app.kubernetes.io/name: {{ include "node-api.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
La plantilla de Deployment usa include + nindent para lograr la indentación correcta, y la anotación checksum/config activa un rollout cada vez que cambia un ConfigMap:
# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "node-api.fullname" . }}
labels:
{{- include "node-api.labels" . | nindent 4 }}
annotations:
# Force a rollout when ConfigMap content changes
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
selector:
matchLabels:
{{- include "node-api.selectorLabels" . | nindent 6 }}
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # zero-downtime: full capacity before removing old pods
template:
metadata:
labels:
{{- include "node-api.selectorLabels" . | nindent 8 }}
spec:
serviceAccountName: {{ include "node-api.fullname" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
env:
{{- range $key, $val := .Values.env }}
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- range $key, $val := .Values.envSecret }}
- name: {{ $key }}
valueFrom:
secretKeyRef:
name: {{ include "node-api.fullname" $ }}-secret
key: {{ $key }}
{{- end }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.readiness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
failureThreshold: 3
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
initialDelaySeconds: {{ .Values.probes.liveness.initialDelaySeconds }}
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
failureThreshold: 3
resources:
{{- toYaml .Values.resources | nindent 12 }}
Hook de migración de base de datos previo a la actualización: ejecuta un Job antes de que inicien los nuevos pods y bloquea el rollout hasta que las migraciones se completen correctamente:
# templates/hooks/db-migrate.yaml
{{- if .Values.migration.enabled }}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "node-api.fullname" . }}-migrate-{{ .Release.Revision }}
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": hook-succeeded
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
command: ["node", "dist/db/migrate.js"]
env:
- name: DB_URL
valueFrom:
secretKeyRef:
name: {{ include "node-api.fullname" . }}-secret
key: DB_URL
{{- end }}
Experto — Flujo de renderizado de plantillas de Helm
helm upgrade node-api ./charts/node-api --values prod.yaml
│
▼
Load Chart.yaml metadata
│
▼
Merge values (precedence: --set > --values files > values.yaml)
│
▼
Render each template in templates/ via Go text/template
├─ Evaluate {{ define }} blocks in _helpers.tpl
├─ Resolve {{ include "..." . }} calls
├─ Apply | pipeline functions (nindent, quote, sha256sum, toYaml)
└─ Conditional blocks: {{- if .Values.autoscaling.enabled }}
│
▼
Validate rendered YAML against Kubernetes OpenAPI schema
│
▼
Execute hooks (pre-upgrade Jobs) — wait for completion
│
▼
kubectl apply (server-side apply) the rendered manifests
│
▼
Wait for Deployment rollout if --wait flag set
Precauciones para expertos:
- Nunca generes
rand.AlphaNumnirandBytesen las plantillas: se vuelven a evaluar en cada actualización, lo que provoca actualizaciones innecesarias de Secrets en cada despliegue. - Nunca uses
latestcomo etiqueta de imagen: impide la reproducibilidad y el rollback. helm lint+helm templateen CI detectan errores de renderizado antes de que lleguen al clúster.
Legendario — Helm como contrato de interfaz de configuración
La idea más importante sobre Helm es que values.yaml no es configuración: es una API pública. Cada clave en values.yaml es un contrato entre quien crea el chart y quien lo consume. Los cambios incompatibles (eliminar una clave o cambiar su tipo) requieren incrementar la versión major de semver en Chart.version.
Esto se vuelve fundamental a escala: si tu equipo de plataforma mantiene un chart utilizado por 20 equipos de aplicaciones, el pipeline de CI de cada equipo establece --set image.tag=... y --values team-overrides.yaml. Cualquier cambio incompatible en el esquema de valores del chart debe versionarse, comunicarse y migrarse, igual que los cambios incompatibles en un paquete de npm.
El patrón avanzado son los library charts: un chart de type: library que solo contiene definiciones de _helpers.tpl y se publica por separado en un registro de Helm. Los charts de aplicaciones dependen de él (dependencies en Chart.yaml), lo que permite compartir la lógica de las plantillas entre todos tus servicios. Es el equivalente en Helm a un sistema de diseño: una fuente de verdad para etiquetas, probes, valores predeterminados de recursos y contextos de seguridad.
3. ArgoCD: entrega continua con GitOps
Básico — ¿Qué es?
ArgoCD monitorea un repositorio de Git y mantiene tu clúster de Kubernetes sincronizado con él. Le indicas a ArgoCD: “esta carpeta en este repositorio de Git es el estado deseado del namespace X en el clúster Y”. ArgoCD revisa cada pocos minutos. Si algo se desvía —alguien edita manualmente un Deployment o se hace push de una nueva configuración—, ArgoCD lo corrige.
El concepto clave: nunca ejecutas kubectl apply en producción. Git es la única forma de cambiar producción. ArgoCD se encarga de hacer cumplir esa regla.
Intermedio — El CRD Application
La entidad principal de ArgoCD es el recurso personalizado Application. Es el vínculo entre Git y el clúster:
# apps/node-api.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: node-api
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io # cascade-delete on removal
spec:
project: acme
source:
repoURL: https://github.com/acme-org/k8s-manifests.git
targetRevision: main
path: charts/node-api
helm:
releaseName: node-api
valueFiles:
- values.yaml
- environments/production/values.yaml
parameters:
- name: image.tag
value: "2.1.0" # CI updates this on every build
destination:
server: https://kubernetes.default.svc
namespace: apps
syncPolicy:
automated:
prune: true # delete resources removed from Git
selfHeal: true # revert manual cluster edits
allowEmpty: false # never sync to empty state
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
revisionHistoryLimit: 10
Instalación de ArgoCD:
kubectl create namespace argocd
kubectl apply -n argocd --server-side \
-f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
# Get initial admin password
argocd admin initial-password -n argocd
# Access the UI locally
kubectl port-forward svc/argocd-server -n argocd 8080:443
argocd login localhost:8080
Avanzado — App of Apps, proyectos y Sync Waves
AppProject delimita a qué repositorios, clústeres y namespaces pueden acceder las aplicaciones de un equipo:
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: acme
namespace: argocd
spec:
description: ACME production applications
sourceRepos:
- https://github.com/acme-org/k8s-manifests.git
destinations:
- server: https://kubernetes.default.svc
namespace: apps
- server: https://kubernetes.default.svc
namespace: agents
clusterResourceWhitelist:
- group: ""
kind: Namespace
roles:
- name: developer
policies:
- p, proj:acme:developer, applications, get, acme/*, allow
- p, proj:acme:developer, applications, sync, acme/*, allow
groups:
- acme-developers # SSO group mapping
App of Apps inicializa un entorno completo desde Git. Una Application raíz administra todos los demás manifiestos de Application:
k8s-manifests/
└── apps/
├── root.yaml ← applied once manually to bootstrap
├── node-api.yaml
├── agno-agent.yaml
├── monitoring.yaml
└── ingress-nginx.yaml
# apps/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-apps
namespace: argocd
spec:
project: acme
source:
repoURL: https://github.com/acme-org/k8s-manifests.git
targetRevision: main
path: apps # directory of Application manifests
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
Después de ejecutar kubectl apply -f apps/root.yaml una vez, ArgoCD se administra a sí mismo y a cada Application hija desde Git. Agregar un servicio nuevo = agregar un archivo YAML y hacer commit.
Las Sync Waves establecen el orden dentro de una misma sincronización:
# Wave -1: deploy databases first
metadata:
annotations:
argocd.argoproj.io/sync-wave: "-1"
# Wave 0 + PreSync hook: run migrations after DB is ready
metadata:
annotations:
argocd.argoproj.io/hook: PreSync
argocd.argoproj.io/sync-wave: "0"
argocd.argoproj.io/hook-delete-policy: HookSucceeded
# Wave 1: deploy the API after migrations complete
metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
Verificación de salud personalizada en Lua (para CRDs sin verificación de salud integrada):
# argocd-cm ConfigMap
data:
resource.customizations.health.batch_CronJob: |
hs = {}
if obj.status ~= nil then
if obj.status.lastScheduleTime ~= nil then
hs.status = "Healthy"
hs.message = "Last scheduled: " .. obj.status.lastScheduleTime
return hs
end
end
hs.status = "Progressing"
hs.message = "Waiting for first schedule"
return hs
Experto — Funcionamiento interno del ciclo de reconciliación
ArgoCD Application Controller (reconcile loop, every 3 minutes or webhook-triggered)
│
├─ 1. DESIRED STATE: git clone / sparse-checkout the target path
│ run: helm template <releaseName> <path> --values ...
│ output: list of Kubernetes manifests (JSON)
│
├─ 2. LIVE STATE: kubectl get all resources in destination namespace
│ output: current cluster state (JSON)
│
├─ 3. DIFF: three-way merge
│ base: last-applied annotation on live objects
│ desired: rendered manifests from step 1
│ live: current cluster state from step 2
│ result: list of changes (add / modify / delete)
│
├─ 4. if diff is empty → Application is Synced, skip
│
└─ 5. if diff exists → Application is OutOfSync
├─ if syncPolicy.automated → trigger sync
└─ if manual → wait for user action
│
▼
Execute sync:
├─ PreSync hooks (Jobs) → wait for completion
├─ Apply wave -1 resources → wait for health
├─ Apply wave 0 resources → wait for health
├─ Apply wave 1 resources → wait for health
└─ PostSync hooks
Precaución para expertos: prune: true eliminará los recursos de Kubernetes que ya no estén en Git, incluidos los ConfigMaps agregados manualmente, los PVCs y los Secrets administrados fuera de ArgoCD. Prueba el comportamiento de pruning en staging con --dry-run antes de habilitarlo en producción.
Legendario — El cambio filosófico: eventos operativos como commits
La implicación más profunda de ArgoCD es organizacional, no técnica. Cuando cada cambio en producción es un commit de Git, los incidentes de guardia cambian de naturaleza. “¿Qué cambió a las 2am?” se responde con un comando git log, no con una investigación de Ops. “Revierte el despliegue defectuoso” significa git revert + esperar 3 minutos, no ejecutar un comando de rollback de Helm con un estado desactualizado.
El desafío de escalabilidad: GitOps genera presión para separar el repositorio de la aplicación del repositorio de manifiestos. Si guardas los valores de Helm en el mismo repositorio que el código fuente de Node.js, cada git push activa tanto una compilación del código como una evaluación de sincronización de ArgoCD. A escala, conviene tener un repositorio de aplicación para los cambios de código fuente (activa CI y produce imágenes) y un repositorio de manifiestos para los cambios de despliegue (ArgoCD lo monitorea). La única tarea de CI es actualizar la etiqueta de imagen en el repositorio de manifiestos. Esta arquitectura se llama push-to-deploy mediante un actualizador de imágenes y puede automatizarse parcialmente con el proyecto Argo CD Image Updater.
4. Despliegue de un agente Agno
Básico — ¿Qué es Agno?
Agno es un framework de Python para crear agentes de IA. Defines un agente (qué modelo usa, qué herramientas tiene y cómo almacena la memoria), y Agno lo encapsula en una aplicación FastAPI: un servidor de API HTTP completo con streaming, gestión de sesiones y endpoints /health integrados.
Desde la perspectiva del despliegue, un agente Agno es simplemente una aplicación FastAPI. Se aplica el mismo flujo de trabajo de Helm + ArgoCD. Las diferencias son que necesita una clave de API para el LLM como Secret, usa más memoria (buffers de inferencia del LLM) y necesita una base de datos persistente para el estado de las sesiones, no un archivo local de SQLite.
Intermedio — Aplicación Agno y Dockerfile
# agent.py
from agno.agent import Agent
from agno.models.anthropic import Claude
from agno.storage.postgres import PostgresStorage
from agno.run.fastapi import AgentOS
from fastapi import FastAPI
agent = Agent(
model=Claude(id="claude-sonnet-4-5"),
description="ACME support agent",
instructions=["Always be concise", "Use bullet points"],
storage=PostgresStorage(
table_name="agent_sessions",
db_url=os.environ["DATABASE_URL"],
),
add_history_to_messages=True,
num_history_responses=5,
)
app: FastAPI = AgentOS(agents=[agent]).get_app()
# Dockerfile
FROM python:3.12-slim
WORKDIR /app
# System deps for PostgreSQL driver
RUN apt-get update && apt-get install -y \
libpq-dev gcc \
&& rm -rf /var/lib/apt/lists/*
# Layer caching: install deps before copying source
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY agent.py .
# Non-root user for security
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["fastapi", "run", "agent.py", "--host", "0.0.0.0", "--port", "8000"]
# requirements.txt
agno[os]>=1.0.0
anthropic>=0.30.0
fastapi>=0.111.0
uvicorn[standard]>=0.29.0
psycopg2-binary>=2.9.9
Avanzado — Valores de Helm y Application de ArgoCD para Agno
El chart de Agno es estructuralmente idéntico al chart de Node.js. Diferencias principales en values.yaml:
# charts/agno-agent/values.yaml
replicaCount: 2
image:
repository: 123456789.dkr.ecr.us-east-1.amazonaws.com/acme/agno-agent
tag: "1.0.0"
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 80
targetPort: 8000
resources:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2000m"
memory: "2Gi" # LLM inference buffers are large
envSecrets:
ANTHROPIC_API_KEY: "" # never in Git — injected from K8s Secret
DATABASE_URL: "" # postgres://user:pass@rds-endpoint:5432/acme
probes:
readiness:
path: /health
initialDelaySeconds: 15 # model clients need time to initialize
periodSeconds: 10
liveness:
path: /health
initialDelaySeconds: 60 # longer delay — LLM client startup is slow
periodSeconds: 30
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 60 # scale earlier; LLM calls are bursty
persistence:
enabled: false # use PostgreSQL, not local SQLite
Application de ArgoCD (mantén las cargas de trabajo de IA en un namespace dedicado):
# apps/agno-agent.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: agno-agent
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: acme
source:
repoURL: https://github.com/acme-org/k8s-manifests.git
targetRevision: main
path: charts/agno-agent
helm:
releaseName: agno-agent
valueFiles:
- values.yaml
- environments/production/agno-values.yaml
destination:
server: https://kubernetes.default.svc
namespace: agents # dedicated namespace for AI workloads
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 3
backoff:
duration: 10s
factor: 2
maxDuration: 2m
Valores sobrescritos para producción:
# environments/production/agno-values.yaml
replicaCount: 3
image:
tag: "1.2.0"
serviceAccount:
annotations:
eks.amazonaws.com/role-arn: "arn:aws:iam::123456789:role/acme-prod-agno-agent"
resources:
requests:
cpu: "1000m"
memory: "1Gi"
limits:
cpu: "4000m"
memory: "4Gi"
autoscaling:
maxReplicas: 20
env:
DATABASE_POOL_SIZE: "10"
Experto — Gestión de secretos para claves de LLM
La ANTHROPIC_API_KEY nunca debe aparecer en Git. El patrón para producción es usar External Secrets Operator (ESO) para obtenerla de AWS Secrets Manager:
# ExternalSecret — creates a K8s Secret from AWS Secrets Manager
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: agno-agent-secrets
namespace: agents
spec:
refreshInterval: 1h
secretStoreRef:
name: aws-secrets-manager
kind: ClusterSecretStore
target:
name: agno-agent-secret # creates this K8s Secret
creationPolicy: Owner
data:
- secretKey: ANTHROPIC_API_KEY
remoteRef:
key: acme/prod/agno-agent
property: anthropic_api_key
- secretKey: DATABASE_URL
remoteRef:
key: acme/prod/agno-agent
property: database_url
Este manifiesto de ExternalSecret vive en el repositorio de manifiestos y ArgoCD lo aplica. Los valores reales de los secretos solo viven en AWS Secrets Manager, nunca en Git.
Legendario — Implicaciones de la arquitectura de Agno a escala
El diseño sin estado de FastAPI en Agno (lógica del agente + capa HTTP, con estado en PostgreSQL) se adapta bien al escalado horizontal de Kubernetes. Pero las cargas de trabajo que atienden solicitudes a LLMs no cumplen las suposiciones del HPA: la utilización de CPU durante una llamada al LLM es baja (el trabajo ocurre en el proveedor de la API), pero la latencia es alta y la concurrencia está limitada por los límites de tasa, no por el cómputo.
Esto significa que un HPA basado en CPU escalará menos de lo necesario. El patrón avanzado es un HPA con métricas personalizadas que usa el número de solicitudes en curso o la profundidad de la cola:
# HPA based on in-flight requests (via KEDA or custom metrics adapter)
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
metrics:
- type: External
external:
metric:
name: agno_agent_in_flight_requests
selector:
matchLabels:
app: agno-agent
target:
type: AverageValue
averageValue: "5" # scale when each pod handles more than 5 concurrent requests
Para arquitecturas multiagente (Agno admite la orquestación de varios agentes especializados), cada tipo de agente debería tener su propio Deployment con objetivos de HPA independientes, ajustados a su patrón de carga de trabajo. Un research-agent que hace muchas llamadas secuenciales al LLM tiene un comportamiento de escalado diferente al de un summary-agent que hace una llamada por solicitud.
5. Pipeline de CI: integración de todos los componentes
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
paths: ["src/**", "package*.json", "Dockerfile"]
env:
AWS_REGION: us-east-1
ECR_REGISTRY: 123456789.dkr.ecr.us-east-1.amazonaws.com
IMAGE_NAME: acme/node-api
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC auth to AWS (no long-lived keys)
contents: read
outputs:
image_tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS via OIDC
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-ecr-push
aws-region: ${{ env.AWS_REGION }}
- name: Login to ECR
uses: aws-actions/amazon-ecr-login@v2
- name: Docker metadata (generates reproducible image tags)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.ECR_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: type=sha,prefix=,format=short
- name: Build and push (with layer caching)
uses: docker/build-push-action@v5
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
update-manifests:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Checkout k8s-manifests repo
uses: actions/checkout@v4
with:
repository: acme-org/k8s-manifests
token: ${{ secrets.MANIFESTS_REPO_TOKEN }}
- name: Update image tag with yq
run: |
yq -i ".image.tag = \"${{ needs.build-and-push.outputs.image_tag }}\"" \
environments/production/values.yaml
- name: Commit and push
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git add environments/production/values.yaml
git commit -m "chore(node-api): bump image to ${{ needs.build-and-push.outputs.image_tag }}"
git push
ArgoCD detecta el commit del manifiesto en un plazo de 3 minutos e inicia una sincronización, sin kubectl ni acceso directo al clúster desde CI.
6. Lista de verificación para producción
Terraform
- Usa estado remoto con bloqueo de DynamoDB; nunca uses estado local en producción
- Marca los secretos con
sensitive = truepara mantenerlos fuera de los logs del plan - Fija las versiones de los providers con
~>(compatible con versiones minor), en lugar de dejarlas sin fijar - Ejecuta
terraform planen CI para cada PR; aplica solo al hacer merge a main - Etiqueta cada recurso para asignar costos y controlar el acceso
- Usa cuentas de AWS separadas por entorno, no solo workspaces, para limitar el alcance de los fallos
Helm
- Nunca guardes secretos en
values.yamlcuando se hace commit del archivo a Git; usa External Secrets Operator - Nunca uses
latestcomo etiqueta de imagen: impide la reproducibilidad y el rollback - Usa
--waitconhelm upgradepara que CI falle si los pods no quedan listos - Incrementa
Chart.versioncuando cambie la estructura del chart yappVersioncuando cambie la aplicación - Usa
helm lint+helm templateen CI para detectar errores de renderizado antes de aplicar los manifiestos al clúster
ArgoCD
- Habilita
selfHeal: truepara evitar desviaciones causadas por ediciones manuales - Habilita
prune: truecon cuidado: pruébalo primero en staging; elimina los recursos que se quitaron de Git - Usa App of Apps desde el primer día: incorporarlo después es complicado
- Mantén los manifiestos de Application de ArgoCD en un repositorio
k8s-manifestsseparado - Usa sync waves para establecer el orden (CRDs antes que controladores, bases de datos antes que APIs, migraciones antes que nuevas versiones de la aplicación)
- Usa
AppProjectpara delimitar el acceso de los equipos; nunca ejecutes producción en el proyectodefault
Agentes Agno
- Usa almacenamiento PostgreSQL en producción, no SQLite: SQLite en un PVC no sobrevive de manera confiable a la reprogramación de pods
- Inyecta
ANTHROPIC_API_KEYdesde AWS Secrets Manager mediante External Secrets Operator; nunca la guardes en Git - Establece los objetivos de HPA en 60% de CPU (menos de lo habitual): las cargas de trabajo de LLM tienen picos
- Da a los liveness probes un
initialDelaySecondsde 60s: los clientes de modelos tardan en inicializar las conexiones - Usa un namespace
agentsdedicado: mantiene las cargas de trabajo de LLM aisladas de las cargas de tus APIs para administrar recursos y RBAC
Preguntas para profundizar
Sobre Terraform:
- ¿Cómo pruebas los módulos de Terraform de forma aislada con
terratestantes de hacer merge? - ¿Cuándo deberías dividir un archivo de estado monolítico en varios archivos más pequeños? ¿Cómo funciona el patrón de fuentes de datos entre estados?
- ¿Cómo administras secretos (contraseñas de RDS, claves de API) en Terraform sin almacenarlos en archivos
.tfvars? Compara Vault, AWS Secrets Manager y SOPS. - ¿En qué consiste el problema de la “detección de desviaciones” a escala y cómo se comparan herramientas como
driftctlcon elrefreshintegrado de Terraform? - ¿Cómo implementas despliegues progresivos de infraestructura (blue/green en la capa de infraestructura, no en la capa de aplicación)?
Sobre Helm:
6. ¿Cómo creas un library chart compartido que proporcione plantillas comunes (_helpers.tpl) para 20 charts de aplicaciones diferentes?
7. ¿Qué es el patrón de repositorio de charts de Helm y cómo publicas charts en un registro OCI privado (ECR) frente a un repositorio tradicional con index.yaml?
8. ¿Cómo permite helm diff (el plugin) hacer actualizaciones más seguras al mostrar las diferencias exactas de los manifiestos antes de aplicarlos?
9. ¿Cuándo tiene sentido pasar de Helm a Kustomize o usar ambos juntos (helm template | kustomize build)?
10. ¿Cómo administras de forma segura los secretos instalados con Helm usando helm-secrets con SOPS y cifrado age?
Sobre ArgoCD:
11. ¿Cómo amplía Argo Rollouts a ArgoCD para admitir despliegues canary y estrategias blue/green a nivel de Deployment?
12. ¿Qué es el CRD ApplicationSet y cómo te permite generar manifiestos de Application para cientos de microservicios a partir de una sola plantilla?
13. ¿Cómo configuras ArgoCD para usar SSO (Okta, GitHub) y asignar grupos de SSO a roles de AppProject?
14. ¿Cómo funciona Argo CD Image Updater y cuándo deberías usarlo en lugar de un commit de manifiestos generado por CI?
15. ¿Cuáles son las ventajas y desventajas de ejecutar ArgoCD en el mismo clúster que administra frente a ejecutarlo en un clúster de administración dedicado?
Sobre Agno y agentes de IA: 16. ¿Cómo implementas la orquestación multiagente en Agno para que un agente coordinador delegue a subagentes especializados en distintos Deployments de Kubernetes? 17. ¿Qué stack de observabilidad (Prometheus, Grafana, OpenTelemetry) integras con la capa FastAPI de Agno para rastrear la latencia de los LLMs, el número de tokens y el número de sesiones? 18. ¿Cómo manejas los límites de tasa de las APIs de LLM (Anthropic, OpenAI) en un despliegue de Agno escalado horizontalmente, con varios pods que acceden a la misma API? 19. ¿Cuál es el patrón para hacer pruebas A/B de dos modelos de LLM (Claude Sonnet vs Haiku) detrás del mismo endpoint de Agno mediante la distribución de tráfico de Kubernetes? 20. ¿Cómo implementas un circuit breaker de costos que pause el HPA del agente Agno cuando el gasto diario en LLMs supere un umbral?
Fuentes
- HashiCorp. Documentación de Terraform — Configuración del backend. https://developer.hashicorp.com/terraform/language/backend
- HashiCorp. Terraform Registry — Módulo de VPC de AWS. https://registry.terraform.io/modules/terraform-aws-modules/vpc/aws/latest
- HashiCorp. Terraform Registry — Módulo de EKS de AWS. https://registry.terraform.io/modules/terraform-aws-modules/eks/aws/latest
- HashiCorp. Terraform Registry — Módulo de IAM de AWS (IRSA). https://registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest
- Documentación de AWS. Roles de IAM para cuentas de servicio (IRSA). https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html
- Helm. Guía del desarrollador de plantillas de charts. https://helm.sh/docs/chart_template_guide/
- Helm. Hooks de charts. https://helm.sh/docs/topics/charts_hooks/
- Helm. Buenas prácticas de Helm. https://helm.sh/docs/chart_best_practices/
- Argo Project. ArgoCD — Primeros pasos. https://argo-cd.readthedocs.io/en/stable/getting_started/
- Argo Project. ArgoCD — Referencia del CRD Application. https://argo-cd.readthedocs.io/en/stable/operator-manual/application.yaml
- Argo Project. ArgoCD — Patrón App of Apps. https://argo-cd.readthedocs.io/en/stable/operator-manual/cluster-bootstrapping/
- Argo Project. ArgoCD — Sync Waves y hooks. https://argo-cd.readthedocs.io/en/stable/user-guide/sync-waves/
- Argo Project. Argo CD Image Updater. https://argocd-image-updater.readthedocs.io/en/stable/
- Argo Project. Argo Rollouts — Entrega progresiva. https://argoproj.github.io/argo-rollouts/
- Agno. Documentación de Agno — AgentOS. https://docs.agno.com/agentos/introduction
- Agno. Documentación de Agno — Backends de almacenamiento. https://docs.agno.com/agents/storage
- Weaveworks. Guía de GitOps. https://www.weave.works/technologies/gitops/
- External Secrets Operator. Introducción e integración con AWS Secrets Manager. https://external-secrets.io/latest/provider/aws-secrets-manager/
- CNCF. Principios de GitOps v1.0. https://opengitops.dev/
- GitHub Actions. Autenticación OIDC con AWS. https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services