NGINX に追加された UDP ロードバランシングを試してみた

つい先日、 NGINX 公式ブログでアナウンスされた UDP ロードバランシング機能が気になっていたので、まだ正式リリース前ではありますが試してみました。

構成

UDP で代表的なプロトコルといえば DNS なので、今回は

graph LR Client --- NGINX NGINX --- dns1[Dnsmasq1
example.com A 127.0.0.10] NGINX --- dns2[Dnsmasq2
example.com A 127.0.0.20]

という構成を Docker Compose で作り、 NGINX に対して dig コマンドで example.com の名前解決クエリを送った時に、 127.0.0.10127.0.0.20 が交互に返ってくるかどうかを見ることにしました。この構成を作るに当たって必要なファイルは Github リポジトリとして公開しています。

準備

NGINX

まず、 UDP ロードバランシング機能が含まれている NGINX を用意する必要がありますが、現時点では開発リポジトリ上にのみ存在する状態のため( 1.9.13 で正式リリース予定)、 Mercurial 経由で

hg clone http://hg.nginx.org/nginx

としてソースコードを取得し、

cd nginx
./auto/configure --with-stream
make
make install

のように --with-stream オプションをつけてコンパイルする必要があります。今回はこれらの一連の NGINX のビルド処理を、 NGINX の Docker 公式リポジトリ の alpine タグの Dockerfile を元に手を加えた Dockerfile を作り、自動化しています。

NGINX が用意できたところで設定ですが、 UDP のみのロードバランシングの場合非常にシンプルなものになります

worker_processes  1;

events {
    worker_connections  1024;
}

stream {
    upstream dns_udp_upstreams {
        server ${DNS1_PORT_53_UDP_ADDR}:53;
        server ${DNS2_PORT_53_UDP_ADDR}:53;
    }

    server {
        listen 53 udp;
        proxy_pass dns_udp_upstreams;
        proxy_timeout 1s;
        proxy_responses 1;
    }
}

upstream の設定は通常と同じで、ポイントは server 部ですが、新たに listen ディレクティブに udp が指定できるようになっています。

また、 proxy_responses ディレクティブが追加され、それぞれのクライアント要求において、いくつアップストリームサーバから UDP パケットを受けとったかを数え、 proxy_responses と同じだけパケットを受けとった場合には接続を閉じる、という処理を定義できます。デフォルトは無制限で、その場合、 proxy_timeout の時間が経過するまで UDP パケットの到着を待ち続ける形となるようです。

Dnsmasq

実際の DNS リクエストに応答する Dnsmasq は andyshinn/dnsmasq の Docker イメージを使い、二つのサーバの起動オプションにシンプルに --host-record を追加し、

dnsmasq -k --host-record=example.com,127.0.0.10
dnsmasq -k --host-record=example.com,127.0.0.20

という形でそれぞれ立ち上がるよう設定することとしました。

Docker Compose

上記の NGINX, Dnsmasq の一連の処理を docker-compose.yml にまとめ、 docker-compose up で環境全体を構築できるようにしています。

動作確認

docker-compose up を実行して環境を構築後、 Docker Machine を使っている場合はリクエスト先の IP アドレスを docker-machine ip your-machine で調べておき、下記のようにコマンドを実行すると、交互に結果が返ってくることが確認できるかと思います。

$ dig @192.168.99.100 example.com +short
127.0.0.10
$ dig @192.168.99.100 example.com +short
127.0.0.20
$ dig @192.168.99.100 example.com +short
127.0.0.10

まとめ

非常にシンプルな設定で UDP ロードバランサが構築できることを確認できました。

これまで UDP ロードバランサを構築しようという場合には、ユーザーモードで動作するロードバランサ、リバースプロキシソフトウェアの選択肢がほぼ存在せず、たとえば Linux においては LVS を用いてのロードバランシングが一般的でした。しかしながら LVS はカーネルモードでの動作を必要とし、昨今隆盛を極めているコンテナ内では使用できないなど、 UDP をベースにしたプロトコルを持つサービスを構築する場合には、物理サーバまたは仮想マシンに頼らざるを得ない状況でした。

今回 NGINX による UDP ロードバランシング機能の提供により、他の TCP, HTTP などのプロトコルと同様に、 UDP もより自由度の高い形でロードバランサを構築することが可能となったことから、今後のサーバ構築時の選択肢を大きく変えていくのではないか、と期待しています

TinyCoreLinux の Docker イメージを作った話

これは NSEG Advent Calendar 2015 -Adventar 15 日目の記事です。

