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

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

株式会社MOSHの皆さんと合同勉強会を開催しました

株式会社ヘンリーでSREなどをしている戸田です。弊社では技術勉強会略してギベンを毎週開催しておりますが、このたび個人の方がネットで簡単にサービス販売できるプラットフォームを開発、提供していらっしゃるMOSH株式会社の皆さんと合同で技術勉強会を開催いたしました。MOSH株式会社様の公式Zennでも開催報告を上げていただきましたので、あわせてご覧ください:

zenn.dev

弊社からは3名のITエンジニアが発表しておりますので、その内容を軽くご紹介いたします。

GitHubでTerraformできる!tfmigrate & tfactionのご紹介

両社の採用技術の共通点であるTerraformに着目して、筆者(id:eller)が弊社でtfmigrateとtfactionを導入するに至った経緯についてお話ししました。

図1 非公開資料より。tfmigrateとtfactionはとても便利です!

ReactNativeで「手軽に速く」サービスをリリースしたい

弊社の2つ目の発表はokbee(id:takamizawa46)が個人でWEBサービスを「手軽に速く」作るうえでの観点や技術について語りました。なぜWEBサービスを作りたいのか、個人でWEBサービスを開発するにあたっての壁、開発にあたってのLLMの活用など様々なポイントについて短い時間で触れ、参加者から共感や関心を得ていました。

図2 非公開資料より。個人でWEBサービスを「手軽に速く」作るうえでの観点や技術について語られました。

MobXを導入されたメンバー視点Redux, Jotaiと比較して

弊社の3つ目の発表はたすく(id:task_kawahara)が先日岩永が紹介ブログを投稿したMobXについて、コードを交えながら実際に使ってみての感想や他のライブラリとの比較について語りました。

株式会社ヘンリーは積極採用中です!

弊社では今後も技術の研鑽と社員の成長に努め、医療機関の皆さんが抱える難しい課題を使いやすいサービスによって解決する礎としていきます。技術交流をしてみたいとお考えの方は、ぜひカジュアル面談などを通じてご連絡ください。お待ちしています!

弊社では、さらなる成長にむけて採用も積極的に行っています。ご興味をお持ちいただけた方は、ぜひお気軽にご連絡ください。

jobs.henry-app.jp

プロダクトフルリニューアルを機に MobX をフロントエンド開発で導入した話

こんにちは、アーキテクト室の岩永です。

ヘンリーでは去年からプロダクトのフルリニューアルを進めており、その一環としてフロントエンド開発に MobX を導入しました。

当初は複雑なUIの実装を整理するための View Model として限定的に使い始めましたが、その有用性から徐々に使用範囲を広げ、現在では新規開発のほぼ全てで MobX を活用しています。

本記事では、MobX の採用に至った経緯や、実際の運用を通じて得られた知見を共有します。 特に以下のポイントについて詳しく説明していきます。

  • MobX の特徴と基本的な使い方
  • フルリニューアルにおける MobX 採用の背景
  • 段階的な導入プロセスと直面した課題
  • Domain Model への MobX の活用と今後の展望

フロントエンド開発における状態管理の選択に悩まれている方や、複雑なドメインのプロダクトを作っている方、大規模なリニューアルプロジェクトに取り組まれている方の参考になれば幸いです。

MobX とは

MobX は値の変更を監視することができるデータ型 (observable) を作るためのライブラリです。

Plain object/array だけでなくクラスに対しても使え、React との連携も容易で、シンプルなコードでリアクティブプログラミングを実現できます。この特徴により、View Model の実装として利用されることが一般的です。

MobX の詳細については以下の記事が詳しいので、ぜひご覧ください。

www.issoh.co.jp

mobx.js.org

ここでは雰囲気を掴みたい方のために、シンプルな例を紹介します。

1. Observable なクラスを定義する

import { observable, computed, action } from "mobx";

class Doubler {
  @observable accessor value: number; // @observable は value プロパティを監視可能にする

  constructor(value: number) {
    this.value = value;
  }

  @computed // @computed は返り値をメモ化しつつ、中で参照されている observable の値が変化したら再計算する
  get double() {
    return this.value * 2;
  }

  @action // @action は observable の値を更新する処理であることを明示する
  increment() {
    this.value++;
  }
}

2. クラスを普通に使う

import { autorun } from "mobx";

const doubler = new Doubler(1);
doubler.value; // 1
doubler.double; // 2

autorun(() => console.log(doubler.double)); // autorun は初回と値が変化するごとに実行される
// Console: 2

doubler.increment();
// Console: 4

3. React で使う

const DoublerView: React.FC<{ doubler: Doubler }> = observer(({ doubler }) => {
  // observer は値が変化したら再レンダリングする
  return <div>{doubler.double}</div>;
});

フルリニューアル

