Terraform職人入門: 日々の運用で学んだ知見を淡々とまとめる

はじめに

この記事は CrowdWorks Advent Calendar 2017 の8日目の記事です。

Terraform職人の @minamijoyo です。Infrastructure as Codeしてますか?

インフラのコード管理に Terraform を使い始めて2年ちょっと、本番環境で運用していると日々色んな学びがあるので、Terraformやってみた系の入門記事では語られない、現場の運用ノウハウ的なものを共有してみようかと思います。
Terraformを使い始めた or 使っている人が、こんなときどうするの?っていうときに参考になれば幸いです。

書き始めたら超長文になりました。概要は以下のとおりです。

  • 公式ドキュメントを読もう
  • tfファイルを書く技術
    • インデントを揃える
    • 組み込み関数に親しむ
    • lifecycleブロックを使う
      • リソースの差分を無視する
      • リソース再生成のときに新しいのを作ってから古いのを削除する
      • リソースのうっかり削除の保護
    • テンプレートを使う
      • 外部ファイルを文字列として読み込む
      • テンプレートに変数を埋め込む
    • モジュールでコードを共通化する
      • モジュールの作り方
      • production/stagingなどの環境差分を管理する
      • モジュール間で値を参照する
      • Stateを跨いで値を参照する
    • 条件分岐したい
      • 条件によって変数の値を変える
      • 条件によってリソースを作成したりしなかったり
  • Terraformのバージョンを管理する技術
    • Terraformをバージョンアップする
    • Terraformのバージョンを切り替える
    • Terraformのバージョンを固定する
    • Terraformプロバイダをバージョンアップする
    • Terraformプロバイダのバージョンを固定する
  • tfstateファイルを書く技術
    • ローカルでミニマムのStateを見てみる
    • リモートのStateを見る
    • 強制的にリソースを再生成する
    • Terraform管理外の既存のリソースをTerraform管理下に入れる
    • tfファイルをリファクタリングする
  • Terraformをデバッグする技術
    • デバッグログを出力する
    • Terraformのソースコードをコンパイルする
    • デバッグログにソースコードのファイル名と行番号を付ける
    • Terraformをデバッガでステップ実行する

本稿執筆時点のTerraformのバージョンはv0.11.1です。普段はAWSをいじってるので、説明の例としてAWSが出てきますが、扱っているトピック自体はAWSだけじゃない汎用的な話がほとんどなので、適宜お使いのクラウドプロバイダーに読み替えて下さい。

公式ドキュメントを読もう

まず最初に、何を当たり前のことを言われそうですが、分からないことはまずは公式ドキュメントを読みましょう。

Terraformは現在も活発に開発されているプロダクトです。ネットでぐぐって出て来る情報は古いことがあるので(この記事もいずれはそうなっちゃうと思いますが)、迷ったら公式ドキュメントを見に行くのが確実です。
https://www.terraform.io/docs/index.html

Terraformで使われている用語が分かると公式ドキュメントなどが読みやすくなるので、最低限理解しておきたい用語についてざっくり説明しておくと、以下の理解でだいたいOK。

用語 意味
Configuration インフラ設定のコード。要するに *.tf ファイルにDSLで書くTerraformのコードのこと。
HCL HashiCorp Configuration Languageの略。*.tf ファイルで使われているDSLのこと。
Resource Terraformで管理する対象の基本単位。
Data Source Terraform管理外だけど、Terraform内で参照したい参照専用の外部データ。
Provider ResourceやData Sourceなどを作成/更新/削除するプラグイン。aws/google/azurermなど。
Provisioner リソースの作成/削除時に実行するスクリプトなどのプラグイン。local-exec/remote-exec/chefなど
State Terraformが認識しているリソースの状態。 *.tfstate ファイルのこと。
Backend Stateの保存先。local/s3/gcsなど。
Module ResourceやData Sourceなどを再利用可能なようにまとめたConfigurationの単位。

ところで、 terraform apply してみたらエラーが出たとかあるあるで、公式ドキュメントの対象のリソースの説明読んでもパラメータの意味とかがよく分からない場合、関連するIssueを探したり、最終的にソースコードを読みに行くしかないです。
Terraformはv0.10からProviderごとにリポジトリが分割されているので、TerraformのIssueやコードを探す場合は、リポジトリ構成を理解しておく必要があります。

Terraformの本体は以下のリポジトリにあります。
https://github.com/hashicorp/terraform

Providerはterraform-providersというOrganizationの下にあって、
https://github.com/terraform-providers

例えばAWSだと以下のリポジトリにあります。
https://github.com/terraform-providers/terraform-provider-aws

ちなみにProvisionerは数が少ないので、Terraform本体のリポジトリのbuiltinディレクトリの下にあります。

tfファイルを書く技術

インデントを揃える

インフラをコードで管理することのメリットの1つは、設定変更がコードレビュー可能になることですが、コードレビューでtfファイルのインデントが揃っていない、とか指摘するのは不毛です。

Terraformでは terraform fmt というインデントなどのスタイルを揃えるコマンドを公式に提供しているので、これを使いましょう。Terraform自体はGo言語で開発されており、Go言語には go fmt というコードスタイルを揃えるコマンドがあるので、たぶんそれの影響を受けてるのかなーと思います。いずれにせよ個人の好みではなく、公式にスタイルはかくあるべしというのが提示されているのは、不毛な議論を減らせてありがたいです。

terraform fmt は、デフォルトでカレントディレクトリにあるファイル全部のスタイルを揃えます。

例えば以下のように微妙にイコールの位置が揃っていないファイルに

main.tf
provider "aws" {
  version = "1.4.0"
  region = "ap-northeast-1"
}

terraform fmt をかけると

$ terraform fmt

以下のようにイコールの位置が修正されます。

provider "aws" {
  version = "1.4.0"
  region  = "ap-northeast-1"
}

ただ毎回手動で実行するのは忘れがちなので、エディタのプラグインなどを使ってファイルの保存時に自動で terraform fmt を実行するようにしておけば、意識せずに常にフォーマットが揃った状態になるのでオススメです。また自動フォーマットの副次的なメリットとして、括弧などのとじ忘れで構文エラーがあると、フォーマットに失敗するので、構文エラーレベルの初歩的なミスに早期に気付くことができて便利です。

