Packer, Ansible, Terraformで作るBlue-Greenデプロイメント (AWS)

#概要
AWS環境で、デプロイメントのフローを自動化する仕事を任されたので、成果物をここにメモしておきます。

デプロイをするシステムは、以下のように構成されています。
![122905.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/283226/06e014c9-9498-508c-1a1a-635cb6474228.png)

Blue/Greenの2環境を用意し、普段は片方のみを使用しています。使用していないほうの環境にデプロイをして、ELBの振り分け先を変更することで、ダウンタイムのないデプロイメントを目指します。また、packerやAnsible, Terraformなどのツールを使うことで、このフローを自動化していきます。

尚、[こちらの記事](https://qiita.com/neko-neko/items/8ad86e733861bb784d2d)や[こちらの記事](https://qiita.com/kanagi/items/6af87a3a59e70e7e06db)などを参考にして作成しました。
#作業手順
1. アプリケーションや必要なミドルウェアなどをインストールした、**ゴールデンAMI**を作成する
2. **ゴールデンAMI**を参照する**起動設定**を新規作成し、デプロイ先の**AutoScaling Group**にそれを読み込ませる
3. **Elastic Load Balancer**のListenerが転送するTargetGroupを変更し、本番環境をBlueからGreenに（もしくはGreenからBlueに）切り替える
 
##AMIの作成
PackerとAnsibleを使って、ゴールデンAMIを作成します。AMIの基本的な設定はPackerで、ツールのインストール等の構成管理はAnsibleで、それぞれ行います。

まずは、Packerの設定です。

```json:packer.json
{
    "variables":{
        "version":"" //デプロイのたびに変わる値は、変数としておきます
    },									
    "builders":      // AMIを作成するために一時的に立てられるEC2インスタンスについて定義								
    [										
        {										
            "type":"amazon-ebs",										
            "ami_name": "test-AMI-v{{user `version`}}",
            "region": "ap-northeast-1",										
            "source_ami": "ami-xxxxxxxxxxx",  // 基とするAMI
            "instance_type": "t2.micro",
            "ssh_username": "centos",
            "tags":{
                "version":"{{user `version`}}"　// 起動設定を作成するときに、見つけやすいようにタグをつけておく
            },
            "force_deregister": true,
            "force_delete_snapshot": true,
            "security_group_ids":["sg-xxxxxxx","sg-xxxxxxx"],
            "iam_instance_profile": "xxxxxxx",
            "subnet_id":"subnet-xxxxxxxx"
        }
    ],
    "provisioners":   // AMI作成用のインスタンスの中で行いたい処理を定義
    [
        {
            "type":"shell",
            "inline": [  // Ansibleを動かすためのツールをインストール
                "sudo -E yum -y update",
                "sudo -E yum -y install epel-release",
                "sudo -E yum -y install python-pip",
                "sudo -E yum -y install ansible"
            ]
        },
        {
            "type":"ansible-local",   // Ansibleの起動
            "playbook_file":"setup.yml"
        }
    ]
}

```

上のファイルの中から呼ばれている、Ansibleの設定ファイル(playbook)の設定は、以下の通りです。
尚、以下ではS3上のmavenリポジトリから成果物をとってくるようにしてあります。

```yaml:setup.yml
- hosts: all
  become: yes
  vars:
    proxy_env: # すべてに共通するプロキシ設定は、変数化しておく
      https_proxy: xxxxxxxxx
      http_PROXY: xxxxxxxxx
      HTTPS_PROXY: xxxxxxxxx
      HTTP_PROXY: xxxxxxxxx
      no_proxy: xxxxxxxxx
  tasks:
    - environment: "{{proxy_env}}"
      name: "install boto3 (required by aws-s3)" # boto3のインストール
      pip:
        name: boto3
    - pip:
        name: lxml
      name: "install lxml" # Ansibleのs3モジュールを動かすのに必要
      environment: "{{proxy_env}}"
    - maven_artifact: # S3上のmavenリポジトリから成果物を取得
        repository_url: 's3://xxxxxxxx'
        group_id: jp.co.xx.xx
        artifact_id: xxxxxx
        version: 1.0.0
        extension: tgz
        dest: /tmp/xxxxxx.tgz

```

`packer`コマンドで上記のpackerおよびAnsibleを実行すると、AMIが作成されるはずです
##ASGの設定変更
Terraformを用いて、まず上で作成したAMIを読み込む起動設定を作成し、そしてデプロイ先のASG(使用中でない環境のASG)にその起動設定を読み込ませるようにします。

```tf:deploy.tf
provider "aws" {
  region = "ap-northeast-1"
}

variable "target_version" {} // デプロイのたびに値が変わるものは、変数化しておく

data "aws_ami" "centos" { // packerとAnsibleで作成したAMIを取得
  most_recent = true
  owners      = ["xxx"]
  filter {  // タグで検索
    name = "version"
    values = ["${var.target_version}"]
  }
}

resource "aws_launch_configuration" "test" { // 起動設定を新規作成
  name                 = "config-test-v${var.target_version}"
  image_id             = "${data.aws_ami.centos.id}" // 作成したAMIを指定
  instance_type        = "t2.micro"
  enable_monitoring    = false
  security_groups      = ["sg-xxxxxxx", "sg-xxxxxxxxx","sg-xxxxxxx"]
  key_name             = "xxxxxxxxx"
  iam_instance_profile = "xxxxxxxxx"
  root_block_device {
    delete_on_termination = true
    iops = 100
    volume_size = 30
    volume_type = "gp2"
  }
}

resource "aws_autoscaling_group" "test_resource" {  // デプロイ先のASGが上の起動設定を読みこむよう、設定変更
  name = "asg-test"
  launch_configuration = "${aws_launch_configuration.test.name}"  // 上で作成した起動設定を指定
  min_size = 2
  max_size = 2
  desired_capacity = 2
  target_group_arns = ["xxxxx","xxxxxx"]
  vpc_zone_identifier = ["xxxxxxx","xxxxxxxx"]
  lifecycle {
    create_before_destroy = true
  }
}

```

`terraform`コマンドで上の設定を反映させると、使用中でない環境のASGに設定が反映されます。
##Blue/Greenの切り替え
最後に、ELBの振り分けの設定を変えます。適当なスクリプト言語でシェルを書くのですが、今回はCentOSにデフォルトで入っているPythonの2系で書きます(~~今時Python2かよ、って感じですが~~)。

処理の手順は、以下の通りです
1. ASGに紐づくEC2のインスタンスのリストを取得
2. EC2がすべて立ち上がって、テストをパスしたか確認する
3. EC2がすべて立ち上がっていれば、ELBのListenerのリクエストの転送先のTargetGroupを変更し、Blue/Greenを切り替える

以下がコードです。

```python:switch_elb.sh
#! /bin/python
# -*- coding: utf-8 -*-

from time import sleep
import json
import subprocess

# ASGに紐づくEC2インスタンスのリストの取得
GET_INSTANCE_LIST = """aws ec2 describe-instances --filters "Name=tag:version,Values=%s"""
# EC2インスタンスがヘルシーな状態であるか確認
GET_INSTANCE_STATUS = "aws ec2 describe-instance-status --instance-id %s"
# ELBの切り替え
SWITCH_ELB_LISTENER = "aws elbv2 modify-rule --rule-arn %s --actions Type=forward,TargetGroupArn=%s"

# EC2のリストを取得し、ヘルスチェック
def get_ec2_health(version):
    instance_ids = []
    # インスタンスのリスト取得
    instance_list = subprocess.check_output(GET_INSTANCE_LIST % version,shell=True)
    instance_dict = json.loads(instance_list)
    for instance in instance_dict["Reservations"]:
        # 停止中のインスタンスは除く
        if instance["Instances"][0]["State"]["Code"] != 48:
            instance_ids.append(instance["Instances"][0]["InstanceId"]) 
    for i in range(30):
        # 一つずつインスタンスをヘルスチェックし、一つでもダメであれば、15秒後に再チェック
        result = check_ec2_status(instance_ids)
        if result == True:
            return True
        else:
            sleep(15)
    return False

# EC2のヘルスチェック
def check_ec2_status(instance_ids):
    for instance_id in instance_ids:
        result = commands.getoutput(GET_INSTANCE_STATUS % instance_id)
        status_dict = json.loads(result) 
        # 一つでもヘルスチェックに失敗すれば、Falseを返す
        if status_dict["InstanceStatuses"][0]["SystemStatus"]["Details"][0]["Status"] != "passed" or status_dict["InstanceStatuses"][0]["InstanceStatus"]["Details"][0]["Status"] != "passed":
            return False
    return True

# ELBの切り替え
def switch_elb():
    ELB_rules = {}
    try:
        # ELBのListenerのRuleのarnをjson形式で羅列しておくファイル
        file = open("../../ELB_rules_arns.json","r")
        ELB_rules = json.load(file)
    except:
        print("ELBのルールの情報が読み込めませんでした")
        return
    target_group_arns = {}
    try:
        # デプロイ先の環境の、TargetGroupのarnをjson形式で羅列しておくファイル
        file = open("./target_group_arns.json","r")
        target_group_arns = json.load(file)
    except:
        print("ターゲットグループの情報が読み込めませんでした")
        return
    for key in ELB_rules:
        result = subprocess.call(SWITCH_ELB_LISTENER % (ELB_rules[key],target_group_arns[key]),shell=True)
        if result == 0:
            print("切り替え成功")
    return 0

# メインの関数
def main():
    version = input("デプロイのバージョンを指定してください")
    ec2_health = get_ec2_health(version)
    if ec2_health == True:
        switch_elb_status = switch_elb()
        if switch_elb_status != 0:
            print("ELBの切り替えがうまくいきませんでした")
        else:
            print("ELBの切り替えに成功しました")
    else:
        print("EC2のヘルスチェックが失敗したため、切り替えを行いません")

if __name__ == "__main__":
    main()

```