株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

フロントエンドをViteからNext.jsに書き換えた話 〜パフォーマンス編〜

こんにちは。4月にヘンリーに入社したSWE / アーキテクト / SETのsumirenです。
弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

今回、HenryのフロントエンドをReact + ViteからNext.jsに書き換えました。
この記事では、最初にNext.jsへの切り替えによってもたらされたユーザー体験の向上について説明します。次に、このユーザー体験の向上がどうして生じたのか、その背後にある技術的な要素をエンジニア向けに詳細に解説します。最後に、フロントエンドアーキテクチャに対する我々の長期的なビジョンについて述べます。

対象読者

  • エンジニアの方
  • ビジネスサイドの方
  • Henryを導入されている医療機関様の方

Next.js導入によるユーザー体験向上

私たちは数年前からHenryを開発しており、IT業界のトレンドに沿って技術をアップデートしています。
今回、業界のトレンドとしてNext.jsという技術が進化を遂げており、こちらで画面表示をリプレイスすることにしました。

以前にブログ記事でも紹介したとおり、ヘンリーでは様々なツールを用いてソフトウェアとエンジニアリングのステータスを定量化しています。 dev.henry.jp

医療機関様の環境での画面の性能は、以下のようになっていました。右下のFCP Distributionという箇所を見ると、200ms〜1秒程度のFCPがボリュームゾーンとなっていました。

ツール上で表示される画面表示のパフォーマンス

大部分が200msで済むならそこまで低い数値でないようにも思えるのですが、定性的にパフォーマンスを深掘りしていくと、ページをリロードしたときに半分以上の方が0.5秒〜2秒程度の待ち時間を感じていることが分かりました。

ツール上で表示される画面表示のパフォーマンス - 定性

この数値が高い(性能が低い)と、ユーザーがページを読み込み直すときに時間がかかり、ユーザー体験を悪化させます。例えば患者様の会計をするために急いでいるときに、Henryのページが高速で表示されなければ、医療事務の方々はストレスを感じることになります。

Next.js採用は、長期的なビジョンに基づいています(それについては主にエンジニアの方々向けに記事の最後のほうで述べます)。それに加えて、短期的なユーザー体験向上のメリットもあり、特に上記のパフォーマンスについても改善できると見込んで導入を決定しました。

デモ

以下2点のパフォーマンスが向上しています。

  • ヘッダーなどのレイアウトが表示されるまでの圧倒的に速くなっている
  • 最終的なデータ取得完了までの時間がやや速くなっている

上がNext.js版(新しい)、下がVite版(古い)の比較になっています。同時にページを読み込んでいます。 少し分かりづらいかと思うのですが、上記2点に注目して見ていただけると幸いです。

www.youtube.com

定量的なパフォーマンス比較

Next.js版のHenryは本番環境にリリースしたばかりで、まだ医療機関様の環境でのパフォーマンスは集計できていません。そのため、検証環境でのパフォーマンスの比較を記載します。
以下が、LighthouseというIT業界で最もスタンダードなツールでの計測結果です。

  • 総合パフォーマンス:56点82点
  • FCP(初期表示):4.0秒0.5秒
  • LCP(データ表示):5.4秒2.9秒
  • CLS(カクつき):0.050.001

FCPが圧倒的に速くなり、LCPもそれにつられてやや速くなっていることが分かります。 CLSというのはデータが表示されるにつれて画面がずれたりするカクつきのことで、これも改善しています。 その結果、総合パフォーマンスが56点から82点まで改善しました。toB バーティカルSaaSとしては先進的な数値ではないかと思います。

計測結果はツールによって異なるので、先述のダッシュボード上の数値と噛み合ってない部分もありますが、本番環境でも同様に高速化すると見込んでいます。

※3画面に対して複数回実施した平均点数を記載しています。
※キャッシュはオフで計測しています。

ユーザー体験がどう向上したか

上記の通り、Next.jsに書き換えたことで、ブラウザのリロード時や初回アクセス時のページ表示が速くなりました。

Henryにアクセスすると、ナビゲーションや画面のレイアウトなど、データ以外の部分が非常に速い速度で表示されます。 これにより、ユーザーが別のタブを見たい場合やデータを新規作成したい場合には、即座に操作を行えます。

また、ページの初期表示が速くなったことで、結果的に最終的なデータ取得までの時間も短くなっています。 加えて、データ以外のレイアウトが先に高速表示されるため、ユーザーは「画面上のこのあたりにデータが出そうだ」というのを事前に認識でき、データ表示後すぐに操作ができます。

ユーザー体験向上の図解
ユーザー体験向上の図解

FCP高速化の技術的な仕組み

この章では、主にエンジニアの方向けにNext.jsを導入するとFCPが高速化する理由を解説します。

元々のHenryは以下のようなアーキテクチャでした。

  • 素のReact + Vite
  • SSR

今回、上記からNext.jsに置き換えるだけで、自然とFCPが向上しています。 いくつか工夫しているポイントもあるため、併せて説明します。

以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。
また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。

まずはNext.jsとViteの基本的なアーキテクチャの違いを説明した後、高速化を後押しする要因である以下2点について説明します。

Next.jsとViteの基本的なアーキテクチャの違い

Vite版とNext.js版のヘンリーでページをリロードしたときの初回レスポンスを見比べてみます。

ViteとNext.jsの初回レスポンスの比較

左側がVite版です。Viteのような通常のSPA構成では、Reactコンポーネントレンダリングは全てブラウザで実行されます。
そのため、サーバーからのレスポンスは、Reactが書かれたJavaScriptを読み込むための<script>タグを含む最小限のhtmlであり、ほとんど何も表示されていません。
通常のSPA構成の経験がある方なら、<div id="root"></div>は馴染みがあるのではないでしょうか。

一方、右側がNext.js版です。Next.jsは、サーバーサイドでページをHTMLとJSに変換します。
そのため、サーバーからのレスポンスの時点で中身のあるHTMLが返されています。

次はChromeのPerformance Insightsで、ブラウザの処理の流れを見比べてみます。
以下のとおり、Next.js版のHenryでは初回のレスポンスでレイアウトの表示が始まっています。

Next.jsのPerformance Insights

一方、Vite版のHenryでは、初回のレスポンスでほとんど空のHTMLが返ってきた後、JSの読み込みがあり、その後JSの処理が行われています。
その結果、初回のレスポンスが返ってきてから、いくつものステップを挟まないと画面上には何も表示されていません。

ViteのPerformance Insights
ViteのPerformance Insights

このアーキテクチャの違いがNext.jsのFCPを高速化しています。
Vite版の場合は、ブラウザに初回レスポンスが来た後、JSの読み込み、ブラウザでのJS処理をして、ようやく画面を表示できます。
一方のNext.js版の場合は、ブラウザに初回レスポンスが来た時点で画面にレイアウトを表示できます。

この基本的なアーキテクチャの違いを踏まえたうえで、高速化を後押ししているNext.jsの2つの機能を見ていきます。
上記のPerformance Insightsの比較は処理の流れにフォーカスするためにキャッシュありで比較していましたが、この後はターンアラウンドタイムの最適化やトラフィック量に関わる話になるため、キャッシュを無効化したPerformance Insightsも見ていければと思います。

補足:工夫しているポイント

HenryはtoB SaaSであり、大部分の画面に認証がかかっています。認証には、クライアントサイドでFirebaseを利用しています。
Vite版のHenryでは、認証の状態が得られてから画面上に出すべきコンポーネントが決まりレンダリングしていました。

今回Next.jsに移行するにあたり、認証できている場合のコンポーネントレンダリングして返しています。これにより、Firebaseの処理待ちやコンポーネントの再レンダリングなしで画面のレイアウトが表示できます。
※データはあくまで認証後に取得するため、セキュリティ上の問題はありません。

const PageRoot: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  // ...

  // Firebaseによる認証は副作用として行う
  useEffect(() => {
    if (currentUser.loading) {
      return;
    }
    if (requiredAuth(router.pathname) && !currentUser.membership) {
      setShowLoginPage(true);
      return;
    }
    setShowLoginPage(false);
  }, [currentUser, router]);

  // ...

  return (
    <div>
      {showLoginPage ? (
        // ...
      ) : (
        // サーバーサイドレンダリング時点では、認証できている前提でレンダリングしておく
        <RootLayout showGlobalNav>
          {children}
          <WindowsPresenter />
        </RootLayout>
      )}
    </div>
  );
};

プリレンダリング

前章で説明したとおり、Next.jsは初回のレスポンスさえ受け取れれば、データを必要としない画面の一部を表示できます。
しかし、単純にリクエストが来てからサーバーサイドでReactをHTMLに変換すると、ブラウザでのJS処理の代わりにサーバーでのJS処理に時間がかかり、初回のレスポンスが遅くなってしまいます。

そこで、Next.jsではビルド時に大部分のHTMLとJSを生成してキャッシュしています。これをプリレンダリングと言います。具体的には、動的なサーバーサイドでのデータ取得を含まないページがプリレンダリングの対象となります。
この機能により、Next.jsは初回リクエストに対するレスポンスのターンアラウンドタイムを最小限に押さえています。

