ACMを利用した独自取得SSL証明書更新をAnsibleでやってみる


#はじめに

Webサービスを運営しているとSSL証明書の更新作業が発生します。
AWSでELBを利用している場合、以下の2パターンになると思います。

- 1.ACM(AWS Certificate Manager)でSSL証明書を発行し、ELBにアタッチ
- 2.取得済みSSL証明書をELBにアタッチ(IAM)




**1.ACMでSSL証明書を発行し、ELBにアタッチ**

ACMでSSL証明書を発行してELBにアタッチしている場合、更新作業は不要です。以上。


**2.取得済みSSL証明書をELBにアタッチ(IAM)**

独自に取得したSSL証明書を利用する場合、

- 1.IAMに新しいSSL証明書をアップロード
- 2.ELBにアタッチされているSSL証明書を新しい証明書に切り替え
- 3.上記を繰り返す

というような作業になります。

ACMが使えるのなら話は早いのですが、何らかの事情によりACMでSSL証明書を発行せず独自に取得したSSL証明書を利用するケースは意外と多いのではないでしょうか。

しかし、その場合でもACMを利用することで作業負荷を減らすことができます。

#ACMを利用した取得済みSSL証明書更新

ACMを利用するメリットは、アップロードしたSSL証明書を上書きできることです。
ELBにアタッチしたSSL証明書を切り替える必要がないため、ACMに対する作業だけで済みます。

##やり方

- 1.ACMに取得済みSSL証明書をアップロード
- 2.ELBに上記ACMのSSL証明書をアタッチ
- 3.ACMのアップロード済みSSL証明書を更新

#ACMを利用した取得済みSSL証明書更新(by Ansible)

上記作業をAnsibleでやってみます。

##前提
- AWS CLIが必要です。
- credential情報は環境変数か`aws configure`でセットしてある必要があります。
- ELBへのSSL証明書アタッチ作業はフォローしません。

##機能
AnsibleにはACMを操作するモジュールがありませんので、`shell`モジュールでAWS CLIを実行します。

###SSL証明書新規登録／更新
####新規登録
- ARN未指定の場合
- work/にあるSSL証明書ファイルをアップロード
- 登録後、work/にアサインされたARNをテキストファイルで保存
- SSL証明書ごとのリージョン指定に対応（CloudFrontのため）
	- 未指定の場合、`my_vars.aws.common.region`で指定したリージョンとなる

