Terraform importで既存リソースをコード管理する

Terraform importで管理外のリソースを取り込む流れを表した図。ばらばらのタイルが形の違う3本の鍵を通り、Terraform管理下の枠に揃って収まる。画像内の文字は Terraform Import、Bring Existing Resources Into Code、Unmanaged、Import ID、No changes 環境構築

Terraform import を使うと、手やコンソールで作った既存のリソースを、作り直さずに Terraform の管理下へ移せます。

私が詰まったのは手順そのものではなく、import ID の形式でした。取り込む対象ごとに区切り文字が違い、同じ種類のリソースでも 2 通りの書き方が通ってしまいます。

この記事では、コマンドとブロックのどちらを使うかの判断から、ID の調べ方、取り込んだ後に残る差分の扱いまでを順に書きます。

本記事のまとめ

  • 取り込みは import ブロックで書く。terraform import コマンドは状態ファイル(Terraform が管理しているリソースの一覧)を書き換えるだけで、設定ファイルを作らない。
  • import ID の形式は推測できない。プロバイダのドキュメントの Import 節を読むしかない。
  • id には変数やほかのリソースの属性を書ける。ただし terraform plan の時点で値が決まるものに限られる。
  • 取り込んだ後は terraform plan が差分なしになるかを見る。消えない差分は lifecycle の ignore_changes で切り離す。

Terraform をまだ入れていない場合は、Macユーザー向け!Terraformをインストールする手順を解説を先に読んでください。

手で作ったリソースを後から取り込む場面

Terraform だけで最初から最後まで作れることは、実際にはあまりありません。私の環境では、次の 3 つの理由で手作業が先に発生しました。

  • 最初の 1 個は手で作るしかない。状態ファイルを置くストレージは、Terraform を動かす前に存在している必要がある。
  • 管理画面で先に作ってしまった。外部サービスとの接続のように、画面から作ったほうが早い設定もある。
  • Terraform から作れなかった。有効化の操作がコードからは通らず、画面で有効にした機能があった。

どれも後から「このリソースだけコードに載っていない」という状態になります。放置すると、設定変更のたびに画面を開くことになり、何が正しい設定なのかがコードから読めなくなります。

ここで使うのが import です。リソースを作り直さずに、Terraform が管理している一覧へ後から登録します。

AWSのコンソールでIAMユーザーterraform-dev-userの概要を開いた画面。ARNのアカウントIDは伏せてある
Terraformの管理下に無いIAMユーザー。アカウントIDは伏せた

importコマンドとimportブロックの違い

import には 2 つのやり方があります。結論から言うと、いま書くなら import ブロックです。

terraform import コマンドimport ブロック
書く場所ターミナル.tf ファイル
設定ファイルの生成しない-generate-config-out で生成できる
実行前の確認できないterraform plan に出る
記録の残り方残らないコードに残る
使えるバージョン古くから使えるTerraform 1.5 以降

公式ドキュメントも、この 2 つを別の用途として書き分けています。コマンドのページには「CLI での import は設定を生成しない」「設定もあわせて生成したいなら import ブロックを使うこと」と書かれています。

コマンドが廃止されたわけではありません。いまも動きますし、1 件だけ急いで取り込みたいときには速いです。ただ、何を取り込んだのかがコードに残らないため、後から見た人には分かりません。

設定ファイルを自動で書き起こす -generate-config-out という選択肢もあります。公式ドキュメントによると Terraform 1.5 で試験的な機能として入ったもので、属性が複雑なリソースでは正しい設定を組み立てられないことがあると明記されています。私はこの機能を使っていないので、ここでは紹介にとどめます。

原文は次のように書かれています。

The terraform import CLI command can only import resources into the state.
Importing via the CLI does not generate configuration.

If you want to generate the accompanying configuration for imported
resources, use the import block instead.

訳すとこうです。「terraform import コマンドは、リソースを状態ファイルに取り込むことしかできない。コマンド経由の取り込みでは設定は生成されない」。続けて「取り込んだリソースの設定もあわせて生成したいなら、代わりに import ブロックを使うこと」とあります。

importブロックで取り込む手順

書くのは import ブロックと resource ブロックの 2 つで、順番は plan で確認してから apply です。ここでは AWS の IAM ユーザーを例にします。

import ブロックには、取り込み先のアドレスを to に、取り込む対象を指す ID を id に書きます。あわせて、同じアドレスの resource ブロックも用意します。

import {
  to = aws_iam_user.terraform_dev_user
  id = "terraform-dev-user"
}

resource "aws_iam_user" "terraform_dev_user" {
  name = "terraform-dev-user"
}

