Nginx を TCP ロードバランサーとして稼働させてカナリアリリースを実現する

## はじめに

サービス利用者への影響を最小限にすることを目的に、新しいバージョンを段階的にサービスインさせるカナリアリリース（カナリアデプロイメントと呼ばれることもある）というデプロイ戦略をご存知の方は多いかと思います。サービス開発において、アプリケーションの冗長化や負荷分散のために上位にロードバランサーを配置することは一般的なので、ロードバランサーで重み付けによるトラフィック振り分けが可能であれば、それだけでカナリアリリースを実現できます。しかし、ロードバランサーの機能不足や、ロードバランサー運用者とサービス開発者が異なることによる連携負荷など、様々な事情によりそれが難しいケースも存在します。

このような背景を受けて、この記事ではロードバランサーとアプリケーションの間に TCP ロードバランサーとして機能する Nginx を配置してカナリアリリースを実現する方法を紹介します。

## TCP/UDP Proxy Module

Nginx を TCP ロードバランサーとして稼働させるためには、TCP/UDP Proxy Module を使用する必要があります。しかし、公式から提供されている Nginx バイナリには TCP/UDP Proxy Module が組み込まれていないため、[Building NGINX From Source](https://www.nginx.com/resources/wiki/start/topics/tutorials/install/#building-nginx-from-source) を参考にソースコードから自前でビルドする必要があります。

http://nginx.org/en/docs/stream/ngx_stream_core_module.html

### ビルド手順

公式サイトからソースコードをダウンロードします。

```bash
cd /usr/local/src
sudo wget https://nginx.org/download/nginx-1.21.0.tar.gz
sudo tar -xf nginx-1.21.0.tar.gz
cd nginx-1.21.0
```

ビルドに必要なライブラリをインストールします。

```bash
sudo yum install gcc
sudo yum install pcre-devel
sudo yum install openssl-devel
```

Nginx 実行用のシステムグループとシステムユーザーを作成します。

```bash
sudo groupadd --system nginx
sudo useradd --system --shell /usr/sbin/nologin --gid nginx nginx
```

必要なオプションを付与して Nginx バイナリをビルドします。

```bash
sudo ./configure \
    --sbin-path=/usr/sbin/nginx \
    --conf-path=/etc/nginx/nginx.conf \
    --pid-path=/var/run/nginx.pid \
    --error-log-path=/var/log/nginx/error.log \
    --http-log-path=/var/log/nginx/access.log \
    --user=nginx \
    --group=nginx \
    --with-stream

sudo make
sudo make install
```

Nginx を TCP ロードバランサーとして使用する際に明示的に組み込む必要がある Module は以下のようになっていますが、今回は必要最低限として TCP/UDP Proxy Module を組み込むオプションのみを指定しています。

```bash
$ ./configure --help | grep with-stream
  --with-stream                      enable TCP/UDP proxy module
  --with-stream=dynamic              enable dynamic TCP/UDP proxy module
  --with-stream_ssl_module           enable ngx_stream_ssl_module
  --with-stream_realip_module        enable ngx_stream_realip_module
  --with-stream_geoip_module         enable ngx_stream_geoip_module
  --with-stream_geoip_module=dynamic enable dynamic ngx_stream_geoip_module
  --with-stream_ssl_preread_module   enable ngx_stream_ssl_preread_module
```

[NGINX systemd service file](https://www.nginx.com/resources/wiki/start/topics/examples/systemd/) を参考に systemd の Unit ファイルも自前で用意します。

```bash
cat << EOF | sudo tee /lib/systemd/system/nginx.service
[Unit]
Description=The NGINX HTTP and reverse proxy server
After=syslog.target network-online.target remote-fs.target nss-lookup.target
Wants=network-online.target

[Service]
Type=forking
PIDFile=/var/run/nginx.pid
ExecStartPre=/usr/sbin/nginx -t
ExecStart=/usr/sbin/nginx
ExecReload=/usr/sbin/nginx -s reload
ExecStop=/bin/kill -s QUIT \$MAINPID
PrivateTmp=true

[Install]
WantedBy=multi-user.target
EOF
```

## Split Clients Module

Nginx でカナリアリリースをするためには Split Clients Module を使用する必要があります。こちらは TCP/UDP Proxy Module とは異なりデフォルトで Nginx バイナリに組み込まれているため、自前でのビルドは不要となります。

https://nginx.org/en/docs/http/ngx_http_split_clients_module.html

Split Clients Module は、MurmurHash2 を使用して第一引数で指定された変数（Core Module でサポートされている変数は [こちら](http://nginx.org/en/docs/http/ngx_http_core_module.html#variables)）のハッシュ値を求めて、その値から割合に応じた値を返却する仕様のため、クライアントの IP アドレスが格納される `$remote_addr` を指定するのが良いかと思います。

```config
split_clients "${remote_addr}" $variant {
    10%    one;
    20%    two;
    *      other;
}
```

蛇足ですが、以下の記事で Split Clients Module は map ディレクティブの応用であることが知れて勉強になりました。

https://qiita.com/cubicdaiya/items/d938f3354f424830630b

## 設定ファイル例

以下は、`version1` グループ（192.168.100.10, 192.168.100.11, 192.168.100.12）にトラフィックの30%を、`version2` グループ（192.168.100.20, 192.168.100.21, 192.168.100.22）にトラフィックの70%を振り分ける設定となっています。

```config:/etc/nginx/nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

stream {
    log_format basic '[$time_local] $remote_addr:$remote_port -> $upstream_addr '
                 '$protocol $status $bytes_sent $bytes_received '
                 '$session_time';

    access_log /var/log/nginx/access.log basic;

    split_clients $remote_addr:$remote_port $upstream {
        30% version1;
        *  version2;
    }

    upstream version1 {
        server 192.168.100.10:8080;
        server 192.168.100.11:8080;
        server 192.168.100.12:8080;
    }
    upstream version2 {
        server 192.168.100.20:8080;
        server 192.168.100.21:8080;
        server 192.168.100.22:8080;
    }
    server {
        listen 8080;
        proxy_pass $upstream;
    }
}
```

なお、ログの設定は [ngx_stream_log_module Module](https://nginx.org/en/docs/stream/ngx_stream_log_module.html) の例を参考にしています。ログフォーマットとして使用できる変数は以下で定義されているので、要件によって設計するのが良いかと思います。

- https://nginx.org/en/docs/stream/ngx_stream_core_module.html#variables
- https://nginx.org/en/docs/stream/ngx_stream_upstream_module.html#variables

設定ファイルが用意できたら、以下のコマンドで Nginx を起動します。

```bash
sudo systemctl daemon-reload
sudo systemctl enable nginx
sudo systemctl start nginx
```

あとは、上位のロードバランサーから Nginx にトラフィックを流せば完成です。カナリアリリースを開始した後は、それぞれのバージョンのアプリケーションメトリクスや、サービス利用者への影響を確認しながら、割合を変更していくのが良いかと思います。

## さいごに

今回は Nginx を TCP ロードバランサーとして稼働させてカナリアリリースを実現する方法を紹介しました。ニッチなナレッジかもしれませんが、どなたかの参考になれば幸いです。

## 参考資料

- https://www.nginx.com/blog/nginx-and-devops-methodologies-go-hand-in-hand/
- https://www.digitalocean.com/community/tutorials/how-to-target-your-users-with-nginx-analytics-and-a-b-testing
- https://docs.nginx.com/nginx/admin-guide/load-balancer/tcp-udp-load-balancer/
- https://engineering.mercari.com/blog/entry/2016-08-17-170114/