Ocho módulos han construido una arquitectura completa: seis subredes, dos grupos de autoescalado, cinco funciones Lambda, cuatro colas, un clúster Aurora, dos tablas de DynamoDB, cinco buckets, un balanceador, una distribución de CloudFront, catorce alarmas y un pipeline que despliega sin cortar el servicio. Todo funciona. Y nada de eso existe en ningún fichero: existe porque alguien escribió un comando algún día. Esta lección arregla la asimetría que Marta señaló al cerrar el módulo 8 con la herramienta nativa de AWS para hacerlo: CloudFormation, el servicio que convierte un fichero de texto en recursos reales y mantiene la relación entre ambos.

Aviso de coste. CloudFormation es gratuito cuando gestiona recursos de AWS: pagas los recursos, no las pilas. Solo cobra por recursos de terceros y tipos privados registrados (0,0009 USD por segundo de manejador). La plantilla de red crea dos NAT gateway: unos 0,045 USD/hora cada uno más tráfico, unos 65 USD al mes si la dejas puesta. El apartado de limpieza la borra con un comando. Datos ficticios.

Contenido

  1. La asimetría entre código e infraestructura
  2. Qué es la infraestructura como código, y declarativo frente a imperativo
  3. Anatomía de una plantilla
  4. Parámetros: tipos, restricciones y valores desde SSM
  5. Mappings, Conditions y funciones intrínsecas
  6. Pilas: ciclo de vida, estados y ROLLBACK_COMPLETE
  7. Políticas de eliminación y protección de terminación
  8. Conjuntos de cambios y los tres tipos de actualización
  9. Detección de deriva
  10. La red de MercadoFresco en una plantilla
  11. La capa de aplicación que importa las salidas
  12. Varias pilas: exportaciones frente a SSM
  13. Pilas anidadas, módulos y recursos personalizados
  14. Importar recursos existentes a una pila
  15. StackSets y despliegue desde el pipeline
  16. Buenas prácticas, cfn-lint y cfn-guard
  17. Límites, coste y limpieza
  18. Errores comunes y consejos
  19. Ejercicios
  20. Conclusión

La asimetría entre código e infraestructura

Marta hace inventario en una tarde y apunta cuatro hechos:

Hecho Evidencia Consecuencia
Nadie puede recrear el entorno No hay documento ni script que lo describa Una región nueva es un proyecto de semanas
Preproducción no es igual que producción Su SG permite 0.0.0.0/0 en el 22 Los incidentes no se reproducen a tiempo
No hay registro de por qué La regla de EventBridge no tiene autor ni motivo Nadie se atreve a borrar nada
El pipeline se creó a mano Un JSON en el portátil de Luis Lo que gobierna el código no está gobernado

El tercero es el más caro. En el módulo 5 apareció un grupo de seguridad con una regla hacia un rango de IP que nadie reconocía y nadie borró: podía ser de un proveedor. Ese es el coste real de no tener infraestructura como código: no es que no puedas crearla; es que no puedes cambiarla con confianza.

Qué es la infraestructura como código: declarativo frente a imperativo

Consiste en describir la infraestructura en ficheros de texto versionados en Git y dejar que una herramienta haga coincidir la realidad con la descripción. No es "automatizar la creación": es que el fichero sea la fuente de verdad. De ahí salen cuatro propiedades:

  • Reproducibilidad. El mismo fichero produce el mismo resultado en otra región, en otra cuenta o dentro de seis meses.
  • Revisión. La infraestructura pasa por una pull request como el código de 08-01: un cambio en un grupo de seguridad se comenta, se aprueba y queda con autor, fecha y motivo.
  • Deriva controlada y documentación viva. La herramienta sabe qué debería existir, así que puede compararlo con lo que existe —sin IaC la deriva es invisible por definición— y el fichero no puede quedarse obsoleto respecto a la realidad, porque es lo que la crea.
  • Se puede borrar sin miedo. Cuando levantar un entorno cuesta un comando, apagarlo el viernes deja de ser un riesgo y pasa a ser un ahorro. Es la propiedad que convence a los escépticos.

MercadoFresco ha usado hasta ahora el enfoque imperativo: comandos que describen cómo llegar al estado. CloudFormation es declarativo: describes qué quieres y el servicio calcula los pasos.

# Asi se creo la VPC en el modulo 3. Ejecuta este bloque dos veces y tendras DOS VPC.
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --profile mercadofresco-dev
aws ec2 create-subnet --vpc-id vpc-0a1b2c3d --cidr-block 10.0.1.0/24 --availability-zone eu-west-1a
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --vpc-id vpc-0a1b2c3d --internet-gateway-id igw-04e5f6a7

El script no sabe nada del estado anterior: cada comando crea. Hacerlo idempotente exigiría envolver cada línea en una comprobación, gestionar los identificadores generados y decidir qué hacer si el recurso existe con otra configuración; es decir, reimplementar CloudFormation en Bash.

Aspecto Scripts de la CLI (imperativo) CloudFormation (declarativo)
Qué describes Los pasos para llegar al estado El estado deseado
Ejecutar dos veces Duplica recursos o falla No hace nada: ya coincide
Actualizar / orden de creación Otro script; esperas manuales Editas el fichero; el orden se deduce de las dependencias
Fallo a medias Deshacer manual, a veces imposible Reversión automática de la pila
Borrar todo / inventario Script inverso a mano / lista manual delete-stack / la pila es el inventario
Cambios manuales Indetectables Detección de deriva

Lo imperativo no desaparece: sigue siendo lo correcto para operaciones puntuales (aws s3 cp, reiniciar una instancia, consultar métricas). La regla: si el recurso debe seguir existiendo mañana, va en una plantilla; si es una acción que ocurre una vez, va en un comando.

Anatomía de una plantilla

Un documento YAML o JSON con nueve secciones posibles. Solo Resources es obligatoria.

AWSTemplateFormatVersion: '2010-09-09'   # Version del formato. La unica que existe.
Description: Red base de MercadoFresco.  # Documentacion. Ponla siempre.
Metadata: { 'AWS::CloudFormation::Interface': {} }   # Para herramientas y consola. No despliega nada.
Parameters:                              # Entradas: hacen la plantilla reutilizable entre entornos.
  Entorno: { Type: String, AllowedValues: [ desarrollo, preproduccion, produccion ] }
Mappings: { PorEntorno: { produccion: { NatPorAz: 2 } } }   # Tablas de consulta estaticas
Conditions:                              # Booleanos a partir de parametros; habilitan recursos.
  EsProduccion: !Equals [ !Ref Entorno, produccion ]
Transform: AWS::Serverless-2016-10-31    # Macros. SAM es la mas conocida. Opcional.
Resources:                               # LA UNICA SECCION OBLIGATORIA.
  Vpc: { Type: 'AWS::EC2::VPC', Properties: { CidrBlock: 10.0.0.0/16 } }
Outputs:                                 # Valores que la pila publica, para leer o para importar.
  IdVpc: { Value: !Ref Vpc }

Faltan Rules (validación de combinaciones de parámetros) y Hooks, mucho menos habituales. La estructura de un recurso siempre es la misma, con Type y Properties obligatorios y el resto opcional:

  NombreLogico:                    # Identificador en la plantilla. NO es el nombre del recurso.
    Type: AWS::EC2::Subnet         # AWS::<servicio>::<recurso>
    Condition: EsProduccion        # Opcionales: condicion, dependencia explicita y politicas de
    DependsOn: [ AdjuntarIgw ]     # borrado y de reemplazo, que se explican mas abajo
    DeletionPolicy: Retain
    Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.1.0/24 }

Primera regla importante: cambiar el nombre lógico de un recurso equivale a borrarlo y crear otro. CloudFormation identifica los recursos por su nombre lógico, no por lo que hacen. Renombrar Vpc a VpcPrincipal en una plantilla desplegada destruye la VPC.

Parámetros: tipos, restricciones y valores desde SSM

Sin parámetros harían falta tres plantillas casi idénticas para los tres entornos.

