🍣

CDKでlaravel12をECS Fargateで動作させる

に公開

https://zenn.dev/catatsumuri/articles/7d5d5f77d3268d

前回まででECRにイメージが作成できているかなと思うんだけど、ある程度問題ありありのイメージである。

  • SQLite(migrate済)が含まれている
  • .envを内包している
  • ローカルストレージの事を何も考えてない(SQLiteにも通じる)
  • しかし動作はする

というとんでもないイメージであるが、ただし「動く」はず。動くならこれをベースにCDKでとりあえず動作させてみようということだ。動作させてみないと本当に動くのかどうなのか全くわからんからね。

ECRリポジトリがキマってる場合は一度削除しておこう。これもCDKで作りたいので。


my-php-fpmを削除。同様にmy-nginxも削除しておく

Bootstrap済みCDK環境を手に入れる

まあこれはAdministratorAccess権限のcloudshellでcdk bootstrapするだけ

cdk initする

ここからいよいよ作成フェーズだ。通常typescriptが使われると思うのでそのようにする。

mkdir laravel-fargate-demo
cd laravel-fargate-demo

cdk init app --language=typescript

最初のstackは消していいんじゃないかな。

git rm --force lib/laravel-fargate-demo-stack.ts

generate only的なオプションを付けないと自動的にgit管理になるので、git rmしてるけどrmでもよい。なおディレクトリが空になるとgitはディレクトリも消してしまう。まあいいよねこの辺は。

vpc作る

lib/vpc-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';

export class VpcStack extends cdk.Stack {
  public readonly vpc: ec2.Vpc;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    this.vpc = new ec2.Vpc(this, 'LaravelDemoVpc', {
      maxAzs: 2, // ap-northeast-1a / 1c など
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: 'PublicSubnet',
          subnetType: ec2.SubnetType.PUBLIC,
        },
      ],
      natGateways: 0, // 最小構成:NAT不要
    });

    new cdk.CfnOutput(this, 'VpcId', {
      value: this.vpc.vpcId,
    });
  }
}

ここでbin/laravel-fargate-demo.tsがエントリポイントになる。ここで最初にあるエントリーだが

new LaravelFargateDemoStack(app, 'LaravelFargateDemoStack', {
  /* If you don't specify 'env', this stack will be environment-agnostic.
   * Account/Region-dependent features and context lookups will not work,
   * but a single synthesized template can be deployed anywhere. */

  /* Uncomment the next line to specialize this stack for the AWS Account
   * and Region that are implied by the current CLI configuration. */
  // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION },

  /* Uncomment the next line if you know exactly what Account and Region you
   * want to deploy the stack to. */
  // env: { account: '123456789012', region: 'us-east-1' },

  /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */
});

これは削除してよいエントリーなのだが、ここでenvとかのpropsを渡さないとコンテキストルックアップが使えなくなったりして微妙なんで、ここでは渡しておく。コンテキストルックアップはデフォルトVPCを使う場合なんかでよく出てくるけど、ここではSSLのドメインを参照するときとかに使うので、一応envをちょいちょい渡していくことにしよう。

というわけで以下のようにする。

bin/laravel-fargate-demo.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { VpcStack } from '../lib/vpc-stack';

const app = new cdk.App();
const env = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
};

const vpcStack = new VpcStack(app, 'DemoVpcStack', { env });

そしたら

cdk diff

とかして

