Estrategias para manejar dependencias entre stacks en CDK

neovasili

Juan Manuel Ruiz Fernández

Posted on October 6, 2023

Estrategias para manejar dependencias entre stacks en CDK

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:

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

const stackB = new ExampleB(app, 'ExampleB', {
  vpc: stackA.vpc,
});
Enter fullscreen mode Exit fullscreen mode

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:

// 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"
    }
   }
  }
 }
 ...
}
Enter fullscreen mode Exit fullscreen mode

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 y stack-b.

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

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

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:

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Y en B tendríamos:

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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

const stackB = new ImportingStack(app, 'ExampleB', {
  vpcId: stackA.vpcId,
});
stackB.addDependency(stackA);
Enter fullscreen mode Exit fullscreen mode

¿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:

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Y en B tendríamos:

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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,
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

Y para el stack B:

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,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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',
    ],
  })
}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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

Agradecimientos a Gonzalo Camarero por su ayuda en la revisión del texto.

💖 💪 🙅 🚩
neovasili
Juan Manuel Ruiz Fernández

Posted on October 6, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related