ECR のイメージを使った ECS (Fargate) の構築を VPC の 中で完結させる

# 解決したいエラー

VPC 内で ECR のイメージを使って ECS Cluster を起動しようとすると、`CannotPullContainerError` のようなエラーが頻出する。

# 原因

ECR からイメージを取得するためには、ECR, S3 に接続する必要があるが、private subnet を使用している場合、これらのリソースにアクセスできず、取得に失敗している。

# 解決方法

- ECR のイメージ取得をインターネット接続を経由して行えるようにする
- VPC 内に各リソースを繋ぐトンネルを構築し、そこ経由で ECR のイメージ等が取得できるようにする(VPC Endpoint)

Ref: https://aws.amazon.com/premiumsupport/knowledge-center/ecs-pull-container-api-error-ecr/?nc1=h_ls

2番目の方が綺麗だけれど、Cloud Formation を書くのがめんどくさい。
一度作成したので、使いまわせるようにここに置いておく。

# Cloud Formation

## VPC

```yml
AWSTemplateFormatVersion: "2010-09-09"
Description:
  VPC and Subnet Create

Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "Project Name Prefix"
        Parameters:
          - PJPrefix
      - Label:
          default: "Network Configuration"
        Parameters:
          - VPCCIDR
          - PublicSubnetACIDR
          - PublicSubnetCCIDR
          - PrivateSubnetACIDR
          - PrivateSubnetCCIDR
    ParameterLabels:
      VPCCIDR:
        default: "VPC CIDR"
      PublicSubnetACIDR:
        default: "PublicSubnetA CIDR"
      PublicSubnetCCIDR:
        default: "PublicSubnetC CIDR"
      PrivateSubnetACIDR:
        default: "PrivateSubnetA CIDR"
      PrivateSubnetCCIDR:
        default: "PrivateSubnetC CIDR"

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  PJPrefix:
    Type: String

  VPCCIDR:
    Type: String
    Default: "10.1.0.0/16"

  PublicSubnetACIDR:
    Type: String
    Default: "10.1.10.0/24"

  PublicSubnetCCIDR:
    Type: String
    Default: "10.1.20.0/24"

  PrivateSubnetACIDR:
    Type: String
    Default: "10.1.100.0/24"

  PrivateSubnetCCIDR:
    Type: String
    Default: "10.1.200.0/24"

Resources:
# ------------------------------------------------------------#
#  VPC
# ------------------------------------------------------------#
# VPC Create
  VPC:
    Type: "AWS::EC2::VPC"
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: "true"
      EnableDnsHostnames: "true"
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-vpc"

# InternetGateway Create
  InternetGateway:
    Type: "AWS::EC2::InternetGateway"
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-igw"

# IGW Attach
  InternetGatewayAttachment:
    Type: "AWS::EC2::VPCGatewayAttachment"
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC

# ------------------------------------------------------------#
#  Subnet
# ------------------------------------------------------------#
# Public SubnetA Create
  PublicSubnetA:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: !Ref PublicSubnetACIDR
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-public-subnet-a"

# Public SubnetC Create
  PublicSubnetC:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: "ap-northeast-1c"
      CidrBlock: !Ref PublicSubnetCCIDR
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-public-subnet-c"

# Private SubnetA Create
  PrivateSubnetA:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: "ap-northeast-1a"
      CidrBlock: !Ref PrivateSubnetACIDR
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-private-subnet-a"

# Private SubnetC Create
  PrivateSubnetC:
    Type: "AWS::EC2::Subnet"
    Properties:
      AvailabilityZone: "ap-northeast-1c"
      CidrBlock: !Ref PrivateSubnetCCIDR
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-private-subnet-c"

# ------------------------------------------------------------#
#  RouteTable
# ------------------------------------------------------------#
# Public RouteTableA Create
  PublicRouteTableA:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-public-route-a"

# Public RouteTableC Create
  PublicRouteTableC:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-public-route-c"

# Private RouteTableA Create
  PrivateRouteTableA:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-private-route-a"

# Private RouteTableC Create
  PrivateRouteTableC:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-private-route-c"

# ------------------------------------------------------------#
# Routing
# ------------------------------------------------------------#
# PublicRouteA Create
  PublicRouteA:
    Type: "AWS::EC2::Route"
    Properties:
      RouteTableId: !Ref PublicRouteTableA
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway

# PublicRouteC Create
  PublicRouteC:
    Type: "AWS::EC2::Route"
    Properties:
      RouteTableId: !Ref PublicRouteTableC
      DestinationCidrBlock: "0.0.0.0/0"
      GatewayId: !Ref InternetGateway

# ------------------------------------------------------------#
# RouteTable Associate
# ------------------------------------------------------------#
# PublicRouteTable Associate SubnetA
  PublicSubnetARouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTableA

# PublicRouteTable Associate SubnetC
  PublicSubnetCRouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      SubnetId: !Ref PublicSubnetC
      RouteTableId: !Ref PublicRouteTableC

# PrivateRouteTable Associate SubnetA
  PrivateSubnetARouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      SubnetId: !Ref PrivateSubnetA
      RouteTableId: !Ref PrivateRouteTableA

# PrivateRouteTable Associate SubnetC
  PrivateSubnetCRouteTableAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      SubnetId: !Ref PrivateSubnetC
      RouteTableId: !Ref PrivateRouteTableC

  SecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: !Ref VPC
      GroupName: !Sub "${PJPrefix}-lambda-sg"
      GroupDescription: "for biomass sam"

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# VPC
  VPC:
    Value: !Ref VPC
    Export:
      Name: !Sub "${PJPrefix}-vpc"

  VPCCIDR:
    Value: !Ref VPCCIDR
    Export:
      Name: !Sub "${PJPrefix}-vpc-cidr"

# Subnet
  PublicSubnetA:
    Value: !Ref PublicSubnetA
    Export:
      Name: !Sub "${PJPrefix}-public-subnet-a"

  PublicSubnetACIDR:
    Value: !Ref PublicSubnetACIDR
    Export:
      Name: !Sub "${PJPrefix}-public-subnet-a-cidr"

  PublicSubnetC:
    Value: !Ref PublicSubnetC
    Export:
      Name: !Sub "${PJPrefix}-public-subnet-c"

  PublicSubnetCCIDR:
    Value: !Ref PublicSubnetCCIDR
    Export:
      Name: !Sub "${PJPrefix}-public-subnet-c-cidr"

  PrivateSubnetA:
    Value: !Ref PrivateSubnetA
    Export:
      Name: !Sub "${PJPrefix}-private-subnet-a"

  PrivateSubnetACIDR:
    Value: !Ref PrivateSubnetACIDR
    Export:
      Name: !Sub "${PJPrefix}-private-subnet-a-cidr"

  PrivateSubnetC:
    Value: !Ref PrivateSubnetC
    Export:
      Name: !Sub "${PJPrefix}-private-subnet-c"

  PrivateSubnetCCIDR:
    Value: !Ref PrivateSubnetCCIDR
    Export:
      Name: !Sub "${PJPrefix}-private-subnet-c-cidr"

# Route
  PublicRouteTableA:
    Value: !Ref PublicRouteTableA
    Export:
      Name: !Sub "${PJPrefix}-public-route-a"

  PublicRouteTableC:
    Value: !Ref PublicRouteTableC
    Export:
      Name: !Sub "${PJPrefix}-public-route-c"

  PrivateRouteTableA:
    Value: !Ref PrivateRouteTableA
    Export:
      Name: !Sub "${PJPrefix}-private-route-a"

  PrivateRouteTableC:
    Value: !Ref PrivateRouteTableC
    Export:
      Name: !Sub "${PJPrefix}-private-route-c"

  SecurityGroup:
    Value: !Ref SecurityGroup
    Export:
      Name: !Sub "${PJPrefix}-lambda-sg"

```