キャッシュなしでのVite版HenryとNext.js版Henryの初回リクエストに対するレスポンス速度を比較してみます。

まず、Vite版のHenryは次のとおりです。
オレンジの部分はJSの読み込みで、かなり気になる長さになっていますが、これは次の章で解説するので一旦見なかったことにしてください。
ここで見たいのは青色の初回リクエストに対するHTMLのレスポンスで、15〜20ms程度に収まっています。これは、Viteでは画面表示にJSが必須となる代わりに、HTMLはほとんど空に近いもので、サーバーサイドでの処理も必要ない静的なものだからです。

Vite版 キャッシュなし時のHTMLのレスポンス速度

次に、Next.js版を見てみます。
一見Vite版より遅くなっていますが、すでにレンダリング済みのHTMLでファイルサイズで多少大きさがあるため、通信完了までの時間が伸びるのは自然です。
特筆すべきはレスポンスが始まるまでのリードタイムです。0msから数えると、わずか5ms程度でレスポンスが始まっているのが分かります。

Next.js版 キャッシュなし時のHTMLのレスポンス速度

何もデータフェッチを行わない動的フェッチ(getServerSideProps)を含むNext.jsのページをローカルで動かすと、このようになりました。Reactのレンダリングで30ms程度ロスしていることが分かります。

Next.js版 非プリレンダリング時のHTMLのレスポンス速度

これがプリレンダリングの威力です。サーバーサイドでReactレンダリングすることでかかってしまう数十msのリードタイムすらなくし、SPAアーキテクチャとの性能差を圧倒的なものにしています。

補足:工夫しているポイント

Vite版のHenryのデータフェッチは、Apollo Clientを用いたクライアントサイドフェッチでした。
今回、マイグレーションにあたり影響を最小限にするために、データフェッチ手段は変えずにNext.jsに書き換えました。そのため、ほぼすべての画面がプリレンダリングの対象となりました。

個人的には、Streaming SSRでない通常のサーバーサイドフェッチはFCPを落としてしまうため、プリレンダリング可能なクライアントサイドフェッチのほうが望ましいと考えています。

コード分割

加えて、Next.jsには、フロントエンドへのJS転送量も大きく軽減する仕組みもあります。
※正式な機能名がドキュメント上で見つけられなかったため、便宜上コード分割としています。

Viteの場合、基本的には全てのReactコンポーネントが1つの巨大なJSファイルにバンドルされます。Lazy Loadingにより個別のコンポーネントを動的に読み込むことは可能ですが、そうした工夫をしなければ全ページ分のコンポーネントの情報がクライアントサイドで読み込まれます。

先ほど少し目に留まったかと思いますが、改めてVite版のHenryのJS読み込み時のPerformance Insightsを見てみます。
キャッシュヒットなし時には1秒経っても読み込みが終わっていません。このindex.jsは、4.0MBもの大きさになっていました。かつ、前の章で説明したとおり、SPAアーキテクチャではこのJSが読み込みとJSのブラウザ側での処理をしないかぎり、画面には何も表示できません。

Vite版のキャッシュあり時となし時のindex.jsの読み込み比較

記事の最初にあるLighthouseの計測結果でVite版のHenryのFCPが4.0秒もかかる一番の要因は、このキャッシュヒットなし時のJSの読み込みです。

続いてNext.js版のHenryのキャッシュヒットなし時のJS読み込みを見てみます。
細かくて分かりづらいかもしれませんが、下記の図から300ms程度でJSが読み込まれていることが分かります。
このように高速なのには2つの理由があります。
1点目の理由は、Next.jsは細かくJSを分けて並列での読み込みを可能にしていることです。これはこの図からも分かりやすいかと思います。

Next.js版 キャッシュなし時のJS読み込み

より重要な2点目の理由は、Next.jsはサーバーサイドでページ単位でReactにレンダリングしているため、そのページに必要なJSだけを返せることです。
実際、上記の図で読み込まれているJSは、数は多く見えますが、1つ1つは10〜300kBほどで、合計すると(この画面では)1MB未満になっています。

以下はNext.jsのビルド実行時のログです。ビルド時点で、ページ単位のFirst Load JSが計算できています。
これによると、多くの画面が500kB〜1MBに収まっており、4.0MB以上を一括で読み込まなければいけないVite版のHenryに対して大きなアドバンテージがあります。
実際にはこれでもNext.js的には大きいということで、First Load JSのログが真っ赤に染まってしまっています。今後、よりFirst Load JSを削減できるように精進していきたいです。

Next.js ビルド時ログ

このように、Next.jsでは個別の最適化を行わずとも、JSを小分けにする仕組み・デフォルトでページに必要なJSしか返さない仕組みがあり、高速化とトラフィック削減が実現されます。

補足:工夫しているポイント

Next.jsでも、Vite同様、Lazy Loadingによる個別の最適化が可能です。
今回は、もともとルートに近いコンポーネントでインポートしていた大量のFloatingWindowなどのコンポーネントをLazy Loadingするようにしました。
まずはマイグレーションを完了させることが最優先のため、手早くできるボリュームゾーンのみを対応しましたが、それでもFirst Load JSを300kBほど削ることができました。

補足:JS読み込みとFCP

Next.jsにとって、このJS読み込みは、厳密にはFCPと直接関係がありません。
ここまでに説明してきたとおり、初期表示のためのHTMLはサーバーサイドでレンダリング済みだからからです。

それではこのJSは何に使うかというと、ざっくり言うとユーザーが画面を操作できるようにするために使います(ハイドレーションと言います)。
あくまで初回レスポンスで返ってくるHTMLで、clickなどのイベントは一切登録されていないため、ブラウザ側にReactコンポーネントを正しく紐付ける必要があるということです。

つまり、このJSの読み込みや処理が遅いと、画面は高速で表示されても、ユーザーはデータを作成したり画面遷移するといった操作ができないことになります。
FCPには直接関わらないものの、広い意味では初期表示の高速化に大きく関わるものです。

長期的なビジョン

今回、Next.jsを導入したことで自然とページロードが高速化しました。
しかし、実のところ、Next.jsを導入したのは、単に短期的なページ高速化のメリットを享受するためではありません。
むしろ、Next.jsを導入できた今、フロントエンドエンジニアリングの可能性がさらに広がったのだと思っています。

この章では、フロントエンドアーキテクチャに関して、どのような長期的なビジョンを持ってNext.jsを導入したのか解説します。
この章では、これまでの章に比べ、主観や推測、判断が多くなります。予めご理解いただければと思います。

フルスタックReactというトレンド

まず、最も長期的な観点として、フルスタックの波に乗ることで、トータルでより先進的なユーザー体験や開発者体験が実現できる可能性が高いと考えています。

背景として、フロントエンド界隈全体の流れとして、Web標準フルスタックに寄せていくことが開発生産性やパフォーマンスを高めることにつながるという動向があるように思います。
Reactチームもそうしたトレンドを推し進めている当事者であり、今後Reactを始めるうえではNext.jsやRemixを採用することを推奨しています。

React公式のスクリーンショット

この記事で解説してきたように、こうしたフルスタックフレームワークはサーバーサイドレンダリングにより初回レスポンスからHTMLを返せます。
また、最近はPOSTについてもServer ActionsやAction Functionのように、個別のinputから値を集めてJSONに詰める今までのやり方に比べ、よりWeb標準に近い形を模索しています。

一方、ViteのようなSPAアーキテクチャはというと、空のHTMLが返ってくることも、巨大なJS読み込みが発生することも、そのJSで画面全体を構築することも、全てがWeb標準フルスタックのトレンドとは真逆です。
そのため、少なくとも今後盛り上がりや進化を見せる可能性は低く、最悪の場合はエコシステムの豊富なライブラリやホスティングサービスとの統合が難しくなっていく懸念もあると判断しています。

こうしたことを踏まえ、フルスタックフレームワークの波に乗ることで、ますます革新的なユーザー体験や開発者体験が可能になり、未来が明るくなると判断しました。

ユーザー体験

より短期的な観点として、Next.jsを導入したことですぐに始められるアプリケーションレベルのアーキテクチャ施策もたくさんあります。

例えばApp Routerを有効化すれば、React Server Componentsを使ってJSバンドルサイズを削減できます。
Steaming SSRによりFCPを保ちながらGraphQLリクエストをサーバーサイドに集約するなどすれば、ページリロード時だけでなくクライアントサイドナビゲーションによる遷移時のパフォーマンスも向上することができる期待があります。

また、Server Componentsの導入と併せてstyled-componentsからゼロランタイムCSS in JSへの置き換えを行うことで、iPadスマートフォンでも高いINPを実現できる見込みもあります。
その他、細かい機能も含めると、フォントやイメージの最適化の活用などもあります。

開発生産性

加えて、開発者体験についても、Next.jsを導入したことで今後向上する見込みです。

