terraformは結構癖があるので慣れるまでは使いにくいと思っています。
最近割と慣れてきた気がするので、個人的に使っているterraformの使い方の具体例を紹介します。
あくまで個人的にいいと思っているやり方であって、最適でもベストプラクティスでもありません。
主にAWSを触っているので例はAWSが多いですが、GCPでも基本的に同じようにできるはずです。
バックエンドを作る
.tfstateを置くバックエンドはlocalに置かないことが推奨されています。
これは同時編集によるコンフリクトを防いだり、stateに書き込まれた機密情報を一箇所にまとめられるなどの利点のためです。
バックエンドをs3にしようと思えば、当然そのバケットはterraform使用開始前に作成しておく必要があります。
これを手動で作成してはせっかくのInfrastructure as Codeが不完全になってしまうので避けたいところです。
terraformで完結させるためには
resource "aws_s3_bucket" "hogehoge_backend" {
bucket = "hogehoge-backend"
}
terraform {
required_version = "= 0.12.20"
}
としてlocal backendのまま terraform applyし、
resource "aws_s3_bucket" "hogehoge_backend" {
bucket = "hogehoge-backend"
}
terraform {
required_version = "= 0.12.20"
+ backend "s3" {
+ bucket = "hogehoge-backend"
+ key = "hogehoge/terraform"
+ region = "ap-northeast-1"
+ }
}
を追記して terraform init することでバックエンドを変更できます。
ローカルの.tfstate のファイルが残る気もしますが不要なので消して大丈夫です。
s3バックエンドの場合はバージョニングの有効化などが推奨されてます
上の例は最小構成なので、実際に使うときはちゃんとバケットの設定しましょう。
あと、このバケット設定はモジュール化しておくと再利用できて良いです。既製品もあります。
モジュールをgithub repoに分ける
バックエンドのところでも触れましたが積極的にモジュール化したほうがいいと思います。
既存のモジュールを使ってもいいですが、自分がよく使う構成は自分のリポジトリにまとめておくとより便利に使えます。
module "backend" {
source = "git::ssh://git@github.com/hoge/my-terraform-s3-backend.git"
}
# リポジトリのサブディレクトリも指定できる
# ひとつのリポジトリに複数モジュールをまとめても良さそう
module "backend" {
source = "git::ssh://git@github.com/hoge/my-terraform-modules.git//s3-backend"
}
# ブランチやタグも指定できる
module "backend" {
source = "git::ssh://git@github.com/hoge/my-terraform-modules.git//s3-backend?ref=master"
}
リポジトリ作るまでもない場合はlocal moduleを使う
現在のプロジェクトでしか使わないモジュールをわざわざ別repositoryにする必要はありません。
例えばhogeというアプリケーションのリソースを定義する場合
.
|-- backend.tf
|-- common.tf // 共通リソース(s3/iamなど)
|-- hoge.dev.tf // dev環境
|-- hoge.prod.tf // prod環境
|-- modules
| |-- hoge
| | |-- cat.tf
| | |-- dog.tf
| | |-- cow.tf
| | `-- variables.tf
| `-- fuga
| |-- fuga.tf
| `-- variables.tf
`-- terraform.tf
こんな感じのディレクトリ構成にして、
module "hoge_prod" {
source = "./modules/hoge"
env = "prod"
common_resource_id = common_resource.common.id # common.tfで定義したリソース
}
などとするといい感じになります。
hogeモジュールからfugaモジュールを参照することも可能です。
エイリアスを張る
terraform planって長いので・・・
alias tp='terraform plan'
alias ta='terraform apply'
ちょっと変更するたびにこまめにplanしています。
planに時間がかかる場合は
tp --target aws_s3_bucket.hogehoge // 特定のリソースだけplan
tp --target module.hoge // hogeモジュールだけplan
が便利です。
認証情報を管理する
AWSの場合$AWS_ACCESS_KEY_IDや$AWS_SECRET_ACCESS_KEY 環境変数を .envなどでexportするのがいい気がします。
.tfvars に書くという手もありますが、認証情報は環境変数に書いたほうが楽です。
こうするとaws-cli の設定を兼ねられます。
GCPの場合デフォルトの認証情報を勝手に読みに行ってくれるので何も気にしなくても大体動きます。ありがたいですね。
開発環境
VScodeのいい感じのpluginがあります。
code format、補完、syntax highlightなどをしてくれます。使いましょう。
tfenvを使う
複数のterraformのバージョンを切り替えながら使えるツールです。いろんな言語でよくあるやつです。
terraformはバージョン互換性がなかったりするので使っておいたほうが幸せになれると思います。
.terraform-versionというファイルを作っておくと自動的にバージョンを切り替えてくれます
ちなみにインストール可能なバージョンの列挙が
tfenv list-remote
となっていて他の同系列のツールとちょっと違う気がします。
手動で作ってからimportする
「terraformより手動でやったほうが早いから手動でやる・・・]
ということを言う人がいますが、実際そのとおりだと思います。
最初からterraformでリソース定義してapplyするのは効率が悪いので、 aws-cliやgcloud コマンドやwebコンソールで手動で作成してから terraform importするのがオススメです。
terraform importはterraform管理外のリソースを、terraform内の未applyのリソース定義と紐付けて取り込むコマンドです。
RDSとかECSとかはこっちのほうがかなり早い気がします。
例えば ECSタスク定義の場合は以下のような手順です。
1 (まずwebコンソールでポチポチとタスク定義を作ります)
2 適当にリソース定義を書きます
# ひとまず最小構成でOK
resource "aws_ecs_task_definition" "service" {
family = "service"
container_definitions = jsonencode([{
}])
}
3 importします
importするリソースの指定方法はリソースによって違うのでドキュメントを見ましょう
terraform import aws_ecs_task_definition.service arn:aws:ecs:ap-northeast-1:123456789:task-definition/service:1
# providerを指定してる場合は明示する必要がある。
terraform import --provider aws.us-east-1 aws_ecs_task_definition.service arn:aws:ecs:ap-northeast-1:123456789:task-definition/service:1
4 terraform planして差分を確認し、差分がなくなるように定義を修正していきます。
terraformの差分表示は変更の前後がひと目で分かるので、簡単に直せます。
以下は別の例ですが、gcloud コマンドで作ったcloud functionsをimportした場合。自動でつく labelが、import後には差分表示されています。

