Mountech(Mountai × Tech)

どこにでもいる一般的な男性の、たぶん技術的なブログ。投稿内容は私個人の意見であり、所属企業・部門を代表するものではありません。

Ansibleで Amazon EC2 Auto Scaling で起動するEC2インスタンスを更新する

この記事は Ansible Advent Calender 2019 21日目の記事として投稿しています。
アドカレのエントリを眺めているとAnsible × AWSネタが無かったため、あえて書いてみました。
・・・需要があるかは不明です。

はじめに

世間ではコンテナ技術が流行っており、AWSのECSやEKS等のコンテナサービスも多く利用されていると思います。
しかし、まだまだ仮想サーバー(Amazon EC2)上で稼働しているサービス、アプリケーションは多いのではないでしょうか。
事情や理由はさておき、そのような状況下でも保守・運用作業は極力省力化したいものです。
省力化に向けてできることの1つとして、AnsibleでのAWSリソースの自動化を「Auto Scalingで起動するEC2インスタンスの更新」を例に挙げ、紹介させていただきます。

AWS用語の解説は簡潔に記載してるか、省いています。その点ご了承下さい。★

なぜAnsibleを選択したか

AWSのリソースに対する操作ですとCloudFormation, terraformを思い浮かべる方が多いと思います。
なぜAnsibleを選択したか、その理由は以下の通りです。

  • ガッツリとした構成管理が目的ではなく、作業の自動化をしたいだけ
  • 日頃からサーバの保守、運用作業でAnsibleを利用している
  • サーバ、AWSに対する作業の自動化を "Ansibleで共通化" することで、学習コストを抑えたい

Ansibleはバージョンアップが盛んであり、AWS系のモジュールも増え続けているため、日に日に出来ることは増えています。そのため、Ansibleを活用したAWSリソースの変更作業の自動化、構成管理は現実的なものであると思っています。
何よりAnsibleは、プラットフォームが異なる対象(クラウドサービス、サーバ、NW機器)への操作を同じツールで行えることが嬉しいです。
ツール選択は適材適所だと思っていますが、運用現場であれこれツールを増やすと反感を買う可能性がある・・・という現実問題もあります。(小声)

やりたいこと

以下のシナリオを "すべてAnsibleで" 自動化する想定です。

  1. 作業用のEC2インスタンスを起動
  2. 作業用のEC2インスタンスへの何らかの処理、テスト等(※1)
  3. 作業用のEC2インスタンスのAMIを取得
  4. AMIの世代管理
  5. 作業用のEC2インスタンスを削除
  6. 起動設定を新規作成
  7. Auto Scaling(※2)で起動するEC2インスタンスを更新
  8. 古い起動設定を削除

※1 (2) については、本記事では省略させていただきます。
※2 Auto Scaling:負荷状況やリクエスト数等の増減に応じてEC2インスタンスの起動台数を増減させる機能。AMIと起動設定で構成される。

一言に「Auto Scalingで起動するEC2インスタンスを更新する」と言っても案外やることが多く、これらを全て手作業で行うのは辛いです・・・。

以下、簡単に各シナリオの内容を説明します。

1. 作業用のEC2インスタンスを起動

最新のAMI(※)から、作業用のEC2インスタンス(一時インスタンス)を起動します。
このインスタンスに対して、Ansibleから処理を実行します。(処理については省略します)

※AMI (Amazon Machine Image):EC2インスタンスのバックアップイメージです。

2. AnsibleからEC2インスタンスへの何らかの処理、テスト等

OS設定の変更、M/Wの設定変更・バージョンアップ、それに伴うユニットテスト等を行う想定です。

3. 作業用のEC2インスタンスのAMIを取得

(2) の作業を終えたら、作業用のEC2インスタンスのAMIを作成します。

4. AMIの世代管理