Parameters:
  Entorno:                                  # Sin Default a proposito: obliga a decidirlo cada vez.
    Type: String
    AllowedValues: [ desarrollo, preproduccion, produccion ]
  CidrVpc:
    Type: String
    Default: 10.0.0.0/16
    AllowedPattern: '^(\d{1,3}\.){3}\d{1,3}/\d{1,2}$'
    ConstraintDescription: Debe ser un CIDR valido, por ejemplo 10.0.0.0/16.
  CapacidadMinima: { Type: Number, Default: 2, MinValue: 1, MaxValue: 10 }
  Propietario:     { Type: String, Default: marta, MinLength: 2, MaxLength: 32 }
  ClaveSsh:     { Type: 'AWS::EC2::KeyPair::KeyName' }   # Valida que exista ANTES de crear nada
  SubredesApp:  { Type: 'List<AWS::EC2::Subnet::Id>' }   # Lista de subredes existentes
  Contrasena:   { Type: String, NoEcho: true }           # Oculta en consola y eventos. NO cifra.
  AmiTienda:                                # El valor NO es la ruta: CloudFormation resuelve el
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>    # parametro de SSM y usa su contenido
    Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64

Tres cosas separan una plantilla amateur de una profesional:

  1. Los tipos específicos de AWS validan antes de empezar. Con Type: String para una clave SSH inexistente, la pila crea media docena de recursos y falla al llegar a la instancia. Con AWS::EC2::KeyPair::KeyName falla en el segundo cero, sin crear nada.
  2. AWS::SSM::Parameter::Value<...> desacopla la plantilla de los valores. La AMI de la tienda cambia cada mes; la plantilla no debería cambiar por eso. MercadoFresco lo usa con /mercadofresco/produccion/ami-tienda.
  3. NoEcho no cifra, solo oculta. Las contraseñas no van en parámetros: van en Secrets Manager (04-03) y se referencian con resolución dinámica, que se evalúa en el despliegue sin que el valor entre nunca en la plantilla:
  # La misma sintaxis funciona con ssm y ssm-secure para Parameter Store
  MasterUserPassword: '{{resolve:secretsmanager:mercadofresco/produccion/rds/mfadmin:SecretString:password}}'

Mappings, Conditions y funciones intrínsecas

Mappings es una tabla de consulta estática de dos niveles, útil para variar por región o por entorno; se lee con Fn::FindInMap. Conditions calcula booleanos con Fn::Equals, Fn::And, Fn::Or y Fn::Not.

Mappings:
  PorEntorno:
    desarrollo:    { NatPorAz: 1, Tipo: t3.small,  RetencionRegistros: 7 }
    preproduccion: { NatPorAz: 1, Tipo: t3.medium, RetencionRegistros: 30 }
    produccion:    { NatPorAz: 2, Tipo: m6i.large, RetencionRegistros: 365 }

Conditions:
  EsProduccion: !Equals [ !Ref Entorno, produccion ]
  EsPreproduccion: !Equals [ !Ref Entorno, preproduccion ]
  NecesitaAltaDisponibilidad: !Or [ !Condition EsProduccion, !Condition EsPreproduccion ]
  CrearSegundoNat: !Equals [ !FindInMap [ PorEntorno, !Ref Entorno, NatPorAz ], 2 ]

Se leen con !FindInMap [ PorEntorno, !Ref Entorno, Tipo ]. Un recurso con Condition: CrearSegundoNat solo se crea si la condición es cierta: nat-mercadofresco-b existe en producción y no en desarrollo con la misma plantilla, y eso ahorra unos 32 USD al mes por entorno no productivo. Aviso: úsalas con moderación. Una plantilla con doce condiciones anidadas es ilegible e imposible de razonar cuando falla; si la diferencia entre entornos es grande, hacen falta plantillas distintas, no más condiciones.

Funciones intrínsecas

Cada una tiene forma larga (Fn::Sub) y corta (!Sub). En YAML se usa la corta salvo cuando hay que anidar dos cortas seguidas, que YAML no permite.

Función Qué devuelve Ejemplo en MercadoFresco
!Ref Identificador "natural" del recurso o valor del parámetro !Ref Vpcvpc-0a1b2c3d
!GetAtt Un atributo del recurso !GetAtt Alb.DNSName
!Sub Cadena con sustitución de variables !Sub 'mercadofresco-${Entorno}-registros'
!Join / !Select / !Split Une, extrae el elemento N y divide listas !Select [ 0, !GetAZs '' ]
!ImportValue Valor exportado por otra pila !ImportValue mercadofresco-red-IdVpc
!FindInMap / !If Valor de un Mappings / valor condicional !If [ EsProduccion, m6i.large, t3.small ]
!GetAZs / !Cidr AZ de la región / subredes calculadas !Cidr [ 10.0.0.0/16, 6, 8 ]

Lo que más confusión genera es !Ref, porque devuelve cosas distintas según el tipo: en una VPC o subred, su identificador; en un bucket, el nombre; en una cola de SQS, la URL; en un tema de SNS, el ARN; en un rol de IAM, el nombre; en una Lambda, el nombre. Esa tabla explica el 80 % de los errores tipo Value of property X must be of type String. Cuando necesites un ARN, casi siempre es !GetAtt Recurso.Arn; la sección "Return values" de cada tipo tiene la lista exacta.

      # Sustitucion de parametros y pseudoparametros
      BucketName: !Sub 'mercadofresco-${Entorno}-registros-${AWS::AccountId}'
      # Atributos de otros recursos: la sintaxis con punto tambien vale
      Value: !Sub 'https://${Distribucion.DomainName}/catalogo'
      # Con mapa de variables locales, cuando hace falta anidar otra funcion
      Description: !Sub
        - 'Cola de pedidos del entorno ${Ent} en la cuenta ${Cuenta}'
        - { Ent: !Ref Entorno, Cuenta: !Ref 'AWS::AccountId' }
      # En desarrollo, sin instantanea final; en produccion, con ella: NoValue omite la propiedad
      FinalSnapshotIdentifier: !If [ EsProduccion, !Sub 'final-${AWS::StackName}', !Ref 'AWS::NoValue' ]

Los pseudoparámetros son variables que AWS proporciona siempre: AWS::AccountId (111122223333), AWS::Region (eu-west-1), AWS::StackName, AWS::StackId, AWS::Partition, AWS::URLSuffix y AWS::NoValue.

Pilas: ciclo de vida, estados y ROLLBACK_COMPLETE

Una pila es una plantilla desplegada: recursos que se crean, actualizan y borran como una unidad. Es la unidad de gestión, de permisos y de deriva.

stateDiagram-v2
    [*] --> CREATE_IN_PROGRESS: create-stack
    CREATE_IN_PROGRESS --> CREATE_COMPLETE: todo bien
    CREATE_IN_PROGRESS --> ROLLBACK_IN_PROGRESS: fallo en la creacion
    ROLLBACK_IN_PROGRESS --> ROLLBACK_COMPLETE: recursos deshechos
    ROLLBACK_COMPLETE --> [*]: solo se puede BORRAR
    CREATE_COMPLETE --> UPDATE_IN_PROGRESS: update-stack
    UPDATE_IN_PROGRESS --> UPDATE_COMPLETE: todo bien
    UPDATE_IN_PROGRESS --> UPDATE_ROLLBACK_COMPLETE: fallo, vuelve atras y se puede reintentar
    CREATE_COMPLETE --> DELETE_IN_PROGRESS: delete-stack
    DELETE_IN_PROGRESS --> DELETE_FAILED: un recurso no se deja borrar

El estado que desconcierta el primer día es ROLLBACK_COMPLETE: ocurre cuando falla la creación inicial y CloudFormation deshace lo creado. La pila queda existiendo pero en estado terminal: no se puede actualizar, solo borrar y volver a crear con la plantilla corregida. No confundir con UPDATE_ROLLBACK_COMPLETE, que sí es sano: la pila volvió a su versión anterior y se puede seguir actualizando.

aws cloudformation validate-template --template-body file://red-mercadofresco.yaml   # solo sintaxis
aws cloudformation create-stack --stack-name mercadofresco-red-produccion \
  --template-body file://red-mercadofresco.yaml \
  --parameters ParameterKey=Entorno,ParameterValue=produccion \
  --tags Key=Proyecto,Value=mercadofresco Key=Entorno,Value=produccion Key=Componente,Value=red \
  --enable-termination-protection --profile mercadofresco-dev --region eu-west-1
aws cloudformation wait stack-create-complete --stack-name mercadofresco-red-produccion
# El PRIMER CREATE_FAILED es la causa real; los demas son consecuencias
aws cloudformation describe-stack-events --stack-name mercadofresco-red-produccion \
  --query 'StackEvents[?ResourceStatus==`CREATE_FAILED`].[LogicalResourceId,ResourceStatusReason]' \
  --output table

