ページ

2016年3月19日土曜日

Dockerと付き合う為の虎の巻 その03

 「Dockerと付き合う為の虎の巻 その03」です。今回は前回のDockerと起動するプログラムとPID1のお話しついてもうすこし掘り下げた回となります。内容的には主に前回の以下の項目についてのお話の続きです。


〇 Dockerコンテナは1つのコンテナに1つのプログラム(プロセス)を起動する事を基本として作成し、使用するものである

〇 Dockerコンテナで起動したプロセスはPID1(プロセスIDが1)のプロセスとして起動される

〇 Dockerコンテナの問題の無い通常の停止(docker stop 等)はPID1に対するSIGTERMシグナルによって行われる。

〇 Dockerコンテナでは基本的にログ出力は使用せずに標準出力にそのまま内容を出力する



 前回のお話しにもありましたが、Dockerにおいてといいますか、LinuxにおいてPID1のプロセスは特別な意味を持っています。一見では単純に Dockerコンテナとして起動したいプロセスをENTRYPOINTとして指定するだけでよさそうに見えるのに実はそうではないという所は大きな落とし穴と言いますか、初見殺しの罠となっているような気がしますので色々捕捉をしていこうかと思います。


 とりあえずはexampleといたしまして、redis君に登場していただきつつ、以下のようなDockerfileにてバッドケースを実演してみる事にします。(最近この手のexampleでやたらとコキ使われている気がしますねredis君)

FROM centos:6

RUN yum -y install epel-release
RUN yum -y install redis cronie

ENTRYPOINT ["/usr/sbin/redis-server"]
CMD []

 内容的には単純にCentOSの6をベースにyumでredisとcronie(crond)をインストールした後に、ENTRYPOINTとしてredisを指定して起動しているものです。cronは後で実験をする為の素材として入れてありますので似たようなものであれば別になんでも良いです。設定ファイルの指定もしていないのでredisとしてまともに機能する訳ではないのですが、今回はそちらは主目的ではないのでとりあえず気にしない事としておきます。


 とりあえず前述のDockerfileをビルドしつつ。
# docker build -t example01 .

 起動を行ってみます。
# docker run -itd --name=example01 example01
# docker ps
CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS               NAMES
1d7a425f2aca        example01           "/usr/sbin/redis-serv"   6 seconds ago       Up 5 seconds                            example01

 無事起動いたしました。とりあえずこのままこのコンテナの中に入ります。

# docker exec -it example01 bash

 さて、この状態でpsコマンドでプロセスの状態を確認してみます。

# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:52 ?        00:00:00 /usr/sbin/redis-server
root        11     0  0 15:54 ?        00:00:00 bash
root        26    11  0 15:56 ?        00:00:00 ps -ef

 ENTRYTPOINTで指定したredis-serverがPID1として実行されているのが分かります。bashというのはexecで実行した今この動作しているbashの事です。色々不自然ですが取り敢えずは置いておきましょう。


 さて、ここまでは普通ぽい動作といいますか、想定通りとなる訳ですが、ここで一つバッドな事を実行してみます。最初のDockerfileにて事前にcronieをインストールしていますので、cronと関連するinitscripts等が事前に入っている状態となります。さっそくそいつを起動してみましょう。

# service crond start
 Starting crond:                                            [  OK  ]
# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:52 ?        00:00:00 /usr/sbin/redis-server
root        11     0  0 15:54 ?        00:00:00 bash
root        47     1  0 16:01 ?        00:00:00 crond
root        49    11  0 16:02 ?        00:00:00 ps -ef

 どうやらうまくcrondが起動したようです。しかし、PIDがちょっと不自然な状況にも見えます。とりあえず分かりやすくする為に今起動したcrondを停止してみます。

# service crond stop
Stopping crond:                                            [FAILED]

 画面出力をみるとどうやら停止に失敗したような出力がなされました。とりあえずpsコマンドで状況を確認してみます。
# ps -ef
UID        PID  PPID  C STIME TTY          TIME CMD
root         1     0  0 15:52 ?        00:00:00 /usr/sbin/redis-server
root        11     0  0 15:54 ?        00:00:00 bash
root        47     1  0 16:01 ?        00:00:00 [crond] <defunct>
root        82    11  0 16:09 ?        00:00:00 ps -ef