Next.jsの規約に従うことで、ルーティングやバンドラの設定を自前で管理する必要がなくなり、アプリケーションに集中しやすくなります。 機能開発自体も、レイアウトやメタデータを簡単に共通化できるようになるなど、配管コードの量が削減できる見込みです

より積極的なアプローチとして、ソフトウェアのアーキテクチャを見直す余地も出てきます。
現状はフロントエンド - GraphQL BFF - gRPC Backendという構成ですが、例えばコンポーネントにServer Actionsを書いて直接gRPCを呼んだり、Server Componentsから直接gRPCを呼んでデータを取得するアーキテクチャに変更できます。
このようにすれば、Reactだけでフロントエンドの開発が完結し、より開発を加速できるかもしれません。

まとめ

HenryをViteからNext.jsに書き換えたことで、フロントエンドエンジニアリングの可能性が広がり、直近ではFCPも向上しました。
引き続きHenryフロントエンドアーキテクチャをよくして、ユーザー体験と開発生産性を高めていきたいと思います。

この記事は比較的一般論に近い解説記事となりました。実際、パフォーマンスに寄与しているのはほとんどNext.jsのビルトインの仕組みです。
一方、今回行ったViteからNext.jsへのマイグレーションプロセスには、より実用的な洞察がたくさん含まれていると考えています。
最も面白い点としては、今回Vite版を破壊せずに、ほとんどのコードを共通化した状態でNext.jsを導入し、開発を止めずにマイグレーションしたことが挙げられます。
こうしたマイグレーションを実現したプロセスやコードベース設計について、別途記事として公開したいと思いますので、よければそちらもご覧ください。

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

修正ログ

  • 2022/06/02 12:57
    • 記事の内容がVite自体の性能について述べるものと誤解を与えうる内容になっていたため、補足を追加しました。
      • 以後、便宜上React + Vite + 非SSRという組み合わせを、「Vite」と呼称することがあります。本質的には、この章における比較は、Next.jsと素のReactの比較になります。

      • また、この章の意図は、上記のような一般的なReact + Vite構成をNext.jsに置き換えることでFCPが高速する旨を伝えることであり、フロントエンドツールとしてのViteの性能に言及するものではありません。

医療系スタートアップのバックエンドをモノレポ化した話 〜技術編〜

こんにちは、ヘンリーの SRE の戸田と VPoT の岩永です。 弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

前編の Henry のバックエンドをモノレポ化した戦略やプロセスに続いて、後編のこちらの記事ではモノレポ化の技術的手法を解説します。 dev.henry.jp

実際のモノレポ化の流れに沿って、ポイントを3点説明します。

  1. 2つの git リポジトリのマージ
  2. アプリケーション・ワークフローのモノレポ対応
  3. モノレポへの切り替え当日に向けた手順書の作成

1. 2つの git リポジトリのマージ

今回のモノレポ化においては、もともと存在していた henry-general-api と henry-receipt-api という2つのマイクロサービスのリポジトリを、1つのリポジトリにマージし、それぞれのマイクロサービスがサブディレクトリとして管理できることを目指していました。

henry-general-api/
├── .git/
└── ...

henry-receipt-api/
├── .git/
└── ...

↓↓↓

henry-backend/
├── .git/
├── general-api/
|   └── ...
└── receipt-api/
    └── ...

コアコンセプト

1つのリポジトリにマージするといっても、その後のコード変更のトレーサビリティを確保するために git の履歴が失われれないようにするための方法を考えなければいけません。

今回は git-filter-repo というツールを使用しました。これは、単一リポジトリの履歴を書き換えるためのツールです。例えば、条件に一致するファイルを履歴から完全に抹消したり、逆にそのファイルに関連する履歴だけを抽出したりすることができます。

このツールを使うと、前述したようなリポジトリのマージは大きく3ステップで実現できます。

  1. git-filter-repo を使い、マージ元の各リポジトリ (henry-general-api, henry-receipt-api) で、すべてのファイルをサブディレクトリのパスに移動させる

     cd /path/to/henry-general-api
     git filter-repo \
       --path-rename ":general-api/" \ # すべてのファイルをサブディレクトリに移動する。
       --tag-rename ":general-api-"    # Tag にもプレフィックスを付ける。
                                       # どちらも「(書き換え前):(書き換え後)」という記法。
    
     cd /path/to/henry-receipt-api
     git filter-repo \
       --path-rename ":receipt-api/" \
       --tag-rename ":receipt-api-"
    
     henry-general-api/
     ├── .git/
     └── (application code)
    
     henry-receipt-api/
     ├── .git/
     └── (application code)
    
     ↓↓↓
    
     henry-general-api/
     ├── .git/
     └── general-api/
         └── (application code)
    
     henry-receipt-api/
     ├── .git/
     └── receipt-api/
         └── (application code)
    
  2. 1 で履歴を書き換えたリポジトリを remote としてマージ先のリポジトリ (henry-backend) に登録する

     cd /path/to/henry-backend
     git remote add general-api /path/to/henry-general-api
     git remote add receipt-api /path/to/henry-receipt-api
    
  3. マージ先のリポジトリ (henry-backend) で、2 で登録した remote の branch を両方1つの branch に取り込む

     cd /path/to/henry-backend
     git merge --allow-unrelated-histories general-api/develop
     git merge --allow-unrelated-histories receipt-api/develop
                                         # ^^^^^^^^^^^ ここは remote の名前
    
     henry-backend/
     ├── .git/
     ├── general-api/  # general-api/develop から merge されたもの
     |   └── ...
     └── receipt-api/  # receipt-api/develop から merge されたもの
         └── ...
    

こうするとサブディレクトリ単位で見たときは元の履歴と全く同じ、親ディレクトリで見れば2つの履歴が混ざったようなものになります。

cd /path/to/henry-backend
git log               # 2つのリポジトリの履歴が混ざった1つの履歴になる
git log ./general-api # マージ前の henry-general-api の履歴と同じものが見れる

いきなりモノレポにすると困ること

コアコンセプトはシンプルなものですが、git-filter-repo は不可逆操作であるという問題があります。git-filter-repo はすべての commit を新しく作り直すので、前後で commit sha1 が完全に変わってしまいます。

つまり一度リポジトリをマージしてしまうと、マージ元のリポジトリで追加の commit があったときにその差分をマージ先のリポジトリに取り込むことができなくなってしまうのです。

これではモノレポ化に伴う変更 (アプリケーション・CI/CD も含めて) をすべて一気にやりきるまでは、普段のプロダクト開発を止めるしかなくなってしまいます。

モノレポ化対象のリポジトリは変更頻度や規模がかなり大きいリポジトリであり、変更がそれなりに大変なことが分かっていましたが、プロダクト開発が長期間止まることは許されません。

submodule から始める

そこで、普段のプロダクト開発を止めることなくモノレポ化に伴う変更を管理できるように、まずは2つのリポジトリを submodule にした状態で、アプリケーションや CI/CD がすべて動くようになることを目指すようにしました。

henry-backend/
├── .git/
├── .gitmodules
├── general-api/ (submodule)
|   ├── .git
|   └── ...
└── receipt-api/ (submodule)
    ├── .git
    └── ...
  1. まずマージ元の2つのリポジトリにそれぞれ、モノレポ化用のブランチを作ります

     cd /path/to/henry-general-api
     git checkout develop
     git checkout -b develop-monorepo
    
     cd /path/to/henry-receipt-api
     git checkout develop
     git checkout -b develop-monorepo
    
  2. そして、マージ先のリポジトリに 1 のブランチにチェックアウトした submodule を追加します

     cd /path/to/henry-backend
     git submodule add -b develop-monorepo https://github.com/bw-company/henry-general-api.git general-api
     git submodule add -b develop-monorepo https://github.com/bw-company/henry-receipt-api.git receipt-api
    

こうすることで以下のことが実現できるようになりました。

  • プロダクト開発で入る変更差分にいつでも追従することができる。
    • 履歴の書き換えは行っていないため、開発ブランチからモノレポ化用のブランチに git merge することができます。
  • モノレポ化のための変更差分 (diff) が確認しやすい。
    • 実際マージ元にも PR を作り差分をレビューしていました。

submodule からモノレポに切り替える (finalize script の作成)

すべての準備が整った最後には、submodule をやめて3つのリポジトリ (henry-backend, henry-general-api, henry-receipt-api) を1つにマージする作業が残っています。

基本的には「コアコンセプト」で示した内容をやることになるのですが、この際 submodule の存在が邪魔になります。例えば、submodule のディレクトリは普通のディレクトリとは git 内部の管理の仕組みが異なるため、submodule のディレクトリと同じパスに対して、普通のディレクトリへの変更 commit がある状態は作れません。(commit 一つ一つで conflict の resolve が必要になってしまいます)

