Gaudiy Tech Blog

Gaudiyの技術、開発組織、カルチャーについてお伝えするブログです

Kotlin(Ktor)にOpenTelemetryを導入し、Google Cloud TraceにExportした話

この記事は、Gaudiyが実施している「Gaudiy Advent Calendar 2022」の24日目の記事です。

はじめまして。Gaudiyでエンジニアをしているあんどう(@Andoobomber)です。

GaudiyではKotlin, Go, Node.jsでのBE開発をしており、前職ではKotlin×Spring BootでC2Cサービスの開発をしていました。

先日、↓の記事でも伝えたように、GaudiyではマイクロサービスにおけるObservabilityの課題に対して、OpenTelemetryの導入を行いました。そこで自分は、Kotlin(Ktor)環境への導入を担当しました。

techblog.gaudiy.com

その中で、実際のアプリケーションでの導入事例や、特定のAPMへのExport方法に関してはまだ情報が少ないかなと感じたので、今回この記事を書くことにしました。

同じような技術スタックの方や、OpenTelemetryを実際に導入しようと考えている方の参考になればと思います。

1. OpenTelemetryって何?

OpenTelemetryとは、テレメトリーデータ(ログ、メトリクス、トレース等)の計装と送信の標準仕様、及びそのライブラリーとエージェントを提供するプロジェクトです。

これだけ聞いてもなんのこっちゃわからんと思いますが、具体的には以下のイメージです。

1-1-1. ログ

アプリケーションに発生したイベントを記録するテキスト。ロガーライブラリによって吐かれるわりとよく見るやつ。

Log_sample

1-1-2. メトリクス

一定期間に測定された数値。 例: アクセス数、レイテンシ、CPU使用率

Metrics_sample

1-1-3. トレース

依存関係のあるシステム間で、リクエストのフローをエンドツーエンドで表すもの。

Trace_sample

OpenTelemetryはこれらを標準仕様化&SDKを言語毎に用意してくれるので、各マイクロサービスに導入することでアプリケーションの健全性を監視したり分析することが容易にできるようになります。

1-2. 計装方法

計装には、手動計装(Manual Instrumentation)と自動計装(Auto Instrumentation)の2パターンがあります。

手動計装

  • 自前で実装する必要がある
  • 柔軟な計装ができる

自動計装

今回は両方の計装を試してみたので、その実装方法について書きたいと思います。

2. Kotlin(Ktor)にOpenTelemetryを導入する

前置きが長くなりましたが、ここからが本題です。

今回検証したのは主にTraceになるのですが、一応Metricsも導入しています。 また、前提情報としてGaudiyのKotlinサーバーではFWにKtor, DIコンテナにKoinを利用しています。

まず、手動計装(Manual Instrumentation)の実装からです。

2-1. 手動計装

2-1-1. 依存関係のダウンロード

// build.gradle

implementation platform('io.opentelemetry:opentelemetry-bom:1.21.0')
implementation 'io.opentelemetry:opentelemetry-api'
implementation 'io.opentelemetry:opentelemetry-sdk'
implementation 'io.opentelemetry:opentelemetry-exporter-jaeger'
implementation 'io.opentelemetry.instrumentation:opentelemetry-ktor-2.0:1.21.0-alpha'
implementation 'com.google.cloud.opentelemetry:exporter-trace:0.23.0'
implementation 'com.google.cloud.opentelemetry:exporter-metrics:0.23.0'
implementation 'com.google.cloud.opentelemetry:propagators-gcp:0.23.0-alpha'

GaudiyではFWとしてKtorを利用しているので、Ktorのinstrumentationを入れています。 jaegerを入れているのはローカル確認用になります。

2-1-2. jaeger用のdocker-compose.ymlを作成

// docker-compose.yml

version: '3.2'

services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "5775:5775/udp"
      - "6831:6831/udp"
      - "6832:6832/udp"
      - "5778:5778"
      - "16686:16686"
      - "14268:14268"
      - "14250:14250"
      - "9411:9411"

2-1-3. コード書く

OpenTelemetryをアプリケーション導入する処理を書きます。

// SetOpenTelemetry.kt

fun Application.setOpenTelemetry(env: Env): OpenTelemetry {
  // Resourceの設定.
  // https://opentelemetry.io/docs/reference/specification/resource/sdk/
  val resource: Resource = Resource.getDefault()
    .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "${SERVICE_NAME}")))

  // PropagatorはW3C Trace ContextとGCP Propagator両方利用しています.
  val propagators = ContextPropagators.create(
    TextMapPropagator.composite(
      XCloudTraceContextPropagator(false),
      W3CTraceContextPropagator.getInstance(),
    ),
  )

  // otel build.
  val openTelemetry = OpenTelemetrySdk.builder()
    .setTracerProvider(createTraceProvider(env, resource))
    .apply { if (!env.isLocal) createMeterProvider(resource) }
    .setPropagators(propagators)
    .build()

  // ktor instrumentationのinstall.
  install(KtorServerTracing) {
    setOpenTelemetry(openTelemetry)
  }
  
  return openTelemetry
}

// TraceProviderの作成
fun createTraceProvider(env: Env, resource: Resource): SdkTracerProvider {
    // localはjaeger, その他環境ではGCPへExportするように設定
  val traceExporter = if (env.isLocal) {
    JaegerGrpcSpanExporter.builder().setEndpoint("http://localhost:14250/api/traces").build()
  } else {
    TraceExporter.createWithDefaultConfiguration()
  }
  return SdkTracerProvider.builder()
    .addSpanProcessor(SimpleSpanProcessor.create(traceExporter))
    .setResource(resource)
    .build()
}

// MeterProviderの作成
fun createMeterProvider(resource: Resource): SdkMeterProvider {
  val metricExporter = GoogleCloudMetricExporter.createWithDefaultConfiguration()
  return SdkMeterProvider.builder()
    .registerMetricReader(
      PeriodicMetricReader.builder(metricExporter)
        .setInterval(Duration.ofSeconds(30))
        .build()
    )
    .setResource(resource)
    .build()
}

計装クラス(SampleService)にTracerを生成し、処理の間にspanを生成・記録します。

// SampleService.kt

class SampleService(private val repository: SampleRepository, openTelemetry: OpenTelemetry) {
  
  private val tracer: Tracer = openTelemetry.getTracer("SampleService")

  fun execute() {
    // start span.
    val span = tracer.spanBuilder("execute").startSpan()
    
    val data = repository.get()
    println(data)
    
    // end span.
    span.end()
  }
}

SampleServiceをエンドポイントを叩いて呼び出すようにします。

// SampleRouting.kt

fun Route.sampleRouting(sampleService: SampleService) {
  route("/sample") {
    get {
      sampleService.execute()
      call.respondText(LocalDateTime.now().toString(), status = HttpStatusCode.OK)
    }
  }
}

最後に、作成したOpenTelemetryやSampleService等をシングルトン化し必要なクラスに渡します。

// Application.kt

fun Application.module() {
    ...
    
    // Env is original config class.
  val env = Env.of(environment)
    val openTelemetry = setOpenTelemetry(env)
    
  install(Koin) {
    module(
        org.koin.dsl.module(createdAtStart = true) {
        single { openTelemetry }
        single { SampleService(get(), get()) } // openTelemetry利用クラス
          }
    )
  }
  
    ...
  
  val sampleService by inject<SampleService>()
  routing {
    sampleRouting(sampleService)
  }
}

2-1-4. Jaeger起動&Application起動&エンドポイント叩く

$ docker compose up

$ ./gradlew runShadow
{"time":"2022-12-14T17:43:01.261Z","message":"Autoreload is disabled because the development mode is off.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T17:43:04.317Z","message":"Application started in 3.156 seconds.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T17:43:04.323Z","message":"Application started: io.ktor.server.application.Application@2eda4eeb","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T17:43:04.588Z","message":"Responding at http://0.0.0.0:8080","logger_name":"Application","thread_name":"DefaultDispatcher-worker-1","severity":"INFO"}

$ curl http://localhost:8080/sample
2022-12-15T02:46:40.909022