(=゚ω゚) !?

 なんと、crondプロセスが<defunct>という表示で残ってしまっています。これはいわゆるゾンビプロセスとよばれる状態になってしまっている事を表しています。色々ダメな状態ですね。


 なぜこのような状態になってしまったのでしょうか。色々と細かい事情が複合してはいますが、簡単に一言で言えば「ENTRYPOINTでPID1として起動させたredis-serverがLinux的なPID1プロセスとして必要な役割を果たしていないから」という事になります。

 今回のケースはちょっとしたバッドケースなのですが、「聞いていた話だけだと一見問題なさそうなはずなのに結構簡単に問題が出てしまったよ」という身近な例として少し長めに説明を割いてみましたがいかがでしょうか?


 さて、それではPID1の役割とは一体何なのかという話になります。自分の場合も完全には調べ切れていないのですが、少なくとも今回のようなケースでは以下の役割が足りていない為に起きているようです。(※下記の文章は公式文書の引用等ではありませんので、もし間違っている部分がありましたら申し訳ございません)




◯ Linux起動時の初期プロセスは全てPID1から起動しており、その他の子プロセスや孫プロセス等も親プロセスをたどっていけばPID1に辿り着くツリー構造のような形態をとる。つまりPID1はプロセスツリー的に全てのプロセスの頂点となるプロセスである。 

◯ 通常linuxのプロセスは終了した後に親プロセスによって終了状態の回収を待つ状態になる。プロセスは親プロセスに終了状態を回収される事により真にプロセスのライフサイクルが終了する。この終了状態の回収が行われずに放置された状態となる事をいわゆるゾンビプロセスという。(※これはPID1でなくてもプロセス共通です)

◯ 親プロセスが居なくなったプロセスの親プロセスは親プロセスの親プロセスに繋ぎ変えられる事になる。最終的には必ず存在するプロセスツリーの頂点で あるPID1のプロセスが親プロセスとなる。例えば意図的に親プロセスを終了させて孤立状態となるデーモンプロセス等のケースが存在するが、このような機構によりデーモンプロセスの親プロセスはPID1のプロセスが担うこととなる。


 以上、簡潔に?ですがPID1の役割を説明してみました。


 つまり、今回のケースでは、crondが起動した際にその親プロセスが終了しているが、PID1が正しく終了状態を回収していない為、親プロセスの継ぎ換えが正しく行われずに切り離された状態となってしまった。そしてその状態でcrondを終了させた事により、実行もされておらず、終了状態の回収もされておらず、親プロセスも存在しないという完全なゾンビプロセス状態となってしまった。

 というのが現象発生の流れという事になるかと思います。



 なんか文章ばかりで疲れてしまいましたが、こうしてみますと、redisは別にdocker用途としてPID1で動くように作られている訳ではありませんので(そういったオプション設定が用意されている可能性もありますが)このような状態になってしまうのはむしろ当然の出来事であるというのが分かります。

 この問題は自作プログラムをdockerコンテナとしたい場合にも直面する問題です。その対象の自作プログラムは本来の動作以外にもPID1として動作を実装しなければならないという事になってしまいます。色々と面倒くさい事になって来てしまいました。(=゚ω゚) エー ミタイナ...

  このPID1としてのプロセス回収の役割ですが、自作のプログラムの場合、基本的にはLinuxのシグナルであるSIGCHLDに対して適切な処理を行う事が必要となって来ます。C言語等でサーバプログラムを作った事がある方ならご存知かと思いますが、親プロセスによる終了状態の回収というやつはSIGCHLDにて行うという手法が一般的に存在しています。PID1の場合はそれがLinuxシステム全体のプロセスの親としてSIGCHLDの処理が任されていると考えれば問題なさそうです。(状況次第ではSIGCHILDにSIG_IGNをセットするだけで事足りる場合もあるかもです)


 なお、今回のredisでは子プロセスの回収処理を行っていない事により問題が発生しておりましたが、昨今のdockerの流行等による影響もあり、最近のソフトウェアではdockerコンテナのPID1として動作させる為の対応を盛り込んできている物もみられるようになって来ております。

 具体的には本ブログの「「Consulを頑張って理解する」 を公開してみました(ノ=゚ω゚)ノ」
