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

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

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

Gradleプロジェクトを分割するときに僕が考えたこと

株式会社ヘンリーでSREなどをやっている戸田(id:eller)です。

私は先日より複数のブログやプレゼンで「Gradleプロジェクトを分割したいけどできてない」と言っております。 Gradleプロジェクト分割にはコードの置き場所が明確になってプログラマの心理的な負担を下げ、Gradleプロジェクトという単位で依存を管理することで不正な依存が入る余地を減らす利点があります。またKotlinコンパイルひいてはGradleビルドの高速化にも繋がるため、プロダクト品質や開発体験を向上するにあたって重要な施策だと考えています。

そして……とうとうやりましたよ!プロジェクト分割を!!!🎉 ということで今回はGradleプロジェクトを分割する際に配慮したことをまとめておきます。

そもそもGradleプロジェクト分割は何が難しいのか

Gradleに限らずMavenでもAntでも良いのですが、プロジェクトをあとから分割することは至難の業です。それは多くの場合、クラスやパッケージの依存関係が複雑かつ循環していることが多いためです。

Javaのウェブアプリケーションは古くから3層アーキテクチャなどの構造が知られており、構造化が進んでいるはずなのですが、現実は厳しいものです。個人的にも ArchUnit を使ったりGradleのプロジェクト分割を使ったりして工夫してきましたが、新規プロジェクトはともかく既存プロジェクトは泥団子状態のことが多いです。

逆に言えば依存関係が整理されていれば、IDEやコンパイラを活用することでプロジェクト分割をスムーズに行えます。コイツが最初の山であり、ラスボスなのです。

import文ベースで粗く依存関係を見るところから着手

依存関係を見るのは、先のスライドで紹介した jdeps による解析が簡単です。これで循環を見つけられればしめたものです。ひとつひとつ見ていき、これをほどいていきましょう。

ほどきかたは本当にケース・バイ・ケースなので、勝ちパターンのようなものはありません。気合と根性!です。ですがリファクタリングの一種ではあるため、広く知られているリファクタリング手法が適用可能です。参考書籍として自分からは、だいぶ古いですが「レガシーソフトウェア改善ガイド」をおすすめしておきます。

とはいえケース・バイ・ケースと言われてもイメージが湧かないかと思いますので、弊社の事例から単純なものを紹介します。

クラスを別パッケージに移動した事例

たとえば gRPC API で使うデータ型のための extension が見た目の循環を生んでいることがありました。次の依存関係はパッケージのレベルは循環していますが、クラスのレベルでは循環していません:

graph LR
  subgraph service
    FooService --> BarService
    BarExtension
  end
  subgraph model
    FooModel --> BarModel
  end
  FooService --> FooModel
  BarService --> BarModel
  FooModel --> BarExtension

この場合は、 extension の置き場を調整して循環を断ち切れます:

graph LR
  subgraph service
    FooService --> BarService
  end
  subgraph model
    FooModel --> BarModel
    BarExtension
  end
  FooService --> FooModel
  BarService --> BarModel
  FooModel --> BarExtension

プロジェクトもきちんと切り分けられそうです:

graph LR
  subgraph foo
    subgraph foo.service
      FooService
    end
    subgraph foo.model
      FooModel
    end
  end
  subgraph bar
    subgraph bar.service
      BarService
    end
    subgraph bar.model
      BarModel
      BarExtension
    end
  end
  FooService --> BarService
  FooModel --> BarModel
  FooService --> FooModel
  BarService --> BarModel
  FooModel --> BarExtension

もちろん移動することに不都合がないかどうかは検討が必要ですが、多くの場合は問題になりにくいのではないかと思われます。またこの過程で internal 修飾子を削除した箇所がそこそこあり、その影響があるかどうかも確認していました。

ドメイン外の概念を見つけてプロジェクトから追い出す

もうひとつ弊社の事例を紹介します。今回は一枚岩のプロジェクトからひとつのドメインに属する概念を取り出すことを目的としていました。理想的には次のようになってほしかったわけです:

graph LR
  subgraph 元一枚岩
    FooService --> FooModel
  end
  subgraph 新プロジェクト
    BarService --> BarModel
  end
  FooService --> BarService
  FooModel --> BarModel

ところが実際には、新プロジェクトのクラスが依存する「新プロジェクトには属するべきではないクラス」が存在します。 これを新プロジェクト内に保持してしまうと、新プロジェクトがまた新たな一枚岩になってしまうリスクがあると感じました:

graph LR
  classDef hoge fill:orange;
  subgraph 元一枚岩
    FooService --> FooModel
  end
  subgraph 新プロジェクト
    BarService --> BarModel --> HogeEntity:::hoge --> HogeValueObject:::hoge
  end
  FooService --> BarService
  FooModel --> BarModel
  FooModel --> HogeEntity

よってこの「あるべきではないクラス」を切り出すための別のGradleプロジェクトを作成し、そこに該当するクラスを追い出しました:

graph LR
  classDef hoge fill:orange;
  subgraph 元一枚岩
    FooService --> FooModel
  end
  subgraph 新プロジェクト
    BarService --> BarModel
  end
  subgraph shared
    HogeEntity:::hoge --> HogeValueObject:::hoge
  end
  BarModel --> HogeEntity
  FooService --> BarService
  FooModel --> BarModel
  FooModel --> HogeEntity

この新しく作成した shared プロジェクトには、今回対象としなかったドメインに属するクラスが入ってきます。プロジェクト間で共有されるためのクラスやユーティリティであれば問題ないのですが、意図せず入ってきてしまったクラスもいくつか見つかりました。今後のリファクタリングで正しい居場所に動かす必要がありそうです。

Gradleプロジェクト分割による効果

当施策の定量的効果として、Gradleビルド高速化効果がどの程度あったのかを見てみました。1週間ほど観察しましたが、残念ながらGitHub Actionsにおけるビルド高速化効果は確認できませんでした。 理屈の上では、sharedプロジェクトないし今回切り出したプロジェクトに変更が入らなければ、それらに属するコードのコンパイルとテストを実施しなくて済むはずなんですけどね。

遠目に見ると20~30秒くらい早くなったかな?という気はしますが明らかな改善ではないですし、試行回数が少ないこともあるためしばらく様子を見たほうが良さそうです。

一方で定性的効果としては、プロジェクト分割による見通しの良さが評価されています。以前から package を使って整理を試みていましたが、package では依存関係の制約が緩く、意図しない依存が混ざることがありました。 プロジェクトを分割することで、こうした依存が明確に洗い出され、今後も意図しない依存の混入が起こりにくくなる効果が期待されています。

まとめ

以上で今回実施したGradleプロジェクト分割の概要を説明しました。依存の整理さえできれば問題ないこと、依存のほどきかたはケース・バイ・ケースだがリファクタリング手法が適用できること、プロジェクト分割によって新たなリファクタリング課題が見つかることについて触れました。

