Skip to main content

13 posts tagged with "terraform"

View All Tags

· 5 min read
Shunsuke Suzuki

Terraform で空の AWS Lambda Function を作ろうとした際にちょっとハマったのでやり方を書いておきます。

「空の Lambda Function」という表現は適切ではないかもしれませんが、 Lambda で実行するコードのデプロイは Terraform 以外のツールでやるけど、 Lambda Function の作成は Terraform で行うので、 dummy のコードを指定して Terraform で Lambda を作るという話です。

自分は今は lambroll というツールで Lambda をデプロイしています。 lambroll は Lambda Function も作ってくれるので Terraform で作る必要は必ずしもありません。

しかし Lambda Function に関連するリソースを Terraform で管理する場合、 Lambda Function も Terraform で作ると Lambda Function の ARN や Invoke ARN を参照できます。

また lambroll でデプロイする場合も先に Terraform で IAM Role を作成する必要がありますが、 Terraform で aws_lambda_permission のようなリソースを作成するには Lambda Function が先に作られている必要があるので、 互いに依存関係が発生し、面倒なことになります。

また Lambda Function の削除も Terraform でできるようになります。

なので、 Terraform で Lambda Function を作っておいたほうが色々都合が良いです。

Terraform で作成と削除は行うものの、更新をしたいわけではないので、 ignore_changes = all を指定します。

https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes

Lambda Function を Web UI などから作る場合 Function code はなくても大丈夫ですが、 Terraform で Lambda Function を作る場合、 filenameimage_uri, s3_bucket のいずれかが必須になります。 これは issue もありますが、仕様のようにみえます。

https://github.com/hashicorp/terraform-provider-aws/issues/5945

ECR や S3 に dummy のコードを用意するというのも一つの手ですが、環境に依存するのがあまり良い気がしないので、 archive_file data source を使って dummy の zip ファイルを生成するという方法を取ることにしました。

https://registry.terraform.io/providers/hashicorp/archive/latest/docs/data-sources/archive_file

次のようなコードで CI で terraform apply を実行しましたが、 zip file がないと言われて失敗しました。

resource "aws_lambda_function" "main" {
# https://www.terraform.io/docs/language/meta-arguments/lifecycle.html#ignore_changes
# Terraform can create and destroy the remote object but will never propose updates to it.
lifecycle {
ignore_changes = all
}

function_name = "foo"
role = aws_iam_role.main.arn

handler = "bootstrap"
runtime = "provided.al2"
filename = data.archive_file.dummy.output_path
}

data "archive_file" "dummy" {
type = "zip"
output_path = "${path.module}/dummy.zip"

source {
content = "dummy"
filename = "bootstrap"
}
}
Error: unable to load "lambda-base/dummy.zip": open lambda-base/dummy.zip: no such file or directory

しかしローカルで terraform plan, apply を実行してみても再現しませんでした。

CI では Pull Request で plan file を生成して S3 に plan file を upload し、 PR をマージした default branch では terraform plan を実行せずに S3 から plan file をダウンロードして terraform apply を実行しています。

Pull Request の terraform plan の実行結果を S3 に保存して安全に apply | Quipper Product Team Blog

plan file を指定して terraform apply を実行した際には zip file が作成されず、上記のエラーが発生することがわかりました。

関連する issue もありました。 https://github.com/hashicorp/terraform-provider-archive/issues/39

この issue では幾つかの解決方法が紹介されています。ちなみに 2021-06-24 現在 Hashicorp 側からは特に反応がないように見えます。 random_uuid や random_string を使った方法もありますが、 Lambda を作成するだけなら null_resource に依存させるだけで十分のように思えました。

data "archive_file" "dummy" {
type = "zip"
output_path = "${path.module}/dummy.zip"
source {
content = "dummy"
filename = "bootstrap"
}
depends_on = [
null_resource.main
]
}

resource "null_resource" "main" {}

このように null_resource に依存させると terraform plan では zip file が作られず、 terraform apply ではじめて zip file が作られるため、 terraform apply が失敗することはなくなりました。

· 4 min read
Shunsuke Suzuki

Terraform v0.14 で local で terraform init すると lock ファイルが更新されてしまう問題に対応しました。

結論を最初に言うと、 100 以上の Terraform 環境をいい感じに v0.14 に upgrade した方法で紹介している方法で Renovate で Terraform Provider を update する際に terraform init -upgrade を実行して lock ファイルを更新してコミット・プッシュしているのですが、 その際に terraform providers lock -platform=darwin_amd64 を実行するようにしました。

Terraform v0.14 で lock ファイル .terraform.lock.hcl が導入されました。 Renovate で Terraform Provider を update する際にも lock ファイルを更新する必要があるので、 terraform init -upgrade を実行して lock ファイルを更新してコミット・プッシュしています。 なのですが、ローカルで terraform init を実行するとなんか lock ファイルが更新されることが良くありました。しばらく放置していたのですが、 developer から「なんかファイル更新されたんだけど、これコミットしていいの?」と聞かれ、このまま放っておいて困惑させたりもやっとさせたりするのは良くないなと思い、調べてみました。

lock ファイルについて .terraform.lock.hcl 完全に理解したで詳しく解説されていたので大変助かりました。

  • lock ファイルには provider の hash 値が記録されている
  • lock ファイルは terraform init で自動的に更新される
  • hash 値は platform (Mac, Linux, etc) によって違う
  • terraform init 実行時に、その platform の hash 値が lock ファイルになければ追加される
    • デフォルトでは実行環境以外の Platform の hash 値は追加されない
  • CI は Linux 上で実行しているので、 Linux の hash 値だけが記録される
  • ローカルで Mac 上で terraform init すると Mac の hash 値が追加され、 lock ファイルに差分が生じる

なので差分が出てしまった場合はコミットするで良いとは思いますが、そもそも CI で lock ファイルを更新する際に Mac の hash 値も追加してしまえばローカルで Mac 上で terraform init しても差分が出なくなります。ちなみに Windows 上で terraform init する人は自分の周りにはいなさそうなので、 Windows は対応しないことにしました。

100 以上の Terraform 環境をいい感じに v0.14 に upgrade した方法で紹介しているようにすでに lock ファイルを更新してコミット・プッシュする仕組みはあるので、変更としては 1 (正確にはコードコメント入れて4)行追加するだけでした。

github-comment exec -- terraform providers lock -platform=darwin_amd64

· 6 min read
Shunsuke Suzuki

Terraform Module の使い方として Terraform Module のテンプレートをコピペして使うというアプローチを紹介します。

Terraform の設定ファイル(以下 tfファイル) を書く際、毎回一から書くのは大変です。 多くの場合、既存のコードを再利用したほうが楽でしょう。

Terraform のコードの再利用の仕組みとして、 Module があります。 Module は勿論便利なのですが、使い方には注意が必要で、「安易に Module 化するな。使うな」というふうな考え方もあるでしょう。 自分も基本的に同意見で、 Module を共用するようになると Module への変更がしづらくなったり、パラメータがどんどん増えて複雑になったりします。

例えば次のように共用の local Module を作成するアプローチがあります。

modules/
lambda-base/
README.md
main.tf
variables.tf
outputs.tf
services/
foo/
staging/
main.tf # リポジトリ直下の modules/lambda-base を参照
production/
main.tf # リポジトリ直下の modules/lambda-base を参照

こうすると modules 配下の Module を変更した際にその Module を使っているすべてのサービスに影響が出てしまい、 サービスのオーナーが様々だったり、曖昧だったり不在だったりすると変更が難しいですし、どんどん Module が複雑になったりします。

Module を別のリポジトリでバージョニングして管理し、バージョンを指定するようにするというやり方もありますが、 結構複雑というか考えることが多いアプローチだとは思います。

Terraform にそこまで詳しくない developer にも書いてもらうとなると、シンプルなアプローチにするのが望ましいでしょう(当然これは組織によりますが)。

そこで Module のテンプレートを用意し、 Module を使いたくなったらそれをコピペして使うというアプローチがあります。 例えば lambda-base という Module の Template を foo というサービスの staging 環境と production 環境で使う場合、次のような感じになります。

services/
foo/
staging/
modules/
lambda-base/ # templates からコピー
main.tf
production/
modules/
lambda-base/ # templates からコピー
main.tf
templates/ # Module のテンプレートを置いておく
lambda-base/ # 中身は普通の Module
README.md
main.tf
variables.tf
outputs.tf

こうすると 2 つの Module はそれぞれ独立しているため、変更がしやすくなりますし、シンプルに保つことが出来ます。

テンプレートエンジンとかは使わない

Module の Template をコピペする際に、コードに変数を埋め込んでコピペする際に置換したりとか、高度なテンプレートエンジンを使って動的に内容を変えたりといったことも考えられますが、 個人的には複雑度が上がるのでやらないほうが良いかなと思っています。 変数であれば Module の input variable として渡せばよいし、テンプレートエンジン使いたいのであれば、テンプレートを分けたり、 HCL で表現したりすればよいのではという気がします。

考えられるデメリット

通常の Module と比べて、デメリットとしては以下のようなことが考えられます。

  • コード量が増える(DRY じゃない)
  • Module を一箇所で管理できない
    • 一括して変更を加えることが出来ない
    • 設定を強制することができない

しかし、個人的にはこれは大した問題じゃないと思っています。

コードが増えることに関しては、それはそうとしか言いようがありません。 一括して変更を加えることが出来ないのは、トレードオフだと思っていて、むしろ対処のサービスを限定しながら Module に変更を加えられるというメリットのほうが大きいと思っています。 設定を強制することができないのは、たしかにそれはあると思っていて、コピペした Module に一括して変更を加えたり Conftest などでテストする仕組みが必要かなと思います。

· 13 min read
Shunsuke Suzuki

terraformer で雑に生成した Terraform の設定ファイル (以下 tf ファイル) と state を分割したくてツールを書きました。

tfmigrator

経緯

miam から Terraform へ移行したい

