Terraform と CI で実現するインフラのコード化と構築の自動化

[Wantedly Advent Calendar 2015](http://qiita.com/advent-calendar/2015/wantedly) __18__日目です。

* * *

インフラチームインターンの @dtan4 です。

Wantedly では [Terraform](https://www.terraform.io) を用いたインフラのコード化 (Infrastructure as Code) を全面的に取り入れています。インフラリソースの追加や修正は、コードを書くこと・CI 上での自動適用によって行われています。

この記事では、今年5月から半年以上の間 Terraform を運用してきた中での

- なぜ Terraform でインフラをコード化しようとしたのか
- どのように Terraform を運用しているのか
- Terraform 運用にあたって注意すべき点
- 既存リソースから Terraform コードを生成する Terraforming について

ということを紹介したいと思います。

# Terraform とは
__[Terraform](https://www.terraform.io/)__ は、[Vagrant](https://www.vagrantup.com) などで有名な [HashiCorp](https://www.hashicorp.com/) が作っている__コードからインフラリソースを作成する・コードでインフラを管理する__ためのツールです。AWS, GCP, Azure, DigitalOcean といったクラウドプロバイダや DNSimple, Mailgun, Rundeck を含む多くの SaaS に幅広く対応しています。

コードは JSON 互換である [HCL (HashiCorp Configuration Language)](https://github.com/hashicorp/hcl) で記述します。例えば AWS ELB と EC2 インスタンスは

```
# from https://www.terraform.io/
resource "aws_elb" "frontend" {
    name = "frontend-load-balancer"
    listener {
        instance_port = 8000
        instance_protocol = "http"
        lb_port = 80
        lb_protocol = "http"
    }

    instances = ["${aws_instance.app.*.id}"]
}

resource "aws_instance" "app" {
    count = 5

    ami = "ami-043a5034"
    instance_type = "m1.small"
}
```

このように書けます。このコードに対して `terraform apply` コマンドを実行すると、実際の AWS 上に ELB と EC2 インスタンス5個が作られます。内容を変更したいときはコードを書き換えて再度 `terraform apply` すれば変更が実環境に適用されます。コードを削除して `terraform apply` すればリソースが削除されます。

ただ Terraform はコードからリソースを作ることはできますが、リソースからコードを生成する機能は今のところ持ちあわせていません（[機能要望の Issue は1年以上前からあるのですが](https://github.com/hashicorp/terraform/issues/581)）。それを解決するためのツールが、自分 @dtan4 の作った [Terraforming](https://github.com/dtan4/terraforming) なのです。Terraforming については後述します。


# なぜインフラをコード化したのか

Terraform 導入前は、以下の様な問題点がありました。

- 開発チームからインフラチームへの依頼によるスタイル
- 新しいリソースを作るのに Management Console 上でポチポチ操作してくのが面倒であり、工数もかかる
- しかもポチポチ作業の履歴を残すのが困難
- Management Console に行かないとリソースの一覧が把握できない
- 同じ構成でもリソースの複製が面倒

これらは

- 開発者が欲しいリソースをコードという形で記述して、インフラチームに Pull Request を送る
- インフラチームは送られてきたコードを GitHub 上でレビューして、"Merge Pull Request" ボタンを押すだけでリソースが作られる
- リソースの追加や変更履歴は、git の diff という形で残る
- リソース複製もコードのコピペで済む

という形で解決しようと考えたのです。

# なぜ Terraform にしたのか
Wantedly では多くの AWS サービスを利用していますが、それらをひとつのツールで一元的に管理できるというのが決め手でした。DNS に関しては Route53 ではなく DNSimple を利用しているのですが、それも含めて管理できるというのは大きかったです。

cookpad が作っている [codenize.tools](http://codenize.tools/) も検討しましたが、サービス単位でツールが別れていることと対応サービスが限られていたので採用を見送りました。

# GitHub + Terraform + CI (wercker) による Terraform flow
開発者（というかリソースを追加・変更したい人）は Terraform のコードを書いて、Terraform コード専用のプライベートリポジトリに Pull Request を作ります。

![image](https://qiita-image-store.s3.amazonaws.com/0/17508/9b5c19a9-0050-8e03-7a6e-1a628cf8d835.png)

コードがリポジトリに push されると、[wercker](https://app.wercker.com/) 上で自動的に `terraform plan` が実行され、.tf ファイルの中身が検査されます。

![image](https://qiita-image-store.s3.amazonaws.com/0/17508/b73d814c-f323-1b0a-d669-ae5f187effdc.png)

Pull Request の内容はインフラチームがレビューして、LGTM :+1: であれば Merge します。master ブランチに Merge されることで wercker 上で `terraform apply` が実行され、実際の環境に変更が反映されるようになっています。

![image](https://qiita-image-store.s3.amazonaws.com/0/17508/9f348af3-f8aa-bd94-b788-503aefe050da.png)

![image](https://qiita-image-store.s3.amazonaws.com/0/17508/160c50bd-ba0f-a7ad-37c5-1a1740f81b53.png)

リソースがすべて GitHub 上でコードとして見られるようになったので、いろいろと便利にもなりました。

![image](https://qiita-image-store.s3.amazonaws.com/0/17508/7ec1509e-40ee-95a2-99db-3c254146d93d.png)

## `wercker.yml`
CI 実行内容である `wercker.yml` は以下のようになっています。

```yaml
box: wercker-labs/docker
no-response-timeout: 30
build:
  steps:
    - create-file:
        name: Create .env
        filename: $WERCKER_SOURCE_DIR/.env
        hide-from-log: true
        content: |
          AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
          AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
          AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION
          DNSIMPLE_EMAIL=$DNSIMPLE_EMAIL
          DNSIMPLE_TOKEN=$DNSIMPLE_TOKEN
    - create-file:
        name: Create terraform.tfvars
        filename: $WERCKER_SOURCE_DIR/terraform.tfvars
        hide-from-log: true
        content: |
          "hoge_password"="$HOGE_PASSWORD"
          ...
    - script:
        name: Pull Terraform image
        code: |
          docker pull quay.io/wantedly/terraform:latest
    - script:
        name: terraform remote config
        code: |
          script/ci-terraform remote-config
    - script:
        name: terraform plan
        code: |
          script/ci-terraform plan
  after-steps:
    - wantedly/pretty-slack-notify:
        webhook_url: $SLACK_WEBHOOK_URL
        channel: $SLACK_CHANNEL
deploy:
  steps:
    - script:
        name: Pull Terraform image
        code: |
          docker pull quay.io/wantedly/terraform:latest
    - script:
        name: terraform apply
        code: |
          script/ci-terraform apply
    - script:
        name: terraform remote push
        code: |
          script/ci-terraform remote-push
  after-steps:
    - wantedly/pretty-slack-notify:
        webhook_url: $SLACK_WEBHOOK_URL
        channel: $SLACK_CHANNEL
```

ここで、`script/ci-terraform` というのは以下の様なシェルスクリプトになっています。Terraform を Docker contaiener として走らせています。

```bash

#!/bin/bash
#
# Usage: ci-terraform apply|plan|remote-config|remote-pull|remote-push
# Description: run Terraform on CI environment
#

if [ $# != 1 ]; then
  exit 1
fi

case $1 in
  "apply" )
    docker run --rm --name terraform --env-file=$WERCKER_SOURCE_DIR/.env -v $PWD:/terraform quay.io/wantedly/terraform:latest terraform apply terraform | script/masking-passwords && exit $PIPESTATUS
    ;;
  "plan" )
    docker run --rm --name terraform --env-file=$WERCKER_SOURCE_DIR/.env -v $PWD:/terraform quay.io/wantedly/terraform:latest terraform plan terraform | script/masking-passwords && exit $PIPESTATUS
    ;;
  "remote-config" )
    docker run --rm --name terraform --env-file=$WERCKER_SOURCE_DIR/.env -v $PWD:/terraform quay.io/wantedly/terraform:latest terraform remote config -backend=S3 -backend-config="bucket=$TFSTATE_BUCKET" -backend-config="key=$TFSTATE_KEY" -backend-config="encrypt=1"
    ;;
  "remote-pull" )
    docker run --rm --name terraform --env-file=$WERCKER_SOURCE_DIR/.env -v $PWD:/terraform quay.io/wantedly/terraform:latest terraform remote pull
    ;;
  "remote-push" )
    docker run --rm --name terraform --env-file=$WERCKER_SOURCE_DIR/.env -v $PWD:/terraform quay.io/wantedly/terraform:latest terraform remote push
    ;;
  * )
    exit 1
    ;;
esac
```

また、`script/masking-passwords` は以下の様なシェルスクリプトです。

```bash
#!/bin/bash
#
# Usage: terraform plan | script/masking-passwords
# Description: masking passwords from `terraform plan|apply` output
# Example:
#   password:                "before" => "after"
#     is replaced to
#   password:                "********" => "********" [hidden]
#

if [ $(uname) == "Darwin" ]; then
  # BSD sed
  sed -E 's/(password:\s+|auth:\s+)".*?" => ".*?"/\1"********" => "********" [hidden]/g'
else
  # GNU sed
  sed -u -r -e 's/(password:\s+|auth:\s+)".*?" => ".*?"/\1"********" => "********" [hidden]/g'
fi
```

このシェルスクリプトは、`plan`, `apply` の出力に含まれる

```
#   password:                "before" => "after"
```

を

```
#   password:                "********" => "********" [hidden]
```

のようにマスキングしてくれます。RDS の .tf には `terraform.tfvars` 経由でパスワードを含める必要があるのですが、それが実行時の差分に生文で出力されてしまいます。さすがにマズいのでこのスクリプトを噛ませています。

## `terraform.tfstate` の共有
Terraform では、Terraform 自身が管理しているインフラの情報を持つ `terraform.tfstate` というファイルがあります。このファイルは S3 上に置くことで、ローカルマシンと CI の間で共有できるようにしてあります。詳しくは下の記事を御覧ください。

[Amazon S3 で Terraform の状態管理ファイル terraform.tfstate を管理 / 共有する - Qiita](http://qiita.com/dtan4/items/04632f1c2f35388a3283)

## Terraform 実行環境
ローカルマシンで Terraform を実行するときは、CoreOS VM 上で [quay.io/wantedly/terraform](https://quay.io/repository/wantedly/terraform) Docker image を使って Docker container として動かしています。CI 上でも同じイメージを用いています。各開発者間のローカル環境と CI を統一したい、という考えのもとこのような形になりました。

ただ Terraform 自体は単体バイナリなので、特に CI に関しては無理に Docker container にしなくてもいいのかな…とは思ってたりします。

# Terraform 運用の実態 @ Wantedly
## どれくらいコード化しているのか
数だけで言うと、__28種類__のリソースを__390個__ (AWS: __222個__, DNSimple: __168個__) Terraform で管理しています。

管理しているリソースの種類は、

```
aws_customer_gateway
aws_db_instance
aws_db_parameter_group
aws_db_security_group
aws_db_subnet_group
aws_elasticache_cluster
aws_elasticache_subnet_group
aws_elb
aws_iam_group
aws_iam_group_membership
aws_iam_group_policy
aws_iam_role
aws_iam_role_policy
aws_iam_user
aws_iam_user_policy
aws_instance
aws_internet_gateway
aws_network_acl
aws_route_table
aws_route_table_association
aws_s3_bucket
aws_security_group
aws_subnet
aws_vpc
aws_vpn_connection
aws_vpn_connection_route
aws_vpn_gateway
dnsimple_record
```

です。

EC2 インスタンスは Terraform で管理していません。ほとんどのインスタンスの増減（= スケーリング）は、自前ツールによって行っているためです。もしコンスタントに立ち続けるインスタンスがあるのであれば、Terraform で管理したほうがよいでしょう。

## どれくらいの開発者に使われているのか

![wantedly_wantedly-terraform.png](https://qiita-image-store.s3.amazonaws.com/0/17508/1b99cc25-38f0-9d7a-609c-3dfee90ccccd.png)

というわけで、__18人__の開発者（インフラチーム3人含む）が Terraform のコードを書いています。新サービス ([Sync](https://www.wantedly.com/sync)) チームから S3 bucket や DNSimple DNS record 追加の Pull Request が来ることもあれば、新しく入社したエンジニアから IAM ユーザを追加する Pull Request も来ます。後者は開発環境構築の一部に組み込まれているので、最近入社したエンジニアは皆 Terraform のコードを触っていますね。

![Add_IAM_user.png](https://qiita-image-store.s3.amazonaws.com/0/17508/4a1c5119-196d-c1f3-df33-77b0bd3e3363.png)

ところで、インフラチーム以外の Terraform に不慣れな人にとってはこのリポジトリを触るハードルが高くなります。そのハードルを少しでも下げるため、リポジトリに __Markdown 形式 (= GitHub 上で整形プレビューされる) のドキュメント__を用意しています。

![README_md.png](https://qiita-image-store.s3.amazonaws.com/0/17508/4e3d8cdd-bcd2-fcc1-6beb-0b85ccf58eb2.png)

# Terraform 運用にあたって注意すべきこと :warning: 
## `terraform apply` で失敗する
`terraform plan` はあくまで .tf ファイルのシンタックスチェックを行うだけなので、__内容が AWS API 的に不正だったとしても pass します__。
例えば `aws_elb` の `name` に変な文字を含めていたとしても `terraform plan` は通ります。

これに関しては、ちゃんと AWS のドキュメントを読んで理解した上で書いたりする必要があります…。API の dry-run ができるとベストなのですが、見た感じ EC2 系の API にしか dry-run オプションが用意されてないようです。

また、CI 上で自動 `apply` する環境を整えている場合はリカバリできるよう、手元でも `terraform apply` を実行できるような環境を用意しておくべきです。
自分たちの場合は、`apply` 失敗したら .tf ファイルを修正した新しい Pull Request を作成し、改めて master に merge => deploy するようにしています。

## ELB 配下のインスタンスが意図せず置き換わる => `lifecycle` で解決!?
Wantedly では、ELB 配下のインスタンスのスケーリングや入れ替えを自前ツールで行っています。このため、.tf ファイルにインスタンス ID をハードコードすると予期せず実際の環境と差分が出てしまうようになっていました。

…が :exclamation: 今年10月にリリースされた Terraform v0.6.4 にて [`ignore_changes`](https://www.terraform.io/docs/configuration/resources.html#ignore_changes) というパラメータが新たに導入されました。`ignore_changes` は、指定した属性の変更を `plan`, `apply` 時に無視するようにしてくれます。

例えば `aws_elb` の `instances` の変更を無視させたい場合は、

```go
resource "aws_elb" "foo" {
    lifecycle {
        ignore_changes = ["instances"]
    }
}
```

と書けばよいのです！まさに今回の問題を解決するための機能でした。

## IAM ユーザ削除に失敗する
退職などに伴って IAM ユーザを削除するには、そのユーザに紐付いている

- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`
- Management Console パスワード
- IAM Policy
- IAM Group 所属情報

を前もって削除する必要があります。Management Console から削除する場合は同時に削除されますが、API 経由だと一つ一つ削除していかないといけません。詳しくはこちら:

[IAM ユーザーの管理 - AWS Identity and Access Management](http://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_users_manage.html#id_users_deleting)

ところで、Terraform で IAM ユーザを消す場合はもちろん API 経由のアプローチになるのですが、その場合__紐付いたリソースを無視していきなり IAM ユーザ削除 API を叩く__ので、高確率で `terraform apply` で以下の様なエラーが出ます。

```
Error applying plan:

1 error(s) occurred:

* Error deleting IAM User hoge: DeleteConflict: Cannot delete entity, must delete access keys first.
```

これの対策として、__Management Console 上で IAM ユーザを削除してしまったうえで `terraform apply` を実行__するようにしています。自動化をすすめる上ではよくないアプローチでと思いますが、現状削除が発生する頻度は少ないのでこうなっています…

# Terraforming: 既存のインフラリソースから Terraform のコードを生成する
ここでいきなり拙作の~~宣伝~~紹介です。

__[Terraforming](https://github.com/dtan4/terraforming)__ は、既存の AWS リソースから Terraform のコード `.tf` と状態管理ファイル `terraform.tfstate` を生成するコマンドラインツールです。Wantedly の Terraform コードの大半は、Terraforming によって生成されたコードです。

Terraforming の詳しい使い方については、Qiita と個人ブログに別記事がありますのでそちらをご参照ください。

- [Terraforming で既存のインフラを Terraform 管理下におく - Qiita](http://qiita.com/dtan4/items/345c56281ab0e87d6646)
- [Terraforming: 既存のインフラリソースを Terraform コードに落としこむ - 端子録](http://dtan4.hatenablog.com/entry/2015/06/20/181559)

企業で Infrastructure as Code を導入するときって、__既存のリソースをコード化したい__という要望が多いんじゃないかと思っています。新規事業を立ち上げるタイミングならともかく…。Wantedly もまさにそうで、最初は対応する .tf 書けばいいのかなと思ったらそれだけじゃ済まないとわかってうわぁああ :scream: となりました。

これは手作業でするには厳しい、でも機能としての要望は多いはず :bulb: そう思って Terraforming を作り始めたのです。

## OSS としての Terraforming

おかげさまで国内外問わず色んな方や企業さんに使っていただいているようで、今でも[継続的に Pull Request を頂いております](https://github.com/dtan4/terraforming/pulls?utf8=%E2%9C%93&q=is%3Apr+-author%3Adtan4+)。
ぶっちゃけ自分が普段触らないリソースに関してはそれほど追加するモチベーションが沸かないのですが、他の方が使ってみた上でこのリソース欲しい！ということで Pull Request をくださるケースが増えています。本当に感謝です、ありがとうございます :bow:

今後も継続的に開発は続けていきます。とりあえず AutoScaling 対応と、別 gem になってる [terraforming-dnsimple](https://github.com/dtan4/terraforming-dnsimple) をプラグインとして読み込めるようにしたいです。RubyKaigi での[古橋さんの発表](http://www.slideshare.net/frsyuki/pluginbased-software-design-with-ruby-and-rubygems)が参考になりそう。

# おわりに
というわけで、Wantedly での Terraform 運用事例の紹介でした。インフラのコード化や、Terraform の導入を考えている方の参考になれば幸いです。
この構築方法はあくまで一例であり、より良いベストプラクティスがあるかもしれません。他の事例も参考にしてよりブラッシュアップしていけたらと思います。

また、今年の8月に開催した [HashiCorp Product(Tools) Meetup](http://engineer.wantedly.com/2015/08/06/hashicorp-product-meetup.html) でも同様の内容を紹介していたのでした。その頃から多少のアップデートもありますが、あわせて御覧ください。

[Terraform at Wantedly // Speaker Deck](https://speakerdeck.com/dtan4/terraform-at-wantedly)

[![スクリーンショット 2015-12-18 22.35.14.png](https://qiita-image-store.s3.amazonaws.com/0/17508/3a2a41d1-d052-8213-9bde-a0434ca67d9d.png)](https://speakerdeck.com/dtan4/terraform-at-wantedly)