「CIの使い捨てVMが隠れた制約で落ちる」問題を図解。リポジトリ名、PR番号、コミットハッシュから生成されたVM識別子が、UNIXパスの長さ制限によりSSH接続失敗でVMが停止するフローが示され、探偵のような男性が調査しています。

はじめに

CIのジョブごとに使い捨てのVM(またはコンテナ)を作り、隔離した環境でテストを実行する構成は、環境の再現性を保つための定番パターンです。ただしこの構成には落とし穴があります。VMの識別子(名前)を組み立てるロジックにわずかな見落としがあると、ドキュメント上の制約はきちんとクリアしているのに、あらゆるジョブが必ず基盤エラーで落ちるという厄介な障害を引き起こすことがあります。

実際に、リポジトリ名やPR番号、コミットハッシュなどを連結して識別子を組み立てていたところ、実運用に近い形で通し実行した途端に全ジョブがVM作成に失敗する、という不具合に遭遇しました。この記事では、その原因の特定から再発防止までの流れを振り返りながら、「エラーメッセージに出てくる制約」と「実際に効いている制約」がズレるケースをどう見つけ、どう対処すればよいかを整理します。

こんな人におすすめ

  • ジョブごとにVMやコンテナを使い捨てで作るCI基盤を運用している方
  • 外部ツール(CLI)のfatalエラーで、原因がつかみにくい障害に悩んだ経験がある方
  • 複数の値を連結してリソースの識別子・名前を組み立てるコードを書いている方
  • macOSとLinuxの両方でシェルスクリプトを動かす必要がある方
  • 「ドキュメント上はセーフなはずなのに落ちる」系の障害調査に興味がある方

本論

表面上の制約をクリアしても落ちることがある

VMやコンテナのツールには、たいてい「識別子は◯文字以内、使える文字は英数字と一部の記号のみ」といったドキュメント化された制約があります。今回のケースでも、識別子は76文字以内という制約をきちんと満たすようにロジックを組んでいました。それでも、通し実行するとどのジョブも必ず基盤エラーで失敗します。

原因を辿ると、実際に効いていたのはドキュメントに明記されていない、もっと厳しい別の制約でした。ツールが内部で作成するUNIXドメインソケットのパス(<HOME>/<識別子>/ssh.sock.<16桁の数字>のような形式)が、OSのソケットパス長上限(UNIX_PATH_MAX、多くの環境で104文字未満)を超えていたのです。識別子側の制約だけを見て「クリアしているから原因はここじゃない」と判断してしまうと、この種の不具合にはたどり着けません。

外部ツールの失敗原因を調べるときは、表に見えている制約だけでなく、そのツールが内部でどんなパスやリソース名を組み立てているかまで踏み込んで確認する必要があります。

作成前に「本当に効く制約」を自前で検証する

原因が分かれば対処は難しくありません。VMを作成する外部コマンドを呼ぶ前に、実際に組み立てられるソケットパスの長さと識別子のパターンを自前で検証し、問題があれば分かりやすいログを出して早期リターンするようにしました。

# ソケットパスがOSの上限に収まるかを、外部コマンドを呼ぶ前に検査する。
# 収まらないと外部コマンドがfatalで落ちるだけで、原因がわかりにくい。
_name_fits() { # <name>
  local name="$1" base sock
  base="${RESOURCE_HOME:-$HOME/.local-state}"
  sock="$base/$name/ssh.sock.0123456789012345"
  if [ "${#sock}" -ge 104 ]; then
    log "名前が長すぎる (ソケットパスが ${#sock} 文字 >= 104): $name"
    return 1
  fi
  if ! printf '%s' "$name" | grep -qE '^[a-z0-9]([a-z0-9-]*[a-z0-9])?$'; then
    log "名前が識別子として不正: $name"
    return 1
  fi
  return 0
}

外部コマンドのfatalエラーに任せるのではなく、呼び出す前に自分たちの言葉でチェックしログを出すようにするだけで、次に同じ問題が起きたときの調査時間が大きく変わります。

識別子を「読める接頭辞 + ハッシュ」で決定的に短縮する

