GoでGraceful ShutdownをFxを用いて実現する

ogp

この記事は enechain Advent Calendar 2023 の21日目の記事です。

はじめに

みなさん、はじめまして。enechainでエンジニアリングマネージャーを務めているyagi2です。

enechainでは、複数のバックエンドサービスを運用しており、それらはGoogle Kubernetes Engine上で動作しています。これらのサービスに関して、Graceful Shutdownの実装は重要な課題の一つです。今回の記事では、まずGraceful Shutdownについての基本から始め、Go言語で書かれたサーバーアプリケーションにおける実装方法を探ります。最終的には、DIライブラリである uber-go/fx を使用した実装方法をサンプルコードと共に紹介します。

私自身、もともとはモバイルアプリエンジニアとしての経験があり、今年からバックエンド開発に携わっています。これまでに3つのバックエンドサービスの立ち上げを経験しました。Fxを利用することで、バックエンド開発の初心者だった私でも直感的にGraceful Shutdownが実現できました。 この記事では、「GoにおけるGraceful Shutdownの実現にどのような課題が存在し、Fxを使うことでどのように解決されるのか」に焦点を当てて解説していきます。

Graceful Shutdownとは

サーバーアプリケーションが終了シグナル(例えば SIGTERMSIGINT)を受け取った際に、安全にサーバーを停止させるプロセスを指します。

終了シグナルを受け取ると、アプリケーションは新たなリクエストを受け付けず、処理中のリクエストを完了させた後にサーバーを停止します。これにより、ログの欠損やデータの不整合を防ぐことが可能です。

冒頭で触れたように、弊社ではGoogle Kubernetes Engineを使用しています。アップデートリリース時にPodのRolling Updateを行う際に、Graceful Shutdownが特に重要になります。正しい設定を行わない場合、Rollout時に進行中のリクエストが強制的に切断され、500系エラーが発生する可能性があります。

リリース時に毎回メンテナンスモードを使用してサービスを停止することも一つの方法ですが、Rolling Updateを活用できる環境では、エンジニアのリリースに対する心理的負担が軽減され、同時にユーザー体験も向上させることができます。Graceful Shutdownの実装は、これらの利点を最大化するために重要です。

Goを用いたサーバーアプリケーションでのGraceful Shutdown

Go言語の net/http パッケージには、Shutdown(ctx context.Context) というメソッドが用意されています。このメソッドは、サーバーのGraceful Shutdownを行う際に基本的な方法となります。

Shutdown メソッドの処理フローは以下の通りです

  1. 開いているリスナーを全て閉じる。
  2. アイドル状態のコネクションを全て閉じる。
  3. コネクションがアイドル状態に戻るまで待機する。
  4. Shutdownを実行する。

一度このメソッドが呼び出されると、再利用はできません。再度メソッドを呼び出すと ErrServerClosed エラーが返されます。

このメソッドを使用することで、新たなリクエストを受け入れずに、進行中のリクエストを完了させることが可能です。

具体的なコード例は以下の通りです。

func main() {
    // 中断シグナルを受け取った際に通知を処理する
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
    defer stop()

    srv := &http.Server{
        Addr:    "0.0.0.0:8888",
        Handler: handler,
    }

    // サーバーを起動
    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            fmt.Errorf("ListenAndServe returned error: %v", err)
        }
    }()

    // シグナルを受け取った際にサーバーをGracefulにShutdownする
    <-ctx.Done()
    if err := srv.Shutdown(ctx); err != nil {
        fmt.Errorf("Failed to shutdown server: %v", err)
    }
}

この実装により、GoでサーバーアプリケーションをGraceful Shutdownすることが可能です。ただし、いくつかの課題があるため我々は最終的にFxを用いてGraceful Shutdownを行うことにしました。

GoでGraceful Shutdownを実装する上での課題

前章では、GoでGraceful Shutdownを行うための具体的な流れとコード例を紹介しましたが、いくつかの課題が存在します。

一つの課題は、HTTPサーバーがGraceful Shutdownされても、サーバーのハンドラから派生した他の処理中のGoroutineが完了するのを待機しない場合があることです。これにより、サーバーはGraceful Shutdownされていても、例えば書き込み処理などが正常に完了していない可能性があります。

さらに、1つのプログラム内で複数のサーバー(例えば、プロダクト用のサーバーとメトリクス用のサーバー)を運用している場合、中断シグナルを受け取った際に全てのサーバーを正しくGraceful Shutdownする必要があり、これにより複雑さが増します。

