AppRunnerをCDKで構築してみた

2022.02.15

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

LINE事業部の太田です。

App RunnerがVPCリソースへのアクセスが可能になったので、RDS + App RunnerをCDKで構築してみたいと思います。

今回作ったものは以下にあります。

ECRを作成

CDKでECRを作成します。

  • app-runner-example-stack.ts
import * as cdk from '@aws-cdk/core'
import * as ecr from '@aws-cdk/aws-ecr'

export class AppRunnerExampleStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    // ECR
    const repository = new ecr.Repository(this, 'AppRunnerExampleRepository')
  }
}

上記をデプロイすると、ECRが作成されます。

Webサーバーを用意

簡単なWebサーバーをExpressを使って構築します。

  • src/index.ts
import express from 'express'

const app = express()

app.use(express.json())
app.use(express.urlencoded({ extended: true }))

app.get('/', (req: express.Request, res: express.Response) => {
  res.send('Hello World!')
})

app.listen(3000, () => {
  console.log('Example app listening on port 3000!')
})
  • package.json
{
  "name": "app-runner-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "ts-node src/index.ts"
  },
  "dependencies": {
    "express": "4.17.2"
  },
  "devDependencies": {
    "@tsconfig/node14": "1.0.1",
    "@types/express": "4.17.13",
    "@types/node": "17.0.17",
    "ts-node": "10.5.0",
    "typescript": "4.5.5"
  }
}
  • Dockerfile
FROM node:14-slim

WORKDIR /usr/src/app

COPY . .

RUN yarn install --pure-lockfile --non-interactive

EXPOSE 3000

CMD ["yarn", "start"]

以下のコマンドでビルドしてECRにプッシュします。

$ aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
$ docker build -t app-runner-example .
$ docker tag app-runner-example ${REPOSITORY_URI}
$ docker push ${REPOSITORY_URI}

App Runnerを作成

CDKでApp Runnerを作成します。

  • app-runner-example-stack.ts
import { AppRunner } from './app-runner'

export class AppRunnerExampleStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
        super(scope, id, props)

    // ECR
    const repository = new ecr.Repository(this, 'AppRunnerExampleRepository')
        
        // AppRunner
    new AppRunner(this, 'AppRunner', { repository })  }
}
  • app-runner.ts
import * as cdk from '@aws-cdk/core'
import * as iam from '@aws-cdk/aws-iam'
import * as apprunner from '@aws-cdk/aws-apprunner'
import * as ecr from '@aws-cdk/aws-ecr'

interface AppRunnerProps {
  repository: ecr.Repository
}

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

    const { repository } = props

    // Roles
    const instanceRole = new iam.Role(scope, 'AppRunnerInstanceRole', {
      assumedBy: new iam.ServicePrincipal('tasks.apprunner.amazonaws.com'),
    })

    const accessRole = new iam.Role(scope, 'AppRunnerAccessRole', {
      assumedBy: new iam.ServicePrincipal('build.apprunner.amazonaws.com'),
    })

    // Apprunner
    new apprunner.Service(scope, 'AppRunnerExampleService', {
      source: apprunner.Source.fromEcr({
        imageConfiguration: {
          port: 3000, // Webサーバーを3000ポートとしているので、ここを合わせます
        },
        repository,
        tag: 'latest',
      }),
      instanceRole: instanceRole,
      accessRole: accessRole,
    })
  }
}

上記をデプロイすると、App Runnerが作成されます。

デフォルトドメインにアクセスするとレスポンスが返ってきます。

$ curl https://xxxxxxxxxx.ap-northeast-1.awsapprunner.com
Hello World!

RDS・VPCを作成

App RunnerからRDSにアクセスできるようにしていきます。

RDSやVPCなどをCDKで作成します。

  • app-runner-example-stack.ts
import * as cdk from '@aws-cdk/core'
import * as ecr from '@aws-cdk/aws-ecr'
import { AppRunner } from './app-runner'
import { Network } from './network'
import { Rds } from './rds'

export class AppRunnerExampleStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props)

    // ECR
    const repository = new ecr.Repository(this, 'AppRunnerExampleRepository')

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

    // RDS
    new Rds(this, 'Rds', { vpc, dbSecurityGroup })

    // AppRunner
    new AppRunner(this, 'AppRunner', { repository })
  }
}
  • network.ts
