Desplegar sobre Kubernetes, fácil y rápido
Utilizando Helm + Argo para desplegar sobre decenas de clústers de Kubernetes, en 4 Clouds diferentes y en +6 regiones distintas.

¿Quieres mejorar los procesos de desarrollo, la eficiencia, la escalabilidad y seguridad en tu Cloud a la vez que ahorras costes?
Contacta con nuestro equipo de expertos en infraestructura Cloud y lleva tus aplicaciones al siguiente nivel.
Existen muchas fórmulas y sabores para poder realizar esto. Nosotros mismos estos últimos años hemos estado experimentando con varias formas de hacer esto. Si bien, todas terminaban funcionando, creo que actualmente nuestros sistemas de CI/CD con Kubernetes se han vuelto mucho mejores.
En Helmcode, debido a que administramos infraestructuras de muchas empresas con negocios completamente diferentes y aplicaciones distintas, debíamos buscar un stack que nos sirviera de forma transversal y que sea lo suficientemente flexible como para poder adaptarlo a cada caso de uso.
Flujo del CI/CD

Como se puede ver en este flujo, esta forma de desplegar sobre Kubernetes nos permite ser agnósticos al repositorio donde esté el cliente (ya sea GitHub, GitLab o el que se prefiera). También y más importante para nosotros, nos permite ser agnósticos al Cloud donde desplegamos.
El stack básicamente se compone de:
- Helm: es el gestor de paquetes y aplicaciones de Kubernetes. En nuestro caso nos permite "encapsular" todos los componentes de una aplicación (ingress, service, deployment, etc) para poder reutilizar y mantener fácilmente todos los aplicativos que gestionamos.
- ArgoCD: es la herramienta que se ocupa de hacer toda la magia. Nos permite mantener sincronizadas las aplicaciones y los Helm charts con el estado de Git. Además nos permite ver e interactuar de forma gráfica y sencilla con las aplicaciones desplegadas en Kubernetes, junto con todos sus componentes.
El stack es muy simple ya que al ser agnóstico a prácticamente todo y basar su estado en Git. Nos da la posibilidad de desplegar de la misma forma en múltiples regiones dentro del mismo Cloud e incluso en diferentes Clouds y clientes. Volviéndolo una de las combinaciones mas versátiles y potentes que hay actualmente.
Configuración de los repositorios
Ya sea que tengamos alojado el código en GitHub o GitLab (SaaS o self-hosted), utilizamos la misma estrategia:

