はじめに
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 管理サイトへのログインをゴールとします。
- VPC
- Secrets Manager
- RDS
- Secrets ローテーション
- コンテナイメージ
- App Runner
VPC
プライベートサブネットだけの最⼩構成です。
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
については後で触れます。
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 テンプレートとして作成され、本体のテンプレートにネストされます。
// 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 のイメージをビルドおよびプッシュします。
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
プロジェクトも作成しています。
構成
src └── image └── django ├── Dockerfile ├── config ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── docker-entrypoint.sh ├── manage.py └── requirements.txt
Dockerfile
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
配下のファイルを以下の記述でコンテナ内に持ち込んでいます。
ARG DIR=django COPY ./ /$DIR/ WORKDIR /$DIR
requirements.txt
Django と PostgreSQL ドライバーをインストールしています。
Django==4.2 psycopg2-binary==2.9.6
docker-entrypoint.sh
App Runner 側で指定するエントリーポイント用のシェルでは以下を実行しています。
- DB マイグレーション
- 管理サイトアクセス用ユーザー作成
- 簡易 Web サーバー起動
#!/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 から取得されます。
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
に書いておき、環境変数で渡します。
CSRF_TRUSTED_ORIGINS = [os.environ.get("DOMAIN")]
DB 接続に関しては、PostgreSQL ドライバーを介して RDS に接続するように書き換えています。これまでと同様に環境変数を使い、すべて Secrets Manager から取得されます。
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 です。
// 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
は配列で渡せた方が嬉しいです。
// 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 が起動していることを確認して以下のコマンドを実行します。問題がなければ、これまで定義してきたリソースが一挙に構築されます。
# 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 コンストラクトがリリースされ次第、別の構成も含め再度検証してみたいと思います。