It Made My Day

Acutally, my whole life is just one big yak shaving exercise.

Woman Who Go Tokyo 10周年記念イベントレポート&登壇セッションサマリー

@micchiebear さんにお声がけいただき、Woman Who Go Tokyoというコミュニティの10周年イベントで登壇させていただきました!

web.womenwhogo.tokyo

とってもかわいいイベントページ!お祭り感があってイベント前からワクワクしました。

この記事では、イベントの様子と私の登壇内容のまとめを書きます。

イベントレポート

micchieさんのかわいすぎるスライドでオープニング。 Gopherくんがクラッカーを引く動作をしていました。 黄色いボードは、TinyGo Keebのワークショップではんだづけ予定の基盤です。

その後のスライドもアニメーションがなめらかに動いていました。 AIの力で実現したそう。すごい。

スポンサーセッションに続いて行われた各セッションの資料を、見つけられた分だけ置いておきます。

私は自分のセッションの後、すいラボさんのワークショップに参加し、 自分のインターネットネームが入ったタグを自作しました! Gopherくんやこのイベント専用のパーツもあって、すいラボさんのご厚意に甘えまくってみんなで大盛り上がりしました。 ワークショップの後はずっと欲しかった念願の酒クズタグも購入し、点灯式をやってもらいました!

その後は、半田付けをしてネームプレートを作りました。 液晶にはかわいいGopherくんの10周年記念ロゴがあり、ツマミを回すとロゴが回転します。 私はこの日キーキャップのデコレーションをする暇がなかったので、デコられた @saki_entineer さんのツイートを拝借。

前回のTinyGo Keeb@TinyGo Confで作ったGopherくん形基盤よりも多機能で難しく、1時間半くらいかかった気がします。 私は超絶不器用なので、半田付け2回目なのにスタッフのみなさまに手取り足取り教えていただきました(感謝🙏)。

TinyGoコミュニティは最近Gopher Con Singaporeで登壇&ワークショップしたそうで、グローバルな話題でも盛り上がりました。

懇親会でもいろんな方の近況を聞いたり、技術的な嗜好を話したりして、総じて盛りだくさんで一瞬で時間が過ぎてしまいました。 運営の皆様、ありがとうございました!

私のセッション、「Simple, Reliable, and Efficient Go」のサマリー

私は実はWoman Who Go Tokyoとは7年の付き合いになり、エンジニアとして初めての執筆と登壇のチャンスを頂いた、とても思い入れのあるコミュニティです。イベントがコミュニティの10周年という節目を祝うものだったため、私も今回の登壇では自分のエンジニアキャリアを振り返りつつ、Goを使った開発の良さについて個人的な意見をお話しました。 30分間の長い間聞いてくださった方々、本当にありがとうございました。

資料だけでは伝わりづらい話が多かったので資料は公開しませんが、お話した内容をまとめます。 自分が過去に携わった開発の経験を順に説明し、過去にイベントで登壇したりテックブログに書いたりした内容を引用します。

負荷試験用CLIツール

私がGoを書き始めた当初、会社の先輩に勉強のために何を作るのが良いと思うか壁打ちしてもらい、選んだのが負荷試験のためのCLIでした。 初期セットアップが容易で、標準ライブラリで大体のことができて、PCのリソースもそんなに食わずシュッと起動して、配布が簡単というのは、当時の自分にとって画期的だったことを今でもよく覚えています。なお私の登壇スライドは、あたたかみのある手作業で作成されております。

まだGoの良さも何もわかっていなかった当時の私にとって、このお題は下記の点で間違いなく最も勉強に適したものでした。

  • CLIツールの作り方がわかる
  • デバッグ用に負荷をかけられる側のサーバも作ることになるため、net/httpパッケージを使ってAPIサーバーを作る経験ができる
  • リクエストのタイムアウトなどを考慮する際、contextパッケージの使い方を考えるきっかけになる
  • HTTPリクエストの中身を見てエラーハンドリングするコードを書く経験ができる
  • gorouineとチャンネルを湯水のように使える。リクエストを送信するための並行処理と集計するための同期機構を軽率に使ってハマりポイントを体験できる
  • 最初は愚直に等間隔でgoroutine起動させ、そこからrate limitのアルゴリズムに想いを馳せてみたりできる
  • 公開してシングルバイナリによる配布の容易さを実感できる

当時の勉強の仕方として先輩におすすめされたのが、自分でやりたいことを実装してみてから、お手本のサービスのコードを読み良いと思った点を取り入れるやり方でした。 具体的には、最初は「1秒間にXXリクエストを送る」という目標を定め、最小の構成で実装しました。 そのあとは機能要件を追加していき、何が欲しいかわからなくなったらお手本のアプリケーションからアイデアをパクったりしつつ自分の理想の機能を実装していきます。私の場合は、負荷試験をやるにあたり、負荷をどうやってかけて(リクエストごとにヘッダやボディの中身を変えるとか)、集計をどう行うのかを考えることで、徐々に機能を足していきました。ツールの使い方だけではなく、システムの負荷について考える時間を取ることにもなり、システムアーキテクチャやミドルウェアに想いを馳せるきっかけにもなりました。

当時、負荷ツールのお手本としてtsenart/vegetaを選びました。

この負荷試験用CLIツール自作は、単に勉強のためだけではなく、何度か携わるプロダクトが変わる中でも数回似たようなツールを自作して実際に使用していたので、実践的だという意味でもGo初学者の方におすすめしています。

マイクロサービスアーキテクチャ

私は過去にモノリスのアプリケーションのマイクロサービス化に携わっていた期間が比較的長かったので、マイクロサービスアーキテクチャのシステムでよくあるアーキテクチャ構成と使用技術についてお話しました。

API Gateway開発の技術的意思決定やアーキテクチャに関する具体的な話は、過去に書いたSoftware Design 2024年7月号 連載「レガシーシステム攻略のプロセス」第3回、 「API Gatewayとサービスメッシュによるリクエスト制御」での説明が、開発現場の事情をかなり詳しく書いたのでよければそちらを読んでみてください。

techblog.zozo.com

システムの複雑さが増すと、機能要件と非機能要件両方の基準をどう担保するのか、そのためにどういうアーキテクチャにするのが良いのかなど、考えることがどんどん増えていきます。 どの言語を使っても難しい開発は難しいのですが、Goで複雑なドメインのアプリケーションをチーム開発した時に感じた難しさをまとめたスライドは一定共感を得たようでした。

ただ、この難しさを補ってあまりあるメリットもGoにはあります。どのようなシステムの開発でも同じですが、シンプルな文法で静的な型があり、後方互換性が維持されスタイルやシンタックスのアップデートツールも充実しているという点での開発者体験の良さも強調しました。

特にクラウド環境で動くマイクロサービスについては、簡単にデプロイできて起動が早くて、並行処理が得意なGoはとても重宝されているように感じており、公式が出しているCase Studiesの中からAmericanExpressのマイクロサービス開発事例も取り上げました。

go.dev

高い同時実行性と低遅延性を重視する決済およびリワードプラットフォームチームが、社内でGo言語の採用をいち早く始めたという話です。 速度/パフォーマンス、ツール、テスト、開発の容易さという観点から、C++、Go、Java、Node.js 4つの言語を比較して最終的にGoを選択した、と書かれています。

アメリカン・エキスプレスの決済処理システムは、長年にわたる歴史の中で開発され、幾度ものアーキテクチャの進化を経てアップデートされてきました。あらゆるアップデートにおいて最も重要なのは、特に大量の取引量においても高速な決済処理を実現することであり、同時に、セキュリティおよび規制基準に準拠したシステム全体にわたる堅牢性も不可欠です。Goの導入により、アメリカン・エキスプレスは決済ネットワークとリワードネットワークの両方に必要なスピードと拡張性を手に入れました。

処理速度や並行処理のハンドリングだけでなく、起動が高速であることにより、ローリングアップデートやオートスケーリングの速さが、リクエストのラウンドトリップ短縮と障害時のダウンタイムの短さに繋がることから、事業的な視点でもGoを使う恩恵はあると個人的には考えています。