Utilizamos dos repositorios:
- El propio de la aplicación, donde está su código y el Pipeline que desata todo este proceso de CI/CD.
- Un repositorio de DevOps o donde alojamos todos los Helm Charts que desplegamos sobre Kubernetes. El cual es actualizado automáticamente por el pipeline del repositorio de la aplicación.
Un ejemplo de GitHub Action que permite hacer este proceso:
name: Build and Deploy to Kubernetes
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
AWS_REGION: us-east-1
ECR_REGISTRY: your-account-id.dkr.ecr.us-east-1.amazonaws.com
ECR_REPOSITORY: your-app-name
ARGOCD_SERVER: your-argocd-server.com
ARGOCD_APP_NAME: your-app-name
jobs:
build_and_push:
name: Build and Push Docker Image
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
image_digest: ${{ steps.build.outputs.digest }}
short_sha: ${{ steps.vars.outputs.short_sha }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up variables
id: vars
run: |
echo "short_sha=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
echo "image_tag=${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}
tags: |
type=raw,value=${{ steps.vars.outputs.short_sha }}
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
id: build
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
update_helm_tags:
name: Update Helm Tags
needs: build_and_push
runs-on: ubuntu-latest
outputs:
built_services: ${{ steps.set-output.outputs.built_services }}
steps:
- name: Set built services output
id: set-output
run: |
echo "built_services=${{ env.ECR_REPOSITORY }}" >> $GITHUB_OUTPUT
- name: Update Helm Chart
uses: client/devops/.github/workflows/update_helm_tag.yaml@main
with:
image_tag: ${{ needs.build_and_push.outputs.short_sha }}
built_services: ${{ steps.set-output.outputs.built_services }}
environment: ${{ github.ref_name == 'main' && 'prod' || 'dev' }}
runner: "ubuntu-latest"
secrets:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
deploy_with_argocd:
name: Deploy with ArgoCD
needs: [build_and_push, update_helm_tags]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- name: Install ArgoCD CLI
run: |
curl -sSL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd
rm argocd-linux-amd64
- name: Login to ArgoCD
env:
ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }}
run: |
argocd login ${{ env.ARGOCD_SERVER }} --auth-token $ARGOCD_AUTH_TOKEN --insecure
- name: Sync ArgoCD Application
env:
ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }}
run: |
argocd app sync ${{ env.ARGOCD_APP_NAME }} --timeout 300
argocd app wait ${{ env.ARGOCD_APP_NAME }} --timeout 600
- name: Get Application Status
env:
ARGOCD_AUTH_TOKEN: ${{ secrets.ARGOCD_AUTH_TOKEN }}
run: |
argocd app get ${{ env.ARGOCD_APP_NAME }}
notify_status:
name: Notify Deployment Status
needs: [build_and_push, update_helm_tags, deploy_with_argocd]
runs-on: ubuntu-latest
if: always()
steps:
- name: Deployment Success
if: needs.deploy_with_argocd.result == 'success'
run: |
echo "✅ Deployment successful!"
echo "Image: ${{ needs.build_and_push.outputs.image_tag }}"
echo "Short SHA: ${{ needs.build_and_push.outputs.short_sha }}"
- name: Deployment Failed
if: needs.deploy_with_argocd.result == 'failure'
run: |
echo "❌ Deployment failed!"
exit 1
Un ejemplo de GitLab CI que permite hacer este mismo proceso:
stages:
- build
- update-helm
- deploy
- notify
variables:
AWS_REGION: "us-east-1"
ECR_REGISTRY: "your-account-id.dkr.ecr.us-east-1.amazonaws.com"
ECR_REPOSITORY: "your-app-name"
ARGOCD_SERVER: "your-argocd-server.com"
ARGOCD_APP_NAME: "your-app-name"
DOCKER_DRIVER: overlay2
DOCKER_TLS_CERTDIR: "/certs"
SHORT_SHA: ${CI_COMMIT_SHA:0:7}
IMAGE_TAG: "${ECR_REGISTRY}/${ECR_REPOSITORY}:${CI_COMMIT_SHA:0:7}"
# Servicios globales
services:
- docker:24-dind
before_script:
- echo "Pipeline iniciado para commit ${SHORT_SHA}"
- echo "Imagen objetivo: ${IMAGE_TAG}"
build_and_push:
stage: build
image: docker:24
variables:
DOCKER_BUILDKIT: 1
before_script:
# Instalar AWS CLI
- apk add --no-cache python3 py3-pip curl
- pip3 install awscli
# Configurar AWS credentials
- aws configure set aws_access_key_id $AWS_ACCESS_KEY_ID
- aws configure set aws_secret_access_key $AWS_SECRET_ACCESS_KEY
- aws configure set region $AWS_REGION
# Login a ECR
- aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ECR_REGISTRY
script:
# Build de la imagen Docker
- |
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from $ECR_REGISTRY/$ECR_REPOSITORY:latest \
-t $IMAGE_TAG \
-t $ECR_REGISTRY/$ECR_REPOSITORY:latest \
.
# Push de las imágenes
- docker push $IMAGE_TAG
- |
if [[ "$CI_COMMIT_REF_NAME" == "main" ]]; then
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
fi
# Guardar información para siguientes stages
- echo "IMAGE_TAG=${IMAGE_TAG}" >> build.env
- echo "SHORT_SHA=${SHORT_SHA}" >> build.env
- echo "BUILT_SERVICES=${ECR_REPOSITORY}" >> build.env
artifacts:
reports:
dotenv: build.env
expire_in: 1 hour
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
update_helm_tags:
stage: update-helm
image: alpine:latest
needs: ["build_and_push"]
variables:
ENVIRONMENT: $([[ "$CI_COMMIT_REF_NAME" == "main" ]] && echo "prod" || echo "dev")
before_script:
- apk add --no-cache git curl jq
- git config --global user.email "ci@company.com"
- git config --global user.name "GitLab CI"
script:
# Clonar el repositorio de DevOps
- git clone https://gitlab-ci-token:${GIT_TOKEN}@gitlab.com/client/devops.git devops-repo
- cd devops-repo
# Determinar el environment basado en la rama
- |
if [[ "$CI_COMMIT_REF_NAME" == "main" ]]; then
ENVIRONMENT="prod"
else
ENVIRONMENT="dev"
fi
# Actualizar el tag en el helm chart
- |
CHART_PATH="charts/${ECR_REPOSITORY}/values-${ENVIRONMENT}.yaml"
if [[ -f "$CHART_PATH" ]]; then
# Actualizar usando sed (asume formato image.tag: "valor")
sed -i "s|tag:.*|tag: \"${SHORT_SHA}\"|g" $CHART_PATH
# Verificar que el cambio se aplicó
echo "Contenido actualizado:"
grep -A 2 -B 2 "tag:" $CHART_PATH || true
# Commit y push
git add $CHART_PATH
git commit -m "Update ${ECR_REPOSITORY} image tag to ${SHORT_SHA} for ${ENVIRONMENT}"
git push origin main
echo "✅ Helm chart actualizado exitosamente"
else
echo "❌ No se encontró el archivo $CHART_PATH"
exit 1
fi
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
deploy_with_argocd:
stage: deploy
image: alpine:latest
needs: ["build_and_push", "update_helm_tags"]
before_script:
- apk add --no-cache curl
# Instalar ArgoCD CLI
- curl -sSL -o /usr/local/bin/argocd https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
- chmod +x /usr/local/bin/argocd
script:
# Login a ArgoCD
- argocd login $ARGOCD_SERVER --auth-token $ARGOCD_AUTH_TOKEN --insecure
# Verificar estado actual
- echo "📋 Estado actual de la aplicación:"
- argocd app get $ARGOCD_APP_NAME
# Sincronizar aplicación
- echo "🚀 Iniciando sincronización..."
- argocd app sync $ARGOCD_APP_NAME --timeout 300
# Esperar a que termine el despliegue
- echo "⏳ Esperando que termine el despliegue..."
- argocd app wait $ARGOCD_APP_NAME --timeout 600
# Verificar estado final
- echo "✅ Estado final de la aplicación:"
- argocd app get $ARGOCD_APP_NAME
# Verificar que esté healthy
- |
STATUS=$(argocd app get $ARGOCD_APP_NAME -o json | jq -r '.status.health.status')
if [[ "$STATUS" != "Healthy" ]]; then
echo "❌ La aplicación no está en estado Healthy: $STATUS"
exit 1
fi
echo "✅ Aplicación desplegada correctamente y en estado Healthy"
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
notify_success:
stage: notify
image: alpine:latest
needs: ["build_and_push", "update_helm_tags", "deploy_with_argocd"]
script:
- echo "🎉 ¡Despliegue completado exitosamente!"
- echo "📦 Imagen: ${IMAGE_TAG}"
- echo "🔖 Short SHA: ${SHORT_SHA}"
- echo "🌍 Environment: $([[ "$CI_COMMIT_REF_NAME" == "main" ]] && echo "prod" || echo "dev")"
- echo "🔗 Pipeline: $CI_PIPELINE_URL"
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
notify_failure:
stage: notify
image: alpine:latest
script:
- echo "❌ El despliegue ha fallado"
- echo "📦 Imagen: ${IMAGE_TAG}"
- echo "🔖 Short SHA: ${SHORT_SHA}"
- echo "🔗 Pipeline: $CI_PIPELINE_URL"
- echo "Por favor revisa los logs para más detalles"
when: on_failure
rules:
- if: $CI_COMMIT_BRANCH == "main"
- if: $CI_COMMIT_BRANCH == "develop"
Optimizaciones
Si bien lo anterior funciona, como te habrás dado cuenta, los pipelines son relativamente extensos y sobre todo repetitivos.
Independientemente de la aplicación, siempre:
- Construimos una imagen y la subimos al registry.
- Actualizamos el helm chart.
- Hacemos sync con Argo
Esto significa que por cada repositorio o por cada servicio dentro del repositorio (en caso de tener monorepos) podemos reutilizar mucho del código del Pipeline. Esto podemos realizarlo gracias a los templates. Dejo links a documentación para no extender aún mas el post pero esto es imprescindible para poder manejar sobre una misma base, múltiples pipelines.
Estos templates ademas los podemos alojar en el repositorio de DevOps y los podemos reutilizar desde cualquier repositorio de aplicación lo cuál lo vuelve algo muy potente.
Templates:
Otro punto menos crítico pero que, en nuestro caso, nos funcionó mejor, fue desactivar el sync automático de ArgoCD. Esto puede parecer contraproducente porque cuando actualizas un Helm Chart, debes ir a Argo y hacer sync de forma manual. Sin embargo, esto nos permitió hacer una cosa que en la experiencia de usuario de los desarrolladores ha sido mejor.
Pongo contexto para explicar esto último. Antes teníamos el sync automático, es decir, Argo tiene la capacidad de hacer pulling de los repositorios con los que está enlazado. Por lo cuál solo subiendo un cambio al repo del helm chart, Argo lo lee y actualiza automáticamente la app en Kubernetes. Esto es realmente cómodo a nivel de infra.
Sin embargo, algo que ocurría es que los devs estaban pendientes de su Pipeline en el repositorio pero no tenían en cuenta ArgoCD. Por lo cuál, había ocasiones en que el Pipeline finalizaba de forma "exitosa" pero el sync de Argo fallaba por problemas de la aplicación o porque había un error de código o por lo que fuera pero no levantaba el pod con el nuevo código.
Esto provocaba falsos positivos porque los desarrolladores daban por bueno su despliegue cuando en varios casos podía no haber sido así. Para remediar esto, se desactivó el sync automático y es el mismo pipeline el que hace el sync con Argo mediante su CLI y además le añadimos un timeout, por ejemplo, de 5 minutos.
Tiempo en el cuál si la aplicación no termina de hacer sync con Argo, el Pipeline va a fallar y "alerta" de alguna forma al desarrollador para que revise qué ocurre con su despliegue. Lo que termina siendo mucho mas productivo para todos.
Con este sistema, a la fecha de publicación del post, desplegamos sobre ~15 clusters de Kubernetes, en 6 regiones, en 4 Clouds diferentes y en infraestructura de varias empresas.
Flujos de desarrollo
Esto si bien "se sale" del scope del post, quiero dedicar un pequeño apartado a esto ya que existen muchas formas de gestionar el flujo del desarrollo. Una habitual que en mi caso me gusta bastante, es poder tener una rama por entorno e ir promocionando ramas, ejemplo:

Este es el que me gusta mas, a titulo personal, ya que permite realizar desarrollos con agilidad, testear y validar bien en un entorno preproductivo los cambios a subir y cuando todo está validado, subir a producción. Con el tiempo he visto que este flujo es el que menos cantidad de bugs/errores tiene. Aunque debo admitir que requiere que el equipo tenga un buen nivel de Git y siga buenas practicas de desarrollo (creando ramas, revisando las PR, etc).
Este flujo, obviamente, no es obligatorio para el sistema de despliegue que hemos comentado. Esta sería otra ventaja del sistema de despliegue y es que se adapta a cualquier flujo de desarrollo:
- Truck Based Development.
- Basado en Releases.
- Despliegues con autorización manual.
- etc
Y vosotros. ¿Qué herramientas para hacer CI/CD utilizáis? ¿Habéis probado o hecho un flujo parecido? Espero ver vuestros comentarios. Nos leemos en la próxima 👋