これまで外部発信などで触れてこなかったのですが、ヘンリーでは去年からプロダクトのフルリニューアルを行っています。詳細は別の機会に譲りますが、フルリニューアルの主な経緯は以下の通りです。

  1. 当初クリニック (外来) 向けだった製品を、後に中小病院 (入院) 向けへと方針転換 (2022年頃)
  2. 病院向けは要件が複雑で全体像が見えないまま、クリニック向けの延長線上に機能拡張を積み重ねる
  3. 組織・プロセスの改善で病院業務の解像度が上がってくると、現状の製品と実態の乖離が明らかに
  4. 根幹の情報設計に限界が生じ開発速度が低下、プロダクト全体の再設計が必要と判断 (2024年)

1. 初手の機能開発で MobX を先行導入

Henry は扱っているドメインが複雑なため、UI ではその複雑さをできるだけ感じさせないようにするために、ビジュアル面でもインタラクション面でもさまざまな工夫がされています。 それゆえ全体的に見た目はシンプルですが UI の実装の難易度は高くなりやすい傾向にあります。

特にリニューアルの初手で開発することになったある機能は、GUI プログラミングとしての複雑性が高く、臨床ドメインとしての複雑性もそれなりにあるものでした。この2つの複雑性が混ざることが、今後のメンテナンスを困難にする主要な原因となるだろうと考えました。

そこで抽象化・構造化の方針として、まず「臨床ドメイン」と「UI のドメイン」を明確に分離することにし、また、特に複雑な後者を UI 基盤 (フレームワーク) として切り出した設計を取ることにしました。

ここで実現したかったことは、以下の通りです。

  • UI を抽象化・構造化しやすい
    • 複雑なレイアウトを抽象化できる
    • 複雑なインタラクションを抽象化できる
    • コンポーネントと状態を分離できる
  • UI (表現)とドメイン(ビジネスロジック)を分離しやすい
    • 表示データの種類の増加や表現のカスタマイズ要求に柔軟に対応できる

これらの要件を実現するため、GUI プログラミングに詳しい知人に相談したところ、本人もよく使っているという MobX をお勧めされました。クラスベースで View Model を実装できる設計思想に惹かれ、実際に PoC を作成してみたところ、私たちの要件に非常にマッチしていたため採用を決定しました。

良かったこと

  • 仕組みが非常によくできており、インターフェースがシンプルで覚えるべき記法・ルールが少ない
  • MobX 本体と React 連携は別パッケージとして分離されており、React への依存を最小限に抑えられる
  • ViewModel をクラスで書ける
    • オブジェクト指向プログラミングにおけるプラクティスや抽象化テクニックをそのまま使える
    • コンポーネントと状態の分離を指南しやすい
      • React hooks でもある程度できるが自由度が高すぎて強制が難しい
      • 将来想定している共同編集のアーキテクチャへの移行がしやすい
      • テストを書きやすい
    • 記述に React hooks のような冗長性がない
  • コンポーネントが実際に参照しているプロパティが変化したときのみレンダリングが走るため効率が良い
  • ローカルステート・グローバルステートどちらの管理にも使える
  • 値の書き込みは同期的・アトミックなので直感的である
    • React hooks は状態の更新が非同期(レンダリングサイクルと一体)であるため処理が冗長になりがち。例えば、ボタンの多重送信防止を正しく実装するには useRef と useState の両方を組み合わせる必要があるなど

懸念だったこと

  • 標準的な React のパラダイム (hooks, pure function, immutability) から離れる
  • クラスの継承は便利な機能だが、過度な使用は複雑性を増大させる可能性があるため、適切な設計と使用が重要である
  • MobX のエッジケースでの挙動で問題が発生する可能性がある (かもしれない)
    • ※ 今のところ実際にはこういう問題には遭遇していない
  • MobX が同期処理・アトミック性を思想として掲げているので、複雑な derivations や reactions が増えてくると scripting cost のチューニングが大変になる可能性がある

代替案

クラスベースで observable をうまく実装しており、React との親和性もあるライブラリは MobX が唯一無二です。 atom や reducer ベースの他の状態管理ライブラリについても検討はしましたが、どれも求めている抽象化・構造化の側面ではソリューションが弱いため採用しませんでした。

2. 全面的な導入

先行導入で MobX の有用性を実感できたため、全面的な導入を決定しました。 MobX へのロックインは懸念材料でしたが、他により良い選択肢が見当たらなかったことも理由の1つです。

特に既存資産との相性が悪かったため、先行導入での機能実装を進める中で、自然と周辺機能も MobX で実装したいという気持ちが高まっていました。 課題としては以下の3点です。

  1. Mutable vs immutable の衝突
    • 素の React や既存資産が immutable を期待しているので、observable object (mutable) を MobX の observer を用いずに immutable な世界に持ち込んでしまうと、MobX 上は状態が更新されていても UI に反映されないなど意図しない挙動になります。
    • 先行導入では範囲が限定的だったため問題になりませんでしたが、全体的に使っていくうえではこの線引が課題でした。
  2. immer と MobX の内部実装の衝突
    • Henry では immer をビジネスロジックからユーティリティまで広く使っています。
      1. にも関連しますが、内部実装の問題で両方のライブラリが Proxy を使っており、MobX で管理されたオブジェクトが immer に渡ると runtime error になってしまう問題が多発していました。
    • この問題は MobX が提供する toJS を挟むことでも解決できますが、根本的には mutable/immutable の境界跨ぎを意識するのが難しい問題と似た構造を持っています。
  3. 既存コンポーネントとの連携
    • MVVM における一般論として view model が view に依存するのはアンチパターンであり、view との連携には view model にコールバックなどを依存注入する形などが取られます。
    • Henry の基本的な UI は design system のコンポーネントとして実装されており、それらを view model 側から使いやすくするために、対応する view model を用意したいと考えるようになりました。