あなたのお気に入りのエディタに、Terraform用のプラグインがないかを調べる価値はあります。
私は普段NeoVimを使ってるので、ここでは(Neo)Vimについて簡単に説明しますが、Emacs/Atom/VSCode/IntelliJなどでも、ぐぐるといろいろ情報が出てくるので、自分の好きなエディタでTerraformのプラグインがないか調べてみて下さい。

(Neo)Vimの場合は、以下の hashivim/vim-terraform を入れるとシンタックスハイライトや、ファイル保存時の自動フォーマットが使えるようになります。
https://github.com/hashivim/vim-terraform

~/.vimrc
NeoBundle 'hashivim/vim-terraform'
let g:terraform_fmt_on_save = 1

READMEにも書いてありますが、ファイル保存時の自動フォーマットを有効にするには let g:terraform_fmt_on_save = 1 をセットしておきます。

また juliosueiras/vim-terraform-completion も入れるとリソースタイプやパラメータ名などのオムニ補完もできるようになります。
https://github.com/juliosueiras/vim-terraform-completion

~/.vimrc
NeoBundle 'juliosueiras/vim-terraform-completion'

ちなみにこの vim-terraform-completion は内部的にRubyに依存してるので、Vimの場合は +ruby オプションが有効になってるか確認して下さい。
NeoVimの場合は gem install neovim が必要です。

組み込み関数に親しむ

Terraformには組み込み関数がいくつかあって、知ってるとtfファイルを書く時に便利なことがあるので、文字列とかを加工したくなったら、便利な組み込み関数がないか調べてみましょう。
https://www.terraform.io/docs/configuration/interpolation.html#built-in-functions

この関数の挙動よく分からんなぁーってときは terraform console でインタラクティブなREPLが起動できるので、これで試すと便利です。

$ terraform console
> coalesce("","hoge")
hoge
> coalesce("fuga","hoge")
fuga

lifecycleブロックを使う

リソースの差分を無視する

Terraform使ってると、一部のリソースの設定差分を無視したいと思う時があります。例えば aws_autoscaling_group を使ってBlue/Greenデプロイメントをしようとすると、アタッチしている load_balancers を動的に変更したくなります。このように、リソース自体はTerraformで作るんだけど、運用の都合上、手動で設定いじっても無視して欲しい属性は lifecycle ブロックで ignore_changes に指定すると差分を無視できます。

main.tf
resource "aws_autoscaling_group" "app_1" {
  name = "app-asg-1"
  ...

  lifecycle {
    ignore_changes = ["load_balancers"]
  }
}

また ignore_changes のよくある用途として、パスワードなどをtfファイルに平文でハードコードしたくない場合に使えます。
Terraformでリソース作るときに設定項目としてパスワードなどのクレデンシャルが必要になる場合があります。例えばAWSのRDSインスタンスを立てる場合は、aws_db_instancepassword にDBのマスターパスワードを設定値として記載する必要があります。
このようなクレデンシャルは、tfファイル上は変数にして、terraform apply 時に渡すこともできますが、この方法はStateにパスワードが平文で記録されてしまい、Stateにアクセス可能な人なら誰でも参照できてしまうので、個人的にはあまりオススメしません。

個人的には、このようなパスワードなども ignore_changes でTerraformの管理対象外にしちゃうのがオススメです。つまり、初回のterraform applyのときに初期パスワードのみ記載しておき、 ignore_changes で指定しておけば、あとでパスワードを変えても差分としては無視されるので、パスワード変更可能です。

main.tf
resource "aws_db_instance" "db_instance" {
  identifier = "db"
  password   = "hoge"
  ...

  lifecycle {
    ignore_changes = ["password"]
  }
}

リソース再生成のときに新しいのを作ってから古いのを削除する

lifecycle の使い方の他の例は、リソースの再生成時に古いの削除=>新しいの作成ではなく、新しいの作成=>古いの削除の順に実行したいときです。例えば aws_autoscaling_group に紐づく aws_launch_configuration を更新したい場合、 aws_launch_configuration は更新ができないリソースなので、基本的に古いの削除=>新しいの作成が毎回発生しますが、このままだとリソースの依存として aws_autoscaling_group からの参照が残っていて古いのを削除できません。 ただ無停止で更新したいので、 aws_autoscaling_group を削除はしたくありません。このような微妙なリソースの依存関係がある場合は、lifecycleブロックで create_before_destroy = true を指定することで、新しいの作成=>古いの削除の順に実行できます。

main.tf
resource "aws_launch_configuration" "as_conf_1" {
  image_id    = "ami-xxxx"
  name_prefix = "app-"

  lifecycle {
    create_before_destroy = true
  }
}

この方法の1点注意点として、新しいの作成=>古いの削除の順に実行するため、一時的に2つのリソースができます。リソースのネーミングでユニーク制約がある場合は、リソースの作成に失敗します。自動採番されるようなリソースであれば問題ありません。

リソースのうっかり削除の保護

lifecycle には prevent_destroy というリソースのうっかり削除を保護するフラグがあります。ただ productionのインフラをいじってるとだいたい消しちゃまずいリソースが大半なので、あんまりこれに頼りすぎるのはよくないかなとは思います。ちゃんとplanを見ましょう。

テンプレートを使う

外部ファイルを文字列として読み込む

例えばリソースの設定パラメータがJSONの文字列を受け取る場合、その場でヒアドキュメントで書くことも可能ですが、外部ファイルを文字列として読み込むこともできます。

main.tf
resource "aws_iam_role" "ec2_role" {
  name = "ec2-role"

  assume_role_policy = "${file("./ec2_assume_role_policy.json")}"
}
ec2_assume_role_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}

特にJSONは外部ファイルに保存するとエディタでシンタックスハイライトが効いたりして、末尾カンマでエラーになるなどの初歩的なミスが防げるので、外部ファイルに切り出すとよいかと思います。

テンプレートに変数を埋め込む

設定ファイルが読み込めるようになると、変数埋め込みたくなることありますよね。
こーゆーときは変数を埋め込みたい場所に ${変数名} と書いておいて、