miam というツールで管理されている大量のリソースを Terraform で管理したくなりました。 多くの AWS Resource は Terraform で管理されていますが、 IAM に関しては miam で管理されています。 なぜ Terraform ではなく miam で管理されているかというと、当時のことは自分には分かりませんが、歴史的な経緯もあると思います。 昔は今よりも Terraform の表現力が豊かではなく、 Ruby で自由にかける miam のほうが扱いやすかったとか、 miam だと miam でリソースを管理することを強制できるため、権限管理を厳格にやるという観点では都合が良いという点もあるかと思います。

ではなぜ Terraform で管理したくなったかというと、 一番大きな理由は miam で頻繁に rate limit に引っかかるようになったからです。 Terraform にしろ miam にしろ CI/CD で test, apply が実行されるようになっています。 miam では毎回全部のリソースを対象に処理が実行されるため、リソースの数が増えるにつれて rate limit に引っかかりやすくなります。 CI を rerun すれば成功するのですが、悪いときは 3 回連続で rate limit に引っかかり、 4 回目でようやく成功するということもありました。

Terraform ではサービスや環境単位で State が分割されており、 CI も PR でファイルが変更された state に対してのみ実行されるため、 rate limit に引っかかることは基本ないようになっています。

他にも色々理由はあるのですが、本題からそれるのでやめておきます。 rate limit だけなら miam でも exclude する機能があるので頑張ればなんとかなる気はします。

やりたかったこと

  • Terraform で既存のリソースを管理したい
    • tf ファイル と state を生成したい
  • Terraform の設定ファイル と state はサービス・環境ごとに分割したい
  • Terraform のリソースパスはヒューマンリーダブルにしたい
    • これは難しければ諦めるのもありだが、できればやりたい

terraformer で自動生成するも色々問題があった

まずは terraformer で雑に tf ファイル と state を生成しました。 今回 terraformer を使うのは初めてで、 terraformer で万事解決なら話は簡単だったのですが、話はそんな簡単ではありませんでした。 これに関しては、自分の問題(使い方を間違っている、ドキュメントをちゃんと読んでいない)なのか、 あるいは terraformer 側の問題なのかよく分かってない部分もあります。

まずドキュメントを読んでもいまいちリソースのフィルタリングの仕方が分かりませんでした。 試しに IAM role の名前を指定してそれだけ import しようとしましたが、なぜか全 IAM リソースが import されてしまいました。

良くわからないので、これは全 IAM リソースを雑に import してからサービス・環境ごとに分割するしか無いかなぁと思いました。

加えて、リソースパスが全然ヒューマンリーダブルではありませんでした。 terraformer としてはこれは仕方ないと思いますが、なんとかリネームしたいと思いました。

最初手作業で始めるも、自動化が必要と悟る

最初特定のサービスに関して手作業で tf ファイル, state を移行する作業を行ってみました。 簡単なシェルスクリプトを書いて半自動化してみました。 tf ファイルの操作には hcledit が便利です。

以下のようなコマンドを使いました。

  • terraform state mv
  • hcledit block get
  • hcledit block mv
  • hcledit block list

で、やってみたものの、なにせ対象が多いので、これを一つ一つ手作業でやるのは大変だし、ヒューマンエラーは避けられないと感じました。 そこでちょっとしたツールを作ることにしました。 手作業で一回やった分手順はイメージできているので、割と簡単にできるだろうと思いました。

tfmigrator

そこで作ったのが tfmigrator です。 今回は AWS の IAM リソースを扱いますが、 tfmigrator は特定の provider などには依存しないツールです。 Terraform CLI と hcledit が必要です。

まず terraformer で IAM リソースを全部 import してきます。

$ terraformer import aws -r iam --compact --path-pattern .

こうすると resources.tf と terraform.tfstate が作られます。

tfmigrator の設定ファイル tfmigrator.yaml を書きます。

---
items:
# 既に Terraform で管理されているものは無視
- rule: '"tags" in Values && Values.tags.ManagedBy == "Terraform"'
exclude: true
- rule: '"name" not in Values'
exclude: true
# `name` に "foo" が含まれているものはサービス foo のリソースとみなして分割
- rule: 'Values.name contains "foo"'
state_out: foo/terraform.tfstate
resource_name: "{{.Values.name}}"
tf_path: foo/resource.tf
# 以下略

tfmigrator の処理の流れをなんとなくそれっぽい擬似言語で表現します。 実際の処理の流れとは若干異なりますが、雰囲気が伝わればと思います。

for resource in state
for item config.items
if item.rule.match(resource)
if item.exclude
# このリソースは処理せず次のリソースの処理に移る
break
# tf の migration (note: 元の tf はそのまま)
hcledit block get resource.path < tf | hcledit block mv resource.path "${resource.type}.${item.resource_name(resource)}" >> item.tf_path
terraform state mv
# 次のリソースの処理に移る
break

各 item の設定の意味はこんな感じです。

  • rule: expr の expression 。この条件にマッチしたリソースをこの item で処理する
  • exclude: true の場合、この item にマッチしたリソースは無視する
  • state_out: terraform state mv-state-out
  • resource_name: 新しいリソース名。デフォルトでは名前はそのまま。 Go の text/template で処理されます
  • tf_path: Terraform の設定ファイルの出力先

設定ファイルを書いたら tfmigrator を実行します。 いきなりマイグレーションをするというよりは、まずは dry run てきなことをして動作を確認したいですね。 -skip-state をつけると terraform state mv を skip し、分割される tf ファイル を新しいファイルに出力だけします。

$ cat *.tf | tfmigrator run -skip-state

生成された tf ファイル を眺めて、良さそうなら -skip-state をとって実行します。

tf ファイル生成時の注意点

tf ファイルの生成は現状追記モードで実行されます。なので -skip-state をつけて複数回実行すると同じ設定が重複して書き込まれることになります。 それが困る場合は実行前に対象ファイルを消してから実行してください。

また、tf ファイルの移行は既存の tf ファイルに対して hcledit block get, hcledit block mv を実行して行われるため、 元の tf ファイルはそのまま残ること、また expression は評価されないことに注意が必要です。 例えば name = var.name のように変数を参照している場合、それもそのまま評価されずに残ります。 とりあえず自分がやりたかったのは terraformer で生成した tf ファイルの移行だったので、そんなに問題にはならないだろうと思っています。

エラーハンドリング

あるリソースの処理でエラーが起こったら即座に異常終了するようにしています。 (当然ですが)ロールバックとかはしません(し、できません)。 エラー出力しつつ次のリソースの処理に移る、というのも考えられますが、間違って terraform state mv されると面倒なので、現状即座に終了するようにしています。 問題のあるリソースを無視したい場合は、 tfmigrator の設定でそのリソースにマッチする item を追加し exclude: true とすればよいでしょう。

このツールの便利なところ

  • expr を用いてリソースを分類できる
  • 設定ファイルに記述し、ワン・コマンドで実行できる
    • レビューできる
    • 後で見返せる

ローカルで試行錯誤しながら複数のコマンドを実行していると、後でなにやったかわからなくなりがちですし、途中で作業を中断したりすると、あとで今どういう状態なのか分からなくなったりします。 ワン・コマンドで実行できるとそういう問題がなくて便利ですね。

肝心の移行はできたのか

まだできていません。移行するために tfmigrator を作ったので、これから移行していこうという段階です。 なので tfmigrator はまだ全然使い込んでないですし、使ってく中で機能修正したりすることもあると思います。

最後に

terraformer で雑に import してきた tf ファイルと state をいい感じに分割するために tfmigrator というツールを作りました。 tfmigrator が役に立つケースは割と限られているというか、日常的に使うようなツールでもないですが、 terraformer で雑に import してきたのは良いが、扱いに困っているなんて人には役に立つかもしれません。

· 2 min read
Shunsuke Suzuki

mercari/tfnotify を Fork して 2 つほど OSS を作りました。

開発の経緯

これまで tfnotify を便利に使わせてもらってたのですが、幾つか改善したいと思うところがあり、本家に PR を投げました。 しかし残念ながらこれまでのところ反応がなく、そこまで本家が活発ではないこと、また他にも色々改修したいところがあったことから、自分でフォークしてメンテすることにしました。 最初は互換性を維持しながら suzuki-shunsuke/tfnotify を開発していました(今もしています)。 しかし、開発を進めるに連れ、自分にとって必要のないプラットフォームなどに関するコードが邪魔であると感じ、それらを消したバージョンを別に開発することにしました。 互換性がなくなることから、名前も変えて tfcmt としました。

https://github.com/suzuki-shunsuke/tfcmt

こういった経緯から、 tfcmt のほうを優先的に開発していますが、 tfcmt で実装した機能を後から suzuki-shunsuke/tfnotify にも実装してたりもします。

Fork 元のバージョン

suzuki-shunsuke/tfnotifymercari/tfnotify v0.7.0 fb178d8 をフォークしました。 一方 tfcmt は suzuki-shunsuke/tfnotify v1.3.3 をフォークしました。

mercari/tfnotify との違い

本家との違いは Release Note とドキュメントを参照してください。

· 2 min read
Shunsuke Suzuki

先日 kreuzwerker/terraform-provider-docker の Collaborator になりました。 kreuzwerker/terraform-provider-docker は Terraform の Docker Provider であり、 Docker コンテナや image, network などを管理できます。 元々は Hashicorp の Official Provider であった terraform-providers/terraform-provider-dockerkreuzwerker/terraform-provider-docker に移管され、 Community Provider になりました。 元のリポジトリは hashicorp org に移され archive されています。

Collaborator になった経緯

リポジトリが移管される際に、メンテナを募集していて過去に contribution していた自分にも声をかけていただきました。

https://github.com/hashicorp/terraform-provider-docker/issues/306

Contributor になった経緯

自分がこの provider に contribution した経緯は、 Terraform の Hands on を書くのに丁度よい provider を探していたことでした。

