実践!AWS CDK #33 RDS Stack

題字・息子たち
2022.03.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

スタック分割リファクタリング最終回!
RDS スタックの実装です。

前回の記事はこちら。

実装

追加するファイルは以下の通り。

├── lib
│   ├── resource
│   │   ├── rds-database.ts
│   │   ├── rds-parameter-group.ts
│   │   └── rds-subnet-group.ts
│   └── stack
│       └── rds-stack.ts
├── test
│   └── stack
│       └── rds-stack.test.ts

こちらもマネジメントコンソールの画面に合わせたリソースの分割となっています。

1

サブネットグループとパラメータグループは ElastiCache にも存在するため、将来を見据えてこちらのファイル名やクラス名には rds のプレフィックスを付けることにしました。

RDS のスタッククラスの実装はこちら。

lib/stack/rds-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { RdsDatabase } from '../resource/rds-database';
import { RdsParameterGroup } from '../resource/rds-parameter-group';
import { RdsSubnetGroup } from '../resource/rds-subnet-group';
import { Ec2Stack } from './ec2-stack';
import { IamStack } from './iam-stack';
import { SecretsManagerStack } from './secrets-manager-stack';
import { VpcStack } from './vpc-stack';

export class RdsStack extends Stack {
    constructor(
        scope: Construct,
        id: string,
        vpcStack: VpcStack,
        iamStack: IamStack,
        ec2Stack: Ec2Stack,
        secretsManagerStack: SecretsManagerStack,
        props?: StackProps
    ) {
        super(scope, id, props);

        // Subnet Group
        const subnetGroup = new RdsSubnetGroup(this, vpcStack.subnet);

        // Parameter Group
        const parameterGroup = new RdsParameterGroup(this);

        // Database
        new RdsDatabase(
            this,
            subnetGroup,
            parameterGroup,
            secretsManagerStack.secret,
            ec2Stack.securityGroup,
            iamStack.role
        );
    }
}

必要なスタックリソースをパラメーターで受け取り、RDS の各リソースを生成しています。

RdsSubnetGroup クラスの実装はこちら。

lib/resource/rds-subnet-group.ts

import { CfnDBSubnetGroup } from "aws-cdk-lib/aws-rds";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";
import { Subnet } from "./subnet";

export class RdsSubnetGroup extends BaseResource {
    public readonly subnetGroup: CfnDBSubnetGroup;

    constructor(scope: Construct, subnet: Subnet) {
        super();

        this.subnetGroup = new CfnDBSubnetGroup(scope, 'RdsDbSubnetGroup', {
            dbSubnetGroupDescription: 'Subnet Group for RDS',
            subnetIds: [subnet.db1a.ref, subnet.db1c.ref],
            dbSubnetGroupName: this.createResourceName(scope, 'rds-sng')
        });
    }
}

RdsParameterGroup クラスの実装はこちら。

lib/resource/rds-parameter-group.ts

import { CfnDBClusterParameterGroup, CfnDBParameterGroup } from "aws-cdk-lib/aws-rds";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";

export class RdsParameterGroup extends BaseResource {
    public readonly cluster: CfnDBClusterParameterGroup;
    public readonly instance: CfnDBParameterGroup;

    constructor(scope: Construct) {
        super();

        this.cluster = new CfnDBClusterParameterGroup(scope, 'RdsDbClusterParameterGroup', {
            description: 'Cluster Parameter Group for RDS',
            family: 'aurora-mysql5.7',
            parameters: { time_zone: 'UTC' }
        });

        this.instance = new CfnDBParameterGroup(scope, 'RdsDbParameterGroup', {
            description: 'Parameter Group for RDS',
            family: 'aurora-mysql5.7'
        });
    }
}

RdsDatabase クラスの実装はこちら。

lib/resource/rds-database.ts

import { CfnDBCluster, CfnDBInstance } from "aws-cdk-lib/aws-rds";
import { Construct } from "constructs";
import { BaseResource } from "./abstract/base-resouce";
import { RdsParameterGroup } from "./rds-parameter-group";
import { RdsSubnetGroup } from "./rds-subnet-group";
import { Role } from "./role";
import { OSecretKey, Secret } from "./secret";
import { SecurityGroup } from "./security-group";

