CloudWatchのルール名とECSのタスクスケジューラー

# はじめに
シェルでECSのタスクスケジューラーを登録するスクリプトを書いていた。
複数の環境で同じタスクをECSのスケジュールに登録する際、ハマったことを記事に残しておく


# シェルの内容
流れとしては以下のようなスクリプト
１）タスク定義を作成
２）CloudWatchのルールを作成
３）1と2を紐付けたものをECSのタスクスケジューラーに登録

<details><summary>シェル全文</summary><div>

```bash
#!/bin/bash
set -eu

# $1 : stg or prod
ENV_ARG=$1
args_count=$#

# 引数チェック
check_args() {
    if [ $args_count -ne 1 ]; then
        echo "指定された引数は $args_count 個です。" 1>&2
        echo "実行するには 1 個の引数が必要です。" 1>&2
        exit 0
    fi
    
    if [ "$ENV_ARG" = prod ]; then
        ENV_NAME_JP="本番環境"
        elif [ "$ENV_ARG" = stg ]; then
        ENV_NAME_JP="ステージング環境"
    else
        echo "第1引数には、stgまたはprodを指定してください" 1>&2
        exit 0
    fi
}

select_branch() {
    DEFAULT_BRANCH_NAME=master
    
    echo "$ENV_NAME_JP へのデプロイを実行します。"
    
    if [ "$ENV_ARG" = stg ]; then
        echo 'デフォルト値は Enter キーを入力してください。'
        while true; do
            read -p "> ブランチ名を指定してください。[$DEFAULT_BRANCH_NAME] : " BRANCH_NAME
            case $BRANCH_NAME in
                "" ) BRANCH_NAME=$DEFAULT_BRANCH_NAME
                    echo ""
                break;;
                * ) echo ""
                break;;
            esac
        done
    else
        BRANCH_NAME=$DEFAULT_BRANCH_NAME
    fi
}

fetch_SHA1() {
    echo "$BRANCH_NAME ブランチの最新のコミットSHA1を検索しています...\n"
    BRANCH_SHA1=$(git ls-remote $(git config --get remote.origin.url) $BRANCH_NAME | awk '{ print $1 }')
    
    # リモートリポジトリ存在チェック
    if [ -z $BRANCH_SHA1 ]; then
        echo "リモートブランチが存在しないため最新のコミットSHA1が取得できません。" 1>&2
        exit 0
    fi
}

check_ecr_build_artifacts () {
    echo "$BRANCH_SHA1 のタグのイメージを検索しています...\n"
    
    APP_IMAGE=$(aws ecr list-images --repository-name sample-api --query "imageIds[*].imageTag")
    
    if echo $APP_IMAGE | jq .[] | xargs echo | grep -q $BRANCH_SHA1; then
        echo "$BRANCH_SHA1 のタグのsample-apiのイメージが見つかりました\n"
    else
        echo "$BRANCH_SHA1 のタグのsample-apiのイメージが見つかりませんでした"
        exit 0
    fi
}

get_commmit_message () {
    if [ ${#BRANCH_SHA1} -gt 0 ]; then
        echo "git fetch しています...\n"
        $(git fetch origin $BRANCH_NAME &> /dev/null)
        COMMIT_MESSAGE=$(git log --format=%B -n 1 $BRANCH_SHA1 2>/dev/null | awk 'NR==1')
    fi
    COMMIT_MESSAGE=${COMMIT_MESSAGE:-"[取得出来ませんでした。]"}
}

select_release_file() {
    while true; do
        read -p "> リリースする対象ファイル名を入力してください。: " file_name
        case $file_name in
            "" ) continue;;
            *) break;;
        esac
    done
    
    file_name_ext="$file_name.json"
    
    ls -l docker/batch/commands/$file_name_ext 2>&1 > /dev/null
    
    release_file_name=$file_name_ext
}

pick_params_from_json() {
    task_name=$(cat docker/batch/commands/$release_file_name | jq -r '.name')
    cron=$(cat docker/batch/commands/$release_file_name | jq -r '.cron')
    description=$(cat docker/batch/commands/$release_file_name | jq -r '.description')
    command=$(cat docker/batch/commands/$release_file_name | jq -r '.command')
}

confirm_release() {
    echo "========================================"
    echo "> 環境               : $ENV_NAME_JP" 1>&2
    echo "> ブランチ名         : $BRANCH_NAME" 1>&2
    echo "> コミットSHA1       : $BRANCH_SHA1" 1>&2
    echo "> コミットメッセージ : $COMMIT_MESSAGE" 1>&2
    echo "> タスク名           : $task_name" 1>&2
    echo "> cron情報           : $cron" 1>&2
    echo "> 説明               : $description" 1>&2
    echo "> 実行コマンド       : $command" 1>&2
    echo "========================================"
    
    while true; do
        read -p "> 以上でスケジュールタスクを作成してもよろしいですか? (y/n) [n] : " yn
        case $yn in
            [Yy] ) break;;
            [Nn] ) echo "終了します。\n"
            exit 0;;
            "" ) echo "終了します。\n"
            exit 0;;
            * ) echo "(y/n) で入力してください。\n";;
        esac
    done
}

# タスク定義の作成
create_task_definision() {
    export COMMAND=$command
    export SHA1=$BRANCH_SHA1
    export LOG_GROUP="/ecs/sample-batch-${ENV_ARG}"
    
    task_definition_name="sample-batch-$ENV_ARG-${task_name}"
    
    ecs-cli configure \
    --cluster sample-${ENV_ARG} \
    --region ap-northeast-1 \
    --default-launch-type FARGATE
    
    ecs-cli compose \
    --project-name ${task_definition_name} \
    --file docker/docker-compose.${ENV_ARG}.yml \
    --ecs-params docker/ecs-params.${ENV_ARG}.yml \
    create
    
    task_arn=$(aws ecs describe-task-definition --task-definition $task_definition_name --query 'taskDefinition.taskDefinitionArn' --output text)
    echo "\nタスク定義を作成しました。タスク定義ARN: $task_arn\n"
}

# CloudWatchのルール作成
create_cloudwatch_rule() {
    rule_arn=$(aws events put-rule --schedule-expression "$cron" --name $task_name --description "$description" --query 'RuleArn' --output text)
    echo "CloudWatchのルールを作成しました。ルールARN: $rule_arn\n"
}

# タスク定義とルールの紐付け
associate() {
    cluster_arn="arn:aws:ecs:ap-northeast-1:784286759117:cluster/sample-${ENV_ARG}"
    
    json=$(cat "./docker/batch/env/${ENV_ARG}.json" | jq '.[0].Id = "'$task_name'" | .[0].Arn = "'$cluster_arn'" | .[0].EcsParameters.TaskDefinitionArn = "'$task_arn'"')
    
    aws events put-targets --rule $task_name --targets "$json"
    
    echo "\n$task_definition_name をsample-${ENV_ARG} クラスターのスケジュールタスクに登録しました。"

    # 読み込んでいるjsonはこんな感じのやつ
    # [
    #   {
    #     "Id": "タスク名",
    #     "Arn": "クラスターのARN",
    #     "RoleArn": "RoleのARN",
    #     "Input": "",
    #     "EcsParameters": {
    #       "TaskDefinitionArn": "タスク定義のARN",
    #       "TaskCount": 1,
    #       "LaunchType": "FARGATE",
    #       "NetworkConfiguration": {
    #         "awsvpcConfiguration": {
    #           "Subnets": [
    #             "サブネットのid"
    #           ],
    #           "SecurityGroups": [
    #             "セキュリティグループのid"
    #           ],
    #           "AssignPublicIp": "ENABLED"
    #         }
    #       }
    #     }
    #   }
    # ]
}

check_args
select_branch
fetch_SHA1
check_ecr_build_artifacts
get_commmit_message
select_release_file
pick_params_from_json
confirm_release
create_task_definision
create_cloudwatch_rule
associate
```
</div></details>