定量的効果であるビルド高速化効果はまだ確認できていませんが、定性的効果としての見通しの良さは package による整理よりも高い成果が得られたと考えています。 製品開発のスケールしやすさを確保するためにも、今後も分割を進めていこうと考えています。

ヘンリーでは各種エンジニア職を積極的に採用しています。医療ドメインに興味がある方も、GradleやSREに興味がある方も、ぜひカジュアル面談でお話させていただければと思います。よろしくお願いいたします!

jobs.henry-app.jp

Our DPE Journey Halved Pre-Merge Build Time

※ 英文記事です。同内容を日本語で掘り下げた記事を弊社有志で発行した同人誌に掲載していますので、よろしければご参照ください。

This report will share our Developer Productivity Engineering (DPE) Journey at Henry, Inc. Our server-side Kotlin project used to take around 20 mins to complete a pre-merge build. It discouraged engineers from adding more automated tests. We implemented several solutions including Develocity, and now pre-merge builds are completed within 8 mins.

About us

Henry, Inc. is developing products with the mission of "keep solving social issues to make a brighter world". As a first step, we are currently developing "Henry" a cloud-based electronic medical record and receipt system, which is a core system for small to medium-sized hospitals.

Our engineering team has ~20 engineers developing server-side Kotlin. We are in the phase to expand team size and quality of services, and it motivates us to improve developer productivity and shorten Lead Time to Change while adding more automated test cases.

DPE barriers in our project

Our Gradle project is a monolith which contains ~10 subprojects. We applied remote build cache with the help of tehlers/gradle-gcs-build-cache, and GitHub Actions cache with the help of setup-gradle action. Some of our engineers understood how to improve build performance with Gradle, so we believe that we did our best to improve DPE, but we still had many barriers:

  1. Kotlin compilation took much time. In our case, the K2 compiler does not shorten compilation time. Usually compilation took 3 to 5 mins to compile.
  2. The unit tests written in Kotest took 13 to 15 mins.

Deployment workflow has one more barrier, database migration processes (ridgepole and Algolia) which take 10~ mins. So our Lead Time to Change usually takes more than 35 mins.

The cause of our slow tests

By historical reasons, our test cases tightly depend on datastore (Postgres), and do not sufficiently account for conflicts between them. It means that one test case could fail due to changes made by other test cases, so we are forced to run test cases in serial. We could not run tests with maxParallelForks option, even though we used a large runner that had 8 cores and 16 GiB memory to launch on-memory Postgres instance for performance.

Why our compilation takes time

Our Gradle project is monolith, but it is not well separated yet. Last year we merged two Git repositories into one, and reorganization is still in progress. So each subproject is still huge and cannot use build caches efficiently. Here is a graph that explains dependencies among subprojects roughly:

graph BT
    master-data --> shared
    general-api --> master-data
    receipt-api --> master-data
    batches --> general-api

Considered solutions

Testcontainers

It is too costly to rewrite existing test cases to keep isolated from other test cases, so we tried to use process-local database to solve the problem. Testcontainers is a suitable solution for us, which lets us launch Postgres database for each test fork. It resolves conflicts between test forks by using an isolated Postgres database for each fork. Testcontainers also makes it possible to run test remotely; not only on GitHub Actions runner but also on Test Distribution Agents hosted by Develocity.

Parallelize data migrations

We run data migrations in parallel, with the help of build matrix. This is easy to maintain, and visualizes the progress of migration. However, it is costly and slow. In total, our workflow took 150 mins/build (cumulative CPU time) and a quarter of it comes from data migration.

We unify all data migrations into one Gradle task, and run it on one Runner. It enables us to run multiple data migrations in one process with the help of coroutines, so we could run migration in shorter time with less computing resource.

Subproject separation

To run Gradle builds in parallel, it is really effective to separate project into multiple subprojects. While we wanted to split the monolithic Gradle project into smaller subprojects to enhance build parallelism, our codebase is still in the process of reorganization. Thus, we had to defer this effort for now.

…and Develocity!

We expected that Develocity can shorten test execution time by Test Distribution Agents. After our verification, we found that it surely halved test execution time and whole pre-merge. You can find that builds with Develocity Test Distribution Agent (No.40 and later) finished in shorter time:

Figure 1: Develocity significantly reduced pre-merge build times by halving the test execution time.

In this POC we have only one Test Distribution Agent host, so sometimes Gradle build cannot run tests remotely due to conflict among build processes. We hope that auto-scaling feature will stabilize the performance. According to the Predictive Test Selection (PTS) simulator, PTS can skip about 20% of test cases, then it also contributes shortening pre-merge build time.

Lessons learned, and our next challenge

In this challenge, we have confirmed that we halved our pre-merge build time with help from Testcontainers and Test Distribution Agents of Develocity. This is really huge contribution to keep changes on our service frequent. It still has several areas for improvement, such as auto-scaling feature of Test Distribution Agents, but is already one of the important parts of our development workflow.

Legacy Builds (serial test exec) New Builds (parallel test exec) New Builds w/ Test Distribution Agents
whole pre-merge workflow 20 mins 15 mins 8 mins
compile tasks 3~5 mins 3~5 mins 3~5 mins
test tasks 13~15 mins 8~10 mins 7~8 mins

However, post-merge build including data migration still takes time. We'll keep working on subproject separation for better parallelism, and reduce time to run data migration.

ヘンリーは Server-Side Kotlin Meetup の運営に参加しました

ヘンリーで SRE をやっている id:nabeop です。

Server-Side Kotlin Meetup とは?

Server-Side Kotlin Meetup とはサーバーサイド Kotlin の開発者コミュニティです。LT を中心としたイベントを定期的に開催しています。

これまでは12回のイベントが開催されており、13回目のイベントとして Server-Side Kotlin LT大会 vol.13 - connpass の開催が 2024年10月25日 (金) に予定しています。

なぜ運営に参加することにしたのか?

クラウド型の電子カルテ・レセコン・オーダーシステム Henry において、サーバーサイド開発の言語として Kotlin を長年使用しており、これまでも活発にさまざまなイベントで登壇をしてきました。また、VPoE の id:Songmu のエントリにあるとおり、開発者コミュニティへの参加やイベントへの参加を通じて、社外のコミュニティとつながることを目指してきました。

エンジニアブログでの Kotlin に関する話題や、過去の登壇内容の例は以下のようなものがあります。

さらにサーバーサイド Kotlin の開発者コミュニティに貢献するため、Server-Side Kolin Meetup への参加を決めた次第です。

今後の意気込み

Server-Side Kotlin Meetup の運営への参加を通じて、サーバーサイド Kotlin コミュニティをさらに盛り上げていきたいです。Server-Side Kotlin Meetup では当日の運営スタッフとしての参加だけではなく、イベントの準備や弊社でのサーバーサイド Kotlin に関する知見の共有などでも積極的に関わっていきたいです。

そしてServer-side Kotlin Meetupを通して、サーバーサイド Kotlinの普及や発展にも貢献していければと考えています。

