こんにちは。ROBOT PAYMENTでエンジニアをしているtakamoriです。 今回は、AWS App Runnerを使って簡単にデプロイできましたの記事の続編として、AppRunner環境の構築に利用しているAWS CDKの実装編を書いていきたいと思います。
目的
本記事の目的は、主にAppRunnerとRDS連携のCDK実装のサンプルコードの紹介です。 AppRunnerはフルマネージドサービスなので簡単な設定で利用できますが、RDSやDynamoDBといった別リソースにアクセスする等が往々に発生します。その際の参考にしていただければ幸いです。
AWS CDKとは
AWS Cloud Development Kit (AWS CDK)とは、TypeScript、Python、Javaなどのプログラミング言語を使用してクラウドインフラストラクチャをコードとして定義し、それを AWS CloudFormation を通じてデプロイするためのオープンソースのソフトウェア開発フレームワークです。 詳細は、Qiitaで@Brutusさんが執筆されている5分で理解するAWS CDKがとても参考になったので、気になる方は是非参照してください。
構築対象の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として構築することも可能です。
- VPC、ECR、RDSなどのAppRunnerが参照するサービス
- 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採用担当アカウント開設!🎉どんどん情報発信していきます!!