そのため、まずはマージ先のリポジトリ (henry-backend) の履歴から submodule の存在を抹消する必要があります。具体的には以下のことをやりました。

  1. 各サブディレクトリを submodule の管理から外して git rm したコミットを作成する
  2. git-filter-repo を使い submodule のパスに一致する過去の commit (submodule の更新など) をすべて書き換えてなかったことにする
  3. 「コアコンセプト」の手順を行う

全体のフローはそこそこ複雑なので、いつどういう状態でも、誰でもできるように、初期の段階でスクリプトを作成しました。処理が途中で失敗したり止まってしまっても、再度実行したときに失敗したところから再開できるような工夫もされています。

具体的な処理を解説すると長くなってしまうので、gist に実際のスクリプトを上げていますので良かったら見てみてください。

Merge two git repositories while perfectly preserving all commit histories · GitHub

2. アプリケーション・ワークフローのモノレポ対応

前編でも説明したとおり、今回モノレポにする目的は、マイクロサービス間をまたがる作業を生産的に行えるようにするためでした。そのため、一般的なモノレポ導入でも同様かと思いますが、アプリケーション・ワークフローをモノレポに適応させることが重要でした。

  • 1つのIDEでモノレポが管理する全てのコードベースで快適に作業ができるようにする
  • モノレポ化以前のデプロイプロセスを踏襲する

これらの要件を実現するために、大きく3つの変更を行いました。

  1. ビルドシステムを multi-project 構成に変える
  2. IDE やエディタの設定を共通化する
  3. CI/CD を再構築する

ビルドシステム (Gradle) を multi-project 構成に変える

Gradle にはネストした複数のプロジェクト(モジュール)を持つ「マルチプロジェクト」という仕組みがあり、モノレポ化対象のプロジェクトは以下のように、既にマルチプロジェクトとなっていました:

  • henry-general-api (root project)
    • app (sub project)
    • utils (sub project)
  • henry-receipt-api (root project)
    • app (sub project)
    • utils (sub project)

これを以下のように既存のプロジェクトルートを新プロジェクトのサブプロジェクトにする形を採りました。

  • henry-backend (root project)
    • general-api
      • app (sub project)
      • utils (sub project)
    • receipt-api
      • app (sub project)
      • utils (sub project)

この判断は前述の git submodule を利用した移行プロセスともよく噛み合い、人間にとっても理解しやすい利点がありました。今後同じようなことをやる場合も、この手法を採るでしょう。

なお buildSrc ディレクトリと settings.gradle.kts ファイル、Gradle wrapper スクリプトはプロジェクトルートに置く必要があるため、これだけは各サブプロジェクトから移動しました。gradle.properties ファイルも各プロジェクトの設定をマージしたファイルを作成しています。

Version Catalogによる依存バージョンの管理

各サブプロジェクトでばらばらにバージョン管理をする必要が無かったため、モノレポ化にあわせて Version Catalog による依存の中央管理を導入しました。

もともと弊社ではRenovateを使い、ライブラリなどの依存を極力最新に保つようにしています。Renovate は Version Catalog もサポートしていますので、特に運用に変更は生じませんでした。

モノレポ化によるビルド時間の変化

モノレポ化によってビルド時間はどう変化したでしょうか。

CIに限った話としては、サブプロジェクトをビルドする際に他のサブプロジェクトのビルドを実行しないように注意すればほとんど影響はありません。Gradleは -pオプションでプロジェクトディレクトリを指定できますので、例えば ./gradlew -p general-api shadowJarとすれば general-api 以外のサブプロジェクトによる影響は最小化できます。

実際に移行前は約20分で終わっていたデプロイが、移行後も約20分程度で終えられています。とはいえこのプロジェクトにおけるデプロイのボトルネックがもともと Gradle ではなくデータマイグレーションにあったことには注意が必要でしょう。

IDE やエディタの設定を共通化する

IntelliJ の設定ファイルは、Gradle プロジェクトができていれば IntelliJ が自動的に生成してくれます。ここでは自動生成では補えない部分について述べます。

.gitignore

.gitignoreには gitignore.io で配布されている IntelliJ 用ものをほとんどそのまま利用しています。さらにGradleによるプロジェクト管理をしているため、Gradle が自動作成してくれるファイルも加えています。

コードスタイル設定

移行前のプロジェクトあった.idea/codeStyleディレクトリは手動で移行する必要がありました。弊社では *を使った import を抑制したり、Trailing Comma を許容したりといったカスタマイズを施していたため、 .idea/codeStyles/Project.xmlファイルをコピーして対応しました。

Detekt プラグイン

弊社では Detekt プラグインを利用しているため、その移行も行いました。

IDEA プラグインの移行自体は .idea/detekt.xmlに必要な情報を書くだけで済みますが、Detekt の設定は丁寧にマージする必要があります。今回は Detekt ルールはゆるい方に合わせ、モノレポ化を終えてから順次ルールを追加していくことにしました。

なおルールさえマージしてしまえば、baseline ファイルは ./gradlew detektBaselineで自動生成できます。

CI/CD を再構築する

CI/CD には CircleCI を使っていました。Docker image を build して、CloudRun + CloudFunction へのデプロイを行う流れです。

Henry のマイクロサービスは git-flow に従ったブランチ管理・デプロイ戦略を採用しています。デプロイ環境が4つあり、それぞれのブランチへの push で自動デプロイがされる仕組みです。

ブランチ デプロイ環境 存続期間
develop staging, sandbox 永久的
release/YYYYMMDD qa 一時的
master production 永久的

モノレポにおいてもこのデプロイフローを再現できる必要がありました。

もともと大きな機能変更をテストするための場として sandbox 環境が用意されていたので、モノレポ化に関しても、一時的にこの環境を専有することで実験を進めました。

モノレポ対応に際しては、なるべく移行時や切替時の設定ミスが少なくなり、長期に渡ってもメンテナンスしやすくなるように設定の共有化やリファクタリングを並行で行いました。具体的には以下のようなものが含まれます。

  • Reusable Config Reference Guide にあるプラクティスに従い、commands や parameters を活用し、なるべく可読性が確保できるようにした
  • Dynamic Configuration を活用し、設定ファイルを分割した。
    • constants.yml — Anchor/alias を使った定数系の定義
    • common.yml — 共有の設定や再利用可能な command や job の定義
    • {general,receipt}-api.yml — 各サービス固有の job と workflow の定義

それ以外にも Gradle Project の構造が変わったことによる変更や、キャッシュ効率を良くするために jar の生成を docker build 内でやっていたものを CI 上でやるように変えたりと、細かいところでいろいろな調整・改善をしましたが、長くなるのでここの知見はまた別の機会に共有したいと思います。

3. モノレポへの切り替え当日に向けた手順書の作成

モノレポ化の PoC が sandbox 環境で動くところまでできたところで、当日の手順書を作成しました。

マージ元リポジトリでの変更差分の反映や、設定が正しくモノレポ移行できているかの最終確認など、finalize script を実行する前後でやることがそれなりありました。

手順書を作るにあたっては、以下の点に気をつけながら3人でレビューを繰り返しました。

  1. 当日何も考えなくて良いように実際に使うコマンドや確認方法も含め詳細に記載する
  2. ステップを踏んで範囲を広げて行き、途中で問題があっても切り替えと顧客への影響が最小になるようにする

    実際には以下のようなの順に確認をしていきました。

    1. master ブランチ + sandbox 環境 (finalize 前 = submodule の状態)
    2. master ブランチ + sandbox 環境 (finalize 後 = モノレポの状態)
    3. develop ブランチ + staging 環境
    4. master ブランチ + qa 環境
    5. master ブランチ + production 環境
  3. 当日の作業だけでなく、切り替え後の開発体験を想像する
    • branch protection を有効化する等、設定や考慮漏れを事前に潰すことができました

実際の手順書の一部はこのようなものです。

  • [ ] general-api と receipt-api で master 追従
  • [ ] 差分を検知して反映して sandbox deploy
    • [ ] general-api と receipt-apimaster-monorepo branch に master を取り込む
    • [ ] monorepo repo で submodule を update する
    • [ ] monorepo repo 側で変更に追従
    • [ ] 反映漏れがないか一通りチェック
      • [ ] .github/workflows
      • [ ] groovy
      • [ ] lib versions 系
    • [ ] master に merge
  • [ ] general-api, receipt-api の repo で develop-monorepo branch を作る

      git checkout develop
      git checkout -b develop-monorepo
      git merge master-monorepo
      # conflict 解消
      git push origin develop-monorepo
    
  • [ ] ./poc-tools/finalize して sandbox deploy

    • 手順
      1. git checkout master
      2. ./poc-tools/finalize
      3. 目視で確認
        • submodule が消えているか
        • receipt-api / general-api が統合され、履歴も統合されているか
        • receipt-api / general-api と monorepo で最新のコミットが一致しているか
      4. CI を確認
        • sandbox デプロイが実行されること
        • 本番デプロイが動いていないこと
  • [ ] deploy branch 変更して staging deploy
    • {general,receipt}-api/develop-monorepomaster branch の3つをマージした develop branch を作成

        git checkout master
        git checkout -b develop
        git merge remotes/general-api/develop-monorepo
        git merge remotes/receipt-api/develop-monorepo
      
    • デプロイのブランチ設定を変更: staging (develop に変える), sandbox (develop に変える)

    • develop に push
    • CI を確認
      • staging デプロイが実行されること
      • 本番デプロイが動いていないこと

