GitOps en AWS ECS con Github y Terraform

Introducción

Recientemente, he estado trabajando con un equipo en el que hemos implementado un sistema GitOps para el despliegue de los servicios en ECS (Elastic Container Service) de Amazon.

GitOps es un conjunto de prácticas para gestionar las configuraciones de las aplicaciones y las infraestructuras a fin de ampliar los procesos actuales y mejorar el ciclo de vida de las aplicaciones. Usa los repositorios de Git como única fuente de información para ofrecer una infraestructura como código (IaC) (https://www.redhat.com/es/topics/devops/what-is-gitops)

A grandes rasgos, la idea es que el equipo de desarrollo se "desentienda" de todos los aspectos de infraestructura necesarios para que su aplicación se despliegue en los diferentes entornos (staging y prod) usando Git como referencia.

Para ello debemos implementar procesos automáticos que reaccionen ante cambios en el repositorio y actúen en consecuencia. En este post vamos a ver cómo implementar estos procesos mediante el uso de Terraform y Github Actions

Básicamente, la organización de repositorios va a consistir en:

  • Un repositorio por servicio donde cada equipo de desarrollo sube sus cambios

  • Un repositorio de Infra terraform, donde reside toda la configuración necesaria no solo de máquinas, redes, etc. sino de aplicaciones con sus versiones. En este repo reside tanto la configuración de staging como prod.

El siguiente diagrama muestra de una forma simplificada la interaccion entre los sistemas:

Diagram

Por una parte, vemos cómo un push del equipo de desarrollo en un servicio desencadena una serie de acciones (Github Actions) que actualizan el repositorio de Infra y después aplican el cambio en AWS mediante Terraform en el entorno de staging. Esto hace que el equipo de dev pueda validar en este entorno los últimos cambios casi de forma instantánea (unos minutos)

Así mismo, un miembro del equipo, bien del propio equipo de desarrollo o un admin, decide cúando la versión actual de staging puede pasar a producción ejecutando un simple Github Action. (Esta parte puede llegar a automatizarse también, pero debido a que el producto a desarrollar se encontraba en sus fases iniciales se decidió que los pasos a producción serían controlados bajo demanda al menos por ahora)

Requisitos

Para la implementación de este flujo se requiere:

  • ECS (2) de AWS donde desplegar los servicios.

  • Proyecto Terraform que refleje toda la infra. En este post NO vamos a entrar en definir toda la infra desplegada, sólo las partes de interés

  • Organización GitHub con los secretos necesarios para poder acceder a AWS

Infra previa

Como se ha comentado, en este post NO vamos a detallar toda la infra creada. De forma general comentar que toda la infra se encuentra definida en un repositorio "tr-infra" donde se han creado diferentes carpetas:

  • global

    • ecr

  • staging

    • network

    • common

    • app1

  • prod

    • network

    • common

    • app1

Como se puede ver, tenemos definidos elementos globales como puede ser la definición de los containers de Docker para los servicios, certificados, etc.

También tenemos los entornos de staging y prod definidos. En common definimos (y desplegamos) el ECS en sí, mientras que en app1 definimos (y desplegamos) la aplicación app1

Infra básica

Como hemos mencionado toda la infra se encuentra definida en un repositorio tr-infra. NO se crea ningún recurso en AWS si no es a través de este repositorio. Para ello, este repositorio cuenta con Github Actions configurados para ejecutar Terraform contra nuestra cuenta en AWS de tal forma que se creen/actualicen/borren todos los recursos de nuestra organización.

Este repo se encuentra organizado en diferentes carpetas donde cada una gestiona recursos Terraform "similares".

Así por ejemplo, la carpeta environments/staging/shared define la red, subred, alarmas, etc. del entorno staging. También se encuentra definido el ECS de staging:

resource "aws_iam_role" "ecs_execution" {...}
resource "aws_iam_role_policy" "ecs_events_run_task" {...}
...
resource "aws_ecs_cluster" "main" {
  name = "cluster-${var.environment}"

  setting {
    name  = "containerInsights"
    value = "disabled"
  }

  tags = {
    Environment = var.environment
  }
}

Cuando se ejecuta tf apply en esta carpeta, Terraform dialoga con AWS para verificar que todos los recursos definidos en ella, incluido el ECS "custer-staging" se encuentran tal cual se indican en el código tf

Infra App1

App1 a su vez se encuentra en la carpeta environments/staging/app1 (y en prod una vez que el servicio está maduro para ser desplegado en este entorno), mediante sus ficheros terraform.

En esta carpeta definimos, entre otras cosas, el sufijo del API, parámetros, secretos, número de instancias a desplegar, etc. así como la definición del servicio dentro del ECS

locals {
  common_secrets = [
    {
      name      = "DB_URL"
      valueFrom = module.ssm_parameter["app1_db_url"].param_arn
    },
    ...
  ]
  app_env = [
    { name = "NODE_ENV", value = var.environment },
    { name = "PORT", value = "3001" },
    ...
  ]
}

module "ecs_service" {
  source = "../../../modules/ecs_app"

  name = local.project_lowercase
  environment = var.environment

  execution_role_arn = data.terraform_remote_state.shared.outputs.execution_role_arn
  task_role_arn      = data.terraform_remote_state.shared.outputs.task_role_arn

  ecs_cpu    = var.ecs_cpu
  ecs_memory = var.ecs_memory
  instances  = var.instances

  health_check = "/v1/health"

  container_definitions = jsonencode([
    {
      name      = "app"
      image     = local.container_image
      essential = true
      portMappings = [
        {
          containerPort = 3001
          hostPort      = 3001
          protocol      = "tcp"
        }
      ]

      secrets          = local.common_secrets
      environment      = local.app_env
    }
  ])

    ...
}

Obviamente, esto es sólo un extracto de una definición completa y sirve sólo para dar contexto a la idea.

De toda esta configuración, lo que nos atañe en este post es la línea:

image = local.container_image

Que es la que le indica a Terraform (y este a AWS) qué imagen debe estar ejecutándose en este servicio.

Esta variable container_image se encuentra definida en un fichero terraform locals.tf :

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

Donde vemos que es la unión a su vez de dos variables:

  • data.terraform_remote_state.global.outputs.ecr_app1_url, es decir, la URL al docker del servicio app1

  • var.service_tag

La magia reside precisamente en esta variable service_tag definida en un auto-vars de Terraform:

versions.auto.tfvars
service_tag = "8100271"
INFO

Tanta granuralidad en los ficheros de terraform permiten que los cambios sean pequeños y perfectamente indentificables

Mediante toda esta configuración podemos "asegurar" que la versión que se encuentra desplegada en el ECS del servicio app1 sea, por ejemplo, "app1:8100271" y que simplemente cambiando el valor de esta variable se desplegará una versión nueva en el ECS al ejecutar tf apply de nuevo

App1

Digamos que tenemos un repositorio en Github app1 donde versionamos el código de dicha aplicación. El equipo usa este repositorio de forma típica, creando ramas feature, hotfix, etc. y mergeandolas en main una vez que el pipeline se completa sin error. Es decir, cada push en main ha sido testeado en Github tanto como el equipo es capaz de testear.

Una vez que se ha mergeado en main, Github ejecuta a su vez los tests, genera una imagen docker usando el commit de la rama como tag y lo publica en ECR, por ejemplo usando algo parecido a este (fragmento) action:

  build:
    name: Build & Push
    runs-on: ubuntu-latest
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - 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: 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 push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
          IMAGE_URI="$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
          echo "image=$IMAGE_URI" >> $GITHUB_OUTPUT
          echo "✅ Built and pushed image: $IMAGE_URI"
    outputs:
      image_tag: ${{ steps.shortsha.outputs.sha7 }}

En este punto la aplicación ha subido una nueva imagen a su ECR con un tag asociado a un commit específico

Ahora lo que querríamos es que esta versión se desplegara automáticamente en staging por lo que añadimos un nuevo step:

  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
      project: app1
      variable: service_tag
      value: ${{ needs.build.outputs.image_tag }}

Este steps va a invocar a un Github action que se encuentra en otro repositorio, específicamente en OUR_ORGANIZATION/platform-ci donde OUR_ORGANIZATION sería nuestra organización y platform_ci, es el repositorio donde tenemos definidos una serie de scripts genéricos y reutilizables (notificar de fallos en los pipelines, de despliegues de versiones, etc.)

A este action le proporcionamos 4 argumentos:

  • El entorno donde queremos actuar. En nuestro caso siempre será staging

  • El proyecto. Tal vez se podría extraer del nombre del repositorio, en nuestro caso hemos optado por especificarlo

  • Variable, es el nombre de la variable que queremos actualizar. Corresponde con service_tag de cada auto-vars

  • Value, es el valor a actualizar en la variable anterior.

Básicamente, este step está invocando a un action de otro repo indicándole que actualize una variable del proyecto app1 en el entorno staging con el commit del repositorio.

platform-ci/.github/workflows/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 }} owner: OUR_ORGANIZATION

  • 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 }} FILE="versions.auto.tfvars" LINE='${{ inputs.variable }} = "${{ inputs.value }}"' if [ -f "$FILE" ]; then if grep -q "^${{ inputs.variable }} =" "$FILE"; then sed -i "s/^${{ inputs.variable }} = .*/$LINE/" "$FILE" else echo "$LINE" >> "$FILE" fi else echo "$LINE" > "$FILE" fi

  • name: Create Pull Request id: cpr 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.value }} to ${{ inputs.environment }}" body: "Deploy new version of ${{ inputs.project }} \n ${{inputs.variable}}=${{inputs.value}}" branch: "gitops/${{ inputs.project }}-${{ inputs.value }}" base: main

  • name: Automerge Pull Request if: steps.cpr.outputs.pull-request-operation == 'created' run: | gh pr merge "${{ steps.cpr.outputs.pull-request-number }}" --merge --auto || \ gh pr merge "${{ steps.cpr.outputs.pull-request-number }}" --merge env: GH_TOKEN: ${{ steps.generate_token.outputs.token }} ---