## ECR

```yml
AWSTemplateFormatVersion: '2010-09-09'
Description: ECR repository

Parameters:
  PJPrefix:
    Type: String
  ENVPrefix:
    Type: String

Resources:
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Sub ${PJPrefix}-${ENVPrefix}-migration-ecr

Outputs:
  ECR:
    Value: !Ref ECR
    Export:
      Name: !Sub ${PJPrefix}-migration-ECR

```

## ECS Cluster

```yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Create ECS Cluster

Parameters:
  PJPrefix:
    Type: String

Resources:
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub ${PJPrefix}
  MigrationSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      GroupName: !Sub "${PJPrefix}-migration-task-sg"
      GroupDescription: !Sub "${PJPrefix}-migration-task-sg"

Outputs:
  ECSCluster:
    Value: !Ref ECSCluster
    Export:
      Name: !Sub ${PJPrefix}-Cluster
  MigrationSecurityGroup:
    Value: !Ref MigrationSecurityGroup
    Export:
      Name: !Sub ${PJPrefix}-migration-sg

```

## Task Definition

```yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Create for RDS migration

Parameters:
  PJPrefix:
    Type: String
  ENVPrefix:
    Type: String
  ImageTag:
    Type: String
  AwsDefaultRegion:
    Type: String
  AwsAccessKeyId:
    Type: String
  AwsSecretAccessKey:
    Type: String

Mappings:
  NodeEnvMap:
    dev:
      VALUE: development
    stg:
      VALUE: production
    prd:
      VALUE: production

Resources:
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/ecs/logs/${PJPrefix}-${ENVPrefix}-migration-groups'

  EcsTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "ecs-tasks.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
        - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess
        - arn:aws:iam::aws:policy/SecretsManagerReadWrite

  ECSTask:
    Type: AWS::ECS::TaskDefinition
    Properties:
      ContainerDefinitions:
        - Name: !Sub ${PJPrefix}-ECR
          Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${PJPrefix}-${ENVPrefix}-migration-ecr:${ImageTag}'
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: ap-northeast-1
              awslogs-stream-prefix: ecs
          MemoryReservation: 1024
          Cpu: 256
          Environment:
            - Name: DB_HOST
              Value: !Sub  "{{resolve:secretsmanager:${PJPrefix}-rds-${ENVPrefix}:SecretString:host}}"
            - Name: DB_PORT
              Value: !Sub  "{{resolve:secretsmanager:${PJPrefix}-rds-${ENVPrefix}:SecretString:port}}"
            - Name: DB_USER
              Value: !Sub  "{{resolve:secretsmanager:${PJPrefix}-rds-${ENVPrefix}:SecretString:username}}"
            - Name: DB_PASSWORD
              Value: !Sub  "{{resolve:secretsmanager:${PJPrefix}-rds-${ENVPrefix}:SecretString:password}}"
            - Name: DB_DATABASE_NAME
              Value: !Sub  "{{resolve:secretsmanager:${PJPrefix}-rds-${ENVPrefix}:SecretString:dbname}}"
            - Name: DYNAMO_ENDPOINT
              Value: !Sub "http://dynamodb.${AwsDefaultRegion}.amazonaws.com"
            - Name: NODE_ENV
              Value: !FindInMap [NodeEnvMap, !Ref "ENVPrefix", VALUE]
            - Name: AWS_ACCESS_KEY_ID
              Value: !Sub "${AwsAccessKeyId}"
            - Name: AWS_SECRET_ACCESS_KEY
              Value: !Sub "${AwsSecretAccessKey}"
            - Name: AWS_DEFAULT_REGION
              Value: !Sub "${AwsDefaultRegion}"
      Cpu: '256'
      ExecutionRoleArn: !Ref EcsTaskExecutionRole
      Family: !Sub ${PJPrefix}-migration-task
      Memory: 1024
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE

Outputs:
  LogGroup:
    Value: !Ref LogGroup
    Export:
      Name: !Sub ${PJPrefix}-${ENVPrefix}-LogGroup
  ECSTask:
    Value: !Ref ECSTask
    Export:
      Name: !Sub ${PJPrefix}-${ENVPrefix}-ECSTask
  EcsTaskExecutionRole:
    Value: !Ref EcsTaskExecutionRole
    Export:
      Name: !Sub ${PJPrefix}-${ENVPrefix}-EcsTaskExecutionRole
```

