LoginSignup
552
417

More than 3 years have passed since last update.

Terraform職人再入門2020

Last updated at Posted at 2020-12-11

はじめに

この記事は CrowdWorks Advent Calendar 2020 の11日目の記事です。

3年ほど前に、「Terraform職人入門」という記事を書きました↓
Terraform職人入門: 日々の運用で学んだ知見を淡々とまとめる

この記事は多くの人に読んでいただきましたが、当時のTerraformのバージョンはv0.11で、2019年5月にリリースされたv0.12以降のHCL2にも対応しておらず、またその後の周辺のエコシステムの変化などもあり、情報がずいぶん古くなってしまった感は否めません。また当時紹介した解決方法よりも、今ならよりよい解決策を知っているものもあります。未だに過去の記事にLGTMをもらうたびに、うれしさ半分と同時に、なんとなく心苦しい気持ち半分でした。

というわけで、「Terraform職人再入門2020」と題して、当時から差分のあった箇所を中心に、運用のTipsやらいろいろ学んだことなどを、またつらつらと書いてみます。この記事もまたいつか古くなってしまうんだろうなと思いつつ。

本稿執筆時点のTerraformの最新版はv0.14.2です。

情報の探し方

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

基本は変わらずですが、分からないことはまずは公式ドキュメントを読みましょう。
ネットでぐぐって出て来る情報は古いことがあるので、迷ったら公式ドキュメントを見に行くのが確実です。

HCL2

Terraform v0.12以降はHCL2が導入されたので、サンプルコードがHCL1のものは古い情報だと思って良いでしょう。HCL (HashiCorp Configuration Language)はtfファイルで使われているDSLで、Terraform v0.11まではHCL1、Terraform v0.12以降はHCL2です。内部実装はほぼ別物なんですが、表面上はかなりの互換性を保っています。いきなり余談ですが、v0.12が出た当時、v0.12の内部実装がどれぐらい別物なのか伝わる趣味的な記事も書いたので、暇だったら読んで見て下さい。v0.11とv0.12がどれぐらい違うのか雰囲気が伝わるはず。

たぶん仕事の役に立たないTerraform v0.12の鑑賞ポイント

HCL1かHCL2を見分ける簡単な方法は、単純な変数の参照に "${}" が必要かどうかです。

hcl1.tf
data "aws_caller_identity" "current" {}

output "account_id" {
  value = "${data.aws_caller_identity.current.account_id}"
}
hcl2.tf
data "aws_caller_identity" "current" {}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

若干補足しておくと、 "${}" 自体が廃止されたわけではなく、今でも文字列の中に変数を埋め込む場合には必要ですが、式が変数の参照しか含まない場合は不要で、v0.13.4以降は冗長な書き方は警告が出ます。ちなみにv0.14のfmtはもう一歩踏み込んで、この書き方を自動で修正するようになりました。古いサンプルコードを雑にコピペできるようになってべんり。

Terraformではなく汎用的なHCLそのものの仕様を調べたいときは、 hashicorp/hcl にありますが、本稿執筆時点ではデフォルトのmasterブランチはまだHCL1であることに注意して下さい。

HCL2の実装やドキュメントはhcl2ブランチにあります。

HCL2を完全に理解したい人は、ユーザガイドやspecを読むとよいでしょう。
https://github.com/hashicorp/hcl/tree/hcl2/guide
https://github.com/hashicorp/hcl/blob/hcl2/spec.md
https://github.com/hashicorp/hcl/blob/hcl2/hclsyntax/spec.md
https://github.com/hashicorp/hcl/blob/hcl2/json/spec.md

用語

用語や基本的な概念自体は以前とほとんど変わっていませんが、細かいことを言うとTerraform EnterpriseのSaaS版であるTerraform Cloudの登場と前後して、いろいろ拡張されています。Terraform Cloudのドキュメント自体も、ところどころEnterpriseの名残が感じられる記載が残ったりしてるので注意。
https://www.terraform.io/docs/cloud/index.html

例えばBackendという用語は若干意味が拡張されました。従来は単にtfstateの置き場を指していたのですが、Terraform CloudやEnterpriseのためにterraformコマンドのCLIがクライアント/サーバモデルに拡張され、Backendはリモートのサーバ側でterraform applyできるような実行環境全体を指す用語になりました。
https://www.terraform.io/docs/backends/index.html

従来のtfstateの置き場は狭義にはBackendの一部としてのState Storageと呼ぶのが正確ですが、広義には単にBackendと呼んでることもあるので、そのへんは文脈から空気を読みましょう。
https://www.terraform.io/docs/backends/state.html

またWorkspaceという用語も、コンテキストによって意味がぶれてことがあるので注意が必要です。従来はterraform workspaceコマンドによる複数面化されたStateを指していた用語ですが、
https://www.terraform.io/docs/commands/workspace/index.html

Terraform CloudにおけるWorkspaceはStateだけじゃなく、ConfigurationやVariableなどを含む実行環境一式で、どちらというと作業ディレクトリのような何かです。個人的には違う概念には違う名前を付けて欲しいのだけれども。

チュートリアル

最近は公式のチュートリアルもいろいろ充実してきて、初心者向けには公式ドキュメントよりもこのへんのコンテンツをオススメするとよいのかもですね。初心者向けのコンテンツのよさは、初心者にしかわからない問題があるので、正直何がオススメか聞かれても困るんですけれども。

Issue

Terraform本体のバグを踏んだときや、こんなことできないの?というようなワークアラウンドを探したりするときは、引き続きTerraformコアのリポジトリのissueを漁るとよいでしょう。
https://github.com/hashicorp/terraform

公式プロバイダのソースコードの場所は以前は terraform-providers というOrganizationの下にあったのですが、最近 hashicorp 配下に移動中です。例えばAWSプロバイダは現在以下のリポジトリでメンテされています。
https://github.com/hashicorp/terraform-provider-aws

本稿執筆時点では、まだ terraform-providers の下に残ってたりしているものもあり、混ざってるので何か作業中のように見えますが、今のところ公式のアナウンスも見当たらないので意図は不明です。

Terraform Registry

以前はモジュールしか登録できなかったTerraform Registryですが、その後v0.13からプロバイダも登録できるようになり、プロバイダ固有のドキュメントもTerraform Registryから参照できるようになりました。例えばAWSプロバイダのドキュメントは以下で参照できます。

プロバイダのドキュメントがバージョンごとにURLが分かれるようになったのが、地味にうれしいポイントですね。

プロバイダにはいくつかの分類があります。

  • Official: HashiCorpがメンテしているもの
  • Verified: HashiCorpのパートナーがメンテしているもの
  • Community: コミュニティでメンテされているもの

またPublicなRegistryに登録されていないカスタムプロバイダは、上の分類に出てきませんが、ドキュメント上は In-house と呼ばれています。

プロバイダの自作

プロバイダ自作したい人以外はあんまり知る必要のない情報ですが、以前はプロバイダの開発に必要なライブラリは hashicorp/terraform のコアのリポジトリ内の helper/ 配下のライブラリをimportして使っていたものが、 terraform-plugin-sdk という独立したライブラリに切り出されました。

プロバイダを自作したい場合は、以下に公式のチュートリアルがあります。

また、プロバイダを自作したい人向けのテンプレートリポジトリも整備中のようです。

さらに、SDKよりも低レベルなプロトコルに直接アクセスしたい人向けの terraform-plugin-go というライブラリができました。SDKは非互換な変更が入れづらいので、玄人向けにSDKでサポートしていないような機能も使えるようにしていこうという意図です。

また、複数のプロバイダ実装を1つのプロバイダとしてユーザに公開する terraform-plugin-mux というライブラリもできました。SDKのバージョンアップをリソースタイプごとに段階的に移行するというような使い方を想定しています。

