はじめに
サーバー作業の現場には、昔からこんなジレンマがあります。
- 手順書のコマンドを1行ずつコピペ実行 → 貼り間違い・行飛ばし・二重実行のリスクが常につきまとう
- スクリプト化して一気に流す → 途中で想定外の出力が出ても止まれない。本番でこれをやる勇気はない
この中間解として「スクリプトを1行ずつ、目視確認しながら実行するラッパー」を作りました。それ自体はよくある発想です。ただ、実際にサーバー上で使い込んでみると、確認UIの設計が想像以上に作業の質を左右することが分かりました。
最初に素直に作った「y/n確認方式」が実運用でどう不評だったか。そしてなぜ「Enterキー一本+コメント行自動スキップ+前後5行表示」に落ち着いたのか。80行ほどの小さなスクリプトを題材に、確認UIの設計を掘り下げます。コード全文は記事の後半に載せています。
最初の設計: y/n確認+進捗ファイル方式
最初の要件はシンプルでした。
スクリプト名を引数に受けて、1行ずつ実行するbashを作りたい。各行の実行前に前後の行を表示して、
run?[y/n]で確認する。
これに加えて「途中でやめたら、次回は続きから再開したい」という要望があったので、初版はこう作りました。
- 各行の実行前に
run?[y/n]>>と確認。yで実行、nで終了 -
yのたびに進捗ファイルask_<スクリプト名>.txtへ実行済み行番号を記録 - 再実行時に進捗ファイルがあれば「前回の続き[3]から実行しますか?[y/n]」と確認
実行イメージはこうです。
$ sh ask.sh deploy.sh
[deploy.sh]を実行開始しますよろしいですか?[y/n]>> y
-----
>1:systemctl stop myapp
2:cp -p app.war /opt/tomcat/webapps/
run?[y/n]>>y
-----
(実行結果)
-----
1:systemctl stop myapp
>2:cp -p app.war /opt/tomcat/webapps/
3:systemctl start myapp
run?[y/n]>>y
...
コメント込みで約65行。テストも通り、この時点では「まあ、こんなものだろう」と思っていました。
実サーバーで使って分かった3つの不満
作ったスクリプトを実際の検証サーバーに転送して手順書ベースの作業に使ってみると、机上では気づかなかった不満が立て続けに出てきました。
不満1: y/n入力がリズムを壊す
これが最大の問題でした。1行実行するたびに「y を押して Enter」の2打鍵。数十行の手順書なら、この儀式を数十回繰り返すことになります。
しかも冷静に考えると、n の存在意義がほとんどないのです。実行を止めたい場面とは「出力がおかしい」「手が滑りそう」といった緊急時であり、そのとき人間が反射的に押すのは n ではなく Ctrl+C です。つまり n は「使われない選択肢のために、毎回1打鍵を余分に要求する」機能でした。
実際に使ったユーザーからのフィードバックは、たった一言でした。
run?のときに[Enter]にしてください。Ctrl+Cで強制終了することは実行初期に記載のみでOK
不満2: 進捗ファイルは「状態管理」が増えるだけ
途中再開のための進捗ファイルは、一見便利そうで、実は運用の負債でした。
- 作業ディレクトリに
ask_deploy.sh.txtのようなファイルが残る(消し忘れると次回の実行に化けて出る) - 手順書スクリプト自体を修正すると行番号がズレて、進捗ファイルが嘘をつく
- 「続きから再開しますか?」という確認が1つ増える
サーバー作業では「手順の3行目まで実行して、問題があったので手順書を直して再実行」がよく起こります。このとき機械が覚えている「前回の続き」は当てになりません。どこから再開すべきかは人間が一番よく知っているのです。
不満3: コメント行にまで確認が出る
実際の手順書スクリプトには、コメントや空行が大量に含まれます。実サーバーでの実行ログ(一般化しています)がこちらです。
$ sh ask.sh deploy.sh
[deploy.sh]を実行開始しますよろしいですか?[y/n]>> y
※途中で強制終了する場合は Ctrl+C を押してください
開始行を入力してください(Enterで最初から)>>
-----
>1:# アプリ停止
2:systemctl stop myapp
run?[Enter]>>
-----
-----
1:# アプリ停止
>2:systemctl stop myapp
3:
run?[Enter]>>^C
1行目のコメント # アプリ停止 に対して run? と聞かれています。コメントの「実行」に確認を求められても困りますし、空行も同様です。手順書の半分がコメントだとすると、確認回数が実コマンド数の2倍になってしまいます。
改善1: y/n を捨てて Enter に一本化
フィードバックを受けて、確認プロンプトをこう変えました。
run?[y/n]>>y⏎ (2打鍵 × 行数)
↓
run?[Enter]>>⏎ (1打鍵 × 行数)
打鍵数が半分になった、という表面的な話以上に、これはメンタルモデルの転換だと考えています。
- y/n方式: 「この行を実行してよいか?」という判断を毎回要求する。人間は Yes/No の意思決定を強制される
- Enter方式: 「読んだら進む」というページ送り。人間の仕事は判断ではなく確認(読むこと)に集中する
数十行の作業で毎回「Yes」を意思決定していると、確実に慣れが生じて y が無意識の連打になります。こうなると確認の意味がありません。それなら最初から「Enterはページ送り、異常を見つけたときだけ Ctrl+C」と役割を分けたほうが、指は楽をして、目は仕事をする状態を維持できます。
中断手段を Ctrl+C に委譲したことで、コードもシンプルになりました。trap すら書いていません。開始時に1回だけ案内を出すだけです。
echo "※途中で強制終了する場合は Ctrl+C を押してください"
「n を実装しない」「trap を書かない」は手抜きではなく、シェル標準のインターフェース(Ctrl+C=SIGINT)に中断を任せるという設計判断です。独自の中断方法を作るほど、緊急時に「えっと、この場合どう止めるんだっけ」という迷いが生まれます。
改善2: 状態を持たない再開設計
進捗ファイル方式は廃止して、開始時に「どこから始めるか」を人間が入力する方式にしました。
開始行を入力してください(Enterで最初から)>>
- Enterのみ → 1行目から
- 数字(例: 14) → 14行目から
- 範囲外・数字以外 → 再入力を促す
面白いのは、機能を置き換えた結果コードが約10行減ったことです(進捗ファイルの生成・記録・再開確認・完走時の削除、という一連の状態管理が丸ごと不要になったため)。再開機能を置き換えたらコードが減る。状態をファイルではなく人間側に持たせたからです。
| 進捗ファイル方式 | 開始行入力方式 | |
|---|---|---|
| 再開位置の記憶 | 機械(ファイル) | 人間 |
| 手順書を修正した場合 | 行番号がズレて破綻 | 人間が読み替えるだけ |
| 残骸ファイル | 残る(消し忘れリスク) | なし |
| コード量 | 約65行 | 約63行 |
サーバー作業のような「実行のたびに状況が違う」用途では、中途半端に賢い状態管理より、ステートレス+人間への問い合わせ1回のほうが堅牢でした。
改善3: 「読める」表示の工夫
Enter連打で進める設計にした以上、目視確認の質は表示がすべてです。ここには3つの工夫を入れました。
(1) コメント行・空行は「表示するが実行しない」
# 始まりの行(インデント付き含む)と空行は、run? プロンプトを出さずに自動で次の実行対象行まで進みます。ただし前後表示には行番号付きでそのまま出します。
1:# アプリ停止
>2:systemctl stop myapp
3:
4:cp -p app.war /opt/tomcat/webapps/
5:# アプリ起動
6:systemctl start myapp
run?[Enter]>>
コメントを表示から消してしまうと、手順書の文脈(「いま何のセクションを実行しているのか」)が失われます。実行はスキップ、表示は残す。この非対称が「流れを追いながらサクサク進む」感覚を作ります。
(2) 前後5行のコンテキスト表示
当初は前後1行(計3行)でしたが、実際に使うと「次に何が来るか」が見えず不安でした。前後5行(計最大11行)に広げたことで実行前に数手先まで目に入るようになり、「この先に rm があるな」といった予期が働きます。ミスを防ぐのは確認ダイアログではなく、この予期です。
(3) 現在行マーカーと桁揃え
現在行に > を付け、それ以外の行は行頭に半角スペースを入れて桁を揃えています。地味ですが、Enter連打の高速な流れの中で「いま実行されるのはどの行か」を一瞬で識別するには、この視覚的アンカーが効きます。
最終形のコード
最終版の全文です(84行、うちロジック本体は約60行)。sh ask.sh <対象スクリプト> で使います。
#!/bin/sh
# ask.sh : 指定したスクリプトを1行ずつ確認しながら実行する
#
# 開始時に開始行を入力できる(Enter だけなら最初の行から)。
# run?[Enter] : Enter を押すとその行を実行して次の行へ
# コメント行(# 始まり)と空行は実行せず自動スキップする(表示はされる)
# 途中でやめたいときは Ctrl+C で強制終了する
usage() {
cat <<EOF
使い方: sh ask.sh <script>
<script> を1行ずつ、実行前に前後5行を表示して Enter で実行します。
開始時に開始行を入力できます(Enterで最初から)。
コメント行(#)と空行は実行せずスキップします。
途中で終了する場合は Ctrl+C を押してください。
EOF
exit 1
}
[ $# -eq 1 ] || usage
[ -f "$1" ] || { echo "エラー: $1 が見つかりません"; exit 1; }
script=$1
total=$(grep -c '' "$script") # 総行数
# $1 行目の内容を返す(Windows の CRLF 改行の \r は除去)
line() { sed -n "${1}p" "$script" | tr -d '\r'; }
# $1 行目が実行対象外(空行・空白のみ・# 始まりのコメント行)なら真
skip() {
case "$(line "$1" | sed 's/^[[:space:]]*//')" in
''|\#*) return 0 ;;
*) return 1 ;;
esac
}
# y/n を y か n が入力されるまで聞く(y:0 / n:1 を返す。EOF は n 扱い)
yn() {
while :; do
printf '%s' "$1"; read a || return 1
case $a in y) return 0 ;; n) return 1 ;; esac
done
}
yn "[${script}]を実行開始しますよろしいですか?[y/n]>> " || exit 0
echo "※途中で強制終了する場合は Ctrl+C を押してください"
# 開始行を入力で受け付ける(Enterのみ:1行目から / 1〜総行数の数字:その行から)
while :; do
printf '開始行を入力してください(Enterで最初から)>> '
read s || s=
[ -z "$s" ] && { start=1; break; }
case $s in
*[!0-9]*) ;; # 数字以外を含む場合は再入力
*) [ "$s" -ge 1 ] && [ "$s" -le "$total" ] && { start=$s; break; } ;;
esac
echo "1〜${total}の数字を入力してください"
done
# メインループ : 前後5行を表示 → Enter 待ち → 実行(コメント行・空行は飛ばす)
i=$start
while [ "$i" -le "$total" ]; do
if skip "$i"; then i=$((i+1)); continue; fi
echo "-----"
from=$((i-5)); [ "$from" -lt 1 ] && from=1
to=$((i+5)); [ "$to" -gt "$total" ] && to=$total
j=$from
while [ "$j" -le "$to" ]; do
if [ "$j" -eq "$i" ]; then
echo ">${j}:$(line "$j")"
else
echo " ${j}:$(line "$j")"
fi
j=$((j+1))
done
echo
printf 'run?[Enter]>>'
read a || exit 0
echo "-----"
eval "$(line "$i")"
i=$((i+1))
done
echo "${script}を実行終了します"
実装上のポイントをいくつか補足します。
eval で「同一シェル内」実行する理由
各行の実行は sh -c ではなく eval にしています。これにより行をまたいで変数や cd が引き継がれます。手順書スクリプトでは
TARGET_DIR=/opt/myapp/releases
cd $TARGET_DIR
のような書き方が普通に出てくるので、1行ごとに子シェルで実行してしまうと手順書が成立しません。逐次実行のラッパーであっても、実行環境は「1つのシェルで上から流した場合」と同じでなければならない、という要件です。
なお eval を使う以上、対象スクリプトは信頼できるもの(自分たちの手順書)に限るのは大前提です。ask.sh は権限昇格をしない「実行の進行役」であり、サンドボックスではありません。
skip判定は「先頭の空白を剥がしてから」
コメント判定は単純な # 前方一致ではなく、先頭の空白を除去してから判定しています。
case "$(line "$1" | sed 's/^[[:space:]]*//')" in
''|\#*) return 0 ;; # 空行・空白のみ・#始まり → スキップ
esac
インデントされたコメントや、スペースだけの行も確実にスキップするためです。実際の手順書はきれいに整形されているとは限りません。
CRLF 対策を読み取り側に入れる
Windows で編集した手順書を Linux サーバーへ転送すると、CRLF 改行が混ざりがちです。行末の \r が残っていると eval 時に謎のエラーになるため、行の読み取り関数で常に除去しています。
line() { sed -n "${1}p" "$script" | tr -d '\r'; }
「実行前に dos2unix してください」という運用ルールにするのではなく、ツール側が吸収するほうが確実です(ただし ask.sh 自体が CRLF 化した場合は別なので、そこは転送時に注意が必要です)。
bash ではなく POSIX sh で書く
シバンは #!/bin/sh で、配列や [[ ]] などの bashism を使っていません。古めの環境や BusyBox 系でも動かせるようにするためで、「どのサーバーに転送しても動く」ことが道具としての信頼につながります。
まとめ: 確認UIの設計原則3箇条
80行のスクリプトですが、実運用のフィードバックを経て、確認UIについて次の3つの原則に行き着きました。
- 判断させない — 毎行の Yes/No は数十回目には無意識の連打になる。「Enter=ページ送り、Ctrl+C=緊急停止」と役割を分け、人間の注意は「読むこと」に使う
- 状態を持たない — 再開位置のような「実行のたびに変わる状態」は、ファイルに覚えさせるより人間に聞くほうが堅牢。機能を捨てたらコードも減った
- 文脈を見せる — 確認の質は表示で決まる。前後5行+現在行マーカーで「数手先の予期」を働かせ、コメント行は表示に残して手順の流れを保つ
「確認します」というと y/n ダイアログを条件反射で作りがちですが、確認とは判断の強制ではなく、止まれる流れを作ること。この視点は、CLIツールに限らず、デプロイパイプラインの承認フローなどにも通じる気がしています。