タスクランナーとしてのmakeを使う際の工夫と注意点

SREチームの長田です。

みなさま開発・運用上の定形オペレーションに伴うタスク実行をどのように管理していますか? 今回は make をタスクランナーとして使う例を紹介します。

タスクランナーがほしい

タスクランナーを使う主なモチベーションは以下の2つです。

  • タスクをリスト化したい
  • タスクの実行インターフェイスを統一したい

タスクがリスト化されていれば、それ自体が生きたドキュメントとして機能します。

また、タスクの実行インターフェイスが統一されていれば、 例えばタスクに前処理や後処理を追加したとしても、 開発・運用メンバーが実行するべき操作が変わることはありません。 操作変更の周知コストも下がりますし、変更に伴う操作ミスも減らすことができます。

タスクランナーに求めるもの

タスクランナーの機能としては必要最低限のものがよいと考えています。 高機能なタスクランナーも魅力的ではあるのですが、タスクランナーの機能を使いすぎると、 その部分のメンテナンスコストが高くなってしまいます。

開発・運用しているプロダクトの寿命よりもタスクランナーの寿命のほうが短い場合、 その都度移行コストがかかってしまいます。

どんなツールを導入する際でも言えることではありますが、 SREが関わるプロダクトは長期運用が前提となっているものが多いので、 ツール類の選定には気を使います(継続的にメンテナンスされているか、一時の流行ではなく十分に枯れているか、など)。

そんな要件を満たしてくれるツールとして make を選択しました。

make をタスクランナーとして使う選択肢

www.gnu.org

本来 make はファイル間の依存解決を行い成果物であるファイルを生成するためのツールです。 タスクランナーとして使うのは目的外利用という見方もありますが、 タスクの結果=成果物 とみなせばそう乖離した利用方法ではないのではないでしょうか。

ツールとしては十分に枯れていますし、 大抵のOS標準パッケージマネージャでインストールできるのでセットアップの手間も少ないです。

同等に枯れていてどこでも使える方法として生の shell script や perl などがありますが、 これらは make と比べるとできることが多すぎ、タスクランナーとしては機能過多であると考えています *1

なお、本記事は GNU make に限った話とさせていただきます。 BSD make なども同様の使い方ができるとは思われますが、検証等は行っておりませんのでご了承ください。

make の使い方

make を使うためにはその動作を記述した Makefile が必要です。 Makefile は基本的には以下のような構成になります。

{ターゲット名}: {依存ターゲット}
    {実行するコマンド}

「ターゲット名」としてタスク名を、「実行するコマンド」としてタスクの本体を記述するわけです。 タスク実行のために必要な別のタスクがあれば「依存ターゲット」に記述します。

例えばタスクの前準備として script/before.shsrc/foo.txt を生成し、 タスク本体として script/one.sh script/two.sh を順に実行する run というタスクを定義する場合、 以下のように書きます。

run: src/foo.txt
    ./script/one.sh
    ./script/two.sh

src/foo.txt:
    ./script/before.sh

より具体的な例として、AWS Lambda にデプロイするためのツール lambroll を使った Lambda function のデプロイタスクを定義してみます。

関連ファイルは以下のように配置されています。

.
├── Makefile
├── bootstrap
├── function
├── function.json
└── src
    └── main.go

Lambda function をデプロイする際の手順は以下の通りです。

  1. Lambda function 本体であるバイナリをビルドする(ソースはgo)
  2. デプロイ対象ディレクトリ function/ に、Lambda function のエントリポイントである bootstrap ファイルと、ビルドした function 本体バイナリを配置する
  3. ディレクトリ function/ の準備ができたら lambroll deploy コマンドを実行して Lambda function をデプロイする

これを実現するための Makefile は以下のようになります。

.PHONY: deploy
deploy: build
    lambroll deploy --function function.json --src=function

.PHONY: build
build: function function/bootstrap function/app

function:
    mkdir -p $@

function/bootstrap: bootstrap
    cp $< > $@

function/app:
    cd src && go build -o $(basename $@)
    mv src/$(basename $@) $@

.PHONY: clean
clean:
    rm -rf function

.PHONY はターゲット名と同名のファイルが存在する場合に、 ファイルの状態とは関係なくターゲットを実行することを指定する特別なターゲットです。

Makefile 中に登場する $ で始まる変数は自動変数です。

  • $@ ターゲット名
  • $* ターゲット名の % にマッチした部分
  • $< 依存ターゲットのうち最初のもの

上記3つ以外にも多数の自動変数が用意されていますが、自動変数を多用すると Makefile の可読性が下がるので、 タスクランナーとして使用する場合は必要最低限に留めるのがよいでしょう。

次は make をタスクランナーとして使う場合の工夫を紹介しましょう。

環境ごとの設定値