リリースするバッチの内容はこんな感じのJSONを事前に用意しておき、それをスクリプトで読み込んで使う。

```json
    {
      "name": "sample",
      "cron": "cron(0/5 * * * ? *)",
      "description": "コマンドのhelpを表示する",
      "command": "node dist/src/command help"
    }
```

例として上記のJSONを読み込んだ場合、CloudWatchのルール名はsampleで作成される。（jsonのnameは `$task_name` に入っている）

```bash:シェル抜粋
    rule_arn=$(aws events put-rule --schedule-expression "$cron" --name $task_name --description "$description" --query 'RuleArn' --output text)
```

作成したsampleのルールを、作成したタスク定義に紐付けてECSに登録する。

```bash:シェル抜粋
    aws events put-targets --rule $task_name --targets "$json"
```

# 起こったこと
上述のシェルを使ってstg用のECSにタスクを登録。
sampleのタスクが登録された。

<img width="1187" alt="スクリーンショット_2020-04-10_6_26_56.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131625/9bd99ad8-2315-1028-4b27-36cd8a69fc12.png">

今度は同じsampleのタスクを本番用のECSに登録する。
ちゃんとsampleが登録された。

<img width="1193" alt="スクリーンショット_2020-04-10_6_28_33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131625/84761131-f89b-a84c-00e2-780435326331.png">

しかし、stgのECSからsampleのタスクが消えた。
<img width="1185" alt="スクリーンショット_2020-04-10_6_28_15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131625/5b6f0149-b326-79b6-615f-abba0face605.png">

# 原因
「CloudWatchのルール `sample` の実行内容は `stg用のタスク定義`。それを `stg用のecsクラスター` に登録」

としていたのを、

「CloudWatchのルール `sample` の実行内容は `prod用のタスク定義`。それを `prod用のecsクラスター` に登録」

に上書きしたことで、stg用のECSからsampleのスケジュールタスクが消えた。

# 対応
CloudWatchのルール名は一意になるように変更した。

```bash
    # タスク定義名に使っているやつをCloudWatchのルール名にも利用
    task_definition_name="sample-batch-$ENV_ARG-${task_name}"

    # CloudWatchのルール作成
    rule_arn=$(aws events put-rule --schedule-expression "$cron" --name $task_definition_name --description "$description" --query 'RuleArn' --output text)

    # CloudWatchのルールをタスク定義、ECSに紐付け
    aws events put-targets --rule ${task_definition_name} --targets "$json"
```

# 最後に
CloudWatchのルール名はグローバル変数みたいな物だから、同じ処理だからといって同じルール名で作成すると今回みたいなことになる。複数の環境で登録するのであれば環境名などをルール名に加えておき、一意にする必要がある。

考えてみれば当たり前のことだが、今回のこの挙動をはじめみたとき、「んん？？」と思ったので残しておく。