Resources
[+] AWS::EC2::VPC LaravelDemoVpc LaravelDemoVpc6FD17F1E
[+] AWS::EC2::Subnet LaravelDemoVpc/PublicSubnetSubnet1/Subnet LaravelDemoVpcPublicSubnetSubnet1SubnetCE8D5AEC
[+] AWS::EC2::RouteTable LaravelDemoVpc/PublicSubnetSubnet1/RouteTable LaravelDemoVpcPublicSubnetSubnet1RouteTable0F0614E9
[+] AWS::EC2::SubnetRouteTableAssociation LaravelDemoVpc/PublicSubnetSubnet1/RouteTableAssociation LaravelDemoVpcPublicSubnetSubnet1RouteTableAssociation8DB612CD
[+] AWS::EC2::Route LaravelDemoVpc/PublicSubnetSubnet1/DefaultRoute LaravelDemoVpcPublicSubnetSubnet1DefaultRoute472BB980
[+] AWS::EC2::Subnet LaravelDemoVpc/PublicSubnetSubnet2/Subnet LaravelDemoVpcPublicSubnetSubnet2Subnet1D476FC2
[+] AWS::EC2::RouteTable LaravelDemoVpc/PublicSubnetSubnet2/RouteTable LaravelDemoVpcPublicSubnetSubnet2RouteTable5932B141
[+] AWS::EC2::SubnetRouteTableAssociation LaravelDemoVpc/PublicSubnetSubnet2/RouteTableAssociation LaravelDemoVpcPublicSubnetSubnet2RouteTableAssociationF4F57D3C
[+] AWS::EC2::Route LaravelDemoVpc/PublicSubnetSubnet2/DefaultRoute LaravelDemoVpcPublicSubnetSubnet2DefaultRoute961B1048
[+] AWS::EC2::InternetGateway LaravelDemoVpc/IGW LaravelDemoVpcIGW0101CC45
[+] AWS::EC2::VPCGatewayAttachment LaravelDemoVpc/VPCGW LaravelDemoVpcVPCGWBCCF278A
[+] Custom::VpcRestrictDefaultSG LaravelDemoVpc/RestrictDefaultSecurityGroupCustomResource LaravelDemoVpcRestrictDefaultSecurityGroupCustomResource8E88B0C0
[+] AWS::IAM::Role Custom::VpcRestrictDefaultSGCustomResourceProvider/Role CustomVpcRestrictDefaultSGCustomResourceProviderRole26592FE0
[+] AWS::Lambda::Function Custom::VpcRestrictDefaultSGCustomResourceProvider/Handler CustomVpcRestrictDefaultSGCustomResourceProviderHandlerDC833E5E

Outputs
[+] Output VpcId VpcId: {"Value":{"Ref":"LaravelDemoVpc6FD17F1E"}}

できるものを確認したら

cdk deploy


deployの確認画面

これでVPCが一本できる


VPCが作成された。リソースマップも一応確認しておくこと

ECR

冒頭でECRリポジトリーは削除してしまったので、ここでCDKを用いてECRリポジトリーを作成してみよう。以下のようにしてmyapp-nginxmyapp-php-fpmリポジトリーの作成を行うスクリプトを起こす。

lib/ecr-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecr from 'aws-cdk-lib/aws-ecr';