前回 に引き続き Docker の話ですが、 Tiny Core LinuxDocker イメージとその仲間(たとえば tinycore-ruby)を作った話をまとめておこうと思います。

Tiny Core Linux とは?

The Core Project で作成されている Linux ディストリビューションの一つで、有名どころでは Docker Machine の OS としても使われていたりします。コア機能に絞り込むと 10MB 以下、 GUI 関連パッケージまで含めても 100MB 以下と、ディスク消費が非常に小さいながらデスクトップ環境まで構築でき、独自形式ながらパッケージ機構も持っているため、他のメジャーなディストリビューションと比べて使用が難しすぎるということもなく、なかなか便利なディストリビューションです。

イメージ作成動機

Docker をある程度使っていると感じることかと思いますが、公式で提供されている各種ディストリビューションの Docker イメージは容量が少なくとも 200MB 越えしているものがほとんどで、かつ、それを元にした各言語の公式実行環境はそれ以上の容量となることが一般的です。

一方で、特に Node.js などは比較的頻繁にリリースされ、その都度環境を更新することも多くなりますが、その度に大容量のイメージをダウンロード・展開することになります。

そこで、もっとディスク容量を抑えた環境はできないかなと思っていたところ、 Tiny Core Linux の存在を知り、特定の一言語の実行環境であればもっとコンパクトなものが作れるのではないかということで、イメージ作成を試してみることにしました。

結果、基本の OS イメージでは 7MB 弱、先に上げた tinycore-ruby イメージでも 60MB そこそこと、一通りの機能を組み込んだ上でもかなり容量を削ることに成功しています。

Tiny Core Linux 基本イメージ

各言語の環境を作る前に、まずは基本となる OS イメージが必要なので、それを x86, x86_64 それぞれで配布されている rootfs.gz ないし rootfs64.gz から構築しています。

この rootfs.gz は cpio 形式でアーカイブされていますが、 Docker に読み込ませるには tar 形式である必要があるため、一度これを展開してアーカイブし直す必要があります。

また、 Tiny Core Linux のパッケージは squashfs を利用して作成されており、パッケージインストール時にはこの squashfs をマウントしてファイルを取りだす仕組みになっているため、そのまま rootfs.gz を展開してイメージを作ってしまうと、パッケージ機構を利用する時にはコンテナに privileged 権限をつけて起動する必要が出てきます。これでは日常利用で不便ですので、それを回避するために、 unsquashfs を入れると同時にパッケージプログラムに少々手を加え、 privileged なしでもパッケージを扱えるようにしています

イメージ生成に Docker の Automated Build を使えるようにするため、イメージ作成に必要な処理は Github にて公開していますので、詳しくはそちらを参照してください。

tinycore-ruby イメージ

OS イメージができたので、それを元にして使いたい言語の環境を作るのですが、ここでは Ruby のものを例として見ていきます。

基本的には公式の Ruby イメージと同じような使い勝手になるよう、公開されている Dockerfile の手順と同様に記述していくことになりますが、元々の目的が容量削減でしたので、一度インストールしたパッケージも動作に必須でなければ極力アンインストールするなどしています。

また、公式でも同様ですが、 RUN の処理を、できるだけ

RUN gem install bundler --version "$BUNDLER_VERSION" \
    && bundle config --global path "$GEM_HOME" \
    && bundle config --global bin "$GEM_HOME/bin"

のように \ && でつなげて書いています。これは、同一のシェルコンテキストで実行とするという意味合いもありますが、それぞれを分けて、たとえば

RUN gem install bundler --version "$BUNDLER_VERSION"
RUN bundle config --global path "$GEM_HOME"
RUN bundle config --global bin "$GEM_HOME/bin"

のように書くと、それぞれの RUN の処理中で生成されたデータがそのままイメージ容量に加算されていき、後の処理で不要なデータを削除したとしてもイメージ全体のサイズが減らなくなってしまうため、それを回避する目的が主です。同一コンテキスト内で最終的に不要なデータを削除しておけば、その分はイメージ容量には加算されないので、容量肥大化を防ぐことができます。

ただし、このように書いてしまうと、一度イメージ作成処理に失敗すると、次に実行する時もキャッシュが効かず、同一コンテキストの最初から(つまり RUN の最初から)やり直しになってしまい、特に大きなパッケージファイルをダウンロードしている時など実行時間がかかって待たされることになるため、試行錯誤している時などは個別の RUN に分割しておくと、キャッシュの恩恵を受けて効率良く作業ができます。処理が固まったら改めて \ && でひとまとめにするといいでしょう。

こちらも Github にてイメージ生成過程は公開してますので、具体的な内容などはそちらを参照してください。

