Terraform v1.4でのTF_PLUGIN_CACHE_DIRと.terraform.lock.hclの挙動変更

# はじめに

Terraform v1.4がリリースされました。

https://www.hashicorp.com/blog/terraform-1-4-improves-the-cli-experience-for-terraform-cloud

上記のアナウンスでは特に言及されてないのですが、v1.4のUpgrade Guideに以下のような記載があり、TF_PLUGIN_CACHE_DIRと.terraform.lock.hclの組み合わせの挙動が若干変更になっています。

https://developer.hashicorp.com/terraform/language/v1.4.x/upgrade-guides

> Provider caching during terraform init
This change affects those who rely upon global provider caching and not the dependency lock file.
terraform init now ignores entries in the optional global provider cache directory unless they match a checksum already tracked in the current configuration's dependency lock file.
Before this change Terraform could not determine the full set of checksums to include in the lockfile when installing a new provider for the first time. Now it can. Once the lock file has been updated to include a checksum covering the item in the global cache, Terraform will then use the cache entry for subsequent installation of the same provider package.
For more details and how to keep using the prior undesirable behavior, please see the documentation.

書いてあるとおりではあるですが、前提知識がないとこれが具体的に何を意味しているのか、行間をだいぶ読まないと分かりづらいかなと思ったので、補足の解説をします。

# サマリ

忙しい人のために先に結論

- Terraform v1.4からTF_PLUGIN_CACHE_DIRでキャッシュされたプロバイダのチェックサムが.terraform.lock.hclでロックされたチェックサムのいずれともマッチしない場合、オリジン(≒Terraform Registry)からプロバイダをダウンロードするようになりました。
- TF_PLUGIN_CACHE_DIRでプロバイダをキャッシュしているつもりでも、たとえば手元はmacOSでCIはLinuxなどマルチプラットフォーム混在の環境では、適切に.terraform.lock.hclを管理しないと無駄にTerraformプロバイダの再ダウンロードが発生するパターンが増えました。
- 結果として、プロバイダのキャッシュを効かせるには、実質的に.terraform.lock.hclの管理が必須になりました。なんらかの理由で.terraform.lock.hclを.gitignoreしているような環境では、初回ダウンロードでTF_PLUGIN_CACHE_DIRは無視されるので、なんらか対応が必要です。
- 暫定回避策として環境変数TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILEでv1.3以前の挙動を戻せますが、基本的には非推奨で、そのうち削除されるかもしれません。
- 同じプロバイダを複数のディレクトリで利用しており、かつマルチプラットフォーム混在の環境で、冗長なダウンロードを避けつつ.terraform.lock.hclを効率よく更新するには、引き続きローカルファイルシステムミラーを使う方法が有効です。

---
(2023/07/20 追記)
tfupdateがv0.7から.terraform.lock.hclの更新に対応したので、複数の.terraform.lock.hclをもっとも効率よく更新する方法法は、tfupdate lockコマンドを使う方法となりました。詳細は以下参照。

https://engineer.crowdworks.jp/entry/2023/07/18/175011

---

# 前提知識とv1.3までの挙動

v1.4での変更点を理解する上で、先にいくつか前提知識とv1.3までの挙動をおさらいしておきます。

## TF_PLUGIN_CACHE_DIR とは

Terraformは初回のterraform initのタイミングで、.terraform/配下にプロバイダをダウンロードし、次回以降はこのローカルのキャッシュを利用します。しかしながら実運用では、Terraformの設定はいくつかのディレクトリに分けて運用することが一般的で、同じプロバイダのバージョンを複数のディレクトリで利用するということはよくあります。単純にディレクトリごとにダウンロードすると、awsなどの巨大なプロバイダは非圧縮の状態で数百MBあるので、ディスク容量も無駄ですし、都度冗長なダウンロードも無駄です。

この対策として、TerraformCLIの設定ファイル(.terraformrc or terraform.rc)のplugin_cache_dir、もしくは環境変数TF_PLUGIN_CACHE_DIRでグローバルなキャッシュディレクトリが設定できます。それぞれローカルのキャッシュは、グローバルなキャッシュへのシンボリックリンクとなります。

