packer と terraform と ansible でインフラを作る

# packer と terraform と ansible でインフラを作る

## 概要

長くなるので概要とつまづいた点に絞ってます。
packer, terraform, ansibleの使い方がわかっていている前提です。

インフラはAWSを使います。

1. packer(+ansible)でアプリケーションが乗ったAMIを作る
2. terraformでインスタンスを動かす
3. ansibleで環境を整える
4. codedeploy でデプロイ


packer, terraform, ansibleの実行にはお互いを必須としないようにしています。そのため入れ替えることは可能なはずです。
terraformはcloudformation、ansibleはchefに変えられるかと思います。

1. packer
AMIをタグ付きで登録する

2. terraform
data "aws_ami"としてAMIを受け取る
インスタンス起動時にAWSタグを登録する。

3. ansible
AWSタグを元に構成する。


## 目標

1. AWSのから割り当てられる動的なアドレスを参照しないで済ます
2. コードに乗らないインフラの設定をなくす

ディレクトリの構成は下記のようにしています。

```
web_build.json
web_build.sh
batch_build.json
batch_build.sh
dev.json
production.json
scripts/web.sh
scripts/batch.sh
scripts/delete_old_ami.sh
terraform/
    dev/
        ...
    production/
        ...
ansible/
    ...
```

## 必要なもの

1. packer
2. terraform
3. ansible
4. awscliで`aws configure`
5. `export AWS_DEFAULT_REGION=region名`

# packer

## イメージを作る

buildersにはamazon-ebs or amazon-instanceで、provisionerでansibleを行うscriptを走らせます。

ロールごとのビルド用のpacker jsonと環境ごとの必要なパラメータを持ったjsonを用意しておくのがいいでしょう。

例えばdev環境のwebロールのビルドはこんな感じで済ませれるようにします。

```
packer build -var-file dev.json build_web.json
```


ansible-localでは後のansibleで紹介するAWSタグを変数として取ってくる機能が扱いづらいので、タグ相当のパラメータをここで渡してしまいます。

また、ビルド時にはやって欲しくないtaskに`when: not build`を設定しておいて、`build=false`を全体のデフォルトにし、ビルド時のみ`build=true`を渡します。具体的にはビルド時にはログを送りたくないのでfluentdは起動しないなどです。


ansibleがローカルモードで動かされるので`hosts`は`127.0.0.1`とされてます。
そのため、`inventory_file`を使って下記のようなファイルを作っていきます。

```ini:build_inventory/dev-web
[dev_web]
127.0.0.1
[web:children]
dev_web
[dev:children]
web
```

よって、ansibleで設定するinventoryによるホストのグループ作成には一定のルールで統一が必要です。
ここでは`環境名`,`役割名`,`環境名_役割名`というグループでまとめることにしています。`dev`が環境名、`web`が役割名です。
playbookは役割名ごとに作成しているので、`playbook_file`の指定は役割名です。

ここでAMIのタグを打っておくことで、後ほどterraformでAMIを取得できます。

また、ansible-vaultを使っているので一時配置して削除しています。これはpacker実行する前に環境変数に `ANSIBLE_VAULT` として入れておきます。
それとansibleを入れるのにgcc必要になってしまったので"Development Tools"追加してます。


```json:build_web.json
{
  "variables": {
    "aws_profile": "{{env `AWS_PROFILE`}}",
    "role": "web",
    "environment": "dev",
    "region": "{{env `AWS_DEFAULT_REGION`}}",
    "ansible_vault": "{{env `ANSIBLE_VAULT`}}",
    "source_ami": ""
  },
  "builders": [{
      "tags": {
      "environment": "{{user `environment`}}",
      "role": "{{user `role`}}",
      "version": "{{timestamp}}",
      "buildYear": "{{isotime \"06\"}}",
      "buildMonth": "{{isotime \"01\"}}",
      "buildDay": "{{isotime \"02\"}}"
    },
    "ami_name": "{{user `environment`}}-{{user `role`}} {{timestamp}}"
  }],
  "provisioners": [
    {
      "type": "shell",
      "inline": [
        "echo {{user `ansible_vault`}} > ~/.ansible-vault",
        "sudo yum groupinstall -y \"Development Tools\"",
        "sudo yum install -y libffi-devel openssl-devel",
        "sudo pip install --upgrade setuptools",
        "sudo pip install cffi",
        "sudo pip install ansible"
      ]
    },
    {
      "type": "ansible-local",
      "extra_arguments": [
        "--extra-vars",
        "'{\"tags\": {\"role\":\"{{user `role`}}\", \"environment\":\"{{user `environment`}}\"}, \"build\":\"true\"}'",
        "-D",
        "-v",
        "--vault-password-file",
        "~/.ansible-vault"
      ],
      "playbook_file": "./ansible/{{user `role`}}.yml",
      "playbook_dir": "./ansible/",
      "inventory_file": "./ansible/build_inventory/{{user `environment`}}-{{user `role`}}"
    },
    {
      "type": "shell",
      "inline": [
        "rm -f ~/.ansible-vault"
      ]
    }
  ]
}
```