まとめ

以上のような流れで、 Ruby 以外の言語のイメージや、特定のプログラムだけを配置したイメージも比較的簡単に作ることができます。容量だけで見れば公式で配布されている busybox イメージを使う、という方法もありますが、それだけでは機能不足であるとか、容量は抑えつつ欲しい機能を手軽に追加したい、という向きには、このイメージは便利に使っていただけるかと思います。

余談ですが、現在公開中の Ruby, Python, Node.js 以外にも、 PHP, Go などの作成も試してみたのですが、 PHP は TinyCore のパッケージに含まれないライブラリへの依存関係が多く挫折、 Go は作成できたものの公式と容量が 100MB も違いがなく(主に go get などの内部で使われる git, mercurial, subversion を組み込んだ影響)、あえて作る意味もないということで公開はしないこととしました。これらの言語では素直に公式イメージを使うのが吉、ということのようです。

Docker を使ってパッケージング

これは NSEG Advent Calendar 2015 -Adventar 11 日目の記事です。

NSEG とは?

一言で言うと「長野の IT 勉強会」ということになりますが、勉強会と言ってもお堅いものではなく、長野県やコンピュータにかかわることを幅広く取り上げて、楽しんだり技術の向上を目指す、といった感じで活動しております。詳しくは nseg.jp もご覧ください。

これは何?

先日の「理論から学ぶデータベース実践入門」読書会スペシャルの懇親会で、「 Docker 周辺が盛り上がっているけど、実際業務に使ってたりする?」ということを聞かれたので、自分は「独自で RPM なんかのパッケージを作る時に便利に活用してますよ」ということを話したのですが、じゃあ具体的にどんなことをしているのか、というのを公開している h2o-rpm を元に書いてみようかな、という趣旨です。

Docker 自体はウェブアプリケーションなどの展開・運用にそのまま便利に使え、そちらが本筋とも思いますが、こんな使い方もできる、という一例にしてもらえれば幸いです。なお RPM の SPEC の書き方については、今回は省略します。

パッケージング作業の概要

そもそも RPM などのパッケージを作る時は、パッケージ対象のソースコードと関連ファイル、 SPEC ファイルのようなパッケージング手順が書かれたファイルなどが必要なわけですが、もう一つ、パッケージを作成する対象のディストリビューションのクリーンな環境が必要です。しかしながらこれを毎回物理サーバやいわゆる仮想マシン上でやっていると、メンテナンスが大変、起動に時間がかかる、など作業上のストレスがあっという間にたまることになるので、気軽に作成・削除のできる Docker でその部分をやろう、ということになります。かつての boot2docker や、最近では Docker Machine のおかげで、自分のマシン上でもお手軽に作業ができるのも利点です。

その場合のおおまかなパッケージ作成の流れは、

  1. パッケージ作成対象のディストリビューションの環境を整える
  2. パッケージ作成に必要なソースファイルなどを Docker コンテナに展開
  3. ビルド、パッケージングを行う
  4. できたパッケージを Docker コンテナから取り出す

となりますが、 h2o-rpm ではこれを DockerfileMakefile で記述して、

  1. Dockerfile を使ったイメージビルドを利用して、環境準備、ソース展開、ビルド、パッケージング
  2. できあがったイメージからデータ取り出しのためだけのダミーコンテナを作ってパッケージを取り出し

という形に落とし込んで、 make 一つで自動でパッケージングできるようにしています。パッケージングまでの部分は docker run でやるのも一つなのですが、イメージビルドを使うことにしたのは、

  • docker run だと、ローカルファイルシステム・コンテナ間でデータの受け渡し方法を用意するのが面倒
  • 結局自動処理のためにシェルスクリプトを書くことになるのなら、 Dockerfile の方がシンプルで見通しがよい
  • Dockerfile の各ステップの中間イメージが残っていくため、失敗した時の作業再開や調査がやりやすい

という理由によるものです。

以下、リポジトリの Dockerfile のうち Dockerfile.centos7Makefile を元に、この流れを見ていきます。

ビルド環境の準備

まず、ビルド環境の準備は Dockerfile に記述してある

FROM centos:7
ENV HOME /
RUN yum update -y
RUN yum install -y rpm-build redhat-rpm-config rpmdevtools cmake gcc-c++ tar make openssl-devel ruby bison
RUN rpmdev-setuptree
RUN echo '%dist   .el7' >> /.rpmmacros

で行っています。 centos7 の公式イメージを元に、環境内のパッケージの全アップデート、ビルド作業に必要なコンパイラなどのパッケージやビルド対象と依存関係にあるパッケージのインストール、ビルド用ディレクトリツリーの作成などを順に実施しています。

