CDKでWordpressをFargate+EFS環境に移行する

## 概要

AWS ECS Fargate1.4.0がついにEFS(Elastic File System)をサポートしました。

これまではコンテナ内にボリュームをマウントしていたため、タスク数が増えたり、タスクがリフレッシュされるとマウントしていたデータが消えてしまっていたので、Wordpressのようなローカルにファイルを保持するようなアプリケーションにFargateは適用できませんでした。

EFSを利用することで、データを永続的に保持することが可能になるため、WordpressでもFargateを利用することができます。

今回は、CDKを利用してWordpressが稼働するインフラ環境を構築しました。

## インフラ構築

### 1. セキュリティグループを定義

RDS、EC2、EFSのセキュリティグループを作成します。

```typescript
export class SystemStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = ec2.Vpc.fromLookup(this, 'VpcForSystem', { vpcName: 'vpc' });

    // RDS用のセキュリティグループ
    // ポート3306番のTCP通信のみを許可
    const dbSecurityGroup = new ec2.CfnSecurityGroup(this, 'RDSSecurityGroup', {
      groupDescription: 'RDS Secutiry Group',
      tags: 'wordpress-rds-sg',
      securityGroupIngress: [
        {
          ipProtocol: 'tcp',
          cidrIp: '0.0.0.0/0',
          fromPort: 3306,
          toPort: 3306,
        }
      ],
      vpcId: vpc.vpcId,
    });

    // EC2用のセキュリティグループ
    // ポート22番のSSH通信のみを許可（踏み台用）
    const ec2SecurityGroup = new ec2.CfnSecurityGroup(this, 'EC2SecurityGroup', {
      groupDescription: 'EC2 Secutiry Group',
      securityGroupIngress: [
        {
          ipProtocol: 'tcp',
          cidrIp: '0.0.0.0/0',
          fromPort: 22,
          toPort: 22,
        }
      ],
      tags: 'wordpress-ec2-sg',
      vpcId: vpc.vpcId,
    });

    // EFS用のセキュリティグループ
    // ポート3306番のTCP通信のみを許可
    const efsSecurityGroup = new ec2.CfnSecurityGroup(this, 'EFSSecurityGroup', {
      groupDescription: 'EFS Secutiry Group',
      tags: 'wordpress-efs-sg',
      securityGroupIngress: [
        {
          ipProtocol: 'tcp',
          cidrIp: '0.0.0.0/0',
          fromPort: 2049,
          toPort: 2049,
        }
      ],
      vpcId: vpc.vpcId,
    });
  }
}
```

### 2. EC2の作成

ローカルからRDSやEFSにアクセスするための踏み台のインスタンスを作成します。

