GitOps con Terraform y Github

Projecto

Actualmente, estoy "ejerciendo" las labores de DevOps/Infra en un equipo multidisciplinar, o sea lo que viene siendo varias personas trabajando en varios microservicios que se despliegan en un mismo sitio, en este caso y por "imperativo legal" un cluster ECS de Amazon (yo hubiera preferido un EKS porque me manejo mejor con kubernetes).

Desde el principio hemos aplicado que todo el tema de infra tiene que ser bajo Terraform y hemos reducido las intervenciones manuales (tanto consola como web) al mínimo.

Así nuestro proyecto terraform despliega todo lo relativo a la red, DNS, bases de datos, secretos y cómo no, para bien o para mal, la definición de todos los microservicios.

Ignorando los detalles, básicamente tenemos:

  • Un proyecto de terraform que define los elementos "globales" entre ellos el repositorio Docker de cada servicio, exponiendo los arn como outputs

  • Diferentes proyectos de terraform con los recursos necesarios para desplegar cada servicio dentro del cluster, diferenciando además entre staging y prod

A cada proyecto terraform de cada servicio, entre otras cosas, se le define la imagen Docker y el tag de la misma a desplegar junto con otras configuraciones como la lista de secretos y environments a usar.

De esta forma, y como la arquitectura en esta fase está "viva", cada servicio se despliega de forma independiente y si es necesario se elimina sin interferir en los otros proyectos.

Servicio

Así, cada servicio cuenta con su locals.tf

locals {
  project           = "xxxxx"
  container_image   = "${data.terraform_remote_state.global.outputs.ecr_servicexxxx_url}:${var.service_tag}"
  ....
}

y en un fichero versions.auto.tfvars definimos el tag a desplegar:

service_tag = 1234567

Desplegar una nueva version es "tan fácil" como editar este fichero con el nuevo commit y ejecutar un terraform apply

GitOps

Obviamente, una vez que este sistema está funcionando y desplegar versiones se convierte en algo tan fácil como esperar a que una versión nueva se encuentre subida al ECR, cambiar el fichero y ejecutar el terraform, surge la "necesidad" de esto hay que automatizarlo

La primera parte para la automatización es "trivial": cada vez que en un servicio se mergee a main, un pipeline construirá y publicará la imagen.

Como estamos usando Github, esto lo resolvemos mediante una GH Action similar a:

deploy.yml
name: CD - Deploy Develop
on:
  push:
    branches: [main]

env:
  AWS_REGION: us-east-1
  ECR_REPOSITORY: servicexxxx

jobs:
    ....
  build:
    name: Build & Push
    runs-on: ubuntu-latest
    needs: test
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: Get short SHA
        id: shortsha
        run: echo "sha7=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT

      - name: Build, tag, and push image to Amazon ECR
        id: build-image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ steps.shortsha.outputs.sha7 }}
        run: |
          docker build --build-arg GITHUB_TOKEN=${{ steps.generate_token.outputs.token  }} -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
          IMAGE_URI="$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
          echo "image=$IMAGE_URI" >> $GITHUB_OUTPUT
          echo "✅ Built and pushed image: $IMAGE_URI"

Ahora que tenemos la imagen creada y subida (recuerda, esto es en cada servicio/proyecto) lo que querríamos es poder "notificar" al repositorio de Terraform que existe una versión nueva para desplegar.

Como esta parte es "común" a todos los servicios hemos creado un repositorio platform-ci donde, entre otros pipelines, tenemos uno para crear una PR en el repositorio de Terraform que modifique únicamente el versions correspondiente. De esta forma "reutilizamos" pipelines y no nos repetimos

Platform CI

El pipeline en este repo es similar a:

deploy-version.yml
name: Deploy service

on:
  workflow_call:
    secrets:
      GH_TOKEN_APP_ID:
        required: true
      GH_TOKEN_PRIVATE_KEY:
        required: true
    inputs:
      environment:
        required: true
        type: string
      project:
        required: true
        type: string
      variable:
        required: true
        type: string
      value:
        required: true
        type: string

