この記事は CyberAgent Developers Advent Calendar 2024 3日目の記事です。
こんにちは。株式会社CAMで Backend / Platform Engineer をしている石川 諒(ishikawa-pro)です。普段は、Fensi Platform という CAM のサービス開発基盤のバックエンドの開発・改善などをしています。
この記事では、中規模組織で Platform Engineering を実践するために、Modular Monolith というアーキテクチャを採用した事例を紹介します。
前提
CAM には約60名のエンジニアが在籍しており、約30のサービスを開発・運用しています。そのような中規模組織の中で、「新R25」をリニューアルし、新たに3つのサービスを展開しました。企業向けPR活動支援SaaS「新R25 Business」、メディア開発者支援SaaS「新R25 Developers」、そして従来の「新R25」を刷新した「新R25 Media」です。
このプロジェクトでは、サービス開発者は20名程度(1チーム6〜7名)で、3つのサービスが連携しているため、それぞれの開発チームも連携して開発を進める必要がありました。
このような複数のチームが連携しつつ、効率的に開発を進める手段として Platform Engineering を実践しています。Platform Engineering は、サービス開発者の開発者体験と生産性を向上させるために、ツールやプロセスやインフラを整備して提供する取り組みのことです。Platform Engineering と聞くと、大規模組織での取り組みというイメージがありますが、私たちのような中規模組織でも Platform Engineering を実践することで、サービス開発者の生産性を向上させることができます。
Modular Monolith とは
はじめに、Modular Monolith とは何かを簡単に説明します。
Modular Monolith は、 Microservices のようにモジュール性を保ちながら、Monolith のように1つのアプリケーションとしてデプロイされるアーキテクチャです。 Modular Monolith のメリットとデメリットを簡単にまとめると下記のようになります。
メリット
- 単一のリポジトリで管理できる
- Module をビジネスドメインごとに分割することで、ビジネスドメインごとのコンテキスト境界を明確にできる
- CI/CD やサーバーなどのインフラ面の管理対象が少なくて済む
デメリット
- コンテキスト境界を維持しつづけるための設計や仕組みが必要
- モジュール間の依存関係が複雑になりがち
- Monolith と同じく単一のアプリケーションとしてデプロイされるため、スケーラビリティが低い
上記のように Modular Monolith は、Microservices のメリットと Monolith のメリットを併せ持つアーキテクチャです。しかし、コンテキスト境界をしっかりと維持し続けるための設計や仕組みがないと、あっという間にただの Monolith になってしまう可能性があるため、注意が必要です。
中規模組織における Modular Monolith と Platform Engineering の相性
私たちのような中規模組織において、Modular Monolith と Platform Engineering は相性が良いと考えています。その理由は3つあります。
対象を小さく留め、効率的な作業を可能にする
Modular Monolith は、1つのリポジトリにすべてのコードが集約されるため、Platform Engineering の対象を小さく留めることができます。そのため、サービス開発者が Platform チームの役割を兼任するような状況でも、少人数で効率的に Platform Engineering を実践することができます。
また、CI/CD やサーバーなどのインフラ面の管理対象も少ないため、 ツールやプロセスの改善、自動化がやりやすくなります。
セルフサービスの実現
セルフサービスとは、SRE チームや Platform チームなどに依存することなく、サービス開発者自身で必要な機能を追加したり、環境を構築したりすることができる状態のことです。
Modular Monolith では、Module を追加することでアプリケーションに新しい機能を追加することができます。つまり、CI/CD やサーバー構築などに時間をかけることなく、コードを追加するだけで新しい機能を追加できるため、簡単にセルフサービスを実現することができます。
ゴールデンパスの提供がしやすい
ゴールデンパスとは、開発者が効率的に作業を進められるための、推奨される一連の手順や道筋のことです。Platform Engineering の文脈では、開発者が迷わず目標に到達できるよう、最適化されたルートを提供することを目的としています。
Modular Monolith では、1つのリポジトリにコードが集約されているため、コードジェネレーターやテンプレートエンジンを使うことで、簡単にゴールデンパスを提供することができます。たとえば、新しい Module を追加する際に、コードジェネレーターを使って自動的に必要なファイルやディレクトリを生成することで、開発者が迷わずに新しい Module を追加できるようになります。また、テンプレートエンジンを使って、新しい Module の実装方法や設計方針を提供することも容易に実現可能です。
上記のような理由で、中規模組織において Modular Monolith と Platform Engineering は相性が良いと考えています。 以降では、CAM におけるサービス開発の課題を紹介し、どのように Modular Monolith を採用して Platform Engineering を実践しているかについて説明します。
CAMにおけるサービス開発の課題
CAM では、下記の図のように Fensi Platform という独自のプラットフォームで、 CAM のサービス開発において必要とされる機能を共通化し、それぞれの機能をリポジトリを分けて Microservices として提供しています。また、サービスごとにもリポジトリを分けて独自のサーバーを建てていました。
このような開発スタイルを約5年間続けてきましたが、次第に下記のような課題が浮かび上がってきました。
- リポジトリが増えすぎて管理が大変
- どこにどの機能が実装されているのかをリポジトリを横断して探すのが難しい
- 新しい共通機能やサービスを開発する際に、リポジトリのセットアップや環境構築に時間を取られる
- ローカル開発環境の構築が複雑で大変
Fensi Platform ができる以前と比べれば、はるかに開発効率は向上していました。しかし、私たちのような中規模組織では、Microservices ごとに専属のチームをもつことが難しいです。そのため、リポジトリや Microservicess が増え続けていくような開発スタイルは、それぞれのサーバーを運用し続けるのが難しく、次第に開発効率を下げる要因となっていきました。
Modular Monolith で支える新R25のサービス開発
そこで、新R25のリニューアルプロジェクトにおいて、Microservices のようなモジュール性を持ちながら、Monolith のように1つのリポジトリで管理できて、単一のアプリケーションとしてデプロイできる Modular Monolith というアーキテクチャを採用し、これらの課題を解決しようとしました。このセクションでは、新R25 の Modular Monolith の構成と技術スタックについて説明します。
サーバーアプリケーションの構成
まずはじめに、新R25 のサーバーアプリケーションの構成について説明します。 新R25の3つのサービスは1つの Modular Monolith として構成されています。この Modular Monolith は、新R25 Developers の機能がベースとなっており、新R25 Business と新R25 Media は、新R25 Developers の機能を利用しつつ、独自の機能を Module として実装して構築されています。
そして、一部の共通機能は、Fensi Platform が提供しているマイクロサービスと連携しています。このように、新R25 の Modular Monolith は、 Fensi Platform のマイクロサービスを組み合わせて構築されています。
技術スタック
CAM では、基本的にサーバーサイドを TypeScript + Express.js で書いています。また、 OpenAPI Specification(OAS) で API を定義し、 openapi-generator で SDK を生成しています。生成されたSDKは、サービス間通信やフロントエンドとの通信に利用しています。
Modular Monolith 内の依存関係の管理には、pnpm workspace を利用しており、Turborepo でビルドやテストの並列化などをしています。
ディレクトリレイアウトは下記のようになっています。
├── apps
│ ├── server
└── packages
├── cms
├── cms-sdk
├── project
├── project-sdk
├── payment
├── payment-sdk
├── etc...
apps配下には、実際に起動する Express.js のサーバーアプリケーションが配置されています。packages配下には、各 Module と Module に対応する openapi-generator で生成された SDK が配置されています。 SDK の利用に関しては次のセクションで詳しく説明します。
コンテキスト境界の維持
Modular Monolith のデメリットでも述べたように、Module 間のコンテキスト境界を維持することは難しいですが、Module 性を維持する上で非常に重要です。
このコンテキスト境界を維持する難しさの要因として、Module 間の連携が挙げられます。Module 間の連携は、専用のインターフェースなどを介してコードレベルで連携することが多いと思います。しかし、このような連携方法だと、不正にコンテキスト境界を超えることが可能なため、それらを防ぐための設計や仕組みが必要になります。
これらの課題は、新R25 の Modular Monolith を設計する上でも、大きな懸念でした。そこで、新R25 の Modular Monolith では、Module 間の連携はコードレベルではなく、WEB API を介して連携するようにしています。
各 Module は下記の様に Express.js の Router として実装されています。 このサンプルでは、Payment Module の Router を示しています。この Module は、 Project Module に依存しており、 project-sdk を介して Project Module と連携しています。
// packages/project/src/router.ts
import { Router } from "express";
import { ProjectClient } from "../../project-sdk";
const router = Router();
const projectClient = new ProjectClient("http://localhost:3001");
router.post("/process", async (req, res) => {
const { projectId, amount } = req.body;
try {
// Project SDK を使ってプロジェクト情報を取得
const project = await projectClient.getProjectById(projectId);
res.json({
message: `Payment of ${amount} processed successfully for project ${project.name}.`,
project,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
export { router };
上記のように、Module間のやりとりは、openapi-generator から生成した SDK を介することで、コード上は疎結合な形で連携しています。これによって、Module 間のコンテキスト境界を維持しつつ、TypeScript のビルドの依存関係もシンプルにすることができました。
しかし、このようなネットワークを介した連携では、遅延したり接続に失敗する可能性があります。これらの課題については、Fensi Platform ではもともと Microservices を運用していたため、 istio を用いた Service Mesh を構築しています。istio proxy が Module 間通信を監視し、再試行やタイムアウトなどの機能を提供しているため、比較的安全に連携を行うことができています。また、Datadog APM などを活用してパフォーマンスをチェックし、レスポンスタイムの改善にも継続的に取り組んでいます。
各 Module の統合
Router として作成された各 Module は、apps/server でルーターを登録することで、1つのサーバーアプリケーションとして統合されてデプロイされます。 下記は、各 Module のルーターを登録するサンプルコードです。
// apps/server/src/index.ts
import express from "express";
import { router as cmsRouter } from "../../../packages/cms/src/router";
import { router as projectRouter } from "../../../packages/project/src/router";
import { router as paymentRouter } from "../../../packages/payment/src/router";
const app = express();
const port = 3000;
// 各モジュールのルーターを登録
app.use("/cms", cmsRouter);
app.use("/project", projectRouter);
app.use("/payment", paymentRouter);
app.listen(port, () => {
console.log(`Server is running on http://localhost:${port}`);
});
このようにすることで、サービス開発者は各 Module を独立して開発することができ、何も意識することなく1つのアプリケーションとして統合されてデプロイすることができます。
Platform Engineering の実践
最後に、新R25 の Modular Monolith を支える Platform Engineering の取り組みについて説明します。
セルフサービスの実現
新R25 の Modular Monolith では、 packages 配下に Module を配置することで、新しい機能として追加することができるため、サービス開発者のセルフサービスを実現しています。 従来の開発スタイルでは、リポジトリのセットアップや環境構築に多くの時間を取られていましたが、それに比べるとコードを追加するだけでいいため、大幅な時間短縮が実現できています。
ゴールデンパスの提供
コードジェネレーターやテンプレートエンジンを使って、ゴールデンパスを提供しています。たとえば、新しい Module を追加する際に、Turborepo が提供する gen コマンドを使うことで、新しい Module の追加に必要なファイルの一部を自動生成することができます。
自動生成用のツールなどもリポジトリ内で完結しており、 npm script で簡単に実行できるため、サービス開発者だけであまり時間をかけずにゴールデンパスを提供することができています。
OpenAPI Specification を活用した開発者体験の向上
OAS は単に API の定義を書くだけでなく、さまざまな用途に活用しています。
1つは、openapi-generator で生成された SDK の利用です。Module 間の連携を疎結合に保ちつつ、SDK の型定義を通じて API の変更をコンパイル時に検知することができます。これによって、API の変更に伴うコードの修正漏れを防ぐことができます。
もう1つは、OAS によるリクエストバリデーションとレスポンスバリデーションの自動化です。OAS で定義されたスキーマを使って、リクエストやレスポンスのバリデーションを行うことで、API の仕様に沿ったリクエストやレスポンスを保証しています。
最後は、OAS によるドキュメントの自動生成です。各 Module の OAS を Swagger UI で公開しており、開発者が API 仕様を確認することができます。
おわりに
この記事では、中規模組織で Platform Engineering を実践するために、Modular Monolith というアーキテクチャを採用した事例を紹介しました。 Modular Monolith を採用したことで、サービス開発も Platform Engineering も少人数で効率的に進めることができています。 まだまだ改善点なども多くありますが、今後も引き続き Platform Engineering を実践していき、サービス開発者の生産性を向上させていきたいと考えています。