Cloudformationを使ったFargateバッチのデプロイプロセスについて考えてみた

バッチ処理アプリをFargateで実行する際のデプロイプロセスについて考えてみた結果をまとめておきます。

## 前提とするFargateバッチのアーキテクチャ

CloudWatchEventのスケジュールトリガで、Fargateのタスクを実行する構成です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/b3de7ece-9d09-004e-ac04-c46dcb0492a7.png" width=80%>


## アーキテクチャ面の対応方針

* Fargateのバッチはタスク定義が実行される際に最新のコンテナイメージをECRから取得している。
* バッチが増えるたびにECRのリポジトリを増やす構成にはしたくない。ECRリポジトリはアプリ単位で作成し、環境変数などで起動するバッチ処理を切り分ける構成とする。CloudwatchEventから実行する単位で環境変数を設定する
  - 初回のデプロイ対象はECR、ECSクラスター、タスク定義  
  - バッチを増やす際はCloudwatchEventの設定のみ。ターゲット指定時に環境変数をセットできる

※「アプリ」は複数バッチから成り立つ存在として記載しています。

まとめると以下のようになります。
アプリ単位のものは初回に一度構築して終わり、バッチ単位のものはバッチ処理が増えるたびに設定を追加するものです。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/e55a9b96-a430-3e53-81b0-c9d764a45c48.png" width=80%>


## 今回取り上げるバッチの作り

