上京エンジニアの葛藤

都会に染まる日々

Go で Graceful Shutdown な Web サーバーを書く

概要

Go で Graceful Shutdown な Web サーバーを書きたかったのでその備忘録です。

結論

net/http パッケージの Server.Shutdown() を使え
http package - net/http - Go Packages
それだけなんですが、動作確認しながら挙動を追いたいので Web サーバーを書きながら確認してみたいと思います。

Graceful Shutdown とは

プロセス内で実行中のプログラムの処理を適切に完了させ、プロセスを安全に終了させることです。
Web サーバーにおける Graceful Shutdown は現在処理中のリクエストを適切に完了させ、新しいリクエストを受け付けずにプロセスを終了させることです。

動作確認

用意したサンプルコードです。

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer cancel()

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        for i := 1; i <= 5; i++ {
            fmt.Println(i)
            time.Sleep(1 * time.Second)
        }
        fmt.Println("Hello, World!")
    })

    server := http.Server{
        Addr:    ":8080",
        Handler: nil,
    }

    done := make(chan error, 1)
    go func() {
        done <- server.ListenAndServe()
    }()

    select {
    case err := <-done:
        if err != http.ErrServerClosed {
            log.Fatalf("HTTP server ListenAndServe: %v", err)
        }
    case <-ctx.Done():
        fmt.Println("Server stopping")
        c, cancel := context.WithTimeout(context.Background(), 10*time.Second)
        defer cancel()
        if err := server.Shutdown(c); err != nil {
            log.Fatalf("HTTP server Shutdown: %v", err)
        }
        fmt.Println("Server gracefully stopped")
    }
}

リクエストを受けたら 5秒スリープした後にレスポンスを返すようなエンドポイントを用意して確認してみます。

確認方法

  1. Web サーバーを serve する
  2. / に GET リクエストを送信する
  3. リクエスト受信後 Web サーバーに Ctrl + C で SIGINT を送信する
  4. GET リクエストが正常にレスポンスを返す

結果

1
2
^CServer stopping
3
4
5
Hello, World!
Server gracefully stopped
// 別 terminal でリクエストをした場合
$ curl http://localhost:8080
curl: (7) Failed to connect to localhost port 8080 after 7 ms: Couldn't connect to server

期待通り正常にレスポンスを返した後にサーバーが落ちていること、新たにリクエストを受け付けていないことが分かりました。
ここで重要なのはタイムアウトを設定したコンテキストを Shutdown メソッドに渡している点で、この例だと SIGINT を受け取って Shutdown を呼び出してから最長 10秒はサーバーが落ちないようになっています。
このタイムアウトが 5秒以下だと SIGINT を受けてから5秒後にはコンテキストのエラーが返され、リクエストの処理が完了前にサーバーは落ちてしまいます。

// タイムアウトを 1秒に設定した例
1
2
^CServer stopping
3
2023/11/08 00:35:28 HTTP server Shutdown: context deadline exceeded
exit status 1

※ドキュメントにも記載があります。

If the provided context expires before the shutdown is complete, Shutdown returns the context's error, otherwise it returns any error returned from closing the Server's underlying Listener(s).

タイムアウトを設定しないと処理に時間がかかるとそれだけ待つことになるため、いつまでもデプロイが完了しないなどの問題が出るので本番環境ではこの辺りの設定を考えないといけません。

初めての Go 言語でテストについて学んだ

概要

初めての Go 言語の テスト の章が勉強になったのでそのまとめです。

まとめ

  • テストは製品版と同じディレクトリ、パッケージに置く
    エクスポートされていない関数や変数にもアクセスしてテストが可能
  • ファイル名は xxx_test.go とする
  • テスト関数は Test という prefix を付けて *testing.T 型の引数を1つだけ取り、名前は t にするのが慣習
    関数のユニットテストは TestMethod と Test のあとに関数名にする
    エクスポートされていない関数の場合 Test_method と _ を間に入れる
package sample

import "testing"