ENV HOME / については、 RPM のパッケージング処理がホームディレクトリ配下の rpmbuild ディレクトリを使う、という方式なのと、一般的なコンテナ内ではトップディレクトリが起点になっているというのを合わせる意味で指定しています。イメージ環境自体は使い捨てなので、通常やらないだろう設定をやってもいっこうに構いません。

ソース展開

ソースの展開は Makefile

cp Dockerfile.$* Dockerfile
tar -czf - Dockerfile rpmbuild | docker build -t $(IMAGE_NAME) -

Dockerfile

ADD ./rpmbuild/ /rpmbuild/

の共同作業になります。

まず Makefile の方ですが、各ディストリビューション用に Dockerfile.{dist} のような形でを用意しているものを Dockerfile にコピーした後、パッケージ用ファイルを配置してある rpmbuild ディレクトリとともに tar.gz 形式にまとめ、それを docker buildSTDIN 経由で渡しています。このように渡されたファイルは、 tar.gz 内のディレクトリツリー構造そのままに DockerfileADDCOPY で参照できるので、これを使ってコンテナ内の /rpmbuild 以下にファイルを展開しています。

この辺りについては Docker の build コマンドのリファレンスDockerfile のリファレンス に詳しいです。

また、最初にこの方式で書いた時期とは異なり、現在では .dockerignoredocker build-f オプションも使えますので、上記のような形ではなく、これらの機能を使うことでも同様のものを実現可能と思います。

ビルドとパッケージング

ここは Dockerfile 内の記述になりますが、

RUN rpmbuild -ba /rpmbuild/SPECS/h2o.spec
RUN tar -czf /tmp/h2o.tar.gz -C /rpmbuild RPMS SRPMS
CMD ["/bin/true"]

という形で、 RPM のビルド処理を行い、できあがったものを /tmp 以下に tar.gz ファイルとしてまとめています。このアーカイブファイルは次の取り出しで参照します。

最後の CMD 行は、イメージから作成したコンテナを即時終了させるために便宜的に設定したものとなります。

パッケージの取り出し

ここは Makefile 側の処理になりますが、前のステップまででできあがったイメージを参照し、

docker run --name $(IMAGE_NAME)-tmp $(IMAGE_NAME)
mkdir -p tmp
docker wait $(IMAGE_NAME)-tmp
docker cp $(IMAGE_NAME)-tmp:/tmp/$(TARGZ_FILE) tmp
docker rm $(IMAGE_NAME)-tmp

のコマンドを順に処理して、 Dockerfile で生成された tar.gz ファイルを取り出しています。

まず docker run で、 Dockerfile で作成したイメージを元にダミーコンテナを起動しています。 Dockerfile 内で指定してある CMD ["/bin/true"] により、このコンテナの実行は即座に終わります。欲しいのはダミーコンテナ内のファイルなので、コンテナが起動している必要はありません。念のため docker wait を入れて、コピー処理の前にコンテナが停止しているのを確実にしています。

ダミーコンテナが停止した後は docker cp を使い、コンテナ内のファイルをローカルのファイルシステムにコピーし、用が済んだダミーコンテナを docker rm で削除しています。

欲しいファイルを取り出せたので、後は好きにこれを展開すればいいのですが、本リポジトリではこれを {dist}.build のようなディレクトリ配下に展開しています。

まとめ

以上のような形で自動パッケージングを行い、ローカル側にコピーしてきているわけですが、もちろんできたものを直接特定のサーバにアップロードするなども自由自在です。対象もパッケージングに限らず、 Docker 環境中でなにかしらの成果物を作り、取り出す、という方法として一般的に使える、と思います。

また、ここでは単に好みから Makefile を使っていますが、シェルスクリプトやバッチファイルでも構いません。かつては tar.gz などのアーカイブファイルを渡さなければいけない以上、 Windows で実行するのは若干難しかったように思いますが、今では前述の .dockerignoredocker build-f オプションもあるので、敷居は下がったのではないかと思います。

さらに Windows Server 2016 では Docker がサポートされることも発表されていますので、 Windows アプリケーションも同じような手法でパッケージングできたりするのかもしれず、今後が楽しみです

docker-machine の env 設定が MacVim と競合する、らしい

docker-machine を使っている環境上で MacVim を起動すると、なぜか編集ウィンドウが開かない問題に遭遇したので、対応内容をメモ。

結論から言うと、 docker-machine の仮想マシン起動後に設定される各種環境変数(たとえば DOCKER_HOST )を自動設定するために .bash_profile の中に書いていた

