valid,invalid

関心を持てる事柄について

docker run -it で学ぶ tty とか標準入出力とかファイルディスクリプタとか

たとえばRubyのちょっとしたスクリプトを試したいときにdocker run -it ruby等でirbを立ち上げて試したりするわけですが、よく使うdocker rundocker exec-i, -t ってそもそも何なんだろうとふとした時に思うわけです。

そして、それぞれ何のためのオプションなのか、--helpを見ても「Keep STDIN open...? pseudo-TTY...?」ぱっと理解できなくて辛くなるわけです。

-i, --interactive Keep STDIN open even if not attached

-t, --tty Allocate a pseudo-TTY

docs.docker.com

なるほどUNIXプロセス』を読んでこのあたりの基礎知識が身についた気がしているので、「とりあえず付けておけばOKでしょ」ぐらいのふわっとした理解から「なぜ無いといけないのか」がわかるところまで掘り下げてみたメモです。

途中で標準入出力とかファイルディスクリプタ等の知識が必要なシーンがあり、個人的に同書の内容の一部について良い復習になりました。

用語

本記事中に出てくる言葉などをサクッと見つめてみます。

/dev

Unixにおいてデバイスファイルをマウントするためのディレクトリ。

Unixの世界では「すべてがファイル」であり、デバイスもソケットやパイプも全てファイルとして扱われる。

tty

ttyとは、標準入出力となっている端末デバイス(制御端末、controlling terminal)の名前を表示するUnix系のコマンドである。元来ttyとはteletypewriter(テレタイプライター)のことを指す。 https://ja.wikipedia.org/wiki/Tty

概念を理解するには ttyとptsとは – PAYFORWARD の図と、以下の説明がわかりやすいです。

20141231_tty_pts

今では考えられないが,一昔前は1つのホストに色々な人が端末をつないで作業をしていたとのこと.ここでいう端末はWindowsMacなどのクライアント端末ではなく,キーボードのような役割を果たしていたと考えていいかな.

pts

pts - スペシャルファイル (デバイス)の説明 - Linux コマンド集 一覧表によると、ptsは擬似端末スレーブ (pseudo-terminal slave; PTS)。

ファイルシステム上では/dev/ptsに作成される、仮想端末を示す。

標準入出力

割愛しますが標準ストリーム - Wikipedia参照。

ファイルディスクリプタ

こちらも割愛します。 ファイル記述子 - Wikipedia参照。

macOS上でttyとあそぶ

macOS 10.14.2でいくつかのコマンドを試してみます。

まずterminal (私の場合はiTerm2)を起動した時点で最後にログインしたterminalのttyの情報が出力されている。(今までぜんぜん気にしていなかった)*1

Last login: Sat May 4 21:31:25 on ttys001

f:id:ohbarye:20190504213052p:plain

複数端末から接続するような状態に似せるため、terminalを2つ起動しておきます。

以降それぞれをterminal 1, terminal 2と呼び、実行したコマンドの先頭行に記載します。特に記載のないものはどちらのterminalで実行しても結果が同じものです(正確には一致しないけれども本筋じゃないので無視できるもの)。

tty

バイスファイル名を取得するという、ttyコマンドを実行します。

# terminal 1
❯ tty
/dev/ttys01

各terminalごとに異なるデバイスファイル名が割り当てられていることがわかります。

# terminal 2
❯ tty
/dev/ttys02

ps

psコマンドで起動しているzshプロセスと、紐づくTTYを確認できる。

❯ ps
  PID TTY           TIME CMD
17083 ttys001    0:00.04 /Users/ohbarye/Applications/iTerm.app/Contents/MacOS/iTerm2 --server login -fp ohbarye
17085 ttys001    0:01.26 -zsh
17126 ttys002    0:00.02 -zsh
17250 ttys003    0:00.08 /Users/ohbarye/Applications/iTerm.app/Contents/MacOS/iTerm2 --server login -fp ohbarye
17252 ttys003    0:01.08 -zsh
17293 ttys004    0:00.01 -zsh

w

wコマンドでもできる。

❯ w
21:39  up 119 days, 20:21, 3 users, load averages: 2.86 2.72 2.73
USER     TTY      FROM              LOGIN@  IDLE WHAT
ohbarye  console  -                01 319  63days -
ohbarye  s003     -                21:38       1 -zsh
ohbarye  s001     -                21:38       - w

