8つのDocker開発パターン

以前、OpenVzコンテナだった私の”ホームクラウド“と、私があらゆるビルドに関して”ビルドサーバ”のリビルドを推奨するようになったワケについて書きました。

Dockerはあっという間に私のお気に入りのツールに仲間入りしました。限りなく静的なサーバ環境を作り出す繰り返し可能なビルドを作成するという考え方が気に入ったからです。

今回は、私がDockerを使用する中で繰り返し現れるようになったいくつかのパターンを説明します。どれも特段に目新しいものでも非常に驚くようなことでもありませんが、皆さんにとってそれが役立つものであり、また皆さんがDockerを使用する中で遭遇するパターンについても聞くことができれば幸いです。

私がDockerを使って色々なことを試す根本にあるのは、データを喪失することなくDockerコンテナそのものが自由に再作成できるよう、ボリュームにあり続ける状態を維持することです(私がDockerfileを更新せずにコンテナの状態を修正したりしていなければ、の話ですが。定期的にコンテナをリビルドしていると、この悪習慣はなくなります)。

以下で例に挙げるDockerfileは全てその点にフォーカスしています。つまり、考えることなくいつでも置き換えることができるコンテナを作成するということです。

定期的にコンテナを再作成し、それを習慣化させればさせるほど、明らかに存続している明確に定義されたロケーションの外側の状態を避ける習慣が強化されます。

1. 共有ベースコンテナ

Dockerは”継承”を推奨します。驚くことではないですね。これはDockerの効果的な使用に関する基本的な側面です。というのも、あまり頻繁にステップを再実行する必要がないことにより、新しいコンテナをビルドするのにかかる時間を削減できるのですから。Dockerは中間ステップのキャッシュ化は優れていますが(時に優れすぎています)、明確でない時は、シェアする機会を逃してしまいがちです。

私の様々なコンテナをDockerにマイグレーションする時に最初に明らかになったことの1つに、冗長な設定の量があります。

私は、どこにいてもデプロイできるように、ほとんどのプロジェクトに対してそれぞれ別々のコンテナを実行しています。少なくとも、長い実行プロセスが必要な時や、私の”標準的な”セットを超える固有のパッケージが必要な時にはそうします。なので私の所持しているコンテナ数は多く、セットは急速に増大しています。

どれくらい急速に増大しているかと言うと、私のベース環境を完全に使い捨てにするために、(私が当てにしているいくつかのデスクトップのアプリケーションを含む)”全て”をDockerで実行してみようと検討するまでにです。

そこで早速、様々な目的のために基本的な設定をベースコンテナへ抽出し始めました。以下が私の現在の”devbase”Dockfileです。 

FROM debian:wheezy
RUN apt-get update
RUN apt-get -y install ruby ruby-dev build-essential git
RUN apt-get install -y libopenssl-ruby libxslt-dev libxml2-dev

# For debugging
RUN apt-get install -y gdb strace

# Set up my user
RUN useradd vidarh -u 1000 -s /bin/bash --no-create-home

RUN gem install -n /usr/bin bundler
RUN gem install -n /usr/bin rake

WORKDIR /home/vidarh/
ENV HOME /home/vidarh

VOLUME ["/home"]
USER vidarh
EXPOSE 8080

特にコメントすることはありませんが、私が常に利用できるようにしておきたいと思う各種ツールをこれでインストールします。そういったツールは人によって異なるでしょう。最適なディストリビューションは個人の判断によるものです。考えてみる価値のあることの一つに、コンテナをリビルドする時のサプライズを避けるために特定のタグを指定すること(私自身、これをもっとできるようにならなくてはいけません。ディストリビューションごとのタグ指定はやりますが、自分のコンテナにタグを付けないのです)。

デフォルトではポート8080を公開しています。ポート8080は私のウェブアプリケーションの公開先であり、私はそのためにいつもこれらのコンテナを使っています。

またこれはユーザを追加し、そのユーザIDとして私が自分のサーバに持っているユーザIDを指定し、/homeディレクトリは作成しません。私が共有の/homeをホストからバインドマウントするためです。ここから、次のパターンへと移ります。

2. 共有ボリューム開発コンテナ

私の全ての開発コンテナは、少なくとも1つのボリュームをホスト”/home”と共有しています。開発を簡単にするためです。ほとんどのアプリケーションにおいて、適切なファイルシステム変更ベースのコードリローダを開発モードにした状態でアプリケーションを実行したままにできます。そうすれば、コンテナがOS/ディストリビューションレベルの依存関係をカプセル化し、コード変更の度に私に仮想マシンを完全にリスタート・リビルドさせることなく初期環境でバンドルされたアプリケーションの検証をするのに役立ちます。一方で、私はかなり頻繁にリスタートし、何も漏れていないかを確認することができますし、実際にそうしています