Hands on の題材として Docker コンテナを作ったりできたらいいんじゃないかなと思って Docker provider を試してみました。 しかし当時の docker_container リソースは read をちゃんとサポートしていませんでした。 なので import や update がまともに動きませんでした。 それを見かねて修正して PR を投げたのが最初です。

その後も幾つか contribution をしました。

なお、 PR を投げたものの、 Hands on は MySQL Provider を使って書きました。

https://techblog.szksh.cloud/terraform-hands-on-with-mysql-provider/

· 2 min read
Shunsuke Suzuki

tfnotify が terraform の標準出力のパースに失敗してコメントを投稿できないことがあります。

コメントを投稿できなくてもビルドのログには残るのですが、やはりコメントを投稿できると便利なので、tfnotify がパースエラーでコメントの投稿に失敗したら、 github-comment でコメントを投稿するようにしました。

なお、この記事を書いている時点のバージョンは tfnotify v0.7.0, github-comment v1.9.0 です。

例えば tfnotify plan がパースエラーになった場合、 cannot parse plan result というメッセージが標準エラー出力されます。 そこで標準エラー出力に cannot parse plan result が含まれていたら github-comment でコメントするようにします。

terraform plan | github-comment exec -k plan -- tfnotify plan

.github-comment.yml

# 細かく template を分けているが、別に分けなくてもよい
templates:
# header は CodeBuild の場合
header: '{{Env "TARGET"}} [Build link]({{Env "CODEBUILD_BUILD_URL"}})'
exit_code: ':{{if eq .ExitCode 0}}white_check_mark{{else}}x{{end}}: Exit Code {{.ExitCode}}'
join_command: |
```
$ {{.JoinCommand}}
```
hidden_combined_output: |
<details>

<pre><code>{{.CombinedOutput}}</code></pre>

</details>
exec_default: |
{{template "header" .}}

{{template "exit_code" .}}

{{template "join_command" .}}

{{template "hidden_combined_output" .}}
exec:
plan:
- when: |
Stderr contains "cannot parse plan result"
template: |
{{template "exec_default" .}}

tfnotify apply の場合は cannot parse apply result というメッセージを出力するので 次のようになります。

terraform apply -auto-approve | github-comment exec -k apply -- tfnotify apply
exec:
apply:
- when: |
Stderr contains "cannot parse apply result"
template: |
{{template "exec_default" .}}

tfnotify のコードを確認

· 40 min read
Shunsuke Suzuki

Terraform を勉強するには実際に使ってみるのが一番手っ取り早いですが、 では手頃な題材はあるかと言われると少し難しいです。

公式の Getting Started では AWS が使われていますが、 AWS のアカウントやクレデンシャルが必要ですしお金もかかってしまいます(無料枠はありますが)。 もう少し手軽なものが欲しいところです。

そこで公式の Provider で丁度いいものはないか探したところ、 MySQL Provider が良さそうでした。 MySQL のユーザーや Database を Terraform で管理したいとは自分は思いませんが、 Terraform の入門で遊ぶにはちょうどよいでしょう。

ちなみに公式の Provider のリストはこちらです。

また、 Terraform に関しては Terraform 入門 も参照してください。

今回の作業用に適当にディレクトリを作成し、そこで作業しましょう。

以降、コマンドの実行結果は一部省略することがあります。

$ mkdir workspace
$ cd workspace

Terraform のバージョンと tfenv

Terraform を複数人で使う場合、 Terraform のバージョンを揃えるのが重要です。 理由の一つとして、 Terraform の State は State を作成した Terraform のバージョンを記録しており、それより古いバージョンの Terraform で terraform plan などを実行すると失敗するようになっていることが挙げられます(この点については後でも触れます)。 そういう意味では、 tfenv によってバージョン管理するのが良いです。

.terraform-version を作成します。

$ echo 0.12.19 > .terraform-version
$ tfenv install
$ terraform version
Terraform v0.12.19

MySQL を Docker で動かす

では MySQL を Docker で動かします。

https://hub.docker.com/_/mysql?tab=description

Docker Compose 使う場合

docker-compose.yml

---
version: "3"
services:
mysql:
image: mysql:5.7.28
ports:
- "23306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
$ docker-compose up -d
$ docker-compose ps # コンテナが起動しているか確認

不要になったら削除しましょう。

$ docker-compose rm -sf mysql

これから Terraform で MySQL の Database を作成します。 作成されているか確認するために MySQL に接続しておきます。

$ docker-compose exec mysql mysql -u root -p -P 23306
mysql> show databases; # database の一覧を確認
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
+--------------------+
4 rows in set (0.00 sec)

Docker Compose を使わない場合

基本的に Docker Compose 使う場合と変わりません。

$ docker run --name terraform-mysql -p "23306:3306" -e MYSQL_ROOT_PASSWORD=password -d mysql:5.7.28
$ docker exec -ti terraform-mysql mysql -u root -p -P 23306
$ docker rm -vf terraform-mysql

リソースの作成

次のような設定ファイルを用意します。

main.tf

provider "mysql" {
endpoint = "localhost:23306"
username = "root"
password = "password"
}

resource "mysql_database" "foo" {
name = "foo"
}

設定できる属性やその意味などはドキュメントを確認してください。

https://www.terraform.io/docs/providers/mysql/r/database.html

MySQL Provider の設定として MySQL に接続するための情報と、Terraform によって作成するデータベース foo の設定が定義されています。 password が平文で書かれているのが気になるかもしれませんが、一旦スルーしてください。

この状態で terraform plan を実行してみます。 terraform planterraform apply によるリソースの作成を DRY RUN するコマンドです。

$ terraform plan

Error: Could not satisfy plugin requirements


Plugin reinitialization required. Please run "terraform init".

Plugins are external binaries that Terraform uses to access and manipulate
resources. The configuration provided requires plugins which can't be located,
don't satisfy the version constraints, or are otherwise incompatible.

Terraform automatically discovers provider requirements from your
configuration, including providers used in child modules. To see the
requirements and constraints from each module, run "terraform providers".



Error: provider.mysql: no suitable version installed
version requirements: "(any version)"
versions installed: none

失敗しました。

Plugin reinitialization required. Please run "terraform init".

とある通り、 terraform planapply などのコマンドを実行する前に terraform init を実行する必要があります。

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Checking for available provider plugins...
- Downloading plugin for provider "mysql" (terraform-providers/mysql) 1.9.0...
^C
The following providers do not have any version constraints in configuration,
so the latest version was installed.

To prevent automatic upgrades to new major versions that may contain breaking
changes, it is recommended to add version = "..." constraints to the
corresponding provider blocks in configuration, with the constraint strings
suggested below.

* provider.mysql: version = "~> 1.9"

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.

Provider のダウンロードが行われています。 terraform init を実行すると .terraform というディレクトリが作成されます。

$ ls -A
.terraform .terraform-version main.tf

terraform init は何度でも安全に実行できます。 .terraform を削除した場合でももう一度 terraform init を実行すれば問題ありません。

次に terraform 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.


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

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

Terraform will perform the following actions:

# mysql_database.foo will be created
+ resource "mysql_database" "foo" {
+ default_character_set = "utf8"
+ default_collation = "utf8_general_ci"
+ id = (known after apply)
+ name = "foo"
}

Plan: 1 to add, 0 to change, 0 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.

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

とある通り、リソースが 1 つ作成されるようです。 DRY RUN なのでまだ作成されてません。

では実際に作成してみましょう。

$ terraform apply
An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
+ create

Terraform will perform the following actions:

# mysql_database.foo will be created
+ resource "mysql_database" "foo" {
+ default_character_set = "utf8"
+ default_collation = "utf8_general_ci"
+ id = (known after apply)
+ name = "foo"
}

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

Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.

Enter a value: yes

mysql_database.foo: Creating...
mysql_database.foo: Creation complete after 0s [id=foo]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

途中確認があるので yes と入力すると実際に変更が適用されます。

本当に Database が作られているか確認します。 MySQL クエリを叩きます。

mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| foo |
| mysql |
| performance_schema |
| sys |
+--------------------+
5 rows in set (0.01 sec)

mysql> select * from INFORMATION_SCHEMA.SCHEMATA where SCHEMA_NAME='foo';
+--------------+-------------+----------------------------+------------------------+----------+
| CATALOG_NAME | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | SQL_PATH |
+--------------+-------------+----------------------------+------------------------+----------+
| def | foo | utf8 | utf8_general_ci | NULL |
+--------------+-------------+----------------------------+------------------------+----------+
1 row in set (0.00 sec)

ありました。

すると terraform.tfstate というファイルが作られているはずです。

$ ls
docker-compose.yml main.tf terraform.tfstate

こんな JSON ファイルになります。

{
"version": 4,
"terraform_version": "0.12.19",
"serial": 1,
"lineage": "7011a551-bfa1-96a5-4153-2c9d6f32251c",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "mysql_database",
"name": "foo",
"provider": "provider.mysql",
"instances": [
{
"schema_version": 0,
"attributes": {
"default_character_set": "utf8",
"default_collation": "utf8_general_ci",
"id": "foo",
"name": "foo"
},
"private": "bnVsbA=="
}
]
}
]
}

管理されているリソースの情報と、 Terraform のバージョンなどのメタ情報が保存されています。 このファイルは基本的に terraform によって更新されるものであり、人間が手で修正するものではありません。

古い Terraform を使ってみる

ここであえて古いバージョンの Terraform を使って terraform plan を実行してみます。

$ echo 0.12.12 > .terraform-version
$ tfenv install
$ terraform version
$ terraform plan
Error: Error locking state: Error acquiring the state lock: state snapshot was created by Terraform v0.12.19, which is newer than current v0.12.12; upgrade to Terraform v0.12.19 or greater to work with this state

Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.

失敗しました。 State の lock に失敗したようです。 State の lock については https://techblog.szksh.cloud/terraform-state-locking/ も参照してください。

このように古いバージョンの terraform は使えません。 一方新しいバージョンを使うには問題なく使えますが、 State の terraform_version が更新され、それまでのバージョンを使ってた人が突然 terraform plan などができなくなりますので、注意が必要です。

