8 min read

シェルラッパーを解体する:gx と ccux を作った話


flock, timeout, setpriv, envdir, direnv があるのに、なぜ gx と ccux を自作したのか。inspect でプロセス起動をデータにする設計と、exec チェーンとしての合成。

シェルラッパーには呪いがある。

最初は一行だ。cd /srv/app && APP_ENV=prod ./server。 それが少し経つと export が増え、$(cat ...) が入り込み、flocktimeout が乗っかり、set -euo pipefail が先頭に来て、コメントが増えていく。 気づけば 40 行のシェルスクリプトが、プロセス起動のためだけに存在している。

gxccux を作ったのは、その呪いを解体するためだ。

inspect から話す

gx はプロセス起動ツールだ。ランタイムエンベロープ——環境変数、作業ディレクトリ、ユーザー——を明示的に組み立てて、シェルなしに子プロセスを起動する。

ただのラッパーなら env -icdexec で同じことはできる。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 を見た。では、なぜ既存ツールで足りないのかを話す。

正直に言うと、flocktimeoutsetprivenvdirdirenv はどれも良いツールだ。 単体ではよく考えられている。問題は、これらが「シェルで合わせることを前提にしている」点にある。

#!/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

このスクリプトが何をするか、説明できるか?できる——が、「推論」が必要だ。

  • CONFIGenv -i の前に計算されているか後か?
  • timeoutflock に対してかかるのか ./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 で環境をリセットして、PATHAPP_ENV だけを入れて、/srv/app に移動して、./server を起動する。

gx execgx 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
'

壊れている。CONFIGenv -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:appgx レイヤーにある。 「ランタイムアイデンティティを確立してから内側に入る」という設計が、コマンドの構造に直接現れている。 setprivsudo -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.jsonCONFIG_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 だけだ。gxccux も消えている。

exec チェーンが持つ意味はそこにある——合成のための層を積んでも、実行時には対象プロセス一本になる。

変更が局所化される。 ファイルマッピングを変えたいなら ccux の行だけ触ればいい。 ランタイムアイデンティティを変えたいなら gx の行だけ触ればいい。

inspect が使える。 gx inspect --json で全設定を実行前に確認できる。 CI で期待する JSON と diff を取れば、起動設定の変化がプルリクエストに見える。

動機

結局のところ、これは「推論の問題」だ。

シェルスクリプトを読むとき、人は環境変数の流れを頭の中でシミュレートしている。 env -i の前後、export のタイミング、評価順序——それらを追って、最後に「たぶんこうなるはずだ」と結論を出す。

たぶん、だ。

gx run ... -- ccux exec ... -- ./server を読むとき、推論は要らない。 書いてある通りに動く。gx inspect --json を叩けば、実行前に全部見える。 プルリクエストに起動プランの diff が載る。「たぶんこのコミットで直った」ではなく、何が変わったかが見える。

40 行のシェルスクリプトと、それを読む 10 分と、それでも残る「たぶん」—— gxccux はその「たぶん」を消すために作った。

0% read
left
8 min total