interface InstanceInfo {
    readonly id: string;
    readonly availabilityZone: string;
    readonly preferredMaintenanceWindow: string;
    readonly resourceName: string;
}

export class RdsDatabase extends BaseResource {
    private static readonly engine = 'aurora-mysql';
    private static readonly databaseName = 'devio';
    private static readonly dbInstanceClass = 'db.r5.large';
    private readonly instances: InstanceInfo[] = [
        {
            id: 'RdsDbInstance1a',
            availabilityZone: 'ap-northeast-1a',
            preferredMaintenanceWindow: 'sun:20:00-sun:20:30',
            resourceName: 'rds-instance-1a'
        },
        {
            id: 'RdsDbInstance1c',
            availabilityZone: 'ap-northeast-1c',
            preferredMaintenanceWindow: 'sun:20:30-sun:21:00',
            resourceName: 'rds-instance-1c'
        }
    ];

    constructor(
        scope: Construct,
        subnetGroup: RdsSubnetGroup,
        parameterGroup: RdsParameterGroup,
        secret: Secret,
        securityGroup: SecurityGroup,
        role: Role
    ) {
        super();

        // DB Cluster
        const cluster = new CfnDBCluster(scope, 'RdsDbCluster', {
            engine: RdsDatabase.engine,
            backupRetentionPeriod: 7,
            databaseName: RdsDatabase.databaseName,
            dbClusterIdentifier: this.createResourceName(scope, 'rds-cluster'),
            dbClusterParameterGroupName: parameterGroup.cluster.ref,
            dbSubnetGroupName: subnetGroup.subnetGroup.ref,
            enableCloudwatchLogsExports: ['error'],
            engineMode: 'provisioned',
            engineVersion: '5.7.mysql_aurora.2.10.0',
            masterUserPassword: Secret.getDynamicReference(secret.rdsCluster, OSecretKey.MasterUserPassword),
            masterUsername: Secret.getDynamicReference(secret.rdsCluster, OSecretKey.MasterUsername),
            port: 3306,
            preferredBackupWindow: '19:00-19:30',
            preferredMaintenanceWindow: 'sun:19:30-sun:20:00',
            storageEncrypted: true,
            vpcSecurityGroupIds: [securityGroup.rds.attrGroupId]
        });

        // DB Instance
        for (const instanceInfo of this.instances) {
            this.createInstance(scope, instanceInfo, cluster, subnetGroup, parameterGroup, role);
        }
    }

    private createInstance(
        scope: Construct,
        instanceInfo: InstanceInfo,
        cluster: CfnDBCluster,
        subnetGroup: RdsSubnetGroup,
        parameterGroup: RdsParameterGroup,
        role: Role
    ): CfnDBInstance {
        const instance = new CfnDBInstance(scope, instanceInfo.id, {
            dbInstanceClass: RdsDatabase.dbInstanceClass,
            autoMinorVersionUpgrade: false,
            availabilityZone: instanceInfo.availabilityZone,
            dbClusterIdentifier: cluster.ref,
            dbInstanceIdentifier: this.createResourceName(scope, instanceInfo.resourceName),
            dbParameterGroupName: parameterGroup.instance.ref,
            dbSubnetGroupName: subnetGroup.subnetGroup.ref,
            enablePerformanceInsights: true,
            engine: RdsDatabase.engine,
            monitoringInterval: 60,
            monitoringRoleArn: role.rds.attrArn,
            performanceInsightsRetentionPeriod: 7,
            preferredMaintenanceWindow: instanceInfo.preferredMaintenanceWindow,
        });

        return instance;
    }
}

クラスターとインスタンスの生成処理を行います。

メインのスタッククラスの実装はこちら。

