はじめに
データ移行という「ミスが許されない現場」でシェルスクリプトと向き合い続けて気づいたことがあります。
スクリプトの問題は「書いたコマンドが間違っていること」より、「意図しない状態で実行されること」の方が圧倒的に多い。
典型的な「意図しない実行」のパターンは2つです。
-
環境変数の設定漏れ:
${DB_HOST}が空のまま rsync が動く、${TARGET_DIR}が空のままrm -rf ${TARGET_DIR}/*が走る -
SSH接続の鍵依存: ローカルで動いていたスクリプトが、CI/CDパイプラインで
Permission denied (publickey)で落ちる
この記事では、この2つに対する即効性の高い対策と、AIをレビューパートナーにして事前に弱点を炙り出す方法を紹介します。
縦軸は1つだけ。
「暗黙の前提」を明示的なコードに変える。
Part 1: ${VAR:?} — 環境変数の未設定を「即死」させる
問題発見:設定漏れは静かに、ときに致命的に壊れる
こんなスクリプトがあるとします。
#!/bin/bash
rsync -av --delete /local/data/ ${REMOTE_USER}@${REMOTE_HOST}:/backup/
REMOTE_HOST を .env から読み込む設計だとして、もし .env の読み込みを忘れたり、typo で変数名を間違えたりした場合、何が起きるでしょうか。
bash のデフォルト挙動では、未設定の変数は空文字として展開されます。つまり上記は:
rsync -av --delete /local/data/ @:/backup/
と解釈され、エラーになります。まだこれはマシな方で、変数がパスの一部に使われている場合はさらに危険です。
# TARGET_DIR が空のとき、このコマンドは rm -rf /* と等価になりうる
rm -rf ${TARGET_DIR}/old_files/
データ移行スクリプトの場合、この手の「静かに壊れる」事象が本番実行後に発覚すると、そのダメージはリカバリ不能に近いことがあります。
解決:${VAR:?} でスクリプトを冒頭で止める
bash のパラメータ展開には、変数が未設定または空のときにエラーで即終了させる構文があります。
${VAR:?エラーメッセージ}
これをスクリプト冒頭の「防壁ブロック」として使います。
#!/bin/bash
set -eu
# 防壁ブロック:必須変数チェック
: ${REMOTE_USER:?"REMOTE_USER が未設定です。deploy.env を確認してください"}
: ${REMOTE_HOST:?"REMOTE_HOST が未設定です。deploy.env を確認してください"}
: ${SSH_KEY_PATH:?"SSH_KEY_PATH が未設定です。CI の Secrets 設定を確認してください"}
# ここから本体
rsync -av --delete \
-e "ssh -i ${SSH_KEY_PATH}" \
/local/data/ \
${REMOTE_USER}@${REMOTE_HOST}:/backup/
${VAR:?message} は変数が未設定または空のとき、message を標準エラーに出力してスクリプトを終了します(終了コード 1)。コマンドとして成立しない : (no-op) の引数として置くのが慣用的な書き方です。
実際に未設定で実行するとこうなります:
deploy.sh: line 4: REMOTE_HOST: REMOTE_HOST が未設定です。deploy.env を確認してください
どの変数が・なぜ必要かが1行でわかる、というのが重要です。bash: REMOTE_HOST: unbound variable というエラーより、格段に原因特定が早くなります。
set -eu との組み合わせ
${VAR:?} は set -u(未設定変数をエラーにするオプション)と重複するように見えますが、両方書く意味があります。
| 機能 | set -u |
${VAR:?} |
|---|---|---|
| 未設定変数をエラーにする | ◯ | ◯ |
| カスタムエラーメッセージ | ✗ | ◯ |
| 空文字を検出する | ✗ | ◯(:? の場合) |
| エラー発生箇所が明確 | △(変数使用箇所) | ◯(防壁ブロック) |
set -u だけだと、変数を実際に使っている行でエラーが出るため、スクリプトが一部実行された後で止まる可能性があります。${VAR:?} を冒頭にまとめることで、本処理が始まる前に確実に止められます。
Before / After
# Before:エラーが出ても気づかない、あるいは中途半端に処理が進む
#!/bin/bash
source .env
rsync -av /data/ ${REMOTE_USER}@${REMOTE_HOST}:/backup/
scp dump.sql ${REMOTE_USER}@${REMOTE_HOST}:/tmp/
# After:本処理の前に全変数を確認、問題があれば明確なメッセージで停止
#!/bin/bash
set -eu
: ${REMOTE_USER:?"REMOTE_USER が未設定"}
: ${REMOTE_HOST:?"REMOTE_HOST が未設定"}
: ${DUMP_FILE:?"DUMP_FILE が未設定"}
rsync -av /data/ ${REMOTE_USER}@${REMOTE_HOST}:/backup/
scp "${DUMP_FILE}" ${REMOTE_USER}@${REMOTE_HOST}:/tmp/
Part 2: CI/CD上の scp -3 で刺さる Permission denied を解消する
問題発見:ローカルでは動く、CI で落ちる
CI/CDパイプラインからDB移行やファイル同期を行う構成として、踏み台経由のリモート間転送(scp -3)を使うことがあります。
CI Runner ──(scp -3)──> src-server:/path/dump.sql
└─────────────────────────────────> dst-server:/path/
scp -3 は、2つのリモートホスト間のファイルコピーを、ローカルマシン(CI ランナー)を経由して行うオプションです。ローカルから手動実行すると問題なく動くのに、CI/CD のジョブにすると:
Permission denied (publickey).
lost connection
で止まる、という事象が起きます。
調査:どの段階で権限エラーが起きているか
scp -vvv でデバッグ出力を有効にすると、どの接続ステップで失敗しているかが見えます。
debug1: Offering public key: /home/runner/.ssh/id_rsa RSA ...
debug1: Authentications that can continue: publickey
debug1: No more authentication methods to try.
Permission denied (publickey).
注目すべきは Offering public key: /home/runner/.ssh/id_rsa の部分です。ローカル環境ではこのパスに有効な鍵があり、接続先サーバの authorized_keys に登録済みだから動きます。しかしCI 環境の /home/runner/.ssh/id_rsa は、接続先に登録されたデプロイ用の鍵ではない(あるいは存在しない)ため、認証に失敗します。
よくある原因を整理すると:
| 原因 | 症状 |
|---|---|
| CI ランナーにデプロイ用鍵がない |
No such identity または Permission denied (publickey)
|
| 鍵ファイルのパーミッションが 600 より緩い | bad permissions: ignore key |
接続先の authorized_keys 未登録 |
Permission denied (publickey) |
StrictHostKeyChecking で known_hosts に引っかかる |
Host key verification failed |
解決:-i で鍵パスを明示、CI Secrets で安全に渡す
ローカルで暗黙的に使われていた鍵を、CI/CD 環境でも明示的に指定します。
Before(鍵指定なし):
scp -3 \
"${SRC_USER}@${SRC_HOST}:/data/dump.sql" \
"${DST_USER}@${DST_HOST}:/data/"
After(鍵を明示):
scp -3 -i /tmp/deploy_key \
"${SRC_USER}@${SRC_HOST}:/data/dump.sql" \
"${DST_USER}@${DST_HOST}:/data/"
CI/CD ジョブスクリプト全体では、鍵の受け渡し〜クリーンアップをセットで書きます:
#!/bin/bash
set -eu
# 必須変数チェック(防壁ブロック)
: ${SRC_USER:?"SRC_USER が未設定"}
: ${SRC_HOST:?"SRC_HOST が未設定"}
: ${DST_USER:?"DST_USER が未設定"}
: ${DST_HOST:?"DST_HOST が未設定"}
: ${SSH_PRIVATE_KEY:?"SSH_PRIVATE_KEY が未設定。CI の Secrets を確認してください"}
# Secrets から鍵ファイルを生成
readonly KEY_FILE=$(mktemp /tmp/deploy_key.XXXXXX)
chmod 600 "${KEY_FILE}" # 書き込み前にパーミッションを絞る
echo "${SSH_PRIVATE_KEY}" > "${KEY_FILE}"
# スクリプト終了時(エラー時含む)に鍵ファイルを必ず削除
trap "rm -f ${KEY_FILE}" EXIT
# SCP 実行
scp -3 -i "${KEY_FILE}" \
-o StrictHostKeyChecking=no \
"${SRC_USER}@${SRC_HOST}:/data/dump.sql" \
"${DST_USER}@${DST_HOST}:/data/"
ここでの設計ポイントは3つです。
-
mktempで一時ファイルを生成:/tmp/deploy_key固定にすると並列ジョブで競合するため、ユニークなパスを使う -
chmod 600を書き込みより先に実行: ファイルに書き込む前に権限を絞ることで、一瞬も 644 の状態を作らない -
trap EXITでクリーンアップ:set -eでどこかの行が失敗しても鍵ファイルが残らない
StrictHostKeyChecking=no について
上記では簡便のため StrictHostKeyChecking=no を使っていますが、本番環境では MITM リスクがあります。接続先が固定の場合は、事前に ssh-keyscan でホスト公開鍵を取得して known_hosts に追記する方法がより安全です。
# パイプライン内での known_hosts 構築例
ssh-keyscan -H "${SRC_HOST}" >> ~/.ssh/known_hosts
ssh-keyscan -H "${DST_HOST}" >> ~/.ssh/known_hosts
scp -3 vs 2段階転送
src と dst で異なる鍵を使う場合は scp -3 が機能しません。その場合は2段階転送に分けます。
# 2段階転送(src/dst で鍵が異なる場合)
scp -i "${SRC_KEY}" "${SRC_USER}@${SRC_HOST}:/data/dump.sql" /tmp/
scp -i "${DST_KEY}" /tmp/dump.sql "${DST_USER}@${DST_HOST}:/data/"
rm -f /tmp/dump.sql
Part 3: AI をスクリプトのレビューパートナーにする
「この変数、未設定だったら?」を機械的に聞く
スクリプトの弱点を自分でレビューするのは難しいです。「当然セットされているはず」という思い込みが、まさに事故を起こすからです。
AI をレビューパートナーとして使うときのプロンプトの型はシンプルです。
このスクリプトで、実行時に未設定または空になりうる変数を全て列挙してください。
そのうち、未設定のまま実行すると危険なものを特に指摘してください。
AI は思い込みなしにコードを読むため、「そこまで確認するの?」という箇所も拾ってきます。
実際のレビューで出てきた指摘の例:
| AI の指摘 | 対応 |
|---|---|
LOG_DIR が空の場合、ログファイルパスがカレントディレクトリ直下になる |
${LOG_DIR:?} を追加 |
DUMP_FILE の存在チェックがない(空文字でも scp のパス解釈が変わる) |
[ -f "${DUMP_FILE}" ] || { echo "ファイルが見つかりません"; exit 1; } を追加 |
TZ が未設定の場合、タイムスタンプが実行環境依存になる |
TZ=Asia/Tokyo を明示 |
SSH/SCP の「暗黙の前提」を顕在化させる
このスクリプトの SSH/SCP 接続で、実行環境によって失敗しうる箇所と
その原因を列挙してください。
ローカル開発環境と CI/CD 環境の差異を特に意識してください。
AI が返す「接続が失敗しうる箇所」は、そのままチェックリストになります。
-
~/.ssh/known_hostsに対象ホストが登録されているか - SSH エージェントが起動しているか(CI 環境ではデフォルト未起動)
- 鍵ファイルのパーミッションが 600 になっているか
- 踏み台の設定が環境(ローカル / ステージング / 本番)ごとに差異がないか
これらを指摘されたら、対処をスクリプト本体に組み込みます。「AI が言ったから安心」ではなく、AI の指摘をトリガーにして、人間がコードに明示する、というのが正しい使い方です。
おわりに
本番環境でシェルスクリプトが意図しない動きをするとき、その原因は「コードが間違っている」より「前提条件が崩れている」ことの方が多いです。
守りのスクリプトの3原則を置いて締めます。
| 原則 | 実装 |
|---|---|
| 即死 | 前提条件が崩れていたら、本処理の前に停止する |
| 明示 | 暗黙の依存(鍵・ホスト・パス)はコードに書き下す |
| 事前検証 | 思い込みを AI に炙り出させ、人間がコードに組み込む |
3つとも、今日から1行追加するだけで始められます。データ移行のような「やり直しが効かない作業」に向き合う前に、スクリプトの防壁を一度見直してみてください。