kms_policy.json
{
  "Version": "2012-10-17",
  "Id": "key-policy-for-${account_name}-account",
  "Statement": [
    {
      "Sid": "Enable IAM User Permissions",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::${account_id}:root"},
      "Action": "kms:*",
      "Resource": "*"
    }
  ]
}

template_file のData Sourceを作って、変数を外から差し込みます。

main.tf
data "template_file" "kms_policy" {
  template = "${file("${path.module}/kms_policy.json")}"

  vars {
    account_name = "dev"
    account_id   = "123456"
  }
}

で、実際に文字列として必要な場所で、renderします。

main.tf
resource "aws_kms_key" "key" {
  policy = "${data.template_file.kms_policy.rendered}"
}

モジュールでコードを共通化する

モジュールの作り方

Terraformのいいところの一つとして、似たような構成のサーバを立てたりするときに、だいたいコピペ&置換で仕事が完了することなのですが、コピペが増えてくると、コードを共通化したくなります。
Terraformで再利用可能なコードの塊を定義する方法として module があります。
適当なhogeディレクトリを掘って、その中に共通化したい resource などを定義します。

hoge/null_resource.tf
provider "null" {}

resource "null_resource" "hoge" {}

そして、利用したい場所で、 module を使って、 source で相対パスを指定して読み込みます。

main.tf
module "bar" {
  source = "./hoge"
}

module "baz" {
  source = "./hoge"
}

このようにディレクトリ掘ってtfファイルをグループ化するだけで、モジュールを使い始められます。
ローカルディレクトリ以外に外部のリポジトリも参照する仕組みがあり、最近Terraform Registryという公開モジュールをオープンに共有する仕組みもできました。
https://registry.terraform.io/

リソースのネーミングルールなどの都合もあるので、公開されているモジュールをそのまま使えるケースはあんまりないんじゃないか?という気もしますが、他の人がどのようなTerraformのコードを書いてるのか参考になるので、見てみるとよいんじゃないでしょうか。

production/stagingなどの環境差分を管理する

モジュールには variable も渡せるので、production/stagingなどの環境を差分を管理する場合は、共通コードをモジュール化し、差分を variable で吸収することが可能です。