Este action automáticamente descarga la última versión del proyecto tr-infra y actualiza el fichero versions.auto.tfvars cambiando el valor de la variable que se le indica, es decir, si por ejemplo el contenido de este fichero fuera:

versions.auto.tfvars
log=INFO
service_tag=8901234

La ejecución de este action lo modificaría, por ejemplo, a:

versions.auto.tfvars
log=INFO
service_tag=1234567

Una vez actualizado el fichero crearía una PR sobre el proyecto tf-infra y automáticamente la mergearía

Visualmente, el desarrollador de app1 (o cualquier otro obviamente) podría observar cómo, tras hacer el merge a main, aparece una PR en el repo de tr-infra con los detalles del cambio en app1 y cómo esta PR se mergea automáticamente en el repo tr-infra

Despliegue en staging

Como se puede imaginar, el repositorio tr-infra cuenta a su vez con los actions necesarios para ejecutar los pasos necesarios de Terraform ante un cambio en alguna de las carpetas de interés. Como estas carpetas están organizadas por entorno y servicio puede determinar de forma fácil qué se quiere desplegar en AWS

Definimos cúando ejecutar el pipeline así como algunas variables globales:

github/workflows/terraform.yml
name: Terraform CI/CD

on:
  push:
    branches:
      - main
    paths:
      - 'environments/**'
