プライベートサブネットでFargateを使う環境をCloudFormationで用意する

# はじめに
オンプレミス環境からCodeCommitへのレプリカを非同期でもいいので取りたく、
git操作＆AWS CLI操作をサーバレスで構えたい機会があった。

フックするのはリポジトリの都合によって決まる。
実行部分は、AWS CLIをDockerで作って、Fargateで実行させようとしたが、
マネコンからポチポチするとだいぶ作業が多い。CFnでやりたい。
制約の多いVPCだと毎度困る話だと思うので、まとめてみた。

#### 制約条件
- 既存のVPCとプライベートサブネットがある状態から環境を用意したい
- NATゲートウェイも使えないプライベートなサブネットでFargateを使いたい
- 極力VCPEndpointの料金を抑えたい（24時間Fargateタスクが立ち上がるわけではない）
- 料金を抑えられるならECSタスク実行が遅れても良い

#### やったこと
- CloudFomationでECS環境を全部つくる（スタック①）
- VCPEndpointを用意するCloudFormation（スタック②）もつくる
- コスト効果を狙って使用時以外、スタック②は削除する

#### つくったもの
- stack1 (stack-${UUID})
- stack2 (stack-Interface)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/54100/359bec96-3704-cf07-a4d4-c282e0a0d846.png)

Docker部分も全部まとめてGitHubに置いた。

https://github.com/bd8z/fargatePrivate.git


## Step1 スタック①の作成
Fargateを実行するために必要なESSのタスク定義、ECSタスクをキックするためのLambdaなど、ECS周りで必要なものをまとめて定義した。スタック②作成時に必要となるCloudFormationのサービスロールも定義する。
```yaml:
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  UUID:
    Description: UUID of stack items
    Type: String
  subnetID:
    Description: subnetID
    Type: AWS::EC2::Subnet::Id
    Default: subnet-XXXXXXXXXXXXXXXXX
  vpcEndpointStackName:
    Description: subnetID
    Type: String
    Default: stack-Interface

Resources:
  MyECSTaskRole:
    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/AmazonS3ReadOnlyAccess
      RoleName: !Sub "MyECSTaskRole-${UUID}"

  taskdefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      ContainerDefinitions:
        - Essential: true
          Image: !Sub "${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/fargate-private"
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-create-group: true
              awslogs-group: !Sub "/ecs/myEcsLogs-${UUID}"
              awslogs-region: ap-northeast-1
              awslogs-stream-prefix: fgtLog
          Name: !Sub "myContainer-${UUID}"
          PortMappings:
            - ContainerPort: 80
              HostPort: 80
              Protocol: tcp
      Cpu: '1024'
      ExecutionRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/ecsTaskExecutionRole"
      TaskRoleArn: !Ref MyECSTaskRole
      Family: !Sub "taskDefinition-${UUID}"
      Memory: '2048'
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      RuntimePlatform:
        OperatingSystemFamily: LINUX

  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "MyCluster-${UUID}"
    DependsOn: taskdefinition

  LambdaServiceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action: sts:AssumeRole
      Policies:
        - PolicyName: InlinePolicy
          PolicyDocument: 
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: ecs:RunTask
                Resource: !Sub "arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:task-definition/taskDefinition-${UUID}"
              - Effect: Allow
                Action: cloudformation:CreateStack
                Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${vpcEndpointStackName}/*"
              - Effect: Allow
                Action: cloudformation:DescribeStacks
                Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${vpcEndpointStackName}/*"
              - Effect: Allow
                Action: cloudformation:GetTemplate
                Resource: !Sub "arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${vpcEndpointStackName}/*"
              - Effect: Allow
                Action: iam:PassRole
                Resource: '*'
      ManagedPolicyArns:
      - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      - arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess
      RoleName: !Sub "LambdaServiceRole-${UUID}"
    DependsOn: ECSCluster

  MyLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub "MyLambda-${UUID}"
      Handler: index.lambda_handler
      Role: !GetAtt LambdaServiceRole.Arn
      Runtime: python3.9
      Timeout: 300
      Environment:
        Variables:
          privateSubnet: !Sub "${subnetID}"
          clusterArn: !GetAtt ECSCluster.Arn
          taskDefinitionArn: !Sub "arn:aws:ecs:ap-northeast-1:${AWS::AccountId}:task-definition/taskDefinition-${UUID}"
          cfnArn: !GetAtt CFnServiceRole.Arn
      Code:
        ZipFile: |
          # coding: utf-8
          import json
          import os
          import time

          import boto3

          ecs_client = boto3.client("ecs",region_name="ap-northeast-1")
          cfn_client = boto3.client("cloudformation", region_name="ap-northeast-1")
          vpcEndpointStackName='stack-Interface'
          vpcEndpointStackTemplateURL = "https://XXXXXXXXXXXXXXXXX/interfaceEndpoint.yaml"

          def checkEdnpointAlive(client, stackName):
              res = False
              try:
                  response = client.get_template(
                  StackName=stackName
                  )

                  if "VPCEndpoint" in response["TemplateBody"]:
                      res = True
                  return res
              #no stack case
              except:
                  pass


          def updatewait(client, stackName):
              attempts = 120
              delay = 30
              time.sleep(delay)
              try:
                  for i in range(attempts):
                      res = client.describe_stacks(
                          StackName=stackName
                          )
                      stack = res["Stacks"][0]
                      if (stack["StackStatus"] is 'CREATE_COMPLETE'):
                          break
                      else:
                          pass
              #no stack case
              except:
                  pass

          def createStackFromTemplate(client, stackName, templateURL, roleArn):
              client.create_stack(
              StackName=stackName,
              TemplateURL=templateURL,
              Capabilities=["CAPABILITY_NAMED_IAM"],
              RoleARN=roleArn
              )

          def lambda_handler(event, context):
              # preparing interface endpoint
              updatewait(cfn_client, vpcEndpointStackName)
              if not(checkEdnpointAlive(cfn_client, vpcEndpointStackName)):
                  createStackFromTemplate(cfn_client, vpcEndpointStackName, vpcEndpointStackTemplateURL, os.environ["cfnArn"])
                  updatewait(cfn_client, vpcEndpointStackName)
              
              # ecsExecute
              ecs_client.run_task(
                  cluster=os.environ["clusterArn"],
                  launchType="FARGATE",
                  networkConfiguration={
                      "awsvpcConfiguration": {
                          "subnets": [os.environ["privateSubnet"]],
                          "assignPublicIp": "DISABLED",
                      }
                  },
                  taskDefinition=os.environ["taskDefinitionArn"]
              )
              
              return {
                  'statusCode': 200,
                  'body': json.dumps('Hello from Lambda!')
              }


  CFnServiceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - cloudformation.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: InlinePolicy
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: 
                  - ec2:*
                Resource: 
                  - "*"
      RoleName: !Sub "CFnServiceRole-${UUID}"
```
- 2つのStack Statusを監視させかったのでboto3のwaiterは使わず、自作waiterとした。
- Dockerで`aws s3 ls`させるため、LambdaServiceRoleにS3の読み取り許可アクセスを付けた。

