Production AI Infrastructure

GitOps en producción: Terraform, Helm y ArgoCD para APIs de Node.js y agentes Agno

Una guía detallada para aprovisionar infraestructura con Terraform, empaquetar aplicaciones con Helm y desplegarlas continuamente con ArgoCD, con configuraciones reales para una API de Node.js y un agente Agno en Python. Organizada en cinco niveles de experiencia, desde Básico hasta Legendario.

32 min de lectura

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:

HerramientaProblema que resuelveDó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 apply despué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 predeterminada
  • templates/ — 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.AlphaNum ni randBytes en las plantillas: se vuelven a evaluar en cada actualización, lo que provoca actualizaciones innecesarias de Secrets en cada despliegue.
  • Nunca uses latest como etiqueta de imagen: impide la reproducibilidad y el rollback.
  • helm lint + helm template en 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 = true para 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 plan en 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.yaml cuando se hace commit del archivo a Git; usa External Secrets Operator
  • Nunca uses latest como etiqueta de imagen: impide la reproducibilidad y el rollback
  • Usa --wait con helm upgrade para que CI falle si los pods no quedan listos
  • Incrementa Chart.version cuando cambie la estructura del chart y appVersion cuando cambie la aplicación
  • Usa helm lint + helm template en CI para detectar errores de renderizado antes de aplicar los manifiestos al clúster

ArgoCD

  • Habilita selfHeal: true para evitar desviaciones causadas por ediciones manuales
  • Habilita prune: true con 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-manifests separado
  • 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 AppProject para delimitar el acceso de los equipos; nunca ejecutes producción en el proyecto default

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_KEY desde 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 initialDelaySeconds de 60s: los clientes de modelos tardan en inicializar las conexiones
  • Usa un namespace agents dedicado: mantiene las cargas de trabajo de LLM aisladas de las cargas de tus APIs para administrar recursos y RBAC

Preguntas para profundizar

Sobre Terraform:

  1. ¿Cómo pruebas los módulos de Terraform de forma aislada con terratest antes de hacer merge?
  2. ¿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?
  3. ¿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.
  4. ¿En qué consiste el problema de la “detección de desviaciones” a escala y cómo se comparan herramientas como driftctl con el refresh integrado de Terraform?
  5. ¿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