この状態で terraform plan を実行すると、取り込む予定のリソースが一覧に出ます。作成でも変更でもなく「取り込み」として表示されるのが、ブロックで書いたときの利点です。

Terraform will perform the following actions:

  # aws_iam_user.terraform_dev_user will be imported
    resource "aws_iam_user" "terraform_dev_user" {
        id   = "terraform-dev-user"
        name = "terraform-dev-user"
        path = "/"
    }

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

1 to import になっていて、to add と to destroy が 0 であることを確認してから terraform apply を実行します。ここに to destroy が出ていたら、アドレスか ID が間違っています。

取り込みが終わると、状態ファイルに対象が登録されます。参考までに、コマンドで取り込んだときの出力も載せておきます。

エディタの下部ターミナルでterraform importコマンドを実行し、Import successful!と表示された画面
コマンドで取り込んだときの出力。設定ファイルは自動では増えない

import IDの形式はリソースごとに違う

ここが一番つまずきます。id に何を書くかは、リソースの種類ごとにまったく違います。

私が実際に書いた import ブロックから、形式だけを抜き出して並べます。値は伏せてあります。

# IAMユーザー(AWS): 区切りなし。名前そのまま
terraform-dev-user

# ストレージのバケット: 区切りなし
(バケット名)

# APIの有効化: スラッシュ
(プロジェクトID)/datalineage.googleapis.com

# プロジェクトへの権限付与: 半角スペース
(プロジェクトID) roles/iap.admin serviceAccount:(アカウント)

# 同じ権限付与の別の書き方: スラッシュ
(プロジェクトID)/roles/resourcemanager.projectIamAdmin/serviceAccount:(アカウント)

# バケットへの権限付与: 先頭に b/ + 半角スペース
b/(バケット名) roles/storage.objectAdmin user:(アカウント)

# グループのメンバー: スラッシュ。メールアドレスでは通らない
groups/(数値ID)/members/(数値ID)

# APIキー: スラッシュ。末尾はUUID
projects/(プロジェクトID)/locations/global/keys/(UUID)

# ロールの継承(Snowflake): 縦棒。二重引用符ごと書く
"SYSADMIN"|ROLE|"(ロール名)"

# 権限の付与(Snowflake): 縦棒。真偽値が混ざる
"(ロール名)"|false|false|CREATE INTEGRATION|OnAccount

注目してほしいのは「プロジェクトへの権限付与」の 2 つです。同じ種類のリソースなのに、片方は半角スペース区切り、もう片方はスラッシュ区切りです。両方とも取り込みに成功しています。

Snowflake の 2 つはさらに読みにくく、二重引用符や false まで ID の一部です。これはプロバイダが内部で使っている形式がそのまま出ているもので、画面を見ても想像はつきません。

もう 1 つ気をつけたいのが、画面に出ている名前が ID とは限らないことです。APIキーの例では、画面の表示名とは別に UUID が割り振られていて、id にも resource ブロックの名前にも UUID を書く必要がありました。グループのメンバーも、メールアドレスではなく内部の数値 ID でしか指定できません。

結論として、ID の形式は推測しないでください。プロバイダのドキュメントには、リソースごとに Import という節があり、使える形式がそこに書かれています。先にそれを読むのが一番早いです。

たとえば監査ログの設定を取り込む場合、プロバイダのドキュメントには次のように書かれています。

An audit config can be imported into a google_project_iam_audit_config
resource using the resource's project_id and the service, e.g:

  "{{project_id}} foo.googleapis.com"

訳すと「監査ログの設定は、project_id と service を使って google_project_iam_audit_config に取り込める」です。並べる順番も、あいだが半角スペースであることも、ここで初めて分かります。プロジェクト ID とサービス名をどうつなぐかは、画面を見ても書いてありません。

idには変数やリソース参照を書ける

id は文字列をそのまま書く場所だと思われがちですが、変数やほかのリソースの属性を埋め込めます。

環境ごとにプロジェクトが分かれている場合、ID をべた書きすると環境の数だけ書き分けることになります。変数を使えば 1 つの書き方で済みます。

# 変数を使う
import {
  to = google_project_service.datalineage_api
  id = "${var.project_id}/datalineage.googleapis.com"
}

# 同じ構成の中のリソースの属性を参照する
import {
  to = google_project_iam_member.github_actions_datastore_owner
  id = "${var.project_id} roles/datastore.owner serviceAccount:${google_service_account.github_actions.email}"
}

下の例では、同じ構成の中で定義しているサービスアカウントのメールアドレスを参照しています。アカウント名を 2 箇所へ書かずに済むので、名前を変えたときの直し忘れが起きません。

