Makefileを使った手順書レスな開発環境構築

:::note info
書いてあること
- 開発環境構築手順をMakefileで実現している実例
- そのままでは使えないかもしれないけど、アレンジすれば使えるかもしれないコード片
- ツール選定の考え方やMakeを使うにあたって気をつけたこと
:::

:::note warn
書いてないこと
- Makefileの文法の説明
- 登場するコマンドの仕様の説明
:::

# はじめに

atama plusのkiraです。社内エンジニアの生産性を高めるためのPlatform Engineeringチームのとりまとめをしています。

この記事は[atama plus Advent Calendar 2023](https://qiita.com/advent-calendar/2023/atamaplus)の13日目の記事になります。

この記事では開発環境構築手順をMakefileを用いて実現している実例を紹介します。atama plusの事情に特化している実例なのでそのままは使えないかもしれないですが、自分たちの工夫が参考になればとても嬉しいです。

# Before

自分がatama plusに入社した2021年12月頃は、開発環境構築手順がドキュメントとして散在していました。

- 開発環境構築手順はコンフルのドキュメントとして存在
  - ただし必ずしも最新の状況に追従しているとは限らず、失敗したら周りに聞きましょうというスタンス
- 開発環境構築手順の前半はコンフルにあるが、後半がGitHubのwikiにある
  - ただし必ずしも最新の状況に追従しているとは限らず（以下略）
- GitHubのwikiからREADME.mdのリンクを辿って中身を見る必要がある
  - ただし（以下略）

そのため、開発環境構築は「ある程度試行錯誤するもの」という位置付けであり、オンボーディングとしては長めの準備期間と専任で質問に答えてくれるオンボ隊長がつくというサポートがありました（めちゃめちゃありがたかったです）。

オンボ期間としては十分な準備時間があり他のエンジニアは進度に合わせてプロダクトチームの実装チケットを取っていくことが多いのですが、自分はPlatform Engineeringチームに配属されることが予め分かっていたのでこれもチーム業務の一部になるだろうと考えて開発環境構築プロセスの改善を始めました。

# ツール選定の考え方

以下のような検討過程を経て、使うツールを決めていきました。

- 自然言語の手順書のままではメンテナンスが難しそう
  - 自然言語の手順書では手順の正しさを担保しにくい、一部現状にそぐわないものがあっても気付かれずに放置されがち
  - 手順書更新に際してソースコードのようなレビュー体制が確立されていない
- コード管理できるようにしよう！
- インストールが必要なツールだと最初の段階で手順書がいるので、できれば最初から入っているものを使いたい
  - atama plusではMacBook Proが標準支給されるので、macOSに最初から入っているものだと望ましい
  - この時点でAnsibleやTaskは一旦除外
- Python、シェルスクリプト、Makeあたりが候補になりそう
  - Pythonやシェルスクリプトだとコマンドライン引数の処理等でコードが必要になりそう
- Makeにするか

状況的にMakeが良いと判断しましたが、本来Makeは多目的なタスクランナーではないので多少ハック的な使い方になってしまいます。[Makefile警察](https://qiita.com/schrosis/items/12b4361c528819d13901)にも捕まりそうです。それでもmacOSに最初から入っている点を重要視しました。

# After

これ以降で実例を紹介します。説明の都合上関係している箇所を抜粋しているので、実際のMakefileとは並びが異なっています。

## 自己ドキュメント化

```makefile
.DEFAULT_GOAL := help

.PHONY: help
help: ## Print rules (https://postd.cc/auto-documented-makefile/)
	@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
```

いきなりおまじないになってしまい恐縮ですが、これがあるとMakefileの説明をMakefile自身にさせることができます。

- ルール行（コロンの前にターゲットが書かれた行）の`##`から始まるコメントをそのターゲットの説明文とみなします。
- `.DEFAULT_GOAL`に`help`を指定することで、引数なしで実行した際にターゲット一覧とそれぞれの説明を表示します。

[元ネタ](https://postd.cc/auto-documented-makefile/)から`sort`コマンドを抜くことでMakefileに書かれた順に表示するようにしていること以外は元ネタの通りです。このような表示が出ます。

![make_help.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618348/d53f02a4-5875-b3c3-8a1c-80e0664f0f13.png)

## 色付きのメッセージを出す

```makefile
define red
@tput setaf 1 && echo $1 && tput sgr0
endef

define green
@tput setaf 2 && echo $1 && tput sgr0
endef
```

メッセージをリッチにすべく赤や緑の表示ができるようなマクロを定義しています。このように使います（この後の例にも出てきます）。

```makefile
	$(call green,"==> pyenv is detected")
	$(call red,"==> pyenv is not found")
```

このように表示されます。

![make_message.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3618348/def95581-8133-1509-a51d-e029de82cb0a.png)

## Python環境準備

```makefile
PYTHON3_VERSION := $(shell cat pyproject.toml | grep "requires-python" | cut -d "=" -f 2 | tr -d " '\"")

PIP := .venv/bin/pip3
PIP_SYNC := .venv/bin/pip-sync

.PHONY: python_packages
python_packages: $(PIP_SYNC) ## Prepare python packages
	$(PIP_SYNC) requirements-dev.txt

$(PIP_SYNC): .python-version requirements-piptools.txt
	python3 -m venv .venv
	$(PIP) install -r requirements-piptools.txt

.python-version:
ifneq "$(shell command -v pyenv)" ""
	$(call green,"==> pyenv is detected")
	pyenv install --skip-existing $(PYTHON3_VERSION)
	pyenv local $(PYTHON3_VERSION)
else
	$(call red,"==> pyenv is not found")
	@exit 1
endif
```

`python_package`ターゲットにより、Python環境準備の手順が順に走るようになっています。Makeでは書いた順に処理が実行されるのではなく、あるターゲットを実行する前に依存する前提ターゲットを先に実行するようになっています。

- `python_package`ターゲットにより、必要なライブラリ群が用意された状況にします。
- `pip-sync`コマンドを実行するのですが、`pip-sync`コマンドがある状態を別のターゲットで用意します。
- `pip-sync`コマンドはPython仮想環境を作った後に`pip`コマンドからインストールします。`pip-sync`コマンドのバージョンは`requirements-piptools.txt`に書かれており、このファイルにも依存しています。また、そもそも指定されたバージョンのPythonが必要になるので、これも別のターゲットで用意します。
- 任意のバージョンのPythonを用意するために[pyenv](https://github.com/pyenv/pyenv)を使っています。`pyenv`は、そのフォルダで使うPythonのバージョンを`.python-version`に記録する仕様になっているので、このファイルの有無でPythonが用意されているかを判別しています。
- これまでの流れで、`pyenv`の場所を指定したターゲットを書きたくなります。しかし、ここでIntel macとM1/M2/M3 macにおいて`brew`でインストールされたコマンドのパスが違うという問題が生じました。atama plusではIntel macとM1/M2/M3 macの両方が存在しているので両者に対応する必要があります。そのため、`ifneq`ディレクティブを使ってパスに依らず`pyenv`コマンドが実行可能か調べ、実行できればPythonを用意、できなければ案内を出すようにしています。

## Node.js環境準備

```makefile
NODE_VERSION := $(shell cat package.json | grep '"node"' | head -n 1 | tr -d ' ",' | cut -d ':' -f 2)
NPM_VERSION := $(shell cat package.json | grep '"npm"' | head -n 1 | tr -d ' ",' | cut -d ':' -f 2)

.PHONY: node_packages
node_packages: .node-version ## Prepare node packages and generate static files
	npm install
	npm run build

.node-version:
ifneq "$(shell command -v volta)" ""
	$(call green,"==> volta is detected")
	node --version # ensure node by volta
	node --version | tr -d v > $@
else
ifneq "$(shell command -v nodenv)" ""
	$(call green,"==> nodenv is detected")
	nodenv install --skip-existing $(NODE_VERSION)
	nodenv local $(NODE_VERSION)
	npm -g install npm@$(NPM_VERSION)
else
	$(call red,"==> volta/nodenv are not found")
	$(call red,"==> Try 'brew install volta' or 'brew install nodenv'")
	@exit 1
endif
endif
```

`node_package`ターゲットにより、Node.js環境準備の手順が順に走るようになっています。

- `node_package`ターゲットにより、必要なライブラリ群が用意され、必要な静的ファイルが作られた状態にしています。指定されたバージョンのNode.jsは別のターゲットで用意します。
- 任意のバージョンNode.jsを用意するために[volta](https://volta.sh/)または[nodenv](https://github.com/nodenv/nodenv)を使っています。どちらか片方に揃えたい気持ちはあるのですが、それぞれの派閥が存在するので両方に対応しています。こちらでもコマンド実行が可能であるかを`ifneq`ディレクティブを用いて判別しており、`volta`が実行できれば`volta`を、`nodenv`が実行できれば`nodenv`を使って必要なランタイムを用意しています。`volta`は使うNode.jsのバージョンを`.node-version`に記録する仕様ではないのですが、同一ターゲットとして機能させるために`nodenv`の仕様に合わせた`.node-version`を生成する処理を含めています。

## コンテナのビルド・起動・停止

```makefile
COMPOSE := docker compose -f docker-compose.base.yml -f docker-compose.legacy.yml

.PHONY: build
build: ## Build docker images
	$(COMPOSE) build web frontend db worker

.PHONY: up
up: ## Create and start docker containers
	$(COMPOSE) up -d frontend db worker

.PHONY: down
down: ## Stop and remove docker containers
	$(COMPOSE) down
```

コンテナの操作もMakeから実行できるようにしています。素直に`docker compose`コマンドを打てればいいのですが、複数の`docker-compose.yml`を読み込む必要があったり、ビルドや起動で指定するコンテナが異なったりして、毎回手打ちするのは大変です（コード例は公開用に一部改変しており、実際はコンテナ指定のところに10個超のコンテナ名が並んでいます）。

## 開発用のDBダンプファイルの取得・リストア

```makefile
LATEST_DUMP_FILE := tmp/rc/latest_dump.sql

.PHONY: restore_database
restore_database: download_dump ## Restore database from the latest downloaded database dump file
	$(COMPOSE) run --rm web python manage.py restore --set-password /$(LATEST_DUMP_FILE)

.PHONY: download_dump
download_dump: $(LATEST_DUMP_FILE) ## Download the latest database dump file

$(LATEST_DUMP_FILE):
	$(COMPOSE) run --rm web python manage.py get_dump_sql
	mv $$(ls tmp/rc/*_light_rc.sql | tail -n 1) $(LATEST_DUMP_FILE)
```

atama plusではローカル環境での開発に際して、動作確認用のDBダンプファイルを取得しリストアする必要があります。手順のそれぞれの部分は既にmanagementコマンドとして実装されているので、それらを繋ぎ合わせたものを用意しています。managementコマンドの引数は独自仕様なので気にせず、こういうこともやってるんだという程度に見てください。

- `restore_database`ターゲットにより、DBのリストアを実行します。DBダンプファイルがローカルにあることが前提になっているので、別のターゲットで用意します。
- `download_dump`ターゲットにより、最新のDBダンプファイルを用意します。$(LATEST_DUMP_FILE)の有無を見ています。
- `$(LATEST_DUMP_FILE)`ターゲットにより、DBダンプファイルのダウンロードを実行します。ダウンロード処理そのものはmanagementコマンドに書かれており、ここではmanagementコマンドを実行した後に最新のDBダンプファイルをターゲットに合わせてリネームしています。

# Makeを使うにあたって気をつけたこと

ツール選定の考え方に書いたように、Makeを多目的なタスクランナーとして使うことは本来の用途から外れています。また古いツールであるためバッドプラクティスが多く、独自の仕様を習得するコストがかかります。なので、以下のようなことに気をつけて使うようにしています。

- [奥が深い症候群](http://0xcc.net/misc/bad-knowhow.html)に陥らないようにする。
  - Makefileの代入にはいくつかの種類（`=`、`?=`、`:=`、`::=`、`+=`、`!=`）があります。それぞれ適切に使えば記述量を減らせますが、仕様を覚えていられないので原則として一般的なプログラミング言語の代入と同じ挙動をする`:=`のみを使うようにします。
  - 特殊変数にもいくつかの種類があります（`$@`、`$<`、`$^`、`$?`、`$+`、`$*`）。それぞれの仕様を覚えておくのは大変です。これらも基本的には使いません。
- 複雑な処理はMakefile内でやらない。
  - Makefile内の処理実行は1行毎のコマンド実行になるので、条件分岐を含んだコマンドを書こうとすると、バックスラッシュによって区切られた実際は1行になるシェルスクリプトを書くことになります。シェルスクリプトのif文でさえ慣れない人が多いので、それがバックスラッシュで区切られてMakefile内に書かれていたら大半の人は拒絶反応を起こすと考えます。
  - 複雑な処理は素直に他のエンジニアが慣れたツールで書くようにします。

中長期的に見て自分を含むエンジニア達がハッピーになりたいので、知識を持った一部のチームだけがメンテナンスできるという状況を避け、なるべく多くのエンジニアが触れることをめざしています。

# Future Work

まだまだ伸びしろがあります。自分としてはもっと改善していきたいです。

- 今回のMakefileはリポジトリをクローンしてから使えるものになります。実際のatama plusの開発では複数リポジトリをクローンする必要があります。一つのリポジトリをクローンすれば、必要なリポジトリ群を全てクローンしそれぞれのセットアップを終えるという状況にしたいです。
- リポジトリにMakefileがあることを標準とし、ターゲット名を揃えることで認知負荷を減らしたいです。例えば、どのリポジトリでも`make prepare`、`make run`と打てば基本的なことができるようになっているイメージです。
- 気をつけて使ったとしてもMakeの仕様はやはりとっつきにくいです。これからの若いエンジニアに多少なりとも知ってもらうのは忍びないので（当初の検討から外しましたが）[Task](https://taskfile.dev/ja-jp/)のようなモダンなツールを使いたいと考えています。
  - とは言え、初手がMakeから始まるのは悪くないと考えているのでMakefileからTaskをインストールし（多少二重管理にはなりますが）MakeのターゲットをTaskに渡すようにすれば両立できるのではないかと目論んでいます。
  - 特定のタスクランナーの標準として社内で浸透すれば晴れてMakeを使わないようにできると思うのですが、少し時間がかかりそうです。

# おわりに

Makefileを使って手順書レスで開発環境構築をしている実例を紹介しました。

古いツールの複雑な仕様を理解したときにはついできるからと言って使いたくなりますが、その気持ちをおさえて用法用量を守って使うならば現代の開発でも十分に役立つのではないかと考えています。

明日のアドベントカレンダー14日目はProjectormatoさんによる「Ionic Framework をv7にupdateした話」です。お楽しみに。

最後まで読んでいただき、ありがとうございました。