長い識別子をただ切り詰めるだけでは、末尾が変わるだけで別の値が衝突する可能性が残ります。そこで、識別子の先頭は人が読める形のまま残しつつ、末尾を元の値のハッシュ値で置き換える方式にしました。

# 制約は2つあり、効くのは後者(もっと厳しい):
#   1. VMツールの instance 名は76文字以下・英数と - のみ
#   2. VMツールは `<HOME>/<name>/ssh.sock.<16桁>` を作る。UNIXドメインソケットの
#      パスは104文字未満でなければならない (UNIX_PATH_MAX)
# 先頭に読める接頭辞を残しつつ、末尾を元の識別子のsha256先頭10桁で畳む
# (決定的: 同じ入力なら同じ出力 → 重複起動防止・後始末ロジックがそのまま効く)
resource_ident() { # <raw_key> [max_len]
  local raw="$1" max="${2:-30}" slug hash keep
  hash="$(printf '%s' "$raw" | shasum -a 256 2>/dev/null | cut -c1-10)"
  [ -n "$hash" ] || hash="0000000000"
  slug="$(printf '%s' "$raw" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9-' '-' | sed -e 's/--*/-/g' -e 's/^-//' -e 's/-$//')"
  keep=$(( max - 11 ))
  [ "$keep" -lt 1 ] && keep=1
  slug="$(printf '%s' "$slug" | cut -c1-"$keep" | sed -e 's/-$//')"
  printf '%s-%s' "$slug" "$hash"
}

ポイントは「決定的(deterministic)」であることです。同じ入力からは必ず同じ出力が得られるため、既存の重複起動防止ロジックや後始末(reap)処理を一切変更せずに、そのまま短縮後の識別子で使い続けられます。ハッシュを使うことで衝突もほぼ避けられます。

mktemp の方言差にも足元をすくわれる

隔離環境で通し実行してみたことで、もうひとつ見つかったのが mktemp の方言差です。mktemp -t <prefix> はmacOS(BSD)の書き方で、GNU coreutils環境(Linuxコンテナなど)では too few X's in template というエラーで失敗します。厄介なのは、このエラーが出た後に変数へ空文字列が代入されてしまうことです。

# NG: `-t <prefix>` はmacOS方言。GNU coreutilsでは失敗し、TMPROOTが空文字列になる
# TMPROOT="$(mktemp -d -t some-test)"

# OK: 明示的なテンプレートパス + 失敗時ガード
TMPROOT="$(mktemp -d "${TMPDIR:-/tmp}/some-test.XXXXXX")"
[ -n "$TMPROOT" ] && [ -d "$TMPROOT" ] || { echo "FAIL: 一時ディレクトリを作れない"; exit 1; }

TMPROOT が空文字列のまま "$TMPROOT/bin" のような文字列展開を行うと、意図せず /bin を指してしまうといった危険なパスの扱いにつながります。クロスプラットフォームなスクリプトでは、方言のあるオプションを避けて明示的なテンプレートパスを書き、コマンド失敗時のガードを必ず入れておくのが安全です。

つまづきやすいポイント

  • エラーメッセージに出てくる制約だけを信じて、「それはクリアしているから原因は別にあるはず」と決めつけてしまう
  • 識別子の短縮を場当たり的に行うと非決定的になり、重複起動防止や後始末のロジックと噛み合わなくなる
  • OS依存コマンドの失敗が「空文字列」という形で静かに伝播し、まったく別の場所で意図しないパスを触ってしまう
  • 単体テストだけでは踏めない不具合がある。隔離環境の中で既存のテストスイートを実際に通し実行してみて初めて見つかる環境差分は少なくない

まとめ

外部ツールが失敗するとき、原因はドキュメントに書かれた制約の外側にあることがあります。今回のケースでは、識別子の文字数制限よりも、ツールが内部で作るソケットパスの長さの方が厳しい制約として効いていました。長い識別子は「読める接頭辞 + 決定的なハッシュ」で短縮し、外部コマンドを呼ぶ前に自前でバリデーションを挟んでおくと、同種の障害を未然に防ぎやすくなります。あわせて、OS間のコマンドの方言差も見落としやすいポイントとして、通し実行のタイミングで洗い出しておくとよさそうです。