AmazonECS / Fargate でカナリアデプロイを実現する

先日、[AmazonECS / Fargate 本番運用のための構築とデプロイ方法まとめ](https://qiita.com/HiromuMasuda0228/items/2170032142ae1c33adcc)というタイトルでECS/Fargateを本番運用するための構成とデプロイについての記事を書き、[HiromuMasuda/ecs-deploy](https://github.com/HiromuMasuda/ecs-deploy)というデプロイ用のスクリプトを公開しました。

<img width="500" alt="Screen Shot 2018-10-23 at 14.02.21.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/36c89bfe-8336-26ef-fca3-86716c90ada7.png">

すると、ECS/Fargateを用いた時のカナリアデプロイの手法が意外と話題になったため、前回は簡単に紹介してしまったカナリアデプロイを、今回は少し首を突っ込んで紹介したいと思います。


# アーキテクチャ全体像

全体像はこちらです。

<img width="800" alt="Screen Shot 2018-10-20 at 17.50.52.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/8f765b9a-a704-2afa-b0b3-9bbdc84e90cd.png">

ECS/Fargateってなんだ？という方や、まだ全記事を読んでない方はまずはこちらを見てください。
> [AmazonECS / Fargate 本番運用のための構築とデプロイ方法まとめ](https://qiita.com/HiromuMasuda0228/items/2170032142ae1c33adcc)


# カナリアデプロイをECSで実現する

service内でtaskが2台動いている状態から、カナリアデプロイをして33%配信を実現するという例を紹介します。

<img width="800" alt="Screen Shot 2018-10-23 at 14.11.10.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/7b24ba1c-43c0-2d68-9db4-2e8b9edf6f66.png">

なお、デプロイ用のスクリプトはこちらで公開しております。

> [HiromuMasuda/ecs-deploy](https://github.com/HiromuMasuda/ecs-deploy)

## 1. 新しいバージョンのイメージをbuildしてpushする

<img width="800" alt="Screen Shot 2018-10-23 at 14.11.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/ad23ccf3-9318-05e7-a45c-aa6398d7aa23.png">

バージョンの識別のために、タイムスタンプとgitのコミットハッシュをタグとしてビルドしたイメージにつけて、ECRにpushします。

```rb
def push_latest_image
  tag_timestamp = Time.now.strftime("%Y%m%d_%H%M")
  tag_git_commit_hash = `git rev-parse HEAD`
  tags = [tag_timestamp, tag_git_commit_hash]
  puts "-----> Push latest image. Tag: #{tags.join(", ")}"

  cmd_build = `docker build -t #{@ecr_name} #{@dockerfile_path}`
  tags.each do |tag|
    cmd = `
      docker tag #{@ecr_name}:latest #{get_ecr_image_name(tag)}
      docker push #{get_ecr_image_name(tag)}`
  end

  return get_ecr_image_name(tag_timestamp)
end
```

## 2. pushしたイメージを参照するTaskDefinitionの新しいリビジョンを作成する

<img width="800" alt="Screen Shot 2018-10-23 at 14.11.26.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/6d5df500-27c7-1ed3-03c8-2a060bdc2414.png">

最新のTaskDefinitionを取得し、ContainerDefinitionの中の参照するimageのバージョンを新しいものに変更し、リビジョンを更新します。

```rb
def update_task_definition(image_name)
  puts "-----> Update task definition"
  task_definition = get_latest_task_definition_description
  container_definitions = task_definition["containerDefinitions"]

  new_container_definitions = []
  container_definitions.each do |container|
    container["image"] = image_name if container["name"] == "#{@container_name}"
    new_container_definitions << container
  end

  new_revision = `aws ecs register-task-definition \
    --family #{@task} \
    --task-role-arn #{task_definition["taskRoleArn"]} \
    --execution-role-arn #{task_definition["executionRoleArn"]} \
    --network-mode #{task_definition["networkMode"]} \
    --volumes '#{task_definition["volumes"].to_json}' \
    --cpu #{task_definition["cpu"]} \
    --memory #{task_definition["memory"]} \
    --requires-compatibilities #{task_definition["requiresCompatibilities"][0]} \
    --container-definitions '#{new_container_definitions.to_json}'`

  new_task_definition_arn = JSON.parse(new_revision)["taskDefinition"]["taskDefinitionArn"]
  puts "-----> New task definition arn: #{new_task_definition_arn}"

  return new_task_definition_arn
end
```

## 3. 作成したTaskDefinitionを元に1台だけTaskを生成する

<img width="800" alt="Screen Shot 2018-10-23 at 14.11.33.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/9d78432a-a1f4-4878-25d6-3177a73233a0.png">

2で作成したTaskDefinitionを元に、taskを新しく作成します。この時、上の図のようにserviceの外に作ります。そうすることで、上の2台のtaskは前のリビジョンのTaskDefinitionから、下の1台は新しいリビジョンのTaskDefinitionを参照していることになります。また、今作成したtaskがカナリアデプロイによるものだと判別がつくように、`started_by`というスペースにタグを追加します。このスクリプトでは`canary`という文字列を指定しています。

```rb
def run_task(task_definition, started_by_tag)
  puts "-----> Run new task"
  service_desc = get_service_description
  conf = service_desc["networkConfiguration"]["awsvpcConfiguration"]
  cmd = `aws ecs run-task \
    --cluster #{@cluster} \
    --task-definition #{task_definition} \
    --network-configuration "awsvpcConfiguration={\
      subnets=[#{conf["subnets"].join(",")}],\
      securityGroups=[#{conf["securityGroups"].join(",")}],\
      assignPublicIp="DISABLED"}" \
    --launch-type FARGATE \
    --started-by #{started_by_tag}`
  return JSON.parse(cmd)
end
```

## 4. 生成したTaskのPrivateIPをロードバランサのターゲットグループに追加する

<img width="800" alt="Screen Shot 2018-10-23 at 14.11.40.png" src="https://qiita-image-store.s3.amazonaws.com/0/108729/f2ce3f85-02d8-db74-c7ee-237d26a9b346.png">

まず、先ほど作成したtaskからprivateIPを取ってきます。taskが作成されてから数秒しないとprivateIPが生成されないため、取得するまでループを回しています。

```rb
def canary_deploy
  ...
  task_arn = new_task["tasks"][0]["taskArn"]
  puts "-----> task ARN: #{task_arn}"

  # take several seconds to get IP
  while true
    private_ip = get_task_private_ip(task_arn)
    if !private_ip.nil?
      puts "-----> private IP: #{private_ip}"
      break
    end
    sleep(1)
  end

  add_task_to_target_group(private_ip)
  ...
end
```

次に、取得したprivateIPをELBのターゲットグループに追加します。これにより、リクエストがカナリアデプロイした1台のtaskにも分散されて流れて来ます。

```rb
def add_task_to_target_group(private_ip)
  puts "-----> Add #{private_ip} to the target group"
  cmd = `aws elbv2 register-targets \
    --target-group-arn #{@target_group_arn} \
    --targets Id=#{private_ip},Port=8000`
  return cmd
end
```

以上のようにして、カナリアデプロイを実現しました。

# まとめ
カナリアデプロイをスクリプト化できたことにより、デプロイによる障害の影響が小さく済むようになりました。ぜひ試して見てください。

また、Twitterでは常に技術系・筋トレ系のアウトプットをしているのでぜひフォローしてください！👉 https://twitter.com/hiromu_bdy