export class EcrStack extends cdk.Stack {
  public readonly nginxRepo: ecr.Repository;
  public readonly phpFpmRepo: ecr.Repository;

  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // myapp-nginx リポジトリ
    this.nginxRepo = new ecr.Repository(this, 'MyAppNginxRepo', {
      repositoryName: 'myapp-nginx',
      imageScanOnPush: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    // myapp-php-fpm リポジトリ
    this.phpFpmRepo = new ecr.Repository(this, 'MyAppPhpFpmRepo', {
      repositoryName: 'myapp-php-fpm',
      imageScanOnPush: true,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    new cdk.CfnOutput(this, 'NginxRepoUri', {
      value: this.nginxRepo.repositoryUri,
    });

    new cdk.CfnOutput(this, 'PhpFpmRepoUri', {
      value: this.phpFpmRepo.repositoryUri,
    });
  }
}

さらにエントリポイントを更新してこれを読むように

bin/laravel-fargate-demo.ts
@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 import * as cdk from 'aws-cdk-lib';
 import { VpcStack } from '../lib/vpc-stack';
+import { EcrStack } from '../lib/ecr-stack';
 
 const app = new cdk.App();
 const env = {
@@ -9,3 +10,4 @@ const env = {
 };
 
 const vpcStack = new VpcStack(app, 'DemoVpcStack', { env });
+const ecrStack = new EcrStack(app, 'DemoEcrStack', { env });

その後cdk diff

Resources
[+] AWS::ECR::Repository MyAppNginxRepo MyAppNginxRepoDAA7AD17
[+] AWS::ECR::Repository MyAppPhpFpmRepo MyAppPhpFpmRepo398BB7C4

のように作成リソースを確認したら

cdk deploy DemoEcrStack

するとリポジトリーができる


リポジトリーができた

RETAINについての解説と再生成について

ここで

removalPolicy: cdk.RemovalPolicy.RETAIN,

とかの記述が見られるがretainとは日本語で「保持」という意味で、ここからわかるように削除したとき残すという指示になる。

たとえば

laravel-fargate-demo $ cdk destroy DemoEcrStack
Are you sure you want to delete: DemoEcrStack (y/n)? y
DemoEcrStack: destroying... [1/1]

 ✅  DemoEcrStack: destroyed

こんな感じでdestroyしても残っている。awscliで確認した例

aws ecr describe-repositories
...
        {
            "repositoryArn": "arn:aws:ecr:ap-northeast-1:****:repository/myapp-nginx",
...

さらにこの状態で再度デプロイすると

cdk deploy DemoEcrStack
moEcrStack failed: _ToolkitError: The stack named DemoEcrStack failed creation, it may need to be manually deleted from the AWS console:

こんな感じになるので、手動で消すか、あるいは既存のものを使う場合は、そもそもCDKで作成するのではなく

    const nginxRepo = ecr.Repository.fromRepositoryName(this, 'NginxRepo', 'myapp-nginx');
    const phpFpmRepo = ecr.Repository.fromRepositoryName(this, 'PhpFpmRepo', 'myapp-php-fpm');

こういう形で整える必要がある。手動作成あるいは共通インフラを別stackに出すなどしてここでのStackはfromRepositoryNameで頑張るというのがむしろいいかも。ここではCDKで作成してみたけど、その辺は運用考えてみてください。

序でだからECRプッシュのユーザーも作ろう

というわけでRETAINが入ってるので既にdeployしてしまった場合は一度webから消してやりなおすとして、このリポジトリを触れるIAMユーザーを作成しキーを出す。CI/CDする場合はOICDも考えてくださいねってことなんだけどまあそれはいいわ。

https://zenn.dev/catatsumuri/articles/43101331bfdbb6
(参考)

lib/ecr-stack.ts
lib/ecr-stack.ts
@@ -1,6 +1,7 @@
 import * as cdk from 'aws-cdk-lib';
 import { Construct } from 'constructs';
 import * as ecr from 'aws-cdk-lib/aws-ecr';
+import * as iam from 'aws-cdk-lib/aws-iam';
 
 export class EcrStack extends cdk.Stack {
   public readonly nginxRepo: ecr.Repository;
@@ -30,5 +31,48 @@ export class EcrStack extends cdk.Stack {
     new cdk.CfnOutput(this, 'PhpFpmRepoUri', {
       value: this.phpFpmRepo.repositoryUri,
     });
+
+    // IAMユーザー作成
+    const ecrUser = new iam.User(this, 'EcrPushUser', {
+      userName: 'ecr-push-user',
+    });
+
+    // ポリシーアタッチ(両リポジトリへのPush権限)
+    const ecrPolicy = new iam.Policy(this, 'EcrPushPolicy', {
+      statements: [
+        new iam.PolicyStatement({
+          actions: ['ecr:GetAuthorizationToken'],
+          resources: ['*'], 
+        }),
+        new iam.PolicyStatement({
+          actions: [
+            'ecr:BatchCheckLayerAvailability',
+            'ecr:PutImage',
+            'ecr:InitiateLayerUpload',
+            'ecr:UploadLayerPart',
+            'ecr:CompleteLayerUpload',
+          ],
+          resources: [
+            this.nginxRepo.repositoryArn,
+            this.phpFpmRepo.repositoryArn,
+          ],
+        }),
+      ],
+    });
+    ecrPolicy.attachToUser(ecrUser);
+
+    // アクセスキー作成
+    const accessKey = new iam.CfnAccessKey(this, 'EcrPushUserAccessKey', {
+      userName: ecrUser.userName,
+    });
+
+    new cdk.CfnOutput(this, 'EcrPushAccessKeyId', {
+      value: accessKey.ref,
+    });
+
+    new cdk.CfnOutput(this, 'EcrPushSecretAccessKey', {
+      value: accessKey.attrSecretAccessKey,
+      description: '!! Copy this immediately. It will not be shown again.',
+    });
   }
 }

cdk diffすると

Resources
[+] AWS::IAM::User EcrPushUser EcrPushUserA1CB08D3
[+] AWS::IAM::Policy EcrPushPolicy EcrPushPolicy42DA89BF
[+] AWS::IAM::AccessKey EcrPushUserAccessKey EcrPushUserAccessKey

Outputs
[+] Output EcrPushAccessKeyId EcrPushAccessKeyId: {"Value":{"Ref":"EcrPushUserAccessKey"}}
[+] Output EcrPushSecretAccessKey EcrPushSecretAccessKey: {"Description":"!! Copy this immediately. It will not be shown again.","Value":{"Fn::GetAtt":["EcrPushUserAccessKey","SecretAccessKey"]}}

こんな感じになるんで、deployしといてください

cdk deploy DemoEcrStack

こんな感じで鍵が取れる


鍵の出力、本来はここでもprintしない方がいいかもしれないが

docker imageのpush

ここは冒頭で述べたように割愛

https://zenn.dev/catatsumuri/articles/7d5d5f77d3268d

(再掲)

ECSクラスターとタスク定義の作成

ではやっていく、ここは1つのスタックにつめこむ事にする。もし1クラスターに複数のサービスを与える構想なら1つのスタックにしない方がいいかも。ここではlib/ecs-stack.tsとする

lib/ecs-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as ecr from 'aws-cdk-lib/aws-ecr';

interface EcsStackProps extends cdk.StackProps {
  vpc: ec2.Vpc;
}

export class EcsStack extends cdk.Stack {
  public readonly cluster: ecs.Cluster;
  public readonly taskDefinition: ecs.FargateTaskDefinition;

  constructor(scope: Construct, id: string, props: EcsStackProps) {
    super(scope, id, props);

    // ECSクラスター作成
    this.cluster = new ecs.Cluster(this, 'LaravelDemoCluster', {
      vpc: props.vpc,
      containerInsights: true,
    });

    // Fargateタスク定義
    this.taskDefinition = new ecs.FargateTaskDefinition(this, 'LaravelFargateTaskDef', {
      cpu: 256,
      memoryLimitMiB: 512,
      runtimePlatform: {
        cpuArchitecture: ecs.CpuArchitecture.ARM64, // !!!!!!!!!!!!!!    ARM64指定
        operatingSystemFamily: ecs.OperatingSystemFamily.LINUX,
      },
    });

    // IAMロール(タスク実行用)に必要なポリシーはCDKが自動付与

    // ロググループ(CloudWatch)
    const logGroup = new logs.LogGroup(this, 'LaravelLogGroup', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });

    // Nginxコンテナ
    this.taskDefinition.addContainer('NginxContainer', {
      image: ecs.ContainerImage.fromEcrRepository(
        ecr.Repository.fromRepositoryName(this, 'NginxRepo', 'myapp-nginx')
      ),
      containerName: 'nginx',
      portMappings: [{ containerPort: 80 }],
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'nginx',
        logGroup,
      }),
    });

    // PHP-FPMコンテナ
    this.taskDefinition.addContainer('PhpFpmContainer', {
      image: ecs.ContainerImage.fromEcrRepository(
        ecr.Repository.fromRepositoryName(this, 'PhpFpmRepo', 'myapp-php-fpm')
      ),
      containerName: 'php-fpm',
      portMappings: [{ containerPort: 9000 }],
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'php-fpm',
        logGroup,
      }),
    });

    new cdk.CfnOutput(this, 'ClusterName', {
      value: this.cluster.clusterName,
    });

    new cdk.CfnOutput(this, 'TaskDefinitionArn', {
      value: this.taskDefinition.taskDefinitionArn,
    });
  }
}

