STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Cloud Runのメモリが3日で枯渇!犯人は10万のGoroutineとgRPCクライアントだった話

はじめに

この記事はSTORES Advent Calendar 2025の17日目の記事です。

顧客向けIdPを開発している佐野です。

本記事では、Cloud Run上で動作するGoサーバーで発生したメモリリークの問題と、その解決までの道のりを紹介します。

ある日、デプロイ後わずか3日でメモリ使用率が95%を超えるという深刻なアラートが鳴り響きました。 「GoはGCがあるからメモリ管理は楽なはずでは?」——そんな甘い考えを打ち砕く、10万のGoroutine隠れたgRPCクライアントとの戦いの記録です。

起きていた問題

今年の5月中旬頃、ネットショップの全会員を顧客向けIdP基盤に移行する対応を実施しました。

数日後、本番環境のCloud Runで動作しているGoサーバーのメモリ使用率が、デプロイ直後から右肩上がりに増え続け、3日程度で上限の95%に達していました。

一時的な対策として、Cloud Runのメモリ割り当てを増やして再デプロイすることで急場を凌ぎましたが、グラフの傾きが変わることはありませんでした。これは明らかにアプリケーション内部で何かがリークしています。

調査プロセス

1. 犯人はメモリではなくGoroutine?

まず、Datadogのプロファイリング機能を使って、メモリ増加と相関のあるメトリクスを探しました。そこで目に飛び込んできたのが、Goroutine総数の異常な増加です。

メモリ使用率が増加するのと同じ期間に、Goroutine総数が劇的に増加しており、ピーク時には約10万ものGoroutineが存在していました。

通常のWebサーバーであれば、Goroutine数はリクエスト数に応じて増減するものの、ベースラインは数百〜数千程度で安定するはずです。10万という数値は、Goroutineリークが発生していることを強く示唆していました。

2. pprofでブロック箇所を特定

次に、「この大量のGoroutineは何をしているのか?」を突き止めるため、pprof (trace) を確認しました。

プロファイルを見ると、google.golang.org/grpc/internal/grpcsync.(*CallbackSerializer).run の内部で長期間ブロックされている(待機状態の)Goroutineが大量にスタックしていることが判明しました。

3. 真犯人の特定

依存ライブラリを調査した結果、アプリケーション内で利用している cloud.google.com/go/cloudtasks(Cloud Tasks SDK)が、内部的にgRPCを利用していることが分かりました。

さらにアプリケーションコードを追うと、疑惑の判定は確信に変わりました。 Cloud Tasksにリクエストを投げるMiddleware UseOutboxImmediately の実装です。

このMiddlewareは、ユーザー情報の更新などをトリガーに、他システムへ変更内容を即座に通知する(Outboxを処理する) ためのものです。 Webサーバーのリクエスト処理の中でContextにOutbox(通知データ)がセットされると、このMiddlewareが検知し、Cloud Tasks経由で通知を飛ばす仕組みになっていました。

// 問題のあった実装イメージ
func (c *CloudTasksClient) Send(ctx context.Context, data []byte, queueName string, workerPath string) error {
    // ... リクエストの準備 ...

    // ⚠️ ここで毎回新しいクライアントを作成している
    cli, err := cloudtasks.NewClient(ctx)
    if err != nil {
        return wrapErr(err)
    }

    // ⚠️ Close() を呼んでいない!
    _, err = cli.CreateTask(ctx, req)
    return err
}

Middlewareはサーバーへのリクエスト単位で動作します。 つまり、ユーザー情報更新のリクエストが来るたびに NewClient が実行され、gRPCクライアントが生成され、Closeされずに放置される という状態になっていたのです。

4 なぜGoroutineが増え続けたのか

cloudtasks.NewClientは単なる構造体の初期化ではありません。内部でgRPC接続を確立するため、以下のようなリソースを生成する「重い」処理です。

  • 接続管理用のGoroutine:サーバーとの接続を維持・監視する
  • コネクションプール:接続を再利用するためのプール

これらのリソースは、明示的に client.Close() を呼ばない限り解放されません。

実は、今回使用していた cloudtasks.NewClient のGoDocを確認すると、そこには今回の事象の原因と、あるべき実装の答えが全て書かれていました。

Clients should be reused instead of created as needed. The methods of Client are safe for concurrent use by multiple goroutines. The returned client must be Closed when it is done being used to clean up its underlying connections.
(以下日本語訳 クライアントは必要に応じて作成するのではなく、再利用すべきです。Clientのメソッドは複数のゴルーチンによる並行使用に対して安全です。返されたクライアントは、基礎となる接続をクリーンアップするために、使用後に必ずCloseされなければなりません。)

pkg.go.dev

ドキュメントをしっかり読んでいれば防げた問題でした。仕様を確認することの重要性を改めて痛感します。

修正内容

原因と公式の推奨実装が分かれば、修正方針は明確です。 「Clientをサーバー起動時に1つだけ作り、それを使い回す(Singletonパターン)」ように変更します。

1. Application構造体でクライアントを保持

DI(依存性の注入)をしやすくするため、Application構造体にクライアントを持たせます

type Application struct {
    // ... DBなど他の依存 ...
    CloudTasksClient interfaces.CloudTasksClient  // 追加
}

// サーバー終了時にCloseするためのメソッド
func (a *Application) Close() error {
    return a.CloudTasksClient.Close()
}

2. サーバー起動時に生成 (Singleton)

main.go などのエントリーポイントで、起動時に一度だけ生成し、終了時に Close するよう変更しました。

func main() {
    // ... 設定の読み込み ...

    // サーバー起動時にクライアントを生成
    cloudTasksClient, err := implements.NewCloudTasksClient(...)
    if err != nil {
        log.Fatal(err)
    }

    app := pomelo.NewApplication(conf, db, cloudTasksClient)

    // ... サーバーの起動 ...

    // サーバー終了時に確実にCloseしてリソースを解放
    if err := app.Close(); err != nil {
        slog.Error("failed to close application", util.ErrAttr(err))
    }
}

3. CloudTasksClientの実装

修正後のクライアント生成コードです。

type CloudTasksClient struct {
    Client     *cloudtasks.Client  // 保持して使い回す
    // ...
}

// 起動時に一度だけ呼ばれるコンストラクタ
func NewCloudTasksClient(projectID, locationID string, useEmulator bool) (*CloudTasksClient, error) {
    c := &CloudTasksClient{ /* ... */ }
    ctx := context.Background()
    c.Client, _ = cloudtasks.NewClient(ctx)

    return c, nil
}

// Sendメソッドは保持しているクライアントを使用
func (c *CloudTasksClient) Send(ctx context.Context, ...) error {
    _, err := c.Client.CreateTask(ctx, req)
    return err
}

// 終了処理
func (c *CloudTasksClient) Close() error {
    if c.Client != nil {
        c.Client.Close()
    }
    // ...
    return nil
}

この修正をdeploy後、メモリ使用率とGoroutine数は劇的に低下し、平穏な日々が戻ってきました。

まとめ

今回の教訓

  1. SDKクライアントの初期化コストを甘く見ない: NewClient 系は内部でコネクションやGoroutineを生成することが多いため、リクエストごとの生成は避けるべきです。

  2. 公式ドキュメント(GoDoc)を必ず読む: 「Clients should be reused」「Must be Closed」という重要な仕様は、ドキュメントの冒頭に書かれています。

  3. メモリリークを疑うときはGoroutine数も見る: Goにおいて「メモリが増え続けている」場合、データ構造の肥大化だけでなく、Goroutineリークが原因であるケースが多々あります。

同じようなメモリ増加に悩んでいる方の参考になれば幸いです。