都度、Graceful Shutdownにおいてケアする必要のあるサーバーや処理を記述し、中断シグナルを受け取った後、正常に完了するまで待機する処理を正しく実装する必要があります。

解決策としては、処理をカプセル化するようなボイラープレートを用意し、そこに繰り返し記述しなければならないコードを隠蔽する方法が考えられます。

また、複数のGoroutineを管理するために oklog/run のようなライブラリを使用し、管理したいGoroutineのライフサイクルを一括で管理する手法もあります。

私たちは、上記のようなボイラープレートの利用やGoroutine管理ライブラリを用いる方法ではなく、最終的に uber-go/fx を使用して問題を解決しました。数ある解決策の中からDIライブラリであるFxを選んだ理由と、その実装方法を紹介していきます。

Fxとは

Fxは、Uberが開発したGo言語用の依存関係注入(DI)ライブラリです。その公式リポジトリのREADMEには、「Fx is a dependency injection system for Go」と記載されています。Fxは、使い勝手が良く、効率的なDIライブラリとして機能します。

Fxの特徴の一つは、シングルトンで管理されるオブジェクトを提供することです。これにより、グローバル変数を使わずに、モジュールとしてエクスポート可能な共有コンポーネントを作成することができます。例えば、context.Context を使用してリクエストの Accept-Language を保存し、ハンドラ内でローカライズ(l10n)を容易に行う仕組みを開発した場合、社内ライブラリとしてFxのモジュールを公開することで、他のプロジェクトでも簡単に再利用できます(利用側もFxを使用している必要がありますが)。

DIライブラリであるFxは、定義された依存関係のインジェクションを実施し、生成されたインスタンスをシングルトンで管理することが主な機能です。同時に、これらのインスタンスの破棄も管理します。この生成と破棄のプロセスは、アプリケーションのライフサイクルと密接に連動しています。

以前のセクションで、Graceful Shutdownがアプリケーションの安全な終了処理に関連することを説明しました。複数のコンポーネントをGracefulにShutdownする必要がある場合、管理の複雑さが増します。ここでFxが注目される理由は、DIライブラリとして、複数のシングルトンインスタンスのライフサイクルを管理しているからです。

私が所属するチームでは、Go言語でのアプリケーション開発にFxをDIライブラリとして利用しています。このライブラリの特性を利用して、Graceful Shutdownの処理を統合することが可能だと考え、実装を行いました。

Fxを用いたGraceful Shutdownの実装

Fxでは、アプリケーションのライフサイクルに対するフックが提供されています。これについては、Fxのドキュメントで詳細が説明されています。

Fxで提供される主なフックは OnStartOnStop の2つです。これらは、非常に直感的でシンプルな設計に基づいていますが、初期化プロセスには介入できないため、一定の制限があります。

私たちのチームでは、既にFxを使用して各インスタンスをModule単位で管理しています。これにより、Serverやその他Graceful Shutdownを行う必要があるコンポーネントもFxのModuleで管理されています。

ここでは、ServerをFxのLifecycleで管理する方法についてのコード例を紹介します。

FxのLifecycleである OnStart, OnStop を用いる際は通常、以下のようなコードを使用します。

fx.Annotate(
    server,
    fx.OnStart(func(ctx context.Context) error{
        // ここでOnStartのタイミングで行いたい処理を記述
    }),
    fx.OnStop(func(ctx context.Context) error {
        // ここでOnStopのタイミングで行いたい処理を記述
    }),
)

毎回FxのLifecycleを使用するModuleを作成する際にこのような記述を行うのは手間がかかるため、より簡便な方法を探求しました。その結果、以下のような汎用的なインターフェースを用意しました。

package di

type WithLifecycle interface {
    WithOnStart
    WithOnStop
}

type WithOnStart interface {
    Start(ctx context.Context) error
}

type WithOnStop interface {
    Stop(ctx context.Context) error
}

func AnnotateWithLifecycle[T WithLifecycle](c any) any {
    return fx.Annotate(
        c,
        fx.OnStart(func(ctx contextContext, t T) error {
            return t.Start(ctx)
        }),
        fx.OnStop(func(ctx context.Context, t T) error {
            return t.Stop(ctx)
        })
    )
}

