🤐

Terraform で archive_file を使った変更検知

2025/01/13に公開

この記事の概要

  • archive プロバイダーarchive_file データソース は zip ファイルを作成する機能を提供します。
  • 作成した zip ファイルのハッシュ値を使用することで、変更検知を行うことができます。つまり、ソースコードが変更された場合にデプロイを行う処理を実現可能です。よくある利用例としては AWS Lambda のデプロイです。
  • 特に 2024年7月にリリースされた archive プロバイダーの v2.5.0excludes パラメーターにディレクトリーやパターンの指定が可能になったことで、とても柔軟に利用できる機能になりました。
  • ちなみに、 archive_file のハッシュの不変性についてはドキュメント内で言及されていないため、実装依存の動作と考えて注意したほうが良いでしょう。

archive_file による変更検知

利用例: Lambda のソースコードの管理

わかりやすい例として、 aws_lambda_function で AWS Lambda 関数を作成する例 を引用します:

data "archive_file" "lambda" {
  type        = "zip"
  source_file = "lambda.js"
  output_path = "lambda_function_payload.zip"
}

resource "aws_lambda_function" "test_lambda" {
  # If the file is not in the current working directory you will need to include a
  # path.module in the filename.
  filename      = "lambda_function_payload.zip"
  function_name = "lambda_function_name"
  role          = aws_iam_role.iam_for_lambda.arn
  handler       = "index.test"

  source_code_hash = data.archive_file.lambda.output_base64sha256

  runtime = "nodejs18.x"

  environment {
    variables = {
      foo = "bar"
    }
  }
}

このテンプレートでは以下の操作を行っています:

  • lambda.js を含む zip ファイル lambda_function_payload.zip を作成する。
  • その zip ファイルから Lambda 関数を作成する。
  • source_code_hash で、作成した zip ファイルのハッシュ値 (SHA256) を設定する

特に重要なのが最後の点で、この指定により、ソースコードが変更されるとハッシュ値が変わり、それによって変更と判断した Terraform が Lambda 関数を再デプロイして、最新のソースコードが反映される仕組みが実現されます。
また逆に、ソースコードに変更がない限りはこのハッシュ値が変わらないため、 Terraform はリソースに変更がないと判断してデプロイ処理を行いません。

なぜ archive_file のハッシュをコードの同一性に使用できるか?

非常に雑に説明すると、 archive_files の以下の動作によります:

  • ファイルの登録順序の一貫性
    • ファイルの登録に golang の filepath.Walk を使用しており (コード)、辞書順 (lexical order) にファイルがリストアップされる仕様のため。
  • ファイルの日時の固定
    • 2049年1月1日 0時0分0秒 に固定されます。
  • ファイルのモードの固定
    • ただしこの動作は output_file_mode を指定する必要があります 。後述。

例えば以下のリポジトリーで make terraform-plan で作成した source.zip に対して zipinfo コマンドを使用することで、この動作を確認することができます:

https://github.com/ikedam/zenn_snippets/tree/archive_file_demo

$ zipinfo source.zip
Archive:  source.zip
Zip file size: 7011 bytes, number of entries: 4
-rw-r--r--  2.0 unx      347 bl defN 49-Jan-01 00:00 Dockerfile
-rw-r--r--  2.0 unx       94 bl defN 49-Jan-01 00:00 factorial/__init__.py
-rw-r--r--  2.0 unx    18244 bl defN 49-Jan-01 00:00 poetry.lock
-rw-r--r--  2.0 unx      408 bl defN 49-Jan-01 00:00 pyproject.toml
4 files, 19093 bytes uncompressed, 6509 bytes compressed:  65.9%
$

-rw-r--r-- とファイルモードが固定されていること、 49-Jan-01 00:00 と時刻が固定されていることを確認できます。
これにより、実際のファイルのモードや時刻が変更されても常に同じアーカイブが作成され、ハッシュが同一になることを期待できます。

逆に、一般的な手法で zip ファイルを作成しても上記のような手当が行われていないため、単純に「zip を作成してハッシュ値を取る」では実施タイミングや環境で差分が生まれてしまい変更検知に利用することができません (「付録」で検証)。

