はじめに

Django の動作する AWS App Runner (以降は App Runner と記載) を CDK で構築してみましたので紹介し
ます。
本記事を書いている時点では App Runner を扱う L2 コンストラクトは正式版が存在しないため、実験段階
の L2 コンストラクトである @aws-cdk/apprunner-alfaを使⽤しています。

リポジトリ

構成図

  • Django のコンテナイメージをビルドし、既存の ECR リポジトリにプッシュする
  • VPC コネクターを作成してプライベートサブネット上の RDS (Aurora サーバーレス V2) に接続する
  • DB や Django で使うクレデンシャルは AWS Secrets Manager (以降は Secrets Manager と記載) で管理し、ローテーションもする

前提

CDK を動かす前に、以下の条件を満たしている必要があります。

  • ECR リポジトリがすでに作成されていること (今回 kawashima/djangoという名前にしています)
  • 最後に App Runner カスタムドメインのリンクを⾏うので、検証で利⽤できるドメインを有していること

構築の流れ

以下の流れでリソースを構築しています。Django 管理サイトへのログインをゴールとします。

  1. VPC
  2. Secrets Manager
  3. RDS
  4. Secrets ローテーション
  5. コンテナイメージ
  6. App Runner

VPC

プライベートサブネットだけの最⼩構成です。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
const vpc = new ec2.Vpc(this, "VPC", {
  ipAddresses: ec2.IpAddresses.cidr("10.0.0.0/24"),
  enableDnsHostnames: true,
  enableDnsSupport: true,
  natGateways: 0,
  maxAzs: 2,
  subnetConfiguration: [
    {
      name: "Private",
      subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
      cidrMask: 26,
    },
  ],
});
const vpcPrivateSubnets = vpc.selectSubnets({
   subnetType: ec2.SubnetType.PRIVATE_ISOLATED,
});

Secrets Manager

DB クレデンシャル⽤と Django アプリケーション⽤で各々シークレットを作成します。

Django で使うシークレットキーはプロジェクトを作成した時点で settings.py に埋め込まれた状態で
⽣成されますが、 settings.py ⾃体はリポジトリで管理したいので、シークレットキーだけ CDK の
contextに書いておき、Secrets Manager で管理するようにしています。cdk.json は ignore する必
要があります。 settings.py については後で触れます。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const djangoSecretKey = app.node.tryGetContext("djangoSecretKey");
const secretExcludeCharacters = " % +~`#$&*()|[]{}:;?!'/@\"\\"
 
// Database credential
const dbSecret = new asm.Secret(this, "DBSecret", {
  secretName: "apprunner-demo-db-secret",
  description: `Credentials for apprunner-demo database`,
  generateSecretString: {
    generateStringKey: "password",
    excludeCharacters: secretExcludeCharacters,
    passwordLength: 30,
    secretStringTemplate: JSON.stringify({ username: "postgres" }),
  },
})
 
// Django credential
const djangoSecret = new asm.Secret(this, "DjangoSecret", {
   secretName: "apprunner-demo-django-secret",
   description: `Credentials for apprunner-demo django`,
   generateSecretString: {
     generateStringKey: "password",
     excludeCharacters: secretExcludeCharacters,
     passwordLength: 30,
     secretStringTemplate: JSON.stringify({
       username: "test-user",
       email: "test@your-domain.com",
       secretkey: djangoSecretKey,
    }),
   },
});

RDS

DB クラスターの作成については割愛します。リポジトリを参照してください。

Secrets ローテーション

aws_secretsmanager.SecretRotation() に必要な props を渡すことでシークレットのローテーションを実装します。これらは単独の CFn テンプレートとして作成され、本体のテンプレートにネストされます。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Lambda function for secrets rotation security group
const secretRotationFunctionSecurityGroupName = "apprunner-demo-secretsecurity-group";
const secretRotationFunctionSecurityGroup = new ec2.SecurityGroup(this,
"DBSecretRotationFunctionSecurityGroup", {
  securityGroupName: secretRotationFunctionSecurityGroupName,
  description: secretRotationFunctionSecurityGroupName,
  vpc: vpc,
  allowAllOutbound: true,
});
Tags.of(secretRotationFunctionSecurityGroup).add("Name",
secretRotationFunctionSecurityGroupName)
 
dbCluster.connections.allowDefaultPortFrom(
  secretRotationFunctionSecurityGroup,
  "Allow DB secret rotation function connect to database"
)
 
// Database credential rotation
new asm.SecretRotation(this, "DBSecretRotation", {
  application: asm.SecretRotationApplication.POSTGRES_ROTATION_SINGLE_USER,
  secret: dbSecret,
  target: dbCluster,
  vpc: vpc,
  automaticallyAfter: Duration.days(7),
  excludeCharacters: secretExcludeCharacters,
  securityGroup: secretRotationFunctionSecurityGroup,
  vpcSubnets: vpcPrivateSubnets,
})
 
