2026年5月、さくらインターネット上の WordPress サイトで SSH 経由の bash コマンドが動かない問題に遭遇した。原因はさくらの既定ログインシェルが csh だったこと。コードベースに散在する bash 構文を _safe_run ヘルパーに集約し、問題は一度収束した。(初発の事故の詳細は「csh ログインシェルに bash 構文のコマンドを送って詰まった話」に記録している。)
それが 2026年6月、さくら契約の複数サイトで DB バックアップが必ず「SSH update error」になるという報告が届いた。
なぜ同じ問題が再発したか
原因は「新機能の実装で同じ罠に踏み込んだ」ことだった。
v1.6.9 の開発で、DB バックアップに進捗監視スクリプトを追加していた。バックアップ実行中の経過ファイルサイズを 30 秒ごとに採取し、完了後に 1 行サマリーとして記録する仕組みで、次のような構成のスクリプトだった。
wp db export /path/backup.sql 2>&1 &
PID=$!
while kill -0 $PID 2>/dev/null; do
echo "size=$(wc -c < /path/backup.sql)"
sleep 30
done
wait $PID; exit $?
このスクリプトには 2>&1・$!・while ... do ... done・$(...) などの bash 構文が含まれている。それ自体は正しいコードだが、paramiko の exec チャネルはサーバー側ユーザーの「ログインシェル」でコマンドを実行するという性質がある。
さくらインターネットの既定ログインシェルは csh。csh はこれらの構文を解釈できない。
| 構文 | csh での結果 |
|---|---|
2>/dev/null |
Ambiguous output redirect またはサイレント空出力(最も危険) |
$! |
バックグラウンドプロセス ID を取れない |
while ...; do ... done |
for: Command not found 系エラー |
VAR=val |
変数代入が機能しない |
前回の修正で整備した _safe_run の範囲は「既存の実装」だった。新機能は新機能として直接 c.run() を呼んでいたため、そこには何のガードもなかった。
最初に試みた修正(base64 方式)
修正の方針として、「スクリプト本体を base64 にエンコードしてから送り、サーバー側でデコードして /bin/sh で実行する」方式を採った。
import base64
def wrap_remote_sh_v1(script):
encoded = base64.b64encode(script.encode()).decode()
return f"echo {encoded} | openssl base64 -d -A | /bin/sh"
echo <base64文字列> はシェルの差異なく動く。デコードには openssl base64 -d を使い、その標準出力を /bin/sh に渡す構成だ。これなら csh が担うのは echo・パイプ・外部コマンド起動だけで、bash 構文の解釈は一切不要になる。
さくら実機で検証したところ、進捗スクリプトが正しく動作することを確認できた。
heteml で再び壊れた
修正を実装したところで、別のサーバー環境での検証を行った。そこで新たな事実が判明した。
heteml(ssh-layer.heteml.net)には openssl も base64 コマンドも存在しない。
base64 方式は openssl base64 -d を使うため、heteml では command not found になる。さくらの問題は解決できても、heteml では全滅する。外部コマンドへの依存は、想定していないサーバー環境で静かに失敗する。
最終的な設計(/bin/sh -c 方式)
外部コマンドへの依存を排除するために採用したのが、/bin/sh -c に直接スクリプトを渡す方式だった。
import shlex
def wrap_remote_sh(script):
if '\n' in script or '\r' in script:
raise ValueError(
"wrap_remote_sh は単一行スクリプトのみ対応 "
"(csh のシングルクォートは改行を跨げない)"
)
return "/bin/sh -c " + shlex.quote(script)
shlex.quote() は Python 標準ライブラリの関数で、文字列を POSIX シングルクォート形式に変換する。埋め込みの ' は '\'' に展開されるため、スクリプト本体にシングルクォートが含まれていても正しく渡せる。
wrap_remote_sh("echo hi && wc -c < /tmp/file")
# → "/bin/sh -c 'echo hi && wc -c < /tmp/file'"
csh はこのコマンドを exec チャネルで受け取ると、シングルクォート内を 1 つのトークンとして /bin/sh に引き渡す。2>/dev/null・$!・while...do...done を解釈するのは csh ではなく /bin/sh になるため、ログインシェルが何であっても正しく動く。
外部コマンドへの依存はゼロ。 /bin/sh はすべてのレンタルサーバーに必ず存在する。
ただし 1 つ制約がある。スクリプトは単一行でなければならない。 csh のシングルクォートは改行を跨げない仕様のため、複数行のスクリプトは csh 環境で Unmatched ' エラーになる。複数行のロジックはセミコロン区切りの単一行に書き直す必要がある(do; は sh 構文エラーなので do <cmd> の形で繋ぐ)。改行が含まれていた場合は事前に ValueError を投げて開発時に検出できるようにした。
3環境での実機検証
修正後、さくら(csh)・Xserver(bash)・heteml(bash・openssl なし)の 3 環境で実機検証を行った。
| 環境 | ログインシェル | openssl/base64 | 結果 |
|---|---|---|---|
| さくら | csh | あり | ✅ 進捗スクリプト完走 / exit 0 確認 |
| Xserver | bash | あり | ✅ 同上 |
| heteml | bash | なし | ✅ 外部コマンド非依存を実証 |
base64 方式では heteml が失敗していたが、/bin/sh -c 方式では 3 環境すべてがパスした。
再発防止のための静的ガードテスト
wrap_remote_sh を実装しても、将来の新機能開発で「またラップを忘れる」リスクは残る。今回の再発がまさにそのパターンだった。
そこで、tests/test_remote_sh_wrap.py に「csh で確実に壊れる構文を含む裸の c.run() が再混入されていないか」を静的に検証するテストを追加した。
def test_no_csh_unsafe_bare_crun_in_maintenance_agent(self):
src = (PROJECT_ROOT / "maintenance_agent.py").read_text(encoding="utf-8")
for i, line in enumerate(src.splitlines(), 1):
if "c.run(" not in line:
continue
if "wrap_remote_sh" in line:
continue
# csh で壊れるマーカー: リダイレクト・コマンド置換・ループ等
if re.search(r'2>|`\$!`|\$\(|while |for.*in.*do', line):
self.fail(f"line {i}: csh-unsafe c.run without wrap: {line.strip()}")
このテストがあれば、「ラップせずに 2> を含む c.run() を追加する」変更は自動的にテスト失敗として検出される。
まとめ
| 観点 | 内容 |
|---|---|
| 問題 | 新機能の実装が既存の安全ラッパーの範囲外で直接 c.run() を呼んでいた |
| 根本原因 | paramiko がログインシェルでコマンドを実行する性質。さくら csh は bash 構文を解釈できない |
| 第1修正 | base64 エンコード + openssl base64 -d デコード方式 → heteml に openssl が存在しないため失敗 |
| 最終修正 | shlex.quote() で POSIX シングルクォート化した単一行スクリプトを /bin/sh -c に渡す |
| 再発防止 | ソースコードの静的テストで「ラップなしの csh-unsafe c.run()」を機械的に検出 |
「修正済みの問題クラスが新機能追加で再燃する」という構造は、修正の対象が「既存コードの修正」に留まり、「新規追加時のガード」が別途必要だったことを示している。機能を追加するたびに「既存の安全ラッパーを通しているか」を確認する習慣か、テストによる機械的な保証の仕組みが必要になる。