K3s para Equipos Ágiles: Feature Branches, QA, Staging y Producción en un Clúster

K3s para Equipos Ágiles: Feature Branches, QA, Staging y Producción en un Clúster

Cómo gestionar el ciclo de vida completo de entornos ágiles en K3s: previews de feature branches, gates de QA, mirrors de staging, RBAC de producción y pipelines GitOps.

Por Omar Flores

Un equipo que trabaja por sprints en una base de código compartida necesita más que un solo clúster con una copia de la aplicación en ejecución. Necesita entornos — aislados, reproducibles y controlados. Uno por feature branch para que los desarrolladores prueben. Uno para que QA valide sin que los desarrolladores sobreescriban su trabajo. Uno que refleje producción exactamente para la aprobación final. Y producción en sí, bloqueada para que solo el pipeline de lanzamiento pueda tocarla.

Conseguir esto con presupuesto limitado es donde K3s gana su lugar. Un solo clúster K3s bien configurado puede alojar todos estos entornos simultáneamente, con aislamiento estricto entre ellos, gestión automática del ciclo de vida, y la misma disciplina GitOps que escala a configuraciones multi-clúster cuando el equipo crece.

Este post se enfoca completamente en el flujo de trabajo del equipo ágil: cómo las funcionalidades se mueven de una rama a producción, qué ocurre en cada gate, quién puede tocar qué, y cómo el sistema se recupera cuando algo sale mal.


El Modelo de Entornos

La decisión fundamental es si usar un clúster con aislamiento por namespace o múltiples clústeres. Para la mayoría de los equipos ágiles de menos de 20 ingenieros, un clúster con namespaces bien separados es el punto de partida correcto. El overhead operativo de mantener múltiples clústeres es significativo y rara vez se justifica hasta que el tamaño del equipo o los requisitos de cumplimiento lo exigen.

El modelo de namespaces para un equipo ágil típico:

clúster: k3s-equipo
├── ns: feature-pr-142        ← efímero, creado al abrir PR, eliminado al mergear
├── ns: feature-pr-167        ← efímero, creado al abrir PR, eliminado al mergear
├── ns: qa                    ← estable, actualizado al mergear en main
├── ns: staging               ← estable, refleja config de producción, actualizado en release branch
├── ns: production            ← estable, actualizado solo via pipeline aprobado
├── ns: monitoring            ← Prometheus, Grafana, Loki — observa todos los entornos
└── ns: flux-system           ← controlador GitOps

Cada namespace tiene su propia:

  • Deployments y services (cargas de trabajo aisladas)
  • Secrets (credenciales específicas del entorno)
  • ResourceQuota (los namespaces de feature están limitados, producción tiene más margen)
  • NetworkPolicy (los feature branches no pueden alcanzar las bases de datos de producción)
  • IngressRoute de Traefik (subdominio único por entorno)

Estructura del Repositorio

repo/
├── app/                        # código fuente de la aplicación
│   ├── cmd/
│   ├── internal/
│   └── Dockerfile
├── k8s/
│   ├── base/                   # manifiestos compartidos
│   │   ├── deployment.yaml
│   │   ├── service.yaml
│   │   ├── kustomization.yaml
│   │   └── migrations-job.yaml
│   └── overlays/
│       ├── feature/            # plantilla para entornos de feature branch
│       │   ├── kustomization.yaml
│       │   ├── namespace.yaml
│       │   ├── ingress.yaml
│       │   ├── postgres.yaml   # BD efímera para features
│       │   └── resource-quota.yaml
│       ├── qa/
│       │   ├── kustomization.yaml
│       │   ├── namespace.yaml
│       │   ├── ingress.yaml
│       │   └── hpa.yaml
│       ├── staging/
│       │   ├── kustomization.yaml
│       │   ├── namespace.yaml
│       │   ├── ingress.yaml
│       │   └── hpa.yaml
│       └── production/
│           ├── kustomization.yaml
│           ├── namespace.yaml
│           ├── ingress.yaml
│           ├── hpa.yaml
│           └── pdb.yaml
└── .github/
    └── workflows/
        ├── feature-deploy.yml
        ├── feature-cleanup.yml
        ├── qa-deploy.yml
        ├── staging-deploy.yml
        └── production-deploy.yml

Entornos de Feature Branch

Un entorno de feature branch se crea automáticamente cuando se abre un pull request y se destruye cuando el PR se mergea o cierra. Cada entorno obtiene un subdominio único derivado del número de PR, una base de datos efímera sembrada con datos de prueba anonimizados, y su propia cuota de recursos.

