Estrategias para manejar dependencias entre stacks en CDK
Tags: aws, cdk, infrastructureascode, iac
Published: 2023-10-06 10:38:51

Es bastante fácil crear varios _stacks_ en CDK y compartir valores entre ellos (por ejemplo el **VPC ID**), pero estas dependencias nos pueden crear muchos dolores de cabeza si no se gestionan adecuadamente.

En este post hablaremos de las dependencias entre _stacks_, los tipos que podemos encontrar, los problemas que pueden surgir y de varias estrategias que podemos seguir para manejarlas.

## Dependencias entre stacks en CDK

Podríamos decir que:

> _las dependencias entre stacks en CDK surgen cuando se comparten uno o más valores entre uno o más stacks gestionados con CDK_

Pongamos un ejemplo muy simple con un primer stack que contiene una VPC:

```typescript
export class ExampleA extends cdk.Stack {
  public readonly vpc: ec2.IVpc;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    this.vpc = new ec2.Vpc(this, 'MyVpc');
  }
}
```

Y un segundo stack, que recibe por parámetro una VPC que se utiliza para crear un grupo de seguridad:

```typescript
interface ExampleBProps extends cdk.StackProps {
  vpc: ec2.IVpc;
}

export class ExampleB extends cdk.Stack {

  constructor(scope: Construct, id: string, props: ExampleBProps) {
    super(scope, id, props);

    const mySecurityGroup = new ec2.SecurityGroup(this, 'MySecurityGroup', {
      vpc: props.vpc,
    });
  }
}
```

Entonces nuestra aplicación de CDK será algo como:

```typescript
const stackA = new ExampleA(app, 'ExampleA');

const stackB = new ExampleB(app, 'ExampleB', {
  vpc: stackA.vpc,
});
```

Donde usamos la propiedad `vpc` de A y las pasamos a B.

Si atendemos al tipo de valores que podemos compartir entre _stacks_, tenemos dependencias:

- **Deterministas**: donde el valor que compartimos es conocido o puede ser inferido o compuesto por otros valores conocidos.
- **No deterministas**: donde a priori no conocemos los valores compartidos, generalmente IDs generados por AWS o CDK.

En el ejemplo anterior tenemos una dependencia **no determinista**, ya que a priori no sabemos el **VPC ID**, que es el valor que estamos compartiendo.

Aquí me gustaría señalar algo que probablemente ya sabéis, pero CDK sintetiza CloudFormation utilizando nuestro código y es éste código sintetizado de CloudFormation lo que se utiliza para desplegar.

¿Por qué es esto importante? porque si sabemos cómo funciona CloudFormation, entenderemos mejor lo que está sucediendo con nuestro código.

En el ejemplo anterior lo que pasamos de A a B es la propiedad `vpc`, pero CDK selecciona sólo el **VPC ID** porque es el valor que se necesita en el recurso de CloudFormation _SecurityGroup_ para especificar la VPC a la que pertenece y además la forma de "pasar" este valor entre A y B es creando un output de CloudFormation en A e importándolo en B, es decir, algo como:

```json
// stack A
{
 ...
 "Outputs": {
  "ExportsOutputRefMyVpcF9F0CA6FBC8737E9": {
   "Value": {
    "Ref": "MyVpcF9F0CA6F"
   },
   "Export": {
    "Name": "ExampleA:ExportsOutputRefMyVpcF9F0CA6FBC8737E9"
   }
  }
 }
 ...
}
// stack B
{
 ...
 "Resources": {
  "MySecurityGroupAC8D442C": {
   "Type": "AWS::EC2::SecurityGroup",
   "Properties": {
    "GroupDescription": "ExampleB/MySecurityGroup",
    "SecurityGroupEgress": [
     {
      "CidrIp": "0.0.0.0/0",
      "Description": "Allow all outbound traffic by default",
      "IpProtocol": "-1"
     }
    ],
    "VpcId": {
     "Fn::ImportValue": "ExampleA:ExportsOutputRefMyVpcF9F0CA6FBC8737E9"
    }
   }
  }
 }
 ...
}
```