// Django credential rotation
new asm.SecretRotation(this, "DjangoSecretRotation", {
  application: asm.SecretRotationApplication.POSTGRES_ROTATION_SINGLE_USER,
  secret: djangoSecret,
  target: dbCluster,
  vpc: vpc,
  automaticallyAfter: Duration.days(7),
  excludeCharacters: secretExcludeCharacters,
  securityGroup: secretRotationFunctionSecurityGroup,
  vpcSubnets: vpcPrivateSubnets,
});

コンテナイメージ

コンテナイメージをビルドして既存の ECR リポジトリにプッシュしてくれる cdk-docker-imagedeployment というコンストラクトがあります。これを使って Django のイメージをビルドおよびプッシュします。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
import { Destination, DockerImageDeployment, Source } from "cdk-dockerimage-deployment";
...
const tag = "latest"
 
// Get ECR repository
const containerRepository = ecr.Repository.fromRepositoryName(this,
"ContainerRepository", "kawashima/django");
 
// Deploy container image
new DockerImageDeployment(this, "ImageDeploy", {
  source: Source.directory("src/image/django"),
  destination: Destination.ecr(containerRepository, {
  tag: tag,
 }),
});

ビルドのための資材を src/image/django 配下に準備し、あらかじめ config という名前で Django
プロジェクトも作成しています。

構成

01
02
03
04
05
06
07
08
09
10
11
12
13
src
└── image
    └── django
        ├── Dockerfile
        ├── config
            ├── __init__.py
        │   ├── asgi.py
        │   ├── settings.py
        │   ├── urls.py
        │   └── wsgi.py
        ├── docker-entrypoint.sh
        ├── manage.py
        └── requirements.txt

Dockerfile

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
FROM python:3.9.16
 
ENV PYTHONUNBUFFERED 1
ENV PYTHONIOENCODING utf-8
ENV DEBCONF_NOWARNINGS yes
ENV DEBIAN_FRONTEND noninteractive
 
ARG DIR=django
COPY ./ /$DIR/
WORKDIR /$DIR
 
RUN set -eux \
&& apt-get update -y -qq \
&& apt-get install -y -qq --no-install-recommends vim git curl tree \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \
&& pip install --no-cache-dir -r requirements.txt \
&& if [ ! -f manage.py ]; then django-admin startproject config .; fi
 
EXPOSE 8080

src/image/django 配下のファイルを以下の記述でコンテナ内に持ち込んでいます。

1
2
3
ARG DIR=django
COPY ./ /$DIR/
WORKDIR /$DIR

requirements.txt

Django と PostgreSQL ドライバーをインストールしています。

1
2
Django==4.2
psycopg2-binary==2.9.6

docker-entrypoint.sh

App Runner 側で指定するエントリーポイント用のシェルでは以下を実行しています。

  1. DB マイグレーション
  2. 管理サイトアクセス用ユーザー作成
  3. 簡易 Web サーバー起動
1
2
3
4
#!/bin/bash
python /django/manage.py migrate
python /django/manage.py createsuperuser --noinput
python /django/manage.py runserver 0.0.0.0:8080

1, 2 は 2 回目以降の実行でも終了エラーにはならないため、Web サーバーを起動できます (if で分岐させた方が健全かもしれません)
また、createsuperuser に関しては、引数に --noinput をつけることで以下の環境変数からパラメータを読み込めます。

  • DJANGO_SUPERUSER_USERNAME
  • DJANGO_SUPERUSER_PASSWORD
  • DJANGO_SUPERUSER_EMAIL

settings.py

config/settings.py に関して、デフォルトからの変更箇所を説明します。

プロジェクトを初期化した時点でハードコーディングされているシークレットキーを環境変数から取得するように変更しています。前述の通り、この値は Secrets Manager から取得されます。

1
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY")

Django 特有の問題ですが、Django 4.0 以降では CSRF 対策として CSRF_TRUSTED_ORIGINS にオリジンを指定しないと POST リクエスト時に 403 が返るようになりました。このため、普通に構築すると管理サイトへのログイン時に 403 が返されます。
これを回避するためにあらかじめオリジンを設定する必要があるのですが、以下のように少々面倒でした。

  • App Runner のデフォルトドメインを使う場合
    • 構築後でないとドメイン名がわからないので、デプロイ後にあとから更新する必要がある
  • カスタムドメインを使う場合
    • あらかじめ設定できるが、現状 @aws-cdk/apprunner-alfa ではカスタムドメインのリンクをサポートしていないため、手動で設定する必要がある

今回は後者にしました。ドメイン名は CDK の context に書いておき、環境変数で渡します。

1
CSRF_TRUSTED_ORIGINS = [os.environ.get("DOMAIN")]

DB 接続に関しては、PostgreSQL ドライバーを介して RDS に接続するように書き換えています。これまでと同様に環境変数を使い、すべて Secrets Manager から取得されます。

