Dockerに載せたサービスをホットデプロイする

みなさん,Docker使ってますか?

開発環境に導入する話はよく聞きますが,本番Dockerで運用してますか?

本番をDockerにする上で障壁になりそうなホットデプロイ

普段,コンテナではなくインスタンス上で直接サービスを動かしている限り,そこまで苦労はしていないんじゃないだろうか. たとえば,Railsであればunicornなどは,graceful restartに対応している. そのため,デプロイの際にサービスを止めずにデプロイすることができるのは,当たり前のことに思える.

しかし,Dockerとなると,ポートという障壁が出てきて,一筋縄ではいかない. ECSのような楽な解決方法がある一方で,Docker swarmを使うようなシーンでは,やはり一筋縄ではいかないのではないだろうか.

お題

本番にDockerを導入するにあたり,以下のような環境を前提として試していた.

  • 中に入れるのは普通のwebアプリケーション,今回はgoで書いたもの
  • アプリケーションはポートをホストマシン側にEXPOSEすることで通信している(socketとかじゃない)
  • インフラはAWSで構築しているけどECSは使ってない
  • ELBも使っていない,インスタンス1台しか用意してない

一度今回はECSはのことは忘れてもらう. ECSでデプロイできれば,今回のような課題はそもそも生まれないだろう.

どこが辛いのか?

インスタンス1台について考えてみてほしい.

ホットデプロイをするためには,サービスを常に稼働し続けておく必要がある. ここで,一つのDockerコンテナそれ自体をgracefulにrestartすることは不可能である.

つまり,一時的にコンテナは2つ起動しなければならない.

即ち,デプロイ時の挙動としては,

  1. 新しいコンテナを立ち上げる
  2. 新しいコンテナにアクセスできる状態にする
  3. 古いコンテナのアクセスを遮断する
  4. 古いコンテナを止める

という順序をでデプロイする必要がある.

ここで問題になるのがポートだ. Dockerは起動する際にポートをホストマシン側にEXPOSEしている.

$ docker run -d -p 9090:9090 --name asumibot_1 asumibot

そして,ホストマシンのnignxはDockerのポートに向かってリバースプロキシしている.

upstream asumibot {
    server 127.0.0.1:9090;
}

server {
    listen 80;
    server_name asumi.ch;

    location / {
        proxy_pass http://asumibot;
    }
}

この状態で,新しいコンテナを起動しようとすると,ホストマシン側のポートが競合してしまう. そのため,同じポートをEXPOSEして起動することができない.

f:id:h3poteto:20160813185735p:plain

さて,どうしようか!

ホスト側のポート番号をずらす?

$ docker run -d -p 9091:9090 --name asumibot_2 asumibot

みたいにすれば起動はできる.

f:id:h3poteto:20160813185745p:plain

しかし,nginxの向き先を9091に変えなければならない.

ここが鬼門になる…….

デプロイ時にnginxの設定を書き換えるとなると,それはそれですごいデプロイスクリプトができそうだ.