の回や「ConsulのDockerコンテナを作成&公開してみました」の回で紹介しました、HashiCorp社の「consul」等もその一つです。consulは元々はdockerコンテナ内部で動作させるものではなかったかと思いますが、dockerの隆盛の流れに乗って進化してきたという経緯がある為か、PID1で動作する場合には子プロセスの回収を行う仕組みが働くようになっています。また、同じくHashiCorp社の「nomad等のソフトウェアでも同様の仕組みが組み込まれています。

 また、今回のPID1問題の解決策として前回すでにsupervisordを使えば問題ありません」と書いてあったりするのですが、もちろんsupervisordにも同様の機構が組み込まれていますのでDockerコンテナ内部のプロセス管理にもってこいという訳であります。

 なお、このような子プロセスの回収の仕組みは主に「reap」という名前で表現されるようです。なにか既存のソフトウェアをdockerコンテナ上で動作させる際に、PID1の動作に対応しているかどうかを確認する際には、対象ソフトウェアのドキュメントや設定ファイル中に「reap」という文字が存在するかどうかを調べてみてください。メジャーなソフトウェアで公式のdockerコンテナを出しているようなソフトウェアに「reap」という記述がありましたら、この子プロセス回収の機構の事でまず間違いありません。(=゚ω゚) タブン


 さてはて、今回色々と説明が長かったのですが、結論としては「SIGCHLDを自分で色々なんとかする」か「supervisordを使う」のどちらかという事になりますでしょうか。

 実際の所、1つのコンテナで1プロセスが動くのみの単純な物であるのならば、そもそも管理する別のプロセスが存在しない事になりますので、PID1としての子プロセスの回収等は行う必要自体が無い事になるのですが、内部で子プロセスを作成したりするデーモン的なソフトウェア等を動作させようとしたりすると、結構この部分で引っかかってしまう事もあるようです。

 次回は一応この問題に対しての解決案の一つ提示してみようかなと考えています。次回でこの問題についての話題は最後にしたいですね!なんか字ばっかりだし!難しそうな説明になるし!(=゚ω゚) テガツカレマシタ


2016年3月15日火曜日

Dockerと付き合う為の虎の巻 その02

 「Dockerと付き合う為の虎の巻 その02」です。今回はDockerコンテナイメージ作成についての説明の序章編的なものとなります。

 さてはて、大変便利なDockerコンテナ君ですが、docker searchコマンドやDockerHubでの検索にて色々と有名なソフトウェアの名前を入れつつ検索してみると、様々なソフトウェアが公式コンテナを公開しています。このような大きな流れの広がりを見ていると、「よーし今日から家もDockerだ!」と、Dockerコンテナを作って公開したくなるのが 人情といいますか、職業的な病気的なアレというものです。きっとチャレンジして損は無いに違いないですね!(=ノ゚ω゚)ノ ポヂテブ!

 それでは早速Dockerコンテナイメージを作ってみようとなる訳ですが、実際に作ろうとすると色々と謎のルールに遭遇して困惑する事が多いのがDocker君の困った所であったります。基本的にはそのような謎のルールについては公式ドキュメントに書いてあるに違いないので、別に謎ではないはずなのですが、公式ドキュメントは英語ですし、なかなか量も多いですし、肝心な事は書かずにスルーしていたり、散々引っ張ったあげく一番最後の方に、なおそんな機能は無いので自分で頑張るのだと書いてあったりと、Dockerを始めたばかりの頃は色々と苦労するものです。

 というわけで今回はそんな感じのルール等を忘れないようにTips化しておくの巻となります。慣れている方には当たり前の事ばかりかもしれませんが、この辺りのルールを知らないまま使用する事により、Dockerが不審な挙動を起こす事もあります。 そして、そのままDockerが不安定なものであるとか、怪しいものであるとか、ネガティブなイメージを持ってしまい、離れて行ってしまう方も一定数以上いるのではないかという気もしていますので、そのような経験を持ってDockerが嫌いになった方にもお勧めの内容となっておりますので、ぜひご覧になってみてください。


 それではDockerTipsの始まりです。(=ノシ゚ω゚)ノシ シャカシャカ