2-1-5. Traceの確認

ブラウザでhttp://localhost:16686にアクセスします。 Trace_manual_local 今回はjaegerで確認しましたが、いい感じに記録されてます。

2-2. 自動計装

続いて自動計装(Auto Instrumentation)になります。

2-2-1. opentelemetry-javaagent.jarのダウンロード

JVM系で自動計装するには、opentelemetryのjavaagentをアプリケーションに入れる必要があります。 Releaseにて最新のopentelemetry-javaagent.jarをダウンロードすることができるのですが、今回はbuild時にgradleで自動ダウンロードするようにしてみました。

// build.gradle

plugins {
  id "com.github.johnrengelman.shadow" version "7.1.2"
    id "de.undercouch.download" version "4.1.1"
}

def agentPath = project.buildDir.toString() + "/otel/opentelemetry-javaagent.jar"

task downloadAgent(type: Download) {
    src "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/download/v1.21.0/opentelemetry-javaagent.jar"
    dest agentPath
    overwrite true
}

shadowJar {
    dependsOn("downloadAgent")
      ...
}

2-2-2. opentelemetry-javaagent.jarの適用

fat jar作成時に-javaagent:path/to/opentelemetry-javaagent.jar のようにjavaagentのconfigを記載します。 また、exporterの指定などotelに必要なconfig達を環境に合わせて渡しておきます。 ※configについて詳しくはagent-configを確認。

// build.gradle

def isLocal = !project.ext.has("ENV")

shadowJar {
    dependsOn("downloadAgent")
    ...
    def args = [
            "-javaagent:${agentPath}",
            "-Dotel.service.name=sample-service",
    ]
    if (isLocal) {
        args += [
                "-Dotel.traces.exporter=jaeger",
                "-Dotel.metrics.exporter=none",
        ]
    } else {
          args += [
                "-Dotel.traces.exporter=google_cloud_trace",
                "-Dotel.metrics.exporter=google_cloud_monitoring",
        ]
    }
    applicationDefaultJvmArgs = args
}

2-2-3. 実装

依存関係の追加

// build.gradle

dependencies {
    ...
    implementation 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations:1.21.0'
    ...
}

手動計装の際にSpan作成した箇所(SampleService)を修正します。OpenTelemetryやTracerを削除し、Span作成するfunctionに対して@WithSpanアノテーションを付与します。

// SampleService.kt

import io.opentelemetry.instrumentation.annotations.WithSpan

class SampleService(private val repository: SampleRepository) {
    @WithSpan
    fun execute() {
        val data = repository.get()
               println(data)
    }
}

2-2-4. Jaeger起動&Application起動&エンドポイント叩く

Jaegerのdockerファイル用意は手動計装と同じなので割愛します。

$ docker compose up

$ ./gradlew runShadow
[otel.javaagent 2022-12-15 03:45:35:551 +0900] [main] INFO io.opentelemetry.javaagent.tooling.VersionLogger - opentelemetry-javaagent - version: 1.18.0
{"time":"2022-12-14T18:45:42.522Z","message":"Autoreload is disabled because the development mode is off.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T18:45:45.724Z","message":"Application started in 3.875 seconds.","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T18:45:45.726Z","message":"Application started: io.ktor.server.application.Application@44e3f3e5","logger_name":"Application","thread_name":"main","severity":"INFO"}
{"time":"2022-12-14T18:45:47.223Z","message":"Responding at http://0.0.0.0:8080","logger_name":"Application","thread_name":"DefaultDispatcher-worker-2","severity":"INFO"}

$ curl http://localhost:8080/sample
2022-12-15T04:05:28.119532

2-2-5. Traceの確認

アノテーション付けただけでSpanが記録されました。とても簡単で便利ですね。 他にも@SpanAttributeなどでSpanに対して属性を追加できるみたいです。※詳しくはannotationsをご確認ください。

Trace_auto_local

これでKotlinで手動計装・自動計装ができました。次にこのテレメトリーデータをGoogle Cloud Traceに出力してみたいと思います。

3. Google Cloud Traceに出力する

今まではサンプルコードを使ってテレメトリーデータを計装してみましたが、どうせなら色々Spanがみれた方が良いと思うので、Gaudiyのアプリケーションを用いてOpenTelemetryを実装・Google Cloud TraceにExportしてみようと思います。

3-1. 手動計装の場合

手動軽装は上記のSampleの実装で利用することができます。 一応おさらいしておくと、Metrics, Trace共にGCP用Exporterを作成し、MetricsProvider, TraceProviderを作成。それをOpenTelemetryに読み込ませるだけです。

// SetOpenTelemetry.kt

fun Application.setOpenTelemetry(env: Env): OpenTelemetry {
  // Resourceの設定.
  // https://opentelemetry.io/docs/reference/specification/resource/sdk/
  val resource: Resource = Resource.getDefault()
    .merge(Resource.create(Attributes.of(ResourceAttributes.SERVICE_NAME, "${SERVICE_NAME}")))

  // PropagatorはW3C Trace ContextとGCP Propagator両方利用しています.
  val propagators = ContextPropagators.create(
    TextMapPropagator.composite(
      XCloudTraceContextPropagator(false),
      W3CTraceContextPropagator.getInstance(),
    ),
  )

  // otel build.
  val openTelemetry = OpenTelemetrySdk.builder()
    .setTracerProvider(createTraceProvider(env, resource))
    .apply { if (!env.isLocal) createMeterProvider(resource) }
    .setPropagators(propagators)
    .build()

  // ktor instrumentationのinstall.
  install(KtorServerTracing) {
    setOpenTelemetry(openTelemetry)
  }
  
  return openTelemetry
}

// TraceProviderの作成
fun createTraceProvider(env: Env, resource: Resource): SdkTracerProvider {
    // localはJaeger, その他環境ではGCPへExportするように設定
  val traceExporter = if (env.isLocal) {
    JaegerGrpcSpanExporter.builder().setEndpoint("http://localhost:14250/api/traces").build()
  } else {
    TraceExporter.createWithDefaultConfiguration()
  }
  return SdkTracerProvider.builder()
    .addSpanProcessor(SimpleSpanProcessor.create(traceExporter))
    .setResource(resource)
    .build()
}

// MeterProviderの作成
fun createMeterProvider(resource: Resource): SdkMeterProvider {
  val metricExporter = GoogleCloudMetricExporter.createWithDefaultConfiguration()
  return SdkMeterProvider.builder()
    .registerMetricReader(
      PeriodicMetricReader.builder(metricExporter)
        .setInterval(Duration.ofSeconds(30))
        .build()
    )
    .setResource(resource)
    .build()
}

3-2. 自動計装の場合

自動計装はopentelemetry-javaagent.jarがexportを行うので、opentelemetry-javaagent.jarにgcp用exporterを指定する必要があります。

しかし、自動計装用のcustom exporterはPoC段階でありexporter-auto.jarファイルをmaven repositoryから取得、もしくは自分で生成しなければならなそうでした。(2022/12/15現在) ※詳しくはexporter-autoをご確認ください

3-2-1. gcp exporterの生成

GCP opentelemetry-eperations-javaをcloneして、fat jarをbuildし、~/exporters/auto/build/libs/exporter-auto(.*).jarを生成します。

参考: opentelemetry-operations-java/build.gradle at main · GoogleCloudPlatform/opentelemetry-operations-java · GitHub

3-2-2. 生成したjarファイルをアプリケーション内に設置

$ cp ~/exporters/auto/build/libs/exporter-auto(.*).jar path/to/application/exporter-auto(.*).jar

3-2-3. exporterをopentelemetry-javaagent.jarのconfigに設定

GaudiyではプラットフォームとしてCloud Runを利用しているため、Dockerfileを書きます。 otel.javaagent.extensionsに先程のpath/to/application/exporter-auto(.*).jarを指定します。

// Dockerfile

FROM adoptopenjdk/openjdk11:alpine-slim

EXPOSE 8080

