最近GoでWEBアプリケーションを運用するにあたってどこに気をつけているかをメモしました。
WEBサーバ
GoでWEBアプリケーションを作る場合、net/httpパッケージを使うことになります。
Go製のWEBフレームワークを使用する場合であっても、HTTPリクエストの処理部分はnet/httpを使用していると思います。
GoでWEBアプリケーションを書いた場合、すべてのリクエストをGoで受けるというケースはあまり想定されず、殆どの場合はリバースプロキシとしてWEBサーバを配置し、そのバックエンドにアプリケーションサーバを配置する構成になると思います。
このような構成を取る理由として、下記のようなものが挙げられると思います。
- ロギング
- ヘッダの柔軟な扱い
- 静的ページのリクエスト処理
- URLのパースやrewrite処理など
- キャッシュの利用など
WEBサーバを利用する場合、多くの場合でnginxかapacheを採用するかと思いますが、どちらの場合でも、まずは同一ホスト内にWEB-アプリケーションサーバを配置して、アプリケーション間の通信にドメインソケットを用いる構成のほうが、portをlistenするよりも速いため、こちらを採用するケースが多いと思います。
- apacheでのドメインソケット設定例
    AddHandler fastcgi-script fcgi
    <IfModule mod_fastcgi.c>
        FastCgiExternalServer /path/to/file -socket /path/to/go/sock.sock
    </IfModule>
    <Directory "/path/to/file">
    </Directory>
- nginxの設定例
    location /hoge{
        include fastcgi_params;
        fastcgi_pass unix:/path/to/go/sock.sock;
    }