terraform-plugin-goterraform-plugin-muxterraform-plugin-sdk を置き換えるものではなく、いずれもSDKの互換性を保ちつつ進化させていくために、相互に補完するものです。それぞれの役割や位置関係は、以下の公式アナウンスが参考になるでしょう。

ちなみに、Terraformのコアとプロバイダ間の通信プロトコルは hashicorp/go-plugin という仕組みで動いていますが、Terraform v0.11まではプロトコルv4ではnet RPCベースでしたが、Terraform v0.12からプロトコルv5になりgRPCが採用されました。protoファイルはここにあります。

gRPCがしゃべれれば、Go以外でプロバイダを実装することが理論上は可能になったわけですが、SDKとかが何もないので、現実的には難しいでしょう。

コミュニティ

discussというコミュニティのフォーラムができました。バグ報告や機能要望ではない使い方の質問などは、ここで質問するとよいでしょう。βテストなどユーザ向けのアナウンスもここで行われています。メンテナの人も見て回答したりしているようなので、Issueというほどでもないんだけどみたいなふわっとしたものを投げ込むのによさそうです。

日本語で使い方の質問や相談したい場合は、 terraform-jp という日本のユーザコミュニティがあるので、Slackに参加して相談するとよいでしょう。Terraform職人のみなさんがきっと相談にのってくれるはず |ω・`)チラ

v0.12-v0.14で増えた文法や関数

v0.12-v0.14で増えた文法や関数で、いくつか知ってると便利そうなものをピックアップして紹介します。

偏ってるので、あれがないとかこれがないとか思うところはあるかもですが、特に何かを網羅するものではありません。すべての変更を知りたい人は、公式のCHANGELOGを参照して下さい。(と言いつつ、v0.12.0は差分が大きすぎてすべてCHANGELOGに載ってないのだけれども)

templatefile

前回の記事では、テンプレートに変数を埋め込むのに、 template_file のData Sourceを使う方法を紹介しましたが、v0.12から組み込み関数として templatefile が使えるようになったので、template_file のData Sourceを使う方法は非推奨になりました。

例えば test.json というファイルに memoryReservation という変数を埋め込む場合は、こんなかんじで書きます。

resource "aws_ecs_task_definition" "test" {
  family = "test"
  container_definitions = templatefile("${path.module}/test.json", {
    memoryReservation = 50,
  })
}
test.json
[
  {
    "name" : "test",
    "image" : "nginx:latest",
    "essential" : true,
    "memoryReservation" : ${memoryReservation}
  }
]

yamldecode

以前から jsonencode という関数があったのですが、v0.12.2から yamldecode も追加されました。組み合わせるとYAMLファイルを読み込んでJSONに変換して食わせるみたいなこともできます。

resource "aws_ecs_task_definition" "test" {
  family                = "test"
  container_definitions = jsonencode(yamldecode(file("${path.module}/test.yaml")))
}
test.yaml
- name: test
  image: nginx:latest
  essential: true
  # 特に根拠はないですが、メモリは50MBもあればたぶん十分でしょう。
  memoryReservation: 50

なんでこんなことをしたいかというと、YAMLなら直接コメントが書けるじゃんってことですね。

jsonencode

と思っていたら、実はJSONにコメント書くのにもっとよい方法がありました。

Terraformのjsonencode関数にはJSONを入れても動くよ

以前からあったと言った jsonencode ですが、HCL2の恩恵で、jsonencodeにJSONをそのまま食わせることも可能になりました。これの何がうれしいかというと、JSONなのにコメントが書けるし、末尾カンマもエラーになりません。

resource "aws_ecs_task_definition" "test" {
  family = "test"
  container_definitions = jsonencode(
    [
      {
        "name" : "test",
        "image" : "nginx:latest",
        "essential" : true,
        # 特に根拠はないですが、メモリは50MBもあればたぶん十分でしょう。
        "memoryReservation" : 50,
      },
    ]
  )
}

なぜこれが動くのかと言うと、jsonencodeのドキュメントには特に言及はないんですが、mapリテラルが = だけじゃなくて : も受け付けるので、JSON風に書くことができるというハックです。なるほどですね。実際にはmapリテラルですが、どうみてもJSONです。やったね。人類が求めていたものだ。

dynamic blocks

これまで、設定をモジュールで外から差し込みたい場合、ブロック数を可変にすることはできなかったのですが、v0.12からdynamicとfor_eachが導入され、ブロックを動的に生成できるようになりました。

例えば、以下のように volume ブロックを複数設定したい場合を考えます。

resource "aws_ecs_task_definition" "test" {
  family = "test"
  container_definitions = "..."

  volume {
    name      = "foo"
    host_path = "/tmp/foo"
  }

  volume {
    name      = "bar"
    host_path = "/tmp/bar"
  }
}

ブロックの数が固定じゃない場合は、 dynamic とfor_each を使うと以下のように書けます。

locals {
  volumes = {
    foo = "/tmp/foo"
    bar = "/tmp/bar"
  }
}

resource "aws_ecs_task_definition" "test" {
  family = "test"
  container_definitions = "..."

  dynamic "volume" {
    for_each = local.volumes
    content {
      name      = volume.key
      host_path = volume.value
    }
  }
}

設定したい項目がブロックか属性かはリソースの型定義次第なので、モジュールでブロック数を可変にしたい場合はdynamicを使うしかないんですが、あんまり使いすぎると可読性が壊滅的になるので、本当にその汎用性は必要なのか?用法用量を守って正しくお使い下さい。

resource for_each

v0.12.6からfor_eachキーワードはresourceレベルでも使えるようになりました。これまでもcountで複数のリソースのコピーを作成することはできたのですが、countだとリソースのアドレスが配列になり、途中のリソースを削除しようとすると、以降のリソースのインデックスがずれてしまい、リソースが再作成になってしまうという問題がありました。for_eachだとリソースのアドレスがマップになるので、途中のキーを消しても他のリソースには影響を与えません。countをリソース作成の0/1に使うなどの特別な理由がない限り、countよりもfor_eachを使うべきでしょう。

例えば、以下のように複数のresourceをcountで作成する場合を考えます。

locals {
  sg = [
    {
      name = "foo"
      desc = "FOO"
    },
    {
      name = "bar"
      desc = "BAR"
    },
  ]
}

resource "aws_security_group" "test" {
  count       = length(local.sg)
  name        = local.sg[count.index]["name"]
  description = local.sg[count.index]["desc"]
}

foobar のアドレスはそれぞれ、 aws_security_group.test[0]aws_security_group.test[1] になっています。
この状態で、sg の配列から foo を取り除くと、 bar のインデックスが 1 から 0 にずれてしまい、 bar も再生成が必要です。

これを、 for_each で書き直すと以下のようになります。

locals {
  sg = {
    foo = "FOO"
    bar = "BAR"
  }
}

resource "aws_security_group" "test" {
  for_each    = local.sg
  name        = each.key
  description = each.value
}

foobar のアドレスはそれぞれ、 aws_security_group.test["foo"]aws_security_group.test["bar"] になっています。これなら foo を取り除いても、 bar のアドレスは変わらず、再生成は不要です。

ちなみに、keyしか実質意味がないような場合は、listは直接for_eachに渡せないので、tosetして渡します。

resource "aws_security_group" "test" {
  for_each = toset(["foo", "bar"])
  name     = each.key
}

module for_each

v0.13からmoduleにもfor_eachが書けるようになりました。同じモジュールを複数デプロイしたいときにべんり。

例えば、先ほどのresource for_eachの例を、testモジュールに切り出してみましょう。 name はvariableで受け取ることにします。

test/main.tf
variable "name" {
  type = string
}

resource "aws_security_group" "test" {
  name = var.name
}

これを、module for_eachを使うと、以下のように書けます。

main.tf
module "test" {
  for_each = toset(["foo", "bar"])
  source   = "./test"
  name     = each.key
}

これ使ったらリージョンごとのproviderのエイリアスをfor_eachで回して、マルチリージョンデプロイできるのでは?って思ったんですが、残念ながら現状ではできないようです。providerはresource削除のためにresourceよりも長いライフサイクルを持っている必要がある、という構造上の問題なので、簡単にはできないらしいです。

variable validation

v0.13からvariableにvalidationが書けるようになりました。モジュールの作者と利用者が分かれてる場合はうれしいんじゃないでしょうか。

例えば、test モジュールの image_id というvariableは ami- から始まるという制約は、以下のように書きます。

test/main.tf
variable "image_id" {
  type        = string
  description = "The id of the machine image (AMI) to use for the server."

  validation {
    condition     = length(var.image_id) > 4 && substr(var.image_id, 0, 4) == "ami-"
    error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
  }
}

このモジュールの呼び出し側で、不正な入力を与えてみます。

main.tf
module "test" {
  source   = "./test"
  image_id = "foo"
}

terraform validate で検証してみると、エラーが検出されます。

$ terraform validate

Error: Invalid value for variable

  on main.tf line 3, in module "test":
   3:   image_id = "foo"

The image_id value must be a valid AMI id, starting with "ami-".

This was checked by the validation rule at test/main.tf:5,3-13.

can

v0.12.20からエラーをboolに変換する can が追加されました。variable validationを書くときとかに使えます。

例えば、 timestamp というvariableはRFC3339形式(例: 2018-01-02T23:12:01Z)に制限する場合を考えます。

組み込みの formatdate(spec, timestamp) 関数は、第1引数に変換後のフォーマットを、第2引数に変換元のタイムスタンプの文字列をRFC3339形式で渡します。第2引数の文字列が不正なフォーマットでパースできないとエラーを返します。エラーが発生した場合に、 can でboolに変換すれば、 validationcondition として利用できます。

variable "timestamp" {
  type = string

  validation {
    condition     = can(formatdate("", var.timestamp))
    error_message = "The timestamp argument requires a valid RFC 3339 timestamp."
  }
}

try

似たようなので、同じくv0.12.20から、エラーの発生しない最初の値を返す try も追加されました。これはデータ構造が実行時にしか分からないようなときに、参照エラーを出さずにデフォルト値を埋めたりするのに使えます。

例えば、以下のように、YAMLで書いた設定ファイルに name という文字列と groups という文字列の配列の属性があり、 groups は必須ではなく省略可能という場合を考えてみます。

example.yaml
name: "foo"
# groups:
#   - "bar"
#   - "baz"

以下のように、単純にファイルを読み込むと、

locals {
  raw_value = yamldecode(file("${path.module}/example.yaml"))
  normalized_value = {
    name   = local.raw_value.name
    groups = local.raw_value.groups
  }
}

groups が未定義なので、参照エラーになってしまいます。

$ terraform validate

Error: Unsupported attribute

  on main.tf line 11, in locals:
  11:     groups = local.raw_value.groups
    |----------------
    | local.raw_value is object with 1 attribute "name"

This object does not have an attribute named "groups".

これを以下のように、 try で囲って、エラーを抑止しつつ、デフォルトは空配列 [] を返すようにしてみます。

locals {
  raw_value = yamldecode(file("${path.module}/example.yaml"))
  normalized_value = {
    name   = local.raw_value.name
    groups = try(local.raw_value.groups, [])
  }
}

エラーにならず、デフォルト値が設定されていることが分かります。

$ terraform validate
Success! The configuration is valid.

$ terraform console
> local.normalized_value.groups
[]

experiments

v0.12.20からexperimentsという仕組みが導入され、いくつかの新機能はexperimentsフラグをonにすると使えるものがあります。

terraform {
  experiments = [example]
}

v0.14系では module_variable_optional_attrsprovider_sensitive_attrs が使えます。experimentsが増えるときはCHANGELOGに記載されるので、詳細はCHANGELOGを参照して下さい。experimentsな機能はproductionで利用することは推奨されませんが、面白そうな機能があれば試してみてフィードバックするとよいでしょう。

tfファイルを書く技術

LSP

エディタは好きなものを使えばよいと思いますが、公式のLSP実装の開発が進んでいることは言及しておくべきでしょう。
LSP(Language Server Protocol)とは、簡単に言うと、エディタxプログラミング言語の組み合わせごとに補完のロジックとか自前で実装するの無駄じゃね?という問題を解決するため、プログラミング言語ごとにエディタ非依存のLanguage Serverを実装して、クライアント/サーバモデルにしようとマイクロソフトが標準化を進めている、テキストエディタのためのプロトコルです。エディタx言語の乗算の組み合わせが、エディタ+言語の加算の組み合わせになって、みんな幸せじゃんというわけです。
https://microsoft.github.io/language-server-protocol/

で、TerraformのためのLanguage ServerをHashiCorpが公式に開発中です。
https://github.com/hashicorp/terraform-ls

今のところリソースタイプや属性の補完など簡単な操作はサポートされていますが、いわゆるプログラミング言語のIDEの体験と比較すると、まだあまり多くのことはできません。つい最近までは補完候補を取得するために、内部的に terraform providers schema コマンドが使われており、これが構造上、Backendのtfstateへのアクセスのため認証を要求してしまい、大変厳しい感じだったのですが、先日出たterraform-ls v0.10で、すべての公式プロバイダの最新版のスキーマ(型定義)をバイナリに埋め込んじゃうという力技が実装され、プロバイダの細かいバージョン差異を気にしなければ、rootモジュールの初期化ができない状況でも、最低限の補完ができるようになりました。現状機能は限定的ですが、かなり活発に開発されているので、今後の展開に期待です。

とりあえず試してみたいということであれば、特にこだわりがなければ、VSCodeを使っておけばよいんじゃないでしょうか。VSCodeのTerraform拡張はv2からHashiCorpが公式にメンテナンスを引き取り、内部的にこのLSP実装が使われているので、これが実質的にリファレンス実装と言えるでしょう。
https://github.com/hashicorp/vscode-terraform

LSP自体はエディタ非依存なので、自分の好みのエディタにLSPのクライアントを入れると使えます。私は普段Vimを使っているので、vim-lspというプラグインを使っています。
https://github.com/prabirshrestha/vim-lsp

いくつかのエディタやプラグインは、設定サンプルが以下にあります。
https://github.com/hashicorp/terraform-ls/blob/main/docs/USAGE.md

設定サンプルに載っていなくても、最近のモダンなテキストエディタは大体LSPをサポートしているので、自分の好みのエディタでLSPを使う方法を調べてみるとよいでしょう。

Linter

Infrastructure as Codeの恩恵は、バージョン管理やコードレビューなどのこれまでのソフトウェア開発のプラクティスを、インフラの世界にも適用できるようになったことですが、コードの内容を自動でチェックするLinterも例外ではありません。

いわゆるLinterというと、コーディングルールへの違反を指摘するイメージですが、HashiCorpが公式に開発しているSentinelは、独自に定義したポリシーへの違反を検出することで組織のガバナンスを強化する、Policy as Codeという概念を提唱しています。Sentinelは有償ツールで、Terraform CloudのTeam & Governance以上のプランで使えます。
https://www.terraform.io/docs/cloud/sentinel/index.html

その他OSSでもいろいろなツールが開発されています。

ツールごとにそれぞれ特色があり、例えばTFLintは潜在的な誤りやベストプラクティス違反を検出しますが、tfsecやTerrascanはセキュリティ上の問題を検出します。またOPAは汎用的なポリシー定義への違反を検出しますが、Terraform専用ツールではなく、Kubernetesなどの設定にも使える汎用ツールです。

TFLintで未使用定義を自動で検出する

TFLintをとりあえず入れてるという人もいるかもですが、いくつかのルールはデフォルトで有効ではありません。そのようなルールの1つとして、TFLint v0.16.0から追加された terraform_unused_declarations があります。これは未使用のlocal/variable/data定義を検出します。便利なのにあまり知られていない気がするのがもったいないので、紹介しておきます。

以下の例では、変数 not_used はどこからも参照されていません。

config.tf
variable "not_used" {}

variable "used" {}
output "out" {
  value = var.used
}

tflintを実行すると、未使用定義として検出されます。

$ tflint -v
TFLint version 0.21.0

$ tflint --enable-rule=terraform_unused_declarations
1 issue(s) found:

Warning: variable "not_used" is declared but not used (terraform_unused_declarations)

  on main.tf line 1:
   1: variable "not_used" {}

Reference: https://github.com/terraform-linters/tflint/blob/v0.21.0/docs/rules/terraform_unused_declarations.md

このわざとらしいサンプルの規模だと自明ですが、実際にはTerraformのモジュールをごにょごにょして試行錯誤しているうちに、variable定義したけど結局使ってないやん、みたいなのあるあるですよね。あとで残ったゴミだけ見つけてなんだっけこれみたいな。 terraform_unused_declarations は 未使用のvariable以外にもlocalやdataも検出します。大体消し忘れか、参照間違いなので、未使用定義が残っている場合は、CIで落とすように強制しておくと、あとでコードをいじる人が幸せになれます。

HCLをコマンドラインから編集する

Terraformのコードが大量にあると、リファクタリングなどで、機械的に書き換えたいみたいなことが稀によくあります。単にHCLを編集したいということであれば、HCLを一旦JSONなど他のデータ構造に変換してしまえば、jqなど既存のツールが活用できるのですが、それだとコメントが失われてしまいます。つらい。HCLをHCLのままでピンポイントで書き換えたいのです。というわけで、HCLをコマンドラインから編集するCLIツールを作ってみたので貼っておきます。

特徴は以下のとおりです。

  • 標準入出力でHCLを読み書きするので、簡単に他のコマンドとパイプして組み合わせることが可能です。
  • HCLのまま直接トークンを書き換えるので、可能な限りソースコード内のコメントを維持します。これは既存のHCLファイルをスクリプトなどで一括処理するのに便利です。
  • Terraformには依存せず、汎用的なHCLを対象にしているのでスキーマレスです。
  • HCL2対応(というかHCL1はサポートするつもりはありません)

まだ基本的なことしかできないんですが、本稿執筆時点のhcledit v0.1.2では、以下のオペレーションができます。

  • block append/get/list/mv/rm
  • attribute append/get/rm/set
  • fmt

例えば以下のようなHCLファイルがあったとして、

attr.hcl
resource "foo" "bar" {
  attr1 = "val1"
  nested {
    attr2 = "val2"
  }
}

例えば attr2 の部分の右辺の値を取得したい場合は、こんなかんじで hcledit attribute get にアドレス resource.foo.bar.nested.attr2 を指定して値を取得できます。

$ cat attr.hcl | hcledit attribute get resource.foo.bar.nested.attr2
"val2"

もちろん、値を書き換えることも可能です。値を書き換えるには hcledit attribute set を使います。第1引数にアドレス、第2引数に変更後の値を指定します。

$ cat attr.hcl | hcledit attribute set resource.foo.bar.nested.attr2 '"val3"'
resource "foo" "bar" {
  attr1 = "val1"
  nested {
    attr2 = "val3"
  }
}

変更後の文字列をセットするときに、文字列を表すダブルクオート " も必要で、 '"val3"' という風にさらにシェルに解釈されないように、シングルクオート ' でエスケープする必要があることに注意して下さい。これは単に val3 と書いた場合、 HCLの仕様的には val3 という変数の参照の意味になってしまうからです。

hcleditそのものは単機能なツールなので、これ単体で何かをするというよりも、スクリプトを書いたりして他のコマンドと組み合わせて使うことを想定しています。例えば、先ほどのTFLintと組み合わせて、未使用のlocal/variable/data定義を検出して一括削除するスクリプトを書いたりすることも可能です。簡単な使い方やサンプルコードは別記事に書いたので、興味ある人はこちらを参照して下さい。

HCLをコマンドで編集するhcleditというツールを書いた

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

バージョン制約を書き換える

ディレクトリを細かく切ってると、バージョン制約を書き換えるのは地味にめんどくさいです。というわけで、指定のディレクトリ配下のTerraform本体/プロバイダ/モジュールのバージョン制約を、いいかんじに一括で書き換えてくれる tfupdate というツールを書いたので貼っておきます。

本稿執筆時点のtfupdate v0.4.3の特徴はこんなかんじです↓

  • Terraform本体/プロバイダ/モジュールのバージョン制約を更新
  • 指定したディレクトリ配下を再帰的に一括更新
  • GitHub/GitLab/TerraformRegistryのリリースタグから最新バージョンを取得
  • Terraform v0.12+対応 (v0.13, v0.14でも動きます)

これを例えばCircleCIのスケジュールジョブなどに仕込むと、毎日チェックして、最新版がリリースされたらバージョンアップ用のPull Requestを自動生成したりできます。

CIへの組み込み方などのサンプルは、以前、別記事に書いたので、興味ある人はこちらを参照して下さい。

tfupdateでTerraform本体/プロバイダ/モジュールのバージョンアップを自動化する

ちなみにこのような依存をアップデートする手段として、 dependabot というサービスがありますが、残念ながら本稿執筆時点では未だにdependabotはHCL2対応されておらず、Terraform v0.12以降では使えません。

SaaSがよい場合は、 renovate はTerraform v0.12以降のHCL2対応しているので、こちらをチェックしてみるとよいでしょう。

tfupdate自体はバージョン制約を書き換えることに特化しており、若干スコープが異なります。スケジューラ部分はCIなりcronなり自前で書く必要はありますが、ただのCLIツールなので自由度が高く、好みのワークフローにいかようにでも組み込めます。

provider source

Terraformの本体やモジュール周りのバージョン管理はあまり変わっていませんが、プロバイダ周りはいろいろ変わりました。

以前は3rd-partyのプロバイダを使う場合は、バイナリをダウンロードしてきて、指定のディレクトリに保存する必要がありましたが、v0.13から3rd-partyのプロバイダもTerraform Registryに登録できるようになり、プロバイダの指定方法に名前空間が導入されました。sourceを指定しない場合は暗黙に公式のプロバイダが仮定されますが、v0.13以降はrequired_providersブロックでsourceを明示することが推奨されます。

terraform {
  required_providers {
    mycloud = {
      source  = "mycorp/mycloud"
      version = "~> 1.0"
    }
  }
}

provider version

前回の記事では、providerのバージョン固定のために、providerブロックでversionを指定する方法を紹介していましが、

provider "aws" {
  version = "3.20.0"
}

providerのversion指定は、v0.13からprovider sourceの導入に合わせて、version指定も前述のとおりrequired_providersのブロックで指定することが推奨されるようになりました。さらにv0.14からproviderブロックでversionを指定するのが非推奨になり警告が出ます。

例えば、上のようなAWSプロバイダの指定は、以下のように書く必要があります。

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.20.0"
    }
  }
}

providerブロックでversionを指定する方法はv0.13より前から使ってる人は大体影響を受けそうですが、v0.15で削除される予定です。v0.14ではまだ動くものの、警告もうざいので早めに新しい方法に書き換えましょう。

.terraform.lock.hcl

以前はプロバイダの依存はバージョン制約しか書けず、マイナーマージョンでも意図しない差分が発生することがありえるので、バージョンを厳密に固定するしかなかったのですが、v0.14から .terraform.lock.hcl というロックファイルが導入されました。これはいろいろなプログラミング言語の依存ライブラリの管理でよくある、バージョン制約と選択の分離です。

v0.14で terraform init すると以下のようなロックファイルが生成されます。

terraform.lock.hcl
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.

provider "registry.terraform.io/hashicorp/aws" {
  version     = "3.20.0"
  constraints = "3.20.0"
  hashes = [
    "h1:Wk7JYiEIslHQorVPWnofRNYUAjyro6IehY/d/Yfmbr8=",
    "zh:1b53d410c21332750be561092d412d83014fa0656e00f940944d2e7b07b1b9ec",
    "zh:307bf780790462fe547fe23f8e38a4c178437f3a9dd725f9aa63c6d8c6cbf25d",
    "zh:5818a978b9766b23a190716b85aad3a4731d33ddb8a81080cf3ef6e4bd68a003",
    "zh:5f68eb4779208e21d9657b9ff492aa5f6496efea7994bdec1d302f88b0b65f34",
    "zh:6028208a7b3738801cd9f3376efa40a1e55f4bb8184584f7387b08c054e43c4c",
    "zh:8130269e2d8c80ea9136dcd26cfeb4e1fac83bda4aab0db70f36651a7b22365d",
    "zh:9dd4a07beb89606e051b64ab05d75e1c1616389871a55065676b370aebaed8e5",
    "zh:b7194500db431ba862ea8008db56a5decececda1f904ed8842d2b0f1a04eea9d",
    "zh:ec214b7341137e6dd47754b843ed16fe3e1d32832537042ee81a64a3ccdbb4bd",
    "zh:ec2973e04f3cb853895e51f6ec56660574610b860ee3de669ccbb1f04d1089c9",
  ]
}

このロックファイルが存在すると、 terraform init が前回と同じバージョンを選択します。更新する場合は terraform init -upgrade を実行します。
v0.14時点では、対象はプロバイダのみで、モジュールは対象外ですが、そのうちモジュールも対応されるでしょう。

1つ注意点として、プロバイダのバイナリは実行するプラットフォームごとに異なるので、例えば手元はmacOSだけど、CIはLinuxみたいな場合は、チェックサムのハッシュ値の扱いに注意が必要です。これを理解するためには、まずロックファイルに記録されているハッシュ値のアルゴリズムが現状 zhh1 の2種類あることを知る必要があります。

zh はプロバイダの配布物のzipのハッシュで、Terraform Registryのオリジンからダウンロードした場合に、すべてのプラットフォーム分がまとめて記録されます。一方、 h1 はプロバイダのバイナリのハッシュ値で、デフォルトではロックファイルを生成したそのプラットフォーム分だけが追記されます。つまり普通に terraform init すると、最初に実行したプラットフォームのh1ハッシュしか記録されず、プラットフォームが混在する環境ではチェックサムミスマッチが起きます。

プラットフォームごとに terraform init -upgrade を実行してロックファイルを更新することも可能ではあるものの、 .terraform.lock.hcl は通常gitでバージョン管理することを想定しているので、それぞれのプラットフォームで追記するのは不便です。というわけで、あらかじめ必要なプラットフォーム分のハッシュを事前に生成するには、 terraform providers lock コマンドを使うとできます。

またzhハッシュを記録するには、Registryからプロバイダをダウンロードする必要がありますが、ローカルにディレクトリが大量にあると無駄なダウンロードが発生します。 terraform providers mirror コマンドでローカルミラーを作ることで、不要なダウンロードを回避できます。

で、ここがややこしいのですが、ミラーからインストールした場合は、h1しか記録されず、zhは記録されません。キャッシュからインストールした場合も同様です。つまり、

  • 複数のプラットフォームが混在している
  • 複数のディレクトリを管理している
  • 重複ダウンロードを避けるためにローカルミラーやキャッシュを使っている

という状況下において、すべてのディレクトリで複数プラットフォーム対応の .terraform.lock.hcl を効率よく生成しようとすると、zhは記録せずにh1だけを使うというのが、現実的な最適解です。

ミラーを作るタイミングでバイナリの署名は検証されるので、そのタイミングで生成されたh1ハッシュをロックファイルに書き込んでおけば、zhは使わなくても実害ない認識です。Registryが返すのがzhなので、zhの方が推奨されているのかと勘違いしがちですが、zhはキャッシュなどから読み込んだ場合など、すべてのインストール方法ではサポートされていないので、ドキュメント上でもレガシーな方法と位置づけられており、そのうちRegistryが直接h1も返してくれるようにならないかなー、などと勝手に期待しております。

現状、h1だけ記録した状態かつキャッシュも何もない状態で、terraform init -upgrade ではなく terraform init するだけロックファイルにzhが追記される挙動になっており、これがバグなのか仕様なのか分からず。まだ自分の中でも運用を模索中のところもあるので、運用が確立できたら、そのうちまとめます。たぶん。

運用Tips

センシティブな値の扱い

前回の記事では、 ignore_changes を使ってリソースの差分を無視することで、パスワードなどのセンシティブな情報をTerraformの管理対象外にする方法を紹介しました。この方法について、若干補足しておきます。

例で出てきた aws_db_instancepassword 属性のような一度設定後、APIから現在の値を確認できないような項目はこれで問題ないのですが、 aws_ssm_parametervalue 属性のような、APIから現在の値を確認できるリソースは、 ignore_changes が指定されていても最新の値はtfstateに書き込まれてしまいます。これは aws_ssm_parameter の場合、 type = SecureString を指定して暗号化していても、平文が tfstateに書き込まれてしまうことを意味します。

この挙動については、 aws_ssm_parameter のドキュメントにも警告が記載されていますが、勘違いしている人が多そうなので、あえて取り上げみました。(というか私も最初勘違いしていました)
https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter

これは aws_ssm_parameter の特定のリソースタイプの問題ではなく、Terraformのアーキテクチャ上の問題なので、他のリソースタイプでも発生し得ます。 ignore_changes を使ってクレデンシャルのtfstateへの平文の書き込みを回避しようとしている場合は、本当に意図したとおりになっているか、念のためtfstateの中身をチェックするとよいでしょう。

ちなみに、Terraform v0.14から variableに sensitive=true を指定できるようになりましたが、これはCLI上の表示を隠すだけで、引き続きtfstateには平文が記録されています。

結局いずれの方法でも、現状tfstateに直接アクセス可能な人には平文が見えてしまうので、tfstate全体をセンシティブなものとして扱うか、そもそもtfstateにセンシティブな値を記録しない方がよいでしょう。

terraform_remote_stateやめよう

前回の記事で、Stateを跨いで値を参照するのに紹介した terraform_remote_state は、その後、運用していくうちに避けたほうがよいかなと思うようになり、私は使うのをやめてしまいました。

私が使うのをやめた直接的な要因は、 terraform_remote_state がBackendと一部実装を共有していることから来る、細々したバグをいくつか立て続けに踏んだことです。

https://github.com/hashicorp/terraform/issues/21478
https://github.com/hashicorp/terraform/issues/15811

そのバグは自体はもう治ってますが、tfstateを読み込んでデコードしないといけないという構造は、読み込み側がすべての値を必要としていなくても、tfstate全体の実装詳細に依存していることを意味しています。

もっと一般的に分かりやすいデメリットは、Terraformのバージョンアップ時に、アップデートする順番の依存関係ができてしまうことです。新しいバージョンのTerraformでは古いバージョンで書かれたtfstateは読み込めますが、その逆はできません。

最近この制限についても厳しすぎるということで、Terraformのバージョンが上がっても、tfstateのフォーマットバージョンが変わらない限りは読み込めるようにしようという変更がv0.14に入り、

古いv0.13やv0.12系のブランチのパッチリリースにも順次バックポートされそうで、この問題も徐々に緩和されつつあるのですが、当然tfstateのフォーマットバージョンが変更になる場合には、引き続きこの制約が発生します。tfstate同士が複雑な依存グラフになっている場合は、バージョンアップの順序に注意を払う必要があります。

また、すべてのリソースがTerraformで管理されている、という仮定も現実的ではありません。例えば

  • 途中からTerraformを導入したので過去に手動で作成したリソースがある
  • 新機能で追加されたリソースタイプがまだプロバイダでサポートされていないので手動で作成した
  • 一部のリソースはTerraformではなくServerless Frameworkなど別のツールで管理している

などなど。Terraformで作成したリソースかそれ以外かで、参照する側の書き方が変わってしまい、そのリソースがどうやって作られたのかの実装詳細に依存しすぎてしまいます。

どうするのがよいかというと、各リソースタイプに対応するData Sourceを使って参照すると疎結合になります。例えばAWSのVPCのIDを参照したいのであれば、リソースに所属を表すservice=foo, env=prod というようなタグを付けておき、 aws_vpc の Data Source を使って、タグで利用者側がフィルタするとよいでしょう。

data "aws_vpc" "vpc" {
  tags = {
    service = var.service
    env     = var.env
  }
}

VPCのIDだけではなく、SubnetのIDなどまとめて取得する項目のセットがある程度決まっている場合は、関連する複数のData Sourceだけを定義したモジュールを書くと便利なことがあります。これは「Data-only モジュール」パターンと呼ばれていて、依存リソースがどうやって作成されたかの実装詳細を隠蔽することができます。

他の案としては、AWSのParameter StoreやConsulのようなKVSに値を書き込んでおき、参照する側がKVSから読み込むという方法もあります。ひと手間かかるので私はここまでやってませんが、データの参照範囲や権限をコントロールしやすくなるので、検討する価値はあるでしょう。

direnv使うのやめよう(AWS限定)

前回の記事では、ディレクトリでprod/devなどのAWSアカウントが分かれている場合に、direnvを使ってAWSアクセスキーなどのクレデンシャルを環境変数で切り替える方法について紹介しましたが、direnvも最近使うのをやめました。

というのも、direnvを使った方法はお手軽に導入できるものの、このディレクトリはどのクレデンシャルが必要なのかという知識が、.gitignoreされたローカルの.envrcに依存してしまい、暗黙知になってしまいます。ディレクトリが少ないうちはこれでも問題なかったのですが、tfstateを細分化して管理するためにディレクトリが増えてくると、新しいディレクトリが増えたときに.envrcを撒くのがめんどくさいというだけではなく、CIでマルチアカウントを扱うのに、ディレクトリ名などからどの認証情報使うかを判断するなどロジックが複雑化しがちです。

他のクラウドプロバイダではちょっとどうすべきなのかはわかってないので、ここではAWS限定という条件付きで説明します。

AWSの場合は、認証方法はいくつかあるのですが、基本的な方針としてはbackendやproviderの設定にハードコードしてコードで管理します。 ただ role_arn を指定する方法だと、権限の強さをコントロールしづらいので、 profile を指定する運用で利用者側に余地を残します。つまり profile = "dev" などをtfファイル側で明示的に指定しておき、 ~/.aws/config で解決させます。

また ~/.aws/config には credential_process という設定で、認証情報の取得を外部のプロセスに委譲する設定があります。これにより、AssumeRoleなどでマルチアカウントの切り替えに aws-vault を使っている場合でも、必要なタイミングで自動でクレデンシャルを aws-vault 経由で読み込ませることが可能です。

例えば base という踏み台アカウントの minamijoyo ユーザが、 dev というアカウントの admins ロールに、AssumeRoleで切り替えたいかつMFAは強制したい場合、~/.aws/config のサンプルはこんなかんじです。

~/.aws/config
[profile base]
region=ap-northeast-1
credential_process = sh -c 'aws-vault exec base --json 2> /dev/tty'
mfa_serial=arn:aws:iam::111:mfa/minamijoyo
role_session_name=minamijoyo

[profile dev]
region=ap-northeast-1
include_profile=base
source_profile=base
role_session_name=minamijoyo
role_arn=arn:aws:iam::222:role/admins

include_profile はaws-vault v6系以降でしか動かないので注意して下さい。

tfファイル側はこんなかんじです。

terraform {
  required_version = "0.14.2"

  backend "s3" {
    region         = "ap-northeast-1"
    bucket         = "example"
    key            = "terraform.tfstate"
    profile        = "dev"
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "3.20.0"
    }
  }
}

provider "aws" {
  region  = "ap-northeast-1"
  profile = "dev"
}

正確さを多少犠牲にしつつイメージで表現すると、認証情報の読み込みの流れはこんなかんじです。

Terraform => ~/.aws/config => profile dev => profile base => credential_process => aws-vault

若干の補足として、Terraform v0.13以降とAWSプロバイダv3以降でAWSの認証周りの読み込み順序がいろいろ変わってるので、それより古いバージョンだとうまく動かないかもです。というのも、 ~/.aws/config は元々awscli用の設定ファイルなので、このファイルを読み込むかはアプリケーション依存ですが、多くのAWS関連ツールはこの設定を読み込みます。Terraform v0.12/AWSプロバイダv2でも AWS_SDK_LOAD_CONFIG=1 とすると読み込まれたらしいですが(未検証)、Terraform v0.13/AWSプロバイダv3からはデフォルトで読み込まれるようになりました。

一見、簡単な設定ですが、仕組みはちょっと複雑なことをしているので解説しておきます。

まずcredential_processは標準出力で認証情報をやりとりするインターフェースとなっており、 aws-vault exec base --json で対応するフォーマットで出力できるようになっています。ただ直接aws-vaultをcredential_processに指定するのではなく、shを経由して、標準エラー出力を /dev/tty にリダイレクトしているのは、MFAのコードを受け取るためです。

元々credential_processの設計上は、ユーザのインタラクションを前提としていない、かつ標準出力で認証情報をやりとりする前提となっているので、MFAの入力に使えるものが標準エラー出力しかなく、若干トリッキーではあるものの、aws-vaultもこの用途で標準エラー出力からMFAコードを受け取れるようになっています。標準エラー出力なのに入力とはこれ如何にってかんじですが。
もしくは、 macOSの場合は、 --prompt=osascript とするとGUIのポップアップも出せます。このへんは好みの問題なので、各自好きに設定して下さい。

credential_process = aws-vault exec base --json --prompt=osascript

ところで、 mfa_serial の設定は base のところで指定して、 dev の方では指定していませんが、これも補足が必要でしょう。というのもTerraformはプロバイダがインタラクティブな入力を受け取れないため、MFAのユーザ入力に対応していません。なので dev のところに mfa_serial を書くとエラーになります。MFAはaws-vaultのセッションに処理させる必要があります。

しかしながら、dev のところから mfa_serial を取り除くと、今度はterraformコマンド関係なく単発で aws-vault exec dev -- aws sts get-caller-identity というようにコマンドを実行したときに、aws-vaultがMFAが必要なことを認識できずに、認証エラーになってしまいます。この問題を解決するために include_profile=base という指定を入れています。若干分かりづらいんですが、 include_profile はprofileを継承するaws-vault独自のキーワードで、profile=base の設定が読み込まれ、結果的に mfa_serial も読み込まれます。

本来 include_profile は重複した冗長な設定をincludeを使ってスッキリ書ける便利機能なのですが、この項目は aws-vaultは理解するが、awscliやterraformコマンドなどは理解できないので無視するという動作をうまく使います。つまり、aws-vault経由で dev のプロファイルを呼び出した場合は、MFAが必要なことが認識され、それ以外のawscliやterraformコマンドが呼び出した場合は無視されて、結果的に base のcredential_process経由でaws-vaultが実行されたタイミングでMFAが読み込まれる、という絶妙なバランスになっています。初見でわかりませんよね。私もこのパズルの解法を見つけるのにだいぶハマりました。

一方、CIでAWS CodeBuildなどを使っている場合は、ビルドのスクリプトの中でprofile設定を生成して、環境変数 AWS_CONFIG_FILE にセットしておくとよいでしょう。

mkdir -p "${CODEBUILD_SRC_DIR}/tmp"
(
cat << 'EOF'
[dev]
credential_source=EcsContainer
role_arn=arn:aws:iam::222:role/terraform-codebuild-role
EOF
) > "${CODEBUILD_SRC_DIR}/tmp/aws_config"
export AWS_CONFIG_FILE="${CODEBUILD_SRC_DIR}/tmp/aws_config"

CodeBuildでAssumeRoleする方法は、公式のドキュメントを見つけられなかったのですが、 credential_source=EcsContainer を指定するとCodeBuildのサービスロールを取得できるので、
CodeBuildのサービスロールから、Terraformを実行するIAMロール(この例だと arn:aws:iam::222:role/terraform-codebuild-role )にAssumeRoleする権限などを付けておけば動きます。
裏がECSなの隠そうとしてなくて正直でよいですね。

プロバイダのダウンロードをキャッシュする

プロバイダのバイナリはデフォルトでは カレントの .terraform ディレクトリ配下に保存されますが、例えばAWSプロバイダのような巨大なプロバイダは圧縮状態でも40MBぐらい、解凍すると200MBぐらいあって結構デカイです。ディレクトリが細かく分かれていると都度ダウンロードしたりするのは無駄ですし、バージョンアップして不要になった、古いバイナリがいろいろなディレクトリに散らばると、チリツモでローカルディスクの容量を圧迫しがちです。

プロバイダのバイナリのキャシュは環境変数 TF_PLUGIN_CACHE_DIR で保存先を変更できます。

export TF_PLUGIN_CACHE_DIR="$HOME/.terraform.d/plugin-cache"

もしくは .terraformrcplugin_cache_dir でも指定可能です。

plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

同じバージョンのバイナリは、カレントの .terraform ディレクトリ配下からsymlinkが貼られて再利用されます。

terraform planの並列度を調整する

プロバイダのバイナリをキャッシュしても、Terraformで管理しているリソースが増えてきて、1つのtfstateが肥大化してくると、terraform planがどんどん遅くなるのは避けられません。そんな場合の根本的な対策は、適度な粒度にtfstateを分割していくことですが、tfstateをいじるのはできなくはないけど、ちょっとめんどくさいです。

もっと手軽に高速化できるかもしれない小手先のテクニックとして、並列度を調整する -parallelism オプションがあります。

デフォルトは10並列で動いてますが、変更可能です。呼び出すAPIやネットワーク構成などに依存するので、最適な値はケースバイケースですが、一般的に扱うリソースが多い場合は、並列度を上げると高速化が期待できます。簡単に試せる割に、特に失うものがないので、とりあえずtfstateが肥大化してきてplanおせぇぇぇという人は試してみるとよいんじゃないでしょうか。

あなたのterraform planを手軽に高速化する(かもしれない)魔法の言葉

tfstateファイルを書く技術

Terraform v0.12からtfstateのフォーマットはv4になりました。
v3とv4は全然違いますが、型定義はここにあります。興味ある人は眺めてみて下さい。

tfstateの中身はその後もv0.13でprovider sourceの対応が入ったり、v0.14 sensitive variableの対応が入ったりして変わっているものの、フォーマットバージョンはまだv4のままです。どうやら後方互換を保って読み込みができる限りは、フォーマットバージョンは変えないようです。

ただ個人的にtfstateファイル自体を直接編集する機会は、奇妙なバグを踏んだとき以外ではほとんどなくなってきたように感じます。たいていの操作は、terraform state mv/rm/import でできるようになってきたからでしょうか。よい傾向だとは思います。

マイグレーション

plan高速化や、事故った場合の影響局所化のために、ディレクトリを分割したいというリファクタリングはよくあると思いますが、チーム開発をしていると *.tf はgitで管理し、tfstateはAWS S3などのBackendのState Storageに保存して共有するというスタイルが一般的です。ここで問題になるのが、terraform state系のコマンドはその場でリモートのtfstateを書き換えしてまい、gitのmaster(main)ブランチと差分が出てしまう問題があります。レビュー前にはリモートのtfstateを書き換えたくないが、terraform state系のコマンドを実行した後の状態でplan差分がないことは、念のため先に確認しておきたいみたいなお気持ち。

この問題を解決するため、state操作をマイグレーションという概念で扱って、マイグレーションファイルをgitにコミットできるようにした、tfmigrateというツールを書いたので貼っておきます。

本稿執筆時点のtfmigrate v0.2.0の特徴はこんなかんじです↓

  • GitOpsフレンドリ: terraform state mv/rm/import をHCLで書いてgitにコミットし、plan/applyできます。
  • モノレポ対応: リソースをディレクトリを跨いで他のtfstateに移動できます。これはtfstateを分割/結合するようなリファクタリングに便利です。
  • dry-run: リモートのtfstateを更新することなく、一時的なローカルのtfstateに対してマイグレーションを実行し、マイグレーション後にterraform planの差分がなくなるかを事前にチェックできます。
  • 履歴管理: どのマイグレーションを適用したかを記録し、未適用分だけ順次適用します。

簡単な使い方などは、以前、別記事に書いたので、興味ある人はこちらを参照して下さい。

Terraformのstate操作をgitにコミットしたくてtfmigrateというツールを書いた

Terraformをデバッグする技術

Terraform本体のデバッグ方法は以前とはあんまり変わってないですが、ちょっとだけアップデートがあるので紹介します。

デバッグログ

デバッグログを出すための TF_LOG の設定ですが、
masterブランチで開発が進んでいる v0.15系では、コアとプロバイダそれぞれ別々に指定できるようにする変更が先日マージされました。

TF_LOG_CORE=level
TF_LOG_PROVIDER=level

デバッグログを出すとすごい量出てしまうので、例えばプロバイダのデバッグをしていてコアのログはいらないんだけど、みたいなときに便利そうです。

$ TF_LOG_CORE=OFF TF_LOG_PROVIDER=TRACE terraform plan

TF_LOG 自体は従来どおり両方とも有効にする設定として残っています。

プロバイダのデバッグ

前回の記事では、プロバイダのデバッグ方法がうまく確立できなかったのですが、その後、Terraform v0.12.26からunmanaged providerという仕組みが入って、terraformコマンドの外で起動したプロバイダのプロセスに、あとから起動したterraformコマンドが接続できるようになりました。

これの何がうれしいかというと、プロバイダのプロセスは通常terraformコマンドから起動されるので、delveのようなデバッカを差し込むのが困難だったのですが、プロバイダのプロセスのライフサイクルをterraformコマンドから切り離せると、この手のデバッグがしやすくなります。テストなどのモックではなく本物のterraformコマンドと通信できるので、任意のtfファイルで実際の挙動を調査できます。

というわけでやってみた。

例としてAWSプロバイダで試してますが、AWS以外でやりたい人は、この機能には最低限プロバイダ側の依存に、hashicorp/terraform-plugin-sdk v2以上が必要です。

手元の環境は以下のとおりです。

  • macOS: 10.15(Catalina)
  • terraform: v0.14.2
  • terraform-provider-aws: v3.20.0
  • delve: v1.5.1
  • Go: v1.14.5

ちなみに terraform-provider-aws v3.20.0が依存しているhashicorp/terraform-plugin-sdkはv2.3.0です。

delveとはGoのデバッガです。TerraformプロバイダはGoで書かれているので、デバッグするにはGoのデバッガを使います。delve自体の簡単な紹介はちょっと古いですが以前書いたんで、こちらをどうぞ。

Golangのデバッガdelveの使い方

上の記事では、delveをHomebrewでインストールしてますが、古いdelveだと最近のGoが動かないので、最新のdelveをgo getで入れます。

$ go get github.com/go-delve/delve/cmd/dlv

他の環境の場合は、公式ドキュメントを参照して下さい。
https://github.com/go-delve/delve/tree/master/Documentation/installation

次に、プロバイダのソースコードを手元に持ってきます。

$ git clone https://github.com/hashicorp/terraform-provider-aws.git
$ cd terraform-provider-aws
$ git checkout v3.20.0

プロバイダ側はデバッグモードで起動する必要があるのですが、この記事を書いてるナイスタイミングで、terraform-provider-aws v3.20.0からデバッグモードで起動する -debug フラグが生えてました。

以前はなかったので、main関数を一時的に書き換えて起動していました。もし他のプロバイダで、デバッグモードで起動するエントリポイントがない場合は、上記のPullRequestを参考にして、main関数を一時的に書き換えてみて下さい。

デバッガ経由でプロバイダのプロセスを -debug フラグ付きで起動します。

$ dlv debug -- -debug
Type 'help' for list of commands.
(dlv)

起動したら、適当なところにブレークポイントを設定します。break 適当な名前 ファイル名:行数 で設定できます。
例えば、clientの認証周りの処理化処理をしている ./aws/config.go:386 あたりにブレークポイントを貼ってみましょう。

(dlv) break client ./aws/config.go:386
Breakpoint client set at 0x4dfc20e for github.com/terraform-providers/terraform-provider-aws/aws.(*Config).Client() ./aws/config.go:386

ブレークポイントを設定したら、 continue でブレークポイントまで実行します。

(dlv) continue
{"@level":"debug","@message":"plugin address","@timestamp":"2020-12-10T22:46:20.743952+09:00","address":"/var/folders/zh/37j0y_pn2pzc295c19rpw2tw0000gp/T/plugin826549644","network":"unix"}
Provider started, to attach Terraform set the TF_REATTACH_PROVIDERS env var:

        TF_REATTACH_PROVIDERS='{"registry.terraform.io/hashicorp/aws":{"Protocol":"grpc","Pid":70249,"Test":true,"Addr":{"Network":"unix","String":"/var/folders/zh/37j0y_pn2pzc295c19rpw2tw0000gp/T/plugin826549644"}}}'

TF_REATTACH_PROVIDERS が表示されれば正常に起動しています。これはプロバイダが待ち受けている接続情報で、あとで使います。

次にterraformコマンド側を準備しましょう。
もう1枚ターミナルを開いて、テスト用に適当なtfファイルを作成します。

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

resource "aws_security_group" "foo" {}

先ほど表示された TF_REATTACH_PROVIDERS の値を環境変数にexportします。

$ export TF_REATTACH_PROVIDERS='{"registry.terraform.io/hashicorp/aws":{"Protocol":"grpc","Pid":70249,"Test":true,"Addr":{"Network":"unix","String":"/var/folders/zh/37j0y_pn2pzc295c19rpw2tw0000gp/T/plugin826549644"}}}'

terraform initします。

$ terraform init

Initializing the backend...

Initializing provider plugins...

Terraform has been successfully initialized!

You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.

If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

terraform planするとプロバイダの初期化処理が行われ、先ほど指定したエンドポイントに初期化のリクエストが飛びます。

$ terraform plan

先ほどのプロバイダのデバッガ側のターミナルが、ブレークポイントで止まれば成功です。

> [client] github.com/terraform-providers/terraform-provider-aws/aws.(*Config).Client() ./aws/config.go:386 (hits goroutine(86):1 total:1) (PC: 0x4dfc20e)
   381:
   382: // Client configures and returns a fully initialized AWSClient
   383: func (c *Config) Client() (interface{}, error) {
   384:         // Get the auth and region. This can fail if keys/regions were not
   385:         // specified and we're attempting to use the environment.
=> 386:         if !c.SkipRegionValidation {
   387:                 if err := awsbase.ValidateRegion(c.Region); err != nil {
   388:                         return nil, err
   389:                 }
   390:         }
   391:
(dlv)

あとは変数を表示したりステップ実行したり、ご自由にどうぞ。

(dlv) print c.SkipRegionValidation
false

今後の展望

この記事を書いてる2020/12/11現在、v0.13が出たのが4ヶ月前(2020/08/10)で、v0.14が出たのが先週(2020/12/02)なので、知識がv0.12ぐらいで止まってた人も多かったかもしれません。v0.12が出たのが1年半前(2019/5/22)で、v0.12時代がまぁまぁ長かったこともあり、まだv0.12だという人も仕方ないでしょう。

しかしここに来てじゃんじゃんバージョンが上がっているのは偶然ではなく、v1.0に向けて破壊的変更を入れてきている意志を感じます。v1.0のリリースを切ってしまうと、互換性維持のためにユーザインターフェース(=CLIや文法、ファイルフォーマットなど)を気軽に変更できなくなってしまうので、まぁ当然といえば当然ですね。

Terraformコアチームはv1.0に向けて、どの機能を入れるべきか、あるいは入れるの無理そうを絶賛検討中のようです。Issueをウォッチしていると、ごめんこの機能は1.0には入れるの無理そう、という期待値調整のコメントをいくつか観測してます。

次のv0.15ではまだなんの機能が入るのか、まだ特にロードマップは示されていませんが、既にGo packageのinternal化が予告されています。

これはCLIとしてのterraformコマンドには直接影響はありませんが、 github.com/hashicorp/terraform の実装を直接依存ライブラリとしてimportして流用している3rd-partyのツールがいっぱい死にそうです。

Go分からん人のために補足しておくと、Goではinternalディレクトリ配下のコードは、同一モジュール内の別packageからimportはできますが、外部モジュールからのimportは禁止されます。ただの慣習ではなくGo1.5以降コンパイラレベルで禁止されているので、github.com/hashicorp/terraform の実装を依存としてimportしているツールは、なんらかの方法でこれらを再実装する必要に迫られています。OSSなのでしばらくforkして凌ぐことも理論上は可能ですが、この依存を切り捨てられないと新しいTerraformのバージョンをサポートすることが困難になり、そのツールは緩やかに死んでいく運命になるでしょう。

github.com/hashicorp/terraform はCLIツールであり、Goライブラリではないので、Goモジュールとしての互換性をなんら保証するものではないというのは、3rd-partyツールを作ってる人はみんな分かってimportしている気はしつつ、とはいえエコシステムに少なくない影響を与えそうです。短期的にはネガティブですが、長期的には安定した実装を強制されるので、v1.0の前にやるしかなかったのでしょう。他人事のように書いてますが、私のメンテしてる tfschema も治さないといけないのであった。。。つらい。

で、v1.0がいつ出るのかって?

まだマイルストーンは何も示されていませんが、そのヒントとして、今年2020年6月のHashiConfで、中の人が1.0の条件についてしゃべってる動画があります。
The Path to Terraform 1.0

HashiCorpの考える1.0の条件は、いわゆる世間一般の1.0 (=Production Readiness)だけではないようです。

  1. The project is deployed broadly and has years of production hardening.
  2. Major use cases are understood and well-supported.
  3. User experience of the project is well-defined.
  4. Technical architecture is stable and mature.

そのためには単に機能だけではなくて、内部アーキテクチャのリファクタリング、開発プロセスの改善、ユーザとのコミュニケーションなどなど、まだまだやらないといけないことがいっぱいあるようです。

明確に1.0を意識した変更がじゃんじゃん入ってますが、まぁまだしばらく時間がかかるんじゃないでしょうか。しらんけど。(個人の見解です)

なので、これ欲しいっていうFeature Requestには積極的に +1 するとよいんじゃないかな。あと言えることは、バージョンが離れすぎるとつらくなるので、置いていかれないように、半年に1回ぐらいはバージョンを上げておいた方がよいんじゃなかろうか。

おわりに

前回の記事から差分のあった箇所を中心に、学んだことなどをつらつら思いつくままに書いてみました。
3年でこんなに変わっちゃうんですね。諸行無常です(  ̄- ̄)トオイメ

552
417
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
552
417