Edited at

GoのWEBアプリケーション運用について

More than 3 years have passed since last update.

最近GoでWEBアプリケーションを運用するにあたってどこに気をつけているかをメモしました。


WEBサーバ

GoでWEBアプリケーションを作る場合、net/httpパッケージを使うことになります。

Go製のWEBフレームワークを使用する場合であっても、HTTPリクエストの処理部分はnet/httpを使用していると思います。

GoでWEBアプリケーションを書いた場合、すべてのリクエストをGoで受けるというケースはあまり想定されず、殆どの場合はリバースプロキシとしてWEBサーバを配置し、そのバックエンドにアプリケーションサーバを配置する構成になると思います。

このような構成を取る理由として、下記のようなものが挙げられると思います。


  • ロギング

  • ヘッダの柔軟な扱い

  • 静的ページのリクエスト処理

  • URLのパースやrewrite処理など

  • キャッシュの利用など

WEBサーバを利用する場合、多くの場合でnginxapacheを採用するかと思いますが、どちらの場合でも、まずは同一ホスト内にWEB-アプリケーションサーバを配置して、アプリケーション間の通信にドメインソケットを用いる構成のほうが、portをlistenするよりも速いため、こちらを採用するケースが多いと思います。


  • apacheでのドメインソケット設定例


httpd.conf

    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の設定例


nginx.conf

    location /hoge{

include fastcgi_params;
fastcgi_pass unix:/path/to/go/sock.sock;
}


APPサーバ

Go側は、net.Listenもしくはhttp.ListenAndServeでリクエストを待ち受けます。

Listenで待ち受けるだけのプログラム例(※こちらを参考にしています)


main.go

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を生成していることがわかります。


net/http/fcgi/child.go

   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言語にも書かれていますが、チャネルを使って同時に行う並列処理に上限を設けます。

接続数制御の例


main.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のコア数などによって異なるので、サーバごとにチューニングする必要があります。


その他のエラーに備える

あまり良い方法とは思いませんが、ユニットテストでも発生しなかったが、想定しないpanicmainが死ぬケースを避けるためにServeHTTP内でrecoverを記述しておく方法があります。


まとめ

GoのWEBアプリケーションをどのように運用するにあたって自分なりに気付いたところを記述しました。ご意見やご指摘がありましたらどうぞコメントください。