Docker はじめの 1.3 歩

「三歩」だと進み過ぎでしょ、第一歩を三つ、みたいなノリなの。

Contents

Docker はじめの 1.3 歩

いまさら、とか言わない。ワタシはこういうのに気付くのが遅いんだよ。

誰得か?

docker on linux を linux 実機ユーザが使うことに価値がないわけではないけれど、実に、docker の恩恵を最も受けるのは、やはり Windows ユーザだと思う。

以前「世界はもはや Unix or Windows である」という言い方をしたことがある。これは、まずはたとえば「DOS/V」だの「TRON OS」だの「plan 9」のようなマイナーなものはほぼ淘汰され、なおかつ、Mac OS がコードベースを BSD Unix に乗り換えたということにより、汎用機・スーパーコンピュータのような特殊な場所を除けば、今の世界中にあるコンピューティングのほとんど全てが「Unix ベースか Windows ベース」に収束したのだ、てハナシ。android もカーネルは linux ね。iOS は、Apple だから Darwin ベースだろうと理解してるんだけど、間違ってないよね? ここが間違ってるとアレなんだけどね。

で、だとした場合に、「その Windows 「だけ」が、開発者フレンドリからかけ離れた「地獄」である」ということ。これがそのままエンドユーザにとっての地獄にも直結している、というのが今の現実世界。

ゆえに、「Docker を使えば、Windows 機上で linux アプリケーションをガンガン動かせるぜ」というのが Docker のメリットとして一番顕著だ、てことになる。けれども。「Docker を Windows で快適に使う」の手段が手に入ったのは、「Windows 10 から」てことね。(docker そのものを使う方法は Windows 7 にもあるけれど、それは「VirtualBox で動かす linux」での話。それだとほとんどおいしくない。)そういうわけで、「いつやるの、今でしょ!」てことなんだよ、Windows ユーザにとっては。

ただ。今回のこのネタ、別に Windows 固有の話はほとんどしてないので、Docker for Mac をしたいアナタにも役に立つと思うよ。

2022-05-26 18時追記: 誰得か?、別解

「docker on linux を linux 実機ユーザが使うことに価値がないわけではない」と linux での効能について控えめな言い方なのはこれは、「Windows ユーザが受ける恩恵と比較するから」だけの話。たとえるなら「Windows ユーザは十兆倍快適になる」のに対し、「linux ユーザは千倍快適になる」。Windows ユーザが受ける恩恵の大きさに較べれば、だいぶ穏健でしょ。

アプリケーションの共有、という発想だけで考えてると、linux on docker のおいしさは「distro 間移植性問題の解消と管理の楽さ」でしかなくて、「もともと linux なら楽だろ」と思っちゃうとそれまでなんだけれど、データセットも込みで丸ごとシェアしたいようなプロジェクトにとっては、docker なしでは成り立たないだろう、というほどメリットを享受出来るだろうと思う。

今ハヤリのでいえば「機械学習」。AI アプリケーションとモデル、学習データを組み込んだイメージを共有する、なんてことをすれば、それを使って開発する開発者は必要なものを取り揃える膨大な手続きを大幅に削減出来るであろう。…というか、こういう Docker プロジェクト、実在してた気がするんだよな。mecab の IPA 辞書って、そうじゃなかったっけ? 記憶違い?

いずれにせよ、あなたにとってのそういう将来図を夢見ながら理解していくといい。それこそ「可能性は無限」。

「導入」方法

本来は公式サイトに出向いてそれに従えばよろしい、で済む話。それでやや済まなかった、というのがこのネタね。(あとたぶん人によってはこれも大事。)

最初のとっかかり資料

公式サイトは当然だけれど、まさに「とっかかり」というか全体像をざっと理解するのに役立ったのはこのビデオ:

アプリケーションを使いたいだけなんや – 既存イメージを実行したいだけ

「この複雑なソフトウェアは Windows で動かない」⇒「Linux なら動く」、みたいなことがあったとして、高確率で「だったら諦める」だったかもしれない人が、「Docker ならあなたの Windows で動かせるぞ簡単に」になる、かもしれないしならないかもしれない、の巻。

共通

まず、コマンド一覧みたいな全体を眺める前に、「run の基本パターン」を知っておくべし:

1 [me@host: ~]$ docker run --name redis01 -p=6379:6379 -d redis

または

1 [me@host: ~]$ docker run --name redis01 --rm -p=6379:6379 -d redis

d と p は後で説明する。--name redis01 redis の部分に注目。「--name」は任意の名前を指定出来るんだけど、「出来る」というか、必ず指定する癖を付けたほうが良い(特にサービスタイプのものの場合)。しないとはっきり言って管理が不快になる。docker stop などこの名前で行うので。指定しないと、コンテナが予め持ってる名前になっちゃうみたい、たとえば「admiring_kilby」みたいなとても記憶出来ないものに。それだけでなくて、そもそも多重起動を自在に出来るようになってるので、まさにそのために、名前管理が不可欠てことみたい。(--rm は終了時に今の例での redis01 を自動で消すという意味。これしとくと楽かも。)

最後の「redis」の部分の意味は、おそらく今あなたがみてるこのページの「イメージ自作」を理解できると自ずと理解出来るんじゃないかと思う。

「人さまが作ってくださったイメージ」は DockerHub にある。欲しいものはここから探せばいい。

2022-05-26 16時追記:
ちょっと説明不足だったことに気付いた。以下から「part I~III」のパターンで説明しているけれど、これは一番キモとなる特徴だけを抽出して「パターン」と言っているが、普通はこれらは常に組み合わせである。「コンソールだけで連携」するのではなく「コンソールとファイルで連携」など。まぁそれ自体は読めばわかるとは思うのだけれど、たとえば「コンソールタイプ」として説明しているものがファイルを必要としないと思われると困ると思ったので一応。(たとえば設定ファイルの連携が関係すれば「ファイル連携パターン」にもなるということ。)

part I – コンソールでの連携タイプ

Nethack がないと死ぬ!:

お取り寄せ
1 [me@host: ~]$ docker pull matsuu/nethack

そして本当に死ぬ:

起動は出来ない
1 [me@host: ~]$ docker run --name nethack matsuu/nethack

このケースでは「TERM の問題」とはっきりと不平を言ってくれるのでわかりやすいけれど、単に標準入出力を使うだけのものは、本当に何も出来ないだけなので注意。このタイプは「-it が必要」。厳密には「i」がインタラクティブ、つまり標準入力を必要とすることを指示し、「t」はターミナル、つまり標準出力・標準エラー出力を必要とすることを指示する。

ゆえ、死なずに Nethack の海に飛び込みたくば:

へろーだんじょん
1 [me@host: ~]$ # 必要に応じて→ docker rm nethack
2 [me@host: ~]$ docker run --name nethack --rm -it matsuu/nethack

もう一つ簡単な例。

へろくじら
 1 [me@host: ~]$ #docker pull docker/whalesay cowsay
 2 [me@host: ~]$ docker run --name=kujira --rm docker/whalesay cowsay Hello
 3  _______ 
 4 < Hello >
 5  ------- 
 6     \
 7      \
 8       \     
 9                     ##        .            