####更新
- ARN指定かつyearタグを変更した時
- yearタグを変更しない限り、更新をスキップする。同一証明書を更新しても実害はないが、アカウントあたり20回/年の制限に引っかかるため
	- アップロードしすぎると`You have imported the maximum number of 20 certificates in the last year.`のようなエラーが出ます。[公式ドキュメント](http://docs.aws.amazon.com/ja_jp/acm/latest/userguide/acm-limits.html)にはないような。。。

###タグ更新
- タグのみの更新はできない。新規登録時またはyearタグ変更時、タグも更新される
- 複数指定可能
- タグ削除には未対応

##ディレクトリ構成

```bash:ディレクトリ構成
site.yml
roles/
|--acm/
|  |--tasks/
|  |  |--main.yml
|  |--templates/
|  |  |--save_arn.j2
hosts/aws                  #inventory
host_vars/
|--localhost.yml
work/                      #SSL証明書ファイルを配置しておく
|--example.com-2017.crt        #サーバ証明書
|--example.com-2017.ca-bundle  #中間証明書
|--example.com-2017.key        #サーバ秘密鍵（パスフレーズなし）
```

##inventory

AWSリソース関連モジュールはすべてlocalhostで実行するので、下記のようなインベントリファイルを用意します。

```bash:hosts/aws
[aws]
localhost
```

##vars

こんな感じに変数を定義します。今回はhost_varsで定義しました。


```yaml:host_vars/localhost.yml
---
my_vars:
  aws:
    common:
      region: ap-northeast-1
    acm:
      ssl_crt:
        example_com:
          name: example.com
          # arn: arn:aws:acm:ap-northeast-1:XXXXXXX:certificate/XXXXXXXXXX #アップロード済みの場合ARNを指定する
          tags:
            - key: year
              value: '2017'
            # - key: tag_key   #他のタグをつける場合
            #   value: tag_value
          files:
            crt:
              org_file: example.com-2017.crt
            chain:
              org_file: example.com-2017.ca-bundle
            key:
              org_file: example.com-2017.key
        example_com_cloudfront:
          name: example.com
          region: us-east-1 #CloudFrontはus-east-1固定
          # arn: arn:aws:acm:us-east-1:XXXXXXX:certificate/XXXXXXXXXX　#アップロード済みの場合ARNを指定する
          tags:
            - key: year
              value: '2017'
            # - key: tag_key   #他のタグをつける場合
            #   value: tag_value
          files:
            crt:
              org_file: example.com-2017.crt
            chain:
              org_file: example.com-2017.ca-bundle
            key:
              org_file: example.com-2017.key
```

##Role

- 現在のyearタグの値を取得し(`aws acm list-tags-for-certificate`)、変数に辞書形式で格納します。yearタグがなければ「N/A」をセットします。
- 与えられたyearタグの値と比較し、異なっていたら証明書をアップロード(`aws acm import-certificate`)し、タグをセット(`aws acm add-tags-to-certificate`)します。
- `aws acm import-certificate`の戻り値はARNなので、これを`template`モジュールでファイルに出力します。


```yaml:roles/acm/tasks/main.yml
---
- name: tag(year)確認
  shell: >-
    {% if item.value.region is defined %}
    export AWS_DEFAULT_REGION='{{ item.value.region }}' && \
    {% else %}
    export AWS_DEFAULT_REGION='{{ my_vars.aws.common.region }}' && \
    {% endif %}
    aws acm list-tags-for-certificate \
     --certificate-arn {{ item.value.arn }} \
     --query 'Tags[?Key == `year`].Value' \
     --output text
  with_dict: "{{ my_vars.aws.acm.ssl_crt }}"
  when: item.value.arn is defined
  check_mode: no
  changed_when: no
  register: acm_tags

- name: tag(year) dict作成
  set_fact:
    year_tag_dict: >-
      {%- set tmpdict = {} -%}
      {%- for i in range(acm_tags.results|length) -%}
      {%-   if acm_tags.results[i].stdout is undefined -%}
      {%-     set _ = tmpdict.update({acm_tags.results[i].item.value.name: "N/A"}) -%}
      {%-   else -%}
      {%-     set _ = tmpdict.update({acm_tags.results[i].item.value.name: acm_tags.results[i].stdout}) -%}
      {%-   endif -%}
      {%- endfor -%}
      {{ tmpdict }}
  check_mode: no
  changed_when: no

- name: SSL証明書アップロード
  shell: >-
    {% if item.value.region is defined %}
    export AWS_DEFAULT_REGION='{{ item.value.region }}' && \
    {% else %}
    export AWS_DEFAULT_REGION='{{ my_vars.aws.common.region }}' && \
    {% endif %}
    CRT_ARN=$( \
    aws acm import-certificate \
     {% if item.value.arn is defined %}
     --certificate-arn {{ item.value.arn }} \
     {% endif %}
     --certificate file://{{ inventory_dir | dirname }}/work/{{ item.value.files.crt.org_file }} \
     --certificate-chain file://{{ inventory_dir | dirname }}/work/{{ item.value.files.chain.org_file }} \
     --private-key file://{{ inventory_dir | dirname }}/work/{{ item.value.files.key.org_file }} \
     --output text \
     ) && \
    aws acm add-tags-to-certificate \
     --certificate-arn ${CRT_ARN} \
     --tags Key=Name,Value={{ item.value.name }} \
      {% set list = [] %}
      {%- for i in range(item.value.tags|length) -%}
      {%-   set _ = list.append("Key="+item.value.tags[i]['key']+",Value="+item.value.tags[i]['value']) -%}
      {%- endfor -%}
      {{ list | join(' ')}} && \
    echo ${CRT_ARN}
  with_dict: "{{ my_vars.aws.acm.ssl_crt }}"
  when: >-
    not ansible_check_mode
    and year_tag_dict["{{ item.value.name }}"] != "{{ item.value.tags[0].value }}"
  register: acm_crt

- name: save ARN
  template:
    src: save_arn.j2
    dest: "{{ inventory_dir | dirname }}/work/{{ item.item.value.name }}_arn.txt"
    mode: 0644
    group: wheel
  delegate_to: localhost
  changed_when: no
  become: yes
  with_items: "{{ acm_crt.results | default([]) }}"
  when: item.stdout is defined and not ansible_check_mode
```

##template
```YAML:roles/acm/templates/save_arn.j2
{{ item.stdout }}
```

##site.yml
```YAML:site.yml
---
- name: acm
  hosts: localhost
  connection: local
  roles:
    - role: acm
```

##実行
```bash:Command
$ ansible-playbook -i hosts/aws -l localhost site.yml
```


#まとめ
ACMは、独自取得SSL証明書をELBやCloudFrontにアタッチする際にも利用できます。
特にワイルドカード証明書を複数のELBで利用している場合、SSL証明書の更新作業が1回で済むので便利です。

AnsibleにはACM用モジュールがないため、Ansible内でAWS CLIを強引に実行していますが、ループ処理やテンプレートなどAnsibleの機能が使えるため、シェルスクリプトで全て実装するよりは簡単かと思います。

AnsibleでAWS CLIを実行する場合の参考になれば。

#参考
##AnsibleでAWSリソースを管理するシリーズ
- [VPCをAnsibleで作成する](http://qiita.com/tomohiko_isobe/items/c245bedfa6966305eb10)
- [IAMユーザをAnsibleで管理する](http://qiita.com/tomohiko_isobe/items/5ae6f15f52a2d20e2635)
- [AWSのセキュリティグループをAnsibleで管理する](http://qiita.com/tomohiko_isobe/items/7342b8ba874d997c5c6c)
- [EC2をAnsibleで管理する](http://qiita.com/tomohiko_isobe/items/163b23d5a0e566aaa159)
- [ec2_group_factsモジュールをAnsible2.2で使えるようにする](http://qiita.com/tomohiko_isobe/items/8684b4dc8f99793e5cf8)
- [RDSをAnsibleで管理する](http://qiita.com/tomohiko_isobe/items/7ce42217e177f0c6b7ec)
- [AnsibleでRoute53を管理する](http://qiita.com/tomohiko_isobe/items/85c5513314a64f2392ba)
- [Ansibleの実行対象AWSアカウントを事前にチェックして事故防止](http://qiita.com/tomohiko_isobe/items/f24fbd86eb64a0e9bd4a)