ただし制限があります。公式のリファレンスには「文字列か、文字列に評価される式を指定する。ID は plan の時点で確定している必要がある」と書かれています。これから作るリソースが決める値は書けません。取り込む対象はすでに存在しているので、実務上は困らない制限です。

この書き方で terraform plan を実行すると、解決された後の ID が出力に出ます。2 つ目の例を実行した結果です。値は伏せてあります。

google_project_iam_member.github_actions_datastore_owner: Preparing import... [id=(プロジェクトID) roles/datastore.owner serviceAccount:(アカウント)]
google_project_iam_member.github_actions_datastore_owner: Refreshing state... [id=(プロジェクトID)/roles/datastore.owner/serviceAccount:(アカウント)]

Terraform will perform the following actions:

  # google_project_iam_member.github_actions_datastore_owner will be imported
    resource "google_project_iam_member" "github_actions_datastore_owner" {
        etag    = "(省略)"
        id      = "(プロジェクトID)/roles/datastore.owner/serviceAccount:(アカウント)"
        member  = "serviceAccount:(アカウント)"
        project = "(プロジェクトID)"
        role    = "roles/datastore.owner"
    }

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

Preparing import... の行に、解決後の ID がそのまま出ます。書いた式が狙った文字列になっているかは、apply の前にここで確かめられます。

次の Refreshing state... の行では、半角スペース区切りで書いた ID がスラッシュ区切りに変わっています。状態ファイルに記録されるのは後者です。同じリソースで 2 通りの書き方が通るのは、これが理由だと私は見ています。

取り込んだ後に確認すること

取り込みが終わったら、もう一度 terraform plan を実行して差分が出ないことを確かめます。ここで差分が残るなら、コードと実物が食い違っています。

取り込みの後にterraform planを実行し、No changesと表示された画面。バケット名が並ぶ行は伏せてある
取り込みが済んだ状態のplan。差分が出なければ、コードと実物が一致している

多くは resource ブロックの書き漏れで、実物に合わせて書き足せば消えます。一方で、書き足しても消えない差分が 2 種類ありました。

1 つ目は、プロバイダが内部の値を表示してくるのに、こちらからは書けない場合です。Snowflake のユーザーを取り込んだとき、plan に既定値を表す内部的な値が出ました。出たのは次の 3 つです。

disable_mfa        = "default"
mins_to_bypass_mfa = -1
mins_to_unlock     = -1

この 3 行を .tf に書き写せば差分は消えるはずでした。ところがプロバイダの検証でエラーになり、apply できませんでした。plan に出てくる値が、そのまま設定ファイルに書ける値とは限らないということです。

2 つ目は、別のツールが作った設定を管理対象にしたくない場合です。画面から生成したサービスを取り込んだとき、生成時に付いた値が差分として出続けました。中身に認証情報が含まれるため、コードに書き写すわけにもいきません。

どちらも lifecycle の ignore_changes で、その項目だけを比較の対象から外しました。

resource "snowflake_user" "dev_tf_user" {
  name = "SVC_DEV_TERRAFORM"

  lifecycle {
    ignore_changes = [
      disable_mfa,
      mins_to_bypass_mfa,
      mins_to_unlock,
    ]
  }
}

ignore_changes は差分を消すのではなく、見ないことにする指定です。原因が分からないまま付けると、本当の設定ミスも見えなくなります。私は、なぜ無視してよいのかをコメントに 1 行書いてから付けるようにしています。

最後に、役目を終えた import ブロックをどうするかです。取り込みは 1 回で終わるので、apply の後は消してかまいません。ただ私は、消す場合と残す場合を分けています。新しく作るリソースと、もともと手で作ったリソースが同じファイルに並ぶと、どちらがどちらか分からなくなるためです。残しておけば、そのリソースが手作業で先に存在していた記録になります。

まとめ

既存のリソースを取り込むときは import ブロックで書き、terraform plan で取り込み対象を確認してから apply します。

作業のほとんどは ID 探しです。形式はリソースごとに違い、同じ種類でも複数の書き方が通ります。画面に出ている名前が ID とは限らないので、プロバイダのドキュメントの Import 節を先に読んでください。

取り込んだ後は差分が出ないことを確かめます。消えない差分は原因を特定したうえで ignore_changes に回し、理由をコメントに残しておくと後から読めます。

なお、Terraform のバージョン要件、-generate-config-out の扱い、プロバイダのドキュメントの書き方は変わります。実際に取り込むときは、最新を公式ドキュメントで確認してください。

Terraform で権限まわりを扱う例は、「Cloud RunのIAPはロードバランサー不要|Terraformで直接設定する」でも書いています。