Timee Product Team Blog

タイミー開発者ブログ

OpenTelemetryで環境ごとにObservability Backend(Jaeger、Datadog)を切り替えてエンジョイしてみたよ

こんにちは! タイミーでPlatform Engineerをしている @MoneyForest です。

こちらは Timee Product Advent Calendar 2024 の10日目の記事です。

2024年8月に入社して、幸いにもチームメンバーにも恵まれて楽しく働いています。 個人的にキャッチアップがあまりできていなかった OpenTelemetry を題材にして実装をしてみたので、ここから得られた気づきや知見を共有したいと思います。

はじめに

アプリケーションの可観測性(Observability)を担保する上で、APM(Application Performance Monitoring)は重要です。

現在、JaegerやPrometheusのような非商用のものから、DatadogやNew RelicやSplunkなど様々なAPMを利用できるツールが存在します。(本記事ではこれらを「Observability Backend」と呼称します。)

これらのObservability BackendでAPMを利用するには、各々が提供する独自のSDKを使用してTraceやSpanを送信する必要があり、以下のような課題がありました。

  • アプリケーションの実装に各SDKの仕様が滲み出る
  • Observability Backendの切り替えが困難

OpenTelemetryの登場により、オブザーバビリティに関する様々な概念が抽象化され、統一された仕様が提供されるようになりました。

これにより、アプリケーションコードとObservability Backendの実装を分離し、状況に応じて適切なツールを選択できるようになりました。

つまり、ローカルではJaegerのような非商用のツールで開発中にAPMを利用し、デプロイしたアプリケーションではDatadogのような商用ツールでリッチなUIでAPMを利用したり、モニタリングアラートを組んだりなどができるということです。

本記事では、Go言語でOpenTelemetryを活用して、環境ごとにObservability Backend(Jaeger、Datadog)を切り替える実装を行うことで、これらのメリットを体感していきたいと思います。

実装してみる

実装の全量はこちらにあります。

https://github.com/MoneyForest/advent-calender-2024

共通インターフェースの定義・初期化処理

OpenTelemetryに準拠した共通インターフェースを定義することで、各バックエンドの実装を抽象化します。(この実装ではinfrastructure/tracer.goに配置しています。)

共通インターフェースはOpenTelemetryのSDKの仕様に則してインターフェースを定義します。 例えばShutdownメソッドは、OpenTelemetryの仕様に基づき定められているものです。トレースデータの欠損を防ぐために必要な標準仕様となっています。

参照:https://opentelemetry.io/docs/specs/otel/trace/sdk/#shutdown

また、今回は環境に応じてObservability Backendを切り替える実装のため、envを条件に初期化するProviderを切り替えています。

// infrastructure/tracer.go
package infrastructure

import "context"

type TracerProviderWrapper interface {
    Shutdown(context.Context) error
}

func InitTracer(env string) (TracerProviderWrapper, error) {
    switch env {
    case "dev":
        return InitDatadog()
    default:
        return InitJaeger()
    }
}

Observability Backend固有の初期化処理

次に各Observability Backendごとの初期化処理を書いていきます。

Datadog

Datadogは2つの方法で送信できます。

  1. ddotelアダプター経由

参照:https://docs.datadoghq.com/ja/tracing/trace_collection/custom_instrumentation/go/otel/

こちらではDatadogのSDKであるdd-trace-goからddotelというアダプターを通じてOtelに準拠したTraceProviderを生成します。

送信するポートはDatadog Agentのデフォルトポートである8126になります。

// infrastructure/datadog.go
package infrastructure

import (
    "context"
    "os"

    "go.opentelemetry.io/otel"
    ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry"
    ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)

type ddTracerProvider struct {
    provider *ddotel.TracerProvider
}

func InitDatadog() (*ddTracerProvider, error) {
    tp := ddotel.NewTracerProvider(
        ddtracer.WithService(os.Getenv("SERVICE")), // MoneyForest
        ddtracer.WithEnv(os.Getenv("ENV")),
        ddtracer.WithServiceVersion("1.0.0"),
        ddtracer.WithAgentAddr("localhost:8126"),
    )
    otel.SetTracerProvider(tp)
    return &ddTracerProvider{provider: tp}, nil
}

func (p *ddTracerProvider) Shutdown(ctx context.Context) error {
    return p.provider.Shutdown()
}
  1. Otel SDK経由

こちらではOtel SDKを使用してTraceProviderを生成します。

参照:https://docs.datadoghq.com/ja/opentelemetry/interoperability/otlp_ingest_in_the_agent/?tab=host#enabling-otlp-ingestion-on-the-datadog-agent

送信するポートはOTLP HTTPのデフォルトポートである4318になります。

// infrastructure/datadog.go
package infrastructure

import (
    "context"
    "os"

    "go.opentelemetry.io/otel"
    ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry"
    ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
)

type ddTracerProvider struct {
    provider *sdktrace.TracerProvider
}

func InitDatadog() (*ddTracerProvider, error) {
    exporter, err := otlptracehttp.New(
        context.Background(),
        otlptracehttp.WithEndpoint("localhost:4318"),
        otlptracehttp.WithInsecure(),
        otlptracehttp.WithURLPath("/v1/traces"),
    )
    if err != nil {
        return nil, err
    }
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String(os.Getenv("SERVICE")), // MoneyForest
            semconv.DeploymentEnvironmentKey.String(os.Getenv("ENV")),
        )),
    )
    otel.SetTracerProvider(tp)
    return &ddTracerProvider{provider: tp}, nil
}

func (p *ddTracerProvider) Shutdown(ctx context.Context) error {
    return p.provider.Shutdown(ctx)
}

Jaeger

Jaegerはjaegar-client-goのSDKがありましたが、2024/5に非推奨となっています。

今はOpenTelemetryに準拠することが推奨されているため、Otel SDKによる実装を行います。

// infrastructure/jaeger.go
package infrastructure

import (
    "context"
    "os"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.4.0"
)

type jaegerTracerProvider struct {
    provider *sdktrace.TracerProvider
}

func InitJaeger() (jaegerTracerProvider, error) {
    exporter, err := otlptracehttp.New(
        context.Background(),
        otlptracehttp.WithEndpoint("localhost:4318"),
        otlptracehttp.WithInsecure(),
    )
    if err != nil {
        return jaegerTracerProvider{}, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String(os.Getenv("SERVICE")),
            semconv.DeploymentEnvironmentKey.String(os.Getenv("ENV")),
        )),
    )
    otel.SetTracerProvider(tp)
    return jaegerTracerProvider{provider: tp}, nil
}

func (p jaegerTracerProvider) Shutdown(ctx context.Context) error {
    return p.provider.Shutdown(ctx)
}

アプリケーションでの利用

アプリケーションでは、OpenTelemetryの標準APIを通じてTracerを使用することで、Observability Backendに依存しない一貫した実装が可能になります。

package main

import (
    "context"
    "crypto/rand"
    "fmt"
    "log"
    "os"

    _ "github.com/go-sql-driver/mysql"

    "github.com/MoneyForest/timee-advent-calender-2024/internal"
    "github.com/MoneyForest/timee-advent-calender-2024/internal/handler"
    "github.com/MoneyForest/timee-advent-calender-2024/internal/infrastructure"
)

func main() {
    // Initialize tracer
    tp, err := infrastructure.InitTracer(os.Getenv("ENV"))
    if err != nil {
        log.Fatal(err)
    }
    defer tp.Shutdown(context.Background())

    // ...
}
func (h *UserHandler) CreateUser(ctx context.Context, email string) error {
    tracer := otel.Tracer(os.Getenv("SERVICE"))
    ctx, span := tracer.Start(ctx, "UserHandler.CreateUser")
    defer span.End()

    span.SetAttributes(
        attribute.String("email", email),
        attribute.String("handler", "UserHandler"),
        attribute.String("method", "CreateUser"),
    )

    return h.userUsecase.CreateUser(ctx, email)
}

動かしてみる

これまでの実装がうまくいっていれば、JaegerとDatadogで同じようなAPMを見ることができる想定です。確認してみましょう。

Docker Composeでgcr.io/datadoghq/agentjaegertracing/all-in-oneのサイドカーを立てます。

本来はdatadog-agentはdev環境以上に必要で、ECSのサイドカーなどに定義すればよいです。 (リポジトリではMySQLやRedisのコンテナも建てていますが省略します。)

JaegarはUIまでパッケージングされたイメージが公式から提供されており、こちらにトレースを送信することで、localhostのUIからAPMを確認できます。

  datadog-agent:
    image: gcr.io/datadoghq/agent:latest
    environment:
      - DD_API_KEY=${DD_API_KEY}
      - DD_SITE=datadoghq.com
      - DD_APM_ENABLED=true
      - DD_APM_NON_LOCAL_TRAFFIC=true
      - DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_HTTP_ENDPOINT=0.0.0.0:4318
    ports:
      - "8126:8126"
      - "4319:4318" # OTLP HTTP (ローカルマシンのポートはJaegarと被るのでずらしている)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /proc/:/host/proc/:ro
      - /sys/fs/cgroup:/host/sys/fs/cgroup:ro
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # UI
      - "14250:14250"
      - "4318:4318" # OTLP HTTP
    environment:
      - COLLECTOR_OTLP_ENABLED=true

