シェルスクリプトでバックアップの世代管理を作ってみよう(総合演習)
ここまでの シェルスクリプト レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。変数・配列・for・if・case・関数・コマンド置換・終了ステータス——すでに練習したものだけで組み立てます。
作るのはバックアップの世代管理です。溜まったバックアップを数え、古いものを見つけ、残す世代数を超えた分を削除の対象として示します。
この演習には、いちばん大事な設計上の判断が入っています。——既定では何も消しません。--dry-runで「消す対象を表示するだけ」にしてあり、実際に消すには--applyを明示します。
これは教材のための遠慮ではありません。ファイルを消すスクリプトは、一度動かすと取り返しがつきません。パスを1文字間違えただけで、消えてはいけないものが消えます。「まず見せて、それから消す」——これが実務で正しい作り方です。
また、この演習が触るのはスクリプト自身が作った一時ディレクトリだけです。あなたのファイルには一切触れません。
このページではコードを実行していません。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。
表示している実行結果は本物です。このページを作るときに、実際のbashで走らせて得た出力をそのまま載せています。
完成イメージ
最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。
===== バックアップの世代管理 ===== 対象: (一時ディレクトリ)/backups 動作: --dry-run(残す世代数 3) 基準日: 20260818 --- 見つかったバックアップ --- 1. app-20260810.tar.gz 日付20260810(8日前) 2. app-20260812.tar.gz 日付20260812(6日前) 3. app-20260814.tar.gz 日付20260814(4日前) 4. app-20260816.tar.gz 日付20260816(2日前) 5. app-20260818.tar.gz 日付20260818(0日前) --- 判定 --- 合計 5件 / 残す 3件 / 削除対象 2件 削除する(予定): app-20260810.tar.gz 削除する(予定): app-20260812.tar.gz ※ --dry-run なので何も消していません。--apply で実行します。 対象外のファイル: 1件(名前が合わないものは触りません)
STEP 1set -euo pipefail で安全に始める
🎯 このステップのゴール: 失敗をそこで止め、未定義の変数を使わせない
スクリプトの1行目は#!/bin/bashです。シェバンと呼ばれ、「このファイルはbashで実行する」という指定になります。
そして2行目——この演習でいちばん大事な1行です。
set -euo pipefail
3つの指定がまとめて書かれています。
-eは「コマンドが失敗したら、そこで止まる」。これが無いと、途中で失敗しても最後まで走り続けます。バックアップ先のディレクトリが無いのに気づかず、次の行でrmが走る——実際に起きる事故です。
-uは「未定義の変数を使ったらエラー」。これが無いと、$WORKDIRの綴りを間違えても空文字として扱われます。rm -rf "$WORKDIR/backups"がrm -rf "/backups"になります——これが「シェルスクリプトで一番怖い事故」の正体です。
-o pipefailは「パイプの途中で失敗したら、全体を失敗とみなす」。既定では最後のコマンドの結果しか見ません。存在しないコマンド | sortはsortが成功するので全体が成功扱いになってしまいます。
この3つは、書く手間に対して守ってくれるものが大きすぎます。bashスクリプトを書くなら、まずこの1行から始めてください。
作業場所はmktemp -dで作ります。重ならない名前のディレクトリを作ってくれるコマンドで、自分で/tmp/workのような固定の名前を決めるより安全です——同時に2つ動いてもぶつかりません。
作業場所を作りました ファイル数: 6 app-20260810.tar.gz app-20260812.tar.gz app-20260814.tar.gz app-20260816.tar.gz app-20260818.tar.gz notes.txt 片付けました
STEP 2trapで後片付けを保証する
🎯 このステップのゴール: どう終わっても一時ディレクトリが残らないようにする
ステップ1では、最後にrm -rf "$WORKDIR"と書いて片付けました。ですが、これでは足りません。
途中でエラーが起きたら、その行に届きません。set -eを入れているので、失敗した時点で止まります——一時ディレクトリが残ったままです。
解決するのがtrapです。
trap cleanup EXIT——「スクリプトが終わるとき、どんな終わり方でもcleanupを呼ぶ」という指定になります。
「どんな終わり方でも」が大事です。正常に終わっても、エラーで止まっても、Ctrl+Cで中断されても——必ず呼ばれます。
これは他の言語のfinallyやデストラクタと同じ考え方です。「開いたら閉じる」「作ったら片付ける」を書き忘れようがない場所に置く——シェルスクリプトでもこれができます。
trapは関数を定義した後に書いてください。まだ存在しない関数を指定すると、呼ばれたときに失敗します。
EXITのほかにINT(Ctrl+C)やTERM(終了要求)も指定できますが、EXITだけで大半は足ります——他の合図で終わるときも、最終的にはEXITを通るからです。
rm -rfを書くときは、変数が空でないことに注意してください。rm -rf "$WORKDIR"で$WORKDIRが空だとrm -rf ""——幸い何も起きませんが、rm -rf "$WORKDIR/"だとrm -rf "/"になります。set -uを入れておく理由がここにあります。
ファイル数: 6 処理を終えます (cleanup が呼ばれました)
STEP 3対象のファイルだけを選び出す
🎯 このステップのゴール: grepで名前の形を確かめ、関係ないファイルを除く
バックアップ置き場にはnotes.txtのような関係ないファイルも混ざります。
ここが世代管理でいちばん危ないところです。「置き場にあるものを全部消す」と書いたスクリプトは、誰かがメモを置いた日に、そのメモも消します。
だから名前の形で絞り込みます。
ls "$dir" | grep -E '^app-[0-9]{8}\.tar\.gz$' | sort
-Eは拡張正規表現を使う指定です。^と$で名前まるごとが一致することを確かめています——これが無いとmy-app-20260810.tar.gz.bakのようなものまで拾います。
\.と打ち消しているのも大事です。正規表現の.は「どんな1文字でも」という意味なので、打ち消さないとapp-20260810Xtar.gzにも合ってしまいます。
[0-9]{8}で数字8桁——日付の形です。これでapp-old.tar.gzのようなものも除けます。
sortを通しているのは日付順に並べるためです。YYYYMMDDという形なら、文字として並べ替えるだけで日付順になります——桁が揃っているからです。
この「桁を揃えて並べ替え可能にする」は、ファイル名を決めるときの定石です。app-2026-8-10のように桁が揃っていないと、この手は使えません。
結果を配列に貯めるところにbash特有の書き方が出てきます。while IFS= read -r f; do ... done < <(...)——<(...)がプロセス置換です。
普通にパイプで繋ぐと、whileが別のシェルで動くので、配列に貯めても消えてしまいます。プロセス置換なら同じシェルの中で回るので、貯めた配列がそのまま使えます。
--- 置き場にある全ファイル --- app-20260810.tar.gz app-20260812.tar.gz app-20260814.tar.gz app-20260816.tar.gz app-20260818.tar.gz notes.txt --- 対象になるものだけ --- app-20260810.tar.gz app-20260812.tar.gz app-20260814.tar.gz app-20260816.tar.gz app-20260818.tar.gz 配列に入った件数: 5
STEP 4caseで引数を読み分ける
🎯 このステップのゴール: --dry-run を既定にし、知らない指定は使い方を出して止める
スクリプトの動きを引数で変えられるようにします。
while [ $# -gt 0 ]; do case "$1" in ... esac; shift; done
$#が残っている引数の数、$1が先頭の引数です。shiftで1つ捨てて次へ進みます。これが引数を順に処理する定番の形です。
caseの各分岐を見てください。
--dry-run|--applyは「どちらか」という書き方です。|で並べられます。
[0-9]*は「数字で始まるもの」——残す世代数として受け取ります。caseのパターンは正規表現ではなくグロブなので、*が「任意の文字列」です。
*)が「それ以外すべて」。ここを必ず書いてください——知らない引数を黙って無視すると、打ち間違いに気づけません。--aplyと打ったのに--dry-runのまま動いた、では困ります。
echo "不明な引数: $1" >&2の>&2は「標準エラー出力へ」という指定です。
エラーは標準エラー出力へ出してください。そうすれば結果だけをファイルに保存したいとき、エラーが混ざりません。パイプで次のコマンドに渡すときも同じです。
return 1で失敗を伝えます。0が成功、それ以外が失敗——シェルの約束です。呼び出した側は$?や&&で判定できます。
既定を--dry-runにしているのが、この演習の要です。引数を何も付けずに実行しても、何も消えません。危ないほうを既定にしないでください——うっかり実行されるのは、いつも危ないほうです。
引数なし: mode=--dry-run keep=3 --apply: mode=--apply keep=3 --apply 5: mode=--apply keep=5 打ち間違いは弾かれました
STEP 5算術式で件数と日数を計算する
🎯 このステップのゴール: $((...))で数を扱い、削除対象の件数を決める
削除する件数を決めます。「見つかった数 − 残す数」です。
remove=$((total - keep))——$(( ))が算術式です。
シェルでは変数はすべて文字列です。total=5と書いても、それは文字の5。$(( ))の中でだけ、数として扱われます。
中では$を付けなくても変数が読めます。$((total - keep))——$((\$total - \$keep))と書いても同じですが、付けないほうが読みやすいのでそちらが普通です。
日付の差も同じように計算しています。age=$((FIXED_DATE - d))——ただしこれは正確な日数ではありません。20260818 - 20260810は8になりますが、月をまたぐと大きな値になります。
正確に日数を出すならdateコマンドで秒に直して引き算します。この演習では同じ月の中に収めてあるので、単純な引き算で足ります——「どこまで正確ならよいか」を決めるのも設計のうちです。
日付の取り出しはsed -E 's/^app-([0-9]{8})\.tar\.gz$/\1/'です。\1が括弧で捕まえた部分——ステップ3のgrepと同じパターンを、今度は取り出すために使っています。
${#files[@]}は配列の要素数です。#が「数を取る」記号で、${#変数}なら文字列の長さになります。
[ "$total" -eq 0 ]の-eqは数値の比較です。文字列の比較は=——混同すると意図と違う判定になります。数なら-eq -lt -gt -le -geと覚えてください。
変数を"$total"と必ず引用符で囲んでいるのにも理由があります。空だったときに[ -eq 0 ]となって構文エラーになるのを防ぎます。
見つかった数: 5 残す数: 3 削除対象: 2 app-20260810.tar.gz 日付20260810(8日前) app-20260812.tar.gz 日付20260812(6日前) app-20260814.tar.gz 日付20260814(4日前) app-20260816.tar.gz 日付20260816(2日前) app-20260818.tar.gz 日付20260818(0日前)
STEP 6ひとつのmainにまとめて仕上げる
🎯 このステップのゴール: 全部をmainに集約し、消す前に必ず見せる形にする
最後の仕上げです。ここまでの部品をmainという1つの関数にまとめます。
末尾のmain "$@"がスクリプト全体の入り口です。"$@"が「受け取った引数を全部そのまま渡す」という書き方——引用符とアットマークの組み合わせでなければなりません。
$*だと、全部が1つの文字列にくっつきます。空白を含む引数が壊れます。"$@"だけが1つずつ別の引数として渡します。
この「処理をmainに閉じ込めて、最後に1行で呼ぶ」形には利点があります。——関数の定義がすべて先に読まれてから実行が始まるので、「下で定義した関数を上で呼んでしまった」という事故が起きません。
そしてこの演習でいちばん伝えたい部分です。
if [ "$mode" = "--apply" ]; then rm -f ...; else echo "削除する(予定): $f"; fi
既定では、消す代わりに名前を表示するだけです。そして最後に「※ --dry-run なので何も消していません」と念を押します。
この作りには3つの利点があります。——間違いに気づける(消す前に一覧が見える)、安心して試せる(初めて動かすときも怖くない)、説明に使える(「これが消えます」と人に見せられる)。
最後に対象外のファイル数も出しています。grep -vc——-vが反転、-cが件数。「触らなかったものが何件あるか」を示すことで、絞り込みが効いていることが確かめられます。
|| trueを付けているのは、grepが1件も見つからないと終了ステータス1を返すからです。set -eがあるとそこで止まってしまうので、「見つからなくてもよい」場面では打ち消します。
完成です。KEEPを1に変えてみてください。削除対象が4件に増えます。10にすれば「削除するものはありません」になります。
===== バックアップの世代管理 ===== 対象: (一時ディレクトリ)/backups 動作: --dry-run(残す世代数 3) 基準日: 20260818 --- 見つかったバックアップ --- 1. app-20260810.tar.gz 日付20260810(8日前) 2. app-20260812.tar.gz 日付20260812(6日前) 3. app-20260814.tar.gz 日付20260814(4日前) 4. app-20260816.tar.gz 日付20260816(2日前) 5. app-20260818.tar.gz 日付20260818(0日前) --- 判定 --- 合計 5件 / 残す 3件 / 削除対象 2件 削除する(予定): app-20260810.tar.gz 削除する(予定): app-20260812.tar.gz ※ --dry-run なので何も消していません。--apply で実行します。 対象外のファイル: 1件(名前が合わないものは触りません)
よくある質問
このページではコードを実行しないのですか?
していません。シェルスクリプトはブラウザの中では走らせられないためです。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。
ただし表示している実行結果は本物です。このページを作るときに実際のbashで走らせた出力を、そのまま載せています。
このスクリプトを試すと、自分のファイルが消えますか
消えません。理由が2つあります。
1つ目——触るのはスクリプト自身がmktemp -dで作った一時ディレクトリだけです。中身もスクリプトが自分で作ったダミーファイルで、終わるときにtrapでまるごと片付けます。
2つ目——既定が--dry-runなので、引数を付けずに実行するかぎりrmは1度も走りません。
自分の環境に合わせて書き換えるときは、--dry-runのまま何度か動かして、表示される一覧が意図どおりか確かめてから--applyを使ってください。
set -euo pipefail は毎回書くべきですか
書いてください。3つとも、手間に対して守ってくれるものが大きすぎます。
とくに-uは効きます。変数の綴りを間違えたとき、これが無いと空文字として扱われます。rm -rf "$WORKDIR/"の$WORKDIRが空になればrm -rf "/"——シェルスクリプトで最も有名な事故が、これで起きます。
ただし-eには例外があります。失敗してもよいコマンドには|| trueを付けて打ち消してください。この演習の最後のgrep -vcがその例です。
while ループで配列に入れたのに空になります
パイプで繋いでいませんか。
list_backups | while read f; do files+=("$f"); doneと書くと、whileが別のシェルで動きます。そのシェルの中で配列に足しても、ループを抜けた時点で消えてしまいます。
解決はプロセス置換です。while ... done < <(list_backups ...)と書けば、whileが同じシェルの中で回ります。
この違いはbashを書いていて最も戸惑うところの1つで、「ループの中では正しいのに、抜けると消える」という症状で現れます。
🚩 次はこのカテゴリへ
スクリプトを書く前に、1つずつのコマンドに慣れておくと格段に楽になります。この演習で使ったls・grep・sed・sortは、どれも単体で強力な道具です。Linuxカテゴリの最終演習では、それらを組み合わせてスクリプトを書かずに調べ上げる流れを練習できます。