cpu/memory共に最底辺を指定

さらにエントリーポイントを更新する

bin/laravel-fargate-demo.ts
@@ -1,6 +1,8 @@
 #!/usr/bin/env node
 import * as cdk from 'aws-cdk-lib';
 import { VpcStack } from '../lib/vpc-stack';
+import { EcrStack } from '../lib/ecr-stack';
+import { EcsStack } from '../lib/ecs-stack';
 
 const app = new cdk.App();
 const env = {
@@ -9,3 +11,8 @@ const env = {
 };
 
 const vpcStack = new VpcStack(app, 'DemoVpcStack', { env });
+const ecrStack = new EcrStack(app, 'DemoEcrStack', { env });
+const ecsStack = new EcsStack(app, 'DemoEcsStack', {
+  env,
+  vpc: vpcStack.vpc,
+});
cdk diff

すると

Resources
[+] AWS::ECS::Cluster LaravelDemoCluster LaravelDemoCluster39A13F47
[+] AWS::IAM::Role LaravelFargateTaskDef/TaskRole LaravelFargateTaskDefTaskRole6DDE0619
[+] AWS::ECS::TaskDefinition LaravelFargateTaskDef LaravelFargateTaskDef9C57D66D
[+] AWS::IAM::Role LaravelFargateTaskDef/ExecutionRole LaravelFargateTaskDefExecutionRole5B112D0A
[+] AWS::IAM::Policy LaravelFargateTaskDef/ExecutionRole/DefaultPolicy LaravelFargateTaskDefExecutionRoleDefaultPolicy3E6EE2B2
[+] AWS::Logs::LogGroup LaravelLogGroup LaravelLogGroupC512EA17

Outputs
[+] Output ClusterName ClusterName: {"Value":{"Ref":"LaravelDemoCluster39A13F47"}}
[+] Output TaskDefinitionArn TaskDefinitionArn: {"Value":{"Ref":"LaravelFargateTaskDef9C57D66D"}}
cdk deploy DemoEcsStack

した後作成されたリソースをwebで確認。https://ap-northeast-1.console.aws.amazon.com/ecs/v2/clusters?region=ap-northeast-1 より


クラスターが出来ている

さらに「タスク定義」に移動する


ARM64でタスク定義ができている

サービス作る

あとはこのタスクを動かし続けるサービスを作れば基本的にはlaravelトップページの御尊顔を拝めるはずだ。

lib/ecs-stack.ts
@@ -66,6 +66,26 @@ export class EcsStack extends cdk.Stack {
       }),
     });
 
