本ページはプロモーションが含まれています。

Pythonで書いた定期バッチをレンタルサーバーのcronに載せたら、1回30分を超えたあたりで途中で止まり、ログが中途半端に切れている。そんな「レンタルサーバー cron 強制終了」の悩みは、共有サーバーの制限と、全件を一気に処理する書き方の組み合わせで起きます。この記事では、止められても続きから再開できる今の書き方と実行時間の測り方を示し、そのうえで共有サーバー・VPS・自宅のどれに置くべきかを2026年11月時点の料金で判断します。

30分超のPythonバッチが共有サーバーで止まる理由と、以前の書き方の弱点

共有サーバーには「1プロセスが使える量」の上限がある

さくらのレンタルサーバやエックスサーバーのような共有サーバー(1台のサーバーを多数の利用者で分け合う形態)では、1人の処理がサーバー全体を圧迫しないように、プロセスの実行時間やCPU時間(CPUが実際に計算に使った時間)に上限を設けているのが普通です。上限の具体的な値や、どの指標で打ち切るかは事業者・プランごとに異なり、変更されることもあります。数値は必ず各社のマニュアル・利用規約で確認してください。この記事では特定の数値を前提にせず、「どこかで打ち切られても困らない書き方」と「自分のバッチが何分・どれだけCPUを使っているかを測る方法」を示します。

また、cronは使えても常駐プロセス(ずっと動き続けるプログラム)は規約上想定されていないことが多く、nohup でバックグラウンドに逃がして長時間動かす方法は、規約違反と判断される可能性があります。共有サーバーで長い処理を回すなら「短く区切って何度も起動する」が基本です。

以前はこう書いていた:全件を1回のcronで処理する

以前の私のバッチは、対象URLの一覧を読み込んで、上から順に全部処理するだけの作りでした。

import time
import requests

urls = open("/home/username/batch/urls.txt", encoding="utf-8").read().split()

results = []
for url in urls:
    resp = requests.get(url, timeout=30)
    results.append((url, len(resp.content)))
    time.sleep(3)

with open("/home/username/batch/result.csv", "w", encoding="utf-8") as f:
    for url, size in results:
        f.write(f"{url},{size}\n")

この書き方には、長時間バッチにとって致命的な弱点が3つあります。

  • 途中で止められると全部やり直し:結果を最後にまとめて書き出すので、強制終了されると何も残らず、次回もまた先頭から始まります。件数が多いと永遠に終わりません。
  • 前回分と重なって二重に動く:処理が長引いている間に次のcronが起動すると、同じサイトに2本同時にアクセスすることになり、相手への負荷も倍になります。
  • 止まったかどうかが分からない:開始・終了の記録が無いので、正常終了したのか打ち切られたのか、何分かかったのかが後から追えません。

今はこう書く:時間予算・チェックポイント・ロックで再開できるバッチ

実行環境

  • Python 3.10以上を想定(サーバー上で python3 --version で確認)
  • 追加パッケージは requests のみ。ほかは標準ライブラリ(sqlite3・fcntl・signal など)
  • バージョン固定が必要な機能は使っていないので、指定は不要です。導入されたバージョンは pip show requests で確認できます
python3 -m venv /home/username/batch/.venv
/home/username/batch/.venv/bin/pip install requests
/home/username/batch/.venv/bin/pip show requests

共有サーバーによっては venv が作れない場合があります。その場合は pip install --user requests でユーザー領域に入れる方法を、各社のマニュアルで可否を確認したうえで使ってください。

処理対象を登録するスクリプト(seed.py)

まず「今日処理すべきURL」をSQLite(ファイル1つで動くデータベース)に pending(未処理)として登録します。1日1回、バッチ本体より先に動かします。

#!/usr/bin/env python3
"""その日の処理対象を pending として登録し直す"""
import sqlite3
from pathlib import Path

DB_PATH = Path("/home/username/batch/state.sqlite3")
URL_LIST = Path("/home/username/batch/urls.txt")

conn = sqlite3.connect(DB_PATH)
conn.execute("""CREATE TABLE IF NOT EXISTS tasks (
    url TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'pending',
    result TEXT,
    updated_at REAL)""")

urls = [line.strip() for line in URL_LIST.read_text(encoding="utf-8").splitlines() if line.strip()]
conn.executemany(
    "INSERT OR REPLACE INTO tasks (url, status) VALUES (?, 'pending')",
    [(u,) for u in urls],
)
conn.commit()
conn.close()

INSERT OR REPLACE で、前日に処理済みだった行も pending に戻します。URLを主キーにしているので、一覧に重複があっても1件として扱われます。