まとめ

プロセスの検討、アプリケーション・ワークフローのモノレポ対応、手順書作成、といった準備には、3人の Working Group による週1の活動でトータル2ヶ月間ほどかかりました。

しかしその入念な準備の甲斐あって、切り替えの作業とそれに伴うコードフリーズはたったの5時間で済みました。また、細かいミスはあったものも、顧客影響は一切なく、開発者もみんな翌日からすぐにストレスなくモノレポでプロダクト開発を再開できたことはとても素晴らしい結果でした。


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

ヘンリーでは一緒に働く仲間を絶賛募集中です。興味がある方はぜひお気軽にご連絡ください。 jobs.henry-app.jp

医療スタートアップのバックエンドをモノレポ化した話 〜戦略・プロセス編〜

こんにちは、ヘンリーの Lead Architect の @kohii です。

弊社ではレセコン一体型クラウド電子カルテHenry を開発・提供しています。

最近 Henry のバックエンドをモノレポ化したので、その戦略やプロセスについて書きたいと思います。

こちらは前編となっており、モノレポ移行の手法やテクニックの話は後編で説明します。

dev.henry.jp

Why モノレポ?

ざっくり説明すると、既存のマイクロサービス/チームの分界点を抜本的に見直し、ドメイン(業務の領域)による分割を目指すため、一旦モノレポにまとめて、理想的な構造の切り出しをやりやすくするという目的です。

モノレポ化前のシステム/チームアーキテクチャ

バックエンド

Henryのバックエンドはマイクロサービスになっていますが、以下の2つのサービスが大部分を占めています。

  • henry-general-api電子カルテ「Henry」のバックエンド。基本的にはフロントエンドから呼び出される形で処理を行い、必要に応じて receipt-api を呼び出します。
  • henry-receipt-api … 「レセコン」と呼ばれる医事会計システムの領域を担当するサービス。ステートレスな計算エンジンとなっていて、general-api から呼び出され、診療報酬制度に乗っ取った処理を行い結果を返します。(ちなみに診療報酬制度はたぶんみなさんが想像するののだいたい50倍くらい膨大かつ複雑です。)

どちらも Kotlin で記述されていて、3年半に渡り50人の開発者が関わってきました。

マイクロサービス SLOC (Kotlin のみ) 累計コミット数 月間 PR マージ数
henry-general-api 13.9万 12,557 154
henry-receipt-api 14.6万 12,561 170

Henryの開発チーム

バックエンドの開発を行うのは以下の2チームでした。

  • Accomplishment Team … ユーザストーリーのデリバリーとインクリメントの最大化を追求する。general-api のオーナー。
  • Receipt Committee … レセプト機能を専門的に扱う。receipt-api のオーナー。

これら以外にも Platform Group(技術基盤の開発などを通じ組織全体の生産性を上げる)というチームもあります。

課題

プロダクトを作り始めて数年経ち、チーム/システムを分割した当初の意図と現状にギャップが生まれるようになってきました。

1. チーム構造とシステム構造の不一致

receipt-api は general-api のために存在する計算エンジンなので、Receipt Committee は Accomplishment Team から依頼を受けることを起点に receipt-api をインクリメントする想定でした。(Team Topologies で言うところの Complicated-subsystem Team)

しかし、実際には Receipt Committee はユーザー体験を含めた医事会計業務全般の開発や問い合わせ先を期待されることが多く、general-api やときにはフロントエンドの開発も担う必要がありました。(Team Topologies で言うところの Stream-aligned Team)

  • 開発フローやコミュニケーションパスの複雑化
  • 1つの機能のデリバリーがチームで完結せず時間がかかる
  • オーナーシップを持たないコードベースへ手を加えたり調査したりすることが多くある
    • 認知負荷の問題
    • 各チームの方針やコンテキストを理解しないコミットが入りやすい

2. チームの分界点の問題

チームの境界付近に非常に難しい問題が落ちていることが多く、そもそも分けてはいけない問題を分けているのでは?みたいな感覚がありました。

  • チームをまたがる開発の手戻りが多く、仕様確定までに時間がかかる
  • 仕様の認識齟齬が残ったままデリバリーされてしまう
  • チーム/システム間をまたいで相互に理解していないと適切なプロダクト設計にたどり着けない

3. 分散した密結合

API 呼び出しの方向は general-api → receipt-api なので、この方向の依存が発生するのは当たり前ですが、逆方向の依存も発生していました。具体的には「general-api は receipt-api を呼び出す時に、自身のドメインモデルをそのまま渡す」ということをやってしまっていたため、receipt-api は general-api のモデルを知っているということが起きていました。

この双方向の依存のため、コードベースやサービスは別れているのに、実質的には1つの密結合な塊として扱わねばならず、サービス境界面を含む変更容易性や理解容易性が損なわれ、アーキテクチャの改善そのものを難しくしていました。

戦略

これらの課題に対して、既存のアーキテクチャの延長線上でいくら捏ねくり回しても根本的な解決に近づくのは困難だと判断し、ゼロベースで理想のチーム・システムを考えました。そしてその理想を目指すトランジションの初手として、バックエンドのコードを一旦モノレポにまとめ、サービス境界の見直しを含めた変更をやりやすくするという戦略を取りました。

モノレポ化までの道

1. モノレポ化を決めるまで

開発体制再考ワーキンググループ発足

2023年2月頃に「チーム/システムの境界を跨ぐ機能に関して、開発がうまく進まない問題が頻発している」ということから「開発体制再考ワーキンググループ」が発足しました。(ヘンリーにはワーキンググループ(WG)という仕組みがあり、特定の課題を解決するために作られる一時的なプロジェクトのようなものが有志によって組成されます。)

その WG の中で議論を重ねた結果、既存のチーム構造を見直し、ドメインによるチーム分割を目指すということで方向性が決まりました。

  • 各チームは、担当する領域について顧客へ価値を提供するために必要なすべての機能/権限を持つ
  • 極力チームをまたがずに自律して意思決定し行動できる

これは「現状で正しそうに見える分割」であり、先に進む中で知見や洞察が深まり、徐々に実践的な正解が見いだされていくものだと思っています。

なお、実際には人数や役割の問題から、移行期として一旦は2チームに分け、それぞれ複数ドメインを受け持っています。

次にシステムアーキテクチャに関する議論が行われました。チームの構造とシステムの構造は一致しているべきということに異論はなく、そこにたどり着くまでのトランジションを議論しました。

いろいろなプランが挙がりましたが、結論としては既存のマイクロサービスを一旦モノレポにすることで理想の構造に移行しやすくするという方針に決めました。

  • 既存の構造の境界部分を含む変更がやりやすくなる
  • 正しいドメイン境界を特定するための試行錯誤がやりやすい

モノレポの具体像

まずは各サービスのデプロイメント単位は変えずに、コードベースを1つにするということを目指します。

Before:

  • henry-general-api (ルート)
  • henry-receipt-api (ルート)

After:

  • henry-backend (ルート)
    • general-api (Gradle のサブプロジェクト)
    • receipt-api (Gradle のサブプロジェクト)
    • utils (ユーティリティを共有。Gradle のサブプロジェクト)

これが終わった後は、モノレポの中でドメインによるコンポーネントの再構成を進める作戦です。

ADR (Architecture Decision Records) を記述

意思決定を ADR という形でまとめ、開発者間で共有しました。ADRアーキテクチャ決定を記述したもので、弊社の場合は Notion のデータベースに書いています。

どのようなアーキテクチャも様々な背景やトレードオフ、方針の上に成り立っていて、それをすべての開発者が理解することは重要です。また将来アーキテクチャに関する検討を行う時に、「なぜ今こうなっているのか」の経緯を後から遡って理解できるようにしておくという意味もあります。

2. モノレポ化の準備

モノレポ分科会の発足

「開発体制再考 WG」から、モノレポへのトランジションを実行する分科会が発足しました。

分科会では次のようなことをやりました。

  • Slack チャンネルを作成
    • 分科会のメンバー以外も任意で参加
  • スケジュールと計画の立案と周知
  • モノレポの具体像の決定
  • モノレポ化のためのバックログの作成・管理
  • 週次の定例ミーティングを設置
    • 週次のスプリントみたいな形で各々作業を進め、1週間後に結果や状況を確認し、また次週のプランを行う

作業日の決定とアナウンス

実施日は最も顧客業務への影響が少ない日曜日の夜間に決めました。

混乱なく進められるように、その週の初めごろに開発者向けのアナウンス Slack で周知しました。

手順書の作成

リスクを極力減らし、当日のコードフリーズ時間を最短にするため詳細な手順書を作成しました。可能な場合は具体的なコマンドまで記述しておきます。

3. モノレポ化当日

