金融IT本部(兼XI本部)の上野です。 現在は主にアプリケーションエンジニアの文脈におけるアーキテクトとして日々アーキテクチャ設計/コーディングに勤しんでおります。 今回は、Claude CodeでIaCコードを書いた際の備忘を残しておきたく、ブログに起こすものです。 読み進めるうえで、最初に私のインフラ関連のスキルセットを記載しておきます。 AWSで頻出のサービスと役割くらいは理解している。資格は一個もない。 Dockerについては理解しており、k8sレベルになると怪しい。なんとなくデプロイはできる。 ネスペ二回受けて二回落ちるくらいの低レイヤ勉強量。 コンソールポチポチでデプロイもできる。 IaCは初めて書くがTypeScriptは書けるしアプリ開発でClaude Codeも使っている。 モチベーション 検証 環境 (1)何もガードレールを引かないシンプルなプロンプトで実行 (2)課題を抽出 (3)ガードレールとして設計とプロンプトをチューニング、どの程度解消されたかを確認 出力されたコード (4)構成の比較(As-Is → To-Be) 総評 最初に吐いたコードはどのレベルか? ガードレール後に出てきたコードはどのレベルか? どのレベルであれば使いこなせるか? 工夫するとしたら? 最後に モチベーション 一人のアプリケーションエンジニアとして、2025年11月にOpus 4.5が出たときは衝撃でした。 これまではClaude Code等によりTUIによる操作ができるようになったことで、コーディング速度は爆速になったがプロダクションで使うにはもう一歩という感触がありました。しかしOpus 4.5の登場により、そのアウトプットや計画能力は飛躍したと感じています。実際にアプリケーションの文脈ではプロダクションレベルのコーディングにClaude Codeを採用し、大幅な開発効率の向上を実現しています。 さてそうなってくると、これまでインフラ経験がほぼ0の私が、どの程度IaCコードによりインフラ構築が可能になるのか気になってきました。 Claude Codeが登場した5月頃には、IaCにおいては以下のような課題がありました。これらはすべてIaCは「状態機械」に過ぎないことから生じた課題であり、その結果出てきたコードがそのまま動かないことも頻発しています。 (1)フィードバックループが極端に遅い。 アプリの場合はUnit Test→ログ出力の即時フィードバックの流れですが、IaCではsynth, deployの流れをとおして数分~数10分かかり、エラー表現も曖昧なことがある (2)ログに出ない暗黙知が多すぎる。 AZ制約→Claudeが生成したコードでAZが無指定、AWSがランダム割当、接続不備 ルーティング地獄→private subnetから外に出られない、NAT Gatewayがない IAMの評価順トラブル→AllowよりもDenyが優先されるがDenyの定義を見逃す (3)精度としての問題。 サービス(Stack)定義の依存関係、パラメータの組み合わせが複雑化しやすいため、正しくコードを生成できない (4)最新パラメータへの追従 最新のクラウド機能に追従しようとすると、モデル側が新サービス・パラメータに追従できていないケースが多い。 Opus 4.5では、知識のカットオフ日が伸びたことで最新の知見が増え、かつコンテキストウィンドウも200kトークンとなり、単体のStackを見てインフラ構成を見るのではなく全体の状態を見ながら差分検知できるようになっています。これにより、少ないログからも高い精度が期待できるようにな\ りま、、\ ったはずです。 (参考)Claudeのトレーニングデータはどの程度最新ですか? 検証 さて、いくつか課題を書きましたが、(4)については触れず、既存の学習モデルでどこまで(1)~(3)の課題に対応できるかを検証します。 まずは、 最低限の指示でどの程度やってくれるのか そこからどの程度ガードレールを引くことで使い物になるのか/ならないのか ガードレールを引くためにどの程度のスキルが必要なのか を検証します。これにより、「今のモデルであれば、どの程度インフラ知識をつけておけば(どの程度設計能力があれば)実務で活用できるのか」という指標を明らかにしていきたいと考えています。方針としては、 (1)何もガードレールを引かないシンプルなプロンプトで実行 (2)課題を抽出 (3)ガードレールとして設計とプロンプトをチューニング、どの程度解消されたかを確認 (4)構成の比較(As-Is → To-Be) 今回は、AWS上に構築。ECS、DBはAuroraの超簡単な構成で検証します。 環境 検証自体は2026年1月であり、以下のツール・モデルを利用します。 Claude Code v2.1.21 モデル Opus 4.5 (1)何もガードレールを引かないシンプルなプロンプトで実行 最初のプロンプトは超簡単に以下です。 ECS、Auroraの構成をCDKで作ってください。 いくつかの質問が返ってきます。 CDKのプログラミング言語は何を使いますか? → TypeScript (Recommended) ECSのコンピューティングタイプはどちらですか? → Fargate (Recommended) Auroraのエンジンはどちらを使いますか? → PostgreSQL (Recommended) Auroraのデプロイ方式はどちらですか? → Serverless v2 (Recommended) 出てきたコードを見てみましょう。 import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns'; import * as rds from 'aws-cdk-lib/aws-rds'; import { Construct } from 'constructs'; export class CdkTestStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // VPC const vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2, natGateways: 1, }); // Aurora PostgreSQL Serverless v2 const dbCluster = new rds.DatabaseCluster(this, 'AuroraCluster', { engine: rds.DatabaseClusterEngine.auroraPostgres({ version: rds.AuroraPostgresEngineVersion.VER_16_4, }), serverlessV2MinCapacity: 0.5, serverlessV2MaxCapacity: 4, writer: rds.ClusterInstance.serverlessV2('Writer'), vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, defaultDatabaseName: 'appdb', removalPolicy: cdk.RemovalPolicy.DESTROY, }); // ECS Cluster const cluster = new ecs.Cluster(this, 'EcsCluster', { vpc, }); // ECS Fargate Service with ALB const fargateService = new ecs_patterns.ApplicationLoadBalancedFargateService( this, 'FargateService', { cluster, taskSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, cpu: 256, memoryLimitMiB: 512, desiredCount: 1, taskImageOptions: { image: ecs.ContainerImage.fromRegistry('nginx:latest'), containerPort: 80, environment: { DB_HOST: dbCluster.clusterEndpoint.hostname, DB_PORT: dbCluster.clusterEndpoint.port.toString(), DB_NAME: 'appdb', }, secrets: { DB_SECRET: ecs.Secret.fromSecretsManager(dbCluster.secret!), }, }, publicLoadBalancer: true, }, ); // Allow ECS tasks to connect to Aurora dbCluster.connections.allowDefaultPortFrom(fargateService.service); // Outputs new cdk.CfnOutput(this, 'AlbDnsName', { value: fargateService.loadBalancer.loadBalancerDnsName, description: 'ALB DNS Name', }); new cdk.CfnOutput(this, 'DbClusterEndpoint', { value: dbCluster.clusterEndpoint.hostname, description: 'Aurora Cluster Endpoint', }); } } 作成されるリソースは以下の通りです。 リソース 詳細 VPC 2 AZ, Public/Private サブネット、NAT Gateway×1 Aurora PostgreSQL Serverless v2, Privateに配置 ECS Fargate ALB付き Security Group ECS→Aurora(PostgreSQL 5432のみ許可) Secrets Manager Aurora認証情報を自動生成、ECSタスクにDB_SECRETとして付与 (2)課題を抽出 上記コードによる課題は以下です。 こちらは自分で洗いだしたものを含め、最終的にChatGPT5.2(Codex)にレビューしてもらいながら全量を記載しています。普段からコーディングエージェントが出力したコードのレビューにはCodexを利用しており、生成元とは別ベンダーのモデルに専門家の立場でレビューさせることで、同一モデルのバイアスを避ける狙いがあります。 No 課題 課題分類 優先度 詳細 1 Aurora が RemovalPolicy.DESTROY 運用 / セキュリティ must スタック削除や置換で DBが削除 され得ます。本番は RETAIN を基本に、削除保護も有効化すべきです。 2 Aurora の削除保護(DeletionProtection)が未設定 運用 / セキュリティ must オペミス( cdk destroy 等)や誤置換で消えるリスクが残ります。 3 バックアップ保持(backup retention)等のデータ保護が薄い 運用 must Aurora 側のバックアップ保持日数・復旧設計を明示していません。最低でも保持期間を要件化し、復旧手順を想定すべきです。 4 NAT Gateway が 1 台(2AZでも単一NAT) 可用性 / コスト must 2AZ 構成でも NAT が単一だと NAT配置AZ障害時に private 側の外向き通信が破綻 しやすいです。加えてクロスAZ経由コストが増えがち。 5 ALB が publicLoadBalancer: true 固定 セキュリティ / 運用 must 無条件でインターネット公開前提になります。用途要件(社内向け/internal か公開か)を決めて選ぶべきです。 6 HTTPS 設定がない(証明書/HTTP→HTTPSリダイレクト) セキュリティ must 現状は HTTP のまま公開になりがち。ACM 証明書の設定と 443 終端、80→443 リダイレクトを入れるのが基本です。 7 Secrets の渡し方が「secret全体」を1変数に入れている 保守性 / セキュリティ recommend DB_SECRET に JSON 全体が入る形になり、アプリ側が解釈依存で壊れやすい。 fromSecretsManager(secret, 'password') のようにキー指定して渡す方が堅牢です。 8 dbCluster.secret! の non-null assertion 保守性 recommend 将来の変更や条件分岐で secret がない構成に寄ると実行時に破綻します。存在前提をコードで担保するか、生成条件を明示すべきです。 9 DB への接続情報を env に直入れ(host/port/name) セキュリティ / 保守性 recommend 機微度は低いが、環境差分や変更に弱い。アプリ設定として一元管理(SSM/Secrets/Config)や接続文字列化などを検討。 10 監視/ログ設計がほぼない(ALBアクセスログ、ECSログ、アラーム等) 運用 recommend デプロイ後に障害対応できない構成になりがち。最低限 CloudWatch Logs、主要メトリクス(CPU/Memory/ALB 5xx/TargetResponseTime)アラームを用意。 11 スタック分割がない(Network/App/Db が単一Stack) 保守性 / 運用 recommend 差分デプロイ・権限分離・変更管理が難しくなります。環境が育つほど辛いです。 12 VPC Endpoint を使わず NAT 依存(ECR/Logs/Secrets 等) コスト / 可用性 recommend NAT はコスト増・単一点化になりやすい。ECR/CloudWatch Logs/Secrets Manager などは VPC Endpoint 併用でコストと可用性を改善できます。 13 SG 設計が最小限(ECS→DBの許可のみで、方針がコード化されていない) セキュリティ nits 今は動くが、将来拡張でルールが散逸しがち。インバウンド/アウトバウンド方針やポート設計をパターン化したい。 14 DB ユーザー/ローテーション方針がコード外 セキュリティ / 運用 nits どの認証方式で運用するか(Secrets rotation、IAM auth、踏み台/SSM 経由など)が未定義。要件として別途決める領域。 特に問題なのは以下ですね。 No5: ALB が publicLoadBalancer: true 固定 No6: HTTPS 設定がない(証明書/HTTP→HTTPSリダイレクト) もともとアプリケーションエンジニアとしては、以下も気になるところです。 No10: 監視/ログ設計がほぼない(ALBアクセスログ、ECSログ、アラーム等) No11: スタック分割がない(Network/App/Db が単一Stack) (3)ガードレールとして設計とプロンプトをチューニング、どの程度解消されたかを確認 出てきた課題点をガードレールとして敷けるよう、プロンプトをチューニングします。 あなたはAWS CDK(TypeScript)を用いて本番運用前提の ECS(Fargate) + Aurora PostgreSQL(Serverless v2) 構成を実装するクラウドアーキテクトです。 doc/require-infra.mdに従って、ドキュメントの内容を漏らさず、実運用に耐える構成で作ってください。 与えるドキュメントは以下のとおりです。 # 共通前提 - CDK: aws-cdk-lib v2 - 言語: TypeScript - AZ: 2AZ - 環境: prod のみ - 出力構成: - lib/network-stack.ts - lib/db-stack.ts - lib/app-stack.ts - bin/app.ts - README.md(デプロイ方法と必須パラメータ) # スタック分割 以下の 3 Stack に分離すること: ## NetworkStack - VPC (2AZ) - public / private subnet - NAT Gateway: AZごとに1台(natGateways: 2) - VPC Endpoint: - ECR(api, dkr) - CloudWatch Logs - Secrets Manager - SSM / SSMMessages / EC2Messages - 共通タグ(Name / Env=prod) ## DbStack - Aurora PostgreSQL Serverless v2 - private subnet のみ - Secrets Manager(DB認証情報) - DB用 SecurityGroup ## AppStack - ECS Cluster - Fargate Service + ALB - ECS用 SecurityGroup - HTTPS Listener - CloudWatch Logs / Alarm # Aurora 要件 - removalPolicy = RETAIN - deletionProtection = true - backup retention = 7 days - DB は public subnet に置かない - Secrets Manager を必ず作成 - dbCluster.secret! の non-null assertion は使用禁止 - CloudWatch Logs export(postgresql)を有効化 # ネットワーク - ECS / Aurora は private subnet - NAT Gateway は 2 台 - ECS が以下に到達できること: - ECR - CloudWatch Logs - Secrets Manager - SSM # ALB / HTTPS - ALB は public(internet-facing) - ACM Certificate ARN は props で受け取る - Listener: - 443 HTTPS - 80 → 443 redirect - SecurityGroup: - ALB: inbound 443 のみ(IP制御はWAFで行う) - ECS: ALB SG から app port のみ - DB: ECS SG から 5432 のみ # WAF - AWS WAFv2 を ALB に関連付ける - 許可する送信元 IP は allowlist 方式とする(それ以外はブロック) - IP set(IPv4)を作成し、CIDR のリストを props で受け取れるようにする - 例:["203.0.113.10/32","198.51.100.0/24"] - WebACL のルール優先度: 1) Allow: 指定 IP set に一致 2) Default action: Block - visibilityConfig を有効化し、WAF のメトリクス/サンプリングログを有効化する # Secrets / 接続情報 - Secrets は JSONキー単位で ECS に渡す: - DB_USER - DB_PASSWORD - DB_HOST / DB_PORT / DB_NAME は Parameter 化 - Secret 全体を1変数に渡さない # Secrets / 接続情報 - Secrets は JSONキー単位で ECS に渡す: - DB_USER - DB_PASSWORD - DB_HOST / DB_PORT / DB_NAME は Parameter 化 - Secret 全体を1変数に渡さない # 監視 / ログ - ECS Container Logs → CloudWatch Logs - Log retention = 30 days - CloudWatch Alarm: - ALB 5xx - ECS CPU / Memory # 保守性 - Network → Db → App の依存方向を守る - VPC は NetworkStack から export - DB endpoint / secret は DbStack から export # SG設計 - SGは明示的に作成 - allowDefaultPortFrom のみは禁止 - どのSG→どのSGか分かるようルールを書く # コンテナ - nginx:latest のような latest タグは禁止 - 固定タグ or digest を使用 # 出力期待 - 各 Stack の責務が明確 - prod 固定構成 - README に以下を記載: - デプロイ手順 - 必須パラメータ(ACM ARN 等) 出力されたコード lib/network-stack.ts import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import { Construct } from 'constructs'; export class NetworkStack extends cdk.Stack { public readonly vpc: ec2.IVpc; constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // ---------------------------------------------------------------- // VPC – 2AZ / public + private / NAT Gateway x2 // ---------------------------------------------------------------- const vpc = new ec2.Vpc(this, 'Vpc', { maxAzs: 2, natGateways: 2, subnetConfiguration: [ { cidrMask: 24, name: 'Public', subnetType: ec2.SubnetType.PUBLIC, }, { cidrMask: 24, name: 'Private', subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS, }, ], }); // ---------------------------------------------------------------- // VPC Endpoints – Interface // ---------------------------------------------------------------- // ECR API vpc.addInterfaceEndpoint('EcrApiEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.ECR, }); // ECR Docker (dkr) vpc.addInterfaceEndpoint('EcrDkrEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER, }); // CloudWatch Logs vpc.addInterfaceEndpoint('CloudWatchLogsEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, }); // Secrets Manager vpc.addInterfaceEndpoint('SecretsManagerEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER, }); // SSM vpc.addInterfaceEndpoint('SsmEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.SSM, }); // SSM Messages vpc.addInterfaceEndpoint('SsmMessagesEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.SSM_MESSAGES, }); // EC2 Messages vpc.addInterfaceEndpoint('Ec2MessagesEndpoint', { service: ec2.InterfaceVpcEndpointAwsService.EC2_MESSAGES, }); // S3 Gateway (ECR イメージレイヤー取得に必要) vpc.addGatewayEndpoint('S3Endpoint', { service: ec2.GatewayVpcEndpointAwsService.S3, }); // ---------------------------------------------------------------- // 共通タグ // ---------------------------------------------------------------- cdk.Tags.of(this).add('Env', 'prod'); this.vpc = vpc; } } lib/app-stack.ts import * as cdk from 'aws-cdk-lib'; import * as acm from 'aws-cdk-lib/aws-certificatemanager'; import * as cloudwatch from 'aws-cdk-lib/aws-cloudwatch'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as ecs from 'aws-cdk-lib/aws-ecs'; import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as rds from 'aws-cdk-lib/aws-rds'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import * as wafv2 from 'aws-cdk-lib/aws-wafv2'; import { Construct } from 'constructs'; export interface AppStackProps extends cdk.StackProps { readonly vpc: ec2.IVpc; readonly dbCluster: rds.DatabaseCluster; readonly dbSecurityGroup: ec2.ISecurityGroup; readonly dbSecret: secretsmanager.ISecret; readonly certificateArn: string; readonly allowedCidrs: string[]; } export class AppStack extends cdk.Stack { constructor(scope: Construct, id: string, props: AppStackProps) { super(scope, id, props); const { vpc, dbCluster, dbSecurityGroup, dbSecret, certificateArn, allowedCidrs } = props; const appPort = 8080; // ---------------------------------------------------------------- // ACM Certificate (ARN から参照) // ---------------------------------------------------------------- const certificate = acm.Certificate.fromCertificateArn( this, 'Certificate', certificateArn, ); // ---------------------------------------------------------------- // Security Groups // ---------------------------------------------------------------- // ALB SG – inbound 443 のみ const albSg = new ec2.SecurityGroup(this, 'AlbSecurityGroup', { vpc, description: 'Security group for Application Load Balancer', allowAllOutbound: true, }); albSg.addIngressRule( ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS inbound', ); // ECS SG – ALB SG から app port のみ const ecsSg = new ec2.SecurityGroup(this, 'EcsSecurityGroup', { vpc, description: 'Security group for ECS Fargate tasks', allowAllOutbound: true, }); ecsSg.addIngressRule( albSg, ec2.Port.tcp(appPort), 'Allow traffic from ALB on app port', ); // DB SG – ECS SG から 5432 のみ // 循環依存を回避するため CfnSecurityGroupIngress を直接作成 new ec2.CfnSecurityGroupIngress(this, 'DbFromEcsIngress', { ipProtocol: 'tcp', fromPort: 5432, toPort: 5432, groupId: dbSecurityGroup.securityGroupId, sourceSecurityGroupId: ecsSg.securityGroupId, description: 'Allow PostgreSQL access from ECS tasks', }); // ---------------------------------------------------------------- // ECS Cluster // ---------------------------------------------------------------- const cluster = new ecs.Cluster(this, 'EcsCluster', { vpc, containerInsightsV2: ecs.ContainerInsights.ENABLED, }); // ---------------------------------------------------------------- // Task Definition // ---------------------------------------------------------------- const taskDef = new ecs.FargateTaskDefinition(this, 'TaskDef', { cpu: 512, memoryLimitMiB: 1024, }); const logGroup = new logs.LogGroup(this, 'AppLogGroup', { retention: logs.RetentionDays.ONE_MONTH, removalPolicy: cdk.RemovalPolicy.DESTROY, }); const container = taskDef.addContainer('app', { image: ecs.ContainerImage.fromRegistry('nginx:1.27-alpine'), logging: ecs.LogDrivers.awsLogs({ logGroup, streamPrefix: 'app', }), environment: { DB_HOST: dbCluster.clusterEndpoint.hostname, DB_PORT: dbCluster.clusterEndpoint.port.toString(), DB_NAME: 'appdb', }, secrets: { DB_USER: ecs.Secret.fromSecretsManager(dbSecret, 'username'), DB_PASSWORD: ecs.Secret.fromSecretsManager(dbSecret, 'password'), }, healthCheck: { command: ['CMD-SHELL', `curl -f http://localhost:${appPort}/ || exit 1`], interval: cdk.Duration.seconds(30), timeout: cdk.Duration.seconds(5), retries: 3, startPeriod: cdk.Duration.seconds(60), }, }); container.addPortMappings({ containerPort: appPort, protocol: ecs.Protocol.TCP, }); // ---------------------------------------------------------------- // Fargate Service // ---------------------------------------------------------------- const service = new ecs.FargateService(this, 'FargateService', { cluster, taskDefinition: taskDef, desiredCount: 2, securityGroups: [ecsSg], vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, minHealthyPercent: 100, maxHealthyPercent: 200, enableExecuteCommand: true, }); // ---------------------------------------------------------------- // ALB – public (IP 制御は WAF で実施) // ---------------------------------------------------------------- const alb = new elbv2.ApplicationLoadBalancer(this, 'Alb', { vpc, internetFacing: true, securityGroup: albSg, vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, }); // HTTPS Listener (443) const httpsListener = alb.addListener('HttpsListener', { port: 443, protocol: elbv2.ApplicationProtocol.HTTPS, certificates: [certificate], }); httpsListener.addTargets('EcsTarget', { port: appPort, protocol: elbv2.ApplicationProtocol.HTTP, targets: [service], healthCheck: { path: '/', healthyHttpCodes: '200', interval: cdk.Duration.seconds(30), timeout: cdk.Duration.seconds(5), healthyThresholdCount: 2, unhealthyThresholdCount: 3, }, }); // HTTP → HTTPS redirect (80 → 443) alb.addListener('HttpRedirectListener', { port: 80, protocol: elbv2.ApplicationProtocol.HTTP, defaultAction: elbv2.ListenerAction.redirect({ protocol: 'HTTPS', port: '443', permanent: true, }), }); // ---------------------------------------------------------------- // WAFv2 – IP allowlist // ---------------------------------------------------------------- const ipSet = new wafv2.CfnIPSet(this, 'AllowedIpSet', { scope: 'REGIONAL', ipAddressVersion: 'IPV4', addresses: allowedCidrs, description: 'Allowed source IP addresses', }); const webAcl = new wafv2.CfnWebACL(this, 'WebAcl', { scope: 'REGIONAL', defaultAction: { block: {} }, visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: 'ProdWebAcl', sampledRequestsEnabled: true, }, rules: [ { name: 'AllowFromIpSet', priority: 1, action: { allow: {} }, statement: { ipSetReferenceStatement: { arn: ipSet.attrArn, }, }, visibilityConfig: { cloudWatchMetricsEnabled: true, metricName: 'AllowFromIpSet', sampledRequestsEnabled: true, }, }, ], }); new wafv2.CfnWebACLAssociation(this, 'WebAclAssociation', { resourceArn: alb.loadBalancerArn, webAclArn: webAcl.attrArn, }); // ---------------------------------------------------------------- // CloudWatch Alarms // ---------------------------------------------------------------- // ALB 5xx new cloudwatch.Alarm(this, 'Alb5xxAlarm', { metric: alb.metrics.httpCodeElb(elbv2.HttpCodeElb.ELB_5XX_COUNT, { period: cdk.Duration.minutes(5), statistic: 'Sum', }), threshold: 10, evaluationPeriods: 2, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, alarmDescription: 'ALB 5xx errors exceeded threshold', treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); // ECS CPU Utilization new cloudwatch.Alarm(this, 'EcsCpuAlarm', { metric: service.metricCpuUtilization({ period: cdk.Duration.minutes(5), statistic: 'Average', }), threshold: 80, evaluationPeriods: 3, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, alarmDescription: 'ECS CPU utilization exceeded 80%', treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); // ECS Memory Utilization new cloudwatch.Alarm(this, 'EcsMemoryAlarm', { metric: service.metricMemoryUtilization({ period: cdk.Duration.minutes(5), statistic: 'Average', }), threshold: 80, evaluationPeriods: 3, comparisonOperator: cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, alarmDescription: 'ECS memory utilization exceeded 80%', treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING, }); // ---------------------------------------------------------------- // 共通タグ // ---------------------------------------------------------------- cdk.Tags.of(this).add('Env', 'prod'); // ---------------------------------------------------------------- // Outputs // ---------------------------------------------------------------- new cdk.CfnOutput(this, 'AlbDnsName', { value: alb.loadBalancerDnsName, description: 'ALB DNS Name', }); new cdk.CfnOutput(this, 'AlbArn', { value: alb.loadBalancerArn, description: 'ALB ARN', }); new cdk.CfnOutput(this, 'EcsClusterName', { value: cluster.clusterName, description: 'ECS Cluster Name', }); new cdk.CfnOutput(this, 'EcsServiceName', { value: service.serviceName, description: 'ECS Service Name', }); } } lib/db-stack.ts import * as cdk from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; import * as logs from 'aws-cdk-lib/aws-logs'; import * as rds from 'aws-cdk-lib/aws-rds'; import { Construct } from 'constructs'; export interface DbStackProps extends cdk.StackProps { readonly vpc: ec2.IVpc; } export class DbStack extends cdk.Stack { public readonly dbCluster: rds.DatabaseCluster; public readonly dbSecurityGroup: ec2.ISecurityGroup; public readonly dbSecret: cdk.aws_secretsmanager.ISecret; constructor(scope: Construct, id: string, props: DbStackProps) { super(scope, id, props); const { vpc } = props; // ---------------------------------------------------------------- // Security Group – DB // ---------------------------------------------------------------- const dbSg = new ec2.SecurityGroup(this, 'DbSecurityGroup', { vpc, description: 'Security group for Aurora PostgreSQL cluster', allowAllOutbound: false, }); // ---------------------------------------------------------------- // Aurora PostgreSQL Serverless v2 // ---------------------------------------------------------------- const cluster = new rds.DatabaseCluster(this, 'AuroraCluster', { engine: rds.DatabaseClusterEngine.auroraPostgres({ version: rds.AuroraPostgresEngineVersion.VER_16_4, }), serverlessV2MinCapacity: 0.5, serverlessV2MaxCapacity: 4, writer: rds.ClusterInstance.serverlessV2('Writer'), readers: [ rds.ClusterInstance.serverlessV2('Reader', { scaleWithWriter: true, }), ], vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, securityGroups: [dbSg], defaultDatabaseName: 'appdb', credentials: rds.Credentials.fromGeneratedSecret('dbadmin'), backup: { retention: cdk.Duration.days(7), }, deletionProtection: true, removalPolicy: cdk.RemovalPolicy.RETAIN, cloudwatchLogsExports: ['postgresql'], cloudwatchLogsRetention: logs.RetentionDays.ONE_MONTH, storageEncrypted: true, }); // Secret の安全な取得(non-null assertion 禁止) const secret = cluster.secret; if (!secret) { throw new Error('Aurora cluster secret was not created'); } // ---------------------------------------------------------------- // 共通タグ // ---------------------------------------------------------------- cdk.Tags.of(this).add('Env', 'prod'); // ---------------------------------------------------------------- // Export // ---------------------------------------------------------------- this.dbCluster = cluster; this.dbSecurityGroup = dbSg; this.dbSecret = secret; } } (4)構成の比較(As-Is → To-Be) ガードレール前後でコード構成がどう変わったかを比較します。 As-Is:ガードレールなし(単一 Stack) CdkTestStack(1ファイルに全部入り) ├─ VPC(NAT×1, Endpoint なし) ├─ Aurora(DESTROY, 削除保護なし, バックアップ未設定) ├─ ECS Fargate │ ├─ ALB(HTTP のみ, publicLoadBalancer: true) │ ├─ Secret 全体を 1 変数で渡す │ └─ nginx:latest ├─ SG: allowDefaultPortFrom のみ └─ 監視なし, WAF なし To-Be:ガードレールあり(3 Stack 分離) NetworkStack └─ VPC(2AZ, NAT×2, VPC Endpoints 8種) ↓ vpc を props で渡す DbStack ├─ Aurora Serverless v2(RETAIN, 削除保護, backup 7日, 暗号化) ├─ DB SecurityGroup(outbound も制限) └─ Secret(fromGeneratedSecret → 存在チェック付きで取得) ↓ dbCluster / dbSecurityGroup / dbSecret を props で渡す AppStack ├─ ECS (Fargate) + ALB (HTTPS 443 + 80→443 redirect) ├─ WAFv2(IP allowlist, デフォルト Block) ├─ SG チェーン: ALB(:443) → ECS(:8080) → DB(:5432) ├─ Secrets は JSONキー単位で渡す(DB_USER / DB_PASSWORD) ├─ CloudWatch Logs(30日保持) └─ CloudWatch Alarms(ALB 5xx / ECS CPU / ECS Memory) 主な差分をまとめると以下です。 観点 As-Is To-Be Stack 分割 1 Stack に全部入り Network / Db / App の 3 分割 NAT Gateway 1 台(AZ 障害で Private 通信断) 2 台(AZ ごと) VPC Endpoint なし(全通信が NAT 経由) ECR / Logs / Secrets / SSM 等 8 種 Aurora 削除保護 DESTROY + 保護なし RETAIN + deletionProtection + backup 7日 HTTPS なし(HTTP 公開) ACM 証明書 + 443 終端 + 80→443 redirect WAF なし IP allowlist(デフォルト Block) SG 設計 allowDefaultPortFrom のみ 明示的に 3 SG を作成しチェーン接続 Secrets の渡し方 Secret 全体を 1 変数 username / password をキー単位で分離 コンテナタグ nginx:latest nginx:1.27-alpine (固定タグ) 監視 なし CloudWatch Logs + Alarms(5xx / CPU / Memory) 外部パラメータ ハードコード certificateArn / allowedCidrs をコンテキスト変数で注入 No 残っている課題 課題分類 優先度 詳細 1 コンテナ(nginx)と appPort=8080 、ヘルスチェックが不整合 可用性 / 運用 must nginx デフォルトは 80。現状の curl http://localhost:8080/ と ALB ターゲット(8080)が成立せず、 タスクがunhealthyになり続ける 。 appPort=80 にそろえるか、nginx の listen を 8080 に変更する。 2 LogGroup が RemovalPolicy.DESTROY 運用 / セキュリティ recommend prod 固定構成でログを DESTROY は事故時の調査・監査に弱い。 RETAIN 推奨 (retention 30日設定は良い)。 3 ALB SG の inbound が anyIpv4(WAF前提でも “ネットワーク境界” として緩い) セキュリティ recommend WAF allowlist で制御する方針はOKだが、SG が 0.0.0.0/0 だと WAF無効化・誤設定・関連付け漏れの際に即全開放になりやすい。 4 NAT×2 と Endpoint 多数が併存(コスト最適化方針が未決) コスト / 運用 recommend 目的に対して二重投資になりがち。 「NATを減らしてEndpointで寄せる」or「Endpointを絞ってNATで寄せる」 の方針を決めたい(ECR/Logs/Secrets/SSMは残す、など)。 5 CfnSecurityGroupIngress 採用理由(循環依存回避)が不透明 保守性 nits 必要性が明確でないと保守時に混乱しやすい。通常の dbSg.addIngressRule(ecsSg, ...) で成立するなら統一、成立しないなら なぜ循環するか をコメントで残す。 6 DB接続情報の Parameter 化が未実装(要件はあるがコード反映が薄い) 保守性 / 運用 nits 現状 DB_HOST/PORT/NAME が直書き寄り。要件通りなら SSM Parameter Store などで管理 し、環境差分・変更容易性を上げる。 7 ECS Exec の運用前提(IAM/監査/利用制御)が未定義 セキュリティ / 運用 nits enableExecuteCommand: true は良いが、 誰が・いつ・どう許可 するか(IAM条件、CloudTrail監査、手順)を設計に落とすと本番運用で揉めにくい。 8 オートスケール戦略が未定義(固定 desiredCount=2) 可用性 / コスト nits 最小構成としては可だが、本番前提なら CPU/Memory/ALB 指標で AutoScaling を検討したい(スパイク耐性・コスト最適化)。 9 ALB アクセスログ(S3)など監査ログが未実装 運用 / セキュリティ nits 監査や障害解析の観点で、要件次第では ALB access log を有効化したい(個人情報や保管ポリシー含めて要検討)。 10 ACM 証明書を参照のみで作成しておらず、更新ライフサイクルが IaC 管理外 運用 / セキュリティ recommend fromCertificateArn で既存証明書を参照しているだけで、証明書の発行・更新が CDK 管理外。外部 CA からのインポート証明書だった場合、ACM は自動更新しないため有効期限切れの運用事故リスクがある。 11 ドメイン・DNS(Route 53)が構成に含まれておらず、IaC 管理範囲が未定義 運用 / 設計 recommend Route 53 Hosted Zone、ALB への Alias レコード、ACM DNS 検証用 CNAME が未定義。ドメインが IaC の外にあるため証明書も CDK で作成できていない。どこまでを IaC で管理するかの方針決定が必要。 12 Aurora のバージョン管理方針が未定義、かつ本番で Serverless v2(minCapacity 0.5)の妥当性が未検討 可用性 / コスト recommend メジャーバージョンのアップグレード戦略(16→17等)が未定義。また Serverless v2 の minCapacity 0.5 ACU は低トラフィック時のコールドスタートやコスト予測の不安定さがあり、本番では Provisioned or minCapacity 引き上げの検討が要る。 (当然ですが)must, recommendが減りました。デプロイするアプリの特性に依存するものを除くと、プロダクションに持っていくには残課題として以下の修正が必要となりそうです。 ALB/App/DBそれぞれをどのサブネットに配置するかを明確に。 PRIVATE_WITH_EGRESS ではなく少なくともALBは PRIVATE_ISOLATED を利用する。 ALBがNAT付きサブネットに置く理由がない。 LogGroupは RemovalPolicy.DESTROY ではなく RETAIN としたい。 SGでもallowlistのCIDRに絞ってWAFと二重防御。 ACMをpropで受け取ることは明記していたものの、ACMを発行するスタックを明示的に指定していなかったことで証明書が正しく機能しないため、明示的にACMを作成するスタックを作成。 くらいでしょうか。 総評 さて、今回出てきたコードの評価と、どういったスキルの人が使いこなせるか、といった観点でまとめます。 まずはインフラエンジニアについて以下のようにレベルを定義します。 ジュニアレベル “動く構成” を素早く組める(VPC/ECS/RDSをつなぐ、疎通させる) ただし 本番の安全要件(削除耐性・HTTPS・監査/運用・境界設計)をデフォルトで落としがち 生成物は「PoC/デモ品質」になりやすい ミドルレベル 本番のガードレール(RETAIN、DeletionProtection、HTTPS、SG分離、ログ/監視、VPC Endpoint 等) を意識して設計に落とせる ただし細部(ポート/ヘルスチェック整合、サブネットの置き方、WAF/SGの多層防御、運用フロー)で穴が残りやすい 「レビュー前提で本番候補」まで持っていける シニアレベル 設計意図・運用・変更耐性(将来の要件変更/誤変更/監査対応)まで含めて、壊れにくいCDKにできる トレードオフ(可用性/コスト/セキュリティ)を前提から言語化し、CDKへ反映できる “動く”だけでなく 事故らない/継続運用できる をデフォルトにできる 最初に吐いたコードはどのレベルか? ジュニアレベルです。PoCの品質としても危ういレベル。象徴的な理由としては、 DBが RemovalPolicy.DESTROY HTTPS通信がないかつパブリック全開放 Secretsの使い方が雑 NAT、可用性、ログ監視等が未定義 ガードレール後に出てきたコードはどのレベルか? ミドルレベルであり、レビュー前提で本番候補。 良い点としては、 Aurora:RETAIN + deletionProtection + backup + logs export 3スタック分割(Network/Db/App)で保守性が上がった HTTPS終端、80→443 redirect VPC Endpoints/SSM系も入り、運用導線(ECS Exec)が成立しやすい WAF allowlist を実装(入口制御をコード化) レビューでシニアレベルの人に弾いていただく必要がある点としては、まだ課題があります。 nginxなのに appPort=8080、ヘルスチェックの不整合 LogGroupがDESTROY(本番環境としては弱い) public ALB + SG anyIpv4 で “WAF依存が強い”(多層防御が薄い) NAT×2 + Endpoint大量の“方針”が曖昧(コスト設計が残る) どのレベルであれば使いこなせるか? 結論として、CDKを読めるシニアまたはミドルでも上位クラスの人が設計者兼レビュアとして使うと、本番環境に持っていけるかなという感触です。 アプリケーション同様に、細部を理解できる人であればIaCコードをClaude Codeで構築することは可能になってきたなと感じました。 工夫するとしたら? Security HubやAWS Config、またprowlerなどのセキュリティ・コンプライアンスチェックの仕組みを利用することでフィードバックループを回す AWS公式のMCP経由で最新ドキュメントを参照させる といったことを加味することで、もう少し実用的なシステムに仕上げることができるでしょう。 最後に 私のようなインフラ経験がない人間でも、このレベルの構成に持っていけるのは素直に感動しました。 もちろん日常から常にAWS環境を触っている人からしたら「当然じゃん」もしくは「穴だらけ」と思われるかもしれませんが、一番感動したのは「勉強速度が上がった」点です。今回の構成に限った話であれば、ものの数時間でインフラジュニアエンジニアと名乗れるくらいにはなったかなと。 これはAIが出始めてアプリケーション開発にも言われていることですが、「理解負債」を完済し続けることでAIと共存しながら成長し続けられるのはインフラについても同じだと実感しました。今後は積極的にIaCにもClaude Codeを利用していきたいと思います。 私たちは一緒に働いてくれる仲間を募集しています! 電通総研 キャリア採用サイト 電通総研 新卒採用サイト 執筆: @kamino.shunichiro レビュー: @ozaki.hisanori ( Shodo で執筆されました )