Search code examples
amazon-web-servicesamazon-rdsaws-cdkaws-fargate

AWS CDK, non-obvious cyclic dependency between fargate task and rds


I am trying to understand the cyclic dependency happening in my app. It's pretty clear that cyclic dependencies can be a <-> b, or something more complex where multiple components are involved. But for my case, it seems to be pretty clear that a -> b, there is no back and forth, but something internal in the way CDK resolves this, creates a cyclic dependency. Usual approach is to promote the resource one level up, but for me, these are pretty atomic, not sure what I can extract/ promote.

I have an RDS stack that only depends on VPC (and bastion) which is independent itself. I have a fargate task that needs to connect to this RDS instance. So

Fargate task -> RDS, this is the dependency chain, nothing else happening.

Here is the exact code

bin.ts

    const vpc = new VPCStack(app, "VPC", {
        deployEnv: environment,
        env: {
            region: ConstValues.region,
            account: ConstValues.account[environment],
        },
    })
    const bastion = new BastionStack(app, "Bastion", {
        deployEnv: environment,
        vpc: vpc.vpc,
        env: {
            region: ConstValues.region,
            account: ConstValues.account[environment],
        },
    })
    const rds = new RDSStack(app, "RDSStack", {
        deployEnv: environment,
        vpc: vpc.vpc,
        bastion: bastion.bastionHost,
        env: {
            region: ConstValues.region,
            account: ConstValues.account[environment],
        },
    })
    new PlausibleStack(app, "PlausibleStack", {
        deployEnv: environment,
        rds: rds.rds,
        vpc: vpc.vpc,
        env: {
            region: ConstValues.region,
            account: ConstValues.account[environment],
        },
    })

My rds-stack.ts

type RDSStackProps = {
    vpc: ec2.Vpc;
    bastion: ec2.BastionHostLinux;
} & StackProps;

export class RDSStack extends Stack {
    public rds: rds.DatabaseInstance;
    private vpc: ec2.Vpc;
    private deployEnv: DeployEnvironments;

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

        this.vpc = props.vpc;
        this.deployEnv = props.deployEnv;

        const credsSecretName = `/${id}/rds/creds/main-rds`.toLowerCase()
        const creds = new rds.DatabaseSecret(this, 'MainRDSCredentials', {
            secretName: credsSecretName,
            username: 'postgres'
        })

        const engine = rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_14 });

        const instanceType = isDev(props.deployEnv) ?
            ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.MICRO) :
            ec2.InstanceType.of(ec2.InstanceClass.T4G, ec2.InstanceSize.SMALL)

        this.rds = new rds.DatabaseInstance(this, 'MainRDS', {
            vpcSubnets: {
                subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
            },
            vpc: props.vpc,
            removalPolicy: isDev(props.deployEnv) ? RemovalPolicy.DESTROY : RemovalPolicy.SNAPSHOT,
            backupRetention: isProd(props.deployEnv) ? Duration.days(1) : Duration.days(0),
            credentials: rds.Credentials.fromSecret(creds),
            databaseName: ConstValues.database.backend.name,
            engine: engine,
            instanceType: instanceType,
            allocatedStorage: 10,
        })

        this.rds.connections.allowDefaultPortFrom(props.bastion)

        this.initCustomResource('RDSInit', creds);
    }

And my plausible-stack.ts <- the dependant

type PlausibleStackProps = {
    vpc: ec2.Vpc;
    rds: DatabaseInstance
} & StackProps

export class PlausibleStack extends cdk.Stack {
    private vpc: ec2.Vpc;
    private deployEnv: DeployEnvironments;
    private cluster: ecs.Cluster;
    private rds: DatabaseInstance;

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

        this.deployEnv = props.deployEnv;
        this.vpc = props.vpc;
        this.rds = props.rds;

        this.cluster = new ecs.Cluster(this, 'PlausibleCluster', {
            vpc: this.vpc,
        })

        this.setupPlausible();
    }

    private setupPlausible() {
        const securityGroup = new ec2.SecurityGroup(this, 'PlausibleSecurityGroup', {
            vpc: this.vpc,
        })

        const taskDef = new ecs.FargateTaskDefinition(this, 'PlausibleTask', {
            memoryLimitMiB: 1024,
            cpu: 512
        });

        const service = new ecs.FargateService(this, 'PlausibleService', {
            cluster: this.cluster,
            taskDefinition: taskDef,
            vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS },
            desiredCount: 1,
            securityGroups: [securityGroup]
        });

        const lb = new elbv2.ApplicationLoadBalancer(this, 'PlausibleALB', {
            vpc: this.vpc,
            internetFacing: true
        });

        const listener = lb.addListener('PlausibleALBListener', {
            port: 80
        });

        const plausibleContainer = taskDef.addContainer('Plausible', {
            image: ecs.ContainerImage.fromRegistry('plausible/analytics:v2.0'),
            logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'plausible' }),
            environment: {
                BASE_URL: `https://${lb.loadBalancerDnsName}`,
                DATABASE_URL: `postgres://${ConstValues.database.analytics.user}@${this.rds.instanceEndpoint}/${ConstValues.database.analytics.name}`,
            }
        });

        plausibleContainer.addPortMappings({
            containerPort: 8000,
        });

        listener.addTargets('PlausibleALBTargetPlausibleService', {
            port: 8000,
            targets: [service],
        });


        this.rds.grantConnect(taskDef.taskRole, ConstValues.database.analytics.user);
        this.rds.connections.allowDefaultPortFrom(securityGroup);
    }

And this is the error I am getting

                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 ^
Error: 'RDSStack' depends on 'PlausibleStack' (RDSStack -> PlausibleStack/PlausibleSecurityGroup/Resource.GroupId). Adding this dependency (PlausibleStack -> RDSStack/MainRDS/Resource.DbiResourceId) would create a cyclic reference.
    at PlausibleStack._addAssemblyDependency 

I get that there is something happening on the this.rds.grant and this.rds.connections lines but I don't see why would this be an issue. In my mind, the RDS instance would get created, then a plausible stack would modify the security group. Doesn't seem like they depend mutually on eachother.

What would be the cleanest way to solve this? And what is causing the issue so I know how to avoid it in the future?


Solution

  • This is called out in the EC2 module documentation:

    Cross stack connections

    If you are attempting to add a connection from a peer in one stack to a peer in a different stack, sometimes it is necessary to ensure that you are making the connection in a specific stack in order to avoid a cyclic reference. If there are no other dependencies between stacks then it will not matter in which stack you make the connection, but if there are existing dependencies (i.e. stack1 already depends on stack2), then it is important to make the connection in the dependent stack (i.e. stack1).

    Whenever you make a connections function call, the ingress and egress security group rules will be added to the stack that the calling object exists in. So if you are doing something like peer1.connections.allowFrom(peer2), then the security group rules (both ingress and egress) will be created in peer1's Stack.

    In your specific case, this means replacing

    this.rds.connections.allowDefaultPortFrom(myService);

    with

    myService.connections.allowToDefaultPort(this.rds);