software design 2014年12月号のDocker特集で、nginx + redisでダイナミックにリバースプロキシする方法が紹介されていました。
[amazonjs asin=”B00P1IQ8FC” locale=”JP” title=”Software Design (ソフトウェア デザイン) 2014年 12月号 雑誌”]
コンテナが追加されるたびに、定期実行しているプログラムでコンテナのローカルアドレスをredisに追加し、nginx-extras + lua moduleを使ってサブドメイン指定で対象コンテナにリバースプロキシするという感じ。コンテナを壊しては作るという変化の激しい状況にあって、いちいちリバースプロキシ先を追加することなく開発環境を管理できるというスグレモノです。
ですが、マルチホスト環境では勝手が違ってきます。たくさんのアプリケーションを走らせたいならばdockerをマルチホストに展開することは必須であるし、そのような状況でもシングルホストのdocker環境と同じように扱いたいものです。
というわけで動的リバースプロキシ環境を、docker swarmをマルチホストに展開させた状況で構築してみました。
まず、シングルホストの構成を再確認してみましょう。SDのやつよりも、下記のブログで紹介されているものの方が良さげ(goの代わりにpythonを使いAPIに接続している)だったのでそちらに即しています。
nginxでダイナミックにリバースプロキシするやつ – はわわーっ: http://yomi322.hateblo.jp/entry/2015/02/01/195917

- containerがrun/stop/startされる度に記録されるapiのイベントをpythonで読み込み、「コンテナのアドレスとexposeされているポート」を、「コンテナ起動時に環境変数として与えた名前」をキーとしてredisに登録します。
- nginxにユーザーが”アプリ名.nginx.ip.add.ress.xip.io”みたいな感じでサブドメインアクセスしてきたらアプリ名をキーとしてredisにアドレス・ポートを問い合わせ、lua moduleでリバースプロキシ先を書き換えます。
マルチホストでこの構成を作る時に問題となったのは、以下の2つでした。
- リバースプロキシ先がdockerネットワークであるので、別ホストに飛ばした上でそのホストのdockerネットワークに飛ばすという2段のプロキシが必要
- アクセス窓口になるコンテナなど、特定のホストに特定の役割を持ったコンテナをリリース出来なければならない
1つめの問題は、すべてのホストに上記nginx+redis+pythonスクリプト構成をデプロイすることで解決出来ます。(他にもっとスマートな方法もあるのかもしれませんが思いつきませんでした。)アクセス先のコンテナが自ホストで動いていない場合は、対象となるホストのnginxにリバースプロキシするようにすれば、それぞれのnginxが自ホストのコンテナを把握していれば事足ります。
ただし、最初にアクセスするnginxのホストでは、アクセスを各ホストに振り分けるために、「dockerクラスタの全コンテナがそれぞれどのホストで動いているか」を把握していなければなりません。幸い、これはdocker swarmのapiで取得できるので、docker swarm managerのホストにこの「窓口となるnginx」を配置します。

