Módulo 5: Apply On Merge The Cd Half

5. Control de concurrencia: evitar el doble `apply`

Descripción

apply.yml ya funciona de punta a punta, pero tiene un hueco que no se nota hasta que dos personas fusionan dos Pull Requests casi al mismo tiempo: nada le impide correr dos veces en paralelo, cada corrida con su propio terraform apply, contra el mismo state. Esta lección cierra ese hueco con concurrency:, el bloque que convierte "cualquier push a main dispara una corrida nueva" en "solo una corrida de apply.yml a la vez, nunca dos escribiendo al mismo tiempo".

Conexión con el módulo

Esta lección extiende el apply.yml de la lección 4 con una sola pieza nueva, sin tocar ningún job existente. La lección 6 cambia de tema —detección de drift—, pero el archivo que construyes aquí sigue siendo, para siempre, el apply.yml que el resto de esta guía usa.


Analogía: un solo cajero por caja registradora

Imagina una caja registradora con un solo cajón de dinero. Si dos cajeros intentaran operar la misma caja al mismo tiempo —uno cobrando, otro dando cambio, ambos abriendo el cajón a la vez— el conteo final del dinero terminaría mal, sin que ninguno de los dos haya cometido un error individual: el problema es que dos personas tocaron el mismo recurso compartido al mismo tiempo. La solución no es "que cada cajero cuente con más cuidado" — es que solo un cajero a la vez tenga acceso a esa caja específica, sin importar cuántos cajeros existan en la tienda. concurrency: es exactamente esa regla, aplicada al state de Terraform: no importa cuántos push a main ocurran, solo una corrida de apply.yml puede estar escribiendo al state de Andes Cargo a la vez.


Por qué esto es un riesgo real, no teórico

Ya conoces la causa raíz desde terraform-and-iac-guide: el state es la fuente de verdad de Terraform, y dos procesos escribiéndolo al mismo tiempo pueden corromperlo o dejarlo inconsistente con la infraestructura real —el mismo riesgo de lock que esa guía ya enseñó en su Módulo 4—. Ahí, ese riesgo era hipotético: eras la única persona corriendo terraform apply, desde tu propia laptop, una vez a la vez, por definición. Con apply.yml corriendo automáticamente en cada push a main, el riesgo se vuelve real por primera vez en esta guía:

   SIN concurrency:                          CON concurrency:

   push #1 a main ──► apply.yml corrida A     push #1 a main ──► apply.yml corrida A (corre)
   push #2 a main ──► apply.yml corrida B         │
        (casi simultáneo)                          │  push #2 a main ──► apply.yml corrida B
                                                     │       (en cola, espera a que A termine)
   A y B intentan escribir                          ▼
   el mismo state.tfstate                     B arranca recién cuando A termina
   AL MISMO TIEMPO                            (o se cancela, según cancel-in-progress)

   riesgo real de state lock /               una sola corrida escribiendo
   corrupción / resultado inconsistente      el state a la vez, siempre

Dos Pull Requests distintos, aprobados y fusionados con pocos minutos de diferencia, disparan dos corridas independientes de apply.yml — cada una, sin concurrency:, empezaría a correr de inmediato, sin saber que la otra existe.


El bloque concurrency:

name: apply

on:
  push:
    branches: [main]

concurrency:
  group: apply-andes-cargo-infra
  cancel-in-progress: false

Dos campos, cada uno con una decisión detrás:

  • group: — un identificador de texto libre que agrupa corridas relacionadas. GitHub Actions garantiza que, entre todas las corridas de workflow que compartan el mismo group, solo una puede estar "en progreso" a la vez —las demás quedan en cola, esperando—. Aquí, el valor es fijo (apply-andes-cargo-infra) porque todo push a main de este repositorio debería competir por el mismo cupo — no tiene sentido que dos corridas de apply.yml sobre el mismo state corran en paralelo, sin importar qué Pull Request las originó.
  • cancel-in-progress: false — la decisión más importante de este bloque, y la que más vale la pena razonar. true cancelaría la corrida en curso apenas llegue una nueva del mismo grupo; false —el valor que usa apply.yml— dice, en cambio, "dejá que la corrida actual termine, y recién después empezá la siguiente, en orden". Para un despliegue de aplicación (una imagen de contenedor, un sitio estático) cancelar una corrida vieja en favor de la más nueva suele ser lo correcto: no importa la versión intermedia, solo la última. Para un terraform apply a mitad de camino, cancelar sería peor que esperar: un apply interrumpido a la mitad puede dejar el state reflejando solo una parte de los recursos, con la infraestructura real en un punto intermedio no representado en ningún plan — el mismo tipo de inconsistencia que esta lección entera intenta evitar. Por eso apply.yml elige false: cada corrida completa su trabajo antes de que empiece la siguiente, sin excepción.