dev環境(Datadog)

https://app.datadoghq.com/apm/traces でトレース情報を見ることができます。

以下はddotelを使用して送信したメトリクスです。

以下はOtel SDKを使用して送信したメトリクスです。

1.と異なる点として、SpanのAttributeにotel の構造体があります。また、process_idruntime-idは存在しません。

local環境(Jaegar)

http://localhost:16686/search でトレース情報を見ることができます。Datadogと一部のメタデータが異なっていたりと、完全に同一ではないものの、概ね同じ内容を見ることができます。

Datadogのトレースと異なる点としては、サービス名として与えていたMoneyForestがotel.libraryにマッピングされていましたが、こちらではotel.scopeにマッピングされているという点です。

実装してわかったこと

GoのOtel SDKから生成されるsdktrace.TracerProviderではShutdownはcontextを引数に取るものの、ddotel.TracerProviderShutdownはcontentを引数に取らない、などの細かな差異があり、共通インターフェースの方で吸収する必要がありました。

Datadogの実装でOtel SDKを直接使う方を採用すれば、共通インターフェースを用意する必要がなく、以下のようにInitTracerは初期化の条件分岐のみを書いた実装が可能です。

func InitTracer(env string) (*sdktrace.TracerProvider, error) {
    switch env {
    case "dev":
        return initDatadog()
    default:
        return initJaeger()
    }
}

またddotelのライブラリにはadded to the OpenTelemetry API will default to being a no-op until implemented by this library.のコメントがあり、新機能の利用にはGoのOtel SDKではなくDatadog Agent側での実装を待つ必要がありそうでした。

そのため、Datadogの独自機能を重視する場合はddotelを採用し、Otelへの準拠を重視する場合はOtel SDKで実装するなど、実装の際に意思決定が必要になりそうです。

また、細かなマッピングの違いも存在しそうなため、Traceを使ってモニタリングアラートを組んでいる場合は注意して切り替える必要がありそうです。(例えばDatadogからNew Relicに変更する場合など)

商用のツールでは料金体系なども異なるため、仕様が同じでもサンプリングレートの値の調整が必要になることもあるでしょう。

まとめ

OpenTelemetryを活用することで、トレーシング実装でより一貫性のあるコードを実現できることがわかりました。

これにより、環境に応じて適切なObservability Backendを利用したり、移行することが容易になったりするのは間違いなさそうです。

一方で標準仕様についてはまだまだ理解が浅いので、OpenTelemetry自体の理解も深めて、さらに楽しいエンジニアライフを送りたいと思います!またね〜

Majestic Monolith, そして Citadel

こちらは Timee Product Advent Calendar 2024 の9日目の記事です。前日は平岡の スクラムマスターが常に意識するべき重要なこと でした。

タイミーでテックリードをしている @euglena1215 です。

最近、Majestic Monolith と Citadel というアーキテクチャ・考え方を知ったのですが、あまり国内では認知度が高くないように感じたので紹介してみたいと思います。

自分が見つけた日本語での記事は モノリス亜種のアーキテクチャ(Modular MonolithとかMajestic MonolithとかCitadel Architectureとか)Rails: AppSignalが採用する「シタデルアーキテクチャ」(翻訳)|TechRacho by BPS株式会社 の2記事のみでした(※記事執筆時点)。

Majestic Monolith

Photo by Gary Walker-Jones on Unsplash

signalvnoise.com

Majestic Monolith の出典は Ruby on Rails の作者である DHH のブログです。2016年に書かれていてすごい。

正確な内容は出典を参照していただくとして、ここでは自分の解釈をまとめてみます。

  • 世間では「モノリスアーキテクチャ」は劣ったアーキテクチャのような評価を受け、「マイクロサービスアーキテクチャ」が優れているような評価を受ける。
  • Amazon や Google、あるいは何千人もの開発者がいるような会社にとってはマイクロサービスアーキテクチャは素晴らしいアーキテクチャだが、「成功している企業で効果があったのだから、我々にも効果があるはずだ」と思い込んで小さな会社がマイクロサービスアーキテクチャを目指すのは間違い。
  • 巨大な一握りの会社以外はモノリスを受け入れ、意図を持ってモノリスを雄大(majestic)に設計しよう。

モノリス自体が悪いと捉えてしまうと、それを解決するためにはモノリスから脱する以外の道はありません。モノリスをまず肯定することによって、初めて自分たちが抱えている様々な技術的課題の輪郭が見えてきます。

もちろんその中にはモノリスを分けなければ解決できない課題もあると思いますが、そうでないものも含まれていると思います。まずモノリスを肯定することによって、様々な技術的課題の解像度を高めることができる示唆に富んだ考え方だと感じています。

Citadel

Photo by K. Mitch Hodge on Unsplash

signalvnoise.com

こちらも出典は Ruby on Rails の作者である DHH のブログです。2020年に書かれています。

  • 多くのケースで Majestic Monolith はうまく機能するが、パフォーマンスや可用性の問題でモノリスでは対処が難しい課題が出てくることもある。
  • その際の次のステップは Citadel。Citadel とは、モノリス(Citadel = 城塞)を中央に据えて周辺に必要に応じてモノリスを補完するようなサービス(Outpost = 前哨基地)を配置する考え方。
  • Outpost としてモノリスとは異なる特性(パフォーマンス上の問題を対処、組織上、実装上など理由は様々)のサービスが提供できれば、アプリケーションの残りの部分は Majestic Monolith として提供し続けられる。

計らずもこの構成になっているプロダクトは多いのではないのでしょうか。タイミーのそのうちの1つです。

Citadel という名前をつけたことで、別物だと感じていた他プロダクトの構成にも共通点を見出せるようになった気がします。DHH の命名の妙だと言わざるを得ません。

状態ではなく、事象に注目している

ソフトウェアアーキテクチャの文脈でよく登場するモノリス・マイクロサービス・モジュラモノリスといった分類は、あくまで「こういった状態のシステムはこのような特性を持っている」というカタログでしかありません。

理解し把握しておく分にはとても有用ですが、「(ここに任意のアーキテクチャ名が入る)化」をしたから我々が直面している様々な技術的課題が解決できるかというと、なかなか難しい面もあるかと思います。

ですが、DHH の提唱する Majestic Monolith にも Citadel にも、現場で起きている事象(問題)に対してどのように対処をしていくかという地に足のついた指針のようなものを感じました。

  1. アーキテクチャに囚われず、きちんと目の前で起きている技術的課題を見定めよう。
  2. 目の前で起きている技術的課題を見定めたら、必要十分な対処をしよう。

隣の芝生は青いということわざがあるように、自分たちが選択していないアーキテクチャが良く見えてしまうのは人間の性だと思います。Majestic Monolith, Citadel を胸に誘惑に負けないよう努力し続けたいと思いました。

 

明日は @MoneyForest の「OpenTelemetryで環境ごとにObservability Backend(Jaeger、Datadog)を切り替えてエンジョイしてみたよ」です。お楽しみに!

学びと経験のアウトプットをしよう!

Timee Advent Calendar 2024 6日目の記事です。

タイミーでスクラムマスター(以下、SM)/アジャイルコーチを担当している正義です!

この記事では

  • 学習したことや学んだことは、どんどんアウトプットするといいよ!
  • どのようなアウトプット方法があるのか?

というお話をします。

なぜアウトプットをするのか?

1. 自身への定着

それは「学習に対する能動性を向上し、自身の記憶としてより定着させるため」です。
よく見かけるラーニングピラミッドのように、能動的な活動になるにつれて定着しやすくなります。

また、活動によって学習に対するアプローチの深さが変わってきます。 https://www.mext.go.jp/b_menu/shingi/chukyo/chukyo3/004/siryo/__icsFiles/afieldfile/2015/09/04/1361407_2_4.pdf

必ずしも一定の深いアプローチをしていれば身に付くわけではなく、学習する内容によってアプローチの手法は変わってきますし、効果的に定着するかはアプローチとの相性によっても異なります。

学習を定着させるために重要なのは「多角的に、能動的に学ぶこと」です。 そのため、色々な学習に対するアプローチを積極的にしてみることで、自身への学びをより効果的にすることができます。

2. 自身の活動のログとなる

自分の学習・経験をログとなる形で残すことで、自分自身で振り返ることができます。 また、その内容は他の人にシェアできたり、自身の今後の評価材料にもできます。

どのような学びのアウトプット方法があるのか?

1. ブログでの発信

ブログでの発信はじっくり考えを整理でき、任意のボリュームでの記述が可能です。 また、図・表・写真など、視覚的な情報も載せられます。
私の場合、ブログの記事を最後まで書くのは意外とパワーが必要となってしまうので、「やるぞ!」という気合がないとどうしても後回しにになって、投稿が遅れがちになります。
同じような状態に陥りがちな方は、期日を設ける/体験した翌日には投稿するなどの個人的なルールを設けることをおすすめします。