```typescript
export class Ec2Stack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const instance = new ec2.Instance(this, 'Instance', {
      instanceType: new ec2.InstanceType('t2.micro'),
      machineImage: ec2.MachineImage.latestAmazonLinux(),
      vpc,
      allowAllOutbound: true,
      instanceName: 'wordpress-bastion',
      keyName: 'wordpress-key',
      securityGroup: ec2.SecurityGroup.fromSecurityGroupId(this, 'Ec2SecurityGroupId', cdk.Fn.importValue('Ec2-SecurityGroup-GroupId')),
    });
    instance.addUserData(
      'yum -y update',
      'rpm -Uvh http://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm',
      'yum -y install --enablerepo=mysql57-community mysql-community-client php-mysqlnd',
      'yum -y install amazon-efs-utils',
      `mkdir -p /mnt/efs/${FileSystemId}`,
      `mount -t efs ${cdk.Fn.importValue(FileSystemId}:/ /mnt/efs/${FileSystemId}`
    );
  }
}
```

### 3. RDSの作成

```typescript
export class RdsStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, stackUtil: StackUtil, props?: cdk.StackProps) {
    super(scope, id, props);

    // Parameter Group
    const parameterGroup = new rds.ParameterGroup(this, 'ParameterGroup', {
      description: 'DB Parameter Group',
      family: 'mysql5.7',
      parameters: {
        'character_set_connection': 'utf8',
        'character_set_database': 'utf8',
        'character_set_results': 'utf8',
        'character_set_server': 'utf8',
        'character_set_client': 'utf8',
      },
    });

    // Instance
    const databaseInstance = new rds.DatabaseInstance(this, 'DbInstance', {
      allocatedStorage: 20,
      autoMinorVersionUpgrade: false,
      backupRetention: cdk.Duration.days(7),
      cloudwatchLogsRetention: logs.RetentionDays.TWO_MONTHS,
      copyTagsToSnapshot: true,
      engine: rds.DatabaseInstanceEngine.MYSQL,
      engineVersion: '5.7',
      instanceClass: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      instanceIdentifier: 'wordpress-rds',
      licenseModel: rds.LicenseModel.GENERAL_PUBLIC_LICENSE,
      masterUsername: 'wp_admin',
      masterUserPassword: this.node.tryGetContext('rds-password'),
      multiAz: false,
      parameterGroup,
      securityGroups: [
        ec2.SecurityGroup.fromSecurityGroupId(this, 'DbSecurityGroup', cdk.Fn.importValue(DBSecurityGroupId)),
      ],
      storageEncrypted: false,
      storageType: rds.StorageType.GP2,
      enablePerformanceInsights: false,
      vpc,
    });
  }
}
```

### 4. ALBの作成

```typescript
export class LbStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, stackUtil: StackUtil, externalProps: IExternalProperties, props?: cdk.StackProps) {
    super(scope, id, props);

    // VPC
    const vpc = ec2.Vpc.fromLookup(this, 'Vpc', { vpcName: stackUtil.getVpcName() });

    // Security Group for LB
    const lbSecurityGroup = new ec2.SecurityGroup(this, 'LbSecurityGroup', {
      securityGroupName: stackUtil.getName('LB-SG'),
      description: 'Security group for Loadbalancer.',
      vpc,
      allowAllOutbound: true,
    });
    lbSecurityGroup.addIngressRule(ec2.Peer.ipv4('0.0.0.0/0'), ec2.Port.tcp(80), '');
    lbSecurityGroup.addIngressRule(ec2.Peer.ipv4('0.0.0.0/0'), ec2.Port.tcp(443), '');
    stackUtil.addTagToResource(lbSecurityGroup, 'LB-SG');

    // Application Load Balancer
    const alb = new elbv2.CfnLoadBalancer(this, 'LoadBalancer', {
      name: 'wordpress-alb',
      type: 'application',
      scheme: 'internet-facing',
      subnets: vpc.publicSubnets.map(c => c.subnetId),
      securityGroups: [
        lbSecurityGroup.securityGroupId,
      ],
    });

    // Target Group for ALB
    const targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', {
      deregistrationDelay: cdk.Duration.seconds(15),
      healthCheck: {
        healthyThresholdCount: 3,
        healthyHttpCodes: '200-299,301,302,303,304',
        interval: cdk.Duration.seconds(90),
        path: '/',
        protocol: elbv2.Protocol.HTTP,
        timeout: cdk.Duration.seconds(60),
        unhealthyThresholdCount: 3,
      },
      port: 80,
      protocol: elbv2.ApplicationProtocol.HTTP,
      targetGroupName: 'wordpress-tg',
      targetType: elbv2.TargetType.IP,
      vpc,
    });

    // HTTPリクエストの場合は、HTTPSにリダイレクト
    new elbv2.CfnListener(this, 'ListenerHttp', {
      port: 80,
      protocol: elbv2.Protocol.HTTP,
      loadBalancerArn: alb.ref,
      defaultActions: [
        { type: 'redirect', redirectConfig: { port: '443', protocol: elbv2.Protocol.HTTPS, statusCode: 'HTTP_301' } },
      ],
    });

    new elbv2.CfnListener(this, 'ListenerHttps', {
      port: 443,
      protocol: elbv2.Protocol.HTTPS,
      loadBalancerArn: alb.ref,
      certificates: [
        { certificateArn: '[LBの証明書ARN]', },
      ],
      defaultActions: [
        { targetGroupArn: targetGroup.targetGroupArn, type: 'forward', },
      ],
    });
  }
}
```

### 5. EFSの作成

```typescript
export class EfsStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const fileSystem = new efs.FileSystem(this, 'FileSystem', {
      vpc,
      fileSystemName: 'wordpress-efs',
      performanceMode: efs.PerformanceMode.GENERAL_PURPOSE,
      securityGroup: ec2.SecurityGroup.fromSecurityGroupId(this, 'EfsSecurityGroup', cdk.Fn.importValue('EfsSecurityGroupd')),
    });
  }
}
```

### 6. System Managerのパラメータストアにパラメータ定義

Fargateのコンテナから環境変数を参照するために、System Managerのパラメータストアにパラメータを定義しておきます。

```typescript
export class SsmStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new ssm.StringParameter(this, 'WpDbHostParameter', {
      description: 'Wordpress DB Host',
      parameterName: '/wordpress/db_host',
      stringValue: '[RDSのDBエンドポイント]',
    });

    new ssm.StringParameter(this, 'WpDbPortParameter', {
      description: 'Wordpress DB Port',
      parameterName: '/wordpress/db_port',
      stringValue: '[RDSのDBポート]',
    });

    new ssm.StringParameter(this, 'WpDbPasswordParameter', {
      description: 'Wordpress DB Password',
      parameterName: '/wordpress/db_password',
      stringValue: 'password',
    });

    new ssm.StringParameter(this, 'WpDbNameParameter', {
      description: 'Wordpress DB Name',
      parameterName: '/wordpress/db_name',
      stringValue: 'wordpress',
    });

    new ssm.StringParameter(this, 'WpDbUserParameter', {
      description: 'Wordpress DB User',
      parameterName: '/wordpress/db_user',
      stringValue: 'wp_admin',
    });
  }
}
```

### 7. ECSの作成

ECSのリソースを作成します。
注意すべき点は、2020年6月現在Fargateのlatestバージョンは1.3.0ですが、1.3.0ではEFSをサポートしていないため、1.4.0を指定する必要があります。
また、CDK及びCloudformationではまだ、インフラコードからFargateとEFSの連携ができないため、手で作成する必要があります。
タスク定義をインフラコードで作成した後、コンソールから「新しいリビジョン」を選択し、EFSの追加、コンテナ上でEFSをマウントします。
コンテナパスは/var/www/htmlにします。

<img width="1398" alt="スクリーンショット 2020-06-23 16.21.07.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196414/54ef893e-9cf0-d3f6-b591-878e1febbfbd.png">
<img width="1383" alt="スクリーンショット 2020-06-23 16.22.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/196414/2946869e-5eef-a764-181d-55a2d015176c.png">

```typescript
export class EcsStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, stackUtil: StackUtil, props?: cdk.StackProps) {
    super(scope, id, props);

    // Load ECS Cluster
    const cluster = new ecs.CfnCluster(this, 'Cluster', {
      clusterName: 'wordpress-cluster',
    });

    // Task Execution Role
    const executionRole = new iam.Role(this, 'TaskExecutionRole', {
      roleName: 'wordpress-taskexecution-role',
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy'),
      ],
      inlinePolicies: {
        'INLINE': new iam.PolicyDocument({
          statements: [
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                'ssm:GetParameter',
                'ssm:GetParameters',
                'ssm:GetParameterHistory',
                'ssm:GetParametersByPath',
              ],
              resources: [
                `arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter/*`,
              ],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                'ssm:DescribeParameters',
              ],
              resources: [
                '*',
              ],
            }),
          ],
        }),
      },
    });

    // Task Role
    const taskRole = new iam.Role(this, 'TaskRole', {
      roleName: 'wordpress-task-role',
      assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
      inlinePolicies: {
        'INLINE': new iam.PolicyDocument({
          statements: [                 
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                'ssm:GetParameter',
                'ssm:GetParameters',
                'ssm:GetParameterHistory',
                'ssm:GetParametersByPath',
              ],
              resources: [
                `arn:aws:ssm:${cdk.Aws.REGION}:${cdk.Aws.ACCOUNT_ID}:parameter/*`,
              ],
            }),
            new iam.PolicyStatement({
              effect: iam.Effect.ALLOW,
              actions: [
                'ssm:DescribeParameters',
              ],
              resources: [
                '*',
              ],
            }),
          ],
        }),
      },
    });

    // Task Definition
    let taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition', {
      cpu: 256,
      executionRole,
      family: 'wordpress-taskdefinition',
      memoryLimitMiB: 512,
      taskRole,
    });

    // Container
    let containerDefinition = new ecs.ContainerDefinition(this, 'wordpress', {
      image: ecs.ContainerImage.fromRegistry('wordpress:latest'),
      taskDefinition,
      cpu: 256,
      environment: {
        REGION: cdk.Aws.REGION,
      },
      logging: ecs.LogDriver.awsLogs({
        streamPrefix: 'wordpress',
        logGroup,
      }),
      memoryReservationMiB: 512,
      secrets: {
        WORDPRESS_DB_HOST: ecs.Secret.fromSsmParameter(
          ssm.StringParameter.fromStringParameterName(this, 'WpDbHostParameterName', `/wordpress/db_host`),
        ),
        WORDPRESS_DB_PORT: ecs.Secret.fromSsmParameter(
          ssm.StringParameter.fromStringParameterName(this, 'WpDbPortParameterName', `/wordpress/db_port`),
        ),
        WORDPRESS_DB_PASSWORD: ecs.Secret.fromSsmParameter(
          ssm.StringParameter.fromStringParameterName(this, 'WpDbPasswordParameterName', `/wordpress/db_password`),
        ),
        WORDPRESS_DB_NAME: ecs.Secret.fromSsmParameter(
          ssm.StringParameter.fromStringParameterName(this, 'WpDbNameParameterName', `/wordpress/db_name`),
        ),
        WORDPRESS_DB_USER: ecs.Secret.fromSsmParameter(
          ssm.StringParameter.fromStringParameterName(this, 'WpDbUserParameterName', `/wordpress/db_user`),
        ),
      }
    });
    containerDefinition.addPortMappings({
      containerPort: 80,
      hostPort: 80,
      protocol: ecs.Protocol.TCP,
    });

    // Service
    const fargateService = new ecs.CfnService(this, 'EcsService', {
      serviceName: 'wordpress-service',
      cluster: cluster.clusterArn,
      desiredCount: 1,
      launchType: ecs.LaunchType.FARGATE,
      loadBalancers: [
        {
          containerName: 'wordpress'
          containerPort: 80,
          targetGroupArn: cdk.Fn.importValue('LbTgArn'),
        },
      ],
      deploymentConfiguration: {
        maximumPercent: 200,
        minimumHealthyPercent: 100,
      },
      networkConfiguration: {
        awsvpcConfiguration: {
          securityGroups: [
            systemSecurityGroup.securityGroupId,
          ],
          subnets: vpc.publicSubnets.map(c => c.subnetId),
          assignPublicIp: 'ENABLED',
        },
      },
      platformVersion: '1.4.0', // TODO: latest
      healthCheckGracePeriodSeconds: 30,
      taskDefinition: taskDefinition.taskDefinitionArn,
    });
  }
}
```

## まとめ

Fargateを利用することでログの可視化（Cloudwatch）や、セキュリティ強化のためにWAFを導入したり、死活監視でXrayを使ったりなど、アーキテクチャの幅が広がりそうです。