Server-Side Kotlin LT大会 vol.13 で会いましょう

弊社が運営に参加してから初のイベントとして 2024年10月25日 (金) の 19:30 から Server-Side Kotlin LT大会 vol.13 - connpass の参加者の募集が始まっています。お時間があえば、ぜひご参加ください。

あるVPoEの心の中

VP of Engineeringの id:Songmu です。さて、ここ1年くらいプロダクト開発に直接携わっていないので、価値提供に直接繋がらなくなったような、なんとなくの不安感があります。これまでの職場では無理矢理でも何らかの形でプロダクト開発に携わっていたので初めての感覚です。

ただ、採用やエンジニアリング組織周りへのフォーカスは、私自身が望んでいることです。自分が過去所属した組織でやりきれなかったことに対するリベンジであり、ありがたいことに、VPoEとして組織開発の当事者としてそれらの課題に主体的に関わるチャンスを与えられているということです。そのあたりの話は、去年末のエントリにも書きました。

それに、あまり表に出してきませんでしたが、私はなんだかんだ、ここ10年くらいマネジメントだったりエンジニア採用に取り組んできたので、そこに関する発信などもしたいとも思うようになっています。

ちなみに、ヘンリーではもう一人のVPoEである張が、今は現場に入りながら価値デリバリーに軸足を置いて活動しています。VPoE間の役割分担は状況に応じて変化させていますが、このユニーク性については別途お話できると面白いと思っています。

フォーカスの危険性

さて、私がプロダクト開発を兼務していた組織では「今はここまで組織開発をやるとやりすぎだな」とバランスを取れました。「今現場はそれどころじゃない」タイミングが分かり、様子見できたのです。しかし、組織開発にフォーカスしている今はバランスを取るための判断材料が乏しく、物差しが壊れていないかが心配になることもしばしばです。

だから「やりすぎてしまう」組織的ビルドトラップ(作りすぎ)を恐れています。組織のバリューストリームを阻害する、無駄な制度、イベント、ワークショップ等の割り込み業務を作り込んでいないかが怖いのです。それに、価値提供に直接繋がっていないと感じるが故に、なおさら成果を出そうと焦って無駄なことをやりすぎてしまわないか、という懸念もあります。

これは、現場感が薄れて局所最適に陥っているというよくあるやつです。変にSNS等での露出ばかり増やしてチャラチャラせず、価値創造・価値提供にフォーカスしていたい。全社員がそれを意識できている組織こそが理想なのに、私自身がその実感が薄れていることは由々しき事態です。

そんな中で、私がバリューストリームとの接続を感じ、ビルドトラップを避けながら、適切に施策を打つための心構えや考えを再整理したのがこの後の内容です。

築城ではなく船団運営

組織の「土台」や「基盤」という言葉が良く使われます。ただ、こういう言葉は「しっかりしていればいるほど良い」という印象を持たせ、作り込む大義名分を発生させてしまう危うさをはらんでいます。そこに無駄な作り込みが生まれやすくなる。ちなみにシステムプラットフォームに対しても同様の印象を持っています。

これはいわば「築城」のメタファーと言え、そういう建築的なメタファーが悪影響を招く例です。築城であれば一箇所にとどまることが前提ですが、実際の組織が一箇所にとどまることは硬直化のリスクが高いです。

組織開発は築城より船団運営に近いのではないでしょうか。船団の船をどういう構成で組むか、乗組員をどのように分散させるか。例えば、大きい船一つだと一直線に速くは進めるけど、方向転換が極めて遅くなるし、進めない海路も増え、転覆時のリスクも高い。しかし、小舟ばかりになると推進力が弱くなる。そういう制約の中で、船団を実際に運行しながら船を増減させ、変化・適応させていく。そちらのほうが動的な組織運営にフィットしたメタファーであると感じます。

適切なメタファーを意識して、土台や基盤をいたずらに太らせて組織を鈍重にすることを防がないといけません。

支援ではなくエンパワーメントフライホイールを駆動する

組織開発では「支援」や「下支え」といった言葉も良く使われます。もちろんそういう側面もありますが、それが全てではありません。また、開発者以外の職種の人から「自分は開発者・クリエイターではないから価値を生み出す『側』ではない」と言った発言を聞くことがあります。文脈もありますが、個人的には少し寂しく感じます。そんなことはなく、社員全員がフラットに価値を生み出すサイクルに加わっているし、各自がそう思える組織が強いと思っているからです。

例えば、私の場合、人材採用、組織開発、技術広報が現状の大きなミッションですが、組織開発にまつわる活動が組織の価値提供とどのようにつながっており、自分の活動がどこに位置するのかを俯瞰するために整理したのが以下の図です。(まだ整理しきれてないのですが公開します)

エンパワーメントフライホイール

これをエンパワーメントフライホイールと呼んでおり、これが滞り無く回り続けることが第一だと考えています。そのサイクルの中での自分の位置づけを意識し、サイクルの中での過剰な部分を削り、手薄になっている部分へテコ入れしながら、サイクルを回し続ける。なので、自分の担当領域をいたずらに固定せず、ポジショニングを変えていくことが前提です。

このように、全体を俯瞰してバリューストリームとの接続を認識しながら、手薄なところを見極めて適切に動き方を変えていきたい。自分の今の領域だけをきっちりやっていれば良いなどと考えて、一部分が過剰にサイロ化するような状況を避けたいと思っています。

コーポレート機能や人事制度は後回しで良いのか?

結局、組織開発もアジャイル開発と同じで必要最低限のことをやりましょう、ということになります。

ただ、スタートアップ界隈ではコーポレート機能や人事制度への軽視も感じます。もちろんミニマムに保つことは必須ですが、プロダクト開発における「当たり前品質」が年々上がっているように、組織に対する「当たり前品質」も年々上がっており、自分たちが意識しているよりかは早めに手を打ったほうが良いと考えるようになりました。

これは、一昔前のスタートアップにおける「SREは後回しで良い」「テストは書かなくて良い」といった風潮に似ているように思います。このあたりは早めに手を付けておかないと、負債が大きくなり、開発速度に影響することが認識されるようになりました。更に、枯れたプラクティスを最初期から導入することで、無駄な負債の発生を防げるようになってきています。

コーポレート機能や人事制度も少し遅れて同様の状況が進行していくと考えています。スタートアップにおいて、コーポレート部門が「これをまだやらなくて大丈夫かな?」と不安を抱えながら、声を上げられない、という状況はよく見られます。もちろん、エンジニアがオーバーエンジニアリングを志向してしまいがちなのと同様に、実際はそこまできちんとやらなくても良いことも多いかもしれません。ただ、それらを俎上に載せて必要性の議論の機会を与えられないと腐るだけです。それらが後々返却困難な負債として襲いかかってくるかもしれません。後回しにされている「組織制度上の負債」には早めに向き合っていく必要があります。

エンジニアリング組織の人事制度設計