日曜日の17時から始めました。Google Meet で1人の作業者の画面を共有しながら行い、途中で夕食を食べたりしながら作業しました。一部想定通りに行かなかったところもありましたが、入念な準備のおかげで大凡滞りなくすべての作業を完了しました。

作業が完了した旨をアナウンスし、開発者が行うべき作業を伝えてこの日はおしまいです。

当日の技術的な内容は後編で説明してますので、よかったら読んでみてください。

dev.henry.jp

4. モノレポ化後初日

特に混乱なくモノレポ化後のコードベースで開発を始められました。

IDEのコードスタイルの設定漏れ等細かいものはありましたが、開発メンバーの協力によってすぐに解消されています。

やってみて

モノレポの Pros / Cons

モノレポ化はアーキテクチャの目的地ではなく、これからの変遷のための初手なので、これ自体に関する良し悪しはあまり本質的ではありませんが、だいたいこんな感じの感想です。

  • Pros:
    • general-api / receipt-api をまたがる変更をアトミックにできる
    • 設定やスクリプトの重複が排除され、一箇所で管理できるようになった
  • Cons:
    • Intelllij のサイドバーごちゃつきがち
    • general-api / receipt-api で同じ名前のクラスがあったため「今開いているこのファイルはどっちの持ち物のやつだっけ?」みたいな確認が必要なときがある

今後

具体的な次の一手としては、API の I/F の定義(proto ファイル)はまだモノレポの外に存在するので、これもモノレポに取り込みたいと考えています。これができれば general-api / receipt-api の境界面に関する変更がさらにやりやすくなるはずです。

一方でモノレポ化はドメインによるサービス分割を行うための一歩目であり、その方向に向かってアーキテクチャを移行していく旅は続きます。その先にあるゴールがマイクロサービスなのかモジュラーモノリスなのかは明確に見えているわけではありませんが、進んでみて、進んだ先にまた新たなことが見えてくる、の繰り返しだと思っています。


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

ヘンリーでは一緒に働く仲間を絶賛募集中です。興味がある方はぜひお気軽にご連絡ください。

jobs.henry-app.jp

Cloud Run (Grafana) + BigQuery + IAPでデータの見える化を実現した

こんにちは、ヘンリーでSREをしているTODA(@Kengo_TODA)です。 弊社ではデータの共有は主にNotionを用いています。ただ各システムからデータをかき集めて動的に共有するには、Notionはちょっと向いていないなと思うところがあります。データを通じてシステムや顧客、チームの課題を掴むことはスタートアップの生命線とも言え、SLOやKPIを動的に図示してスタンドアップミーティングなどで共有できる仕組みが必要だと感じていました。

このため、Grafanaを用いた仕組みをGCP上に構築しました。ウェブページを自動生成できるツールからの情報は以前Noteでご紹介したサーバーレス社内サイトで展開していますが、Grafanaであれば動的にコンテンツを構築して提供できると期待しています。

この記事ではGCPないしGrafanaの設定をどのようにしたか、その背景とともに説明していきます。

どのようなデータを共有したかったか

サービスを提供するうえで向き合うべきデータは多数存在します。例えばSRE文脈ではFour Keys Metricsや可用性が重要な指標となりますし、ユーザ体験の観点からはレスポンスのスピードも追っていく必要があります。また効果的にお金を使っていくうえで、クラウドウェブサービスにかかっている費用も監視したいです。

また成長期にあるスタートアップとして価値提供の速度を増していくヒントを探すことも重要です。Product Managerを採用してStream-aligned Teamを増やすことを基本戦略としましたが、ひとつのサービスを複数チームで開発するにあたってチームのKPIやSLOを各チームが独自かつ低コストに追える体制が重要だと考えました。

なぜシステム化が必要だったか

こうしたデータをかき集めること自体は今までもやってきましたが、複数システムに横断したデータを扱おうとするとどうしても手作業が出てきてしまいます。 例えばFour Keys Metricsの変更障害率を算出する場合、デプロイ回数をSentryから、障害の数や対応所要時間をNotionから集めていました。

これらの数値をGoogle Spreadsheetで統合して図示しNotionに蓄積していたのですが、手作業だとデータの鮮度も落ちますし作業コストもかかるという課題がありました。各チームが毎日毎週参照してシステムやチーム、顧客の「今」を掴む仕組みとしてはかなり足りない状態でした。トイル排除が必要だったということです。

既存ソリューションを試す

まずは既存のソリューションを調査しました。評判が良さそうなサービスとしてWaydevがありましたが、事前情報よりかなり金額が高かったため検討から外しました。

OSSのソリューションとしては技術スタックの近いdora-team/fourkeysを検討しました。最近v1.0がリリースされたこともあり好感触でしたが、弊社が採用している開発ツールとのかみ合わせが充分ではなく、多くの指標が算出できないことがわかりました。

forkして対応することも考えましたがあまり再利用できるところがなく、新規に作成することとしました。

基本構成

データをBigQueryに集約してGrafanaで表示する、というdora-team/fourkeysの構成を踏襲しました。GrafanaはPRベースで開発することが可能なため、git-flowに最適化された既存開発体制にマッチしやすいことも強みになります。なおdora-team/fourkeysではPubSubを利用していますが、まだチーム規模は大きくないため不要と考え、PubSubは挟まずデプロイワークフローから直接BigQueryに書き込む形を採っています。

また解析と表示にはLooker Studioを使う選択肢もありましたが、別の用途で使っていることから混乱を避けるため、利用を避けました。

Cloud Runの手前にはIdentity-Aware Proxy(IAP)を置くことで認証をしています。これは以前ご紹介した社内向けサイトと同様の構成なので割愛しますが、GrafanaはProxyによる認証をサポートしていますので誰が何をどう使っているか把握しやすいのが嬉しいです。

どのような値を図示しているか

弊社には複数のStream-aligned Teamがあり、それぞれにプロダクトマネージャが存在します。彼らやエンジニアと議論したりヒアリングしたりしながら、チームごと・サービス全体としてどのような値を見たいかを詰めていきました。

現時点ではサービス全体の指標として、Four Keys Metricsやサーバサイドレイテンシを表示しています。これに加えてインフラコストや可用性、クライアントサイドレイテンシなどの情報を表示していきたいと考えています。

Four Keys Metricsを図示した例(一部)

チームごとの指標としては、PRライフサイクルのどこにボトルネックがあるか・チームメイトがまんべんなくPRレビュアーになれているか・まんべんなくPR作成者になれているかなどの傾向をつかめるようにしています。

さらにお客様の体験を少しでも理解するために、可視化できるファクトを充実させていきたいと考えています。現時点で考えているのは、APIごとのレイテンシやエラー率、Cloud SQLのリソース利用状況の異常検知、バッチ処理におけるMTTRに替わる指標の模索などです。

まとめ:顧客の課題を知って向き合うためのダッシュボードを構築した

お客様の課題解決のためにシステムを作り込めば、それだけシステムが複雑になりお客様の体験を理解することが難しくなります。データがあちらこちらに散在していたり、一本軸を通してデータをつなぎ合わせることが難しかったり、そもそもデータを取れているのかわからなくなったりするからです。

それでも重要なのでコストをかけて解決するわけですが、ある程度型が見えてきたらこれをトイルとみなして削減する必要があります。使い勝手の良いダッシュボードを作成することで、サービスを開発するエンジニアとそのユーザとの距離を縮め、今まで以上の品質改善に役立てていけると考えています。

今回は背景と実装の話をしましたので、近々別の記事で実際に運用してみてどうチームや製品が変わったかをお伝えします。

要件定義失敗と改善の歴史 ~ その時、要求・ユーザーストーリーをどうまとめ、どう改善してきたか ~

こんにちは。ヘンリーCEOの逆瀬川です。

開発する上で、難しい部分の一つである要件定義。

最近、社内では「要求仕様」と呼ばれるようになり、要求仕様化のプロセスとフォーマットの改善に取り組んでいます。しかし、3年間にわたって苦労し、失敗と改善を繰り返してきた歴史があります。

本ブログでは、主にプロセスとフォーマットの失敗について触れますので、詳細は割愛します。「ココもっと深く知りたい!」という方は、ぜひカジュアルにお話しましょう。その場で深堀りいただいた内容を元に、更にブログで考察していきたいと思います。

では、過去私たちが体験した5つの時代と今後訪れるだろう要求開発黄金時代についてお話しましょう。

  1. ユースケースで仕様漏れた時代
  2. 要求導入混沌時代
  3. 要求を全員で書くぞ時代
  4. プロダクト要求と仕様を分けて書き始めた時代
  5. CSと連携して速度が上がり始めた夜明け前
  6. 将来訪れるだろう要求開発黄金時代へ

本題に入る前に

要求の難易度を理解するために、Henryについて少しだけご紹介します。

