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

dynamic_proxy_single
シングルホストでのダイナミックリバースプロキシ構成

 

  1. containerがrun/stop/startされる度に記録されるapiのイベントをpythonで読み込み、「コンテナのアドレスとexposeされているポート」を、「コンテナ起動時に環境変数として与えた名前」をキーとしてredisに登録します。
  2. 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」を配置します。

dynamic_proxy_multihost
docker swarmクラスタでのダイナミックリバースプロキシ構成

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ホストにコンテナをリリース出来るというわけです。

という訳でゴタクを並べてきましたが、実際に作ってみましょう。

  1. docker swarmクラスタの構築
  2. ディレクトリの作成
  3. managerのリバースプロキシ環境を作成
  4. workerのリバースプロキシ環境を作成

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アドレス:ポート

Contents

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耐性が著しく低かったので、これが精一杯でした。。。

※ コメットさん☆は名作です

関連記事

fitbitの睡眠スコアを90弱で安定させる良い睡眠を続ける簡単な方法

m1 ipad pro 12.9 2021のusb-cハブはコレがベスト

Time Machine不要!Macを11.2.3にダウングレードして原神をm1 macbook airでプレイする

MH-Z19CとM5StickCで二酸化炭素濃度モニタリング

【神軽量HMD】Avegant Glyph 改造: 瓶詰堂さんのaltglyphを作った

PC、iPad、Android、switchもドックいらず!あまりに万能なusb-cハブが最強だった

コメント

コメントを返信する

メールアドレスが公開されることはありません。 が付いている欄は必須項目です