Base Kustomization

# k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      initContainers:
        - name: wait-for-db
          image: busybox:1.36
          command: ['sh', '-c', 'until nc -z $DB_HOST 5432; do sleep 2; done']
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: api-config
                  key: db-host
      containers:
        - name: api
          image: ghcr.io/tuorg/myapp/api:latest
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: api-config
            - secretRef:
                name: api-secret
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 20

Overlay de Feature

# k8s/overlays/feature/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: feature-pr-PRNUMBER     # reemplazado por CI
resources:
  - ../../base
  - namespace.yaml
  - postgres.yaml
  - ingress.yaml
  - resource-quota.yaml
patches:
  - patch: |-
      - op: replace
        path: /spec/template/spec/containers/0/resources
        value:
          requests:
            memory: "64Mi"
            cpu: "50m"
          limits:
            memory: "128Mi"
            cpu: "200m"
    target:
      kind: Deployment
      name: api
images:
  - name: ghcr.io/tuorg/myapp/api
    newTag: "GITSHA"               # reemplazado por CI
# k8s/overlays/feature/resource-quota.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
  name: feature-quota
spec:
  hard:
    requests.cpu: "500m"
    requests.memory: 512Mi
    limits.cpu: "1"
    limits.memory: 1Gi
    pods: "10"
# k8s/overlays/feature/postgres.yaml
apiVersion: apps/v1
kind: Deployment    # Deployment, no StatefulSet — lo efímero está bien para features
metadata:
  name: postgres
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:17-alpine
          env:
            - name: POSTGRES_DB
              value: myapp
            - name: POSTGRES_USER
              value: myapp
            - name: POSTGRES_PASSWORD
              value: feature-local-password    # no son credenciales reales
          ports:
            - containerPort: 5432
          # sin volumen persistente — los datos viven solo mientras el pod existe
---
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
# k8s/overlays/feature/ingress.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: api-feature
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`pr-PRNUMBER.preview.tuequipo.dev`)
      kind: Rule
      services:
        - name: api
          port: 80
  tls:
    certResolver: letsencrypt

GitHub Actions: PR Abierto → Desplegar Entorno de Feature

# .github/workflows/feature-deploy.yml
name: Entorno de Feature

on:
  pull_request:
    types: [opened, synchronize, reopened]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}/api
  PR_NAMESPACE: feature-pr-${{ github.event.number }}
  PR_HOST: pr-${{ github.event.number }}.preview.tuequipo.dev

jobs:
  deploy-feature:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4

      - name: Iniciar sesión en GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Construir y publicar
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - name: Generar manifiestos
        run: |
          cd k8s/overlays/feature
          sed -i "s/feature-pr-PRNUMBER/${{ env.PR_NAMESPACE }}/g" kustomization.yaml namespace.yaml ingress.yaml
          sed -i "s/pr-PRNUMBER/${{ github.event.number }}/g" ingress.yaml
          sed -i "s/GITSHA/${{ github.sha }}/g" kustomization.yaml
          kustomize build . > /tmp/feature-manifests.yaml

      - name: Desplegar en K3s
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          kubectl apply -f /tmp/feature-manifests.yaml
          kubectl rollout status deployment/api \
            --namespace ${{ env.PR_NAMESPACE }} \
            --timeout=3m

      - name: Sembrar datos de prueba
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          kubectl create job seed-$(date +%s) \
            --namespace ${{ env.PR_NAMESPACE }} \
            --image=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
            -- /app/seed --env=feature

      - name: Comentar URL de preview en el PR
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `**Entorno de preview desplegado.**\n\n` +
                    `URL: https://${{ env.PR_HOST }}\n` +
                    `Namespace: \`${{ env.PR_NAMESPACE }}\`\n` +
                    `Imagen: \`${{ github.sha }}\`\n\n` +
                    `Este entorno se destruirá cuando el PR sea mergeado o cerrado.`
            })

GitHub Actions: PR Cerrado → Destruir Entorno

# .github/workflows/feature-cleanup.yml
name: Limpiar Entorno de Feature

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Eliminar namespace
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          NAMESPACE=feature-pr-${{ github.event.number }}
          if kubectl get namespace $NAMESPACE &>/dev/null; then
            kubectl delete namespace $NAMESPACE --timeout=2m
            echo "Namespace eliminado: $NAMESPACE"
          fi