ITシステムにけるインフラやSRE投資が重要であるように、組織において人やチームへの投資も当然重要です。良い人を集めるだけではなく、個の力を最大限発揮し、チームでベクトルを合わせることで、価値創造と価値提供を最大化しなくてはいけません。

そのために、個々の専門性を活き活きと発揮して長期的に活躍してもらえる環境、成長意欲の高い人が成長実感を感じてもらえる土壌の整備が必要です。

ヘンリーでは現状エンジニアリング組織の人事制度設計に取り組んでいます。プロダクト開発組織も40名を越え、いよいよそこに向き合う必要が出てきました。むしろ後手に回っている感覚もあります。

このエントリでは、抽象的な話に終止してしまいましたが、そのあたりをしっかり整えて次回はそれについて書きたいと考えています。

このような段階の組織ですが、一緒にヘンリーで世の中への価値提供に取り組んでくれる方を募集しています。単に興味があって話してみたいというのも歓迎なので、まずは連絡をお待ちしています。

サーバーサイド Java / Kotlin エコシステムに潜む ThreadLocal ~ Kotlin Coroutine と ThreadLocal を安全につなぎこむ

こんにちは!ヘンリーでソフトウェアエンジニアをしている @agatan です。

今日は小ネタで、サーバーサイド Java / Kotlin エコシステムで意外と使われている ThreadLocal と、それを Coroutine と安全に組み合わせる方法について紹介します!

TL; DR

ThreadContextElementを使おう!

ThreadLocal とは

java.lang.ThreadLocal<T> は、その名の通り、スレッドローカルな(= スレッドごとに独立した値を持つ)変数を定義するための機構です。

ある Thread で値を書き換えたとしても、他の Thread から見た ThreadLocal 変数の中身は書き換わらない、という性質があります。

import kotlin.concurrent.thread

val tls: ThreadLocal<Int> = ThreadLocal.withInitial { -1 }

fun printTls() {
    println("${Thread.currentThread().name}: ${tls.get()}")
}

fun main() {
    val th1 = thread {
        printTls() // => Thread-0: -1
        tls.set(0)
        printTls() // => Thread-0: 0
    }
    val th2 = thread {
        printTls() // => Thread-1: -1
        tls.set(1)
        printTls() // => Thread-1: 1
    }
    th1.join()
    th2.join()
    printTls() // => main: -1
}

サーバーサイド Java / Kotlin エコシステムでの ThreadLocal

ThreadLocal は暗黙の状態であり、グローバル変数的な性質を持っています。スレッドローカルなので、データ競合こそ起きませんが、一般にグローバル変数は避けたいものですよね。

ところが、サーバーサイド Java / Kotlin エコシステムでは、この ThreadLocal が思ったより頻繁に登場しています。

gRPC-Java

gRPC には Context という概念があります。リクエストごとのコンテキスト情報を保持する概念で、典型的なユースケースとして、認証情報を詰めたり OpenTelemetry の Trace ID の伝搬に使われたりします。

gRPC-Java での Context は以下のようにして使います。

// 現在の Context を取得する
val current = Context.current()
// Context に key=value を詰める
val newCtx = current.withValue(key, value)
// key=value が格納されたコンテキスト下で処理を実行する
newCtx.run {
  Context.current()  // (1) newCtx が得られる
}

Context.current を呼び出すと、現在のコンテキストを取得できます。上の例でいえば、引数として引き回したりしていないのに、 (1) の部分で newCtx が取得できるのですが、それを実現する方法として ThreadLocal が内部で利用されています

Exposed

Exposed は Jetbrains 社謹製の ORM です。以下のようなコードが書けます。

val db = Database.connect()
transaction(db) {
  Users.selectAll().where { Users.id.eq(1) }.toList()
}

このコードでは、 Users.selectAll() の部分で実際のデータベースアクセスが行われるのですが、データベースへのコネクションを握っているのは db オブジェクトです。

明示的に引数として渡したりしていないのに、どうやってデータベースへのコネクションを取得するかというと、やっぱり ThreadLocal を使っています。(Spring と併用している場合など、ThreadLocal に直接依存しない機構も提供されていますが、Henry では Spring を使っていないので ThreadLocal に依存した使い方になっています。)

OpenTelemetry

opentelemetry-java には、現在の Span の情報を取得する方法として、以下のような API が生えています。

Span.current()

これも、いくつかのクラス(Context, ContextStorage, LazyContext など)を経て、最終的に ThreadLocal依存の実装 にたどり着きます。

このように、サーバーサイド Java / Kotlin でよく使われるインフラ的なフレームワークたちの内部では、ThreadLocal が頻繁に使われています。

これらのフレームワークでは共通して、ThreadLocalに依存する “ContextStorage” 的なクラスが提供されていますが、API としては ThreadLocal 非依存な Interface になっていて、実装を差し替えることも可能になっています。

しかし、引数として持ち回さずに “Context” っぽいものを伝搬する機能を提供しようと思うと、JVM では ThreadLocal に依存しないことは難しく、自前実装に差し替えるとしても ThreadLocal を回避するのは困難です。

Coroutine と ThreadLocal

Henry はサーバーサイド API を Kotlin を使って記述していますが、Kotlin には強力な並行処理の道具として “Coroutine” というものがあります。

Coroutine には、「ある一つのCoroutineの実行が複数のスレッドにまたがる可能性がある」という性質があります。これは、この記事の主題に大きく影響する性質です。

ある Coroutine (launchasync などで起動する一つの Coroutine)  の処理が、そもそも別スレッドで開始される可能性があり、さらに処理の途中で別のスレッドに移動することもあるのです。

以下に具体的な挙動を示すサンプルを記載します。

import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import java.util.concurrent.Executors

suspend fun f(tag: String) {
    println("$tag @ ${Thread.currentThread().name} (before yield)")
    yield()
    println("$tag @ ${Thread.currentThread().name} (after yield)")
}

fun main() {
    runBlocking(Executors.newFixedThreadPool(2).asCoroutineDispatcher()) {
        for (n in 1..3) {
            launch {
                f("launch-$n")
            }
        }
    }
}

このサンプルでは、2 threads のスレッドプールの上で 3 つの coroutine を起動しています。

それぞれの coroutine の中では、自分自身が動いている Thread の名前 (= Thread.currentThread().name) と Coroutine の名前を 2 回 print していますが、1 回目と 2 回目のあいだで yield() を挟んで処理を suspend させています。

実行結果は例えば以下のようになるはずです。(環境依存)

launch-1 @ pool-1-thread-2 (before yield)
launch-2 @ pool-1-thread-1 (before yield)
launch-3 @ pool-1-thread-2 (before yield)
launch-1 @ pool-1-thread-1 (after yield)
launch-2 @ pool-1-thread-2 (after yield)
launch-3 @ pool-1-thread-2 (after yield)

ここから次のことがわかります。

  • すべての coroutine は、スレッドプール上のスレッドで動いており、main スレッドでは動いていない
  • suspend の前後で別のスレッドに移動することがある
    • 例えば launch-1 に相当する coroutine は、yield 前は pool-1-thread-2 で動いているが、yield 後は pool-1-thread-1 で動いている