Las etiquetas de la pila se propagan a todos los recursos que las admitan: es la forma más barata de cumplir el etiquetado obligatorio de MercadoFresco sin repetirlo recurso a recurso (se retoma en 11-02). Para el día a día, deploy crea la pila si no existe y la actualiza si existe, con un conjunto de cambios por debajo:

aws cloudformation deploy --stack-name mercadofresco-red-produccion \
  --template-file red-mercadofresco.yaml --parameter-overrides Entorno=produccion \
  --no-fail-on-empty-changeset --capabilities CAPABILITY_NAMED_IAM

--no-fail-on-empty-changeset evita que el pipeline se ponga en rojo cuando no hay nada que cambiar, el caso más frecuente. --capabilities es obligatorio cuando la plantilla crea recursos de IAM: es una confirmación explícita de que sabes que estás creando permisos (CAPABILITY_IAM, CAPABILITY_NAMED_IAM si les pones nombre, CAPABILITY_AUTO_EXPAND para macros y pilas anidadas).

Políticas de eliminación y protección de terminación

Por omisión, borrar una pila borra sus recursos. Para aurora-mercadofresco-pedidos y mercadofresco-catalogo-fotos eso es inaceptable.

  BaseDatosPedidos:
    Type: AWS::RDS::DBCluster
    DeletionPolicy: Snapshot          # Al borrar la pila: instantanea final y luego borra
    UpdateReplacePolicy: Retain       # Si una actualizacion exige reemplazo: conserva el viejo
  BucketFotos:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain            # Lo deja en pie, huerfano de la pila
    UpdateReplacePolicy: Retain
    Properties: { BucketName: mercadofresco-catalogo-fotos }
Política Valores Qué hace
DeletionPolicy Delete (por omisión) Borra el recurso al borrar la pila
Retain / RetainExceptOnCreate Lo deja existiendo / igual, pero sí borra en reversión de creación
Snapshot Instantánea final y borrado (RDS, Redshift, ElastiCache, EBS, Neptune)
UpdateReplacePolicy Los mismos valores Se aplica cuando una actualización reemplaza el recurso

UpdateReplacePolicy es el que la gente olvida y el que más disgustos evita: cambiar el DBClusterIdentifier de Aurora provoca un reemplazo, y sin ella el clúster viejo —con los datos— desaparece en cuanto el nuevo está listo. La protección de terminación es una capa más, a nivel de pila: aws cloudformation update-termination-protection --stack-name mercadofresco-datos-produccion --enable-termination-protection, y con ella activa delete-stack falla directamente. La regla de MercadoFresco: protección de terminación en las tres pilas de producción, y Retain o Snapshot en todo recurso con estado, en cualquier entorno.

Conjuntos de cambios y los tres tipos de actualización

Un conjunto de cambios es una simulación: CloudFormation compara la plantilla nueva con la pila actual y dice qué haría, sin hacerlo. Es la respuesta a la pregunta que nadie podía contestar en el módulo 8: ¿qué va a pasar si aplico esto?

aws cloudformation create-change-set --stack-name mercadofresco-red-produccion \
  --change-set-name anadir-endpoint-dynamodb --template-body file://red-mercadofresco.yaml \
  --parameters ParameterKey=Entorno,ParameterValue=produccion --capabilities CAPABILITY_IAM

aws cloudformation describe-change-set --stack-name mercadofresco-red-produccion \
  --change-set-name anadir-endpoint-dynamodb \
  --query 'Changes[].ResourceChange.[Action,LogicalResourceId,ResourceType,Replacement]' --output table

# |  Add     |  EndpointDynamoDb  |  AWS::EC2::VPCEndpoint  |  None   |
# |  Modify  |  TablaRutasApp     |  AWS::EC2::RouteTable   |  False  |
# |  Modify  |  SubredDatosA      |  AWS::EC2::Subnet       |  True   |

La última línea es una alarma roja: Replacement: True significa que la subred se borra y se crea otra, con identificador nuevo, y todo lo que dependa de ella se ve afectado. Sin mirar el conjunto de cambios, eso habría ocurrido un viernes a las once.

Tipo Qué ocurre Identificador físico Ejemplos
Sin interrupción Se actualiza en caliente Se conserva Etiquetas, reglas de un SG, tamaño de un ASG, capacidad de DynamoDB
Con interrupción Se detiene y arranca Se conserva InstanceType de EC2, clase de RDS sin Multi-AZ, memoria de una Lambda
Con reemplazo Se crea uno nuevo y se borra el viejo Cambia AvailabilityZone o CidrBlock de subred, BucketName, DBClusterIdentifier, KeyName

La tercera fila es la que duele: un reemplazo en mercadofresco-catalogo-fotos significa bucket nuevo vacío y bucket viejo borrado con las 40.000 fotos dentro, salvo que UpdateReplacePolicy lo impida. La documentación de cada tipo indica, propiedad a propiedad, el "Update requires", y hay que mirarla antes de escribir el cambio, no después.

flowchart LR
    A[Editar plantilla] --> B[create-change-set]
    B --> C{describe-change-set}
    C -->|Replacement False| D[execute-change-set]
    C -->|Algun Replacement True| E{Es aceptable?}
    E -->|No| F[Otro enfoque o<br/>migrar datos antes]
    E -->|Si, con ventana| D
    D --> G[UPDATE_COMPLETE o<br/>UPDATE_ROLLBACK_COMPLETE]

Precaución: si alguien modifica la pila entre que lo creas y lo ejecutas, la simulación deja de ser válida; por eso los pipelines lo crean y lo ejecutan en etapas consecutivas, no con horas de por medio.

Detección de deriva

La deriva es la diferencia entre lo que dice la plantilla y lo que hay. Aparece siempre: durante un incidente, a las tres de la mañana, nadie edita una plantilla.

ID=$(aws cloudformation detect-stack-drift --stack-name mercadofresco-red-produccion \
  --query StackDriftDetectionId --output text)                       # la deteccion es asincrona
aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id "$ID"
aws cloudformation describe-stack-resource-drifts --stack-name mercadofresco-red-produccion \
  --stack-resource-drift-status-filters MODIFIED DELETED

Los estados por recurso son IN_SYNC, MODIFIED, DELETED y NOT_CHECKED. Este último importa: no todos los tipos admiten detección de deriva, así que un IN_SYNC de pila no garantiza que nada haya cambiado. Al encontrar deriva hay dos caminos: actualizar la plantilla para reflejar la realidad, si el cambio manual era legítimo, documentando el motivo en la PR; o reaplicar la pila para deshacerlo. Matiz importante: CloudFormation no tiene un "corregir deriva" automático, porque una actualización solo toca lo que cambió en la plantilla; para forzarlo hay que alterar el valor derivado en la plantilla y actualizar. En MercadoFresco, un trabajo programado la ejecuta cada semana sobre las pilas de producción y publica el resultado en alertas-mercadofresco. AWS Config (05-04) tiene además la regla cloudformation-stack-drift-detection-check, que hace esto de forma continua.

La red de MercadoFresco en una plantilla

red-mercadofresco.yaml, en el repositorio mercadofresco-infra, reproduce la red del módulo 3.

AWSTemplateFormatVersion: '2010-09-09'
Description: MercadoFresco - Red base. VPC con seis subredes en dos AZ, IGW, NAT y endpoint de S3.

Parameters:
  Entorno: { Type: String, AllowedValues: [ desarrollo, preproduccion, produccion ] }
  CidrVpc: { Type: String, Default: 10.0.0.0/16 }
  Propietario: { Type: String, Default: marta }

Mappings:
  PorEntorno: { desarrollo: { NatPorAz: 1 }, preproduccion: { NatPorAz: 1 }, produccion: { NatPorAz: 2 } }