Eliminar el namespace crea una cascada — todos los deployments, services, pods, PVCs, secrets y configmaps dentro de él se eliminan automáticamente.


Entorno QA

El namespace de QA es estable — no se recrea por PR. Se actualiza automáticamente cuando el código se mergea en la rama main. Los ingenieros de QA son los dueños de este entorno: ejecutan suites de regresión, pruebas exploratorias y aprueban funcionalidades antes de que vayan a staging.

Diferencias clave con los entornos de feature:

  • Base de datos externa (PostgreSQL persistente, no efímera), compartida por los testers de QA
  • Datos de prueba estables mantenidos por QA (no sobreescritos en cada despliegue)
  • Dos réplicas para pruebas de carga realistas
  • Auto-scaling deshabilitado (para que las pruebas de carga sean predecibles)
# k8s/overlays/qa/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: qa
resources:
  - ../../base
  - namespace.yaml
  - ingress.yaml
  - hpa.yaml
patches:
  - patch: |-
      - op: replace
        path: /spec/replicas
        value: 2
    target:
      kind: Deployment
      name: api
images:
  - name: ghcr.io/tuorg/myapp/api
    newTag: "GITSHA"
configMapGenerator:
  - name: api-config
    literals:
      - db-host=postgres.qa.svc.cluster.local
      - app-env=qa
      - log-level=debug
# k8s/overlays/qa/ingress.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: api-qa
  namespace: qa
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`qa.tuequipo.dev`)
      kind: Rule
      middlewares:
        - name: basic-auth
          namespace: qa
      services:
        - name: api
          port: 80
  tls:
    certResolver: letsencrypt
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: basic-auth
  namespace: qa
spec:
  basicAuth:
    secret: qa-basic-auth-secret

Desplegar a QA al Mergear en Main

# .github/workflows/qa-deploy.yml
name: Despliegue QA

on:
  push:
    branches: [main]

jobs:
  deploy-qa:
    runs-on: ubuntu-latest
    environment: qa

    steps:
      - uses: actions/checkout@v4

      - name: Construir y publicar
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ github.repository }}/api:${{ github.sha }}
            ghcr.io/${{ github.repository }}/api:qa-latest

      - name: Ejecutar migraciones de BD (QA)
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig

          cat << EOF | kubectl apply -f -
          apiVersion: batch/v1
          kind: Job
          metadata:
            name: migrate-${{ github.sha }}
            namespace: qa
          spec:
            ttlSecondsAfterFinished: 300
            template:
              spec:
                restartPolicy: Never
                containers:
                  - name: migrate
                    image: ghcr.io/${{ github.repository }}/api:${{ github.sha }}
                    command: ["/app/migrate", "up"]
                    envFrom:
                      - secretRef:
                          name: api-secret
                      - configMapRef:
                          name: api-config
          EOF

          kubectl wait job/migrate-${{ github.sha }} \
            --namespace qa \
            --for=condition=complete \
            --timeout=5m

      - name: Desplegar en QA
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          kubectl set image deployment/api \
            api=ghcr.io/${{ github.repository }}/api:${{ github.sha }} \
            --namespace qa
          kubectl rollout status deployment/api --namespace qa --timeout=5m

      - name: Pruebas de humo en QA
        run: |
          sleep 15
          curl -sf https://qa.tuequipo.dev/health
          echo "Pruebas de humo de QA pasaron"

      - name: Notificar al equipo de QA
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "QA desplegado: `${{ github.sha }}` — ${{ github.event.head_commit.message }}\nhttps://qa.tuequipo.dev"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_QA_WEBHOOK }}

Entorno Staging

Staging es un espejo de producción. Corre el mismo número de réplicas, los mismos límites de recursos, la misma versión del motor de base de datos, la misma estructura de secrets y la misma configuración de ingress que producción. La única diferencia es el nombre de dominio y los datos.

Esta fidelidad es el punto. Un bug que solo aparece bajo condiciones similares a producción — una consulta lenta en conjuntos de datos grandes, una condición de carrera que requiere múltiples réplicas — debe surgir en staging, no en producción.

# k8s/overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: staging
resources:
  - ../../base
  - namespace.yaml
  - ingress.yaml
  - hpa.yaml
  - pdb.yaml
patches:
  - patch: |-
      - op: replace
        path: /spec/replicas
        value: 3          # igual que producción
    target:
      kind: Deployment
      name: api
  - patch: |-
      - op: replace
        path: /spec/template/spec/containers/0/resources
        value:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "1000m"
    target:
      kind: Deployment
      name: api
