GoとDockerでGraceful Shutdown
はじめに
先日,某社のインターンシップの面接を受けたのですがその際に,メンターの方から「あなたの作ったプログラムは,サーバーを Graceful Shutdown していて良いですね~」(意訳)という声をかけていただきました.
Go の標準パッケージの場合,server.Shutdown を呼び出せはサーバーをシャットダウンすることができますが,これだけでは Graceful Shutdown できない場合もあります.
Graceful Shutdown したつもりができていなかったという悲劇が繰り返されないように,Graceful Shutdown のやり方を調べている・勉強している人の参考になれば良いなと思います.
Graceful Shutdown とは?
まず,Graceful Shutdown について軽く話しておこうと思います.Graceful Shutdown とは,Graceful(優雅な) Shutdown(シャットダウン)ということで,直訳すると優雅なシャットダウンとなります.
Web サーバーにおける Graceful Shutdown とは,サーバーを終了する前に各リソースの適切な開放やデータの永続化等を行ってからサーバーを終了するようなシャットダウンのことです.
Go では,同期シグナルはランタイムパニックに変換されます.コンソールで Ctrl + C で送信することができる SIGINT は,規定でプログラムを強制終了させる[1]ため Graceful Shutdown するためにはこの動作を上書きする必要があります.
Go におけるシグナルハンドリング
シグナルの受信方法
Ctrl + C などで送信されるシグナルを Go のプログラム内部から処理するには,os/signal
パッケージの Notify 関数,もしくは NotifyContext を使用し,シグナルを受信する必要があります.
Notify と NotifyContext の違いはチャンネルを使うか Context を使うのかの違いです.Context を使用するほうが扱うのが容易である上,キャンセル信号の伝達が容易であるため,特別な理由がない場合は NotifyContext を使ったほうがいいと思います.
NotifyContext を使ったシグナルハンドリング
ここでは,NotifyContext を使ったシグナルハンドリングについてサンプルコードを例示しますが,チャンネルを使った場合も対して内容は変わりません.
注意するべきなのは,NotifyContext の第 2 返り値である stop 関数です.この関数を呼び出すまでシグナルを受信したときに Context をキャンセルし続けます.
そのため,基本的に呼び出しておいたほうがいいです.(よくわからない場合はdefer
で呼び出しておけばいいと思います.)
package main
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
)
func main(){
var wg sync.waitGroup
// シグナルを受診したときにDoneになるContextを作成する.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
wg.Add(1)
go func(ctx context.Context){
defer wg.Done()
// チャンネルの場合は,ここが受け取ったチャンネルに変わる.
<-ctx.Done()
fmt.Println("signal received")
}(ctx)
wg.Wait()
}
上記のコードの中で,シグナルを受け取ったときの処理は,goroutine として呼び出されている無名関数の中身です.
Context やチャンネルは,値を受信するまでその goroutine をブロックする仕様を利用してシグナルを受診するまで処理をブロックしています.
Graceful Shutdown
やっと本題に戻ってきましたが,ここまでくれば Graceful Shutdown のやり方は大体想像がつくと思います.
上記のコードで,
go func(ctx context.Context){
defer wg.Done()
// チャンネルの場合は,ここが受け取ったチャンネルに変わる.
<-ctx.Done()
fmt.Println("signal received")
}(ctx)
の中身をサーバーをシャットダウンする処理に変えるだけです.そのため,標準ライブラリのnet/http
で Graceful Shutdown する場合は以下のようになります.
ハマりやすい罠
server.ListenAndServer 関数は,Shutdown()が呼ばれると即時に ErrServerClosed を返します.実装にもよりますが,大体の場合,別の goroutine で動いている Shutdown が終了するよりも前に main 関数の動いている goroutine が終了してしまい,Shutdown が最後まで終了しない可能性があります.
When Shutdown is called, Serve, ListenAndServe, and ListenAndServeTLS immediately return ErrServerClosed. Make sure the program doesn't exit and waits instead for Shutdown to return.[2]
これを防ぐには,net/http
パッケージの GoDoc でも書かれているようにプログラムが終了する前に Shutdown が終了するまで待機する必要があります.そのため今回の実装では sync.WaiyGroup を使うことで Shutdown が終了するのを待機しています.
Docker 内部でのシグナルハンドリング
Go で作成したアプリケーションを Docker で動作させることが有ると思うのですが,Docker が送信するシグナルは少し工夫しないと受け取って処理することができません.
コンテナを停止する場合,docker stop
やdocker kill
などを使用すると思うのですが,docker stop
はSIGTERM,docker kill
で送信されるシグナルは規定でSIGKILLとなっているため上記の実装では,ただの強制終了になってしまいます.
基本的に,Go でシグナルハンドリングを行う場合は os パッケージに定義されているos.Interrupt
,もしくはos.Kill
を対象にハンドリングするのが好ましいです.[3]また,os.Kill
は,SIGKILL と対応しているためできれば変更しないほうが良いです.
そのため,受信するシグナルに SIGTERM などを追加するのはあまり好ましい実装ではないです.(一応 syscall
パッケージに SIGTERM なども定義されてはいる.)
よって,Docker がコンテナに送信するシグナルを SIGINT に変えるほうが良いでしょう.
送信するシグナルを切り替える方法は,主に以下のようになっています.
-
docker kill
・docker compose kill
を使う場合
オプションで送信するシグナルを変更できる.
docker kill --signal SIGINT <Container Name>
-
docker stop
を使う場合
Dockerfile で,STOPSIGNAL
を指定する.[4]
STOPSIGNAL signal
-
docker compose stop
・docker compose down
の場合
- デフォルトでは,
SIGTERM
が送信される. -
stop_signal
を service の設定で指定する
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
stop_signal: SIGINT
まとめ
すこしでも Go で API サーバーなどの開発をしている人の参考になったのなら幸いです.
参考
Discussion