[pythonでバッチスクリプトを書くときの雛形](https://qiita.com/fetaro/items/77cb5571c472eac85031)を参考にpythonでかいてみました。

コードはこちら。
https://github.com/nyasba/python-batch-fargate

ローカルで実行してみた結果です。例外が発生していますが、想定したものですので問題ありません。

```bash
$ python -V
Python 3.7.2
$ python app/bin/my_batch.py aaaa
2019-04-21 09:39:13,188 [    INFO] start
2019-04-21 09:39:13,188 [   ERROR] arg1 = aaaa
2019-04-21 09:39:13,188 [    INFO] False
2019-04-21 09:39:13,188 [    INFO] my_lib
2019-04-21 09:39:13,188 [    INFO] key1_value
2019-04-21 09:39:13,188 [    INFO] True
2019-04-21 09:39:13,188 [   ERROR] my expected Exception
Traceback (most recent call last):
  File "app/bin/my_batch.py", line 73, in <module>
    raise Exception("my expected Exception")
Exception: my expected Exception
2019-04-21 09:39:13,189 [    INFO] no problem.
```

Dockerfileはpython環境でentrypoint.shを実行するのみ。

```Dockerfile
FROM python:3.7
ADD app .
RUN chmod +x ./entrypoint.sh
ENTRYPOINT ["./entrypoint.sh" ]
```

環境変数に応じて処理するバッチのソースを切り分けるようにしています。

```entrypoint.sh
#!/bin/sh

cd `dirname $0`

BATCH=${batch:-no1}
ARG1=${arg1:-aaa}

python -V
python bin/my_batch_${BATCH}.py ${ARG1}
```

（注意）あくまでサンプルですので、もし商用で使う場合はちゃんとしたものにしてください。

## 初期構築

### 1. 権限周り（ロール作成）

#### Fargateタスク実行用のEcsTaskロール

Fargateの実行ロールです。名称は `ecsTaskExecutionRole` とします。
ロールの作成から、サービス`ElasticContainerService`、ユースケース`ElasticContainerServiceTask`を選択して、ポリシーとして`AmazonECSTaskExecutionRolePolicy`を付与します。

| 項目 | 設定値 |
|:--------------|:-----------------------|
| 信頼されたエンティティ | ecs-tasks.amazonaws.com |
| ポリシー       | AmazonECSTaskExecutionRolePolicy |



#### スケジュール実行用のCloudWatchEventロール

こちらはスケジュール実行で利用するCloudwatchEventの実行ロールです。名称は `ecsEventRole` とします。

ロールの作成から、サービス`CloudWatch Events`、ユースケース`CloudWatch Events`を選択して、一旦そのままのポリシーで作成します。その後、デフォルトでついているポリシーをデタッチし、新たにポリシーとして`AmazonEC2ContainerServiceEventsRole`を付与します。

| 項目 | 設定値 |
|:--------------|:-----------------------|
| 信頼されたエンティティ | events.amazonaws.com |
| ポリシー       | 【追加】 AmazonEC2ContainerServiceEventsRole <br> 【削除】 CloudWatchEventsBuiltInTargetExecutionAccess <br> 【削除】 CloudWatchEventsInvocationAccess |


### 2. ECRリポジトリの作成

バッチのDockerイメージを管理するためのECRリポジトリを作成します。Fargateのタスク定義を作成する前にリポジトリに何かのイメージをpushしておきたいのでこのタイミングで作ります。ECRで管理できるイメージ数には上限がありますので、LifecyclePolicyも設定しておきます。

AWS CLIの例です

```bash
aws ecr create-repository --repository-name batch
aws ecr put-lifecycle-policy --repository-name batch --lifecycle-policy-text file://ecr-policy.json
```

タグ付けされていない最新の1イメージのみ残す設定としています。

```ecr-policy.json
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep only one untagged image, expire all others",
            "selection": {
                "tagStatus": "untagged",
                "countType": "imageCountMoreThan",
                "countNumber": 1
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
```

### 3. バッチのDockerイメージをpush 

ECRのDockerRegistryにログインして、ビルドしたDockerイメージをpushします。
最初のコマンドが少しわかりにくいですが、`aws ecr get-login` で `docker login`形式のECRにログインできるコマンドが返却されますので `$()`で囲って実行する形になっています。

```bash
$(aws ecr get-login --no-include-email)
docker build -t batch .
docker tag batch:latest $(aws ecr describe-repositories --repository-names batch | jq -r .repositories[0].repositoryUri):latest
docker push $(aws ecr describe-repositories --repository-names batch | jq -r .repositories[0].repositoryUri):latest
```

Docker環境を作るとimageやキャッシュができてしまうため、自分はCloud9環境を都度立ち上げて実行しています。jqが入っていない場合はインストールしてください。


### 4. Fargateのデプロイ

Fargateでバッチを実行するために必要な、タスク定義・クラスターなどをCloudformationで作ります。

```bash
aws cloudformation create-stack --stack-name fargate-batch \
  --template-body file://./fargate-batch.cf.yml \
  --parameters \
      ParameterKey=BatchName,ParameterValue=batch \
      ParameterKey=TaskExecutionRoleName,ParameterValue=ecsTaskExecutionRole
```

```fargate-batch.cf.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'fargate scheduled task for batch execution'
Parameters:
  BatchName:
    Type: String
    Description: A name of batch
    Default: 'batch'
  TaskExecutionRoleName:
    Type: String
    Description: A name of task execution role
    Default: 'ecsTaskExecutionRole'
Resources:
  CloudwatchLogsGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/fargate/${BatchName}"
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub "${BatchName}"
  ECSTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties: 
      Cpu: "256"
      Memory: "512"
      Family: !Sub "${BatchName}"
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/${TaskExecutionRoleName}"
      TaskRoleArn: !Sub "arn:aws:iam::${AWS::AccountId}:role/${TaskExecutionRoleName}"
      NetworkMode: awsvpc
      ContainerDefinitions:
        - Name: !Sub "${BatchName}"
          Image: !Sub "${AWS::AccountId}.dkr.ecr.ap-northeast-1.amazonaws.com/${BatchName}:latest"
          MemoryReservation: 128
          Environment: 
            - Name: "arg1"
              Value: "test"
          LogConfiguration:
            LogDriver: 'awslogs'
            Options:
              awslogs-group: !Sub "/fargate/${BatchName}"
              awslogs-region: !Sub "${AWS::Region}"
              awslogs-stream-prefix: "batch"
```

Cloudformationの画面はこのようになっています。
![スクリーンショット 2019-04-25 22.29.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/2c69cb72-a808-8a29-c5a4-babb61a5b3bf.png)

ECSのタスク定義もできているはずです。

### 5. スケジュール定義の設定

Fargateを実行する際のSubnet、SecurityGroupは確認して事前にIDを取得しておいてください。
環境変数に設定しておきます。

```bash
export ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
export SUBNET_A=subnet-1111111
export SUBNET_C=subnet-2222222
export SG=sg-aaaaaaaaa
```

#### バッチ1の設定

```bash
aws events put-rule \
  --name batch1-every-5min \
  --description "batch no1" \
  --schedule-expression "cron(0/5 * * * ? *)"

aws events put-targets \
  --rule "batch1-every-5min" \
  --targets "[{ \
      \"Id\": \"1\", \
      \"Arn\": \"arn:aws:ecs:ap-northeast-1:${ACCOUNT_ID}:cluster/batch\", \
      \"RoleArn\": \"arn:aws:iam::${ACCOUNT_ID}:role/ecsEventRole\", \
      \"EcsParameters\": { \"TaskDefinitionArn\": \"$(aws ecs describe-task-definition --task-definition batch | jq -r .taskDefinition.taskDefinitionArn)\", \"TaskCount\": 1, \"LaunchType\": \"FARGATE\", \"NetworkConfiguration\": { \"awsvpcConfiguration\" : { \"Subnets\" : [ \"${SUBNET_A}\", \"${SUBNET_C}\"], \"SecurityGroups\" : [\"${SG}\"], \"AssignPublicIp\": \"ENABLED\"} } }, \
      \"Input\": \"{ \\\"containerOverrides\\\": [{ \\\"name\\\": \\\"batch\\\", \\\"environment\\\": [ {\\\"name\\\": \\\"batch\\\", \\\"value\\\": \\\"no1\\\" }] }] }\" \
  }]"
```

#### バッチ2の設定

ほぼ同じです。

```bash
aws events put-rule \
  --name batch2-every-5min \
  --description "batch no2" \
  --schedule-expression "cron(0/5 * * * ? *)"

aws events put-targets \
  --rule "batch2-every-5min" \
  --targets "[{ \
      \"Id\": \"1\", \
      \"Arn\": \"arn:aws:ecs:ap-northeast-1:${ACCOUNT_ID}:cluster/batch\", \
      \"RoleArn\": \"arn:aws:iam::${ACCOUNT_ID}:role/ecsEventRole\", \
      \"EcsParameters\": { \"TaskDefinitionArn\": \"$(aws ecs describe-task-definition --task-definition batch | jq -r .taskDefinition.taskDefinitionArn)\", \"TaskCount\": 1, \"LaunchType\": \"FARGATE\", \"NetworkConfiguration\": { \"awsvpcConfiguration\" : { \"Subnets\" : [ \"${SUBNET_A}\", \"${SUBNET_C}\"], \"SecurityGroups\" : [\"${SG}\"], \"AssignPublicIp\": \"ENABLED\"} } }, \
      \"Input\": \"{ \\\"containerOverrides\\\": [{ \\\"name\\\": \\\"batch\\\", \\\"environment\\\": [ {\\\"name\\\": \\\"batch\\\", \\\"value\\\": \\\"no2\\\" }] }] }\" \
  }]"
```

Inputでの環境変数の上書きはかなり苦労しましたが、こちらを参考にしました。
https://github.com/aws/aws-cli/issues/2760

#### 登録結果

スケジュールが2件登録されています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/2e9b2981-d876-6164-95ab-653bf0c2b4b3.png)