https://developer.hashicorp.com/terraform/cli/config/config-file#provider-plugin-cache

Terraformを実運用で使うおうとすると、早かれ遅かれみんな必要性に気づく設定でしょう。

## .terraform.lock.hcl とは

.terraform.lock.hcl とは、Terraform v0.14から導入されたプロバイダのチェックサムを記録した依存のロックファイルです。

https://developer.hashicorp.com/terraform/language/files/dependency-lock

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

provider "registry.terraform.io/hashicorp/null" {
  version = "3.2.1"
  hashes = [
    "h1:tSj1mL6OQ8ILGqR2mDu7OYYYWf+hoir0pf9KAQ8IzO8=",
    "zh:58ed64389620cc7b82f01332e27723856422820cfd302e304b5f6c3436fb9840",
    "zh:62a5cc82c3b2ddef7ef3a6f2fedb7b9b3deff4ab7b414938b08e51d6e8be87cb",
    "zh:63cff4de03af983175a7e37e52d4bd89d990be256b16b5c7f919aff5ad485aa5",
    "zh:74cb22c6700e48486b7cabefa10b33b801dfcab56f1a6ac9b6624531f3d36ea3",
    "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
    "zh:79e553aff77f1cfa9012a2218b8238dd672ea5e1b2924775ac9ac24d2a75c238",
    "zh:a1e06ddda0b5ac48f7e7c7d59e1ab5a4073bbcf876c73c0299e4610ed53859dc",
    "zh:c37a97090f1a82222925d45d84483b2aa702ef7ab66532af6cbcfb567818b970",
    "zh:e4453fbebf90c53ca3323a92e7ca0f9961427d2f0ce0d2b65523cc04d5d999c2",
    "zh:e80a746921946d8b6761e77305b752ad188da60688cfd2059322875d363be5f5",
    "zh:fbdb892d9822ed0e4cb60f2fedbdbb556e4da0d88d3b942ae963ed6ff091e48f",
    "zh:fca01a623d90d0cad0843102f9b8b9fe0d3ff8244593bd817f126582b52dd694",
  ]
}
```

各モジュールが要求する必要なプロバイダのバージョン制約の依存を解決した結果、選択されたバージョンと、プロバイダのチェックサムが記録されています。プロバイダのバイナリはOSやCPUアーキテクチャごとのプラットフォームに依存するので、チェックサムもプラットフォームごとに異なります。また、zip配布されている状態のチェックサムzhと、zipの中身であるプロバイダのバイナリそのもののチェックサムh1の2種類のチェックサムが記録されています。zhはオリジン(≒Terraform Registry)からすべてのプラットフォーム分取得できますが、h1はローカルに展開されたタイミングで記録されるので、macOS/Linux混在などマルチプラットフォーム環境で利用する場合には取り扱いに注意が必要です。

特に初回のterraform initのタイミングでTF_PLUGIN_CACHE_DIRでグローバルキャッシュがある場合、優先的にキャッシュが参照されオリジンと通信しないのでzhが記録されず、そのプラットフォームのh1だけが記録されるため、そのロックを他のプラットフォームで読み込むと、チェックサムミスマッチエラーが出るという問題がありました。zhが記録されてる状態で新しいh1が追記されるのはエラーになりませんが、zhが記録されていない状態で、知らないh1が見つかるとチェックサムミスマッチエラーになる。

なんでこんな奇妙な仕様になっているのかは歴史的な経緯があるのですが、興味ある人はv0.14当時に.terraform.lock.hclの解説を以下にまとめてるので、以下を参照して下さい。

https://speakerdeck.com/minamijoyo/how-to-update-terraform-dot-lock-dot-hcl-efficiently

v0.14当時からの特筆すべき変更点としては、パズルの最後のピースと言ってた terraform init -lockfile=readonly がv0.15に入ったことぐらいで、v1.3までは大きな変更はありませんでした。

https://github.com/hashicorp/terraform/pull/27630

Terraformの公式では.terraform.lock.hclをGitにコミットすることを推奨していますが、よくある汎用プログラミング言語の依存ライブラリのロックと思って扱うとわりと不思議な動作をして、取り扱いが煩雑なので.gitignoreしたくなる人の気持ちもわからんではないです。

# Terraform v1.4での変更点

.terraform.lock.hclの更新がグローバルプロバイダキャッシュ(TF_PLUGIN_CACHE_DIR)の状態に依存して決定的ではないため、多くの混乱を生んでいるのではないかということで、v1.4から、初回のterraform initではTF_PLUGIN_CACHE_DIRを無視するようになりました。

https://github.com/hashicorp/terraform/pull/32129

ここで言う初回のとは、.terraform.lock.hclも存在しないまっさらな状態のことで、terraform initでロックファイルが生成されたあとは、今までどおりTF_PLUGIN_CACHE_DIRは効きます。また.terraform.lock.hclが存在し、TF_PLUGIN_CACHE_DIRにチェックサムが一致するキャッシュがある場合はそれを読み込んでくれるので、あらかじめ terraform providers lock などで.terraform.lock.hclを生成しているケースでは、terraform initでTF_PLUGIN_CACHE_DIRは無視されずにキャッシュは効きます。なので既に適切に.terraform.lock.hclを管理できている人には直接的な影響はありません。

この変更自体はまぁ論理的に考えるとそうなるよねという妥当な帰結だと思うのですが、結果としてTF_PLUGIN_CACHE_DIRでキャッシュを効かせるには実質的に.terraform.lock.hclの管理が必須になるということで、αリリースの段階から.gitignoreしていたユーザから多くの懸念が寄せられました。

https://github.com/hashicorp/terraform/issues/32205

最終的にTerraform CLI設定ファイルで `plugin_cache_may_break_dependency_lock_file = true` という強そうな名前のフラグを設定することで、v1.3以前の挙動に戻す救済措置が入りました。

https://github.com/hashicorp/terraform/pull/32494

また、この設定は環境変数 `TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE=true` でも有効化できます。厳密に言うと右辺の値は空文字と0以外の文字列であれば、有効と判定されます。

https://developer.hashicorp.com/terraform/cli/config/config-file#allowing-the-provider-plugin-cache-to-break-the-dependency-lock-file

ただこのフラグは公式ドキュメントに、自分が何をしているか理解した上で有効化すること、そのうち将来的に消すよ、このフラグが必要な場合はissueを立ててユースケースを共有してね、という過剰なほどの注意書きがあり基本的には非推奨です。

`BREAK` という強い単語が使われていますが、これの意味するところは、zhのチェックサムが記録されておらず、一部のプラットフォームのチェックサムが記録されていない不完全な状態になる可能性があることを指していると理解しています。つまりv1.4以前の「壊れた挙動」に依存している場合は、リスクを理解した上でopt-inして元の挙動に戻せますという意味で、物理的にロックファイルがファイルとして破損するという意味ではないです。

ところでTF_PLUGIN_CACHE_DIRに複数のプロセスが同時に書き込むのは現状適切に排他制御されておらず、キャッシュされているプロバイダのバイナリのファイルが破損してチェックサムミスマッチエラーとなるという別の既存の問題はあり、こちらはv1.4現在も未解決のまま残っています。別件ですが、混同しないように一応ここで言及しておきます。

https://github.com/hashicorp/terraform/issues/31964

# 対策

TF_PLUGIN_CACHE_DIRは実運用では必要であることを鑑みると、.terraform.lock.hclを.gitignoreしている場合は、初回のterraform initがロックとマッチすることはありえないのでキャッシュとして機能せず、v1.4以降.terraform.lock.hclの管理は実質的に必須となったと言えるでしょう。

.terraform.lock.hclを既に管理しており、かつマルチプラットフォーム混在の環境でない場合は特に気にする必要はありません。
また初回のterraform initで冗長なダウンロードが発生しても、2回め以降はローカルのキャッシュが効くので、ディレクトリ数が少なかったり、頻繁にプロバイダをアップデートしない場合は、それほど実害はないかもしれません。

一方、マルチプラットフォーム混在の環境で、特に手元とCI環境が異なる場合は、うっかり不完全な.terraform.lock.hclをコミットしてしまった場合、これまでチェックサムミスマッチエラーで落ちてたものが、v1.4から暗黙にダウンロードが発生してエラーにならなくなったので、気づかないうちに大量の冗長なダウンロードが発生するようになる可能性はあります。v1.4にアップデート後、CIの実行時間が大幅に遅くなってないか確認しておくとよいでしょう。

同じプロバイダを複数のディレクトリで利用しており、かつマルチプラットフォーム混在の環境で、冗長なダウンロードを避けつつ.terraform.lock.hclを効率よく更新する方法を追求すると、前述の [「.terraform.lock.hcl完全に理解した」](https://speakerdeck.com/minamijoyo/how-to-update-terraform-dot-lock-dot-hcl-efficiently) の資料で説明している、terraform providers mirrorとterraform providers lockを組み合わせて使う方法に辿り着くのですが、この方法はv1.4でも有効です。

以下のスクリプトは、プロバイダのバージョンアップ時にロックファイルを一括で更新するスクリプトです。
リポジトリルートで必要なプロバイダを列挙した.tfをあらかじめ配置している想定で、リポジトリルートでローカルファイルシステムミラーを作成しつつ、サブディレクトリでロックファイルを更新します。この例ではターゲットとして-platformにlinux_amd64, darwin_amd64, darwin_arm64を指定してたり、modules/配下は無視したりしてますが、環境に合わせて適宜読み替えてください。

```main.tf
terraform {
  required_version = "1.4.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "4.58.0"
    }
  }
}
```

```tflock_generate.sh
#!/bin/bash
set -eo pipefail