output_file_mode によるファイルのモードの固定

output_file_mode により、ファイルのモードを固定することができます。
ファイルのモードはパーミッションとも言われるやつで、「書き込み権限」「読み込み権限」「実行権限」の設定です。

output_file_mode を指定しない場合、以下の事情から archive_file の出力するアーカイブのハッシュの一意性を保証できなくなります:

  • Linux や Mac の場合、実際のファイルのパーミッションが設定される。このため、 OS の設定 (umask) や、どのようにファイルを作成したかなどで異なるハッシュのアーカイブが作られる場合がある。
  • Windows の場合、ファイルのモードがそのまま存在しないので、 Linux や Mac と異なる値が設定される。
    • 多分、 Windows ネイティブで terraform を実行した場合と、 Docker Desktop でバインドマウントして terraform を実行した場合も異なる動作になるので、これもまたややこしい話になる。

上記の問題が archive_file produces different results on different OSs #34 で指摘されて導入されたのが output_file_mode です。

以下のように設定することで、上記の問題を回避することができます:

data "archive_file" "lambda" {
  type        = "zip"
  source_file = "lambda.js"
  output_path = "lambda_function_payload.zip"
  # ファイルのモードを固定してハッシュが変化することを避ける
  output_file_mode = "0644"
}

なお archive_file で作成される zip には、ファイルのみが含まれディレクトリーが含まれないため、ファイル向けのモードだけ指定すればよいです。

注意事項: ハッシュの不変性は仕様として明記されていない

「アーカイブ対象のファイルとその内容が同一であれば、ハッシュ値が変わらない」という旨はどうやら archive プロバイダーの仕様としては明記されていません:

このため、「archive_file のハッシュ値による変更検知」は、あくまで実装に依存した手法であると考えたほうが良いでしょう。
具体的には、以下について注意する必要があります:

  • arvhive プロバイダーのバージョンを変更した場合に、ファイルに変更がなくてもハッシュ値が変わる可能性があります。
    • このため、 ロックファイル を運用するか、プロバイダーのバージョンに範囲指定を使わない(~> X.XX などを使用しない)ようにするとよいでしょう。
  • 実行する環境、特に OS を変更した場合に、ファイルに変更がなくてもハッシュ値が変わる可能性があります。
    • 特に CI/CD で使用する OS 環境が複数ある場合や、開発チームのメンバーが使用する OS が異なる場合に、 archive_file 由来の想定外の動作があった場合は疑ってみるとよいでしょう。

実装例: コンテナーイメージの変更検知

実装の概要

archive_file を使用して、 コンテナーイメージの作成に使用するソースコードの変更検知を行います。
※正確には、変更検知に使えるハッシュ値を作成するところまでを実装する。

アウトラインは以下の通り:

  • ソースコードリポジトリー内のファイルを archive_file でアーカイブしてハッシュ値を計算する。
  • ただし、 .dockerignore に含まれるファイルを除外対象に指定する。
    • .dockerignore がちゃんと書かれていることが前提。
    • .dockerignore では、以下のようなファイルを指定しておくことで、変更がないのにソースコードの変更と判断されてイメージキャッシュが使用されなくなったり、不要なファイルが docker デーモンに送られることで処理が遅くなることを回避できます:
      • .git などの管理用のファイル
      • __pycache__ などのキャッシュファイル
      • その他、アプリケーションの実行には関係ないファイル (テストコード、 README.md など)

この実装を行ったコードが以下になります:

https://github.com/ikedam/zenn_snippets/tree/archive_file_demo

「階乗を計算する」というとてもエキサイティングなライブラリーを作成する Python プロジェクトになっており、そこで作成するコンテナーの変更検知を行います。
※ライブラリーだけコンテナーにしても意味がないので、実際に出来上がるコンテナーは特に使い道はない。

.dockerignore の中身

.dockerignore により、 .gitterraform tests など、アプリケーションの実行に関係ないディレクトリーやファイルを除外します。

また、このプロジェクトにはテストコードやコードチェックの機能も含んでいます。
テストコードやコードチェックを実行すると、以下のように処理に必要ないキャッシュ(グレーアウトされているディレクトリー)が作成されるので、これを除外するように .dockerignore を設定します:

