はじめに
個人開発で AWS のサーバレス構成(CloudFormation + Lambda + S3 + CloudFront)を作っていて、デプロイ手順を Makefile にまとめました。
ところが Windows の make は Linux/macOS と挙動が違う部分がいくつもあり、順調に踏み抜きました。if [[ ]] が動かない、cat が見つからない、パスの空白で壊れる、カンマが引数として食われる……。
同じことをやる人が必ずハマると思うので、原因と対処を全部書き残します。
想定読者は「Makefile を書いたことはあるが Windows では初めて」あるいは「そもそも Makefile をコマンド集として使ったことがない」人です。前半は基礎、後半が本題の罠です。
第 1 部:Makefile をコマンド集として使う
なぜ Makefile なのか
デプロイの実体はこういうコマンドです。
aws cloudformation execute-change-set \
--profile myprofile --region ap-northeast-1 \
--stack-name myapp --change-set-name myapp-changeset
毎回これを正確に打つのは無理があります。--profile を忘れれば別のアカウントに向きますし、--stack-name を間違えれば別のスタックを触ります。
Makefile に一度書いておけば、以後はこれで済みます。
make deploy
「正しい打ち方」をファイルに固定するための仕組み、という理解が実用上は一番近いです。もともとは C のビルドツールですが、いまはこの用途がかなり多いはずです。
npm scripts でもいいのでは、という話はあります。ただ Node のプロジェクトでない場合や、シェルの制御構文をがっつり書きたい場合は Makefile のほうが素直でした。
最小の Makefile
ファイル名は Makefile、拡張子なし、プロジェクトのルートに置きます。
hello:
@echo "こんにちは"
make hello
Windows のエクスプローラーは既定で拡張子を隠すので、メモ帳で保存すると Makefile.txt になって make から見つかりません。最初の詰まりポイントがこれです。
罠 0:インデントはタブ
レシピの行頭は必ずタブ。スペースだと動きません。
Makefile:2: *** missing separator. Stop.
このエラーはほぼ 100% これです。ところが VS Code は既定でタブをスペースに変換するので、設定で止めておきます。
{
"[makefile]": {
"editor.insertSpaces": false,
"editor.detectIndentation": false
}
}
変数
PROFILE ?= myprofile
REGION ?= ap-northeast-1
PROJECT := myapp
AWS := aws --profile $(PROFILE) --region $(REGION)
| 記法 | 意味 |
|---|---|
:= |
その場で確定 |
?= |
未指定ならこの値(コマンドラインから上書きできる) |
make deploy PROFILE=other
共通部分をまるごと変数にしてしまうのが効きます。以後 $(AWS) s3 ls と書けるので、プロファイル指定の書き忘れという事故が構造的に消えます。
依存関係
build:
npm run build
deploy: build
aws s3 sync dist/ s3://my-bucket/
make deploy で build が自動で先に走ります。ビルドを忘れて古い成果物をデプロイする事故を仕組みで防げるのが、単なるシェルスクリプト置き場との違いです。
.PHONY を付ける
.PHONY: build deploy
make は本来「ファイルを作る道具」です。build というディレクトリが実在すると「もうあるので何もしません」と言って黙って終了します。原因が分からず数分溶かしました。
コマンド集として使うなら全ターゲットに付けるのが安全です。
ヘルプの自動生成
ターゲットが増えると自分でも忘れるので、これを入れておきます。
.DEFAULT_GOAL := help
.PHONY: help
help: ## このヘルプを表示
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
各ターゲットに ## 説明 を書けば、make と打つだけで一覧が出ます。定番のイディオムです。
help このヘルプを表示
validate テンプレートの検証
changeset チェンジセットを作成し差分を表示
deploy チェンジセットを実行
第 2 部:Windows 固有の罠
ここからが本題です。
罠 1:if [[ ]] も for も cat も動かない
logs:
@if [[ -z "$(FN)" ]]; then echo "使い方: make logs FN=api"; exit 1; fi
process_begin: CreateProcess(NULL, if [[ -z "" ]]; then ...) failed.
原因:Windows の make は既定でコマンドを cmd.exe に渡します。[[ ]] も for ... do ... done も grep も cat も存在しません。
対処:SHELL を Git Bash に向けます。
SHELL := C:/Program Files/Git/bin/bash.exe
.SHELLFLAGS := -c
……これは動きません。次の罠に続きます。
罠 2:Program Files の空白でパスが分割される
make は変数を空白区切りのリストとして扱う場面があり、C:/Program Files/... は C:/Program と Files/Git/bin/bash.exe の 2 つに割れます。$(wildcard) を通すと消えます。
対処:8.3 形式の短いパスを使います。
BASH_CANDIDATES := C:/PROGRA~1/Git/bin/bash.exe C:/PROGRA~2/Git/bin/bash.exe /bin/bash
SHELL := $(firstword $(foreach candidate,$(BASH_CANDIDATES),$(wildcard $(candidate))) /bin/bash)
.SHELLFLAGS := -c
PROGRA~1 が Program Files、PROGRA~2 が Program Files (x86) です。存在する最初の候補を採用し、どれも無ければ /bin/bash にフォールバックします。これで macOS/Linux でもそのまま動きます。
短いパスは dir /x で確認できます。
C:\> dir /x C:\ | findstr Program
PROGRA~1 Program Files
PROGRA~2 Program Files (x86)
罠 3:SHELL を指定したのに cat が見つからない
web-env:
@echo "KEY=value" > .env.local
cat .env.local
process_begin: CreateProcess(NULL, cat .env.local) failed.
SHELL は指定したのに、なぜか無視されます。
原因:GNU Make には**「単純なコマンドならシェルを介さず直接起動する」という最適化**があります。パイプもリダイレクトも制御構文も無い 1 コマンドだと、SHELL の指定を素通りして CreateProcess を直接呼びます。cat は Windows のコマンドではないので当然見つかりません。
対処:シェル経由を強制します。一番手軽なのは行末にセミコロンを 1 つ足すことです。
cat .env.local ;
これで「単純なコマンドではない」と判定され、SHELL が使われます。
; は見た目が不自然なので、コメントを添えておくのを勧めます。半年後の自分が「なんだこれ」と消して壊します。
@# 単純なコマンドは make がシェルを介さず起動するため、`;` でシェル経由に固定する
cat .env.local ;
罠 4:$(if ...) の中でカンマが引数として食われる
AWS CLI にパラメータを渡すとき、こう書きたくなります。
PARAM_OVERRIDES = $(if $(MODEL),--parameters ParameterKey=ModelId,ParameterValue=$(MODEL))
原因:$(if 条件,真の場合,偽の場合) はカンマで引数を区切るため、ParameterKey=ModelId,ParameterValue=... のカンマが第 3 引数の区切りとして解釈されます。
対処:カンマだけを変数に逃がします。
COMMA := ,
PARAM_OVERRIDES = $(if $(MODEL),--parameters ParameterKey=ModelId$(COMMA)ParameterValue=$(MODEL))
make の関数構文と値が同じ文字で衝突する、という古典的な問題です。
罠 5:複数行のシェル構文が壊れる
validate:
@for f in a.yaml b.yaml; do
echo $$f
done
原因:make はレシピの 1 行ごとに新しいシェルを起動します。for と done が別プロセスになるので当然壊れます。
対処:行末に \ を置いて 1 行に繋ぎます。
validate:
@for f in a.yaml b.yaml; do \
echo "検証中: $$f"; \
aws cloudformation validate-template --template-body file://$$f >/dev/null; \
done
各行の末尾のセミコロンも必要です(1 行に繋がるため)。
.ONESHELL: を宣言すればレシピ全体を 1 つのシェルで実行できますが、挙動が変わる範囲が広いので、個人的には \ で繋ぐほうを選びました。
罠 6:$ はシェルに渡すとき 2 つ重ねる
上の例の $$f がそれです。make は $ を自分の変数展開として食べるので、シェル側の変数を書きたいときは $$ と重ねます。
@echo "make の変数: $(PROJECT)"
@echo "シェルの変数: $$HOME"
同じ行に両方が混在することもあり、慣れるまで読みにくいところです。
罠 7(おまけ):AWS CLI v2 がテンプレートを cp932 で読む
Makefile の話ではありませんが、同じ Windows 環境で踏んだので書いておきます。
'cp932' codec can't decode byte 0x8b in position ...
CloudFormation のテンプレートに日本語のコメントを書いて aws cloudformation package を実行すると落ちます。Windows の AWS CLI v2 がテンプレートをシステムのコードページ(日本語環境なら cp932)で読むためです。
対処:テンプレートは ASCII のみで書く。設計の意図は別のドキュメント(ADR や README)に日本語で残す。
PYTHONUTF8=1 などで回避できる場合もありますが、環境依存を増やしたくなかったので「テンプレートは ASCII」という規約にしました。CI やチーム開発を考えても、こちらのほうが安全だと思います。
完成形
罠を全部潰すと、先頭はこうなります。
# Windows では make が既定で cmd.exe を使うため、`if [[ ]]` や `for` が動かない。
# Git Bash があればそれを使う。
# make の wildcard は空白でパスを分割するため、8.3 形式の短いパスで指定する
BASH_CANDIDATES := C:/PROGRA~1/Git/bin/bash.exe C:/PROGRA~2/Git/bin/bash.exe /bin/bash
SHELL := $(firstword $(foreach candidate,$(BASH_CANDIDATES),$(wildcard $(candidate))) /bin/bash)
.SHELLFLAGS := -c
.DEFAULT_GOAL := help
PROFILE ?= myprofile
REGION ?= ap-northeast-1
PROJECT := myapp
STACK := $(PROJECT)
AWS := aws --profile $(PROFILE) --region $(REGION)
# $(if ...) は引数をカンマで区切るため、値のカンマは変数経由で埋める
COMMA := ,
.PHONY: help
help: ## このヘルプを表示
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
.PHONY: whoami
whoami: ## どの AWS アカウントに繋がっているか表示
$(AWS) sts get-caller-identity --output table
.PHONY: changeset
changeset: package ## チェンジセットを作成し差分を表示(実行はしない)
$(AWS) cloudformation create-change-set \
--stack-name $(STACK) \
--change-set-name $(STACK)-changeset \
--template-body file://infra/packaged.yaml \
--include-nested-stacks \
--capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND \
--change-set-type UPDATE
.PHONY: deploy
deploy: ## 作成済みチェンジセットを実行
$(AWS) cloudformation execute-change-set \
--stack-name $(STACK) --change-set-name $(STACK)-changeset
$(AWS) cloudformation wait stack-update-complete --stack-name $(STACK)
おまけ:スタックの出力を引く関数
デプロイ先のバケット名やディストリビューション ID を Makefile にベタ書きしたくないので、CloudFormation の出力から引く関数を作っています。
define stack_output
$(shell $(AWS) cloudformation describe-stacks --stack-name $(STACK) \
--query "Stacks[0].Outputs[?OutputKey=='$(1)'].OutputValue" --output text)
endef
.PHONY: deploy-web
deploy-web: ## フロントを S3 に配置して CloudFront のキャッシュを破棄
npm run build
$(AWS) s3 sync dist/ s3://$(call stack_output,WebBucketName)/ --delete
$(AWS) cloudfront create-invalidation \
--distribution-id $(call stack_output,DistributionId) --paths "/*"
@echo "公開 URL: $(call stack_output,WebUrl)"
$(call 関数名,引数) で呼び、関数側では $(1) で受けます。これでリソース ID をどこにも書かずに済みます。スタックを作り直しても Makefile を直す必要がありません。
罠まとめ
| 症状 | 原因 | 対処 |
|---|---|---|
missing separator |
インデントがスペース | タブにする |
| ターゲットが黙って終わる | 同名のファイル/ディレクトリがある |
.PHONY を付ける |
if/for/grep が動かない |
cmd.exe で実行されている |
SHELL を Git Bash に |
SHELL を指定したのにパスが通らない |
Program Files の空白で分割 |
8.3 形式(PROGRA~1)で書く |
SHELL を指定したのに cat が無い |
単純なコマンドは直接起動される | 行末に ; を付ける |
| カンマを含む値が壊れる |
$(if ...) の引数区切り |
COMMA := , を経由 |
| シェル変数が空になる |
$ を make が食べた |
$$ と重ねる |
複数行の for が構文エラー |
行ごとに別シェル | 行末 \ で 1 行に繋ぐ |
CLI が cp932 で落ちる |
テンプレートの日本語 | テンプレートは ASCII のみ |
おわりに
Makefile そのものは枯れた技術ですが、Windows で使うと固有の落とし穴が一気に増えます。特に「SHELL を指定したのに効かない」(罠 3)は、原因が GNU Make の最適化にあると分からないと延々悩みます。
いきなり全部書こうとせず、「2 回打ったコマンドを 1 行足す」を繰り返すのがおすすめです。私も最初は deploy 1 つだけでした。
同じところで詰まった人の助けになれば幸いです。