docker swarmクラスタの構築はここでは詳しくは触れませんが、下記記事などが参考になります。
Docker Swarmでクラスタを構築する – Qiita: http://qiita.com/atsaki/items/16c50dfd579a9339c333
そして、docker managerホストにリリースするコンテナと他ホストのコンテナをrun時に設定するnameで区別出来るようにするため、出来ればrun時にホストを選択したいです。これは、docker swarmのfilterという機能を使います。
Swarm filters – Docker Documentation: https://docs.docker.com/swarm/scheduler/filter/
要するに、docker daemonの起動オプションとして “–label”を追加すれば、docker swarmを利用したrun時に指定したlabelを持ったホスト群のどれかにリリース出来る、という機能です。
docker swarm managerホストには”node_type=manager”ラベルを、それ以外には”node_type=worker”ラベルを付与し、コンテナのrun時に”-e constraint:node_type==manager”オプションを付与すれば、docker swarmでも確実にmanagerホストにコンテナをリリース出来るというわけです。
という訳でゴタクを並べてきましたが、実際に作ってみましょう。
3つのホストで構成されるネットワークを考えてみます。
ホスト名 | IPアドレス | swarmノードタイプ | wordpressコンテナの名前 |
---|---|---|---|
comet | 192.168.100.11 | manager | comet-blog |
meteor | 192.168.100.12 | worker | meteor-blog |
planet | 192.168.100.13 | worker | planet-blog |
ホスト名の意味はごくごく一部の人にしか伝わらない※と思いますのでスルーしてください。それぞれのホストで1台ずつwordpressが動いているとします。この時、各ホストのredisには以下の情報が保存されるようになります。
ホスト名 | key1 | value1 | key2 | value2 | key3 | value3 |
---|---|---|---|---|---|---|
comet | comet-blog | コンテナのIPアドレス:ポート | meteor-blog | 192.168.100.12:80 | planet-blog | 192.168.100.13:80 |
meteor | meteor-blog | コンテナのIPアドレス:ポート | ||||
planet | planet-blog | コンテナのIPアドレス:ポート |
1. docker swarmクラスタの構築
dockerをインストールしたら、/etc/default/dockerに下記起動時オプションを追記します。
DOCKER_OPTS="-H tcp://0.0.0.0:2375 -H unix:///var/run/docker.sock --label node_type=manager"
workerノードの場合は “node_type=worker”とします。次に、全ノードでswarm イメージをpullします。
$ sudo docker pull swarm
いずれかのホストでswarmクラスタを作成します。
$ sudo docker run --rm swarm create
この時発行されるtokenがCLUSTER_IDとなります。ホストをswarmクラスタに参加させるため、すべてのホストで下記を実行します。
$ sudo docker run -d swarm join --addr=172.17.8.102:2375 token://<CLUSTER_ID>
managerとなるホスト(comet)で以下を実行します。
$ sudo docker run -d -p 2377:2375 swarm manage token://<CLUSTER_ID>
これでdocker swarmクラスタが構築できました。managerノードで-H オプションをつけることで、今まで通りのdockerコマンドでクラスタ全体を扱うことが出来ます。
$ sudo docker -H tcp://localhost:2377 info
2. ディレクトリの作成
docker swarmでコンテナをrunした時、クラスタに所属するノードのいずれかにコンテナがリリースされます。コンテナはいつでも停止できる状態にするのがベストプラクティスですので、nginxの設定ファイルやmysqlのデータはdockerのVOLUME機能を使用してホストと共有することになると思います。例えばホスト meteor上に/home/meteor/jenkinsみたいなディレクトリを用意して、以下のようにおじさんを起動するとします。
$ sudo docker -H tcp://0.0.0.0:2377 run --name jenkins -d -v /home/meteor/jenkins:/var/jenkins_home -e JAVA_OPTS='-Duser.timezone=Asia/Tokyo -Dfile.encoding=UTF-8 -
Dsun.jnu.encoding=UTF-8' jenkins
この時リリース先がcometまたはplanetであったなら、そんなディレクトリは存在ないのでrunに失敗してしまいます。docker swarmでクラスタを構築するにあたっては、共通のディレクトリを用意しておくのが良いです。ここでは /docker_volumes というディレクトリを全ホストで作っておくことにします。
$ sudo mkdir /docker_volumes
3. managerのリバースプロキシ環境を作成
まず、redisをupstreamsという名前で起動します。名前SDの記事に準じますがお好みで。最初にお話したように、constraintを与えることによりmanagerホストで起動させます。
$ sudo docker -H tcp://0.0.0.0:2377 run -e constraint:node_type==manager --name upstreams -P -d redis
次にnginxを起動するのですが、公式のnginx imageはrua_moduleをインストールしていないので、自分でimageを作る必要があります。docker swarmは現時点でbuildをサポートしていないため、下記Dockerfileを作り、全ホストで同名imageをbuildするか、「build後dockerhub or プライベートなdocker registryにpushしたimage」をswarmのコマンドでpullするかする必要があります。
FROM ubuntu:trusty
# Install Nginx.
RUN apt-get update && \
apt-get -y install nginx-extras lua-nginx-redis && \
rm -rf /var/lib/apt/lists/*
RUN rm /etc/nginx/sites-enabled/default
RUN chown -R www-data:www-data /var/lib/nginx
# Define mountable directories.
VOLUME ["/var/log/nginx"]
# Define working directory.
WORKDIR /etc/nginx
# Define default command.
CMD ["nginx", "-g", "daemon off;"]
# Expose ports.
EXPOSE 80 443
imageが全ホストに存在する状態になったなら、cometに/docker_volumes/dproxyディレクトリ、/docker_volumes/dproxy/logディレクトリを作成し、/docker_volumes/dproxy配下に下記のnginx.confを作成します。
user www-data;
env DOCKER_NETWORK_ADDR;
env REDIS_PORT_6379_TCP_ADDR;
env REDIS_PORT_6379_TCP_PORT;
error_log /var/log/nginx/error.log;
pid /var/run/nginx.pid;
events {
worker_connections 768;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
sendfile on;
keepalive_timeout 65;
tcp_nodelay on;
gzip on;
gzip_disable "MSIE [1-6]\.(?!.*SV1)";
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
server {
listen 80 default_server;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
set $upstream "";
rewrite_by_lua '
local redis = require "nginx.redis"
local client = redis:new()
local docker_net_addr = os.getenv("DOCKER_NETWORK_ADDR")
subdomain, _ = string.gsub(ngx.var.host, "([^%.]+)%..*", "%1")
local redis_host = os.getenv("REDIS_PORT_6379_TCP_ADDR")
local redis_port = os.getenv("REDIS_PORT_6379_TCP_PORT")
local ok, err = client:connect(redis_host, redis_port)
if not ok then
ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
local res, err = client:get(subdomain)
if err then
ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
if res == ngx.full then
ngx.exit(ngx.HTTP_NOT_FOUND)
else
ip_1, _ = string.gsub(res, "([^%.]+)%.([^%.]+)%.([^%.]+)%.([^%.]+):.*", "%1")
ip_2, _ = string.gsub(res, "([^%.]+)%.([^%.]+)%.([^%.]+)%.([^%.]+):.*", "%2")
ip_3, _ = string.gsub(res, "([^%.]+)%.([^%.]+)%.([^%.]+)%.([^%.]+):.*", "%3")
ip_4, _ = string.gsub(res, "([^%.]+)%.([^%.]+)%.([^%.]+)%.([^%.]+):.*", "%4")
local ip_addr = ip_1.."."..ip_2.."."..ip_3.."."..ip_4
local ip_addr_network = ip_1.."."..ip_2..".0.0"
if ip_addr_network ~= docker_net_addr then
forward_ip, _ = string.gsub(res, "([^:]+):([0-9]+)", "%1")
port, _ = string.gsub(res, "([^:]+):([0-9]+)", "%2")
ngx.var.upstream = ip_addr
else
ngx.var.upstream = res
end
end
';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect off;
proxy_max_temp_file_size 0;
proxy_pass http://$upstream;
}
}
}
サブドメインをキーとしてredisから取得した値のネットワークアドレスが、環境変数DOCKER_NETWORK_ADDRと異なるならば、対象ネットワークに、そうでないならそのままredisから呼び出したIPアドレス:ポート番号にリバースプロキシする、という感じです。
image名がdrunkar/nginx-extrasであったなら、下記コマンドでdproxyという名前でnginxを起動します。起動時にDOCKER_NETWORK_ADDRという名前の環境変数を渡していますが、これはcometのdockerネットワークのアドレスです。
$ sudo docker -H tcp://0.0.0.0:2377 run -e constraint:node_type==manager --name dproxy -d -p 80:80 -p 443:443 -e DOCKER_NETWORK_ADDR=172.17.0.0 -v /docker_volumes/dproxy/nginx.conf:/etc/nginx/nginx.conf:ro -v /docker_volumes/dproxy/log:/var/log/nginx -d --link upstreams:redis drunkar/nginx-extras
このdockerネットワークのアドレスは、先ほど起動したupstreamsをinspectすることで見ることが出来ます。
$ sudo docker -H tcp://0.0.0.0:2377 inspect upstreams
表示される結果のうち、”NetworkingSettings”下に僕の場合は”IPAddress”: “172.17.0.3”、”IPPrefixLen”: 16 とあったので、172.17.0.0をネットワークアドレスとして渡しています。ちなみにここで表示される結果のうち、”Node”という項目はdocker swarmに特有のもので、これによりコンテナの起動しているホストと、ホストのIPアドレスを取得することが出来ます。後述するPythonスクリプトの内部ではここを利用しています。
では、最後にredisの情報を更新するPythonスクリプトを走らせるコンテナを起動します。Pythonでdocker apiとredisにアクセスするために、dockerモジュールとredisモジュールをインストールしたimageを作る必要があります。下記のDockerfileにより作成します。
FROM python:3-onbuild
CMD [ "python", "-u", "./linked.py" ]
Dockerfileと同じディレクトリに、インストールしたいモジュールを列挙したrequirements.txtを置いていれば、build時にモジュールをインストールしてくれます。便利。
docker-py
redis
imageが出来たら、下記のlinked.pyを/docker_volumes/linked.pyに配置します。
#!/usr/bin/python3
import datetime
import docker
import json
import os
import redis
import sys
import urllib.request
HOST_IP = os.getenv('HOST_IP')
DOCKER_HOST = os.getenv('DOCKER_HOST')
DOCKER_API_BASE_URL = 'tcp://' + DOCKER_HOST
DOCKER_EVENT_API_URL = 'http://' + DOCKER_HOST + '/events'
REDIS_ADDR = os.getenv('REDIS_PORT_6379_TCP_ADDR')
REDIS_PORT = os.getenv('REDIS_PORT_6379_TCP_PORT')
CLIENT = docker.Client(base_url=DOCKER_API_BASE_URL)
EVENT = urllib.request.urlopen(DOCKER_EVENT_API_URL)
REDIS = redis.Redis(host=REDIS_ADDR, port=REDIS_PORT)
def getforwardinfo(container):
inspect = CLIENT.inspect_container(container.get('Id'))
env = inspect.get('Config').get('Env')
name = next(filter(lambda x: 'DPROXY_FORWARD_NAME' in x, env), None)
if name is None:
return None
name = name.split('=').pop()
# if swarm manager node
if 'Node' in inspect:
nodeip = inspect.get('Node').get('Ip')
if nodeip != HOST_IP:
ipaddr = nodeip
port = '80'
else:
ipaddr = inspect.get('NetworkSettings').get('IPAddress')
port = str(container.get('Ports').pop().get('PrivatePort'))
else:
ipaddr = inspect.get('NetworkSettings').get('IPAddress')
port = str(container.get('Ports').pop().get('PrivatePort'))
forward = ipaddr + ':' + port
return (name, forward)
def updateredis():
info = []
for c in CLIENT.containers():
tmp = getforwardinfo(c) if tmp is None:
continue
info.append(tmp)
REDIS.flushall()
for name, forward in info:
REDIS.set(name, forward)
def dumpredis():
print(datetime.datetime.now().ctime())
for k in REDIS.scan_iter():
s = "%10s:\t%s" % (k.decode('utf-8'), REDIS.get(k).decode('utf-8'))
print(s)
def readevent():
x = []
bracket_count = 0
while True:
c = EVENT.read(1)
x.append(c)
if c == b'{':
bracket_count += 1
elif c == b'}':
bracket_count -= 1
if bracket_count == 0:
break
return b''.join(x).decode('utf-8')
def main():
updateredis()
dumpredis()
while True:
ev = readevent()
status = json.loads(ev).get('status')
if status in ('start', 'stop'):
updateredis()
dumpredis()
if __name__ == '__main__':
main()
exit(0)
DOCKER_HOSTで渡されたアドレスにAPIアクセスしてイベントを取得し、NodeのIPアドレスがDOCKER_MANAGER_HOST_IPで渡されたアドレス(cometのアドレス)と異なるならば、NodeのIPアドレスをredisに登録するようになっています。同じならばNetoworkSettingsのIPAddressとPortsのPrivatePortを登録します。ちなみに、このスクリプトはworkerホストでも動くようにしています。workerの場合はswarmではなく自ホストのdocker apiにアクセスし、Nodeプロパティが無いことでswarm apiでないと認識してIPAddressとPortsのPrivatePortを登録します。
先ほどbuildしたimage (drunkar/linked-pyとします)からコンテナを起動します。
$ sudo docker -H tcp://0.0.0.0:2377 run -e constraint:node_type==manager --name linked -d -v /docker_volumes/linked/linked.py:/linked.py --link upstreams:redis -e DOCKER_MANAGER_HOST_IP=192.168.100.11 -e DOCKER_HOST=172.17.42.1:2377 drunkar/linked-py
これで、cometでの環境構築は終了です。シングルホストのダイナミックリバースプロキシと同様に利用できることを確認しましょう。
$ sudo docker -H tcp://0.0.0.0:2377 run -e constraint:node_type==manager -d -P --name comet-blog-db -e MYSQL_ROOT_PASSWORD=cometpass mysql
$ sudo docker -H tcp://0.0.0.0:2377 run -e constraint:node_type==manager -d -P --name foo -e DPROXY_FORWARD_NAME=comet-blog --link comet-blog-db:mysql wordpress
DPROXY_FORWARD_NAMEで渡す名前をサブドメインとしてアクセスすることで、wordpressが開けると思います。やったね!
4. workerのリバースプロキシ環境の作成
もうそろそろゴールしたい感じですが、workerの設定は一瞬です。設定ファイルやスクリプトはmanagerのものと同じものが使えます。redis、nginxはnode_typeがworkerになるだけで、起動方法も一緒です。Pythonスクリプトの起動は下記のようになります(meteorでの例)。
$ sudo docker run --name linked_worker_1 -d -v /docker_volumes/linked/linked.py:/linked.py --link upstreams_worker_1:redis -e HOST_IP=192.168.100.12 -e DOCKER_HOST=172.17.42.1:2375 drunkar/linked-py
swarmからの起動でないことに注意してください。HOST_IPを指定する必要があるので、各ホストで実行する必要があります。
以上で完了です!開発環境を作りまくって、リバースプロキシしまくりです。残る課題として、nginxにリバースプロキシ部がipアドレスでのアクセスにしか対応していないので、ホスト名でのアクセスに対応するにはluaをいじる必要があります。個人的にlua耐性が著しく低かったので、これが精一杯でした。。。
コメント