目次
はじめに
皆さん、Next.jsアプリケーションをデプロイするとき、どんなサービス上にデプロイしてますでしょうか?
Vercel? Amplify? はたまたCloud Run? 様々な候補があると思いますがこの記事では、AWS CDK(Cloud Development Kit)を使って、Next.jsアプリケーションをApp Runnerにデプロイし、Route53を使ったカスタムドメイン設定を自動化するまでの手法をご紹介します。
特に、AWS Lambda Custom Resourceを活用した証明書検証やDNSレコード管理など、CDKによる完全なる自動化を目標にしました。
前提条件
こちらのリポジトリから今回使用するソースコードをご確認いただけます。
- AWSアカウントがあること
- AWS CLIがインストールされ、適切に設定されていること
- Node.js v22.x以上
- pnpm v10.x以上
- AWS CDK v2
- 有効なルートドメインがRoute53に登録されていること
アーキテクチャ
AWS App Runnerは、コンテナ化されたアプリケーションをインフラ管理なしでデプロイできるフルマネージドサービスで、Next.jsアプリケーションを高可用性、スケーラビリティ、運用の簡素化などと様々な面で期待できるサービスとなっております。
今回の構成では、以下のAWSサービスを組み合わせて使用します。
- AWS App Runner: Next.jsアプリケーションをホスティングするサービスです。スケーリングやデプロイを自動的に処理してくれるので、インフラの心配をする必要がありません。
- Amazon Route53: DNSレコードを管理し、カスタムドメイン(例:app.example.com)をApp Runnerサービスに向けるためのサービスです。
- AWS Certificate Manager (ACM): HTTPSで安全に通信するために必要なSSL/TLS証明書を発行・管理します。
- AWS Lambda: カスタムリソースとして使い、ドメイン検証を自動化します。普通なら手動でやる必要がある作業を今回はLambdaを使って自動化していきます。
- AWS IAM: 各サービスに適切な権限を付与して、セキュリティを確保します。
今回のご紹介する構成には、以下のような特徴があります:
- IaC(Infrastructure as Code)アプローチ: AWSリソースをコードとして定義するので、環境の再現性が高まりヒューマンエラーを起こしにくくできます。
- モジュール化された設計: 再利用可能なCDKコンストラクトを作成することで、似たようなインフラを簡単に構築できるようになります。
- 自動化されたDNS検証: これが本当にすごいところで、手動操作なしでSSL証明書を検証できます。AWSコンソールでポチポチする必要がなくなリます!
- クリーンな依存関係管理: リソース間の依存関係を明示的に定義することで、デプロイの安定性が向上します。
プロジェクト構造
プロジェクトのディレクトリ構造は以下のような構成になっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
apps/ main-app/ web-app/ # Next.jsアプリケーション infra/ # インフラストラクチャ定義 lib/ constructs/ # 再利用可能なCDKコンストラクト apprunner-service.ts route53-config.ts lambda/ # カスタムリソースのLambda関数 certificate-validation/ custom-domain/ stacks/ # CDKスタック infra-stack.ts bin/ # エントリーポイント infra.ts |
実装の詳細
インフラストラクチャスタック
まずは全体のインフラを定義するCDKスタックを見てみましょう。このスタックが、App Runnerサービスのデプロイとカスタムドメイン設定の中心となります。
1 2 3 4 5 6 7 8 9 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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
// apps/main-app/infra/lib/stacks/infra-stack.ts import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import { NextJsAppRunnerService } from "../constructs/apprunner-service"; import { Route53Config } from "../constructs/route53-config"; import { Cpu, Memory } from "@aws-cdk/aws-apprunner-alpha"; /** * テナントインフラストラクチャスタックのプロパティ */ export interface InfraStackProps extends cdk.StackProps { /** * 環境名 (dev/stg/prod) */ envName: string; /** * アプリケーション名 */ appName: string; /** * ホストゾーン名 (Route53) */ hostedZoneName?: string; /** * サブドメイン名 */ subdomainName?: string; } /** * インフラストラクチャスタック * このスタックはApp Runnerサービスとカスタムドメイン設定を管理します。 */ export class InfraStack extends cdk.Stack { /** * App Runnerサービス */ public readonly appRunnerService: NextJsAppRunnerService; /** * Route53設定 */ public readonly route53Config?: Route53Config; /** * Webアプリケーション URL */ public readonly webAppUrl: string; constructor(scope: Construct, id: string, props: InfraStackProps) { super(scope, id, { ...props, description: `${props.appName} infrastructure for ${props.envName} environment`, crossRegionReferences: true, tags: { Application: props.appName, Environment: props.envName, ManagedBy: "CDK", }, }); // 環境変数 const { envName, appName, hostedZoneName, subdomainName } = props; // リソース名のプレフィックス const resourcePrefix = `${appName}-${envName}`; // App Runnerサービスをデプロイ this.appRunnerService = this.deployAppRunnerService( resourcePrefix, envName ); this.webAppUrl = `https://${this.appRunnerService.service.serviceUrl}`; // カスタムドメイン設定 const route53ConfigResult = this.configureCustomDomain( hostedZoneName, subdomainName ); if (route53ConfigResult) { Object.defineProperty(this, "route53Config", { value: route53ConfigResult, writable: false, configurable: false, }); } // スタックの出力 this.createStackOutputs(resourcePrefix); } /** * App Runnerサービスをデプロイする */ private deployAppRunnerService( resourcePrefix: string, envName: string ): NextJsAppRunnerService { return new NextJsAppRunnerService(this, "NextJsAppRunner", { serviceName: resourcePrefix, cpu: Cpu.ONE_VCPU, memory: Memory.TWO_GB, environmentVariables: { PORT: "3000", NODE_ENV: "production", APP_ENV: envName, NEXT_TELEMETRY_DISABLED: "1", HOSTNAME: "0.0.0.0", }, autoDeploymentsEnabled: false, healthCheckConfig: { path: "/", interval: cdk.Duration.seconds(10), timeout: cdk.Duration.seconds(5), }, }); } /** * カスタムドメインを設定する(ホストゾーン名とサブドメイン名が指定されている場合) */ private configureCustomDomain( hostedZoneName?: string, subdomainName?: string ): Route53Config | undefined { if (hostedZoneName && subdomainName) { return new Route53Config(this, "Route53Config", { hostedZoneName, subdomainName, appRunnerService: this.appRunnerService.service, recordType: "ALIAS", }); } return undefined; } /** * スタックの出力を作成する */ private createStackOutputs(resourcePrefix: string): void { new cdk.CfnOutput(this, "WebAppUrl", { value: this.webAppUrl, description: "Web Application URL", exportName: `${resourcePrefix}-web-app-url`, }); } } |
このコードがやっていることを、分かりやすく説明します。
- スタックのプロパティ:
InfraStackProps
インターフェースで、環境名やアプリケーション名、ドメイン設定などのパラメータを定義しています。これらの値は、あなたが.env
ファイルで設定した値が入ります。 - App Runnerのデプロイ:
deployAppRunnerService
メソッドでは、Next.jsアプリケーションを動かすためのApp Runnerサービスを構成しています。CPUやメモリのサイズ、環境変数、ヘルスチェックの設定なども行っていますよ。 - カスタムドメイン設定:
configureCustomDomain
メソッドでは、ホストゾーン名とサブドメイン名が指定されている場合に、Route53の設定を行います。この部分が、あなたのカスタムドメイン(例:app.example.com)をApp Runnerサービスに紐づける役割を担っています。 - 出力の定義:
createStackOutputs
メソッドでは、WebアプリケーションのURLを出力として定義しています。これにより、デプロイ完了後にアクセス先のURLが表示されますし、他のスタックやCI/CDパイプラインからもこの値を参照できるようになります。
Route53設定コンストラクト
次に、Route53の設定を担当するコンストラクトを見てみましょう。このコンポーネントが、HTTPSを使ったカスタムドメイン設定の中核となります。
1 2 3 4 5 6 7 8 9 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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 |
// apps/main-app/infra/lib/constructs/route53-config.ts import * as cdk from "aws-cdk-lib"; import * as route53 from "aws-cdk-lib/aws-route53"; import * as acm from "aws-cdk-lib/aws-certificatemanager"; import { Construct } from "constructs"; import { Service } from "@aws-cdk/aws-apprunner-alpha"; import * as lambda from "aws-cdk-lib/aws-lambda"; import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs"; import * as iam from "aws-cdk-lib/aws-iam"; import * as path from "path"; import * as cr from "aws-cdk-lib/custom-resources"; // リージョンごとのApp Runnerホストゾーンマッピング const APP_RUNNER_HOSTED_ZONE_MAP = new Map<string, string>([ ["us-east-2", "Z0224347AD7KVHMLOX31"], ["us-east-1", "Z01915732ZBZKC8D32TPT"], ["us-west-2", "Z02243383FTQ64HJ5772Q"], ["ap-southeast-1", "Z09819469CZ3KQ8PWMCL"], ["ap-southeast-2", "Z03657752RA8799S0TI5I"], ["ap-northeast-1", "Z08491812XW6IPYLR6CCA"], ["eu-central-1", "Z0334911C2FDI2Q9M4FZ"], ["eu-west-1", "Z087551914Z2PCAU0QHMW"], ]); // App RunnerのホストゾーンIDを取得する関数 function getAppRunnerHostedZone(region: string): string { return ( APP_RUNNER_HOSTED_ZONE_MAP.get(region) || APP_RUNNER_HOSTED_ZONE_MAP.get("ap-northeast-1")! ); } /** * Route53設定のプロパティ */ export interface Route53ConfigProps { /** * ホストゾーン名 */ hostedZoneName: string; /** * サブドメイン名(オプショナル) * 指定しない場合はApexドメインを使用 */ subdomainName?: string; /** * App Runnerサービス */ appRunnerService: Service; /** * DNSレコードタイプ * デフォルトはALIAS */ recordType?: "ALIAS" | "CNAME"; } /** * Route53の設定を行うコンストラクト */ export class Route53Config extends Construct { /** * SSL/TLS証明書 */ public readonly certificate: acm.Certificate; /** * ホストゾーン */ public readonly hostedZone: route53.IHostedZone; /** * カスタムドメインURL */ public readonly customDomainUrl: string; constructor(scope: Construct, id: string, props: Route53ConfigProps) { super(scope, id); const { hostedZoneName, subdomainName, appRunnerService, recordType = "ALIAS", } = props; // 既存のホストゾーンを参照 this.hostedZone = route53.HostedZone.fromLookup(this, "HostedZone", { domainName: hostedZoneName, }); // 完全修飾ドメイン名を構築 const fqdn = subdomainName ? `${subdomainName}.${hostedZoneName}` : hostedZoneName; this.customDomainUrl = `https://${fqdn}`; // SSL/TLS証明書の作成 this.certificate = new acm.Certificate(this, "Certificate", { domainName: fqdn, validation: acm.CertificateValidation.fromDns(this.hostedZone), }); // 証明書検証ハンドラーの作成 const validationHandler = this.createValidationHandler(this.hostedZone); // 証明書検証プロバイダーの作成 const certificateValidationProvider = new cr.Provider( this, "CertificateValidationProvider", { onEventHandler: validationHandler, } ); // 証明書検証用のDNSレコードを自動的に作成・管理するカスタムリソース const dnsValidationResource = new cdk.CustomResource( this, "DNSValidationResource", { serviceToken: certificateValidationProvider.serviceToken, properties: { HostedZoneId: this.hostedZone.hostedZoneId, CertificateArn: this.certificate.certificateArn, DomainName: fqdn, }, resourceType: "Custom::DNSValidation", } ); // カスタムドメイン管理のためのLambda関数とリソースを作成 const customDomainFunction = this.createCustomDomainFunction(); const customDomain = this.createCustomDomainResource( customDomainFunction, appRunnerService, fqdn ); // DNSレコードの作成 const dnsRecord = this.createDnsRecord( recordType, this.hostedZone, appRunnerService, subdomainName ); // 依存関係を設定 customDomain.node.addDependency(dnsValidationResource); customDomain.node.addDependency(dnsRecord); customDomain.node.addDependency( appRunnerService.node.defaultChild as cdk.CfnResource ); // 出力の設定 this.createOutputs(fqdn); } /** * 証明書検証ハンドラーを作成する */ private createValidationHandler( hostedZone: route53.IHostedZone ): NodejsFunction { return new NodejsFunction(this, "ValidationHandler", { entry: path.join(__dirname, "../lambda/certificate-validation/index.ts"), handler: "handler", runtime: lambda.Runtime.NODEJS_20_X, memorySize: 128, timeout: cdk.Duration.seconds(900), initialPolicy: [ new iam.PolicyStatement({ actions: ["route53:ChangeResourceRecordSets"], resources: [hostedZone.hostedZoneArn], effect: iam.Effect.ALLOW, }), new iam.PolicyStatement({ actions: ["acm:DescribeCertificate"], resources: ["*"], effect: iam.Effect.ALLOW, }), ], environment: { NODE_OPTIONS: "--enable-source-maps", }, bundling: { minify: true, sourceMap: true, externalModules: ["@aws-sdk/*"], esbuildArgs: { "--packages=bundle": true, }, }, }); } /** * カスタムドメイン管理用のLambda関数を作成する */ private createCustomDomainFunction(): NodejsFunction { // Lambda関数のロールを作成 const lambdaRole = new iam.Role(this, "LambdaRole", { assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), }); // App Runnerのカスタムドメイン関連の権限を追加 lambdaRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "apprunner:AssociateCustomDomain", "apprunner:DisassociateCustomDomain", "apprunner:DescribeCustomDomains", ], resources: ["*"], }) ); // CloudWatchログの権限を追加 lambdaRole.addToPolicy( new iam.PolicyStatement({ effect: iam.Effect.ALLOW, actions: [ "logs:CreateLogStream", "logs:PutLogEvents", "logs:CreateLogGroup", ], resources: ["*"], }) ); // カスタムドメインのLambda関数を作成 return new NodejsFunction(this, "CustomDomainFunction", { entry: path.join(__dirname, "../lambda/custom-domain/index.ts"), handler: "handler", runtime: lambda.Runtime.NODEJS_20_X, memorySize: 128, timeout: cdk.Duration.seconds(900), role: lambdaRole, bundling: { minify: true, sourceMap: true, externalModules: ["@aws-sdk/*"], esbuildArgs: { "--packages=bundle": true, }, }, }); } /** * カスタムドメインリソースを作成する */ private createCustomDomainResource( customDomainFunction: NodejsFunction, appRunnerService: Service, fqdn: string ): cdk.CustomResource { return new cdk.CustomResource(this, "CustomDomain", { serviceToken: customDomainFunction.functionArn, properties: { ServiceArn: appRunnerService.serviceArn, DomainName: fqdn, }, }); } /** * DNSレコードを作成する */ private createDnsRecord( recordType: string, hostedZone: route53.IHostedZone, appRunnerService: Service, subdomainName?: string ): route53.RecordSet { if (recordType === "ALIAS") { // ALIASレコード return new route53.RecordSet(this, "DomainAliasRecord", { recordType: route53.RecordType.A, target: route53.RecordTarget.fromAlias({ bind: () => ({ dnsName: appRunnerService.serviceUrl, hostedZoneId: getAppRunnerHostedZone(cdk.Stack.of(this).region), evaluateTargetHealth: true, }), }), zone: hostedZone, recordName: subdomainName, deleteExisting: true, }); } else { // CNAMEレコード return new route53.RecordSet(this, "DomainCnameRecord", { recordType: route53.RecordType.CNAME, target: route53.RecordTarget.fromValues(appRunnerService.serviceUrl), zone: hostedZone, recordName: subdomainName, deleteExisting: true, }); } } /** * CloudFormation出力を設定する */ private createOutputs(fqdn: string): void { new cdk.CfnOutput(this, "CustomDomainUrl", { value: this.customDomainUrl, description: "Custom Domain URL", }); new cdk.CfnOutput(this, "CertificateArn", { value: this.certificate.certificateArn, description: "ACM Certificate ARN", }); new cdk.CfnOutput(this, "AppRunnerDomainUrl", { value: `https://${this.certificate.node.scope}`, description: "App Runner Original Domain URL", }); new cdk.CfnOutput(this, "SecurityNotes", { value: `セキュリティに関する注意事項: 1. セキュアなCookieの設定を確認してください。SameSite=Strict, Secure, HttpOnlyの設定を推奨します。 2. DNSプロパゲーションには時間がかかる場合があります。アプリケーションがすぐに利用できない場合があります。 3. SSL/TLSの設定はus-east-1リージョンで行われているため、リージョン間の遅延が発生する可能性があります。`, description: "Security Notes", }); } } |
このRoute53Configコンストラクトは、カスタムドメイン設定の中核となる複雑なロジックをカプセル化しています。主な機能と実装上のポイントを見ていきます:
- App Runner用のホストゾーンマッピング:リージョンごとのApp RunnerサービスのホストゾーンIDを定義しています。これはALIASレコードを設定する際に必要となります。
- 証明書自動検証:AWS Certificate Manager(ACM)で発行したSSL/TLS証明書の検証プロセスを自動化するためのカスタムリソースを実装しています。
- カスタムドメイン関連付け:App RunnerサービスとカスタムドメインとのAssociation(関連付け)を処理するLambda関数を作成しています。
- DNSレコード設定:AレコードまたはCNAMEレコードを自動的に設定し、カスタムドメインからApp Runnerサービスへのルーティングを確立します。
- リソース間の依存関係管理:各リソースの作成順序とデプロイ依存関係を明示的に定義しています。
特に、createValidationHandler
とcreateCustomDomainFunction
メソッドは、Lambda関数を使ったカスタムリソースプロバイダーの作成方法を示しており、CDKの高度な使い方を実装しています。これにより、CloudFormationだけでは直接サポートされていない操作(証明書の検証やApp Runnerのカスタムドメイン設定など)を自動化しています。
Lambdaによる証明書検証の自動化
Route53Configコンストラクトの中核機能の一つ、SSL/TLS証明書の検証を自動化するLambda関数を見ていきましょう。この関数によって証明書のDNS検証レコードの作成・更新が全部自動で設定が可能になってます。
1 2 3 4 5 6 7 8 9 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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 |
// apps/main-app/infra/lib/lambda/certificate-validation/index.ts import type { CloudFormationCustomResourceEvent, Context } from "aws-lambda"; import { Route53Client, ChangeResourceRecordSetsCommand, Change, ChangeAction, } from "@aws-sdk/client-route-53"; import { ACMClient, DescribeCertificateCommand, DomainValidation, } from "@aws-sdk/client-acm"; import * as https from "https"; import type { IncomingMessage } from "http"; const route53Client = new Route53Client({}); const acmClient = new ACMClient({}); interface ResourceProperties { readonly CertificateArn: string; readonly HostedZoneId: string; readonly DomainName: string; } type CustomCloudFormationEvent = CloudFormationCustomResourceEvent & { ResourceProperties: ResourceProperties; }; type CloudFormationCustomResourceStatus = "SUCCESS" | "FAILED"; interface CustomCloudFormationCustomResourceResponse { Status: CloudFormationCustomResourceStatus; Reason?: string; PhysicalResourceId: string; StackId: string; RequestId: string; LogicalResourceId: string; Data?: Record<string, any>; } // 証明書から検証レコード情報を取得する関数 const getCertificateValidationRecords = async ( certificateArn: string ): Promise<DomainValidation[]> => { try { const command = new DescribeCertificateCommand({ CertificateArn: certificateArn, }); const response = await acmClient.send(command); const certificate = response.Certificate; if (!certificate || !certificate.DomainValidationOptions) { throw new Error("証明書の検証情報が見つかりません"); } console.log( "証明書の検証情報を取得しました:", JSON.stringify(certificate.DomainValidationOptions, null, 2) ); return certificate.DomainValidationOptions; } catch (error) { console.error("証明書の検証情報取得に失敗しました:", error); throw error; } }; const createChangeRequest = ( validationOption: DomainValidation, action: ChangeAction ): Change | null => { if ( !validationOption.ResourceRecord || !validationOption.ResourceRecord.Name || !validationOption.ResourceRecord.Value || !validationOption.ResourceRecord.Type ) { console.warn("検証レコード情報が不完全です:", validationOption); return null; } const { ResourceRecord } = validationOption; return { Action: action, ResourceRecordSet: { Name: ResourceRecord.Name, Type: ResourceRecord.Type as any, TTL: 300, ResourceRecords: [{ Value: ResourceRecord.Value }], }, }; }; const postResponseToCloudformation = async ( url: string, response: CustomCloudFormationCustomResourceResponse ): Promise<void> => { const data = JSON.stringify(response); console.log("Sending response to CloudFormation:", data); return new Promise((resolve, reject) => { const request = https.request( url, { method: "PUT", headers: { "Content-Type": "", "Content-Length": Buffer.byteLength(data), }, }, (res: IncomingMessage) => { let responseData = ""; res.on("data", (chunk: Buffer) => (responseData += chunk)); res.on("end", () => { console.log("Response from CloudFormation:", responseData); resolve(); }); res.on("error", reject); } ); request.on("error", reject); request.write(data); request.end(); }); }; const handleCertificateValidation = async ( event: CustomCloudFormationEvent ): Promise<CustomCloudFormationCustomResourceResponse> => { console.log("イベント:", JSON.stringify(event, null, 2)); const response: CustomCloudFormationCustomResourceResponse = { Status: "SUCCESS", PhysicalResourceId: `certificate-validation-${event.RequestId}`, StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, }; try { const { CertificateArn, HostedZoneId, DomainName } = event.ResourceProperties; const requestType = event.RequestType as string; if (!CertificateArn || !HostedZoneId || !DomainName) { throw new Error( "必須パラメータが不足しています: CertificateArn, HostedZoneId, DomainNameが必要です" ); } // 証明書の検証情報を取得 console.log(`証明書ARN: ${CertificateArn} の検証情報を取得します`); const validationOptions = await retryOperation( () => getCertificateValidationRecords(CertificateArn), 3, 1000 ); // ドメインに関連する検証情報をフィルタリング const domainValidations = validationOptions.filter( (option) => option.DomainName === DomainName || option.DomainName === `*.${DomainName}` ); if (domainValidations.length === 0) { throw new Error( `ドメイン ${DomainName} に関連する検証情報が見つかりませんでした。証明書の発行が完了していることを確認してください。` ); } console.log( `${domainValidations.length}個の検証情報が見つかりました:`, JSON.stringify(domainValidations, null, 2) ); switch (requestType) { case "Create": case "Update": { console.log("DNS検証レコードを作成/更新します"); const changeRequests = domainValidations .map((validation) => createChangeRequest(validation, "UPSERT")) .filter((change): change is Change => change !== null); if (changeRequests.length === 0) { console.warn("作成/更新するDNS検証レコードがありません"); break; } await Promise.all( changeRequests.map((change) => { console.log( "レコードを処理します:", JSON.stringify(change, null, 2) ); return route53Client.send( new ChangeResourceRecordSetsCommand({ HostedZoneId, ChangeBatch: { Changes: [change], }, }) ); }) ); break; } case "Delete": { console.log("DNS検証レコードを削除します"); try { const changeRequests = domainValidations .map((validation) => createChangeRequest(validation, "DELETE")) .filter((change): change is Change => change !== null); if (changeRequests.length === 0) { console.warn("削除するDNS検証レコードがありません"); break; } await Promise.all( changeRequests.map((change) => { console.log( "レコードを削除します:", JSON.stringify(change, null, 2) ); return route53Client.send( new ChangeResourceRecordSetsCommand({ HostedZoneId, ChangeBatch: { Changes: [change], }, }) ); }) ); } catch (error) { console.log( "レコード削除中にエラーが発生しました(無視します):", error ); // 削除時のエラーは無視(レコードが既に存在しない可能性があるため) } break; } default: throw new Error(`サポートされていないリクエストタイプ: ${requestType}`); } // データを返す response.Data = { NumRecordsProcessed: domainValidations.length, CertificateArn, DomainName, }; } catch (error) { console.error("エラー:", error); response.Status = "FAILED"; response.Reason = error instanceof Error ? error.message : String(error); } return response; }; // リトライロジックを実装する関数 async function retryOperation<T>( operation: () => Promise<T>, maxRetries: number, delay: number ): Promise<T> { let lastError: Error | undefined; for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { console.warn( `操作に失敗しました (リトライ ${i + 1}/${maxRetries}):`, error ); lastError = error instanceof Error ? error : new Error(String(error)); if (i < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, delay * Math.pow(2, i)) ); } } } throw lastError || new Error("不明なエラーでリトライが失敗しました"); } export const handler = async ( event: CustomCloudFormationEvent, _context: Context ): Promise<void> => { const response = await handleCertificateValidation(event); await postResponseToCloudformation(event.ResponseURL, response); }; |
このLambda関数は、CloudFormationのカスタムリソースのライフサイクルイベント(Create、Update、Delete)に応じて証明書の検証レコードをRoute53に自動的に作成・更新・削除します。一見複雑に見えるかもしれませんが、主に以下のことをやっています:
- ACMからの検証情報取得: AWS Certificate Manager(ACM)から、証明書に関連する検証情報を取得します。これには「どんなDNSレコードを作れば証明書が検証されるか」という情報が含まれています。
- Route53でのレコード操作: 取得した検証情報を元に、Route53にCNAMEレコードを作成・更新・削除します。これにより、SSLの検証が完全に自動化されます。
- エラー処理と再試行: ネットワークエラーやタイミングの問題に備えて、リトライロジックも実装しています。証明書の情報がすぐに利用できない場合でも、数回試行してくれます。
特に注目してほしいのは retryOperation
関数です。AWS APIの呼び出しはたまに一時的なエラーが発生することがあるので、こういった再試行の仕組みがあると信頼性が大幅に向上します。しかも、単純な再試行ではなく「指数バックオフ」という手法を使っているんですよ。これは待機時間を徐々に長くしていく方法で、AWSのベストプラクティスに沿ったアプローチです。
カスタムドメイン関連付けのLambda関数
お次は、App Runnerサービスとカスタムドメインを関連付けるLambda関数を見ていきましょう。このLambda関数も、CloudFormationでは直接サポートされていない操作を自動化するためのカスタムリソースとして機能します。
1 2 3 4 5 6 7 8 9 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 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 |
// apps/main-app/infra/lib/lambda/custom-domain/index.ts import { AppRunner, AssociateCustomDomainCommand, DescribeCustomDomainsCommand, DisassociateCustomDomainCommand, DescribeCustomDomainsCommandOutput, } from "@aws-sdk/client-apprunner"; import type { CloudFormationCustomResourceCreateEvent, CloudFormationCustomResourceUpdateEvent, CloudFormationCustomResourceDeleteEvent, CloudFormationCustomResourceResponse, Context, } from "aws-lambda"; import * as https from "https"; import type { IncomingMessage } from "http"; /** * 検証レコード情報 */ interface ValidationRecord { readonly Name: string; readonly Type: string; readonly Value: string; } /** * カスタムドメインの情報 */ interface CustomDomain { readonly DomainName: string; readonly Status: string; readonly CertificateValidationRecords: readonly ValidationRecord[]; } /** * App Runnerカスタムドメインの情報 */ interface CustomDomains { readonly DNSTarget: string; readonly ServiceArn: string; readonly CustomDomain: CustomDomain; readonly CustomDomains: readonly CustomDomain[]; } /** * CloudFormationリソースのプロパティ */ interface ResourceProperties { readonly ServiceArn: string; readonly DomainName: string; } /** * リソースレコード情報 */ interface ResourceRecord { readonly Name: string; readonly Type: string; readonly ResourceRecords: readonly string[]; readonly SetIdentifier: string; readonly Weight: string; readonly TTL: string; } /** * 抽出した属性情報 */ interface ExtractedAttributes { readonly DNSTarget: string; readonly ValidationResourceRecords: readonly ResourceRecord[]; } type CloudFormationCustomResourceEvent = | CloudFormationCustomResourceCreateEvent<ResourceProperties> | CloudFormationCustomResourceUpdateEvent< ResourceProperties, ResourceProperties > | CloudFormationCustomResourceDeleteEvent<ResourceProperties>; type CloudFormationCustomResourceStatus = "SUCCESS" | "FAILED"; /** * CloudFormationカスタムリソースレスポンス */ interface CustomCloudFormationCustomResourceResponse extends Omit<CloudFormationCustomResourceResponse, "Status"> { Status: CloudFormationCustomResourceStatus; } /** * 物理リソースIDを生成する */ const createPhysicalId = (serviceArn: string, domainName: string): string => `${serviceArn},${domainName}`; /** * カスタムドメインの状態をチェックする */ const checkCustomDomain = async ( apprunner: AppRunner, props: ResourceProperties ): Promise<DescribeCustomDomainsCommandOutput> => { const command = new DescribeCustomDomainsCommand({ ServiceArn: props.ServiceArn, }); return apprunner.send(command); }; /** * カスタムドメインを作成する */ const createCustomDomain = async ( apprunner: AppRunner, request: CloudFormationCustomResourceCreateEvent<ResourceProperties>, response: CustomCloudFormationCustomResourceResponse ): Promise<void> => { const props = request.ResourceProperties; // 入力検証 if (!props.ServiceArn || !props.DomainName) { throw new Error("ServiceArnとDomainNameは必須パラメータです"); } // カスタムドメインの追加リクエスト const command = new AssociateCustomDomainCommand({ ServiceArn: props.ServiceArn, DomainName: props.DomainName, EnableWWWSubdomain: false, }); try { // リトライロジックを使ってカスタムドメインを関連付ける await retryOperation(() => apprunner.send(command), 3, 2000); response.PhysicalResourceId = createPhysicalId( props.ServiceArn, props.DomainName ); response.Status = "SUCCESS"; // 現在のカスタムドメイン情報を取得して返す const domainInfo = await checkCustomDomain(apprunner, props); response.Data = { DomainName: props.DomainName, Status: "CREATING", // 初期ステータス DetailedInfo: JSON.stringify(domainInfo, null, 2), }; } catch (error) { console.error("カスタムドメイン関連付けに失敗しました:", error); throw new Error(`カスタムドメイン関連付けに失敗しました: ${error}`); } }; /** * カスタムドメインを更新する */ const updateCustomDomain = async ( apprunner: AppRunner, request: CloudFormationCustomResourceUpdateEvent< ResourceProperties, ResourceProperties >, response: CustomCloudFormationCustomResourceResponse ): Promise<void> => { const props = request.ResourceProperties; const oldProps = request.OldResourceProperties; // 変更がない場合は何もしない if ( oldProps.ServiceArn === props.ServiceArn && oldProps.DomainName === props.DomainName ) { response.Reason = "変更はありません"; response.PhysicalResourceId = request.PhysicalResourceId; response.Status = "SUCCESS"; return; } // 新しいドメインの設定 console.log( `カスタムドメイン設定を更新: ${oldProps.DomainName} -> ${props.DomainName}` ); // 古いドメインを削除 try { const deleteCommand = new DisassociateCustomDomainCommand({ ServiceArn: oldProps.ServiceArn, DomainName: oldProps.DomainName, }); await apprunner.send(deleteCommand); console.log( `古いカスタムドメイン ${oldProps.DomainName} の関連付けを解除しました` ); } catch (error) { console.warn( `古いカスタムドメインの関連付け解除中にエラーが発生しました(続行します):`, error ); } // 新しいドメインを設定 const createRequest: CloudFormationCustomResourceCreateEvent<ResourceProperties> = { ...request, RequestType: "Create", }; await createCustomDomain(apprunner, createRequest, response); }; /** * カスタムドメインを削除する */ const deleteCustomDomain = async ( apprunner: AppRunner, request: CloudFormationCustomResourceDeleteEvent<ResourceProperties>, response: CustomCloudFormationCustomResourceResponse ): Promise<void> => { // 作成時に失敗した場合は無視する if (!request.PhysicalResourceId?.startsWith("arn:aws:apprunner")) { response.Reason = "作成済みリソースが見つからないため、削除をスキップします"; response.Status = "SUCCESS"; return; } const props = request.ResourceProperties; const command = new DisassociateCustomDomainCommand({ ServiceArn: props.ServiceArn, DomainName: props.DomainName, }); try { await retryOperation(() => apprunner.send(command), 3, 2000); console.log( `カスタムドメイン ${props.DomainName} の関連付けを解除しました` ); response.Status = "SUCCESS"; } catch (error) { console.error("カスタムドメイン関連付け解除に失敗しました", error); // 削除エラーは無視して成功として扱う(すでに存在しない可能性がある) response.Reason = "カスタムドメイン関連付け解除中のエラーを無視します"; response.Status = "SUCCESS"; } }; /** * カスタムドメインイベントを処理する */ const handleCustomDomain = async ( request: CloudFormationCustomResourceEvent ): Promise<CustomCloudFormationCustomResourceResponse> => { const apprunner = new AppRunner({}); const response: CustomCloudFormationCustomResourceResponse = { StackId: request.StackId, RequestId: request.RequestId, LogicalResourceId: request.LogicalResourceId, Status: "SUCCESS", PhysicalResourceId: "NONE", }; try { switch (request.RequestType) { case "Create": await createCustomDomain(apprunner, request, response); break; case "Update": await updateCustomDomain(apprunner, request, response); break; case "Delete": await deleteCustomDomain(apprunner, request, response); break; default: response.Status = "FAILED"; response.Reason = "サポートされていないリクエストタイプです"; } } catch (error) { response.Status = "FAILED"; response.Reason = `${error}`; console.error(response.Reason); } return response; }; /** * CloudFormationにレスポンスを送信する */ const postResponseToCloudformation = async ( url: string, response: CustomCloudFormationCustomResourceResponse ): Promise<void> => { const data = JSON.stringify(response); console.log("CloudFormationにレスポンスを送信:", data); return new Promise((resolve, reject) => { const request = https.request( url, { method: "PUT", headers: { "Content-Type": "", "Content-Length": Buffer.byteLength(data), }, }, (res: IncomingMessage) => { let responseData = ""; res.on("data", (chunk: Buffer) => (responseData += chunk)); res.on("end", () => { console.log("CloudFormationからのレスポンス:", responseData); resolve(); }); res.on("error", reject); } ); request.on("error", reject); request.write(data); request.end(); }); }; /** * 操作をリトライする */ async function retryOperation<T>( operation: () => Promise<T>, maxRetries: number, delay: number ): Promise<T> { let lastError: Error | undefined; for (let i = 0; i < maxRetries; i++) { try { return await operation(); } catch (error) { console.warn( `操作に失敗しました (リトライ ${i + 1}/${maxRetries}):`, error ); lastError = error instanceof Error ? error : new Error(String(error)); if (i < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, delay * Math.pow(2, i)) ); } } } throw lastError || new Error("不明なエラーでリトライが失敗しました"); } /** * Lambda関数ハンドラー */ export const handler = async ( event: CloudFormationCustomResourceEvent, _context: Context ): Promise<void> => { console.log("イベントを受信:", JSON.stringify(event, null, 2)); try { const response = await handleCustomDomain(event); await postResponseToCloudformation(event.ResponseURL, response); } catch (error) { console.error("ハンドラーでエラーが発生しました:", error); // 最後の手段としてエラーレスポンスを送信 const errorResponse: CustomCloudFormationCustomResourceResponse = { StackId: event.StackId, RequestId: event.RequestId, LogicalResourceId: event.LogicalResourceId, Status: "FAILED", PhysicalResourceId: "ERROR_HANDLER", Reason: `処理中に致命的なエラーが発生しました: ${error}`, }; await postResponseToCloudformation(event.ResponseURL, errorResponse); } }; |
このLambda関数も、とても重要な役割を担っています。具体的には以下のことをやっています:
- カスタムドメインの関連付け: App Runnerサービスに対して、カスタムドメイン(例:app.example.com)を関連付けます。これにより、指定のドメインでアプリにアクセスできるようになります。
- 状態の監視: 関連付けは非同期で行われるため、状態を監視する機能も実装されています。
waitForCustomDomainStatus
メソッドがその役割を担います。 - 柔軟な更新処理: ドメイン名が変更された場合には、古いドメインの関連付けを解除してから、新しいドメインを関連付けるという処理もサポートしています。
- CloudFormationとの連携: この関数はカスタムリソースとして動作するため、CloudFormationのライフサイクルイベント(Create、Update、Delete)と連携しています。
この関数のおかげで、App Runnerのカスタムドメイン設定がCloudFormationのスタックライフサイクルに組み込まれ、完全に自動化できるようになってます。
デプロイ方法
さて、ここまで説明してきたコードを使って、実際にNext.jsアプリをデプロイしてみましょう!
AWS CDKからApp Runnerへのデプロイ手順をご紹介します。
環境変数の設定
まずは、デプロイに必要な環境変数を設定していきましょう。プロジェクトのルートディレクトリに .env
ファイルを作成して、以下のように設定してください。これらの変数がインフラ構築を制御します。
1 2 3 4 5 6 7 8 9 10 11 |
# アプリケーション設定 APP_NAME=apprunner-nextjs ENVIRONMENT=dev # ドメイン設定 HOSTED_ZONE_NAME=example.com SUBDOMAIN_NAME=app # AWS設定 REGION=ap-northeast-1 |
デプロイ手順
準備ができたら、以下のコマンドを順番に実行してインフラをデプロイしていきましょう。
1 2 3 4 5 6 7 8 9 10 |
# 依存関係をインストール cd apps/main-app/infra pnpm install # CDKスタックをビルド pnpm run build # CDKスタックをデプロイ pnpm run deploy |
デプロイが無事完了すると、ターミナルに以下のような出力が表示されます。これらのURLを開いてアプリケーションにアクセスできるようになっているのが確認できたらOKです。
1 2 3 4 |
Outputs: TenantInfraStack.NextJsAppRunnerWebAppUrl = https://xxxxxxxxxxxx.ap-northeast-1.awsapprunner.com TenantInfraStack.Route53ConfigCustomDomainUrl = https://app.example.com TenantInfraStack.Route53ConfigCertificateArn = arn:aws:acm:us-east-1:xxxxxxxxxxxx:certificate/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
今回私はcreate-next-appの初期のままデプロイしたのでドメインにアクセスすると以下のページが開けました。
まとめ
この記事では、AWS App RunnerでNext.jsアプリケーションをデプロイし、カスタムドメインを設定する方法について詳しく解説しました。
- AWS CDKによる自動化: インフラをコードとして管理し、複雑な設定を自動化することで、デプロイの再現性と信頼性を高めました。
- カスタムドメイン設定の自動化: Route53Configコンストラクトを使って、SSL/TLS証明書の発行・検証からDNSレコードの設定まで、すべてを自動化しました。
- Lambda関数による拡張: CloudFormationでは直接サポートされていない操作を、カスタムリソースとLambda関数を組み合わせることで実現しました。
- セキュリティとパフォーマンスの最適化: 最小権限の原則に基づいたIAM設定や、適切なリソースサイジングにより、安全かつコスト効率の良いデプロイを実現しました。
この実装アプローチにより、開発者はインフラの複雑さを気にすることなく、アプリケーション開発に集中できるようになります。また、環境の再構築も簡単に行えるため、開発からステージング、本番環境まで一貫したデプロイパイプラインを構築できます。
AWS App RunnerとCDKを組み合わせることで、コンテナ技術の複雑さを抽象化しつつ、カスタムドメインやSSL/TLS証明書などの本番環境に必要な要素を簡単に導入できるようになったかと思います。
他にも技術ブログをあげているのでそちらもよろしければ見ていってださい。
また、株式会社ギークフィードでは開発エンジニアなどの職種で一緒に働く仲間を募集しています。
弊社に興味を持っていただいたり、会社のことをカジュアルに聞いてみたいという場合でも、ご気軽にフォームからお問い合わせください。その場合はコメント欄に、カジュアルにお話したいです、と記載ください!
- Next.jsアプリケーションをAWS App Runnerにデプロイする実践ガイド - 2025-04-07
- 去年1年間で最も勢いのあったJavaScriptライブラリを見ていく【JavaScript Rising Stars 2024】 - 2025-01-09
- Next.jsでAmazon Connectの標準CCPを埋め込み動的データを取得する方法 - 2025-01-06
- Twilio Flex v2.x.x系でLINE連携を実装する方法 - 2024-12-23
- AWS Bedrockを活用したAI生成テキスト評価と再生成の実装技法 - 2024-06-17
【採用情報】一緒に働く仲間を募集しています