これらの理由から MobX の導入範囲を限定的にするよりも、アプリケーション全体で採用した方が開発生産性は高くなると判断しました。

この意思決定をしたときの ADR の内容を一部抜粋して紹介します。

方針

ラベルは RFC 2119 に従う。

  • [May] MobX は全体で使って良いものとする。
  • [Should] 一定の複雑性がある部分については MobX を積極的に使う。
  • [Must] 共通実装 (例: useForm) や design system の汎用コンポーネントは必ず MobX 版を用意する。
    • [Should] 既存の interface を残し、MobX 以外からは従来通り使えるようにする。(例: MobX を wrap した hooks を提供するなど)

移行

  • 基本的に使えるところから使い始めて良い。
  • ただし共有実装・design system 側の変更が必要な部分に関しては慎重に取り組む。
    • 特に useForm の移行を優先的に計画に載せたい。
  • 別途、トップレベルで (MobX ベースの) 新規実装と既存実装を分離することを検討したい。
    • ADR「フロントエンドを multi-package 構成にする」で決定する。

3. ドメインモデルも MobX で (現在)

現在では新規開発部分は 100%、MobX による View Model が作られるようになり、既存部分も徐々に MobX に移行しています。

最近は View Model に留まらず、フロントエンドで Domain Model の導入を積極的に進めています。 これまでフロントエンド開発では、GraphQL から取得した型定義を直接利用し、それらの immutable object に対してビジネスロジックを関数として実装していました。 例えば「外来カルテの医薬品」のドメインだけでもこのような関数が50個ほどあり、ドメインの理解を妨げる原因となっていました。

Before

  • GraphQL の型定義とレスポンスの immutable object そのまま使用
  • ビジネスロジックを immutable object に対する純粋関数として実装
  • ビュー側にもビジネスロジックが点在

一方バックエンド開発では以前から Domain Model を活用してきた実績があり、フロントエンド開発でも同様に MobX のクラスとして Domain Model を実装することに挑戦しています。

After

  • GraphQL の型定義を直接利用することを禁止
    • Domain Model との相互変換を徹底
  • ビジネスロジックを Domain Model に徹底的に集約
    • 表示用の整形ロジックだけでなく、データの更新にまつわる制約や整合性保証も適切に管理
  • ビュー側のロジックが最小限に

ここの知見はまた別のブログにできればと考えていますが、それまではカジュアル面談などで直接話を聞きにきてもらえると嬉しいです。

(補足: 関数型プログラミングの知見不足も一要因でしたが、より本質的には、ドメインを育てていくという意識と取り組みが不十分だったことが、これまでの課題だったと考えています。その上で MobX は View Model と Domain Model 両方で同じ技術を使えることにより、総体的な意識を高める効果があったように感じています)

新規開発のドメインモデルの実装例

*実際のコードから一部改変しています。

/** 処方オーダーのレシピ */
export class PrescriptionOrderRpModel {
  /** ID */
  readonly id: string;
  /** 基準日 */
  @observable accessor #baseDate: DateValue;
  /** 頓用指示か */
  @observable accessor #asNeeded = false;
  /** 持参薬であるか */
  @observable accessor #isBringing = false;
  /** タイミング */
  @nested readonly medicationTiming = new MedicationTimingModel();
  /** スケール指示か */
  @observable accessor slidingScaleEnabled = false;
  /** 投与方法のフリーテキスト */
  @observable accessor dosageText = "";
  /** 投与日数 */
  @observable accessor boundsDurationDays: number | null = null;
  /** 内用薬の頓用回数 */
  @observable accessor expectedRepeatCount: number | null = null;
  /** 注射手技 */
  @observable accessor injectionTechnique: InjectionTechnique | null = null;
  /** 指示一覧 */
  @nested @observable accessor instructions: (MedicationDosageInstructionModel | EquipmentInstructionModel)[] = [];