lib/devio-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Ec2Stack } from './stack/ec2-stack';
import { IamStack } from './stack/iam-stack';
import { RdsStack } from './stack/rds-stack';
import { SecretsManagerStack } from './stack/secrets-manager-stack';
import { VpcStack } from './stack/vpc-stack';

export class DevioStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // VPC Stack
    const vpcStack = new VpcStack(scope, 'VpcStack', {
      stackName: this.createStackName(scope, 'vpc')
    });

    // IAM Stack
    const iamStack = new IamStack(scope, 'IamStack', {
      stackName: this.createStackName(scope, 'iam')
    });

    // EC2 Stack
    const ec2Stack = new Ec2Stack(scope, 'Ec2Stack', vpcStack, iamStack, {
      stackName: this.createStackName(scope, 'ec2')
    });

    // Secrets Manager Stack
    const secretsManagerStack = new SecretsManagerStack(scope, 'SecretsManagerStack', {
      stackName: this.createStackName(scope, 'secrets-manager')
    });

    // RDS Stack
    new RdsStack(
      scope,
      'RdsStack',
      vpcStack,
      iamStack,
      ec2Stack,
      secretsManagerStack,
      { stackName: this.createStackName(scope, 'rds') }
    );
  }

  private createStackName(scope: Construct, originalName: string): string {
    const systemName = scope.node.tryGetContext('systemName');
    const envType = scope.node.tryGetContext('envType');
    const stackNamePrefix = `${systemName}-${envType}-stack-`;

    return `${stackNamePrefix}${originalName}`;
  }
}

ハイライト部分を追記しています。

ちなみにリファクタリング前の実装はこちら。

lib/devio-stack.ts

import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Vpc } from './resource/vpc';
import { Subnet } from './resource/subnet';
import { InternetGateway } from './resource/internetGateway';
import { ElasticIp } from './resource/elasticIp';
import { NatGateway } from './resource/natGateway';
import { RouteTable } from './resource/routeTable';
import { NetworkAcl } from './resource/networkAcl';
import { IamRole } from './resource/iamRole';
import { SecurityGroup } from './resource/securityGroup';
import { Ec2 } from './resource/ec2';
import { Alb } from './resource/alb';
import { SecretsManager } from './resource/secretsManager';
import { Rds } from './resource/rds';

export class DevioStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = new Vpc();
    vpc.createResources(this);

    // Subnet
    const subnet = new Subnet(vpc.vpc);
    subnet.createResources(this);

    // Internet Gateway
    const internetGateway = new InternetGateway(vpc.vpc);
    internetGateway.createResources(this);

    // Elastic IP
    const elasticIp = new ElasticIp();
    elasticIp.createResources(this);

    // NAT Gateway
    const natGateway = new NatGateway(
      subnet.public1a,
      subnet.public1c,
      elasticIp.ngw1a,
      elasticIp.ngw1c
    );
    natGateway.createResources(this);

    // Route Table
    const routeTable = new RouteTable(
      vpc.vpc,
      subnet.public1a,
      subnet.public1c,
      subnet.app1a,
      subnet.app1c,
      subnet.db1a,
      subnet.db1c,
      internetGateway.igw,
      natGateway.ngw1a,
      natGateway.ngw1c
    );
    routeTable.createResources(this);

    // Network ACL
    const networkAcl = new NetworkAcl(
      vpc.vpc,
      subnet.public1a,
      subnet.public1c,
      subnet.app1a,
      subnet.app1c,
      subnet.db1a,
      subnet.db1c
    );
    networkAcl.createResources(this);

    // IAM Role
    const iamRole = new IamRole();
    iamRole.createResources(this);

    // Security Group
    const securityGroup = new SecurityGroup(vpc.vpc);
    securityGroup.createResources(this);

    // EC2
    const ec2 = new Ec2(
      subnet.app1a,
      subnet.app1c,
      iamRole.instanceProfileEc2,
      securityGroup.ec2
    );
    ec2.createResources(this);

    // ALB
    const alb = new Alb(
      vpc.vpc,
      subnet.public1a,
      subnet.public1c,
      securityGroup.alb,
      ec2.instance1a,
      ec2.instance1c
    );
    alb.createResources(this);

    // Secrets Manager
    const secretsManager = new SecretsManager();
    secretsManager.createResources(this);

    // RDS
    const rds = new Rds(
      subnet.db1a,
      subnet.db1c,
      securityGroup.rds,
      secretsManager.secretRdsCluster,
      iamRole.rds
    );
    rds.createResources(this);
  }
}

