Ansibleでテスト環境ごとに微妙に異なるCloudFormationテンプレートファイルを生成する

## はじめに
AWS CloudFormationを使う際、テスト環境や本番環境間の差異に苦しめられました。
綺麗に管理し、イケてるテンプレートを作るために、たどり着いた手法を記事にしたいと思います。

## Infrastructure as Code
Infrastructure as Codeによる恩恵は様々あります。

- 人為的オペミスの防止
- 環境ごとの差異の最小化
- 手順書のメンテナンスからの開放

AWSでは[CloudFormation](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/Welcome.html)(以下cfnと書きます)というサービスを使用することで、インフラのコード化が実現できます。

cfnはyml形式またはjson形式で書かれた、テンプレートファイルに基づきAWSの各リソースを作成/変更/削除を行うことができます。利用するAWSの各サービスの設定を書いたテンプレートファイルを作成することで、インフラのコード化ができるわけです。

## 理想と現実
一つのCloudFormationテンプレートファイルでテスト環境や開発(デバッグ)環境、そして本番環境まで作成できることが理想です。ですが、現実はそう簡単にはいきません。

以下のような理由でどうしても環境ごとに設定に差異が発生するケースがあります。

- 連携先の外部システムのエンドポイントが異なる
- コスト等の理由でインスタンスタイプが違う
- テストと本番でAWSリソースの設定値が異なる
- 構成のテスト環境と本番環境で一部が異なる

環境ごとの差異の程度により、いくつか実現方法があると思います。
実際に検討した実現方法をいくつかご紹介します。

## 環境ごとにcfnテンプレートファイルを分ける
あまりやりたくない方法です。
環境ごとの差異が少ない場合、テンプレートファイルを環境ごとに用意すると、重複する内容が多くなってしまいます。重複が多いとどうしても変更漏れが出てきてしまうのでおすすめしません。

環境ごとの構成が大きく異なるような場合は、cfnテンプレートファイルを分けるしかないですが。

## Parametersによる実現方法
cfnには[Parameters](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/parameters-section-structure.html)という機能があります。
これは、cfnのテンプレートからスタックを作成する際に、テンプレートの先頭で定義したParametersの値を指定します。cfnテンプレート中ではこのParametersを参照することができるので、一部の設定値が異なるようなレベルであれば、この方法がおすすめです。

使い方は次のような感じになります。

```yaml
AWSTemplateFormatVersion: "2010-09-09"
Parameters: 
  InstanceTypeParameter: 
    Type: String
    Description: Enter t2.micro, m1.small, or m1.large.
Resources:
  Ec2Instance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType:
        Ref: InstanceTypeParameter
      ImageId: ami-2f726546
```

このように定義したパラメーターを`Ref: InstanceTypeParameter`として任意の場所で呼び出すことができます。

## Fn::ImportValueによる実現方法
cfnテンプレートでは[Output](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html)という記述で、他のテンプレートから参照できる変数みたいなものを定義できます。
別スタックのOutputを参照する関数が[Fn::ImportValue](http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-importvalue.html)です。

これらを組み合わせることで、共通部分を記述したのcfnテンプレートからスタックを作成し、固有部分が記述されたスタックからはImportValue関数で必要な情報を取得する、といった実装が可能になります。

こちらも、環境ごとの差異が小さい場合におすすめの方法です。

## Ansibleによる実現方法
上記で紹介した方法では、設定値の差や名前の違い等の小さな環境ごとの差は吸収することができます。
ですが、以下に示す例のような場合はcfnテンプレートの重複を避けつつ実現することは困難です。

- 環境によって必要なAWSリソースの数が異なる
  - 本番環境だけSQSが多い
  - 本番環境だけドメインが2個あるのでELBが2個必要
- 一部の環境だけ記述が必要/不要な設定がある
  - テスト環境だけ特定の文字列が入ったタグが必要 