その他のアプリケーションに関しては、コード変更を認識するためにコンテナを(リビルドよりも)リスタートできます。

テスト、ステージング及び本番コンテナにおいて、私はたいていの場合、ボリューム経由でコード共有するのを避け、代わりに”ADD”を使って当該コードをDockerコンテナに直接追加します。

例えば、以下は私の個人的な自家製wikiを含む、私の”homepage”開発コンテナのDockerfileです。既に共有している/homeボリュームを私の”devbase”コンテナから利用しているので、共有ベースや私がどのように共有/homeボリュームを使っているかを示しています。

FROM vidarh/devbase
WORKDIR /home/vidarh/src/repos/homepage
ENTRYPOINT bin/homepage web

(注:本当に私は自分のdevbaseコンテナにバージョンタグを付けるべきですね)

私のブログの開発バージョンはこちらです。

FROM vidarh/devbase

WORKDIR /
USER root

# For Graphivz integration
RUN apt-get update
RUN apt-get -y install graphviz xsltproc imagemagick

USER vidarh
WORKDIR /home/vidarh/src/repos/hokstad-com
ENTRYPOINT bundle exec rackup -p 8080

これらはコードを共有リポジトリから取得し共有ベースコンテナに基づいているため、これらのコンテナは一般的に、依存関係を追加・修正・削除する時に非常に早くリビルドします。このことは、隠れ依存関係を残す回避策になびかないようにするためにも重要なことだと思います。

とは言っても、改善したい点はあります。上述のDockerfileのベースは軽量になっていますが、はるかに重たくなることも当然あり得ます。これらのコンテナの中に残っているもののほとんどは使用されないままですが。Dockerはコピーオンライトを用いたオーバーレイを使用するので、これによって大量のオーバーヘッドが発生することはありません。それでもなお、私が真に最小限の要件をとらえていないことや、また攻撃されたりエラーを引き起こすような外面を可能な限り最小化していないことを意味しています(今回のケースに関して言えば攻撃されうる外面についてはあまり心配していません。なぜなら、例えば私のブログは2つの別のロケーションにある2つのgitリポジトリにはない”ライブ”バージョンでは、重要な状態は何も維持しておらず、プッシュする度にオーバーライトされるからです。ですが、私が先に述べたことがあくまで原則です)。

3. 開発ツールコンテナ

これは、SSHを頼りにscreenセッションでコードを取り扱うのが好きな人には最も魅力的で、IDEが好きな人にはそうでもないかもしれません。しかし私にとって上記の設定で気に入っていることの1つは、コードの編集とテスト実行部分を開発中のアプリケーションの実行から分離できることです。

これまで開発システムで私が頭を悩ませてきたことの1つは、開発・本番の依存関係と開発ツールの依存関係がいとも簡単にごちゃまぜになってしまうということでした。分離しておくことはできますが、これらのセットアップが本当に分離されていないと、ドキュメント化されてない隠れた依存関係が簡単に作られてしまいます。例えばデバッグ中のバインドにおいて、straceやtcpdump、あるいはEmacsさえも、本来はそれを必要としない環境にapt-getし、様々なツール用の大量の依存関係を一度に引き入れてしまうのです。

私はかつて、開発環境、テスト、初期プロトタイプのデプロイ環境が混合したために、あらゆる種類の隠れ依存関係が詰め込まれてしまったあるアプリケーションの依存関係を、何週間もかけて”リバースエンジニアリング”したことがあります。その結果、私はこの問題をはっきりと理解しました。

定期的にテストデプロイを行うなら、この解決方法はたくさんありますが、私には上記のパターンを組み合わせた解決策があります。この解決策は、そもそも問題の発生を防ぐので、私は大変気に入っています:

それは、独立した1つのコンテナに、Emacsインストールと、利用したい様々なその他のツールを入れておくことです。あまり詰め込みすぎないように注意していますが、重要なのは、私のscreenセッションがこのコンテナ内で存続でき、ラップトップに設定された”autossh”と合わせて、ほとんど常にこのセッションとの接続が保たれるので、他の開発コンテナと”ライブ”で共有されたコードを編集できることです。

このコンテナでは、たまにはパッケージを直接インストールするという違反を許すことにしています(ただし、リビルドすると一掃されてしまうので、ずっと手元に置きたい場合はDockerfileに入れる必要があるので、この点には留意のこと)。なぜならこれはデバッグと開発にしか影響しないからです。

現時点では、以下のようになっています:

FROM vidarh/devbase
RUN apt-get update
RUN apt-get -y install openssh-server emacs23-nox htop screen

# For debugging
RUN apt-get -y install sudo wget curl telnet tcpdump

# For 32-bit experiments
RUN apt-get -y install gcc-multilib 

# Man pages and "most" viewer:
RUN apt-get install -y man most

