CUBE SUGAR CONTAINER

技術系のこと書きます。

Re:VIEW で書いた本に記載するコマンドライン操作をテストする方法について考えた

今回は、Re:VIEW で記述している技術書に記載するコマンドライン操作がちゃんと動くか確認する方法について考えてみた話。 このエントリでは、コマンドライン操作を記述しているテキストファイルをシェルスクリプトに変換して実行する方法を提案する。 なお、Re:VIEW にはさほど慣れていないので、もっと良いやり方があれば教えてもらいたい。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ review version
3.2.0
$ sed --version | head -n 1
gsed (GNU sed) 4.7
$ bash --version
GNU bash, version 3.2.57(1)-release (x86_64-apple-darwin18)
Copyright (C) 2007 Free Software Foundation, Inc.

もくじ

技術書にコマンドライン操作を記載する際の課題について

技術書にコマンドライン操作を記載する場合、その内容が実機でちゃんと動作するか確認する必要がある。 それも、動作確認は一回やって終わりではなく、少なくとも書籍が完成するまでは継続的に実施することになる。 なぜなら、記載する内容は随時修正される可能性があるから。 ある箇所を修正したら、別の箇所で整合性が取れなくなっていないかレグレッションテストしなければならない。 また、出版してからも、使っているシステムやツールの更新などによって動作しなくなっていないか確認することが望ましい。

しかし、上記の作業を手作業で実施することには困難が伴う。 内容を修正する度に、本から一行ずつコマンドをコピペしていく作業は考えただけで気が遠くなる。 そのため、なるべく一連の作業は自動化することが望ましい。

また、コマンドライン操作特有の要件として、以下のようなものがある。

  • 実行して得られる出力も記載したい
    • 入力だけを羅列すれば良いわけではない
    • 出力はコマンドによって毎回内容が異なる可能性がある (ping のレイテンシなど)
  • 自動化する工程を環境構築用にも流用したい
    • たくさんのコマンドライン操作を読者に強要することを避けるため

今回は、上記を満たせるように Re:VIEW において自動化する方法を考えてみた。 具体的には、一連のコマンドライン操作をプリプロセッサが解釈する命令を頼りにシェルスクリプトに変換する。 シェルスクリプトの形になっていれば、実機で実行することも比較的容易といえるはず。

サンプルとなる Re:VIEW のプロジェクトを用意する

ここからは、実際に Re:VIEW を使って課題を解決するまでの流れを解説する。 なお、Re:VIEW の環境構築などについては以下のエントリを参照のこと。

blog.amedama.jp

まずはサンプルとなるプロジェクトを用意しよう。 ここでは example-book という名前で Re:VIEW のプロジェクトを作成する。

$ review-init example-book

すると、次のように必要なファイル一式が用意される。

$ cd example-book
$ ls
Gemfile     config.yml  images      sty
Rakefile    doc     layouts     style.css
catalog.yml example-book.re lib

上記の中で example-book.re ファイルがデフォルトで組み込まれるマークアップテキストファイルとなる。

Re:VIEW にコマンドライン操作を記載する方法について

Re:VIEW でコマンドライン操作を示すには、ブロック命令 cmd を用いる。 以下では、実際に cmd 命令を使っていくつかのコマンドライン操作を示すように、マークアップテキストファイルを編集している。

$ cat << 'EOF-' > example-book.re
= Hello, World

//cmd{
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
//}

EOF-

それでは、実際に上記をテキストフォーマットにビルドしてみよう。

$ rake text

ビルドすると、次のような結果が得られた。 コマンドラインの操作が本の中に組み込まれていることがわかる。

$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プロプロセッサ命令を使って外部のファイルを取り込む

先ほどのやり方では、コマンドライン操作を直接マークアップテキストファイルに記述した。 このやり方は単純で分かりやすい反面、記述した内容を変更する際のメンテナンス性が悪い。 そこで、Re:VIEW ではプリプロセッサを使ってファイルのファイルに記述された内容を取り込むことができる。

例えば、次のように commands.txt という名前でコマンドライン操作を記述したテキストファイルを用意する。

$ cat << 'EOF-' > commands.txt
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
EOF-

そして、マークアップテキストファイルでは上記のファイルをプリプロセッサ命令の #@mapfile で読み込むように指定する。

$ cat << 'EOF' > example-book.re
= Hello, World

//cmd{
#@mapfile(commands.txt)
#@end
//}

EOF

準備ができたら rakepreproc タスクを実行する。

$ rake preproc

すると、プリプロセッサ命令の中にファイルの内容が取り込まれる。

$ cat example-book.re 
= Hello, World

//cmd{
#@mapfile(commands.txt)
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@end
//}

もちろん、ビルドした後の成果物にはプリプロセッサ命令は残らない。

$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
$ cat << 'EOF' > greet.txt
Hello, World
EOF
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プロプロセッサ命令を使って外部のファイルの一部を取り込む

先ほどのやり方も、直接マークアップテキストファイルにコマンドライン操作をベタ書きするよりはだいぶメンテナンス性がよくなる。 ただ、本の中で取り扱うコマンドライン操作が多いと、大量のファイルを扱うことになってこれまたメンテナンス性が悪い。 そこで、Re:VIEW にはファイルの一部だけを取り込むプリプロセッサ命令も用意されている。

例えば、それぞれのコマンドライン操作をプリプロセッサ命令を使って別々に取り出すことを考えてみよう。 この場合、次のように外部のファイルにもプリプロセッサ命令を記述する。 具体的には #@range_begin(<id>) ~ #@range_end(<id>) を使って取り出す範囲を指定する。

$ cat << 'EOF-' > commands.txt

#@range_begin(whole)

#@range_begin(echo)
$ echo 'Hello, World'
Hello, World
#@range_end(echo)

#@range_begin(cat)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(cat)

#@range_begin(ping)
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

コマンドライン操作を記述したファイルが用意できたら、それをマークアップテキストファイルに取り込む。 取り込むときは #@maprange(<filepath>, <id>) ~ #@end を使って取り出す内容を指定する。

$ cat << 'EOF' > example-book.re
= Hello, World

//cmd{
#@maprange(commands.txt,echo)
#@end
//}

//cmd{
#@maprange(commands.txt,cat)
#@end
//}

//cmd{
#@maprange(commands.txt,ping)
#@end
//}

//cmd{
#@maprange(commands.txt,whole)
#@end
//}

EOF

プリプロセスを実行する。

$ rake preproc

すると、次のようにファイルの一部分が取り込まれる。

$ cat example-book.re           
= Hello, World

//cmd{
#@maprange(commands.txt,echo)
$ echo 'Hello, World'
Hello, World
#@end
//}

//cmd{
#@maprange(commands.txt,cat)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@end
//}

//cmd{
#@maprange(commands.txt,ping)
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@end
//}

//cmd{
#@maprange(commands.txt,whole)

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms

#@end
//}

ビルドした結果は次の通り。

