LoginSignup
14
9

More than 3 years have passed since last update.

原点回帰 GNU Make - 評価ルールを理解してdocker時代のタスクランナーとして使う

Posted at

なぜ今Makeなのか?

並列実行が優秀

makeには-j (--jobs) オプションがあり、依存関係を解決したあとで可能な限り並列でビルドしようとします。これが非常に優秀で、特に並列実行のためにプログラムを書かなくてもうまくやってくれます。

.PHONY: all
# $^ はすべての必須項目を表す自動変数です。この場合は google.header yahoo.header に展開されます
all: google.header yahoo.header
    cat $^

# ターゲット内の % はワイルドカードです
# % でキャプチャされた文字列(stemと呼ばれる)はコマンドスクリプト(レシピ)内で $* として参照可能
# $@ はターゲットを表す自動変数です。この場合は google.header や yahoo.header に展開されます
%.header:
    curl -sI https://$*.com > $@
$ make -j2 -f jobs.mk
curl -sI https://google.com > google.header
curl -sI https://yahoo.com > yahoo.header
cat google.header yahoo.header
HTTP/2 301
location: https://www.google.com/
(略)

HTTP/2 301
date: Sat, 11 Apr 2020 07:10:58 GMT
strict-transport-security: max-age=31536000
location: https://www.yahoo.com/
(略)

普段実行するコマンドを並べるだけの手軽さ

常にターミナルを開いておくタイプの人や、VSCodeやVim等のエディタでターミナルを常駐させている人にとって、シェルコマンドや簡単なシェルスクリプトを書くことは生活の一部です。普段実行している何気ないコマンドの組み合わせをMakefileに書くだけで、インクリメンタルビルドや並列実行の恩恵が受けられます。

go buildwebpackなど、昨今の言語やフレームワークはそれ自体が優秀なコマンドを持っている事が多く、1つのコマンドで大体のことをやってくれます。これらをメモ代わりにMakefileにしておくところから手軽に始められます。

タスクランナーの可搬性とdockerの普及

私はMacOSで仕事をしていますが、同僚のほとんどはWindowsユーザです。また、ビルドコマンドやスクリプトはLinuxサーバやCI環境内のdockerなどでも実行させるのが主目的であることが多いです。

このような環境では可搬性が非常に重要になりますが、特にWindowsのサポートが重荷でした。以前はRakeを採用していましたが、どうしてもOS間の差異の吸収は必要でした。これはantを呼び出すRakefileの一部です。

def is_windows?() # {{{
  if (RUBY_PLATFORM.downcase =~ /mswin(?!ce)|mingw|cygwin|bccwin/)
    return true
  end
  return false
end

ANT = is_windows? ? "call ant" : "ant"

これだけなら良いのですが、外部コマンドを使った処理はシェルや外部コマンドに依存するため、複雑な処理はRubyのメソッドとして実装せざるを得ないケースが多くあります。私の環境ではRubyを書くのはほとんどRakefileをいじるときだけのため、メンテナンスが徐々に負担になっていました。

また、CI環境にRakeのためだけにRubyを入れるのもdockerイメージサイズが大きくなったり、ベースイメージの選択肢が狭まったりしてあまり良いとは思えなくなっていました。

そこでWindowsユーザはMakeをdockerで実行するということにして、私はMakeに戻りました。これにはいくつか理由があります。

  • Linux, MacへのMakeのインストールは簡単。dockerイメージも作りやすい
  • WindowsでもMakeの他にawsclijq, webpackなど各種CLIツールをインストールする必要がある
  • Dockerfileを書いておけば、いちいちツールのインストール方法を書いたり、教えたりしなくてもよくなる
    • ツールのバージョン違いや環境による差異をなくせる
    • 特にWindowsでMSYS2やGit for Windowsの混在、PATHの順番などで質問を受けるのはうんざり…