RUN mkdir /var/run/sshd
ENTRYPOINT /usr/sbin/sshd -D

VOLUME ["/home"]
EXPOSE 22
EXPOSE 8080

共有された/homeと合わせて、SSHを入れるのに十分機能的な小さい場所ができました。これは使っていくにしたがって増強していかなくてはいけませんが、今のところは、私のニーズに十分足りています。

4. 異なる環境内のテストコンテナ

Dockerで特に気に入っていることの1つは、異なる環境で気軽にコードをテストできることです。例えば、RubyコンパイラプロジェクトをRuby 1.9を扱えるように(遅ればせながら)アップグレードした時、メイン開発環境を1.9に移行した後でRuby 1.8環境にシェルを生成できるように、以下のささやかなDockerfileを作成しました。

FROM vidarh/devbase
RUN apt-get update
RUN apt-get -y install ruby1.8 git ruby1.8-dev

もちろん、rbenvなどを使っても同様の効果を得られるのですが、私はいつもこれらのツールを面倒だと感じてきました。というのも、私はできるだけディストリビューションパッケージでデプロイするのが好きだからで(過去のRubyを使うのと同じくらい苦痛ですが)、スムーズに機能すると私が保証することで他の人が私のコードを使いやすくなるのですから、なおさらです。

ほんの数分だけ異なる環境が必要な時に単純にdocker runできるDockerコンテナを持つことで、問題がきれいに解決します。また、Rubyのようにバージョニングに対応するために事前にパッケージ化されたカスタムツールを持たなければならないというような制約に縛られることがないこともメリットの1つです。

フル仮想マシンを使うことでも、私が説明する多くのものと同様にこのことも達成できるでしょう。しかし上述のDockerコンテナの方がずっと短時間に開始できます。

5. ビルドコンテナ

最近私が使う言語はほとんどがインタプリタ型ですが、その場合であっても、便利な”ビルド”ステップというのは大抵、いつでも実行したいとは思えないほど費用がかかります。

例えば、Rubyアプリケーション用の”bundler”があります。bundlerはRubygemsのキャッシュされた依存関係(更に、オプションで全gemファイル、あるいはアンパックされた内容までも)を更新するので、より大きいアプリケーションの場合はある程度時間がかかる場合があります。

またこれは、アプリケーションのランタイムには不要な依存関係を必要とする場合がよくあります。例えば、ネイティブ拡張に依存するgemのインストールは、しばしば依存するパッケージが多すぎるため(しかも大抵は正確な文書化もされません)、単に全てのbuild-essentialとその依存関係を引き入れることで開始する方が簡単です。bundlerに全ての作業を前もってやらせることもできますが、私はデプロイに使用したいコンテナと互換性があるかないか分からないホスト環境でそれを実行したいとは全然思いません。

解決策はビルドコンテナを作成することです。依存関係が異なる場合は個別のDockerfileを作成できますし、メインアプリケーションのDockerfileを再利用して、希望するビルドコマンドを実行するようにコマンドを上書きすることもできます。例えば、Dockerfileは次のようなものになります。

FROM myapp
RUN apt-get update
RUN apt-get install -y build-essential [assorted dev packages for libraries]
VOLUME ["/build"]
WORKDIR /build
CMD ["bundler", "install","--path","vendor","--standalone"]

実行する場合は、依存関係を更新したコンテナの/buildディレクトリに自分のbuild/sourceディレクトリをマウントします。適宜、置き換えてください。

重要なのは、最終的なパッケージングからアプリケーションのビルドやその一部を切り離すことができることです。ビルドとパッケージングの両方の、プロセスとその依存関係をDockerコンテナにカプセル化したまま、プロセスを2つ以上のコンテナに分割することで可能になっています。

(私はよく、最終パッケージングステップを再現できることを100%確信するために、ビルドアーティファクトにチェックインします。代替手段は、完全なエンドツーエンドのビルドとテストを定期的に行って、この分割がそのプロセスの全てを正確に反映するかどうか確かめることです。)

6.インストールコンテナ

これは私自身のものではありませんが、実に特筆に値するものです。nsenterとdocker-enterというすばらしいツールはインストールオプションを備えているので、curl [あなたの管理下にないURL] | bash という一般的で恐ろしいパターンから一歩前進です。”ビルドコンテナ”パターンを上から実装しているDockerコンテナを提供することでこれを実現していますが、さらにもう一歩前進します。一見の価値がありますよ。

以下はDocerfileの最後の部分で、適したバージョンのnsenterをダウンロードしビルドしたところからになります(1つ注意しておきたいのは、ダウンロードしたアーカイブの一貫性チェックがないということです)。

ADD installer /installer
CMD /installer

installer は以下のような感じです。

#!/bin/sh
if mountpoint -q /target; then
       echo "Installing nsenter to /target"
       cp /nsenter /target
       echo "Installing docker-enter to /target"
       cp /docker-enter /target