私は、これらの環境ごとに異なるテンプレートを、重複した記述を避けつつ作成するために、[Ansible](https://www.ansible.com/)を利用してcfnテンプレートを全環境分一括で生成する手法を採用しました。

テンプレート機能を持っていればツールとしては何でも良かったのですが、以下の理由からAnsibleを採用しました。

- すでにEC2インスタンス用のAMIの作成のためにAnsibleを採用している
- Ansibleのテンプレート機能はpythonライブラリのjinja2
- Ansibleの設定ファイルはyamlであり、cfnテンプレートと同じなため、学習コストが低い

### サンプル
サンプルは以下で公開しています。
[ansible-cfn-template-generator-sample](https://github.com/reireias/ansible-cfn-template-generator-sample/tree/master)

ファイル構成は次のようになっています。
フォルダ構成はAnsibleのベストプラクティスを参考にしています。

```
.
├── cfn_template
│   ├── develop-template.yml
│   ├── product-template.yml
│   └── test-template.yml
├── generate-template.yml
└── roles
    └── generate-template
        ├── tasks
        │   └── main.yml
        ├── templates
        │   └── cfn-template.yml.j2
        └── vars
            └── main.yml
```

ファイル生成の関係は次のようになります。(roles/generate-templateのパスは省略)
![ansible.png](https://qiita-image-store.s3.amazonaws.com/0/214526/cead5359-ab54-db15-4d72-0a4544c32a47.png)
`vars/main.yml`に書かれた環境ごとの設定と、雛形となる`templates/cfn-template.yml.j2`というjinja2テンプレートファイルをansibleスクリプトである`tasks/main.yml`が読み込みます。このタスクにて、設定ファイルに書かれた環境分のcfnテンプレートファイルが生成され、cfn_templateディレクトリ以下に出力されます。
ジェネレートされたファイルを直接編集しないことで、重複を排除できています。

では、1つずつ中身を見ていきましょう。

まずはAnsible playbookファイルである`generate-template.yml`です。
このファイルはlocalで`generate-template`ロールを実行する、ということが書かれています。

```yaml:generate-template.yml
---
- name: Generate CloudFormation template files.
  hosts: localhost
  connection: local
  roles:
    - generate-template
```

次に設定ファイルである`roles/generate-template/vars/main.yml`です。
このファイルではjinja2テンプレートから参照する値を定義しています。
develop, test, productの3つの環境について設定を定義しています。

```yaml:roles/generate-template/vars/main.yml
---
environments:
  # 開発環境
  - name: 'develop'
    hoge_tag: false
    sqs:
      - 'dev-queue'

  # テスト環境
  - name: 'test'
    hoge_tag: false
    sqs:
      - 'test-queue'

  # 本番環境
  - name: product
    hoge_tag: true
    sqs:
      - 'product-queue1'
      - 'product-queue2'
```

続いて全環境の雛形となるjinja2テンプレートファイル`roles/generate-template/templates/cfn-template.yml.j2`です。
jinjaテンプレートはfor文やif文を使用することができ、より柔軟に環境の差異を実現することができます。

```jinja:roles/generate-template/templates/cfn-template.yml.j2
AWSTemplateFormatVersion: "2010-09-09"
Description: "{{ item.name }} template"
Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: "{{ item.name }}-bucket"
{% if item.hoge_tag %}
      Tags:
        - Key: "hoge-key"
          Value: "hoge-value"
{% endif %}
{# jinja2はこのようにコメントを書くこともできる #}
{% for sqs in item.sqs %}
  SQS{{ loop.index }}:
    Type: "AWS::SQS::Queue"
    Properties:
      QueueName: "{{ sqs }}"
{% endfor %}
```

そして、これらのファイルからcfnテンプレートを生成するタスクファイル`roles/generate-template/tasks/main.yml`がこちらです。

```yaml:roles/generate-template/tasks/main.yml
---
- name: Create cfn template files.
  template:
    src: cfn-template.yml.j2
    dest: 'cfn_template/{{ item.name }}-template.yml'
    mode: 0644
    validate: 'aws cloudformation validate-template --template-body file://%s'
  with_items: '{{ environments }}'
```

Ansibleのtemplateモジュールを使い、cfnテンプレートファイルを生成しています。
`with_items`で設定ファイルのenvironmentsを指定しています。こう指定することで、jinjaテンプレート中で変数`item`でenvironmentsの1要素にアクセスすることができるのです。
また、`validate`にはawscliによるcfnテンプレートのvalidateコマンドを指定しています。こうすることで、生成されたcfnテンプレートファイルのバリデーションに失敗した場合、Ansibleのタスクが失敗し、テンプレートファイルは生成がキャンセルされます。

以下のansible-playbookコマンドを実行することでcfnテンプレートを生成することができます。

```shell
$ ansible-playbook generate-template.yml
```

それでは、生成されたcfnテンプレートファイルを見てみましょう。

```yaml:cfn_template/develop-template.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "develop template"
Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: "develop-bucket"
  SQS1:
    Type: "AWS::SQS::Queue"
    Properties:
      QueueName: "dev-queue"
```

```yaml:cfn_template/test-template.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "test template"
Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: "test-bucket"
  SQS1:
    Type: "AWS::SQS::Queue"
    Properties:
      QueueName: "test-queue"
```

```yaml:cfn_template/product-template.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "product template"
Resources:
  S3Bucket:
    Type: "AWS::S3::Bucket"
    Properties:
      BucketName: "product-bucket"
      Tags:
        - Key: "hoge-key"
          Value: "hoge-value"
  SQS1:
    Type: "AWS::SQS::Queue"
    Properties:
      QueueName: "product-queue1"
  SQS2:
    Type: "AWS::SQS::Queue"
    Properties:
      QueueName: "product-queue2"
```

`hoge_tag: true`と指定したproduct環境だけS3にTagの設定が書かれています。
また、sqsもproduct環境だけ2個あり、他の環境は1個になっています。

これで環境ごとの差異を設定ファイルからcfnテンプレートに流しこむことができました。

## まとめ

Ansibleって割となんでもできる！！