10               ## ## ##       ==            
11            ## ## ## ##      ===            
12        /""""""""""""""""___/ ===        
13   ~~~ {~~ ~~~~ ~~~ ~~~~ ~~ ~ /  ===- ~~~   
14        \______ o          __/            
15         \    \        __/             
16           \____\______/   

二つ目の例が一つ目と違うのは、イメージ(今の場合「docker/whalesay」)の直後にコマンド名(cowsay)を指定してること。ここが nethack のとも pandoc でやったパターンとも違う点だが、これは Dockerfile の記述について理解出来てくればわかってくると思う。(あるいは、上でリンクしたビデオ内でも説明されてるので、そちらを見てもらってもいい。)

part II – ファイルをやりとりするタイプ

pandoc の例を既に書いたけれど、せっかくなので別の例を。

お取り寄せ
1 [me@host: ~]$ docker pull risaacson/graphviz

graphviz ね。入力:

sample.dot
1 digraph sample {
2   alpha -> beta;
3   alpha -> gamma;
4   beta -> delta;
5 }
実行
1 [me@host: ~]$ docker run --name gv --rm -it \
2 > -v "`pwd`"://wk -w //wk risaacson/graphviz \
3 > dot sample.dot -Kdot -Tpng -o sample.png


この複雑なコマンドラインを理解するためのポイントは、まずは「あたかもリモート機器の相手をしてるようなつもりで」。ssh や rsh (や putty)を思い出すといい。もうひとつが、イメージ指定(今の場合の「risaacson/graphviz」)の前と後ろで分けて理解すること。

--name--rm-it については既に説明済み。-v-w が「あたかもリモート機器」のつもりで理解しないとわからないところで、v は「仮想マウントポイント」と理解したらいい。「オレんち」つまり、リモートではなく(たとえば)Windows 実機のカレントディレクトリを「リモート側のマウントポイントとマッピングする」というのが v ね。w はワーキングディレクトリ。だからこれはそこに cd してると思えばいい。ので、入出力が結果的にカレントディレクトリになる、というのがこのコマンドライン。

「/wk」でなく「//wk」としてる意味についてはpandoc の例参照。

もうひとつほとんど同じ例。

お取り寄せ
1 [me@host: ~]$ docker pull krickwix/ffmpeg
実行
1 [me@host: ~]$ cat shesha.txt
2 She sells seashells by the seashore, The shells she sells are seashells, I'm sure.
3 So if she sells seashells on the seashore, Then I'm sure she sells seashore shells.
4 [me@host: ~]$ docker run --name ffm --rm -it \
5 > -v "`pwd`"://wk -w //wk krickwix/ffmpeg \
6 > ffmpeg -f lavfi -i "flite=textfile=shesha.txt:voice=slt" shesha.mka

これについても「shesha.txt」という入力ファイルの場所を /wk にマッピングすることで連携している。

part III – 通信タイプ(いわゆる C/S)

これに関しても Redis の例を既に書いてるんだけど、これもせっかくなので別の例を。

Memcached の例が簡単だったのだわ。

このケース、「サーバ側だけ Docker にいればいい」ので、クライアント側は Windows ネイティブな CPython のクライアントを使おうか、てことなんだけれど、まぁ歴史が長いだけあって、パッケージは乱立しててちょっと困ったことにはなってる。完全に衝突してるのもあるし。ただ今はそこは問わず、「なんでもいいや検証出来れば」のノリで python-memcached を使うことにする。(これが他のと衝突しててほんとはオススメのやつじゃないんだけどね、今は気にしない。)

とにかくサーバの準備:

お取り寄せ
1 [me@host: ~]$ docker pull memcached
実行
1 [me@host: ~]$ docker run --name mc --rm -p 11211:11211 -d memcached

「p」がポートのマッピングなんだけど、おそらくこれは省略出来ないと思う。このタイプで省略して実行できた経験がない。説明では出来るかのように書かれてるんだけどねぇ、Windows だからとかあるのかね? で、このマッピングの理解も「あたかもリモート機器」のノリで理解すればいい。「ローカル:リモート」。(→ 2022-05-26 18時追記: 「ポートマッピングを」省略出来ないのは、これは「ホスト(Windows)側から直接アクセスしたい場合」の話だった。docker の中の世界では個々のコンテナは、例えば上の実行例の場合は「mc」というホストであるかのように扱われるので、docker コンテナどうしの連携ではこの「mc」に依存出来る。--links などを使うことで。この場合はポートマッピングの指定が必須ということにはならない。紹介したビデオの「Compose」チャプターを見てもらうとわかりやすいと思う。)

-d」はデーモンモードね。どこの説明でも大抵「バックグランド」として説明してるけど、Unix に慣れてる人にとっては「デーモン」の方が理解しやすいかなと。これはもちろん指定必須というものではないけれど、こういうサービスが端末に貼り付いてると鬱陶しいので、うまくいってるうちは必ず指定することだろう。

潜ったプロセスは docker ps で確認出来るよ。停止は docker stop、などなど。公式リファレンス参照。

で、さっそくクライアントからアクセスしてみる:

 1 Python 3.9.10 (tags/v3.9.10:f2f3f53, Jan 17 2022, 15:14:21) [MSC v.1929 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> import memcache
 4 >>> mc = memcache.Client(['127.0.0.1:11211'], debug=0)
 5 >>> mc.set("some_key", "Some value")
 6 True
 7 >>> mc.get("some_key")
 8 'Some value'
 9 >>> mc.set("another_key", 3)
10 True
11 >>> mc.get("another_key")
12 3
13 >>> mc.incr("another_key")
14 4
15 >>> mc.incr("another_key")
16 5
17 >>> mc.decr("another_key")
18 4
19 >>> mc.get("another_key")
20 4
21 >>> mc.delete("another_key")
22 1
23 >>> mc.get("another_key")
24 >>> 

うむ、よき。

人にはよるだろうけれど、このパターンに期待する人が一番多いんではなかろうかと思っている。けどこのタイプの、なおかつ皆が欲しがるようなものって、やっぱり複雑なコンフィグレーションを伴うことが多くて、つまりは「Docker を介する前から扱いが大変」なものが多いわけ。なのでこういうのは「Docker としてのはじめの一歩」としてのネタにはそぐわない。むしろ「PostgreSQL を Docker で使ってみた」みたいな単独ネタの方が相応しいと思う。(具体的に言うと「単に docker run するだけ」だけでは済まないことが多い、てこと。)

2022-05-27追記: やっぱ Redis の例も

Redis の例を既に書いたとは言ったけれど、これは基礎でない部分だけのお試しだったので、改めて書いといた方がいいかなと思って。memcached もそうだけど、redis もその使い方のシンプルさゆえの、「docker 初心者向け」ネタとして相応しいと思うんだ。

既に上で出しちゃったけど、実行例:

DOCKER OFFICIAL IMAGE の redis
1 [me@host: ~]$ #docker pull redis
2 [me@host: ~]$ docker run --rm --name redis01 -p=6379:6379 -d redis

memcached での例と同じく、クライアントはホスト(Windows)側のものを使う例ね。クライアントの確認には telnet も使えるが、まぁそれはいいよね。python のクライアントとして、まずは redis、そして、デコレータの PyMemoize でも遊んでみる。

memoize のまえに、剥き身の redis 使い例:

 1 Python 3.9.10 (tags/v3.9.10:f2f3f53, Jan 17 2022, 15:14:21) [MSC v.1929 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> import redis
 4 >>> db = redis.Redis(host='127.0.0.1', port=6379, db=0)
 5 >>> db.set("aaa", "aval")
 6 True
 7 >>> db.keys()
 8 [b'aaa']
 9 >>> db.get("aaa")
10 b'aval'
11 >>> db.delete("aaa")
12 1
13 >>> db.keys()
14 []

良いよね? (Windows ネイティブな redis が動作してないことは確認しといてね。これについては こちらの追記に触れておいた。まぁこれについては Memcached とかでも同じだけれど。)

memoize を使う「インタラクティブな」例:

 1 Python 3.9.10 (tags/v3.9.10:f2f3f53, Jan 17 2022, 15:14:21) [MSC v.1929 64 bit (AMD64)] on win32
 2 Type "help", "copyright", "credits" or "license" for more information.
 3 >>> import redis
 4 >>> db = redis.Redis(host='127.0.0.1', port=6379, db=0)
 5 >>>
 6 >>> import memoize.redis
 7 >>> store = memoize.redis.wrap(db)
 8 >>> memo = memoize.Memoizer(store)
 9 >>>
10 >>> import time
11 >>> import datetime
12 >>>
13 >>> @memo(max_age=30)
14 ... def hello(s):
15 ...     return "{}: {}".format(datetime.datetime.now(), s)
16 ...
17 >>> hello("world!")
18 '2022-05-27 00:13:47.440992: world!'
19 >>> db.keys()
20 [b"__main__.hello('world!')"]
21 >>> hello("world!")
22 '2022-05-27 00:13:47.440992: world!'
23 >>> time.sleep(30)
24 >>> hello("world!")
25 '2022-05-27 00:14:17.562884: world!'
26 >>>
27 >>> db.keys()
28 [b"__main__.hello('world!')"]
29 >>>

良きでしょ? (db.keys() は、あなたの Redis の状態依存なので、同じ結果にはならないかもしれないよ。)

「Windows ネイティブな Redis サーバをインストールして動かして」、は、これは「手間ではない」。はっきり言ってしまえば「アホでも出来る」ほどに簡単。けれどもそれは「最新の redis でなくてもいい場合」の話。こういう C/S タイプのアプリケーションのサーバって、ほとんどの場合「Windows 版だけ極端に開発が困難」であり、かなり多くの OSS が Windows での開発の多くを諦めている。Redis もそうで、Windows 版は完全に見捨てられている。手に入るバージョンは非常に古い。けれども Docker に頼れば、このように「最新の redis を使える」、ということ。

さて、「memoize + redis」なのだが、ワタシはこういうことを日常にしたいよなと:

 1 import redis
 2 db = redis.Redis(host='127.0.0.1', port=6379, db=0)
 3 
 4 import memoize.redis
 5 store = memoize.redis.wrap(db)
 6 memo = memoize.Memoizer(store)
 7 
 8 #
 9 import io
10 import json
11 from urllib.request import urlretrieve
12 import bs4
13 
14 @memo(max_age=60)
15 def getplitems(url):
16     fn, _ = urlretrieve(url)
17     htcont = io.open(fn, encoding="utf-8").read()
18     soup = bs4.BeautifulSoup(htcont, features="html.parser")
19     result = []
20     ndp = "var ytInitialData = "
21     for scr in soup.find_all("script"):
22         c = list(scr.children)
23         if not c:
24             continue
25         s = c[0]
26         if ndp not in s:
27             continue
28         result.append(json.loads(s[len(ndp):-1]))
29     return result
30 
31 print(
32     json.dumps(
33         getplitems("https://www.youtube.com/playlist?list=PLcfPXeQFURRGNW-ubSN6rTP9A0DUHCDu7"),
34         indent=4, ensure_ascii=False))

スクレイパをワタシは良く書くんだけどね、そういうスクレイパは必ずしも「最新コンテンツ」にこだわる理由がないことが多いわけ。相手が no-cache 要求してようが、だよ。だから「オレ流儀のキャッシュ」を毎度必要としてる。けどね…、たとえばワタシがこれを何かの説明の実例として「お披露目」する際にね、「あんたの PC で redis 動かしといてちょ」という前提を要求するのに躊躇することが多いわけだよ。そこに「Windows」が絡めばなおさら、てことね。「Windows の redis クライアント」であり続ける限りは、それが付きまとう。

ゆえに、続いて考えたくなるのが「このクライアントも docker に乗っけちゃえ」であり、そのために必要になるのが続く「イメージ自作」や、Docker Compose などの「インスタンスを組み合わせて使う」というネタだよ。

2022-05-27追記: コマンドと –rm と永続ストレージの考え方についての補足

docker run で実行することになるコマンドが、たとえば「ls」みたいにその場で反応してその場で答えを出してその場で終了するような「非常駐型」の場合、そういう Docker コンテナのインスタンスが「終了後に」生きている意味はない。もう何も出来ずに名前を圧迫するだけ。なので、そうしたものの場合は(--nameは手癖として指定してもいいけど)--rm は必須、と覚えておくといい。

これに対して、いわゆるデーモンモードで起動したくなるような「サービス」タイプ、つまり「常駐型」の場合は、そのインスタンスをずっと起動したまま使うならば「イメージ内部のファイルシステム」に相当する場所への書き込みが出来る(技術的には「コピーオンライト」で)し、その内容への依存も可能。そして、サービス一時停止後の再開をすれば、書き込んだ結果は生き残っている。この管理単位になるのが「インスタンス」。なので常駐タイプの場合は、--rm を指定する必要性はあまりないだろうと思う。redis で揮発性データだけ扱いたいなら指定してもいいけれど。で、--name はその管理の手助けとなるもの、と考えると良さそうだ。

この常駐タイプの振る舞いに関して、redis で試してみればわかりやすいと思う:

 1 [me@host: ~]$ docker run --name redis01 -p=6379:6379 -d redis
 2 [me@host: ~]$ docker run --name redis02 -p=6380:6379 -d redis
 3 [me@host: ~]$ py.exe -3.9
 4 Python 3.9.10 (tags/v3.9.10:f2f3f53, Jan 17 2022, 15:14:21) [MSC v.1929 64 bit (AMD64)] on win32
 5 Type "help", "copyright", "credits" or "license" for more information.
 6 >>> import redis
 7 >>> db1 = redis.Redis(host='127.0.0.1', port=6379, db=0)
 8 >>> db2 = redis.Redis(host='127.0.0.1', port=6380, db=0)
 9 >>> db1.set("k1", "value1")
10 True
11 >>> db2.set("k1", "VALUE2")
12 True
13 >>>
14 >>> db1.get("k1")
15 b'value1'
16 >>> db2.get("k1")
17 b'VALUE2'
18 >>>
19 [me@host: ~]$ docker stop redis01
20 [me@host: ~]$ docker restart redis01
21 [me@host: ~]$ py.exe -3.9
22 Python 3.9.10 (tags/v3.9.10:f2f3f53, Jan 17 2022, 15:14:21) [MSC v.1929 64 bit (AMD64)] on win32
23 Type "help", "copyright", "credits" or "license" for more information.
24 >>> import redis
25 >>> db1 = redis.Redis(host='127.0.0.1', port=6379, db=0)
26 >>> db2 = redis.Redis(host='127.0.0.1', port=6380, db=0)
27 >>> db1.get("k1")
28 b'value1'
29 >>> db2.get("k1")
30 b'VALUE2'
31 >>>
32 [me@host: ~]$ 

イメージ自作

用語についてワタシは混乱している

「イメージ自作」ととりあえず言うことにしたけど、この手続きで作るのは「コンテナ」なのかしらん、と混乱しているのだが、用語集をみてもこの疑問は解消せず。むぅ、わからん。とりあえずワタシがこの用語の意味を取り違えてる場合は、単に読み替えておくれ。

動機だよ

「とても良い OSS なのだが Windows だととてもこんなに大変だったのだぜっ」という「ネタ」は、まぁそれをしたい人にとっては価値のあることともなりうるのだけれど、でも「オレは使いたいだけなんだよ」という普通の人にとっては、本当にどうでもいいことなのだこれは。理想的には「誰でも簡単に同じものを使えて、使えば同じ使い方なら皆同じ結果を享受出来る」ことだけが本当の「ハッピー」なのであって、そうでない「微救世主」は所詮は「微」でしかない、と。

で、そういう、「誰でも簡単に同じものを使えるというのは程遠く、使えたとても同じ使い方が出来るとは限らない上に、同じ使い方をしてるはずなのに皆同じ結果になることが保証されない」となる最凶の原因こそが「Windows」なのだ。だから「ワタシと同じ環境を作れば、ワタシのこのスクリプトはこういう結果になるんだぜ?」と言ってみたところで「いや、お前と同じ環境を作るのがそもそもヘビィなんだって」という不平になったり、「いや、やってるさ、やってるけどちっともそうならんぞ?」になってみたりと。これこそが「Docker が解決したい問題」てことな。

何かをお披露目したい人、つまり今の場合はワタシ、それとその何かの恩恵を受けたい人、今の場合アナタ。この関係の場合の本当の理想は「ワタシが DockerHub に公開し、アナタはそれを使うだけ」なのは当たり前。けれども、ここではその直前までのみ扱う。つまり「ワタシ」ではなく「アナタが」イメージを弄ぶことを目指せるようにする、てことでもある。

また、今後のワタシは「しょーもないゴミ道具の公開を DockerHub で」なんてことをするわけがないんであって、その場合は例えば「Windows ネイティブに cartopy を構築するのが億劫なら Docker を利用するといいぜ、そのための docker build はこうするんだぜ」という公開のノリにするはずなのよ。つまり、よほどのものでない限りはワタシは DockerHub に公開するようなことはせず、Dockerfile の公開だけにすることになると思う。その形での情報が嬉しいのか、てのは、人によるんだとは思うよ。けどさ、例えば cartopy みたいな巨大なものを「Windows ネイティブなものを、うまくいくかわからないのに時間と手間を膨大にかけて構築する」よりは、Docker ならば Dockerfile の記述が適切なら必ず成功するわけ。成功するとわかってる一時間なら待てるでしょ。うまくいくかわからない一時間はね、「二の矢三の矢」が来るんだよ、普通は。

イメージ自作の本当の初めの一歩

「自作アプリケーションを」ではなくて、「オレはアプリケーションを使いたいだけなんや」のそのまんまの延長の「イメージ自作」。

非常に簡単な例だが、「figlet を使いたいだけなんじゃわりゃぁ」の場合、たとえばこのような Dockerfile を作る:

Dockerfile
1 #
2 FROM ubuntu:22.04
3 
4 RUN apt-get update && \
5     apt-get -y upgrade
6 
7 RUN apt-get install figlet
8 
9 ENTRYPOINT ["figlet"]

あるいは

Dockerfile
 1 #
 2 FROM ubuntu:22.04
 3 
 4 RUN apt-get update && \
 5     apt-get -y upgrade
 6 
 7 RUN apt-get install figlet
 8 
 9 ENTRYPOINT ["figlet"]
10 CMD ["hello, world."]

(二つの違いは是非ご自身で確認してみると良い。これの説明はここにある。)

これをビルドする。たとえばイメージ名を figlet にするとして:

1 [me@host: ~]$ # 必要に応じて Dockerfile がある場所に cd してね。
2 [me@host: ~]$ # build はカレントにある Dockerfile に基づくビルドをする。
3 [me@host: ~]$ docker build -t figlet .

target として指定してる名前は「DockerHub に公開する」ことを考えるなら真剣に考える必要がある、ケド今はローカルで使うだけなので無頓着にやってる。

実行:

1 [me@host: ~]$ docker run --name=kb --rm -it figlet hello!
2  _          _ _       _ 
3 | |__   ___| | | ___ | |
4 | '_ \ / _ \ | |/ _ \| |
5 | | | |  __/ | | (_) |_|
6 |_| |_|\___|_|_|\___/(_)
7                         

実行はどこからやってもいい。Dockerfile の場所とも関係ない。関係するのは作ったイメージ名だけ。

Dockerfile の記述は、たとえばこの例だと「実機 ubuntu あるいは WSL/WSL2 の ubuntu そのものを知らないと絶対わからん」ものだと言えるし、まったく逆の「ubuntu そのものを知ってるなら一瞬で理解で出来る」ものなのだよこれ。あるいは「linux のうち ubuntu しか知らない人は、fedora になった途端に理解不能になる」ことはありうる。パッケージマネージャが違うので。

あるいはね、なんなら野良ビルドだって出来るんだよ、これ:

Dockerfile
 1 #
 2 FROM ubuntu:22.04
 3 
 4 RUN apt-get update && \
 5     apt-get -y upgrade
 6 
 7 RUN apt-get install -y curl
 8 RUN apt-get install -y unzip
 9 # build-essential でもいいがデカいので gcc のみ
10 RUN apt-get install -y gcc
11 WORKDIR /app
12 RUN curl http://hhsprings.pinoko.jp/personal_works/site-hhs_downloads/bsdbanner_with_msvc.zip -obsdbanner_with_msvc.zip
13 RUN unzip bsdbanner_with_msvc.zip
14 RUN gcc -Dlint bsdbanner_with_msvc/banner.c -obanner
15 
16 ENTRYPOINT ["./banner"]
17 CMD ["BSD!"]
2022-05-29 7時追記: もう一か所の「2022-05-29 7時追記」参照。「curl」でダンロードするのではなく、ADD を使えば良かったのであった。
1 [me@host: ~]$ docker build -t bsdbanner .
 1 [me@host: ~]$ docker run --name=banner --rm -it bsdbanner -w 60 Yo!
 2                                                       # 
 3                                                       # 
 4                                                     ### 
 5                                                   ##### 
 6                                                ######## 
 7                                            ############ 
 8                                         ############### 
 9               ##                     ##############   # 
10               ##                   #############      # 
11               ############################### 
12               ########################### 
13               #########################
14               ######################
15               ##                  ##### 
16               ##                     ##### 
17                                          ######       # 
18                                              #####    # 
19                                                 ####### 
20                                                    #### 
21                                                       # 
22                                                       # 
23                                                       # 
24                       ######## 
25                    ###############
26                  ################## 
27                 #####################
28                ####               ####
29                ##                   ## 
30               ##                     ##
31               ##                     ##
32               ##                    ###
33                ##                   ## 
34                ####               ####
35                 #####################
36                  ################## 
37                    ###############
38                       ######## 
39                 ###                      #########
40                #####          ########################
41               ######     ##############################
42                #####          ########################
43                 ###                      #########

Dockerfile は、Makefile を知ってるなら、ノリはそれに近い。Makefile 構文そのものだけ理解してもらちが明かなくて「Unix そのものの知識が必要」というそのまんま。

java と Windows の組み合わせこそが「ハードルが高いコマンドライン」の元凶だと言ったことがある。この、バカみたいに管理が煩雑になる「Windows での java 問題」を軽減するための Docker、というのも一つあるかもしれない:

Dockerfile
 1 #
 2 FROM ubuntu:22.04
 3 
 4 RUN apt-get update && \
 5     apt-get -y upgrade
 6 
 7 RUN apt-get install -y unzip
 8 RUN apt-get install -y default-jre
 9 
10 # epubcheck-4.2.6.zip を curl, wget でダウンロードするのが難しかった(出来なかった)
11 # ので、これは https://github.com/w3c/epubcheck/releases/download/v4.2.6/
12 # にご自身で出向いてブラウザであなたの PC にダウンロードしちくり。でダウンロードした
13 # それをこの Dockerfile と同じ場所に置くのだぞ。
14 WORKDIR /mylib
15 COPY epubcheck-4.2.6.zip .
16 RUN unzip epubcheck-4.2.6.zip
17 
18 ENTRYPOINT ["java", "-Duser.language=en", "-jar", "/mylib/epubcheck-4.2.6/epubcheck.jar"]
1 [me@host: ~]$ docker build -t epubcheck .
1 [me@host: myepubdoc]$ docker run -it --rm --name=epc -v "`pwd`"://wk -w //wk epubcheck myepub.epub

ただまぁこれの場合は「さらに python で包みたくなる」例なので、これだけだとあまり嬉しくもないのだが…。

2022-05-29 7時追記:
curl や wget でダンロードする、という発想そのものが必要ないことがわかった。ADD が、src が url ならダウンロードしてきてくれる。ゆえに、Dockerfile はこれでいい:

Dockerfile
 1 #
 2 FROM ubuntu:22.04
 3 
 4 RUN apt-get update && \
 5     apt-get -y upgrade
 6 
 7 RUN apt-get install -y unzip
 8 RUN apt-get install -y default-jre
 9 
10 # epubcheck-4.2.6.zip を curl, wget でダウンロードするのが難しかった(出来なかった)
11 # ので、これは https://github.com/w3c/epubcheck/releases/download/v4.2.6/
12 # にご自身で出向いてブラウザであなたの PC にダウンロードしなければならないと思った
13 # のだが、「ADD」が、なんと、リモートからのお取り寄せに対応しているのであった。
14 # ただし、「ADD」は、url の場合とローカルファイルの場合とで振る舞いが違い、ローカル
15 # ファイルだと zip などを自動で展開するのに url だとそうしない、みたいな不統一が
16 # あるのが残念。
17 WORKDIR /mylib
18 ADD https://github.com/w3c/epubcheck/releases/download/v4.2.6/epubcheck-4.2.6.zip .
19 RUN unzip epubcheck-4.2.6.zip
20 
21 ENTRYPOINT ["java", "-Duser.language=en", "-jar", "/mylib/epubcheck-4.2.6/epubcheck.jar"]

なお、Dockerfile に「ENTRYPOINT」や「CMD」を書かないものも試してみることをおすすめする。そしてこれを書くのと書かないのとの各々の用途について、想像を巡らすといい。(ヒントは、docker/whalesay cowsay に関係すること。)

オレの作ったもんを楽に動かして欲しいんや – 自作アプリケーションのためのイメージ自作

ワタシは Windows で動くものを作った

今回の実例は作為的なものなので「Windows ではとてつもなく困難」なものにはあたらない。普通に公式 Windows 版 CPython、node.js で何か苦労するわけでもない。ただの例だから。

今回の実例にしようと考えていたのは本当はこれだった。これは pygrib、cartopy の構築が Windows では非常に厄介なシロモノなので、これこそが Docker が真価を発揮するネタとして面白いはずなわけよ。だけれども、「はじめの1.3歩」にそぐわないほど複雑なんだよ。だって linux ですらややこしいのだからこれ(cartopy の方がね)。ゆえに、これの基本構造をそのままに、大幅に単純な例に書き換えたものを今回の例にすることにした。

こんなね:

serv.js
 1 'use strict';
 2 
 3 const listenport = parseInt(process.argv[2], 10) || 8080;
 4 const pythonexecutable = process.argv[3] || "py";
 5 var fs = require('fs');
 6 var ejs = require('ejs');
 7 var express = require('express');
 8 var path = require('path');
 9 var url = require('url');
10 var app = module.exports = express();
11 //const querystring = require('querystring');
12 var bodyParser = require('body-parser');
13 const subprocess = require('child_process');
14 const mime = require('mime-types');
15 const md5 = require("md5");
16 
17 app.engine('.html', ejs.__express);
18 app.set('views', path.join(__dirname, 'views'));
19 app.set('view engine', 'html');
20 // bodyParser がないと formvalue を取れない…、の? 解せん…。
21 app.use(bodyParser.urlencoded({extended: false}));
22 
23 //
24 app.get("/", (req, res) => {
25     console.log(`GET ${req.url}`);
26     res.render("result.html", {resfile: null, defaulturl: ""});
27 })
28 app.post("/", (req, res) => {
29     console.log(`POST ${req.url}`);
30     let imgurl = req.body.imgurl;
31     let resfile = md5(imgurl).toString() + ".txt";
32     res.render("result.html", {resfile: "/" + resfile, defaulturl: imgurl});
33     console.log(`${imgurl} -> ${resfile}`);
34     const py1res = subprocess.spawn(pythonexecutable, [
35         "mkaa.py", imgurl, resfile
36     ]);
37 });
38 app.get("*", (req, res) => {
39     console.log(`GET ${req.url}`);
40     fs.readFile(
41         "." + decodeURI(url.parse(req.url, true).pathname), function(err, data) {
42         if (!err) {
43             res.writeHead(200, {'Content-Type': mime.lookup(req.url)});
44             res.write(data);
45         } else {
46             console.log(err);
47             res.writeHead(404, {'Content-Type': 'text/html'});
48             res.write("Not Found.");
49         }
50         return res.end();
51     });
52 });
53 
54 /* istanbul ignore next */
55 if (!module.parent) {
56     app.listen(listenport);
57     console.log('Express started on port ' + listenport);
58 }
views/result.html
 1 <!DOCTYPE html>
 2 <html>
 3   <head>
 4     <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
 5     <script>
 6       var timerId = null;
 7       var table = null;
 8       function waitData() {
 9           let datapath = "<%= resfile %>";
10           if (!datapath) {
11               return;
12           }
13           $.ajax(
14               {
15                   url: datapath,
16                   success: function(result) {
17                       console.log(result);
18                       $('#art').html(result);
19                       clearInterval(timerId);
20                   },
21                   error: function(xhr) {
22                       console.log(xhr);
23                       if (!timerId) {
24                           timerId = setInterval(waitData, 1000);
25                       }
26                   }
27               });
28       }
29       $(document).ready(waitData);
30     </script>
31   </head>
32   <body>
33     <form action="/" method="post">
34       <input type="input" id="imgurl" name="imgurl" size="100" value="<%= defaulturl %>"></input>
35       <input type="submit"></input>
36     </form>
37     <pre id="art">
38     </pre>
39   </body>
40 </html>
mkaa.py
 1 # -*- coding: utf-8 -*-
 2 import io
 3 ####################
 4 import urllib
 5 import urllib.request
 6 import ssl
 7 __USER_AGENT__ = "\
 8 Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
 9 AppleWebKit/537.36 (KHTML, like Gecko) \
10 Chrome/91.0.4472.124 Safari/537.36"
11 _htctxssl = ssl.create_default_context()
12 _htctxssl.check_hostname = False
13 _htctxssl.verify_mode = ssl.CERT_NONE
14 https_handler = urllib.request.HTTPSHandler(context=_htctxssl)
15 opener = urllib.request.build_opener(https_handler)
16 opener.addheaders = [('User-Agent', __USER_AGENT__)]
17 urllib.request.install_opener(opener)
18 ####################
19 import ascii_magic
20 
21 
22 if __name__ == '__main__':
23     import argparse
24     ap = argparse.ArgumentParser()
25     ap.add_argument("url")
26     ap.add_argument("outfile")
27     args = ap.parse_args()
28     my_art = ascii_magic.from_url(args.url, mode=ascii_magic.Modes.HTML)
29     with io.open(args.outfile, "w", encoding="utf-8", newline="\n") as fo:
30         print(my_art, file=fo)

このアプリケーションそのものをイキナリ読むんでもいいんだけれど、(1)(2)(3)(3.5)の順に読み進めると理解しやすいんではないかと思う。

これを「Windows で」実行するには:

1 [me@host: myappwk]$ node.exe serv.js 8080 py

とし、ブラウザの localhost:8080/ にアクセスするだけね。こういうアホなアプリケーションであるよ:

これの場合の「あなたのところで動かしたければ」は、Python3 と node.js をインストールしていたとしても「エラーになるたびに python なら pip で、node.js のなら npm で不足モジュールインストールしてね」てことね。なんならそういう pip と npm の使い方説明も、初心者向けということなら必要だよね。

Dockerfile と docker build

つまりイメージ作成。

どうやって作った、はまぁいいでしょうよ、これだ、でいいよね:

Dockerfile
 1 #
 2 FROM ubuntu:22.04
 3 
 4 # apt-get だのなんだの、これらは ubuntu linux のコマンドそのものなので、
 5 # 知っている人にとってはなんてことないが、知らないとお手上げだろう。
 6 # これはもう、Windows ユーザの場合は WSL/WSL2 や VirtualBox で本物の
 7 # linux に触れて慣れてもらうしかない。(「そういう呪文だ」という暗記ノリ
 8 # よりは、本物を触ってみる方が早いと思う。)
 9 #
10 # FROM のベースとして大きなものを選ばない限り、この必要依存物のインストール
11 # ステップはかなりの時間がかかる。ワタシの環境では npm のインストールまでで
12 # 丁度一時間かかった。(build や pull などのこれまでの実行回数が多いほど
13 # キャッシュやベースが溜まっているので、だんだん同じことでも時間がかから
14 # なくなる。)
15 RUN apt-get update && \
16     apt-get -y upgrade
17 
18 RUN apt-get -y install python3 && \
19     apt-get -y install python3-pip
20 
21 RUN python3 -m pip install ascii_magic
22 
23 RUN apt-get -y install nodejs && \
24     apt-get -y install npm
25 
26 # Docker のマウントポイントはかなり自由度があり、/app という場所は好きに
27 # 決めてよく、そしてこうやって記述したその時点で場所は作られる。また、
28 # COPY は再帰的に動く。「.」は Dockerfile のある場所。ゆえ、ここにある
29 # views フォルダもそのまま /app にコピーされる。
30 COPY . /app
31 WORKDIR /app
32 # node.js のモジュールについては -g でインストールしてモジュール検索パス
33 # を指定するのでもいいが、ここでは /app 直下にインストールしてる。
34 RUN npm install ejs express body-parser mime-types md5
35 
36 # ↓docker run すると実行されることになるコマンドラインである。
37 #  (serv.js のコマンドラインインターフェイスはワタシが決めたのであって、
38 #   これは個々に個々である。)
39 CMD ["node", "serv.js", "8080", "python3"]
40 EXPOSE 8080

やってることは、この例の場合は「CMD」の前に書いてるものすべては「CMD が動作するのに足る全ての依存物を準備する」こと。この「準備部分」が、ワタシが作る時点でビルド成功してるなら、アナタがそこで失敗する確率は低い、てことだよ、わかる? まっさらな OS へのクリーンインストールを再現してると思えば理解できると思う。これこそが Docker を使うことの意義。(バージョン番号など詳細の特定が細かければ細かいほど、失敗する確率が減っていくことになる。)

この Dockerfile は serv.js などと同じ場所に置く。というか zip でまとめたので、この中をみとくれ: app.zip

ビルドは、例えばイメージ名を「mkaahtml」とするなら:

1 [me@host: ~]$ docker build -t mkaahtml .

Dockerfile 内のコメントにも書いたけれど、環境によっては非常に時間がかかる。一時間かかった。もちろん通信環境依存なので、恵まれた環境ならもっと早く終わるだろう。ただこれは、「やればやるほど早くなる」よこれ。キャッシュやイメージのベースとなる部分が少しずつ育っていくので。

出来上がったイメージの使い方は、これまで理解してきたのと同じね:

1 [me@host: ~]$ docker run --name=mkaahtml01 --rm -p=8080:8080 -it mkaahtml

蛇足

ところで、「受け手」、つまり「Dockerfile やイメージを使う人」の立場だと理想の部分だけ恩恵を受けられるのは確かなんだけれど、「そもそもワタシのアプリケーションをどこで作る? 作ったアプリケーションが Docker 環境で動かないことをどうやって保障しながら作る?」というのは悩ましい問題だなと思った。

今回のワタシのは「Windows で動くように作った」ものを「ubuntu 対応した」ということに対応する。で、「Dockerfile 記述」はこれは「Docker 内 ubuntu にログインして…」なんて作業をしているわけじゃないので、apt-get だのがどこで失敗するのかについての「予備知識」がない状態で書いてるわけよ。これがとても苦しかった。

つまりは「最初から(実機がないなら)WSL などの ubuntu で作る」のが理想なわけよ。だけれども Docker イメージに例えば ubuntu:22.04 があったとして、同じバージョンを WSL に作れるかというと作れないんだよね。こういう場合に Dockerfile の記述の工夫で(例えば) ubuntu にログイン出来るようにしてそこで開発しちゃう、みたいなことをしてるっぽいビデオを見た。これはいずれネタにするかもしんない。(→ 2022-06-04 の追記として書いた。)

2022-05-26追記: GUI アプリケーション関係と「環境変数」

これの追記に、まずは「WSL2 の linux GUI」が少なくとも利用者目線で不思議なものではないことがわかった、ということを書いておいた。Hyper-V が頑張ってくれてる、ということには違いはないけれど、少なくともわれわれ利用者からみれば単に「linux on WSL2 でちゃんと X サーバが動いてる」というだけのことだった。

WSL2 に乗っかってる linux というのは、その入出力は Windows の層を通るわけね。つまり「X Window System のサーバ」は「linux で動いている」のにも関わらず「Windows のウィンドウを操作する」ことになってるわけね。これが WSL2 というか Hyper-V の魔法。これがあるから「Windows 11 で Linux on WSL2 の GUI が動く」。けれどもこの魔法をかけている相手は、これは「あくまでも X サーバ」ということね。これに最初気付かなくてな。つまり、個々の X クライアント、たとえば emacs、gedit、xcalc、etc… は単に X サーバに(ソケット通信で)リクエストしてるだけなので、相手がどの X サーバだろうと無関係なの。

ここから導くことが出来る結論は…。

  1. Dockerコンテナ内の X サーバのことは必ずしも考えなくていい。
  2. つまりほかの X サーバがあるならそれに接続出来ることだけ考えればいい。
  3. なんなら「コンテナ内の linux の外部」、つまり「ホスト」(Windows)の X サーバでいい。

WSL1 と WSL2 とはネットワークの考え方が違っていて、WSL2 は、ホスト(Windows)と WSL2 インスタンスの linux は「別機器」つまり IP アドレスが異なるものとして構築される。ので、Windows の X サーバを使う場合はこれは「リモート越しの X サーバ接続」ということになる。これの「正当な」アプローチは「over SSH」で接続すること、であり、それを試みてる人々は結構見つかる。が、もっとインチキな「ザル」にしちゃうやり方もある。このサイトで紹介してるのがそれ:

完全に信頼して、認証なしで接続を許しちゃう、てことだよ。これが出来る X サーバがどれだけあるのかは知らんけど、とにかく vcxsrv は「Disable access control」だけでそれが出来てしまう、ということね。

X クライアントが X サーバに接続する手続きは、「認証」がないなら非常に単純明快で、DISPLAY 環境変数に IP アドレスとディスプレイ番号のペアをセットするだけである。実に簡単。例えば「リモート」が 172.44.33.1 とかだったら、「DISPLAY=172.44.33.1:0」。なので、Docker においても、この DISPLAY 環境変数をコントロール出来ることと、「コンテナ内 linux から見た Windows 機の IP アドレス」がわかりさえすれば、「linux on docker」の GUI アプリケーションは、何の問題もなく動作するはずだ。

お試しで、meld でやってみようと思った。Dockerfile:

Dockerfile
1 #
2 FROM ubuntu:22.04
3 
4 RUN apt-get update && \
5     apt-get -y upgrade
6 
7 RUN apt-get install -y meld

CMD も ENTRYPOINT もなく、ほんとうに meld をインストールしただけのもの。ビルド:

1 [me@host: ~]$ docker build -t meldinstalled .

まずは「オレんちの IP アドレス」(もちろん「WSL2 世界における仮想の」ね)を知る必要があるが、これは wsl の distro どれか一つにでもログインして、/etc/resolve.conf をのぞいてみればわかる。または、Windows コマンドラインで ipconfig を叩くことでもわかる。つまり、ワタシのケースではこうなった:

実行
1 [me@host: ~]$ docker run --name meld --rm -v "$(pwd)"://wk -w //wk \
2 > -e DISPLAY=172.23.32.1:0 meldinstalled meld some.txt.orig some.txt

「-e」で環境変数をセットしてる。今ワタシの Windows では vcxsrv (これのランチャは XLaunch)がちゃんと動いているので、実際にこんなふうに動いてくれた:

この絵の上の方で見切れてる「CRITICAL: Gtk: 云々」のエラーが vcxsrv (XLaunch) の起動前。

vcxsrv の安定性とかそういう「評価」はよくわからないけれど、仮にこれが完全だとするならば、もはや Docker コンテナに期待するアプリケーションが「X Window System ベース」であるかどうかは全く気にする必要がない、ということになる。それと、ワタシはもう Windows 10 には戻せないので確認しようがないのだが、こういうことならば、Windows 11 である必要はない気がする。

というか、font 関係とかって、サーバ側に設定いらないんだっけ? あれはクライアントの設定なんだっけか? 細かいことは、色々あるのかもしれないね、でも、このように「うまくいくものはうまくいく」てことで、ひとまずは満足しておいてみる。

「興奮する」ものではないけれど、apt-get で素直にインストール可能な gnome-mines はまったく問題なく動いたよ:

まったく問題なく、は嘘か。dconf-WARNING なんてのが出てるわけだし。これは Dockerfile か docker のコマンドラインオプションで措置出来る問題なのかしらね? まぁやっぱり GUI 関係は色々ある。dbus のエラーはお馴染みの、という気はするが、だからといって「お馴染みの措置方法」を知ってるわけではない。

なお、たぶんオーディオが絡むとダメなんじゃないかと思う。ffplay で音声が入ってるビデオがもうダメだった。これは X 関係ないから、てことなんだが、まぁこれこそが Unix の闇。Unix の初期の開発者たちが願った世界を壊したものこそ、BSD socket、X Window System と NAS とか ALSA とかみたいなサウンドドライバ…。そういうわけで、あまり期待しすぎないほうがいいね、GUI ものには。

2022-06-09追記: GUI アプリケーション関係の二つの別解

ふたつといっても一方は単に「別の X Server for Windows を見つけた」てだけの話。MobaXTerm。名前からは到底「X サーバ」に結びつかないが、ちゃんと X Server が同梱されてる。商用製品のようだが、Home Edition (またの名を Personal Edition)は無料であるし、なんかあんまし制約なさげよな。こちらも「無制限アクセス」の設定が簡単に出来るのは同じ。vcxsrv (またはそのもとになった xming) よりもモダンで自由度も高そうにみえるんだけど、どうなのかな。まぁなんであれ選択肢が増えるのは良いことだ。

あと、見つけたというか知ってたけど今のところ手を出したくないのがあって、それは「cygwin のやつ」。これはね…、それこそ「Virtual Bix, MSYS, WSL2, Docker を持ってるのにさらにバカでかい「UNIX もどき」を入れるのがなぁ」てこと。普段から cygwin ユーザならともかく、ワタシはそうではないので。でも結構一番有望な可能性が高いてのはあるんだよねこれ。なんだかんだ、ほぼオリジナルのソースコードの改変なく動かすってノリのはずだから、XOrg のものほぼそのままの可能性があるから。まぁ「たぶん」て想像でしかないんだけどね。cygwin の中でもとりわけ特殊なものだろうから、完全に書き直してる可能性もないとは言えんから。ともあれ、ワタシはこれについて何にもわからない。

で、もう一つの別解の方が本題。DISPLAY 環境変数として「DISPLAY=host.docker.internal:0」が使える。ただ、「これだと「Windows 11 + WSL2」なら「自分でホスト Windows に X サーバはいらない」」ことを期待したけれどそうではないみたい。残念。

どちらのケースでも これで試した sopwith が動いた。gnome-mines も同じく。あと、hhsprings/ffmpeg-yours--enable-ffplay ビルドに書き換えたんだけれど、この ffplay も(音声がないなら)問題なく動く。

2022-06-11追記: GUI アプリケーション関係の「サウンド」

一週間くらい迷走してたかなぁ。ずっと pulseaudio ならイケるはずだと思っていたのに叶わずとても困っていたのだが、やっと把握した。この問題の元凶は「for Windows」バイナリが二系統出回ってて、一方がまったく動作しないことなのだが、まぁもうそれは言うまい…。「1.1」というやつでイケるよ。

実際にどうすれば音声も再生出来るのかの実例は Using FFMPEG on Docker に書いておいた。

2022-05-27追記: もう一つイメージ自作「C/Sアプローチ不可避、もしくはその方が楽」パターン

最初の ascii-magic を使うやつ(mkaahtml と名付けたやつ)と作りはほとんど変わらないが、それよりも価値がわかりやすい例を作れたので、追記の形で紹介しようと思った。

「C/Sアプローチ不可避、もしくはその方が楽」パターンは、ワタシが良くやりたくなる「HTML を利用したお便利道具作り」に良く登場する。IndexedDB の話をした際にも言った「ストレージなどリソースを自由に使えるかどうか」の形で現れることも多いけれど、「CORS (Cross Origin Resource Sharing)」が動機になることもある。今回のはそのパターン。

ffmpeg で MPEG-DASH ストリームを公開する、というネタね。ローカルに作った html に dash.js を乗っけて…はこれは、CORS 問題で動かせない。

実用性は考えないで。「とりあえず動く」だけを目指した。

まずは「ffmpeg で dash」の、「ただのシェルスクリプト」:

atocqt.sh
 1 #! /bin/sh
 2 od="`dirname \"${2}\"`"
 3 test -d "${od}" || mkdir -p "${od}"
 4 test -f "${2}" || ffmpeg -y -i "${1}" -filter_complex "
 5 [0:a]showcqt=s=1920x1080:fps=8,crop=1864:1080:0:0,scale=1920:1080,setsar=1[vcqt];
 6 [0:a]showvolume,scale=1280:40,setsar=1,colorkey=black,fps=12[vv];
 7 [0:a]showwaves=split_channels=1:mode=line:
 8 colors=red@0.8|green@0.9:s=1280x480,setsar=1,fps=12[vs];
 9 [vs][vv]overlay=x=0:y=(H-h)/2,fps=23[v1];
10 [vcqt][v1]
11 overlay=x=W-w-50:y=50
12 ,drawtext='fontsize=220:fontcolor=white@0.2:text=%{pts \: gmtime \: 0 \: \%H\\\\\\:\%M\\\\\\:\%S}':x=760:y=200[av];
13 [av]scale=640:480,fps=23
14 " -f dash "${2}"

インターネットに転がってる「ffmpeg で dash」例の多くが、「何かを垂れ流す」例なのだが、「持ってるビデオを公開するだけ」のものなら面白くもなんともないし、「PC に接続した WEB カメラを入力とする」例だと最初の例にするには「必要知識が多過ぎる複雑な例」になって「hello, mpeg-dash with ffmpeg」には相応しくない、ので、ワタシのこれは「ワタシのかなり初心者向け ffmpeg 実例集」程度の理解でもすぐにわかる、まさに「-f dash」してるだけの本当にシンプルだけれども「垂れ流しではない」例ね、オーディオファイルを showcqt などを使って可視化する。(ただしこのスクリプトの第二引数は「manifest.mpd」となることが前提。)

サーバは mkaahtml と同じく node.js + express (+ ejs) だが、今回のは python は呼び出さない:

serv.js
 1 'use strict';
 2 
 3 const listenport = parseInt(process.argv[2], 10) || 8080;
 4 
 5 var fs = require('fs');
 6 var ejs = require('ejs');
 7 var express = require('express');
 8 var path = require('path');
 9 var url = require('url');
10 var app = module.exports = express();
11 //const querystring = require('querystring');
12 var bodyParser = require('body-parser');
13 const subprocess = require('child_process');
14 const mime = require('mime-types');
15 const md5 = require("md5");
16 
17 app.engine('.html', ejs.__express);
18 app.set('views', path.join(__dirname, 'views'));
19 app.set('view engine', 'html');
20 // bodyParser がないと formvalue を取れない…、の? 解せん…。
21 app.use(bodyParser.urlencoded({extended: false}));
22 
23 //
24 app.get("/", (req, res) => {
25     console.log(`GET ${req.url}`);
26     res.render("index.html", {resfile: "", defaultvid: ""});
27 })
28 app.post("/", (req, res) => {
29     let vidfile = req.body.vidfile;
30     let resfile = "";
31     if (vidfile === undefined) {
32         vidfile = "";
33     } else {
34         vidfile = decodeURI(vidfile)
35         resfile = "mpeg-dash/" + md5(vidfile).toString() + "/manifest.mpd"; 
36     }
37     res.render("index.html", {resfile: resfile, defaultvid: vidfile});
38     console.log(`${vidfile} -> ${resfile}`);
39     if (!vidfile) {
40         return;
41     }
42     const procres = subprocess.spawn("sh", [
43         "/app/atocqt.sh", vidfile, resfile
44     ]);
45     procres.stderr.on("data", (data) => {
46         console.log(data.toString());
47     });
48 });
49 app.get("*", (req, res) => {
50     console.log(`GET ${req.url}`);
51     fs.readFile(
52         "." + decodeURI(url.parse(req.url, true).pathname), function(err, data) {
53         if (!err) {
54             res.writeHead(200, {'Content-Type': mime.lookup(req.url)});
55             res.write(data);
56         } else {
57             //console.log(err);
58             res.writeHead(404, {'Content-Type': 'text/html'});
59             res.write("Not Found.");
60         }
61         return res.end();
62     });
63 });
64 
65 /* istanbul ignore next */
66 if (!module.parent) {
67     app.listen(listenport);
68     console.log('Express started on port ' + listenport);
69 }
views/index.html
 1 <!DOCTYPE html>
 2 <head>
 3   <meta charset="UTF-8">
 4   <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
 5   <script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
 6   <script>
 7     var timerId = null;
 8     function buildPlayer() {
 9         $("#busy").html("");
10         var video = document.querySelector("video");
11         var player = dashjs.MediaPlayer().create();
12         player.initialize(); /* initialize the MediaPlayer instance */
13         player.updateSettings({
14             'debug': {
15                 /* turns off console logging */
16                 'logLevel': dashjs.Debug.LOG_LEVEL_NONE
17             },
18             'streaming': {
19                 'scheduling': {
20                     /* stops the player from loading segments while paused */
21                     'scheduleWhilePaused': false,
22                 },
23                 'buffer': {
24                     /*
25                      * enables buffer replacement when switching bitrates
26                      * for faster switching.
27                      */
28                     'fastSwitchEnabled': true
29                 }
30             }
31         });
32         player.setAutoPlay(true);
33         player.attachView(video); /* tell the player which videoElement it should use */
34         player.attachSource("<%= resfile %>"); /* provide the manifest source */
35         console.log("<%= resfile %>");
36     }
37     function waitData() {
38         let datapath = "<%= resfile %>";
39         if (!datapath) {
40             return;
41         }
42         $.ajax(
43             {
44                 url: "<%= resfile %>",
45                 success: function(result) {
46                     setTimeout(() => {
47                         $.ajax({
48                             url: "<%= resfile %>",
49                             success: function(result) {
50                                 buildPlayer();
51                             },
52                         })}, 5000);
53                     clearInterval(timerId);
54                 },
55                 error: function(xhr) {
56                     const dt = new Date();
57                     $("#busy").html(`[${dt}] waiting...`);
58                     //console.log(xhr);
59                     if (!timerId) {
60                         timerId = setInterval(waitData, 15000);
61                     }
62                 }
63             });
64     }
65     $(document).ready(waitData);
66     </script>
67   <style>
68     video {
69         width: 640px;
70         height: 480px;
71     }
72   </style>
73   <body>
74     <form action="/" method="POST">
75       <input type="input" id="vidfile" name="vidfile" size="100" value="<%= defaultvid %>"></input>
76       <input type="submit"></input>
77     </form>
78     <video controls="true">
79     </video>
80     <div id="busy"></div>
81   </body>
82 </html>

Windows でも「sh.exe がないんです!!」レベルに該当しない人なら動かせると思うけれど、docker で動かそう、てことね。Dockerfile のノリは mkaahtml のものと微妙に違うので、それに注意深く反応してみて欲しい:

Dockerfile
 1 FROM krickwix/ffmpeg
 2 # ↑ubuntu ベースらしい。
 3 
 4 RUN apt-get update && \
 5     apt-get -y upgrade
 6 
 7 RUN apt-get -y install nodejs && \
 8     apt-get -y install npm
 9 
10 COPY . /app
11 WORKDIR /app
12 RUN npm install ejs express body-parser mime-types md5
13 
14 # 今回のパターンは WORKDIR /app への全幅の信頼が出来ない。docker run 時に指定する
15 # 位置のビデオを操作したいので、WORKDIR 前提のスクリプトには出来ない。
16 
17 ENV NODE_PATH=${NODE_PATH}:/app
18 CMD ["node", "/app/serv.js", "8080"]
19 EXPOSE 8080

どういうことかというと、「入力となるビデオ」は「サーバの持ち物」としたい、ということ。けれどもこれは「docker イメージの中にブチ込む」のではなくて、docker run を「ビデオ置き場」に基づいて行いたい、ということ。例えばあなたの Windows のビデオ置き場「c:/Users/anata/Videos」に基づいて動きたい、てことね。これを「docker run -v と -w の指定」で行いたい、ということになるため、mkaahtml では「WORKDIR /app」に完全に依存していたが、今回のは出来ない、ということ。

build して動かしてみるには:

1 [me@host: myapp]$ docker build -t dashffmpegstreamer_example .
2 [me@host: myapp]$ cd /c/Users/hhsprings/Videos
3 [me@host: Videos]$ docker run --name=dashffmpegstreamer_example -it --rm \
4 > -p 8080:8080 -v "`/bin/pwd`"://videos -w //videos dashffmpegstreamer_example

など。動作結果はたとえばこんな感じ:

なお、今回のを docker で動かす場合、シェルスクリプト(atocqt.sh)の改行コードに注意。DOS 形式だと \r が原因で振る舞いが壊れる。また、dash.js のせいなのかなんなのかわからないが、ffmpeg が動いてる最中と ffmpeg 終了後で振る舞いが全然違うなんてことが起こるみたいなので、わけわからんとなったら、出力フォルダ(mpeg-dash)を消して動かすべし。(何が起こってるのかワタシはわからない。今はそういうもんなのかと思ってる。)

今回のも zip で固めたので、コピペ作業が面倒な方はどぞ: app2.zip

2022-05-29追記: さらにイメージ自作「野良ビルドを共有」というメリット、について思う、の巻

「野良ビルド」は、多くのケースは「ノウハウの共有」を超えるネタになりにくい。オートメーションに頼る部分が限定的になりがちだからだ。

Docker に頼る場合、「野良ってるビルド」のその手順全てが Dockerfile に記述されることになる。ので、それがどんなに行儀が悪かろうが乱暴な作業だろうが、それが Dockerfile 作成者の期待通りの結果になる限りは、「あなたも同じ期待が出来、そしてその結果を確実に享受出来る」。

野良ビルドの「行儀の悪さ」の目立つ最大のものが、大抵は「システムのパッケージマネージャに逆らう」という根本部分そのものであり、「システムのパッケージマネージャに逆らうと呪われる」のは確率としては低くはないので、平均的なユーザがこれを躊躇したくなるのは致し方ないこと。けれども、ことそれが Docker の中の世界となれば全く話が別。だって「run で実行するものだけ期待通り動作すればいい」だけなんだから。

「システムのパッケージマネージャに逆らうと呪われる」は、最近は Python 関係で増えてきた。ここ10年でも python が linux システムの根幹に関わる部分に使われる比率が驚くほど高くなってきていてかなりビックリする。たとえば /etc/lsb_release は python スクリプトになっているので、「linux ディストリビューションのパッケージマネージャの管理外で python をリプレイスすると lsb_release が動作しなくなる」なんてことが起こったりする。これが「linux システムそのものにログインして使っている場合の「逆らうと呪われる」」。けど、たとえば Docker で「ffmpeg」を動かしたい、という時に「lsb_relase が動かない」ことは本当にどうでもいいこと。ENTRYPOINT や CMD を書いた Dockerfile で作ったイメージなら、そもそも lsb_relase を実行する手段も失われていることだろうし(→ 2022-06-04の追記参照。手段は失われてない。「どうでもいいこと」という主張は変わらないけれど。)

ffmpeg のね、「--enable-xxxx でビルドした ffmpeg でないと使えない」というものが少しばかりあって。今は Windows 向けとして公式に紹介されているヤツがかなり頑張って「-full という名に恥じない」ビルドを提供しているので、日常で使う ffmpeg としてはそんなに困る機会もないのだけれど、それでも少しはある。ワタシは「ocr」を試してみたかった。けれどもワタシが今使ってる ffmpeg-4.4-full_build-shared というヤツは(--enable-libtesseract が有効になってないから?)ocr は使えないし、たとえば ubuntu の apt 管理の ffmpeg も ocr は使えなくて、なおかつ DockerHub にある ffmpeg を目的としたイメージをいくつか見てみたが、ocr を使えるものは見つけられなかった。

「Windows 版の野良ビルド」なんて考えるととてもやる気がしない(やろうとしたことはあるし動かせたこともあるが)けれど、Docker ならあまり躊躇する理由もないぞ、ということだわな。

「ocr を使えるもの」というだけだとかなり簡単だったよ、1分で書いた Dockerfile でビルドもそんなに時間はかからなかった。けれども ffmpeg-4.4-full_build-shared の水準を目指すと恐ろしく大変ね。ビルドの時間も相当。たとえば今の状態はこんな:

Dockerfile
  1 #
  2 FROM ubuntu:22.04
  3 #
  4 WORKDIR /tmp/build
  5 #
  6 ARG __APT_Y="-yq --no-install-recommends"
  7 
  8 # ----------------------------------------------------------
  9 #
 10 # prepare dependancies which we can get with apt-get 
 11 #
 12 # ----------------------------------------------------------
 13 RUN apt-get update && \
 14     apt-get ${__APT_Y} upgrade
 15 
 16 # base build system, and some
 17 RUN apt-get install ${__APT_Y} build-essential
 18 RUN apt-get install ${__APT_Y} yasm
 19 RUN apt-get install ${__APT_Y} pkg-config
 20 
 21 # ----------------------------------------------------------
 22 #
 23 # ffmpeg and opencv are interdependent with each other.
 24 # Therefore, install the small ffmpeg in advance.
 25 #
 26 # ----------------------------------------------------------
 27 # download specific released version, not current snapshot via git.
 28 ARG __FFMVER=4.4.2
 29 ADD https://ffmpeg.org/releases/ffmpeg-${__FFMVER}.tar.xz .
 30 
 31 RUN tar Jxvf ffmpeg-${__FFMVER}.tar.xz
 32 ENV FFMPEG_SRCDIR=/tmp/build/ffmpeg-${__FFMVER}
 33 WORKDIR ${FFMPEG_SRCDIR}
 34 
 35 #
 36 ARG __PREFIX=/usr/local
 37 ENV LD_LIBRARY_PATH=${__PREFIX}/lib64:${__PREFIX}/lib:${LD_LIBRARY_PATH:-/usr/lib64:/usr/lib}
 38 ENV PKG_CONFIG_PATH=${__PREFIX}/lib64/pkgconfig:${__PREFIX}/lib/pkgconfig:${PKG_CONFIG_PATH:-/usr/lib64/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig}
 39 
 40 #
 41 RUN sh configure \
 42     --disable-static \
 43     --enable-shared \
 44     \
 45     --disable-ffplay \
 46     --disable-doc \
 47     --disable-htmlpages \
 48     --disable-manpages \
 49     --disable-podpages \
 50     --disable-txtpages
 51 
 52 RUN make install
 53 
 54 # ----------------------------------------------------------
 55 #
 56 # build opencv from source, because libopencv-dev of apt
 57 # is broken or at least CRAZY in my humble opinion.
 58 #
 59 # ----------------------------------------------------------
 60 # opencv from source
 61 RUN apt-get install ${__APT_Y} cmake
 62 RUN apt-get install ${__APT_Y} libgtk2.0-dev
 63 RUN apt-get install ${__APT_Y} libjpeg-dev libpng-dev libtiff-dev
 64 
 65 WORKDIR /tmp/build
 66 # download specific released version, not current snapshot via git.
 67 ARG __OCVVER=3.4.15
 68 ADD https://github.com/opencv/opencv/archive/refs/tags/${__OCVVER}.tar.gz .
 69 
 70 RUN tar zxvf ${__OCVVER}.tar.gz
 71 ENV OPENCV_SRCDIR=/tmp/build/opencv-${__OCVVER}
 72 WORKDIR ${OPENCV_SRCDIR}
 73 RUN mkdir build
 74 WORKDIR ${OPENCV_SRCDIR}/build
 75 # "-D OPENCV_GENERATE_PKGCONFIG=ON" has no effect if opencv 3.x, this is only for opencv 4+.
 76 RUN cmake -D CMAKE_BUILD_TYPE=RELEASE -D OPENCV_GENERATE_PKGCONFIG=ON -D CMAKE_INSTALL_PREFIX=${__PREFIX} ..
 77 RUN make install
 78 
 79 # ----------------------------------------------------------
 80 #
 81 # prepare dependancies which we can get with apt-get
 82 # for full-build of ffmpeg
 83 #
 84 # ----------------------------------------------------------
 85 # --- base level 0
 86 RUN apt-get install ${__APT_Y} libbz2-dev
 87 RUN apt-get install ${__APT_Y} libz-dev
 88 RUN apt-get install ${__APT_Y} liblzma-dev
 89 RUN apt-get install ${__APT_Y} libxml2-dev
 90 
 91 # --- base level 1
 92 RUN apt-get install ${__APT_Y} libssh-dev
 93 RUN apt-get install ${__APT_Y} libgnutls28-dev
 94 RUN apt-get install ${__APT_Y} nvidia-opencl-dev
 95 
 96 # --- base level 2
 97 RUN apt-get install ${__APT_Y} libsrt-gnutls-dev
 98 RUN apt-get install ${__APT_Y} libass-dev
 99 RUN apt-get install ${__APT_Y} libfribidi-dev
100 RUN apt-get install ${__APT_Y} libfontconfig-dev
101 RUN apt-get install ${__APT_Y} libfreetype-dev
102 
103 # --- base level 3
104 RUN apt-get install ${__APT_Y} libx264-dev
105 RUN apt-get install ${__APT_Y} libx265-dev
106 RUN apt-get install ${__APT_Y} libvpx-dev
107 RUN apt-get install ${__APT_Y} libwebp-dev
108 RUN apt-get install ${__APT_Y} libaom-dev
109 RUN apt-get install ${__APT_Y} libmp3lame-dev
110 RUN apt-get install ${__APT_Y} libtwolame-dev
111 RUN apt-get install ${__APT_Y} libopus-dev
112 RUN apt-get install ${__APT_Y} libvorbis-dev
113 
114 # --- advanced
115 RUN apt-get install ${__APT_Y} libbs2b-dev
116 RUN apt-get install ${__APT_Y} frei0r-plugins-dev
117 RUN apt-get install ${__APT_Y} libtesseract-dev libleptonica-dev tesseract-ocr-eng
118 RUN apt-get install ${__APT_Y} flite1-dev
119 
120 # ----------------------------------------------------------
121 #
122 # finally, full-build of ffmpeg.
123 #
124 # ----------------------------------------------------------
125 #
126 WORKDIR ${FFMPEG_SRCDIR}
127 
128 RUN sh `head -1 ffbuild/config.log | sed 's@^# @@'` \
129     --enable-gpl \
130     --enable-version3 \
131     \
132     --enable-libsrt \
133     --enable-libass \
134     --enable-libfribidi \
135     --enable-libfontconfig \
136     --enable-libfreetype \
137     --enable-libxml2 \
138     \
139     --enable-libssh \
140     --enable-gnutls \
141     \
142     --enable-libx264 \
143     --enable-libx265 \
144     --enable-libvpx \
145     --enable-libwebp \
146     --enable-libaom \
147     --enable-libmp3lame \
148     --enable-libtwolame \
149     --enable-libopus \
150     --enable-libvorbis \
151     \
152     --enable-libbs2b \
153     --enable-frei0r \
154     --enable-libflite \
155     --enable-libtesseract \
156     \
157     --enable-opencl \
158     --enable-libopencv
159 
160 RUN make install
161 # -------------------------------------------------------

opencv がヒドい。libopencv-dev は、控えめに言ってクレイジー、率直に言ってキチガイ。依存されてる相手を依存物として入れようとしてるのか、あるいは test が依存してるのを拾ってしまってるのかどっちかだとは思うが、まぁご自身で一度試してみるといいさ「apt-get install -y libopencv-dev」を。一生かかっても終わらん、てなると思うよ。最低でも24時間では終わらない。(正確に追いかけてはいないが、たぶん proj-data か何か GIS 関係の依存物がワーストで、それ以外にも何百もの依存パッケージをインストールしようとする。)

opencv に関してパッケージマネージャに逆らうことは、これは少なくともワタシにとっては「正義」なので、つまりこれは「正義 vs 正義の戦争」ということになるのだが、「linux 実機ならばこの戦争状態そのこと自体が何かトラブルを招くかもしれない」としても、docker run ... ffmpeg ... が期待通り動く限りにおいて、そうした「潜在的なトラブル」が顕在化することはほとんど考えなくてよい、ということだ。こういうのも Docker のメリットだよな、と。

なお、この Dockerfile は gist で管理してる。ほんとはこれで作った Docker イメージを DockerHub に公開しちゃうのが理想ではあるけどそれはしない。そもそもビルドに膨大な時間がかかる割には ffmpeg-4.4-full_build-shared の水準にまだ追いついてないなど、結構中途半端でもあるし。(→ 2022-06-01追記: ちゃんと公式のを作った。)

せっかく作ったので、ffmpeg-4.4-full_build-shared では出来ない例をいくつか。hhsprings/ffmpeg-custombuilt というタグで docker build したとして:

1 [me@host: ~]$ docker run --rm -it -v "$(/bin/pwd)"://wk -w //wk \
2 > hhsprings/ffmpeg-custombuilt \
3 > ffmpeg -y -i input.mkv -vf 'ocr,metadata=mode=print:file=ocrout.txt' -f null -

libtesseract による OCR の実力は…、まぁそんなものであろう、という程度。過度に期待しちゃダメだけど、認識出来るものは認識出来る。(なお、ワタシの Dockerfile は langage=en のみへの対応なことに注意。)

1 [me@host: ~]$ docker run --rm -it -v "$(/bin/pwd)"://wk -w //wk \
2 > hhsprings/ffmpeg-custombuilt \
3 > ffmpeg -y -i input.mkv -vf 'ocv=filter_name=dilate:filter_params=5x5+2x2/cross|2' out.mkv
1 [me@host: ~]$ docker run --rm -it -v "$(/bin/pwd)"://wk -w //wk \
2 > hhsprings/ffmpeg-custombuilt \
3 > ffmpeg -y -i input.mkv -vf 'ocv=filter_name=smooth' out.mkv

Dockerfile の書き方そのものについての話をいくつか。

まず、ARGENV は当然意図して使い分けてる(前者がビルドステップ専用の変数、後者がランタイムに影響する本物の環境変数)けれど、ARG 変数名にアンダースコアを使うのは別にお約束ではないし、書く場所もお行儀に従ってないよ、ワタシにとってのわかりやすさを採用しただけ。ARG はシェルスクリプトにおける引数に相当する機能なので、冒頭に書くのが普通と思う。あと、リファレンスに書かれている通り、この ARG 変数は docker build コマンドラインで変更出来る。つまりワタシが書いたのは ffmpeg 4.4.2 と opencv 3.4.15 という組み合わせだけれど、これは Dockerfile を書き換えることなく変更出来る。(ただし ffmpeg 4.4.2 が期待する opencv は 3.x。パッケージ名が opencv 4.x では変わるので、ffmppeg に手を入れない限りは使えない。)

もうひとつが「インクリメンタルに Dockerfile を育てていきたい場合」の「コツ」の話。どうも「内容に変更があった」ことを「依存順=記述順」という単純な考え方で制御するみたいなんだよね、Docker は。つまりたとえば Dockerfile の 10 行目を変えると Docker は「11行目以降が廃れた」と判断して、キャッシュを使わない。これが何度も書き換えながらビルドする際の、非常に大きな時間ロスを生んでしまうことになるんだけれども、だとするならば、これの記述のコツは「絶対的に動かない(書き換えない)自信のあるものほど上の方に」書くようにすること、ということになる。言うは易し、ではあるけれど、これがうまく出来るのと出来ないのとでは作業時間に驚くほどの差が出る。(実際、作ってる最中では、opencv のビルド部分はもっと下のほうでやってた。)

あと、うまく記述出来た部分があるならそれを「ベース」と考えて独立したイメージにしてしまい、それを FROM で引き継ぐことも考えたほうがいい。少なくとも「開発中は」。事実上で挙げたワタシの一枚岩の Dockerfile も、書いてる最中はそうしていた。それのマージはあとで考えればいい。(ワタシの書いたヤツをビルドしてそれをベースとして残す使い方をする場合は、${FFMPEG_SRCDIR} にソースがそのまま残っているので、その作業場所と configure を(`head -1 ffbuild/config.log | sed 's@^# @@'` のような手段で)そのまま引き継げばいい。)

2022-06-06追記: 二度目の FROM が…

人さまのを読んでで驚きの事実を知る。こんなことが出来ちゃうのね:

1 FROM hhsprings/ffmpeg-yours AS ffmpeg-yours
2 
3 FROM ubuntu:22.10
4 COPY --from=ffmpeg-yours /usr/local /usr/local

これ、二度目の FROM で、「もう一度まっさらの ubuntu:22.10 になる」。でも「最初の FROM からその後にやったこと」も利用出来る。つまりこの例の場合、「ffmpeg-yours の /usr/local だけ」を引っこ抜いている。(これで作ったイメージは非常に小さい。そりゃそーだ。)

この Dockerfile で作ったコンテナは肝心の ffmpeg は動かない。環境変数も設定しなおしてないし、依存ライブラリもまっさらなので。けど、この仕組みを利用出来るとなれば、「最初は xxx-dev 依存でビルドし、最後に配布イメージを作るために今度は -dev でない xxx をインストールし直す」のが結構簡単になるんだよね。

ワタシの「ffmpeg-yours」は「FROM で丸ごと開発環境を引き継ぐ」ことを目的として始めたものなので「-dev をアンインストールして、-dev でない xxx をインストールし直す」を実際に適用することはするつもりはないんだけれど、ただ、「それをするための準備」は提供出来るよなと。「依存ランタイムのインストールスクリプト」を用意すればいい。うーん、どうすっかなぁ、そこまでやっとうこうかねぇ?

2022-06-07追記: buildx を使おうとして…

どうも、進化の流れとして、過渡期っぽくみえる。「元の build → BuildKit という名の拡張が開発され → それを取り込んだ buildx が使えるようになり → 元の build すらも「環境変数 DOCKER_BUILDKIT=1」すれば BuildKit 拡張を利用出来るようになり → Docker Desktop は既にそれが出来るようになっている」のようなことらしい、「興奮するぜ」と解釈した、のだけれども。

「error: invalid character ‘\x00′ looking for beginning of value」ってナニそれ? 同じ事象に悩むひとたち。まったく同じ場所で起こっていたわけではないんだけれど、ほぼ同じだった。ワタシのケースでは、「c:/Users/hhsprings/.docker/contexts/meta」にあったデータが確かに \0 だらけ猫肺だらけだったので、試しに消してみたら、動くようになった。なんかそもそもの Docker Desktop 導入時を思い出す。ほんと、こういう情報のをある程度自力で発掘出来る人以外にはまだ難しいてことなのかねぇ…、あと一歩なんだよなぁ、て感じ。

何をしたくて buildx の方を使いたくなったかの動機は、buildx そのものについての話を先にしないと成立しなそうなので、ここで書くのはやめとく。ちょっとかなりノリが違うところがあるみたいで、つまづいてるの、あたし。→ 2022-06-09追記: これについて書いた

2022-06-09追記: DEBIAN_FRONTEND=noninteractive はイメージ自作の心の友

debian 系列、つまり主として debian と ubuntu の場合は、DEBIAN_FRONTEND=noninteractive の存在を忘れないこと。Dockerfile のケースでは ENV で。

これをセットしない場合、おそらく必中で問題を起こすのが tz。あろうことか、「yes」のような自動応答では制御出来ないプロンプトをやらかしてくるのだが、これが Dockerfile 内だから困るわけだ。上で GUI お試しをいくつか紹介したが、その例を探してる際は、結構 tz で止まった。

2022-06-14追記: 「いつかは溢れる」- GitHub ですら、てのと failed to register layer: max depth exceeded.

初心者であるかどうかによらず皆にとって重要なのは後者なので先にこっちから。

ついぞさっき喰らった。こういう制限はそりゃあるだろうとは思ってはいたけれど、想像してたよりずっと小さかった。この修正のほぼ全てが「failed to register layer: max depth exceeded.」向け措置。何をしてるかって、「レイヤーを減らしている」。もっと詳しく言う。まずはこれ:

Dockerfile (ver1)
1 FROM ubuntu
2 
3 RUN apt-get update
4 RUN apt-get install -yq --no-install-recommends libx264-dev
5 RUN apt-get install -yq --no-install-recommends libx265-dev
6 #...

この「libx264-dev」を処理してる行と「libx265-dev」を処理してる行は「別のレイヤーになる」。「failed to register layer: max depth exceeded.」はこのレイヤーが多過ぎる、と言われている、てこと。のでこういうことをするしかないわけだ:

Dockerfile (ver2)
1 FROM ubuntu
2 
3 RUN apt-get update
4 RUN (\
5     apt-get install -yq --no-install-recommends libx264-dev ; \
6     apt-get install -yq --no-install-recommends libx265-dev \
7     )
8 #...

こうやってレイヤーをある程度の塊にまとめることでレイヤーの数を減らす、てことね。どうも人様の Dockerfile をみると皆極端に少ないレイヤー数で書いてることが多いんだよね、なんでだろうと思ってたのだが、これも理由なのかも。でも、このレイヤー管理をどう考えるかはほんと人それぞれ違うだろうと思う。理想的には「枯れたものたちなら動かない(変えないで済む)ので、そういうのを一括で」としたいんだよ、キャッシュを有効活用するためにも。でも「絶賛作り中」はそういうまとまりは簡単には見つからなかったりもするし、変わらないと思っていたものも結構簡単に変わるしね、理想と現実は違うってこった。

「GitHub ですら」にハマる人は、さすがに本当の初心者には少ないかも。これな。こういうふうに「一気に多くのイメージを作」ろうとすると、キャッシュやら色々で「いつかはディスクが溢れる」。GitHub の場合は、当然「全世界のユーザに無尽蔵にディスクを使わせる」わきゃなくて、この場合は QEMU のクオータなのかね、正確にどんなサイズなのかはわからんけど、とにかく「ディスクフル」を喰らった。今は buildx の prune で都度クリアしてるし、そうする前は docker rm で都度消してた。まぁあなたがいつこれを喰らうかはわからんけれど、「そういうことはいつかは起こりうる」ことは頭の片隅には置いとこうね、てことで。

2022-06-04追記: コンテナに「ログイン」したい話

やっとわかった…。

特に「絶賛 Dockerfile 作りかけ」の時に、「ひとまずうまくとこまで」のビルドをして、「出来上がっているとこまで」の中身を確認したいんだよね。

いくつかのパターンがあって、それがちょっと初学者の理解を阻む要因となっていた。

まずは、「CMD」も「ENTRYPOINT」も書いていない Dockerfile で作られたコンテナの場合。これが一番簡単なパターンで、コンテナ内にインストールされているコマンドは全て自由に実行出来るので、コンテナの作者が意図的に bash を利用不可能にしてない限りはそのまま使える:

次が「CMD」は書かれているが「ENTRYPOINT」は書いていない Dockerfile で作られたコンテナの場合。これは実は「どちらも書いてない場合」と全く同じ。なぜなら「CMD」は、これは実は「run 時の、引数未指定時デフォルト」という機能だから。たとえばこういうアホな Dockerfile:

1 FROM ubuntu:22.04
2 
3 CMD ["cat", "/etc/host.conf"]

これを hoge という名前でビルドしたとして:

問題は ENTRYPOINT があるものなのだが、それでも、常駐モノなら話は簡単で、これは exec で可能。上で例にした redis を run していたとする:

1 [me@host: wk]$ docker run --name redis01 --rm -p=6379:6379 -d redis
2 [me@host: wk]$ docker run --name redis02 --rm -p=6380:6379 -d redis
3 [me@host: wk]$ docker ps
4 CONTAINER ID   IMAGE     COMMAND                  CREATED      STATUS      PORTS                    NAMES
5 be8579160185   redis     "docker-entrypoint...."   7 days ago   Up 7 days   0.0.0.0:6380->6379/tcp   redis02
6 0b3dd81f7d97   redis     "docker-entrypoint...."   7 days ago   Up 7 days   0.0.0.0:6379->6379/tcp   redis01

この通り:

で。

1 FROM ubuntu:22.04
2 
3 ENTRYPOINT ["cat", "/etc/host.conf"]

このパターンは、run 時に後ろに追加していく引数は単に cat への追加引数になっていくだけなので、ここから bash を起動する術はなく、なおかつこの cat は一瞬で終了して、このコンテナの役目を終えてしまうわけね。もちろんこれ:

1 FROM ubuntu:22.04
2 
3 ENTRYPOINT ["sleep", "180"]

なら、redis の例と同じく 3分の sleep の間は exec 出来るけれど、3分過ぎて終了してしまえば、上の cat の例と同じになる。--rm せずに名前が残っていたとしても、このようなエラーを喰らう:

1 Error response from daemon: Container xxx is not running

どうしたものか、と思うわけなのだが、いやいや、--entrypoint があるんだわ:

コンテナ作者の意図を無碍に出来る、ということね。

この知識によって、Dockerfile 自作時には、たとえばワタシの ffmpeg-yours の例で言えば、新たに apt-get で追加インストールしてみたい、というのを「膨大な時間をかけて最初から docker build する」ことなく試してみることが出来るし、人の作ったものならば、たとえばデータファイルや設定ファイルがどうなっているのかの確認なんかも自由に出来る、ということ。

尻とつーざねくすと

本当に一番基礎となるとこだけやったつもりだけど、その基礎としても、実は最低でも二つ大きな部分が欠けてる。「ネットワーク」についてのポート以外についてが一つね。ビデオで説明されてるので、わかる人はそっち見てもらったらいい。それは、おいおいで、かもしれんし、ワタシは書かないかもしれないしそれは出たとこ勝負かね。もう一つが GUI もの。こちらはそもそも Windows で出来るのかどうかの検証から必要。最低でも「Windows 11 + WSL2」という条件を満たさないとダメなのだろうし、Windows でないケースですら特殊な手続きが必要だし(リモートの X にアクセスするためには必ず必要な設定)。(→ GUIものについては「2022-05-26追記」として「イメージ自作」の一つとして書いた。)

で、その「一番基礎となる」の次に控えてる部分が、実は「自作イメージ」の話と「使いたいだけなんや」のちょうど組み合わせたような話になる「Compose」などの話。出来合いのイメージを組み合わせて使う、てことね。これも Docker の真骨頂といえる大きなネタと思うんだけれど、少なくとも「はじめの1.3歩」で書くような内容じゃないので、やるなら完全に別ネタとして書くつもり。

そして、この「1.3歩」までであなたがどこまでハッピーになれるか、なんだけれど、個人的には「かなり」だと思ってる。少なくとももう「あぁ、Windows、やだなぁ」とならないケースが格段に増えるはずだから。それにね、これ、「管理がとても楽」だよ。あちこちから都度都度ノリが違うインストーラをあちこちからダウンロードしてきて都度都度統一感のない場所にインストールされてしまう、とか、「置くだけで動く」的な乱暴なやつの管理に困ったり、みたいな、そういう混乱とは無縁だから、これ。なので、是非ともあなたの PC にも Docker を導入して、これを日常にして欲しいと思う。「linux にそれなりに詳しい人向け」なのは少し残念だけれど、逆に言えば「linux にそれなりに詳しい人」にはかなり強くオススメする。

あとワタシは「ワタシという個人とアナタの関係」で今説明してきたけど、「チームでの環境共有」を考えるならば、これはもはや必須のスキルと考えればいい、とも思うよ。これまでそういったことで苦労してきた人にこそオススメるよ。(場合にもよるが「VirtualBox のディスクイメージを配る」みたいな不愉快な管理の必要性も、これによって減っていく気がするよね。)


なお、「linux に詳しい人だけにオススメ」問題に関して、Docker Desktop には「少しだけ」のサポートはある。設定に関してがそうだが、docker ps やらのいくらかは GUI がある。ただこれがあったからといって「linux? ナニソレタベレンノ」層の人々が使えるようになる、わけではないよね。どのみち linux への理解は不可欠。それだけじゃなくてさ、この GUI の完成度が低いみたいなのよね、起動はめちゃくちゃ遅いわ反応は鈍いわ。なので正直 GUI の存在は無視してコマンドラインからだけ使ってたほうが、今のところは幸せ。ここはもっと頑張ってほしいかなぁ。

あと「linux に詳しい」云々だけでなくて「コマンドラインから使うのだけが快適」なのだとすると、結局は MSYS だの cygwin だのってコトになってくるんだよね。剥き身の DOS プロンプトから docker なんか考えたくないよ。特にボリュームマッピング部分さ、MSYS や cygwin なら「"`pwd`"」とか「"$(pwd)"」とか書けるけど、素の DOS だと…? 不快だよね。てわけで、万人にとって幸せな世界にはなかなか近付かないんだよな。これが「sh.exe がないって言われるんです!」問題の解決に即なれば良かったんだけどねぇ、まだ残念ながらそこまでではない…。

2022-05-26 15時追記:
DOS プロンプトだと地獄だけれど、powershell だと少しいいかも。bash のような優秀なコマンドラインエディッティングがないだけでも苦痛だ、のだとしても、DOS プロンプトよりは遥かにマシ:

1 PS C:\Program Files> docker run -v ${PWD}:/wk -w /wk myimg find -ls

空白を含むパスでも ${PWD} を囲まなくていいのはほかのを知ってるとちょっと気色悪いが、でもまぁ「docker を使う」環境としては問題ないね。

2022-06-04追記: 「linuxに詳しい人には」のもう一つの例

上で書いたこれ:

それにね、これ、「管理がとても楽」だよ。あちこちから都度都度ノリが違うインストーラをあちこちからダウンロードしてきて都度都度統一感のない場所にインストールされてしまう、とか、「置くだけで動く」的な乱暴なやつの管理に困ったり、みたいな、そういう混乱とは無縁だから、これ。

これは例えば「このサービスをアンインストールするには、Windows ボタンをクリックして、「アプリケーションの追加と削除」をクリックします、しからば「hogeApp」という名前を探します、それを選択状態にして右クリックすると「アンインストール」メニューが出てくるのでアンインストール出来るのだぜ or DIE!」という「説明の面倒くささ」とそれに従うユーザの「手間」、などの話。そもそもこういう「手順説明」はすぐに廃れると相場が決まっている。Windows XP と Windows 11 では、「アプリケーションの追加と削除」へ至る操作はかなり変わった。

あるいは Windows ならばさらに「PowerShell ならばこれを自動化出来たりもするんだぜぃ、すげーでしょ、かっちょいいー」というネタに繋がるのだが、これこそが「技術格差もしくは意識格差」となり、「出来る人だけが楽出来る」という Windows の闇となる。だいたいにして、技術者でもない普通の PC ユーザが「楽するために PowerShell」なんて発想に至ること自体、普通は絶望的だろう。

Docker についての相当する話はまさに「出来る人だけが楽出来る」そのものでしかないのだけれど、軸足が最初からオートメーションであって GUI ではないという点が大きく違っていて、つまり「PowerShell なら楽出来るんだぜ」という「裏技」ではなく、CLI が「表玄関」なのだ。だから「アンインストール」に相当する操作が「Windows ボタンをクリックして、「アプリケーションの追加と削除」を…」という表玄関に対する「楽な裏技」としてではなく、「docker rm」「docker rmi」というコマンドで行う/行える、というシンプルな事実だけ把握していれば良い、ユーザは。これを「管理が楽だ」と言ったわけだ。

けれども、「まさに「出来る人だけが楽出来る」」は、すなわち「Unix 経験が豊富な人とそうでない人」の格差も出るだろうなと思うのね。たとえば、untagged などで参照元を失ったイメージが出来ることがあるんだけど、これのゴミ掃除をワタシは(例えば)以下のようなコマンドラインでこなす:

ワタシのケースでは、MSYS bsh コマンドライン。
1 [me@host: wk]$ for i in `docker images | grep '^<none' | awk '{print $3}'` ; do docker rmi $i ; done

これは「Unix の知識の賜物」でしかなく、docker そのものの知識はほとんど関係ない。こういう「Unix で当たり前に出来ること/普通になされること」そのまま活用出来るのが docker の UI のシンプルさなわけね。これが例えば「docker サービスの localhost:8080/ にブラウザでアクセスして、~欄に~を入力して…」みたいなクールな設計になってたら、それは「docker 固有のノウハウ」そのものでしかなかったところ。であれば「管理が楽だ」なんて感想にはならない。(実際「Docker Desktop の GUI が唯一の docker イメージ・コンテナの活殺手段である」だったことを想像すると、「おぞましい」という言葉しか出てこない。)

なかなか難しいよね、これほど「Unix 経験の有無・程度」で大きな差が出ちゃうと。正直これはワタシにとっては「天国でしかない」と言うほどに「簡単で使いやすくて快適」なものでしかないけれど、Unix 知識皆無の人には、それこそ「ハードルが高いコマンドライン」という心の障壁となってしまうだろうから。うーん、これはあれか、もはや「Unix を覚えて世界平和!」運動でもした方がいいか?

2022-06-05追記: else true の世界

「linux に詳しい人でも」の話は、Docker にはそんなに多くないと思うのだけれど、やっててちょっと新鮮に感じたこと。

たとえば make とかって、「依存関係を前から積み上げていく場合に、必須ではないステップの失敗を無視出来る構文がある」。マイナス記号とかアットマークとかの特殊記号で、そういう振舞いの制御が出来るわけね。最近使わんから忘れかけてるけれど、こんなよね:

ワタシの sphinx のための makefile
 1 # Minimal makefile for Sphinx documentation
 2 #
 3 
 4 # You can set these variables from the command line.
 5 SPHINXOPTS    =
 6 SPHINXBUILD   = sphinx-build
 7 SPHINXPROJ    = myexamples
 8 SOURCEDIR     = source
 9 BUILDDIR      = build
10 
11 # Put it first so that "make" without argument is like "make help".
12 help:
13 	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14 
15 .PHONY: help Makefile
16 
17 # Catch-all target: route all unknown targets to Sphinx using the new
18 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
19 html: Makefile
20 	-@rm -fr "$(BUILDDIR)"
21 	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

ビルドのためのディレクトリクリーンは失敗してもいい、と。たぶん初回がそうなるよね。

で、Dockerfile は、「RUN」で起動するコマンドの終了コードが、「どんな形であれ非ゼロを返すと docker build が致命エラーとして終了する」。なんだよ当り前じゃないか、と思うかもしれないけれど、これがそうでもない。

例えば、あるファイルの中身をチェックして、その中身のなんらかチェックが「成功したら~をインストールする」としたいとして以下のようなことをすると、build は失敗する。いや、「失敗したら失敗する」。何言ってるかわからない?:

1 # /tmp/log.txt に「hoge」が含まれている場合に限り fubar をインストールする、
2 # 以外は何もしない、という「つもり」
3 RUN grep "hoge" /tmp/log.txt && apt-get install -y fubar

これは「以外は何もしない」という意図である場合は、間違った記述なの。なぜならその場合、このステートメントの終了コードが非ゼロになるから。つまり致命エラーとなって、ビルドが終了する。

つまりこういう場合の「else」を逐一常に全部書かねばならんのよ。これは Dockerfile の外に書いたシェルスクリプトに頼る場合のシェルスクリプトでもそれを踏まえたほうがいい、てことになる。こんなだね。

まぁ大した話ではないんだけれどさ、これはちょっと linux 経験豊富でも最初てこずるかもしれんと思った。

あと蛇足としては、普段ワタシは「true」コマンドではなくて「:」を使うけど、さすがに else に書くコマンドとして「:」は読みにくくなるよな、と思った。これもまぁどうでもいい話。



Related Posts