  private constructor(args: Pick<PrescriptionOrderRpModel, "id" | "baseDate">) {
    ...

    makeValidatable(this, (b) => {
      if (this.instructions.length === 0) {
        b.invalidate("instructions", "1つ以上の指示を追加してください");
      } else if (this.#allDosageFormTypes.size > 1) {
        b.invalidate("instructions", "異なる剤形の指示を同時に追加することはできません");
      } else if (!this.hasMedicationDosageInstruction) {
        b.invalidate("instructions", "医薬品を追加してください");
      }
      if (this.canSpecifyBoundsDurationDays && (this.boundsDurationDays === null || this.boundsDurationDays < 1)) {
        b.invalidate("boundsDurationDays", "1日以上を指定してください");
      }
      if (this.canSpecifyExpectedRepeatCount && (this.expectedRepeatCount === null || this.expectedRepeatCount < 1)) {
        b.invalidate("expectedRepeatCount", "1回以上を指定してください");
      }
      if (this.isInjection && !this.injectionTechnique) {
        b.invalidate("injectionTechnique", "手技を指定してください");
      }
    });
    makeValidatable(this.medicationTiming, (b) => {
      if (!this.medicationTiming.hasValue && !this.dosageText) {
        b.invalidate("canonicalPrescriptionUsage", "用法を指定してください");
      }
    });
  }

  ...

  /** 頓用指示か */
  get asNeeded() {
    return this.#asNeeded;
  }
  /** 頓用指示か */
  set asNeeded(value: boolean) {
    this.#asNeeded = value;
    this.#ensureDataConsistency({ isAsNeededChanged: true });

    for (const inst of this.instructions) {
      if (inst instanceof MedicationDosageInstructionModel) {
        inst.asNeeded = value;
      }
    }
  }

  ...

  /**
   * 剤形
   *
   * @returns 指示がない場合や、複数の剤形が混在する場合は null を返す。
   */
  @computed
  get dosageFormType() {
    if (this.#allDosageFormTypes.size === 1) {
      for (const value of this.#allDosageFormTypes) {
        return value;
      }
    }
    return null;
  }

  /** 内用薬か */
  get isInternal() {
    return this.dosageFormType === DosageFormType.internal;
  }
  /** 外用薬か */
  get isExternal() {
    return this.dosageFormType === DosageFormType.external;
  }
  /** 注射薬か */
  get isInjection() {
    return this.dosageFormType === DosageFormType.injection;
  }

  /** 内用薬の頓用回数が指定可能か */
  @computed
  get canSpecifyExpectedRepeatCount() {
    // 頓用の内用薬
    return this.asNeeded && this.isInternal;
  }

  /** 投与日数が指定可能か */
  @computed
  get canSpecifyBoundsDurationDays() {
    // 頓用でない内用薬
    return !this.asNeeded && this.isInternal;
  }

  ...

  /**
   * 医薬品を追加する
   *
   * @returns 追加できた場合は true、互換性がなく追加できなかった場合は false.
   */
  @action
  addMedication(medication: MedicationModel) {
    if (this.dosageFormType && medication.dosageFormType !== this.dosageFormType) {
      return false;
    }
    this.instructions.push(
      MedicationDosageInstructionModel.create({
        medication,
        isInjectionSelfAdministered: true,
        asNeeded: this.asNeeded,
        isBringing: this.isBringing,
        baseDate: this.baseDate,
      })
    );
    this.#ensureDataConsistency();
    return true;
  }

  ...
}

まとめ

半年以上運用してみての感想をまとめます。

  • 全体的にはかなりポジティブ
    • ロジックの見通しが良くなり、開発生産性と品質が向上した。
    • 特に複雑なドメインや GUI の実装において、コードの構造化と保守性が大きく改善した。
  • MobX の API はシンプルだが細部は複雑
    • 基本的な使用においては学習コストは低い。
    • ただし、深く使いこなすためには MobX の詳細な挙動を理解しておくことを勧める。
    • ドキュメントに明記されていない実装の詳細があり、時には実装を読む必要がある。
  • Domain Model の実装に新たな可能性と課題
    • MobX のクラスベースの設計は Domain Model の実装に適している。
    • バックエンドとは異なる特有の課題 (動的な側面をどこまで Domain Model に持たせるかなど) があり、ベストプラクティスを模索中。
  • 設計の重要性が一層明確に
    • Domain Model, View Model を導入したことで、適切でない設計が目立つように。
    • Domain Model の設計は本質的に難しく、より深い思考と設計力が必要。

複雑なドメインを扱うフロントエンド開発において、MobX は強力な武器となりました。

特に View Model による UI の構造化と、Domain Model によるドメインロジックの表現という2つの側面で、大きな価値を発揮しています。今後も実践を通じて知見を深め、より良い設計パターンを確立していきたいと考えています。

Cloud ProfilerのJVM用agentをjibのコンテナに入れる

SREの戸田(id:eller)です。先日出した合同誌でもJavaagentをGradleでダウンロードしてコンテナに入れる方法を紹介しましたが、さらに固定URLからダウンロードした tar.gz を解凍する必要のあるケースがあったので紹介します。

固定URLから依存をダウンロードする

Maven Centralではない場所から依存をダウンロードする場面は、Java開発をやっているとわりとよく遭遇します。たとえば以前はOracle社のJDBCドライバがMaven Centralに存在しなかったため、自分で管理したMavenリポジトリに入れて管理するなどの手法をとっていました。

今回はCloud Profilerのエージェントが固定URLで配布されていました。GradleであればMavenリポジトリを作らなくても、配布URLをIvyリポジトリに見立てることでダウンロードを自動化できます。ビルドスクリプトは以下のようになります。