APPサーバ
Go側は、net.Listenもしくはhttp.ListenAndServeでリクエストを待ち受けます。
Listenで待ち受けるだけのプログラム例(※こちらを参考にしています)
package main
import (
    "fmt"
    "net"
    "net/http"
    "net/http/fcgi"
    "os"
    "os/signal"
    "syscall"
)
type Server struct {
}
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request){ 
	str := "Hello World!"
	w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
	w.Header().Set("Content-Length", fmt.Sprint(len(str)))
	fmt.Fprint(w, str)
}
func main(){
    const SOCK = "/path/to/sock"
    sig := make(chan os.Signal)
    signal.Notify(sig, os.Interrupt)
    signal.Notify(sig, syscall.SIGTERM)
    server := Server{}
    l, _ := net.Listen("unix", SOCK)
    go func() {
		fcgi.Serve(l, server)
    }()
    <-sig
	if err := os.Remove(SOCK); err != nil {
		panic("socket file remove error.")
	}
}
fcgi.Serve()はWEBアプリケーションからのリクエストごとにgoroutineを発行します。
goroutineの並列性能がGoで捌ける処理数であり、その性能はGOMAXPROCSなどによって変動します。
ここで注意したいのは、goroutineを生成するmainプログラムが異常終了すると、アプリケーション全体が停止してしまうという点が挙げられす。
mainが死なないようにするには、panicやエラーが発生しないよう十分なユニットテストを行うことが最重要ですが、メモリが増えてOOMキラーに殺されないようメモリリークの可能性を潰しておくことも大事です。
メモリの使用率を確認する
Goのアプリケーションでメモリの使用量は、プロファイラを使うことで簡単に見ることができます。
先ほどのプログラムで、pprofを仕掛けた例です。
heap profile: 1: 288 [1: 288] @ heap/1048576
1: 288 [1: 288] @ 0x41667a 0x43315f 0x435aff 0x430727 0x431be9 0x4318e5 0x43cbc6
#	0x43315f	allocg+0x1f		/usr/local/go/src/runtime/proc.c:925
#	0x435aff	runtime.malg+0x1f	/usr/local/go/src/runtime/proc.c:2106
#	0x430727	runtime.mpreinit+0x27	/usr/local/go/src/runtime/os_linux.c:219
#	0x431be9	mcommoninit+0xc9	/usr/local/go/src/runtime/proc.c:201
#	0x4318e5	runtime.schedinit+0x55	/usr/local/go/src/runtime/proc.c:138
#	0x43cbc6	runtime.rt0_go+0x116	/usr/local/go/src/runtime/asm_amd64.s:95
0: 0 [0: 0] @ 0x409446 0x4085a8 0x62459f 0x597336 0x4dcda7 0x4014c2 0x415ea4 0x43f1c1
#	0x62459f	html.init+0xdf			/usr/local/go/src/html/entity.go:2155
#	0x597336	html/template.init+0x76		/usr/local/go/src/html/template/url.go:105
#	0x4dcda7	net/http/pprof.init+0x77	/usr/local/go/src/net/http/pprof/pprof.go:209
#	0x4014c2	main.init+0x42			/hoge/fuga/main.go:48
#	0x415ea4	runtime.main+0xe4		/usr/local/go/src/runtime/proc.go:58
# runtime.MemStats
# Alloc = 322680
# TotalAlloc = 477560
# Sys = 2885880
# Lookups = 8
# Mallocs = 1786
# Frees = 458
# HeapAlloc = 322680
# HeapSys = 802816
# HeapIdle = 212992
# HeapInuse = 589824
# HeapReleased = 0
# HeapObjects = 1328
# Stack = 245760 / 245760
# MSpan = 5824 / 16384
# MCache = 1200 / 16384
# BuckHashSys = 1440400
# NextGC = 364336
# PauseNs = [137872 85399 122811 132260 146674 865689 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0]
# NumGC = 6
# EnableGC = true
# DebugGC = false
ここではGCの回数やアロケート済みのメモリ量などが確認できます。
パラメタの詳細はこちらにわかりやすくまとめられています。
HeapSysがプログラムがOSに求めるメモリの量、HeapAllocが現在プログラムに割り当てられているメモリ量です。HeapIdleは割り当て済みだが使われていないメモリ量、HeapReleasedがOSに返却するメモリの量で、GoはHeapIdleにメモリを確保しておき、確保しておいたものの、5分以上使われなかった領域がリリース対象となるようです。
Goのメモリの使用量の推移はtopや、下記のコマンドで見ることができます。
$ while sleep 1 ; do date -d '5 seconds ago' '+%H:%M:%S' ; ps aux | grep main(もしくはバイナリ名) ; done
root     42599  0.0  0.0  90352  2752 ?        S    Jun22   0:00  ./main
www      42600  0.0  0.4 569504 15824 ?        Sl   Jun22   5:51 ./main
14050    48019  0.0  0.0   6384   684 pts/0    S+   10:48   0:00 grep main
この場合、2行めの./mainの15824が./mainが保持している実メモリのサイズとなります。
pprofのheapで現在割り当てられているメモリ量を確認してもほとんど変化がないのに、mainの使用するメモリ量が徐々に増えていく場合がありますが、その場合、一時的にメモリがプールされていることも多く、継続して監視を行い負荷が下がったところでメモリの使用量がちゃんと減ってることまで確認します。
さらに増え続けていくようであれば、メモリリークが起きている可能性があります。
接続数の制御
GoのアプリケーションはOSから見ると1つのプロセスですので、並列処理はあくまで言語内のgoroutineの処理となります。
(必要に応じてOSスレッドを追加します。現在のスレッド数はtop Shift+Hで確認できます)
fcgi.Serve()の内部を見ると、リクエストごとにgoroutineを生成していることがわかります。
   280  // Serve accepts incoming FastCGI connections on the listener l, creating a new
   281	// goroutine for each. The goroutine reads requests and then calls handler
   282	// to reply to them.
   283	// If l is nil, Serve accepts connections from os.Stdin.
   284	// If handler is nil, http.DefaultServeMux is used.
   285	func Serve(l net.Listener, handler http.Handler) error {
   286		if l == nil {
   287			var err error
   288			l, err = net.FileListener(os.Stdin)
   289			if err != nil {
   290				return err
   291			}
   292			defer l.Close()
   293		}
   294		if handler == nil {
   295			handler = http.DefaultServeMux
   296		}
   297		for {
   298			rw, err := l.Accept()
   299			if err != nil {
   300				return err
   301			}
   302			c := newChild(rw, handler)
   303			go c.serve()
   304		}
   305	}
このとき、goはgoroutine発行時にCPUの負荷を考慮しない(?)ようで、CPUに余裕がない状態でgoroutine生成が続くと、大量の処理待ちが発生してしまい急激に処理速度が劣化します。
これを防ぐために同時処理数の制御を行います。
同時処理制御は実践GO言語にも書かれていますが、チャネルを使って同時に行う並列処理に上限を設けます。
接続数制御の例
var	sem = make(chan int, 1) //上限数を設定
func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request){ 
	sem <- 1 // キューのセット
	str := "Hello World!"
	w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
	w.Header().Set("Content-Length", fmt.Sprint(len(str)))
	fmt.Fprint(w, str)]
    <-sem //キューの処理完了まで待つ
}
並列性はCPUのコア数などによって異なるので、サーバごとにチューニングする必要があります。
その他のエラーに備える
あまり良い方法とは思いませんが、ユニットテストでも発生しなかったが、想定しないpanicでmainが死ぬケースを避けるためにServeHTTP内でrecoverを記述しておく方法があります。
まとめ
GoのWEBアプリケーションをどのように運用するにあたって自分なりに気付いたところを記述しました。ご意見やご指摘がありましたらどうぞコメントください。