$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ cat << 'EOF' > greet.txt
Hello, World
EOF
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

◆→開始:コマンド←◆

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

プリプロセッサ命令を頼りに外部のファイルをシェルスクリプトに変換する

さて、これでようやく前提知識の共有が終わったので本題に入る。 基本的なコンセプトとしては、コマンドライン操作を記述したファイルの内容をシェルスクリプトに変換したい。 シェルスクリプトに変換できれば、あとはそれを実機上で実行するだけで済む。

しかし、コマンドライン操作を記述したファイルはそのままだと使えない。 例えば先ほどのファイルの内容を見るとコマンドラインの操作を実行して得られた標準 (エラー) 出力の内容も含まれている。 また、ヒアドキュメントや複数行のコマンドライン操作も存在して話をややこしくしている。

そこで、実際には使わないプリプロセッサ命令を使って「ここがコマンドラインで実行したい箇所ですよ」と印をつけることにした。 論よりソースということで、以下のコマンドで生成されるファイルを見てほしい。 この中には exec という ID を使って、実際に実行したいコマンドライン操作の前後にプリプロセッサ命令が記述されている。

$ cat << 'EOF-' > commands.txt

#@range_begin(whole)

#@range_begin(echo)
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
Hello, World
#@range_end(echo)

#@range_begin(cat)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_end(cat)

#@range_begin(ping)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

上記のように、実際には使われないプリプロセッサ命令が含まれていたとしても、無視されるだけで成果物のビルドには影響を与えない。

$ rake preproc
$ rake text
$ cat book-text/example-book.txt 
■H1■第1章 Hello, World

◆→開始:コマンド←◆
$ echo 'Hello, World'
Hello, World
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ cat << 'EOF' > greet.txt
Hello, World
EOF
◆→終了:コマンド←◆

◆→開始:コマンド←◆
$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

◆→開始:コマンド←◆

$ echo 'Hello, World'
Hello, World

$ cat << 'EOF' > greet.txt
Hello, World
EOF

$ ping \
    -c 3 \
    8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
◆→終了:コマンド←◆

しかし、命令を頼りにコマンドライン操作で実行したい箇所を次のように取り出すことはできる。

$ sed -n '/^#@range_begin(exec)$/,/^#@range_end(exec)$/p' commands.txt
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)

なお、今回は GNU sed を使ってシェルスクリプトに変換してみた。 とはいえ、別に何を使っても良いと思う。

$ brew install gnu-sed
$ alias sed=gsed

こんな感じでシェルスクリプトに変換できる。 set -e しておくと、コマンドライン操作で非ゼロ (エラー) が返ったときに止まるので異常に気づきやすくなる。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  '
#!/usr/bin/env bash
set -Ceuxo pipefail
echo 'Hello, World'
cat << 'EOF' > greet.txt
Hello, World
EOF
ping \
    -c 3 \
    8.8.8.8

変換したスクリプトを、ファイルとして書き出してみよう。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  ' > commands.sh

できたシェルスクリプトを実行してみる。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ cat
+ ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=9.517 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=15.521 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=9.736 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 9.517/11.591/15.521/2.780 ms

ちゃんと実行できた。

今回のサンプルだと、スクリプトに set -C しているので、リダイレクトでファイルが上書きできないようにしてある。 なので、もう一度実行してみたときコマンドライン操作の中でファイルを上書きしてしまっているということにも気づける。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ cat
commands.sh: line 4: greet.txt: cannot overwrite existing file

もし、一時的にチェックを無効にして回避するならコマンドライン操作を記述したファイルに set +C を追加する。

$ cat << 'EOF-' > commands.txt           

#@range_begin(whole)

#@range_begin(echo)
#@range_begin(exec)
$ echo 'Hello, World'
#@range_end(exec)
Hello, World
#@range_end(echo)

#@range_begin(exec)
$ set +C  # temporarily disable overwrite check
#@range_end(exec)

#@range_begin(cat)
#@range_begin(exec)
$ cat << 'EOF' > greet.txt
Hello, World
EOF
#@range_end(exec)
#@range_end(cat)

#@range_begin(exec)
$ set -C  # enable overwrite check
#@range_end(exec)

#@range_begin(ping)
#@range_begin(exec)
$ ping \
    -c 3 \
    8.8.8.8
#@range_end(exec)
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=51 time=2.133 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=51 time=2.276 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=51 time=2.354 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 2.133/2.254/2.354/0.092 ms
#@range_end(ping)

#@range_end(whole)

EOF-

シェルスクリプトを作り直す。

$ cat commands.txt | sed -n '
    /^#@range_begin(exec)$/,/^#@range_end(exec)$/p
  ' | sed -e '
    1i#!/usr/bin/env bash
    1iset -Ceuxo pipefail
    /^#@range_.*$/d
    s/^$ //g
  ' > commands.sh

実行すると、今度は失敗しない。 スクリプトに冪等性が得られた。

$ bash commands.sh
+ echo 'Hello, World'
Hello, World
+ set +C
+ cat
+ set -C
+ ping -c 3 8.8.8.8
PING 8.8.8.8 (8.8.8.8): 56 data bytes
64 bytes from 8.8.8.8: icmp_seq=0 ttl=56 time=9.741 ms
64 bytes from 8.8.8.8: icmp_seq=1 ttl=56 time=9.680 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=56 time=10.080 ms

--- 8.8.8.8 ping statistics ---
3 packets transmitted, 3 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 9.680/9.834/10.080/0.176 ms

このやり方ならコマンドラインの操作とテストする内容が二重管理にもならないところが良いかなーと思っている。 ただ、コマンドライン操作を記述するファイルがちょっとごちゃごちゃするね。 あと、解決できていない課題もある。 例えば、ユーザの入力を伴うインタラクティブな操作があるときどうするかとか。 あと、複数のターミナルにまたがった操作をどうするか、とか。

いじょう。

Docker のマルチステージビルドで自前でビルドした Wheel を含むイメージを作る

今回は Docker のマルチステージビルドを使って Wheel が提供されていない Python パッケージを含む Docker イメージを作ってみる。 これだけだと、なんのこっちゃという感じなので、以下で前提から説明しておく。

まず、今の Python のパッケージングにはソースコード配布物 (sdist) と Wheel という二つのフォーマットが主に使われている。 ソースコード配布物は、文字通りソースコードをそのままパッケージングしたもの。 ソースコード配布物では、パッケージの中に Python/C API などで書かれた拡張モジュールがあっても、ソースコードの状態で含まれる。 それに対して Wheel は、拡張モジュールが含まれる場合にはビルドされたバイナリの状態で提供される。 そして、現行の pip はソースコード配布物をインストールするとき、一旦 Wheel にビルドした上でインストールするように振る舞う。 このソースコード配布物を Wheel にビルドするタイミングでは、ランタイムとは別にビルドで必要なツール類の一式が必要になる。