images:
  - name: ghcr.io/tuorg/myapp/api
    newTag: "GITSHA"
configMapGenerator:
  - name: api-config
    literals:
      - db-host=postgres.staging.svc.cluster.local
      - app-env=staging
      - log-level=info    # igual que producción
# k8s/overlays/staging/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-pdb
  namespace: staging
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: api

Renovación de Datos en Staging

# k8s/overlays/staging/data-refresh-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: staging-data-refresh
  namespace: staging
spec:
  schedule: "0 2 * * 0"    # cada domingo a las 2 AM
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: refresh
              image: ghcr.io/tuorg/myapp/tools:latest
              command:
                - /app/tools
                - refresh-staging-data
                - --source=production
                - --target=staging
                - --anonymize-pii    # obligatorio — no copiar PII real a staging
              envFrom:
                - secretRef:
                    name: data-refresh-secret

El flag --anonymize-pii no es opcional. Copiar datos reales de usuarios a un entorno menos controlado sin anonimización es una violación de GDPR/LGPD.


Entorno de Producción

Producción tiene los controles más estrictos. Ningún desarrollador puede desplegar directamente. El único camino es a través del pipeline, que requiere aprobación de QA en staging.

# k8s/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
  - ../../base
  - namespace.yaml
  - ingress.yaml
  - hpa.yaml
  - pdb.yaml
patches:
  - patch: |-
      - op: replace
        path: /spec/replicas
        value: 3
    target:
      kind: Deployment
      name: api
  - patch: |-
      - op: add
        path: /spec/strategy
        value:
          type: RollingUpdate
          rollingUpdate:
            maxSurge: 1
            maxUnavailable: 0     # cero downtime — siempre tener 3 pods corriendo
    target:
      kind: Deployment
      name: api
images:
  - name: ghcr.io/tuorg/myapp/api
    newTag: "GITSHA"

Pipeline de Despliegue a Producción

# .github/workflows/production-deploy.yml
name: Despliegue a Producción

on:
  workflow_dispatch:      # solo disparo manual — no hay despliegues automáticos a producción
    inputs:
      image_sha:
        description: 'Git SHA de la imagen a desplegar (debe estar probada en staging)'
        required: true
        type: string

jobs:
  validar:
    runs-on: ubuntu-latest
    steps:
      - name: Verificar que la imagen existe en el registro
        run: |
          docker manifest inspect ghcr.io/${{ github.repository }}/api:${{ inputs.image_sha }}

      - name: Verificar que la imagen fue desplegada en staging
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig

          STAGING_SHA=$(kubectl get deployment api \
            --namespace staging \
            -o jsonpath='{.spec.template.spec.containers[0].image}' \
            | cut -d: -f2)

          if [ "$STAGING_SHA" != "${{ inputs.image_sha }}" ]; then
            echo "ERROR: La imagen ${{ inputs.image_sha }} no fue el último despliegue en staging."
            echo "Staging está corriendo: $STAGING_SHA"
            echo "Despliega en staging primero, valida, luego promueve a producción."
            exit 1
          fi

          echo "Validación de staging pasó. La imagen coincide."

  deploy-production:
    needs: validar
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://api.tuequipo.dev
    # el entorno 'production' requiere 2 aprobadores en la configuración de GitHub

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ inputs.image_sha }}

      - name: Ejecutar migraciones de BD (producción)
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig

          cat << EOF | kubectl apply -f -
          apiVersion: batch/v1
          kind: Job
          metadata:
            name: migrate-${{ inputs.image_sha }}
            namespace: production
          spec:
            ttlSecondsAfterFinished: 3600
            backoffLimit: 0          # sin reintentos — los fallos de migración deben investigarse
            template:
              spec:
                restartPolicy: Never
                containers:
                  - name: migrate
                    image: ghcr.io/${{ github.repository }}/api:${{ inputs.image_sha }}
                    command: ["/app/migrate", "up"]
                    envFrom:
                      - secretRef:
                          name: api-secret
                      - configMapRef:
                          name: api-config
          EOF

          kubectl wait job/migrate-${{ inputs.image_sha }} \
            --namespace production \
            --for=condition=complete \
            --timeout=10m

      - name: Desplegar en producción (rolling update)
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig

          kubectl set image deployment/api \
            api=ghcr.io/${{ github.repository }}/api:${{ inputs.image_sha }} \
            --namespace production

          kubectl rollout status deployment/api \
            --namespace production \
            --timeout=10m

      - name: Prueba de humo en producción
        run: |
          sleep 10
          curl -sf https://api.tuequipo.dev/health
          echo "Prueba de humo de producción pasó"

      - name: Notificar éxito
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Producción desplegada: `${{ inputs.image_sha }}`\nhttps://api.tuequipo.dev"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_PROD_WEBHOOK }}

      - name: Rollback en caso de fallo
        if: failure()
        env:
          KUBECONFIG_DATA: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_DATA" | base64 -d > /tmp/kubeconfig
          export KUBECONFIG=/tmp/kubeconfig
          kubectl rollout undo deployment/api --namespace production
          echo "Rollback de producción ejecutado"

