2026年8月17日
2026年8月17日
Pythonでシェルスクリプトを置き換える方法|対応表と移行手順・WordPressバックアップの実例
数十行を超えて条件分岐やエラー処理が増えたシェルスクリプトは、Python に置き換えると読みやすく、失敗にも気づきやすくなります。置き換えのコツは、シェルのコマンドを1つずつ subprocess で呼び直すのではなく、ファイル操作や引数処理を Python の標準ライブラリに任せ、外部コマンドは本当に必要なものだけ呼ぶことです。この記事では、置き換えるべきかの判断基準、シェルの書き方と Python の対応表、WordPress のバックアップスクリプトを実際に移行する例、本番で切り替えるときの手順を説明します。
置き換えるべきスクリプト、残すべきスクリプト
すべてのシェルスクリプトを Python にする必要はありません。次のような特徴があれば置き換えを検討する価値があります。
- 引数の種類が増え、
getoptsやcaseの分岐が読みにくくなっている - 失敗したときにどこで止まったのか分からない、ログが残らない
- ファイル名に空白が入ると壊れる、クォートの付け忘れが怖い
- JSON の解析や日付の計算、API 呼び出しを
jqやdateの組み合わせでしのいでいる - 同じ処理を複数のサーバーで使い回しており、テストを書きたい
逆に、数行でパイプをつなぐだけの処理や、Python が入っていない最小構成のコンテナで動く起動スクリプトは、シェルのままのほうが手軽です。「長さ」より「分岐とエラー処理の量」で判断すると失敗しにくくなります。
シェルの書き方と Python の対応表
置き換えの多くは、次の対応で標準ライブラリに移せます。外部コマンドを呼ばずに済むぶん、OS ごとのオプションの違い(GNU と BSD の sed など)にも悩まされなくなります。
| シェル | Python(標準ライブラリ) |
|---|---|
$1、getopts |
argparse |
set -e |
例外で止まる(外部コマンドは check=True) |
trap ... EXIT |
try / finally |
mkdir -p |
Path.mkdir(parents=True, exist_ok=True) |
cp、mv、rm -rf |
shutil.copy2、shutil.move、shutil.rmtree |
find -name -mtime |
Path.glob() / rglob() と stat().st_mtime |
tar czf |
tarfile |
grep、sed |
文字列メソッド、re |
jq |
json |
date +%Y%m%d |
datetime.now().strftime("%Y%m%d") |
mktemp |
tempfile |
$HOME、export VAR= |
os.environ |
echo "..." >&2 |
logging |
exit 1 |
sys.exit(1) |
set -e は便利な反面、パイプの途中やコマンド置換の中の失敗を見逃す場面があり、挙動を正確に覚えるのが難しい仕組みです。Python では例外を捕まえない限り処理が止まるため、「失敗したのに進んでしまう」事故が起きにくくなります。
実例:WordPress のバックアップスクリプトを移行する
よくある WordPress のバックアップ用シェルスクリプトを題材にします。
#!/bin/bash
set -euo pipefail
SITE=/var/www/html
DEST=/backup/wp
DATE=$(date +%Y%m%d-%H%M)
mkdir -p "$DEST"
wp --path="$SITE" db export "$DEST/db-$DATE.sql"
tar czf "$DEST/files-$DATE.tar.gz" -C "$SITE" wp-content
find "$DEST" -name "*.sql" -mtime +7 -delete
find "$DEST" -name "*.tar.gz" -mtime +7 -delete
短いうちはこれで十分ですが、「同時に2本動いたら困る」「失敗したらログに残したい」「保存日数を引数で変えたい」といった要望が出てくると、急に複雑になります。Python で書き直すと次のようになります。
#!/usr/bin/env python3
"""WordPress の DB と wp-content をバックアップし、古いものを削除する。"""
import argparse
import fcntl
import logging
import shutil
import subprocess
import sys
import tarfile
import time
from datetime import datetime
from pathlib import Path
log = logging.getLogger("wp-backup")
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--site", type=Path, default=Path("/var/www/html"))
p.add_argument("--dest", type=Path, default=Path("/backup/wp"))
p.add_argument("--keep-days", type=int, default=7)
p.add_argument("--dry-run", action="store_true", help="削除対象を表示するだけ")
return p.parse_args()
def export_db(site: Path, out: Path) -> None:
wp = shutil.which("wp")
if wp is None:
raise RuntimeError("wp コマンドが見つかりません")
subprocess.run(
[wp, f"--path={site}", "db", "export", str(out)],
check=True, capture_output=True, text=True, timeout=600,
)
def archive_files(site: Path, out: Path) -> None:
with tarfile.open(out, "w:gz") as tar:
tar.add(site / "wp-content", arcname="wp-content")
def remove_old(dest: Path, keep_days: int, dry_run: bool) -> None:
limit = time.time() - keep_days * 86400
for pattern in ("db-*.sql", "files-*.tar.gz"):
for path in dest.glob(pattern):
if path.stat().st_mtime < limit:
log.info("削除%s: %s", "(dry-run)" if dry_run else "", path)
if not dry_run:
path.unlink()
def main() -> int:
args = parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args.dest.mkdir(parents=True, exist_ok=True)
# 二重起動を防ぐ(Linux / macOS 向け)
lock = open(args.dest / ".backup.lock", "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log.error("別のバックアップが実行中です")
return 1
stamp = datetime.now().strftime("%Y%m%d-%H%M")
try:
export_db(args.site, args.dest / f"db-{stamp}.sql")
archive_files(args.site, args.dest / f"files-{stamp}.tar.gz")
remove_old(args.dest, args.keep_days, args.dry_run)
except subprocess.CalledProcessError as e:
log.error("DB エクスポートに失敗しました(終了コード %d): %s", e.returncode, e.stderr.strip())
return 1
except subprocess.TimeoutExpired:
log.error("DB エクスポートが時間内に終わりませんでした")
return 1
except (OSError, RuntimeError) as e:
log.error("バックアップに失敗しました: %s", e)
return 1
finally:
lock.close()
log.info("バックアップ完了: %s", stamp)
return 0
if __name__ == "__main__":
sys.exit(main())
ポイントを順に見ていきます。
**引数は argparse に任せる。** --help が自動で作られ、型の変換(--keep-days を整数に)や既定値も一か所で管理できます。--dry-run を用意しておくと、削除処理を本番で試す前に対象を確認できます。
外部コマンドは WP-CLI だけ。 DB のエクスポートは WP-CLI に任せ、アーカイブ作成と古いファイルの削除は Python 側で行っています。shutil.which() で事前にコマンドの場所を確認しておくと、PATH が限られる cron 実行時の「コマンドが見つからない」が分かりやすいエラーになります。subprocess の細かい使い方(タイムアウト、出力の受け取り、shell=True を避ける理由)は関連記事にまとめています。
終了コードを返す。 main() が 0 か 1 を返し、sys.exit() に渡しています。cron や監視ツール、呼び出し元のシェルは終了コードで成否を判断するため、元のスクリプトと同じ意味の終了コードを返すことが移行の前提になります。
ロックで二重起動を防ぐ。 バックアップが長引いて次の cron の時刻を過ぎると、2本が同時に動いてファイルを壊すおそれがあります。fcntl.flock は Linux と macOS で使える方法で、プロセスが終了すればロックは自動で外れます。
本番で切り替える手順
置き換えで一番怖いのは、元のスクリプトが暗黙にやっていたことを落とすことです。次の順で進めると安全です。
- 元のスクリプトの入出力を書き出す。 引数、読み書きするファイル、環境変数、終了コード、出力先のログを一覧にします。
set -xを付けて一度実行すると、実際に呼ばれているコマンドを確認できます。 - ステージング環境で両方を動かして比べる。 作られたファイルの名前・サイズ・中身(
tar tzfで一覧を見るなど)を比べます。バックアップの場合は、取得したファイルから実際に復元できるかまで確かめておくと安心です。 - cron の呼び出し先を差し替える。 元のスクリプトは削除せず、しばらく残しておけばすぐ戻せます。
実行環境にも注意します。最近の Debian / Ubuntu などでは、システムの Python に pip install しようとすると externally-managed-environment というエラーで止まります。運用スクリプトはなるべく標準ライブラリだけで書き、外部パッケージが必要なら仮想環境を作って、cron からは仮想環境内の python をフルパスで呼びます。
WP-CLI を root で実行すると、安全のため --allow-root を付けない限り処理を拒否されます。root で動かすより、sudo -u www-data のように Web サーバーのユーザー(環境によって名前は異なります)で実行するほうが、ファイルの所有者もずれません。
対応する Python のバージョンも確認しておきます。上の例は Python 3.8 以降で動く書き方にしてありますが、サポートが続いているバージョンは年々変わるため、サーバーの python3 --version を確認し、公式のサポート期限が切れたものは更新を計画してください。3.9 以前はすでにサポートが終了しており、3.10 もサポート期限を迎える時期にあたります(2026年10月現在)。
まとめ
- 置き換えの判断は行数ではなく、分岐とエラー処理の量で決める
- ファイル操作・引数・日付・JSON は標準ライブラリに任せ、外部コマンドは必要なものだけ
subprocess.run(check=True)で呼ぶ - 終了コードを元のスクリプトと揃え、ログとロックで運用上の事故を防ぐ
- ステージングで新旧を並べて結果を比べ、元のスクリプトを残したまま cron を差し替える
- システムの Python には直接 pip で入れず、標準ライブラリか仮想環境を使う