環境変数の上書きもできているようです。
![スクリーンショット 2019-04-26 10.36.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/11563332-99af-ec28-cfee-74e12bc5f397.png)


### 6. 起動確認

どちらのバッチも5分間隔で実行していますので、少し待ちます。

一目見ると「エラーが発生したかな？」と思ってしまいましたが、タスクが実行されて終了した結果がこちらです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/8b8b69c1-22fb-2c54-1e2b-7128fa17e242.png)

Logsタブからタスクの実行ログも確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/d3f13c63-c1f6-8145-17cf-fe48582b6acf.png)



もし以下のようなエラーが発生している場合は、FargateからInternetに向けた通信が失敗しています。

```
CannotPullContainerError: API error : Get https://registry-1.docker.io/v2/: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)
```

本説明ではPublicIP割り当てを**ENABLED**前提で記載していますが、ネットワーク環境の前提が異なる場合は公式ドキュメントを参考に設定を見直してください。
https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task_cannot_pull_image.html

## アプリ修正に伴うデプロイ (1)Dockerイメージの最新化 

### 1. Dockerイメージの再push

初期構築の3の手順と同じです。latestタグのイメージを置き換えることで次回の実行タスクから最新のDockerイメージが実行されるようになります。


## アプリ修正に伴うデプロイ (2)タスク定義のバージョン最新化 