https://note.com/rakuraku_justice/

2. Xでの発信

Xでの発信は文字数が限られているため、知見や考えたことの要点のみをまとめることに役立ちます(課金していない人限定)。文量が少ない分、ブログよりも気持ちのハードルを下げて投稿できます。 また、図・表・写真など、視覚的な情報も載せられます。

私の場合はカンファレンスなどで得た情報をサクッとメモがわりに投稿しています。 その情報を元に、あとあと見返してブログなど他のアウトプットにつなげたりしています。

3. カンファレンスプロポーザルでの発信

主にRSGTやスクラムフェスなど、特定のカンファレンスに限られるかもしれませんが、プロポーザルを募集していたりするので、テーマに沿った内容を記載して発信します。

ブログと違う点は、完全にフリーな場ではなくカンファレンスごとに、ある程度テーマや書き方が定まっていることです。

私の場合は、カンファレンスを一つの区切りとして、そこまでに得た知見と関連する経験を記載して投稿してみることが多いです。 (登壇につながれば、さらなるアウトプットもできる!)

いざ登壇したいとなっても、うまく書けなかったり、書く内容が思い浮かばなかったりするので、ひとまずカンファレンスプロポーザルは書けそうなら書いてみることをおすすめします。

https://confengine.com/conferences/scrum-fest-osaka-2022/proposal/16594

4. 登壇する

カンファレンスやコミュニティイベントでのLT会などの場で、登壇できるならば登壇してみましょう!

登壇による学習の効果は高いと考えています。資料をまとめるだけでも一つのアウトプットですし、それを自身の言葉で発表することは更なるアウトプットや情報の整理につながります。

また、Q&Aの時間で質問してもらえたらラッキーで、瞬時に色々と考えた上で自分の言葉にする経験を得ることができます。

このように登壇では複数のアウトプットを体験できるので、機会はどんどん獲得してたくさん登壇しましょう!

5. Podcastなどのラジオ形式での配信

1人もしくは数名でラジオ形式の収録を行い、編集と配信をします。

個人的には、一番アウトプットが楽に感じています。一緒に収録してくれる人がいれば、テーマに対し議論しているだけでアウトプットにつながります。

編集は少し時間がかかります(30minのラジオに対して1hほど)。しかし、失敗したりおかしいと感じたりする部分はカットできるので、安心して収録できます。

ブログを書くよりも準備や考えることが少なく始められるので、おすすめです!

https://creators.spotify.com/pod/show/yoriyokufm

他にも色々なアウトプット方法がある

  • エンジニアやデザイナーであれば業務外において趣味で創作をする
  • 仕事で得た知識を別の場で活用してみる
  • 1on1や数名の場で議論をしてみる

一つの形に絞るのではなく、色々な形式を試してみることで自分に合う形式を見つけられるかもしれません。

最後に

臆せず、アウトプットすることが大切です。

「自分が話してもすごい経験があるわけでもないし…」

「誰かがすでに記事にしていそうだし…」

と、考えてしましい、アウトプットをしないのはもったいないです。

あなたの経験には、必ず価値があり、誰かの役に立ちます。

まずはどんな形でもよいので、発信してみて自分の学習のためのアウトプットにチャレンジしてみましょう!

いつかそれが誰かの役に立っていると気づく日がきっときます!

私のTimee Advent Calendar 2024 Day 6は終わりです!
引き続き、明日の記事も是非読んでみてください!

スクラムマスターとリーダーとプロジェクトマネジメント

Timee Advent Calendar 2024 6日目の記事です。

タイミーでスクラムマスター(以下、SM)/アジャイルコーチを担当している正義です!

この記事では

  • SMに求められるリーダーの性質は何か
  • SMもプロジェクトマネジメントに関する知識を得ておくと良い

というお話をします。

スクラムマスターに求められるリーダーの性質は色々ある

SMはどのようなリーダーであるべきか、色々な観点で話されているのを見かけます。 スクラムガイドから読み取ろうとしたり、実践ベースで考えたり、学術的な観点などがあります。

(過去に弊社SMが登壇した際の資料もご紹介します!) https://speakerdeck.com/shinop/practical-scrum-master-vs-theoretical-scrum-researcher-d29104ff-15ed-4fcc-a0e9-9e0bef2a0d3a

私も数年間SMを担当してきたので、自分なりに考えていることをお話ししてみたいと思います。

スクラムマスターはどんなリーダーなのか?

「スクラムマスター」というロールはスクラムガイド(2013、2017、2020年版)で定義されているので、そこから見ていきましょう。

まずは2013年版から。

スクラムマスター

スクラムマスターは、スクラムの理解と成立に責任を持つ。そのためにスクラムマスターは、スクラムチームにスクラムの理論・プラクティス・ルールを守ってもらうようにする。

スクラムマスターは、スクラムチームのサーバントリーダーである(訳注:メンバーが成果を上げるために支援や奉仕をするリーダーのこと)。 スクラムマスターは、スクラムチームとやり取りをするときに役に立つこと/立たないことをスクラムチームの外部の人たちに理解してもらう。スクラムマスターは、こうしたやり取りに変化をもたらすことで、スクラムチームの作る価値を最大化する。
このように、明確にリーダーのスタイルがサーバントリーダーとして説明されています。

そして責任についても、「スクラムの理解と成立に責任を持つ」とあります。そのために、理論・プラクティス・ルールをチームに落とし込み、チーム外の人々に対してスクラムチームを理解してもらう活動が中心に記載されています。
つまり、大切にすべき事柄をチームが会得し、実践していくための環境・システム作りに重きを置いていると捉えました。

続いて2017年版から。

スクラムマスター

スクラムマスターは、スクラムガイドで定義されたスクラムの促進と支援に責任を持つ。スクラムマスターは、スクラムの理論・プラクティス・ルール・価値基準を全員に理解してもらえるように支援することで、その責任を果たす。

スクラムマスターは、スクラムチームのサーバントリーダーである(訳注:メンバーが成果を上げるために支援や奉仕をするリーダーのこと)。 スクラムマスターは、スクラムチームとやり取りをするときに役に立つこと/立たないことをスクラムチームの外部の人たちに理解してもらう。スクラムマスターは、こうしたやり取りに変化をもたらすことで、スクラムチームの作る価値を最大化する。

「スクラム」が「スクラムチーム」に変更されていますが、記載されている内容については大きな変更はありません。
責任については「SMは、スクラムガイドで定義されたスクラムの促進と支援に責任を持つ」とあります。「成立」ではなく「促進と支援」に変わったのは、スクラム成立はSMだけが責任を負うわけではなく、スクラムチームで進めていくことを意識付けているように感じました。
活動自体は、チームに落とし込む項目に「価値基準」が足されてはいますが、大枠では2013年からの変化はありません。引き続き2013年と同様の事柄に重きを置いていると解釈しました。

そして2020年版

スクラムマスター スクラムマスターは、スクラムガイドで定義されたスクラムを確⽴させることの結果に責任を持つ。スクラムマスターは、スクラムチームと組織において、スクラムの理論とプラクティスを全員に理解してもらえるよう⽀援することで、その責任を果たす。 スクラムマスターは、スクラムチームの有効性に責任を持つ。スクラムマスターは、スクラムチームがスクラムフレームワーク内でプラクティスを改善できるようにすることで、その責任を果たす。 スクラムマスターは、スクラムチームと、より⼤きな組織に奉仕する真のリーダーである。

これまでとは異なる点が増えています。
「責任」に触れている部分(ほぼ全体)。

  • スクラムマスターは、スクラムガイドで定義されたスクラムを確⽴させることの結果に責任を持つ。
  • スクラムマスターは、スクラムチームと組織において、スクラムの理論とプラクティスを全員に理解してもらえるよう⽀援することで、その責任を果たす。
  • スクラムマスターは、スクラムチームの有効性に責任を持つ。
  • スクラムマスターは、スクラムチームがスクラムフレームワーク内でプラクティスを改善できるようにすることで、その責任を果たす。

責任の内容とその果たし方がセットで書かれています。

注目したい点の一つは「スクラムガイドで定義されたスクラムを確⽴させることの結果に責任を持つ」です。今までの「促進と支援」よりその先の変化、つまり結果にフォーカスしています。
また、「スクラムチームの有効性に責任を持つ」という点が一番大きい変化だと捉えています。スクラムチームの有効性、つまり結果に対し責任を持つことが明確に記載されました。

以前はスクラムの定着がSMの役割でしたが、スクラムが効果的に働き、有効である、結果を出すことがSMに求められるように変化しました。スクラムはあくまで方法であり、大前提となる目的を達成することとSMがそこに意識を向けるために責任が明文化されたと考えています。