ここで、ソースコード配布物として提供されている Python パッケージを Docker イメージに含めることを考えてみよう。 もし、対象のパッケージが拡張モジュールを含む場合、ビルドに必要なツール類の一式が Docker イメージに必要になってしまう。 Docker イメージは、なるべく不要なものを入れない方が一般的に望ましいとされている。

そこで、上記の問題を解決するのに Docker のマルチステージビルドという機能が使える。 マルチステージビルドでは、複数のイメージを連携させて一つのイメージが作れる。 例えばパッケージのビルドをするステージと、それを組み込むステージを分ければ、後者にはビルドに必要なツールが必要なくなるというわけ。

使った環境は次の通り。

$ sw_vers     
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ docker version                              
Client: Docker Engine - Community
 Version:           19.03.2
 API version:       1.40
 Go version:        go1.12.8
 Git commit:        6a30dfc
 Built:             Thu Aug 29 05:26:49 2019
 OS/Arch:           darwin/amd64
 Experimental:      false

Server: Docker Engine - Community
 Engine:
  Version:          19.03.2
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.12.8
  Git commit:       6a30dfc
  Built:            Thu Aug 29 05:32:21 2019
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          v1.2.6
  GitCommit:        894b81a4b802e4eb2a91d1ce216b8817763c29fb
 runc:
  Version:          1.0.0-rc8
  GitCommit:        425e105d5a03fabd737a126ad93d62a9eeede87f
 docker-init:
  Version:          0.18.0
  GitCommit:        fec3683

Wheel のビルドについて

実際にマルチステージビルドを試す前に、Wheel に関するオペレーションについて解説しておく。 まず、Python のパッケージ管理ツールの pip は、デフォルトで PyPI というパッケージサイトからパッケージを探してくる。

pypi.org

インストールするパッケージに、使っているプラットフォーム向けの Wheel があれば、それがインストールされる。 もし、ソースコード配布物しか提供されていないときは、それを元に Wheel をビルドした上でインストールされる。 ただし、Wheel が提供されている場合であってもオプションを指定することで、あえてソースコード配布物から自前でビルドすることもできる。

実際に試してみることにしよう。 Docker を使って Ubuntu 18.04 LTS のイメージを起動する。

$ docker run --rm -it ubuntu:18.04 /bin/bash

パッケージシステムの APT を使って Python 3 の pip をインストールする。

# apt update
# apt -y install python3-pip

試しに LightGBM というパッケージをソースコード配布物からビルドしてみよう。 pip は wheel サブコマンドを使うことでインストールに必要な Wheel パッケージが取得できる。 その際、--no-binary オプションを指定すると、ビルド済みの Wheel ではなく自分でソースコード配布物からビルドすることになる。 ちなみに、このオプションは pip install サブコマンドでも有効なので覚えておくと良いかも。

# pip3 wheel --no-binary lightgbm lightgbm

なお、上記のコマンド実行は失敗する。 なぜならビルドに必要なパッケージ類が入っていないため。

# pip3 wheel --no-binary lightgbm lightgbm
...
  Exception: Please install CMake and all required dependencies first
  The full version of error log was saved into /root/LightGBM_compilation.log
  
  ----------------------------------------
  Failed building wheel for lightgbm
  Running setup.py clean for lightgbm
Failed to build lightgbm
ERROR: Failed to build one or more wheels

Ubuntu で LightGBM をビルドするのに必要な cmake と gcc をインストールしよう。

# apt -y install cmake gcc

もう一度、先ほどのコマンドを実行すると、今度はエラーにならず上手くいく。

# pip3 wheel --no-binary lightgbm lightgbm

これで、カレントワーキングディレクトリにインストールに必要な Wheel 一式ができあがる。 この中の LightGBM は自分でソースコード配布物からビルドしたもの。

# ls *.whl
joblib-0.14.0-py2.py3-none-any.whl
lightgbm-2.3.0-py3-none-any.whl
numpy-1.17.3-cp36-cp36m-manylinux1_x86_64.whl
scikit_learn-0.21.3-cp36-cp36m-manylinux1_x86_64.whl
scipy-1.3.1-cp36-cp36m-manylinux1_x86_64.whl

ビルドした Wheel をインストールしてみよう。

# pip3 install lightgbm-2.3.0-py3-none-any.whl

これでインストールしたパッケージが使えるようになる。

# python3 -c "import lightgbm"

シングルステージで Docker イメージをビルドする

続いては、マルチステージビルドを試す前にシングルステージの場合を見ておこう。 これは、ビルドに必要なツールも一緒に Docker イメージに含まれてしまうパターン。

以下のように Dockerfile を用意する。 ビルドに必要なパッケージをインストールした上で、LightGBM をインストールする構成になっている。

FROM ubuntu:18.04

# Use fastest mirror
RUN sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

# Install prerequisite apt packages to build
RUN apt update \
 && apt -yq dist-upgrade \
 && apt -yq install \
      python3-pip \
      cmake \
      gcc \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Install Python package from source code distribution
RUN pip3 install --no-binary lightgbm lightgbm

上記を元に Docker イメージをビルドする。

$ docker build -t example/singlestage .

ビルドしたイメージを元にコンテナを起動してみよう。

$ docker run --rm -it example/singlestage /bin/bash

このイメージでは、ちゃんとインストールした LightGBM がインポートできる。

# python3 -c "import lightgbm as lgb"

反面、ビルドに使った cmake や gcc もイメージに含まれてしまっている。

# cmake --version
cmake version 3.10.2

CMake suite maintained and supported by Kitware (kitware.com/cmake).

マルチステージで Docker イメージをビルドする

それでは、今回の本題となるマルチステージビルドを試してみよう。

マルチステージビルドでは FROM 命令が複数登場する。 それぞれの FROM 命令がステージとなる Docker イメージを表しており、AS を使って名前をつけられる。 名前をつけた Docker イメージからは COPY 命令を使ってファイルをコピーできる。

以下の Dockerfile は build-stageusing-stage という二つのステージに分かれている。 まず、build-stage では LightGBM の Wheel をビルドしている。 そして、using-stage でビルドした Wheel をインストールしている。

FROM ubuntu:18.04 AS build-stage

# Use fastest mirror
RUN sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

# Install prerequisite apt packages to build
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq \
      python3-pip \
      cmake \
      gcc \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Build wheel
RUN pip3 wheel -w /tmp/wheelhouse --no-binary lightgbm lightgbm

FROM ubuntu:18.04 AS using-stage

# Use fastest mirror
RUN sed -i.bak -e 's%http://[^ ]\+%mirror://mirrors.ubuntu.com/mirrors.txt%g' /etc/apt/sources.list