RBAC: Quién Puede Tocar Qué

# rbac/developer-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: developer
rules:
  - apiGroups: ["", "apps", "batch"]
    resources: ["pods", "deployments", "services", "jobs", "configmaps"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get"]
  - apiGroups: [""]
    resources: ["pods/exec"]
    verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: developers-read
subjects:
  - kind: Group
    name: developers
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: developer
  apiGroup: rbac.authorization.k8s.io
# rbac/qa-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: qa-admin
  namespace: qa
rules:
  - apiGroups: ["", "apps", "batch"]
    resources: ["*"]
    verbs: ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: qa-team-binding
  namespace: qa
subjects:
  - kind: Group
    name: qa-engineers
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: qa-admin
  apiGroup: rbac.authorization.k8s.io
# rbac/ci-production-role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deploy
  namespace: production
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "patch", "list"]
  - apiGroups: ["batch"]
    resources: ["jobs"]
    verbs: ["create", "get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deploy-binding
  namespace: production
subjects:
  - kind: ServiceAccount
    name: github-ci
    namespace: kube-system
roleRef:
  kind: Role
  name: ci-deploy
  apiGroup: rbac.authorization.k8s.io

Ningún desarrollador tiene acceso de escritura a los namespaces staging o production. Esto se aplica a nivel de API — no puede evitarse ejecutando kubectl apply manualmente.


Migraciones de BD en el Pipeline

Las migraciones son la parte más peligrosa de cualquier despliegue. El patrón seguro: cada migración debe ser compatible hacia atrás con la versión anterior del código de la aplicación.

Las reglas:

  1. Nunca eliminar una columna o tabla en la misma migración que la renombra. Primero despliega el renombre (añade nueva, conserva vieja). Segundo despliegue elimina la vieja.
  2. Nunca añadir una columna NOT NULL sin valor por defecto. Añade con default → despliega → rellena → hazla non-null en una tercera migración.
  3. Nunca renombrar una columna directamente. Añade nueva → despliega ambas versiones leyendo de ambas → migra datos → elimina vieja en un lanzamiento posterior.

Esto permite el patrón de rollback seguro: si el nuevo despliegue rompe algo, haz rollback del código pero no de la migración. La versión anterior del código sigue funcionando con el nuevo esquema porque la migración era compatible hacia atrás.

# k8s/base/migrations-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: migrate
spec:
  backoffLimit: 3
  activeDeadlineSeconds: 300
  template:
    spec:
      restartPolicy: OnFailure
      initContainers:
        - name: wait-for-db
          image: busybox:1.36
          command: ['sh', '-c', 'until nc -z $DB_HOST 5432; do sleep 2; done']
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: api-config
                  key: db-host
      containers:
        - name: migrate
          image: ghcr.io/tuorg/myapp/api:latest
          command: ["/app/migrate", "up"]
          envFrom:
            - secretRef:
                name: api-secret
            - configMapRef:
                name: api-config

La secuencia en el pipeline es siempre:

1. Aplicar Job de migración → esperar completación
2. Solo si las migraciones tienen éxito → kubectl set image (rolling update)
3. Si el rolling update falla → kubectl rollout undo (sin rollback de migración — el esquema es compatible hacia atrás)

GitOps con Flux por Entorno

# infrastructure/clusters/team-cluster/qa.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: myapp-qa
  namespace: flux-system
spec:
  interval: 2m
  path: ./k8s/overlays/qa
  prune: true
  sourceRef:
    kind: GitRepository
    name: myapp
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: api
      namespace: qa
# infrastructure/clusters/team-cluster/production.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: myapp-production
  namespace: flux-system
spec:
  interval: 10m
  path: ./k8s/overlays/production
  prune: true
  sourceRef:
    kind: GitRepository
    name: myapp
  suspend: false              # pon en true para pausar la sincronización durante incidentes
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: api
      namespace: production
  timeout: 10m

Con esta configuración, promover de staging a producción se convierte en una operación Git — actualiza el tag de imagen en k8s/overlays/production/kustomization.yaml via un PR, revísalo y mergealo, y Flux lo aplica.


Observabilidad por Entorno

# monitoring/prometheus-rules.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: api-alerts
  namespace: monitoring
spec:
  groups:
    - name: api.production
      interval: 30s
      rules:
        - alert: ProductionHighErrorRate
          expr: |
            sum(rate(http_requests_total{namespace="production", status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total{namespace="production"}[5m])) > 0.05
          for: 2m
          labels:
            severity: critical
            environment: production
          annotations:
            summary: "Tasa de error en producción por encima del 5%"
            runbook: "https://wiki.tuequipo.dev/runbooks/high-error-rate"

        - alert: ProductionPodCrashLooping
          expr: |
            increase(kube_pod_container_status_restarts_total{namespace="production"}[15m]) > 3
          for: 5m
          labels:
            severity: critical
            environment: production

    - name: api.staging
      rules:
        - alert: StagingHighErrorRate
          expr: |
            sum(rate(http_requests_total{namespace="staging", status=~"5.."}[5m]))
            /
            sum(rate(http_requests_total{namespace="staging"}[5m])) > 0.10
          for: 5m
          labels:
            severity: warning
            environment: staging

El Ciclo de Vida Completo de una Funcionalidad

1. Desarrollador crea feature branch desde main
   → PR abierto
   → GitHub Actions construye imagen, crea namespace feature-pr-142
   → PostgreSQL efímero desplegado, datos de prueba sembrados
   → URL de preview publicada en el PR: https://pr-142.preview.tuequipo.dev
   → Desarrollador itera, hace push de commits, CI reconstruye y redespliega

2. Revisión del PR
   → Revisión de código por compañeros
   → Desarrollador puede compartir URL de preview con stakeholders
   → Tests CI (unit, integración) deben pasar antes del merge

3. PR mergeado en main
   → Namespace feature-pr-142 eliminado (todos los recursos en cascada)
   → CI construye imagen final para el SHA del commit
   → QA deploy workflow se dispara
   → Migraciones ejecutadas contra BD de QA
   → Namespace QA actualizado a nueva imagen
   → Notificación Slack al canal de QA

4. Validación de QA
   → Ingeniero de QA prueba qa.tuequipo.dev
   → Ejecuta suite de regresión
   → Reporta bugs como issues si encuentra problemas (vuelve al paso 1)
   → Cuando está satisfecho, aprueba el entorno 'staging' de GitHub

5. Despliegue a Staging (requiere aprobación de QA)
   → Pipeline se reanuda después de la aprobación
   → Migraciones ejecutadas contra BD de staging
   → Namespace staging actualizado
   → Pruebas de humo automatizadas ejecutadas
   → Release manager valida staging.tuequipo.dev

6. Despliegue a Producción (manual, requiere SHA de imagen)
   → Release manager dispara el workflow de producción manualmente
   → Especifica el SHA de imagen validado en staging
   → Pipeline valida que el SHA coincide con el despliegue de staging
   → Entorno 'production' de GitHub requiere 2 aprobadores
   → Después de la aprobación: migraciones ejecutadas, rolling update comienza
   → Cero downtime: maxUnavailable=0 garantiza al menos 3 pods siempre activos
   → Pruebas de humo verifican el endpoint de producción
   → Monitoreo observa la tasa de error por 30 minutos post-despliegue
   → Si la tasa de error sube: rollback automático via kubectl rollout undo

Este modelo — entornos de feature efímeros, QA estable, staging espejo de producción, producción bloqueada — no existe por amor al proceso. Existe porque el costo de un bug en producción es órdenes de magnitud mayor que el costo de encontrarlo en QA o staging. Cada gate es un mecanismo de reducción de riesgo. Cada pieza de automatización elimina un paso manual que de otro modo se saltaría bajo presión de entrega.

El mejor momento para encontrar un bug es antes de que llegue a producción. El modelo de entornos existe para hacer eso posible — siempre, no solo cuando alguien recuerda probar.

Tags

#kubernetes #devops #tutorial #guide #best-practices #ci-cd #backend