モノレポでマイクロサービスを開発するための戦略と運用

目次

はじめに

こんにちは。いっちー(@icchy_san)です。以前書いた「HireRoo のインフラ基盤を Cloud Run から Kubernetes へ移行しモノレポで管理し始めた話」の続きの記事です。

弊社では各サービスごとに管理していたリポジトリをモノレポ化しました。そこで本記事では弊社におけるモノレポでの開発ワークフローや設計、そしてビルド時間短縮の際に工夫したことを紹介します。

マイクロサービス+分散したリポジトリ管理からモノレポへ移行しようとしている人や、マイクロサービス+モノレポの構成に興味がある人の手助けになれば幸いです。

モノレポのメリット

モノレポとは、1 つのリポジトリで複数のプロジェクトを管理する方法のことです。そのため、例えば 2 つのマイクロサービスがあった場合、それぞれに 1 つずつリポジトリを用意するのではなく、1 つのリポジトリの中にディレクトリを用いて、コードを管理します。

├── README.md
└── microservices/
    ├── serviceA/
    └── serviceB/

このように管理することで次のようなメリットがあります。

  • 各チームで依存するサービスの最新バージョンの把握が容易
  • 1 つのリポジトリのみを管理するため、複数のリポジトリをクローンする必要がない
  • 共通するインフラの設定(terraform や CI の config)をまとめることができる
  • 開発時に複数のリポジトリを IDE などで開く必要がなくプロジェクト間の移動が容易

設計

ディレクトリ構成

分散したリポジトリ管理からモノレポに移行したことで次のような構造になりました。

.
├── Makefile
├── README.md
├── common
│   ├── charts
│   ├── messages
│   └── modules
├── gen
├── microservices
│   ├── algorithm
│   │   ├── app
│   │   ├── client
│   │   ├── hooks
│   │   ├── kubernetes
│   │   ├── proto
│   │   └── terraform
│   ├── …
│   └── translation
├── platform
│   ├── ambassador
│   ├── builder
│   ├── cert-manager
│   ├── datadog
│   ├── external-secrets
│   ├── infrastructure
│   ├── istio-system
│   ├── ms-toolkit
│   └── zoo
└── scripts
    ├── _lib
    ├── app
    ├── …
    └── terraform

それぞれに次のように分割しています。

  • common : サービスを横断して利用するファイルを管理するディレクトリで、中には helm の chart ファイルや各マイクロサービスの terraform で利用する module が配置されています。
  • gen : CI の中でコンパイルした proto ファイルを一時的に配置するディレクトリです。
  • microservices : 各マイクロサービスのプロジェクトコードを管理するディレクトリです。
  • platform : Kubernetes クラスタやその他共通で利用するインフラの設定を管理しているティレクトリです。
  • scripts : CI で利用する Bash スクリプトやローカルで認証で利用するトークンを発行する Bash スクリプトを管理しているディレクトリです。

ワークフロー(CI の全体像)

上図(図 1)は弊社モノレポ環境における CI のワークフローの全体像です。

ユーザーのアイコンを起点に右側は、appkubernetesterraformの変更があった場合に変更があるサービスを表しており、左側はprotoディレクトリに変更があった場合に影響のあるサービスを表しています。

行っていることは次のとおり:

  • proto ファイルをビルドしてパッケージリポジトリのJFrogにプッシュ
  • アプリケーションのビルドをトリガ(CloudBuild へリクエストを飛ばす)
  • Kubernetes リソースの変更適用
  • Terraform リソースの変更適用

開発者は、CI を通してプッシュされたパッケージを go getnpm iを用いて取得することで開発を行います。

サイクルとしては、

  1. proto の更新
  2. go getnpm iを用いてローカルで pb ファイルを取得
  3. 処理の実装
  4. terraform でリソースの変更が必要であれば、変更
  5. アプリケーションイメージ生成のために GitHub へプッシュ
  6. イメージ生成後 Kuberentes のdeployment.yamlで利用するイメージハッシュを更新
  7. GitHub へプッシュしてデプロイ

となります。

基本的に CI ベースのワークフローになっており、proto ファイルの更新に際して CI を通す必要があるため、他のサービスの最新コード、作成されているプルリクエストを把握することが容易になっています。

技術選定

Bash を利用する

弊社では CI のジョブ内で実行する処理には Bash スクリプトを用いています。

Bash は MacOS や Linux を利用しているほとんどの開発者が環境構築をすることなく利用できる点、CI で利用しているイメージに標準搭載されており、CircleCI に限らず他の CI にも簡単に移植可能である点、Bash を用いることでコマンドを直接実行することができる点がメリットとして挙げられます。