リダイレクト

ttyコマンドでそれぞれのterminalのデバイスファイル名がわかったところで、標準出力の内容を渡してみます。

前述の通り「Unixはすべてをファイルとして扱う」ため、デバイスファイル /dev/ttys001 もファイルのように振る舞えます。他のデバイス (terminal) にメッセージを送るときもファイルにリダイレクトするかのように echo "hello from $(tty)" > /dev/ttys001 ができます。

# terminal 2echo "hello from $(tty)" > /dev/ttys001

# terminal 1 の方に hello from /dev/ttys003 と出力される
# terminal 1 で上記コマンドを実行した場合は terminal 1 にそのまま返ってくる

f:id:ohbarye:20190504214644g:plain
バイスファイルにリダイレクトする様子

リダイレクトは「標準出力の出力先を別ファイルにすること」なので送信元のターミナル(デバイス)には結果が出力されず、送信先のターミナル(デバイス)に出力されるというわけです。

リダイレクト先を自身のデバイスファイルにすると、自身のterminalに返ってきます。

docker container上でttyとあそぶ

ようやくdockerコマンド打っていくところです。

まず、1つめのterminalからdocker containerをdocker run --rm -it --name tty-test ruby bash で起動します。rubyのdocker imageをたまたま使っていますが何でもよいです。

ttyコマンドを実行すると現在利用している端末がpts(擬似端末)として表現されていることがわかります。

# terminal 1

$ docker run --rm -it --name tty-test ruby bash

root@f8e2c6358cea:/# tty
/dev/pts/0

次に、2つめのterminalからdocker exec -it tty-test bashを実行し、同じdocker container上にもう一つbashプロセスを立ち上げてみます。 今度はデバイスファイル名がincrementされた別物が出現しました。接続を増やすたびに増えていくようです。

# terminal 2

$ docker exec -it tty-test bash

root@f8e2c6358cea:/# tty
/dev/pts/1

f:id:ohbarye:20190504215525g:plain
docker container上にbashプロセスを2つ立ち上げてtty確認

/dev/ptsを見ることで現在接続されている端末の数がわかります。

root@221079d1d77c:/# ls -la /dev/pts
total 0
drwxr-xr-x 2 root root      0 May  4 09:21 .
drwxr-xr-x 5 root root    360 May  4 09:21 ..
crw--w---- 1 root tty  136, 0 May  4 09:22 0
crw--w---- 1 root tty  136, 1 May  4 09:24 1
crw-rw-rw- 1 root root   5, 2 May  4 09:24 ptmx

ptmxについてはpts - スペシャルファイル (デバイス)の説明 - Linux コマンド集 一覧表を参照(あまりよくわかっていない)

ps aすると2つのbashプロセス(とps aプロセス)が見えます。

root@221079d1d77c:/# ps a
  PID TTY      STAT   TIME COMMAND
    1 pts/0    Ss     0:00 bash
    7 pts/1    Ss+    0:00 bash
   25 pts/0    R+     0:00 ps a

docker run (terminal 1側) で先に起動したのはpid=1の方です。dockerはcontainerを立ち上げるときに指定されたコマンドをpid=1で実行するためです。

消去法でdocker execで後に起動したのはpid=7の方だとわかりますが、このpidは実行のたびに変わるので同じ番号であることは保証されません。

pid=1に関する余談

ふつうUnixではpid=1はinitプロセスで、initプロセスに対する操作は特殊な扱いになります。(man 2 kill等に記載あり)

また、node.jsをdocker container上で動かす場合にこのpid=1が問題になることがあるようです。

Docker で node.js を動かすときは PID 1 にしてはいけない - ngzmのブログ


docker container上でも同じくリダイレクトによるメッセージの送り合いが echo "hello from $(tty)" > /dev/pts/0 等で出来ます。

f:id:ohbarye:20190504220833g:plain
`echo "hello from $(tty)" > /dev/pts/0`

ptsとファイルディスクリプタ

/proc/<pid>/fdを確認することで各bashプロセスが利用するファイルディスクリプタを確認することもできます。

