ROBOT PAYMENT TECH-BLOG

株式会社ROBOT PAYMENTのテックブログです

AWS CDKを利用したAppRunner環境構築の実装

こんにちは。ROBOT PAYMENTでエンジニアをしているtakamoriです。 今回は、AWS App Runnerを使って簡単にデプロイできましたの記事の続編として、AppRunner環境の構築に利用しているAWS CDKの実装編を書いていきたいと思います。

tech.robotpayment.co.jp

目的

本記事の目的は、主にAppRunnerとRDS連携のCDK実装のサンプルコードの紹介です。 AppRunnerはフルマネージドサービスなので簡単な設定で利用できますが、RDSやDynamoDBといった別リソースにアクセスする等が往々に発生します。その際の参考にしていただければ幸いです。

AWS CDKとは

AWS Cloud Development Kit (AWS CDK)とは、TypeScript、Python、Javaなどのプログラミング言語を使用してクラウドインフラストラクチャをコードとして定義し、それを AWS CloudFormation を通じてデプロイするためのオープンソースのソフトウェア開発フレームワークです。 詳細は、Qiitaで@Brutusさんが執筆されている5分で理解するAWS CDKがとても参考になったので、気になる方は是非参照してください。

qiita.com

構築対象のAWSサービス

今回の対象AWSサービスは下記のサービスです。 また、構成に関してはAWS App Runnerを使って簡単にデプロイできましたの記事で紹介しています。

  • VPC
  • SecurityGroup (SG)
  • ECR
  • RDS
  • AppRunner

実装方針

AppRunner環境の構築はCDKの方針に則って以下の順序で実装します。 1. Constructで各サービスを実装 2. StackでConstructで定義した各サービスの構築タイミングや順序の制御 3. AppでStackの依存関係を定義

Constructで各サービスを実装

VPC, SecurityGroup

VPCとSGはAWS上でも紐付きが強いので、今回はNetworkとして1つのConstruct内で同時に定義しました。 CDKではクラス変数を定義することで、インスタンス生成時にそれらの値を受け取ることが可能です。

import { Construct } from 'constructs'
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import { Tags } from 'aws-cdk-lib'

export class Network extends Construct {
  readonly vpc: ec2.Vpc
  readonly dbSecurityGroup: ec2.SecurityGroup
  readonly appRunnerSecurityGroup: ec2.SecurityGroup

  constructor(scope: Construct, id: string) {
    super(scope, id)

    this.vpc = new ec2.Vpc(scope, 'VPC', {
      vpcName: 'myapp-vpc',
      cidr: '10.250.0.0/16',
      maxAzs: 3,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'myapp-Isolated',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
        {
          cidrMask: 24,
          name: 'myapp-Public',
          subnetType: ec2.SubnetType.PUBLIC,
        },
        {
          cidrMask: 24,
          name: 'myapp-Private',
          subnetType: ec2.SubnetType.PRIVATE_WITH_NAT,
        },
      ],
      natGateways: 1,
    })

    // App Runnerに設定するセキュリティグループ
    this.appRunnerSecurityGroup = new ec2.SecurityGroup(scope, 'AppRunnerSecurityGroup', {
      securityGroupName: 'myapp-app-runner-sg',
      description: 'for myapp-app-runner',
      vpc: this.vpc,
    })

    // RDSに設定するセキュリティグループ
    this.dbSecurityGroup = new ec2.SecurityGroup(scope, 'DBSecurityGroup', {
      allowAllOutbound: true,
      securityGroupName: 'myapp-db',
      description: 'for myapp-db',
      vpc: this.vpc,
    })
    // AppRunnerSecurityGroupからのポート5432のインバウンドを許可
    this.dbSecurityGroup.addIngressRule(this.appRunnerSecurityGroup, ec2.Port.tcp(5432))
  }
}

ECR

ECRではLifecycleRuleを設定して、過去のイメージが残らないようにします。

import { RemovalPolicy } from 'aws-cdk-lib'
import * as ecr from 'aws-cdk-lib/aws-ecr'
import { Construct } from 'constructs'

export class EcrRepository extends Construct {
  readonly repository: ecr.Repository

