内容物が同一なのにハッシュ値の異なる ZIP ファイルが出来ないようにするには(あるいは、AWS Lambda へ同一コードを update することを防ぐには)

コマンドラインの zip(1) コマンドで ZIP アーカイブファイルを作成すると、少なくとも Mac OS の環境では、出来上がるファイルのハッシュが毎回変わってしまいます。

$ mkdir -p test/
$ touch test/{foo,bar}
$ stat -f "%N: %a, %m" test/*
test/bar: 1463742429, 1463742429
test/foo: 1463742429, 1463742429
$ (cd test; zip -r ../test-1st.zip *; cd ..)
  adding: bar (stored 0%)
  adding: foo (stored 0%)
$ stat -f "%N: %a, %m" test/*
test/bar: 1463742460, 1463742429
test/foo: 1463742460, 1463742429
$ (cd test; zip -r ../test-2nd.zip *; cd ..)
  adding: bar (stored 0%)
  adding: foo (stored 0%)
$ openssl dgst -sha256 test-1st.zip test-2nd.zip
SHA256(test-1st.zip)= 12be5c402b169b4058d167ffc41a3148f94e97c13721fb37c622b48bd0f3b40a
SHA256(test-2nd.zip)= 3464c8c47d89dc5f19186aec895207b5141e2f340819c06bf4a8fb2b019dda24

原因

これは、POSIX 系の環境で作成する ZIP ファイルでは、収容する各ファイルのメタ情報(オーナーや最終変更日時など)もあわせて格納しているのですが、Mac OS の zip(1) コマンドでは、1 回目の実行で各ファイルをナメた際に最終アクセス時刻を変更してしまうため、2 回目に実行した際にはメタ情報が変わってしまい、最終的な ZIP ファイルの内容が異なるものになってしまうためです。

Note: Linux では起きないようです。考慮してあるのかな?

Note: これらのメタ情報は、ZIP ファイル内では、各エントリごとのメタ情報を格納する「ローカルファイルヘッダ」の「拡張フィールド」に入っています。

「拡張フィールドはOSに特化した属性のような様々なオプションデータを含む」 ∥ ZIP (ファイルフォーマット) - Wikipedia

下記は、先の 2 つのファイル(test-1st.zip と test-2nd.zip)のバイナリダンプの diff を取ったものです。シグネチャの “0x504B0304” (“PK\003\004”) からしばらく先にファイル名(ここでは “bar”)があり、その直後が拡張フィールドです。拡張フィールドの長さは 0x1c (28) バイトだとあるので、けっこうたっぷりあります。いかにも日付っぽいところに差分が出ています。

00000000 | 50 4B 03 04 0A 00 00 00 00 00 E5 A0 B4 48 00 00 | PK...........H.. 00000010 | 00 00 00 00 00 00 00 00 00 00 03 00 1C 00 62 61 | ..............ba -00000020 | 72 55 54 09 00 03 DD EF 3E 57 DD EF 3E 57 75 78 | rUT.....>W..>Wux +00000020 | 72 55 54 09 00 03 DD EF 3E 57 FC EF 3E 57 75 78 | rUT.....>W..>Wux 00000030 | 0B 00 01 04 F6 01 00 00 04 00 00 00 00 50 4B 03 | .............PK. 00000040 | 04 0A 00 00 00 00 00 E5 A0 B4 48 00 00 00 00 00 | ..........H..... 00000050 | 00 00 00 00 00 00 00 03 00 1C 00 66 6F 6F 55 54 | ...........fooUT -00000060 | 09 00 03 DD EF 3E 57 DD EF 3E 57 75 78 0B 00 01 | .....>W..>Wux... +00000060 | 09 00 03 DD EF 3E 57 FC EF 3E 57 75 78 0B 00 01 | .....>W..>Wux... 00000070 | 04 F6 01 00 00 04 00 00 00 00 50 4B 01 02 1E 03 | ..........PK.... 00000080 | 0A 00 00 00 00 00 E5 A0 B4 48 00 00 00 00 00 00 | .........H...... 00000090 | 00 00 00 00 00 00 03 00 18 00 00 00 00 00 00 00 | ................


# 対策

対策としては大きく 2 つありますので、必要に応じて使い分けます。

1. 最終アクセス日時を修正してから zip(1) を実行する(あまりオススメしない)
2. そもそもで最終アクセス日時を ZIP ファイルに入れない

## 最終アクセス日時を修正してから zip(1) を実行する(あまりオススメしない)

下記のように、対象の最終アクセス日時を修正(ここでは、最終更新日時と同じ日時に修正)してから zip(1) を実行します。

$ find test | while read line; do touch -a -t $(date -r $(stat -f %m $line) +%Y%m%d%H%M.%S) $line; done $ (cd test; zip -r ../test-3rd.zip *; cd ..) adding: bar (stored 0%) adding: foo (stored 0%) $ find test | while read line; do touch -a -t $(date -r $(stat -f %m $line) +%Y%m%d%H%M.%S) $line; done $ (cd test; zip -r ../test-4th.zip *; cd ..) adding: bar (stored 0%) adding: foo (stored 0%) $ openssl dgst -sha256 test-3rd.zip test-4th.zip SHA256(test-3rd.zip)= 12be5c402b169b4058d167ffc41a3148f94e97c13721fb37c622b48bd0f3b40a SHA256(test-4th.zip)= 12be5c402b169b4058d167ffc41a3148f94e97c13721fb37c622b48bd0f3b40a


オススメできない理由は、圧縮の対象がファイルであればこれでも良いのですが、ディレクトリを含む場合、どうも zip(1) 実行時にそのディレクトリを踏んでしまい、そこで最終アクセス日時が更新されてしまうようなんですよね。困ったもんだ。

## そもそもで最終アクセス日時を ZIP ファイルに入れない

`-X` オプションで、拡張フィールド無しの ZIP ファイルを作成することができます。ZIP ファイルを持って行った先で、これといって attributes を使う予定が無いのであれば、この方が気楽で良いです。

$ (cd test; zip -r -X ../test-5th.zip *; cd ..) adding: bar (stored 0%) adding: foo (stored 0%) $ (cd test; zip -r -X ../test-6th.zip *; cd ..) adding: bar (stored 0%) adding: foo (stored 0%) $ openssl dgst -sha256 test-5th.zip test-6th.zip SHA256(test-5th.zip)= d509ecf560a63b35e5cd653043fe15892d4eac63aeb9740adb9a92023609ccfb SHA256(test-6th.zip)= d509ecf560a63b35e5cd653043fe15892d4eac63aeb9740adb9a92023609ccfb


# AWS Lambda へ同一コードを update することを防ぐには

AWS Lambda では、前回 ZIP でアップロードした Lambda function のハッシュ値が取得できます。下記の “Configuration.CodeSha256” の値です。

~~~~console:
$ function_name=hogeFuga
$ aws lambda get-function --function-name $function_name
{
    "Code": {
        "RepositoryType": "S3",
        "Location": "https:// ... %2FVQ%3D"
    },
    "Configuration": {
        "Version": "$LATEST",
        "CodeSha256": "wtsccucselIe1XXHynELHai8jaOGv/qTrbh99KUdwLs=",
        "FunctionName": "hogeFuga",
        "MemorySize": 128,
        "CodeSize": 945,
        "FunctionArn": "arn:aws:lambda:us-west-2: ... :function:hogeFuga",
        "Handler": "index.handler",
        "Role": "arn:aws:iam:: ... :role/LambdaRole",
        "Timeout": 3,
        "LastModified": "2016-05-20T09:46:02.905+0000",
        "Runtime": "nodejs4.3",
        "Description": ""
    }
}

ということは、手元にあるコードでもう一度 ZIP を作成して、同じハッシュを持つ ZIP ファイルが出来上がるならばアップデートは必要無いですし、コードの更新処理も冪等な処理にすることが出来るんですよね。ところが、普通に ZIP を作ってしまうと毎度違う ZIP が出来るので、この判別ができなくて困ってしまい、上記のようなことを調べていた次第です。

最終的にはこんな感じですね。先方のハッシュと手元のハッシュを比較して、異なっている時にだけ update-function-code を実行します。

function=$(aws lambda get-function --function-name $function_name)
hash_old=$(jq -r ".Configuration.CodeSha256" <<<$function)
(rm -f $function_name.zip; cd src/; zip -r -X ../$function_name.zip *; cd ..)
hash_new=$(openssl dgst -binary -sha256 < $function_name.zip | openssl base64)
if test "$hash_old" = "$hash_new"
then
  echo This code is alredy uploaded. Skipping.
else
  echo Hash differs. Updating.
  function=$(aws lambda update-function-code \
    --function-name $function_name \
    --zip-file fileb://$function_name.zip \
    --publish )
  echo $(jq -r ".FunctionArn" <<<$function) created.
fi

では。