テストやチェック実行後のファイルツリー

設定内容:
https://github.com/ikedam/zenn_snippets/blob/archive_file_demo/.dockerignore

.dockerignore から archive_zip の excludes 指定を作成する

.dockerignore の中身に対して以下の操作を行い、 excludes の指定を作成します:

  • .dockerignore のファイルの中身を改行区切りでリストに変換
  • コメント行、空行を除外
  • Dockerfile または /Dockerfile の行を除外
    • Dockerfile に変更があった場合は、コンテナーイメージのソースに変更があったのと同様に扱いたいため。
  • / から始まる行の場合は、 / を除外して使用
  • / から始まらない行は、先頭に **/ を追加して使用

特に最後の2つの処理については、 「配下の任意のディレクトリー内で適用するパターン」の指定方法が以下のように逆転した関係になっていることに注意が必要です:

  • .dockerignore では、特別な指定を使用しない場合はその動作になり、直下のディレクトリーだけにしたい場合に / を先頭につける。
  • archive_filesexcludes では、その動作をさせるためには ** の指定が必要で、特別な指定をしない場合は直下のディレクトリーだけが対象になる。

以下のような実装になります:

https://github.com/ikedam/zenn_snippets/blob/archive_file_demo/terraform/main.tf#L12-L37

この実装によって作成される値は以下のようになります:

  + check_excludes = [
      + "**/.mypy_cache",
      + "**/.pytest_cache",
      + "**/__pycache__",
      + ".git",
      + "terraform",
      + "tests",
      + ".dockerignore",
      + ".gitignore",
      + "docker-compose.yaml",
      + "Makefile",
      + "README.md",
      + "*.zip",
    ]

適用と実際に作成されるファイル

作成した dockerignore_excludesarchive_files に指定します:

https://github.com/ikedam/zenn_snippets/blob/archive_file_demo/terraform/main.tf#L39-L45

実際に作成されるファイルはこんな内容になります。コンテナーイメージの作成に必要なファイルだけになっていることを確認できます:

$ zipinfo source.zip
Archive:  source.zip
Zip file size: 7011 bytes, number of entries: 4
-rw-r--r--  2.0 unx      347 bl defN 49-Jan-01 00:00 Dockerfile
-rw-r--r--  2.0 unx       94 bl defN 49-Jan-01 00:00 factorial/__init__.py
-rw-r--r--  2.0 unx    18244 bl defN 49-Jan-01 00:00 poetry.lock
-rw-r--r--  2.0 unx      408 bl defN 49-Jan-01 00:00 pyproject.toml
4 files, 19093 bytes uncompressed, 6509 bytes compressed:  65.9%
$

付録

archive での時刻の固定

このコードで固定しています:

golang で time.Time のゼロ値は 0001年1月1日 です。
ただし zip ファイルでは年を 1980 年からの相対値で設定するため、 -1979 を設定しようとしてオーバーフローの結果 69 が設定され、 2049 年になります。

手動で zip を作ってハッシュを固定できるかの検証

実行する OS や使用する zip のバージョンにも依存するので、参考程度にごらんください。

Windows11 上の cygwin で検証を行いました:

zip -v の出力
$ zip -v
Copyright (c) 1990-2008 Info-ZIP - Type 'zip "-L"' for software license.
This is Zip 3.0 (July 5th 2008), by Info-ZIP.
Currently maintained by E. Gordon.  Please send bug reports to
the authors using the web page at www.info-zip.org; see README for details.

Latest sources and executables are at ftp://ftp.info-zip.org/pub/infozip,
as of above date; see http://www.info-zip.org/ for other sites.

Compiled with gcc 11.3.0 for Unix (Cygwin) on Jun 16 2022.