マーケティングメール配信基盤

マイクロサービスともまた少し毛色が違うサービスとして、データパイプラインに近い性質を持つマーケティングメール配信基盤システムのアーキテクチャも最後に紹介しました。

これは元々はワークフローエンジンを使っていたシステムを、Go製アプリケーションで置き換えた実際の事例になります。 昨年のGoogle Cloud Next Tokyoで、詳細なアーキテクチャと技術選定の背景を説明しました。ここではGoogle Cloudの各種サービスの特徴も踏まえて解説していますので、よかったらご覧ください。

www.youtube.com

基盤システムは、多くの周辺システムやサービスが依存する土台であり、障害などの影響がシステム全体へ波及するという性質があります。そのため、複数のシステムから受けたリクエストを、安定的かつ効率的に処理する必要があります。

このような要件では特にGoが輝くと個人的に感じており、実際に上記の事例ではメール配信までの処理時間が最大(最も混雑している時間で計算)4割改善しました。加えて、ランタイムが安定していることから処理の中断などによる障害も少なく、運用もかなり楽になりました。

同様の要件があるクラウドやDevOps系のツール・サービスではGoは一世を風靡した感があり、これについてはGoogle I/Oでも強調されていました。

まとめ

以上、Go開発チームはProductivity + Production Readinessをテーマに掲げており、個人の経験としてもそれを実感したよ、という事例集でした。

その他リンク集

今回のセッションでは、自分が携わったシステム以外にも、Goの開発チームの意向と言語の思想についてもお話しました。 特に、最近行われたGoogle I/OでのGoチームのセッションはアップデートされたメッセージを聞けて良かったです。

www.youtube.com

GoのユースケースとしてWhy Go?というページを紹介しました。

go.dev

今回の登壇ではこの中から、主にCase StudiesとUse Casesの記事をまとめました。 Goが何に使われているのか、なぜGoなのかについて、公式が出しているというのが興味深かったです。

Case Studiesでは企業ごとの活用事例、Use Casesのページでは下記のようにジャンルごとの採用事例を見ることができます。

  • Cloud & Network Services
  • Command-lin interfaces (CLIs)
  • Web Development
  • Development Operations & Site Reliability Engineering

ーーーーーーー

改めて、Woman Who Go Tokyoの10周年、お祝い申し上げます! ここから次の10年も盛り上げていきましょう🎉

参考リンク

WWGT界隈の人たちの著書など

zenn.dev

他の参加者の方のイベントレポート

(Go) sync.OnceのDoメソッドが一度だけの実行を保証するために使用する、Double-Checked Lockingパターンについて

Goでコードを書いていると、その裏側にあるロジックや動作を保証するための苦労を知らずとも便利なパッケージを使えてしまうので、実装背景にある理論をまとめてみる。 本記事では、syncパッケージのOnce構造体が、Doメソッドの中で何をしているのかをまとめてみた。

sync.Onceとは

sync.OnceのDoメソッドは、複数のゴルーチンから並行して呼び出された場合でも、引数として渡された関数を一度だけ実行することを保証する。 こんな感じで使う↓。

package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once
    var wg sync.WaitGroup

    // 一度だけ実行したい処理
    task := func() {
        fmt.Println("--- 1回限りの初期化を実行 ---")
    }

    for i := 1; i <= 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            fmt.Printf("ゴルーチン %d: 開始\n", id)
         
            // どのゴルーチンが先に到達しても実行は1回のみ
            once.Do(task)
         
            fmt.Printf("ゴルーチン %d: 完了\n", id)
        }(i)
    }

    wg.Wait()
}

本記事執筆時点のsync.Once構造体とDoメソッドの実装を、コードコメントを抜いた状態で記載する。

type Once struct {
    _ noCopy

    done atomic.Bool
    m    Mutex
}