新たにAMIが作成されたため、古いAMIの世代管理を行います。
塵も積もればなんとやらで、AMIの世代管理を行わないと課金額が膨れ上がってしまうため、AMIを一定の世代数だけ保持し、古いAMIは都度削除したいです。
せっかくなのでAMIの世代管理も自動化するため、Playbookに組み込みます。

  • 余談
    Ansible Tower(AWX)のジョブスケジュール機能を活用すれば、AMIの世代管理処理を日次で "自動で" 行う、ということも実現可能です。

5. 作業用のEC2インスタンスを削除

作業用のEC2インスタンスは一時的な作業用のものです。
AMI取得後は不要であるため、削除(terminate)します。

6. 起動設定を新規作成

ここでは、(3)で取得したAMIを起動時に使用するAMIに指定し、起動設定(※1)を新規作成(※2)します。

※1 起動設定:Auto Scalingグループで起動するEC2インスタンスを、どのような設定で起動するのかを定義するテンプレート的なものです。
※2 起動設定は作成後に変更ができないため、都度新規作成しています。

7. Auto Scalingで起動するEC2インスタンスを更新

(6) で作成した起動設定をAuto Scalingグループに割り当てます。
Auto Scalingグループに割り当てられる起動設定は1つのみなので、起動設定を更新する都度、新しいものに差し替えます。
なお更新はローリングアップデートで行い、稼働中のサービスへの影響が無いようにします。

8. 古い起動設定を削除

古い起動設定を削除します。
切り戻しは行わない(※)想定なので、Auto Scalingグループに設定されているもの以外は削除します。

※不具合が発生した場合は、原因箇所を修正し、シナリオを再度実行すればいいと考えています。

構成

検証環境の情報

以下の環境で動作検証をしています。

システム構成

ごくごくシンプルな構成です。
Multi-AZ構成のAuto Scalingで稼働するEC2インスタンスを、ローリングアップデートにより無停止で更新(インスタンスの新旧入れ替え)を行います。
全ての操作を、構成図上のAnsibleサーバから行います。

f:id:abs757:20191212160559p:plain

Ansibleのディレクトリ構成

ディレクトリ構成は以下の通りです。
PlaybookやRoleを複数システムで共用する場合は、Playbookとrolesディレクトリを切り出す想定です。

.
└ AWS
  |
  ├ group_vars
  |  ├ uat
  |  |  └ aws.yml
  |  └ prod
  |    └ aws.yml
  |
  ├ host_vars
  |  ├ uat-web01
  |  |  └ all.yml
  |  └ prod-web01
  |    └ all.yml
  |
  ├ roles
  |  ├ CreateAMI
  |  ├ CreateLaunchConfig
  |  ├ DeregisterAMI
  |  ├ LaunchTemporarilyInstance
  |  ├ TerminateTemporarilyInstance
  |  └ UpdateAutoScalingGroup
  |
  ├ ansible.cfg
  |
  ├ inventory
  |
  └ UpdateAutoScaling.yml

group_vars

システム環境毎にグループを分けています。
環境毎にAWSアカウントもしくはVPCを分けることを想定し、各環境のグループ変数にそれぞれパラメータを定義したいためです。

  • 補足
    制御ノード(Ansibleをインストールしたサーバ)とするEC2インスタンスには、本シナリオの実行に必要な権限を付与したIAMロールを割り当てています。そのため、Ansibleのコード上にアクセスキー、シークレットキーは定義していません。
    オンプレミス環境からAWSリソースを操作する場合は、group_varsにキーを定義する想定です。

host_vars

作業用のEC2インスタンス(一時インスタンス)のパラメータ、AutoScalingに関するパラメータを各ホストのホスト変数に定義します。

roles

各RoleでやることはRole名の通りなので、簡潔に記載します。

Role名 処理概要
CreateAMI 作業用のEC2インスタンスのAMIを取得する
CreateLaunchConfig 起動設定 (LaunchConfg) を作成する
DeregisterAMI 定義した世代数以上に存在するAMIを、古いものから登録解除する
LaunchEC2Instance EC2インスタンスを起動する
TerminateEC2Instance EC2インスタンスを終了(削除)する
UpdateAutoScalingGroup Auto Scalingグループを更新する