# Install prerequisite apt packages to build
RUN apt update \
 && apt -yq dist-upgrade \
 && apt install -yq \
      python3-pip \
 && apt clean \
 && rm -rf /var/lib/apt/lists/*

# Copy binaries from building stage
COPY --from=build-stage /tmp/wheelhouse /tmp/wheelhouse

# Install binary package
RUN pip3 install /tmp/wheelhouse/lightgbm-*.whl

それでは、上記の Dockerfile をビルドしてみよう。

$ docker build -t example/multistage .

ビルドしたイメージからコンテナを起動する。

$ docker run --rm -it example/multistage /bin/bash

このイメージでは、ちゃんと LightGBM がインポートして使える。

# python3 -c "import lightgbm as lgb"

そして、イメージにはビルド用のツールも含まれていない。

# cmake
bash: cmake: command not found

いじょう。

macOS (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の macOS でシステムコールをアセンブラから呼んでみる。 ただし、前回のエントリで FreeBSD についても同じようにシステムコールをアセンブラから呼んだ。 macOS は BSD を先祖に持つ XNU カーネルで動いている。 そのため、大筋は FreeBSD の場合と違いはない。 ようするに System V x86/x86-64 ABI の規約にもとづいて呼び出してやればいいだけだ。

blog.amedama.jp

とはいえ、FreeBSD と全く違いがないわけではない。 なので、それについて見ていくことにしよう。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ uname -sr
Darwin 18.7.0
$ nasm -v
NASM version 2.14.02 compiled on Dec 27 2018
$ ld -v
@(#)PROGRAM:ld  PROJECT:ld64-450.3
BUILD 18:16:53 Apr  5 2019
configured to support archs: armv6 armv7 armv7s arm64 arm64e arm64_32 i386 x86_64 x86_64h armv6m armv7k armv7m armv7em
LTO support using: LLVM version 10.0.1, (clang-1001.0.46.4) (static support for 22, runtime is 22)
TAPI support using: Apple TAPI version 10.0.1 (tapi-1001.0.4.1)

もくじ

下準備

最初に、アセンブラの実装として NASM (Netwide Assembler) を Homebrew でインストールしておく。

$ brew install nasm

GAS (GNU Assembler) を使っても問題ないけど、INTEL 記法と AT&T 記法でちょっと手直しが必要になる。

x86 のアセンブラで exit(2) を呼び出すだけのプログラム

先のエントリと同じように、まずは終了するだけのプログラムを書いてみよう。 macOS にも、他の Unix 系 OS と同じようにプログラムを終了するためのシステムコールとして exit(2) がある。 macOS のカーネルである XNU のソースコードは、リリースからやや遅れはあるもののオープンソースとして公開されている。 そのため、次のようにシステムコールの識別子を確認できる。

github.com

上記から、exit(1) に対応する識別子が 1 であることがわかる。 ただし、実は上記の値をそのまま使ってもエラーになってしまう。

実際には、上記の識別子に 0x2000000 を加えなきゃいけない。 その理由は以下のファイルを見るとわかる。

github.com

どうやら macOS のカーネルである XNU が Mach と BSD から生まれた影響で、何に由来するシステムコールなのか指定が必要らしい。 その指定が、BSD であれば 2 を 24 ビット左シフトした値、ということみたいだ。

前置きが長くなったので、そろそろサンプルコードを見ることにしよう。 以下が exit(2) を呼び出すだけのサンプルコードになる。 FreeBSD の x86 版と異なるのは、前述したマスク値がひとつ。 もう一つがエントリポイントのシンボルが _start ではなく _main になっている。 まあ、これはデフォルト値の話なので、リンカで別途上書きしてしまっても良いはず。

global _main

section .text

_syscall:
  int 0x80
  ret

_main:
  ; exit system call (macOS / x86)
  push dword 0
  mov eax, 0x2000001  ; 2 << 24 + 1
  call _syscall
  add esp, byte 4  ; restore ESP
  ret  ; don't reach here

x86 (32bit) 向けにビルドする

コードが用意できたので、x86 (32bit) 向けのバイナリとして Mach-O 32bit フォーマットにアセンブルする。

$ nasm -f macho32 nop.asm
$ file nop.o                       
nop.o: Mach-O object i386

動作に必要な最低バージョンの指定を入れつつ実行可能オブジェクトファイルにリンクする。

$ ld -macosx_version_min 10.14 -lsystem -o nop nop.o
$ file nop  
nop: Mach-O executable i386

できたファイルを実行してみよう。

$ ./nop
$ echo $?
0

ちゃんと返り値もゼロが設定されている。 うまく動いているようだ。

ちなみに、今回使っている macOS のバージョンが Mojave (10.14) だからこそ上記は動く。 なぜなら、次のバージョン Catalina (10.15) からは 32bit アプリケーションのサポートがなくなってしまった。 なので、おそらくこのマシンも OS をアップデートしたら上記のバイナリが動かなくなるはずだ。

x86-64 (64bit) 向けにビルドする

じゃあ 64bit 向けにビルドすれば良いのか、というとそうもいかない。 なぜなら、先のエントリで取り扱った通り System V ABI は x86 と x86-64 で引数の渡し方が異なっている。 そのため、先ほどのサンプルコードは x86-64 (64bit) 向けのアプリケーションとしては動作しない。

やろうと思えば、一応は Mach-O 64bit フォーマットとしてアセンブリはできる。

$ nasm -f macho64 nop.asm
$ file nop.o
nop.o: Mach-O 64-bit object x86_64
$ ld -macosx_version_min 10.14 -lsystem -o nop nop.o
$ file nop  
nop: Mach-O 64-bit executable x86_64

しかし、実行しようとするとエラーになってしまう。

$ ./nop 
zsh: illegal hardware instruction  ./nop

x86-64 のアセンブラで exit(2) を呼び出すだけのプログラム

64 bit 向けのアプリケーションを作るためには、以下のように System V v86-64 ABI に準拠した呼び出しが必要になる。

global _main

section .text

_main:
  ; exit system call (macOS / x86-64)
  mov rax, 0x2000001
  mov rdi, 0
  syscall

上記を Mach-O 64 bit フォーマットとしてアセンブリする。

$ nasm -f macho64 nop.asm
$ file nop.o
nop.o: Mach-O 64-bit object x86_64

そして、リンクする。

$ ld -macosx_version_min 10.14 -lsystem -o nop nop.o
$ file nop  
nop: Mach-O 64-bit executable x86_64

できたファイルを実行してみよう。 今度はエラーにならない。

$ ./nop
$ echo $?
0

返り値についても、ちゃんとゼロが設定されている。

x86-64 のアセンブラでハローワールド

一応、ハローワールドについても以下にサンプルコードを示す。

global _main

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_main:
  ; write system call (macOS / x86-64)
  mov rax, 0x2000004
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (macOS / x86-64)
  mov rax, 0x2000001
  mov rdi, 0
  syscall

ビルドする。

$ nasm -f macho64 greet.asm
$ ld -macosx_version_min 10.14 -lsystem -o greet greet.o

実行する。

$ ./greet
Hello, World!
$ echo $?
0

うまく動いているようだ。

いじょう。

FreeBSD (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の FreeBSD でアセンブラからシステムコールを呼んでみる。 システムコールは、OS (ディストリビューション) のコアとなるカーネルがユーザ空間のプログラムに向けて提供しているインターフェースのこと。 ファイルの入出力など、ユーザープログラムは大抵のことはシステムコールを通じてカーネルにお願いしてやってもらうことになる。 ただ、普段は色々な API がラップして実体が見えにくいので、今回はアセンブラから直接呼んでみることにした。

使った環境は次の通り。

$ uname -sr
FreeBSD 12.0-RELEASE
$ nasm -v
NASM version 2.14.02 compiled on Aug 22 2019
$ ld -v
LLD 6.0.1 (FreeBSD 335540-1200005) (compatible with GNU linkers)

TL; DR

分かったことは次の通り。

  • 商用 UNIX の流れをくむ Unix 系 OS のシステムコールは ABI (Application Binary Interface) が x86 と x86-64 で異なる
    • x86 (32bit) ではスタックを使って引数を渡す
    • x86-64 (64bit) ではレジスタを使って引数を渡す

ちなみに Linux では、どちらもレジスタ経由でシステムコールの引数を渡していた点が異なる。

blog.amedama.jp

もくじ

下準備

あらかじめ、下準備として NASM (Netwide Assembler) をインストールしておく。

$ sudo pkg install nasm

NASM / x86 で exit(2) を呼び出すだけのプログラム

まずは、ハローワールドですらなく「終了するだけ」のプログラムを書いてみる。 FreeBSD には、プログラムを終了するためのシステムコールとして exit(2) がある。

最初は x86 (32bit) アーキテクチャのアセンブラを用いる。 FreeBSD で x86 のシステムコールに関するドキュメントとしては以下があった。

www.freebsd.org

以下が、上記のドキュメントにもとづいて x86 で exit(2) を呼び出すだけのアセンブラのソースコードとなる。 FreeBSD の x86 アセンブラでは、割り込み番号 0x80int 命令を発行するだけの関数経由でシステムコールを呼ぶことを想定しているらしい。 また、Linux の x86 のシステムコール呼び出しとは異なり、引数はシステムコールの識別子を除いてスタック経由で受け渡しされる。

global _start

section .text

_syscall:  ; FreeBSD expects to call system call via int 0x80 only function
  int 0x80
  ret

_start:
  ; exit system call (FreeBSD / x86)
  push dword 0  ; return code
  mov eax, 1  ; system call id (exit)
  call _syscall  ; call system call
  add esp, byte 4  ; restore ESP (DWORD x1)
  ret  ; don't reach here

x86 (32bit) 向けにビルドする

それでは、上記を実際にビルドして実行してみよう。

まずは NASM を使って 32bit の ELF (Executable and Linkable Format) としてアセンブルする。

$ nasm -f elf32 nop.asm
$ file nop.o
nop.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

上記をリンカを使って実行可能オブジェクトファイルにする。

$ ld -m elf_i386_fbsd -o nop nop.o
$ file nop
nop: ELF 32-bit LSB executable, Intel 80386, version 1 (FreeBSD), statically linked, not stripped

できたファイルを実行してみよう。

$ ./nop

何も表示されないけど、エラーにならずに実行できていることがうまくいっていることを示している。

プログラムの返り値についてもスタック経由で指定したゼロになっている。

$ echo $?
0

x86-64 (64bit) 向けにビルドする

では、次に先ほどのサンプルコードを x86-64 (64bit) 向けのアプリケーションとしてビルドしてみよう。 x86-64 は機械語のレベルでは x86 と後方互換性があるけど、どういった結果になるだろうか。

まずは 64bit の ELF としてアセンブルする。

$ nasm -f elf64 nop.asm

実行可能オブジェクトファイルとしてリンクする。

$ ld -m elf_amd64_fbsd -o nop nop.o

実行してみよう。

$ ./nop

特にエラーにならず実行できているので、うまくいっていそうだけど…?

返り値を見ると非ゼロの値が入っている。

$ echo $?
144

どうやら返り値の受け渡しの部分がうまくいっていないようだ。

NASM / x86-64 で exit(2) を呼び出すだけのプログラム

実は FreeBSD というか商用 UNIX の流れをくんだ Unix 系 OS は x86 と x86-64 でシステムコールの ABI が異なっている。 具体的には、x86 ではスタック経由で引数を渡していたのが x86-64 ではレジスタ経由で渡すことになった。 この問題については以下が分かりやすい。

stackoverflow.com

また、詳しくは以下のリポジトリでメンテナンスされている PDF についても参照のこと。

github.com

上記にもとづいて 64 ビットのアプリケーションとしてビルドできるサンプルコードを以下に示す。 これはシステムコールの識別子は異なるものの、前述した GNU/Linux の記事に出てきたサンプルコードとほとんど同じもの。 x86-64 においては Linux と商用 UNIX の子孫たちでシステムコールの呼び出し方は同じになっている。

global _start

section .text

_start:
  ; exit system call (FreeBSD / x86-64)
  mov rax, 1  ; system call id
  mov rdi, 0  ; return value
  syscall  ; call system call

実際に上記を 64 bit の実行可能オブジェクトファイルとしてビルドしてみよう。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ ld -m elf_amd64_fbsd -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD), statically linked, not stripped

できたファイルを実行する。

$ ./nop
$ echo $?
0

今度は返り値についてもゼロが設定されている。

ちなみに Linux では同じシステムコールでも x86 t x86-64 で識別子が異なったけど、FreeBSD の場合は変わらないらしい。

github.com

write(2) を追加したハローワールドでも比較してみる

呼び出すシステムコールが exit(2) だけだと味気ないので write(2) も追加してハローワールドも見ておこう。

x86 版 ABI のハローワールド

まずは x86 版 ABI を使ったハローワールドが以下の通り。

global _start

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_syscall:
  int 0x80
  ret

_start:
  ; write system call (FreeBSD / x86)
  push dword msg_len
  push dword msg
  push dword 1
  mov eax, 4
  call _syscall
  add esp, byte 12  ; restore ESP (DWORD x3)

  ; exit system call (FreeBSD / x86)
  push dword 0
  mov eax, 1
  call _syscall
  add esp, byte 4  ; restore ESP (DWORD x1)
  ret  ; don't reach here

x86 (32bit) 向けの実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf32 greet.asm
$ ld -m elf_i386_fbsd -o greet greet.o

できたファイルを実行する。

$ ./greet
Hello, World!
$ echo $?
0

ちゃんと動作しているようだ。

x86-64 版 ABI のハローワールド

続いて以下が x86-64 版 ABI のハローワールドになる。

global _start

section .data
  msg db 'Hello, World!', 0x0A
  msg_len equ $ - msg

section .text

_start:
  ; write system call (FreeBSD / x86-64)
  mov rax, 4
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (FreeBSD / x86-64)
  mov rax, 1
  mov rdi, 0
  syscall

x86-64 (64bit) 向けの実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf64 greet.asm
$ ld -m elf_amd64_fbsd -o greet greet.o

できたファイルを実行する。

$ ./greet
Hello, World!
$ echo $?
0

うまくいっている。

FreeBSD の Linux バイナリ互換機能

ちなみに FreeBSD には Linux の ABI を使ったアプリケーションを実行するためのエミュレーション機能があるらしい。 実際に試してみよう。

エミュレーション機能はカーネルモジュールとして実行されているため linux.ko を読み込む。

$ sudo kldload linux.ko
$ kldstat | grep linux
 7    1 0xffffffff8284c000    39960 linux.ko
 8    1 0xffffffff82886000     2e28 linux_common.ko

Linux で動作する x86 版 ABI のサンプルコードを以下のように用意する。 レジスタで引数を渡しているので System V の x86 版 ABI とは異なる。

global _start

section .text

_start:
  mov eax, 1
  mov ebx, 0
  int 0x80

上記を 32 版の実行可能オブジェクトファイルとしてビルドする。

$ nasm -f elf32 nop.asm
$ ld -m elf_i386 -o nop nop.o

このままでは実行できない。

$ ./nop 
ELF binary type "0" not known.
-bash: ./nop: cannot execute binary file: Exec format error

そこで、brandelf コマンドを使って Linux 互換のバイナリとしてマークする。

$ brandelf -t Linux nop

実行してみる。

$ ./nop
$ echo $?
0

今度はちゃんと動作した。

GNU/Linux (x86/x86-64) のシステムコールをアセンブラから呼んでみる

今回は、表題の通り x86/x86-64 の GNU/Linux でシステムコールをアセンブラから呼んでみる。 システムコールは、OS (ディストリビューション) のコアとなるカーネルがユーザ空間のプログラムに向けて提供しているインターフェースのこと。

なお、アセンブラの実装に関しては以下の二つを試した。

  • NASM (Netwide Assembler)
  • GAS (GNU Assembler)

アセンブラには INTEL 記法と AT&T 記法という二つのシンタックスがある。 NASM はデフォルトで INTEL 記法を、GAS はデフォルトで AT&T 記法を使うことになる。

使った環境は次の通り。

$ uname -sr
Linux 4.15.0-65-generic
$ nasm -v
NASM version 2.13.02
$ as -v
GNU assembler version 2.30 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.30
$ ld -v
GNU ld (GNU Binutils for Ubuntu) 2.30
$ gcc --version
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=18.04
DISTRIB_CODENAME=bionic
DISTRIB_DESCRIPTION="Ubuntu 18.04.3 LTS"

もくじ

下準備

あらかじめ、使うパッケージをインストールしておく。

$ sudo apt -y install nasm binutils gcc

NASM / x86 で exit(2) を呼び出すだけのプログラム

まずは、ハローワールドですらなく「終了するだけ」のプログラムから書いてみる。 GNU/Linux には、プログラムを終了するためのシステムコールとして exit(2) がある。

以下のサンプルコードは NASM (Netwide Assembler) 向けの x86 アセンブラで Linux の exit(2) を呼び出すだけのプログラムとなっている。 ちょっとゴチャゴチャしてるけど ; はコメントで、決まりきった内容も含むので実質的には 3 行しかない。

global _start

section .text

_start:
  ; exit system call (Linux / x86)
  mov eax, 1  ; system call id
  mov ebx, 0  ; return code
  int 0x80  ; soft interrupt

まず、global _start_start というシンボルを外部に公開することを示している。 そして、シンボル _start: 以下が実際の処理になっている。 section .text については、プログラムのテキスト領域がここからありますよ、という指示になっている。

そして、Linux / x86 (32bit) の場合、システムコールを呼ぶには以下の手順が必要になる。

  1. eax レジスタに呼びたいシステムコールの識別子を代入する
  2. 必要に応じて各レジスタにシステムコールの引数を代入する
  3. 割り込み番号 0x80 を指定して INT 命令を実行する

先ほどのサンプルコードの _start では、各行が上記の (1) ~ (3) に対応している。

  1. eax レジスタに exit(2) システムコールに対応する識別子の 1 を代入している
  2. exit(2) システムコールの第一引数として、プログラムの返り値 0ebx レジスタに代入している
  3. int 0x80 を呼ぶことで、ソフトウェア割り込み経由でシステムコールを呼び出している

x86 (32bit) 向けにビルドする

それでは、実際にサンプルコードをビルドしてみる。 今どきそうそう必要になることはないだろうけど、まずは x86 (32bit) 向けのバイナリにしてみよう。

nasm コマンドを使ってソースコードをコンパイル…じゃなくてアセンブルする。 出力フォーマットには 32bit 版の ELF (Executable and Linkable Format) を指定する。

$ nasm -f elf32 nop.asm

これで、32bit 版の再配置可能オブジェクトファイルができた。

$ file nop.o 
nop.o: ELF 32-bit LSB relocatable, Intel 80386, version 1 (SYSV), not stripped

リンカを使って上記を実行可能オブジェクトファイルにする。

$ ld -m elf_i386 -o nop nop.o
$ file nop
nop: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), statically linked, not stripped

ちなみに、エントリポイントとなるシンボルを指定したいときは -e オプションを使えば良い。 デフォルトでは _start が使われるので、先ほどのサンプルコードを使っている限り特に指定する必要はない。

できあがった実行可能オブジェクトファイルを実際に実行してみよう。

$ ./nop

何も起こらないけど、そもそも何もエラーにならずプログラムが終了している、という点が上手くいっていることを示している。

プログラムの返り値を確認しても非ゼロなので正常終了している。

$ echo $?
0

x86-64 (64bit) 向けにビルドする

x86-64 (64bit) アーキテクチャは x86 (32bit) アーキテクチャと後方互換性がある。 なので、先ほどの x86 向けのアセンブラは x86-64 向けにもビルドできる。

実際に、x86-64 向けにビルドしてみよう。 今度は 64bit 版の ELF としてアセンブルする。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

そして、リンカを使って実行可能オブジェクトファイルにリンクする。

$ ld -m elf_x86_64 -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

できたファイルを実行してみよう。

$ ./nop

こちらも、エラーにならずちゃんとプログラムが終了している。

返り値についても非ゼロなので正常終了しているようだ。

$ echo $?
0

NASM / x86-64 で exit(2) を呼び出すだけのプログラム

次こそはハローワールド…の前に、x86-64 アセンブラを扱っておく。 内容は先ほどと同じ exit(2) を呼び出すだけのプログラム。 純粋にシステムコールの呼び出し方の違いが分かりやすいはず。

以下が NASM 向けの x86-64 アセンブラで Linux の exit(2) を呼び出すだけのサンプルコード。 変わっているのは _start の中だけ。

global _start

section .text

_start:
  ; exit system call (Linux / x86-64)
  mov rax, 60  ; system call id
  mov rdi, 0  ; return code
  syscall  ; system call op code

x86 アセンブラとの違いを見ていくと、まず最初の 2 行で使うレジスタが異なっていることがわかる。 これは、x86-64 でレジスタが 64bit に拡張されたことに伴って名前が変わっている。

また、exit(2) システムコールを表す識別子も 1 から 60 に変わっていることがわかる。 この識別子はどこにあるの、という話なんだけど以下のように探せる。

$ locate syscalls | grep 32.h
/usr/src/linux-headers-4.15.0-62/arch/sh/include/asm/syscalls_32.h
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/.syscalls_32.h.cmd
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_32.h
$ locate syscalls | grep 64.h
/usr/src/linux-headers-4.15.0-62/arch/sh/include/asm/syscalls_64.h
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/.syscalls_64.h.cmd
/usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_64.h
$ grep sys_exit /usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_32.h
__SYSCALL_I386(1, sys_exit, )
__SYSCALL_I386(252, sys_exit_group, )
$ grep sys_exit /usr/src/linux-headers-4.15.0-62-generic/arch/x86/include/generated/asm/syscalls_64.h
__SYSCALL_64(60, sys_exit, )
__SYSCALL_64(231, sys_exit_group, )

ソースコードにあるテーブルを見ても良いかも。

linux/syscall_32.tbl at v4.15 · torvalds/linux · GitHub

linux/syscall_64.tbl at v4.15 · torvalds/linux · GitHub

そして 3 行目が決定的な違いで、x86-64 アセンブラでは syscall 命令という専用のオペコードが用意されている。

それでは、上記をビルドして実行してみよう。 x86-64 のアセンブラは x86 アーキテクチャでは動かないので、x86-64 向けにのみビルドする。

まずは NASM で x86-64 向けにアセンブリする。

$ nasm -f elf64 nop.asm
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

リンクして実行可能オブジェクトファイルにする。

$ ld -m elf_x86_64 -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

次の通り、ちゃんと動くことがわかる。

$ ./nop
$ echo $?
0

NASM / x86-64 で write(2) も呼んでハローワールドしてみる

次は呼び出すシステムコールを増やしてコンソールの標準出力にハローワールドを出してみよう。 変わるのは、出力する文字列を扱うことになるのと、write(2) システムコールを呼び出すところ。

以下がコンソールに Hello, World! と出力するサンプルコードになる。 新たに増えた .data のセクションはプログラムのデータ領域を示している。 ただし、今回の本題はシステムコールなので、ここでは深入りしない。

global _start

section .data
  msg db 'Hello, World!', 0x0A  ; 0x0A = \n
  msg_len equ $ - msg

section .text

_start:
  ; write system call (Linux / x86-64)
  mov rax, 1
  mov rdi, 1
  mov rsi, msg
  mov rdx, msg_len
  syscall

  ; exit system call (Linux / x86-64)
  mov     rax, 60
  mov     rdi, 0
  syscall

上記のサンプルコードは先ほどに比べると syscall の呼び出しが増えている。 増えているのは write(2) の呼び出しで、これはファイルディスクリプタへのデータの書き込みをするシステムコールになっている。 mov rax, 1write(2) の識別子を rax レジスタに代入していて、その後ろがシステムコールの引数となっている。 rdi に代入している 1 は、標準出力のファイルディスクリプタの番号を示している。 rsi に代入している msg は出力する文字列のアドレスで、rdx に代入している msg_len が長さを示している。

それでは、上記を実際にビルドして実行してみよう。 先ほどと同じように実行可能オブジェクトファイルを作る。

$ nasm -f elf64 greet.asm
$ ld -m elf_x86_64 -o greet greet.o

できたファイルを実行すると、ちゃんと文字列が標準出力に表示される。

$ ./greet 
Hello, World!
$ echo $?
0

GAS / x86-64 で exit(2) を呼び出すだけのプログラム

続いては GAS (GNU Assembler) 向けのアセンブラを書いてみる。 書いていることは同じだけど記法が若干異なる。 NASM は INTEL 記法だったけど、GAS はデフォルトで AT&T 記法が使われる。 違いは、オペランドを書く順番が逆だったり、値やレジスタに記号が必要だったりする。

以下が AT&T 記法で書いた GAS 向けの exit(2) だけを呼び出す x86-64 アセンブラのサンプルコード。

.global _start

.text

_start:
  # exit system call (Linux / x86-64)
  mov $60, %rax
  mov $00, %rdi
  syscall

上記を as でアセンブルする。

$ as -o nop.o nop.s
$ file nop.o
nop.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

リンクして実行可能オブジェクトファイルにする。

$ ld -o nop nop.o
$ file nop
nop: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped_
$ ./nop
$ echo $?
0

GAS でプリプロセッサを使う

ちなみに GAS の場合はアセンブルに gcc を使うとプリプロセッサが使えるようだ。 プリプロセッサが使えると、例えばヘッダファイルを読み込んでシンボルを解決できたりする。

以下は GAS / x86-64 のハローワールドだけど、ヘッダとして <sys/syscall.h> を読み込んでいる。 ヘッダに含まれるシンボルはシステムコールを呼び出すところで $SYS_* という風に使っている。

#include <sys/syscall.h>

.global _start

.data
  message:
    .ascii "Hello, World!\n"
  message_len = . - message

.text

_start:
  # write system call
  mov $SYS_write, %rax
  mov $1, %rdi
  mov $message, %rsi
  mov $message_len, %rdx
  syscall

  # exit system call
  mov $SYS_exit, %rax
  mov $0, %rdi
  syscall

ファイル名の拡張子を大文字の .S にして gcc でアセンブルする。

$ gcc -c greet.S
$ ld -o greet greet.o

できたファイルを実行してみよう。

$ ./greet 
Hello, World!
$ echo $?
0

うまく動いているようだ。

参考

https://blog.packagecloud.io/eng/2016/04/05/the-definitive-guide-to-linux-system-calls/blog.packagecloud.io

python-livereload で Re:VIEW の執筆を捗らせてみる

普段、Sphinx でドキュメントを書くときは sphinx-autobuild というツールを使っている。 このツールを使うと、編集している内容をブラウザからリアルタイムで確認できるようになる。

blog.amedama.jp

今回は、上記のような環境が Re:VIEW でも欲しくて python-livereload というパッケージで実現してみた。 ちなみに、python-livereload は、前述した sphinx-autobuild が内部的に使っているパッケージの一つ。 利点としては、ブラウザ側に livereload 系のプラグインを入れる必要がない点が挙げられる。 これは、Web サーバの機能の中で livereload に使うスクリプトを HTML に動的に挿入することで実現している。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ python -V          
Python 3.7.4

もくじ

Re:VIEW の原稿を用意する

まずは Re:VIEW の環境を用意しておく。 詳しくは以下のエントリを参照のこと。

blog.amedama.jp

python-livereload をインストールする

続いて python-livereload をインストールする。

$ pip install livereload

インストールすると livereload コマンドが使えるようになる。 基本的な機能であれば、このコマンドで完結することもある。

$ livereload --help
usage: livereload [-h] [--host HOST] [-p PORT] [-t TARGET] [-w WAIT]
                  [-o OPEN_URL_DELAY] [-d]
                  [directory]

Start a `livereload` server

positional arguments:
  directory             Directory to serve files from

optional arguments:
  -h, --help            show this help message and exit
  --host HOST           Hostname to run `livereload` server on
  -p PORT, --port PORT  Port to run `livereload` server on
  -t TARGET, --target TARGET
                        File or directory to watch for changes
  -w WAIT, --wait WAIT  Time delay in seconds before reloading
  -o OPEN_URL_DELAY, --open-url-delay OPEN_URL_DELAY
                        If set, triggers browser opening <D> seconds after
                        starting
  -d, --debug           Enable Tornado pretty logging

今回は、監視対象のファイルを指定する部分がちょっと複雑になったのでスクリプトから使うことにした。

監視・ビルド用のスクリプトを用意する

続いては python-livereload を使うスクリプトを用意する。 これは、ファイルを監視して、変更があったときはビルドを実行するというもの。

以下のようなスクリプトを用意した。 HTML の入ったディレクトリ以外を監視して、変更があったら rake web を実行するというもの。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os

from livereload import Server
from livereload import shell


def main():
    # livereload が Web サーバでホストするパス
    serve_path = 'webroot'
    # HTML をビルドするコマンド
    build_func = shell('rake web')
    # 監視対象のファイルパス
    watch_targets = ['*', '*/*']

    # パスがないときはあらかじめビルドしておく
    if not os.path.exists(serve_path):
        build_func()

    # 監視を開始する
    server = Server()
    for watch_target in watch_targets:
        server.watch(filepath=watch_target,
                     ignore=lambda path: path == serve_path,
                     func=build_func)
    server.serve(root=serve_path,
                 open_url_delay=1)