### 作成時に詰まったところ
#### CloudFomationのcreate-stackで使用されるIAMロール
普段、意識せずにローカルのコンソール環境にて`aws cloudformation create-stack`をすれば、スタックが無事作成されるが、今回Lambdaからスタック②を立ち上げたり更新するときに何度も権限不足のエラーに見舞われた。

どうやら明示的にLambdaから、`--role-arn`を設定しなかったことが、原因の様子。
ドキュメントを見ると、サービスの仕様としては、
- 既に使われたロールがあれば、その権限が使用される。
- 何も定義されていない場合、ユーザ認証情報の権限が使用される。

https://docs.aws.amazon.com/cli/latest/reference/cloudformation/create-stack.html

>The Amazon Resource Name (ARN) of an Identity and Access Management (IAM) role that CloudFormation assumes to create the stack. CloudFormation uses the role's credentials to make calls on your behalf. CloudFormation always uses this role for all future operations on the stack. Provided that users have permission to operate on the stack, CloudFormation uses this role even if the users don't have permission to pass it. Ensure that the role grants least privilege.
>If you don't specify a value, CloudFormation uses the role that was previously associated with the stack. If no role is available, CloudFormation uses a temporary session that's generated from your user credentials.

今回は間違いなくスタック操作ができるように、スタック②の作成に必要な権限をサービスロール`CFnServiceRole`で定義した。

## Step2 スタック②の作成
プライベートサブネットにおけるFargateの利用では、Fargateエージェントが使用するECRやClodudWatchLogsなどのエンドポイントの名前解決ができるように各種エンドポイントを作成することが必要である。Logsへ通信が疎通できないと、ログが残らないため、タスクが成功したのか失敗したのかデバッグもできずたちが悪い。
S3はGateway型エンドポイントのため、立ち上げ時間に対する課金もないため立ち下げの必要もないが、ルートテーブルの設定が必要だったりとInterface型と異なるところもあるため、必要なリソースをまとめて定義し、作ったロールでcreate-stackするようにした。

参考：

https://repost.aws/ja/knowledge-center/ecs-fargate-tasks-private-subnet