func Test_privateFunc(t *testing.T) {
    res := privateFunc()
    if res != "sample" {
        t.Error("failed", res)
    }
}
  • 検証結果が正しくなければ t.Error を使う
    t.Fatal を使うとテスト関数を終了させられる
  • go test ./... でカレント、サブディレクトリ全て実行
    -v で詳細な出力
$ go test -v
=== RUN   Test_privateFunc
--- PASS: Test_privateFunc (0.00s)
PASS
ok      testing-go/sample       0.110s
  • TestMain という関数を使えば before, after のようなことが実現できる
    ただしテストごとに呼ばれない
package sample_test

import (
    "fmt"
    "testing"
    "testing-go/sample"

    "github.com/google/go-cmp/cmp"
)

func TestMain(m *testing.M) {
    fmt.Println("before")

    m.Run()

    fmt.Println("after")
}

func TestPublicFunc(t *testing.T) {
    expected := sample.Sample{
        FirstName: "first",
        LastName:  "last",
    }
    res := sample.PublicFunc()
    if diff := cmp.Diff(expected, res); diff != "" {
        t.Error(res)
    }
}
go test -v sample2_test.go
before
=== RUN   TestPublicFunc
--- PASS: TestPublicFunc (0.00s)
=== RUN   TestPublicFunc2
--- PASS: TestPublicFunc2 (0.00s)
PASS
after
ok      command-line-arguments  0.120s
package sample

import (
    "testing"

    "github.com/google/go-cmp/cmp"
)

func Test_privateFunc(t *testing.T) {
    expected := Sample{
        Name: "name",
    }
    res := privateFunc()
    if diff := cmp.Diff(expected, res); diff != "" {
        t.Error(res)
    }
}
  • 比較したくないフィールドがある場合はローカル関数として比較関数を定義する
package sample

import (
    "testing"

    "github.com/google/go-cmp/cmp"
)

func Test_privateFunc(t *testing.T) {
    expected := Sample{
        FirstName: "first",
        LastName:  "first",
    }
    comparer := cmp.Comparer(func(x, y Sample) bool {
        return x.FirstName == y.FirstName
    })
    res := privateFunc()
    if diff := cmp.Diff(expected, res, comparer); diff != "" {
        t.Error(res)
    }
}
  • 無名構造体を定義してテーブルテストを行う
  • -cover オプションでコードカバレッジの出力
    -coverprofile=c.out でファイルへの保存
    go tool cover -html=c.out で HTML を生成
$ go test -cover
PASS
coverage: 100.0% of statements
ok      testing-go/sample       0.228s
var blackhole sample.Sample

func BenchmarkPublicFunc(b *testing.B) {
    res := sample.PublicFunc()
    blackhole = res
}
go test ./sample2_test.go -bench=. -benchmem
before
goos: darwin
goarch: arm64
BenchmarkPublicFunc-8           1000000000               0.0000002 ns/op               0 B/op          0 allocs/op
PASS
after
ok      command-line-arguments  0.323s
  • httptest を使えば http request が行われる関数のテストが容易に可能
  • httptest.NewServer は未使用なポートをランダムに使用して HTTP サーバーを serve してくれる
  • go test -short で時間のかかるテストをスキップできる
  • -race ではレースチェッカーを有効にできる

Go での開発経験がなくテスト周りの基礎から学びたかったので内容がちょうど良かったです。
モック、スタブなどもう少し実践的なテクニックの理解が必要な気がしているので他の書籍も合わせて学びたいと思います。

connect-go の interceptor に外部から context を渡す

概要

connect-go の interceptor に外部から context を渡す方法についてです。

あまりすることは無さそうですが、context に値をセットするような interceptor を書いていて、単体テストを書く際に今回のような方法が有用だったので紹介します。

interceptor

package sample

import (
    "context"

    "github.com/bufbuild/connect-go"
)

const CtxKey = "key"