環境設定のjsonで `environment`やら`source_ami`などを埋めるようにしたものを `dev.json` として用意した上で

`packer build -var-file dev.json build_web.json`

でビルドができるようになります。

## その他スクリプト

古くなったAMIを削除するといった素敵機能はないため、AMI作成時にversionのタグ付けておいてタイムスタンプを値に入れておき、タグ`version`の値から`${REMOVE_TIMESTAMP}`より低い対象をのAMIを一覧することで削除しています。

```
aws ec2 describe-images --owner self --filters 'Name=tag-key,Values=version' --query "Images[?Tags[?Key==\`version\`].Value|[0]<=\`\"${REMOVE_TIMESTAMP}\"\`][].ImageId" --output text
```

デプロイされてるAMIを無視するなどの機能を付与してスクリプトを配置してビルド時についでに実行させます。

他に、S3に上げる古いバージョンのデータ削除するとかpackerで定期的に実行する必要があるものがあればscripts/に追加しています。



# terraform

AMIは下記のように取得できます。

```
data "aws_ami" "web" {
  owners = ["self"]

  filter {
    name   = "state"
    values = ["available"]
  }

  filter {
    name   = "tag:role"
    values = ["web"]
  }

  most_recent = true
}

```


data.aws_amiは下記のように参照できます。
インスタンスの設定にはansibleのために必ずサーバの環境名と役割名を記述したAWSのタグを付与するようにタグを付与してます。

```
  image_id              = data.aws_ami.web.id
  tag {
    key                 = "Name"
    value               = "web-${data.aws_ami.web.tags["version"]}"
    propagate_at_launch = true
  }

  tag {
    key                 = "role"
    value               = "web"
    propagate_at_launch = true
  }

  tag {
    key                 = "environment"
    value               = "dev"
    propagate_at_launch = true
  }

  tag {
    key                 = "version"
    value               = data.aws_ami.web.tags["version"]
    propagate_at_launch = true
  }
```

## ビルドしたインスタンス

ビルドしたAMIを参照するのには上記の通り`data.aws_ami`を使っています。これによって、新しくビルドが行われバージョンが変わった際に`terraform apply`でインスタンスを切り替えることができます。

ただし、削除してから起動するのがデフォルトの動作で、新しいインスタンスを作ってから削除する場合には```lifecycle { create_before_destroy = true }```
が必須です。

そのため、フロントのアプリケーションサーバのような一時的にでも消えてもらっては困るサーバは`aws_autoscaling_group`, `aws_launch_configuration`を作ります。

または、route53で指定しているサーバでも、下記手順でサーバの移動が可能です。複数台同時に起動してもいい前提です。

1. terraformのtfstateから該当サーバの情報を削除
2. terraformを`-target`付きでinstanceのみ適用し、新しいインスタンスを起動する
3. インスタンスが起動し終わったら、再びterraformを適用してroute53も更新する
4. 古いインスタンスを手動で削除する


### ローリングアップデート

`aws_autoscaling_group` に `instance_refresh` が追加されているので、自動でやりたければそちらを設定しましょう。
手動でやりたければautoscaling画面からも実行できます。


## その他インスタンス

データベースサーバのようなものはAMIを作ってもデータの入っているEBSがなければ意味がないので、ansibleのみで環境設定しています。

入れ替えは自動で起こらないので初回起動時にansibleを実行してセットアップするという方針にしています。

## route53

terraformではroute53は絶対に活用すべきです。すべてのELB及びインスタンスなどにDNSを設定しておけば、環境を削除して作り直しても同じ名前が設定不要で使えます。


# ansible

## dynamic inventory

ansibleのdynamic inventory機能を使ったec2から情報を取ってくるinventory pluginを使うことで指定した範囲で起動しているインスタンスがタグ付きで管理できます。よってterraformで一律設定したAWSのタグが生きてきます。

https://docs.ansible.com/ansible/latest/plugins/inventory/aws_ec2.html

環境ごとのinventoryのディレクトリ内において、さらにタグごとにホストをまとめるようなファイルを配置しておくと扱いやすいです。
また、親子関係を作っておくことで、変数の上書きが使えるようになるので環境ごとの違いを設定しやすくなります。

タグは`tags.tagkey` の形式で受け取れます。この辺は`ansible-inventory -i 環境名 --graph --var`を直接実行すると確認できます。

まとめかたはpackerで利用している都合上統一する必要があるので`環境名`,`役割名`,`環境名_役割名`で作ります。

```dev/aws_ec2.yml
plugin: aws_ec2
regions:
  - us-east-1
strict: False
hostnames:
  - private-ip-address
# tag_role_`roleValue` と tag_Name_`NameValue` という名前でグルーピングする
keyed_groups:
  - prefix: tag_role
    key: tags.role
  - prefix: tag_Name
    key: tags.Name
# VPCとVPNして利用しているので、ansibleのhostはprivate_ip_addressを使ってます
compose:
  ansible_host: private_ip_address
```

