シェルラッパーを解体する:gx と ccux を作った話
flock, timeout, setpriv, envdir, direnv があるのに、なぜ gx と ccux を自作したのか。inspect でプロセス起動をデータにする設計と、exec チェーンとしての合成。
シェルラッパーには呪いがある。
最初は一行だ。cd /srv/app && APP_ENV=prod ./server。
それが少し経つと export が増え、$(cat ...) が入り込み、flock と timeout が乗っかり、set -euo pipefail が先頭に来て、コメントが増えていく。
気づけば 40 行のシェルスクリプトが、プロセス起動のためだけに存在している。
gx と ccux を作ったのは、その呪いを解体するためだ。
inspect から話す
gx はプロセス起動ツールだ。ランタイムエンベロープ——環境変数、作業ディレクトリ、ユーザー——を明示的に組み立てて、シェルなしに子プロセスを起動する。
ただのラッパーなら env -i と cd と exec で同じことはできる。gx が面白いのは inspect サブコマンドにある。先に見てほしい。
gx inspect \
--clear-env \
--env PATH=/usr/bin:/bin \
--env APP_ENV=prod \
--chdir /srv/app \
--user app:app \
-- ./server
出力:
program: ./server
resolved path: /srv/app/server
user: app (uid 1001)
group: app (gid 1002)
chdir: /srv/app
clear_env: true
env:
PATH=/usr/bin:/bin
APP_ENV=prod
実行しない。起動プランを出力するだけだ。
--json をつければ機械可読になる:
{
"mode": "inspect",
"program": "./server",
"resolved_path": "/srv/app/server",
"argv": ["./server"],
"clear_env": true,
"env": [
{"key": "PATH", "value": "/usr/bin:/bin"},
{"key": "APP_ENV", "value": "prod"}
],
"chdir": "/srv/app",
"user": "app",
"uid": 1001,
"group": "app",
"gid": 1002
}
起動プランがデータになる。jq でフィルタできる。CI で期待する JSON と diff を取れる。バージョン管理できる。
既存ツールでこれができるか?
flock --help はフラグの説明を返す。timeout --help も同様だ。
「このプロセスはこの環境でこのパスから起動される」を実行前に確認するインターフェイスを、既成ツールは持っていない。
既存ツールは何が足りないのか
inspect を見た。では、なぜ既存ツールで足りないのかを話す。
正直に言うと、flock・timeout・setpriv・envdir・direnv はどれも良いツールだ。
単体ではよく考えられている。問題は、これらが「シェルで合わせることを前提にしている」点にある。
#!/bin/sh
set -eu
cd /srv/app
export PATH=/usr/bin:/bin
export APP_ENV=prod
CONFIG="$(cat /run/app-inputs/config.json)"
export CONFIG
exec flock -w 10 /run/lock/server.lock \
timeout -k 5s 30s \
./server
このスクリプトが何をするか、説明できるか?できる——が、「推論」が必要だ。
CONFIGはenv -iの前に計算されているか後か?timeoutはflockに対してかかるのか./serverに対してかかるのか?setprivを追加したとき、lock ファイルは誰の権限で取得されるのか?
スクリプトは動作を「書いている」のではなく、動作を「暗示している」。 暗示を読むには、シェルの評価順序を頭の中でシミュレートしなければならない。
direnv はプロジェクトルートを cd したときに自動で環境変数を読み込む仕組みで、開発体験には優れている。
envdir は指定したディレクトリの各ファイルを env に流し込む。
どちらも「ディレクトリベース」で「起動のたびに明示しない」設計だ。
何がどのファイルから来ているかを、シェルスクリプトの外から即座に分かる形で書けない。
gx は何をするか
gx run がやることはひとつ: ランタイムエンベロープを組み立てて、子プロセスを起動する。
gx run \
--clear-env \
--env PATH=/usr/bin:/bin \
--env APP_ENV=prod \
--chdir /srv/app \
-- ./server
これを読んだとき、推論は要らない。
--clear-env で環境をリセットして、PATH と APP_ENV だけを入れて、/srv/app に移動して、./server を起動する。
gx exec は gx run と同じ設定を受け取るが、子プロセスを fork せず自分を対象バイナリで置き換える(execve 相当)。
ccux と組み合わせるとき、この区別が意味を持つ。
環境境界が構造として見える
シェルスクリプトでよく起きるバグがある。
CONFIG="$(cat /run/app-inputs/config.json)"
export CONFIG
env -i PATH=/usr/bin:/bin APP_ENV=prod sh -c '
cd /srv/app
./server
'
壊れている。CONFIG は env -i の前に計算されているが、env -i がそれを破棄する。
./server が受け取る CONFIG は空だ。このバグは見つけにくい。
gx --clear-env を使うと、環境がリセットされるタイミングが構造として見える。
gx の外で設定されたものはリセットされる、--env で渡したものが入る——それがコマンド自体に書いてある。
権限境界が起動設計に入る
gx run \
--clear-env \
--env PATH=/usr/bin:/bin \
--user app:app \
--chdir /srv/app \
-- ./server
--user app:app が gx レイヤーにある。
「ランタイムアイデンティティを確立してから内側に入る」という設計が、コマンドの構造に直接現れている。
setpriv や sudo -u では、どのタイミングで特権が切り替わるかがシェルスクリプトに埋もれる。
ccux を次に作った
gx でランタイムエンベロープを作れるようになったが、すぐに問題が見えた。
ファイル由来の環境変数を注入したい。シェルなしで、だ。
gx run --clear-env --env PATH=/usr/bin:/bin --chdir /srv/app -- \
sh -c 'CONFIG="$(cat /run/app-inputs/config.json)" && export CONFIG && exec ./server'
シェルを持ち込んだ瞬間に、gx が提供した明示性が崩れる。
フラグを読むだけで起動プランが分かる——その保証が消える。
envdir は使えるが、ディレクトリ全体のファイルが自動で env に流れ込む。
CONFIG=config.json という「このファイルをこの変数名で渡す」という明示的なマッピングが書けない。
そこで ccux だ。
ccux exec \
--from /run/app-inputs \
--map CONFIG=config.json \
--map-path CONFIG_PATH=config.json \
-- ./server
--map CONFIG=config.json は /run/app-inputs/config.json の内容を読んで CONFIG 環境変数に入れる。
--map-path CONFIG_PATH=config.json は内容ではなくパス文字列 /run/app-inputs/config.json を CONFIG_PATH に入れる。
プログラムによっては「ファイルの内容」を受け取る設計もあれば「ファイルのパス」を受け取る設計もある。
--map と --map-path を分けることで、その意図が起動コマンドから読み取れる。
env に機密情報を乗せない
ccux exec --from /run/secrets --map-path TOKEN_PATH=token.txt -- ./server
--map-path はファイルの中身を環境変数に展開しない。
env に乗った文字列は ps や /proc/<pid>/environ から読まれるリスクがある。
プログラム自身がファイルを読む設計なら、パスだけ渡せばいい。
このトレードオフがコマンドラインに表れている。
gx + ccux で何が変わるか
最初に挙げたシェルスクリプトの意図を gx + ccux で書くとこうなる:
gx run \
--clear-env \
--env PATH=/usr/bin:/bin \
--env APP_ENV=prod \
--chdir /srv/app \
-- \
ccux exec \
--from /run/app-inputs \
--map CONFIG=config.json \
-- \
./server
gx run は起動を保持しつつ待機する。ccux exec はファイルマッピングを環境に追加して、execve で自分を ./server に置き換える。
最終的にプロセスツリーに残るのは ./server だけだ。gx も ccux も消えている。
exec チェーンが持つ意味はそこにある——合成のための層を積んでも、実行時には対象プロセス一本になる。
変更が局所化される。 ファイルマッピングを変えたいなら ccux の行だけ触ればいい。
ランタイムアイデンティティを変えたいなら gx の行だけ触ればいい。
inspect が使える。 gx inspect --json で全設定を実行前に確認できる。
CI で期待する JSON と diff を取れば、起動設定の変化がプルリクエストに見える。
動機
結局のところ、これは「推論の問題」だ。
シェルスクリプトを読むとき、人は環境変数の流れを頭の中でシミュレートしている。
env -i の前後、export のタイミング、評価順序——それらを追って、最後に「たぶんこうなるはずだ」と結論を出す。
たぶん、だ。
gx run ... -- ccux exec ... -- ./server を読むとき、推論は要らない。
書いてある通りに動く。gx inspect --json を叩けば、実行前に全部見える。
プルリクエストに起動プランの diff が載る。「たぶんこのコミットで直った」ではなく、何が変わったかが見える。
40 行のシェルスクリプトと、それを読む 10 分と、それでも残る「たぶん」——
gx と ccux はその「たぶん」を消すために作った。