jobs:
  update-version:
    runs-on: ubuntu-latest
    steps:
      - name: Generate GitHub App Token
        id: generate_token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ secrets.GH_TOKEN_APP_ID }}
          private-key: ${{ secrets.GH_TOKEN_PRIVATE_KEY }}

      - name: Checkout Infra Repo
        uses: actions/checkout@v4
        with:
          repository: OUR_ORGANIZATION/tr-infra
          token: ${{ steps.generate_token.outputs.token }}

      - name: Update Version File
        run: |
          cd environments/${{ inputs.environment }}/${{ inputs.project }}
          sed -i 's/${{ inputs.variable }} = .*/${{ inputs.variable }} = "${{ inputs.value }}"/' versions.auto.tfvars

      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v5
        with:
          token: ${{ steps.generate_token.outputs.token }}
          commit-message: "deploy: update ${{ inputs.variable }} (${{ inputs.environment }}) to ${{ inputs.value }}"
          title: "🚀 Deploy ${{ inputs.project }} (${{ inputs.environment }})"
          body: "Deploy new version of **${{ inputs.project }}** \n ${{inputs.variable}}=${{inputs.value}}"
          branch: "gitops/${{ inputs.variable }}-${{ inputs.value }}"
          base: main

Este pipeline requiere 4 parámetros:

  • environment (staging o production), referencia a una de las dos carpetas "padres"

  • project (service1, service2), referencia a una de las multiples subcarpetas donde tenemos definidos los servicios

  • variable, por ahora usamos "service_tag" pero lo dejamos abierto para futuros usos

  • value, el valor a usar que en este caso será el tag pero asi esta abierto para futuros usos

El pipeline simplemente clona el repo terraform, se ubica en la carpeta {environment}/{project}, modifica el valor proporcionado usando un simple sed y crea una PR

INFO

Por ahora las PR que se crean deben ser aprobadas de forma manual hasta que todo vaya más rodado

Así pues, simplemente tenemos que incluir en el pipeline de cada servicio una invocación a este una vez que la imagen se encuentra subida:

deploy.yml
  notify-infra:
    needs: build
    uses: OUR_ORGANIZATION/platform-ci/.github/workflows/deploy-version.yml@main
    secrets:
      GH_TOKEN_APP_ID: ${{ secrets.GH_TOKEN_APP_ID }}
      GH_TOKEN_PRIVATE_KEY: ${{ secrets.GH_TOKEN_PRIVATE_KEY }}
    with:
      environment: staging  (1)
      project: service1
      variable: service_tag
      value: ${{ needs.build.outputs.image_tag }}
1 En este caso desplegamos en staging, pero cuando es un merge versionado usamos production

Cuando la imagen ha sido publicada y este último steps se ejecuta, tenemos una PR creada en el repo de Terraform. En un proceso típico simplemente se mergea y el pipeline del proyecto Terraform despliega la última versión.

Este mecanismo nos permite de una forma fácil hacer rollback a la versión anterior, pues simplemente es hacer otra PR (esta vez manual) indicando el tag a usar, pero lo más importante (en mi opinión) permite al equipo ser "dueño" del proceso de despliegue sin depender de nadie.

Conclusion

La propuesta primera era incluir en cada servicio los comandos aws necesarios para modificar la Task Definition proporcionándole el tag a usar, pero esto creaba que el proyecto terraform se quedará "desactualizado".

La otra opción era "mover" la parte terraform a cada servicio, pero esto nos dificulta la reutilización de módulos de terraform y de alguna manera tendríamos la infra "distribuida" en varios proyectos. Por ahora nos sentimos más cómodos teniéndolo unificado en un único repo

En cualquier caso la idea es conseguir proporcionar herramientas y procedimientos simples al equipo lo más automatizados posible para que los despliegues sean fluídos

Este texto ha sido escrito por un humano

This post has been written by a human

2019 - 2026 | Mixed with Bootstrap | Baked with JBake v2.6.7 | Terminos Terminos y Privacidad