  constructor(scope: Construct, id: string) {
    super(scope, id)
    // リポジトリ作成
    this.repository = new ecr.Repository(scope, 'MyAppRepository', {
      repositoryName: 'myapp-repository',
      removalPolicy: RemovalPolicy.RETAIN,
      imageScanOnPush: true,
    })

    // LifecycleRule作成
    this.repository.addLifecycleRule({
      tagStatus: ecr.TagStatus.ANY,
      description: 'Delete more than 30 image',
      maxImageCount: 30,
    })
  }
}

RDS

RDSではVPC, SGで定義したリソースをPropsとして受け取ります。 受け取ったPropsはDatabaseClusterを定義する際のVPCとSGとしてそれぞれ利用します。 また、AppRunnerから接続するためのユーザー/パスワード情報は、SecretManagerに保存して秘匿性を保っています。 今回は、Aurora PostgreSQLでRDSを構築しています。

import { Construct } from 'constructs'
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as rds from 'aws-cdk-lib/aws-rds'

interface RdsProps {
  vpc: ec2.Vpc
  dbSecurityGroup: ec2.SecurityGroup
}

export class Rds extends Construct {
  constructor(scope: Construct, id: string, props: RdsProps) {
    super(scope, id)

    const { vpc, dbSecurityGroup } = props
    const instances = 2
    const instanceType = ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE4_GRAVITON, ec2.InstanceSize.MEDIUM)

    // RDSのパスワードを自動生成してSecret Managerに格納
    const rdsCredentials = rds.Credentials.fromGeneratedSecret('db_user', {
      secretName: 'myapp-DbSecret',
    })

    // DB クラスターのパラメータグループ作成
    const clusterParameterGroup = new rds.ParameterGroup(scope, 'ClusterParameterGroup', {
      engine: rds.DatabaseClusterEngine.auroraPostgres({
        version: rds.AuroraPostgresEngineVersion.VER_13_6,
      }),
      parameters: {
        timezone: 'Asia/Tokyo',
      },
      description: 'for-myapp',
    })
    clusterParameterGroup.bindToCluster({})

    // DB インスタンスのパラメータグループ作成
    const instanceParameterGroups = new rds.ParameterGroup(scope, 'InstanceParameterGroups', {
      engine: rds.DatabaseClusterEngine.auroraPostgres({
        version: rds.AuroraPostgresEngineVersion.VER_13_6,
      }),
      parameters: {
        log_lock_waits: '1', //deadlockのタイムアウト時間を超えたら出力
      },
      description: 'for-myapp',
    })
    instanceParameterGroups.bindToInstance({})

    new rds.DatabaseCluster(scope, 'MyAppDbCluster', {
      engine: rds.DatabaseClusterEngine.auroraPostgres({
        version: rds.AuroraPostgresEngineVersion.VER_13_6,
      }),
      storageEncrypted: true,
      credentials: rdsCredentials,
      clusterIdentifier: 'myapp-cluster',
      instanceIdentifierBase: 'myapp-instance',
      instances,
      instanceProps: {
        instanceType,
        vpc,
        vpcSubnets: vpc.selectSubnets({
          subnetGroupName: 'myapp-Isolated',
        }),
        securityGroups: [dbSecurityGroup],
        parameterGroup: instanceParameterGroups,
        enablePerformanceInsights: true,
        performanceInsightRetention: rds.PerformanceInsightRetention.LONG_TERM,
      },
      parameterGroup: clusterParameterGroup,
      defaultDatabaseName: 'myapp',
    })
  }
}

AppRunner

AppRunnerではVPC, SG, ECRで定義したリソースをPropsとして受け取ります。 AppRunnerからRDSへの接続情報は、RDSで事前登録しておいたSecretManagerからキー情報を入力して取得し、環境変数として登録します。

import { Construct } from 'constructs'
import * as iam from 'aws-cdk-lib/aws-iam'
import * as ecr from 'aws-cdk-lib/aws-ecr'
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'
import { aws_apprunner as appRunner } from 'aws-cdk-lib'
import * as ec2 from 'aws-cdk-lib/aws-ec2'

interface AppRunnerProps {
  vpc: ec2.Vpc
  repository: ecr.Repository
  appRunnerSecurityGroup: ec2.SecurityGroup
}