else
       echo "/target is not a mountpoint."
       echo "You can either:"
       echo "- re-run this container with -v /usr/local/bin:/target"
       echo "- extract the nsenter binary (located at /nsenter)"
fi

悪意のある攻撃者が権限昇格というコンテナの潜在的な問題を悪用しようとしている可能性が残っていますが、少なくとも攻撃されうる外面はかなり減少します。

しかし、ほとんどの人にとって直接的な利益になる可能性が最も高いのは、このパターンを使うことによって、善意の開発者が時々インストールスクリプトで非常に危険なミスをするリスクを回避できることです。

私は、この方法が非常に大好きです。そして願わくは、この方法が curl [何らかのURL] | bash という嫌なやり方を減らす一助となるといいと思います(たとえ減らなかったとしても、少なくとも簡単にコンテナ内にも含めることができますが……)。

7. デフォルトサービス全部入りのコンテナ

私がアプリに”本気”になった場合、プロジェクト用のデータベース等を処理するために適したコンテナを比較的すばやく準備しますが、一連の”基本的な”インフラコンテナをそこに置いておいても意味がないことが分かります。例えば、私が選んだデータベースまたは適したデフォルト設定を備えたキューイングシステムなどをスピンアップできるようにするために適切な微調整が必要なのです。

もちろん docker run [アプリ名] を試して、Docker indexにすばらしい代替があるように祈るだけでも、”目的はほぼ達成”できますし、大抵の場合、代替があります。しかし私は初めに綿密に調べて、どのようにデータを処理するかなどを把握しておくのが好きです。そうすれば、私自身が微調整したバージョンを私自身の”ライブラリ”に追加しやすくなります。

例えば、Beanstalkdを見てみましょう。

FROM debian:wheezy
ENV DEBIAN_FRONTEND noninteractive
RUN apt-get -q update
RUN apt-get -y install build-essential
ADD http://github.com/kr/beanstalkd/archive/v1.9.tar.gz /tmp/
RUN cd /tmp && tar zxvf v1.9.tar.gz
RUN cd /tmp/beanstalkd-1.9/ && make
RUN cp /tmp/beanstalkd-1.9/beanstalkd /usr/local/bin/
EXPOSE 11300
CMD ["/usr/local/bin/beanstalkd","-n"]

(例えば、数ある中でもはるかに複雑なPostgresもデータやコンフィギュレーション等が持続可能なボリュームに設定します。)

「よし、memcachedとpostgresとbeanstalkが必要になるな」と思ったら、3回素早く docker run した後、3つのコンテナが稼働し、それらのデフォルト環境が自分好みの環境に微調整されたもので、使う準備ができている、といったことができるのが目標です。”標準的な”インフラ構成の設定は1分でできる仕事であるべきで、開発そのものから離れてしまう。

8. インフラ/グルーコンテナ

これらのパターンの多くは開発環境に重点を置いています(そして、このことは本番環境については別の機会にさらに多くの議論が必要となることを意味します)。しかし、もう1つ大きなカテゴリが欠けています。

コンテナの目的は自分の環境を全体と繋ぎ合わせることです。これは私としては今のところ検討中の分野ですが、1つ具体例を挙げましょう。

自分のコンテナに簡単にアクセスできるようにするため、私は小さなHAProxyコンテナを持っています。私のホームサーバにはワイルドカードDNSというエントリポイントがあり、 HAProxyコンテナへとアクセスを開くiptableエントリもあります。このDockerfileはとくに特別なものではありません。

FROM debian:wheezy
ADD wheezy-backports.list /etc/apt/sources.list.d/
RUN apt-get update
RUN apt-get -y install haproxy
ADD haproxy.cfg /etc/haproxy/haproxy.cfg
CMD ["haproxy", "-db", "-f", "/etc/haproxy/haproxy.cfg"]
EXPOSE 80
EXPOSE 443

ここで面白いのはhaproxy.cfgで、以下のようなdocker psの出力からbackendセクションを生成するスクリプトによって生成されます。

backend test
    acl authok http_auth(adminusers)
    http-request auth realm Hokstad if !authok
    server s1 192.168.0.44:8084

そしてフロントエンド定義内のたくさんのacluse_backendステートメントが[hostname].mydomainを正しいbackend.backendテストへに送ります。

もっと手の込んだものにしたいときは、AirBnB’s Synapseのようなものをデプロイしてもいいですが、私が開発のために必要とするよりもはるかに多くのオプションがあります。

私のホーム環境にとって、これで私のインフラのニーズのほとんどが満たされます。仕事中、私はどんどん増えるインフラコンテナを公開していて、それらの目的は実際のアプリケーションのデプロイメントを順調に進ませることです。私が完全にプライベートなクラウドシステムをDockerに移行させているときに行っています。