dockerが普及して、Windowsにも簡単にインストールできるようになって久しいです。プロジェクト毎にツールのセットアップ方法を考えるより、dockerのインストール方法を一度教えるほうが楽でしょう。

Make入門

参考書籍

こちらの書籍が無料で公開されています。

PDFを結合したファイルを公開されている方もいらっしゃいます。

ライセンスはGNU Free Documentation Licenseとの事で、一つに結合したものをGitHubに公開しました。よかったら活用ください

「GNU Make 第3版 日本語版(オライリー)」の無料PDF | The modern stone age.

ちょっと古い内容も含みますが、非常に良著です。Makeに興味が出たら、ぜひ一読することをおすすめします。こちらの記事でも引用させていただきます。

最も単純な例

Makefileには、作りたいターゲットとその必須項目を定義し、必須項目からターゲットを生成するコマンドをレシピとして書きます。

ターゲットと必須項目は基本的にすべてファイル名であることを忘れないでください。

dist/hoge.yml: src/hoge.yml
    cat $< > $@

ビルトインルールの無効化

いきなりですが、Makeにはビルトインルール(暗黙のルールとも)が多数含まれています。これはC言語やCVSなど使ってビルドするには便利ですが、あまり最近のツールには関係ありません。

Makeのデバッグをするときに-pオプションで解析されたルールをダンプすることができますが、このときにビルトインルールがノイズになります。また、昨今の設定の原則として「宣言的に書く」というのがあります。ビルドルールはMakefileを1つ見ればわかるようにするためにも、無効化がおすすめです。

# ビルトインルールを無効にする
MAKEFLAGS += --no-builtin-rules
.SUFFIXES:

Makeの処理フェーズ

文法やマクロ、関数はググれば出てきますし、見ればなんとなくわかります。しかし、それらに触れる前にMakeがどのようにMakefileを処理して実行するかを理解する必要があります。Makefileに書いてある順に動くわけではないからです。

make は実行された際に、2 段階に分かれた作業を行います。最初の段階ではmakefile とそこから インクルードされたmakefile を読み込みます。このとき変数とルールがmake の内部データベースに 格納され、依存関係グラフが作成されます。次の段階では依存関係グラフを解析し更新すべきター ゲットを特定した後、更新に必要なコマンドを実行します。

(中略)

  • 変数の代入における代入の左辺は、第 1 段階でmake が代入行を読み込んだときに展開される
  • = か?= の右辺は、第 2 段階にて使われるまで展開されない
  • := の右辺は、直ちに展開される
  • += の左辺が単純変数として定義されたものである場合には、右辺も直ちに展開される。そうでなければ、右辺の展開は後で行われる
  • (define 命令を使った)マクロ定義では、マクロ名は直ちに展開されるが、本体は使われるまで展開されない
  • ルールでは常にコマンドが後で展開されるが、ターゲットと必須項目は直ちに展開される

『GNU Make 第3版』 Robert Mecklenburg 著、矢吹 道郎 監訳、菊池 彰 訳 Copyright 2005 O'Reilly Media, Inc., ISBN4-87311-269-9

ここで言及されているように、第1段階と第2段階が分かれていることが非常に重要です。また、この段階の中間にもう一つ評価フェーズを差し挟むことができます。SECONDEXPANSIONです。

SECONDEXPANSIONは必須項目の生成に%以外の自動変数($@, $*など)や関数、マクロを使うときに使用します。これからMakefileを書く場合は、デフォルトで有効にしておくことをおすすめします。

こちらにわかりやすい例が載っていますが、ここでは割愛します。あとで戻ってきましょう。

SECONDEXPANSIONは常に有効にしておくことをおすすめします。したがって、Makeの評価フェーズは3つになりました。

フェーズはGNUのサイトではImmediate Context, Deferred Contextと呼ばれています。SECONDEXPANSIONで評価されるフェーズに名前がないようなので、便宜的にSecondary Expansionと呼んでおくことにします。

Reading Makefiles (GNU make)