〇 公に公開するようなDockerコンテナイメージはDockerfileを記述して定義して作成するものである


 とりあえず最初なので基本からです。基本的には公開するようなDockerコンテナイメージは全てDockerfileから作るようにしなければならないと思って問題ありません。やり方によっては「起動した後に内部に入って設定等を行い、その時点のイメージを保存してマスターとする」等の方法も可能ですが、公開するようなコンテナでは原則としてはやりません。ただし、そのような方法の方がはるかに楽なケースも多いですので、非公開の開発用とか個人の趣味的な用途のコンテナ等、比較的どうでも良いものに関してはDockerfileを一々書く必要はないかと考えられます。


〇 Dockerコンテナは1つのコンテナに1つのプログラム(プロセス)を起動する事を基本として作成し、使用するものである


 1コンテナ1プログラム(プロセス)のルールです。これはDockerfileのENTRYPOINT(最初に起動するプログラム)として1つのプログラムを指定し、それ以外は起動しないという意味となります。シェルスクリプトを置いてserviceコマンド等で起動する等ではありません。例えばredisが起動するコンテナであればENTRYPOINTとして/usr/bin/redis-server等と直接プログラムを指定する事によりプログラムを1つ起動するという意味になります。(※実際の所はシェルスクリプトをENTRYPOINTに指定し、内部でexecコマンドによるプロセスの置き換えを実行する事により、目的のプログラムを起動するのが一般的です。説明がややこしいですが。)

 また、このENTRYPOINTで指定して起動するプログラムはフォワグラウンドプロセスとして起動される必要があります。要は普通のプロセスとして起動しっぱなしにする必要があります。単純にENTRYPOINTで起動したプロセスが終了すると、Dockerコンテナもそのまま終了する仕組みに
なっているというのもありますが、後述のPID1の問題やログの問題他様々な要因によるルールとなっております。

 通常の今迄の非コンテナのアプリケーションの場合、そのアプリケーション単独で動作するのではなく、例えばcronに登録して時間で処理を実行させたり、ログはsyslogサーバに出力したりetc...と各種アプリケーション間で連携して動作するようになっていたりする事も多いものですが、Dockerコンテナでは基本的にはそのようなものは同時に動かしたりはしないような方針を採る事となっているようです。そのような場合は例えばcron処理のみを行う別のコンテナを作成し、2つのコンテナを起動する事により、1つのアプリケーションと成すような作り方が基本形となってくるようです。

 この1コンテナ1プログラム(プロセス)というルールは必ずしも必須ではないかと思いますが、なにせDockerの製作者サイドがそのように想定しているものですので、この道を外れると色々と不都合が出て来る場面も出て来るかもしれません。初見で聞くと「へ〜」ぐらいの印象であったりするのですが、実際に作り始めるようになると必ず落とし穴にハマるポイントではないかなと思います。


# Dockerfileで言う所のココです
ENTRYPOINT /usr/sbin/redis-server
CMD []

 なお、どうしても複数のプロセスを起動したい場合等はsupervisord等のソフトウェアを介して1つのプロセスを主として起動し、そのプロセスに下位のプロセスをまとめて管理させる手法を採る事が推奨されています。