env:
  AWS_REGION: ${{ vars.AWS_REGION || 'us-east-1' }}
  TF_VERSION: '1.14.6'

Changes realiza la magia de determinar qué entorno/servicio se está desplegando a partir de la carpeta modificada

github/workflows/terraform.yml
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      directories: ${{ steps.set-matrix.outputs.directories }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0 # Important

      - name: Get changed directories
        id: set-matrix
        run: |
          BASE_SHA=${{ github.event.pull_request.base.sha || github.event.before }}
          DIFF=$(git diff --name-only $BASE_SHA ${{ github.sha }} | grep -E '^(environments/|azure/)' || true)
          if [ -z "$DIFF" ]; then
            echo "directories=[]" >> $GITHUB_OUTPUT
            exit 0
          fi
          DIRS=$(echo "$DIFF" | xargs -I {} dirname {} | sort -u | while read dir; do
            if ls $dir/*.tf >/dev/null 2>&1; then
              echo "$dir"
            fi
          done | jq -R -s -c 'split("\n")[:-1]')
          echo "directories=$DIRS" >> $GITHUB_OUTPUT

Instalamos y ejecutamos terraform únicamente si se ha detectado un cambio de interés:

github/workflows/terraform.yml
  terraform:
    needs: [changes]
    if: ${{ needs.changes.outputs.directories != '[]' && needs.changes.outputs.directories != '' }}
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
      pull-requests: write
      issues: write
    strategy:
      fail-fast: false
      matrix:
        path: ${{ fromJSON(needs.changes.outputs.directories) }}

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - 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 }}

Ejecutamos Terraform sobre la carpeta modificada

github/workflows/terraform.yml
      - name: Terraform Init
        run: |
          cd ${{ matrix.path }}
          terraform fmt -check -recursive
          terraform init -backend=${{ github.event_name == 'push' || github.event_name == 'pull_request' }}

      - name: Terraform Validate
        run: |
          cd ${{ matrix.path }}
          terraform validate

      # Podemos ejecutar un plan y añadir los cambios como comentarios a la PR

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: |
          cd ${{ matrix.path }}
          terraform apply -input=false -auto-approve

En este punto Terraform está comunicándose con AWS para verificar que todos los recursos definidos en el repositorio se encuentran desplegados en AWS tal como se define en el código, creando/actualizando/borrando lo necesario, de tal forma que el cambio en el auto-vars provoca que AWS despliegue una nueva versión del servicio.

(En nuestro pipeline podemos hacer también que AWS, una vez desplegada la nueva versión del servicio, se quede a la escucha y compruebe que la nueva versión se ha desplegado correctamente y mande una alarma si no es así)

Promote to prod

En nuestro caso se decidió que el paso a prod sería de forma "manual". Con esto queremos decir que la decisión de promocionar un servicio, app1, a producción sería tomada por el equipo cuando determinara que staging es correcto. Para ello "simplemente" habría que actualizar el auto-vars de prod con el actual de staging

INFO

Para ello antes hubo la labor de infra de replicar y ajustar la carpeta tomando como base staging en prod

Para evitar errores se implementó una Github Action en el repo de tf-infra que usando las capacidades ya vistas de GitHub para crear y mergear automáticamente PRs pidiera qué servicio (carpeta) se desea promover y simplemente actualizaba el fichero autovars de prod con el valor de staging.

Al mergearse automáticamente la PR, el Github action anteriormente explicado lo detectaría y se volvería a ejecutar, esta vez sobre una carpeta del entorno prod

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