アプリケーションのデプロイオペレーションをタスクとして定義する場合を考えます。 デプロイ先として本番環境とステージング環境があるとします。

タスクの大部分は共通ですが、デプロイ先の環境ごとに異なる設定値が必要になる場合があります。 デプロイ先の情報や、環境固有の設定値などがこれに当たります。

タスク定義を共通化しつつ、環境別の設定値を定義する方法として、 symlink と Makefile の include 機能を組み合わせて利用しています。

以下のように環境ごとにディレクトリを分けた構成をつくり、 共通で利用するファイルを symlink で参照しています。

.
├── production
│   ├── Makefile
│   ├── Makefile.inc
│   └── deploy.sh
└── staging
    ├── Makefile -> ../production/Makefile
    ├── Makefile.inc
    ├── deploy.sh -> ../production/deploy.sh
    └── shutdown.sh

symlink の参照方向からわかるように、基本的には production 用に定義された Makefile を使用します。

Makefile 内で環境用の設定値が変数として記述されたファイル (Makefile.inc) を include することで、 タスクの定義は共通化しつつ、環境ごとの設定値を利用することができます。

変数だけでなく、ターゲットも環境ごとに定義できるので、 「staging環境でのみ行う操作」をタスクとして定義することもできます。

以下の例では、環境ごとに異なる変数 AWS_ACCOUNT_ID を定義しています。 また、production 環境では実行したくない、何らかのシステムを停止する shutdown ターゲットを staging 環境用に追加で定義しています。

# production/Makefile
DEPLOY_ENV := $(shell basename `pwd`)

include Makefile.inc

.PHONY: deploy
deploy:
    @echo "Deploying to ${DEPLOY_ENV}"
    deploy.sh

.PHONY: dryrun
dryrun:
    @echo "Deploying to ${DEPLOY_ENV} (dry run)"
    deploy.sh --dry-run
# production/Makefile.inc
export AWS_ACCOUNT_ID := 111111111111
# staging/Makefile.inc
export AWS_ACCOUNT_ID := 222222222222

.PHONY: shutdown
shutdown:
    @echo "Shutdown ${DEPLOY_ENV}"
    shutdown.sh

Makefile の多層化

たとえばコンテナイメージをビルドする場合、イメージの種類ごとに異なる前処理が必要な場合があります。 そのような場合は Makefile を多層化することで、タスクの実行インターフェイスを共通化しつつ個別の処理を定義することができます。

親Makefile にはイメージの種類に依らない共通のタスクと、子Makefile のターゲット呼び出しを担当します。 子Makefile はイメージごとのタスクを定義します。

親Makefile で定義された環境変数は 子Makefile でも利用できます。 すべてのイメージで共通のビルド変数を定義する場合に重宝します。

.
├── Makefile
├── app
│   ├── Dockerfile
│   ├── Makefile
│   ├── build-assets.sh
│   └── upload-assets.sh
└── nginx
    ├── Dockerfile
    ├── Makefile
    ├── generate-nginx-conf.sh
    └── nginx.conf.tmpl
# Makefile
export APP_ENV := production
export DOCKER_REPO := repo.example.com
export IMAGE_TAG := $(shell git log -1 --format=%H)

build/%: clean/%
    cd $* && $(MAKE) build

push/%:
    docker push $(DOCKER_REPO)/$*:$(IMAGE_TAG)

clean/%:
    cd $* && $(MAKE) clean
# app/Makefile
.PHONY: build
build: build-assets
    docker build \
        --build-arg APP_ENV=$(APP_ENV) \
        --tag $(IMAGE_TAG) \
        .
    $(MAKE) upload-assets

.PHONY: build-assets
build-assets:
    ./build-assets.sh

.PHONY: upload-assets
upload-assets:
    ./upload-assets.sh

.PHONY: clean
clean:
    rm -rf assets
# nginx/Makefile
.PHONY: build
build: nginx.conf
    docker build \
        --build-arg APP_ENV=$(APP_ENV) \
        --tag $(IMAGE_TAG) \
        .

nginx.conf: nginx.conf.tmpl
    ./generate-nginx-conf.sh > $@

.PHONY: clean
clean:
    rm -f nginx.conf

例として挙げた Makefile は以下のように実行します。

$ make build/app

この場合、以下の順でコマンドが実行されます。

  1. ./Makefile の build/%
  2. ./app/Makefile の build-assets (build の依存対象)
    • ./build-assets.sh の実行
  3. ./app/Makefile の build
    • docker build の実行
    • ./upload-assets.sh の実行

ハマりどころ

最後にタスクランナーとして make を使用する場合の注意点を紹介しましょう。

make の機能を使いすぎる