inventory

inventoryファイルの内容は以下の通りです。
各環境毎に親グループを作成し、各環境下のホストグループを所属させ、各環境用のグループ変数を読み込むようにします。

[uat-web]
uat-web01 ansible_host=<IP Address>

[uat:children]
uat-web

[prod-web]
prod-web01 ansible_host=<IP Address>

[prod:children]
prod-web

実際のコード

長いため、折りたたんでいます。ファイルパスをクリックして展開して下さい。
変数は検証時に適当にセットしたものです。また、各リソースのIDはマスクしています。

group_vars/uat/aws.yml

---
region: us-west-2
vpc_id: <VPC ID>>

host_vars/uat-web01/all.yml

---
# General
hostname: uat-web01

# Temporarily instance
temp_ec2_instance_type: t3.medium
temp_ec2_security_groups:
  - <Security Group ID>
temp_ec2_subnet_id: subnet-

# AMI
ami_no_reboot: no
ami_wait: yes
ami_wait_timeout: 1800
ami_generation: 3

# Launch Config
lc_instance_type: t3.medium
lc_detail_monitoring: yes
lc_iam_role: <IAM Role Name>
lc_security_groups:
  - <Security Group ID>

# AutoScaling Group
asg_name: <Auto Scaling Group Name>
asg_health_check_period: 300
asg_wait_timeout: 1800
asg_vpc_subnet_id:
  - <Subnet-A ID>
  - <Subnet-B ID>
asg_health_check_type: ELB

roles/CreateAMI/tasks/main.yml

---
- name: Get InstanceID
  ec2_instance_facts:
    filters:
      network-interface.addresses.private-ip-address: "{{ ansible_host }}"
    region: "{{ region }}"
  delegate_to: localhost
  register: return_instance_info

- name: Set InstanceID
  set_fact:
    _instance_id: "{{ return_instance_info.instances.0.instance_id }}"

- name: Get current date and time
  setup:
    filter: ansible_date_time
  delegate_to: localhost
  register: return_datetime

- name: Set current date and time
  set_fact:
    _current_datetime: "{{ return_datetime.ansible_facts.ansible_date_time }}"

- name: Set AMI name
  set_fact:
    _ami_name: "{{ hostname }}-{{ _current_datetime.date | regex_replace('-','') }}-{{ _current_datetime.hour }}{{ _current_datetime.minute }}"

- name: Create AMI
  ec2_ami:
    instance_id: "{{ _instance_id }}"
    name: "{{ _ami_name }}"
    no_reboot: "{{ ami_no_reboot | default('no') }}"
    wait: "{{ ami_wait | default('yes') }}"
    wait_timeout: "{{ ami_wait_timeout | default(omit) }}"
    region: "{{ region }}"
  delegate_to: localhost

roles/CreateLaunchConfig/tasks/main.yml

---
- name: Get current date and time
  setup:
    filter: ansible_date_time
  delegate_to: localhost
  register: return_datetime

- name: Set current datetime
  set_fact:
    _current_datetime: "{{ return_datetime.ansible_facts.ansible_date_time }}"

- name: Shaping datetime
  set_fact:
    _current_datetime: "{{ _current_datetime.date | regex_replace('-','') }}{{ _current_datetime.hour }}{{ _current_datetime.minute }}"

- name: Set Launch Config name
  set_fact:
    _lc_name: "asg-lc-{{ hostname }}-{{ _current_datetime }}"

- name: Get AMI list
  ec2_ami_facts:
    filters:
      name: "{{ hostname }}*"
    region: "{{ region }}"
  delegate_to: localhost
  register: return_ami_list

- name: Sort a AMI list
  set_fact:
    _ami_list: "{{ return_ami_list.images | sort(attribute='creation_date',reverse=True) }}"