そしてリーダーについて言及している部分は「スクラムマスターは、スクラムチームと、より⼤きな組織に奉仕する真のリーダーである」です。
これまでに色々な議論のきっかけになっている「真のリーダー」、これはとても抽象的なので様々な解釈があると思います。
継続して「奉仕する」が使用されているので、これまでのサーバントリーダーは引き継いでいるように見えます。しかし、サーバントリーダーが明示的に記載されなくなったのは、それ以外にも求められるリーダーとしての役割、リーダーシップが必要であることを示唆しているようです。
パス・ゴール理論の4つのリーダータイプではありませんが、

  • サーバントというスタイルで、チームの自律性を促し、支援するリーダー
  • 自身が先導し、チームがゴールへ到達することを導くリーダー

という形で、自分でチームを引っ張り、目的にコミットする形も必要だと考えられます。 (結果に対し責任を持つということは、こちらに繋がってきます)

スクラムマスターが向き合う”結果”とは?

「結果」についての分解方法としては「直接的な結果」と「間接的な結果」があります。

  • 直接的な結果:ある役割や行動が、最終的な成果やアウトプットに対して直接的に影響を与える場合を指す
  • 間接的な結果:ある役割や行動が、最終的な成果やアウトプットに直接関与せず、それを実現するためのプロセスや環境を整備する役割を果たす場合を指す

開発においては、下記のようになります。

項目 直接的な結果 間接的な結果
対象 成果物などアウトプット プロセスや環境
影響の範囲 短期的で即時的 長期的で持続的
測定のしやすさ 測定可能(例:完成した機能の数) 測定が難しい(例:チームの生産性)
成果との距離 成果に直接結びつく 成果を間接的に支える

SMはどちらの結果に向き合うべきでしょうか。 その答えはどちらか一方ではなく、アジャイルソフトウェア開発宣言のように「左記の結果を求めながら右記の結果に重きを置く」と考えています。

スクラムガイドでは、「スクラムマスターは、スクラムチームがスクラムフレームワーク内でプラクティスを改善できるようにすることで、その責任を果たす」とあります。これはスクラムチームに対して、間接的な結果および責任を果たすことに向き合うことを意味します。

しかし、SMは直接的な結果について完全に無関心でいることはできません。直接的な結果(品質、納期、成果物など)は、間接的な責任を果たす上での重要な指標であり、SMが仕事を効果的に遂行するためには、直接的な結果にどのように影響を与えるかを理解し、それを考慮する必要があります。

スクラムマスターがプロジェクトマネジメントを学ぶ必要性について

SMはチームが自律的に活動していけるように、「皆さんはどうしたいですか?」を問う姿が多く見られます。 しかし、チームに問いかけ続けていれば全て問題なく進められるかというと、そうではありません。

チーム内で補える観点や知見に基づいてチーム内で考えることもできますが、それ以上の観点や知識はティーチングを主要なメタスキルとするSMが補う必要があります。

(ラーメンを構成する各材料の作り方をチームが知っていても、合わせて「美味しいラーメン」という成果物にする方法を知らなければ、想定よりもぬるかったり美味しくないラーメンができてしまうイメージ)

SMがプロダクト開発に紐づく全てのことをティーチングできれば良いのですが、スクラムガイドやスクラム・アジャイルに関する知見のみでは難しいです。 スクラムガイドはスクラムを定義しており、スクラムは軽量級のフレームワークです。スクラムガイド自体も目次や用語集などを含めて全17ページ程度の少ないボリュームです。 スクラムにおける重要な情報が厳選され、抽象化して記載されていて、プロダクト開発をチームで進める上で必要な考え方や観点を全て網羅しているわけではありません。 そこで、比較的SMにとって親和性が高く、知っておくと活用しやすい知識は「プロジェクトマネジメント」だと考えています。 理由はシンプルに、「そのプロダクト開発は、いつ・どうなったら終わりなのか?」という説明責任をSMにも果たしてほしいからです。

チームが自己管理できるよう、チームの自律性を高めることを主軸にして意識していくSMだからこそ、上記の質問には答えられる必要があると考えています。 チームが自分たちで考えられる環境を求めていく一方で、チームに対してティーチングとコーチングを適切に行っていくためには、SM自身がプロダクト開発の完遂に対して必要な知見と考え方を自分で説明できるようになっていてほしいです。 そうでないと、プロダクト開発およびプロジェクトを前に進めるための適切なWhyの説明ができず、チームにとってひたすら自身の思い浮かぶプラクティスを推進しようとする人になってしまうのでしょう…(経験談)

プロジェクトマネジメントから得られる観点を取り入れて知的創造を果たす

「そのプロダクト開発は、いつ・どうなったら終わりなのか?」という説明責任を果たすことは、一つのリーダーシップの体現です。

これはモノづくりをする上で行う2つの創造:知的創造と物的創造のうちの知的創造に該当します。 書籍:スティーブン・R.コヴィー『完訳 7つの習慣 人格主義の回復』(キングベアー出版)p.155 第二部「私的成功」第2の習慣「終わりを思い描くことから始める」より

簡単に説明すると、物的創造は定められた方向に向かって限られたリソースを活用し効率よく業務を遂行するための活動、つまりマネジメントと実行責任の完遂を意味します。 一方、知的創造は組織が向かう方向を定め、「終わりを思い描く」「ゴールを決める」活動となります。つまり、リーダーシップとそれに伴う説明責任の完遂です。 (ここでいう「終わり/ゴール」には抽象的なビジョンやミッションではなく、プロダクト開発のゴール、プロジェクトの終わりを当てはめています) プロダクト開発のSMが示す一つのリーダーシップであり、「自身が先導し、チームがゴールへ到達することを導くリーダー」に求められます。

では、どうやったらプロダクト開発における「終わりを思い描く」「ゴールを決める」という知的創造を果たすことができるのか? そのための一つとして、「プロジェクトマネジメントを学ぶ」を私はおすすめしているのです。

プロジェクトマネジメントのすべてをここで紹介するのは非常に大変なので、知っておくと良い点として一つ挙げるとPMBOK第6版に記載されている「10の知識エリア」があります。 注:PMBOKは2021年に第7版が出版されています。第6版では日本語版で約780ページあったものが、第7版では約370ページになっています。また、内容としてもプロジェクトマネジメントの手順をまとめたものから、プロジェクトの方針や考え方といった原理・原則をメインにした構成になっています。今回は、観点として具体的に分かりやすくリストアップされている第6版の内容を記載します。

10の知識エリア

プロジェクト統合マネジメント プロジェクトやフェーズをどのように進めるのかを定義する知識エリア
プロジェクト・スコープ・マネジメント プロジェクトやフェーズにおける作業範囲や、成果物の設定に関して定義する知識エリア
プロジェクト・スケジュール・マネジメント 納期管理に関する知識エリア
プロジェクト・コスト・マネジメント プロジェクトで承認された予算に関する知識エリア
プロジェクト・品質・マネジメント 生成される成果物やプロジェクトの品質に関する知識エリア
プロジェクト・資源・マネジメント メンバーなどの人的資源や、物的資源などの管理に関する知識エリア
プロジェクト・コミュニケーション・マネジメント 会議予定を調整し、適切にコミュニケーション内容や方法を管理する知識エリア
プロジェクト・リスク・マネジメント プロジェクトにおけるリスクの特定・分析・対応方法、対応策の実行、リスク監視に関する知識エリア
プロジェクト・調達・マネジメント 契約終結やベンダーの管理に関する知識エリア
プロジェクト・ステークホルダー・マネジメント ステークホルダーの関与度の定義や管理に関する知識エリア

すべてを満遍なく検討してマネジメントしましょう、というわけではありません。 ただ、これらの項目について考えてみることで、プロダクト開発をスクラムで行おうとしている現場において、スクラムガイドに記載されていない観点を補うことができると考えます。

例えば、プロジェクト・ステークホルダー・マネジメント。スクラムガイドでは、「ステークホルダー」が誰なのかをはっきりと定義していません。それはプロダクト開発におけるステークホルダーは多く存在しますし、それぞれのステークホルダーに対しての期待や重要度は常に一定ではないからでしょう。

そのため個々のプロジェクトにおいては、チームが関わるステークホルダーは誰なのかを明確にし、ステークホルダーとの関わり方を定義しないと、チームの動き方や目指すことは容易にぶれてしまいます。

ステークホルダーマネジメントでよく見るのは、影響度合いと関心度合いを2次元で表現した次の図です。

ここに、チームが関わるプロダクト開発において関連するステークホルダーを配置します。 そうすることで、ステークホルダーの洗い出しと対応方針をチームで見える化し、透明性を高めることができます。

さらに、ステークホルダーに対しての具体的な関わり方を定義していくことで、プロジェクト・コミュニケーション・マネジメント(どのような場で何を議論すれば良いのかを定義し、チームにとって必要なコミュニケーションを適切に管理する知識エリア)の一部を満たすことができます。

また、チームの中からは意識しづらいが、じつは重要なステークホルダーも存在します(例えばCTOやCPOなど予算を握っている人やチーム)。そのようなステークホルダーに対しても意識を向ける視座をSMが獲得していて、チームに気づかせることができれば、必要な報告などを行うことができるようになり、急なプロジェクトの方向転換の回避などリスクマネジメントにつながる可能性もあります。 それは、SMの支援に記載されている項目の一つ、「スクラムチームの進捗を妨げる障害物を排除するように働きかける」という項目に該当します。

