AWS CDK v2 + L1 + TypeScriptで苦しんで構築するApp Runner

AWS CDK v2で若干苦しんだので書きます。

## 環境
AppRunnerにRailsアプリをデプロイしようと思います。
たぶん無料枠超えてると思うので試す時はご注意を。

## CDKの環境を作る
いつも通り`cdk init`で作ります。
CDK v2.54.0なので、CDK v2のテンプレートで作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/461ee7b9-5cb2-3276-4674-95845ee39386.png)

### VPC書く
よしなに書くだけですね。

```ts
const vpc = new ec2.Vpc(this, 'VPC', {
  subnetConfiguration: [
    {
      cidrMask: 24,
      name: 'rds',
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED
    }
  ]
})
```

#### セキュリティグループ作る
App Runnerのインスタンス用とRDS用のセキュリティグループを作成して、RDSへのアクセスはApp Runnerのセキュリティグループからのみ許可するようにします。

```ts
const appRunnerSecurityGroup = new ec2.SecurityGroup(this, 'AppRunnerSecurityGroup', { vpc })
const rdsSecurityGroup = new ec2.SecurityGroup(this, 'RDSSecurityGroup', { vpc })
rdsSecurityGroup.addIngressRule(appRunnerSecurityGroup, ec2.Port.tcp(3306))
```


### RDSインスタンス書く
こちらも同じくよしなに書けばいい感じに作ってくれます。
ただ、デフォルトでm5.largeだった気がするのでinstanceTypeは必ず指定しておいたほうが良いかと。

今回は使い捨てなので平文パスワードですが実際はSSMやSecrets Managerの値を使ってくださいね！

```ts
const dbInstance = new rds.DatabaseInstance(this, 'RDS', {
  engine: rds.DatabaseInstanceEngine.MYSQL,
  vpc: vpc,
  vpcSubnets: {
    subnets: vpc.isolatedSubnets
  },
  securityGroups: [rdsSecurityGroup],
  instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MICRO),
  credentials: {
    username: 'root',
    password: SecretValue.unsafePlainText('INSECURE_PASSWORD')
  }
})
```

### ECR作る
今回は作って消すので`removalPolicy`を設定して`cdk destroy`で消せるようにしておきます。

```ts
const repository = new ecr.Repository(this, 'ECR', {
  removalPolicy: RemovalPolicy.DESTROY
})
```

### AppRunner書く
本題です。
先程まではL2コンストラクト使ってたんですが、ここからはL1しか無いのでCloudFormationのドキュメントとにらめっこしながら書く必要があります。

`aws-cdk-lib/aws_apprunner`のドキュメントにはほとんど情報がないのでとにかくドキュメントを読むしか無いです。
CDKの中身はCFnなのでそうなるよなーって感じですね。

https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-apprunner-service.html

#### App Runner用ロールを作る
インスタンスロールとECRアクセス用のロールを作成します。
ECRのロールに`AWSAppRunnerServicePolicyForECRAccess`を使ってますがどのリポジトリにもアクセスできてしまうため制限したい場合は自分でポリシー作成したほうが良いと思います。

```ts
const instanceRole = new iam.Role(this, 'AppRunnerInstanceRole', {
  assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com')
})
const ecrAccessRole = new iam.Role(this, 'AppRunnerECRAccessRole', {
  assumedBy: new iam.ServicePrincipal('build.apprunner.amazonaws.com'),
  managedPolicies: [
    iam.ManagedPolicy.fromAwsManagedPolicyName(
      'service-role/AWSAppRunnerServicePolicyForECRAccess',
    ),
  ]
})
```

#### VPC connector書く
RDSがプライベートなサブネットにいるので、App Runnerのインスタンスが通信できるようVPCコネクターを作成する必要があります。
L1なので`ISubnet[]`じゃなくてsubnetIdの方を渡してあげないといけないです。

```ts
const vpcConnector = new apprunner.CfnVpcConnector(this, 'VPCConnector', {
  subnets: vpc.isolatedSubnets.map((subnet) => subnet.subnetId),
  securityGroups: [appRunnerSecurityGroup.securityGroupId]
})
```

**App Runnerのインスタンスヘルスチェックに失敗すると全部ロールバックされるので、この時点で一旦デプロイしてしまったほうが楽かもしれないです。**

#### App Runnerサービス書く
サービスを書いていきます。
`healthCheckConfiguration`にハマりポイントがあって、プロトコルをHTTPにしないとpathプロパティを設定しても無視されます。
自分はこれで3時間位潰しました。

ドキュメントにバッチリ書いてありますね。
> Path is only applicable when you set Protocol to HTTP.

また、サービスがヘルスチェックに通らないとCloudFormationのデプロイに失敗するので予めECRにヘルスチェックが通るイメージを上げておく必要があります。
今回は初期化して適当に設定合わせたRailsプロジェクトを上げてます。

データベースパスワードなどのシークレットを平文で渡してますが、App Runnerがシークレットな環境変数をサポートしていないのでとりあえずやってます。
本来はRailsアプリ側でSSMやSecrets Managerからシークレットを取得するようにしないといけないです。