docker runで起動したpid=1のプロセス(bash)では0:標準入力(stdin)、1:標準出力(stdout)、2:標準エラー出力(stderr)の3つが/dev/pts/0に割り当てられています。(255はbash特有のもの*2

# terminal 1
root@8ee8c01fb676:/# ls -la /proc/1/fd
total 0
dr-x------ 2 root root  0 May  4 09:40 .
dr-xr-xr-x 9 root root  0 May  4 09:40 ..
lrwx------ 1 root root 64 May  4 09:40 0 -> /dev/pts/0
lrwx------ 1 root root 64 May  4 09:40 1 -> /dev/pts/0
lrwx------ 1 root root 64 May  4 09:40 2 -> /dev/pts/0
lrwx------ 1 root root 64 May  4 09:40 255 -> /dev/pts/0

docker execで起動したプロセス側も同様に/dev/pts/1が使われています。

# terminal 2
root@8ee8c01fb676:/# ls -la /proc/7/fd
total 0
dr-x------ 2 root root  0 May  4 09:40 .
dr-xr-xr-x 9 root root  0 May  4 09:40 ..
lrwx------ 1 root root 64 May  4 09:40 0 -> /dev/pts/1
lrwx------ 1 root root 64 May  4 09:40 1 -> /dev/pts/1
lrwx------ 1 root root 64 May  4 09:40 2 -> /dev/pts/1
lrwx------ 1 root root 64 May  4 09:40 255 -> /dev/pts/1

-t, -i の役割を確認する

長らく本題から逸れました。

ここからようやく、それぞれのオプションが無い場合にどんな挙動をするかを見ることで、-t, -i オプションの役割を確認する作業に入っていきます。

-t オプションを無くしてみる

docker exec --helpを見ると-tはpseudo-TTY (pts) を割り当てるオプションとあります。

  -t, --tty                  Allocate a pseudo-TTY

ptsが無い場合にどのような挙動をするのか見てみます。

docker run --rm -it --name tty-test ruby bash で立ち上げたterminal 1側のbashプロセスはそのままに、docker exec -i tty-test bash (-tがないことに注目) を実行します。

ps axを実行するとdocker exec側のbashプロセスのTTYが ? になっています。 これは制御するターミナル(デバイス)が割り当てられていないことを示しています。

# terminal 1
$ docker run --rm -it --name tty-test ruby bash

# terminal 2
$ docker exec -i tty-test bash

# terminal 1
root@a7144f8d8ef0:/# ps ax
  PID TTY      STAT   TIME COMMAND
    1 pts/0    Ss     0:00 bash
   17 ?        Ss     0:00 bash
   41 pts/0    R+     0:00 ps ax

ちなみにps aではterminal 2側のbashプロセスは出てきません。xオプションが必要です。このオプションをつけることで制御端末がないプロセスも含むようになります。

# terminal 1
root@a7144f8d8ef0:/# ps a
  PID TTY      STAT   TIME COMMAND
    1 pts/0    Ss     0:00 bash
   40 pts/0    R+     0:00 ps a

include processes which do not have a controlling terminal man ps より

/dev/ptsを見てみても、たしかに0(terminal 1, docker run側)しかいません。

root@a7144f8d8ef0:/# ls -la /dev/pts
total 0
drwxr-xr-x 2 root root      0 May  4 09:28 .
drwxr-xr-x 5 root root    360 May  4 09:28 ..
crw--w---- 1 root tty  136, 0 May  4 09:38 0
crw-rw-rw- 1 root root   5, 2 May  4 09:38 ptmx

(terminal 2, docker exec側で)立ち上げたbashプロセスのファイルディスクリプタはどうなっているのでしょうか?

すべてpipe:[2700x]のような表記になっています。これが何なのかよくわかっていないが少なくともデバイスファイルでなくpipeであることはわかりました。

root@203d1f3d7ddb:/# ls -la /proc/6/fd
total 0
dr-x------ 2 root root  0 May  4 09:41 .
dr-xr-xr-x 9 root root  0 May  4 09:41 ..
lr-x------ 1 root root 64 May  4 09:41 0 -> pipe:[27004]
l-wx------ 1 root root 64 May  4 09:41 1 -> pipe:[27005]
l-wx------ 1 root root 64 May  4 09:41 2 -> pipe:[27006]

また、docker execしたほうのterminal 2でttyを打ってみると、not a ttyという結果が返ってきます。

# terminal 2
❯ docker exec -i tty-test bash
tty
not a tty

つまり「-tオプションを付けないと、ptsが割り当てられず=標準入出力がterminalに繋がらず、インタラクティブな操作ができない」ことがわかりました。

-i オプションをなくしてみる

次は-iオプションを見てみます。こちらもdocker exec --helpによる説明を貼っておきます。

  -i, --interactive          Keep STDIN open even if not attached

今度は-i抜きのコマンドdocker exec -t tty-test bashを試してみます。

docker exec側のterminal 2では標準入力を受け付けなくなります。echo '1'などを実行しても結果が返ってきません。

ただし-tによりptsが割り当てられているのでデバイスファイル名を特定すればメッセージを送りつけることはできます。

f:id:ohbarye:20190504230515g:plain
`-i`オプションがないと標準入力を受け付けなくなる

# terminal 1
root@fe2c5869d96c:/# ps au
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0  18184  3240 pts/0    Ss   14:05   0:00 bash
root         6  0.0  0.0  18180  3160 pts/1    Ss+  14:05   0:00 bash
root        13  0.0  0.0  36632  2764 pts/0    R+   14:07   0:00 ps au

root@fe2c5869d96c:/# ls -la /proc/6/fd
total 0
dr-x------ 2 root root  0 May  4 14:05 .
dr-xr-xr-x 9 root root  0 May  4 14:05 ..
lrwx------ 1 root root 64 May  4 14:05 0 -> /dev/pts/1
lrwx------ 1 root root 64 May  4 14:05 1 -> /dev/pts/1
lrwx------ 1 root root 64 May  4 14:05 2 -> /dev/pts/1
lrwx------ 1 root root 64 May  4 14:07 255 -> /dev/pts/1

root@fb2a467d7ee8:/# echo "hello from $(tty)" > /dev/pts/1
# terminal 2 側に表示される

-iオプションはhelpの説明そのままですが「-iオプションを付けないと、terminalからの入力を受け付けなくなる」ことが実際に試してわかりました。

-it オプションをなくしてみる

-i-tも省略したdocker exec tty-test bashを実行すると一瞬で終了します。

# terminal 2
$ docker exec tty-test bash

f:id:ohbarye:20190504231038g:plain

うーん、何も受け付けないし出力もしないbashプロセスが立ち上がるのではと思ったのですが、どういう仕組みでこのプロセスを終了させているのかよくわかっていません。

docker attach

docker execと似ているけどぜんぜん違うコマンドdocker attachについてもついでに見ておきます。

  • docker attachは指定した起動中のcontainerのルートプロセス(pid=1のプロセス)の標準出力・標準入力につなぐ
  • docker execは指定した起動中のcontainerで新しいプロセスを立ち上げる
# terminal 1 で起動したあとに
$ docker run --rm -it --name tty-test ruby bash

# terminal 2 で attach する
$ docker attach tty-test

f:id:ohbarye:20190504232538g:plain
docker attachでpid=1と同化したようす

片方のterminalで行った入出力がまったく同じようにもう一方のterminalに再現するのがわかります。macOS上のterminalは2つでも、docker container上ではまったく同一のプロセスであり、標準入出力が同一のpts (ここでは/dev/pts/0) に割り当てられているためです。

また、docker attach--no-stdin オプションをつけるとSTDOUT、STDERRは受け付けるが入力は受け付けられないことも同様のやり方で確認できます。

f:id:ohbarye:20190504233113g:plain
`docker attach --no-stdin`でSTDINを受け付けないようす

ちなみにプロセスをexitせずにattachを抜けるには Ctrl+P -> Ctrl+Q です。このショートカットが他のソフトウェアと衝突する場合には --detach-keysショートカットで別のkeyを割り当てることもできます。*3

感想

docker (run|exec)-i, tオプションからttyとは何かを理解しようと手を動かす中で、dockerを理解するにはUnixシステムの初歩の初歩を押さえることが本当に大事だと痛感しました。これまでもこの辺(プロセス・ファイルディスクリプタ・標準入出力 etc.)の理解が足りなかったんだろうな〜と反省。

ふだん何気なく使っているdockerのコマンド群もUnixシステムの理解がないといつまでも「俺たちは雰囲気でdockerを使っている」状態から抜け出せないなと思うわけです。

これからもやっていくぞ!!

参考

The Art of UNIX Programming
The Art of UNIX Programming
posted with amazlet at 19.05.04
Eric S.Raymond
KADOKAWA (2019-03-08)
売り上げランキング: 193,583