+    // SecurityGroup 
+    const serviceSecurityGroup = new ec2.SecurityGroup(this, 'ServiceSG', {
+      vpc: props.vpc,
+      allowAllOutbound: true,
+      description: 'Security group for Fargate service',
+    });
+    serviceSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP'); // ポート80フルオープン
+
+    // Fargate サービス
+    const fargateService = new ecs.FargateService(this, 'LaravelFargateService', {
+      cluster: this.cluster,
+      taskDefinition: this.taskDefinition,
+      desiredCount: 1,
+      assignPublicIp: true,
+      securityGroups: [serviceSecurityGroup],
+      vpcSubnets: {
+        subnetType: ec2.SubnetType.PUBLIC,
+      },
+    });
+
     new cdk.CfnOutput(this, 'ClusterName', {
       value: this.cluster.clusterName,
     });
@@ -73,5 +93,9 @@ export class EcsStack extends cdk.Stack {
     new cdk.CfnOutput(this, 'TaskDefinitionArn', {
       value: this.taskDefinition.taskDefinitionArn,
     });
+
+    new cdk.CfnOutput(this, 'ServiceName', {
+      value: fargateService.serviceName,
+    });
   }
 }
cdk deploy DemoEcsStack

deployすると「結構時間かかった後」サービスが作成され、タスクが起動してくるはず(うまくいけば)。うまくいかなければcloudwatchとかのログとかみてください。


タスクが起動した


タスクのネットワーキングのタブからパブリックIPを得る

この辺からネットワークを確認してアクセスするとlaravelの画面が出る


laravel12のstarter kit画面

起動確認としては一応ログイン成功まで確認しとくこと

ログインできている

ALBにする

まさか個別タスクのIPを指定してアクセスさせるわけにはいかんのでALBで包む。ALBは結構高いんでテストで構築するのはまだしも、そのまま運用せずほったらかしにするとかなり痛いから注意が必要

ALBの設定においてはCDK の高レベル construct を使い、ALB + TargetGroup + Listener + SecurityGroup を全部自動生成するのがよい。字面はむずいがライブラリーを置き換えるコードをはめこむだけなのでdiffでみれば割とsimpleではないだろうか。

lib/ecs-stack.ts
@@ -5,6 +5,7 @@ import * as ec2 from 'aws-cdk-lib/aws-ec2';
 import * as iam from 'aws-cdk-lib/aws-iam';
 import * as logs from 'aws-cdk-lib/aws-logs';
 import * as ecr from 'aws-cdk-lib/aws-ecr';