export class AppRunner extends Construct {
  constructor(scope: Construct, id: string, props: AppRunnerProps) {
    super(scope, id)

    const { vpc, repository, appRunnerSecurityGroup } = props

    // Roles
    const instanceRole = new iam.Role(scope, 'AppRunnerInstanceRole', {
      roleName: 'myapp-AppRunnerInstanceRole',
      assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com'),
    })
    const accessRole = new iam.Role(scope, 'AppRunnerAccessRole', {
      roleName: 'myapp-AppRunnerAccessRole',
      assumedBy: new iam.ServicePrincipal('build.apprunner.amazonaws.com'),
    })
    accessRole.addManagedPolicy(
      iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSAppRunnerServicePolicyForECRAccess'),
    )

    // AppRunner用Secrets Manager
    const secretsAppRunner = new secretsmanager.Secret(this, 'SecretAppRunner', {
      description: 'for-myapp-AppRunner',
      secretName: 'myapp-AppRunner',
      generateSecretString: {
        includeSpace: false,
        excludePunctuation: true,
        passwordLength: 48,
        excludeCharacters: ' %+~`#$&*()|[]{}:;<>?!\'/@"\\',
        generateStringKey: 'api_key',
        secretStringTemplate: JSON.stringify({
          // 固定値を追加登録したい場合はここに追記
          environment: 'dev',
        }),
      },
    })

    // Rdsクラスで作成したRDSの接続情報をSecret Managerから取得
    const secretsDB = secretsmanager.Secret.fromSecretNameV2(scope, 'MyAppDbSecret', 'myapp-DbSecret')
    const secrets = secretsmanager.Secret.fromSecretNameV2(this, 'MyAppSecret', 'myapp-robo-dev')

    const vpcConnector = new appRunner.CfnVpcConnector(scope, 'MyAppVpcConnector', {
      subnets: vpc.selectSubnets({
        subnetGroupName: 'myapp-Private',
      }).subnetIds,
      securityGroups: [appRunnerSecurityGroup.securityGroupId],
      vpcConnectorName: 'myapp',
    })
    const cfnService = new appRunner.CfnService(scope, 'MyAppService', {
      sourceConfiguration: {
        authenticationConfiguration: {
          accessRoleArn: accessRole.roleArn,
        },
        autoDeploymentsEnabled: true,
        imageRepository: {
          imageIdentifier: `${repository.repositoryUri}:latest`,
          imageRepositoryType: 'ECR',
          imageConfiguration: {
            port: '3000',
            runtimeEnvironmentVariables: [
              // AppRunner用環境変数を定義
              {
                name: 'DB_HOST',
                value: secretsDB.secretValueFromJson('host').toString(),
              },
              {
                name: 'DB_PORT',
                value: secretsDB.secretValueFromJson('port').toString(),
              },
              {
                name: 'DB_USERNAME',
                value: secretsDB.secretValueFromJson('username').toString(),
              },
              {
                name: 'DB_PASSWORD',
                value: secretsDB.secretValueFromJson('password').toString(),
              },
              {
                name: 'DB_DATABASE',
                value: secretsDB.secretValueFromJson('dbname').toString(),
              },
            ],
          },
        },
      },
      healthCheckConfiguration: {
        timeout: 5,
        interval: 10,
        healthyThreshold: 2,
        unhealthyThreshold: 5,
      },
      instanceConfiguration: {
        instanceRoleArn: instanceRole.roleArn,
        cpu: '1024',
        memory: '2048',
      },
      networkConfiguration: {
        egressConfiguration: {
          egressType: 'VPC',
          vpcConnectorArn: vpcConnector.attrVpcConnectorArn,
        },
      },
      serviceName: 'myapp',
    })
  }
}

StackでConstructで定義した各サービスの構築タイミングや順序の制御

今回はAppRunner本体を更新するケースが多いことを想定し、下記2つにStackを分割しました。 もちろん、1つのStackとして構築することも可能です。

  1. VPC、ECR、RDSなどのAppRunnerが参照するサービス
  2. AppRunner本体

VPC、ECR、RDSなどのAppRunnerが参照するサービス