modules/hoge/* 配下に共通化したいtfファイルを置いて、
services/myapp/production/main.tf でproduction環境用の設定を、services/myapp/staging/main.tf でstaging環境用の設定をとディレクトリを分けて管理します。

$ tree
.
├── modules
│   └── ec2
│       └── aws_instance.tf
└── services
    └── myapp
        ├── production
        │   └── main.tf
        └── staging
            └── main.tf
modules/ec2/aws_instance.tf
provider "aws" {}

resource "aws_instance" "app" {
  ...

  tags {
    Name    = "app-${var.service}-${var.env}"
    service = "${var.service}"
    env     = "${var.env}"
  }
}
services/myapp/production/main.tf
module "app" {
  source  = "../../../modules/ec2"
  service = "myapp"
  env     = "production"
}

services/myapp/staging/main.tf
module "app" {
  source  = "../../../modules/ec2"
  service = "myapp"
  env     = "staging"
}

実はTerraformには1つのディレクトリで複数のStateを扱うWorkspaceという機能もあるのですが、個人的には普通にディレクトリを分けて管理する方が楽です。
production/stagingは完全に同じリソース構成で、設定のパラメータの差分がちょっとだけあるという理想的な世界ではWorkspaceでも運用できるかもしれませんが、現実的には、stagingだけまだリリース前の検証用の一時的なリソースが立ってたりとか。完全に同じ構成にならないことも多いので、モジュールの読み込みの有無や一部の環境だけ存在するリソースなど、差分を吸収できる場所があったほうが都合がよいからです。
また、ディレクトリを分けることの副次的な効果として、production/stagingなどの環境ごとにAWSのアカウントが異なる場合に、AWSアクセスキーなどの環境変数を使い分けないといけないのですが、ディレクトリが分かれていればdirenvなどのツールを用いて、環境変数の切り替えが簡単に行なえます。

複数の環境をまたいでアカウントでグローバルなリソースは、別のディレクトリでStateを分けて、後述のStateを跨いで値を参照する方法を使っています。

モジュール間で値を参照する

モジュール分割を進めると、こっちのモジュールで作った値を、別のモジュールで参照したいということが発生します。
モジュールからの出力は output で、モジュールへの入力は variable でできるので、これらを組み合わせることで、モジュール間で値を引き回せます。

modules/ec2/aws_instance.tf
provider "aws" {}

variable "service" {}

variable "env" {}

output "hoge_private_ip" {
  value = "${aws_instance.hoge.private_ip}"
}

resource "aws_instance" "hoge" {
  tags {
    Name    = "hoge-${var.service}-${var.env}"
    service = "${var.service}"
    env     = "${var.env}"
  }
}
services/myapp/production/main.tf
module "app" {
  source  = "../../../modules/ec2"
  service = "myapp"
  env     = "production"
}

module "foo" {
  source = "../../../modules/bar"
  ip     = "${module.app.hoge_private_ip}"
}
modules/bar/bar.tf
variable "ip" {}

Stateを跨いで値を参照する

先ほどの例ではモジュールをまたいだ変数の参照でしたが、Stateが分かれてる場合は terraform_remote_state のData Sourceを使うことで、他のStateの値を参照することができます。

例えばStateAで作ったリソースをStateBで参照したい場合、まずStateA側で参照したい値をoutputで出力しておきます。

accounts/main/iam/output.tf
output "hoge_instance_profile_name" {
  value = "${aws_iam_instance_profile.hoge.name}"
}

で、参照したい側のStateBで terraform_remote_state を使ってStateAのData Sourceを作って参照します。

services/myapp/main.tf
data "terraform_remote_state" "aws_main_iam" {
  backend = "s3"

  config {
    bucket = "my-tfstate-main"
    key    = "aws-accounts/main/iam/terraform.tfstate"
    region = "ap-northeast-1"
  }
}

module "hoge" {
  source = "../../../modules/hoge"

  instance_profile_name = "${data.terraform_remote_state.aws_main_iam.hoge_instance_profile_name}"
}

管理するリソースが増えてStateが肥大化すると、 terraform plan のリフレッシュが遅くなったり、オペミスで事故った時の影響範囲が広くなるので、適当な粒度でStateを分割しておくとよいでしょう。

条件分岐したい

条件によって変数の値を変える

Terraform書いてるとif文書きたくなることありますよねー。残念ながら今のところif文はないんですが、三項演算子なら書けます。

main.tf
resource "aws_instance" "web" {
  subnet = "${var.env == "production" ? var.prod_subnet : var.dev_subnet}"
}

条件によってリソースを作成したりしなかったり

モジュールでコードを共通化したものの、変数だけで差分を吸収しきれず、条件によって一部のリソースを作ったり作らなかったりしたい時もあります。まぁモジュールを分けてもいいんだけど、ほぼ同じだったりすると、分けるほどでもないかなみたいなとき。

Terraformにはリソースを複数個作成するのに count というパラメータがあって、これを先ほどの三項演算子と組み合わせると、条件によって作ったり作らなかったりが実現できます。

main.tf
resource "aws_cloudwatch_log_group" "hoge" {
  count = "${var.create_log_group ? 1 : 0}"
  name  = "hoge"
}

Terraformのバージョンを管理する技術

Terraformをバージョンアップする

Terraformは日々進化しています。バージョンアップしたい理由は様々ですが、最新の機能が使いたい、踏んだ既知のバグが治ってる、など長く運用しているとバージョンアップしたくなることもあるでしょう。

本稿執筆時点のTerraformのバージョンはv0.11.1ですが、まだv1.0に到達していないので、v0.10.x => v0.11.x などマイナーバージョンアップでも非互換な変更が入ります。ここではTerraform本体のバージョンアップについて記載します。ここでTerraform本体と言っているのは、 hashicorp/terraform のことで、Terraformはv0.10から各クラウドプロバイダ(AWS, GCP, Azureなど)に依存したプラグインが terraform-providers/terraform-provider-* に分離されました。プロバイダのバージョンアップについては後述します。

Terraformのバージョンアップをする場合には、いくつか注意点がありますが、まずバージョンアップ前に既に出ている警告を解消することをオススメします。事前にdeprecatedな警告ログを出しておいて、次バージョンで機能削除などがあるので。
このような理由からマイナーバージョンアップでもv0.9.x => v0.11.x などバージョン飛ばしはせずに、v0.9.x => v0.10.x => v0.11.x と刻んで上げた方が無難です。マイナーバージョンはだいたい今のところ、数ヶ月に1回ぐらいの頻度でリリースされてます。

非互換な修正が入る場合は、UpgradeガイドやCHANGELOGに記載があるので、必ず確認しましょう。*.tf ファイルの修正が必要な場合もあります。

公式のUpgradeガイドはバージョンごとに以下で参照できます。
https://www.terraform.io/upgrade-guides/index.html

CHANGELOGはGitHubの本体のリポジトリにあります。
https://github.com/hashicorp/terraform/blob/master/CHANGELOG.md

チームで複数人で開発している場合は、バージョンを揃えないと事故の元です。バージョンアップに伴い、非互換変更で、*.tf ファイルの修正が必要な場合は、チーム内でタイミングを見計らって一気に切り替えます。

バージョンアップの具体的な手順は、後述の「Terraformのバージョンを切り替える」を参考にして下さい。

バージョンアップしたら terraform plan しなおして、意図しない差分が出ていないかや、新しい警告が出ていないかチェックしましょう。変な差分が出てる場合は、関連した変更がないかCHANGELOGを探しましょう。

Terraformのバージョンを切り替える

Terraformは活発に開発されており、バージョンアップも比較的早いプロダクトです。
チームで開発する場合は、全員が同じバージョンを使わないと、微妙なバージョン違いで意図しない差分が出ることがあり注意が必要です。
Terraformはコンパイル済のバイナリとして配布されているので、特定のバージョンをダウンロードしてきてPATHを通すだけで、好きなバージョンが使えるのですが、その辺を簡単にしてくれる tfenv というツールを使うのがオススメです。

tfenvはMac OSXであればHomebrew経由でインストールできます。それ以外の場合は、git cloneしてきて適当にPATHを通すと使えます。詳細はtfenvのREADMEを参照して下さい。

$ brew install tfenv
$ tfenv --help
Usage: tfenv <command> [<options>]

Commands:
   install       Install a specific version of Terraform
   use           Switch a version to use
   uninstall     Uninstall a specific version of Terraform
   list          List all installed versions
   list-remote   List all installable versions

利用可能なバージョンを確認します。

$ tfenv list-remote | head
0.11.1
0.11.0
0.11.0-rc1
0.11.0-beta1
0.10.8
0.10.7
0.10.6
0.10.5
0.10.4
0.10.3

指定のバージョンをインストールします。

$ tfenv install 0.11.0
[INFO] Installing Terraform v0.11.0
[INFO] Downloading release tarball from https://releases.hashicorp.com/terraform/0.11.0/terraform_0.11.0_darwin_amd64.zip
######################################################################## 100.0%
[INFO] Downloading SHA hash file from https://releases.hashicorp.com/terraform/0.11.0/terraform_0.11.0_SHA256SUMS
tfenv: tfenv-install: [WARN] No keybase install found, skipping GPG signature verification
Archive:  tfenv_download.ef4YCM/terraform_0.11.0_darwin_amd64.zip
  inflating: /usr/local/Cellar/tfenv/0.6.0/versions/0.11.0/terraform
[INFO] Installation of terraform v0.11.0 successful
[INFO] Switching to v0.11.0
[INFO] Switching completed
$ terraform --version
Terraform v0.11.0

tfファイルを管理しているリポジトリのルートにでも .terraform-version というファイルを置いて、その中にバージョン番号を書いておくと、 tfenv install のバージョン番号を省略できます。

$ cat .terraform-version
0.10.7
$ tfenv install
[INFO] Installing Terraform v0.10.7
[INFO] Downloading release tarball from https://releases.hashicorp.com/terraform/0.10.7/terraform_0.10.7_darwin_amd64.zip
######################################################################## 100.0%
[INFO] Downloading SHA hash file from https://releases.hashicorp.com/terraform/0.10.7/terraform_0.10.7_SHA256SUMS
tfenv: tfenv-install: [WARN] No keybase install found, skipping GPG signature verification
Archive:  tfenv_download.bHmtrT/terraform_0.10.7_darwin_amd64.zip
  inflating: /usr/local/Cellar/tfenv/0.6.0/versions/0.10.7/terraform
[INFO] Installation of terraform v0.10.7 successful
[INFO] Switching to v0.10.7
[INFO] Switching completed

この方法のうれしいところは、tfファイルのリポジトリごとにTerraformのバージョンが違っても、cdすると自動で .terraform-version を見てバージョンを切り替えてくれるところです。

Terraformのバージョンを固定する

tfenvはバージョンを切り替えてくれる便利ツールですが、開発チーム全員がtfenvを使っていない場合はバージョンの強制にはなりません。使用するTerraformのバージョンを固定して強制するにはtfファイルに terraform.required_version を定義します。

例として、terraform v0.10.7に固定してみました。

main.tf
terraform {
  required_version = "= 0.10.7"
}

これを例えばv0.11.0で使うと、以下のようにエラーが出ます。

$ terraform --version
Terraform v0.11.0
+ provider.null v1.0.0

$ terraform plan

Error: The currently running version of Terraform doesn't meet the
version requirements explicitly specified by the configuration.
Please use the required version or update the configuration.
Note that version requirements are usually set for a reason, so
we recommend verifying with whoever set the version requirements
prior to making any manual changes.

  Module: root
  Required version: = 0.10.7
  Current version: 0.11.0

バージョンを厳密に強制したい場合は、tfファイル側に書くとよいでしょう。

Terraformプロバイダをバージョンアップする

Terraformプロバイダをバージョンアップする際の注意点は、警告ログやCHANGELOGを確認するなど基本動作はだいたいTerraform本体と同じです。

たとえばAWSの場合は、CHANGELOGは以下にあります。
https://github.com/terraform-providers/terraform-provider-aws/blob/master/CHANGELOG.md

バージョンアップの具体的な手順は terraform init コマンドに -upgrade オプションを指定すると、バージョン制約を満たす最新のプロバイダにアップグレードされます。

$ terraform init -upgrade

なので、指定のバージョンにアップグレードするには、プロバイダもバージョン制約で明示的に固定しておくとよいでしょう。

Terraformプロバイダのバージョンを固定する

プロバイダのバージョン制約はproviderブロックに書けます。例えばAWSの場合はこんなかんじです。

main.tf
provider "aws" {
  version = "= 1.3.0"
}

チーム開発する場合は、バージョン間の微妙な差分でハマらないように固定しておくとよいでしょう。

tfstateファイルを書く技術

これ以降はかなり発展的なトピックです。
Terraformを使いこなすには、Stateの状態を記録した terraform.tfstate の理解は避けて通れません。
tfstateを制すものがTerraformを制すと言っても過言ではないでしょう。

tfstateファイルは内部実装なので、ここに記載したこともTerraformのバージョンが上がれば古くなってしまう可能性が高いです。しかしながら現時点の知見を共有することにはなにがしかの意味があると思うので、理解している範囲で書きます。

お約束: tfstateファイルを手動でいじるのは大変危険ですので、インフラがぶっ壊れても責任は取れません。試すなら壊れて良い環境でまずは試し、自分が何をしているかを理解した上で自己責任で行って下さい。

ローカルでミニマムのStateを見てみる

サンプルで適当なtfファイルを書きます。

main.tf
provider "null" {}

resource "null_resource" "hoge" {}

terrform apply すると、Stateが保存されます。
リモートStateを使っていないデフォルトの場合は、ローカルのカレントディレクトリに terraform.tfstate という名前で保存されます。
ちょっと中身を見てみましょう。

{
    "version": 3,
    "terraform_version": "0.11.1",
    "serial": 1,
    "lineage": "6fab4dbe-eca7-405b-8210-c82c5276ac28",
    "modules": [
        {
            "path": [
                "root"
            ],
            "outputs": {},
            "resources": {
                "null_resource.hoge": {
                    "type": "null_resource",
                    "depends_on": [],
                    "primary": {
                        "id": "1492336661259070634",
                        "attributes": {
                            "id": "1492336661259070634"
                        },
                        "meta": {},
                        "tainted": false
                    },
                    "deposed": [],
                    "provider": "provider.null"
                }
            },
            "depends_on": []
        }
    ]
}

見るとわかりますが、ただのJSONです。

各項目の意味は実装詳細としてドキュメント化されていませんが、ソースコードを眺めたかんじ、以下のとおりです。

項目 意味
version Stateファイルのフォーマットバージョンです。Stateファイル自体のフォーマットが変更された場合にマイグレーションできるようにフォーマット自体のバージョンがあります。
terraform_version Terraformのバージョン。古いTerraformで書かれたStateは新しいTerraformで読めますが、その逆はエラーになります。
serial シリアル番号です。Stateが更新されるたびにインクリメントされます。諸般の事情で手動でStateを編集する場合は、このシリアル番号もインクリメントする必要があります。
lineage Stateの血統です。うっかり間違って無関係のStateを上書いたりしないように、シリアル番号を比較することに意味があるかどうかを判別するため、Stateが初期化されるタイミングでUUIDが発行されます。
modules モジュールの状態です。

modulesの中は、moduleごとに状態が記録されていますが、moduleは以下の要素で構成されています。

項目 意味
path モジュールのツリーのパスです。必ずrootモジュールが1つ存在します。rootモジュールから読み込まれているhoge moduleの場合は、rootから見たツリー上の位置が、 [root, hoge] というように記録されてます。
output モジュールのoutputです。
resources リソースの状態です。
depends_on モジュール間の依存です。

resoucesの中は、resourceごとに状態が記録されていますが、resourceは以下の要素で構成されています。ちなみにresourceと言いつつ、data sourceも data. というprefixがついた 特殊なresourceとして記録されてます。

項目 意味
type リソースの種類です。
depends_on リソース間の依存です。
primary アクティブなインスタンスの状態です。
deposed 削除予定のインスタンスです。 create_before_destroy のときに使われます。
provider プロバイダの名前です。プロバイダはマルチリージョンしたりするのに、エイリアスを作ったりできるので記録されています。

primaryの中が実際のリソースの状態です。

項目 意味
id リソースIDです。何がIDになりうるかはリソースタイプによって異なります。 null_resouce の場合はただの乱数ですが、一般的にはインスタンスIDなど、APIなどでそのリソース情報を取得するのにユニークに特定し得る値が使われます。
attributes リソース属性です。リソースの設定項目などの具体的な値を保存しているのはここです。
meta Terraformコアからは無視されるけど外部ツールが使うことを意図したメタ情報です。
tainted 汚染フラグです。リソースをtainted状態にすると、次回のplanで削除=>作成で再作成するplanになります。

リモートのStateを見る

リモートにStateを保存している場合でも、 terraform state pull するとStateを標準出力に表示できます。
Stateの状態がおかしくなっちゃったときに状態を確認したり、リダイレクトして簡易バックアップすることも可能です。

{
    "version": 3,
    "terraform_version": "0.10.7",
    "serial": 554,
    "lineage": "dd612537-035f-4cf3-94c8-0618bab715cd",
    "backend": {
        "type": "s3",
        "config": {
            "bucket": "my-tfstate-main",
            "key": "services/myapp/staging/terraform.tfstate",
            "region": "ap-northeast-1"
        },
        "hash": 4435600807653202963
    },
    "modules": [
        {
            "path": [
                "root"
            ],

強制的にリソースを再生成する

諸般の理由でリソースを強制的に再生成したいときがあります。
例えば terraform apply に失敗しちゃって、なんか中途半端な微妙な状態のリソースができちゃったときとか。
そーゆーときは、 terraform taint でリソースをtainted状態にマークすると、次回のplanで強制的に削除=>作成で再生成されるplanが作られます。

main.tf
provider "null" {}

resource "null_resource" "hoge" {}
$ terraform taint null_resource.hoge
The resource null_resource.hoge in the module root has been marked as tainted!

ここではrootモジュールのリソースを指定していますが、root以外のモジュール内のリソースをtaintする場合は、 引数 -module= でモジュールのパスを指定して下さい。

planを見てみると、以下のように -/+ で再生成しようとしていることが分かります。

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

null_resource.hoge: Refreshing state... (ID: 1492336661259070634)

------------------------------------------------------------------------

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

-/+ null_resource.hoge (tainted) (new resource required)
      id: "1492336661259070634" => <computed> (forces new resource)


Plan: 1 to add, 0 to change, 1 to destroy.

------------------------------------------------------------------------

Note: You didn't specify an "-out" parameter to save this plan, so Terraform
can't guarantee that exactly these actions will be performed if
"terraform apply" is subsequently run.

Terraform管理外の既存のリソースをTerraform管理下に入れる

すべてがTerraformで管理されている理想的な世界であればよいのですが、既存のプロジェクトにあとからTerraformを導入した場合は、既にあるTerraform管理外のリソースをTerraformの管理下に入れたくなります。気軽に再生成できるリソースであれば、作り直しちゃうのが手っ取り早いですが、productionでもう動いちゃってるものは、現実的にはなかなか作り直しづらいものが多いのも事実です。

これまで見てきたように、Terraformは自身が管理しているリソースの状態をStateとして管理しているので、なんらかの方法でこのStateを作ってやる方法があります。

まず最初に試してみるのは、公式で提供されている terraform import コマンドです。このコマンドは、importしたいリソースのState上のアドレスとリソースIDを指定すると、Stateに新しいリソースを追加してくれます。注意点として、Configurationを自動で生成してくれるわけではないので、tfファイルは自分で書く必要があります。

main.tf
provider "aws" {}

ここでは例として、リソースタイプ aws_iam_role で、Terraform上のリソース名 test_kitchen_ec2_role として、 AWS上のIAMロール名 test-kitchen-ec2-role をimportしてみましょう。

おもむろに、tfファイル側に定義がない状態でimportしようとすると、エラーが出ます。

$ terraform import aws_iam_role.test_kitchen_ec2_role test-kitchen-ec2-role
Error: resource address "aws_iam_role.test_kitchen_ec2_role" does not exist in the configuration.

Before importing this resource, please create its configuration in the root module. For example:

resource "aws_iam_role" "test_kitchen_ec2_role" {
  # (resource arguments)
}

以前はできたんですが、Stateだけ追加されてConfigurationがないと、うっかりdestroyのplanができちゃうので、安全にエラーになるようになったんでしょう。tfファイルにリソースの枠だけ作ってみます。

main.tf
provider "aws" {}

resource "aws_iam_role" "test_kitchen_ec2_role" {
}

importしてみます。

$ terraform import aws_iam_role.test_kitchen_ec2_role test-kitchen-ec2-role
aws_iam_role.test_kitchen_ec2_role: Importing from ID "test-kitchen-ec2-role"...
aws_iam_role.test_kitchen_ec2_role: Import complete!
  Imported aws_iam_role (ID: test-kitchen-ec2-role)
aws_iam_role.test_kitchen_ec2_role: Refreshing state... (ID: test-kitchen-ec2-role)

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

terraform.tfstate を見てみましょう。

{
    "version": 3,
    "terraform_version": "0.11.1",
    "serial": 2,
    "lineage": "591a7666-d636-4e4f-aea8-8a9df641e460",
    "modules": [
        {
            "path": [
                "root"
            ],
            "outputs": {},
            "resources": {
                "aws_iam_role.test_kitchen_ec2_role": {
                    "type": "aws_iam_role",
                    "depends_on": [],
                    "primary": {
                        "id": "test-kitchen-ec2-role",
                        "attributes": {
                            "arn": "arn:aws:iam::XXXXXX:role/test-kitchen-ec2-role",
                            "assume_role_policy": "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}",
                            "create_date": "2016-07-12T03:00:40Z",
                            "force_detach_policies": "false",
                            "id": "test-kitchen-ec2-role",
                            "name": "test-kitchen-ec2-role",
                            "path": "/",
                            "unique_id": "XXXXXXX"
                        },
                        "meta": {},
                        "tainted": false
                    },
                    "deposed": [],
                    "provider": "provider.aws"
                }
            },
            "depends_on": []
        }
    ]
}

assume_role_policy などが読み込まれていることが分かります。
planしてみます。

$ terraform plan

Error: aws_iam_role.test_kitchen_ec2_role: "assume_role_policy": required field is not set

assume_role_policyが必須項目だったのでエラーになりました。

assume_role_policyをtfファイル側に追加します。
Stateの値をそのままtfファイルにハードコードしてもよいんですが、改行なしのJSONは読み辛いので、適当にjqで整形して外ファイルを読み込むようにしておきましょう。

$ echo "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"ec2.amazonaws.com\"},\"Action\":\"sts:AssumeRole\"}]}" \
| jq . > ec2_assume_role_policy.json
ec2_assume_role_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "ec2.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
main.tf
provider "aws" {}

resource "aws_iam_role" "test_kitchen_ec2_role" {
  assume_role_policy = "${file("./ec2_assume_role_policy.json")}"
}

planします。

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

aws_iam_role.test_kitchen_ec2_role: Refreshing state... (ID: test-kitchen-ec2-role)

------------------------------------------------------------------------

No changes. Infrastructure is up-to-date.

This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, no
actions need to be performed.

差分がなくなりました。このようにplan差分がなくなるまで、tfファイルの設定を調整します。

ところで残念なお知らせですが、この terraform import コマンドはすべてのリソースタイプに対応しているわけではありません。
対応していないリソースタイプの場合は、import実行するとエラーが出ます。

公式のimportコマンドが対応していないリソースタイプの場合は、自分でStateを書く必要があります。
しかし何もないところからおもむろにStateを書くのは、どのようなattributesがあるのか分からないので難しいです。実際には、以下のような流れで実施します。

  • Terraformのドキュメントを眺めつつダミーのtfファイルを作る
  • ダミーのリソースを terraform apply してtfstateの雛形作成
  • AWSコンソールを眺めつつターゲットとなるリソースのtfファイルを作る
  • tfstateの雛形をコピーしてターゲットとなるリソースのtfstateの枠を作る
  • terraform refresh して terraformでtfstateを既存リソースの状態に合わせる
  • ダミーリソースを削除して terraform plan で差分がなくなれば完成

Terraform v0.6時代に私が書いた資料で若干古いんですが、実際の例は以下の記事も参考にしてみて下さい。基本的な流れは変わりません。

Terraforming未対応の既存リソースも自力でtfstateを書いてTerraform管理下に入れる

当時からの差分として注意すべきこととしては、StateをRemoteに保存している場合は、デフォルトでローカルに terraform.tfstate が保存されなくなりました。Stateを編集するには、 terraform state pull をファイルにリダイレクトして一旦ローカルに保存し、ファイルを編集、 terraform state push で上書きが必要です。

余談ですが、上記の記事で出て来るterraformingというツールは terraform import コマンドが公式にできる前からあった、サードパーティーなツールです。

tfファイルをリファクタリングする

Terraformのコードは成長してくると、リファクタリングしたくなってきます。
リファクタリングしたくなる理由は様々ですが、リソース名の表記ゆれを揃えたくなったり、よく使うリソースのセットをmoduleに切り出したりなど。ここで問題になるのがtfstateをどうするかです。

簡単な例を見てみましょう。以下のような null_resource.hoge リソースがあるとして、

main.tf
provider "null" {}

resource "null_resource" "hoge" {}

これを null_resource.fuga リソースにリネームしたいとします。

main.tf
provider "null" {}

resource "null_resource" "fuga" {}

何も考えずにリネームしてしまうと、以下のように、 null_resource.hoge を一度削除して、null_resource.fuga を新しく作成するplanが生成されてしまいます。

$ terraform plan

Terraform will perform the following actions:

  + null_resource.fuga
      id: <computed>

  - null_resource.hoge


Plan: 1 to add, 0 to change, 1 to destroy.

再生成可能なリソースであればこれでも構わないのですが、実際問題DBなど作り直しづらいリソースも存在します。
こういう場合は terraform state mv コマンドを使います。
このコマンドはtfstate内のリソースの位置を移動しますが、このとき名前を変更すると同時にリネームもできます。

$ terraform state mv null_resource.hoge null_resource.fuga
Moved null_resource.hoge to null_resource.fuga

リネーム後に再度planしてみると差分がなくなりました。

$ terraform plan
Refreshing Terraform state in-memory prior to plan...
The refreshed state will be used to calculate this plan, but will not be
persisted to local or remote state storage.

null_resource.fuga: Refreshing state... (ID: 8190077603963424291)

------------------------------------------------------------------------

No changes. Infrastructure is up-to-date.

This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, no
actions need to be performed.

Terraformをデバッグする技術

デバッグログを出力する

Terraformのバグを踏んだかな?と思ったらデバッグログを出力してみましょう。
TF_LOG=DEBUG を環境変数にセットするとデバッグログが出ます。ログレベルはTRACE, DEBUG, INFO, WARN, ERROR が指定可能です。

$ TF_LOG=DEBUG terraform init
2017/12/05 23:27:52 [INFO] Terraform version: 0.11.1  a42fdb08a43c7fabb8898fe8c286b793bbaa4835+CHANGES
2017/12/05 23:27:52 [INFO] Go runtime version: go1.9
2017/12/05 23:27:52 [INFO] CLI args: []string{"/usr/local/Cellar/tfenv/0.6.0/versions/0.11.1/terraform", "init"}
2017/12/05 23:27:52 [DEBUG] Attempting to open CLI config file: /Users/minamijoyo/.terraformrc
2017/12/05 23:27:52 [DEBUG] File doesn't exist, but doesn't need to. Ignoring.
2017/12/05 23:27:52 [INFO] CLI command args: []string{"init"}
2017/12/05 23:27:52 [DEBUG] plugin: waiting for all plugin processes to complete...
Terraform initialized in an empty directory!

The directory has no Terraform configuration files. You may begin working
with Terraform immediately by creating Terraform configuration files.

Terraformのソースコードをコンパイルする

terraformコマンドは使うだけなら意識することはないですが、内部的にはマルチプロセスで動いています。内部構造もわりと複雑でデバッグログを出してもソースコードとの対応がぱっと見分かりづらいです。デバッグログを眺めつつソースコードを追うのに、ファイル名と行番号も出ると幸せになれそうです。
これには現状ソースコードをちょっといじる必要があります。
というわけでTerraformをソースコードからコンパイルしてみましょう。

TerraformはGo言語で書かれており、 go get してきて、コードをいじって、 go install しなおせば修正バージョンを手元で動かすことができます。Go言語の開発環境の構築は割愛します。適宜ググって下さい。
コンパイルに必要なGoのバージョンは .travis.yml あたりを見るとよいでしょう。現在はGo 1.9.1のようです。

$ go get github.com/hashicorp/terraform
$ cd $GOPAHT/src/github.com/hashicorp/terraform
(コードいじる)
$ go install

これはTerraform本体だけでなくproviderも同じです。普通に $GOPATH/bin にパスが通ってればproviderも検索対象に含まれ、自分でコンパイルしたバージョンを使用できます。

$ go get github.com/terraform-providers/terraform-provider-null
$ cd $GOPAHT/src/github.com/terraform-providers/terraform-provider-null
(コードいじる)
$ go install

デバッグログにソースコードのファイル名と行番号を付ける

コンパイルの仕方がわかったところで、 デバッグログにソースコードのファイル名と行番号を付けてみましょう。
hashicorp/terraformmain.go の中にある main関数の先頭で、ログの設定をちょっといじってみます。

https://github.com/hashicorp/terraform/blob/8e57c4361c7981010a34b6cb866d91d188f3e91f/main.go#L32

main.go
func main() {
    log.SetFlags(log.Llongfile) // ★この行を追加
    // Override global prefix set by go-dynect during init()
    log.SetPrefix("")
    os.Exit(realMain())
}

やってること自体は、Goの標準logパッケージの log.Llongfile をセットするだけです。
コンパイルして $GOPATH/bin 配下にバイナリをインストールします。

$ go install

実行してみましょう。

$ TF_LOG=DEBUG $GOPATH/bin/terraform init
/Users/minamijoyo/src/github.com/hashicorp/terraform/main.go:120: [INFO] Terraform version: 0.11.2 dev
/Users/minamijoyo/src/github.com/hashicorp/terraform/main.go:123: [INFO] Go runtime version: go1.9
/Users/minamijoyo/src/github.com/hashicorp/terraform/main.go:124: [INFO] CLI args: []string{"/Users/minamijoyo/bin/terraform", "init"}
/Users/minamijoyo/src/github.com/hashicorp/terraform/main.go:239: [DEBUG] Attempting to open CLI config file: /Users/minamijoyo/.terraformrc
/Users/minamijoyo/src/github.com/hashicorp/terraform/main.go:250: [DEBUG] File doesn't exist, but doesn't need to. Ignoring.
/Users/minamijoyo/src/github.com/hashicorp/terraform/main.go:198: [INFO] CLI command args: []string{"init"}
/Users/minamijoyo/src/github.com/hashicorp/terraform/vendor/github.com/hashicorp/go-plugin/client.go:235: [DEBUG] plugin: waiting for all plugin processes to complete...
Terraform initialized in an empty directory!

The directory has no Terraform configuration files. You may begin working
with Terraform immediately by creating Terraform configuration files.

ログを出力している箇所のファイル名のフルパスと行番号が出るようになりました。これでコードリーディングが捗りそうです。

Terraformをデバッガでステップ実行する

ログを眺めているとデバッガでステップ実行したくなりますよね。
Go言語には delve というデバッガがあるので、これを使います。
delve自体の使い方の説明は以前書いたことがあるので、こちらの記事も参考にしてみて下さい。

Golangのデバッガdelveの使い方

ただterraformでdelveを使う場合注意すべきことがあります。それはterraformは内部的に複数のプロセスで構成されていることです。terraformはpanicをラップして綺麗なログを出すために内部的に mitchellh/panicwrap というライブラリを使って、自分自身を別のプロセスとして起動し直してます。コードを読めばわかりますが、 TF_FORK=0 という環境変数をセットすると、この動作を回避して直接プロセスを起動できます。
delveでデバッグするときは、デバッグ対象のコマンドに渡す引数は -- 以降に記載します。
なので、 terraform init を動かす場合は、こんなかんじ。

$ TF_LOG=TRACE TF_FORK=0 dlv debug -- init
Type 'help' for list of commands.
(dlv) b main.main
Breakpoint 1 set at 0x27b28af for main.main() ./main.go:32
(dlv) c
> main.main() ./main.go:32 (hits goroutine(1):1 total:1) (PC: 0x27b28af)
    27: const (
    28:         // EnvCLI is the environment variable name to set additional CLI args.
    29:         EnvCLI = "TF_CLI_ARGS"
    30: )
    31:
=>  32: func main() {
    33:         log.SetFlags(log.Llongfile) // ★この行を追加
    34:         // Override global prefix set by go-dynect during init()
    35:         log.SetPrefix("")
    36:         os.Exit(realMain())
    37: }

ところでterraformのメインプロセスと、providerのプロセスは分かれており、内部的にRPCでプロセス間通信を行っています。
このprovider側のプロセスにdelveをアタッチする方法はまだ編み出せていません。delveにはリモートプロセスにアタッチする機能もあるのですが、providerはデーモンのように常時起動しているわけではなく、terraformのコマンドが実行されている間だけ起動しているので、タイミングを見計らってアタッチするのも難しそうです。

現状provider側のコードをデバッグするには、適当なログ出力を仕込む、いわゆるprintデバッグ以上の有効な技を知らないので、誰かもっといいprovider側のデバッグ方法を知ってる人がいたら教えて下さい。

おわりに

Terraformを使っていて日々学んだ知見を淡々とまとめてみました。
「だいたい知ってたわー」、「あるある過ぎてツライw」、「こんな超絶技巧テクニックも知ってるよー」
というTerraform職人の皆様は、ぜひ知見を共有して下さい。