このように、プロジェクトマネジメントの要素を知り、観点として身につけていくことで「スクラムガイドには記載されていないが、プロダクト開発を行う上で知っておくべきこと」を補えるようになります。 そしてその知識を活用し、チームが向き合う開発が「いつ・どのように終わるのか?」をSMなりに考え、チームに適切なティーチングを行いつつ、プロダクト開発にとって必要なことをチームで考えていける環境を作ることがSMのリーダーシップの体現だと私は思います。

最後に

プロダクト開発において、知っておかないといけないこと、チームで話した方がよいことはとても多いです。チームがそのすべてを足並み揃えて一つずつ学び、ディスカッションを行い意思決定できると、チームにとって学びを多く得られて良いのかもしれませんが、プロダクト開発という点においては、その動きが最適とは限らない状況もあります。
そのため、プロダクト開発を進める上で必要な様々なことに優先順位をつけて考え、その上でスクラムの理論とプラクティスを無理なくチームに適用し、徐々にチームの自律性を高めつつ、時には自分がプロダクト開発をリードしていけるSMでありたいです。

以上、約2年間で考えていた自分のSM像を少し言語化してみました! SMのあり方に正解はありませんが、一つのイメージとして誰かの参考になればいいなと思っています。

それでは、私のTimee Advent Calendar 2024 Day 6は終わりとなります。

明日は、SMのしほりんさんのターンですので、お楽しみに!

DuckDB を使ったデータ品質保証の実践

この記事は Timee Advent Calendar 2024 シリーズ 1 の5日目の記事です。

はじめに

こんにちは。タイミーの DRE チームの chanyou です。2024年の3月に DRE チームにジョインして、社内のデータ基盤を作って運用しています。

DuckDB を使ってデータ基盤で扱うデータの品質を保証し始めたので、その内容をご紹介します。

データ品質と完全性

タイミーのデータ基盤で重視しているデータ品質

タイミーでは、DMBOK を参考に以下のデータ品質を重視して設計や日々の運用を行っています。

 特性 意味
完全性 データが欠損していないか
適時性 必要なときにすぐにデータを参照できるか
一意性 データが重複していないか
一貫性 型・タイムゾーン・表記揺れなど、値の書式や意味が統一されているか

今回は完全性にフォーカスします。

完全性が損なわれるタイミング

上記の通り、完全性とは「どの程度データに欠損があるか」を意味します。

データの欠損は、主にデータ転送時に生じる場合が多いです。例えば、以下のようなケースが考えられます。

  • あるテーブルが転送対象から外れてしまっていた
  • 転送元のシステムに追加されたカラムが対象に含まれていなかった
  • パーティション分割された Parquet ファイルのうち、一部のファイルしか転送できていなかった

従来の完全性テストの実施方法

完全性を保証するということは、欠損がないことを保証することと同義です。欠損が生じやすい転送前後のデータを比較することで、欠損の有無を検知できます。

欠損を検知する仕組みのことを、タイミーでは「完全性テスト」と呼んでいます。

表形式のデータに対して厳密に完全性テストを実施するには、セル単位で比較を行う必要があります。

これまでは計算コストがかかるため、統計量を比較する手法を取っていました。

近似的に比較していたため、全レコード全カラムに対して欠損が全くないことを厳密に保証できない課題がありました。

詳細は昨年のアドベントカレンダー記事をご覧いただければ幸いです。

刷新した完全性テストの実施方法

今回のケース

刷新のきっかけとなったケースについて説明します。

S3 にある Parquet ファイルを BigQuery にロードする、非常にシンプルなケースでした。

S3 の Parquet ファイルと転送後の BigQuery テーブルのデータが完全に一致することを保証する必要がありました。

Parquet ファイルと BigQuery のデータ比較のためにスクリプトを実装しました。その内部のクエリエンジンとして DuckDB を採用して、セル単位の厳密なデータの比較に対応しました。

DuckDB を採用した理由

BigQuery 内のテーブル同士であれば BigQuery のクエリで完結しますが、データベースをまたいだ完全性テストは BigQuery の外側でデータの比較をする必要があります。

DuckDB は高いパフォーマンスを維持しながら、複数のデータソースに対して同様にクエリをかけることが可能で、今回のケースに非常にマッチしていました。

データの比較も EXCEPT 句が利用可能で、簡潔なクエリで表現可能でした。

他の選択肢として Pandas や Polers などの DataFrame インターフェイスのツールも候補に挙がりますが、依存モジュールのメンテナンスコストが一定かかるため、今回のケースではシングルバイナリでより手軽に実行環境を整備しやすい DuckDB に軍配が上がりました。

以上の理由で DuckDB を採用しました。

具体的な実装方針

S3 の Parquet ファイルの読み込みについては DuckDB が標準で対応しているため、DuckDB の read_parquet() 関数で簡単に読み込むことができます。

BigQuery に対するクエリは、後述の理由により BigQuery から GCS に Parquet ファイルとして出力を行い、 GCS の Parquet ファイルを DuckDB から読み込むことで対応しました。

DuckDB の EXCEPT 句を使って、片方のテーブルにしか存在しないレコードを抽出するクエリを実行します。以下がクエリの例です。

WITH source AS (
    SELECT * FROM read_parquet(getenv('source_path'))
    ORDER BY id
),
destination AS (
    SELECT * FROM read_parquet(getenv('destination_path'))
    ORDER BY id
)
SELECT
    'source' AS _location,
    *,
FROM (
    SELECT * FROM source
    EXCEPT
    SELECT * FROM destination
)
UNION ALL
SELECT
    'destination' AS _location,
    *,
FROM (
    SELECT * FROM destination
    EXCEPT
    SELECT * FROM source
);

source だけあるレコードと destination だけにあるレコードを抽出して、連結して出力しています。

事前に DuckDB の Secret Manager で各クラウドリソースへの認証情報を設定する必要がありますが、それだけで上記のようなクエリでセル単位の厳密な完全性テストが可能となりました。

これらを実行するシェルスクリプトを実装して、Docker コンテナにまとめて実行環境に展開しました。

よかったところ

複数のデータソースに対するクエリが、非常に簡単に実行できた

ローカル、S3、 GCS のどこにデータがあっても、 read_parquet() で読み込めるのは非常に体験がよかったです。

パフォーマンスが高く安定して実行できた

従来の完全性テストから実行環境が変わったため実行時間の比較ができないのですが、刷新後は 100GB 程度の Parquet ファイルの完全性テストが IO 含めて10分以内に実行できています。

デイリー程度の転送頻度であれば毎回実行しても差し支えない実行時間で、全く問題ありませんでした。

詰まったところや工夫したところ

BigQuery Community Extension で読み取れないカラムがあった

当初 DuckDB の BigQuery Community Extension を使って、BigQuery に直接クエリを実行しようとしていました。

大半のデータには問題なく使えたのですが、一部の文字列型のフィールドで読み取れないカラムがありました。

エラーメッセージがなく、読み取れなかったカラムが ORDER BY で結果の順序を変えると読み取れることがあるなど、原因特定から難航しそうなので今回のケースでは Community Extension の使用は見送りました。

BigQuery 側のログではちゃんとクエリが走っていたので、 DuckDB での処理のどこかでコケてしまっていたようでした。時間があるときに内部実装を追って、修正できそうであれば PR を送りたいと思います。

jsonlines モードと jq の組み合わせが楽だった

DuckDB には csv, json, html などの出力形式が多数あります。

今回はシェルスクリプトで DuckDB の結果を扱いたかったため、 jsonlines で出力したうえで jq で結果を処理するのが簡単でした。

柔軟に出力を切り替えられるので、あらゆるスクリプトで利用しやすいと思います。

まとめ

完全性テストを DuckDB を使って実施する内容をご紹介しました。DuckDB を使うことで、手軽にマルチクラウドな環境においても厳密な完全性テストを行えました。

DuckDB は非常に魅力的ですが、分析用途での DuckDB はガバナンスを効かせながら運用することが難しく、現状は社内で広く使ってもらうには様々なハードルがあるように思います。

一方で今回のデータテストのように、スクリプトの内部で利用するには統制を取りやすく、非常に相性がよいように感じました。分析用途の場合は DuckDB のステートを同期し続ける必要がありますが、テストの場合は同期が不要で揮発しても問題なく、カジュアルに DuckDB を使いやすかったです。

またシングルバイナリで環境整備も非常に簡単な点も運用しやすく、他のデータテストでも機会があれば利用を検討したいと思いました。

他にも dbt で CI 実行するときに、 DuckDB アダプタに切り替えることでコストを圧縮できそうです。 CI やスクリプト用途における DuckDB の活用の余地がまだまだありそうで、今後も模索したいと思いました。

AndroidアプリでFirebase Remote Config を使ったABテスト実装時に遭遇した罠(不具合事例紹介)

