Docker コンテナごと Node.js アプリケーションをホットデプロイする方法

docker-logo

こんにちは。Tokyo Otaku Mode(以下 TOM)ソフトウェアエンジニアの稲田です。

TOM が運営している otakumode.com は現在 1 日約 3、4 回ほどの頻度で更新されています。
これを多いと見るか少ないと見るかは人によって違うと思いますが、デプロイのたびにサイトにアクセスできない状態になっていては、まともなサイト運営とは言えないでしょう。

そこで、サイトへのアクセスを一瞬も止めることなくデプロイする、いわゆるホットデプロイと呼ばれるものが必要になります。

TOM ではアプリケーションサーバーに Node.js を使っており、既に Cluster モジュールを使った独自のホットデプロイの仕組みが実装されています。

今回はこのホットデプロイを Docker と Node.js でもできるようにしていきます。

Docker

Docker とは Docker, Inc.(旧 dotCloud, Inc.)が開発しているオープンソースの仮想化のためのソフトウェアです。
従来の仮想化と違うところはコンテナと呼ばれる単位で管理され、仮想マシンが必要ないのでオーバーヘッドがほとんどなくパフォーマンスに優れています。

また、ビルドした Docker コンテナを別の Docker が動くマシンに持っていっても動作させることができるため、1 度コンテナをビルドすれば複数台のサーバーで同じ Docker コンテナが動かせます。これはアプリケーションサーバーのデプロイに向いていると思いませんか?

これ以上の Docker についての説明はここでは割愛しますが、もっと詳しく知りたい場合は 公式ドキュメント を見てください。

必要なもの

  • Docker
  • Linux カーネル 3.9 以降
  • Nginx 1.9.1 以降
  • Node.js

今回必要なものとして Node.js を上げましたが、他のアプリケーションサーバーにも応用できます。

ホットデプロイの概要

Docker コンテナを事前にビルドしてサーバーにデプロイするという方式を取る際にも必要になるのが冒頭でも書いた、無停止でのデプロイ、ホットデプロイです。
しかし Docker コンテナは単なる環境なので、中で実行するプログラムが必要です。
TOM では主に Node.js を使用しているので、以降は Node.js で作成したアプリケーションサーバーが Docker コンテナで実行されると仮定して進めます。

単純に Docker コンテナを停止/起動させた場合、アプリケーションサーバーも一緒に停止するため停止してから起動するまでの間に接続できない時間が発生してしまいます。
そこで、新しい Docker コンテナを起動してから古い Docker コンテナを停止させることができれば、接続できない時間を発生させることなくデプロイできます。

例えば現在動いている Docker コンテナを A、新しく起動する Docker コンテナを B とすると

A 停止 → B 起動

ではなく

B 起動 → A 停止

とできれば良いということです。

通常のホットデプロイは、あるポートを bind しているソケットを複数のアプリケーションサーバーで共有することによって上記を実現しますが、今回は SO_REUSEPORT を使います。

SO_REUSEPORT

SO_REUSEPORT は Linux カーネル 3.9 から入った機能で、これを使うことによって同じポートで待ち受けるアプリケーションを複数立ち上げられるようになります。

しかしながら 2015/12/17 現在の Node.js v5.2.0 は Linux の SO_REUSEPORT に対応していません。

そこで、SO_REUSEPORT に対応している Nginx(バージョン 1.9.1 以降)をリバースプロキシとして配置して、Node.js アプリケーションサーバーを upstream にすることで解決します。

Examples

実際にホットデプロイを行うために用意したファイルと設定について解説していきます。

ファイル構成は以下の通りです。

.
|-- Dockerfile
|-- app.js
`-- docker
    |-- nginx.conf
    |-- nginx.repo
    `-- run.sh

Dockerfile

FROM centos:7

RUN yum update -y && yum clean all
RUN yum groupinstall -y "Development Tools"

