ngx_dynamic_upstreamを用いてdockerコンテナのblue/greenデプロイをしてみる

出来上がった物を先に見たいという方はこちらをどうぞ。

github.com

きっかけ

最近いろんなアプリケーションをdockernizeすることがふえており、そんな中でてきた課題が「どうやってダウンタイムなしでデプロイしようか」というもの。 もしAWS環境ならCodeDeployを活用すれば良さそうな話だが、自分が今扱っているアプリケーションはわけありでAWSなどは活用できず、dockerだけでどうにかする必要があった。

パッと思いたのは「nginxのコンテナを前段に立ててデプロイの度にupstreamを書き換える」という方法。 blueコンテナとgreenコンテナが記載されたupstream.confをボリュームマウントさせてnginxコンテナを起動しておき、デプロイのときにそれぞれをdown/upさせながらデプロイさせるというもの。 軽くググってみても似たことを試している事例は結構あった。

でも都度ファイルを書き換える事をしていると、リポジトリでのバージョン管理がとてもやりにくくなるのでupstream.confを書き換えなくてもblue/greenデプロイできるようないい方法無いかな〜と模索していた。 そんななか見つけたのがngx_dynamic_upstreamである。

ngx_dynamic_upstreamとは?

GitHub - cubicdaiya/ngx_dynamic_upstream: Dynamic upstream for nginx

nginxモジュールの一つで、upstreamの動的な更新を可能とする。 APIはHTTPで提供されており、curlコマンドなどでupstreamに登録されたサーバの一覧をとったり、down/upさせたり、サーバの追加/削除などが可能になる。

また、nginxを起動するときは初期値としてupstreamの定義が必要だが、nginxを起動した後であれば、API経由でupstreamを更新してもconfigファイル自体の書き換えは行われない。 おそらくオンメモリでupstreamの情報を持っており、それだけを更新しているのだろう。

今回の検証環境

ざっとこんな構成にしてみる。

今回試したいのはロードバランサの部分なので、アプリケーション部分は適当にdocker runするだけでも使えそうなものとしてdocker/getting-startedを採用した。 ちなみにdocker/getting-startedとはdockerのチュートリアルを見れるアプリケーションである。 詳しくは割愛するので、気になる人は各自GitHubをのぞくなりコンテナを起動するなりしてくだされ。

ロードバランサをビルドするためのDockerfile

ngx_dynamic_upstreamはリポジトリをcloneしてソースビルドでインストールする必要がある。 ngx_dynamic_upstream の開発者である cubicdaiyaさんが便利なビルドツールnginx-buildを提供していたので使ってみることにした。 ubuntu:20.04をベースイメージとしてその中でビルドした。

作成したDockerfileはこちら:https://github.com/rsym/docker-bg-deployment/blob/main/Dockerfile

ビルドの流れはこんな感じ。

  1. 必要なパッケージをインストール
    • nginx-buildをgo getで入手するためにgolangをインストールする
    • ngx_dynamic_upstreamをgit cloneするためにgitをインストールする
    • その他、nginx-buildには必要パッケージをよしなにインストールしてくれる機能があるが、それだけでは入らなかったものがあったので個別に入れることにした
  2. nginx-buildを実行
    • 指定するオプションは以下
      • nginx:1.21.6の公式コンテナで採用されているオプション(docker run --rm -it nginx:1.21.6 nginx -V で確認できる)
      • ngx_dymanic_upstreamを追加するためのオプション --add-module=/path/to/ngx_dynamic_upstream
  3. make installを実行
  4. 必要なユーザやディレクトリを作成
    • 2.で指定したオプションに合わせて必要なもの作成する
  5. upstreamを定義したnginx.confをイメージへCOPY
  6. EXPOSE書いたりCMD書いたり

blue/greenデプロイをしてみる

deploy.shというデプロイスクリプトを実行するだけでblue/greenデプロイをしてくれるようにした。 ちなみにコンテナがどれも起動していないときはloadbalancerとblueコンテナを起動してくれるようにしている。

$ docker-compose ps
NAME                COMMAND                  SERVICE             STATUS              PORTS
app-blue            "/docker-entrypoint.…"   app-blue            running             80/tcp
loadbalancer        "nginx -g 'daemon of…"   loadbalancer        running             0.0.0.0:80->80/tcp, :::80->80/tcp

http://localhostにアクセスするとapp-blueに流れていることがわかる。

$ docker-compose logs -f

...

app-blue      | 172.18.0.4 - - [16/Mar/2022:11:14:52 +0000] "GET /tutorial/ HTTP/1.0" 304 0 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36" "-"
app-blue      | 172.18.0.4 - - [16/Mar/2022:11:14:53 +0000] "GET /assets/images/favicon.png HTTP/1.0" 200 521 "http://localhost/tutorial/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36" "-"

この状態でdeploy.shを実行するとapp-greenに切り替えてくれる。 「app-green起動」→「upstream書き換え(app-green up & app-green down)」→「app-blue停止」という流れでデプロイしている。

