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.
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:
- 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.
- 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.
- 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.