私たちは、電子カルテ・会計システムをコアとするクラウド型基幹システム「Henry」の開発をしております。複雑な診療フローと法制度の制約、多くのアクターが使うシステムになるため開発難易度が高く、実は四半世紀以上新規プレイヤーが参入してきてない業界です。

  • 開発難易度を上げる具体的な話
    • 診療報酬制度は、A4で1,700ページ以上あり、かつ2年に1度大きなルール変更がある
    • 3省2ガイドラインという官庁が出しているセキュリティ要件がある
    • 病院には、多くの診療科・病棟の種類があり、それぞれ細かいニーズが異なる
    • 入院患者さんの治療には、医者だけではなく、看護師、クラーク(医者の事務補助)、セラピスト、栄養士、薬剤師、検査技師、医療事務(会計・受付)など、多くの人が関わる
      などなど

上記の様に、基幹システムでありかつ会計の機能も開発しているシステムのため、要求をしっかり捉えることが重要になります。

事業の詳細を知りたい人は下記動画を御覧ください。

レセコン×電子カルテ スタートアップ「Henry」彼らは今、何を考えているのか〜Henry CEO 逆瀬川光人 - YouTube

1. ユースケースで仕様漏れた時代

最初にクリニック向けの電子カルテの開発では、ユースケースを詳細にまとめ、デザイナーとエンジニアがそれを読んで設計や内部仕様を決めるという流れで進めていました。

クリニック向けの開発が大きな事故なくリリースできたため、病院向けの開発が始まった当初も同じように進めましたが、病院ではアクターが多く、複数のアクターが関わるユースケースの複雑性を捉えるのに漏れなく失敗しました。

例えば、処方のオーダー(院内の指示出しと指示受けのワークフロー)という機能についても、医師や看護リーダー、現場の看護師、薬剤師、医療事務、クラークといった幅広い職種が別の理由で利用します。

要件定義で行ったこと

  • 業務ワークフローの書き起こし
  • 最小限のユーザーインタビュー
  • ユースケースの書き起こし
  • テストケースの書き起こし

何を失敗したのか

  • アクターが多く、すべてのユースケースを洗い出せなかった
  • 当人ではない相手にユーザーインタビューをしてしまい、自分がやらない業務について、想定の回答をもらってしまった。
  • ユースケースでまとめたため、プロダクトで求められる要求をMECEに整理ができなかった などなど

失敗した結果何が起こったのか

  • 一部の機能を作り直した・・・

2. 要求導入混沌時代

プロダクト要求をきちんとMECEに整理しようという気持ちになり、要求を仕様化する技術・表現する技術の輪読会をして、要求と仕様を書き始めました。どういうフォーマットにするべきか、どういう粒度で書くかをすり合わせして書き出しました。電子カルテでコアとなる入退院のモデルがあるのですが、これに関しては10回くらい書き直しています。加えて、ドメインの知識を実装するエンジニアも必要だろうということで社内勉強会で各ユースケースの説明をしていきました。完成フローやフォーマットが固まるまで地獄でしたが、光が見え始めた時代です。

要件定義で行ったこと

  • ユースケース理解の動画を作った
  • プロダクト要求を書いた
  • ドメインエキスパートを増やした
  • 難しい機能についてはユーザーインタビューとユーザーテストを行った

何を失敗したのか

  • Notionで書くフォーマットにして、MECEのチェックが難しかった
  • レビュープロセスが定まってなく、要求をどこまで書けば完成と言えるか分からなかった

失敗した結果何が起こったのか

  • 要求の完成が見えず、開発の終わりが見えない状況が続いた
    • チームにストレスをかけてしまった

3. 要求を全員で書くぞ時代

フォーマットとレビュープロセスを定めて、ある程度書けるぞとなったタイミングで要求と仕様を開発メンバー全員で書いて開発速度を早めようとTryしました。元々私とPdMロールを担う2名で書いていたのですが、様々な理由で要求がスタックすることが多くスピードを上げる施策として採用しました。

これは大きな失敗でした。そもそも、書く技術もドメイン知識もバラバラな中で全員で書けるわけがなかったのですが、
失敗に気づくのも早く、この時代は短命に終わりました。

要件定義で行ったこと

  • 要求導入混沌時代と同様

何を失敗したのか

  • ドメイン知識がないメンバーにも要求を書くのを求めてしまった
  • PdM経験などあるメンバーとないメンバーの要求化の技術力の差を考慮せずに、みんな書けることを目指してしまった

失敗した結果何が起こったのか

  • 要求が2週間程度全く進まなかった

4. プロダクト要求と仕様を分けて書き始めた時代

紆余曲折を経て、現在は要求と仕様を分けて書いています。書くツールはいろいろ試した結果、要求仕様をGoogle Spreadsheetに書き貯めています。 基本的な構成はユーザーストーリーと近く、下記構成で書いています。

  • 要求
    • それに紐づく理由
    • 紐づく仕様

要求仕様フォーマットのサンプル *1

また、レビュープロセスも要求と仕様でそれぞれ定めており、要求化→レビュー、要求の内容FIX、仕様化→レビューのサイクルを定めて、適切にレビューサイクルを回して完了できるように進めています。 プロダクトチーム内の改善がだいぶ進んで来たなと振り返ると感じます。

要件定義で行ったこと

要求導入混沌時代に加えて。。。

  • レビュープロセスを整えた
  • 要求仕様の書くフォーマットを整えた

何を失敗したのか

  • Epic単位での提供価値とユーザーのペイン・ゲインが見えづらくなった
    • プロダクト要求をきちんと書くことを意識しすぎた
  • このプロダクト要求がビジネス的にどのようなインパクトがあるのか、なぜ必要なのかというビジネス要求がわからなくなった
  • どういうものが最終的に必要なのかイメージ出来なくなった
  • 要求・仕様をきちんと書かなくて良いライトな機能についても書きすぎてしまう

失敗した結果何が起こったのか

  • 仕様を見直そうとした時に、何を基準に判断すればよいかわからない
  • 開発速度が遅れた

5. CSと連携して速度が上がり始めた夜明け前

ここでブレイクスルーが起こります。CS(カスタマーサクセス)を担当している山本さん永井さんがエンジニアチームのDaily Stand Up等のチームアクティビティに参加し、密に連携を取り始めました。2人は実際に病院で医事課業務を行っており、ワークフローへの理解も深いです。CSメンバーが積極的に開発に関わるようになったことで開発プロセスと要求の質が劇的に向上しました。

行ったこと

  • CSのメンバーが開発チームにJOIN
  • CSがビジネス要求や不明点の収集をお願いした
  • 要求分析の相談をDailyで行った
  • 要求化のプロセスものはCSを通じてアジャイルなサイクルを回した

ポジティブな変化

  • ビジネス要求の把握がほぼリアルタイムで行えるようになった
  • 顧客インタビューや観察スピードが劇的にUPして、要求化のスピードが改善した
  • CSを通じて顧客との距離が近くなり、ライトなものはすぐ改善するサイクルが出来た(要求を書く強弱をつけられるようになった)
  • プロセス全体としてアジャイルに徐々に品質を上げていけるようになった

6. 将来訪れるだろう要求開発黄金時代へ

3年間の学びを受けて、これから改善していきたいなと考えている内容をつらつらと書きます。継続的に開発プロセスに導入することができれば良いですが、全部改善できるというわけでもないので、一つずつ改善していきたいと思っています。

これからやりたい流れ

  • ビジネス要求のデータベース化
    • プロダクト要求とビジネス要求を接続し、なぜ作るかがわかりやすく
  • 実例マッピングの導入
    • ドメインエキスパート、関係者全員で実例マッピングを行い、全員が作るもののイメージを揃えるとともに、ライトなものは要求仕様を軽めに書くなどの判断して開発スピードを上げる
  • 要求のメンテナンスのプロセス化
    • 要求が常に最新であることを保つためのプロセス設計
  • 要求とプロジェクト管理の整理
    • 要求と開発チケットを紐付け、相互リンクできるように
  • QAプロセスの改善
    • すべてのプロセスでQAを行い、品質向上を目指す

など

おわりに : アジャイルなプロセスで要求開発の質を上げつつ、スピードを担保する

要求開発は、あくまで価値提供のスピードと品質を上げるための施策に過ぎません。元々、開発内で閉じた改善活動もCSと協働することで、開発スピードが上がり、要求の質も高めることが出来ました。これからも、開発内に閉じず、会社全体で良いプロダクトを提供していくために改善していく予定ですが、要求開発とそのプロセスは重要な要素の一つであり続けると思います。
ぜひ、一緒に良いプロダクトを作りたい、そのために要求の質を上げていきたいぞっていう方がいたら、ご連絡ください。
2月末と3月頭にイベントもやるので、ワイワイ話しましょう!

henry.connpass.com

henry.connpass.com

プロセスを設計する上で参考にした書籍・リンク

www.amazon.co.jp

www.amazon.co.jp

leanpub.com

speakerdeck.com

*1:要求仕様フォーマットのサンプル

Cloud Runで動くJVMの監視にログベースの指標が便利

