みなさん,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つ起動しなければならない.
即ち,デプロイ時の挙動としては,
- 新しいコンテナを立ち上げる
- 新しいコンテナにアクセスできる状態にする
- 古いコンテナのアクセスを遮断する
- 古いコンテナを止める
という順序をでデプロイする必要がある.
ここで問題になるのがポートだ. 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して起動することができない.
さて,どうしようか!
ホスト側のポート番号をずらす?
$ docker run -d -p 9091:9090 --name asumibot_2 asumibot
みたいにすれば起動はできる.
しかし,nginxの向き先を9091に変えなければならない.
ここが鬼門になる…….
デプロイ時にnginxの設定を書き換えるとなると,それはそれですごいデプロイスクリプトができそうだ.
デプロイ時のnginx書き換え選択肢としては,
- sedで頑張る(無謀
- sites-availableに2パターンのconfを作った上で,dockerの起動時に特定パスにファイルを吐き出し,nginxではファイルの存在判定をすることで,2つのconfを切り替える
- ひとつ別のサービスを挟んでみる(consulみたいな
というようなのが考えられる. nginxでファイルの存在判定はありかなーとは思うが,若干めんどくささはある.さらに,後からポート番号を変えたいときなどに手を加える箇所が多くなる.
consulを使ったconsul templateのようなのは,なかなか良い選択肢だ.
confdという救世主
consulはいいのだけれど,consulの運用をしたくないというのがある.
もうすこしバックエンドのサービスを上手いこと外部に出せないかなーと思って, @minamijoyo に紹介してもらったのが,confdだ.
こいつは筋が良い.
バックエンドのサービスは,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を起動しておく.
デプロイ手順
- 新しいimageをpullする
- pullしたimageで新しいコンテナを起動する(使ってないポートを適当に割り当てる
- 起動時に割り当てたポートをredisに送信する
- confdがredisの変更を検知し,勝手にnginxの設定を書き換えreloadしてくれる
- nginxのreloadまで待つ
- 古いコンテナをstopする
- 古いコンテナを消す
筋がいい.すごくいい.
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デプロイをしてしまえば良い.
普通の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の向き先も変わっていきます.
なかなか良さそうな気がしてきません?