これが前述した意味になります。

Terraform を複数人で使う場合、 Terraform のバージョンを揃えるのが重要です。 理由の一つとして、 Terraform の State は State を作成した Terraform のバージョンを記録しており、それより古いバージョンの Terraform で terraform plan などを実行すると失敗するようになっていることが挙げられます(この点については後でも触れます)。

では元のバージョンに戻しましょう。

$ echo 0.12.19 > .terraform-version
$ terraform version
$ terraform plan
No changes. Infrastructure is up-to-date.

リソースの更新

同じ調子でもう一つ Database を作ってみましょう。 Database foo の設定をコピーして terraform plan を実行します。

resource "mysql_database" "foo" {
name = "foo"
}

resource "mysql_database" "foo" {
name = "foo"
}
$ terraform plan

Error: Duplicate resource "mysql_database" configuration

on main.tf line 11:
11: resource "mysql_database" "foo" {

A mysql_database resource named "foo" was already declared at main.tf:7,1-32.
Resource names must be unique per type in each module.

失敗しました。エラーメッセージの通り、リソースパスはユニークでないといけません。 修正しましょう。

resource "mysql_database" "foo" {
name = "foo"
}

resource "mysql_database" "bar" { # "foo" を "bar" に変更
name = "foo"
}
$ terraform plan
Terraform will perform the following actions:

# mysql_database.bar will be created
+ resource "mysql_database" "bar" {
+ default_character_set = "utf8"
+ default_collation = "utf8_general_ci"
+ id = (known after apply)
+ name = "foo"
}

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

作成されるようです。 apply してみましょう。

$ terraform apply
mysql_database.bar: Creating...

Error: Error 1007: Can't create database 'foo'; database exists

on main.tf line 11, in resource "mysql_database" "bar":
11: resource "mysql_database" "bar" {

失敗しました。同じ名前のデータベースは作成できないので当然です。 このように plan に成功しても apply に失敗することはあります。

では name を修正しましょう。ついでに database foo の default character set を修正します。

resource "mysql_database" "foo" {
name = "foo"
default_character_set = "utf8mb4" # default character set を修正。デフォルトは utf8
}

resource "mysql_database" "bar" {
name = "bar" # 名前を foo から bar に変更
}
$ terraform plan
Terraform will perform the following actions:

# mysql_database.bar will be created
+ resource "mysql_database" "bar" {
+ default_character_set = "utf8"
+ default_collation = "utf8_general_ci"
+ id = (known after apply)
+ name = "bar"
}

# mysql_database.foo will be updated in-place
~ resource "mysql_database" "foo" {
~ default_character_set = "utf8" -> "utf8mb4"
default_collation = "utf8_general_ci"
id = "foo"
name = "foo"
}

Plan: 1 to add, 1 to change, 0 to destroy.
$ terraform apply
mysql_database.bar: Creating...
mysql_database.foo: Modifying... [id=foo]
mysql_database.bar: Creation complete after 0s [id=bar]

Error: Error 1253: COLLATION 'utf8_general_ci' is not valid for CHARACTER SET 'utf8mb4'

on main.tf line 7, in resource "mysql_database" "foo":
7: resource "mysql_database" "foo" {

mysql_database.foo の変更に失敗しました。一方 mysql_database.bar の作成には成功しています。

mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| bar |
| foo |
| mysql |
| performance_schema |
| sys |
+--------------------+
6 rows in set (0.00 sec)

mysql> select * from INFORMATION_SCHEMA.SCHEMATA where SCHEMA_NAME='foo';
+--------------+-------------+----------------------------+------------------------+----------+
| CATALOG_NAME | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | SQL_PATH |
+--------------+-------------+----------------------------+------------------------+----------+
| def | foo | utf8 | utf8_general_ci | NULL |
+--------------+-------------+----------------------------+------------------------+----------+
1 row in set (0.00 sec)

このように、一部の変更の適用に失敗にしても、その他の変更は適用されるという、ある意味中途半端に apply される場合があります。こういった場合に rollback するようなコマンドはないので気をつけましょう。適用された変更はちゃんと State に反映されています。

では mysql_database.foo の変更に失敗したので、適切に設定を変更しましょう。

resource "mysql_database" "foo" {
name = "foo"
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci" # utf8_general_ci から変更
}

apply に --auto-approve というオプションをつけると確認なしに適用されます。CIで実行する場合には基本これをつけることになると思います。

$ terraform apply --auto-approve
mysql_database.foo: Refreshing state... [id=foo]
mysql_database.bar: Refreshing state... [id=bar]
mysql_database.foo: Modifying... [id=foo]
mysql_database.foo: Modifications complete after 0s [id=foo]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

変更できました。

mysql> select * from INFORMATION_SCHEMA.SCHEMATA where SCHEMA_NAME='foo';
+--------------+-------------+----------------------------+------------------------+----------+
| CATALOG_NAME | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | SQL_PATH |
+--------------+-------------+----------------------------+------------------------+----------+
| def | foo | utf8mb4 | utf8mb4_general_ci | NULL |
+--------------+-------------+----------------------------+------------------------+----------+
1 row in set (0.00 sec)

terraform.tfstate.backup

ところで terraform.tfstate.backup というファイルが作られています。

$ ls
docker-compose.yml main.tf terraform.tfstate terraform.state.backup

これは名前の通り terraform.tfstate のバックアップです。 terraform.tfstate が terraform のコマンドによって更新される前に自動的にバックアップが作成されます。

リソースの recreate

今度は database の名前を変更してみましょう。

resource "mysql_database" "foo" {
name = "foo2" # foo から変更
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci"
}
$ terraform plan
Terraform will perform the following actions:

# mysql_database.foo must be replaced
-/+ resource "mysql_database" "foo" {
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci"
~ id = "foo" -> (known after apply)
~ name = "foo" -> "foo2" # forces replacement
}

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

先程 default_collation と default_character_set を変更した際は既存のデータベースが更新されましたが、今度は新しく作り直されるようです。 これはリソースの定義に依存します。ソースコードを確認しましょう。

https://github.com/terraform-providers/terraform-provider-mysql/blob/v1.9.0/mysql/resource_database.go#L31

属性の定義で ForceNew: true となっている場合、その属性が変更されるとリソースが作り直されます。デフォルトだと既存のリソースの更新になります。 新しく作り直されるということは、テーブルなどは消えるはずです。試しにテーブルを作っておいて、 apply してみましょう。

mysql> show tables from foo;
Empty set (0.00 sec)

mysql> create table foo.zoo (id int);
Query OK, 0 rows affected (0.03 sec)

mysql> show tables from foo;
+---------------+
| Tables_in_foo |
+---------------+
| zoo |
+---------------+
1 row in set (0.00 sec)
$ terraform apply --auto-approve
mysql_database.foo: Refreshing state... [id=foo]
mysql_database.bar: Refreshing state... [id=bar]
mysql_database.foo: Destroying... [id=foo]
mysql_database.foo: Destruction complete after 0s
mysql_database.foo: Creating...
mysql_database.foo: Creation complete after 0s [id=foo2]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

mysql_database.foo: Destroying... [id=foo]

とあるように一度削除されています。

mysql> show tables from foo;
ERROR 1049 (42000): Unknown database 'foo'
mysql> show tables from foo2;
Empty set (0.00 sec)

新しいデータベースには先程作成したテーブルがありません。このように recreate は危険な操作なのでやるときには注意を払いましょう。

変数の利用

変数を使ってみましょう。設定を修正します。

resource "mysql_database" "foo" {
name = "foo2"
default_character_set = var.default_character_set # 変数 default_character_set を参照
default_collation = "utf8mb4_general_ci"
}
$ terraform plan

Error: Reference to undeclared input variable

on main.tf line 9, in resource "mysql_database" "foo":
9: default_character_set = var.default_character_set

An input variable with the name "default_character_set" has not been declared.
This variable can be declared with a variable "default_character_set" {}
block.

エラーになりました。変数を利用するには宣言が必要です。 variables.tf というファイルを作成しましょう。

variable "default_character_set" {
type = string
}
$ terraform plan
var.default_character_set
Enter a value:

値の入力を求められました。これは変数の値が設定されていないからです。 ここでは foo と入力してみます。

$ terraform plan
var.default_character_set
Enter a value: foo

Terraform will perform the following actions:

# mysql_database.foo will be updated in-place
~ resource "mysql_database" "foo" {
~ default_character_set = "utf8mb4" -> "foo"
default_collation = "utf8mb4_general_ci"
id = "foo2"
name = "foo2"
}

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

-input=false にすると入力を求められずにエラーを返します。

$ terraform plan -input=false

Error: No value for required variable

on variables.tf line 1:
1: variable "default_character_set" {

The root module input variable "default_character_set" is not set, and has no
default value. Use a -var or -var-file command line argument to provide a
value for this variable.

terraform.tfvars というファイルを作成し、値を設定しましょう。

default_character_set = "utf8mb4"
$ terraform plan

No changes. Infrastructure is up-to-date.

terraform.tfvars は特別なファイル名で、カレントディレクトリにこのファイルがあると自動で読み込まれます。

ファイル名を変えて terraform plan をしてみましょう。

$ mv terraform.tfvars main.tfvars
$ terraform plan
var.default_character_set
Enter a value:

聞かれました。 main.tfvars が読み込まれていません。 -var-file オプションでファイルを指定すれば main.tfvars を読み込めます。

$ terraform plan -var-file=main.tfvars

もとに戻しておきます。

$ mv main.tfvars terraform.tfvars

他のリソースの属性の参照

設定を修正します。

resource "mysql_database" "foo" {
name = "foo2"
default_character_set = var.default_character_set
default_collation = "utf8mb4_general_ci"
}

resource "mysql_database" "bar" {
name = "bar"
default_character_set = mysql_database.foo.default_character_set # 他のリソースの属性を参照
default_collation = "utf8mb4_general_ci" # default_character_set に合わせて変更
}
$ terraform plan
Terraform will perform the following actions:

# mysql_database.bar will be updated in-place
~ resource "mysql_database" "bar" {
~ default_character_set = "utf8" -> "utf8mb4"
~ default_collation = "utf8_general_ci" -> "utf8mb4_general_ci"
id = "bar"
name = "bar"
}

Plan: 0 to add, 1 to change, 0 to destroy.
$ terraform apply --auto-approve
mysql_database.foo: Refreshing state... [id=foo2]
mysql_database.bar: Refreshing state... [id=bar]
mysql_database.bar: Modifying... [id=bar]
mysql_database.bar: Modifications complete after 0s [id=bar]

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

terraform state rm

https://www.terraform.io/docs/commands/state/rm.html

Terraform は State によって設定ファイル中のリソースと実際のリソースをマッピングしています。 State からリソースを削除して terraform plan を実行してみると、 Terraform はそのリソースを新規で作成しようとします。

$ terraform plan
No changes. Infrastructure is up-to-date.

$ terraform state rm mysql_database.bar
Removed mysql_database.bar
Successfully removed 1 resource instance(s).

$ terraform plan
Terraform will perform the following actions:

# mysql_database.bar will be created
+ resource "mysql_database" "bar" {
+ default_character_set = "utf8mb4"
+ default_collation = "utf8mb4_general_ci"
+ id = (known after apply)
+ name = "bar"
}

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

実際には同じ名前のデータベースは作成できないので apply で失敗するはずです。 terraform state rm は元々 Terraform で管理していたものを Terraform で管理するのを止めたり、あるいは手動で削除してしまったリソースを State からも削除するのに使えます。

terraform import

https://www.terraform.io/docs/import/index.html https://www.terraform.io/docs/commands/import.html

terraform import は Terraform で管理されていないリソースを Terraform で管理するために State にリソースのデータを取り込むコマンドです。 ちょうど mysql_database.bar を State から消してしまったので、 import することにしましょう。

https://www.terraform.io/docs/providers/mysql/r/database.html#import にある通り、データベースはデータベース名を指定すれば import 出来ます。

$ terraform import mysql_database.bar bar
mysql_database.bar: Importing from ID "bar"...
mysql_database.bar: Import prepared!
Prepared mysql_database for import
mysql_database.bar: Refreshing state... [id=bar]

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.

Import 出来ました。 terraform plan を実行して差分がなくなっていることを確認します。

$ terraform plan
No changes. Infrastructure is up-to-date.

次に手動で Database を作成してそれを import してみましょう。

mysql> create database zoo;
Query OK, 1 row affected (0.00 sec)
$ terraform import mysql_database.zoo zoo
Error: resource address "mysql_database.zoo" does not exist in the configuration.

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

resource "mysql_database" "zoo" {
# (resource arguments)
}

失敗しました。 mysql_database.zoo が存在しないからです。 空で良いのでリソースの設定を追加しましょう。

resource "mysql_database" "zoo" {
}
$ terraform import mysql_database.zoo zoo
mysql_database.zoo: Importing from ID "zoo"...
mysql_database.zoo: Import prepared!
Prepared mysql_database for import
mysql_database.zoo: Refreshing state... [id=zoo]

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.

Import 出来たので zoo を Terraform で管理できるようになりました。 terraform plan を実行してみます。

$ terraform plan

Error: Missing required argument

on main.tf line 19, in resource "mysql_database" "zoo":
19: resource "mysql_database" "zoo" {

The argument "name" is required, but no definition was found.

zoo の設定が空で name が設定されていないので失敗しました。 修正します。

resource "mysql_database" "zoo" {
name = "zoo"
}
$ terraform plan
Terraform will perform the following actions:

# mysql_database.zoo will be updated in-place
~ resource "mysql_database" "zoo" {
~ default_character_set = "latin1" -> "utf8"
~ default_collation = "latin1_swedish_ci" -> "utf8_general_ci"
id = "zoo"
name = "zoo"
}

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

まだ差分が出てしまいました。

Import は State にはリソースのデータを反映してくれますが、設定ファイルには反映してくれないので自分で反映させる必要があります。 一部の Provider では、設定ファイルに自動で反映させるためのサードパーティのツールもあります。

修正します。

resource "mysql_database" "zoo" {
name = "zoo"
default_character_set = "latin1"
default_collation = "latin1_swedish_ci"
}
$ terraform plan
No changes. Infrastructure is up-to-date.

無事差分がなくなりました。

リソースの削除

次にリソースを削除してみます。

terraform destroy もありますが、今回は設定をコメントアウトします。 ちなみに Terraform の設定ファイルの記述言語である HCL ではコメントアウトは # でも // でもどちらでも良いです。

https://github.com/hashicorp/hcl#syntax

# resource "mysql_database" "bar" {
# name = "bar"
# default_character_set = mysql_database.foo.default_character_set
# default_collation = "utf8mb4_general_ci"
# }
$ terraform plan
Terraform will perform the following actions:

# mysql_database.bar will be destroyed
- resource "mysql_database" "bar" {
- default_character_set = "utf8mb4" -> null
- default_collation = "utf8mb4_general_ci" -> null
- id = "bar" -> null
- name = "bar" -> null
}

Plan: 0 to add, 0 to change, 1 to destroy.
$ terraform apply --auto-approve
mysql_database.foo: Refreshing state... [id=foo2]
mysql_database.bar: Refreshing state... [id=bar]
mysql_database.bar: Destroying... [id=bar]
mysql_database.bar: Destruction complete after 0s

Apply complete! Resources: 0 added, 0 changed, 1 destroyed.
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| foo2 |
| mysql |
| performance_schema |
| sys |
| zoo |
+--------------------+
6 rows in set (0.00 sec)

確かに削除されています。

terraform state mv

https://www.terraform.io/docs/commands/state/mv.html

ここで リソースの名前を変えて terraform plan してみましょう。名前を変えたくなることはまぁあると思います。

resource "mysql_database" "foo2" { # foo から foo2 に変更
name = "foo2"
default_character_set = var.default_character_set
default_collation = "utf8mb4_general_ci"
}
$ terraform plan
Terraform will perform the following actions:

# mysql_database.foo will be destroyed
- resource "mysql_database" "foo" {
- default_character_set = "utf8mb4" -> null
- default_collation = "utf8mb4_general_ci" -> null
- id = "foo2" -> null
- name = "foo2" -> null
}

# mysql_database.foo2 will be created
+ resource "mysql_database" "foo2" {
+ default_character_set = "utf8mb4"
+ default_collation = "utf8mb4_general_ci"
+ id = (known after apply)
+ name = "foo2"
}

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

既存のデータベースが削除されて新しいデータベースが作成されてしまいそうです。 State に記録されているリソースのパスを修正する必要があります。

$ terraform state mv mysql_database.foo mysql_database.foo2
Move "mysql_database.foo" to "mysql_database.foo2"
Successfully moved 1 object(s).
$ terraform plan
No changes. Infrastructure is up-to-date.

差分がなくなりました。

このようにコマンドによって State を更新する必要があるので、特に複数人で作業する場合は安易にリソースパスを変更するべきではありません。

terraform refresh

https://www.terraform.io/docs/commands/refresh.html

terraform refresh は実際のインフラの情報を取得して State に反映させるコマンドです。 Terraform を使わずに加えた変更を State に反映させるのに使えます。 Terraform を使ってインフラを管理している以上、 Terraform を使わずにインフラを変更するのは望ましくないですが、実際のところはよくある話かと思います。

Terraform を使わずに default_character_set を変更してみましょう。

mysql> ALTER DATABASE zoo DEFAULT CHARACTER SET utf8mb4;
Query OK, 1 row affected (0.01 sec)

mysql> select * from INFORMATION_SCHEMA.SCHEMATA where SCHEMA_NAME='zoo';
+--------------+-------------+----------------------------+------------------------+----------+
| CATALOG_NAME | SCHEMA_NAME | DEFAULT_CHARACTER_SET_NAME | DEFAULT_COLLATION_NAME | SQL_PATH |
+--------------+-------------+----------------------------+------------------------+----------+
| def | zoo | utf8mb4 | utf8mb4_general_ci | NULL |
+--------------+-------------+----------------------------+------------------------+----------+
1 row in set (0.00 sec)

default_character_set を変えると自動で DEFAULT_COLLATION_NAME も変わりました。 設定ファイルにも変更を反映させましょう。

resource "mysql_database" "zoo" {
name = "zoo"
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci"
}

-refresh=false をつけて terraform plan を実行してみます。

$ terraform plan -refresh=false
Terraform will perform the following actions:

# mysql_database.zoo will be updated in-place
~ resource "mysql_database" "zoo" {
~ default_character_set = "latin1" -> "utf8mb4"
~ default_collation = "latin1_swedish_ci" -> "utf8mb4_general_ci"
id = "zoo"
name = "zoo"
}

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

Warning: Resource targeting is in effect

You are creating a plan with the -target option, which means that the result
of this plan may not represent all of the changes requested by the current
configuration.

The -target option is not for routine use, and is provided only for
exceptional situations such as recovering from errors or mistakes, or when
Terraform specifically suggests to use it as part of an error message.

差分が出てしまいました。これは State が更新されていないからです。 terraform.tfstate を確認してみましょう。

    {
"mode": "managed",
"type": "mysql_database",
"name": "zoo",
"provider": "provider.mysql",
"instances": [
{
"schema_version": 0,
"attributes": {
"default_character_set": "latin1",
"default_collation": "latin1_swedish_ci",
"id": "zoo",
"name": "zoo"
},
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
}
]
}

-refresh=false を取ると今度は差分がなくなるはずです。

$ terraform plan
No changes. Infrastructure is up-to-date.

これは、実は terraform plan はデフォルトでは State の内容と設定ファイルを比較する前に実際のインフラの情報を取得し State の内容をインメモリで更新した上で設定ファイルと比較しているからです。 ただし、インメモリでの更新であり、実際の State が更新されているわけではないです。

今までスルーしてきましたが、そのことは terraform 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.

ではなんで -refresh=false が必要になるかというと、理由の一つとして terraform plan が速くなることが挙げられます。 むしろ -refresh=false をつけないと、管理するリソースの数が増えれば増えるほど情報を取得してくるのに時間がかかるようになりますし、 場合によっては API の rate limit に引っかかるなんてこともあるかもしれません。

なので普段 -refresh=false をつけて terraform plan を実行するようにしていると Terraform を使わずに加えたインフラの修正を State に取り込むために terraform refresh を実行する必要が出てきたりします。

State は更新されていないので -refresh=false とすれば相変わらず差分は出ます。

$ terraform plan -refresh=false
Terraform will perform the following actions:

# mysql_database.zoo will be updated in-place
~ resource "mysql_database" "zoo" {
~ default_character_set = "utf8mb4" -> "latin1"
~ default_collation = "utf8mb4_general_ci" -> "latin1_swedish_ci"
id = "zoo"
name = "zoo"
}

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

差分が出ました。 terraform refresh を実行すると State が更新され、差分がなくなるはずです。

$ terraform refresh
module.app.mysql_database.db: Refreshing state... [id=app]
mysql_database.foo2: Refreshing state... [id=foo2]
mysql_database.zoo: Refreshing state... [id=zoo]
    {
"mode": "managed",
"type": "mysql_database",
"name": "zoo",
"provider": "provider.mysql",
"instances": [
{
"schema_version": 0,
"attributes": {
"default_character_set": "utf8mb4",
"default_collation": "utf8mb4_general_ci",
"id": "zoo",
"name": "zoo"
},
"private": "eyJzY2hlbWFfdmVyc2lvbiI6IjAifQ=="
}
]
}
$ terraform plan -refresh=false
No changes. Infrastructure is up-to-date.

Module

https://www.terraform.io/docs/configuration/modules.html

簡単なモジュールを作ってみましょう。

$ mkdir database
vi database/database.tf
resource "mysql_database" "db" {
name = "app"
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci"
}

では Module を使って database を作ってみます。

module "app" {
source = "./database"
}
$ terraform plan
Error: Module not installed

on main.tf line 25:
25: module "app" {

This module is not yet installed. Run "terraform init" to install all modules
required by this configuration.

失敗しました。 terraform init する必要があります。

$ terraform init
$ terraform plan
Terraform will perform the following actions:

# module.app.mysql_database.db will be created
+ resource "mysql_database" "db" {
+ default_character_set = "utf8mb4"
+ default_collation = "utf8mb4_general_ci"
+ id = (known after apply)
+ name = "app"
}

Plan: 1 to add, 0 to change, 0 to destroy.
$ terraform apply --auto-approve
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Module パラメータ

これで Module を使って Database を作れましたが、 name が "app" 固定なので使いづらいですね。名前を変えられるようにしましょう。

database/database.tf

resource "mysql_database" "db" {
name = var.name
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci"
}

database/variables.tf

variable "name" {
type = string
}
$ terraform plan

Error: Missing required argument

on main.tf line 25, in module "app":
25: module "app" {

The argument "name" is required, but no definition was found.

必須パラメータを指定していないので失敗しました。デフォルト値を設定しましょう。

https://www.terraform.io/docs/configuration/variables.html

variable "name" {
type = string
default = "app"
}
$ terraform plan
No changes. Infrastructure is up-to-date.

パラメータを渡して name を変更しましょう。

module "app" {
source = "./database"
name = "app2"
}
$ terraform plan
Terraform will perform the following actions:

# module.app.mysql_database.db must be replaced
-/+ resource "mysql_database" "db" {
default_character_set = "utf8mb4"
default_collation = "utf8mb4_general_ci"
~ id = "app" -> (known after apply)
~ name = "app" -> "app2" # forces replacement
}

パラメータを渡せました。もとに戻しておきます。

module "app" {
source = "./database"
name = "app"
}

Module Output

https://www.terraform.io/docs/configuration/outputs.html

mysql_database.bar から mysql_database.foo の属性を参照したように、 module.app で作成したデータベースの属性を参照するにはどうしたらよいでしょうか? モジュール内であれば普通に参照できますが、モジュールの外から参照するには、 Module 側で明示的に公開する必要があります。不便な側面もあるかもしれませんが、カプセル化とも言えますね。

default_character_set を公開しましょう。

database/output.tf

output "default_character_set" {
value = mysql_database.db.default_character_set
}

そして参照します。

main.tf

resource "mysql_database" "zoo" {
name = "zoo"
default_character_set = module.app.default_character_set # 参照
default_collation = "utf8mb4_general_ci"
}
$ terraform plan
No changes. Infrastructure is up-to-date.

terraform destroy

https://www.terraform.io/docs/commands/destroy.html

ここまでお疲れさまでした。 ハンズオンの最後にこれまで作ったデータベースを削除してしまいましょう。 -target オプションでリソースパスを指定して特定のリソースだけ削除できます。 -target オプションは複数回指定することで複数のリソースを指定できます。

$ terraform destroy -target=module.app

Terraform will perform the following actions:

# mysql_database.zoo will be destroyed
- resource "mysql_database" "zoo" {
- default_character_set = "utf8mb4" -> null
- default_collation = "utf8mb4_general_ci" -> null
- id = "zoo" -> null
- name = "zoo" -> null
}

# module.app.mysql_database.db will be destroyed
- resource "mysql_database" "db" {
- default_character_set = "utf8mb4" -> null
- default_collation = "utf8mb4_general_ci" -> null
- id = "app" -> null
- name = "app" -> null
}

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

Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.

Enter a value: yes

module.app を消そうとしたら、 mysql_database.zoo も削除されそうになっています。 これは mysql_database.zoo が module.app の属性に依存しているからです。

resource "mysql_database" "zoo" {
name = "zoo"
default_character_set = module.app.default_character_set # module.app に依存
default_collation = "utf8mb4_general_ci"
}

-target オプションで mysql_database.zoo を指定すればそれだけ削除されます。

$ terraform destroy -target=mysql_database.zoo
Terraform will perform the following actions:

# mysql_database.zoo will be destroyed
- resource "mysql_database" "zoo" {
- default_character_set = "utf8mb4" -> null
- default_collation = "utf8mb4_general_ci" -> null
- id = "zoo" -> null
- name = "zoo" -> null
}

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

Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.

Enter a value:

確認されるので、 yes と入力して削除します。

  Enter a value: yes

mysql_database.zoo: Destroying... [id=zoo]
mysql_database.zoo: Destruction complete after 0s

Warning: Applied changes may be incomplete

The plan was created with the -target option in effect, so some changes
requested in the configuration may have been ignored and the output values may
not be fully updated. Run the following command to verify that no other
changes are pending:
terraform plan

Note that the -target option is not suitable for routine use, and is provided
only for exceptional situations such as recovering from errors or mistakes, or
when Terraform specifically suggests to use it as part of an error message.


Destroy complete! Resources: 1 destroyed.
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| app |
| foo2 |
| mysql |
| performance_schema |
| sys |
+--------------------+
6 rows in set (0.00 sec)

確かに消えています。 設定ファイルからは消えていないので terraform plan を実行すると Create しようとします。

$ terraform plan
Terraform will perform the following actions:

# mysql_database.zoo will be created
+ resource "mysql_database" "zoo" {
+ default_character_set = "utf8mb4"
+ default_collation = "utf8mb4_general_ci"
+ id = (known after apply)
+ name = "zoo"
}

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

リソースパスを指定しないと全てのリソースを削除します。

$ terraform destroy --auto-approve
module.app.mysql_database.db: Refreshing state... [id=app]
mysql_database.foo2: Refreshing state... [id=foo2]
mysql_database.foo2: Destroying... [id=foo2]
module.app.mysql_database.db: Destroying... [id=app]
module.app.mysql_database.db: Destruction complete after 0s
mysql_database.foo2: Destruction complete after 0s

Destroy complete! Resources: 2 destroyed.
mysql> show databases;
+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
+--------------------+
4 rows in set (0.00 sec)

全部消えました。

· 26 min read
Shunsuke Suzuki

参考

手を動かしたい方は Terraform ハンズオン with MySQL Provider も参考にしてください。

前提

  • 執筆時点 (2020/01/05) で Terraform の最新バージョンは v0.12.18 です

Terraform とは

Terraform は Infrastructure as Code を実現する汎用的なCLIツールです。 インフラの状態を設定ファイルに定義し、コマンドを実行することで、 実際のインフラの状態と設定ファイルの差分を検知し、設定ファイルに記述されたとおりになるようにインフラを変更(CRUD)します。

Hashicorp という企業がホストしている OSS になります。 Go で書かれています。 https://github.com/hashicorp/terraform

Terraform のインストール

Terraform は Go 製なので 1 バイナリをダウンロードしてインストールするだけです。

https://www.terraform.io/downloads.html

tfenv を使うと管理が楽です。

https://github.com/tfutils/tfenv

tfenv は Terraform のバージョン管理ツールです。 pyenv や rbenv の Terraform 版みたいなものです。

用語集

https://www.terraform.io/docs/glossary.html

Provider

https://www.terraform.io/docs/glossary.html#terraform-provider

Terraform を「汎用的な」ツールといいましたが、ここでいう「汎用的」とは、 AWS などの特定のサービス専用ではなく、様々なサービスを同じように扱うことが出来るという意味です。 実際に AWS などのインフラを操作する場合にはインフラが提供する API を利用するわけですが、 サービス固有の API 呼び出しなどの処理を Provider という形で Terraform 本体から切り出すことでこの汎用性は実現されています。 Provider は AWS や GCP などの Hashicorp 公式のものもあれば、サードパーティ製のものもありますし、自分で作ってしまうことも出来ます。

少し突っ込んだ話をすると、 Terraform 本体同様 Provider も実態は Golang で書かれた1バイナリであり、本体から Provider を RPC によって呼び出すという形でプラグイン機構を実現しています。 https://github.com/hashicorp/go-plugin

余談: Provider の自作

Provider の自作に興味のある方は https://www.terraform.io/docs/extend/writing-custom-providers.html を読むと良いでしょう。 ただし、それを読めば全てわかるということはなく、作りながら Terraform 本体や AWSなどの公式の Provider のソースコードや GoDoc を読みつつ試行錯誤することになると思います。

リソース

https://www.terraform.io/docs/glossary.html#resource

Terraform ではインフラを構成する最小の構成要素を リソース と呼びます。 例えばサーバが 3 台あればそれぞれが別々のリソースと言えます。 リソースごとに設定を記述します。 terraform apply コマンドを実行すると各リソースについて設定と実際のインフラの状態の差分が検知され、リソースごとに CRUD 処理が実行されます。

設定ファイル

設定ファイルは HCL という言語で記述します。 HCL は Hashicorp Configuration Language の略で、 Hashicorp が開発している JSON や YAML のような設定を記述する言語です。

Terraform v0.12 では HCL の v2 を使います。 v1 は古いので注意してください。

設定ファイルの拡張子は基本 .tf です。 次のような形でリソースを定義します。

# resource "リソースの種類" "リソース名"
resource "aws_instance" "example" {
# 属性名 = 属性値
ami = "ami-2757f631"
instance_type = "t2.micro"
}

余談ですが、 GFM (GitHub Flavored Markdown) では hcl でシンタックスハイライトができます。

各リソース定義はパスによって識別されます。 上記のようなシンプルなリソースの定義の場合 リソースの種類.リソース名 (aws_instance.example) です。パスは一意でなければいけません。

設定ファイルは同じ1つのディレクトリに置きます。 terraform plan などのコマンドはカレントディレクトリ直下のファイルしか見ません。

リソースの属性はリソースの種類によって異なります。 各Provider のドキュメントを参照しましょう。

例えば AWS の EC2 instance のドキュメントはこちらです。 https://www.terraform.io/docs/providers/aws/r/instance.html

リソースの属性の参照

あるリソースの設定で他のリソースの属性値を参照することが出来ます。

サンプルを示します。

resource "aws_iam_instance_profile" "example" {
# リソース aws_iam_role.example の属性 name の値を参照
role = aws_iam_role.example.name
}

State

https://www.terraform.io/docs/state/index.html

設定ファイルと実際のインフラの差分を検知するには、設定ファイルのリソース定義と実際のインフラのマッピングが必要になります。 設定ファイル上ではリソースはパスによって識別されますが、実際のインフラのリソースはインフラ固有の ID などで識別されます。 設定ファイル上のリソースのパスと、実際のインフラのリソースの ID とのマッピングを保存するストレージが State になります。 ただし State の役割はマッピングだけではありません。詳細は https://www.terraform.io/docs/state/purpose.html を参照してください。 「ストレージ」といいましたがその実態はデフォルトではただの JSON ファイルです。 デフォルトでは terraform apply などを実行すると勝手に terraform.tfstate という名前のファイルがカレントディレクトリに作成、更新されます。 State の保存先は S3 を含め色々サポートされています。

https://www.terraform.io/docs/backends/types/index.html

発展: State を持たないツールとの違い

Terraform の他にもインフラを管理するツールはありますが、その中には State のようなマッピングを持たないものもあります。 マッピングを持たないツールだと、ツールを使わずに作ったリソースがツールによって削除される可能性があります。 一方、 Terraform ではツールを使わずに作ったリソースは変更の対象になりません。 変更の対象にしたい場合はそのリソースの情報を State に追加する必要があります。 ただの JSON ファイルなので手で修正することも出来ますが、 terraform import という専用のコマンドがあるのでそれを使うほうが安全です。

https://www.terraform.io/docs/import/index.html

逆に、あまりないケースではありますが、あるリソースについて Terraform で管理するのをやめたい場合、 そのリソースを設定ファイルだけでなく State からも削除する必要があります。 単純に設定ファイルから削除するだけだと、 Terraform によって実際のインフラのリソースが削除されてしまいます。 State から削除するには terraform state rm というコマンドを使います。

その他 State を管理するためのコマンドが色々あるのでドキュメントを参照してください。

https://www.terraform.io/docs/commands/state/index.html

発展: Remote State を使うか否か

Terraform を CI/CD によって実行する前提です。 基本的に remote state を使うべきだと思います。

remote state を使わない場合、 terraform.tfstate を Git で管理することになるでしょう。 その場合、 feature branch の terraform.tfstate と実際のインフラの状態の乖離が起こりえます。 チームの規模が大きく複数の開発が並行して行われれば行われるほど、乖離の弊害が大きくなり、 remote state を使ったほうが良いということになるでしょう。

また、 CI/CD で更新された terraform.tfstate を commit & push する必要があります。 terraform apply で失敗した場合、一部のリソースの更新には成功し、State が更新されているかもしれません。 terraform apply が失敗しても即座に終了させずに terraform.tfstate を commit & push する必要があります。 これは CI/CD のコードで気をつければ問題ないのでデメリットと言うほどではないですが、昔自分は何度か commit & push し損ねて面倒くさいことになりました。

変数

設定ファイルでは変数が使えます。 変数を使うには宣言が必要です。宣言では型なども指定できます。

https://www.terraform.io/docs/configuration/types.html

変数の宣言はつぎのようにします。 .tf のどこに書いても大丈夫です。

# variable "変数名"
variable "region" {
type = "string"
default = "us-east-1"
}

変数の値の設定は次のようにします。 terraform.tfvars というファイル名に書いておくとコマンド実行時に自動で読み込まれます。

ami = {
"us-east-1" = "ami-abc123"
"us-west-2" = "ami-def456"
}

コマンドライン引数で渡すことも出来ます。

Provider の設定

Provider を使うには設定が必要です。 設定の属性は Provider によって違います。

# provider "Provider名"
provider "google" {
project = "acme-app"
region = "us-central1"
}

Module

https://www.terraform.io/docs/modules/index.html https://www.terraform.io/docs/configuration/modules.html

複数のリソースの設定をまとめて再利用可能な形でパッケージングする仕組みとして Module があります。 リソースが1つだけでも、チーム固有の設定を Module のデフォルト値として設定したり、チームでは使わないリソースの属性を隠蔽したり用途はあるかと思います。

Module の作り方は通常の Terraform の設定ファイルの記述と同様、 1つのディレクトリ直下にパッケージングするリソースの設定ファイルを記述するだけです。

Module は次のように使います。

# module "名前"
module "servers" {
# モジュールへのパス
source = "./app-cluster"

# モジュールのパラメータ
servers = 5
}

Module のパスは module.モジュール名 (module.servers) になります。

Module の source としてはローカルのディレクトリへのパス以外にも様々なものをサポートしています。

https://www.terraform.io/docs/modules/sources.html

コミュニティの Module

コミュニティによって様々な Module が提供されています。

Module 化するべきか否か

Go や Python などのプログラミング言語でのモジュール(ライブラリやパッケージ等呼び方は様々ですが)化と比べ、 Terraform の Module 化は慎重でなければなりません。 理由はいくつかありますが、

  • Module を変更すると State の変更も必要になる場合もある
  • Terraform の設定は中々パワフルだとは思いますが、プログラミング言語に比べると柔軟性が足らず、変更に弱く、複雑になるとメンテナンス性が悪くなります

偏見かもしれませんが、プログラミング言語に比べると、 Terraform に「精通」している人はそれほど多くないと思います。 初心者は直ぐ Module 化に飛びつくのはやめた方が良いと思います(尤も使ってみないと理解が深まらないという意味では使ったほうが良いですが)。

Output

https://www.terraform.io/docs/configuration/outputs.html

Module で定義したリソースの属性は基本的に外部から隠蔽されます。 リソースの属性を参照できるようにするには、次のように個別に Output として宣言する必要があります。

output "instance_ip_addr" {
value = aws_instance.server.private_ip
}

これはモジュールで定義したリソース aws_instance.server の属性 private_ip を Module の属性 instance_ip_addr として外部に公開するという意味です。 Module のパスが module.servers だとすると、 module.servers.instance_ip_addr で参照できます。

Data Source

https://www.terraform.io/docs/configuration/data-sources.html

Data source は Terraform で管理していない(あるいは他の State で管理している)リソースの属性を参照するための仕組みです。

次のように記述します。

# data "リソースの種類" "リソース名"
data "aws_ami" "example" {
# リソースを一意に識別するためのクエリ

most_recent = true

owners = ["self"]
tags = {
Name = "app-server"
Tested = "true"
}
}

Data source では属性によって実際のインフラのリソースを検索します。検索にマッチするリソースは必ず1つでなければならず、 複数マッチしたり1つもマッチしなかったりすると失敗します(厳密には Provider の実装次第ですが)。

他の State で管理されているリソースの属性を Data source として参照する

https://www.terraform.io/docs/providers/terraform/d/remote_state.html

他の State で管理されているリソースの属性を Data source を使って参照するために terraform_remote_state があります。 参照するには Module 同様 その属性が Output によって外部に公開されている必要があります。

data "terraform_remote_state" "vpc" {
backend = "remote"

config = {
organization = "hashicorp"
workspaces = {
name = "vpc-prod"
}
}
}

workspace

Terraform には workspace という機能がありますが、こちらの機能は自分は使ったことがないので簡単に触れるだけに留めます。 workspace の代表的なユースケースは production や staging などの異なる環境で同じ設定ファイルを共有しつつ State を switch することだと思います。

workspace を使わない場合環境ごとにディレクトリ及び設定ファイルを完全に分けることになると思います。

production/
ec2.tf
terraform.tfstate
...
staging/
ec2.tf
terraform.tfstate
...

workspace を使うと設定ファイルを共有できます。

ec2.tf
terraform.tfstate.d/
production/
terraform.tfstate
staging/
terraform.tfstate

環境ごとの設定の微妙な違いは設定ファイル内で分岐することになるのでしょう。

発展: workspace を使うべきか

workspace を使うべきか否かは意見が分かれている様に思えます。 恐らくユースケースやチームメンバーの Terraform への成熟度にもよるでしょう。

現状自分は「使わない」というスタンスです。

自分がこれまで関わってきたチーム事情だと Terraform に全員が精通しているというよりは、むしろ Terraform にそこまで詳しくない人も触ることが多いです。 偏見かもしれませんが、そういうチームは少なくないのではないでしょうか? そういうチーム状況では、 workspace を使って DRY になるというメリット以上に、 workspace そのものへの学習コストや環境によって設定ファイル内で分岐する学習コストを省き、 Terraform に精通していなくても理解できるくらいシンプルに保つことのほうが大事なのではないかなと思います。

特に、これまで Terraform を特定のサービス横断的なチームで管理していた状態から各サービスの担当者に ownership を委譲しようとする場合、上述の学習コスト・複雑さが弊害になるのではないかなと感じています。

ただし、繰り返しになりますが自分は workspace を使ったことがありません。 上記の自分の認識が間違っているかもしれませんし、今後 workspace を使ってみたら考えが変わるかもしれません。

Terraform command の基本的な使い方

https://www.terraform.io/docs/commands/index.html

設定ファイルを書いた上で基本的なコマンドの使い方について説明します。

terraform init

https://www.terraform.io/docs/commands/init.html

まず、 terraform init を実行する必要があります。

$ terraform init

terraform init によって、依存する Provider がインストールされたりします。

terraform fmt

https://www.terraform.io/docs/commands/fmt.html

terraform fmt によってコードを整形することが出来ます。

$ terraform fmt [-check] [-recursive]

-check をつけると、コードを整形する代わりに、コードが整形されていなかったら exit code が non 0 で終了します。 CI でコードが整形されているかのチェックに使えます。

terraform plan

https://www.terraform.io/docs/commands/plan.html

terraform plan によって terraform apply の dry run が出来ます。

$ terraform plan [-refresh=false]

実際のインフラは変更されず、 apply を実行した場合にどのような変更が行われるか出力されます。

terraform apply

https://www.terraform.io/docs/commands/apply.html

実際にインフラを設定ファイルに合わせて変更します。

$ terraform apply [-auto-approve]

デフォルトでは最初に plan の実行結果が出力されて本当に変更を適用してよいか確認があります。 -auto-approve を指定すると確認を skip 出来ます。

plan に成功しても apply には失敗する場合もあります。 簡単な例としては更新する権限がない場合です。 apply に失敗すると一部のリソースだけ更新されるということは起こりえます。 そうなってもロールバックとかは出来ない(terraform に rollback の機能はない)ので注意してください。

terraform destroy

https://www.terraform.io/docs/commands/destroy.html

Terraform で作成したインフラを削除します。

$ terraform destroy

恐らく実際の運用でこれを使うことはあまりないと思います。 削除したいなら設定ファイルからそのリソースを消して apply するでしょう。 Terraform の勉強がてら遊びで作ったものを丸っと消すとか主にそういう用途で使われる気がします。

terraform refresh

https://www.terraform.io/docs/commands/refresh.html

State を実際のインフラの状態に合わせて更新します。 インフラは更新されません。 手動でインフラを変更した場合に、その変更を State に反映させるのに使えます。

$ terraform refresh

terraform import

https://www.terraform.io/docs/commands/import.html https://www.terraform.io/docs/import/index.html

Terraform で管理されていないリソースのデータを State にインポートします。 State はリソースパスとインフラのID のマッピングを管理すると言いましたが、 import コマンドの引数ではこの2つを渡すことでマッピングできるようにします。

# リソースパス ID
$ terraform import aws_instance.example i-abcd1234

ちなみに、一部のリソースは import をサポートしてません。 サポートしているかどうかは Provider の実装に依存します。

terraform state

https://www.terraform.io/docs/commands/state/index.html

State を操作するためのコマンドです。様々なサブコマンドがあります。

Terraform の CI/CD

terraform apply は原則 CD によって実行されるべきだし、 CI によって terraform fmt -checkterraform plan は実行されるべきだと思っています。

逆に terraform planapply は CI/CD でやってるけど、 terraform stateterraform import はローカルから実行しているというチームも少なくはないのかなという気がしています。

課題となりうるのは

  • Credential の管理
  • IP制限

かなと思います。

Credential の管理

Terraform で使うクレデンシャルは強力な権限を持ちがちなので扱いに注意しないといけません。 権限を絞るのが理想ですが、結構難しかったりします。

例えば PR の CI では terraform apply を実行しないのであれば、 PR の CI 用に Read Only なクレデンシャルを用意するとかもありかもしれません。

例えば AWS のインフラ管理を Terraform + CircleCI で行う場合、 AWS のクレデンシャルを CircleCI で参照できるようにする必要があります。 CircleCI では SSH でコンテナにログインできるため、クレデンシャルを盗もうと盗めますし、 CircleCI に限らず悪意のあるコードを CI で実行して外部にクレデンシャルを送ることも出来ます。

IP 制限

CI で Terraform を使う場合、 CI の実行環境からインフラの API にアクセスできる必要があります。 IP 制限をかけている場合、 CI の実行環境からはアクセスできるようにするなどの工夫が必要です。 CI の実行環境の IP range が定まってない場合、話は更に難しくなります。

発展: 設定ファイル中で使える関数

設定ファイル内ではビルドイン関数が使えます。 公式ドキュメントを参照してください。

https://www.terraform.io/docs/configuration/functions.html

余談: awesome-terraform

https://github.com/shuaibiyy/awesome-terraform

Terraform 関連の awesome なツールのリンク集です。

· 6 min read
Shunsuke Suzuki

Terraform の State Locking という機能の概要について説明します。 ただし、自分もちゃんと理解しているわけではないので、推測も混じります。 基本的には公式ドキュメントに書いてある内容なのでそちらをご参照ください。

State Locking とは

terraform plan などのコマンドは State を変更する場合があります。 その処理は atomic ではないため、同時に複数のコマンドが State を書き換えようとすると不整合が生じる可能性があります。


例えば S3 backend の state を state rm で更新する場合を考えます。 これはコマンド内部で

  1. 現在の State を取得する (READ)
  2. 修正した State を S3 に push する (WRITE)

という処理を行っているはずであり、複数のコマンドを実行した場合、READ と WRITE の間に他のコマンドによって WRITE されると、その WRITE による変更が消えてしまいます。


そこで State Locking を使うと各コマンドで State を変更する前に lock を取り、WRITE 後に lock を解除します。

コマンドラインオプション

plan, apply, refresh, state rm, state mv, state push には次のようなオプションがあります。

-lock=true          Lock the state file when locking is supported.
-lock-timeout=0s Duration to retry a state lock.

-lock はデフォルトで true なので State Locking のことを知らなくても実は State Locking 使ってたということもありえますが、 Backend type によっては State Locking のための設定をしていないと State Locking が無効になっている可能性があります。

例えば S3 backend で State Locking をするには DynamoDB が必要であり、 DynamoDB の設定 dynamodb_table が設定されていないと State Locking は無効になります。

また、-lock=false で無効化できますが、公式的に非推奨になります。

-lock-timeout は lock の取得に失敗した場合に何秒後にリトライするかの設定になります。

force-unlock

lock の解放に失敗した場合のために、 force-unlock というコマンドがあります。 何らかのトラブルで lock が解放されない場合に使います。

$ terraform force-unlock LOCK_ID

例えば plan を実行中にキャンセルすると lock が解放されないことがあるようです。

lock が解放されていない状態で plan などを実行すると lock の取得に失敗し、次のようなエラーが起こります。

Acquiring state lock. This may take a few moments...
Error: Error locking state: Error acquiring the state lock: ConditionalCheckFailedException: The conditional request failed
status code: 400, request id: xxx
Lock Info:
ID: xxx
Path: terraform.tfstate
Operation: OperationTypePlan
Who: xxx
Version: 0.12.13
Created: 2020-01-09 09:30:37.41120929 +0000 UTC
Info:
Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.

ここで出力される ID を force-unlock の引数として指定します。

$ terraform force-unlock xxx
Do you really want to force-unlock?
Terraform will remove the lock on the remote state.
This will allow local Terraform commands to modify this state, even though it
may be still be in use. Only 'yes' will be accepted to confirm.
Enter a value: yes
Terraform state has been successfully unlocked!
The state has been unlocked, and Terraform commands should now be able to
obtain a new lock on the remote state.

S3 backend

State Locking をサポートしているかは Backend type によりますが、 S3 の場合、 DynamoDB を使えばできます。

https://www.terraform.io/docs/backends/types/s3.html#configuration-variables

backend の設定で dynamodb_table を設定する必要があります。

https://www.terraform.io/docs/backends/types/s3.html#dynamodb_table

dynamodb_table - (Optional) The name of a DynamoDB table to use for state locking and consistency. The table must have a primary key named LockID. If not present, locking will be disabled.

data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "terraform-state-prod"
key = "network/terraform.tfstate"
region = "us-east-1"

# state locking の設定
dynamodb_table = "???"
}
}

IAMの権限としては https://www.terraform.io/docs/backends/types/s3.html#dynamodb-table-permissions が必要です。

DynamoDB のテーブルには LockID という Primary Key が必要です。 型は 文字列 です。

そして State Locking を有効にした状態で plan などを実行すると DynamoDB のテーブルにレコードが State ごとに作られるようです。

State Locking をすれば安全、というわけではない

State Locking 自体は安全性に寄与する仕組みではありますが、 State Locking さえすれば安全かというとそうではないと思います。

複数人が同時に plan や apply などを実行する環境では、別のロック機構も必要だと思います。

詳細はまた別途書こうと思いますが、CI/CD で plan, apply などを実行する場合、 apply 実行中はそれが終わるまで他の plan や apply の実行を wait するような仕組みがないと危険です。