if [ "$(docker-machine status myvm)" = "Running" ]; then
    eval "$(docker-machine env myvm)"
fi

の部分、特に eval "$(docker-machine env myvm)" が問題を引き起こしており、これを MacVim からの起動時だけ実行しないようにすることで解消した。具体的には、 MacVim から起動した時は TERM 環境変数が dumb となり、通常起動時の値とは異なっていることを利用して

if [ "$TERM" != "dumb" -a "$(docker-machine status myvm)" = "Running" ]; then
    eval "$(docker-machine env myvm)"
fi

とした。

具体的にこの行がダメな理由についてはよくわからない。 MacVim 自体のソースコードを追った結果、 MacVim は mvim コマンドを開くために内部的にログインシェルを開き、そこからコマンドを起動しているらしく、その流れで .bash_profile が読み込まれて実行されているようだったが、 eval 自体は同じ .bash_profile 中の他の部分でも使用しているので、直接それが問題のようには見えなかった。この辺りは機会があれば調べてみることに。

Fig で Libreboard 環境を構築してみた

前回 は Docker 上で Libreboard を動かしてみたが、今のところ Libreboard の開発は非常に活発で、更新を Docker コンテナに反映させるたびに長々としたコマンドを叩くのが不便に感じてきたため、 Fig を使って必要なコマンドなどをあらかじめ定義しておき、 fig buildfig up で楽に更新適用ができるようにしてみた。以下はその手順

  1. Fig をインストール。今回は手元のサーバが Gentoo だったので emerge で入れてしまったが、 Installing Fig を見る限り、他の環境でも簡単に導入できると思う
  2. Fig の作業ディレクトリを適当に作成して移動する

    mkdir fig_libreboard
    cd fig_libreboard
    
  3. fig.yml を作成する。今回は下記のようにしてみた

    libreboard:
      build: ../libreboard
      links:
        - mongo
      ports:
        - "5555:8080"
      environment:
        ROOT_URL: "http://libreboard.example.com"
      command: "sh -c 'export MONGO_URL=mongodb://$MONGO_PORT_27017_TCP_ADDR:$MONGO_PORT_27017_TCP_PORT/libreboard; /meteor-run.sh'"
    data:
      image: busybox
      volumes:
        - /data/db
    backup:
      image: busybox
      volumes_from:
        - data
    mongo:
      image: mongo
      volumes_from:
        - data
      entrypoint: mongod
    

    libreboard, data, backup, mongo の 4 台構成になっている。それぞれの役割は、

    • libreboard はアプリケーションを稼働させるコンテナ。先ほど作った fig_libreboard と同一階層の libreboard ディレクトリにある Dockerfile を読み込んで Docker イメージを構築し、それを実行する。データストアとなる MongoDB コンテナとリンクして、起動時に環境変数経由で接続を行っている。
    • dataManaging data in containers - Docker Documentation で言うところのデータボリュームコンテナで、 MongoDB のデータを保持するためだけに使われている。
    • mongo は実際に MongoDB が稼働するコンテナ。 data のボリュームに接続してデータをそこに保存している。
    • backup は、 data からは /data/db の中身が見えなかったため、確認作業などのために便宜上作成しておいたコンテナ。これがあると気軽に fig run --rm backup ls -al /data/db などと打って中身を確認したり、 tar でのバックアップ作業がやりやすくなる。が、特別稼働そのものには関係ないので、なくてもかまわない

    といったところ。起動コマンドやポートの設定は 前回 のものを引き継ぐ形で指定している

  4. 上記設定ができたら、 fig up を実行してコンテナ群を起動する。初回は libreboard コンテナ用のイメージのビルドが実行されるので、少しばかり時間がかかる。無事に起動してウェブブラウザで 5555 番ポートにアクセスしてログイン画面が出れば成功。バックグラウンドでの起動に切り替えたい場合は、いったん Ctrl-c を入力してコンテナ群を停止し、改めて fig up -d で起動すればよい。

  5. Libreboard の更新があった場合は fig build を実行して Docker イメージを再作成する。イメージ作成に成功したら fig up -d を実行して環境を再起動すればよい

Fig を使うと、このような感じで一度 fig.yml を書くだけで、機能ごとに分割した複数のコンテナを保持しつつ、日常の操作は簡単な fig コマンドを実行するだけで済むようになる。今回の Libreboard に限らず、 Fig を使うことでホスト側の環境にあれこれ手を入れずとも気軽にウェブアプリケーションの実行環境を用意できるので、単一ホスト内で実行させたいウェブアプリケーションの管理には非常に優れていると思う