import * as cdk from '@aws-cdk/core'
import * as ec2 from '@aws-cdk/aws-ec2'

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

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

    this.vpc = new ec2.Vpc(this, 'VPC', {
      cidr: '10.0.0.0/16',
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'DB',
          subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
        },
      ],
      natGateways: 0,
    })

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

    // RDSに設定するセキュリティグループ
    this.dbSecurityGroup = new ec2.SecurityGroup(this, 'DBSecurityGroup', {
      allowAllOutbound: true,
      securityGroupName: 'app-runner-example-sg-db',
      vpc: this.vpc,
    })
        // AppRunnerSecurityGroupからのポート5432のインバウンドを許可
    this.dbSecurityGroup.addIngressRule(
      AppRunnerSecurityGroup,
      ec2.Port.tcp(5432)
    )
  }
}
  • rds.ts
import * as cdk from '@aws-cdk/core'
import * as rds from '@aws-cdk/aws-rds'
import * as ec2 from '@aws-cdk/aws-ec2'

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

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

    const { vpc, dbSecurityGroup } = props

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

    new rds.DatabaseCluster(
      scope,
      'AppRunnerExampleDbCluster',
      {
        engine: rds.DatabaseClusterEngine.auroraPostgres({
          version: rds.AuroraPostgresEngineVersion.VER_12_4,
        }),
        credentials: rdsCredentials,
        instances: 1,
        instanceProps: {
          instanceType: ec2.InstanceType.of(
            ec2.InstanceClass.BURSTABLE3,
            ec2.InstanceSize.MEDIUM
          ),
          vpc,
          vpcSubnets: vpc.selectSubnets({ subnetGroupName: 'DB' }),
          securityGroups: [dbSecurityGroup],
        },
        defaultDatabaseName: 'appRunnerExample',
      }
    )
  }
}
  • app-runner.ts
import * as cdk from '@aws-cdk/core'
import * as iam from '@aws-cdk/aws-iam'
import * as apprunner from '@aws-cdk/aws-apprunner'
import * as ecr from '@aws-cdk/aws-ecr'
import * as secretsmanager from '@aws-cdk/aws-secretsmanager'

interface AppRunnerProps {
  repository: ecr.Repository
}

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

        ...

      // rds.tsで作成したRDSの接続情報をSecret Managerから取得
    const secret = secretsmanager.Secret.fromSecretNameV2(
      scope,
      'AppRunnerExampleDbSecret',
      'AppRunnerExampleDbSecret'
    )

    // Apprunner
    new apprunner.Service(scope, 'AppRunnerExampleService', {
      source: apprunner.Source.fromEcr({
        imageConfiguration: {
          port: 3000,
          environment: {
            // DBの接続情報を環境変数へ格納
            dbUserName: secret.secretValueFromJson('username').toString(),
            dbPassword: secret.secretValueFromJson('password').toString(),
            dbHost: secret.secretValueFromJson('host').toString(),
            dbPort: secret.secretValueFromJson('port').toString(),
            dbName: secret.secretValueFromJson('dbname').toString(),
          },
        },
        repository,
        tag: 'latest',
      }),
      instanceRole: instanceRole,
      accessRole: accessRole,
    })
  }
}

ここまでをデプロイすると、RDSやVCPが作成されます。

App RunnerからRDSに接続

App RunnerからRDSに接続するために設定をします。

CDKでできれば良いのですが、この辺りはまだCDKでは提供されてないようです。

App Runnerのコンソールに移動し、[設定]タブの[サービスを設定]の編集ボタンを押します。

編集画面になるので、ネットワークキングの設定で[カスタムVPC] - [新規追加]を押して以下のように設定します。

あとは、[追加] - [変更を保存]を押して終了です。

WebサーバーでRDSに接続

Webサーバー側でRDSに接続するように修正して、ECRにプッシュします。

ORMとしてprismaを利用します。

  • src/index.ts
import express from 'express'
import * as prisma from '@prisma/client'

const app = express()

const prismaClient = new prisma.PrismaClient()