- name: Set latest AMI ID to variable
  set_fact:
    _ami_id: "{{ _ami_list.0.image_id }}"

- name: Create new Launch Config
  ec2_lc:
    name: "{{ _lc_name }}"
    image_id: "{{ _ami_id }}"
    instance_monitoring: "{{ lc_detail_monitoring }}"
    instance_profile_name: "{{ lc_iam_role }}"
    instance_type: "{{ lc_instance_type }}"
    security_groups: "{{ lc_security_groups }}"
    vpc_id: "{{ vpc_id }}"
    region: "{{ region }}"
  delegate_to: localhost

roles/DeregisterAMI/tasks/main.yml

---
- name: Get AMI list
  ec2_ami_facts:
    filters:
      name: "{{ hostname }}*"
    region: "{{ region }}"
  delegate_to: localhost
  register: return_ami_list

- name: Set AMI list
  set_fact:
    _ami_list: "{{ return_ami_list.images | sort(attribute='creation_date',reverse=True) }}"

- name: Deregister AMI
  ec2_ami:
    image_id: "{{ item.image_id }}"
    state: absent
    delete_snapshot: yes
    region: "{{ region }}"
  when: ansible_loop.index > ami_retained
  loop: "{{ _ami_list }}"
  loop_control:
    label: "{{ item.image_id }}"
    extended: yes
  delegate_to: localhost

roles/LaunchEC2Instance/tasks/main.yml

---
- name: Get AMI list
  ec2_ami_facts:
    filters:
      name: "{{ hostname }}*"
    region: "{{ region }}"
  delegate_to: localhost
  register: return_ami_list

- name: Sort AMI list
  set_fact:
    _ami_list: "{{ return_ami_list.images | sort(attribute='creation_date',reverse=True) }}"

- name: Set latest AMI ID
  set_fact:
    _ami_id: "{{ _ami_list.0.image_id }}"

- name: Launch EC2 instance
  ec2_instance:
    image_id: "{{ _ami_id }}"
    instance_type: "{{ temp_ec2_instance_type }}"
    vpc_subnet_id: "{{ temp_ec2_subnet_id }}"
    security_groups: "{{ temp_ec2_security_groups }}"
    network:
      assign_public_ip: true
      private_ip_address: "{{ ansible_host }}"
    tags:
      Name: "{{ hostname }}_temp"
    wait: yes
    region: "{{ region }}"
  delegate_to: localhost

roles/TerminateEC2Instance/tasks/main.yml

---
- name: Get instance ID
  ec2_instance_facts:
    filters:
      network-interface.addresses.private-ip-address: "{{ ansible_host }}"
    region: "{{ region }}"
  delegate_to: localhost
  register: return_instance_info

- name: Set instance ID
  set_fact:
    _instance_id: "{{ return_instance_info.instances.0.instance_id }}"

- name: Terminate EC2 instance
  ec2_instance:
    instance_ids: "{{ _instance_id }}"
    state: absent
    wait: yes
    region: "{{ region }}"
  delegate_to: localhost

roles/UpdateAutoScalingGroup/tasks/main.yml

---
- name: Get Auto Scaling Group facts
  ec2_asg_facts:
    name: "{{ asg_name }}"
  delegate_to: localhost
  register: return_asg_facts

- name: Set current Launch Config
  set_fact:
    _old_lc_name: "{{ return_asg_facts.results.0.launch_config_name }}"

- name: Get latest Launch Config
  ec2_lc_find:
    name_regex: "asg-lc-{{ hostname }}*"
    sort_order: descending
    limit: 1
    region: "{{ region }}"
  delegate_to: localhost
  register: return_lc_info

- name: Set latest Launch Config
  set_fact:
    _latest_lc_name: "{{ return_lc_info.results.0.name }}"