if __name__ == '__main__':
    main()

用意したスクリプトを実行すると、Web サーバが立ち上がって自動的にブラウザを開く。 監視対象の何らかのファイルを変更すると、ドキュメントがビルドされ直してブラウザがリロードされる。

$ python autobuild.py
[I 191009 00:17:14 server:296] Serving on http://127.0.0.1:5500
[W 191009 00:17:14 server:304] Use `open_url_delay` instead of `open_url`
[I 191009 00:17:14 handlers:62] Start watching changes
[I 191009 00:17:14 handlers:64] Start detecting changes
[I 191009 00:17:20 handlers:135] Browser Connected: http://127.0.0.1:5500/

ばっちり。

Rake のタスクにする

スクリプトとして実行するのでも良いんだけど、Rake のタスクにしておいた方が使うとき便利かもしれない。

次の通り Rake の設定ファイルを用意する。

$ cat << 'EOF' > lib/tasks/autobuild.rake 
require 'rake'

desc 'watch the directory and build if any files changed'
task :autobuild do
  sh('python autobuild.py')
end
EOF

これで rake autobuild するとスクリプトが実行されるようになる。

$ rake autobuild

捗りそうだ。

補足

本当は Ruby で完結させたくて、最初はその道を模索した。 ただ、ブラウザのプラグインを必要としない livereload 系の実装が Ruby には見当たらなくて。 加えて、Ruby に不慣れということもあって不本意ながら Python が混ざることになってしまった。 けどまあ、手間をほとんどかけずに実現できたのは良かったかな。