ENTRYPOINT java \
  -Dfile.encoding=UTF-8 \
  -javaagent:path/to/otel/opentelemetry-javaagent.jar \
  -Dotel.javaagent.extensions=path/to/application/exporter-auto(.*).jar \
  -Dotel.service.name=sample-service \
  -Dotel.metrics.exporter=google_cloud_monitoring \
  -Dotel.traces.exporter=google_cloud_trace \
  -jar ./build/libs/sample.jar

この時、exporterやservice.nameなどのopentelemetry configを渡します。

3-2-4. Docker build & Docker Push & Cloud Runデプロイ

$ docker image build -t $IMAGE_PATH .
$ docker image push $IMAGE_PATH
$ gcloud beta run deploy sample-service --image $IMAGE_PATH

3-2-5. Traceの確認

少しわかりにくいかもしれませんが、ServiceA → ServiceB → ServiceCとTraceが伝搬されていることがわかります。 自動計装によりGraphQL, Firestore, Spannerの処理も計装できていていい感じですね。

Trace

  • ServiceA: Node.js
  • ServiceB: Kotlin
  • ServiceC: Kotlin

4. まとめ

Kotlin(Ktor)サーバーにOpenTelemetryの導入ができました。ただ、Google Cloud TraceへのExportがPoCであり若干の荒技感あるので、今後はOpenTelemetry Collector経由でテレメトリーデータを扱うようにしたいと考えています。

また、Metrics, Logについても本格的にアプリケーションに適用して、Observabilityの向上に努めたいです。

そういえば今日はクリスマスイブですね。Merry Xmas, Merry OpenTelemetry!

recruit.gaudiy.com

5. 参考文献

opentelemetry.io

cloud.google.com

github.com

github.com

opencensus.io

DevOpsのCAMS原則に従ってアラート/不具合対応プロセスを改善する

この記事は「Gaudiy Advent Calendar 2022」の21日目の記事になります。

こんにちは。Web3スタートアップのGaudiyで、エンジニアをしているhaseyan(@hassey_11)です。

Gaudiyは、この1年で開発組織が倍以上になってきており、来る2023年に向けて組織もプロダクトも加速度的に成長しています。それに伴い、開発〜運用プロセスにおいていくつかの問題が発生していたため、DevOps の CAMS 原則に則ってアラート/不具合対応プロセスの改善に取り組みました

今回は、その改善プロセスについて一連の流れを振り返ってみたいと思います。スケールする開発組織の課題を抱えているチームの方に参考になれば嬉しいです!

1. スケールするプロダクト組織と DevOps の必要性

Gaudiy は、Gaudiy Fanlinkという、IPコンテンツホルダー自身がファンコミュニティ-ECプラットフォームを立ち上げることを可能にするサービスを開発・提供しています。

一般的に、人が増えるのに比例して、プロダクトの成長スピードが上がっていくわけでは必ずしもありません。むしろ、人数やサービスの規模が増えるにつれて、これまでは問題にならなかったことが新たな問題として表出してきます。

開発面では、トラフィック増加によるパフォーマンス問題が発生したり、開発の大規模化に伴うリファクタやシステムのリプレイスが必要になる場合もあります。また運用面では、リリースが複雑になりすぎたり、一部のドメイン/サービス領域が属人化するといった問題も生まれるかもしれません。また当然ですが、開発や運用だけでなく、組織面での問題も発生します。

スタートアップがスケール後もその成長スピードを保つためには、こうした問題を可能な限り防ぎ、スケールしても1人1人がプロダクトのアウトカムに貢献できる総量を高く保ち続けることが重要です。

こうした問題の中でも、運用や組織の開発プロセスに関する問題にアプローチする際に役立つ考え方のひとつが、DevOps です。

2. 実際に起こっていた問題

表題の通り、Gaudiyではアラート/不具合対応プロセスに関して、以下の課題を抱えていました。

  • アラート/不具合が発生した際に、明確な対応フローが定まっておらず、対応が属人化している
  • 不具合の対応状況が運営チームと開発チーム間でうまく同期されていない etc.

これまでは特に仕組みを作らずとも、アラートや不具合が発生した際にメンバーが自発的に対応を行うだけで、問題なく運用できてました。

しかし、開発に関わるメンバーが増えるにつれて対応する/できる領域が属人化していき、いわゆるアジャイルのトラックナンバー 1 が少ない状態になっていきました。また各人が通常のスクラムプロセス外で対応を行うので、報告者及び対応者以外の第三者が状況を把握することが難しくなっていました。

こうした属人化の影響が大きくなると、不具合全体の定量分析によって発見できたはずの、アーキテクチャレイヤの潜在的な問題点を見落としたり、監視として最適なアラートの設計などが遅れるリスクも高まります2

こうした課題を解決するために、DevOps の考え方に基づき、アラート/不具合対応のプロセスについて改善を行いました。

3. DevOps の CAMS 原則に基づく改善策の検討

DevOps の考え方の重要な要素として、CAMS(culture, automation, metrics, sharing)というものがあります。 システム運用アンチパターンによると、CAMS の各要素は以下のようになっています。

  • culture(文化)
    • チームが活動する上での基準に影響するもの
  • automation(自動化)
    • 人的資本を平凡な作業から解放するもの
  • metrics(メトリクス)
    • 物事がうまく機能しているかどうかを判断するために必要なもの
  • sharing(共有)
    • 知識は自由であるという考えのもと、文化を強化するために必要なもの

各要素の詳細や DevOps のより具体的な考え方などは上記の書籍などで学ぶことができますが、大まかには、運用を改善していくには「automation によってアンチパターンを避けながらリソースを効率化し、その結果について metrics を用いて判断・フィードバックを行った上で、得られた知見を sharing することで culture が強化されていく」と捉えると理解しやすいかもしれません。

この CAMS に従って、アラート/不具合対応プロセスの問題に対して、以下の流れで改善策を検討しました。

  1. 関連メンバーに現状のプロセスの概要及び課題感のヒアリング
  2. プロセス改善の why と改善方針のsharing
  3. プロセスの automation の実施
  4. 各種不具合/アラート対応 metrics の可視化と評価
  5. metrics の結果分かったことと更なる改善方針の sharing

4. アラート/不具合対応プロセスの改善でやったこと

上記の検討に沿って、具体的にアクションを実行していきました。

4-1. 関連メンバーに現状のプロセスの概要及び課題感のヒアリング

Gaudiy には「Be Agile に最大効率を徹底しよう」という行動指針があり、今回の改善でも、小さな改善を繰り返し行っていくことを意識していました。

具体的にはヒアリングをもとに、現状のプロセスの整理と各メンバーの課題感の洗い出しを行い、最も取り組むべき問題を特定することを試みました。

この行動指針ではチームをまたいだ「ちょいコラ」(ちょっとコラボするの略、Gaudiyの社内用語)が推奨されており、どのメンバーも「ちょいコラ」に慣れているので、コラボレーションがとてもしやすい環境になっていると感じています。

プロセスと課題感の整理は miro で行い、以下のような形に整理しました。ヒアリングはプロセスを追いながら課題感を付箋でマークしていく形で行い、課題のある場所とその内容が明確になりました。また、各人がプロセスに関してそれぞれ異なった認識をしていたこともわかりました。

プロセスの整理

この結果、アラートがわかりづらくて課題を特定しづらい、情報共有がうまくできてないなど、いくつかの開発/運用/コミュニケーションに関する問題が出てきました。特に、現状の一番大きな課題は 「不具合/アラート発生後の進捗管理・対応方法が属人化しており、特定個人の負荷が高まっていること」であると結論づけました。

4-2. プロセス改善のwhyと改善方針のsharing

軽微な仕組みであっても、プロセスの実行主体は常に受け入れる側であるため、その受け入れる側に納得感や理解がなければ仕組みの導入や浸透が難しくなります。また、担当者がいなくなると仕組みがすぐに陳腐化するといったこともあるので、仕組みを作るだけでなく、それを支える文化を醸成していくことも同時に行う必要があります。

今回の取り組みでは、ヒアリングを通じて把握できた現状の課題感と、将来的にアラート/不具合改善のプロセスを改善した結果、どういったことを実現したいかをドキュメントにしてチームに共有しました。