株式会社ヘンリーでSREをやっているTODA(@Kengo_TODA)です。弊社ではGoogle Cloud Platform(GCP)を活用してサービスを構築しており、またサーバサイドにはKotlinを利用しています。Cloud Runで動くJVMサービスの監視にログベースの指標が便利だったので紹介します。

何をもってJVMで駆動するサービスを「メモリが足りていない」と判断するか

Cloud Runのメモリ監視で最も利用しやすいのは、Cloud Monitoringでメモリ利用率などを見ることでしょう。次に示す図のように、サービスごとのデータを取ってグラフ化できます。

図1 メモリ利用率をプロットしてみた

ではこのグラフから何がわかるのでしょうか?例えば下側に紫色で示されたCloud Runサービスはメモリにずいぶんと余裕がありそうです。常時この状態であれば、メモリ割当量を減らしても良さそうですね。

反面上側にオレンジ色で示されたCloud Runサービスは、だいぶメモリを使っています。しかし、これをもって「メモリが足りないので増やす」判断を下すことはできません。JVMで動いているサービスは、Javaヒープなどの領域がどのように使われているか・フルGCがどの程度生じているのかを分析して初めて「メモリが足りているかどうか」判断できるからです。

単にメモリの利用率だけを監視すると、むしろ理想的にメモリを活用できているサービスを”犯人”だとしてしまう可能性があります。実際弊社でもユーザ増加に起因するメモリ利用量増加によって、一部サービスが頻繁にアラートを投げるようになりました。

フルGCの回数を数える

それでは対象のCloud Runサービスで、どのくらいフルGCが実行されているのか数えてみましょう。

GCの発生回数を取得するには、まずGC実行記録をログを出すようにします。最新のJVMで必要な設定は久保田さんのUnified JVM Loggingに詳しく説明されていて、弊社では -Xlog:gcJVM起動オプションに追加しました。この設定により、以下のようなログがCloud Loggingに出力されます:

[4204.610s][info][gc] GC(223) Pause Young (Allocation Failure) 139M->72M(247M) 4.592ms
[4077.128s][info][gc] GC(217) Pause Full (Allocation Failure) 176M->62M(250M) 302.523ms

こうしたログがあれば、grepやwcを組み合わせてフルGC回数を数えることはできそうに感じませんか?GCPの「ログベースの指標」はこうしたニーズの面倒を見てくれます。例えばTerraformなら以下のように設定すると、対象サービスのフルGC回数を数えられます:

resource "google_logging_metric" "full_gc" {
  name        = "${google_cloud_run_service.service.name}-full-gc"
  description = "The full GC (G1GC) triggered in the target JVM"
  filter = join(" AND ", [
    "resource.type=\"cloud_run_revision\"",
    "resource.labels.service_name=\"${google_cloud_run_service.service.name}\"",
    "textPayload:\"[gc]\"",
    "textPayload:\"Pause Full\"",
  ])
  metric_descriptor {
    metric_kind = "DELTA"
    value_type  = "INT64"
  }
}

更にMetrics Explorerを使うとグラフ化もできます。弊社の事例では、時間帯によってフルGCが生じているものの最頻値でも1分に1回程度であり、余裕はあると判断しました:

図2 フルGC回数を数えてみた

フルGCが頻繁に実行されたらSlackに通知を投げる

さて今は余裕があるとしても、今後負荷が増えてフルGCが増えてくる可能性もあります。よってフルGCの頻度が増えたらSlackに通知を投げるようにしておきましょう。これはCloud Monitoring Alertsによって実現できます。

今回はサービスごとのフルGC回数を数え、5分間に10回以上のフルGCが実行された場合にアラートを投げるようにしました。ケースによってはコンテナインスタンスごとにフルGCを数えても丁寧で良いと思います。

resource "google_monitoring_alert_policy" "full_gc" {
  display_name = "[${var.env}] Frequent Full GC (${google_cloud_run_service.service.name})"
  combiner     = "OR"
  conditions {
    display_name = "Full GCが5分間に10回以上実行されました。"
    condition_threshold {
      filter = join(" AND ", [
        "metric.type=\"logging.googleapis.com/user/${google_logging_metric.full_gc.name}\"",
        "resource.type=\"cloud_run_revision\""
      ])
      threshold_value = 9
      duration        = "300s"
      comparison      = "COMPARISON_GT"
      aggregations {
        alignment_period     = "60s"
        cross_series_reducer = "REDUCE_SUM"
        per_series_aligner   = "ALIGN_SUM"
      }
    }
  }

  notification_channels = [
    var.notification_channel,
  ]
}

まとめ

Cloud Runで動くJVMの監視では、単にメモリ利用率だけ見てしまうとノイズが多くなることを見てきました。 これを踏まえてフルGCの頻度を監視し、高頻度のGCを検知したらSlackにアラートを投げるように変更したことで、ノイズを減らしてより本質的な監視ができるようになったと感じています。


ヘンリーは顧客と同僚の体験を改善していきたい仲間を絶賛採用しております。急拡大するチームを支えるリリース体制を実現したい方、品質保証プロセスの理想を踏まえた開発体制構築に関心のある方は気軽にご連絡ください。お待ちしています!

jobs.henry-app.jp

ヘンリーはQAに注力していくぞ!

はじめまして。ヘンリー CEOの逆瀬川です。
タイトル通り、今年は会社としてQA(Quality Assurance)の向上に注力していきます。
今までも要求開発やテストなどの改善を行ってきましたが、今年は会社としてより一層注力していきたく、改めてここに宣言します。

医療業界において求められるQA基準

弊社は、医療機関向けの基幹システムとして、電子カルテと医療会計システム(レセプトコンピューター、通称レセコン)を開発している会社です。
医療機関において、電子カルテと医療会計システムは業務上で欠かせないシステムです。システムトラブルで実際の業務が止まり、新規患者受付を一時的に停止したといった最悪の事態につながる可能性があり、より安定性の高いシステムを提供していく必要があります。

また、電子カルテでは患者さんへの治療方針と指示、記録等を残すシステムです。投薬量を間違えるなどと操作ミスが患者さんの命を危険にさらすリスクもあります。

医療機関目線で言うと、医療機関は命を扱う現場であるため各医療機関は日々、「医療安全対策」 をしています。患者さんの安全を最優先に考えた業務プロセスの設計や、院内での研修や啓蒙活動が行われています。

医療安全の例

全社をあげたQA活動を

上記の様に、基幹システムであることとお客様の業務の特異性から、求められるQAの基準は大変高く、プロダクトだけではなくプロダクトを利用したワークフローやオペレーションレベルで高い品質の実現が求められます。

まだまだ理想とするレベルには程遠いですが、着実に前に進むべくプロダクトチームだけではなく、営業、カスタマーサクセス、コーポレートと一丸となって、お客様の医療安全の実現に向けたQA活動を行っていきたいと思っています。現在イメージしている各ロールの役割分担は下記の通りです。

  1. 営業 → 顧客への啓蒙
  2. カスタマーサクセス → 現場、ワークフローやオペレーションへの落とし込み
  3. プロダクト → 安定性の高いプロダクトの実現
  4. コーポレートガイドライン・規約等への反映

現在地と今年の目標

今までは重要性を感じつつも投資できてなかった、というのが正直なところです。専任のQAメンバーもおらず、各自が勉強し試行錯誤しながら施策を導入している状況です。

昨年まず、外部の講師を読んで勉強会をしたり、要求開発の勉強会をするなどして、要求仕様での検証やテストの改善などを行ってきました。また、外部の検証会社にお願いしていたリリース前の動作確認を内製化し、リグレッションテストの充実も図っています。今年に入ってからは、リグレッションテストの自動化やテスト戦略の設計なども開始しました。

今まで行った取り組み

今年開始した取り組み

見えてきた課題

リリース当初よりだいぶ状況は良くなってきていますが、手探りで進めているため、発生した課題ベースや各個人の興味をベースに改善している傾向があります。そのため、本質的なアプローチが出来ておらず、改善速度が早いとは言えない状況です。また、プロダクトチームに閉じた活動になっており、ビジネス系も含めたアクションプランに落とし込めてない状況です。
ただ、KPT等でQAに関連するトピックが上がる頻度は上がってきていてみんな強い関心をもって取り組もうとしています。QAリードの人がJOINして、全体ディレクションをしてくれたらうまくいく!!というイメージが湧いてきています。(ヘンリーのエンジニアは学習欲が強く、新しい取り組みには協力的です。)

今年の目標

  • QAリードの採用
  • QA戦略の策定
  • 全エンジニアがQA活動を改善している状態の実現
  • ワークフローへの落とし込み 等

上記のように、取り組み自体は徐々に増えていますが、会社が提供したいレベルとのギャップは大きく、この取組みに賛同してくれる素晴らしい仲間を見つけ、活動を加速していきたいと思っております!
ぜひ、1人目QAリードに興味があるよという方はお気軽にお声掛けください。 一方で、新しい仲間が入るまでにやらなくていいということではなく、出来るところからAgileに改善していきます。

jobs.henry-app.jp