こんにちは!タイミーでAndroidエンジニアとして働いている  @orerus ことmurataです。今回は弊社のアプリ開発チームで経験した、Firebase Remote Config(以下 RemoteConfig)を使用したABテスト実装時のトラブルと、その再発防止策について共有いたします。

はじめに

モバイルアプリ開発において、ABテストは機能改善の効果を測定する上で重要な手法の一つです。今回は、私たちが実装したABテストで発生した予期せぬ動作と、そこから学んだ教訓についてお話しします。

おことわり

なお、今回の話はRemoteConfing自体に問題があるというものではなく、使用方法が適切でなかった為に発生した事象ですのでご留意ください!

発生した事象

実装内容

今回の事象のきっかけとなったのは、アプリ起動時にまず表示される「さがす画面」において、検索結果のソート順切り替え機能のABテストの実装でした。

リリース後は何事も問題なく動作していたのですが、しばらく経ったある特定のタイミングで同時に複数の不具合報告が挙がりました。

報告が挙がった不具合内容は「ソート順がおかしい」「検索結果が表示されない」といった事象でした。

以下は、実際に不具合が発生していた時の画面の例です。 アプリを開いた直後に表示される一番大事な検索結果の部分が空っぽになってしまっていますね・・・。

問題の具体に入る前に、ここでこの「さがす」画面の構成について簡単に説明します。

画面構成

こちらの画面の構成としては、画面上部のカレンダーを含む画面全体、および画面下部の検索結果が表示されている部分とで異なるFragmentが使用されています。(前者をCalendarFragment、後者をResultFragmentと仮称します)

検索結果を表示するResultFragmentは日付ごとに存在しており、ViewPagerにて管理しています。

カレンダーから日付が選択されると、その日付の検索結果を表示するためのResultFragmentが生成され表示されます。

ABテスト制御

ABテストの制御にはRemoteConfigを使用しており、CalendarFragment、ResultFragment、それぞれのFragmentが生成されるタイミングでRemoteConfigからソート機能のON/OFFのConfig値をそれぞれのFragment内に保持して使用しています。

不具合発生!!!

平穏に暮らしていた中で突如同時多発的に不具合報告が挙がったタイミング、それがABテストのロールアウトを行った時でした。

この時は全ユーザーに対してソート順切り替え機能をONにするロールアウトを行いました。

その結果、先述した「ソート順がおかしい」「検索結果が表示されない」といった不具合が複数のユーザーから報告されました。

もちろんすぐに原因調査を行いましたが、手元の環境では事象がなかなか再現せず困っていたところ、ABテストのロールアウトを行っていたことを思い出しRemoteConfigから取得したConfig値の利用箇所周りを重点的に調査した結果、その利用方法に問題があり先述のような不具合が発生する可能性があることが判明しました。

以下、その不具合発生に至った原因について解説します。

原因分析

1. RemoteConfigから取得したConfig値をFragment毎にキャッシュしていた

画面が破棄されるまでの間にConfig値が変化したとしても突然画面内で機能が変化しないように、Fragment生成時にRemoteConfigからConfig値を取得し、インスタンス変数にキャッシュしていました。

このキャッシュのやり方は画面内で1箇所のみでしか行われない場合には問題が生じませんが、画面内に複数のFragmentがある場合に不整合を生じさせる余地が発生してしまいます。

とはいえ、それだけなら不整合が発生する確率は低かったのですが、次の原因がその確率を大きく引き上げてしまいました。

2. Fragmentの生成タイミングの違い

先述した通り、この画面には複数のFragmentが存在しており、またそれぞれ生成タイミングが異なります。

  • CalendarFragment
    • 「さがす画面」が表示されたタイミングで生成される
  • ResultFragment
    • 日付が選択されたタイミングで生成される

この生成タイミングの違いにより、現実的に起こり得るケース(例えば「さがす画面」のままアプリが長時間バックグラウンドになっており、復帰後に日付が再選択されたケースなど)でCalendarFragmentおよび複数日付のResultFragmentの間でConfig値に不整合が発生してしまいました。

細かなロジックは省略しますが、この不整合が引き金となり冒頭で紹介したような不具合が発生していました。

なお、今回の事象が防げなかった原因がもう一つあります。

3. QAカバレッジの不足

今回のABテストについてももちろんQAを行っていたのですが、以下の観点が意識されておらずテストケースから見落とされてしまっていました。

  • アプリ起動中のRemoteConfigの値更新
  • Config値を複数箇所で保持することによるFragment再生成時の不整合発生の可能性

一度起きてしまえば「何故気づかなかったのだろう」と思えるようなシンプルな原因ではあるのですが、「画面が破棄されるまでは同じConfig値が使われる」という思い込みが気づきを遠ざけてしまっていました。

再発防止策

再発防止策として以下の取り組みを実施しました。

1. QAプロセスの改善

QAチェックリストのテンプレートに、RemoteConfigを用いたABテストやFeatureFlagの実装時はアプリを生存させたまま値を動的に更新するテストケースを実施する旨を追加しました。

2. RemoteConfigに関するデバッグ機能の拡充

RemoteConfigは内部でキャッシュされており、先述の動的なConfig値の更新のQAを行うことが困難であった為、デバッグ時にConfig値を容易に変更できる機能を実装しました。

3. ActivityやFragmentでのConfig値のキャッシュを止める

こちらは今後の話になりますが、ActivityやFragmentでConfig値をキャッシュすると類似の問題が発生する可能性がある為、画面(または一連の機能)を構成する単位で必ず同一箇所のConfig値のキャッシュを参照するような構成への変更を検討しています。例えば、画面(または一連の機能)から参照される共通のViewModel内でのキャッシュを考えています。

なお、アプリの起動中は全てのConfig値が変化しないようにするという選択肢も考えられますが、FeatureFlag管理にもRemoteConfigを利用しており、Config値の変更は可能な限り速やかに行いたい(将来的にFirebase Realtime RemoteConfigへの置き換えも視野に入れている)為、そちらの選択肢は選択しませんでした。

まとめ

今回の経験から、以下の教訓を得ることができました。

  1. 画面または一連の機能(整合性を保ちたい単位)で必ず同一箇所のConfig値のキャッシュを参照するようにする
    1. 例えば共通のViewModel内でのキャッシュなど
  2. RemoteConfigから取得する値は変化するものという前提のうえでQAを行う

RemoteConfigは非常に便利な機能ですが、適切な実装と十分なQAがないと思わぬ落とし穴に遭遇する可能性があります。今回の紹介で、自分と同じような体験をしてしまう方を少しでも減らすことができれば幸いです・・・! 皆様安心安全な状態でクリスマスや年末を迎えましょう!

Android Chapter のリリースワークフローを紹介します

はじめに

この記事は Timee Advent Calendar エンジニアリングパート 3日目

担当は Android Chapter の tick-taku です。

来月でタイミーに入社して1年になります。Rails など新しいことにチャレンジしたり DroidKaigi や RubyKaigi など様々なカンファレンスに参加させてもらったりと濃い体験をさせてもらえて、この1年長かったような短かったようなという不思議な気持ちです。

1年間何やったかなと振り返ってみて Hilt やデザインシステムの導入など開発の基盤となることをメインにやっていたな〜と思ったので、この記事では入社直後からやっていた開発における自動化や仕組み作りの一環としてリリースワークフローを整えた話を実装ベースで紹介します。

リリース作業の自動化

タイミーの Android Chapter はこの1年でチームメンバーが3人から8人に増加しました。(嬉しい)

メンバーが増加するにあたって追い風となる反面、手作業で行っていたタスクも多くメンバー間で迷いなくスピーディに実行できるよう効率化する「レール」を敷く必要があると感じ、仕組み作り・自動化に取り組みました。

タイミーではストリームアラインドチームを採用しており基本的に Android エンジニアは各チームに散らばっています。その中でも1スプリントを1週間としているチームも多く、スプリント終了後にリリースを行うルールを採用しています。リリースサイクルが短いためリリース作業も頻繁に発生しており、バイナリ作成など一部 Bitrise で自動化されているものもありましたが基本的には以下のような作業を毎週繰り返していました。

  • リリース用のブランチ作成、アプリバージョンの変更・コミットなどの事前準備
  • リリース作業を行う PullRequest (以下、PR) を作成
  • リリース PR 上でのバイナリの作成・ストアへのアップロードタスクの実行と動作検証
  • 「Next Release」マイルストーンに紐づくPRを目視で確認し、リンクをリリースPRの description や GitHub Releases に記載
  • 各実装 PR に対してマージするタイミングで「Next Release」のマイルストーンを手動でアサイン
  • これらをその週のリリース担当者(ランダムで選出)が作業

そこでこれらを一部自動化する Workflow を作成し、リリース作業の効率化を図りました。その Workflow を紹介する前に、前提となる運用やスクリプトについて紹介します。

GitHub CLI

始めに頻出する GitHub CLI を紹介しておきます。

個人的には一番お世話になっているツールです。そもそも Git 操作を CLI で行うので、その延長で GitHub 上の様々な操作をコマンドで実行できるため非常に便利です。