これを大雑把に整理すると、以下のようになります。

  1. Immediate Context
    • 単純変数:=を定義。右辺を評価して値をセット
    • 再帰変数=を定義。右辺は評価せず、単なる文字列として一時保存
    • マクロdefineを定義。内容は評価せず、単なる文字列として一時保存
    • ルールのターゲット(target) と必須項目(prerequisites) を評価。ルールに含まれるコマンドラインは評価しない
    • 条件分岐命令(Conditional Directives)を評価
      • ifeq, ifneq, ifdef, ifndef
  2. Secondary Expansion
    • ルールの必須項目(prerequisites) を再評価。Immediate contextで評価された結果、変数や関数が現れたらそれを評価することになる
  3. Deferred Context
    • すでに評価済みの値は単に文字列として残っている状態。この時点でMakefile内からはifeq, ifneq, ifdef, ifndefが消滅しており、単純変数は文字列として展開されていることをイメージするとよい
    • 依存関係を解決し、レシピを1行ごとに実行していく
      • 再帰変数を評価
      • マクロを評価
      • 関数を評価
      • すべての評価が終わったら、残された文字列をコマンドとして実行

以下に例を示します。

# Secondary Expansionを有効化
.SECONDEXPANSION:

# immediate: 単純変数srcを宣言して、右辺を評価
src := hoge.in
# immediate: 再帰変数dateを宣言して、右辺は評価しない
date = date +"%Y-%m-%d %H:%M:%S.%N"

.PHONY: all
all: hoge.out

# immediate: ターゲットhoge.outを依存関係グラフに追加
# immediate: 必須項目 hoge.in $$(addsuffix .spice,$(src)) を評価
#            $(src) を評価して値hoge.inを得る
#            $$(addsuffix...) は$エスケープを解除
#            結果として $(addsuffix .spice,hoge.in) が残る
# secondary: 必須項目 hoge.in $(addsuffix .spice,hoge.in) を評価
#            関数addsuffixを実行
#            hoge.in hoge.in.spice が残る
hoge.out: hoge.in $$(addsuffix .spice,$(src))
  # deferred: date変数を評価し、入っている文字列 date +"%Y-%m-%d %H:%M:%S.%N" を得る
  #           文字列をコマンドとして実行
    $(date)
  # deferred: 自動変数 $^ を評価し、 @echo hoge.in hoge.in.spice を得る
  #           文字列をコマンドとして実行
    @echo $^
  # deferred: 自動変数 $< を評価し、 hoge.in を得る
  #           自動変数 $@ を評価し、 hoge.out を得る
  #           文字列をコマンドとして実行
    cat $< > $@
  # deferred: date変数を評価し、入っている文字列 date +"%Y-%m-%d %H:%M:%S.%N" を得る
  #           文字列をコマンドとして実行
    $(date)
$ make -f context.mk
date +"%Y-%m-%d %H:%M:%S.%N"
2020-04-11 17:39:10.740184000
hoge.in hoge.in.spice
cat hoge.in > hoge.out
date +"%Y-%m-%d %H:%M:%S.%N"
2020-04-11 17:39:10.770470000

Make命令(Directives)とMake関数

Makeの命令と関数は上記の通り、実行されるフェーズが異なります。非常に間違いやすいですが、ifeq命令はImmediate contextで評価され、$(if ...)関数はDeferred contextで評価されます。

これを理解しておかないと、「ifeqが常に真なんだけど…」ということになります。

よって、ifeqなどの条件分岐命令はMakefileの読み込み直後に結果が確定するものだけに使用し、コマンドの実行結果などで処理を変えたいものには$(if ...)関数を使用しましょう。

ifeq "$(MAKECMDGOALS)" "clean"
  $(warning cleanが呼ばれました!)
endif

define ok
  @echo $1 is ok!
endef

define ng
  @echo $1 is ng...
endef

.PHONY: all
all:
    $(if $(shell cat hoge.out),$(call ok,hoge.out),$(call ng,hoge.out))

