6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本番環境で震えないためのシェルスクリプト術:${VAR:?}による防壁とSSH/SCPの権限エラー解消

6
Posted at

はじめに

データ移行という「ミスが許されない現場」でシェルスクリプトと向き合い続けて気づいたことがあります。

スクリプトの問題は「書いたコマンドが間違っていること」より、「意図しない状態で実行されること」の方が圧倒的に多い。

典型的な「意図しない実行」のパターンは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つです。

  1. mktemp で一時ファイルを生成: /tmp/deploy_key 固定にすると並列ジョブで競合するため、ユニークなパスを使う
  2. chmod 600 を書き込みより先に実行: ファイルに書き込む前に権限を絞ることで、一瞬も 644 の状態を作らない
  3. 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行追加するだけで始められます。データ移行のような「やり直しが効かない作業」に向き合う前に、スクリプトの防壁を一度見直してみてください。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?