terraform側は以下のように修正します
resource "google_cloudfunctions_function" "hoge" {
...略...
+ labels = {
+ deployment-tool = "cli-gcloud"
+ }
}
再度 terraform plan してみると差分が消えています。
これで最初からterraformで作ったのと同じ状態になりました。

ちなみにimportしたresourceを管理外に外したいときは
terraform state rm google_cloudfunctions_function.hoge
で外せます。
リソースを差し替えたいときなどには一度 state rm してからimportする合せ技が有効です。
機能単位にファイルを分ける
terraformでは以下のようにサービス単位でファイルを分けることがよく行われている気がします。
.
|-- s3.tf
|-- iam.tf
|-- vpc.tf
|-- rds.tf
|-- cloud_watch.tf
`-- ecs.tf
しかし、この分け方だと特定の機能の構成を変更するとき複数ファイルにまたがった変更になりますし、どのリソースがどこで使われているのかがひと目でわかりません。
以下のように機能ごとにファイルを分けることを個人的には推奨します。
.
|-- common.tf // vpc,iam,s3など
|-- webserver.tf // ecs,ecr,security groupなど
|-- error_notification.tf // cloud watch, lambdaなど
`-- database.tf // rds,security groupなど
こうするとかなり読みやすくなると思います。(注: 機能の分け方は適当な例です)
もし1ファイルに収まらなくなってきたら、./modules にモジュールとして分割しましょう。
機能ごとの結合部分が明確になり、疎結合性を保ちやすくなります。
逆にサービスごとの定義が分散して見にくいという意見もありますが、 terraformではresource名はproviderに定められた固定の文字列(aws_s3_bucketなど)を使うので、文字列検索で容易に一覧できます。
variableにobjectを使うときはデフォルト値を設定できない
変数にはデフォルト値を設定できますが、object型の変数に対して指定した場合、
variable "ports" {
type = object({
webserver = number
db = number
})
default = {
webserver = 8080
db = 3306
}
}
以下のようにbdの値だけ指定したいと思っても
ports = {
db = 3000
}
attribute "webserver" is required. というエラーが起きます。
object型のデフォルト値はobject全体の値をまとめて指定することしかできないからです。(できるようにしてほしい・・・)
デフォルト値を一部だけ上書きすることが想定される場合、object型ではなく個別の変数にするべきでしょう。
variable "port_webserver" {
type = number
default = 8080
}
variable "port_db" {
type = number
default = 3306
}