clean: ;
$ make -f if.mk all
hoge.out is ok!

$ make -f if.mk clean
if.mk:2: cleanが呼ばれました!
make: 'clean' is up to date.

tabの扱い

Makefile中では、行頭にtabがある文字列はコマンドスクリプトとして扱われ、シェルに渡されます。逆に言えば、行頭にtabがないものはすべてMakeの命令として扱われます。行頭のスペースは無視されます。そして、tabがあるコマンドスクリプト中で呼ばれたマクロや変数を評価した結果には、行頭にtabが補われます。

これはマクロを作る場合には重要です。先程の例では、okngというマクロが定義されています。わかりにくいですが、@echoの前には2つのスペースが置かれています。これをallターゲット内のコマンドスクリプトで呼ばれたとき、@echoの前にはtabが補われ、シェルのechoコマンドが呼ばれたということです。

個人的Makefileベストプラクティス

ここからは、Makefileを書くにあたっての個人的ベストプラクティスをご紹介します。

ターゲットと必須項目はすべて事前にMakeの変数として宣言する

必須項目は原則としてソースファイルで、gitで管理されているリポジトリでは常にすべてのファイルが存在します。つまり、ソースファイルは単純変数にリストすることができます。

ファイルを探してくるには、ほとんどの場合Makeの関数で十分です。wildcardfilterなどで探してきましょう、複雑な場合はshell関数を呼んでも良いですが、最後の手段にしましょう。