仮に、プログラミング言語で処理を書いてしまうと、Bash コマンドを実行したいだけにもかかわらず不要な宣言や関数の実行などが必要になったりします。また言語によっては環境構築が必要になるため、今回はなるべくシンプルに、をモットーに Bash を利用することにしました。

CircleCI を利用する

CircleCI を選択した背景としては

  • 開発者たちの学習コストをなるべく減らすこと
  • すでにある既存資産が利用できること

です。

弊社では以前より CircleCI を利用したワークフローを構築しており、開発陣も使い慣れており、CircleCI に関する学習コストもそこまで高くありません。

Cloud Run で運用していた時から利用している Cloud Build を用いたアプリケーションイメージのビルドジョブも再利用することができ、移行コストも削減できたため、CircleCI を利用するという判断をしました。

前節の「Bash を利用する」でも述べた通り、Bash スクリプトはポータビリティが高いため仮に他の CI に移行したとしても、大部分が再利用可能です。

ワークフローの運用方法

マイクロサービス独自の処理の追加

モノレポにする場合、CI のジョブが共通化されるため同じ処理を使い回すことができる反面、各マイクロサービスごとに独自の処理を入れることが難しくなります。

例えば、2 つのサービス A、B があり、A は Python で実装されているサービスで、B は Go で実装されているとします。このサービスのテストコードを実行したい場合、共通化された CI だと愚直なままだと実現が困難になります。なぜなら各サービスが「何の言語で書かれているのか」、また「テストコードの実行コマンドは何か」、を共通の CI の設定に記述する必要があるためです。

弊社ではこの問題に対して、hooksを用いて独自の処理を注入することで解決しました。

hooks自体は、Git Hooksを参考に設計しており、各ジョブ内にある特定処理の前後に別の処理を追加したい場合にpre_app_buildpost_app_buildのようなファイルを作成することでフックすることができるようになっています。

hooksで利用するファイルは各マイクロサービスのhooksディレクトリ内に配置し、ファイルは Bash スクリプトで記述されることが期待されます。

microservices/algorithm
├── app
├── …
└──  hooks
   └── pre_app_build

上記のディレクトリ構成で表示されている pre_app_build が該当の Bash スクリプトになります。

中身は

#!/bin/bash
set -eo pipefail
# for algorithm
pushd app/server
go test -v -tags test -race ./...
popd

# Upload source zip file for CloudFunctions
pushd app/functions/algorithm-event-log
apt-get update -y && apt-get install -y zip
go mod vendor && zip -r function.zip . -x "go.*"
gsutil cp function.zip "gs://algorithm-functions-prod/functions/algorithm-event-log/commit-$(git describe --always)/function.zip"
popd

というようになっており、テストの実行や、algorithm サービスでしか実行されないような Cloud Function 用の zip ファイル作成を行っています。

もしこのhooksがなかった場合、独自のビルドの扱いが非常に困難になるため、非常に重要な役割を担っています。

差分チェックによる CI の時間短縮

モノレポにする上で挙げられる課題として他にも管理するマイクロサービスが増えるたびに CI の時間が延びてしまうことがあります。

例えば 2 つのサービス A、B があり、

サービス開発者が自身の管理するアプリケーションイメージを生成して、Kubernetes の Pod のイメージを更新したいケースを考えます。

愚直な CI の場合、CI の設定ファイルが共通のものを利用しているため、サービス A を更新するためにサービス B のビルドも走ってしまいます。つまり、サービスが増えるたびにビルドの時間が増えてしまいます。弊社ではすでに 30 個ほどのマイクロサービスがあるため、仮に 1 つに 3 分要すると考えると、単純計算で 1 回の CI で 1 時間半待つ必要があることになります。

これは DX が非常に低く、とても現実的ではありません。

そこで、弊社では git の差分を用いてビルドの対象か、そうでないかを判断するようにしました。

実装としては次のとおりです。

# Script 1
# 変更のあるファイルをgitのdiffコマンドを用いてチェック
changed_files() {
	local basedir pattern current_branch
	basedir="${1}"
	pattern="${2:-".*"}"
	current_branch="$(git rev-parse --abbrev-ref @)"
	if [[ ${current_branch} == "main" ]]; then
		git diff --name-only "HEAD^" "HEAD" "${basedir}" | grep -E "${pattern}" | sort -u
	else
		git diff --name-only "$(git merge-base origin/HEAD HEAD)" "${basedir}" | grep -E "${pattern}" | sort -u
	fi
}
# Script 2
# 変更のあるファイルのあるディレクトリを取得する。
changed_service_dirs() {
	local basedir pattern
	basedir="${1}"
	pattern="${2:-".*"}"
	newdirs=()

	for file in $(changed_files "${basedir}" "${pattern}"); do
		newdirs+=("$(echo ${file} | cut -d/ -f-2)")
	done

	# shellcheck disable=SC2207
	uniq_dir=($(printf "%s\n" "${newdirs[@]}" | sort -u | tr '\n' ' '))
	echo "${uniq_dir[@]}"
}