01
02
03
04
05
06
07
08
09
10
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql_psycopg2",
        "HOST": os.environ.get("DB_HOST"),
        "PORT": os.environ.get("DB_PORT"),
        "NAME": os.environ.get("DB_NAME"),
        "USER": os.environ.get("DB_USER"),
        "PASSWORD": os.environ.get("DB_PASSWORD"),
    }
}

App Runner

最後に App Runner です。まず VPC コネクターを作ります。実体は App Runner のサービスが VPC 内のリソースにアクセスするための ENI です。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
// VPC Connector security group
const vpcConnectorSecurityGroupName = "apprunner-demo-vpc-connector-security-group";
const vpcConnectorSecurityGroup = new ec2.SecurityGroup(this, "VpcConnectorSecurityGroup", {
  securityGroupName: vpcConnectorSecurityGroupName,
  description: vpcConnectorSecurityGroupName,
  vpc: vpc,
  allowAllOutbound: true,
});
 
Tags.of(vpcConnectorSecurityGroup).add("Name", vpcConnectorSecurityGroupName)
 
// VPC Connector
const vpcConnector = new apprunner.VpcConnector(this, "VpcConnector", {
  vpcConnectorName: "apprunner-demo-vpc-connector",
  vpc: vpc,
  vpcSubnets: vpcPrivateSubnets,
  securityGroups: [vpcConnectorSecurityGroup],
});
vpcConnector.node.addDependency(dbCluster)
 
dbCluster.connections.allowDefaultPortFrom(vpcConnectorSecurityGroup, "Allow App Runner connect to database");

App Runner のサービスを作成します。ほとんどの環境変数は Secrets Manager から取得しているためセキュアです。

1 点だけ不具合っぽい事象があったのですが、serviceName を指定しても、ランダムなサフィックスのついたデフォルトのサービス名で App Runner サービスがデプロイされました。この対策として、最後の行で L1 コンストラクトにアクセスし、サービス名を上書きしています。あと個人的には startCommand は配列で渡せた方が嬉しいです。

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// AppRunner service
const appRunnerService = new apprunner.Service(this, "AppRunnerService", {
  serviceName: "apprunner-demo-service", // Not working, probably because it's an alpha version.
  cpu: apprunner.Cpu.QUARTER_VCPU,
  memory: apprunner.Memory.HALF_GB,
  vpcConnector: vpcConnector,
  autoDeploymentsEnabled: true,
  source: apprunner.Source.fromEcr({
    repository: containerRepository,
    imageConfiguration: {
      environmentSecrets: {
        DB_USER: apprunner.Secret.fromSecretsManager(dbSecret, "username"),
        DB_PASSWORD: apprunner.Secret.fromSecretsManager(dbSecret, "password"),
        DB_HOST: apprunner.Secret.fromSecretsManager(dbSecret, "host"),
        DB_PORT: apprunner.Secret.fromSecretsManager(dbSecret, "port"),
        DB_NAME: apprunner.Secret.fromSecretsManager(dbSecret, "dbname"),
        DJANGO_SUPERUSER_USERNAME: apprunner.Secret.fromSecretsManager(djangoSecret, "username"),
        DJANGO_SUPERUSER_PASSWORD: apprunner.Secret.fromSecretsManager(djangoSecret, "password"),
        DJANGO_SUPERUSER_EMAIL: apprunner.Secret.fromSecretsManager(djangoSecret, "email"),
        DJANGO_SECRET_KEY: apprunner.Secret.fromSecretsManager(djangoSecret, "secretkey"),
      },
      environmentVariables: {
        DOMAIN: app.node.tryGetContext("domain"),
      },
      port: 8080,
      startCommand: "bash docker-entrypoint.sh",
    },
    tagOrDigest: tag,
  }),
});
(appRunnerService.node.defaultChild as aws_apprunner.CfnService).serviceName = "apprunner-demo-service";

デプロイ

ローカルで Docker が起動していることを確認して以下のコマンドを実行します。問題がなければ、これまで定義してきたリソースが一挙に構築されます。

1
2
3
4
5
# CFn テンプレート作成
npx cdk synth
 
# デプロイ
npx cdk deploy

アクセス確認

マネジメントコンソールからカスタムドメインをリンクし、Secrets Manager から取得したシークレットで Django 管理サイト /admin にログインできることを確認します。

現時点でできなさそうなこと

今回使用した @aws-cdk/apprunner-alfa では以下の機能が未実装のようでした。

  • カスタムドメインのリンク
  • Auto Scaling 設定
  • ヘルスチェック設定
  • WAF 連携
  • プライベートエンドポイント
  • 可観測性 (X-Ray を使った追跡)

本番で使用するには難があるため、正式に L2 コンストラクトが出るまで待つのが安全そうです。

おわりに

Django のサンプルプロジェクトを題材として、CDK で App Runner を構築する手順について紹介しました。正式な L2 コンストラクトがリリースされ次第、別の構成も含め再度検証してみたいと思います。