GoでWebサーバを作る際のTips

Posted on
Go

本エントリは オリジナル の一部を再編集して掲載しています。(2020/03/31)

内部ツール向けプロキシサーバを自作 しているためいわゆるリバースプロキシサーバをGoの標準ライブラリを活用して作っています。 今回はこのプロキシを作っている際に気がついたTipsについてご紹介します。

※決して全て自前でHTTPプロトコルをパースして作るWebサーバのことではありません。

1. http.ServerにIdleTimeoutを指定する

IdleTimeout はkeep-aliveの最大持続時間です。 特にHTTP/2で通信している場合において、適度にコネクションを切ってあげないとgoroutineが増え続けます。

HTTP/2のサーバは1クライアントに対していくつかのgoroutineを起動しています。 クライアント数がそこまで多くない場合であればgoroutineが増えたとしてもたかがしれています。 ですが、goroutineが増え続けることにより、 http.Server 以外でgoroutineリークが発生していた場合発見が遅れるかもしれません。

使用するリソースがわずかとはいえ不必要なgoroutineが起動されっぱなしというのはよくないので早めに潰しておきましょう。

なお自分は最初にこのgoroutineリークを観測した時にここが原因だとは知らなかったのでgoroutineのリストをダンプする機能をつけるなどしてデバッグをしました。

2. http.Serverが内部で持っているLoggerを奪っておく

http.Serverはエラーログの出力用に ErrorLog という変数を持っておりこれがnilの場合は log パッケージの標準Loggerが使われます。

他でも標準Loggerを使われているのであればそのままでも問題ないですが、ロガーのライブラリを使っててそちらもStdout/errに出力している場合は2つのフォーマットが混じってしまいます。 そこでこの ErrorLog に自分たちのロガーを差し込んで同じフォーマットで出力させましょう。

特に自分たちの場合はシステムのログをStackdriver Loggingで集めており後から検索する都合上、同じフォーマットの方がより望ましい状態でした。 (今回の話題のベースになっているプロキシはGKE上にデプロイしています)

ただこの ErrorLoglog.Logger なのでほんの少しだけ工夫しないとロガーを差し込めません。

type buf struct{
    internal *zap.Logger
}

func (b *buf) Write(p []byte) (int, error) {
    b.internal.Debug(string(bytes.TrimRight(p, "\n")))
    return len(p), nil
}

func NewServer() {
    logger, _ := zap.NewProduction()
    server := &http.Server{
        ErrorLog: log.New(&buf{internal: logger}, "", 0)
    }
}

やることとしてはこれだけです。

io.Writer を実装して自分たちのロガーとブリッジするだけです。

3. HTTP/2を有効にする別の手段を知っておく

http.Server はデフォルトでHTTP/2をサポートしています。

ですが、これは複雑なサーバを作らない場合に限られ、ALPNを使った拡張などを実装してしまうと標準ライブラリだけではHTTP/2を有効にできません。

そういった場合は golang.org/x/net/http2 を使いましょう。 http2.ConfigureServerhttp.Server を渡せばALPNで h2 とそれ以外を共存させることができます。 (そんなことをしたい人はそれほど多くないかもしれませんが…)

4. 開発時からTLSを必須にしてしまう

これは自作のプロキシがALPNでプロトコルを切り替えているためTLSが必須ということもありますが、開発時からTLSを有効にしてしまった方が楽でした。 普通はこれは逆かもしれません。開発用に証明書を用意するのは普通は面倒です。

開発用の証明書はLet’s Encryptなどで発行してもいいですが、あくまで開発用なのでいわゆるオレオレ証明書を作っています。 オレオレ証明書をopensslコマンドで作るのも面倒なので、開発時に必要なファイルをセットアップするCLIツールを作ってありコマンド1発でCAからサーバ証明書まで用意できるようになっています。

Goは標準ライブラリだけで自己署名CAとそのCAに署名されたサーバ証明書を作り、pemでエンコードして出力できます。 それを出力する実装をするには少し証明書の知識が必要にはなりますが基本的に必要なところを穴埋めするだけで実装が完了します。

ここまで標準ライブラリで用意されているGoはとても便利ですね!

5. TLS 1.3にオプトインする(Go 1.12の場合のみ)

Go 1.12の場合はTLS 1.3がオプトインになっています。

func init() {
    os.Setenv("GODEBUG", os.Getenv("GODEBUG")+",tls13=1")
}

もっとも現在であればオプトインを考える前にGo 1.13へのアップデートを考えてください。 1.13ではTLS 1.3はオプトアウトになっておりデフォルトで有効です。

ただし、GoのTLS 1.3はまだ 0-RTT Resumption が実装されてないなど完全な実装ではありません。 ですが通常の通信をする分にはまったく問題がないと思うので積極的に有効にしていきましょう。(そして何か問題があればバグ報告へ…)

6. サーバ証明書を動的にリロードする

実際のトラフィックを受け取るプロセスはオレオレ証明書ではなく本物の証明書を使うことでしょう。 昨今はDV証明書でかつ有効期限が短い証明書を使われることが多いと思います(e.g. Let’s Encrypt)

なので、初期から証明書の動的な再読込に対応しておきましょう。

再読込を実現する方法は色々あるかと思いますが、k8sにデプロイするのであればSecretの変更(証明書の更新)を検知してファイルを再読込するのがいいと思います。 crypto/tlstls.Config は動的な証明書の読み込みが GetCertificate で実現できます。

TLSのハンドシェイクで ClientHello メッセージを受け取った後にこの関数が呼ばれるのでSNIをチェックし証明書を変えることもできますし、その必要がなければ同じ証明書を返せば良いです。

自作プロキシでは証明書はk8sのSecretをVolumeとしてマウントして読み込むので、このVolumeの変更をinotifyで監視しています。 ファイルに変更があれば証明書を読み込み、成功した場合はロックを取得し GetCertificate が返す証明書を置き換えます。