先に「shell script や perl は機能過多」と書きましたが、 make も突き詰めれば有り余るほど高機能です。 ターゲット定義 → コマンド実行 だけを使用していればシンプルですが、 make の機能をふんだんに使った Makefile は読みづらくなりがちです *2

「make の使い方」でも触れたとおり、自動変数も多用すると読み解くのが難しくなります。 make をタスクランナーとして使用する場合は自動変数の使用は $* $@ $< 程度に抑えて、 あるいはそれすらも省いて、ベタに愚直に書くのがよいでしょう。

Makefile 内で頑張りすぎない

Makefile で実行するコマンドは shell script ですが、通常の shell script とは異なり1行毎にコンテキストが変わります。そのため複数行に分けて書きたい場合は、 文ごとに ; で区切り、行末に \ を付ける必要があります。

foo:
    if [ $(FOO) == 'foo' ]; then \
        echo 'this is foo'; \
    fi

このようにif文も複数行に分けて書くことができる・・・のですが、 複数文をまとめて実行する必要が出てきた場合は大人しく単体の shell script なり perl や ruby のスクリプトなりに切り出したほうがよいでしょう。

また、Makefile に書かれたコードは Makefile として一度評価された後にさらに shell script として評価されるので、 特に変数展開周りの把握が難しくなりがちです。

例えば、ある変数を shell script として評価したい場合はエスケープのために $ を二重に書く必要があります。 $ 一つの場合は Makefile 上の変数として解釈されます。

BAR := hoge

# 前者は Makefile 内で定義された HOME という変数
# 後者は shell script として評価された場合の HOMEという環境変数
foo:
    @BAR=piyo; \
    echo $(BAR); \
    echo $$BAR
$ make foo
hoge
piyo

少しでも把握に辛さを感じたら Makefile の中で頑張らずに外に追い出しましょう。

include と Makefile の多層化によりどの変数が使われるのかが分かりにくくなる

実務では先に紹介した include と Makefile の多層化を組み合わせて、 共通タスクを定義しつつ個別のターゲット・変数定義を行っていくことになります。

Makefileが育ってくると、あるディレクトリで make コマンドを実行した際に、 どの変数にどの値が使われているのかの把握が難しくなる場合があります。

階層ごとや、 include されるファイルについて、どの変数を定義するべきなのかをルール化しておくとよいでしょう。 変数名が衝突しないように、階層ごとにプレフィックスを付けるなどの工夫も有効かと思います。

あるいは、変数の定義を Makefile とは別のファイルに切り出してしまうのも手でしょう。 変数共通化は一旦あきらめて、必要な変数をすべて羅列したファイルを書くディレクトリに配置すれば、 どの変数が使われるか問題は発生しません。

ちなみに、Makefile に定義された変数は、 .VARIABLES という特別な変数を参照することで一覧することができます。 以下のようなターゲットを定義しておくと、変数定義状況の把握に役立つかもしれません。

list-variables:
    @$(foreach v,$(.VARIABLES),$(info $v=$($v)))

余談

最近ではタスクを GitHub Actions などの CI/CD サービス上で実行することも多くなりました。 実行者の環境によらず、誰が操作しても同じ環境で実行した結果が得られるというメリットがあり、カヤックでも多用しています。

しかし、個人的には CI/CD ツール上でしか動作しないタスクを定義するのは抵抗があります。 例えば GitHub Actions では公開されている Action を組み合わせることで簡便な記述で複雑なタスクを実行できますが、 障害などで CI/CD ツールが使用できない状態になった場合、タスクの実行が困難になってしまいます。

これを避けるために、タスクの定義は Makefile で行い、 CI/CD ツールでは実行環境を整えて make コマンドを実行するだけ、という形を取っています。 ローカルマシンからでもタスクの実行ができるので、滞りなく運用できるというわけです *3

とはいえ結局はプロダクトごとの方針によるところが全てなので、関係者内で 「GitHub Actions の機能をフルに利用する。Actions が利用できない際はそこに定義されたタスクの実行はあきらめる」 という合意が得られていれば問題はないでしょう。

不要な仕事を減らすためには便利なものは使っていきたいですしね。

まとめ

make をタスクランナーとして使い場合の工夫を紹介しました。 何事もやりすぎは運用・開発の複雑性が増すことになるので、ちょうどいい塩梅の使い方を見つけていただければ幸いです。

カヤックではあの手この手で運用コストを下げたいエンジニアを募集しています!

hubspot.kayac.com

*1:きちんと整備すれば生 shell script や perl も十分にタスクランナーとして機能しますが、 その整備の手間が惜しいというはなしです

*2:慣れないC++プロジェクトの Makefile を覗いて「読めない・・・」となったのは自分だけではないはず

*3:避難訓練的に、CI/CD ツールを使わずにタスクを実行してみる訓練もセットで必要になります。 普段からやっていないことを緊急事態に急にやるのは難しいので。