srcs := $(wildcard src/*.yml)

そして、ターゲットも基本的にソースから推測できるはずです。

targets := $(subst src/,dist/,$(srcs))

これで、すべてのターゲットを作成する疑似ターゲットallが宣言できます。

.PHONY: all
all: $(targets)

dist/%.yml: src/%.yml
    cat $< > $@

これの何が嬉しいかと言うと、srcとdistのファイルを比較して、更新が必要なものだけ更新してくれるということです。これがすべての出発点です。

また、コマンドで指定する出力ファイル名には必ず$@でターゲット名を使いましょう。

シェルの制御構文を使わない

複雑なコマンドでファイルを生成したいとき、シェルのforifを使いたくなりますが、やめましょう。基本的にMakeの関数で代替できます。これにはいくつか理由があります。

  • Makeはデフォルトで実行コマンドを標準出力するため
    • CI環境や他の開発者の端末で実行されたコマンドそのものをログから得られますが、ifなど制御構文が入っていると評価結果が一見してわからなくなります
  • forで繰り返し処理するものはMakeに任せたほうが、順序や並列度の面で良いため

シェルのforifを使わずにMakeを書くためのTipsを紹介していきます。

$(if ...)関数

awscliを使ってECSサービスを登録するとき、すでにサービスが登録済みならupdate-service, まだならcreate-serviceを呼びます。これをMakeの$$(if ...)関数を使って書くとこのようにできます。

ecs/service.ymlをinputファイルとして使い、事前にaws ecs describe-serviceの結果をecs/describe-services.jsonに保存しているとします。

ecs/service-response.json: ecs/service.yml ecs/describe-services.json FORCE
      aws ecs $(if $(filter 0,$(shell cat ecs/describe-services.json| jq -r '.services|length')),create-service,update-service) \
        --cli-input-yaml file://$< > $@

これを実行したログは以下のようになります。

$ make ecs/service-response.json
aws ecs create-service \
        --cli-input-yaml file://ecs/service.yml > ecs/service-response.json

Makeの変数はコマンドになる前に展開されるため、ログには実行されたコマンドそのものが残ります。非常に明快ですね。

$(if ...)関数は、第一引数が空文字の場合true, そうでない場合falseになります。

第一引数が空文字かどうかチェックするにはwildcard関数やfilter関数が有用です。wildcard関数は第一引数に与えられたパターンに第二引数がマッチした場合、パターン文字列を返します。filter関数は第二引数で指定された空白区切りの文字列のうち、第一引数に与えられた文字列にマッチした要素のみを返します。

いずれも、条件にマッチしない場合は何も返しません。このため、==などの比較演算子がないMakeではこれらの関数を使います。

ここでは、$(shell ...)関数を使ってjqコマンドを呼び出し、その結果が0という文字列に一致する場合create-serviceという文字列を返し、そうでない場合update-serviceという文字列を返します。

Makeの評価結果は常にただの文字列になるというのが非常に重要なポイントです。

$(if ...)の結果でさらに他のMake関数やマクロを呼ぶことは可能です。少し前で書いた例がそれです。ifの結果で処理を大きく変えたいなら、trueとfalseの処理をそれぞれマクロにしておくのが良いでしょう。

.PHONY: all
all:
    $(if $(shell cat hoge.out),$(call ok,hoge.out),$(call ng,hoge.out))

$(foreach ...)関数

基本的にはMakeで繰り返しをさせるのは良くないものです。ターゲットをきちんと宣言すればMakeが賢く解決してくれるからです。ただ、そうとばかりも言っていられないこともあります。

例えば『make allが呼ばれたときには単にすべてのターゲットを生成するが、make testが呼ばれたらすべてのターゲットのテストコマンドを実行したい』といったときです。すべてのターゲットに対するテストを疑似ターゲットを使って宣言することで並列化ができるようになりますが、テストが少なく短時間で終わるなら、単純に以下のように書くことができます。

all: $(exports) $(templates)

.PHONY: test
test: all
    $(foreach template,$(templates),$(call cfn-lint,$(template)))
    $(foreach export,$(exports),$(call exports-lint,$(export)))

# -----------
# test macros
# -----------
# NOTE: foreachによって展開されるため末尾に空行が必要
# $(call cfn-lint,file)
define cfn-lint
  cfn-lint $1
  aws cloudformation validate-template --template-body file://$1

endef

# NOTE: foreachによって展開されるため末尾に空行が必要
# $(call exports-lint,file)
define exports-lint
if cat $1| jq -r 'to_entries[].key'| grep -vE '^\w+-\w+:\w+$$'; then echo "found invalid keys"; exit 1; else echo "OK"; fi

endef

コメントを入れていますが、$(foreach ...)でマクロを呼ぶ場合、マクロの末尾に空行が必要です。これは以下のような仕様があるためです。

再帰変数か define 命令を処理する際に、変数内の行またはマクロの本体が改行も含めて展開され ないまま保存されます。マクロ定義の最後にある改行はマクロの一部としては保存されません。マク ロが展開されるときにmake が最後の改行を補います。

『GNU Make 第3版』 Robert Mecklenburg 著、矢吹 道郎 監訳、菊池 彰 訳 Copyright 2005 O'Reilly Media, Inc., ISBN4-87311-269-9

マクロが展開されたときにmakeが最後の改行を補う、とありますが、実際に補ってくれるのは$(foreach)の呼び出しが終わったあとの1回だけで、個々のマクロの呼び出し時には補完してくれません。このため、末尾の空行がないと、$(foreach)が展開したコマンド行が改行されずに繋がってしまいます。

@でエコー抑制するのはechoprintfだけにする

上記でシェルの制御構文を回避できたら、そのログ出力は有用なものなります。コマンドの前に@を置いてエコーを抑制するのは、echoprintfだけで十分です。

レシピ内で固有の変数は$(eval)でMakeの変数を定義して使う

レシピ内にMakeの変数宣言を置くことはできません。また、コマンドスクリプトは(.ONESHELLを使わない限り)1行ずつ別々のサブシェルで実行されるため次の行で参照できません。このため$(eval)を使います。

-jで並列実行した場合に変数のスコープが独立するかどうかを示したドキュメントは見当たりませんでしたが、試した限り独立しているようです。同名変数がスレッドセーフに扱われるかどうか、仕様をご存知の方はコメントください。

.PHONY: all
all: test-1 test-2

test-%:
  # tabが前置されているので、以下はシェルで実行されシェル変数になる
    v_shell=$*
  # しかし、個々のコマンド行は別々のサブシェルで実行されるので、違う行では参照できない
    @echo v_shell is $$v_shell
  # Makeの変数を宣言しようとして、単にtabを前置しないだけだとシンタックスエラーになる
  # v-make := $*
  # variable.mk:13: *** recipe commences before first target.  Stop.
  # Makeの変数を宣言するには、$(eval)関数を使って、宣言文をDeferredフェーズで評価させる
    $(eval v-make := $*)
    @echo v-make is $(v-make) at context $@
    $(eval v-make-sleep := $*)
    sleep $*
    @echo v-make-sleep is $(v-make-sleep) at context $@
$ make -f variable.mk -j2
v_shell=1
v_shell=2
v_shell is
v_shell is
v-make is 2 at context test-2
sleep 2
v-make is 1 at context test-1
sleep 1
v-make-sleep is 1 at context test-1
v-make-sleep is 2 at context test-2

.PHONYの複雑な仕様

疑似ターゲットと呼ばれる.PHONYですが、わかりにくい挙動がいくつかあります。

たとえばtestターゲットを.PHONYにしておくのは、testという名前のファイルがもしあったとしても必ず実行してほしいからです。

.PHONY: test
test:
    @echo test
# カレントディレクトリにtestは無い
$ make -f phony.mk test
test
# testを作る
$ touch test
test
# .PHONY: testの行を消してから実行
$ make -f phony.mk test
make: 'test' is up to date.

GNUのマニュアルには『Phonyターゲットは実際のファイルの必須項目に指定してはいけない』とあります。

A phony target should not be a prerequisite of a real target file

Phony Targets (GNU make)

packageというファイルを作成する前に、必ずtestを実行したいとしましょう。packageターゲットの必須項目にtestを追加すればいいように思えますが、これは思ったとおりに動きません。

.PHONY: test
test:
    @echo test

package: package.in test
    cat $< > $@
$ make -f phony.mk package
test
cat package.in > package

package.inファイルを更新していないときはpackageの生成は必要ありませんが、testが必須項目にいることで毎回再生成されてしまいます。

これの回避策は、testを必須項目にせず単にmake test packageとして呼ぶか、必ずtestを実行したい場合はtestのレシピをマクロ化してpackageに含めてしまうことです。これでpackage.inが更新されたときだけ実行されます。

define test-cmd
  @echo test

endef

package: package.in
    $(call test-cmd)
    cat $< > $@

さて、.PHONYの役割はもう一つ、ショートカットコマンドを作ることです。複数のファイルを一気に生成したいときや、生成したいファイルが深い階層にいる場合はすべてターゲットを指定するのが面倒です。よくあるallターゲットです。

Makeではコマンドラインから任意のオプションやサブコマンドを渡すことはできず、単にターゲット名を指定します。このため、デプロイコマンドをMakeで作りたい場合、.PHONYターゲットを利用することになります。

.PHONY: deploy-1 deploy-2
deploy-1:
    @echo deploy 1

deploy-2:
    @echo deploy 2

デプロイコマンドは同じなので、これはパターンルールで記述してしまいたくなりますが、以下はうまく動きません。

# 注意: 正しくない例
.PHONY: deploy-%
deploy-%:
    @echo deploy $*
$ touch deploy-1
$ make -f phony.mk deploy-1 deploy-2
make: 'deploy-1' is up to date.
deploy 2

必須項目に現れた%文字は、ターゲット内の%文字がキャプチャした文字列に置き換えられます。.PHONYターゲットの必須項目として指定されたdeploy-%は、ターゲットに%がないため特に置き換えられず、単にdeploy-%という文字列として解釈されます。このためdeploy-1deploy-2は疑似ターゲットにならず、deploy-1というファイルがあると実行されません。この挙動はmake -pで確認できます。

これを回避する方法は、すべてのターゲットをきちんと宣言することです。といっても繰り返しは嫌なので、宣言をマクロにし、$(eval)関数で評価させてしまいましょう。

.PHONYの必須項目を関数で生成するので、.SECONDEXPANSIONが必要になります。

.SECONDEXPANSION:

# NOTE: foreachによって展開されるため末尾に空行が必要
define deploy-cmd
deploy-$1:
    @echo deploy $1

endef

targets := 1 2
.PHONY: $(addprefix deploy-,$(targets))
$(foreach t,$(targets),$(eval $(call deploy-cmd,$(t))))

makefile - GNU Make: Multiple PHONY Targets with Wildcard-like Functionality? (Passing different pre-processor directives to compiler) - Stack Overflow

ファイルを必ず再作成したい場合はFORCEを使う

.PHONYはファイルを生成しない処理に使います。そして、実ファイルの必須項目に疑似ターゲットを指定すると必ず実行されてしまうことを学びました。

これを利用して、自分以外が更新しているかもしれない情報や、実行するたびに変わるかもしれないデータをファイルに保存して使用したい場合、そのファイルの必須項目に空の.PHONYターゲットを指定します。慣例的に、これにはFORCEという空のターゲットが使用されます。

# ファイルがあっても強制実行
# NOTE: `;`は空のレシピを示すために必要
.PHONY: FORCE
FORCE: ;

myip.txt: FORCE
    curl -s -o $@ https://checkip.amazonaws.com/

myname.txt:
    whoami > $@

myconfig.json: myip.txt myname.txt
    @printf '{"address": "%s", "name": "%s"}\n' \
        "$$(cat myip.txt)" \
        "$$(cat myname.txt)" \
        > $@
# 1回目
$ make -f force.mk myconfig.json
curl -s -o myip.txt https://checkip.amazonaws.com/
whoami > myname.txt
printf '{"address": "%s", "name": "%s"}\n' \
        "$(cat myip.txt)" \
        "$(cat myname.txt)" \
        > myconfig.json

# 2回目
$ make -f force.mk myconfig.json
curl -s -o myip.txt https://checkip.amazonaws.com/
printf '{"address": "%s", "name": "%s"}\n' \
        "$(cat myip.txt)" \
        "$(cat myname.txt)" \
        > myconfig.json

1回目の実行ではmyname.txtが生成されていますが、2回目はされていません。

Makeをタスクランナーとした使う場合、タスクの結果をファイルに保存してターゲットにする

関数やコミットと同じように、Makefileのターゲットも最小単位まで分割することを検討しましょう。

ソースをコンパイルしてバイナリを得るなどの場合はファイルの実体があるので想像しやすいのですが、Makeをタスクランナーとして使ってデプロイなどを実行する場合、それぞれのコマンドの出力やデプロイ結果をファイルに出力することでターゲットを単純なファイルにすることができます。

例えば、私のプロジェクトでECSサービスを更新するためには以下の操作が必要です。

  1. CloudFormation スタックの出力(Outputs)を取得
  2. ECRの最新のタグを取得
  3. ここまでの結果を使って、ECSタスク定義のパラメータファイル (aws ecs create-task --cli-input-yamlに渡すファイル) をテンプレートから生成
  4. ECSタスク定義を登録
  5. 既存のECSサービスを取得
  6. ここまでの結果を使って、ECSサービスのパラメータファイル (aws ecs create-service --cli-input-yamlに渡すファイル) をテンプレートから生成
  7. ECSサービスを登録or更新

これを、個々の操作結果をすべてファイルに保存することで個別のターゲットを作ります。

Note

  • $(CFN_ENV) にはデプロイ先のAWSアカウントを区別するための環境名が入ります
  • 実際の必須項目は非常に多いので簡略化しています
# 1. CloudFormation スタックの出力(Outputs)を取得
exports/$(CFN_ENV)-%.json: FORCE
    aws cloudformation describe-stacks --region $* \
        | jq '[.Stacks[]| select(has("Outputs")).Outputs[]| select(has("ExportName")) |{key:.ExportName, value:.OutputValue}] |from_entries' \
        > $@

# 2. ECRの最新のタグを取得
exports/$(CFN_ENV)-latest-%.json: FORCE
    aws ecr describe-images \
      --filter tagStatus=TAGGED --no-paginate --repository-name $* \
      | jq -r '{"Tag": .imageDetails|sort_by(.imagePushedAt)| .[-1].imageTags[0]}' \
      > $@

# 3. タスク定義パラメータファイルの生成
.PRECIOUS: dist/$(CFN_ENV)/ecs/%-task-definition.yml
dist/$(CFN_ENV)/ecs/%-task-definition.yml: $(task-prereqs)
    @mkdir -p $(dir $@)
    gomplate $(GOMPLATE_OPTS) -f ecs/task-definition.yml -o $@ -d <datasources...>

# 4. タスク定義のデプロイ
.PRECIOUS: dist/$(CFN_ENV)/ecs/%-task-definition-response.json
dist/$(CFN_ENV)/ecs/%-task-definition-response.json: dist/$(CFN_ENV)/ecs/%-task-definition.yml FORCE
    @echo "registering task definition ..."
    aws ecs register-task-definition --cli-input-yaml file://$< > $@

# 5. 既存サービスの取得
.PRECIOUS: dist/$(CFN_ENV)/ecs/%-describe-services.json
dist/$(CFN_ENV)/ecs/%-describe-services.json: dist/$(CFN_ENV)/ecs/%-task-definition-response.json FORCE
    aws ecs describe-services --cluster $*-ECSCluster --service $*-ECSService \
        | jq '{"services": .services|map(select(.status!="INACTIVE"))}' \
        > $@

# 6. サービスパラメータファイルの生成
.PRECIOUS: dist/$(CFN_ENV)/ecs/%-service.yml
dist/$(CFN_ENV)/ecs/%-service.yml: $(service-prereqs)
    gomplate $(GOMPLATE_OPTS) -f ecs/service.yml -o $@ -d <datasources...>

# 7. サービスのデプロイ
.PRECIOUS: dist/$(CFN_ENV)/ecs/%-service-response.json
dist/$(CFN_ENV)/ecs/%-service-response.json: \
 dist/$(CFN_ENV)/ecs/%-service.yml\
 dist/$(CFN_ENV)/ecs/%-describe-services.json\
 dist/$(CFN_ENV)/ecs/%-task-definition-response.json FORCE
      aws ecs $(if $(filter 0,$(shell cat dist/$(CFN_ENV)/ecs/$*-describe-services.json| jq -r '.services|length')),create-service,update-service) \
        --cli-input-yaml file://$< > $@

このようにすることで、複数のサービスを同時に更新しようとしたとき、-jオプションで並列実行させることが可能です。

また、残しておいて後で結果を確認したいファイルには.PRECIOUSをつけておかないと、中間ファイルとみなされてMakeが消してしまいますので、必要に応じてつけてください。

このデプロイは一連のコマンドセットなので、1つの疑似ターゲットにコマンドを並べることもできますし、設計上そちらが正しい場合もあります。冒頭に書きましたがターゲットと必須項目は基本的にすべてファイル名であるため、ファイルを生成する処理は個別のターゲットにしたほうが変更に強いと考えています。

まとめ

Makeは癖があり、理解するまでが大変かもしれません。しかし、評価の仕組みと数えるほどしかない関数を覚えてしまえば、インストールが容易で高速に動き、コマンドを並べるだけで気の利いた並列実行までしてくれる便利なツールです。言語の知識を要求されることもありません。dockerが普及した今こそ、Makeを使う価値があるのではないでしょうか。

14
9
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
9