```dev/groups.yml
dev:
  children:
    web:
      children:
        dev_web:
          children:
            tag_role_web
    batch:
      children:
        dev_batch:
          children:
            tag_role_batch
    db:
      children:
        dev_db:
          children:
            tag_role_db:
            db_master:
              children:
                tag_Name_dev_db01
              vars:
                db_master: true
```

これでanasible playbookのhosts指定やgroup_varsの指定としてwebやbatchが使えます。
ec2のインスタンスのアドレスやインスタンス数がどう変わろうがansible側の設定は変更不要ということです。
ただ、前述のpackerのビルドのinventoryファイルがあるため完全に一意の場所で管理できていない残念なところです。

そうなってしまう理由としてpackerでは

1. `inventory_groups`はchildrenが使えない
2. `inventory_groups`と`inventory_files`は両立しない

ことが挙げられます。よってplaybookファイルを分けるか`inventory_files`をビルドように作るしかありません。他に何か方法があればいいのですが・・・。


# ビルドについて

デプロイに必要なデータ一式を適当なディレクトリに配置するビルドスクリプトを用意しておいてCodeDeployにpushしs3にアップロードします。

codedeployにpushする方法はCIツールによるので省略します。jenkinsのプラグインもありますし、codeship, werckerなどにもexampleが存在しています。

デプロイしてその結果が成功かどうかまで面倒を見てくれるのでawscliのコマンドからスクリプトを作るよりいいでしょう。
codedeployの設定はterraformで記述できるようになっているので、ありがたく利用させてもらうことにしましょう。

codedeployでデプロイ対象としてautoscalingを指定している場合は新しく起動するインスタンスに最新の成功したビルドが起動時にデプロイされますが、autoscalingではないインスタンスはやってくれないので、下記のようなスクリプトをuserdataに指定して最新をデプロイさせるようにしました。

```
#!/bin/bash -ex
APPLICATION=dev-batch
REGION=us-east-1

exec > >(tee /var/log/user-data.log|logger -t user-data -s 2>/dev/console) 2>&1
aws s3 cp s3://aws-codedeploy-us-east-1/latest/install . --region ${REGION}
chmod +x ./install
./install auto

DEPLOY_GROUP=$(aws --region ${REGION} deploy get-deployment-group --application-name ${APPLICATION} --deployment-group-name ${APPLICATION}|jq '.deploymentGroupInfo.targetRevision.s3Location')

if [ -z "${DEPLOY_GROUP}" ]
then
echo "no deployment"
exit 0
fi

ETAG=$(echo "${DEPLOY_GROUP}" | jq .eTag)
S3_BUCKET=$(echo "${DEPLOY_GROUP}" | jq .bucket)
S3_KEY=$(echo "${DEPLOY_GROUP}" | jq .key)
BUNDLE_TYPE=$(echo "${DEPLOY_GROUP}" | jq .bundleType)

aws --region "${REGION}" deploy create-deployment --application-name "${APPLICATION}" --deployment-group-name "${APPLICATION}" --s3-location bucket="${S3_BUCKET}",key="${S3_KEY}",bundleType="${BUNDLE_TYPE}",eTag="${ETAG}"
```

ビルド時に必要な変数はpackerと同じく環境用のjsonから取ってくれば無駄がありません。`jq`あたりで簡単に値を取得できるので、packerと同じ変数を渡す開発用のビルドスクリプトはそう手間がかからずに出来上がります。


# CIでの実行

環境変数に下記を設定してビルドスクリプトを実行します。

```env
AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY
AWS_DEFAULT_REGION
```

### ansible

ansibleで行うような変更は自動化しないほうが扱いやすかったので、変更時に自分で実行するようにしています。


### terraform

ansibleと同様に変更時に実行するようにしています。

terraform cloudがHost単位ではなくユーザ単位の料金プランになってくれたので乗り換え検討してもよさそうです。

https://www.terraform.io/


# アプリケーションの配置

Codedeploy, ECSなどを利用します。
長くなるので別記事にしてます。
codedeployならば、デプロイの度にAMIを作り直さなくても、最新のデプロイデータを配置することが簡単に実現できます。

* [CodeDeployによるデプロイ環境](http://qiita.com/kanagi/items/197594c8d7ff36e18d9b)

ECSだとアプリケーションはdockerイメージがあればいいのでDocker使うならこのほうが楽です。

* [AWS ECSとconsul で作る docker デプロイ環境](https://qiita.com/kanagi/items/1b55afcd574c950b9127)

# 最後に

全インフラ構成がgitに乗っかるので

1. コードレビューできる
1. すべてコードに乗るので作り直しが簡単。不要な時間インスタンスを落とせて、好きな時に立ち上げることもできる。

サーバのローカルに、codedeployに乗らないようなファイルや、保存されるファイルがないようになっていれば、AMIからすぐインスタンスの追加ができます。

一応、保存されるファイルがある場合にも、terraformでebsを別記述としてdestroyする際にインスタンスのみを削除することebsは消さずに次回インスタンス作成時に同じebsをattachさせるようなことはtfファイルを書き換えずに実現は可能です。

* [terraformでcountを使った複数インスタンスにEBSを割り当てる](http://qiita.com/kanagi/items/95e1e0d79fa098b95610)