〇 Dockerコンテナで起動したプロセスはPID1(プロセスIDが1)のプロセスとして起動される


 前項の1コンテナ1プログラム(プロセス)のルールにおいて、最もやっかいな部分でもあるのですが、基本的に何も起動していないDockerコンテナでは文字通り、何もプロセスが起動していないような状態にあります。

 よって最初に起動する1コンテナ1プログラム(プロセス)のプロセスはPID1のプロセスとして起動する事になります。

 Linuxでは通常、起動時にはまずinitプログラムがPID1のプログラムとして起動され、その他のプログラムはこのPID1のプログラムの子プロセスとして起動するような状態となります。つまり、PID1のプロセスはその他のプロセスと一線を介した特殊なプロセスであり、またLinux的にもそのような動作を求めているのが実際の所であったりします。

 実はDockerにて1コンテナ1プログラム(プロセス)のプロセスとして起動する場合にもこの条件は当てはまる為、Dockerで起動するプログラムはPID1としての動作をする事がLinuxから求められている事になります。

 この問題は見た目以上にやっかいな問題であったりします。PID1に求められる独特の挙動により、不審な動きをする事が実際に確認されていますが、Linux的には当たり前の挙動であったりする為、Docker固有のクセと混同しがちであったりします。

 どうしてもという時は前項にありました、supervisord等をあえて1枚かましてみるのも手かもしれません。supervisordはPID1として動作させた場合、PID1として求められる振る舞いをしてくれるようです。


〇 Dockerコンテナの問題の無い通常の停止(docker stop 等)はPID1に対するSIGTERMシグナルによって行われる。


 またまたPID1ネタであったりしますが、今回は割と普通です。docker stopコマンドでdockerコンテナを停止した場合、PID1(ENTRYPOINTで指定したプロセス)にSIGTERMが到着しますので、受け取ったプログラムはそれを元に適切に終了処理を行う必要があります。

 SIGTERMはkillコマンドを実行した時に使用されるシグナルですので、適切に作られたプログラムであれば基本的には問題無く停止処理に入ります。自作のプログラム等を動作させる場合は、SIGTERMによる停止処理の対応を忘れないようにする必要があります。

 ちなみにsupervisordを用いた場合には、supervisordにSIGTERMが到着すると、その配下にある子プロセスにSIGTERMを連鎖的に送出するような仕組みがあるようですので、適切に作られているプログラムであればsupervisord経由でも問題なく停止出来るかと考えられます。

 なお、docker stop 等で停止命令としてSIGTERMが送出された際には、10秒以内に適切に終了しないと強引にkillされてしまいますので、適切に処理がなされない場合、データ破損等の危険がありますので注意が必要です。


〇 Dockerコンテナは基本的には環境変数によりそのコンテナの挙動を変える設定を行い起動するものである。


 なんか小難しい書き方になっておりますが、極端に言い換えれば「設定は全部環境変数で行う事」と言い換えても良いかもしれません。通常の今迄の非コンテナのアプリケーションですと、インストールした後に設定ファイルを書き換えて、それから起動スクリプトを有効にしてetc...といった流れで使用する事になるのですが、Dockerコンテナでは基本的にインストール、起動、設定は同時に行うようなイメージとなります。簡単に言うと「環境変数を指定してコンテナを起動した→やった動いた!」となるのが理想です。

# Dockerfileで言う所のこんな感じの所です
ENV DATABASE_URL="example.jp"


〇 Dockerコンテナ内部のストレージは遅いのでそのままデータディレクトリとしては使わない/使えないものである。


 Dockerコンテナのイメージをpullしたり作成したりすると分かるのですが、1つのDockerコンテナは階層化された複数のイメージがセットとなり動作するようになっています。Dockerでは、このような階層化されたイメージの仕組みを利用して、ある特定の時点での状態を新しいイメージとして保存する事が出来たり、特定のイメージから継承や派生等を行いつつ新しいイメージを作成したり等が出来るようになっています。そのような理由もあり、基本的にはDockerコンテナ内部のストレージ領域はとにかく遅いと言いますか、頻繁に書き換えが走るようなデータディレクトリにはそもそも向いていないものと言えるかと思われます。

 そのような場合、一般的にはホストのボリュームをマウントして使用するか、Dockerfile中でVOLUME指定を行う等により、速度面の問題を回避する事となります。ただし、同時にDockerコンテナ内部のストレージである事のメリットも失われてしまいますので、使用方法には注意が必要です。

# Dockerfileで言う所のこんな感じの所です
VOLUME /var/lib/example


〇 Dockerfileには基本的には同じ項目は同じ1行にまとめて記述するようにする


 Dockerfileを実際に記述する場合、例えば環境変数3つを定義したい場合にそのまま書くと