repositories {
    ivy {
        name = "cloud-profiler"
        url = uri("https://storage.googleapis.com/cloud-profiler/java/")

        // メタデータファイルをダウンロードしないように設定
        metadataSources { artifact() }

        patternLayout {
            // "latest/profiler_java_agent.tar.gz" などのパスになる
            artifact("[revision]/[module].tar.gz")
        }
        content { 
            includeGroup("com.googleapis.storage")
        }
    }
}

val cloudprofileragent: Configuration by configurations.creating {
    description = "Cloud Profiler 向けの javaagent"
}

dependencies {
    cloudprofileragent("com.googleapis.storage:profiler_java_agent:latest")
}

tarballからファイルを取り出す

しかし今ダウンロードしたのは .tar.gz ファイルであり、今回利用したい .so ファイルをここから解凍しなければなりません。resources.gziptarTree を組み合わせてこれを実現できます。ビルドスクリプトは以下のようになります。

val destDir = layout.buildDirectory.dir("container")

val downloadCloudProfilerAgent by tasks.registering(Copy::class) {
    description = "コンテナに埋め込むCloud Profiler agentをダウンロードする"

    doFirst {
        mkdir(destDir)
    }
    // tar.gz からファイルを取り出す
    from(tarTree(resources.gzip(cloudprofileragent.singleFile))) {
        include("**/*.so")
    }
    into(destDir)
}

tasks.jib {
    dependsOn(tasks.downloadCloudProfilerAgent)
}

以上の2つを組み合わせることで、固定URLで配布されているtarballからファイルを解凍してコンテナに組み込むことができました。どなたかの参考になれば幸いです。

Cloud Run + OpenTelemetryでもトレースが途切れないようにPropagatorを自作する

株式会社ヘンリーでSREをしているsumirenです。

ヘンリーではオブザーバビリティバックエンドにHoneycombを採用しています。 Cloud Runでサービス間通信をしている場合、こうした外部オブザーバビリティバックエンドとOpenTelemetryを使うと、トレースが途切れてしまう課題があります。

解決してから1年弱経ってしまったのですが、対処事例を紹介します。

Cloud Run + OpenTelemetry + 外部バックエンドでトレースが途切れてしまう理由

途切れてしまう理由を解説するために図解を用意しました。ここでは2つのCloud Runアプリケーションがサービス間通信を行い、番号順に処理を行ってトレースを生成しています。便宜上各アプリケーションはスパンを1つしか生成しない形で図解していますが、スパン数が増えても問題の本質は変わりません。またトレースIDやスパンIDは簡易的なものにしています。

Cloud Run + OpenTelemetry + 外部バックエンドでトレースが途切れてしまう図

トレースにおいてスパン間がつながるのは番号2・4・6のトレースIDや親スパンIDの伝播のおかげです。この仕組みはContext Propagationと呼ばれ、HTTPによる通信であれば、そのヘッダに特定の形式でトレースIDや親スパンIDを埋め込むことで実現されます。Cloud RunはOpenTelemetryをサポートし、スパン生成に加え、こうしたスパンの伝播も行います。OpenTelemetryにおける標準的なContext Propagationの形式であるW3C Trace Contextをサポートし、自身がアプリケーションにプロキシする際にtraceparentヘッダを付与したり、逆に自身が受け取ったリクエストにそのヘッダが付与されていれば、そのスパンに紐づける形で子スパンを生成したりします。

さて、注目すべきは最終的にHoneycombとCloud Traceにどのようなスパンが送信されるかです。Cloud Runが生成したスパンはCloud Traceにしか送られません。一方で、アプリケーションでは正常にHoneycombにスパンを送信しています。そのため、ツリー構造を成すはずの1つのトレースのスパンが、HoneycombとCloud Traceに半分ずつ送られてしまっています。ツリー構造においては、当然ながら間のノードが抜けてしまうと構造が破綻します。これが、トレースが途中で途切れてしまう理由です。

Cloud Runが生成したスパンの送信先を変更したり、Cloud RunがアプリケーションのOpenTelemetryトレースに干渉することを無効化できればよいのですが、残念ながらこの挙動は変更も無効化もできないようです。少なくとも、2023年の10月から12月にかけてGoogle Cloud様に問い合わせ、技術者の方ともやりとりしましたが、解決には至りませんでした。

ちなみに、Honeycombのような外部オブザーバビリティバックエンドを使っていなくても、トレースが途切れる場合があります。Cloud RunはContext Propagationに含まれるサンプリングフラグを考慮する(Cloud Trace 用の計装  |  Google Cloud)とされていますが、Google Cloud様のサポート曰く実際にはレートリミットのようなものがあり、割とすぐにサンプリングされてしまいます。ヘンリーではトレースをサンプリングなしですべて収集する戦略を取っているため、Cloud Trace運用時にも途切れた事例があります。

2024年4月頃まで改善していなかったことを確認していますが、他社のSREの方からも困っている旨を耳にするので、おそらく2025年1月現在も解消していないのではと推測しています。