また、その実現に向けた大まかな方針から紐解き足元における改善後のプロセスを示し議論をすることで、納得感を得られるように努めました。方針については最終的な理想系から逆算する形で提案したことで、将来像の議論と共通認識をすることができ、結果としてとても良い議論ができました。

Why/方針の共有 改善後プロセスの共有
Why の共有 改善後プロセスの共有

具体的な改善後のプロセスとしては、個人単位ではなくサービスのオーナー単位で対応を行い、そのリソースもチーム内で管理するという方針で合意しました3

4-3. プロセスの automation の実施

DevOps の中でも個人的に重要だと考えているのが、この automation の部分です。人力での作業/属人性を減らし、プロダクトのアウトカムにリソースを集中できるかの多くは、このautomationの度合いに依存します。

上述の改善後プロセスの画像内の黒い四角の1つ1つは、各プロセスで出てくる Slack bot のスクリーンショットになっています。このプロセスの図は一見すると少し複雑になっていますが、ほぼ全てがSlack上で完結するようになっています。

不具合・アラートの検知 サービスオーナーのアサイン
解決済みの不具合・アラート 次スプリントで解決 ignore済みの不具合・アラート

この Slack bot は TypeScript/Node.js で作成し、 Cloud Function 上の1つのエンドポイントで動作しています。Slack の API はとてもリッチで、簡単にインタラクティブな bot を作成することができます。DevOpsを推進する上では非常に強力なツールになるので、興味がある方は是非試してみてください。

また、手動による不具合報告とサーバやSentryなどの3rdパーティツールから送られるアラートは、zapierを利用して一つのチャンネルに集約され、全て統一されたプロセスで対応できるようになっています。zapierもかなり自由度が高く、おすすめのツールです。

4-4. 各種不具合/アラート対応 metrics の可視化

上記のプロセスにて運用を開始してからしばらくしたのち、いくつかの指標を用いて当初の課題が解決されているかを確認しました。

1つ1つのアラート/不具合のデータは全て Notion に蓄積されているようにしているため、そのままダウンロードし、Google Spreadsheet で可視化することができました。Notion はドキュメント管理だけでなく、DevOps 観点では簡易 DB としても利用できるため、非常に重宝しています。

【確認したい課題1】個人単位の負荷は減ったか?

こちらの課題は、どれくらいアラート/不具合対応の担当領域が分散しているか、という観点で、発生したアラート/不具合のサービス領域の割合を確認しました。

サービス分散の図

この図から、特定の領域でのみ対応が発生しているということはなく、個人に大幅な負担がかかっている状況は解消されつつあるのかなと判断しています。また、データだけでなく、メンバーにヒアリングした際にも、問題の解消が確認できました。

【確認したい課題2】対応方法は標準化されているか?

こちらは、発生したアラート/報告された不具合のうち、改善後のプロセスでは、属人的に解決される=プロセス外で解決or対応漏れが発生していないことを確認しました。結果としては、約1ヶ月で37件の対応が発生し、そのうちの34件はその日のうちに適切なプロセスでチーム内タスクへの統合や根本対応が行われ、丸2日以上放置される件数は0となっていました。

この結果から、個人で対応するのではなく、チームで対応するという仕組みが実現できたと判断しました。

Gaudiy の社内用語には「チャレオン」(人のチャレンジに乗っかるの意)というものがあり、こうした新しい仕組みにも積極的に乗っかってくれる人が多く、非常にスムーズに導入が進みました。

4-5. metrics の結果わかったことと、さらなる改善方針の sharing

ここまでのCAMSの一周目の取り組みを通して、

  • 不具合/アラートが起きやすい箇所やその原因の分析
  • 発生したときに対処しやすい意味のあるアラート設計の方針

など、metricsを取ったことで得られた新しい知見が得られ、さらなる改善方針などをより解像度高く考えられるようになりました。

これらは現在、teamごとのDevLeader(選挙で選ばれる任期性の開発リーダー)と週次で共有・振り返りをしており、ネクストアクションを制定したり、必要に応じてチームや横断的な改善チームに相談しながら、改善に向けて動いています。

また、このプロセス自体にもいくつかのさらなる改善ポイントがあり(開発JiraチケットとNotionの連携automationなど)、随時2周目となるアップデートを行っていく予定です。

開発チーム全体には、週報として Slack でサマリを展開しています。

週報

5. おわりに

こうした DevOps の取り組みは、やりたいけど時間がない、という方は多いと思います。

ありがたいことにGaudiyでは、平日のアウトカムを高めるために、毎週水曜日に横断的な課題を解決するために時間をとる「EMPOWER-DAY」という仕組みがあります。今回の取り組みはほぼ全てこの水曜日に行ったものであり、 組織全体でこうした取り組みに時間を割くことを許容する文化があるので、非常に進めやすかったです。

あらゆる課題は、代表Dev(選挙で選ばれる任期性の開発全体のリーダー、所謂CTOポジション)などと適切に合意を取れば、手を挙げれば誰でもどんな課題でも取り組むことができ、課題解決が好きな人は楽しい環境かなと思います。

私自身DevOpsの考え方は大好きですし、スケールする開発組織には必須だと考えているので、引き続きプロダクト開発と並行して改善を進めていこうと思います。

recruit.gaudiy.com


  1. 特定の人物がトラックに轢かれるなど何かしらの要因で突発的に離脱することで、プロジェクトが立ちゆかなくなったり、継続して開発が困難になる人数
  2. 不具合の報告の際にはランダムにメンバーがアサインされる形でしたが、FE/BE といった領域や、サービスに関係なくアサインされるため、人によってはキャッチアップコストが高かったり、ユーザ影響が小さく優先度が低い場合に通常のタスクの後回しになり、対応漏れが発生するといった問題もありました。こうした対応漏れもメンバーの自主的な動きによって解決されていましたが、プロダクトのスケールとともに各人が持つコンテキストが複雑になっていき、負荷が高まっていました。
  3. 元々はチーム単位で、という提案でしたが、この改善の最中にサービスオーナー(各ドメインのオーナー)という概念が生まれ、そのほか各種プロセスに利用されたので、今回の改善でもその流れを汲むこととなりました。

OpenTelemetry Collector導入のPoCと今後に向けて

この記事は、Gaudiyが実施している「Gaudiy Advent Calendar 2022」の20日目の記事です。

はじめまして、Web3スタートアップのGaudiyでエンジニアをしているsato(@yusukesatoo06)です。

GaudiyではGoでのBE開発をメインで担当しており、前職ではNode.jsでのBE開発やFlutterでのモバイルアプリ開発を行っていました!

今回は、Gaudiyのインフラ環境でOpenTelemetry Collector導入のPoCを行ったので、その調査経過を書こうと思います(残念ながらまだ導入には至っていません…)

OpenTelemetry Collectorの記事はあまりないと思うので、導入を考えている方の手助けになれば幸いです🙇

1. Gaudiyのアーキテクチャが抱える課題

さて、本題に入る前にGaudiyのアーキテクチャをご紹介します。

https://speakerdeck.com/winor30/gaudiy-fanlink-architecture?slide=5

このように、BEに関してはGCP Cloud Runベースのマイクロサービス構成となっています。

そのため、マイクロサービスが抱える一般的な課題を、Gaudiyも漏れなく抱えていました。具体的には以下のような課題がありました。(マイクロサービスを扱ってる組織ならおそらく一度は悩むはず…)

  • マイクロサービスに分散したログをリクエスト単位で追うのが難しい
  • エラーが起きた際にどこのサービスが原因でエラーが起きたかわかりずらい
  • レスポンス遅延が起きた際に、どのサービスがボトルネックなのか把握しづらい etc.

その解決に向けた活動の一環が、今回のCollector導入の背景になっています。

2. 解決策としてのObservability

そして、このなかで話題に上がったのがObservability(オブザーバビリティ)です。

オブサーバビリティとは、システムを監視するのではなく「システムの内部で何が起きているのかを説明できる状態を作ること」を指し、クラウドベースのシステムやマイクロサービスを運用するにあたって注目が集まってる領域です。