このインターフェースを用意することで、FxのLifecycleに連動したインスタンスを作成する際にはそのインターフェースに対して WithLifecycle を持たせることで Start, Stop メソッドを実装することができ、コードを整理することができます。ここからのコードは実際にServerのインターフェースと構造体を用意するところからはじまりますが、ここまでで作った WithLifecycle インターフェースを用いて定義していきます。

それでは、Serverのインターフェースと構造体を準備し、Fxで管理するインスタンスを生成するためのメソッドを定義します。

type Server interface {
    di.WithLifecycle
}

type server struct {
    httpServer *http.Server
    logger     *slog.Logger // Loggerは別の部分でDIされるものを使用
}

func newServer(logger *slog.Logger) Server {
    srv := &http.Server{
        Addr:    "0.0.0.0:8888",
        Handler: handler,
    }

    return &server{
        httpServer: srv,
        logger:     logger,
    }
}

di.WithLifecycle を持たせたので Start, Stop をそれぞれ実装することができるようになります。

Start メソッドの実装は以下の通りです。ここでは、サーバーを起動します。

func (s *server) Start(context.Context) error {
    listener, err := net.Listen("tcp", s.httpServer.Addr)
    if err != nil {
        return err
    }

    s.logger.Info("Server is Listening on " + s.httpServer.Addr)

    go func() {
        if err := s.httpServer.Serve(listener); err != nil {
            if errors.Is(err, http.ErrServerClosed) {
                s.logger.Info("http.Server is closed")
                return
            }
            s.logger.Error("http.Server returns an error", err)
        }
    }()

    return nil
}

Stop メソッドは Shutdown を呼び出し、Graceful Shutdownを行います。

func (s *server) Stop(ctx context.Context) error {
    return s.httpServer.Shutdown(ctx)
}

最後に、FxのModuleでラップし、DIに使用する準備をします。

var provideOptions = fx.Provide(
    di.AnnotateWithLifecycle[Server](newServer),
)

var Module = fx.Module(
    provideOptions
)

di.AnnotateWithLifecycle を使用することで、Fxの OnStart および OnStop フックが、指定した StartStop メソッドを呼び出します。こうすることで、Fxを使用してServerのライフサイクルを管理することが可能になります。

ここではServerについての例を紹介しましたが、重要な点は「管理したい単位をModuleでラップして管理する」ということです。これにより、複数のサーバーやその他のコンポーネントをGracefulにShutdownする必要がある場合でも、Fxを使用することで管理が簡素化され、アプリケーションの終了処理がスムーズになります。

おわりに

この記事では、Fxを使用しない場合の処理とその際の課題、そしてFxを用いることでそれらがどのように解決できるのかに焦点を当てました。

個人的には、バックエンドサービスやGoの初心者として、今年から様々なサービスの実装に取り組んでいます。この過程で、Kubernetesを使用してPodのデプロイを行った際に、経験不足から意図しない挙動を引き起こした経験が何度かありました。今回のアプローチでは、管理しやすい形を採用し、これらの問題を防ぎつつ、Moduleを使って統一感のある管理を行うことができました。また、チームの拡大やコードレビューの際にも、Start/Stopの処理の流れを把握しやすくなるメリットがありました。

私が所属するチームでは、FxをDIライブラリとして採用していることから、DIとGraceful Shutdownを自然に組み合わせて管理することが可能になりました。

また、Googleが開発した google/wire というDIライブラリでも同様のアプローチが可能です。同じ課題を感じている方は、DIを活用したGraceful Shutdownを試してみると良いでしょう。

現状、弊社でFxを用いたGraceful Shutdownの仕組みを導入しているサービスでは、運用中の課題には直面していませんが、複雑性が増すにつれて新たな課題が明らかになる可能性はあります。社内ではバックエンドサービスをGoで構築しており、汎用的な仕組みはPrivate GitHub Repositoryを用いてGoライブラリとして運用しています。現状、本記事で紹介したインターフェース部などはそのリポジトリには含まれていませんが、この仕組みを採用するサービスが増えれば、社内共通ライブラリとして切り出し、使う側がより簡単にライフサイクルに則った管理を行えるようにすることを検討しています。

明日はData Platform DeskのAki Zhouが「ワークフロー、タスクで見るか?データで見るか?」というテーマで記事を執筆します。是非チェックしてみてください。

enechainでは、一緒に事業を拡大していける仲間を募集しています。興味がある方は以下のリンクをご覧ください。

herp.careers herp.careers