Sitemap

不要な再デプロイを避けるために再現性のある ZIP を作る

17 min readMay 6, 2025
Press enter or click to view image in full size

お久しぶりです。ぺりーです!
最近は Vim と Cursor を行き来しながらコードを書くことも多くなりました。

先日、Terraform で AWS Lambdaを管理している中で、Goのコードに変更がないはずなのに、source_code_hashに差分が生じ、Terraform が毎回 Lambda を再デプロイしてしまうことに困っていました。

Lambda の source_code_hash は ZIP ファイルの内容に対して計算されるため、ビルドプロセスの中に非決定的な要素があると、ハッシュが毎回変わってしまいます。

この状態では、CI/CD 上で挙動が安定せず、意図しない再デプロイが発生します。

source_code_hashをブラさず意図しない再デプロイを避けるために、今回は Python のスクリプトを使って、再現性のある ZIP を作成する方法を備忘録のために記事にします。

source_code_hash とは

Terraform では、Lambda 関数のコードを ZIP ファイルで指定する場合に、source_code_hash を使って差分を検出します。

resource "aws_lambda_function" "example" {
filename = "lambda.zip"
source_code_hash = filebase64sha256("lambda.zip")
}

このハッシュが前回と異なる場合、Lambda の再デプロイがトリガーされます。

ハッシュはファイルの内容に依存するため、ZIP 内のタイムスタンプやファイル順、UNIX メタデータといった見えづらい差分も影響します。

source_code_hash は次のように計算されます。

$ openssl dgst -sha256 -binary lambda.zip | base64

なぜ source_code_hash の管理が重要か

source_code_hash を指定しないと、コードが変更されていても Terraform は差分を検知できず、Lambda の更新が行われません。

逆に、ハッシュが意図せず変化してしまうと、コード変更がないのに毎回再デプロイが発生します。

Lambda のバージョン管理やリリースの最適化を Terraform に任せるには、source_code_hash の管理が非常に重要です。

試したこと

ファイルに差分がない場合に再デプロイしなくて済むように source_code_hashを固定化するために、いくつかのアプローチを段階的に試しました。

Step 1: Go で生成したバイナリを ZIP 化する(null_resource)

最初は、Go で生成したバイナリを含めた ZIP を Terraform の null_resource で生成していました。

Terraform に deploy する ZIP を差分管理にも使用しています。
(ZIP ではなくバイナリをそのまま指定しても同じ)

resource "null_resource" "build" {
provisioner "local-exec" {
working_dir = path.root
command = "make build"
}
}

data "archive_file" "command" {
type = "zip"
source_dir = "${path.root}/artifacts/command"
output_path = "${path.root}/artifacts/lambda.zip"
depends_on = [null_resource.build]
}

resource "aws_lambda_function" "example" {
filename = data.archive_file.command.output_path
function_name = local.lambda_name
handler = "bootstrap"
runtime = "provided.al2"

source_code_hash = data.archive_file.chatbotctl_command.output_base64sha256

depends_on = [null_resource.build]
}

しかし、Go のバイナリはビルド時に VCS 情報や日時などのメタデータを埋め込むため、毎回ビルド結果が異なり、ハッシュも変わってしまいます。

LDFLAGS := -ldflags="-s -w" -trimpath
.PHONY: build
build:
@GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o ../$(ARTIFACTS_DIR)/command/bootstrap -v $(LDFLAGS)
$ go version -m artifacts/command/bootstrap
artifacts/command/bootstrap: go1.24.2
path github.com/satorunoshie/example/command
mod github.com/satorunooshie/example v0.0.0-20250505115056-6c8f70c56272
dep github.com/aws/aws-lambda-go v1.48.0 h1:1aZUYsrJu0yo5fC4z+Rba1KhNImXcJcvHu763BxoyIo=
dep github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
dep github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
dep github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
dep github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
dep github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
dep github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
dep github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
dep github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
dep github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
dep github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.4 h1:EKXYJ8kgz4fiqef8xApu7eH0eae2SrVG+oHCLFybMRI=
dep github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
dep github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
dep github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
dep github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
dep github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
build -buildmode=exe
build -compiler=gc
build -trimpath=true
build CGO_ENABLED=0
build GOARCH=amd64
build GOOS=linux
build GOAMD64=v1
build vcs=git
build vcs.revision=6c8f70c562726ab376f13234a4dc363387ead6b4
build vcs.time=2025-05-05T11:50:56Z
build vcs.modified=false

今回は特殊な事情があり、また commit hash や timestamp などを runtime/debug から取得して使用したかったため、-buildvcs=false をつけませんでした。

-buildvcs=false つけて build し VCS 情報やメタデータを取得するには go tool link を使う必要があります。

