概要
Nginx + GoのApp
という構成をとっている時に、単純にデプロイでGoだけ更新するとNginxからは
[error] 1413#1413: *1 connect() failed (111: Connection refused) [error] 1412#1412: *3 connect() failed (111: Connection refused) [error] 1412#1412: *5 connect() failed (111: Connection refused)
このような111: Connection refused
エラーが起きて、クライアント側には502
エラーが返ってしまいます。
理由はデプロイ時にプロセスが一時的に落ちるためです。
これを防ぐためにCircusというソケットマネージャを利用します。
環境
- Ubuntu 16.04
- Nginx 1.15.3
- Circus 0.15.0
- go 1.11.5
Circusのデプロイフロー
まずなぜCircusを使うとデプロイ時でも落ちないかと言うと、以下のようにfork(2)によってfile descriptorを複製するためです。
- fork(2)によって古いプロセスをコピーした新しいプロセスを立ち上げて、複製したfdをlistenerに利用する
- 古いプロセスに終了シグナルを投げる
- 古いプロセスが終了する
つまり
- 新しいプロセスと古いプロセスが同時に動く期間がある
- listenしているfdが複製されたものであり同じopen file descriptionを参照する
なためdown timeを0にすることができます。
fork(2)とは?
fork(2)は親プロセスから子プロセスを作るシステムコールです。子プロセスは
- 親プロセスと子プロセスのIDは異なる
- 親プロセスで使われているすべてのメモリのコピーを引き継ぐ
- 親プロセスが開いているfile descriptorのコピーも引き継ぐ
- file descriptorが参照するopen file descriptionは同一
- fdがopen file descriptionを参照する最期のファイルディスクリプタであった場合、open file descriptionに関連するリソースが解放される
という性質があるため、先程のような状態を実現できます。
ref: fork(2) - Linux manual page
ref: Man page of CLOSE
カーネルデータ構造
図で表すと以下です。
ref: https://www.usna.edu/Users/cs/aviv/classes/ic221/s16/lec/21/lec.html
open file description = file table entryです。
listenerはなぜクローズされないのか?
fdがopen file descriptionを参照する最期のファイルディスクリプタであった場合〜の下りからわかるように、古いプロセスが自分のソケット(fd)をクローズしたとしても、新しいプロセスのソケット(fd)がopen file descriptionを参照しているためリソースは解放されない=listenerを保持し続けられる、ということになります。
リクエストのバランシングはどうなっているか?
同一ソケット(=fd)でlistenしている場合、リクエストが来ると各listenerがaccept(2)しようとする訳ですが、コピーされたソケット間での負荷分散をカーネル側が引き受けてくれるのでプロセス側は意識しなくても大丈夫です。
それぞれのワーカープロセスには開かれたソケットが完全にコピーされている。このソケットへの接続を、それぞれのワーカーがaccept(2)を使って処理しようとする。このときの、コピーされた10個のソケット間での処理負荷を分散する仕事をカーネルが引き受けてくれる。カーネルは、ソケットへの接続について、一つの接続については必ず一つのプロセスだけがこなすように処理を割り振る。おかげで高負荷状況にあっても、プロセス間で負荷が分散されること、一つの接続に対しては一つのプロセスが処理を担当することをカーネルが保証してくれるのだ。
ref: Jesse Storimer. なるほどUnixプロセス
設定
Nginx
/etc/nginx/conf.d/default.conf
以下のようにします。
upstream goapp { server unix:/var/run/goapp.sock; } server { listen 80; server_name localhost; location / { proxy_pass http://goapp; proxy_set_header X-Forwarded-Host $host; } ... # その他の設定 }
ポイント
WebApp(Go)
リスナーを以下のようにします。
goapp.go
func main() { handler := http.NewServeMux() handler.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("<h1>It works!</h1>\n")) }) fd := flag.Uint("fd", 0, "File descriptor to listen and serve.") flag.Parse() if *fd != 0 { listener, err := net.FileListener(os.NewFile(uintptr(*fd), "")) if err != nil { panic(err) } http.Serve(listener, handler) } else { http.ListenAndServe(":8080", handler) } }
ポイント
- file descriptorの指定があればそれを使い、なければtcpでListen
実際には
でやったようにシグナルハンドリングしてgracefulに終了させるのが良いですが、今回は設定の簡略化のため省略してます。
ビルドして/usr/local/bin/goapp
として置いておきます。
Circus
/etc/circus/circus.ini
以下のようにします。
[circus] statsd = 1 [watcher:webapp] cmd = /usr/local/bin/goapp --fd $(circus.sockets.web) stop_signal = SIGINT numprocesses = 1 use_sockets = True copy_env = True stdout_stream.class = FileStream stdout_stream.filename = /var/log/goapp.log # optionally rotate the log file when it reaches 50 MB # and save 10 copied of rotated files stdout_stream.max_bytes = 52428800 stdout_stream.backup_count = 10 # sets the maximum number of open files rlimit_nofile = 65536 [env:webapp] LOG_LEVEL = debug [socket:web] path = /var/run/goapp.sock family = AF_UNIX
ポイント
cmd
に今回起動するgoappを指定- stop時はgoappに対して
SIGINT
を投げる socket:web
のpath
にnginxで設定したソケットを指定family
にAF_UNIX
を指定
その他ログローテーションやulimitを設定しています。
より詳細な設定は公式ドキュメントを参照してください。
Configuration — Circus 0.17.1 documentation
/etc/systemd/system/circus.service
簡単のためsystemdで起動できるようにしておきます。
[Unit] Description=Circus process manager After=syslog.target network.target nss-lookup.target [Service] Type=simple ExecReload=/usr/local/bin/circusctl reload ExecStart=/usr/local/bin/circusd /etc/circus/circus.ini Restart=always RestartSec=5 [Install] WantedBy=default.target
ポイント
ExecReload
にcircusのreload処理を設定ExecStart
に先程のcircus.iniを指定
サービス登録
自動起動するようにして起動させます。
$ sudo systemctl enable circus $ sudo systemctl status circus
起動するとgoapp.sockが生成されてることがわかります。
$ ls /var/run/goapp.sock /var/run/goapp.sock
動作確認
起動
上記設定をしたあとでnginxを再起動すると、goappからのレスポンスを取得できます。
$ sudo service nginx restart $ curl localhost <h1>It works!</h1>
Circusのreloadを確認
Circusをreloadすると
$ sudo systemctl reload circus
このように古いプロセスと新しいプロセスが一度同時に動き、その後で切り替わってることが確認できます。
デプロイでもリクエストが失敗しないか確認
abで検証してみます。
$ ab -c 100 -n 50000 http://127.0.0.1/
abが動いている間に別ターミナルで
$ sudo systemctl reload circus
とします。結果は
Server Software: nginx/1.15.0 Server Hostname: 127.0.0.1 Server Port: 80 Document Path: / Document Length: 19 bytes Concurrency Level: 100 Time taken for tests: 8.642 seconds Complete requests: 50000 Failed requests: 0 Total transferred: 8800000 bytes HTML transferred: 950000 bytes
このようにコケずにデプロイできることがわかりました。
104: Connection reset by peer が出た
リクエストが多いとたまに
[error] 3065#3065: *219162 recv() failed (104: Connection reset by peer)
というエラーが出ますが、これはgoapp側がgraceful shutdownせずに終了するせいです。
goapp側でgraceful shutdownさせればこれも出なくなります。
疑問点
サーバの起動が遅い場合どうなるか
古いプロセスと新しいプロセスが同時に起動する期間はあるものの、新しいプロセスがリクエストを受け付けるまでの時間が長ければどうなるのか?という疑問です。
検証したところ、Nginx側ではエラーにはならず、サーバ準備が完了するまでリクエストが止まっていました。
Circus側にwebappの起動を待つconfigはなさそうなので、webapp側でsignalをハンドルして終了を少し遅らせる対応が必要そうです。