# create a plugin cache dir
export TF_PLUGIN_CACHE_DIR="/tmp/terraform.d/plugin-cache"
mkdir -p "${TF_PLUGIN_CACHE_DIR}"

# remove an old lock file before providers mirror command
rm -f .terraform.lock.hcl

# create a local filesystem mirror to avoid duplicate downloads
FS_MIRROR="/tmp/terraform.d/plugins"
terraform providers mirror -platform=linux_amd64 -platform=darwin_amd64 -platform=darwin_arm64 "${FS_MIRROR}"

# update the lock file
ALL_DIRS=$(find . -type f -name '*.tf' | xargs -I {} dirname {} | sort | uniq | grep -v 'modules/')
for dir in ${ALL_DIRS}
do
  pushd "$dir"
  # always create a new lock to avoid duplicate downloads by terraoform init -upgrade
  rm -f .terraform.lock.hcl
  # get modules to detect provider dependencies inside module
  terraform init -input=false -no-color -backend=false -plugin-dir="${FS_MIRROR}"
  # remove a temporary lock file to avoid a checksum mismatch error
  rm -f .terraform.lock.hcl
  # generate h1 hashes for all platforms you need
  # recording zh hashes requires to download from origin, so we intentionally ignore them.
  terraform providers lock -fs-mirror="${FS_MIRROR}" -platform=linux_amd64 -platform=darwin_amd64 -platform=darwin_arm64
  # clean up
  rm -rf .terraform
  popd
done
```

(2023/06/02 追記)
Terraform v1.4.1の変更で、terraform providers mirrorコマンドが .terraform.lock.hcl をチェックするようになりました。上記のスクリプトで、terraform providers mirrorを実行する直前に `rm -f .terraform.lock.hcl` も必要になったので修正しました。
https://github.com/hashicorp/terraform/pull/32742

# おわりに

Terraform v1.4でのTF_PLUGIN_CACHE_DIRと.terraform.lock.hclの挙動変更について解説しました。
諸悪の根源はTerraform Regsitryがh1ハッシュを返してくれないことだと思うので、そろそろRegistryプロトコルの仕様から見直して欲しいところです。というissueをむかし立てたので、.terraform.lock.hcl無駄に複雑すぎ〜と思ってるTerraform職人の皆さんは、以下のissueに +1 しておいて下さい。

https://github.com/hashicorp/terraform/issues/27264

---

(2023/07/20 追記)
tfupdateがv0.7から.terraform.lock.hclの更新に対応したので、複数の.terraform.lock.hclをもっとも効率よく更新する方法法は、tfupdate lockコマンドを使う方法となりました。詳細は以下参照。

https://engineer.crowdworks.jp/entry/2023/07/18/175011