+import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns';
 
 interface EcsStackProps extends cdk.StackProps {
   vpc: ec2.Vpc;
@@ -75,15 +76,16 @@ export class EcsStack extends cdk.Stack {
     serviceSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP');
 
     // Fargate サービス
-    const fargateService = new ecs.FargateService(this, 'LaravelFargateService', {
+    const albFargateService = new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'LaravelAlbFargateService', {
       cluster: this.cluster,
       taskDefinition: this.taskDefinition,
       desiredCount: 1,
       assignPublicIp: true,
-      securityGroups: [serviceSecurityGroup],
-      vpcSubnets: {
-        subnetType: ec2.SubnetType.PUBLIC,
-      },
+      publicLoadBalancer: true,
+      listenerPort: 80,
+    });
+    new cdk.CfnOutput(this, 'AlbDnsName', {
+      value: albFargateService.loadBalancer.loadBalancerDnsName,
     });
 
     new cdk.CfnOutput(this, 'ClusterName', {
@@ -95,7 +97,7 @@ export class EcsStack extends cdk.Stack {
     });
 
     new cdk.CfnOutput(this, 'ServiceName', {
-      value: fargateService.serviceName,
+      value: albFargateService.service.serviceName,
     });
   }
 }

これで

cdk diff

にて

Resources
[-] AWS::ECS::Service LaravelFargateService/Service LaravelFargateService6560B798 destroy
[+] AWS::ElasticLoadBalancingV2::LoadBalancer LaravelAlbFargateService/LB LaravelAlbFargateServiceLBB23286CC
[+] AWS::EC2::SecurityGroup LaravelAlbFargateService/LB/SecurityGroup LaravelAlbFargateServiceLBSecurityGroup85184954
[+] AWS::EC2::SecurityGroupEgress LaravelAlbFargateService/LB/SecurityGroup/to DemoEcsStackLaravelAlbFargateServiceSecurityGroup81C68626:80 LaravelAlbFargateServiceLBSecurityGrouptoDemoEcsStackLaravelAlbFargateServiceSecurityGroup81C6862680C3164CDA
[+] AWS::ElasticLoadBalancingV2::Listener LaravelAlbFargateService/LB/PublicListener LaravelAlbFargateServiceLBPublicListener0DD9EEB6
[+] AWS::ElasticLoadBalancingV2::TargetGroup LaravelAlbFargateService/LB/PublicListener/ECSGroup LaravelAlbFargateServiceLBPublicListenerECSGroup99A93CC7
[+] AWS::ECS::Service LaravelAlbFargateService/Service/Service LaravelAlbFargateServiceA0372A8E
[+] AWS::EC2::SecurityGroup LaravelAlbFargateService/Service/SecurityGroup LaravelAlbFargateServiceSecurityGroup031E26FB
[+] AWS::EC2::SecurityGroupIngress LaravelAlbFargateService/Service/SecurityGroup/from DemoEcsStackLaravelAlbFargateServiceLBSecurityGroup96CD2788:80 LaravelAlbFargateServiceSecurityGroupfromDemoEcsStackLaravelAlbFargateServiceLBSecurityGroup96CD278880C6FB95ED

Outputs
[+] Output LaravelAlbFargateService/LoadBalancerDNS LaravelAlbFargateServiceLoadBalancerDNSCC803231: {"Value":{"Fn::GetAtt":["LaravelAlbFargateServiceLBB23286CC","DNSName"]}}
[+] Output LaravelAlbFargateService/ServiceURL LaravelAlbFargateServiceServiceURL54CACF1E: {"Value":{"Fn::Join":["",["http://",{"Fn::GetAtt":["LaravelAlbFargateServiceLBB23286CC","DNSName"]}]]}}
[+] Output AlbDnsName AlbDnsName: {"Value":{"Fn::GetAtt":["LaravelAlbFargateServiceLBB23286CC","DNSName"]}}
[~] Output ServiceName ServiceName: {"Value":{"Fn::GetAtt":["LaravelFargateService6560B798","Name"]}} to {"Value":{"Fn::GetAtt":["LaravelAlbFargateServiceA0372A8E","Name"]}}

diffを確認したら

cdk deploy DemoEcsStack

やってることがやってることだけに非常にapplyが長くなるが、ちょっと待ってると


AlbDnsNameが出力されている

こんな感じでAlbDnsNameが出てくるので、これにシンプルにアクセスすると...


ALB経由でトップページにアクセス

こうなるわけだ。まあこれは


サービスの詳細 → ロードバランサーの状態のところにあるこれを押して


ロードバランサーの設定からDNS名を確認する方法

この手法でも取れます

SSL

ここまでで9割終わってんだけどSSL proxyしたときちゃんとlaravelの画面出ねえってのが割とあるんで、やっときましょう。これは基本的にイメージビルドの状態でちゃんと設定しておかないといけなくて

参考
https://zenn.dev/catatsumuri/articles/79505e2c2a0907

この設定しておかないとまずうまくいかない。起動してまっしろになるとかある。

ドメインを用意

SSLの構築にはこれはドメインが必要。ここではtest.catatsumuri.orgってのを持ってきた。この辺は各自なんとか調達してroute53のパブリックホストゾーンに放りこんでください。route53に放りこめないって?そりゃ調査するしかねえな...


パブリックホストゾーンに当該ドメインをセット

lib/ecs-stack.ts
@@ -6,6 +6,9 @@ import * as iam from 'aws-cdk-lib/aws-iam';
 import * as logs from 'aws-cdk-lib/aws-logs';
 import * as ecr from 'aws-cdk-lib/aws-ecr';
 import * as ecs_patterns from 'aws-cdk-lib/aws-ecs-patterns';
+import * as route53 from 'aws-cdk-lib/aws-route53';
+import * as targets from 'aws-cdk-lib/aws-route53-targets';
+import * as acm from 'aws-cdk-lib/aws-certificatemanager';
 
 interface EcsStackProps extends cdk.StackProps {
   vpc: ec2.Vpc;
@@ -75,6 +78,16 @@ export class EcsStack extends cdk.Stack {
     });
     serviceSecurityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP');
 
+    const hostedZone = route53.HostedZone.fromLookup(this, 'HostedZone', {
+      domainName: 'test.catatsumuri.org',
+    });
+
+    const certificate = new acm.DnsValidatedCertificate(this, 'LaravelCert', {
+      domainName: 'laravel.test.catatsumuri.org',
+      hostedZone,
+      region: 'ap-northeast-1', 
+    });
+
     // Fargate サービス
     const albFargateService = new ecs_patterns.ApplicationLoadBalancedFargateService(this, 'LaravelAlbFargateService', {
       cluster: this.cluster,
@@ -82,7 +95,10 @@ export class EcsStack extends cdk.Stack {
       desiredCount: 1,
       assignPublicIp: true,
       publicLoadBalancer: true,
-      listenerPort: 80,
+      domainName: 'laravel.test.catatsumuri.org',
+      domainZone: hostedZone,
+      certificate,
+      listenerPort: 443,
     });
     new cdk.CfnOutput(this, 'AlbDnsName', {
       value: albFargateService.loadBalancer.loadBalancerDnsName,

そしたら

cdk diff

すると

Resources
[+] AWS::IAM::Role LaravelCert/CertificateRequestorFunction/ServiceRole LaravelCertCertificateRequestorFunctionServiceRoleB39F39E4
[+] AWS::IAM::Policy LaravelCert/CertificateRequestorFunction/ServiceRole/DefaultPolicy LaravelCertCertificateRequestorFunctionServiceRoleDefaultPolicy96CF41EA
[+] AWS::Lambda::Function LaravelCert/CertificateRequestorFunction LaravelCertCertificateRequestorFunction6AEED3FA
[+] AWS::Logs::LogGroup LaravelCert/CertificateRequestorFunction/LogGroup LaravelCertCertificateRequestorFunctionLogGroupDF0C1241
[+] AWS::CloudFormation::CustomResource LaravelCert/CertificateRequestorResource LaravelCertCertificateRequestorResource944F34FF
[+] AWS::Route53::RecordSet LaravelAlbFargateService/DNS LaravelAlbFargateServiceDNSB00FE099
[~] AWS::EC2::SecurityGroup LaravelAlbFargateService/LB/SecurityGroup LaravelAlbFargateServiceLBSecurityGroup85184954
 └─ [~] SecurityGroupIngress
     └─ @@ -1,9 +1,9 @@
        [ ] [
        [ ]   {
        [ ]     "CidrIp": "0.0.0.0/0",
        [-]     "Description": "Allow from anyone on port 80",
        [-]     "FromPort": 80,
        [+]     "Description": "Allow from anyone on port 443",
        [+]     "FromPort": 443,
        [ ]     "IpProtocol": "tcp",
        [-]     "ToPort": 80
        [+]     "ToPort": 443
        [ ]   }
        [ ] ]
[~] AWS::ElasticLoadBalancingV2::Listener LaravelAlbFargateService/LB/PublicListener LaravelAlbFargateServiceLBPublicListener0DD9EEB6
 ├─ [+] Certificates
 │   └─ [{"CertificateArn":{"Fn::GetAtt":["LaravelCertCertificateRequestorResource944F34FF","Arn"]}}]
 ├─ [~] Port
 │   ├─ [-] 80
 │   └─ [+] 443
 └─ [~] Protocol
     ├─ [-] HTTP
     └─ [+] HTTPS

みたいにhttpsに切り変わる風味になる。route53でゾーンを管理してればあとは証明書の発行から適当にやってくれる


証明書がセットされSSLが有効となった

ここまでで完成。

壊す

壊すテストは必ずすること。特にテストで作って起動しっぱなしにしてると問答無用で課金(先述したけど、この構成では特にALBがきつい)

cdk destroy --all

削除中はcloudformationのスタック一覧見ておくと精神的に楽かも


消されている状況がより明確にわかる気がする

再構築

cdk deploy --all

でやっぱりECRリポジトリーがretainされてて辛いって場合

先述したようにECRがある状態でCREATEするとエラーになるんで


Already Exists的なエラー

@@ -4,26 +4,15 @@ import * as ecr from 'aws-cdk-lib/aws-ecr';
 import * as iam from 'aws-cdk-lib/aws-iam';
 
 export class EcrStack extends cdk.Stack {
-  public readonly nginxRepo: ecr.Repository;
-  public readonly phpFpmRepo: ecr.Repository;
+  public readonly nginxRepo: ecr.IRepository;
+  public readonly phpFpmRepo: ecr.IRepository;
 
   constructor(scope: Construct, id: string, props?: cdk.StackProps) {
     super(scope, id, props);
 
-    // myapp-nginx リポジトリ
-    this.nginxRepo = new ecr.Repository(this, 'MyAppNginxRepo', {
-      repositoryName: 'myapp-nginx',
-      imageScanOnPush: true,
-      removalPolicy: cdk.RemovalPolicy.RETAIN,
-    });
-
-    // myapp-php-fpm リポジトリ
-    this.phpFpmRepo = new ecr.Repository(this, 'MyAppPhpFpmRepo', {
-      repositoryName: 'myapp-php-fpm',
-      imageScanOnPush: true,
-      removalPolicy: cdk.RemovalPolicy.RETAIN,
-    });
-
+    // 既存のリポジトリを参照
+    this.nginxRepo = ecr.Repository.fromRepositoryName(this, 'MyAppNginxRepo', 'myapp-nginx');
+    this.phpFpmRepo = ecr.Repository.fromRepositoryName(this, 'MyAppPhpFpmRepo', 'myapp-php-fpm');

こんな感じで

IAMも消したくない場合もあるかも

先にIAMユーザーを何かしら作っておいて(ここではecr-push-user)

const ecrUser = iam.User.fromUserName(this, 'ExistingEcrPushUser', 'ecr-push-user');

とかで参照するようにする、とか。

必要な知識がこの程度でも割と多岐に渡る

項目 説明
cdk init app --language=typescript CDKアプリケーションの初期化
Stack分割(VpcStack, EcrStack, EcsStack) スタックごとの責務分離
RemovalPolicy.RETAIN リソース削除と再デプロイの挙動理解
fromRepositoryName で既存ECRを参照 手動管理リソースのCDK参照方法
ApplicationLoadBalancedFargateService ECS + ALBの高レベルConstruct
Fargate タスク定義 (cpu, memory) 起動条件の最低指定
タスク → サービス → クラスター ECSのリソース構造
DockerイメージのECR push手順 aws cli + docker login の基本操作
IAMユーザーへのPush権限付与 ユーザーへの適切な権限管理
php-fpm + nginx の2コンテナ構成 イメージ分割と通信連携の想定
Laravel Starter Kit が動く程度の構成 DBアクセス/HTTPSを要求しない構成に制限
.env、SQLite込み 運用には程遠いがPoC的な意味ではまぁ...
ACM証明書の DnsValidatedCertificate 自動でACM発行 & Route53レコード生成
domainZone, domainName CDKによるALB+HTTPS自動化構成の設定値
Public Subnet / NAT不要構成 検証環境としての最小構成選択の意図
allowAllOutbound SG ECSサービスからの外部アクセス許可の必要性
cdk destroy --all の重要性 放置によるコスト爆発の防止
ALBの料金構造に対する警戒 少人数検証でも本番並みに金がかかる現実
CloudFormation Stackの確認 削除状況把握とデプロイトラブル回避に必須
cdk diff を使った差分確認 破壊的変更の事前検知の基本

そりゃ記事も長くなるわ。

ただし将来的に上流を任せられそうであればコード開発を血眼になってやるよりこの手のインフラの技術検証を空き時間にやっておかないと今日日きびしいかもしれないな。

まとめ

とりあえず構築のノウハウは終わり。あとは繰り返してやるだけ。本来は手詰みでこれやってからのIoCCDKにコードを落としていくのがよいが、時間ない場合はいきなりterraformだのCDK触らせられる事もあるんでしょうなあ...CDKでやる場合はネットワーク(VPC)の構築の詳細がほぼ自動になっているので最低限AZとかサブネットとかその辺理解しておかないと運用でハマるかもしれないです。

今回はNATもないですしね。

次回はさすがにもうちょいまともなイメージの作り方とか、CI/CDをぶちこんだ開発サイクルとか書いて終わりにしたいメリねえ...

Discussion