Carpe Diem

備忘録

Circusを使ってgraceful restart

概要

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を複製するためです。

  1. fork(2)によって古いプロセスをコピーした新しいプロセスを立ち上げて、複製したfdをlistenerに利用する
  2. 古いプロセスに終了シグナルを投げる
  3. 古いプロセスが終了する

つまり

  • 新しいプロセスと古いプロセスが同時に動く期間がある
  • 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

カーネルデータ構造

図で表すと以下です。

f:id:quoll00:20191220154748p:plain

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

実際には

christina04.hatenablog.com

でやったようにシグナルハンドリングして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:webpathにnginxで設定したソケットを指定
  • familyAF_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

f:id:quoll00:20190130114748g:plain

このように古いプロセスと新しいプロセスが一度同時に動き、その後で切り替わってることが確認できます。

デプロイでもリクエストが失敗しないか確認

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をハンドルして終了を少し遅らせる対応が必要そうです。