```yaml 
AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  #VPCID
  fargateVPC:
    Description: "VPC ID"
    Type: AWS::EC2::VPC::Id
    Default: vpc-XXXXXXXXXXXXXXXXX

  #InterfaceSubnet1
  fargateSubnetId1:
    Description: "Interface Subnet 1st"
    Type: AWS::EC2::Subnet::Id
    Default: subnet-XXXXXXXXXXXXXXXX

Resources:
  VpcRouteTalbe:
    Type: "AWS::EC2::RouteTable"
    Properties:
      VpcId: !Ref fargateVPC

  VpcRouteTalbeAssociation:
    Type: "AWS::EC2::SubnetRouteTableAssociation"
    Properties:
      RouteTableId: !Ref VpcRouteTalbe
      SubnetId: !Ref fargateSubnetId1

  EndpointSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties: 
      GroupDescription: "Security Group for VPC Endpoints"
      SecurityGroupEgress: 
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
      SecurityGroupIngress: 
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0
      VpcId: !Ref fargateVPC

  LogEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      PrivateDnsEnabled: true
      SecurityGroupIds: 
        - !Ref EndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.logs
      SubnetIds: 
        - !Ref fargateSubnetId1
      VpcEndpointType: Interface
      VpcId: !GetAtt EndpointSecurityGroup.VpcId
  EcrDkrEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      PrivateDnsEnabled: true
      SecurityGroupIds: 
        - !Ref EndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.dkr
      SubnetIds: 
        - !Ref fargateSubnetId1
      VpcEndpointType: Interface
      VpcId: !GetAtt EndpointSecurityGroup.VpcId
  EcrApiEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      PrivateDnsEnabled: true
      SecurityGroupIds: 
        - !Ref EndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ecr.api
      SubnetIds: 
        - !Ref fargateSubnetId1
      VpcEndpointType: Interface
      VpcId: !GetAtt EndpointSecurityGroup.VpcId
  SMEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      PrivateDnsEnabled: true
      SecurityGroupIds: 
        - !Ref EndpointSecurityGroup
      ServiceName: !Sub com.amazonaws.${AWS::Region}.secretsmanager
      SubnetIds: 
        - !Ref fargateSubnetId1
      VpcEndpointType: Interface
      VpcId: !GetAtt EndpointSecurityGroup.VpcId
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
      RouteTableIds: [!Ref VpcRouteTalbe]
      VpcEndpointType: Gateway
      VpcId: !GetAtt EndpointSecurityGroup.VpcId
```


## Step3 テンプレート②の立ち下げ
VPCエンドポイント（Interface型）は、立ち上げているだけで東京リージョンで0.014USD/時間かかる。一度立ち上げると、課金が始まり、立ち上げ時間が1時間未満でも1時間とカウントされる。コスト効果を考えると、Fargateタスクが発生しないときはInterfaceエンドポイントを立ち下げたい。

>1 時間未満の VPC エンドポイント使用時間は、1 時間分として請求されます。


https://aws.amazon.com/jp/privatelink/pricing/


スタック②の起動時間とテンプレートの内容から、
VPCエンドポイントの稼働状態・稼働時間を判断し、
スタックごと削除するLambda関数を作った。

このLambdaとEventBridge(Rate30Minutes)を使えば、
期待値で45分はスタック②を起動した状態を保てる。

```python:lambda_function.py
# coding: utf-8
import json
import boto3
import time
import datetime
import os

vpcEndpointStackName='stack-Interface'
cfn_client = boto3.client('cloudformation')

def updatewait(client, stackName):
    attempts = 120
    delay = 30
    time.sleep(delay)
    try:
        for i in range(attempts):
            res = client.describe_stacks(
                StackName=stackName
                )
            stack = res["Stacks"][0]
            if (stack["StackStatus"] is 'CREATE_COMPLETE'):
                break
            else:
                pass
    #no stack case
    except:
        pass
            
def checkEdnpointAlive(client, stackName):
    res = False
    try:
        response = client.get_template(
        StackName=stackName
        )

        if "VPCEndpoint" in response["TemplateBody"]:
            res = True
        return res
    #no stack case
    except:
        pass

def lambda_handler(event, context):
    
    # TODO implement
    nowTime = datetime.datetime.now(datetime.timezone(datetime.timedelta(hours=+9), 'JST'))

    if checkEdnpointAlive(cfn_client,vpcEndpointStackName):
        updatewait(cfn_client, vpcEndpointStackName)
        response_des = cfn_client.describe_stacks(StackName=vpcEndpointStackName)
        for stack in response_des['Stacks']:

            lastTime = stack["CreationTime"]
            if "LastUpdatedTime" in list(stack.keys()):
                lastTime = stack["LastUpdatedTime"]
            
            if ((nowTime-lastTime).seconds) > int(os.environ["timeToTerminate"]):
                response = cfn_client.delete_stack(
                    StackName=vpcEndpointStackName
                    )

    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

```

# まとめ
- CloudFormationからプライベートサブネットでFargateを使えるようにした（テンプレート①）
- 時間課金部分VPCエンドポイントを作成する部分だけ別のテンプレート②で管理した
　⇒この②部分だけ必要に応じて立ち下げ/立ち上げすることで、コスト低減できる。

## 今後やりたい
とりあえず動くが、この構成の問題点もいくつか。
- VPCエンドポイントを1時間弱をフルで使いきれない。
- Fargateタスクが間欠的に発生する場合だと、
　VPCエンドポイントの作成を繰り返すより、立ち上げっぱな方がコスト的に有利。
- Lambdaを頻繁に叩くことになる（とは言え30分に一回か）
　
EventBridge SchedulerとCFnをうまく使って、
VPCエンドポイントを使い切る構成としたい。