Coroutine の動くスレッドが固定されないということは、ThreadLocal との併用がうまくいかないことを意味します。

ThreadLocal に値を set した後、処理が suspend して別スレッドにうつってしまった場合、さっき set した値を get することはできなくなります。

次に示すコードでは、Coroutine の中から ThreadLocal に値を set し、yield 前後で ThreadLocal の値を print しています。

import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import java.util.concurrent.Executors

val tls = ThreadLocal<String?>()

suspend fun f(tag: String) {
    println("$tag @ ${Thread.currentThread().name} (before yield): ${tls.get()}")
    yield()
    println("$tag @ ${Thread.currentThread().name} (after yield): ${tls.get()}")
}

fun main() {
    runBlocking(Executors.newFixedThreadPool(2).asCoroutineDispatcher()) {
        for (n in 1..3) {
            launch {
                tls.set("launch-$n")
                f("launch-$n")
            }
        }
    }
}

実行結果は以下のようになりました。

launch-1 @ pool-1-thread-2 (before yield): launch-1
launch-2 @ pool-1-thread-1 (before yield): launch-2
launch-3 @ pool-1-thread-2 (before yield): launch-3
launch-1 @ pool-1-thread-1 (after yield): launch-2
launch-2 @ pool-1-thread-2 (after yield): launch-3
launch-3 @ pool-1-thread-2 (after yield): launch-3

launch-1 に相当する coroutine に注目すると、yield 前は tls.get() の結果が launch-1 になっていて期待通りですが、yield 後は tls.get() == "launch-2" になってしまっています。

これは launch-1 に相当する coroutine を実行するスレッドが yield 前後で別のスレッドになっていることと、一つのスレッドで複数の coroutine (ここでは launch-2) が実行されていることが原因です。

というわけで、ThreadLocal を利用するコードと Coroutine は、何も考えずに併用するとバグる、ということが確認できました。 このままだと、gRPC のコンテキストにアクセスできなくなったり、意図せず Exposed のトランザクションが分離してしまったり、OpenTelemetry の Trace が繋がらなくなったりしてしまいます。

ThreadContextElement で Thread と Coroutine の仲を取り持つ

kotlinx.coroutine には ThreadContextElementというクラスが提供されています。これをつかうことで、「Thread と Coroutine のミスマッチを補完する」機会を得ることが出来ます。

先にコードを示します。以下のように記述することで、ThreadLocal と Coroutine を安全に併用することができるようになります。

import kotlinx.coroutines.ThreadContextElement
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import java.util.concurrent.Executors
import kotlin.coroutines.CoroutineContext

val tls = ThreadLocal<String?>()

class ThreadLocalContext(val value: String?) : ThreadContextElement<String?> {
    companion object Key : CoroutineContext.Key<ThreadLocalContext>

    override val key: CoroutineContext.Key<*>
        get() = Key

    override fun updateThreadContext(context: CoroutineContext): String? {
        val previous = tls.get()
        tls.set(value)
        return previous
    }

    override fun restoreThreadContext(context: CoroutineContext, oldState: String?) {
        tls.set(oldState)
    }
}

suspend fun f(tag: String) {
    println("$tag @ ${Thread.currentThread().name} (before yield): ${tls.get()}")
    yield()
    println("$tag @ ${Thread.currentThread().name} (after yield): ${tls.get()}")
}

fun main() {
    runBlocking(Executors.newFixedThreadPool(2).asCoroutineDispatcher()) {
        for (n in 1..3) {
            launch(ThreadLocalContext("launch-$n")) {
                f("launch-$n")
            }
        }
    }
}

肝は ThreadLocalContext クラスです。さきほど紹介した ThreadContextElement を継承したクラスです。これを launch するときに Context として指定することで、実行結果が以下のように期待通りになります。

launch-1 @ pool-1-thread-2 (before yield): launch-1
launch-2 @ pool-1-thread-1 (before yield): launch-2
launch-3 @ pool-1-thread-2 (before yield): launch-3
launch-1 @ pool-1-thread-1 (after yield): launch-1
launch-2 @ pool-1-thread-2 (after yield): launch-2
launch-3 @ pool-1-thread-2 (after yield): launch-3

Coroutine の名前と ThreadLocal に格納された値の整合性が(スレッドをまたいでも)一貫していることがわかります。

肝となる ThreadLocalContext の実装を再掲します。

class ThreadLocalContext(val value: String?) : ThreadContextElement<String?> {
    companion object Key : CoroutineContext.Key<ThreadLocalContext>

    override val key: CoroutineContext.Key<*>
        get() = Key

    override fun updateThreadContext(context: CoroutineContext): String? {
        val previous = tls.get()
        tls.set(value)
        return previous
    }

    override fun restoreThreadContext(context: CoroutineContext, oldState: String?) {
        tls.set(oldState)
    }
}

Key , key に関しては、 ThreadContextElement というよりはその更に親である CoroutineContext を定義するときのボイラープレートみたいなものなので、ここでは無視します。 key 以外に2つのメソッドを override しており、これらが今回の主題です。

1つ目のメソッドである updateThreadContext は、 「Coroutineの実行が開始・再開するときに、その Coroutine を実行しようとしているスレッド上で呼び出される hook」 です。 「その Coroutine を実行しようとしているスレッド上で呼び出される」というのが重要で、このメソッドの中で Thread.currentThread() を呼んで取得できる Thread は、その Coroutine が次に suspend するまでの間の実行スレッドと一致します。 したがって、 updateThreadContext の中で ThreadLocal の更新を行えば、Coroutine の実行時には必ず ThreadLocal の中身が期待通りになっていることが保証されます。

2つ目のメソッドである restoreThreadContext は、さっきの逆で、 「Coroutine の実行が終了・中断するときに、その Coroutine を実行していたスレッド上で呼び出される hook」 です。 updateThreadContext で Coroutine の実行前に現在のスレッドの状態を変更したあと、Coroutine から抜けるときにその状態を復元してあげることができます。 引数に渡される oldState は、 updateThreadContext の返り値です。

全体を通した流れとしては以下のようになります。

  • スケジューラによって、あるスレッド X 上で Coroutine 1 を実行することが決まる
  • ThreadContextElement.updateThreadContext がスレッド X 上で呼び出される
    • スレッド X の現在の状態(ThreadLocal の中身など)を取り出す ☆
    • Coroutine 1 を実行するために、スレッド X の状態を書き換える
  • Coroutine 1 の実行が始まる
  • Coroutine 1 の処理が suspend する
  • ThreadContextElement.restoreThreadContext がスレッド X 上で呼び出される
    • ☆ で取り出しておいた状態が引数にわたってくるので、それを元にスレッド X の状態を復元する

このように、どんなにスレッドが使い回されても、Coroutine の出入りのタイミングで状態を復元するので、安全にスレッドと Coroutine を組み合わせることができます。