- name: Update Auto Scaling Group
  ec2_asg:
    name: "{{ asg_name }}"
    health_check_period: "{{ asg_health_check_period }}"
    health_check_type: "{{ asg_health_check_type }}"
    launch_config_name: "{{ _latest_lc_name }}"
    metrics_collection: yes
    region: "{{ region }}"
    replace_all_instances: yes
    replace_batch_size: 1
    termination_policies: OldestLaunchConfiguration
    wait_for_instances: yes
    wait_timeout: "{{ asg_wait_timeout }}"
    vpc_zone_identifier: "{{ asg_vpc_subnet_id }}"
  delegate_to: localhost

- name: Delete old Launch Config
  ec2_lc:
    name: "{{ _old_lc_name }}"
    state: absent
  delegate_to: localhost

inventory

[uat-web]
uat-web01 ansible_host=<IP Address>

[uat:children]
uat-web

[prod-web]
prod-web01 ansible_host=<IP Address>

[prod:children]
prod-web

ansible.cfg

[defaults]
deprecation_warnings = False
force_valid_group_names = ignore
interpreter_python = auto_legacy_silent

UpdateAutoScalingGroup.yml

※ OS以上に対して何か処理をしたい場合は、Role: LaunchEC2Instance 〜 CreateAMIの間に、やりたいことを行うRoleを挿入します。

---
- hosts: all
  gather_facts: False

  roles:
    - LaunchEC2Instance
    - CreateAMI
    - DeregisterAMI
    - TerminateEC2Instance
    - CreateLaunchConfig
    - UpdateAutoScalingGroup

実行

Playbookの実行結果です。(クリックして展開して下さい)

# ansible-playbook -l uat-web01 -i inventory UpdateAutoScaling.yml

PLAY [all] ***************************************************************************************************************************************************************************************

TASK [LaunchEC2Instance : Get AMI list] **********************************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [LaunchEC2Instance : Sort AMI list] *********************************************************************************************************************************************************
ok: [uat-web01]

TASK [LaunchEC2Instance : Set latest AMI ID] *****************************************************************************************************************************************************
ok: [uat-web01]

TASK [LaunchEC2Instance : Launch EC2 instance] ***************************************************************************************************************************************************
changed: [uat-web01 -> localhost]

TASK [CreateAMI : Get InstanceID] ****************************************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [CreateAMI : Set InstanceID] ****************************************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateAMI : Get current date and time] *****************************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [CreateAMI : Set current date and time] *****************************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateAMI : Set AMI name] ******************************************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateAMI : Create AMI] ********************************************************************************************************************************************************************
changed: [uat-web01 -> localhost]

TASK [DeregisterAMI : Get AMI list] **************************************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [DeregisterAMI : Set AMI list] **************************************************************************************************************************************************************
ok: [uat-web01]

TASK [DeregisterAMI : Deregister AMI] ************************************************************************************************************************************************************
skipping: [uat-web01] => (item=ami-xxxxxxxxxxxxxxxxx)
skipping: [uat-web01] => (item=ami-xxxxxxxxxxxxxxxxx)
skipping: [uat-web01] => (item=ami-xxxxxxxxxxxxxxxxx)
changed: [uat-web01 -> localhost] => (item=ami-xxxxxxxxxxxxxxxxx)
changed: [uat-web01 -> localhost] => (item=ami-xxxxxxxxxxxxxxxxx)

TASK [TerminateEC2Instance : Get instance ID] ****************************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [TerminateEC2Instance : Set instance ID] ****************************************************************************************************************************************************
ok: [uat-web01]

TASK [TerminateEC2Instance : Terminate EC2 instance] *********************************************************************************************************************************************
changed: [uat-web01 -> localhost]

TASK [CreateLaunchConfig : Get current date and time] ********************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [CreateLaunchConfig : Set current datetime] *************************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateLaunchConfig : Shaping datetime] *****************************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateLaunchConfig : Set Launch Config name] ***********************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateLaunchConfig : Get AMI list] *********************************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [CreateLaunchConfig : Sort a AMI list] ******************************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateLaunchConfig : Set latest AMI ID to variable] ****************************************************************************************************************************************
ok: [uat-web01]