新しいDockerイメージを別タグでpushして、タスク定義を新しいバージョンにする、という方法です。(1)とあまり変わらないかもしれませんが実際にやってみました。

### 1. Dockerイメージの再push

greenというタグでイメージをpushします

### 2. タスク定義を更新（バージョン追加）

タスク定義で新しいバージョンを作ります。その際に「コンテナの定義」を修正します。

| 項目 | 設定値 |
|:--------------|:-----------------------|
| イメージ | ACCOUNT_ID.dkr.ecr.ap-northeast-1.amazonaws.com/batch:green |
| awslogs-stream-prefix  | batch/green |

新しいバージョン `batch:2`ができました

### 3. CloudWatchEventのターゲット設定となるタスク定義を最新化

「ターゲットのスケジュール」を確認すると、`batch:1`です。これを`batch:2`に編集します。

以上。

手間ですが、バッチ単位で新バージョンに切り替えるタイミングを分けたい場合はよいのかもしれません。ただし、スケジューリングされたバッチ数が増えると作業が大変になりますし、都度デプロイ方法を変える方がミスにつながるため、多分使わないと思います。

## 使ってみた感想

### :o: コストメリットは大きい

バッチの実行するタイミングのみ環境を立ち上げるという点が実現できたため、日次バッチのような頻度がそこまで高くないものであれば料金的なメリットは大きいと感じました。一時的な環境のため、スペックを上げることも簡単にできます。

Jobのスケジュール実行しか検討しませんでしたが、 **キューからのタスク実行**などもあれば活用の範囲は広がります。

### :x: バッチ単位でのログが特定しづらい

タスクの実行ログはCloudwatchLogsを利用しています。実行時間により参照したいタスクが特定できれば問題ありませんが、環境変数で渡している「どのバッチが実行されたか（バッチ1、バッチ2）」の情報がタスクの詳細をログを見ないとわかりませんでした。

一覧画面で表示されるログストリームのprefixでバッチの種類が特定できるとすごくありがたかったです。（結構前ですが、[要望](https://github.com/aws/containers-roadmap/issues/198)も出ているようですね）　ログ周りの設定は「タスク定義」で行なっているため、prefixを分けるためにはタスク定義を分割するしか方法がなさそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47589/b3870bcb-a456-7623-df28-0b43ff777f1a.png)

### :question: Lambdaとの使い分けは悩ましい

Fargateは「実行時間の制限がない」点はまず挙げられますので、Lambdaの15分制限が気になっている方はFargateがよいと思います。

今回の構成では、「処理が増えるたびにデプロイプロセスができないようにしたい」という思いから環境変数で切り分けるようにしましたが、Lambdaでできないことでもない処理ではあります。Lambdaのデプロイツールも充実していますし、判断基準は悩ましい問題です。

あくまで個人的な見解ですが、現時点ではLambdaはスケールが必要な単一処理のワンポイント利用、通常のアプリはFargateにするのが使いやすいのかなと感じています。



## まとめ

今回Cloudformationを使ったと書きながら、そんなに管理対象リソースがなかったのでワンポイントでした。すみません。
ログ部分など多少悩ましいところもありますが、十分使えるのではと思いました。ただし、本格的にバッチシステムを作り込む場合はJobスケジューラをまず導入した方がいいです。

あとはアプリのコードpushのWebhookなどに連動して、CircleCIでECRにイメージpushというのも試してみたいと思います。Webサービスのほうのデプロイプロセスも今後書きます！


> 2019/4/29追記
CircleCIでECRにイメージpushする記事を書きました。
https://qiita.com/nyasba/items/517c0d6b15600bf774b5

> 2019/5/15追記
Webアプリのデプロイに関する記事も書きました。
https://qiita.com/nyasba/items/26ebcba9858d4f722aba