Conditions:
  # Solo produccion tiene dos NAT; en el resto, app-b sale por el NAT de la AZ a.
  CrearSegundoNat: !Equals [ !FindInMap [ PorEntorno, !Ref Entorno, NatPorAz ], 2 ]

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref CidrVpc
      EnableDnsSupport: true        # Necesario para el endpoint de S3 y los nombres de RDS
      EnableDnsHostnames: true
      Tags: [ { Key: Name, Value: vpc-mercadofresco }, { Key: Proyecto, Value: mercadofresco },
              { Key: Entorno, Value: !Ref Entorno }, { Key: Componente, Value: red },
              { Key: Propietario, Value: !Ref Propietario }, { Key: CentroCoste, Value: plataforma } ]

  Igw: { Type: 'AWS::EC2::InternetGateway',
    Properties: { Tags: [ { Key: Name, Value: igw-mercadofresco } ] } }
  AdjuntarIgw: { Type: 'AWS::EC2::VPCGatewayAttachment',
    Properties: { VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw } }

  # --- Subredes. !Select con !GetAZs evita fijar 'eu-west-1a': la plantilla vale en otra region.
  SubredPublicaA:
    Type: AWS::EC2::Subnet
    Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.0.0/24, MapPublicIpOnLaunch: true,
      AvailabilityZone: !Select [ 0, !GetAZs '' ],
      Tags: [ { Key: Name, Value: snet-mercadofresco-publica-a } ] }
  SubredPublicaB:
    Type: AWS::EC2::Subnet
    Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.1.0/24, MapPublicIpOnLaunch: true,
      AvailabilityZone: !Select [ 1, !GetAZs '' ],
      Tags: [ { Key: Name, Value: snet-mercadofresco-publica-b } ] }
  SubredAppA: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.10.0/24,
    AvailabilityZone: !Select [ 0, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-app-a } ] } }
  SubredAppB: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.11.0/24,
    AvailabilityZone: !Select [ 1, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-app-b } ] } }
  SubredDatosA: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.20.0/24,
    AvailabilityZone: !Select [ 0, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-datos-a } ] } }
  SubredDatosB: { Type: 'AWS::EC2::Subnet', Properties: { VpcId: !Ref Vpc, CidrBlock: 10.0.21.0/24,
    AvailabilityZone: !Select [ 1, !GetAZs '' ], Tags: [ { Key: Name, Value: snet-mercadofresco-datos-b } ] } }

  # --- NAT: el segundo solo existe en produccion
  IpNatA: { Type: 'AWS::EC2::EIP', Properties: { Domain: vpc } }
  IpNatB: { Type: 'AWS::EC2::EIP', Condition: CrearSegundoNat, Properties: { Domain: vpc } }
  NatA: { Type: 'AWS::EC2::NatGateway', Properties: { AllocationId: !GetAtt IpNatA.AllocationId,
    SubnetId: !Ref SubredPublicaA, Tags: [ { Key: Name, Value: nat-mercadofresco-a } ] } }
  NatB: { Type: 'AWS::EC2::NatGateway', Condition: CrearSegundoNat,
    Properties: { AllocationId: !GetAtt IpNatB.AllocationId, SubnetId: !Ref SubredPublicaB,
      Tags: [ { Key: Name, Value: nat-mercadofresco-b } ] } }

  # --- Tablas de rutas
  TablaRutasPublica: { Type: 'AWS::EC2::RouteTable',
    Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-publica } ] } }
  RutaInternet:
    Type: AWS::EC2::Route
    DependsOn: AdjuntarIgw    # Dependencia EXPLICITA: no se deduce de las propiedades
    Properties: { RouteTableId: !Ref TablaRutasPublica, DestinationCidrBlock: 0.0.0.0/0, GatewayId: !Ref Igw }
  AsociarPublicaA: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
    Properties: { SubnetId: !Ref SubredPublicaA, RouteTableId: !Ref TablaRutasPublica } }
  AsociarPublicaB: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
    Properties: { SubnetId: !Ref SubredPublicaB, RouteTableId: !Ref TablaRutasPublica } }

  TablaRutasAppA: { Type: 'AWS::EC2::RouteTable',
    Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-app-a } ] } }
  RutaNatA: { Type: 'AWS::EC2::Route', Properties: { RouteTableId: !Ref TablaRutasAppA,
    DestinationCidrBlock: 0.0.0.0/0, NatGatewayId: !Ref NatA } }
  AsociarAppA: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
    Properties: { SubnetId: !Ref SubredAppA, RouteTableId: !Ref TablaRutasAppA } }

  TablaRutasAppB: { Type: 'AWS::EC2::RouteTable',
    Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-app-b } ] } }
  RutaNatB:   # Con segundo NAT sale por el suyo; si no, comparte el de la AZ a
    Type: AWS::EC2::Route
    Properties: { RouteTableId: !Ref TablaRutasAppB, DestinationCidrBlock: 0.0.0.0/0,
      NatGatewayId: !If [ CrearSegundoNat, !Ref NatB, !Ref NatA ] }
  AsociarAppB: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
    Properties: { SubnetId: !Ref SubredAppB, RouteTableId: !Ref TablaRutasAppB } }

  # Las subredes de datos NO tienen ruta a 0.0.0.0/0: sin salida a internet, por diseno (03-01).
  TablaRutasDatos: { Type: 'AWS::EC2::RouteTable',
    Properties: { VpcId: !Ref Vpc, Tags: [ { Key: Name, Value: rt-mercadofresco-datos } ] } }
  AsociarDatosA: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
    Properties: { SubnetId: !Ref SubredDatosA, RouteTableId: !Ref TablaRutasDatos } }
  AsociarDatosB: { Type: 'AWS::EC2::SubnetRouteTableAssociation',
    Properties: { SubnetId: !Ref SubredDatosB, RouteTableId: !Ref TablaRutasDatos } }

  EndpointS3:   # De tipo Gateway: no cuesta nada y ahorra el trafico por NAT de las copias a S3
    Type: AWS::EC2::VPCEndpoint
    Properties: { VpcId: !Ref Vpc, VpcEndpointType: Gateway,
      ServiceName: !Sub 'com.amazonaws.${AWS::Region}.s3',
      RouteTableIds: [ !Ref TablaRutasAppA, !Ref TablaRutasAppB, !Ref TablaRutasDatos ] }

Outputs:
  IdVpc: { Value: !Ref Vpc, Export: { Name: !Sub '${AWS::StackName}-IdVpc' } }
  SubredesPublicas: { Value: !Join [ ',', [ !Ref SubredPublicaA, !Ref SubredPublicaB ] ],
    Export: { Name: !Sub '${AWS::StackName}-SubredesPublicas' } }
  SubredesApp: { Value: !Join [ ',', [ !Ref SubredAppA, !Ref SubredAppB ] ],
    Export: { Name: !Sub '${AWS::StackName}-SubredesApp' } }
  SubredesDatos: { Value: !Join [ ',', [ !Ref SubredDatosA, !Ref SubredDatosB ] ],
    Export: { Name: !Sub '${AWS::StackName}-SubredesDatos' } }

Cuatro comentarios. El orden de creación no está en ninguna parte: CloudFormation deduce que SubredPublicaA necesita Vpc porque la referencia con !Ref, y crea en paralelo lo independiente; el único DependsOn es la única dependencia que no se deduce de las propiedades. !GetAZs evita fijar eu-west-1a. !If con !Ref NatB degrada en entornos pequeños sin duplicar la plantilla. Y las salidas se exportan con el prefijo ${AWS::StackName}, de modo que producción y preproducción no chocan: los nombres de exportación son únicos por región.

cfn-lint red-mercadofresco.yaml
aws cloudformation deploy --stack-name mercadofresco-red-produccion \
  --template-file red-mercadofresco.yaml --parameter-overrides Entorno=produccion Propietario=marta \
  --tags Proyecto=mercadofresco Entorno=produccion Componente=red Propietario=marta \
         CentroCoste=plataforma --profile mercadofresco-dev --region eu-west-1

Siete minutos después —los NAT tardan unos dos minutos cada uno— la red completa existe. Y esta vez existe también en un fichero, con historial en Git.

La capa de aplicación que importa las salidas

aplicacion-mercadofresco.yaml monta el ALB, el grupo de destino y el ASG encima, sin conocer un solo identificador:

Parameters:
  Entorno: { Type: String, AllowedValues: [ desarrollo, preproduccion, produccion ] }
  PilaRed: { Type: String, Default: mercadofresco-red-produccion }
  AmiTienda: { Type: 'AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>',
    Default: /mercadofresco/produccion/ami-tienda }

Mappings:
  PorEntorno: { desarrollo: { Tipo: t3.small, Min: 1, Max: 2 },
    preproduccion: { Tipo: t3.medium, Min: 2, Max: 3 }, produccion: { Tipo: m6i.large, Min: 2, Max: 4 } }