app.use(express.json())
app.use(express.urlencoded({ extended: true }))

app.get('/users/:id', async (req: express.Request, res: express.Response) => {
  const id = req.params.id
  const user = await prismaClient.user.findUnique({ where: { id: Number(id) } })
  res.send(user ?? `User not found`)
})

app.post('/users', async (req: express.Request, res: express.Response) => {
  const name = req.body.name
  const user = await prismaClient.user.create({
    data: {
      name,
    },
  })
  res.send(user)
})

app.listen(3000, () => {
  console.log('Example app listening on port 3000!')
})
  • package.json
{
  "name": "app-runner-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "ts-node src/index.ts",
    "db:generate": "prisma generate",
    "db:migrate": "prisma migrate deploy"
  },
  "dependencies": {
    "@prisma/client": "3.9.2",
    "express": "4.17.2"
  },
  "devDependencies": {
    "@tsconfig/node14": "1.0.1",
    "@types/express": "4.17.13",
    "@types/node": "17.0.17",
    "prisma": "3.9.2",
    "ts-node": "10.5.0",
    "typescript": "4.5.5"
  }
}
  • Dockerfile
FROM node:14-slim

WORKDIR /usr/src/app

# https://github.com/prisma/prisma/issues/1301#issuecomment-574317426
RUN apt-get -qy update
RUN apt-get -qy install openssl

COPY . .

RUN yarn install --pure-lockfile --non-interactive

RUN yarn db:generate

EXPOSE 3000

CMD sh -c "yarn db:migrate && yarn start"
  • prisma/schema.prisma

prismaのDBスキーム定義です。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

あとは再度、ビルド&プッシュしてApp Runnerを再度デプロイします。

$ aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
$ docker build -t app-runner-example .
$ docker tag app-runner-example ${REPOSITORY_URI}
$ docker push ${REPOSITORY_URI}
$ aws apprunner start-deployment --service-arn ${APP_RUNNER_ARN}

App Runnerのデプロイが完了すれば、RDSにアクセス可能になります。

$ curl -X POST 'https://xxxxxxxxxx.ap-northeast-1.awsapprunner.com/users' -d 'name=test' 
{
    "id":1,
    "name":"test",
    "createdAt":"2022-02-15T07:31:23.638Z",
    "updatedAt":"2022-02-15T07:31:23.639Z"
}

$ curl https://xxxxxxxxxx.ap-northeast-1.awsapprunner.com/users/1
{
    "id":1,
    "name":"test",
    "createdAt":"2022-02-15T07:31:23.638Z",
    "updatedAt":"2022-02-15T07:31:23.639Z"
}

感想

実際に利用するにあたり以下の内容が個人的には気になりました。

自動デプロイ

App Runnerでは指定したタグ(上記の例ではlatest)を持つイメージがプッシュされると自動的にデプロイされる機能がありますが、タグは完全一致みたいなのでコミットのハッシュなどからタグを生成した場合は利用はできないかと思います。

その場合は手動デプロイで対応することになると思うので、タグのプレフィックスなどで自動デプロイが出来ると更に便利なのかなと思いました。

また、現在だと自動デプロイの設定はCDK側からは出来ないのでコンソール側から設定する必要がありそうです。

WAFは未対応

現在だとWAFは未対応の状態です。

要望としてWAF対応のissueがあがっているので今後対応されるかもしれません。

VPCの接続設定が手動

CDKではまだVPCの接続設定ができないので、手動で設定を入れる必要があります。

また、CDKのデプロイでApp Runnerが更新されると設定が解除されてしまうのでそのあたりも注意が必要かもしれません。

App Runnerでイメージのタグを変えたい場合は、AWS CLIで更新する必要があります。

Secret Managerから環境変数展開

Secret Mangerからの環境変数はCDK側で展開して環境変数にいれているので、コンソールからRDSのパスワードが見れてしまいます。

ECSのように直接展開できると便利かと思いました。

最後に

ECSやfargateを利用するよりは遥かに簡単に環境が構築できました。

今回は無理にCDKを使った感があるので、現在App Runnerを利用するならコンソールもしくはAWS CLIからの構築でも良いかと思います。

今後も機能拡張されてさらに便利になると思うので、利用していきたいです。