GitHub CLI

基本的に PR の作成や CI のステータス確認などは GitHub CLI を利用しています。 コードレビューに関してだけはビジュアライズされている方が理解が速いので Web で確認していますが、それも GitHub CLI から見たい PR をブラウザで開くことができるので捗ります。

GitHub Actions においても基本的にはランナーにインストールされており token も secrets に用意されているので利用するハードルも低く相性もいいです。今回も GitHub 上の操作を自動化するために多用しています。

リリース PR の自動作成

まずリリース作業用の PR を自動で作成するスクリプトを用意しました。

#!/bin/bash

new_version="$1"

# リリースに含まれる PR をリストアップ
# クローズされた PR のリストから次のリリースの対象となる Milestone にアサインされたものをフィルタリングします
release_title="Release ${new_version}"
pr_numbers=$(gh pr list -s closed -L 100 --json "milestone,number,labels" -q "[.[] | select(.milestone.title == \\"${release_title}\\")]")
updates=$(echo "$pr_numbers" | jq -r '{
    feature_updates: [.[] | select(.labels[].name == "Update") | .number],
    bug_fixes: [.[] | select(.labels[].name == "BugFix") | .number],
    development_updates: [.[] | select(.labels[].name == "DevelopmentUpdate") | .number],
    others: [.[] | select(all(.labels[].name; . != "Update" and . != "BugFix" and . != "DevelopmentUpdate")) | .number],
}')
updates_body=$(echo "$updates" | jq -r '
"## Updates",
(.feature_updates | map("- #" + tostring) | join("\\n")),
"## Bug Fix",
(.bug_fixes | map("- #" + tostring) | join("\\n")),
"## Development Updates",
(.development_updates | map("- #" + tostring) | join("\\n")),
"## Others",
(.others | map("- #" + tostring) | join("\\n"))
')

pr_body="""
# $release_title

## Release Note
\\`\\`\\`
$(cat releasenotes/whatsnew-ja-JP)
\\`\\`\\`

$updates_body
"""

gh pr create \\
  -B master \\
  -t "$release_title" \\
  -m "$release_title" \\
  -b "$pr_body" \\
  -l "Release"

ストアに申請する際のリリースノートも description 上で確認できるようにしています。

実装 PR の分類とラベルによる自動化

タイミーでは内部へのリリースお知らせなどのために、リリース作業時に各PRを FeatureUpdateBugFix などに分類して description に記載しています。 以前はリリース担当者が手動で振り分け作業を行っていましたが、これが大きな負担となっていました。

そこで PR 作成時に実装者が Update などのラベルをつけることでどれがどの分類なのか自動で振り分けるようにしました。それが上記のスクリプトの updates_body 作成の部分です。

また、手動でラベルをアサインするのも手間なので feature/update/ なら Update ラベルを追加するなど branch 名で自動で付与されるようにもしています。

name: PullRequest bootstrap

on:
  pull_request:
    types:
      - opened

jobs:
  assign_updates_label:
    runs-on: ubuntu-latest
    if: startsWith(github.head_ref, 'feature')
    env:
      PR_NUMBER: ${{ github.event.pull_request.number }}
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - name: Assign Update label
        if: startsWith(github.head_ref, 'feature/update/')
        run: |
          gh pr edit $PR_NUMBER --add-label "Update"
      - name: Assign Update label
        if: startsWith(github.head_ref, 'feature/bugfix/')
        run: |
          gh pr edit $PR_NUMBER --add-label "BugFix"
      - name: Assign Update label
        if: startsWith(github.head_ref, 'feature/development/') || startsWith(github.head_ref, 'feature/development_update/')
        run: |
          gh pr edit $PR_NUMBER --add-label "DevelopmentUpdate"

バージョン管理の改善とアップデートコミットの自動化

以前は Gradle の config 内で直接アプリバージョンを管理しており、リリースのたびに build.gradle に差分が生じていました。

gradle には他にも様々なアプリのコアとなる記述があり、頻繁に gradle を触るのも嫌ですがさらに自動で差分を更新しコミットするのもハードルが高そうです。

これを改善するため、入社時のオンボーディングタスクとしてちょうど VersionCatalog を導入したこともあり、バージョン情報を toml ファイルで管理しました。

[versions]
versionMajor = "1"
versionMinor = "0"
versionPatch = "0"
versionOffset = "0"
def versionMajor = libs.versions.versionMajor.get().toInteger()
def versionMinor = libs.versions.versionMinor.get().toInteger()
def versionPatch = libs.versions.versionPatch.get().toInteger()
def versionOffset = libs.versions.versionOffset.get().toInteger()

versionCode =
    versionMajor * 1000000 + versionMinor * 10000 + versionPatch * 100 + versionOffset
versionName = "$versionMajor.$versionMinor.$versionPatch"

そして toml ファイル内の各バージョンを入力値に更新するスクリプトを用意します。

#!/bin/bash

libs_file_path="gradle/libs.versions.toml"

version="$1"
versions=(${version//./ })

sed -i -e "/versionMajor/s/.*/versionMajor = \\"${versions[0]}\\"/g" $libs_file_path
sed -i -e "/versionMinor/s/.*/versionMinor = \\"${versions[1]}\\"/g" $libs_file_path
sed -i -e "/versionPatch/s/.*/versionPatch = \\"${versions[2]}\\"/g" $libs_file_path
sed -i -e "/versionOffset/s/.*/versionOffset = \\"${versions[3]}\\"/g" $libs_file_path

rm -rf "$libs_file_path-e"

これを Workflow 内で実行することでアプリバージョンのアップデート作業を自動化しました。

sed でなんとでもなると昔から教わってきたので使いがちですが、余計なファイルが出来たりもするしあんまりイケてないのではと最近気付きはじめました...

QA の運用について

タイミーでは PR ごとに QA チェックリストを記載し動作検証を行っています。以前はチェックリストを PR の description に直接記載して PR 単位の動作検証を行っていました。

しかしリリース時の QA でもそれを見ながら検証していたので QA の度に該当の PR を見に行く必要がありました。 非常に手間がかかってしまうので、QA を Notion のデータベースで一元管理しページ内にチェックリストを記載する運用を導入しました。

Milestone が Release となっているものがリリース時の QA 作業対象となっており、リリース時には Notion を参照するだけでQA作業を進められるようになっています。イメージはこんな感じ。

ただし PR 作成時に Notion にわざわざ移動してページを作成するのも大変だったり忘れたりするので、PR が作成されると QA ページを自動で作成し URL を PR にコメントするようにしています。

Notion API によるクエリの実装は こちらを参考に させていただきました。

#!/bin/bash

pr_number="$1"

# 既に同じ number のページが存在する場合は処理を終わらせる
page_id=$(curl -X POST '<https://api.notion.com/v1/databases/'$NOTION_QA_DATABASE'/query>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data '{
    "filter": {
      "property": "PR Number",
      "number": {
        "equals": '$pr_number'
      }
    }
  }' | jq -r .results[0].id)

if [ $page_id != 'null' ]; then
  exit 0
fi

pr=$(gh pr view $pr_number --json "title,milestone,url")

# PR のメタデータを元に QA ページを作成
title='"title": [ { "text": { "content": "'$(echo "$pr" | jq -r .title)'" } } ]'
data='{
  "parent": { "database_id": "'$NOTION_QA_DATABASE'" },
  "properties": {
      "Title": { '$title' },
      "PR Number": { "number": '$pr_number' },
      "PR": { "url": "'$(echo "$pr" | jq -r .url)'" }
  }
}'

qa_url=$(curl -X POST '<https://api.notion.com/v1/pages>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data "$data" \\
  | jq -r .url)

# 作成できた QA ページの URL を PR にコメント
gh pr comment $pr_number -b """
## Make QA :memo:
$qa_url
"""

PR 作成時にスクリプトが走るよう Workflow を作成します。 タイミーでは branch の名前で自動化運用しているものもあり、特定の branch の場合は必要ないので走らせないようにしています。

name: PullRequest bootstrap

on:
  pull_request:
    types:
      - opened

jobs:
  make_qa:
    runs-on: ubuntu-latest
    if: ${{ !startsWith(github.head_ref, 'release') && !startsWith(github.head_ref, 'ladr') && github.head_ref != 'master' }}
    steps:
      - uses: actions/checkout@v4
      - name: Make QA
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          ./.github/script/post_qa.sh ${{ github.event.pull_request.number }}

実装 PR がマージされたら Milestone をアサインする

上述しましたが、タイミーではリリースの差分を把握するために Milestone を利用しています。 なので PR を develop にマージしたら Next Release の Milestone をアサインする必要がありました。

が、これが結構忘れます。なのでリリース作業時に「あれ?これリリースの対象では...?」といった確認を慎重に行う必要があったりとりこぼしが発生したりと、精神的負荷が高い状態でした。

それを解決するために PR がマージされたらその PR に Milestone をアサインする Workflow を用意しました。同時に QA もリリースの対象として可視化されるように Milestone を変更します。すでに Milestone が付いている場合は実行しないようにしています。

name: Assign milestone on merged

on:
  pull_request:
    types:
      - closed

jobs:
  assign-milestone:
    runs-on: ubuntu-latest
    if: |
      github.event.pull_request.merged == true
        && !github.event.pull_request.milestone
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      PR_NUMBER: ${{ github.event.pull_request.number }}
    steps:
      - uses: actions/checkout@v4
      - name: Assign milestone
        run: |
          gh pr edit "$PR_NUMBER" -m "Next Release"
      - name: Assign QA milestone
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
        run: |
          ./.github/script/update_qa_milestone.sh "$PR_NUMBER"
#!/bin/bash

pr_number="$1"

page_id=$(curl -X POST '<https://api.notion.com/v1/databases/'$NOTION_QA_DATABASE'/query>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data '{
    "filter": {
      "property": "PR Number",
      "number": {
          "equals": '$pr_number'
      }
    }
  }' | jq -r .results[0].id)

data='{
  "properties": {
    "Milestone": {
      "select": { "name": "NextRelease" }
    }
  }
}'

curl -X PATCH '<https://api.notion.com/v1/pages/'$page_id'>' \\
  -H 'Authorization: Bearer '$NOTION_API_SECRET'' \\
  -H 'Content-Type: application/json' \\
  -H 'Notion-Version: 2022-06-28' \\
  --data "$data"

リリース準備作業を実行する Workflow

これらを含めリリース準備作業を実行する Workflow を用意します。

name: Prepare Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: "Target release version"
        required: true
        type: string

jobs:
  release:
    runs-on: ubuntu-latest
    env:
      NEW_VERSION: ${{ inputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Modify milestone title
        run: |
          milestone_number=$(gh api repos/${{ github.repository }}/milestones -q ".[] | select(.title == \\"Next Release\\") | .number")
          gh api repos/${{ github.repository }}/milestones/$milestone_number -X PATCH -F title="Release $NEW_VERSION"
          gh api repos/${{ github.repository }}/milestones -X POST -F title="Next Release"

      - name: Prepare release QA
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
        run: |
          ./.github/script/replace_qas_milestone.sh -t NextRelease -v Release

      - name: Make PullRequest
        uses: ./.github/actions/make_release_pull_request
        id: make_pr
        with:
          version: ${{ inputs.version }}
          github_token: ${{ secrets.GITHUB_TOKEN }}

      - name: Make release build
        uses: ./.github/actions/bitrise_upload_app
        with:
          pr_number: ${{ steps.make_pr.outputs.pr_number }}
          app_slug: ${{ secrets.APP_SLUG }}
          workflow_id: "upload-app-bundle-to-google-play-store"
          build_trigger_token: ${{ secrets.BUILD_TRIGGER_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}

      - name: Make app build
        uses: ./.github/actions/bitrise_upload_app
        with:
          pr_number: ${{ steps.make_pr.outputs.pr_number }}
          app_slug: ${{ secrets.APP_SLUG }}
          workflow_id: "upload-apk-firebase-app-distribution"
          build_trigger_token: ${{ secrets.BUILD_TRIGGER_TOKEN }}
          github_token: ${{ secrets.GITHUB_TOKEN }}
name: Make release pullrequest

inputs:
  version:
    description: "Target release version"
    required: true
    type: string
  github_token:
    description: "GitHub token for github cli"
    required: true

outputs:
  pr_number:
    description: "Release PR's number"
    value: ${{ steps.make_pr.outputs.pr_number }}

runs:
  using: "composite"
  steps:
    - name: Set to env
      run: |
        echo "NEW_VERSION=${{ inputs.version }}" >> $GITHUB_ENV
        echo "GITHUB_TOKEN=${{ inputs.github_token }}" >> $GITHUB_ENV
      shell: bash
    - name: Switch release branch
      run: |
        git switch -c "release/$NEW_VERSION"
      shell: bash
    - name: Increment version
      run: |
        ./.github/script/bump_version.sh "$NEW_VERSION"
        git config user.name "actions-user"
        git config user.email "action@github.com"
        git add .
        git commit -m "Bump version to $NEW_VERSION"
        git push origin $(git branch --show-current)
      shell: bash
    - name: Make Pull Request
      id: make_pr
      run: |
        ./.github/script/make_release_pr.sh "$NEW_VERSION"
        echo "pr_number=$(gh pr list -s open --json number,labels -q '[.[] | select(.labels.[].name == "Release")][0] | .number')" >> $GITHUB_OUTPUT
      shell: bash

簡単に各 step では、

  1. Milestone のタイトルを Next Release から Release x.x.x に変更し次のリリース用の Next Release Milestone を作成
  2. Notion データベース上のリリース対象となる QA の Milestone を更新してピックアップ
  3. リリース PR の作成
  4. production のリリースバイナリを PlayStore のテストトラックにアップロード
  5. staging のデバッグバイナリを Firebase App Distribution にアップロード

のようなことをやっています。 バイナリ作成のワークフローは既に Bitrise に CI が用意されておりそれを実行しています。

[!NOTE] Milestone を Next Release としているのは次のバージョンがいくつになるかリリース作業時に確定するためです。リリース作業中にバージョンを確定させ、gradle 内を更新し Milestone のタイトルを Release x.x.x のようなフォーマットに変更し次のリリース対象となる Next Release Milestone を作成します。

workflow_dispatch で次のバージョンを受け取るようにしており、GitHub Actions のタブから手動で実行することができます。

[!NOTE] 今回は長くなるので紹介していませんがリリース Workflow は通常用と hotfix でわけており、PR を作成するための step やアプリのビルドは共通で使い回すため Composite Action として切り分けています。

これで Workflow を実行すればリリース作業用の PR を勝手に作成してくれるようになり、今まで手動で時間をかけていた作業がボタンぽちーで終わるようになりました。

ちなみに GitHub CLI を利用していると以下のように実行できてとても便利です。

gh workflow run prepare_release.yml -f version=x.x.x

お片づけ

最後にリリースした後についてです。

ストアにリリースし終えたら作業用 PR をマージします。その際に以下を実行する Workflow を用意しています。

  1. GitHub Releases に該当バージョンのリリースを作成
  2. master to develop の PR 作成
  3. リリースしたマイルストーンのクローズ
  4. 検証した Notion の QA ページの Milestone を更新
name: Make Release

on:
  pull_request:
    branches:
      - master
    types: [closed]

jobs:
  release:
    if: github.event.pull_request.merged == true && contains(github.event.pull_request.title, 'Release')
    runs-on: ubuntu-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      PR_NUMBER: "${{ github.event.number }}"
    steps:
      - uses: actions/checkout@v3
      - name: Make Release
        run: |
          ./.github/script/make_release.sh "$PR_NUMBER"
      - name: Make Pull Request to develop
        run: |
          git switch master
          gh pr create -B develop -t "Master" -b "Merge master to develop"
      - name: Close milestone
        run: |
          milestone_number=$(gh pr view "$PR_NUMBER" --json "milestone" -q ".milestone.number")
          gh api repos/Taimee/taimee-android/milestones/$milestone_number -X PATCH -F state=closed
      - name: Update QA Milestone
        env:
          NOTION_API_SECRET: ${{ secrets.NOTION_API_SECRET }}
          NOTION_QA_DATABASE: ${{ secrets.NOTION_QA_DATABASE }}
        run: |
          version=$(gh pr view "$PR_NUMBER" --json "title" -q ".title" | awk 'match($0, /([0-9]+\\.[0-9]+\\.[0-9]+(\\.[0-9]+)?)/) {print substr($0, RSTART, RLENGTH)}')
          ./.github/script/replace_qas_milestone.sh -t Release -v $version

[!NOTE] make_release のスクリプトは PR の description をそのまま GitHub Releases にコピペするだけですし、replace_qas_milestone は QA の Milestone をリリースしたバージョンのテキストに更新します。

最後に

以上がタイミー Android Chapter のリリース作業に利用している Workflow の紹介でした。

今まで手作業で30分〜1時間くらいかけて行っていたタスクが長くても10分以内には収まっていたり負担も減っていると感じています。

ただし、- #PRNumber だけでタイトルを補完してくれるのは PR の中だけで GitHub Releases には番号しか見えてなかったりします。

またビルド関連は Bitrise で行っていて GitHub Actions から Bitrise の CI をトリガーする事が多いです。Bitrise.io の QR からインストールできるのは非エンジニアがデモで触ってもらう際に非常に助かっているのですが、GitHub Actions の artifact で似たような事ができるなら費用面やパフォーマンス面を考慮して GitHub Actions に統一も検討できるといいかもしれません。

スクリプトがごり押しだったりまだまだ課題は残っていますし、もっと効率のいい運用がある気がしているのでメンバーのフィードバックを拾い上げて継続的に改善していきたいですね。

ぜひみなさんの オレの考えた最強のリリースワークフロー を教えてください!

明日は我らが Android Chapter のリーダー、murata-san です!お楽しみに