func NewSampleInterceptor() connect.UnaryInterceptorFunc {
    interceptor := func(next connect.UnaryFunc) connect.UnaryFunc {
        return connect.UnaryFunc(func(
            ctx context.Context,
            req connect.AnyRequest,
        ) (connect.AnyResponse, error) {
            ctx = context.WithValue(ctx, CtxKey, "hoge")
            return next(ctx, req)
        })
    }
    return connect.UnaryInterceptorFunc(interceptor)
}

サンプルは context に値をセットするだけの interceptor です。
単体テストを書く場合 context.WithValue() が期待通りに呼ばれたことでも良い気がしますが、今回は context の値を比較して検証したいと思いました。

この関数は高階関数になっており、そのことを正しく理解していれば上手くテストを書くことができたのですが理解不足のため context の受け渡しに悩みました。

test

package sample_test

import (
    "context"
    "sample/cmd/server/interceptor/sample"
    "testing"

    "github.com/bufbuild/connect-go"
    "github.com/stretchr/testify/assert"
)

func TestNewSampleInterceptor(t *testing.T) {
    interceptor := sample.NewSampleInterceptor()

    ctx := context.Background()
    req := connect.NewRequest(&struct{}{})

    next := connect.UnaryFunc(func(
        ctx context.Context,
        req connect.AnyRequest,
    ) (connect.AnyResponse, error) {
        assert.Equal(t, "hoge", ctx.Value(sample.CtxKey))
        return nil, nil
    })
    interceptor(next)(ctx, req)
}

結論このようにテストを書きました。

変数 interceptor は関数型の UnaryInterceptorFunc が入り、引数の型は UnaryFunc です。

そのため新たに定義した UnaryFunc (変数 next) 内で値の確認を行うようにしています。

これで NewSampleInterceptor 内で定義している関数に ctx, req を渡し、引数で渡している関数 next に上手く ctx, req を渡して context の検証をすることができます。

まとめ

Go における高階関数について理解不足だったので悩みましたが、なんとかやりたいことはできました。
もっと良いやり方があればぜひ教えてください!

golang-jwt/jwt で JWT の発行、検証を行う

golang-jwt/jwt を使って JWT の発行、検証を試したので残しておきます。

github.com

サンプルコードはこちら。

github.com

JWT の発行

func generate() (string, error) {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{
        Issuer:    "issuer",
        Subject:   "subject",
        Audience:  []string{"audience"},
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 + time.Minute)),
        NotBefore: jwt.NewNumericDate(time.Now()),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        ID:        "id",
    })
    tokenString, err := token.SignedString(secret)
    if err != nil {
        return "", err
    }

    return tokenString, nil
}

JWT の検証

func verify(tokenString string) error {
    token, err := jwt.ParseWithClaims(
        tokenString,
        &jwt.RegisteredClaims{},
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
            }
            return secret, nil
        },
    )
    if err != nil {
        return err
    }

    if claims, ok := token.Claims.(*jwt.RegisteredClaims); ok && token.Valid {
        fmt.Println(claims.Issuer)
    } else {
        return err
    }
    return nil
}

Claims の値を検証したいことがあると思いますが、この時 jwt.ParseWithClaims メソッドの引数に ParserOption を渡すことで上手くバリデーションをしてくれるようで使い勝手が良かったです。

例えば、このように sub 値をバリデーションしてくれます。

   token, err := jwt.ParseWithClaims(
        tokenString,
        &jwt.RegisteredClaims{},
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
            }
            return secret, nil
        },
        jwt.WithSubject("hoge"),
    )

もちろん sub 値だけではなく、exp, nbf 値なども定義されているメソッドでバリデーションすることができます。

jwt/validator.go at 8b7470d561f313acbf05e17701cbe34ab39fb970 · golang-jwt/jwt · GitHub

go install で落とした binary に PATH を通す

asdf でバージョン管理をしているため GOPATH が動的に変わるので以下のように PATH を通した。

export PATH="$(go env GOPATH)/bin:$PATH"

.zprofile だと上手く読み込めなかったので .zlogin に記述した。