デジタル出版システム Re:VIEW を使ってみる

書籍の執筆環境として、最近は Re:VIEW の評判が良いので試してみることにした。 しばらく使い込んでみて良さそうだったら、既存の Sphinx の環境から移行するのもありかもしれない。 もちろん Sphinx もドキュメントを書くには良いツールなんだけど、はじめから書籍の執筆を試行しているか否かは Re:VIEW と異なる。

使った環境は次の通り。

$ sw_vers         
ProductName:    Mac OS X
ProductVersion: 10.14.6
BuildVersion:   18G103
$ review version  
3.2.0

もくじ

Ruby をインストールする

Re:VIEW は Ruby で書かれたツールセットなので、インストールするのに Ruby が必要となる。 macOS のシステムにインストールされた Ruby でも大丈夫だとは思うけど、念のため rbenv で最新版を入れておく。

まずは Homebrew で rbenv をインストールする。

$ brew install rbenv ruby-build

現時点で最新版の Ruby をインストールする。

$ rbenv install 2.6.5

デフォルトのバージョンをインストールしたものに切り替える。

$ rbenv global 2.6.5

パスを通すためにシェルの設定ファイルに rbenv の初期設定用の記述を追加する。 以下は ZSH を使っている場合に、rbenv コマンドがあるときだけ初期化するというもの。