デプロイ時のnginx書き換え選択肢としては,

  • sedで頑張る(無謀
  • sites-availableに2パターンのconfを作った上で,dockerの起動時に特定パスにファイルを吐き出し,nginxではファイルの存在判定をすることで,2つのconfを切り替える
  • ひとつ別のサービスを挟んでみる(consulみたいな

というようなのが考えられる. nginxでファイルの存在判定はありかなーとは思うが,若干めんどくささはある.さらに,後からポート番号を変えたいときなどに手を加える箇所が多くなる.

consulを使ったconsul templateのようなのは,なかなか良い選択肢だ.

confdという救世主

consulはいいのだけれど,consulの運用をしたくないというのがある.

もうすこしバックエンドのサービスを上手いこと外部に出せないかなーと思って, @minamijoyo に紹介してもらったのが,confdだ.

github.com

こいつは筋が良い.

バックエンドのサービスは,consulをはじめ,etcd, etcd, consul, dynamodb, redis, vault, zookeeper, 環境変数といろいろ選べる.

redisはいいぞー!なにしろElastiCacheに丸投げできる!

おまけに,nginxの設定がサンプルで書いてある.

confd/template-resources.md at master · kelseyhightower/confd · GitHub

これは便利だ.

機能としては,consul templateと大体同じだ. 特定のフォーマットでテンプレートを書いておくと,バックエンドのredis等のキーの値変更を検知して,confをテンプレートに沿って書き換えてくれて,終わったらreloadコマンドを打ってくれる.

デプロイ方法

confdの準備

confdのテンプレートはサンプル通りに書いた.

[template]
src = "nginx.conf.tmpl"
dest = "/etc/nginx/nginx.conf"
uid = 0
gid = 0
mode = "0644"
keys = [
  "/nginx/upstream/asumibot/port",
]
check_cmd = "/usr/sbin/nginx -t -c {{.src}}"
reload_cmd = "/usr/sbin/service nginx reload"

また,nginxのconfigテンプレートはこんな感じ.

upstream asumibot {
    server 127.0.0.1:{{getv "/nginx/upstream/asumibot/port"}};
}

server {
    listen 80;
    server_name asumi.ch;

    location / {
        proxy_pass http://asumibot;
    }
}

これでnginxとconfdを起動しておく.

デプロイ手順

  1. 新しいimageをpullする
  2. pullしたimageで新しいコンテナを起動する(使ってないポートを適当に割り当てる
  3. 起動時に割り当てたポートをredisに送信する
  4. confdがredisの変更を検知し,勝手にnginxの設定を書き換えreloadしてくれる
  5. nginxのreloadまで待つ
  6. 古いコンテナをstopする
  7. 古いコンテナを消す

筋がいい.すごくいい.

nginxのconf書き換えのために,デプロイスクリプト内でsudoする必要がないあたりも,すごく良い.

遊びの結果

遊びではあったんですが,これにより,fascia.io の本番がDocker化されました.

デプロイには,こんなgoのソースを書いています.

fascia/deploy.go at master · h3poteto/fascia · GitHub

雑感

そもそもなんで1台のインスタンスで完結したかったかというと,ELB使うと追加コストが結構かかるからです. 遊びで作ってるものなので,本番をDockerにするためだけにELBは高い. ましてや今のところ1台で運用できているレベルなので,複数台用意する必要がないのにELBって.

実をいうと,goのwebアプリケーションそれ自体も,graceful restartはできていないんですね. もちろんそういうことができるように,goのサーバをまともに書けばいけるのかもしれないんですが.

そいうわけで,今まではgoをデーモン化するのに,circusを使っていました.

ただ,circusを使ってgracefulにrestartできるようになるからといって,デプロイはそう簡単じゃないです. やっぱりCapistranoみたいに,新世代,旧世代のディレクトリをそれぞれ作って,symlinkを張り替えて……みたいなことをしないと,実現できないんですね.

そこまで作りこもうかとも思ったんですが,どうもgoのデプロイを調べているとDockerばかりが出てくる.

やりたいのはよくわかります.せっかくワンバイナリになるgoを,わざわざサーバ側でコンパイルしたくないとか. そもそもコンパイルしても,goのバイナリをどこで配布するのかとか.

そういうわけで,どうせマトモなデプロイを作るなら,Dockerでやろうと思いました.

下で書きますが,意外にもこの方式,Docker swarmで使えそうな気がしています.

その他いろいろ

confdをホスト側に作りたくない

どうせDockerでインフラ構築するのであれば,このnginx設定やconfdの設定もDockerに乗せておきたくなります.

ただ,これは仕組み上なかなかうまく実現できそうになかった.

confdが動くということは,それで1プロセス使います. Dockerの思想的には,1コンテナに1プロセスを割り当てたいので,confdで1コンテナ,nginxで1コンテナにしたいところではあります. しかし,confdのreload_cmd はDockerのコンテナ間通信までは対応していないでしょう.

となると,どうしてもconfdとnginxは同じコンテナ内で動いている必要があります.

なので,nginxのコンテナで,supervisordか何かを使ってconfdも起動しておけばいけるかもしれません. ただ,そこまでしてこれをDockerに載せたいかなぁ……というところです.

当然のことですが,この構成の場合,nginx+confdのコンテナをデプロイするためにはサービス断が発生します.

BlueGreenできれば楽

今回は一台のインスタンスに限った話をしていますが,インスタンスを2台以上用意できるのであれば,もっと簡単に決着します. つまり,BlueGreenデプロイをしてしまえば良い.

  1. ELBにインスタンス1がついているとする
  2. インスタンス2を起動し,Dockerを起動する
  3. ELBにインスタンス2をつけて,インスタンス1を切り離す
  4. インスタンス1を停止する

普通のBlueGreenですね. インスタンスが別なので,ポートがかぶることはありえない. そのため,ELBは常に同じポートをListenさせておけば問題ないというわけです.

ECSなどはこの方式でデプロイしていて,デプロイ時には一時的にインスタンス数を増やして,BlueGreenして新しいコンテナに切り替えています.

Swarmだと今回の話が役立つ?

まだswarmでクラスタを組んで試したわけではないので,以下の話は妄想としてください.

Docker swarmでコンテナ複数個をクラスタ組んで用意した場合に,どうやってデプロイするんでしょうか. コンテナのデプロイ自体はswarmがやってくれます. しかし,それをgracefulに,サービスを動かしながらやるには?

そもそも,swarmは,1インスタンスに1コンテナというような制約は特にありません. そのため,たとえば1台のインスタンスrailsのコンテナが複数個立っているような場合も想定できます.

そういったときに,コンテナ起動時のポートをどうするか. 実は,swarm managerに登録するときに申告しておけば,どんなポートを割り当てておいてもswarm manager側から把握できます.

なので,1インスタンス内に,同じコンテナを複数立てるようなことも可能なのがswarmの特徴です.

しかし,コンテナのオーケストレーションはそれでよくても,Webサービスとしてのアクセスはどう解決するんでしょうか. そのレイヤーまではswarmで面倒見てくれません.

こうなってくると今回の問題に近くなってきます.

swarm側でポートの固定は行わずに,swarm managerが知っているポートを,etcdやconsulに預けておくというのは,なかなか良い手です. そして,ELBからアクセスを受け付けるnginxだけはポートを固定しておく.

そして,nginxの設定をconfdで書き換えれば,swarm側でコンテナがデプロイされ,切り替わったタイミングに合わせて,nginxの向き先も変わっていきます.

なかなか良さそうな気がしてきません?

参考

qiita.com

qiita.com