Ejecutándolo: el YAML es válido, el job corre igual

concurrency: no cambia nada sobre cómo corre un job individual bajo act — solo confirma que el parseo es correcto y que el job sigue funcionando con el bloque agregado:

act -l -W .github/workflows/apply.yml

Qué esperar (literal) — la misma tabla de siempre, sin ninguna columna nueva relacionada con concurrency::

Stage  Job ID                Job name              Workflow name  Workflow file  Events
0      fetch-reviewed-plan   fetch-reviewed-plan   apply          apply.yml      push
1      terraform-apply       terraform-apply        apply          apply.yml      push
act push -W .github/workflows/apply.yml --artifact-server-path ./.artifacts --artifact-server-addr "$ARTIFACT_ADDR"

El resultado es idéntico al de la lección 4: fetch-reviewed-plan corre y tiene éxito, terraform-apply corre y falla en el mismo punto exacto (connection refused sin LocalStack) — concurrency: no altera en absoluto el comportamiento de una corrida individual, porque su efecto solo existe cuando hay más de una corrida compitiendo por el mismo group, algo que una sola invocación de act nunca produce.


Por qué act no puede demostrar el bloqueo (representativo, con la razón exacta)

Aquí llega la honestidad que ya esperarías de esta guía. concurrency: es un mecanismo que GitHub Actions aplica entre corridas de workflow, coordinado centralmente por la infraestructura de GitHub —una cola compartida que sabe, en todo momento, cuántas corridas de un group específico existen y en qué estado están—. Cada invocación de act en tu máquina es un proceso completamente aislado: no hay ningún servidor central, ninguna cola compartida, ninguna forma de que dos invocaciones de act, corridas por separado, se enteren la una de la otra. Aunque abrieras dos terminales y corrieras act push -W apply.yml en ambas al mismo tiempo, cada una correría de forma completamente independiente, sin ningún bloqueo entre ellas —act simplemente no implementa el concepto de group en absoluto—.

Esto no es una laguna de este módulo: es la misma familia de limitación que ya viste con environment: y required reviewers en el Módulo 4 (lección 6) — un mecanismo real, correcto, necesario en producción, que depende de una coordinación centralizada que solo existe en GitHub.com real. concurrency: se suma a esa lista: funciona exactamente como se describe aquí en un repositorio real, y no hay forma honesta de demostrar el bloqueo con act local.


Profundización: qué pasaría en un repositorio real

Para que la teoría no quede abstracta, vale la pena describir con precisión qué verías en GitHub.com real, aunque no puedas reproducirlo aquí. Si fusionas el Pull Request A y, treinta segundos después, alguien más fusiona el Pull Request B:

  1. La corrida de apply.yml para A arranca de inmediato, con estado "In progress".
  2. La corrida de apply.yml para B también se dispara —el evento push ocurrió, GitHub la encola—, pero en vez de empezar a correr, su estado en la pestaña Actions muestra "Queued", con una nota explícita indicando que está esperando a que termine una corrida anterior del mismo group.
  3. Cuando la corrida de A termina —éxito o fallo, no importa cuál— la corrida de B arranca automáticamente, sin que nadie tenga que reintentarla a mano.

Ninguna corrida se pierde, ninguna se cancela (por el cancel-in-progress: false), y en ningún momento hay dos terraform apply escribiendo al mismo state simultáneamente.


Errores comunes

Poner cancel-in-progress: true "porque suena más eficiente" (el error central de esta lección). Qué pasa: alguien, familiarizado con patrones de CI de código de aplicación —donde cancelar una build vieja en favor de una nueva es normal y deseable—, copia ese mismo valor a apply.yml. Por qué pasa: en la mayoría de los pipelines de CI/CD que la gente conoce (tests, builds), cancelar lo viejo por lo nuevo es la norma. Cómo detectarlo: si tu apply.yml tiene cancel-in-progress: true. Cómo corregirlo: para infraestructura, un apply cancelado a mitad de camino puede dejar recursos a medio crear y el state reflejando solo una parte de lo que realmente existe — un problema mucho peor que esperar unos minutos extra en la cola. false es la elección correcta específicamente para este archivo.

Usar un group: distinto por cada Pull Request (de diseño). Qué pasa: alguien escribe group: apply-${{ github.event.pull_request.number }} o algo similar, pensando que cada cambio necesita su propio grupo. Cómo detectarlo: si tu group: incluye alguna variable que cambia entre Pull Requests distintos. Cómo corregirlo: el propósito de concurrency: aquí es proteger el state compartido, no aislar cada Pull Request — un group que varía por PR permitiría que dos apply de PRs distintos corran en paralelo de todas formas, exactamente el problema que esta lección resuelve. El group de apply.yml debe ser fijo, uno solo, compartido por todo push a main de este repositorio.