こうすることで、まず差分のあるファイルから対象のサービスディレクトリを抽出します。

# Script 3
for service_dir in $(changed_service_dirs ${basedir} ${pattern}); do # 1
	# Loop through each cloudbuild yaml file, inside of service directory that has some change
	while read line; do # 2
		dir=$(dirname ${line}) # 3
		if directory_has_dockerfile ${dir}; then # 4
			echo "[INFO] Running 'scripts/app/build' for ${dir}/app on CircleCI"

			# trigger {pre,post}_app_build hook
			trigger_hook "$(get_service_path ${dir})/hooks/pre_app_build" # 5
			run_app_build ${dir} "$(get_service_version ${service_dir})-${metadata}" 2>&1 & #6
			trigger_hook "$(get_service_path ${dir})/hooks/post_app_build" # 7
		fi
	done <<<"$(find "${service_dir}/app" -type f -name '.cloudbuild.yaml')"
done

上記の Script 3 では次のことを行っています。

  1. changed_service_dirs 関数を用いて差分のあったサービスのディレクトリを取得
  2. 対象のディレクトリに .cloudbuild.yamlがあれば while 内の処理実行(ここでは CloudBuild を用いて Build をしているため .cloudbuild.yamlがない場合はビルドできないためスキップ)
  3. dir にディレクトリの PATH を取得し代入
  4. ディレクトリ内に dockerfile が存在するか確認(CloudBuild で利用するため確認が必要)
  5. 前節で述べたhooksで、pre_app_build がある場合に実行する
  6. バックグラウンドで CloudBuild へアプリケーションイメージ生成リクエストを送る処理
  7. build 完了後に実行するhookspost_app_buildがある場合に実行する

ここではアプリケーションにフォーカスしましたが、proto や Terraform、Kubernetes の実行も同様に差分を見て実行しています。

proto build

for service_dir in $(changed_service_dirs ${basedir} ${pattern}); do
	echo "[INFO] Running 'scripts/proto/build' for ${service_dir}/proto on CircleCI"
	# trigger {pre,post}_proto_build hook
	trigger_hook "${service_dir}/hooks/pre_proto_build"
	run_protoc_gen "${service_dir}/proto" "${destdir}" ${metadata} 2>&1
	trigger_hook "${service_dir}/hooks/post_proto_build"
done

terraform apply

for dir in $(changed_dirs ${basedir} ${pattern}); do
	if directory_has_terraform_configuration ${dir}; then
		echo "[INFO] Running 'scripts/terraform/apply' for ${dir} on CircleCI"

# trigger {pre,post}_terraform_apply hook
		trigger_hook "$(get_service_path ${dir})/hooks/pre_terraform_apply"
		run_terraform_apply ${dir} 2>&1
		trigger_hook "$(get_service_path ${dir})/hooks/post_terraform_apply"
	fi
done

kubernetes apply

for dir in $(changed_dirs "${basedir}" "${pattern}"); do
	cluster_env="$(get_cluster_env "${dir}")"
	if ! validate_cluster_env "${cluster_env}"; then
		echo "[ERROR] ${cluster_env}: invalid cluster env name" >&2
		exit 1
	fi

	# trigger {pre,post}_kubernetes_apply hook
	trigger_hook "$(get_service_path ${dir})/hooks/pre_kubernetes_apply"
	run_kubernetes_apply "${dir}" 2>&1
	trigger_hook "$(get_service_path ${dir})/hooks/post_kubernetes_apply"
done

このように差分のみをビルドの対象として扱うという方法はモノレポでマイクロサービスを管理する場合に非常に有効です。

こちらの図 2 は実際の CI にかかった時間を表しており、app_buildがアプリケーションイメージのビルド・テストを実行しているジョブになります。経過時間としては3m5sと現実的な時間になっています。

このように工夫次第で、モノレポにおける多くの課題は解決できると考えられます。

まとめ

本記事では、ワークフローの全体像、モノレポに移行のタイミングで工夫したことについて述べてきました。

マイクロサービスをモノレポで管理する上で避けては通れない「マイクロサービス独自のビルド処理の追加」、「ビルド時間の肥大化」の弊社における解決方法について述べてきました。

仮に愚直な設計にすると 1 時間半要するような処理も、今回紹介した方法で 3 分程度まで減らすことができました。また、hooksを組み合わせて利用することで各マイクロサービスごとに設定を追加することができ、柔軟性の高い設計になりました。

今後の予告

以下に今後公開を予定している記事のテーマを載せておきます。

  • Cloud Run vs Kubernetes

  • マイクロサービス開発における DX

  • Datadog 導入により変わったトラッキングの世界