(参考文献:https://pages.awscloud.com/rs/112-TZM-766/images/20221027_23th_ISV_DiveDeepSeminar_Observability.pdf

このオブザーバビリティは、以下の3本柱で構成されています。

  • メトリクス
    • 特定期間のデータの集計値 (何が起きたか)
  • トレース
    • リクエスト、トランザクションの経過観察 (どこで起きたか)
  • ログ
    • 特定地点の記録を示す (なぜ起きたか)

これを実現する方法はいくつかありますが、GaudiyではOpenTelemetryというプロジェクトのライブラリを利用することに決定しました。

3. OpenTelemetryとは

OpenTelemetryとは、Cloud Native Computing Foundation(CNCF)でKubernetesに次いで2番目に活発なプロジェクトです。

以下がCNCF内のプロジェクト毎のvelocityなのですが、OpenTelemetryが活発に開発されているのがわかると思います。

https://github.com/cncf/velocity

https://docs.google.com/spreadsheets/d/1JdAZrQx52m3XVzloE7KK5ciI-Xu-P-swGxdV3T9pXoY/edit#gid=976519966

OpenTelemetryを簡単に説明すると、メトリクス、ログ、トレーシングを包括的に扱うためのプラットフォームで、様々な言語に対応したOSSのライブラリが用意されています。

また元々存在していたOpenMetricsOpenCensusというプロジェクトが統合されたプロジェクトでもあります。

このOpenTelemetryの使い方については、andoさん(@Andoobomber)が24日目の記事で紹介してくれます。

4. OpenTelemetry Collectorとは

4-1. 概要

OpenTelemetryのライブラリは言語毎に用意されており、すごく便利に扱える一方で、プロジェクトの開発者は言語毎にライブラリの実装が必要となります。そのため、メトリクスを送る先のバックエンドサービス x プログラミング言語の数だけ対応が必要でした。

またOpenTelemetryを利用する側も、取得データに共通する加工やバックエンドサービスの変更をする際にすべてのアプリケーションに手を加える必要があり、一定のコストがかかってしまいます。

上記課題を解決するのがOpenTelemetry Collectorです。

以下がOpenTelemetry Collectorの概要図となっています。このような形で、一度OpenTelemetry Collectorに情報を集約し、その後Collectorがバックエンドサービスにデータを送る形となっています。

https://www.splunk.com/ja_jp/solutions/opentelemetry.html

共通の変換処理を行いたい場合や、バックエンドサービスを切り替えたい場合は、Collector側で設定を追加、変更することで共通的に対応することが可能です。

この辺りの設定については次のセクションで詳しく説明します。

4-2. アーキテクチャ

OpenTelemetry Collectorはデータの受信、加工、データの送信を1つのパイプラインとして定義し、処理を行っていきます。またCollectorは1つ以上のパイプラインから構成されます。

アーキテクチャは以下のようになっています。

https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/design.md

<Pipeline>

後述するReceiver、Processor、Exporterを組み合わせたもので、メトリクス、トレース、ログそれぞれに対してPipelineを定義することができます。 またそれぞれに対して複数のPipelineを設定することも可能です。

<Receiver>

Receiverとはメトリクス等を受け取るためのパーツです。アプリケーション用にはgRPCとHTTPのインターフェースが用意されており、ライブラリを通じてやり取りすることができます。それ以外にもクラウドサービス用の専用Receiverもあり、クラウドサービス固有の情報も取得することが可能です。

※提供されているReceiver一覧はこちらで確認できます。

<Processor>

Processorは名前の通りReceiverから受け取った値を加工するためのパーツです。メトリクスやログの変換や、トレースデータにラベルを付与したり等いくつかの処理を行うことができます。また、Exporterに流す時の方法だったり流量の制御や、Collectorのメモリ利用量等の制御も可能です。

※提供されているProcessor一覧はこちらで確認できます。

<Exporter>

Exporterはバックエンドサービスにデータを送信するためのパーツです。PrometheusのようなTime Series Databaseや、GCP、DataDogのようなクラウドサービスなど幅広いバックエンドサービスをサポートしています。ここのExporterには複数のバックエンドサービスを設定できるため、一度に複数箇所に書き込むことができます。

またバックエンドサービスを切り替えたい場合は、Exporterの設定を変更するだけでアプリケーションコードに手を加えず変更することができるため、非常に便利です。

※提供されているExporter一覧はこちらで確認できます。

では次のセクションで、実際に動かすための設定を見ていきたいと思います。

5. OpenTelemetry Collectorを設定してみる

5-1. Collectorの入手方法

まずCollectorの入手方法ですが、こちらはいくつか方法があります。

一番簡単な方法はDistributeされているDocker Imageを使う方法です、以下のコマンドでイメージを取得できます。

docker pull otel/opentelemetry-collector:0.67.0

一方で自身でカスタマイズしたCollectorを使いたい場合は、こちらの専用のビルダーを使ってビルドすることが可能です。

5-2. Configuration

まず、今回のPoCで利用したConfigの全体です。(少し記事用に変更してる部分はあります。)

各フィールドの説明は以降のセクションで説明します。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: localhost:4317 # Default

processors:
  batch:

exporters:
  logging:
    verbosity: detailed
  googlecloud:
    retry_on_failure:
      enabled: true
    project: <プロジェクト名>

service:
  telemetry:
    logs:
      level: debug
      initial_fields:
        service: poc-instance
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [googlecloud, logging]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [googlecloud, logging]

<Receivers>

ここではReceiverの設定を行います。アプリケーションからの情報取得であれば、以下のように設定するだけで完了します。

※他のReceiverの設定に関してはこちらをご確認ください。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: localhost:4317 # Default

<Processors>

ここではProcessorの設定を行います。今回は複数データをまとめて書き込むためのbatchのみ設定しています。

※他のProcessorの設定に関してはこちらをご確認ください。

processors:
  batch:

<Exporters>

ここではExporterの設定を行います。今回はPoCということもあり、Collectorの実行環境に出力するための logging と、GCPに出力するための設定を行っています。

※他のExporterの設定に関してはこちらをご確認ください。

exporters:
  logging:
    verbosity: detailed
  googlecloud:
    retry_on_failure:
      enabled: true
    project: <プロジェクト名>

<Service / Pipelines>

Pipelineの設定はserviceフィールドで行います。以下のようにメトリクスやトレース毎に、それぞれどの定義を使うかを選択することでPipelineを組み立てます。

ここの組み合わせを変えることで、メトリクスだけ別のバックエンドサービスに流したり、トレースだけ別のProcessorを挟むことが可能です。

service:
  pipelines:
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [googlecloud, logging]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [googlecloud, logging]

<Debug用>

またserviceフィールドではログを出力する設定もあるため、Debugの際などに使ってみてください。

service:
  telemetry:
    logs:
      level: debug
      initial_fields:
        service: poc-instance

<その他>

Collectorの設定の中で同じ種類のフィールドに対して複数の定義を行うこともできます。

例えばotlpのreceiverを複数定義したい場合は、以下のように type[/name] で一意の識別子をつけることで複数定義が可能です。

receivers:
  otlp:
    protocols:
      grpc:
      http:
  otlp/2:
    protocols:
      grpc:
        endpoint: 0.0.0.0:55690

また利用する際は、以下のように定義した名前をそのまま使ってあげればOKです。

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: []
      exporters: []
    traces/2:
      receivers: [otlp/2]
      processors: []
      exporters: []

6. 実際に動かしてみる

「では実際に、上記設定でCollectorを動かしていきましょう」と言いたいところなのですが、GCPへのExporter設定をしている関係で上記設定を手元で動かすことができないため、別の環境と設定を使いたいと思います…

自分でデモ環境を用意しようと思ったのですが、OpenTelemetry内のプロジェクトに素晴らしいデモ用のコードサンプルがあったため、こちらを活用させていただこうと思います。

上記リンクからディレクトリをダウンロードして、docker-compose up -d を実行すると以下の環境が立ち上がります。

※ここにあるZipkin、Jaegerは分散トレーシング用のシステムです

https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/examples/demo

この環境は全ての情報がCollector経由でExportされているため、このディレクトリ内にあるCollectorの設定を色々編集しながらぜひ遊んでみてください。

7. 今後やっていきたいこと

PoCの中でCollectorの有用性は評価できたのですが、Gaudiyのインフラ環境がCloud Runベースのため、どのような構成でCollectorを導入するのがよいかが一番の課題となっています。

まずCollectorをCloud Runでホスティングする案が上がったのですが、Cloud Runの特性上、常時立ち上げておく必要があるCollectorとの相性が悪く断念しました。(ここはもう少し調査の余地があります...)

またインフラ環境を全てGKE上にホストする案や、Collectorだけ別の形でホスティングする案等色々出ています、がCollectorの利用が目的となる案のため保留となりました。

ですので実際の導入方法に関しては、今後継続的に模索していきたいと思っています。

8. さいごに

今回のPoCですが、実はGaudiyの以下の制度を使った取り組みでした!

note.com

Gaudiyでは毎週水曜の午後、「EMPOWER-DAY」と称して、チームや会社全体の中長期の成長につなげる様々な取り組みを行っています。直近、この時間の使い方をより効果的にするための変更が行われましたが、基本的に「チームの底上げに時間を使う」という目的は変わらずに取り組んでいます。

このような形でGaudiyには新しい技術の導入を積極的に受け入れてくれる組織文化があるので、おもしろい環境かなと思います。Gaudiyの課題と自分の技術チャレンジをリンクさせて解決してくれる仲間も募集しています!

recruit.gaudiy.com

Gaudiy Advent Calendar 2022の21日目は、同じチームの @hassey_11 さんが担当してくれます!

gRPC × Go × Node.js におけるエラーハンドリングの実現方法

この記事は「Gaudiy Advent Calendar 2022」の19日目の記事です。

Web3 スタートアップの Gaudiy でソフトウェアエンジニアをしている Namiki ( @ruwatana ) です 🙂

ワールドカップを楽しみすぎていたら(日本代表もそして数時間前の決勝戦も本当に最高でした 🙌)、あっという間に自分の番が来てしまったわけですが、先月個人にフォーカスした 入社エントリ を書かせていただいたのもあり、今回は直近取り組んでいた gRPC周りのエラーハンドリングで得た技術的なナレッジ を共有できればと思います。

みなさんエモさ全開の記事を書いているところ、ただ実直にTechでコアな話をしていきます。

ちなみに弊社 Tech Blog への寄稿は初となりますので、お手柔らかにお願いします。誤ったことを書いていましたらこそっとご指摘いただけますと幸いです 🙇

1. gRPC について

現在、Gaudiy のプロダクトにおけるバックエンドサーバー間の API 通信のプロトコルとして gRPC を採用し始めています。

そもそも gRPC とは?の詳細については今回は割愛させていただきますが、簡単にいうとインターフェースを定義した言語(IDL)である Protocol Buffers をベースとしたエコシステムを用いることで、クライアント・サーバー間のメッセージのやり取りを効率的に実現することができる仕組みです。

詳しくは、公式 docs をご参考いただければと思います。 grpc.io

Gaudiy のプロダクトにおけるシステム構成は執筆現在では以下の図のようになっており、Go 製のマイクロサービス間での一部やりとりや、Node.js 製の BFF と Go サービス間の一部やりとりなどに gRPC を徐々に採用していっています。

この辺りの初期導入は、Dev Enabling チームのメンバーを中心に行っています。取り組み内容の詳細については、また別途記事になった時にご覧いただければと思います(誰か書きましょう)。

GaudiyのTech stack

2. 独自のパターンを用いたエラーハンドリングの実現

自分の所属している Stream Aligned な開発チームでは、直近で決済基盤のリニューアル開発を行ってきましたが 、その実装をしていく上で、gRPC サーバーにて複数の独自エラーパターンの返却を行い、フロントエンドで表示を制御したいというユースケースに直面しました。

これはさまざまなシステムにおいても、ごく一般的なユースケースかと思うのですが、gRPC でどのように実現したのかを詳しく説明していきたいと思います!

2-1. gRPC で推奨されているエラーモデル

まず、gRPC 公式の docs を見ると、Error handling についての Guide が提供されていることがわかります。2つの Error model についての説明がなされていますが、どちらかに沿って実現していくことが提唱されています。 grpc.io

2-1-1. Standard error model

抽象的かつシンプルなエラーハンドリングに関しては、docs にある通り Standard error model での表現が可能です。

codemessage という文字列が参照可能となっており、サーバー側で任意の情報を詰め、クライアント側でそれをもとにハンドリングするといったやり方で実現が可能です。

詳しい実装方法については、公式 docs に記載されている下記リポジトリにて言語ごとにサンプルが公開されているので割愛します(とても参考になります)。 github.com

2-1-2. Richer error model

非常にシンプルなエラーハンドリングであれば Standard error model で事足りますが、オブジェクトの中にデータも詰めてエラー返却をしたいなどのケースを考慮すると、Richer error model での表現がベターになってきます。

今回、Gaudiy で実際に直面したユースケースでは、カスタムのエラーコードとそれに付随する任意の情報を含めたいといったものでした。その点、Standard error model でも上手くやりくりすれば実現自体はできたかもしれないのですが、より拡張性や共通フォーマットの利用を意識して、こちらのモデルにて実装を行いました。

2-2. Richer error model の実装について

それでは、実際にどのように実装していったか、サンプルコードとともに順に見ていきます。

2-2-1. Protocol Buffers の定義

まずは、エラーの汎用的な独自の message を Protocol Buffers に定義し、protoc で各々の言語のコードを自動生成します。

今回は以下のような感じで、独自の message を定義し、サーバー側ではこの型を用いて任意の情報を詰め、クライアント側でデシリアライズ (パース) して受け取るという流れで実装しました。

独自 message の各フィールドには docs でも推奨されている、google が提供している error_details.proto の各messageを採用し、汎用的かつ条件によってさまざまな情報を詰め込むことができるようにしています。

syntax = "proto3";

package gaudiy.apierror;

import "google/api/field_behavior.proto";
import "google/protobuf/any.proto";
import "google/rpc/error_details.proto";
import "google/rpc/status.proto";

~~~

message Details {
  google.rpc.ErrorInfo error_info = 1 [(google.api.field_behavior) = REQUIRED];
  google.rpc.BadRequest bad_request = 2;
  google.rpc.DebugInfo debug_info = 3;
  google.rpc.LocalizedMessage localized_message = 4;
  google.rpc.PreconditionFailure precondition_failure = 5;
  
   ...

また、任意のカスタムエラーコードなどの情報も Protocol Buffers で enum などを用いて定義しておくと、クライアント・サーバー双方から参照できるので良いと思います。

先述した通り、Gaudiy ではこの辺りの整備や方針は Dev Enabling チームが導入から利用までをサポートしてくれたので、現在は非常にシームレスな利用が可能になっています。フィーチャー開発がメインの我々にとっては本当に頼もしい限りです🙌 (大感謝)

2-2-2.サーバー側の実装

さて、サーバー側の実装ですが、Gaudiy ではシステム構成的にも基本的に Go での実装となります。

エラーの詰め方としては、 grpc/status パッケージの error インターフェースの実装型である status.Status ( google/rpc/status.proto の message に相当 ) の details フィールドに先ほど作成した独自の message をセットすることで実現しています。

下の例では、Required なフィールドとなる ErrorInfo の Reason に Protocol Buffers で定義しておいたカスタムエラーコード (enum) の文字列を詰めるような実装にしています。

もちろん、Metadata フィールドに customErrorCode のような key で格納しても良いと思いますし、カスタムエラーコードに依存した message を作ってしまうというのも良いと思います。この辺りは、開発メンバーの中で合意形成が必要でしょう。

以下が実装サンプルのコードになります。

import (
  "google.golang.org/grpc/codes"
  "google.golang.org/grpc/status"
  "google.golang.org/protobuf/types/known/emptypb"

  "gaudiy/apierror"
  hogepb "gaudiy/hoge"
)

func (s *Server) Hoge() (*emptypb.Empty, error) {
  res, err := s.service.Hoge()
  if err != nil {
    // status.Statusを任意のcodeとerrから作成
    st := status.New(codes.InvalidArgument, err.String())

    // Status.detailsに定義した型を詰める
    st, _ = st.WithDetails(&apierror.Details{
      ErrorInfo: &errdetails.ErrorInfo{
        // pbに定義したカスタムのエラーコードをReasonに詰める
        Reason: hogepb.CustomErrorCode.Hoge.String(),
        Domain: "your.domain",
        Metadata: map[string]string{
          // その他任意の情報があれば詰める
          "key": "value",
        },
      },
      // その他の error_details フィールドにも任意の情報を詰められる
    })

    // statusからerrorを生成して返却
    // (wrapしてしまうとstatusが正しくセットされないのでその場合はUnwrapが必要)
    return nil, st.Err()
  }

  return &emptypb.Empty{}, nil
}

2-2-3.クライアント側の実装

Gaudiy ではクライアント側となりうるのは Go 製のマイクロサービスまたは Node.js 製の BFF です。それぞれの実装を見ていきます。

まず、Go で受け取る場合は先ほどの逆を同じパッケージを用いつつ行うだけなので非常にシンプルです 🙆

import (
  "log"

  "google.golang.org/grpc/status"
  "google.golang.org/protobuf/types/known/emptypb"

  "gaudiy/apierror"
)

func (c *Client) Hoge() (*emptypb.Empty, error) {
  res, err := c.service.Hoge()
  if err != nil {
    // errからstatus型への変換を試みる
    st, ok := status.FromError(err)
    if !ok {
      return nil, err
    }

    // status.detailsを取り出して独自の型へのキャストを試みる
    details := st.Details()
    if len(details) > 0 {
      switch d := st.Details()[0].(type) {
      case *apierror.Details:
        // expected case
        log.Printf("reason: %v", d.ErrorInfo.Reason)
      default:
        // unexpected case
      }
    }

  ~~~

さて、問題は Node.js での実装です。

gRPC は、各言語のライブラリがそれぞれ独立された思想で実装・提供されているため、status という構造1つとっても、Go と同じように Node.js でも簡単に扱えるというわけではありません。

Richer error model の説明にも下記のようにあり、Node.js ライブラリは非対応だったりします。

This richer error model is already supported in the C++, Go, Java, Python, and Ruby libraries, and at least the grpc-web and Node.js libraries have open issues requesting it.

Go の gRPC 公式ライブラリである grpc-go では、status.details の情報を gRPC HTTP Client / Server どちらの実装にも serialize / deserialize してくれるロジックが埋め込まれており、利用する側はあまり HTTP 通信の規格までを意識せずに非常にシームレスに扱うことができます。

しかし、Node.js の公式パッケージである grpc-js にはそうした実装が現在なく、Go 製のサーバーから status の情報が HTTP 通信に含まれてきたとしても、自分でクライアントのデシリアライズロジックを書く必要があります。

上記の grpc-go の実装の通り、status.details の情報は grpc-status-details-bin という HTTP header key に格納されていることがわかるので、そちらを取得しつつ手動でデシリアライズする必要があります。

この辺りを行ってくれる Node.js の OSS が下記のようにいくつか公開されていますが、そこまでヘビーなものではないので Gaudiy では自前で実装することにしました。

それでは、サンプルコードとともに紹介していきます。 やってることは Go と同じでリクエストのエラーオブジェクトから status オブジェクトとその中の details に格納された独自の message の型情報を取り出しています。

import { credentials } from '@grpc/grpc-js';

// protobufから自動生成された各種コードをimport
import * as GaudiyApiError from './gaudiy/apierror/apierror_pb';
import * as grpc from './gaudiy/hoge/hoge_grpc_pb';
import { HogeRequest, HogeResponse } from './gaudiy/hoge/hoge_pb';
import { Status } from './google/rpc/status_pb';

const client = new grpc.HogeClient('localhost:5001', credentials.createInsecure());

client.hoge(
  new HogeRequest(),
  function (error: ServiceError | null, response: HogeResponse) {
    if (!error) {
      // 正常系処理
      return;
    }

    // grpc-goのサーバー側で埋め込まれたkeyからstatus.detailsのバイナリ情報を取り出す
    const detailsBinary = error.metadata.get('grpc-status-details-bin');
    if (!detailsBinary) {
      throw error;
    }

    const buffer = detailsBinary[0];
    if (!Buffer.isBuffer(buffer)) {
      throw error;
    }

    // binaryをdeserializeして該当の型だった場合のみunpackする
    const details = Status.deserializeBinary(new Uint8Array(buffer))
      .getDetailsList()
      .map((element) => {
        // protobufで定義したtype名で格納されているので判定
        if (element.getTypeName() === 'gaudiy.apierror.Details') {
          return element.unpack(GaudiyApiError.Details.deserializeBinary, element.getTypeName())?.toObject();
        }
        return null;
      })
      .find((element) => element);

    if (!details) {
      throw error;
    }

    // detailsの中身が取れる
    console.log(details?.errorInfo?.reason);
  }
);

かなり取り回しにくい感じではありますが、これでようやく Go のように Node.js でも Richer error model の情報を取得することができました(Go に比べるとコード量が多いですね😅)。

いかがでしたでしょうか。
gRPC と Go の記事は多いものの、Node.js を駆使したエラーの実装方法やナレッジはなかなか探しても見つからなかったため、改めて全体感をまとめました。

サンプルで紹介したような実装に行き着く際にも、文献や実装を漁ったり、デバッグやトライアンドエラーを繰り返しして実現したので、苦労したポイントでした。
この周辺で悩む方がいましたらその助けになれば幸いです 🙏

3. おわりに

Gaudiy では今回紹介した gRPC をはじめ、新しい技術に対して積極的に投資・チャレンジしていくというスタイルで開発を行っています!

一緒に働くエンジニアも積極的に募集しておりますので、こうした技術や開発スタイルに興味をお持ちの方がいらっしゃいましたら、選考前にカジュアル面談も可能ですので、ぜひ採用ページからお気軽にご応募いただければと思います!

recruit.gaudiy.com

Gaudiy Advent Calendar 2022の20日目は、@yusukesatoo06 さんが担当です!
今年も残り少なくなってきましたね、身体にはお気をつけて良い年末をお過ごしください🎄
それでは、また 👋

Gaudiyのいちエンジニアが代表Dev(技術責任者)になって感じたこと

この記事は「Gaudiy Advent Calendar 2022」の18日目の記事です。

こんにちは。ファンと共に時代を進める、Web3スタートアップのGaudiyでエンジニアをしている勝又(@winor)です。11月初めごろから、いちエンジニアをしていた自分が、社内で「代表Dev」と呼ばれている技術責任者になりました。

今まで明確なマネジメント経験がない自分がストレッチなロールを任されたところもあり、苦戦しながらも日々を過ごしているので、今回はその振り返りができればと思います。

かなりリアルでハードな話になってはいるのですが、同じような境遇の方やこれから遭遇するかもしれないどなたかの参考になれば幸いです。

1. 代表Devが新設された背景

これまでのGaudiyは、CEO以外の役職や階層がないフラットな組織でした。プロダクト開発組織は、Stream aligned teamが複数存在するのみで、チーム内のロールはPdM、UI・UXデザイナー、エンジニアとシンプルなメンバー構成でした。この状況でもメンバーのオーナーシップは非常に高く、他職能メンバーとのコラボレーションを頻繁に行い、個々人が効率的に成果を生む自律的な組織だったと思います。

(その頃の組織体制については、こちらの記事をご参考ください。)

techblog.gaudiy.com

しかし、シリーズBを迎え急激に組織も拡大していった結果、頻繁なコラボレーションが認知負荷の増大を招いたり、合意形成に時間がかかったり、生産性が低下したりする問題が発生してきました。

この問題を解決するために、完全フラットな組織から、各チームの代表者を立てて意思決定の所在と責任を明確にし、よりスピーディな意思決定ができるような自律分散型組織へと移行しました。

この結果として、Stream aligned team以外にもDevやDesignなどの職能横断の組織と、責任者である「代表」という役割が誕生しました。その開発組織の代表が、自分が担う「代表Dev」になります。

また、この組織設計のGaudiyらしいユニークなところは、各組織の代表は各組織にいるメンバーから「選挙」で決定される点です。こうすることで、成果だけでなくメンバーからの信頼を得ないと続投ができない設計になっています。

この理由も詳しくあるのですが、少し本筋とズレてしまうので興味ある方は下記の記事を参考にしていただければと思います。

type.jp

2. 代表Devになる前はどんな役割だったか?

振り返りをする前に、前提情報として、代表Devになる前の自分はどんな役割だったか?について触れることで、なぜ後述する課題にぶち当たってしまったのかの参考になればと思います。

基本的には前述したStream aligned teamに所属をし、アウトカムのためのデリバリーを達成するためにはなんでもするようなエンジニアでした。他のメンバーからは、「不確実性に対して推進力がある」みたいなところは評価いただくこともありました。

チームでは暗黙的に開発のリーダーのような役割を担うことはありましたが、明確にマネージャー的な役割を経験したことは、前職も含め今までのキャリアでは一度もありませんでした。

3. 代表Devになって最初に躓いたこと

3-1. ボールを打ち返すことに精一杯になっている

最初に躓いた点は、様々な課題や要望に対するボールを打ち返すことに精一杯になってしまうことでした。

最初はなにかあったときの一次ボール受けになっていて、スピーディーに打ち返していくように頑張っていたのですが、当たり前ですがわりとすぐにこれだとスケールできないなということに気づきました。

この課題は、今では役割を分けて権限委譲したことによりある程度は解決できています。とはいえ、リソースが限られた開発組織ですべてのボールを打ち返せるかというとそれは難しく、ボールに対して優先順位をつけたり、まとめて解決できるようにしていくことなども大切でした。

3-2. ピープルマネジメントを重視せず、問題が大きくなって気づく

元々の意思決定では、ピープルマネジメントに時間を割かないようにしていました。理由としては、今思うと自分目線の考えだったなと思うのですが、Gaudiyの開発組織でそういった問題が発生しないだろうと考えていたためです。

ただ結果的には、自分が観測できていなかった開発組織内の課題が大きくなり、問題が発生してから気づくケースが複数件ありました。この結果を踏まえて、開発のメンバーと1on1を行うようになり、継続的に開発組織の課題を検知することに対して優先度を上げるようにしています。

失敗をして気づいたことでもありますが、「エンジニアリングマネジメントトライアングル」という、エンジニアリングマネジメントで必要な3つの役割(Technology、Product、Team)の軸をつないだ図から考えても、TeamとProductをつなぐTeam Development的な部分を軽視していたということがわかりました。

また、これを参考にすると、今なにが足りていないのか? 本当にそこが足りていなくて良いのか?という視点が得られ、それ以降の開発組織や仕組みの改善への参考となりました。

steam.place

4. 代表Devになって変化したこと

4-1. 開発組織と隣接組織の成果の総和という考え方を持てた

本や記事を読んで学んだことですが、EM的なポジションになりたての方などでよくありがちなのが、コードを書く時間が減って自分のアウトプット量が減ることにより、フラストレーションが貯まることです。この点に関しては、会社や事業の目標を達成するためにできることを行うという考え方を元から持っていたのでそこまで問題はなく、開発組織全体の成果を上げていくことが重要だと考えていました。

一方で、HIGH OUTPUT MANAGEMENTという本を読み、少し考えが変わったことがあります。それは開発組織だけでなく、開発に影響力が及ぶ隣接する組織の成果を挙げることも、代表Devのロールに対する成果として大事だということです。

最終的に会社として達成すべきことは、事業目標を達成することであり、Gaudiyで言えばミッションである「ファンと共に、時代を進める」ことが最も大事です。そして、この事業目標の達成に必要な価値は、各組織から直接的に生まれることはほぼなく、ある組織で生まれた価値を他の組織がまた違う形に変換をしていき、ユーザーに届くことによって生まれます

例えばGaudiyの開発組織でいえば、社内の管理画面開発を怠ることでコミュニティ内での施策を回すスピードが落ちてしまい、CS組織の成果を落とすような影響があったり、他にも、デザインシステムの運用を怠ることでUIデザインとプロダクトの乖離が発生し、デザインのチェックコストなどにリソースを使ってしまい、デザイン組織の成果を落とすような影響などがあったりします。

開発組織の影響は、直接的な事業への貢献と隣接する組織への貢献の2種類あり、これらを最大化していくことが大事であるという考え方を持てたし、課題が発生したときの優先順位の軸として重要な要素だと考えることができました。

4-2. 開発の役割を明確化し、様々な開発施策をクオリティ高く実行できるようになった

以前はボールが集中する課題もあったため、開発組織内の役割を新設して、開発メンバーを頼るような形に変えていきました。

プロセスとしては、開発、組織、プロダクトなどの課題などから必要な役割を逆算していきました。例えば、Stream aligned teamのデリバリーに責任をもつ開発リーダーや、BE・FEの各リーダーなどの役割を明示して、各々のメンバーに責任の範囲で自律的に動いてもらっています。

彼らのおかげで開発の施策や仕組みづくりがクオリティ高く行われるようになり、最もうまくいった改善点ではないかとも感じています。

5. 今後の課題と向き合い方

5-1. 中長期的な意思決定ができていない

代表Devとして改善しなくてはと思っている一番の課題は、中長期的な意思決定がうまくできていないという点かなと思います。この理由はいくつかあるのですが、

  1. 改善すべき大きな課題はありつつも、その中でどこがボトルネックなのか?が特定できていない
  2. ヒアリング過多になっていて、意思決定の収束までが遅い

が主な要因だと考えています。

ここは自分の力量が本当になかったなと思うところはありつつ、他の方からフィードバックをもらって変えていこうと思ったのは、不安なところがあると、逃げのヒアリングを行ってしまい、とりあえず課題を探ろうとしすぎている点です。

マーケティングやR&Dなどでも、「仮説を持たずに調査やヒアリングばかりを行うのは、すべての課題を揃えて解決策を出していくことになり、効率が悪い意思決定になってしまう」というアンチパターンが実際にあるといいます。

課題を揃えていくことも重要だとは思いますが、事業やプロダクトの不確実性が非常に高いスタートアップの環境では、意思決定を素早く行い、実際に検証をして改善していくことも非常に大切です。そのため、今後はこの考え方も取り入れて意思決定をしていくことが大事だと痛感しました。

5-2. 副業メンターの方がいることで、自分の行動やモヤを振り返られる

代表Devとして今までとだいぶ違った動きをしているところもあり、モヤや躓きが多かったのですが、こういった点を他の方に相談して意見をもらえるのは本当にありがたかったです。飲み会で率直に色々とダメ出ししてもらったりしたことは本当に感謝しています笑。

また、Gaudiyで副業をしていただいてるEMの@yowrb_0905さんと週1で1on1を行っているのですが、メンタリングをしていただいたり、EMとして働かれている経験から新しい考え方に気づかせていただくことが多く、1on1をしてもらう重要性も再認識できました。

6. まとめ

今回は代表Devになってみての振り返りをまとめてみました。良かったところもありましたが、全体的に反省点や自分の至らなさで失敗してしまったところはやはり多いなと感じました。

ただ個人的には、Gaudiyの「ファンと共に、時代を進める」という壮大なMissionに共感しており、この実現にはエンジニアリングの力が本当に大切だと考えています。そのためにも、選挙で変わる可能性はありますが、どんな形でも強い開発組織を作っていき、少しでもVisionの実現に近づけていきたいと思っています。

また、強い開発組織を作っていく文脈でもエンジニアの採用は非常に力を入れていますので、ぜひ興味ある方は下記リンクか自分(@winor)へ連絡でもいただければと思います!

recruit.gaudiy.com

明日のアドベントカレンダーはエンジニアの並木さん(@ruwatana)が担当します。