Resources:
  SgAlb: { Type: 'AWS::EC2::SecurityGroup', Properties: {
    GroupDescription: Entrada HTTPS publica hacia el ALB,
    VpcId: !ImportValue { 'Fn::Sub': '${PilaRed}-IdVpc' },
    SecurityGroupIngress: [ { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 } ] } }

  SgTienda: { Type: 'AWS::EC2::SecurityGroup', Properties: {    # Origen por grupo de seguridad y
    GroupDescription: Trafico del ALB hacia la tienda,          # no por CIDR: el patron de 03-02
    VpcId: !ImportValue { 'Fn::Sub': '${PilaRed}-IdVpc' },
    SecurityGroupIngress: [ { IpProtocol: tcp, FromPort: 8080, ToPort: 8080,
                              SourceSecurityGroupId: !Ref SgAlb } ] } }

  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties: { Name: !Sub 'alb-mercadofresco-tienda-${Entorno}', Scheme: internet-facing,
      Type: application, SecurityGroups: [ !Ref SgAlb ],
      Subnets: !Split [ ',', !ImportValue { 'Fn::Sub': '${PilaRed}-SubredesPublicas' } ] }

  GrupoDestino:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties: { Name: !Sub 'tg-mercadofresco-tienda-${Entorno}', Port: 8080, Protocol: HTTP,
      VpcId: !ImportValue { 'Fn::Sub': '${PilaRed}-IdVpc' },
      HealthCheckPath: /salud, HealthCheckIntervalSeconds: 15,
      HealthyThresholdCount: 2, UnhealthyThresholdCount: 3,
      TargetGroupAttributes: [ { Key: deregistration_delay.timeout_seconds, Value: '30' } ] }

  PlantillaLanzamiento:
    Type: AWS::EC2::LaunchTemplate
    Properties: { LaunchTemplateName: !Sub 'lt-mercadofresco-tienda-${Entorno}',
      LaunchTemplateData: { ImageId: !Ref AmiTienda, SecurityGroupIds: [ !Ref SgTienda ],
        InstanceType: !FindInMap [ PorEntorno, !Ref Entorno, Tipo ],
        IamInstanceProfile: { Name: rol-mercadofresco-tienda },
        MetadataOptions: { HttpTokens: required } } }    # IMDSv2 obligatorio (04-01)

  Asg:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      AutoScalingGroupName: !Sub 'asg-mercadofresco-tienda-${Entorno}'
      MinSize: !FindInMap [ PorEntorno, !Ref Entorno, Min ]
      MaxSize: !FindInMap [ PorEntorno, !Ref Entorno, Max ]
      HealthCheckType: ELB
      HealthCheckGracePeriod: 120
      VPCZoneIdentifier: !Split [ ',', !ImportValue { 'Fn::Sub': '${PilaRed}-SubredesApp' } ]
      TargetGroupARNs: [ !Ref GrupoDestino ]
      LaunchTemplate: { LaunchTemplateId: !Ref PlantillaLanzamiento,
        Version: !GetAtt PlantillaLanzamiento.LatestVersionNumber }
    UpdatePolicy:   # Como sustituir las instancias cuando cambia la plantilla de lanzamiento
      AutoScalingRollingUpdate: { MinInstancesInService: 2, MaxBatchSize: 1, PauseTime: PT5M }

Outputs:
  DnsAlb: { Value: !GetAtt Alb.DNSName }

Dos detalles. YAML no admite dos etiquetas cortas seguidas, así que !ImportValue con !Sub dentro obliga a escribir la interna en forma larga ('Fn::Sub'); el error que produce olvidarlo, unsupported structure, no ayuda nada. Y UpdatePolicy decide qué pasa cuando cambia la plantilla de lanzamiento: sin ella, CloudFormation actualiza el ASG y no toca las instancias existentes, así que el cambio no se aplica hasta el siguiente escalado. Es el despliegue por renovación de 08-03 aplicado a la infraestructura.

Varias pilas: exportaciones frente a SSM

Una pila con 300 recursos tarda 40 minutos, revierte entera ante cualquier fallo y no la puede tocar una persona sin coordinarse con las otras dos. La partición sigue dos criterios: por ciclo de vida (lo que cambia junto va junto) y por propiedad (cada pila tiene un dueño claro).

Pila Contenido Frecuencia Propietario
mercadofresco-red-<entorno> VPC, subredes, NAT, rutas, endpoints Muy baja Marta
mercadofresco-datos-<entorno> Aurora, DynamoDB, ElastiCache, buckets Baja Marta
mercadofresco-integracion-<entorno> Colas, DLQ, temas, bus, reglas, Step Functions Media Luis
mercadofresco-aplicacion-<entorno> ALB, grupos de destino, ASG, Lambdas Alta Luis
mercadofresco-observabilidad-<entorno> Alarmas, paneles, filtros de métricas Media Marta
Criterio Exportaciones e Fn::ImportValue Parámetros de SSM
Acoplamiento Fuerte: no se puede cambiar ni borrar una exportación en uso Débil: es solo un valor leído
Ámbito Misma cuenta y misma región Cualquiera: cruza cuentas y regiones
Actualizar el valor / orden de borrado Imposible mientras se importe; borrado impuesto Libre; el orden queda a la disciplina
Legibilidad Alta: la dependencia es explícita Menor: hay que saber qué ruta lee cada pila
Riesgo típico Quedarse sin poder cambiar la red Que alguien cambie el valor y nadie se entere

La primera fila es la clave: una exportación en uso es un cerrojo. Eso es bueno —evita romper cosas— y es malo —bloquea evoluciones—. La regla de MercadoFresco: exportaciones para lo estructural que no va a cambiar (VPC y subredes), y parámetros de SSM para todo lo demás y siempre que el valor cruce cuentas o regiones (ARN de colas, nombres de tablas, alias de claves).

  ParametroArnColaPedidos:            # Publica desde la pila de integracion
    Type: AWS::SSM::Parameter
    Properties: { Type: String, Value: !GetAtt ColaPedidos.Arn,
      Name: !Sub '/mercadofresco/${Entorno}/integracion/arn-cola-pedidos' }
# Y la pila de aplicacion lo consume como parametro:
#   ArnColaPedidos: { Type: 'AWS::SSM::Parameter::Value<String>',
#     Default: /mercadofresco/produccion/integracion/arn-cola-pedidos }

Pilas anidadas, módulos y recursos personalizados