バッチ本体(batch.py)

#!/usr/bin/env python3
"""長時間バッチを時間予算で区切り、途中から再開できるようにした版"""
import fcntl
import json
import logging
import os
import signal
import sqlite3
import sys
import time
from pathlib import Path

import requests

BASE_DIR = Path("/home/username/batch")
DB_PATH = BASE_DIR / "state.sqlite3"
LOCK_PATH = BASE_DIR / "batch.lock"
LOG_PATH = BASE_DIR / "batch.log"

# 1回の実行で使う上限時間(秒)。0 なら無制限
TIME_BUDGET_SEC = int(os.environ.get("BATCH_TIME_BUDGET_SEC", 20 * 60))
REQUEST_INTERVAL_SEC = 3
USER_AGENT = "example-batch/1.0 (+https://example.com/contact)"

logging.basicConfig(
    filename=LOG_PATH,
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

stop_requested = False


def handle_stop(signum, frame):
    global stop_requested
    stop_requested = True
    logging.warning("signal %s received, stopping after current item", signum)


def acquire_lock():
    f = open(LOCK_PATH, "w")
    try:
        fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except BlockingIOError:
        logging.info("previous run still active, skip")
        sys.exit(0)
    return f


def next_task(conn):
    row = conn.execute(
        "SELECT url FROM tasks WHERE status='pending' ORDER BY rowid LIMIT 1"
    ).fetchone()
    return row[0] if row else None


def process(session, url):
    resp = session.get(url, timeout=30)
    resp.raise_for_status()
    return {"length": len(resp.content)}  # 実際の抽出処理に置き換える


def main():
    lock = acquire_lock()
    for name in ("SIGTERM", "SIGINT", "SIGXCPU"):
        if hasattr(signal, name):
            signal.signal(getattr(signal, name), handle_stop)

    conn = sqlite3.connect(DB_PATH)
    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT

    started = time.monotonic()
    done = 0
    reason = "completed"
    logging.info("run start budget=%ds", TIME_BUDGET_SEC)

    while True:
        if stop_requested:
            reason = "signal"
            break
        if TIME_BUDGET_SEC and time.monotonic() - started > TIME_BUDGET_SEC:
            reason = "budget"
            break
        url = next_task(conn)
        if url is None:
            break
        try:
            result = process(session, url)
            conn.execute(
                "UPDATE tasks SET status='done', result=?, updated_at=? WHERE url=?",
                (json.dumps(result), time.time(), url),
            )
        except requests.HTTPError as e:
            if e.response is not None and e.response.status_code in (429, 503):
                logging.warning("throttled by server at %s, stop this run", url)
                reason = "throttled"
                break
            logging.warning("failed %s: %s", url, e)
            conn.execute(
                "UPDATE tasks SET status='error', updated_at=? WHERE url=?",
                (time.time(), url),
            )
        except requests.RequestException as e:
            logging.warning("failed %s: %s", url, e)
            conn.execute(
                "UPDATE tasks SET status='error', updated_at=? WHERE url=?",
                (time.time(), url),
            )
        conn.commit()
        done += 1
        time.sleep(REQUEST_INTERVAL_SEC)

    remaining = conn.execute(
        "SELECT COUNT(*) FROM tasks WHERE status='pending'"
    ).fetchone()[0]
    logging.info(
        "run end reason=%s done=%d remaining=%d wall=%.1fs cpu=%.1fs",
        reason, done, remaining, time.monotonic() - started, time.process_time(),
    )
    conn.close()


if __name__ == "__main__":
    main()

ブロックごとの説明

  • 設定部分:TIME_BUDGET_SEC は「1回の起動で処理してよい時間」です。共有サーバーでは事業者の上限より十分短く(初期値20分)、VPSでは環境変数で 0(無制限)に切り替えられるようにしています。同じコードを置き場所によって変えずに使うためです。
  • handle_stop:終了を求めるシグナル(OSからプロセスに送られる通知)を受けたら、すぐには落ちずにフラグだけ立て、今処理中の1件を終えてから抜けます。SIGXCPU はOSのCPU時間制限に達したときに送られることがあるシグナルで、存在する環境だけ登録しています。なお、強制終了の SIGKILL はプログラム側で受け取れません。
  • acquire_lock:ロックファイルに排他ロックを掛け、前回の実行がまだ動いていれば今回は何もせず終了します。これで二重起動が起きません。
  • next_task:データベースから未処理の1件だけを取り出します。一覧をメモリに全部読み込まないので、件数が増えてもメモリ使用量がほぼ変わりません。
  • メインループ:1件処理するたびに commit(データベースへの確定保存)します。SIGKILL で突然落とされても、失うのは処理中だった1件だけで、次回はその続きから始まります。これが以前の書き方との最大の違いです。
  • 429・503の扱い:相手サーバーが「混んでいる・減速してほしい」と返したときは、その回の処理を打ち切り、URLは未処理のまま残します。再試行で押し切らない設計です。
  • 終了ログ:終了理由、処理件数、残り件数、経過時間(wall)、CPU時間(cpu)を1行で残します。この2つの時間が、置き場所を選ぶときの判断材料になります。

共有サーバーのcronに登録する

0 3 * * * /home/username/batch/.venv/bin/python /home/username/batch/seed.py >> /home/username/batch/cron.log 2>&1
*/30 * * * * /home/username/batch/.venv/bin/python /home/username/batch/batch.py >> /home/username/batch/cron.log 2>&1

毎日3時に対象を登録し直し、30分ごとにバッチを起動します。1回20分で区切られても、30分後の起動で続きが処理され、全件終われば以降の起動は「残り0件」ですぐ終わります。cronの最短実行間隔や設定方法(コントロールパネルから登録するか、crontab -e が使えるか)は事業者ごとに異なるので、マニュアルで確認してください。

実行時間とメモリを測る:置き場所を決める前にやること

止められたかどうかをログで見分ける

grep -E "run (start|end)" /home/username/batch/batch.log | tail -n 20

見るべきポイントは次のとおりです。

ログの状態

意味

対応

run start の次に run end がある

正常に区切って終了

そのままでよい

run start の次がまた run start

終了ログを書く前に落とされた(SIGKILL など)

TIME_BUDGET_SEC を短くする

reason=signal

終了要求のシグナルを受けて抜けた

事業者の上限に近い。予算を短くする

remaining が翌日の登録時刻まで0にならない

1日分が処理しきれていない

共有サーバーでは限界。VPSか自宅を検討

wallとcpuの差を見る

スクレイピングはアクセス間隔の待ち時間が大半なので、経過時間(wall)は長くてもCPU時間(cpu)は小さいことが多いです。一方、取得後の集計や画像処理・ヘッドレスブラウザ(画面を出さずに動かすブラウザ)を使う処理はCPU時間が伸びます。事業者の上限がCPU時間なのか経過時間なのかによって、同じバッチでも止まる・止まらないが変わるので、両方を記録しておく意味があります。

最大メモリを測る

BATCH_TIME_BUDGET_SEC=300 /usr/bin/time -v /home/username/batch/.venv/bin/python /home/username/batch/batch.py

手元のLinuxやVPSで、5分だけ動かして出力の Maximum resident set size(プロセスが使った最大メモリ、単位はKB)を確認します。後述するVPSのメモリ1GBプランで足りるか、2GBにすべきかはこの値で決めます。私の経験では、requests だけの取得処理は小さく収まりますが、ヘッドレスブラウザを使うと一気に増えるので、必ず実測してから選んでください。

共有サーバー・VPS・自宅の料金と止まる条件(2026年11月時点)

料金と制約の比較表

以下は2026年11月時点の調査に基づく数値です。料金は36ヶ月契約などの長期契約時の月額換算が中心で、契約期間で変わります。申し込み前に公式サイトで最新の料金と受付状況を確認してください。

サービス

種別

月額の目安(税込)

cron

常駐・root

2026年11月時点の注意点

エックスサーバー スタンダード

共有

990円(36ヶ月一括前払い)

可

常駐は想定されていない/rootなし

10日間無料お試しあり。11月4日17時で最大30%オフキャンペーン終了、その後の公式キャンペーンなし。10月21日公表のPHP脆弱性は修正済みで新規申し込み再開

シン・レンタルサーバー ベーシック

共有

1,078円(36ヶ月)

可(要確認)

常駐は想定されていない/rootなし

vCPU 6コア・メモリ8GB・NVMe SSD 700GB。10日間無料お試しあり

ロリポップ! ハイスピード

共有

660円(36ヶ月)

SSHはスタンダード以上

常駐は想定されていない/rootなし

10月24日から新規申し込みを一時停止中(再開時期未定)

ConoHa VPS

VPS

時間課金/まとめトク(メモリ1GB・2コアで月額約700円という個人の運用例あり)

自由

可/rootあり

Linuxカーネル脆弱性対応で新規受付を停止。10月23日に再開後、24日に緊急メンテナンスで再停止。月間稼働率99.99%以上のSLA、データ転送量無制限

シンVPS 大容量メモリ

VPS

1GB:451円、2GB:950円(36ヶ月)

自由

可/rootあり

9月1日に料金改定(値上げ)後の価格。無料お試しなし、最低利用期間3ヶ月

KAGOYA CLOUD VPS

VPS

日額20円から。2Core/2GB/NVMe 200GBで770円

自由

可/rootあり

定期スナップショット機能が9月1日から停止中。10月27日に新規受付を再開

XServer VPS

VPS

公式で確認(9月1日に料金改定)

自由

可/rootあり

2GBプランは新規受付停止中

さくらのVPS

VPS

公式で確認

自由

可/rootあり

クレジットカード払いで2週間無料お試し。SLA提供開始。石狩リージョンは10月23日、大阪リージョンの一部プランは10月30日に新規受付再開

Amazon Lightsail

VPS

3.50ドル(IPv6専用)/5ドル(IPv4付き)から。東京の2GBメモリ・60GB SSDで12ドル

自由

可/rootあり

新規向け3ヶ月無料トライアルは終了し、AWS Free Tierのクレジットに置き換え

自宅サーバー

自宅

電気代と回線費用(機器代は別途)

自由

可/rootあり

停電・回線障害・機器故障がそのまま停止につながる

切り口として挙げたConoHa VPSは、2026年11月時点で新規受付が止まっています。これから移行先を決めるなら、受付が再開しているシンVPS・KAGOYA CLOUD VPS・さくらのVPSなども同じ条件で比べるのが現実的です。2026年10月にはLinuxカーネルの脆弱性対応で複数のVPS事業者が相次いで新規受付を止めており、「申し込みたいときに申し込めるとは限らない」ことは置き場所選びの前提として覚えておくべきです。

VPSではsystemdタイマーで動かす

VPSではroot権限があるので、cronでもよいのですが、私は今はsystemd(Linuxでサービスの起動・停止を管理する仕組み)のタイマーを使っています。前回の実行が終わっていなければ次を起動しない、電源が落ちていた間に予定時刻を過ぎた分を起動後に実行する、といった動きを設定だけで書けるからです。

# /etc/systemd/system/batch.service
[Unit]
Description=daily scraping batch

[Service]
Type=oneshot
User=username
WorkingDirectory=/home/username/batch
Environment=BATCH_TIME_BUDGET_SEC=0
ExecStartPre=/home/username/batch/.venv/bin/python seed.py
ExecStart=/home/username/batch/.venv/bin/python batch.py
TimeoutStartSec=4h
# /etc/systemd/system/batch.timer
[Unit]
Description=run batch every day at 03:00

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now batch.timer
systemctl list-timers batch.timer
journalctl -u batch.service -n 50
  • Environment=BATCH_TIME_BUDGET_SEC=0:VPSでは時間予算を外し、1回で最後まで処理させます。コードは共有サーバー版と同じです。
  • ExecStartPre:本体の前に seed.py を実行し、その日の対象を登録します。cronのように時刻をずらして2行書く必要がありません。
  • TimeoutStartSec=4h:相手サーバーの応答待ちなどで固まったときの上限です。普段の所要時間を測ってから、余裕を持った値にしてください。
  • Persistent=true:再起動などで3時を逃した場合、起動後に実行します。

1回30分以上の定期バッチはどこに置くか:条件別の結論

判断の手順

  1. 前述の終了ログで wall と cpu を数日分記録する
  2. 共有サーバーで時間予算を区切った運用にして、翌日の登録時刻までに remaining=0 になるか確認する
  3. ならない、または run start が連続する(落とされている)なら、VPSか自宅に移す
  4. VPSのメモリは /usr/bin/time -v の最大メモリを見て、1GBで余裕がなければ2GBにする

条件別のおすすめと、それぞれが止まる条件

置き場所

向いている条件

止まる条件

手元のPC

試作中、または週に数回だけ動けばよい

スリープ・シャットダウン・OSの自動更新による再起動。PCを持ち出している間も止まる

共有レンタルサーバーのcron

待ち時間が中心でCPU時間が小さく、区切って再開する書き方で1日分が終わる。すでにWebサイト用に契約している

事業者の実行時間・CPU時間の上限に掛かる。規約・制限の変更。メモリを多く使う処理(ヘッドレスブラウザなど)は制限に当たりやすい

VPS

1回で30分以上かけて最後まで処理したい。常駐やroot権限が要る。区切っても1日分が終わらない

メモリ不足でプロセスが落とされる。事業者のメンテナンス。契約更新・支払いの失敗。OS更新後の再起動(Persistent=true で取りこぼしは防げる)

自宅サーバー

CPU・メモリを大きく使う処理で、24時間動かせる機器がすでにある

停電・回線障害・機器の故障・夏場の熱。家庭内の事情で電源を抜かれる

私の結論として、「1回の処理に30分以上かかる定期バッチ」なら、基本はVPSです。シンVPSの1GBプランなら36ヶ月契約で月額451円、2GBでも950円で、エックスサーバー スタンダードの990円と同程度以下で、時間制限を気にせず最後まで回せます。共有サーバーは、区切って再開する書き方で1日分が確実に終わる場合に限り、すでに契約しているなら追加費用なしで使える選択肢です。

自宅サーバーは、スクレイピングのような「こちらから外へ取りに行くだけ」の処理なら、ポート開放や固定IPアドレスは要りません。外部に公開しない分、自宅運用の難所の多くを避けられます。ただし停電や回線障害で止まったことに気づける仕組み(終了ログの確認や通知)は自分で用意する必要があります。常駐するプログラムをVPSと自宅のどちらに置くかは、Ollama 常駐はVPSか自宅か|メモリ4GB比較 26年11月でもメモリ量を軸に比べています。処理結果を画面で見せたい場合の置き場所はStreamlit 常時公開の置き場所3択比較|26年11月が参考になります。

取得先サイトへの配慮:robots.txt・利用規約・アクセス間隔

処理を分割しても、アクセスの速さは変えない

時間予算で区切る書き方は、自分の環境の制限に合わせるためのもので、相手へのアクセスを速めるためのものではありません。並列化して短時間で終わらせる、制限を回避するために接続元を変える、といった書き方はしないでください。上のコードは次の方針で書いています。

  • 1件ごとに3秒待つ(REQUEST_INTERVAL_SEC)。相手が間隔を指定していればそれに従う
  • ロックで二重起動を防ぎ、同時に複数本でアクセスしない
  • 429・503を受けたらその回は打ち切り、再試行で押し切らない
  • User-Agent に連絡先(ここでは example.com)を入れ、何者のアクセスか分かるようにする

robots.txtを確認してから取りに行く

robots.txt(サイト側が機械的な巡回の可否を示すファイル)は標準ライブラリで確認できます。process の前に呼び、不可なら処理しないようにします。

from urllib import robotparser
from urllib.parse import urlsplit

_robots_cache = {}


def allowed(url, user_agent):
    parts = urlsplit(url)
    base = f"{parts.scheme}://{parts.netloc}"
    rp = _robots_cache.get(base)
    if rp is None:
        rp = robotparser.RobotFileParser(base + "/robots.txt")
        rp.read()
        _robots_cache[base] = rp
    return rp.can_fetch(user_agent, url)

ホストごとに1回だけ robots.txt を読み、結果をキャッシュ(一時保存)して使い回します。毎回読みに行くと、それ自体が余計なアクセスになるためです。rp.crawl_delay(user_agent) で巡回間隔の指定が取れる場合は、REQUEST_INTERVAL_SEC をその値以上にしてください。

robots.txtで許可されていても、利用規約で自動取得を禁止しているサイトはあります。ログインが必要なページや、APIが提供されているデータは、規約とAPIの利用条件を先に確認するのが筋です。

関連する選択肢

毎日決まった時刻にスクリプトを動かすだけなら、共有のレンタルサーバーでも足ります。cronが使えるプランを選べば、自分でOSを管理する必要はありません。

レンタルサーバー エックスサーバー

※ 広告を含みます(A8.net)。リンク経由でお申し込みがあった場合、手数料を受け取ることがあります。

2026年11月時点の結論

レンタルサーバーのcronで30分超のPythonバッチが強制終了されるなら、まず書き方を変えます。全件を一度に処理するのをやめ、SQLiteに1件ずつ進捗を保存し、時間予算で区切って次の起動で続きから再開する形にすれば、途中で落とされても失うのは1件だけです。終了ログに経過時間とCPU時間を残し、1日分が終わらない、または落とされ続けるならVPSに移します。2026年11月時点ではシンVPSが1GBで月額451円、KAGOYA CLOUD VPSが2GBで770円です。ConoHa VPSは新規受付停止中なので、受付状況の確認が先になります。VPSではsystemdタイマーで最後まで回し、自宅は機器があり停電や回線断を許容できる場合に選びます。どこに置いても、アクセス間隔とrobots.txtは変えずに守ってください。