Zip special compilation options:
        USE_EF_UT_TIME       (store Universal Time)
        BZIP2_SUPPORT        (bzip2 library version 1.0.8, 13-Jul-2019)
            bzip2 code and library copyright (c) Julian R Seward
            (See the bzip2 license for terms of use)
        SYMLINK_SUPPORT      (symbolic links supported)
        LARGE_FILE_SUPPORT   (can read and write large files on file system)
        ZIP64_SUPPORT        (use Zip64 to store large files in archives)
        UNICODE_SUPPORT      (store and read UTF-8 Unicode paths)
        STORE_UNIX_UIDs_GIDs (store UID/GID sizes/values using new extra field)
        UIDGID_NOT_16BIT     (old Unix 16-bit UID/GID extra field not used)
        [encryption, version 2.91 of 05 Jan 2007] (modified for Zip 3)

Encryption notice:
        The encryption code of this program is not copyrighted and is
        put in the public domain.  It was originally written in Europe
        and, to the best of our knowledge, can be freely distributed
        in both source and object forms from any country, including
        the USA under License Exception TSU of the U.S. Export
        Administration Regulations (section 740.13(e)) of 6 June 2002.

Zip environment options:
             ZIP:  [none]
          ZIPOPT:  [none]
$

同一のファイルでアーカイブを作成すればハッシュ値が同じになることの確認

$ zip -X source1.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
  adding: Dockerfile (deflated 30%)
  adding: factorial/__init__.py (deflated 30%)
  adding: poetry.lock (deflated 66%)
  adding: pyproject.toml (deflated 38%)
$ zip -X source2.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
  adding: Dockerfile (deflated 30%)
  adding: factorial/__init__.py (deflated 30%)
  adding: poetry.lock (deflated 66%)
  adding: pyproject.toml (deflated 38%)
$ md5sum source1.zip source2.zip
f939f0734ca5439dbc4ca939c42d02a4 *source1.zip
f939f0734ca5439dbc4ca939c42d02a4 *source2.zip
$

ちなみに -X オプションを指定しないとハッシュ値は同一になりませんでした。 Extra Timestamp Header が設定されて、ファイルのアクセス時刻が入るためです:

$ zip source1.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
  adding: Dockerfile (deflated 30%)
  adding: factorial/__init__.py (deflated 30%)
  adding: poetry.lock (deflated 66%)
  adding: pyproject.toml (deflated 38%)
$ zip source2.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
  adding: Dockerfile (deflated 30%)
  adding: factorial/__init__.py (deflated 30%)
  adding: poetry.lock (deflated 66%)
  adding: pyproject.toml (deflated 38%)
$ md5sum source1.zip source2.zip
b396dd060adfce83ec1018a3ad1ab68d *source1.zip
e8db07684de5bc5e01afb2099b7e92f0 *source2.zip
$

ファイルの順序でハッシュ値が変わることの確認

$ zip -X source1.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
  adding: Dockerfile (deflated 30%)
  adding: factorial/__init__.py (deflated 30%)
  adding: poetry.lock (deflated 66%)
  adding: pyproject.toml (deflated 38%)
$ zip -X source2.zip Dockerfile factorial/__init__.py pyproject.toml poetry.lock
  adding: Dockerfile (deflated 30%)
  adding: factorial/__init__.py (deflated 30%)
  adding: pyproject.toml (deflated 38%)
  adding: poetry.lock (deflated 66%)
$ md5sum source1.zip source2.zip
f939f0734ca5439dbc4ca939c42d02a4 *source1.zip
6e9400e239ae9306599d425e1a9a1207 *source2.zip
$

ファイルのタイムスタンプでハッシュ値が変わることの確認

$ zip -X source1.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
updating: Dockerfile (deflated 30%)
updating: factorial/__init__.py (deflated 30%)
updating: poetry.lock (deflated 66%)
updating: pyproject.toml (deflated 38%)
$ touch Dockerfile
$ zip -X source2.zip Dockerfile factorial/__init__.py poetry.lock pyproject.toml
updating: Dockerfile (deflated 30%)
updating: factorial/__init__.py (deflated 30%)
updating: pyproject.toml (deflated 38%)
updating: poetry.lock (deflated 66%)
$ md5sum source1.zip source2.zip
f939f0734ca5439dbc4ca939c42d02a4 *source1.zip
6898103858d8b1cf405ae7b836443dd7 *source2.zip
$

Discussion