$ sh deploy.sh
[+] Running 1/1
 ⠿ app-green Pulled                                                                                                             2.4s
[+] Running 1/1
 ⠿ Container app-green  Started                                                                                                 0.6s
server 172.18.0.2:80 weight=1 max_fails=1 fail_timeout=10;
server 172.18.0.3:80 weight=1 max_fails=1 fail_timeout=10;
server 172.18.0.2:80 weight=1 max_fails=1 fail_timeout=10 down;
server 172.18.0.3:80 weight=1 max_fails=1 fail_timeout=10;
[+] Running 1/1
 ⠿ Container app-blue  Stopped                                                                                                  0.2s
Going to remove app-blue
[+] Running 1/0
 ⠿ Container app-blue  Removed                                                                                                  0.0s
deployment is completed!
===== current upstream =====
server 172.18.0.2:80 down;
server 172.18.0.3:80;
===== current service =====
NAME                COMMAND                  SERVICE             STATUS              PORTS
app-green           "/docker-entrypoint.…"   app-green           running             80/tcp
loadbalancer        "nginx -g 'daemon of…"   loadbalancer        running             0.0.0.0:80->80/tcp, :::80->80/tcp

切り替え中のdocker-composeのログはこんな感じ。

app-green     | 2022/03/16 11:17:18 [notice] 1#1: using the "epoll" event method
app-green     | 2022/03/16 11:17:18 [notice] 1#1: nginx/1.21.6
app-green     | 2022/03/16 11:17:18 [notice] 1#1: built by gcc 10.3.1 20211027 (Alpine 10.3.1_git20211027)
app-green     | 2022/03/16 11:17:18 [notice] 1#1: OS: Linux 4.18.0-348.el8.0.2.x86_64
app-green     | 2022/03/16 11:17:18 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576
app-green     | 2022/03/16 11:17:18 [notice] 1#1: start worker processes
app-green     | 2022/03/16 11:17:18 [notice] 1#1: start worker process 32
app-green     | 2022/03/16 11:17:18 [notice] 1#1: start worker process 33
app-green     | 2022/03/16 11:17:18 [notice] 1#1: start worker process 34
app-green     | 2022/03/16 11:17:18 [notice] 1#1: start worker process 35
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down
app-blue      | 2022/03/16 11:17:19 [notice] 32#32: gracefully shutting down
app-blue      | 2022/03/16 11:17:19 [notice] 32#32: exiting
app-blue      | 2022/03/16 11:17:19 [notice] 33#33: gracefully shutting down
app-blue      | 2022/03/16 11:17:19 [notice] 33#33: exiting
app-blue      | 2022/03/16 11:17:19 [notice] 34#34: gracefully shutting down
app-blue      | 2022/03/16 11:17:19 [notice] 32#32: exit
app-blue      | 2022/03/16 11:17:19 [notice] 34#34: exiting
app-blue      | 2022/03/16 11:17:19 [notice] 33#33: exit
app-blue      | 2022/03/16 11:17:19 [notice] 35#35: gracefully shutting down
app-blue      | 2022/03/16 11:17:19 [notice] 35#35: exiting
app-blue      | 2022/03/16 11:17:19 [notice] 34#34: exit
app-blue      | 2022/03/16 11:17:19 [notice] 35#35: exit
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: signal 17 (SIGCHLD) received from 34
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: worker process 33 exited with code 0
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: worker process 34 exited with code 0
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: signal 29 (SIGIO) received
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: signal 17 (SIGCHLD) received from 32
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: worker process 32 exited with code 0
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: worker process 35 exited with code 0
app-blue      | 2022/03/16 11:17:19 [notice] 1#1: exit

再びhttp://localhostにアクセスするとapp-greenへ流れていることがわかる。

app-green     | 172.18.0.4 - - [16/Mar/2022:11:21:01 +0000] "GET /tutorial/ HTTP/1.0" 304 0 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36" "-"
app-green     | 172.18.0.4 - - [16/Mar/2022:11:21:08 +0000] "GET /tutorial/ HTTP/1.0" 304 0 "http://localhost/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36" "-"

ちなみにコンテナ内のnginx.confに記述したupstreamはコンテナ起動時のまま。 でもちゃんとapp-greenにリクエストが流れてくれる。

$ docker exec -it loadbalancer cat /etc/nginx/nginx.conf

...

    upstream backends {
        zone zone_for_backends 128k;
        server app-blue;
        server app-green down;
    }
}

$ curl "http://127.0.0.1/dynamic?upstream=zone_for_backends"
server 172.18.0.2:80 down; ←app-blue
server 172.18.0.3:80;   ←app-green

以後deploy.shを実行するたびにapp-blue→app-green→...となってくれる。


工夫の余地はあるかもしれないが、ngx_dynamic_upstreamを用いてblue/greenデプロイを実現することはできそう。