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 で使える方法で、プロセスが終了すればロックは自動で外れます。

本番で切り替える手順

置き換えで一番怖いのは、元のスクリプトが暗黙にやっていたことを落とすことです。次の順で進めると安全です。

  1. 元のスクリプトの入出力を書き出す。 引数、読み書きするファイル、環境変数、終了コード、出力先のログを一覧にします。set -x を付けて一度実行すると、実際に呼ばれているコマンドを確認できます。
  2. ステージング環境で両方を動かして比べる。 作られたファイルの名前・サイズ・中身(tar tzf で一覧を見るなど)を比べます。バックアップの場合は、取得したファイルから実際に復元できるかまで確かめておくと安心です。
  3. 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 で入れず、標準ライブラリか仮想環境を使う

お気軽にご相談ください

お見積りへ お問い合わせへ
▲