途切れた場合のオブザーバビリティバックエンド上での見え方については以前に別の記事 事例から学ぶクラウドへのOpenTelemetry導入のハマりどころ - ヘンリー - 株式会社ヘンリー エンジニアブログ で紹介していますので、よければ参照ください。

Propagatorを自作するという方針

Cloud Run側でOpenTelemetryサポートを無効化できない以上、ユーザーランドで何らかのワークアラウンドをするしかありません。例えば、オブザーバビリティベンダの独自エージェントを使いOpenTelemetryを避けるといった手段があります。OpenTelemetryを継続しつつワークアラウンドを行う手段として、この記事ではアプリケーションのContext Propagationの振る舞いを変更するためにPropagatorを自作する方法を紹介します。ヘンリーではGraphQL BFFにNode.js、gRPCバックエンドにKotlin/JVMを使っているためそれぞれ紹介します。一部、ヘンリー固有の実装もあるため補足します。

自作するとはいっても、今回はCloud Runが知る由のないHTTPヘッダでやりとりできればそれでいいため、ヘッダ名のみ独自として、データ自体はW3C Trace Contextのフォーマットのままとしました。自作する以外にも、OpenTelemetryの各種言語のSDKではB3などW3C Trace Context以外の仕様も利用でき、Sansan様の技術ブログでもそうした異なる標準仕様を利用する方法が紹介されています(参考: Vol. 04 Cloud RunでCloud Trace以外のAPMを使う場合の一工夫 - Sansan Tech Blog)。筆者が試した環境ではこの方法でうまく解決しなかったため自作することにしたのですが、方針に関しては大変参考になりました。

以下は先程の図解について、Propagatorを自作して独自ヘッダでコンテキストを取り回した場合の変化を記述しています。Cloud Trace側には壊れたトレースが生成されていますが、外部オブザーバビリティバックエンド側にはアプリケーションで生成されたスパンのみから成るピュアなトレースが完成していることがわかるかと思います。

トレースが途切れないようにする

Node.jsの実装例

Node.jsでPropagatorをカスタマイズするには、サーバー起動時に実行するOpenTelemetryの計装スクリプトに以下のような修正を加えます。@opentelemetry/apiは1.9.0のバージョンです。

サーバーにリクエストが入ってきたときにはextractメソッドで親スパンのIDを取り出します。ヘンリーにおいてはNode.jsはGraphQLのBFFで利用しており、OpenTelemetryトレースにおけるルートのサービスになります。そのため、my-traceparentからの引き継ぎ処理はしていません。代わりにブラウザからトレースIDを指定することでテストを容易にしたいため、my-traceparent-traceidという独自のヘッダを設けています。これは単純なトレースIDのみをブラウザから指定したいためデータフォーマット自体も独自です。アーキテクチャ次第では、injectの実装を普通のtraceparentのフォーマットからの取得処理にしたり、ルートとなるサービスでは省略したりすることになるでしょう。

逆に、下流サービスにリクエストするときには、injectメソッドが呼ばれます。ここではmy-traceparentをW3C Trace Contextのフォーマットで設定しています。下流サービスでは、extractでこのHTTPヘッダにアクセスし、同じフォーマットでトレースIDや親スパンIDを取り出すことになります。

my-traceparent-traceidにせよmy-traceparentにせよx-のプリフィックスをつけていない理由は特にないのですが、なんとなくCloud Runに触られる可能性をなるべく減らしたいという気持ちが表れています。

initializeOpenTelemetry();

function initializeOpenTelemetry() {
  const provider = new NodeTracerProvider({
    resource: Resource.default().merge(
      // ...
    ),
    sampler: new AlwaysOnSampler(),
  });

  const exporter = new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
  });
  const processor = new BatchSpanProcessor(exporter, {
    // ...
  });
  provider.addSpanProcessor(processor);

  provider.register({
    propagator: new MyTraceContextPropagator(),
  });

  registerInstrumentations({
    // ...
  });
}

class MyTraceContextPropagator {
  inject(context: Context, carrier: any, setter: TextMapSetter = defaultTextMapSetter) {
    const spanContext = trace.getSpanContext(context);
    if (!spanContext) return;
    const traceparent = `${spanContext.traceId}-${spanContext.spanId}-${spanContext.traceFlags.toString(16).padStart(2, "0")}`;
    setter.set(carrier, "my-traceparent", traceparent);
  }
  extract(context: Context, carrier: any, getter: TextMapGetter = defaultTextMapGetter) {
    const traceparentTraceId = getter.get(carrier, "my-traceparent-traceid") as string;
    if (!traceparentTraceId) return context;
    const match = traceparentTraceId.match(/([0-9a-f]{32})/);
    if (!match) return context;
    const traceId = match[1];
    const spanContext = { traceId, spanId: generateRandomSpanId(), traceFlags: 1, isRemote: false };
    return trace.setSpanContext(context, spanContext);
  }
  fields() {
    return ["my-traceparent"];
  }
}

JVM/Kotlinの実装例