func (o *Once) Do(f func()) {
    if !o.done.Load() {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if !o.done.Load() {
        defer o.done.Store(true)
        f()
    }
}

Double-Checked Lockingパターン(以下、DCLP)は、この動作を効率的に実現する設計パターン。

Double-Checked Lockingパターン(DCLP)とは

※このセクションの説明は、Wikipediaをはじめ複数の文献の文章を引用している。

Double-Checked Lockingパターン(DCLP)は、マルチスレッド環境においてシングルトンなどのインスタンスを遅延初期化する際に、実行効率と安全性を両立させるための設計手法。

このパターンの最大の特徴は、スレッド間の競合を防ぐための排他制御(ロック)を二段階の条件判定で管理する点にある。通常、複数のスレッドが同時に初期化処理へアクセスすると、インスタンスが重複して生成されるリスクが生じる。これを防ぐために毎回ロックを取得すると、すでに初期化が完了している場合でもロックの処理によるオーバーヘッドが発生し、プログラムの実行速度が低下する。

DCLPでは、まずロックを取得せずにインスタンスが未生成であるかを確認し、未生成の場合のみロックを取得して、再度その内部で生成の有無を判定する。これにより、初期化が済んだ後はロックを回避して素早くインスタンスを取得できるようになる。

ただし、この手法を正しく動作させるにはコンピュータの最適化による挙動を考慮しなければならない。現代のCPUやコンパイラは、処理速度を上げるために命令の実行順序を入れ替える「アウトオブオーダー実行」を行うことがある。この最適化の影響で、メモリへの書き込み順序が変わり、他のスレッドが不完全な状態のインスタンスを参照してしまう危険性がある。

この問題を解決するために、C++などではメモリバリアという、CPUやコンパイラに対してメモリ操作の順序を強制する同期の仕組みが導入されている。これは、バリア設定箇所の前後で処理が追い越されないように制約をかける。これを用いることで、インスタンスの生成と変数への代入が意図した順序で完了することを保証し、並列計算における初期化の唯一性と安全性を維持する。

sync.Onceの処理の流れ

func (o *Once) Do(f func()) {
    // 1. Fast Path: Mutexをロックせずにdoneの状態を確認
    if !o.done.Load() {
        // doneではなかった場合、doSlowメソッドへ移行しMutexをロック
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    // 2. Mutexによる排他制御
    o.m.Lock()
    defer o.m.Unlock()
    // 3. Slow Path: ロックを取得した直後、再度doneの状態を確認
    if !o.done.Load() {
        defer o.done.Store(true)
        f()
    }
}

処理の流れ:

  1. o.done.Load(atomic.Bool型)がtrue、つまりすでに初期化が完了している場合、ロックの取得と解放というオーバーヘッドを回避して即座にリターンする。
  2. doSlowメソッド内でMutexをロックした時点で、複数のゴルーチンが同時に実行関数fを呼び出すことを防ぐ。
  3. ロックを取得した直後、再度doneの状態を確認する。これは、1回目のチェックとロック取得の間に、別のゴルーチンが初期化を完了させた可能性があるため。

メモリ順序の保証

DCLPを安全に機能させるためには、単なる条件分岐だけでなく、メモリの可視性と実行順序の保証が必要。

GoのメモリモデルはDRF-SC(Data-Race-Freeならば順次一貫性を保証する)であるため、sync.OnceのDoメソッドは、Mutexとsync/atomicパッケージを用いて、ハプンズ・ビフォー(Happens-before、一方が必ず他方の前に起きること)関係を構築している。

doSlowメソッド内のStore(true)は、フラグを立てる前に、その前に行われた全ての書き込み(初期化)を完了させ、メモリに反映する。 実行関数fによる書き込みが完了した後にStoreが実行されることを保証し、その結果を他のゴルーチンから可視化する。

DoメソッドおよびdoSlowメソッド内のLoad()では、共有変数の読み込み操作が、それ以降のメモリ操作(読み書き両方)を絶対に追い越さない。 Loadがtrueを返した場合、それ以降の読み取りにおいて、リリース側で行われた書き込み内容がすべて最新の状態で参照できることを保証する。

これらにより、初期化途中の不完全なデータを他のゴルーチンが参照するリスクを排除している。

Goのメモリモデルによると、GoはAcquire-Release Semantics(およびメモリバリア)を直接的な操作対象として明示的にサポートしておらず、メモリモデルとして規定されている以上の詳細はハードウェア依存の実装となる。Goではsync/atomic パッケージの操作がSequentially Consistent(逐次一貫性)に近い挙動を保証し、低レイヤのメモリバリアを直接制御する必要がない。

JavaやC++などでは過去、メモリモデルが不完全であったことからDCLPパターンを安全に実装する手段が事実上存在せず、アンチパターンと見なされる時期が長く続いたそう。

Mutexの必要性

once.go のコードコメントに、避けるべき実装例が書かれている。下記に和訳したものを掲載する。

package sync

import (
    "sync/atomic"
)

// Onceは、ただ一つのアクションを正確に一度だけ実行するオブジェクト。
//
// Onceは最初の使用後にコピーしてはならない。
//
// [Goメモリモデル]の用語では、fからのリターンは、
// 任意のonce.Do(f)の呼び出しからのリターンに対して「同期前(synchronizes before)」となる。
//
// [Goメモリモデル]: https://go.dev/ref/mem
type Once struct {
    _ noCopy

    // doneはアクションが実行されたかどうかを示す。
    // ホットパスで使用されるため、構造体の先頭に配置されている。
    // ホットパスは全ての呼び出し箇所でインライン化される。
    // doneを先頭に配置することで、一部のアーキテクチャ(amd64/386)ではよりコンパクトな命令が可能になり、
    // 他のアーキテクチャでは(オフセット計算のための)命令数を削減できる。
    done atomic.Bool
    m    Mutex
}

// Doは、この[Once]インスタンスに対してDoが初めて呼び出された場合に限り、関数fを呼び出す。
// 言い換えれば、
//
// var once Once
//
// が定義されているとき、once.Do(f)が複数回呼び出されても、最初の呼び出しのみがfを起動する。
// 各呼び出しでfが異なる値を持っていても同様である。
// 実行する関数ごとにOnceの新しいインスタンスが必要となる。
//
// Doは、正確に一度だけ実行しなければならない初期化を目的としている。
// fは引数を持たないため、Doによって起動される関数の引数をキャプチャするには、
// 以下のように関数リテラルを使用する必要がある:
//
// config.once.Do(func() { config.init(filename) })
//
// fがリターンするまでDoの呼び出しはリターンしないため、
// もしfがDoを呼び出す原因となった場合、デッドロックが発生する。
//
// fがパニックを起こした場合、Doはfがリターンしたものとみなす。
// 以降のDoの呼び出しは、fを呼び出すことなくリターンする。
func (o *Once) Do(f func()) {
    // 注意:以下はDoの誤った実装である:
    //
    // if o.done.CompareAndSwap(false, true) {
    //     f()
    // }
    //
    // Doは、自身がリターンする際にfが完了していることを保証する。
    // この実装(CASを用いたもの)はその保証を履行できない。
    // 2つの呼び出しが同時に発生した場合、CASに勝った方はfを呼び出すが、
    // 2番目の呼び出しは最初のfの呼び出し完了を待機せずに即座にリターンしてしまう。
    // これが、スローパスでミューテックス(Mutex)にフォールバックする理由であり、
    // o.done.Storeをfがリターンするまで遅延させなければならない理由である。

    if !o.done.Load() {
        // ファストパスのインライン化を可能にするため、スローパスをアウトライン化している。
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if !o.done.Load() {
        defer o.done.Store(true)
        f()
    }
}

下記のような単純なCompare and Swap実装は、マルチスレッド環境における実行完了の保証が欠けているため使用できない。

if o.done.CompareAndSwap(false, true) {
    f()
}

これについて、1. 先行スレッドと後続スレッドの衝突と、2. 未完了データへのアクセスの2点で説明する。

2つのゴルーチン(AとB)が同時に Do(f) を呼び出したと仮定すると、下記の状況になり得る。

  • ゴルーチンA: CASに成功し、done を true に書き換えた後、重たい処理である f() の実行を開始。
  • ゴルーチンB: Aが done を true にした直後に到着。CASを試みるがすでに true なので失敗し、そのまま Do メソッドをリターン。

ここで問題になるのは、ゴルーチンBがリターンした瞬間、ゴルーチンAの f() はまだ実行中かもしれないという点。

sync.Once の利用者は、「Do(f) がリターンしたのだから、初期化は完全に終わっているはずだ」と期待して次の処理に進む。しかし、CAS方式では「関数を実行する権利」を得た瞬間にフラグが立ってしまうため、後続のスレッドを待たせることができない。結果として、ゴルーチンBは不完全な(初期化が終わっていない)データにアクセスしてしまい、プログラムがクラッシュしたり、予期せぬ挙動を示したりする。

余談:doSlowプライベートメソッドの存在意義

Doメソッドの下記のコメントも興味深い。

   if !o.done.Load() {
        // ファストパスのインライン化を可能にするため、スローパスをアウトライン化している。
        o.doSlow(f)
    }

もし Do メソッドの中に、スローパスであるdoSlow関数の重たい処理(Mutexやdeferなど)をすべて直接書き込んでしまうと、Do メソッド全体の「コスト」が高くなりすぎて、コンパイラが「この関数はインライン化しない」と判断してしまう。sync.Once は、一度初期化が終われば、その後は何度 Do が呼ばれても何もせずリターンすることを期待されているため、2回目以降の呼び出しが必ず通るファストパスをインライン化することにより、チェックのみの処理を高速化しているものと思われる。

余談:Goのメモリモデルにおける逐次一貫性

haruyama480さんにご教示いただき、Goのメモリモデルについて理解が深まったのでここで紹介したい。

メモリ順序の保証に関連し、Goのメモリモデルという論文から、Goで逐次一貫性を実現するために採られた設計方針を読み取ることができる。

research.swtch.com

「Document happens-before for sync/atomic」というセクションは、明言が避けられてきた sync/atomic における happens-before 関係が明示的にドキュメント化された際に追加された。

パッケージ内のAPIはsync/atomic、複数のゴルーチンの実行を同期させるために使用できる「アトミック操作」の総称です。アトミック操作Aの効果がアトミック操作Bによって観測される場合、AはBよりも先に実行されます。プログラム内で実行されるすべてのアトミック操作は、ある順序一貫性に基づいて実行されたかのように動作します。

github.com

アトミックな操作を実現する際、他言語では取得と解放のセマンティクス(Acquire-Release Semantics)と呼ばれるセマンティクス(実行時にシステムが守るべき厳密なルール)によるメモリバリアが使用されることがあるが、Goではその使用を極力避けており、言語レベルでは十分に抽象化され開発者が意識する必要がないようになっている。

同期/アトミック操作にacquire/releaseセマンティクスを採用した場合、ARMv8上で書かれたプログラムは必然的に逐次一貫性を得ることになります。そうなると、より強力な順序付けに依存するプログラムが、意図せず低スペックのプラットフォームで動作しなくなるという事態が確実に発生するでしょう。


同様の理由に加え、Goの全体的な哲学である、最小限で理解しやすいAPIを提供するという方針も相まって、acquire/releaseを別のAPIセットとして並行して提供することには反対です。最も理解しやすく、最も有用で、誤用されにくいアトミック操作のみを提供するのが最善策と考えられます。


一般的に、アトミック操作はフェンスよりもはるかに理解しやすく、既にアトミック操作を提供しているため、簡単に削除することはできません。2つのメカニズムよりも1つのメカニズムの方が良いでしょう。

最初にこの記事を公開した際に、一部のCPU命令を参照してGoにおいてもAcquire-Release Semanticsをサポートしているように読める説明を書いてしまっていたが、実際には実装はハードウェアに依存するものでありGoが言語として設計しているものではない。

参考資料

この記事で取り上げたonce.goのコードが何をしているのかを逐次説明している記事↓

zenn.dev

en.wikipedia.org

Double-Checked Locking

go.dev

qiita.com

ja.wikipedia.org

uchan.hateblo.jp

2025年振り返り

2025年のハイライト

気づけば(極度の引きこもりな自分にとっては)めちゃくちゃアクティブな1年になった。

  • アーキテクチャを担当したメール配信基盤をリリース(仕事)(年前半)
  • Cloud Composer(Airflow)と格闘した(仕事)(春以降)
  • Google Cloud Next@ラスベガス初参加(仕事)(4月)
  • Google Cloud Next Tokyoで初登壇(仕事)(8月)
  • Go Conferenceで初登壇(9月)
  • 筋トレデビューした
  • たくさん旅行した

テックブログ(このブログを除く)と登壇:

以下、自分的に大きかったテーマについていくつか書いておく。

大規模なイベントに参加したり登壇したりした

今年自分にとって一番新しかった経験が、カンファレンスやイベントへの参加・登壇だった。 国内・海外ともに、大規模なカンファレンスにはオフライン参加すらしたことがなかったが、結局2025年は2回登壇し、海外カンファレンスにも初参加する機会に恵まれた。

4月には、ラスベガスで行われたGoogle Cloud Next '25に会社の同僚と参加(会社で行かせてくれるのとてもありがたい)。 ラスベガスに行くのは人生2度目で学生の頃の貧乏旅行以来だったが、どこを歩いてもディズニーランドみたいなすごい街だったし、カンファレンスも企業ブースがポップアップストアみたいに豪華で基調講演なんてもはや有名アーティストのライブイベントみたいだった(実際ライブイベントもあった)。

Google Cloud Next 25基調講演(テックブログより拝借)

会場の空気やセッションが生成AIに対する興味をより強くしてくれてそれだけで十分に得難い経験だったが、正直一番興奮したのはGoの開発チームと話せたことだったかもしれない。大体のブースはかなりの人混みだったがなぜか開発者ブースは人が全然いなくて、GitHubでよくアイコンを見るあんな人やこんな人に、当時出たばかりの標準パッケージの新機能の想定ユースケースは何かとか他言語の同じ機能とどう違うかとか、G社内でどう言うツール使ってるかとか、最近力を入れた開発など、裏話をいろいろ聞けて最高だった。自分は英語のスキルをアイデンティティにはしていないが、この時ばかりは考えていることを表現できる語彙力があって良かったと心から思った(ブースを回っていた同僚と合流した後に15分くらい「やべえ」って言ってた気がする)。去り際にGopherくんキーホルダーとステッカーをもらった。

出張自体はとても楽しかったけど、飛行機の乗り換えは結構しんどかった。あと水のペットボトル1本1,000円した(7ドルとか)。

夏に開催された Google Cloud Next Tokyo と Go Conference には登壇者として参加した。どちらもかなり大規模なイベントで、この規模のイベントに登壇したことは今までになかったが、どちらもエンジニア人生を変えたかもしれないと思うくらいには良い経験だった。

Google Cloud Next Tokyoでは、なんと基調講演と同じステージという、最高に豪華な場所での登壇となった。めちゃくちゃ緊張したけど聴衆の方々が真剣に聞いてくださっているのが壇上からでもわかったし、話した内容をXでつぶやいてくれたり記事化してくださるオンラインメディアもあり(事前に営業担当の方から、「記事化されることはほとんどない」と聞いていた)、自分の話に興味を持って聞きに来てくれる人がいるというのは素晴らしい機会だった。

Google Cloudの営業担当、カスタマーエンジニアの方々には日頃から無理難題をお願いしまくって死ぬほどお世話になっているが、ラスベガスでのNextの参加者向けインプットや現地でのアテンド、東京でのRecapイベントの共同開催、Next TokyoでもリハからAsk the Expertセッションのアレンジ、登壇本番前に応援しにきてくれたり、イベント終了後のアンケート集計まで、とても親身にフォローしていただいた。

夏に開催されたGo Conferenceでは、「GoのinterfaceとGenericsの内部構造と進化」というテーマで登壇し、中には「一番印象に残った!」と言っていただいた方もいて、本当に励みになった。一方で、自分が楽しいと思ったこと、面白いと思ったことを盛り込みすぎて色々と説明を省略してしまい、一部の方から「よくわからなかった」というフィードバックもあって反省すべき点が多い出来だったとも思っている。 後悔が残ったため、後日前提や参考にした情報を補足した記事を書いた。

turbofish.hatenablog.com

今後は聞きにきてくれる人たちにより有益な時間を過ごしてもらえるよう努力したい。 ただ、挑戦してみたこと自体は自分にとってとても良い経験になった。イベント自体はセッションも面白く学びも多かったし、ワークショップや懇親会などにも参加して様々な人とお話することができ、コミュニティの良さというものを強く実感した(要はとても楽しかった)。そこで、2026年は運営にも参加することにした。 12月にキックオフをしたばかりだが、今からとても楽しみ。

そもそも人見知り(懇親会でぼっちになるタイプ)なのもあってイベントごと全般に対してかなりの苦手意識があったが、参加するだけでも興味が湧くテーマを見つけられたりしたし、さらに登壇すると自分が血反吐吐いて設計・実装・テストしたシステムの意思決定や、技術に対する自分なりの理解に対して質問や意見を寄せてくれる人と話ができるため、非常に勉強になって良かった。今後も定期的に参加していきたい。

改めて、私の登壇を聞いてくださった方、Ask the Speakerで話しかけてくださった方、アーカイブ動画や資料を見てくださった方、本当にありがとうございました。心の支えになりました。運営の皆様、素晴らしいイベントをありがとうございました。

今年は、参加者としてもできるだけイベントに足を運ぶようにした。 Go Conferenceの控え室で@ken5owataさんに誘っていただき、10月に初開催となるTinyGo Conferenceにも参加した。 TinyGo全然わからんけど大丈夫かなと思って恐る恐る当日を迎えたものの、ワークショップも充実していたし、おそらく中学生くらいぶりにはんだづけをしてとても楽しかった。 このコミュニティのフレンドリーさは本当にすごくて、愛とホスピタリティ溢れる素敵なカンファレンスだった。

マネージャーになることについて考えた(結局ならなかった)

前提として私の職場にはエンジニアリングマネージャーという役割が存在しておらず、いわゆるプレイングマネージャーとしてリーダーがメンバー育成や評価も担当する(ただしメンバーもフォロワーシップやある程度の育成を行うことは評価基準に組み込まれている)。 元々私は開発をして生きていくことが必須な人間なので、プレイングマネージャーという存在の在り方についても考え、自分がもしそうなったら毎日どう働き、どうモチベを保つのか妄想したりした。 マネージャーやるやらについては過去にも一度悩んだことがあり、時間と共に自分のやりたいことや考え方も多少変わっており今回も結構迷ったが、やはりそれなりに規模が大きい組織でマネージャーのポジションにつく場合には管理の仕事から逃れられないため、結論としてはまだまだ技術に渇いている状態で今その仕事を引き受けたら一生後悔すると感じ辞退した。


色々な観点で検討はしたが、後から考えると、目先のメリットらしきものに惑わされずに自分の人生において中長期的に頑張れる選択をできて良かった。 ただ、私もいつか自分で生産性の高いチーム作りに一役買いたいという思いはあるので、それがマネージャーという形であるかはわからないが、何らかの形でリーダーシップ面でのチャレンジもしていきたい。 マネージャー前提での動き方をしたことは現職、前職でもあるので、(どちらの時も結局マネージャーにはならなかったものの)自分のスタイルとしてどうしていきたいのかは少しづつわかってきた気がしているし、同時に自分のスキル的にもそれなりに多様な状況に対応できるようにはなってきている気がしている。

マネージャーにならないならどういうエンジニアとして生きていくのかと言う、成果を出して越境してさえいれば良かった30代の頃には想像もしていなかった先の見えなさと向き合う中で、出会いも別れも多かった1年だった。 職場が同じ人も違う人も、話を聞かせてくれた方々、聞いてくれた方々、本当にありがとうございました。 良いエンジニアになっていつか報いられるよう頑張ります。

プライベート

去年はランニングにハマっていたが、今年は人生初の筋トレのパーソナルジムに通いだし、結構筋肉がついてきたように感じる。 パーソナルはそれなりにお高いが、ジムを契約するだけだと絶対に挫折していたと思うし、何より怪我をせず自分に適切な負荷をかけることができるトレーニングやフォームを教えてもらえてとても良かった。 最初は「数ヶ月やってメニューとフォーム覚えたら自分で安いところ通おう」くらいに思っていたけど、トレーナーと雑談している時にストレッチや食事などの話も聞けたし、自力でやると確実にサボると言うタイミングも何度かあったので、なんやかんや1年近く通い続けている。

結局自力でジムに行くようにはならなかったし、寒かったり暑かったりする時に外出が億劫になるのが一番自分の行動を制約している気がしたので、屋内で多少運動ができるようウォーキングマシンを購入した。 これが今年買ったガジェットの中で一番ライフチェンジングだったかもしれない。 過去にステッパーを購入したこともあったが、体を捻る運動なので作業を同時進行することができなかった。 ウォーキングマシンであれば、歩きながら集中して話を聞くことも、コーディングをすることも全然できるため、集中して作業しながら歩いていると簡単に30分くらい経っていてとても良い。 去年読んだ脳を鍛えるには運動しかないという本に、有酸素運動と強度の高い運動を交互にやる方法が書かれていて、これが手軽に実践できるようになった。

大学院の卒業が近く(生成AIの助けもあり)課題にかかる時間も減ってきたため、今年は(いつもお世話になっている本当にありがたい)友達に便乗してワーケーションにも2回ほど行った。 長野県乗鞍高原でワーケーションした際には、帰りに生まれて初めて上高地に行った。 7月下旬にもかかわらずエアコンが必要ない涼しさに衝撃を受けた。

9月には数年貯めていたクレカのポイントを全ブッパして完全に休暇としてハワイに行き(浮かれた写真しかなかったのでXに投稿するのはやめておいた)、12月は石垣島に行って現地在住の同僚に現地情報を色々教えてもらったりもした。

沖縄本島には何度か行ったことがあったが離島は初めてで、こんなにごはんと泡盛が美味しいとは思っておらずかなり衝撃だった。中でも一番驚いたのは魚の刺身(基本一度も冷凍せずに出しているらしい)とイカ墨チャーハンと、泡盛をコーヒーで割って飲むこと(めちゃくちゃ美味しかった)。 石垣はかなり南にあるため12月なのにシュノーケリングができる暖かさで、ハワイより透明度が高く生き物が多い海を見たり、竹富島に日帰りで行ったりと、とても良い体験ができた。

ーーーーーーー

目標としていた「人生を楽しむこと」を、思ったよりも大幅に達成できてとても良い2025年だった。 2026年もちゃんと外に出て、公私共に身近な人たちと、自分の心が動く経験を大切にしたいと思う。

Happy New Year!!!

(Go) アプリケーションログとログを使用した監視ロジックの関心分離と、slogを使ったログ設計

私が担当していたLINE、PUSH、メールのマーケティング配信基盤システムでは、ロギングにzapを使用していたのですが、今年全てslogに置き換えました。その際、ログまわりの実装方針を大きく変更しました。

方針変更の背景には、構造化ログ生成のうち監視に関わるデータ設定・整形のロジックを極力アプリケーションコードに含めたくなかった(=アプリケーションロジックとは別のパッケージに隠蔽したかった)ことが挙げられます。本記事では、slogを使って「アプリケーションログ」と「ログを使った監視」を実現する際に、同じログでもロギングと監視という2つの責務を切り離すための実装方針をご紹介します。

この記事の前提として、私のチームではGoogle Cloudを使用しアプリケーションをGoで実装しています。

なお、タイトルは「slogを使った」としていますが、本手法は他のパッケージを使用しても工夫次第で実現可能であり、あくまで設計方針やコードの読みやすさにおける一つの選択肢として捉えていただければ幸いです。とはいえ、著者はslog推しです。本当はもっと細かいslogの機能とか使い方の話をしたいんですが、本記事は推し活願望をグッと堪えて書いています。

監視の方針

配信基盤システムの監視には、対応方法ごとに下記のような複数のニーズ(実際にはもう少し細かいニーズがあります)があり、(アラートの頻度を減らすためにも)状況によって開発チームへの通知方法をある程度変える必要がありました。

  • 急ぎ対応を要するため即座にアラートを鳴らしたいエラー
  • 長時間繰り返していたらアラートを鳴らしたいエラー
    • 例: 一時的なBigQueryの不調による失敗など、待てば治る可能性が高いエラー
    • ただしこの状況は、リクエストがpubsubなどのキューからリトライされることを前提としており、サーバー側から見ると複数回のリクエストを跨いでいる状態の可能性が高い(例:Cloud Runのタイムアウトは5分にしているが、監視は30分間のエラー継続で発火したいなど)ものとします
  • 対応が不要(≒アラートを鳴らされても対応できない)だがSlackに通知して欲しいログ(これを監視と呼ぶかはアレですが)
    • 例: 基盤ユーザーからのリクエストに期限が指定されており、配信前にその期限が切れた場合
      • このケースがあり得るのは、配信基盤の処理が一部fire and forget方式(リクエストを受け付けたら基盤からは早めにレスポンスを返し、その後基盤内で処理が完了するまで処理をリトライするようになっている)であり、配信状況の詳細を知りたい場合はユーザーが結果データをポーリングして確認する必要があるためです

キューからのリトライとか言うけど、実際どういうアーキテクチャになってんの?と思われた方は拙著のこちら↓をご参考ください。

techblog.zozo.com

(この時点でもしかしたらちょっと状況が特殊かもしれないのですが、変わったログベース監視をしていなかったとしてもslogは普通にログ周りの管理がしやすくなる良いツールだと思っているので、よろしければ最後までお付き合いください🙇‍♀️)

アラートに通知方法と対応優先度がいろいろあるとはいえ、アプリケーションロジックの中にそのような「監視用の処理」を愚直に実装してしまうと、ドメインやビジネスロジックに加えて保守運用方法に関するコードが混ざってきて、読んでいるときにしんどい気持ちになることがあります。アプリケーションのロジックを開発している時に、「このエラーはアラートは鳴らしたくないけどSlack通知をしたいから、この行でSlack通知をしてくれる関数を呼んでいるんだな」みたいな推測に脳のエネルギーを使いたくはありません。また、たとえ監視の方法の詳細を抽象化した関数を提供するパッケージを作ったとしても、「エラーが "リクエストを跨いで" 一定時間繰り返していた場合」を検知することができません(がんばればできるのかもしれませんがあまりやりたくはない)。

通知をモニタリングツールに任せる前提でログレベルによるアラート発火も検討しました(というかそうしていた時もありました)が、使用できるログレベルの種類は決まっているため「エラーレベルだからアラート」といった単純な分類しかできなくなってしまい、監視の種類が増える場合に柔軟性に欠ける(なぜ監視の柔軟化が必要かについては「設計の背景」セクションでご説明します)ことに加え、アプリケーションコード上でのログレベルの指定が職人芸になるリスクがありました。

そのため、まずは監視(と一部の通知)の方針として、アプリケーションログに監視用のフィールドを設定し、それをCloud Loggingでフィルタして検知し、アラートポリシーを通じて通知するようにしています。本記事において、この「監視の際にフィルタとして指定される文字列」を、タグと呼ぶことにします。 この方針を決めたことで、アプリケーションログが監視のトリガーにもなるため、ロギングの実装は簡素化されますが、関心事は結合することになります。

移行前のロガーの状況と課題

この方針の場合、実装がどのようになるか見てみます。

仮にここでは、「対応が不要だがSlackに通知して欲しいログ」には {"monitoring":"slack-only"} というフィールドを追加するというルールにします(実際にはタグも通知方法以外に多少複雑な情報を使用できるような構造にしています)。

Cloud Loggingのメトリックのフィルタ指定はこんな感じになります↓(deprecatedなMQLで失礼いたします)。

resource.type="cloud_run_revision"
AND jsonPayload.monitoring="slack-only"

このフィールドをアプリケーションログに設定するためには、zapで超素朴に実装すると下記のようなことをする必要が出てきます。

   logger.Info("failed to do something",
        zap.String("monitoring", "slack-only"),
    )

Go Playgroundで実行すると、下記のように出力されます。

{"level":"info","ts":1257894000,"caller":"sandbox3996370350/prog.go:12","msg":"failed to do something","monitoring":"slack-only"}

単純なコードであればなんとなく意図を理解できることもありますが、この実装は(ログに多くのデータが含まれ構造が複雑になったりすると特に)後からコードを読む人にとっては何が監視のフィルタとして使用されているかよくわからないために、誤ってコードを修正してしまい監視が作動しなくなることがありました。 例えば、zap.String("monitoring", "deuplication") というタグがあったとして(ここではmonitoringに指定するバリューの粒度については無視してください)、バリューである"deuplication"は重複排除の意味である "deduplication" を意味しているとします。 ここで善良な開発者が、「あっタイポしてるんだな、直してあげよう」とPRを出し、レビュワーもパッとみて「確かにタイポしてるね」という気持ちになってそのままリリースされ、元々タイポした文字列である"deuplication"でログをフィルタしていた監視が機能しなくなったことに誰も気づかないという、怖い状況が簡単に生み出されてしまいます。

これは、「ログに出力したいメッセージ(可変)」と、「監視のトリガーとなるタグ(不変)」がその重要度の違いを明示せず文字列として引数に渡されているため、どの引数がどのように使用されているかが事前知識なしにはわかりづらいことが原因だと考えました。 そのため、そもそもアプリケーションのロジックと監視対象のログを指定するロジックを分離することを検討しました。

slogを使い、ロガー用のパッケージに監視用のタグとログ整形ロジックを閉じ込める

slogは、Go1.21で新しく標準パッケージに導入されました。slogのHandlerインターフェースを使う思想はロジックの隔離にとても向いていると感じたので、今回の課題の解決に使用してみることにしました。

slogでは、Handlerインターフェースを満たすようメソッドを実装すれば、ログをフラッシュする前にHandlerOptionsのReplaceAttrやHandleメソッドに定義したロジックを実行してくれます。そのため、自前で実装するHandleメソッドでログを整形することで、ロガーを定義しているパッケージの中にログの整形ロジックを閉じ込めることができます。

具体的には、監視用のロガーと監視しない普通のログを出力するロガーを作成することにしました。監視用のロガーのコードはざっくり下記のようにしました(Go Playgroundで動くコードをご確認いただけます)。普通のログを出力するロガーは同じパッケージに別構造体として定義し、独自のHandleメソッドを実装しています。

package logger

import (
    "context"
    "log/slog"
    "os"
)

/*
NOTE: constで定義されたバリューは監視のフィルタに使用されます.

   変更する際は監視に使用されていないことを確認してください.
*/
const (
    monitoringKey = "monitoring"

    slackLogTag   = "slack-only"
)

type MonitoredLogHandler struct {
    handler    slog.Handler
    labelValue string
}

var _ slog.Handler = &LogHandler{}

func NewSlackNoticeLogger() *slog.Logger {
    return slog.New(newMonitoredLogHandler(slackLogTag))
}

func newMonitoredLogHandler(label string) *MonitoredLogHandler {
    inner := slog.NewJSONHandler(
        os.Stdout,
        &slog.HandlerOptions{
            AddSource:   true,
            Level:       slog.LevelInfo,
            ReplaceAttr: replaceAttr,
        },
    )
    return &MonitoredLogHandler{inner, label}
}

func (h *MonitoredLogHandler) Handle(ctx context.Context, r slog.Record) error {
    // ここで監視用のタグとなるフィールドを設定
    r.AddAttrs(slog.Group("monitoring", slog.String(monitoringKey, string(h.labelValue))))

    // ミドルウェアでリクエストあたり共通の情報をcontextに詰め込み、
    // ここでcontextから取得して構造化ログにセットすることができる
    id, err := contexts.GetRequestIDFromContext(ctx)
    if err == nil {
        r.AddAttrs(slog.Group("logging.googleapis.com/labels", slog.Uint64("id", uint64(*id))))
    }

    // ここにトレーシング用のr.AddAttrsが来る(気になる方はGo Playgroundで見てみてください)

    return h.handler.Handle(ctx, r)
}

// この関数は全ロガー共通
func replaceAttr(_ []string, a slog.Attr) slog.Attr {
    // Google Cloud の Cloud Logging に対応したフィールドに変換している
    switch a.Key {
    case slog.MessageKey:
        return slog.String("message", a.Value.String())
    case slog.LevelKey:
        if a.Value.String() == slog.LevelWarn.String() {
            return slog.String("severity", "WARNING")
        }
        return slog.String("severity", a.Value.String())
    case slog.SourceKey:
        a.Key = "logging.googleapis.com/sourceLocation"
    }
    return a
}

// 以下インターフェースを満たすための実装
func (h *MonitoredLogHandler) Enabled(ctx context.Context, level slog.Level) bool {
    return h.handler.Enabled(ctx, level)
}

func (h *MonitoredLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &LogHandler{h.handler.WithAttrs(attrs)}
}

func (h *MonitoredLogHandler) WithGroup(name string) slog.Handler {
    return &LogHandler{h.handler.WithGroup(name)}
}

Go Playground

監視に使用するフィールド名やフィルタとして使う文字列(本記事でタグと呼んでいるもの)をconstにまとめて全てunexportedにすることでアプリケーションコードからいじることができないようにし、監視に使用されていることをコメントで記載してそのブロックのコードを修正することに対する注意を呼びかけています。こうすることで、「監視に使うフィルタ(タグ)とログ整形ロジックはすべてロガー用のパッケージ内を見れば良い」状態を実現しました。

パッケージを使用する側はこんな感じ↓で監視用のロガーを必要な箇所で作成し、ログメッセージを渡すだけで(下に続く)

func main() {
    ctx := context.Background()
    slog.SetDefault(logger.NewLogger()) // デフォルトのロガーの作成&設定(以降全てのコードで適用される)
    slog.InfoContext(ctx, "普通のログ")

    // ロジック色々...
    l := logger.NewSlackNoticeLogger() // 監視用のロガーの作成
    l.InfoContext(ctx, "監視用のログ")
}

監視したいログに(アプリケーションコードで指定していない)監視で使用する"monitoring"フィールドが追加されます。

{"time":"2009-11-10T23:00:00Z","severity":"INFO","logging.googleapis.com/sourceLocation":{"function":"main.main","file":"/tmp/sandbox559092726/main.go","line":13},"message":"普通のログ"}
{"time":"2009-11-10T23:00:00Z","severity":"INFO","logging.googleapis.com/sourceLocation":{"function":"main.main","file":"/tmp/sandbox559092726/main.go","line":17},"message":"監視用のログ","monitoring":"slack-only"}

コード全体を見ないとどうなっているか分かりづらいかもしれないので、よかったらGo Playground で遊んでみてください。ただし、今回最低限の雰囲気を掴んでいただくためロガーの実装としては色々省略しており、本番環境でこのまま使えるソースコードになっていない点はご留意ください。

この設計では、監視の種類を増やすごとに監視用のフィールドの定義とロガーのコンストラクタを増やすことになります。ロガー(≒監視の種類)を追加する際の修正は下記のようになり、たとえタグを多少複雑なものにしたとしてもそんなに大きな範囲での改修をする必要はありません。

const alertLogTag   = "alert"

func NewAlertLogger() *slog.Logger {
    return slog.New(newMonitoredLogHandler(alertLogTag))
}

設計の背景

注意点として、監視対象のログを出力したい場合には、必ず先に適用させたい監視に対応するコンストラクタを呼ぶ必要があります。 そのため、ロガーを使用する側の状況としては「可変ぽい顔をしてるけど実は副作用を持つ危険な文字列の引数指定をしなくてよくなった」のは事実ですが、いずれにせよどの監視の対象のログなのかを判断して実装する必要があるという意味では、アプリケーションコードから完全に監視の観点がなくなったわけではありません。 私のチームでは監視が発火した際の対応方法の整備と調査のしやすさが最大の関心ごとだったので、完全にアラートや通知の背景がわからないコードになってしまうのもそれはそれで良くなく、この程度の落とし所が一番認知負荷が低そうと感じています。

実装に関する別の選択肢として、まず冒頭に挙げた「監視の通知方法を抽象化する関数をまとめてパッケージ化して提供すれば良いのでは」という方法については、「リクエストを跨いだエラーを検知する必要がある」ケースのみ例外としてログのフィルタリングを活用したCloud Monitoring経由の通知にすれば実現できます。Cloud Monitoringのアラートポリシーでは、"XX分以上閾値を超えていたら", "一定時間にn回以上発生したら"のような指定もできるためです。 zenn.dev

しかし、今回の実装にすることで、全ての監視パターンの検知と通知の実現方法を同じ(つまり、通知方法はコードベースに一切持たず、ログの特定のタグをフィルタリングしてトリガーするという方法に統一する)にすることで、監視方針のシンプル化と柔軟な設計の余地を残すことができたと考えています。

次に、監視用のロガーを作成するのではなく出力時によしなに整形される専用の定義型(slogであればslog.Recordをラップした型になりそう)を作り、ログメッセージには必ずその型を使ってね、というルールにすることもできるとは思います。 その場合も、ロガーパッケージにexportedな定義型を作り、必要なデータはunexportedで定義してロガーパッケージ内で整形すれば同じような状況を作れます。zapを使用する場合は、おそらくこの方針になりそうな気がしています(最近zap触ってないので違ったらすいません)。 この方針でも、監視の種類に対応する型を選定する時点で、(よほどシステマチックなルールを制定してそれすら意識しなくて良いようにしたりしない限り)同様に運用ルールを見越した型の選択が必要になるでしょう。

この点は好みや状況によるかとは思いますが、今回の私の判断としては、全体的な監視の方針が「監視対象となるログに特定のタグをつけ、それをフィルタに使用する」方法だったために、タグの定義のみで監視の種類を表現できるようにしたいと考えました。 一方で、決められたキーで決められた形式のバリューを指定するという処理は監視の対象となる全てのログで共通していたことから、(Goはオブジェクト指向言語ではありませんが)ログメッセージを表現する定義型の振る舞い的なノリで実装したくなかったのです。

また、ロガーを使用する側としても、「ロガーを入れ替えれば監視対象になったりならなかったりする」ようなコードにした方が、監視とログの責務の分離をコード上で(見かけ上)分けることができて、監視の種類によってログメッセージをラップする型を入れ替えるよりは分かりやすいように思えました。もし監視の観点がなくて純粋にメッセージを整形したいというニーズだったら、定義型を使っていた可能性が高いです(これもただの私の好みですよね...なんかすいません...😇)。

最後に、現時点ではこのようなニーズはないので妄想にはなるのですが、slogのHandleメソッドにロジックを寄せるとcontext.Contextに入れたリクエストスコープの値を使えるので、リクエストに関連する監視を作りたいといったニーズが出てきた場合に柔軟化の実装が多少楽になるかも知れないと言う思惑もありました。 監視の柔軟化をなぜ視野に入れる必要があるかというと、配信にはキャンペーンという概念があり、キャンペーンによってある程度配信の性質が異なるなどの背景があるため、配信基盤側でもリクエストの性質の違いに対応する必要性も捨てきれなかったためです。 リクエストスコープで使用する情報をミドルウェアでcontextに値を詰め込んでそれをアプリケーションコードで使用する、というのはよくある実装かと思いますが、定義型のメソッドで監視用のログ生成を行う実装だった場合、contextに含まれる値を使用したいときはその型のインスタンスもしくはメソッドにcontext自体もしくは使用したいデータをcontextから取り出して引数として渡す必要があります。一方でslogの場合は、Handleメソッド内にログ整形ロジックがあれば、ログ出力時にcontext.Contextを使用するメソッド(slog.ErrorContextなど)を使うだけでロガーの種類ごとに定められたロジックでcontext.Contextも使って処理してくれるため、監視対象であろうがなかろうが全てのロガーで変わらない使い勝手にすることができます。

結果

この記事の内容は、今年の正月くらいにアラート対応が辛すぎたことをバネにメンバーの日常の幸せを守るという使命感のもと頑張って設計しました。 1年程度経った今振り返ってみても、開発時に監視を無効化してしまうかもしれないと怯えずに済むようになったし、きめ細かい監視を実現しやすくなり運用負荷も低い状態になったと感じています。

アラートが減ったのはこのロガーのおかげだけではないにせよ、やはり地道に監視の性質をチューニングし、不要な呼び出しを減らして自動的に復旧する仕組みを整えたりする努力を行なうこと、そしてそのベースとなる今回のような設計を行うことはとても大切だと思っています。 設計は将来に備えることと同時に不要な柔軟性を前提としないことも大切なので、具体的に何をどう実装するのが正解なのかは難しいところですが。

みなさん監視方針ってどうやって設計してるんすかねー。気になる。

参考資料

↓ロギングって奥が深い... go.dev

↓slogのハンドラ実装ガイド github.com

↓Google Cloudより、Cloud Tracingを使用する際のslogのHandlerインターフェースの実装例が公開されています。

github.com

↓トレーシングというテーマなのでロガーの話は全然していないのですが、私の部でslogを使っている事例です。このチームも元はzapを使用しており、勉強会で部内の複数チームのエンジニアとslogの良さについて話して部全体で導入の機運が広がったので、個人的に思い出深いブログです。

techblog.zozo.com

(Google Cloud) Cloud Schedulerだけで、gcloud コマンド相当のことを定期的に行う

はじめに

この記事では、Google CloudのマネージドなcronサービスであるCloud Schedulerを活用してGoogle Cloud APIを叩くことによって、Cloud RunやCloud Functionsといった追加のコンピューティングリソースを使用することなく、gcloudコマンド相当の操作をサーバーレスかつ定期的に実行する方法を紹介します。

ただし、「GitHub Actionsなどを定期実行を行うランナーとして使ってgcloudコマンドを実行する処理を書けば良いのでは?」というご意見はおっしゃる通りです。 Cloud Schedulerだとterraformで管理もできますし、CIだとWorkload Identityの設定が必要になるなどの前提もあると思うので、管理場所の認知負荷と(場合によっては)実装コストなどでお好みでやり方を選ぶのが良いかと思います。

また、本記事の方法では、複数のAPI呼び出しを連続して行うような複雑なワークフローについては実現できません。その場合は、Cloud Run もしくは Cloud Workflows の実行や、Pub/Sub を介した Cloud Composer などのワークフローサービスと組み合わせることが必要になります。

Cloud Schedulerとは

Cloud Schedulerは、Linuxで使われるcron(定期実行)コマンドの機能を、サーバーレスで提供するサービスです。 2025年11月末時点では、ターゲットとしてHTTP、Pub/Sub、Cloud Workflows が選択できます。 しかし、Cloud Schedulerではgcloudコマンドを直接実行することはできません。

なんで機能として存在しないのかについてはおそらく内部的な事情があるのだろうとは思うのですが、私の場合は、例えば 下記のようなニーズがたまにあります。

  • Cloud Composer の開発環境を夜間だけ削除したい(環境が稼働した時間で課金されるため)
  • 定期的にCloud Runをデプロイし直したい
  • Compute EngineのMIG内のインスタンスを定期的に入れ替えたい

サービスアカウントとOAuthトークンによるGoogle Cloud API直接呼び出し

本記事では、例としてCloud Composer の環境を削除するCloud Schedulerジョブを実際に作成してみます。

Cloud Schedulerの画面から「ジョブを作成」ボタンを押し、スケジュールを定義してから(下に続く)

「実行内容を構成する」セクションの「ターゲットタイプ」でHTTPを選択しましょう。

次にAPIのURLを指定します。 公式ドキュメント「Cloud Composer 環境を削除する」に、「Cloud Composer環境を削除する」というセクションがあり、そこにどのAPIを使えば良いかが書かれています。

このAPIのリンクをクリックすると、APIドキュメントに遷移しますAPIドキュメントには、エンドポイントのフルパスとHTTPメソッドが記載されています。 これを、Cloud Schedulerのジョブ作成画面の「URL」に記載します。

次に、ジョブ作成画面の「サービスアカウント」に、指定したAPIを実行する権限のあるサービスアカウント(SA)を指定します。やりたい処理に必要な権限も、APIドキュメントで確認できます。今回は「composer.environments.delete」が必要なようですね。

Cloud SchedulerのHTTPヘッダー設定には、「Authヘッダー」を選択できるプルダウンがあり、そこで「OAuthトークンを追加」を選択することで、指定したSAの権限を持つOAuth 2.0アクセストークンを生成し、リクエストに含めることができます。必要であればスコープも指定します。

全て入力するとこんな感じ↓になります。

POSTであれば JSON ペイロード(リクエストボディ)も設定できます。

「作成」ボタンを押下してジョブを作成したら、一覧画面から作成したジョブを選択して「強制実行」するか、(下に続く)

もしくは「操作」の3点アイコンから「強制実行」をクリックして動作確認することができます。

注意点

このやり方は、gcloudコマンドの抽象化層を通さないため、操作対象APIの正確なURL、HTTPメソッド、JSONペイロード(リクエストボディ)を、Google CloudのAPIリファレンスで確認する必要があります。

また、Google Cloudの API を経由した管理操作の多くは非同期で実行されます。Cloud SchedulerはAPIのレスポンス(操作ID)を受け取るだけで、操作の完了を待つ機能はありません。後続の処理の成功を確認したい場合は、他の仕組みを組み合わせる必要があります。 例えば、今回実行したCloud Composerの環境削除には10分以上時間がかかることがありますが、ジョブは環境削除の実行を開始したのち数秒で成功状態で完了します。 そのため、操作の完了を検知したい場合は別の仕組みが必要になることがあります。

一方でgcloudコマンドの場合は、デフォルトで操作の完了を待つ挙動をする場合が多い気がします(--asyncオプションをつけると非同期になる)が、これについてもGitHub Actionsなどで実行する場合は操作の完了を待つ間課金されることを考えると、非同期実行にして完了の検知を別途行なった方が良い場合もあるかもしれません。

参考資料

docs.cloud.google.com

docs.cloud.google.com

(Go) 設定ファイルのパスを相対パスにした場合、設定を使用する他のレイヤでの単体テストに困った話

書いた後にこの↓記事と言いたいことが丸被っていたことに気づいた。

zenn.dev

この記事の内容はディレクトリ構成によって挙動が変わるため、実際にどう動くのか検証しやすいよう、サンプルコードを公開したので、よかったらcloneして動かしてみてください。 READMEに遊び方を記載しておきました

xxx.toml: no such file or directory

業務で、ヘキサゴナルアーキテクチャでGoのアプリを開発し、設定ファイルをTOMLで管理することが多い。

ディレクトリ構成(イメージです)

├── adapter
│   ├── driven
│   │    └── db.go                configパッケージを呼び出す
│   └── driver
│       ├── get_something_test.go driven adapterを初期化する処理を含む単体テスト。設定ファイルがないと言われ失敗する
│       └── get_something.go      driven adapterを初期化し、処理を行う
...
├── config
│   ├── environmtnes               設定ファイル
│   │    ├── dev.toml
│   │    ├── local.toml 
│   │    ├── prd.toml
│   │    └── stg.toml
│   └── config.go                 tomlファイルをパースして、構造体にしている
...
└── main.go                       エントリポイント

config ディレクトリの設定ファイルを環境準備し、環境変数で指定された環境名を使ってファイルを読み込む処理を下記のように書いている。
docker の中だけで動かす場合には、Dockerfile内でTOMLファイルをコピーしてGoでは絶対パス指定でファイルをOpenすれば良いのかもしれないが、Goのコードをローカル環境でシュッと動かしたいとき(特に単体テスト実行時など)があるので、相対パスでファイルを指定している。

config/config.go の、tomlファイルをパースする処理

type Config struct {
    xxx string `toml:"xxx"`
}

func NewConfig() Config {
    env := os.Getenv("ENV")
    if env == "" {
        env = "local"
    }
    filepath := "./config/environments/" + env + ".toml"
    file, err := os.Open(filepath)
    if err != nil {
        panic(err)
    }
    defer file.Close()

    conf := &Config{}
    _ = toml.NewDecoder(file).Decode(conf)
    return *conf
}

このconfigパッケージのコンストラクタである NewConfig 関数は、adapterディレクトリにあるパッケージから呼び出される。

このコードの場合、ルートディレクトリからmain.goを実行すると正しく設定ファイルを読み取れるが、adapterディレクトリ下に単体テストを書いた場合、単体テストにおいてはそんなファイルないよ、というエラーになってしまう。

これはファイルパスの指定が相対パスであるために起こっており、カレントディレクトリがどこなのかが実行場所によって変わることが原因。 Goカレントディレクトリをどこだと認識しているのかは、下記のようなコードで確認できる。

   currentDir, _ := os.Getwd()
    fmt.Printf("current directory: %s\n", currentDir)

go run main.go時と単体テスト時で、出力されたcurrent directoryが異なるのがわかる↓

単体テストのカレントディレクトリを移動させた

Goでは、カレントディレクトリが実行ファイルを起動した場所に固定される。 そのため、アプリケーションのエントリポイントと単体テストの実行パッケージでカレントディレクトリが異なる。 そこで、TestMainでテスト実行時のカレントディレクトリを移動させることで解決した。

adapter/driver ディレクトリ下に、下記のTestMainを定義した。

import (
    "os"
    "testing"
)

func TestMain(m *testing.M) {
    repoRoot := "../.."
    err := os.Chdir(repoRoot)
    if err != nil {
        os.Exit(1)
    }

    exitCode := m.Run()
    os.Exit(exitCode)
}

冒頭の記事でも言及されていたが、カレントディレクトリを元に戻したい時はそのような処理を書いておくと良さそう。

(Google Cloud) Cloud Run Jobに引数を渡したら"Application failed to start"と言われ実行できなくなった話

Cloud Run Jobを実行するとき、GUIから「コンテナに引数を渡す」を設定する、もしくはgcloud run deployコマンドで --args オプションを設定することなどにより、アプリケーションで引数を受け取ることができる。

$ gcloud run jobs execute sample-job \
    --region $DEFAULT_COMPUTE_REGION \
    --args="--param1=test,--param2=1"

この引数を指定する前は普通に実行できていたはずなのに、「コンテナに引数を渡す」を設定すると"terminated: Application failed to start: The container may have exited abnormally."というエラーが出て実行できなくなってしまった。

これは、DockerfileのCMDをENTRYPOINTに変えると直った(これまでCloud Runで使用するコンテナのDockerfileでCMDを使っておらず、たまたまこの時生成AIの影響か何かでCMDが紛れ込んでいた)。dockerの、コンテナ実行時にdocker runコマンドの引数でCMDを上書きする挙動になったものと思われる。 よく見ると、GUIでも「エントリ ポイント コマンドに渡される引数。」と書かれている。

参考: [docker] CMD とENTRYPOINT の違いを試してみた

公式ドキュメント サービスのコンテナを構成するより抜粋:

Cloud Run がコンテナを起動すると、イメージのデフォルトのエントリポイント コマンドとデフォルトのコマンド引数が実行されます。イメージのデフォルトのエントリポイントとコマンド引数をオーバーライドする場合は、コンテナ構成の command フィールドと args フィールドを使用できます。command フィールドは、コンテナによって実行される実際のコマンドを指定します。args フィールドは、そのコマンドに渡される引数を指定します。