Stackの実装はシンプルでConstructで定義したクラスのインスタンスを生成するだけです。 前述した通り、インスタンス生成の返り値としてConstruct内で定義していたクラス変数が取得できます。 また、RDSのように他のリソースが必要な場合は、インスタンス生成時に第3引数で指定します。

import { Stack, StackProps } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as ecr from 'aws-cdk-lib/aws-ecr'
import * as ec2 from 'aws-cdk-lib/aws-ec2'

import { Network } from '../construct/network'
import { Rds } from '../construct/rds'
import { EcrRepository } from '../construct/ecr-repository'

export class BackendInitStack extends Stack {
  readonly repository: ecr.Repository
  readonly vpc: ec2.Vpc
  readonly appRunnerSecurityGroup: ec2.SecurityGroup

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

    // VPC
    const { vpc, dbSecurityGroup, appRunnerSecurityGroup } = new Network(this, 'Network')

    // ECR
    const { repository } = new EcrRepository(this, 'Ecr')

    // RDS
    // VPCとSGのリソース情報をPropsとして引き渡す
    new Rds(this, 'Rds', { vpc, dbSecurityGroup })

    this.repository = repository
    this.vpc = vpc
    this.appRunnerSecurityGroup = appRunnerSecurityGroup
  }
}

AppRunner本体

AppRunnerのStackもシンプルに必要なリソースを引数として引き渡すだけです。

import { Stack, StackProps } from 'aws-cdk-lib'
import { Construct } from 'constructs'
import * as ec2 from 'aws-cdk-lib/aws-ec2'
import * as ecr from 'aws-cdk-lib/aws-ecr'

import { AppRunner } from '../construct/app-runner'

interface AppProps {
  repository: ecr.Repository
  vpc: ec2.Vpc
  appRunnerSecurityGroup: ec2.SecurityGroup
}

export class BackendAppStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps, appProps: AppProps) {
    super(scope, id, props)
    const { vpc, repository, appRunnerSecurityGroup } = appProps

    // AppRunner
    new AppRunner(this, 'AppRunner', { vpc, repository, appRunnerSecurityGroup })
  }
}

AppでStackの依存関係を定義

最後に依存関係のあるStackをAppに紐づけていきます。 Appの実装も簡単で、Stackで実装したクラスのインスタンス生成を行うだけです。 依存される側から先にインスタンス化する必要があるので、VPC等を含んだBackendInitStackクラスから生成します。

import 'source-map-support/register'
import * as cdk from 'aws-cdk-lib'

import { BackendInitStack } from '../lib/stack/backend-init-stack'
import { BackendAppStack } from '../lib/stack/backend-app-stack'

const app = new cdk.App()
const service = 'myapp'

const { vpc, repository, appRunnerSecurityGroup } = new BackendInitStack(app, `myapp-backend-stack-init`, {
  description: `backend-for-myapp-init`,
  tags: {
    service: service,
    environment: 'dev',
  },
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: process.env.CDK_DEFAULT_REGION,
  },
})

new BackendAppStack(
  app,
  `myapp-backend-stack-app`,
  {
    description: `backend-for-myapp-app`,
    tags: {
      service: service,
      environment: 'dev',
    },
    env: {
      account: process.env.CDK_DEFAULT_ACCOUNT,
      region: process.env.CDK_DEFAULT_REGION,
    },
  },
  { vpc, repository, appRunnerSecurityGroup },
)

最後に

以上がAppRunnerとRDS連携のCDK実装のサンプルコードの紹介でした。CDKは身近なプログラミング言語を利用してインフラ構築や管理が簡単にできるので重宝しています。CDKの欠点としては、裏側ではAWS CloudFormationを利用するので、

  • CloudFormationがサポートしていないAWSサービスの構築ができない
  • CloudFormationがサポートしてから、CDKでもサポートするまでに少し時間がかかる(SDKバージョンアップが必要)
  • GCPやAzureなど他のクラウドサービスに流用できない

が、考えられます。とはいえ、通常良く利用するAWSサービスはサポートしていますので、AWSでインフラ構築する場合は選択肢の一つとして取り入れても良さそうです。

We are hiring!!

ROBOT PAYMENTでは一緒に働く仲間を募集しています!!!

speakerdeck.com
www.robotpayment.co.jp
🎉twitter採用担当アカウント開設!🎉どんどん情報発信していきます!!