$ cat << 'EOF' >> ~/.zlogin
: "rbenv" && {
  which rbenv >/dev/null 2>&1
  if [ $? -eq 0 ]; then
    eval "$(rbenv init -)"
  fi
}
EOF

設定を追加したら読み込む。

$ source ~/.zlogin

Ruby のバージョンが切り替わればおっけー。

$ ruby --version
ruby 2.6.5p114 (2019-10-01 revision 67812) [x86_64-darwin18]

Mac TeX をインストールする

Re:VIEW は PDF をビルドするのに TeX Live を必要とする。 そこで、macOS であれば Homebrew Cask で Mac TeX を入れておく。

$ brew cask install mactex

Re:VIEW をインストールする

準備ができたので Gem を使って Re:VIEW をインストールする。

$ gem install review

review コマンドが叩けるようになれば良い。

$ review version
3.2.0

プロジェクトを作る

まずは review-init コマンドでプロジェクトのひな形を作る。

$ review-init helloworld

これで、必要なファイル群ができた。

$ ls helloworld 
Gemfile     config.yml  images      sty
Rakefile    doc     layouts     style.css
catalog.yml helloworld.re   lib

できたディレクトリに移動する。

$ cd helloworld

Re:VIEW では .re ファイルに Re:VIEW の軽量マークアップ言語を使って文章を書いていく。 試しに適当な文章を追加しておこう。

$ cat << 'EOF' > helloworld.re
= Hello, World!

hello, world
EOF

HTML にビルドする

各形式には Rake のタスクを実行することでビルドできる。 タスクは内部的には review-* のコマンドを叩いている。

$ rake web

例えば web タスクであれば webroot ディレクトリに HTML の成果物がビルドされる。

$ ls webroot
helloworld.html images      index.html  style.css   titlepage.html

PDF にビルドする

同様に、rake pdf なら PDF がビルドできる。

$ rake pdf
$ file book.pdf
book.pdf: PDF document, version 1.5

EPUB にビルドする

電子書籍のフォーマットである EPUB も rake epub でビルドできる。

$ rake epub
$ file book.epub
book.epub: EPUB document

所感

設定ファイルなどを見た限りでも、Re:VIEW は書籍の執筆というドメインにかなり特化している印象を受ける。 電子書籍を書く、という目的であれば既存のドキュメンテーションツールをカスタマイズしていくよりも手間が少なく済みそうだ。