Esperar ver el bloqueo demostrado bajo act (de expectativa). Qué pasa: alguien intenta correr dos instancias de act push en paralelo, esperando ver una en cola. Cómo corregirlo: como explicó esta lección, act no implementa group en absoluto — cada invocación corre de forma completamente aislada. Confía en que el mecanismo funciona en GitHub real, sin intentar demostrarlo localmente.


Ejercicios

Ejercicio 1 — Explica cancel-in-progress: false con la analogía del cajero. Sin mirar esta lección, explica por qué "el cajero termina de cobrar antes de que el siguiente cliente pueda empezar" es una mejor analogía para apply.yml que "el cliente más reciente pasa primero".

Ver solución

Una respuesta completa suena, más o menos, así: "Si el segundo cliente 'cortara' la transacción del primero a mitad de camino, la caja quedaría con un cobro incompleto — ni el dinero del primer cliente está bien contado, ni el segundo cliente puede empezar sobre una caja consistente. Con terraform apply, cancelar una corrida a mitad de camino deja el state reflejando solo algunos de los recursos que se estaban creando, sin que quede claro cuáles — mucho peor que hacer esperar a la segunda corrida unos minutos hasta que la primera termine limpiamente."

Ejercicio 2 — Decide el group correcto para un segundo proyecto. Si Andes Cargo tuviera un segundo proyecto de Terraform completamente independiente —por ejemplo, andes-cargo-analytics/, con su propio state, en un repositorio distinto—, ¿debería compartir el mismo group: que andes-cargo-infra/? Justifica.

Ver solución

No. El propósito de group: es evitar que dos corridas que escriben al mismo state corran en paralelo — si andes-cargo-analytics/ tiene su propio state completamente separado, no hay ningún riesgo de conflicto entre un apply de ese proyecto y uno de andes-cargo-infra/; forzarlos al mismo group solo haría que uno esperara innecesariamente al otro, sin ningún beneficio de seguridad. Cada state independiente debería tener su propio group, típicamente nombrado de forma que identifique el proyecto específico.

Ejercicio 3 — Predice el estado de una corrida en cola. En un repositorio real de GitHub, si fusionas dos Pull Requests con treinta segundos de diferencia, ¿qué verías en la pestaña Actions para la segunda corrida de `apply.yml' mientras la primera todavía está aplicando?

Ver solución

Verías la segunda corrida con el estado "Queued" (en cola), con una indicación de que está esperando porque otra corrida del mismo group (apply-andes-cargo-infra) todavía está en progreso. No arrancaría, no se cancelaría, no fallaría — simplemente esperaría, y arrancaría automáticamente en cuanto la primera corrida termine, sea cual sea su resultado.


Resumen y siguiente paso

En esta lección agregaste concurrency: { group: apply-andes-cargo-infra, cancel-in-progress: false } a apply.yml, entendiste por qué dos terraform apply simultáneos contra el mismo state son un riesgo real —el mismo riesgo de lock que terraform-and-iac-guide ya enseñó, ahora posible por primera vez porque el apply corre automáticamente—, y confirmaste que el YAML es válido y el job sigue corriendo igual bajo act. También viste, con la honestidad ya esperada de esta guía, por qué act no puede demostrar el bloqueo real: es un mecanismo de coordinación centralizada de GitHub, sin equivalente en una simulación local aislada.

Antes de avanzar deberías poder: escribir el bloque concurrency: de memoria; explicar por qué cancel-in-progress: false es la elección correcta para infraestructura, distinta de lo que elegirías para un pipeline de aplicación; y describir, sin poder demostrarlo con act, exactamente qué verías en GitHub real cuando dos corridas compiten por el mismo group.

apply.yml queda completo con esta lección: disparador correcto (lección 2), jobs encadenados con el plan exacto (lecciones 3 y 4), y protegido contra corridas simultáneas (esta lección). La lección 6 cambia de tema: la detección de cambios que ocurren fuera de este pipeline por completo.

Recursos

  1. GitHub Docs — Using concurrency — documentación oficial completa de concurrency:, group: y cancel-in-progress:.
  2. terraform-and-iac-guide, Módulo 4 (NIEVA) — la explicación original del riesgo de state lock en Terraform, retomado aquí en el contexto de un pipeline automatizado.
  3. Módulo 4 de esta guía (06-github-environments-dev-and-prod.md) — la misma familia de limitación de act (mecanismos de coordinación centralizada de GitHub, no demostrables localmente).