TASK [CreateLaunchConfig : Create new Launch Config] *********************************************************************************************************************************************
changed: [uat-web01 -> localhost]

TASK [UpdateAutoScalingGroup : Get Auto Scaling Group facts] *************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [UpdateAutoScalingGroup : Set current Launch Config] ****************************************************************************************************************************************
ok: [uat-web01]

TASK [UpdateAutoScalingGroup : Get latest Launch Config] *****************************************************************************************************************************************
ok: [uat-web01 -> localhost]

TASK [UpdateAutoScalingGroup : Set latest Launch Config] *****************************************************************************************************************************************
ok: [uat-web01]

TASK [UpdateAutoScalingGroup : Update Auto Scaling Group] ****************************************************************************************************************************************
changed: [uat-web01 -> localhost]

TASK [UpdateAutoScalingGroup : Delete old Launch Config] *****************************************************************************************************************************************
changed: [uat-web01 -> localhost]

PLAY RECAP ***************************************************************************************************************************************************************************************
uat-web01        : ok=31   changed=7    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

ec2_asgモジュール

個人的に気になったec2_asgモジュールのパラメータを解説します。

termination_policies

Auto Scalingで稼働中のインスタンス(入れ替え前のインスタンス)を、どのような基準で削除するかを指定します。
本シナリオではOldestLaunchConfigurationを指定し、古い起動設定で起動したインスタンスから順に削除するようにしています。

replace_batch_size

新しいインスタンスが起動される際に、同時に起動するインスタンスの台数を指定します。
本パラメータで指定した台数が一気に置き換えられるのではない、というのがポイントです。

wait_for_instances

入れ替え後のインスタンスが正常にサービスインしたかを確認したいため、サービスインするまで待機するようにyesを指定します。
インスタンスがサービスインしたかの判定はELBのステータスを見て(InServiceになっているか)判定したいため、Auto Scalingグループの設定項目「 ヘルスチェックのタイプ(Health Check Type)」では「ELB」を指定しておきます。

f:id:abs757:20191212171015p:plain

wait_timeout

本パラメータで指定するタイムアウト値は、全インスタンスの入れ替えが完了するまでのタイムアウト値です。
そのため、インスタンスがサービスイン状態になるまで掛かる時間、インスタンスの稼働台数によってチューニングが必要となります。

ローリングアップデート時の動作

下図のようにインスタンスの更新(ローリングアップデート)が行われます。
AZ-Aが完了すると、続けてAZ-Bのインスタンスが更新されます。

更新前の状態です。 f:id:abs757:20191220132450p:plain

新しいAMI(新しく作成した起動設定)でインスタンスが起動されます。
この時点ではまだ、新しいインスタンスへバランシングは行われていません。
f:id:abs757:20191220132452p:plain

インスタンスがAutoScalingグループのステータスで "Healthy" になると、インスタンスはALBのターゲットグループにアタッチされます。
ヘルスチェックに合格すると、ターゲットグループでのステータスが "healthy" となり、バランシングが開始されます。
f:id:abs757:20191220132510p:plain

その後、元々起動していたインスタンス(古い起動設定により起動していたインスタンス)は終了(terminate)されます。
これで、入れ替えが完了します。
f:id:abs757:20191220132520p:plain

さいごに

いかがでしたでしょうか。サクッと書く予定が、結構なボリュームになってしまいました。
私がCloudFormation, terraformの経験が無いため、実装・構成や利便性、実運用してみての比較等は書けませんでしたが・・・AnsibleによるAWSリソースの操作も割とシンプルに行えることはご理解いただけたと思います。
繰り返しとなりますが、AWSリソースの操作とOS上の操作が入れ交じるシナリオはAnsibleで一本化できるため、複数のツールを運用して苦労されてる方は、お試しいただければと思います。

ハッピーオートメーション!

バトンリレー

明日は @akira6592 さんです!
よろしくお願いしまーす!!!