各リソースをスタッククラスで一階層まとめたことによってだいぶスッキリしましたね。

テスト

テストコードはこちら。

test/stack/rds-stack.test.ts

import { App } from 'aws-cdk-lib';
import { Match, Template } from 'aws-cdk-lib/assertions';
import { Ec2Stack } from '../../lib/stack/ec2-stack';
import { IamStack } from '../../lib/stack/iam-stack';
import { RdsStack } from '../../lib/stack/rds-stack';
import { SecretsManagerStack } from '../../lib/stack/secrets-manager-stack';
import { VpcStack } from '../../lib/stack/vpc-stack';

test('Rds Stack', () => {
    const app = new App({
        context: {
            'systemName': 'devio',
            'envType': 'stg'
        }
    });
    const vpcStack = new VpcStack(app, 'VpcStack');
    const iamStack = new IamStack(app, 'IamStack');
    const ec2Stack = new Ec2Stack(app, 'Ec2Stack', vpcStack, iamStack);
    const secretsManagerStack = new SecretsManagerStack(app, 'SecretsManagerStack');
    const rdsStack = new RdsStack(app, 'RdsStack', vpcStack, iamStack, ec2Stack, secretsManagerStack);
    const template = Template.fromStack(rdsStack);

    // Subnet Group
    template.resourceCountIs('AWS::RDS::DBSubnetGroup', 1);
    template.hasResourceProperties('AWS::RDS::DBSubnetGroup', {
        DBSubnetGroupDescription: 'Subnet Group for RDS',
        SubnetIds: Match.anyValue(),
        DBSubnetGroupName: 'devio-stg-rds-sng'
    });

    // Parameter Group
    template.resourceCountIs('AWS::RDS::DBClusterParameterGroup', 1);
    template.hasResourceProperties('AWS::RDS::DBClusterParameterGroup', {
        Description: 'Cluster Parameter Group for RDS',
        Family: 'aurora-mysql5.7',
        Parameters: { time_zone: 'UTC' }
    });
    template.resourceCountIs('AWS::RDS::DBParameterGroup', 1);
    template.hasResourceProperties('AWS::RDS::DBParameterGroup', {
        Description: 'Parameter Group for RDS',
        Family: 'aurora-mysql5.7'
    });

    // DB Cluster
    template.resourceCountIs('AWS::RDS::DBCluster', 1);
    template.hasResourceProperties('AWS::RDS::DBCluster', {
        Engine: 'aurora-mysql',
        BackupRetentionPeriod: 7,
        DatabaseName: 'devio',
        DBClusterIdentifier: 'devio-stg-rds-cluster',
        DBClusterParameterGroupName: Match.anyValue(),
        DBSubnetGroupName: Match.anyValue(),
        EnableCloudwatchLogsExports: ['error'],
        EngineMode: 'provisioned',
        EngineVersion: '5.7.mysql_aurora.2.10.0',
        MasterUsername: Match.anyValue(),
        MasterUserPassword: Match.anyValue(),
        Port: 3306,
        PreferredBackupWindow: '19:00-19:30',
        PreferredMaintenanceWindow: 'sun:19:30-sun:20:00',
        StorageEncrypted: true,
        VpcSecurityGroupIds: Match.anyValue()
    });

    // DB Instance
    template.resourceCountIs('AWS::RDS::DBInstance', 2);
    template.hasResourceProperties('AWS::RDS::DBInstance', {
        DBInstanceClass: 'db.r5.large',
        AutoMinorVersionUpgrade: false,
        AvailabilityZone: 'ap-northeast-1a',
        DBClusterIdentifier: Match.anyValue(),
        DBInstanceIdentifier: 'devio-stg-rds-instance-1a',
        DBParameterGroupName: Match.anyValue(),
        DBSubnetGroupName: Match.anyValue(),
        EnablePerformanceInsights: true,
        Engine: 'aurora-mysql',
        MonitoringInterval: 60,
        MonitoringRoleArn: Match.anyValue(),
        PerformanceInsightsRetentionPeriod: 7,
        PreferredMaintenanceWindow: 'sun:20:00-sun:20:30',
    });
    template.hasResourceProperties('AWS::RDS::DBInstance', {
        DBInstanceClass: 'db.r5.large',
        AutoMinorVersionUpgrade: false,
        AvailabilityZone: 'ap-northeast-1c',
        DBClusterIdentifier: Match.anyValue(),
        DBInstanceIdentifier: 'devio-stg-rds-instance-1c',
        DBParameterGroupName: Match.anyValue(),
        DBSubnetGroupName: Match.anyValue(),
        EnablePerformanceInsights: true,
        Engine: 'aurora-mysql',
        MonitoringInterval: 60,
        MonitoringRoleArn: Match.anyValue(),
        PerformanceInsightsRetentionPeriod: 7,
        PreferredMaintenanceWindow: 'sun:20:30-sun:21:00',
    });
});

