たとえばRubyのちょっとしたスクリプトを試したいときにdocker run -it ruby
等でirb
を立ち上げて試したりするわけですが、よく使うdocker run
やdocker exec
の -i
, -t
ってそもそも何なんだろうとふとした時に思いました。
そして、それぞれ何のためのオプションなのか、--help
を見ても「Keep STDIN open...? pseudo-TTY...?」ぱっと理解できなくて辛くなり。
-i, --interactive Keep STDIN open even if not attached
-t, --tty Allocate a pseudo-TTY
『なるほどUNIXプロセス』を読んでこのあたりの基礎知識が身についた気がしているので、「とりあえず付けておけばOKでしょ」ぐらいのふわっとした理解から「なぜ無いといけないのか」がわかるところまで掘り下げてみたメモです。
途中で標準入出力とかファイルディスクリプタ等の知識が必要なシーンがあり、個人的に同書の内容の一部について良い復習になりました。
用語
本記事中に出てくる言葉などをサクッと見つめてみます。
/dev
Unixにおいてデバイスファイルをマウントするためのディレクトリ。
Unixの世界では「すべてがファイル」であり、デバイスもソケットやパイプも全てファイルとして扱われる。
tty
ttyとは、標準入出力となっている端末デバイス(制御端末、controlling terminal)の名前を表示するUnix系のコマンドである。元来ttyとはteletypewriter(テレタイプライター)のことを指す。 https://ja.wikipedia.org/wiki/Tty
概念を理解するには ttyとptsとは – PAYFORWARD の図と、以下の説明がわかりやすいです。
今では考えられないが,一昔前は1つのホストに色々な人が端末をつないで作業をしていたとのこと.ここでいう端末はWindowsやMacなどのクライアント端末ではなく,キーボードのような役割を果たしていたと考えていいかな.
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
複数端末から接続するような状態に似せるため、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 2 ❯ echo "hello from $(tty)" > /dev/ttys001 # terminal 1 の方に hello from /dev/ttys003 と出力される # terminal 1 で上記コマンドを実行した場合は terminal 1 にそのまま返ってくる
リダイレクトは「標準出力の出力先を別ファイルにすること」なので送信元のターミナル(デバイス)には結果が出力されず、送信先のターミナル(デバイス)に出力されるというわけです。
リダイレクト先を自身のデバイスファイルにすると、自身の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
/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
等で出来ます。
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が割り当てられているのでデバイスファイル名を特定すればメッセージを送りつけることはできます。
# 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
うーん、何も受け付けないし出力もしない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
片方のterminalで行った入出力がまったく同じようにもう一方のterminalに再現するのがわかります。macOS上のterminalは2つでも、docker container上ではまったく同一のプロセスであり、標準入出力が同一のpts (ここでは/dev/pts/0
) に割り当てられているためです。
また、docker attach
に--no-stdin
オプションをつけるとSTDOUT、STDERRは受け付けるが入力は受け付けられないことも同様のやり方で確認できます。
ちなみにプロセスをexitせずにattachを抜けるには Ctrl+P -> Ctrl+Q です。このショートカットが他のソフトウェアと衝突する場合には --detach-keys
ショートカットで別のkeyを割り当てることもできます。*3
感想
docker (run|exec)
の-i
, t
オプションからttyとは何かを理解しようと手を動かす中で、dockerを理解するにはUnixシステムの初歩の初歩を押さえることが本当に大事だと痛感しました。これまでもこの辺(プロセス・ファイルディスクリプタ・標準入出力 etc.)の理解が足りなかったんだろうな〜と反省。
ふだん何気なく使っているdockerのコマンド群もUnixシステムの理解がないといつまでも「俺たちは雰囲気でdockerを使っている」状態から抜け出せないなと思うわけです。
これからもやっていくぞ!!
参考
- ttyとかptsとかについて確認してみる - Qiita
- ttyとptsの違い - ひよっこエンジニアの日記
- ttyについて ttyやptsってなんぞ? - それマグで!
- ttyとptsとptmxとpty - tweeeetyのぶろぐ的めも
- pts - スペシャルファイル (デバイス)の説明 - Linux コマンド集 一覧表
- Docker - docker runのオプションについて|teratail
*1:これはどのプログラムが出しているのだろう
*2:linux – bashプロセスにおけるファイルディスクリプタ255の使い方 - コードログ 参照
*3:詳細は https://docs.docker.com/engine/reference/commandline/attach/ 参照