JVMでJava Agentを用いてOpenTelemetryの自動計装を行っている場合、Propagatorをカスタマイズするには、SPIを利用します(参考:Configure the SDK | OpenTelemetry)。io.opentelemetry:opentelemetry-apiのバージョン1.42.0時点のものです。

まずresources/META-INF.services配下に以下のようなio.opentelemetry.sdk.autoconfigure.spi.ConfigurablePropagatorProviderのファイル名を配置し、利用するPropagatorを返すProviderを記載します。パッケージ名などは適宜読み替えてください。

jp.myapp.opentelemetry.agent.MyTraceContextPropagatorProvider

Providerは上記に指定したファイル名で以下のように実装します。

public class MyTraceContextPropagatorProvider : ConfigurablePropagatorProvider {
    override fun getPropagator(config: ConfigProperties): TextMapPropagator {
        return MyTraceContextPropagator()
    }

    override fun getName(): String {
        return "mypropagator"
    }
}

Propagatorは以下のようになります。サーバーにリクエストが入ってきたときにはextractメソッドで親スパンのIDを取り出します。ヘンリーにおいてはJVMはBFFからリクエストを受け取る下流サービスにあたるため、my-traceparentからトレースIDと親スパンIDを引き継ぐこの処理が必須になります。フォーマットはW3C Trace Context同様で、ヘッダ名だけが独自となっています。

逆に、下流サービスにリクエストするときには、injectメソッドが呼ばれます。ここはNode.js版で紹介したものと全く挙動で、my-traceparentをW3C Trace Contextのフォーマットで設定しています。下流サービスでは、extractでこのHTTPヘッダにアクセスし、同じフォーマットでトレースIDや親スパンIDを取り出します。

public class MyTraceContextPropagator : TextMapPropagator {
    private val traceparentRegex = """([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})""".toRegex()

    override fun fields(): MutableCollection<String> {
        return mutableListOf("my-traceparent")
    }

    override fun <C : Any?> inject(context: Context, carrier: C?, setter: TextMapSetter<C>) {
        val spanContext = Span.fromContext(context).spanContext
        if (!spanContext.isValid) return

        val traceparent = "${spanContext.traceId}-${spanContext.spanId}-${spanContext.traceFlags.toString().toInt().toString(16).padStart(2, '0')}"
        setter.set(carrier, "my-traceparent", traceparent)
    }

    override fun <C : Any?> extract(context: Context, carrier: C?, getter: TextMapGetter<C>): Context {
        val traceparent = getter.get(carrier, "my-traceparent") ?: return context

        val match = traceparentRegex.find(traceparent)
        if (match == null || match.groupValues.size != 4) {
            return context
        }

        val traceId = match.groupValues[1]
        val spanId = match.groupValues[2]
        val traceFlags = match.groupValues[3].toInt(16)

        val spanContext = SpanContext.createFromRemoteParent(traceId, spanId, TraceFlags.fromByte(traceFlags.toByte()), TraceState.getDefault())
        return context.with(Span.wrap(spanContext))
    }

    // ...
}

ヘンリーではこれらを別のjarファイルにまとめているため、サーバーを起動する際にjavaagentのextensionとして読み込んでいます。

exec java \
  -cp ... \
  -javaagent:/app/otel_agent.jar \
  -Dotel.javaagent.extensions=/app/otel-agent-extension.jar \
  ...

設計上のトレードオフ

このアーキテクチャのトレードオフとして、個別のマイクロサービスでPropagatorを実装しなければならない点に注意が必要です。せっかくOpenTelemetry Collectorを導入してシグナルの加工やオブザーバビリティバックエンドの選択をアプリケーションから切り離しているのに個別マイクロサービスに横断的関心事を実装してしまっている、という見方もあります。

これを回避するために思いつく方法としては、例えばサイドカーに下流サービスへのリクエストをプロキシさせ、そこで一度HTTPSを終端してtraceparentヘッダを独自ヘッダに書き換えさせたり、受け取ったリクエストの独自ヘッダをtraceparentに書き換える、といったアーキテクチャが考えられます(筆者は試していないためあくまで仮説です)。

とはいえ、そもそもトレースが途切れてしまうこと自体がCloud Run特有の事情であり、せっかくCloud Runでクラスタ運用を省力化しているのに高い費用を払ってサイドカーを使う必要があるだろうかと考えた結果、ヘンリーではこのような構成にしています。結局のところ個別自動計装のオンオフやOpenTelemetry Collectorへの送信の設定などは全てのアプリケーションに対して行っており、言語もNode.jsとJVMの2つだけでマイクロサービス数も少ないため、総じて大きな負債にはならないと考えました。

まとめ

この記事ではCloud Run + OpenTelemetry + 外部オブザーバビリティバックエンドでトレースが途切れる原因を解説し、それを避ける手段の1つとしてアプリケーション上でPropagatorを自前実装する方法を紹介しました。

ぜひトレースが途切れる現象に心当たりがあれば参考にしてください。

2024年ヘンリーアドベントカレンダー完走の感想