```ts
new apprunner.CfnService(this, 'AppRunnerService', {
  healthCheckConfiguration: {
    protocol: 'HTTP',
    path: '/',
  },
  networkConfiguration: {
    egressConfiguration: {
      egressType: 'VPC',
      vpcConnectorArn: vpcConnector.attrVpcConnectorArn,
    },
  },
  sourceConfiguration: {
    authenticationConfiguration: {
      accessRoleArn: ecrAccessRole.roleArn,
    },
    imageRepository: {
      imageRepositoryType: 'ECR',
      imageIdentifier: repository.repositoryUriForTag('latest'),
      imageConfiguration: {
        port: '3000',
        runtimeEnvironmentVariables: [
          {
            name: 'RAILS_ENV',
            value: 'production'
          },
          {
            name: 'RAILS_LOG_TO_STDOUT',
            value: 'true',
          },
          {
            name: 'DATABASE_HOST',
            value: dbInstance.dbInstanceEndpointAddress,
          },
          {
            name: 'DATABASE_PASSWORD',
            value: 'INSECURE_PASSWORD'
          }
        ]
      }
    },
  },
  instanceConfiguration: {
    cpu: '1024',
    memory: '2048',
    instanceRoleArn: instanceRole.roleArn,
  },
})
```

### デプロイ
最終的に以下の形になりました。

```ts
import { 
  Stack,
  StackProps,
  aws_apprunner as apprunner, 
  aws_ec2 as ec2, 
  aws_rds as rds,
  aws_iam as iam,
  aws_ecr as ecr,
  RemovalPolicy,
  SecretValue,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';

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

    /*
     * VPC
     */
    const vpc = new ec2.Vpc(this, 'VPC', {
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'rds',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED
        }
      ]
    })

    /*
     * Security groups
     */
    const appRunnerSecurityGroup = new ec2.SecurityGroup(this, 'AppRunnerSecurityGroup', { vpc })
    const rdsSecurityGroup = new ec2.SecurityGroup(this, 'RDSSecurityGroup', { vpc })
    rdsSecurityGroup.addIngressRule(appRunnerSecurityGroup, ec2.Port.tcp(3306))

    /*
     * RDS
     */
    const dbInstance = new rds.DatabaseInstance(this, 'RDS', {
      engine: rds.DatabaseInstanceEngine.MYSQL,
      vpc: vpc,
      vpcSubnets: {
        subnets: vpc.isolatedSubnets
      },
      securityGroups: [rdsSecurityGroup],
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MICRO),
      credentials: {
        username: 'root',
        password: SecretValue.unsafePlainText('INSECURE_PASSWORD')
      }
    })

    /*
     * IAM
     */
    const instanceRole = new iam.Role(this, 'AppRunnerInstanceRole', {
      assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com')
    })
    const ecrAccessRole = new iam.Role(this, 'AppRunnerECRAccessRole', {
      assumedBy: new iam.ServicePrincipal('build.apprunner.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName(
          'service-role/AWSAppRunnerServicePolicyForECRAccess',
        ),
      ]
    })

    /*
     * ECR
     */
    const repository = new ecr.Repository(this, 'ECR', {
      removalPolicy: RemovalPolicy.DESTROY
    })

    /*
     * App Runner
     */
    const vpcConnector = new apprunner.CfnVpcConnector(this, 'VPCConnector', {
      subnets: vpc.isolatedSubnets.map((subnet) => subnet.subnetId),
      securityGroups: [appRunnerSecurityGroup.securityGroupId]
    })

    new apprunner.CfnService(this, 'AppRunnerService', {
      healthCheckConfiguration: {
        protocol: 'HTTP',
        path: '/',
      },
      networkConfiguration: {
        egressConfiguration: {
          egressType: 'VPC',
          vpcConnectorArn: vpcConnector.attrVpcConnectorArn,
        },
      },
      sourceConfiguration: {
        authenticationConfiguration: {
          accessRoleArn: ecrAccessRole.roleArn,
        },
        imageRepository: {
          imageRepositoryType: 'ECR',
          imageIdentifier: repository.repositoryUriForTag('latest'),
          imageConfiguration: {
            port: '3000',
            runtimeEnvironmentVariables: [
              {
                name: 'RAILS_ENV',
                value: 'production'
              },
              {
                name: 'RAILS_LOG_TO_STDOUT',
                value: 'true',
              },
              {
                name: 'DATABASE_HOST',
                value: dbInstance.dbInstanceEndpointAddress,
              },
              {
                name: 'DATABASE_PASSWORD',
                value: 'INSECURE_PASSWORD'
              }
            ]
          }
        },
      },
      instanceConfiguration: {
        cpu: '1024',
        memory: '2048',
        instanceRoleArn: instanceRole.roleArn,
      },
    })
  }
}
```

起動時のスクリプトはこんな感じで用意しました。
必要に応じてアセットビルドしたりforeman等を呼び出したりすると良いと思います。
```sh
#!/bin/bash
set -eu

bundle exec rails db:create
bundle exec rails db:migrate
bundle exec rails server -p 3000 -b '0.0.0.0'
```

作成したECRにDockerイメージをpushした状態で`cdk deploy`したら無事デプロイできると思います。

## 動作確認
App Runnerサービスに表示されているエンドポイントURLにアクセスしたら無事Railsアプリが表示されると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/1474dd9b-782e-0118-8f3c-ec326b10edd3.png)

データベース作成＆マイグレーションもちゃんと行われていますね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/f6bdfd15-3124-8b60-fcc4-654aceb6e680.png)

## お掃除
一通り試したので`cdk destroy`で全部まるっと消してしまいましょう。

ECRはイメージがあると消えないことがあるので、`cdk destroy`でエラーが出たら手動で削除してください。
`removalPolicy`設定してるのになんで消えないんですかね？

## おわり
1日遅れてしまいましたが10日目でした。

今回はECRでデプロイしましたが、GitHubからCDKでデプロイする方法は未だに思いついてないです。
API叩けば行けると思うんですが...

https://docs.aws.amazon.com/apprunner/latest/api/API_CreateConnection.html

今のApp Runnerでは機密情報を環境変数に設定するといったことができないので早くできるようになってほしいなといった感じです。