## VPC Endpoint

これで、パブリックネットワークを経由せず各リソース間の連携ができるようになる。

```yml
AWSTemplateFormatVersion: "2010-09-09"
Description: Create for RDS migration

Parameters:
  PJPrefix:
    Type: String

Resources:
  MigrationEndpointSecurityGroup:
    Type: "AWS::EC2::SecurityGroup"
    Properties:
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      GroupName: !Sub "${PJPrefix}-migration-vpce-sg"
      GroupDescription: !Sub "${PJPrefix}-migration-vpce-sg"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: "443"
          ToPort: "443"
          SourceSecurityGroupId: { "Fn::ImportValue": !Sub "${PJPrefix}-migration-sg" }
      Tags:
        - Key: Name
          Value: !Sub "${PJPrefix}-migration-vpce-sg"
  ECRApiEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ecr.api"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      SubnetIds:
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-a" }
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-c" }
      SecurityGroupIds:
        - !Ref MigrationEndpointSecurityGroup
  ECRDkrEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.ecr.dkr"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      SubnetIds:
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-a" }
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-c" }
      SecurityGroupIds:
        - !Ref MigrationEndpointSecurityGroup
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.s3"
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      RouteTableIds:
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-route-a" }
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-route-c" }
  LogsEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.logs"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      VpcId: { "Fn::ImportValue": !Sub "${PJPrefix}-vpc" }
      SubnetIds:
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-a" }
        - { "Fn::ImportValue": !Sub "${PJPrefix}-private-subnet-c" }
      SecurityGroupIds:
        - !Ref MigrationEndpointSecurityGroup
        - { "Fn::ImportValue": !Sub "${PJPrefix}-lambda-sg" }

Outputs:
  MigrationEndpointSecurityGroup:
    Value: !Ref MigrationEndpointSecurityGroup
    Export:
      Name: !Sub "${PJPrefix}-MigrationEndpointSecurityGroup"
  ECRApiEndpoint:
    Value: !Ref ECRApiEndpoint
    Export:
      Name: !Sub "${PJPrefix}-ECRApiEndpoint"
  ECRDkrEndpoint:
    Value: !Ref ECRDkrEndpoint
    Export:
      Name: !Sub "${PJPrefix}-ECRDkrEndpoint"
  S3Endpoint:
    Value: !Ref S3Endpoint
    Export:
      Name: !Sub "${PJPrefix}-S3Endpoint"
  LogsEndpoint:
    Value: !Ref LogsEndpoint
    Export:
      Name: !Sub "${PJPrefix}-LogsEndpoint"
```