COPY ./docker/nginx.repo /etc/yum.repos.d/

RUN yum install -y nginx

ENV NODE_VERSION v5.2.0
RUN curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.29.0/install.sh | bash
RUN . ~/.nvm/nvm.sh && nvm install ${NODE_VERSION} && nvm alias default ${NODE_VERSION}
RUN echo ". ~/.nvm/nvm.sh" >> ~/.bashrc

RUN rm -f /etc/nginx/conf.d/*
COPY ./docker/nginx.conf /etc/nginx/

COPY . /apps/

CMD ["/apps/docker/run.sh"]

app.js

今回は簡単に特定の文字列を返すだけのアプリケーションです。

var fs = require("fs");

var server = require("http").createServer(function(req, res) {
  res.write("hello hot-deploy!\n");
  res.end();
});

server.on("listening", function() {
  fs.writeFileSync("/var/run/app.pid", process.pid);
});

server.listen("/var/run/app.sock");

前述の通り Node.js は SO_REUSEPORT に対応していないので、ここで

server.listen(4000);

などとすると、新しい Docker コンテナを起動したときに同じ 4000 番ポートを bind しようとしてエラーになり起動しません。
それを回避するために Unix Domain Socket を使い、後述の nginx.conf でこの Unix Domain Socket を指定します。
また、完全に起動した後に app.pid を書き出すようにして、アプリケーションサーバーが起動したかどうかを run.sh から確認できるようにしておきます。

nginx.repo

Nginx 1.9 以降が必要なので mainline を使います。

[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/mainline/centos/$releasever/$basearch/
gpgcheck=0
enabled=1

nginx.conf

user root;
worker_processes auto;
daemon off;

events {
    worker_connections 4096;
}

http {
    include /etc/nginx/mime.types;
    access_log off;
    sendfile on;
    server_tokens off;
    keepalive_timeout 0;

    upstream app {
        server unix:/var/run/app.sock;
    }

    server {
        listen 3000 reuseport;

        location / {
            root /apps/public;
            try_files $uri @app;
        }

        location @app {
            proxy_set_header X-Forwarded-HTTPS $http_x_forwarded_https;
            proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Host $http_host;

            proxy_pass http://app;
        }
    }
}

ポイントは

  1. listen 3000 reuseport; として SO_REUSEPORT を使うようにする
  2. バックエンドのアプリケーションサーバーとのやり取りは Unix Domain Socket を使う
  3. daemon off; としてフォアグラウンドで動作させるようにする

です。

1. listen 3000 reuseport; として SO_REUSEPORT を使うようにする

説明するまでもないですね :)

2. バックエンドのアプリケーションサーバーとのやり取りは Unix Domain Socket を使う

app.js は Unix Domain Socket で待ち受けているので、Nginx 側もそれに合わせます。

upstream app {
    server unix:/var/run/app.sock;
}

3. daemon off; としてフォアグラウンドで動作させるようにする

Nginx を Docker のメインプロセスとして動作させるため、daemon off; とします。

run.sh

#!/bin/sh

. ~/.nvm/nvm.sh

export NODE_ENV=${NODE_ENV:-development}

node /apps/app.js &

until ls /var/run/app.pid >/dev/null 2>&1; do
    sleep 1
done

exec /usr/sbin/nginx -c /etc/nginx/nginx.conf

app.js が起動した後に書き出す app.pid ができるのを待ってから Nginx を起動するようにします。
そうすることによって Nginx が先に起動して Bad Gateway になってしまう問題を防ぎます。
app.js を起動する部分は forever などを使ってもいいでしょう。

また、これは Docker コンテナのメインプロセスとなるスクリプトなので実行権限を付けておきます。

chmod +x run.sh

ホットデプロイの実践

Docker コンテナのビルド

ここではわかりやすいように hotdeploy_app というタグを付けています。

docker build -t hotdeploy_app .

Docker コンテナの起動

先ほどビルドした Docker コンテナを起動します。

docker run -d --net=host -e NODE_ENV=development hotdeploy_app

--net=host オプションを付けることでホスト側のネットワークスタックをそのまま使うようしています。
これでホスト側が Linux カーネル 3.9 以上であればコンテナ側でも SO_REUSEPORT が使えるようになります。

--net=host オプションを付けて起動しているので localhost に対して接続テストをします。

$ curl http://localhost:3000/
hello hot-deploy!

Docker コンテナのホットデプロイ

それではいよいよ Docker コンテナ自体をホットデプロイしてみます。

restart.sh という名前で下記の内容のシェルスクリプトを用意します。

# 現在起動している Docker コンテナの ID を取得
running_container_id=$(docker ps -ql --no-trunc --filter='status=running')

# 新しく Docker コンテナを起動してその ID を取得
container_id=$(docker run -d --net=host -e NODE_ENV=development hotdeploy_app)

# 新しく起動した Dokcer コンテナが完全に起動するのを待つ
docker exec $container_id bash -c 'while [ -z "$(cat /var/run/nginx.pid 2>/dev/null)" ]; do sleep 1; done'

# 古い Docker コンテナのメインプロセスを落とす
# run.sh の最後で Nginx を起動しているためメインプロセスは Nginx です
# ですので Nginx に対して Graceful restart を行うため SIGQUIT を送ります
docker kill -s QUIT $running_container_id

restart.sh を実行する前に、現在起動している Docker コンテナの ID を確認しておきます。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                 CREATED             STATUS              PORTS               NAMES
789b134a613d        hotdeploy_app       "/apps/docker/run.sh"   8 seconds ago       Up 7 seconds                            gloomy_bhabha

789b134a613d が現在起動している Docker コンテナの ID です。

次に、ホットデプロイができているかを検証するために ab コマンドでリクエストを送りつつ別のターミナルで restart.sh を実行します。

ab -c 4 -n 100000 http://localhost:3000/
sh ./restart.sh
This is ApacheBench, Version 2.3 <$Revision: 1706008 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 10000 requests
Completed 20000 requests
Completed 30000 requests
Completed 40000 requests
Completed 50000 requests
Completed 60000 requests
Completed 70000 requests
Completed 80000 requests
Completed 90000 requests
Completed 100000 requests
Finished 100000 requests


Server Software:        nginx
Server Hostname:        localhost
Server Port:            3000

Document Path:          /
Document Length:        18 bytes

Concurrency Level:      4
Time taken for tests:   21.314 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      10800000 bytes
HTML transferred:       1800000 bytes
Requests per second:    4691.68 [#/sec] (mean)
Time per request:       0.853 [ms] (mean)
Time per request:       0.213 [ms] (mean, across all concurrent requests)
Transfer rate:          494.83 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0      11
Processing:     0    1   0.9      1      31
Waiting:        0    1   0.9      1      31
Total:          0    1   1.0      1      32

Percentage of the requests served within a certain time (ms)
  50%      1
  66%      1
  75%      1
  80%      1
  90%      1
  95%      3
  98%      3
  99%      5
 100%     32 (longest request)

Failed requests が 0 になっているので、失敗したリクエストがないことがわかります。
現在起動している Docker コンテナも確認してみましょう。

$ docker ps
CONTAINER ID        IMAGE               COMMAND                 CREATED             STATUS              PORTS               NAMES
a3b1d183c018        hotdeploy_app       "/apps/docker/run.sh"   9 seconds ago       Up 8 seconds                            hopeful_feynman

789b134a613d から a3b1d183c018 にコンテナ ID が変わっており、ホットデプロイができていることがわかります。

まとめ

Docker + Node.js でホットデプロイが実現できることがわかりました。
TOM ではまだ検証段階ですが、今後の検証結果次第では十分実用できるのではないかと思います。
それでは、良い Docker ライフをお送りください。

参考