依存関係があるスタッククラスも生成しなければいけないこのやり方はなんとかならないものだろうか。

MySQL に関する問題

EC2 インスタンスから RDS への接続確認を行ったところ、EC2 インスタンスに MySQL クライアントがインストールされていないことが発覚しました。

EC2 のユーザーデータは以下の通りです。

lib/script/ec2/userData.sh

#!/bin/bash

# Apache のインストール
sudo yum -y install httpd
sudo systemctl enable httpd
sudo systemctl start httpd

# MySQL クライアントのインストール
sudo yum -y install https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm
sudo yum-config-manager --disable mysql80-community
sudo yum-config-manager --enable mysql57-community
sudo yum -y install mysql-community-client

調査したところハイライト部分のクライアントインストールコマンドで以下のエラーが出力されていました。

The GPG keys listed for the "MySQL 5.7 Community Server" repository are already installed but they are not correct for this package.
Check that the correct key URLs are configured for this repository.


 Failing package is: mysql-community-libs-compat-5.7.37-1.el7.x86_64
 GPG Keys are configured as: file:///etc/pki/rpm-gpg/RPM-GPG-KEY-mysql

どうやら MySQL 8.0.28 がリリースされた 2022/01/18 から GnuPG ビルドキー というものが更新されたようで、その時点から私が書いた従来のユーザーデータではクライアントがインストールできていなかったようですね、すみません。

新たなキーをインポートすることでこの問題は解消されます。

Note

The GnuPG build key used to sign MySQL downloadable packages was updated with the MySQL 8.0.28 release. To avoid key verification errors when upgrading to MySQL 8.0.28 or higher, import the new GnuPG key:

rpm --import https://repo.mysql.com/RPM-GPG-KEY-mysql-2022

インストールコマンドの直前に新たなキーのインポートコマンドを追加しました。

lib/script/ec2/userData.sh

#!/bin/bash

# Apache のインストール
sudo yum -y install httpd
sudo systemctl enable httpd
sudo systemctl start httpd

# MySQL クライアントのインストール
sudo yum -y install https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm
sudo yum-config-manager --disable mysql80-community
sudo yum-config-manager --enable mysql57-community
sudo rpm --import https://repo.mysql.com/RPM-GPG-KEY-mysql-2022
sudo yum -y install mysql-community-client

これで MySQL クライアントのインストールが成功し、RDS に接続することもできました。

GitHub

今回のソースコードは コチラ です。

おわりに

今回でスタック分割リファクタイングは完了です。以降はこの構成をベースに実装・構築し、改善点などを見つけていきたいと考えています。本シリーズは一旦ここで終了となりますが、気が向いたときには新たなリソースに関する記事を書いていこうと思います。

今までありがとうございました。
さようなら。

リンク