(また Experimental ですが、子 Coroutine が作られるたびにコンテキストをコピーすることで独立性を更に高める CopyableThreadContextElement という API もあります。)

実例

さきほどサーバーサイド Java / Kotlin エコシステムに潜む ThreadLocal の例として Exposed , gRPC-Java, OpenTelemetry を挙げました。

実はこのうち Exposed, gRPC については、まさにいま紹介した ThreadContextElement を使ったブリッジの機構が提供されています。

Exposed

Exposed には suspendTransactionAsync や newSuspendedTransactionwithSuspendedTransaction といった API がはえており、これらを使うことで安全に Coroutine を使うことができるようになっています。(あまり目立たないのですが、公式ドキュメントに Coroutine についてのセクションがあります。JDBC 依存なので、同期的実行が前提になっており、Coroutine によるパフォーマンスゲインは限定的で、それもあってあまり大々的に Coroutine を使うことを想定していない印象です。)

これらの実装の内部を探っていくと、 ThreadContextElement を継承したクラスが使われていることがわかります。(実装

gRPC

gRPC については、gRPC-Java ではなく、gRPC-Kotlin からブリッジ機構が提供されています。(gRPC-Kotlin は gRPC-Java に依存しており、Context そのものは gRPC-Java の実装が使われています。)

GrpcContextElement というクラスが提供されており、その実装は ThreadContextElement をつかっています。

(Henry では gRPC-Kotlin をつかっていないので、自前でこれに相当する処理を記述する必要がありました。)

OpenTelemetry

OpenTelemetry については、僕の調べた限りはこの手のブリッジが存在しないので、手で書く必要があります。

こんな感じの ThreadContextElement を定義すれば OK です。

class OTelSpanContext(private val span: Span) : ThreadContextElement<Scope> {
    companion object Key : CoroutineContext.Key<OTelSpanContext>

    override val key: CoroutineContext.Key<OTelSpanContext>
        get() = Key

    override fun updateThreadContext(context: CoroutineContext): Scope {
        return span.makeCurrent()
    }

    override fun restoreThreadContext(context: CoroutineContext, oldState: Scope) {
        oldState.close()
    }
}

実際に使う側では

tracer.spanBuilder("foo").startAndCall {
  // Coroutine に入る前に Span.current を呼べば安全
  launch(Dispatchers.IO + OTelSpanContext(Span.current()) {
    ...
  }
}

という感じで呼び出します。

余談: この問題は Coroutine 固有の問題なのか?

実は ThreadLocal に依存した “Context” 伝搬を正しく扱う難しさというのは、Coroutine 固有の問題ではありません。

普通に Java の Thread を使っていても、なにもケアしなければ容易に Context の連続性が失われます。

たとえば、Java で並行処理をする場合、典型的には java.util.concurrent.ExecutorService を使うことが多いと思いますが、この場合も結局新しい Thread に処理が移るので、ThreadLocal の中身は引き継がれません。

InheritableThreadLocal は使えないか?

Thread の場合は Coroutine と違って、 java.lang.InheritableThreadLocal<T> という道具が提供されています。これをつかうと、「ThreadLocal の初期値として親スレッドでの値を引き継ぐ」ということが可能になります。

import kotlin.concurrent.thread

val inheritableTls = InheritableThreadLocal<String?>()
val tls = ThreadLocal<String?>()

fun main() {
    inheritableTls.set(Thread.currentThread().name)
    tls.set(Thread.currentThread().name)

    thread {
        println("thread@${Thread.currentThread().name}: inheritable=${inheritableTls.get()}, normal=${tls.get()}")
        // => thread@Thread-0: inheritable=main, normal=null
    }.join()
}

しかし、 InheritableThreadLocal にはいくつかの問題があり、暗黙の Context 伝搬には使えません。

  • そもそも gRPC-Java などのライブラリの内部で InheritableThreadLocal を使ってもらう必要がある
  • スレッド作成時の親スレッドでの状態に依存するので、スレッドプールのように一度用意したスレッドを使い回すタイプの処理に対応できない

スレッドなら普通に初期化と終了処理を手書きすればいいのでは?

スレッドの場合は Coroutine と違って実行スレッドがぴょんぴょん飛び回ったりしませんから、処理の先頭と末尾で初期化・終了処理を手書きすれば問題なく動きます。

try-with-resources や Closable.use を使えば、安全かつそれなりに手軽に初期化・終了処理を記述できます。

実際、それで十分なケースは多いかと思います。が、せっかく Kotlin を使っている以上、Coroutine は(安全に正しく使えるなら)積極的に使うべきだと思います。

Coroutine はスレッドより効率が良いだけでなく、kotlinx.coroutine が提供する Structured Concurrency のための仕組みは、普通にプログラミングをするにあたって便利な機能を備えています。(キャンセルとか Deferred とか Context とか Dispatchers とか)

Henry ではまだまだ Coroutine を自ら使い倒すことはできていませんが、Ktor が Coroutine を使った API を提供しており、外部サービスの公式 API Client ライブラリが Ktor に依存しているなど、間接的に Coroutine が登場するシーンもあるため、Coroutine から逃げ切ることは難しくなっています。

また、どうせ初期化・終了処理を記述するなら、kotlinx.coroutine の提供する仕組みに乗っかれるほうが readability / maintainability の観点からも有利です。こういうのは自前の仕組みを作るより、すでにある仕組みに乗っかったほうが、ドキュメンテーションのコストを節約できたり、新しく入ってきた開発者からも見通しがよかったりと嬉しい事が多いです。

Javaにも Virtual Threads が登場して、少し事情が変わってきそうな見込みもありますが、Kotlin をつかう限り Coroutine に賭けておいて損はないんじゃないかというのが僕のいまの見解です。

まとめ

この記事では

  • ThreadLocal はスレッドごとに独立した値をもつグローバル変数を定義できる機能
  • サーバーサイド Java / Kotlin のエコシステムでは、ThreadLocal を利用して暗黙の Context 伝搬を実現しているケースがある
  • Coroutine は複数のスレッドにまたがって実行されるため、ThreadLocal と組み合わせるとバグる
  • ThreadContextElement を使うことで Coroutine とスレッドの状態を同期できる
    • エコシステム側で提供されていることもあるし、手書きでも簡単に書けるので便利

ということを紹介しました。

Coroutine は結構 API が充実していてふつうに便利なうえに、パフォーマンスも良くなりやすいので、積極的に使っていきたいですね!

【この沼】キーボード自慢大会【深い】

株式会社ヘンリーで SRE をやっている id:nabeop です。

みなさん、キーボードを使っていますか?エンジニアに限らず、毎日触っているガジェットで一番使っているガジェットは何か?という問いをすると、かなりの割合でキーボードが上がると思います。実際にヘンリーの Slack ワークスペースにはキーボードに関する話題を扱う #zzz-social-keyboard というチャンネルがあり、おすすめのキーボードの相談から、気になるキーボードやキースイッチの紹介などで賑わっています。

そんな中で M3 さんのテックブログで突撃! 隣のキーボード M3 2024 - エムスリーテックブログというエントリが公開され、#zzz-social-keyboard でも話題になりました。で、#zzz-social-keyboard の参加者もキーボードには負けないくらいのこだわりがあるので、アンケートを取って公開したら面白いのではないか?という話になりました。

そこで、以下の質問をしたところ、かなりの熱量の回答が集まったので、ヘンリー版のキーボード特集エントリを作ってみました。

  • キーボードの名前と簡単な紹介
  • キーボードのこだわりポイント
  • キーボードの改善したいポイント
  • 今気になっているキーボード

最終的には回答数が12もあり、中には独立したエントリで公開した方がいいんじゃない?という分量の回答もあったりと、かなり読み応えのあるエントリになってしまいました。

続きを読む

ヘンリーのオブザーバビリティ成熟度を考える

sumirenです。

ヘンリーではオブザーバビリティに投資をし、開発生産性と品質を高める取り組みをしています。 この記事では、ヘンリーが考えるオブザーバビリティ成熟度を解説し、最後にヘンリーの現状と今後について解説します。

オブザーバビリティ成熟度

全体像

筆者は、オブザーバビリティの成熟度について、以下のように考えています。 これはあくまで一般的な概念ではなく、筆者が説明のために考えた便宜上のモデルになります。

  1. なにもない
  2. インフラメトリック
  3. アプリケーションログ
    1. 非構造化ログ
    2. 構造化ログ
    3. リクエストに紐づくログ
  4. アプリケーションメトリック(ログベース)
  5. トレース
    1. トレース単体
    2. システム固有の共通的な計装
    3. ドメイン/機能カットの計装
    4. トレースの分析と集計
    5. トレースの相関分析

オブザーバビリティ成熟度が低い状態〜中程度の状態

1. なにもない〜 2. インフラメトリック

なにもない状態は、オブザーバビリティがない状態です。この状態では、システムで問題が起きてもトラブルシュートはできません。

そこから1つ進んだ段階に、インフラメトリックのみ存在する状態があります。この状態では、アプリケーションやDBのCPUやメモリ利用率などが確認できます。大半の組織では、インフラメトリックは取れているのではないでしょうか。一方で、アジリティの高いエンジニアリング組織では障害の大半はアプリケーションレイヤで発生するため、依然としてオブザーバビリティが低い状態と言えます。

3. アプリケーションログ

アプリケーションレイヤの障害に備えてログが取れている状態です。3.2.の構造化ログまで進むと、JSON等の形式に対するクエリや集計が可能になり、生産性が高まります。

Webサービスの場合、並行して複数のサービスでリクエストが処理されるため、ログが混在して問題のリクエストを追うのが難しくなります。マイクロサービスをまたいでリクエストにIDを割り振り、構造化ログに出力することで、システム全体でリクエストが何を行ったかを把握できるようになります(3.3.)。

4. アプリケーションメトリック

例えば「過去2時間に最も実行されたAPIはなんでしょうか」という質問に答えることはできるでしょうか。これはアプリケーションのメトリックと言えます。

オブザーバビリティ成熟度が3.2.の構造化ログの段階を超え、ロギングサービスが高速なクエリや集計に対応していれば、これに答えることができます。しかし、そうでなければ難しいでしょう。

代替アプローチとして、ログからメトリックを非同期で生成して保存しておくというものがあります。この方法では、サマリ情報を保存するため、データ量やクエリ速度の観点で効果的です。ただし、事前に決めた形式でメトリックを保存するため、生データがないため、新たな問いにすぐに答えられないというデメリットもあります。

オブザーバビリティ成熟度が高い状態

分散トレーシングが活用できている組織はオブザーバビリティ成熟度が高いと考えられます。トレースの可視化により、システムで何が発生したかを視覚的に表示でき、トラブルシュートが容易になります。

一方で、トレースの活用段階にもいくつかの成熟度があると筆者は考えています。

5.1. トレース単体

まずはトレース単体が利用されている状態です。例えばシステムで例外が発生したらSentry経由で通知され、トレースIDでトレーシングサービスを検索するといったオペレーションができている状態です。

パフォーマンスを定期的に分析している組織であれば、遅い懸念のあるエンドポイントの名前でトレーシングサービスを検索し、トレース単体を見てN+1やスロークエリの問題を判断することもできます。アプリケーションメトリックが運用されていれば、遅いエンドポイントを特定してトレーシングサービスを検索することも可能です。

5.2. システム固有の共通的な計装

上記のアプローチでN+1など技術的なトラブルを解決することはできますが、アプリケーションのトラブルの大半はロジックの問題です。例えば、もしPOST /user エンドポイントのエラーレートが10%で、そのエラーがフィーチャーフラグに依るとしたらどうでしょう。

もちろん、トレースからシステムの振る舞いの全体像を掴むことは可能で、それ自体に十分価値があります。しかし、例えばBFFや個別マイクロサービスでリクエストのペイロードやフィーチャーフラグなどの情報が(個人情報の取り扱いに注意しつつ)スパンに記録されていれていたらどうでしょうか。ソースコードを読むまでもなくトレースだけでトラブルシュートが完結する場合さえあるはずです。

5.3. ドメイン/機能カットの計装

アプリケーションレイヤの障害で最も厄介なのはドメインロジックの問題です。例えばPUT /userで、DBに保存されているユーザーとフィーチャーフラグの組み合わせでエラーが発生するとします。ドメインレイヤでifに入ったかelseに入ったかで後々エラーが発生するとしたら、共通的な計装だけでトラブルシュートを完結することは難しいでしょう。

こうしたトラブルに対処するためには、個別機能で重要な情報をトレースのスパンに記録する文化が必要です。

多くの組織では、オブザーバビリティ成熟度の5.1.〜5.3.の段階を理想として目指しているか、その段階にあるのではないでしょうか。

5.4. トレースの分析と集計

筆者は、トレースの活用において、より進んだ段階として「トレースの分析と集計」があると考えています。

例えば5.2.〜5.3.の成果で、PUT /userはDBに保存されているユーザーとフィーチャーフラグの組み合わせでエラーを起こす可能性があると分かったとします。しかし、これは「可能性がある」だけです。なぜ断定できないのでしょうか。それは、見ているのがトレース単体であり他のトレースも同じ問題を持っているかの確証がないからです。

例えば、トレースをデータベースのテーブルのように扱い、当該フィーチャーフラグとユーザーの属性で全てのトレースをGROUP BYしてグループごとのエラーレートを可視化したらどうでしょうか。特定のグループが高いエラーレートを示せば、仮説に確証が持てます。

トレースに対する分析や集計のイメージ

実のところ、5.2.や5.3.はログに情報を記録するという手もありました。しかし、筆者はトレースのスパンへの記録が望ましいと考えています。それは、スパンに記録したものはトレース単体の確認とトレースの分析集計の両方で活用できるからです。

5.5. トレースの相関分析

筆者が現時点で最も先進的だと考えているのは、トレースの相関分析が利用できている状態です。

5.4.では、トレースの分析集計により、立てた仮説の検証をトレーシングサービスで完結できる可能性を説明しました。しかし、そもそも最も難しいのは良い仮説を立てることです。なぜ数あるスパンの属性の中から、特定のフィーチャーフラグやDB上の項目がエラーレートと相関している可能性が高いと思いついたのでしょうか。その仮説は、その機能を開発した人でなくても立てられる仮説でしょうか。

結局のところ、やりたいことは「PUT /userのトレース/スパンの全ての属性全てから、エラーレートと相関性の高いものをピックアップする」ということです。これは、トレーシングサービス側で全属性を突合してくれれば自動化できます。こうしたトレース属性の相関分析を活用し、誰でも良質な仮説を立てられる世界観を、筆者は目指しています。

ヘンリーのオブザーバビリティ成熟度の過程とこれから

2022年

2022年時点では、ヘンリーのオブザーバビリティ成熟度は3.3.の段階にありました。インフラメトリクスやアプリケーションログについては十分に活用できていましたが、分散トレーシングやアプリケーションメトリックはまだ導入されていませんでした。この段階では、インフラの監視とアプリケーション構造化ログの収集が中心でした。

2023年

2023年から、ヘンリーでは本格的にオブザーバビリティに投資を始めました。4のアプリケーションメトリックの成熟度が大きく高まり、5.1のトレース単体の活用にも着手しました。この年は多くの取り組みが行われ、大きな転機となりました。

OpenTelemetryの導入

OpenTelemetryを導入し、OpenTelemetry Collectorをデプロイしました。これにより、5.1.のトレース単体について技術的な整備が進み、一部のエンジニアがCloud Traceを使い始めるようになりました。

インフラに強いSREが入社

インフラに強いSREが入社しました。様々な成果を上げられていますが、オブザーバビリティに関しては、特に4. のアプリケーションメトリックの生成や運用が進みました。これにより、システムの全体感に対する可観測性が大きく向上しました。

2024年〜現在(7月)

2024年開始時点の課題は、5.1のトレース単体の技術的な成熟度が十分でなく、一部のエンジニアしか活用できていなかったことです。また、5.2〜5.3についても手つかずで、N+1などの技術的障害の解決と、システムの処理の全体感を掴むことのみが可能でした。

2024年は、上記の課題に取り組んできました。それに加え、あるべき姿を見据え、5.4.と5.5.のトレースの分析集計・相関分析にも取り組みました。その結果、成熟度は以下のような状態にあります。

  • 5.1 トレース単体の成熟度:完全
  • 5.2. システム固有の共通的な計装:高い
  • 5.3. ドメイン/機能カットの計装:着手済み
  • 5.4. トレースの分析と集計:完全
  • 5.5. トレースの相関分析:着手済み

5.1. Context Propagatorの自作

以前記事で紹介したとおり、ヘンリーではCloud Runの不具合でCloud Run間の通信でトレースが切れるという課題がありました。dev.henry.jp

この問題に対処するために、JVMとNode.jsそれぞれでContext Propagatorを自作し、Cloud Runが知る由もないHTTPヘッダでトレースコンテキストをやりとりするように改善しました。これにより、5.1.のトレース単体の技術的な整備が完全となりました。

5.2.〜5.3. トレース情報の充実化とEmbedded SREing

5.2.の共通的な計装でトレースの情報を充実させ、フィーチャーフラグ・認証情報・バージョン・エンドポイントのメタデータなど、多くの情報を共通的にトレースに含めるようにしました。また、新しいフィーチャー開発においてはEmbedded SREとして支援に入り、5.3.の個別機能の計装のイネーブルメントを進めています。

特に個別機能の計装は1つ1つの取組みの範囲こそ狭いですが、その機能でトラブルが発生したときのインパクトは絶大だろうと考えており、強く期待をしています。

5.4.〜5.5. Honeycombの導入

5.4.と5.5.の達成に向けて、トレースの分析と集計が強力なHoneycombを試験的に導入しました。Honeycombではスパンに対して柔軟に集計や可視化を行うことができ、導入しイネーブルメントすることで5.4.の成熟度が完全なものとなりました。

また、HoneycombにはBubbleUpというスパン間の相関分析もあり、5.5.についても技術的なケイパビリティがあります。ただし、これは使いこなすのが難しく、実運用で再現性が得られないと成熟度が高いとは言えないとも考えています。

今後の展望

今後は、現在低い成熟度の部分を高めていくことを目指します。当然ながら難しいテーマや時間のかかるテーマが残っている認識ですので、腰を据えて取り組んでいきたいです。加えて、上記成熟度の整理に含んでいない技術的テーマや、文化のイネーブルメントにも取り組んでいく必要があります。

5.3.の個別機能の計装、5.5.の相関分析

先述のとおり、5.3.の個別機能の計装は非常に期待の大きいテーマです。まだ始めたばかりなので、プロダクトエンジニアと密に関わりながら、腰を据えて進めていきたいと考えています。また、5.5.の相関分析についても、実運用で再現性を確立できれば、銀の弾丸といっても過言ではないほど強力な武器になりうると考えています。HoneycombのBubbleUpを実運用で利用し、ナレッジを蓄積して勝ちパターンを増やすことで、トラブルシュートにおいて誰でも良質な仮説を立てられる世界観を実現したいです。

フロントエンドオブザーバビリティ

これはOpenTelemetryにベットしていることの反動でもあるのですが、フロントエンドオブザーバビリティについては手つかずです。Sentryの導入によるトレース取得はできていますが、パフォーマンス改善のPDCAが運用されていません。この分野の整理と強化も進めていきたいです。

オブザーバビリティ文化の醸成

最後に、オブザーバビリティで最も重要な目標は、全エンジニアがオブザーバビリティを活用できることであり、イネーブルメントが肝要と考えています。ツール活用や計装のイネーブルメントの他にも、例えばオブザーバビリティ成熟度が高まったことで、既存機能でパフォーマンスの問題が多数見つかっています。そうして見つけた既存機能の問題を改善するサイクルを根付かせていくことなどにも取り組んでいきたいです。

最後に

この記事では、ヘンリーが考えるオブザーバビリティ成熟度と、現状および展望について解説しました。この記事が皆さまの組織においてオブザーバビリティの議論の役に立ったり、トレース単体活用の先にあるオブザーバビリティの世界観を知るきっかけになれば幸いです。

また、Honeycombについては、国内事例が少ないかもしれません。Honeycombを使った5.4.や5.5.の達成方法や活用事例についても、今後発信していきたいと考えています。

ヘンリーでは各種エンジニア職を積極的に採用しています。医療ドメインに興味がある方も、オブザーバビリティに興味がある方も、ぜひカジュアル面談でお話させていただければと思います。

jobs.henry-app.jp