Las pilas anidadas incluyen una plantilla dentro de otra como si fuera un recurso; la hija debe estar en S3 (mercadofresco-artefactos sirve) y sus salidas se leen con !GetAtt PilaSubredes.Outputs.SubredesApp.

  PilaSubredes:
    Type: AWS::CloudFormation::Stack
    Properties: { Parameters: { IdVpc: !Ref Vpc, Entorno: !Ref Entorno },
      TemplateURL: https://s3.eu-west-1.amazonaws.com/mercadofresco-artefactos/plantillas/subredes.yaml }
Enfoque Cuándo Inconvenientes
Pilas separadas Ciclos de vida y propietarios distintos Hay que coordinar el orden
Pilas anidadas Un mismo ciclo de vida con partes repetitivas Depuración difícil; subir a S3; CAPABILITY_AUTO_EXPAND
Módulos Un patrón idéntico en muchas plantillas Hay que registrarlos por cuenta y región; poco extendidos

Antes o después aparece algo que CloudFormation no sabe crear: un usuario en una herramienta externa, una carga inicial de datos. Un recurso personalizado delega en una Lambda: CloudFormation le envía un evento con RequestType y una URL prefirmada, y la función debe responder a esa URL.

import json, urllib.request

def responder(evento, contexto, estado, datos=None, motivo=""):
    """Responder a la URL prefirmada. Si no se llama, la pila se queda una hora colgada."""
    cuerpo = json.dumps({"Status": estado,              # SUCCESS o FAILED
        "Reason": motivo or f"Ver registros: {contexto.log_stream_name}",
        "PhysicalResourceId": evento.get("PhysicalResourceId", contexto.log_stream_name),
        "StackId": evento["StackId"], "RequestId": evento["RequestId"],
        "LogicalResourceId": evento["LogicalResourceId"], "Data": datos or {}}).encode()
    peticion = urllib.request.Request(evento["ResponseURL"], data=cuerpo, method="PUT")
    peticion.add_header("content-type", "")
    urllib.request.urlopen(peticion)

def handler(evento, contexto):
    try:
        if evento["RequestType"] == "Delete":
            return responder(evento, contexto, "SUCCESS")   # Nada que deshacer
        cargar_categorias(evento["ResourceProperties"]["Tabla"])
        responder(evento, contexto, "SUCCESS", {"Categorias": "12"})
    except Exception as error:                              # Capturar SIEMPRE: una excepcion sin
        responder(evento, contexto, "FAILED", motivo=str(error))   # responder cuelga la pila 1 hora

Los proveedores de recursos son la alternativa formal: se registra un tipo nuevo, por ejemplo MercadoFresco::Facturacion::Cliente, con su esquema JSON y sus manejadores, y a partir de ahí se usa como cualquier otro recurso, con deriva y validación incluidas. Cuesta más y se paga por segundo de manejador, pero el registro público ya trae tipos de terceros —Datadog, MongoDB Atlas, GitHub— que se activan en un clic.

Importar recursos existentes a una pila

Esta es la operación que MercadoFresco necesita de verdad, porque su infraestructura ya existe. La alternativa ingenua —crear pilas nuevas y borrar lo viejo— implicaría migrar datos y una ventana de corte. La importación adopta los recursos existentes sin tocarlos. Cuatro pasos:

  1. Escribir la plantilla que describe exactamente lo que existe. Aquí no se improvisa: si dice /health y el grupo de destino real tiene /salud, la importación pasa y el siguiente update-stack cambia la realidad sin avisar.
  2. Añadir DeletionPolicy: Retain a todos los recursos a importar —es obligatorio— y preparar el fichero de identificadores, que empareja cada nombre lógico con el recurso real.
  3. Crear y ejecutar un conjunto de cambios de tipo IMPORT.
[
  { "ResourceType": "AWS::EC2::VPC", "LogicalResourceId": "Vpc",
    "ResourceIdentifier": { "VpcId": "vpc-0a1b2c3d4e5f6a7b8" } },
  { "ResourceType": "AWS::EC2::Subnet", "LogicalResourceId": "SubredAppA",
    "ResourceIdentifier": { "SubnetId": "subnet-0c1d2e3f4a5b6c7d8" } },
  { "ResourceType": "AWS::SQS::Queue", "LogicalResourceId": "ColaPedidos", "ResourceIdentifier": {
      "QueueUrl": "https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-pedidos" } }
]
aws cloudformation create-change-set --stack-name mercadofresco-red-produccion \
  --change-set-name importar-red-existente --change-set-type IMPORT \
  --resources-to-import file://recursos-a-importar.json \
  --template-body file://red-mercadofresco.yaml \
  --parameters ParameterKey=Entorno,ParameterValue=produccion
aws cloudformation execute-change-set --stack-name mercadofresco-red-produccion \
  --change-set-name importar-red-existente
aws cloudformation detect-stack-drift --stack-name mercadofresco-red-produccion   # PASO OBLIGATORIO

La importación no comprueba que la plantilla coincida con la realidad: solo que los recursos existan y sean del tipo declarado. La deriva inmediatamente después es lo que revela las diferencias. Marta encuentra tres:

Recurso Deriva Decisión
SubredAppB Etiqueta Propietario ausente Corregir la realidad: aplicar la plantilla
TablaRutasDatos Ruta 0.0.0.0/0 hacia el NAT que nadie recordaba Investigar y quitar: los datos no salen
EndpointS3 Política más restrictiva que la plantilla Corregir la plantilla: la realidad era la buena

El tercer caso explica por qué el orden importa: primero importar y detectar, después corregir. Escribir la plantilla "como debería ser" y aplicarla directamente habría relajado una política de seguridad sin que nadie se enterara. MercadoFresco importa por capas, empezando por la red —la más estable— y dejando la de datos para el final, con ventana acordada y copia reciente de Aurora.

StackSets y despliegue desde el pipeline

Un StackSet despliega la misma plantilla en un conjunto de cuentas y regiones desde una única operación. Hoy MercadoFresco tiene una cuenta, así que su utilidad es limitada; en 09-04, con cuatro, será la pieza que garantice que toda cuenta nueva nace con la línea base puesta: trail, grabador de Config, alarmas de facturación y roles.

aws cloudformation create-stack-set --stack-set-name ss-mercadofresco-linea-base \
  --template-body file://linea-base.yaml --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true --capabilities CAPABILITY_NAMED_IAM

SELF_MANAGED exige crear a mano dos roles de IAM; SERVICE_MANAGED se apoya en Organizations y no requiere nada. Con --auto-deployment Enabled=true, una cuenta añadida a una OU destino recibe la pila sola. Y la conclusión práctica del módulo 8: si la infraestructura es código, se despliega desde el pipeline. CodePipeline tiene acción nativa con cuatro modos; el patrón de MercadoFresco separa simulación de ejecución con una aprobación en medio.

flowchart TB
    A[Fuente: mercadofresco-infra] --> B[Construccion:<br/>cfn-lint + cfn-guard]
    B --> C[CHANGE_SET_REPLACE]
    C --> D[Publicar el resumen<br/>del conjunto de cambios]
    D --> E{Aprobacion de Marta}
    E -->|Rechaza| G[Fin: nada se ha tocado]
    E -->|Aprueba| F[CHANGE_SET_EXECUTE] --> H[Verificar deriva y salidas]
- name: InfraestructuraProduccion
  actions:
    - name: PrepararCambio
      actionTypeId: { category: Deploy, owner: AWS, provider: CloudFormation, version: '1' }
      runOrder: 1
      configuration:
        ActionMode: CHANGE_SET_REPLACE
        StackName: mercadofresco-red-produccion
        ChangeSetName: cambio-desde-pipeline
        TemplatePath: 'Infra::plantillas/red-mercadofresco.yaml'
        TemplateConfiguration: 'Infra::parametros/produccion.json'
        RoleArn: arn:aws:iam::111122223333:role/rol-cloudformation-mercadofresco
        Capabilities: CAPABILITY_NAMED_IAM
    - { name: AprobacionMarta, runOrder: 2,
        actionTypeId: { category: Approval, owner: AWS, provider: Manual, version: '1' } }
    - name: EjecutarCambio
      actionTypeId: { category: Deploy, owner: AWS, provider: CloudFormation, version: '1' }
      runOrder: 3
      configuration: { ActionMode: CHANGE_SET_EXECUTE, StackName: mercadofresco-red-produccion,
        ChangeSetName: cambio-desde-pipeline }

RoleArn es clave: CloudFormation asume ese rol para crear los recursos, no los permisos de quien lanza el pipeline, así que Luis puede disparar un despliegue de infraestructura sin tener él permiso para crear VPC. Y TemplateConfiguration apunta a un JSON por entorno en el repositorio con Parameters y Tags: con eso, la diferencia entre preproducción y producción deja de ser un misterio y pasa a ser un fichero que se compara con diff en diez segundos.

Buenas prácticas, cfn-lint y cfn-guard

Seis reglas en el README de mercadofresco-infra:

  1. Plantillas pequeñas y con una responsabilidad. Más de 400 líneas u 80 recursos: se parte, por ciclo de vida.
  2. Parámetros mínimos. Cada parámetro es una decisión que alguien puede tomar mal. Lo que no varía entre entornos es una constante, no un parámetro.
  3. Nada de valores mágicos, y nombres físicos solo cuando hacen falta. Ni AMI a mano, ni CIDR repetidos, ni ARN a pelo: !Sub, Mappings y SSM cubren todos los casos. Y fijar BucketName o GroupName impide actualizaciones sin interrupción y bloquea desplegar dos veces la misma plantilla en una cuenta.
  4. Todo pasa por PR y por validación automática. Ningún deploy manual contra producción; una emergencia obliga a abrir la PR de reconciliación el mismo día.
pip install cfn-lint && cfn-lint plantillas/*.yaml
# E3002 Invalid Property Resources/GrupoDestino/Properties/HealthCheckPeriod
# W2001 Parameter Propietario not used
cfn-guard validate --data plantillas/ --rules reglas/mercadofresco.guard
let buckets = Resources.*[ Type == 'AWS::S3::Bucket' ]
rule buckets_cifrados when %buckets !empty {
  %buckets.Properties.BucketEncryption exists
  <<Todo bucket de MercadoFresco debe declarar cifrado. Ver 04-02.>>
}
let sgs = Resources.*[ Type == 'AWS::EC2::SecurityGroup' ]
rule sin_ssh_abierto when %sgs !empty {
  %sgs.Properties.SecurityGroupIngress[*] { when FromPort == 22 { CidrIp != '0.0.0.0/0' } }
}

Esa segunda regla es la que habría impedido la diferencia entre preproducción y producción del principio de la lección. Ambas herramientas se ejecutan en build-mercadofresco-integracion (08-02) y fallan la construcción: no son avisos.

Límites, coste y limpieza

Límite Valor Qué hacer al llegar
Recursos por pila / pilas por cuenta y región 500 / 2.000 Partir en varias pilas o anidar; consolidar
Parámetros / Salidas 200 / 200 Agrupar valores en SSM
Plantilla local / en S3 / anidamiento 51.200 bytes / 1 MB / 5 niveles --template-url; partir; replantear

CloudFormation no cobra por gestionar recursos de AWS; cobra 0,0009 USD por segundo de manejador en recursos de terceros y tipos privados, con 1.000 operaciones mensuales gratuitas. El coste real es siempre el de los recursos creados.

# Borrar en orden inverso a las dependencias: primero los consumidores
aws cloudformation delete-stack --stack-name mercadofresco-aplicacion-pruebas
aws cloudformation wait stack-delete-complete --stack-name mercadofresco-aplicacion-pruebas
aws cloudformation delete-stack --stack-name mercadofresco-red-pruebas
aws cloudformation wait stack-delete-complete --stack-name mercadofresco-red-pruebas
# Las IP elasticas huerfanas se cobran
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output table

Si el borrado queda en DELETE_FAILED, la causa casi siempre es un bucket con objetos dentro o una interfaz de red creada a mano en la subred; --retain-resources permite terminar el borrado dejando fuera esos recursos concretos.

Errores Comunes y Consejos

Error: renombrar un recurso lógico y perder el recurso. Cambiar Vpc por VpcPrincipal no renombra: borra la VPC y crea otra. Consejo: los nombres lógicos son inmutables en la práctica; si hay que renombrar, se hace en dos pasos con DeletionPolicy: Retain más una importación posterior.

Error: desplegar sin mirar el conjunto de cambios. Es fusionar una PR sin leer el diff. Consejo: que el pipeline lo genere y lo publique antes de la aprobación, prestando atención a la columna Replacement. Error: no entender ROLLBACK_COMPLETE. Es un estado terminal de una creación fallida. Consejo: bórrala y vuelve a crearla, pero mira los eventos antes de borrar, filtrando CREATE_FAILED: el primero es la causa, los demás son consecuencias.

Error: contraseñas en parámetros con NoEcho creyendo que están seguras, o exportaciones para todo. Con veinte exportaciones cruzadas la red se vuelve inmodificable. Consejo: {{resolve:secretsmanager:...}} o {{resolve:ssm-secure:...}} para los secretos, exportaciones solo para lo estructural y SSM para el resto.

Error: creer que la importación valida la plantilla. No lo hace: detección de deriva justo después de cada importación, sin excepción.

Consejo: usa --role-arn en todos los despliegues y una política de pila en los recursos con datos. Lo primero es la diferencia entre "cualquiera que pueda desplegar puede crear cualquier cosa" y "solo la plantilla revisada crea lo que declara"; lo segundo, con set-stack-policy, deniega Update:Replace y Update:Delete sobre recursos concretos, un cinturón para el tirante de UpdateReplacePolicy.

Consejo: mira la plantilla desplegada, no solo la del repositorio. get-template --template-stage Processed la devuelve con macros y transformaciones ya aplicadas; es lo que hay que revisar cuando el resultado no coincide con lo que creías escribir.

Consejo: Description en la plantilla, en cada parámetro y en cada salida. Es la documentación viva del principio, y cuesta treinta segundos.

Ejercicios

Ejercicio 1: la plantilla de integración

Escribe integracion-mercadofresco.yaml: la cola cola-mercadofresco-pedidos con su DLQ mercadofresco-pedidos-fallidos y 5 intentos, el tema mercadofresco-pedido-confirmado, la suscripción de la cola al tema y la publicación del ARN de la cola en Parameter Store. Requisitos: cifrado con alias/mercadofresco-datos, retención de 14 días en la DLQ y 4 en la cola, sufijo de entorno en los nombres, etiquetado obligatorio completo y DeletionPolicy adecuada en la DLQ. Indica qué tipo de actualización provocaría cambiar VisibilityTimeout y cuál cambiar QueueName.

Ejercicio 2: adoptar el ALB existente

alb-mercadofresco-tienda y tg-mercadofresco-tienda llevan ocho meses en producción, creados a mano. Marta quiere adoptarlos en mercadofresco-aplicacion-produccion sin cortar el servicio. Describe el procedimiento: qué recopilar antes y con qué comandos, qué debe llevar la plantilla obligatoriamente, cómo se construye el fichero de identificadores, qué comandos se ejecutan y en qué orden, y qué se hace inmediatamente después. Explica qué ocurriría si la plantilla declarase HealthCheckIntervalSeconds: 30 cuando el grupo de destino real tiene 15, y en qué momento exacto se manifestaría.

Ejercicio 3: partir el monolito de plantilla

Un compañero ha escrito una plantilla de 780 líneas con 96 recursos: VPC y subredes, Aurora, dos tablas de DynamoDB, ElastiCache, ALB, ASG, cuatro colas, bus de eventos, doce alarmas y tres buckets. Tarda 41 minutos y cualquier fallo revierte todo; la semana pasada, un error en una alarma revirtió también un cambio de subred correcto. Propón la partición: qué pilas, con qué contenido y con qué criterio; para cada frontera, si usarías exportación o SSM y por qué; el orden de despliegue y el de borrado; y qué habría pasado la semana pasada con tu partición.

Soluciones

Solución 1

Resources:
  ColaPedidosFallidos:
    Type: AWS::SQS::Queue
    DeletionPolicy: Retain          # La DLQ guarda mensajes sin procesar: nunca se borra con la pila
    UpdateReplacePolicy: Retain
    Properties:
      QueueName: !Sub 'mercadofresco-pedidos-fallidos-${Entorno}'
      MessageRetentionPeriod: 1209600        # 14 dias, el maximo
      KmsMasterKeyId: alias/mercadofresco-datos
      Tags: &etiquetas                       # Ancla YAML: CloudFormation admite alias y evita repetir
        - { Key: Proyecto, Value: mercadofresco }
        - { Key: Entorno, Value: !Ref Entorno }
        - { Key: Componente, Value: integracion }
        - { Key: Propietario, Value: luis }
        - { Key: CentroCoste, Value: plataforma }

  ColaPedidos:
    Type: AWS::SQS::Queue
    Properties: { QueueName: !Sub 'cola-mercadofresco-pedidos-${Entorno}',
      MessageRetentionPeriod: 345600, VisibilityTimeout: 180,    # 4 dias de retencion
      KmsMasterKeyId: alias/mercadofresco-datos, Tags: *etiquetas,
      RedrivePolicy: { deadLetterTargetArn: !GetAtt ColaPedidosFallidos.Arn, maxReceiveCount: 5 } }

  TemaPedidoConfirmado:
    Type: AWS::SNS::Topic
    Properties: { TopicName: !Sub 'mercadofresco-pedido-confirmado-${Entorno}',
      KmsMasterKeyId: alias/mercadofresco-datos, Tags: *etiquetas }

  SuscripcionColaAlTema:
    Type: AWS::SNS::Subscription
    Properties: { TopicArn: !Ref TemaPedidoConfirmado,   # !Ref de un tema devuelve el ARN
      Protocol: sqs, Endpoint: !GetAtt ColaPedidos.Arn, RawMessageDelivery: true }

  PoliticaColaParaSns:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues: [ !Ref ColaPedidos ]             # !Ref de una cola devuelve la URL: es lo que espera aqui
      PolicyDocument:
        Version: '2012-10-17'
        Statement: [ { Effect: Allow, Action: 'sqs:SendMessage', Resource: !GetAtt ColaPedidos.Arn,
            Principal: { Service: sns.amazonaws.com },
            Condition: { ArnEquals: { 'aws:SourceArn': !Ref TemaPedidoConfirmado } } } ]

  ParametroArnCola:
    Type: AWS::SSM::Parameter
    Properties: { Type: String, Value: !GetAtt ColaPedidos.Arn,
      Name: !Sub '/mercadofresco/${Entorno}/integracion/arn-cola-pedidos' }

Cambiar VisibilityTimeout es sin interrupción: SQS lo aplica en caliente y la URL no cambia. Cambiar QueueName es con reemplazo: SQS no permite renombrar, así que se crea una cola nueva y se borra la vieja con los mensajes dentro. Es un ejemplo perfecto de por qué fijar nombres físicos tiene coste y de por qué la DLQ lleva Retain. Y un detalle fácil de olvidar: sin la política de cola, SNS no puede entregar y los mensajes se pierden en silencio; la condición aws:SourceArn evita que cualquier otro tema de la cuenta escriba en ella, el mínimo privilegio de 04-01 aplicado a recursos.

Solución 2

Antes: leer la configuración real, nunca escribirla de memoria.

aws elbv2 describe-load-balancers --names alb-mercadofresco-tienda > alb-real.json
aws elbv2 describe-target-groups --names tg-mercadofresco-tienda > tg-real.json
aws elbv2 describe-listeners --load-balancer-arn "$ARN_ALB" > listeners-real.json
aws elbv2 describe-target-group-attributes --target-group-arn "$ARN_TG" > tg-attrs.json

La plantilla se escribe a partir de esos ficheros, propiedad por propiedad, incluidos los atributos poco evidentes: deregistration_delay.timeout_seconds, stickiness.enabled, la política TLS del listener y el certificado de ACM. Obligatorio: DeletionPolicy: Retain en el ALB, el grupo de destino y el listener; sin ella la importación se rechaza, y conviene añadir UpdateReplacePolicy: Retain. El fichero de identificadores empareja LogicalResourceId con LoadBalancerArn y TargetGroupArn, usando el ARN completo (arn:aws:elasticloadbalancing:eu-west-1:111122223333:loadbalancer/app/alb-mercadofresco-tienda/a1b2c3).

Orden: cfn-lint; create-change-set con --change-set-type IMPORT; describe-change-set para confirmar que todas las acciones son Import y ninguna es Add o Modify —una sola Modify significa que la plantilla no coincide con la realidad y hay que corregirla antes—; execute-change-set; wait stack-import-complete. Inmediatamente después: detección de deriva, resolviendo caso a caso si manda la plantilla o la realidad. El caso de 30 frente a 15. La importación pasa sin quejarse: solo verifica existencia y tipo. La deriva lo marcaría como MODIFIED. Y si nadie mira, el problema se manifiesta en el siguiente update-stack cualquiera, aunque no tenga nada que ver con el grupo de destino: CloudFormation aplica la plantilla completa y el intervalo pasa a 30 segundos. En la práctica, el ALB tarda el doble en detectar una instancia caída —de 30 a 60 segundos con dos comprobaciones fallidas—, justo el tipo de degradación silenciosa que nadie relaciona con un despliegue de otra cosa. Ese es el argumento entero a favor del paso de deriva.

Solución 3

Cinco pilas, las mismas de la tabla de la lección: red (casi inmutable, todo depende de ella), datos (estado, borrado catastrófico, requiere protección), integracion (sin estado duradero, propietario Luis), aplicacion (lo que más cambia) y observabilidad (cambia con los incidentes, riesgo nulo). Fronteras y orden. Red → todas: exportación, porque los identificadores de VPC y subredes son estructurales, no van a cambiar, y queremos el cerrojo: nadie debe poder borrar la red mientras haya algo encima. Datos → aplicación (endpoint de Aurora, nombres de tablas): SSM, porque cambian con más facilidad de la que parece —una migración a Serverless v2, un endpoint de lectura— y una exportación bloquearía justamente eso. Integración → aplicación: SSM, además porque en 09-04 estas pilas van a vivir en cuentas distintas, donde las exportaciones no funcionan. Todas → observabilidad: SSM; la pila que menos importa y más cambia no debe poder bloquear a nadie.

El orden de despliegue es red → datos e integración en paralelo → aplicación → observabilidad, y el de borrado, el inverso exacto: las exportaciones lo imponen solas en el caso de la red, y en el resto hay que respetarlo por disciplina porque SSM no impone nada.

La semana pasada. El error en la alarma habría fallado únicamente mercadofresco-observabilidad, que despliega en menos de dos minutos y no tiene recursos críticos; el cambio de subred, en mercadofresco-red, ya estaría aplicado y no se habría revertido. El ámbito del fallo pasa de 96 recursos a 12, y los 41 minutos pasan a cuatro despliegues de entre 2 y 12 minutos, algunos en paralelo. Un matiz honesto: la partición no es gratis. Introduce orden de despliegue, obliga a mantener las fronteras y hace que un cambio que toca dos capas necesite dos pilas. La regla que lo justifica es la misma de 08-05 con los despliegues: el ámbito de un fallo debe ser proporcional al riesgo del cambio, y mezclar una alarma con una subred viola esa regla.

Conclusión

La infraestructura de MercadoFresco está por fin en ficheros. red-mercadofresco.yaml describe la VPC, las seis subredes, el gateway de internet, los NAT —dos en producción y uno en el resto, con la misma plantilla— y el endpoint de S3, con historial en Git; aplicacion-mercadofresco.yaml monta encima el ALB, el grupo de destino y el grupo de autoescalado sin conocer un solo identificador, importando las salidas de la primera. Y tienes la anatomía de una plantilla, con parámetros que validan antes de empezar gracias a los tipos específicos de AWS, valores que llegan desde Parameter Store sin tocar la plantilla y secretos resueltos con {{resolve:secretsmanager:...}} en lugar del falso amigo que es NoEcho. Tienes las funciones intrínsecas y la tabla que evita el error más frecuente: que !Ref devuelve la URL de una cola, el ARN de un tema y el nombre de un rol, y que casi siempre lo que buscas es !GetAtt Recurso.Arn. Y tienes el ciclo de vida de una pila, con el estado que desconcierta el primer día: ROLLBACK_COMPLETE, que solo se puede borrar.

Lo que de verdad cambia la forma de trabajar son tres mecanismos. Los conjuntos de cambios, que contestan por escrito a la pregunta que en el módulo 8 nadie podía contestar —¿qué va a pasar si aplico esto?— y que muestran la columna Replacement que separa un cambio rutinario de la destrucción de una subred. Las políticas de eliminación y de reemplazo más la protección de terminación, lo único que se interpone entre un comando y los datos de Aurora. Y la detección de deriva, que convierte los cambios manuales de invisibles en un informe semanal. Con la importación de recursos existentes, MercadoFresco no recrea nada: adopta lo que ya funciona, capa a capa, empezando por la red, y ejecuta la deriva justo después —el paso que nadie debe saltarse, porque la importación comprueba que los recursos existan, no que la plantilla los describa bien—. Las cinco pilas por entorno quedan repartidas por ciclo de vida y por propiedad, con exportaciones para lo estructural y SSM para lo que puede cambiar o cruzar cuentas, sabiendo que una exportación en uso es un cerrojo. Y la infraestructura pasa por el pipeline de 08-04 con el mismo rigor que el código: cfn-lint y cfn-guard que fallan la construcción, un conjunto de cambios publicado, la aprobación de Marta y la ejecución con RoleArn, para que quien dispara no necesite los permisos que se ejercen.

Queda una limitación que se nota en cuanto la plantilla crece. La red describe seis subredes casi idénticas repetidas a mano: no hay bucles, no hay funciones, no hay forma de escribir "crea una subred por cada AZ" ni de encapsular "la red estándar de MercadoFresco" en algo reutilizable. Las condiciones sirven para dos casos y se vuelven ilegibles a partir de cinco. Y no hay manera de escribir una prueba que verifique que ninguna plantilla abre el puerto 22 al mundo: cfn-guard ayuda, pero es otro lenguaje más que aprender.

En 09-02, «AWS CDK», esa misma infraestructura se escribe en TypeScript y en Python, con bucles, condicionales, tipos, constructos reutilizables y pruebas unitarias de verdad, y el resultado sigue siendo una plantilla de CloudFormation desplegada como una pila. Todo lo de esta lección sigue siendo cierto por debajo: el CDK no sustituye a CloudFormation, lo escribe por ti.

Curso de AWS

Módulo 1: Introducción a AWS

Módulo 2: Servicios principales de AWS

Módulo 3: Redes y entrega de contenido

Módulo 4: Seguridad e identidad

Módulo 5: Monitorización y gestión

Módulo 6: Bases de datos

Módulo 7: Integración de aplicaciones

Módulo 8: Herramientas para desarrolladores

Módulo 9: Infraestructura como código y gobierno de cuentas

Módulo 10: Contenedores en AWS

Módulo 11: Mejores prácticas y gestión de costos

© Copyright 2026. Todos los derechos reservados