⚙️

GoにおけるWorkerパターンとGraceful Shutdownを理解する

に公開

GoにおけるWorkerパターンとGraceful Shutdownを理解する

Goでバックグラウンド処理とGraceful Shutdownを実装する機会があり、実際のコードを読みながら理解を深めたので、その内容をまとめます。

はじめに

Webアプリケーションを開発していると、こんな処理が必要になることがあります:

  • 期限切れのセッションを定期的に削除したい
  • キューに溜まったジョブを順次処理したい
  • キャッシュを定期的に更新したい

これらは「バックグラウンドジョブ」として実装されます。しかし、単純にgoroutineで動かすだけでは、アプリケーション終了時に処理途中のジョブが強制終了されてしまいます。

Goでは goroutine + channel + context で、安全に停止できるWorkerを実装できます。

他言語との対応
C#なら BackgroundService + CancellationToken、Javaなら ExecutorService + shutdown() にあたります。

題材:期限切れ予約のクリーナー

今回実装したのは、チケット予約システムで「15分経っても支払いが完了しない仮予約を自動キャンセルする」Workerです。

Workerの実装

基本構造

package worker

import (
    "context"
    "time"
)

type ExpiredReservationCleaner struct {
    reservationService ReservationCleaner
    interval           time.Duration  // 実行間隔
    expireAfter        time.Duration  // 期限
    stopCh             chan struct{}  // 停止シグナル
    doneCh             chan struct{}  // 完了通知
}

ポイント: 2つのチャネル

チャネル 役割
stopCh 「止まれ」というシグナルを受け取る
doneCh 「止まった」という完了を通知する

Startメソッド

func (c *ExpiredReservationCleaner) Start(ctx context.Context) {
    ticker := time.NewTicker(c.interval)
    defer ticker.Stop()
    defer close(c.doneCh)  // 終了時に完了を通知

    for {
        select {
        case <-ctx.Done():
            // アプリケーションからの停止シグナル
            return
        case <-c.stopCh:
            // 明示的な停止シグナル
            return
        case <-ticker.C:
            // 一定時間経過 → 処理実行
            c.cleanup(ctx)
        }
    }
}

select

select複数のチャネルを同時に待つ構文です。どれかのチャネルからデータが届いた時点で、該当するcaseが実行されます。

select {
case msg := <-ch1:
    // ch1 からデータが来た
case msg := <-ch2:
    // ch2 からデータが来た
case <-time.After(5 * time.Second):
    // 5秒経過(タイムアウト)
}

time.NewTicker

指定した間隔で、チャネルに現在時刻を送り続けるタイマーです。

ticker := time.NewTicker(1 * time.Minute)

for {
    <-ticker.C  // 1分ごとにブロック解除
    fmt.Println("1分経過")
}

Stopメソッド

func (c *ExpiredReservationCleaner) Stop() {
    close(c.stopCh)  // 停止シグナルを送る
    <-c.doneCh       // 完了を待つ
}

完了を待つ理由

待たないと、Workerが処理途中でもプログラムが終了してしまいます。

待たない場合:

待つ場合:

Graceful Shutdownの実装

main.go での使い方

func main() {
    // 1. キャンセル可能なコンテキストを作成
    ctx, cancel := context.WithCancel(context.Background())
    
    // 2. Workerを作成・開始
    cleaner := worker.NewExpiredReservationCleaner(
        reservationService,
        1*time.Minute,   // 1分ごとにチェック
        15*time.Minute,  // 15分経過した予約をキャンセル
    )
    go cleaner.Start(ctx)  // goroutineで開始

    // 3. HTTPサーバー起動(別goroutine)
    go func() {
        if err := e.Start(":8080"); err != nil {
            log.Fatal(err)
        }
    }()

    // 4. シグナルを待つ
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit  // Ctrl+C または kill を待つ

    // 5. シャットダウン開始
    log.Println("シャットダウン開始...")

    // 6. Workerを停止
    cancel()          // ctx.Done() を発火
    cleaner.Stop()    // 完了を待つ
    log.Println("Worker停止完了")

    // 7. HTTPサーバーを停止
    shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer shutdownCancel()
    if err := e.Shutdown(shutdownCtx); err != nil {
        log.Error(err)
    }
    log.Println("サーバー停止完了")
}

シャットダウンの流れ

context.WithCancel

Goの context パッケージを使うと、キャンセル信号を伝播させることができます。

// 親コンテキストからキャンセル可能な子コンテキストを作成
ctx, cancel := context.WithCancel(context.Background())

// cancel() を呼ぶと...
cancel()

// ctx.Done() チャネルが閉じられる
<-ctx.Done()  // ブロック解除

contextを使う理由

  1. 伝播: 親がキャンセルされると子も自動でキャンセルされる
  2. 標準: Goのライブラリは context を受け取るのが慣習
  3. タイムアウト: WithTimeout でタイムアウトも設定できる
// 30秒でタイムアウト
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

select {
case result := <-doSomething(ctx):
    fmt.Println(result)
case <-ctx.Done():
    fmt.Println("タイムアウト!")
}

stopChctx.Done() の両方がある理由

このコードでは stopCh は冗長です。cancel() を呼べば ctx.Done() が発火するので、stopCh がなくても動きます。

両方ある理由(設計上の意図):

  1. 将来の拡張性: context を使わずに Worker 単体で停止させたい場合に対応
  2. 明示性: 「このWorkerには明確なStop()メソッドがある」というAPI設計
  3. テスタビリティ: 単体テストで context なしでも停止できる

シンプルにするなら:

func (c *ExpiredReservationCleaner) Start(ctx context.Context) {
    ticker := time.NewTicker(c.interval)
    defer ticker.Stop()
    defer close(c.doneCh)

    for {
        select {
        case <-ctx.Done():  // これだけでOK
            return
        case <-ticker.C:
            c.cleanup(ctx)
        }
    }
}

func (c *ExpiredReservationCleaner) Stop() {
    <-c.doneCh  // 完了を待つだけ(cancelはmain側で呼ぶ)
}

テスト

Workerのテストでは、実際に時間を待つのではなく、短い間隔でテストします。

func TestExpiredReservationCleaner(t *testing.T) {
    // モックサービス
    mockService := &MockReservationCleaner{
        CancelCount: 0,
    }

    // 短い間隔でテスト
    cleaner := NewExpiredReservationCleaner(
        mockService,
        10*time.Millisecond,  // 10ms間隔
        1*time.Second,
    )

    ctx, cancel := context.WithCancel(context.Background())
    go cleaner.Start(ctx)

    // 50ms待つ → cleanup が数回実行されるはず
    time.Sleep(50 * time.Millisecond)

    cancel()
    cleaner.Stop()

    // 検証
    assert.GreaterOrEqual(t, mockService.CancelCount, 3)
}

まとめ

Workerパターンのポイント

  1. for + select でイベントループを作る
  2. time.Ticker で定期実行
  3. context.Done() で停止シグナルを受け取る
  4. defer close(doneCh) で完了を通知

Graceful Shutdownのポイント

  1. シグナルを捕捉: signal.Notify で SIGINT/SIGTERM を待つ
  2. Workerを停止: cancel() + Stop() で安全に停止
  3. 完了を待つ: 処理途中のジョブが終わるまで待機
  4. HTTPサーバーを停止: 接続中のリクエストが終わるまで待機

使用したGoの機能

機能 用途
context.WithCancel キャンセル可能なコンテキスト作成
time.NewTicker 定期的なイベント発生
select 複数チャネルの同時待機
chan struct{} シグナル専用チャネル(データなし)
signal.Notify OSシグナルの捕捉

参考リンク

Discussion