CUBE SUGAR CONTAINER

技術系のこと書きます。

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 は書籍の執筆というドメインにかなり特化している印象を受ける。 電子書籍を書く、という目的であれば既存のドキュメンテーションツールをカスタマイズしていくよりも手間が少なく済みそうだ。

返り値のチェックでシェルスクリプトが止まらないようにする

シェルスクリプトで set -e しておくとコマンドの返り値が非ゼロ (エラー) のときにスクリプトを止めることができる。 この機能を使うと、コマンドの実行結果がエラーになった状態で処理が突き進んでしまうことを防止できる。 ただ、この機能は便利な反面、スクリプトが意図せず止まってしまうこともある。 今回は、それを回避する方法について。

使った環境は次の通り。

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

もくじ

set -e について

シェルスクリプトで set -e しておくと、コマンドの返り値が非ゼロ (エラー) のときに、処理をそこで止めることができる。 例えば以下のスクリプトでは bash -c "exit 1" のところで止まる。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの返り値が非ゼロなので異常終了
bash -c "exit 1"  # ここで停止する

# ここまで到達しない
echo "Hello, World"

上記を実行してみよう。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'

最後まで到達しないことが分かる。

一応、コマンドがゼロを返すパターンについても確認しておく。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの返り値がゼロなので正常終了
bash -c "exit 0"

# エラーがないので、ここまで到達する
echo "Hello, World"

上記を実行すると、今度は最後まで到達する。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 0'
+ echo 'Hello, World'
Hello, World

返り値が非ゼロになるコマンドを実行したい

ただ、この機能を有効にしていると困るときもある。 例えば、返り値が非ゼロになるコマンドをどうしても実行したい場合について。

まずは正攻法から。 次のように set +e を使って一時的に返り値のチェックを無効化できる。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

set +e  # 一時的に機能を無効化する
bash -c "exit 1"  # 返り値が非ゼロになるコマンドを実行する
set -e  # コマンドの実行が終わったら再び有効化する

# ちゃんとここまで到達する
echo "Hello, World"

上記を実行してみよう。 ちゃんと返り値が非ゼロになるコマンドも実行できていることがわかる。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 1'
+ set -e
+ echo 'Hello, World'
Hello, World

別解

ただ、上記は割と冗長なので、以下のようにもできるようだ。 一連のコマンドの返り値が必ずゼロになるようにするアプローチ。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 一連のコマンドとして返り値が必ずゼロになる
bash -c "exit 1" || true

# ちゃんとここまで到達する
echo "Hello, World"

上記を実行してみよう。 ちゃんと返り値が非ゼロになるコマンドも実行できている。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'
+ true
+ echo 'Hello, World'
Hello, World

返り値を使って条件分岐したい

続いては、特定のコマンドの返り値を使って条件分岐したいというパターン。

以下のサンプルコードでは、何もケアをしていないため返り値が非ゼロになると処理が止まってしまう。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンドの実行結果を元に処理を分岐させたい
bash -c "exit 1"  # しかし set -e があると停止してしまう

# 以下の分岐まで到達しない
if [ $? -eq 0 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 想定通り、非ゼロが返る時点で終了してしまう。

$ bash errchk.sh 
+ set -e
+ bash -c 'exit 1'

ゼロか非ゼロかしか見ない場合

まず、コマンドの返り値がゼロか非ゼロかしか見ないのであれば if に直接コマンドを埋め込んでしまえば良い。 if は元々内側のコマンドの返り値がゼロか非ゼロかで分岐する構文になっているため。

このアプローチを採用したサンプルコードは次の通り。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# if であれば直接コマンドを埋め込んでしまえば良い
if bash -c "exit 1"; then
  echo "ok"
else
  echo "ng"
fi

上記を実行した結果が次の通り。 set -e の影響を受けることなく意図通りに処理されていることがわかる。

$ bash errchk.sh
+ set -e
+ bash -c 'exit 1'
+ echo ng
ng

それ以外の返り値も確認したい場合

それ以外の返り値も確認したい場合には、ちょっと面倒になる。 まず、基本となるアプローチとしては直前に実行したコマンドの返り値が得られる変数 $? を使うことになる。 しかし、以下のように非ゼロになりうるコマンドの前後で無効化・有効化をしてしまうとうまくいかない。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# こうしたいんだけどダメなパターン
set +e
bash -c "exit 100"
set -e  # これもコマンドの実行なので $? が上書きされてしまう

# 分岐が意図通りにいかない
if [ $? -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 本来なら "ok" になってほしいところが "ng" になってしまった。 これは set -e によって $? の値が上書きされてしまったため。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 100'
+ set -e
+ '[' 0 -eq 100 ']'
+ echo ng
ng

そのため、次のようにコマンドの返り値を別の変数に退避しておく必要がある。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 返り値のチェックを止める影響を最小化する
set +e
bash -c "exit 100"
RET=$?  # コマンドの実行が終わったら返り値を変数に退避させる
set -e  # 退避したらエラーのチェックを戻す

# 分岐では退避させた変数を見る
if [ ${RET} -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 今度はちゃんとうまくいった。

$ bash errchk.sh 
+ set -e
+ set +e
+ bash -c 'exit 100'
+ RET=100
+ set -e
+ '[' 100 -eq 100 ']'
+ echo ok
ok

別解 (別プロセスのシェルで実行できる場合)

なお、別プロセスのシェルで実行できる処理であれば、コマンド置換を使って以下のように書くこともできる。 この方法であれば set -e されていない別シェルで実行しているのと同じ扱いになるので、一時的に set +e する必要がない。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# コマンド置換を使って返り値を求める
RET=$(bash -c "exit 100"; echo $?)

# 分岐では退避させた変数を見る
if [ $RET -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

上記を実行してみよう。 ちゃんとうまくいっているようだ。

$ bash errchk.sh 
+ set -e
++ bash -c 'exit 100'
++ echo 100
+ RET=100
+ '[' 100 -eq 100 ']'
+ echo ok
ok

また、このやり方であれば以下のように条件分岐の中に埋め込むこともできる。 これは if 文に test コマンドを使わず直接実行したいコマンドを埋め込んだ最初のパターンに近い簡便さになってると思う。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 条件分岐に埋め込むパターン
if [ $(bash -c "exit 100"; echo $?) -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

別解 (set +e を使わないアプローチ)

もうひとつ、最初の非ゼロのコマンドを実行するときの別解に似たやり方が次の通り。 コマンドの実行結果をワンライナーで変数に退避させることで set +e が不要になっている。

#!/usr/bin/env bash

# コマンドの実行履歴を出力する
set -x

# コマンドの返り値が非ゼロのとき停止する
set -e

# 変数を初期化する
RET=0
# 一連のコマンドとして返り値をゼロにするアプローチ
bash -c "exit 100" || RET=$?

# 分岐では退避させた変数を見る
if [ $RET -eq 100 ]; then
  echo "ok"
else
  echo "ng"
fi

実行結果は次の通り。

$ bash errchk.sh 
+ set -e
+ RET=0
+ bash -c 'exit 100'
+ RET=100
+ '[' 100 -eq 100 ']'
+ echo ok
ok

いじょう。