Además, si atendemos al origen de la dependencia, tenemos dependencias:

- **Heredadas**: el valor exportado está en el stack “fuente” y el importado en el stack “destino” - es decir, el output estaría en A y el import en B.
- **Reflejadas**: el valor exportado está en el stack “destino” y el importado en el stack “fuente” - es decir, el output estaría en B y el import en A.

Esto significa que no siempre veremos el output en el stack "del que salen" los valores en nuestro código CDK. Podéis ver un ejemplo de una dependencia reflejada con estos dos _stacks_ [stack-a](https://github.com/neovasili/cdk-day-2023-stacks-dependencies/blob/main/lib/stacks/03-cloudfront-example/stack-a.ts) y [stack-b](https://github.com/neovasili/cdk-day-2023-stacks-dependencies/blob/main/lib/stacks/03-cloudfront-example/stack-b.ts).

Si tenemos por costumbre hacer `cdk diff` conforme vamos haciendo nuestros cambios, podemos observar lo que sucede en CloudFormation. Otra opción es revisar directamente los _templates_ producidos en `cdk.out/`.

## Potenciales problemas con las dependencias

Por norma general, cuando estamos creando una nueva dependencia no tendremos problemas salvo que estemos intentando introducir alguna dependencia cíclica, que es algo que deberíamos tratar de evitar siempre que sea posible.

No obstante, sí que podemos encontrar problemas cuando intentamos actualizar alguno de los valores que estamos compartiendo o si intentamos eliminar una dependencia que ya no es necesario. ¿Por qué? 

Porque CDK tiene que establecer un orden a la hora de desplegar con los _stacks_ que son dependientes y salvo que le especifiquemos lo contrario, usará la "dirección" de la dependencia que definimos en nuestro código para establecer la jerarquía.

En el ejemplo anterior B depende de A, por tanto la jerarquía es `A -> B`.

Esto se traduce en que si **eliminamos la dependencia existente**, CDK tratará de eliminar primero el output en A y luego la importación en B, pero CloudFormation fallará al tratar de eliminar el output en A porque sigue en uso en B:

![CDK diff cuando tratamos de eliminar una dependencia existente donde CDK trata de eliminar el output de A antes que realizar los cambios en B](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lazj16hwbtu3ate6uu1a.png)

En el caso de que **intentemos actualizar una dependencia**, pasará algo parecido, puesto que tratará de actualizar un valor que está en uso o incluso puede que trate de eliminar el output original y crear uno nuevo:

![CDK diff cuando tratamos de actualizar una dependencia existente donde CDK trata de eliminar el output existente y crear uno nuevo antes de realizar los cambios en B](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c0l86vmt2ln3t88rhek4.png)

Podríamos mitigar estos problemas haciendo despliegues parciales o desplegando manualmente en el orden correcto, pero aparte de los riesgos asociados a las operaciones manuales, puede que estos despliegues parciales no sean tan triviales, dependiendo de las dependencias que tengamos u otros cambios que hayamos acumulado.

## Estrategias para manejar dependencias

Considerando el anterior párrafo, podemos idear diferentes formas de gestionar estas dependencias que nos ayuden a evitar los problemas mencionados.

### Exportación manual de valores

Como su propio nombre indica, en lugar de dejar que CDK cree los outputs y los imports automáticamente, podemos hacerlo nosotros de forma explícita, de esta forma, podemos decidir en el código cuándo actualizar o eliminar los valores según nos convenga.

Para el ejemplo que veíamos al principio, para A sería algo como:

```typescript
export class ExportingStack extends cdk.Stack {
  public readonly vpcId: cdk.CfnOutput;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, 'MyVpc');

    new cdk.CfnOutput(this, 'VpcId', {
      exportName: 'MyVpcId',
      value: vpc.vpcId,
    });
  }
}
```

Y en B tendríamos:

```typescript
export class ImportingStack extends cdk.Stack {

  constructor(scope: Construct, id: string, props: ImportingStackProps) {
    super(scope, id, props);

    const vpc = ec2.Vpc.fromLookup(this, 'MyVpc', {
      vpcId: cdk.Fn.importValue('MyVpcId'),
    });

    const mySecurityGroup = new ec2.SecurityGroup(this, 'MySecurityGroup', {
      vpc,
    });
  }
}
```

De esta forma, en B estamos creando un _construct_ de una VPC usando el valor importado para poder usarla como si la VPC se hubiese creado en B.

No obstante, al hacerlo así, CDK no sabe que existe una dependencia entre A y B, por lo que deberíamos hacerlo explícito nosotros:

```typescript
const stackA = new ExportingStack(app, 'ExampleA');

const stackB = new ImportingStack(app, 'ExampleB', {
  vpcId: stackA.vpcId,
});
stackB.addDependency(stackA);
```

¿Qué PROs y Contras tiene esta estrategia?

Pues entre los PROs podemos considerar:

- **Los exports e imports se hacen explícitos** - lo que antes sólo se podía ver en las templates generadas de CloudFormation ahora está en el código.
- **Evita los problemas de eliminación o actualización de dependencias** - ya que podemos controlar el ciclo de vida de exports e imports de forma independiente, podríamos primero cambiar unos y luego los otros, evitando así los problemas mencionados.
- **Puede ayudar a mitigar problemas de dependencias ya existentes** - podemos hacer explícitos exports ya existentes y así controlar su ciclo de vida; eso sí, necesitaremos utilizar exactamente los mismos IDs que ya existen en CloudFormation, para que CDK considere que son los mismos.

Y ¿qué Contras tenemos?

- **Require importación y exportación manual** - añade más código y por ende requiere de más mantenimiento.
- **Puede requerir `addDepedency` manual** - en la mayoría de los casos necesitaremos explicitar la dependencia entre _stacks_ manualmente con el `addDependency` que veíamos antes.
- **Los nombres de los exports son únicos por cuenta-región** - esto implica que tenemos que evitar las colisiones de nombres de forma manual, usando un inventario o bien convenios de nombres.

### Código estático

Se trata de usar métodos y/o valores estáticos definidos en un lugar concreto para compartirlos entre _stacks_ usando el código. Pongamos un ejemplo:

```typescript
export class CommonStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, 'MyVpc', {
      vpcName: 'my-vpc',
    });

    new ecs.Cluster(this, 'MyEcsCluster', {
      clusterName: CommonStack.getEcsClusterName(),
      vpc,
    });
  }

  private static getEcsClusterName(): string {
    return 'my-cluster';
  }

  public static getEcsCluster(scope: Construct, id: string): ecs.ICluster {
    const vpc = ec2.Vpc.fromLookup(scope, `${id}Vpc`, {
      vpcName: 'my-vpc',
    });

    return ecs.Cluster.fromClusterAttributes(scope, id, {
      clusterName: this.getEcsClusterName(),
      vpc,
    });
  }
}
```

Y en B tendríamos:

```typescript
export class NewServiceStack extends cdk.Stack {

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const cluster = CommonStack.getEcsCluster(this, 'MyCluster');

    const taskDefinition = new ecs.FargateTaskDefinition(this, 'MyTask');

    taskDefinition.addContainer('MyApp', {
      image: ecs.RepositoryImage.fromEcrRepository(
        ecr.Repository.fromRepositoryName(this, 'MyAppEcrRepo', 'my-app'),
        'latest',
      ),
    });

    new ecs.FargateService(this, 'MyEcsService', {
      cluster,
      taskDefinition,
      desiredCount: 0,
    });
  }
}
```

Cuando los valores a compartir son valores estáticos, constantes o pueden ser compuestos por valores conocidos, resulta trivial utilizar una solución donde, usando las características del lenguaje, creamos los elementos necesarios para compartir información sin necesidad de exportar ni importar nada.

En este ejemplo, estamos creando métodos estáticos en A para crear _constructs_ CDK para un scope pasado por parámetro usando _constructs_ importados (`fromLookup` y `fromClusterAttributes`), de esta forma, podemos instanciar fácilmente estos recursos en otros _stacks_ y operar con ellos como si se hubiesen creado en los _stacks_ consumidores.

En lo relativo a PROs y CONs, tenemos como PROs:

- Al igual que antes, **evitamos los problemas** de eliminar o actualizar dependencias.
- **Es más natural para el código de nuestra aplicación CDK** - en B, podemos ver de forma explícita en el código que la instancia del cluster proviene de A y en B no necesitamos preocuparnos por los detalles de cómo se obtuvo.
- **Evita los exports e imports de CloudFormation** - en este caso no se necesitan los outputs de CloudFormation, en B se utilizarán en la mayoría de los casos directamente los valores que se necesiten.
- **Aporta un motivo para la creación de librerías CDK** - podemos de forma sencilla llevar las partes comunes más reutilizables a una librería y poder compartir ese código entre otras aplicaciones CDK.

En cuanto los Contras:

- **Puede requerir `addDepedency` manual** - en la mayoría de los casos necesitaremos explicitar la dependencia entre _stacks_ manualmente con el `addDependency` que veíamos antes.
- **La creación de estos “constructs importados” a veces no es trivial**- aunque CDK provee de muchas funciones tipo `fromXXXX` y `lookups` que nos ayudan mucho con esto, a veces puede ocurrir que necesitemos valores que a priori no disponemos de ellos fácilmente y puede requerir adaptar nuestro código para obtenerlos.

### Uso de parámetros SSM

Esta estrategia es muy similar a la de "exportación manual", pero en lugar de usar outputs de CloudFormation, usaremos parámetros SSM, ya que éstos no crean una dependencia rígida en CloudFormation y además nos permite exponer estos valores a fuentes externas, como otras aplicaciones CDK o el backend de nuestros _workloads_.

Además, CloudFormation acepta SSM como parámetros de _stacks_, y es así como CDK "traduce" esta aproximación para CloudFormation, haciendo que la dependencia también esté en CloudFormation de forma explícita.

Si tomamos el ejemplo de la estrategia de exportación manual y lo modificamos un poco (podemos aprovechar también para mezclarla con la segunda estrategia) quedaría algo así para el stack A:

```typescript
export class NetworkStack extends cdk.Stack {
  public readonly managementSecurityGroupIdSSM: ssm.IStringParameter;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = new ec2.Vpc(this, 'MyVpc');
    
    new ssm.StringParameter(this, 'VPCIdSSM', {
      parameterName: NetworkStack.getVpcIdSSMParamName(),
      stringValue: vpc.vpcId,
    });
  }

  private static getVpcIdSSMParamName(): string {
    return '/network/vpc/id';
  }

  public static getVpc(scope: Construct, id: string): ec2.IVpc {
    const vpcIdSSM = ssm.StringParameter.fromStringParameterName(scope, `${id}VPCId`,
      NetworkStack.getVpcIdSSMParamName(),
    );

    return ec2.Vpc.fromLookup(scope, id, {
      vpcId: vpcIdSSM.stringValue,
    })
  }
}
```

Y para el stack B:

```typescript
export class ApplicationStack extends cdk.Stack {

  constructor(scope: Construct, id: string, props: cdk.StackProps) {
    super(scope, id, props);

    const vpc = NetworkStack.getVpc(this, 'Vpc');

    const mySecurityGroup = new ec2.SecurityGroup(this, 'MySecurityGroup', {
      vpc,
    });
  }
}
```

En cuanto a nuestra comparativa, como PROs, podemos señalar, todas las ventajas de los métodos anteriores:

- Los valores compartidos **se puede leer fácilmente desde otras fuentes** - es decir, al estar almacenados en SSM, estos valores se podrían leer desde otras aplicaciones no necesariamente en CDK o incluso desde el código de nuestras cargas de trabajo; este sin duda es el mayor beneficio de usar SSM.

Y en los Contras, básicamente, podemos encontrar casi los mismos que veíamos en las estrategias anteriores y al mismo tiempo:

- **Es fácil perder la trazabilidad de quien está consumiendo los parámetros SSM** - dado que es fácil consumirlos desde fuentes externas, resulta difícil saber si ya no están en uso, habría que analizar las llamadas a la API y tener muy controladas las políticas de acceso.

### Referencias dinámicas de CloudFormation

Por último, vamos a hablar de otra estrategia que es básicamente una evolución de la anterior: el uso de referencias dinámicas a parámetros SSM de CloudFormation.

Modificamos el ejemplo anterior, en A cambiamos el método `getVpc` y añadimos uno nuevo:

```typescript
private static getReferenceFromSSMParameter(
  ssmParameterName: string,
  ssmParameterVersion: number,
): string {
  return new cdk.CfnDynamicReference(
    cdk.CfnDynamicReferenceService.SSM,
    `${ssmParameterName}:${String(ssmParameterVersion)}`,
  ).toString();
}

public static getVpc(scope: Construct, id: string): ec2.IVpc {
  const vpcId = CoreStack.getReferenceFromSSMParameter(NetworkStack.getVpcIdSSMParamName(), 1);

  return ec2.Vpc.fromVpcAttributes(scope, id, {
    vpcId,
    availabilityZones: [
      'eu-west-1a',
      'eu-west-1b',
      'eu-west-1c',
    ],
  })
}
```

Y B permanecería exactamente igual.

El nuevo método que hemos añadido `getReferenceFromSSMParameter`, básicamente es el encargado de crear la referencia dinámica usando el nombre del parámetro SSM y su versión, lo que en CloudFormation sería algo como:

```
{{resolve:ssm:/network/vpc/id:1}}
```

Esta referencia se resolverá en tiempo de despliegue de CloudFormation y nos permite además consumir una versión específica de un parámetro SSM aportando un grado extra de control en el ciclo de vida de estas dependencias.

Entre los PROs, vemos todos los mismos que tenía el método de los parámetros SSM y además:

- **Evita los lookups durante la sintetización** - usando parámetros SSM CDK comprobará que los parámetros existen o forman parte de la aplicación CDK y puede que haga llamadas a la API de AWS al sintetizar las templates de CloudFormation antes del despliegue, con los parámetros dinámicos esto sucede en tiempo de despliegue, reduciendo el tiempo de sintetización y por ende acortando tiempos de desarrollo y testing.
- **Permite un control del ciclo de vida de las dependencias mucho más exhaustivo** - al usar la versión concreta del parámetro SSM, podemos tener _stacks_ que sólo actualizan a una versión nueva del valor cuando realmente lo necesitan, no antes.

Con los Contras, también vemos todos los mismos que tenía el método de los parámetros SSM y además:

- **Requiere la gestión de versiones de los parámetros SSM** - esto es un arma de doble filo porque si bien representa un beneficio, también añade un extra de esfuerzo en el mantenimiento. Podríamos no especificar la versión, pero entonces CloudFormation calcula la última disponible en el momento del despliegue y nunca detectará un cambio a no ser que explícitamente cambiemos la referencia y usemos una versión determinada.

## Conclusiones

Hemos visto qué y cómo son las dependencias entre _stacks_ así como cuatro diferentes estrategias para combatir los posibles problemas inherentes a las mismas.

Como recomendación final, no os quedéis con una estrategia concreta, utilizad aquellas que más os convengan según la situación, mezcladlas con otras estrategias o prescindid de todas ellas si vuestro caso de uso no tiene la complejidad suficiente.

Se trata de encontrar el equilibrio adecuado para cada caso.

## Referencias

- [AWS CDK documentación oficial](https://docs.aws.amazon.com/cdk/api/v2/).
- [CloudFormation outputs](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html).
- [CloudFormation import values](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html).
- [CloudFormation stack parameters](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html).
- [CloudFormation dynamic references](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.html).
- [GitHub repository con códigos de ejemplo](https://github.com/neovasili/cdk-day-2023-stacks-dependencies).


_Agradecimientos a [Gonzalo Camarero](https://twitter.com/gonzalocamarero) por su ayuda en la revisión del texto._