ENV ARG01="101"
ENV ARG02="102"
ENV ARG03="103"

 のように3行のENV定義を書く事になります。

 しかし、実際にこのように記述してイメージをビルドしてみると、このENV1行につき1つの内部イメージが作成される事が分かります。DockerではDockerfileの1行に付き1つの階層のイメージを作成するような動作をするようになっているようです。 この動きはイメージの共有やキャッシュという面では優れているのですが、実際にイメージの配布等を行う際に、1つのコンテナに多数の階層イメージが含まれていると、色々と煩わしい場面が出てきてしまう事も多いようです。

 そのような場合は1行にまとめて記述する事により、階層イメージも1つとする事が出来ます。
 
 ENV \
  ARG01=101 \
  ARG02=102 \
  ARG03=103

  机上論的なDockerfileとしての定義としては1行づつの記述の方がスマートな記述方法にも見えるのですが、どうやらこのイメージがやたら多くなる問題は世界中のみんなが大嫌いであるようで、基本的に世界中のみんなが1行にまとめる記述方式を採用しているようですので、この際気にせずに1行にまとめて記述するようにしましょう。(=゚ω゚) デファクトスタンダードデス


〇 Dockerfileにはやや冗長と思える場合でも基本的な値については意味を理解した上で正しく定義しておく方が良い


 具体的にはENV、VOLUME、EXPOSE、WORKDIR辺りの定義でしょうか、この辺りの定義については、適切に定義を行っておくと後でお得であったりします。Dockerではこれらの値はコンテナイメージを作成する際の命令文であると共に、そのコンテナイメージがどのような構成で作られているかを公示するような意味合いを持つようになって来ています。

 例えばENVの値は別にDockerfileで定義しなくても、環境変数を元に挙動を変える事は可能ですので、一見必要ないかもしれませんが、とあるDockerコンテナのデプロイツールでは、使用したいDockerコンテナを指定すると、そのコンテナイメージからENV定義されている値を引っ張り出し、「このような設定が行えるコンテナである」と、一覧表示してくれたりします。

 例えばEXPOSEの値ですが別にDockerfileで定義しなくても問題なく使えるケースも多いかと思いますが、とあるDockerコンテナアプリケーションでは同じホストで起動や停止するコンテナを監視し、その起動したコンテナのEXPOSEの値を引っ張り出し、DNSサーバのSRVレコードに自動登録するような動きをする物があります。

 このように単なる命令文ではなく、構成の公示となる事を意識して定義するのは結構大事であったりします。



〇 Dockerコンテナでは基本的にログ出力は使用せずに標準出力にそのまま内容を出力する


 割と基本にして議論の分かれる部分かもしれませんが、基本的にDockerでは起動したコンテナで標準出力したものは、Dockerホストにてログとして保存されるようになっています。つまり、Dockerの想定している基本的なコンテナのログの機構が、標準出力に出力する事という事になります。

 このような方法で標準出力を経由してホストに渡されたログはログドライバ等の機能により、別のサーバに飛ばす等、様々なログのコントロールが行えるようになっています。

 ただし、実際の所アプリケーションがそもそもログファイルに出力をする事を前提や必須として作られていたりする場合等もありますので、ハイソーデスカと標準出力しましょう!等とは行かなかったりします。

 ちなみにnginxのコンテナイメージではDockerfile中にて /dev/stdout のシンボリックリンクを普段のログ出力先ファイル名に割り当てる等の工夫をする事によってこの問題を回避していたりと、なかなか面白い仕組みを使用していますので、手段として覚えて置くとお得です。(;=゚ω゚)b ナイショデス


〇 とりあえず今回はここまでです


 書き始めて気が付いたら結構な量になってしまいましたのでとりあえず今回はここまでとしておきます。なんかまだまだ色々ありそうな感じですね。

 こうしてみると罠だらけのような気もして来ますが、問題解決の為のベターな手法等もある程度固まって来ていたりする気もしますので、見た目よりは大丈夫に違いないです。多分!


それでわまた(=゚ω゚)ノシ