ヘンリーで SRE をやっている id:nabeop です。2023年に引き続き2024年もアドベントカレンダーが完走できました。

ということで公開が遅くなってしまいましたが、各エントリーの感想をまとめたエントリを作りました。今年は僕の他に組織広報の小山 (@helene815) さんと技術広報ギルドを手伝ってもらっている odasho (@odashoDotCom) さんにも感想を書いてもらいました。

続きを読む

4年モノGoogle Cloudプロジェクトで導入して良かった新機能

株式会社ヘンリーでSREをしている@Kengo_TODAです。弊社が提供しているレセコン一体型のクラウド電子カルテサービス「Henry」では4年前からGoogle Cloudを採用しております。 Google Cloudは日々進化しており、弊社サービスでもこの4年間で増えた機能を活用することで様々な恩恵を受けてきました。このブログ記事では採用してよかった新機能を3点紹介します。

Cloud Runのstartup CPU boost

以前の記事 Server-Side Kotlinで書かれたCloud Runサービスのコールドスタートレイテンシを短縮するで紹介済みです。JVMで動くKotlinアプリケーションを高速に起動させるのに有効でした。

cloud.google.com

Cloud RunのVPC direct egress

従来Cloud RunがVPC内のサービスと通信するには、Serverless VPC Access connectorというサービスを併用する必要がありました。Serverless VPC Access connectorはマネージドサービスでありほぼメンテナンスフリーなのですが、運用上の課題がありました:

  • 提供されているメトリックが不十分で、スケールアウトのタイミングを判断しにくい
  • インスタンスを複数台起動していても、すべてのインスタンスが予期しないタイミングで一括再起動してしまう

この再起動がネットワーク瞬断の原因となり、ユーザ体験を損なっていました。アプリケーションでのリトライも可能とは言え、コントロール不能なところで不定期に瞬断が発生するのは望ましい状況ではありません。

ところが昨年8月にDirect VPC egress for Cloud Runがアナウンスされ、Serverless VPC Access connectorそのものが不要となりました。

cloud.google.com

制約もいくつかありますが弊社サービスで利用している範囲では大きな課題とはならず、採用の運びとなりました。 執筆時点ではCloud Run functionsの設定はTerraformで行えないことには注意が必要ですが、ネットワークも安定し、費用も若干ではありますが下がり、良いことづくめでした。

Google Groupと連動したCloud SQLの認証

エンジニアが調査やデータマイグレーションを目的としてCloud SQLに接続する場合、Googleアカウントを使った認証が便利です。Cloud SQLのためだけに認証情報を作成・管理することなく、管理の手間を省けるためです。またGoogleアカウントは退職時の削除手続きが社内的に確立されており、安全性という点からも非常に便利なものでした。

cloud.google.com

一方で個々人に対してTerraformで設定をする必要があり、入社時退職時にTerraformでの .tf ファイル変更やその適用が必要となっていました。 これはこれで手間ですし、手順が漏れてアクセスできるべきエンジニアがアクセスできないという状況もありました。

しかしながら昨年12月にCloud SQL IAM group authenticationがアナウンスされ、この問題から開放されました。 入社時にGoogleアカウントをGoogle Groupへ追加することさえ忘れなければ、Terraform側での作業は不要です。

cloud.google.com

はじめにCloud SQLインスタンスの cloudsql.iam_authentication フラグを立てておく必要があります。 次にグループに対して接続権限を付与するのですが、たとえばTerraformを使った設定は以下のようになるでしょう:

locals {
  group_address = "engineers@example.com"
}

resource "google_sql_user" "api_iam_group" {
  instance = google_sql_database_instance.database.name
  name     = local.group_address
  type     = "CLOUD_IAM_GROUP"
}

resource "google_project_iam_member" "cloudsql_client_group" {
  project = google_sql_database_instance.database.project
  role    = "roles/cloudsql.client"
  member  = "group:${local.group_address}"
}

権限設定をよしなにしたら、Cloud SQL Auth Proxyを使ってプロキシを立てます。こちらの手順は公式ドキュメントに詳しく書かれていますので、参照してください。

たとえば localhost:5432 から対象のインスタンスに接続するときの実行例は以下のようになります。 gcloud auth login する必要はありますが、その分Postgresインスタンスのパスワードを管理・取得する必要はなくるのが魅力的です。

$ cloud-sql-proxy --auto-iam-authn $INSTANCE_CONNECTION_NAME

なお cloud-sql-proxy は2バージョンあり、もし --auto-iam-authn オプションが無く --enable_iam_login オプションはあるバージョンをお手元で使っている場合は、先に更新しておくと良いでしょう。

来年もよろしくお願いいたします

クラウドベンダー各社は活発に開発を進めており、新しく必要な機能にもれなく気づくこと・それを応用することはなかなか難しいところがあります。 Google Cloudは新機能をブログを通じて発信することが多いので、まずはそこをwatchしておくと良さそうです。

この記事が筆者による2024年最後のブログ投稿でした。来年も引き続き弊社で利用している技術についての情報を発信してまいりますので、よろしくお願いいたします。