VERSION=$(shell go env GOVERSION | sed -e 's/go//')
COMMITHASH=$(shell git rev-parse --short HEAD)
DATETIME=$(shell date +%FT%T%z)
LDFLAGS := -ldflags=”-s -w -X main.Version=$(VERSION) -X main.CommitHash=$(COMMITHASH) -X main.DateTime=$(DATETIME)” -trimpath`

-X importpath.name=value
Set the value of the string variable in importpath named name to value.
This is only effective if the variable is declared in the source code either uninitialized or initialized to a constant string expression. -X will not work if the initializer makes a function call or refers to other variables.
Note that before Go 1.5 this option took two separate arguments.

https://pkg.go.dev/cmd/link

Step 2: バイナリを除いたソースコードだけを ZIP 化する(null_resource)

次に、Go で生成したバイナリを ZIP に含めず、ファイルの順序を固定して、タイムスタンプなどのUNIX メタ情報を除外し、ソースコードだけを ZIP にまとめました。

resource "aws_lambda_function" "example" {
filename = data.archive_file.command.output_path
handler = "bootstrap"
runtime = "provided.al2"

source_code_hash = data.archive_file.snapshot.output_base64sha256

depends_on = [null_resource.build]
}

data "archive_file" "snapshot" {
type = "zip"
source_dir = "${path.root}/command"
output_path = "${path.root}/artifacts/snapshot/command.zip"
}

Step 2: バイナリを除いたソースコードだけを ZIP 化する

次に、Go で生成したバイナリを ZIP に含めず、ファイルの順序を固定して、タイムスタンプなどのUNIX メタ情報を除外し、ソースコードだけを ZIP にまとめました。

.PHONY: zip
zip: build
@find . -type f ! -path "./$(ARTIFACTS_DIR)/*" | LC_ALL=C sort | zip -qr -X -@ $(ARTIFACTS_DIR)/snapshot/command.zip

ただし、この方法でも最終更新日時によりハッシュが変化し、再デプロイが発生する問題は残りました。

Step 3: SOURCE_DATE_EPOCHを使って安定化させる

次に、環境変数 SOURCE_DATE_EPOCH=0 を指定することで、最終更新日時を固定しようとしました。

.PHONY: zip
zip: build
@(
export SOURCE_DATE_EPOCH=0; \
find . -type f ! -path "./$(ARTIFACTS_DIR)/*" | LC_ALL=C sort | zip -qr -X -@ $(ARTIFACTS_DIR)/snapshot/command.zip
)

しかし、Reproducible Builds のために提案された比較的新しい仕様らしく macOS の BSD zip や GitHub Actions の Info-ZIP 3.0 では SOURCE_DATE_EPOCH がサポートされていないことがわかりました。

# GitHub Actions ubuntu-latest で実行した例
$ which zip
/usr/bin/zip

$ zip -v | head -n 1
Copyright (c) 1990-2008 Info-ZIP - Type 'zip "-L"' for software license.

$ export SOURCE_DATE_EPOCH=0
$ mkdir test && echo foo > test/foo.txt
$ cd test && zip -q -X ../test.zip foo.txt
$ unzip -l ../test.zip
Archive: ../test.zip
Length Date Time Name
--------- ---------- ----- ----
4 2025-05-05 01:34 foo.txt
--------- -------
4 1 file

# macOS で実行した例
$ zip -v | head -n 2
Copyright (c) 1990-2008 Info-ZIP - Type 'zip "-L"' for software license.
This is Zip 3.0 (July 5th 2008), by Info-ZIP, with modifications by Apple Inc.

サポートされていれば 01–01–1980 00:00 foo.txt が返ってくるはずでした。

SOURCE_DATE_EPOCH

A UNIX timestamp, defined as the number of seconds, excluding leap seconds, since 01 Jan 1970 00:00:00 UTC.

The value MUST be exported through the operating system’s usual environment mechanism.

The value MUST be an ASCII representation of an integer with no fractional component, identical to the output format of date +%s.

The value MUST be reproducible (deterministic) across different executions of the build, depending only on the source code. It SHOULD be set to the last modification time of the source, incorporating any packaging-specific modifications.

Build processes MUST use this variable for embedded timestamps in place of the “current” date and time.

Where build processes embed timestamps that are not “current”, but are nevertheless still specific to one execution of the build process, they MUST use a timestamp no later than the value of this variable. This is often called “timestamp clamping”.

Build processes MUST NOT unset this variable for child processes if it is already present.

Formatting MUST be deferred until runtime if an end user should observe the value in their own locale or timezone.

If the value is malformed, the build process SHOULD exit with a non-zero error code.

※ GNU zip に入れ替えれば解消するが、手間がかかるのでやらない

SOURCE_DATE_EPOCH

If set to any value, disables compression with DEFLATE COMPRESSION CALL. This variable is normally set during reproducible builds, where DEFLATE COMPRESSION CALL must be disabled, because its output may not be reproducible.
https://www.gnu.org/software/gzip/manual/gzip.html

Step 4: Python のスクリプトで ZIP を生成する

ZIP 作成前に touch -t 19801010000 などで再起的に mtime を固定する方法もありましたが、副作用も大きいため、 Python の zipfile モジュールを使って、タイムスタンプ・ファイル順・UNIX メタ情報をすべて制御するスクリプトを作成しました。

このスクリプトにより、どの環境で実行しても同じハッシュを持つ ZIP ファイルが生成できるようになりました。

@python3 create_reproducible_zip.py <source_dir> <output_zip> [exclude_prefix]

結論

Lambda の source_code_hash で効率的なデプロイを実現するため、Python を使って再現性のある ZIP を作成できるようにしました。

Go を使う場合はほとんどのケースで build する際に -buildvcs=false を付与すれば安定したバイナリを作ることができるため、登場頻度は少ないかもしれません。

--

--

ぺりー
ぺりー

Written by ぺりー

Satoru Kitaguchi. Software Engineer at Third Intelligence.

No responses yet