サーバーのPythonを3.14に上げたら、ProcessPoolExecutorで並列取得していた収集スクリプトが朝の定期実行で落ちていた。そんな経験はないでしょうか。Python 3.14では、Linuxでのmultiprocessingの既定がforkからforkserverに変わりました。Ubuntu 26.04 LTSの標準Pythonも3.14系です。この記事では、if __name__ == "__main__"・mp_context・initializerの3点を押さえて書き直す方法を説明します。そのうえで、systemd timerで毎日止まらずに回すところまでコード付きで解説します。

Python 3.14でmultiprocessingの既定がforkからforkserverに変わった

multiprocessingは、子プロセスの作り方(start method)を3種類持っています。Python 3.13まで、Linuxの既定はforkでした。3.14からは、macOS以外のUnixで既定がforkserverになっています。

start method

子プロセスの作り方

親の変数を引き継ぐか

既定になっている環境

fork

親プロセスのメモリを丸ごと複製する

引き継ぐ(実行中に書き換えた値も見える)

Python 3.13までのLinux

forkserver

最初に起動した小さな「サーバー」プロセスから子を作り、メインのスクリプトを読み込み直す

引き継がない

Python 3.14以降のLinux

spawn

新しいPythonを起動し、メインのスクリプトを読み込み直す

引き継がない

macOS(3.8以降)・Windows

手元の環境がどちらになっているかは、次のコマンドで確認できます。

python3 -V
python3 -c "import multiprocessing as mp; print(mp.get_start_method())"

Python 3.14のLinuxならforkserver、macOSならspawnと表示されます。

なぜ「Linuxだけで育ったスクリプト」が壊れるのか

forkの環境では、次の2つの書き方がたまたま動いていました。

  • ガードなし:プールを起動する処理を、ファイルの一番外側に直接書いていた
  • グローバル変数の共有:mainの中で書き換えた設定値(ヘッダー、取得対象、DB接続など)を、子プロセスがそのまま読んでいた

forkserverとspawnでは、子プロセスがメインのスクリプトをimportし直します。そのため、外側に書いた処理は子の中でもう一度実行されます。また、親が実行中に書き換えた値は子には見えません。

macOSで書いてサーバーへ持っていくスクリプトは、すでにspawnを経験しているので壊れにくいです。壊れるのは、Linux上で3.12〜3.13のまま育ったスクリプトです。

そもそもスクレイピングでProcessPoolExecutorを使う場面

HTTPの待ち時間だけが問題なら、プロセスは要りません。スレッドや非同期で足ります。httpxのAsyncClientで並列取得する書き方は、httpx proxies=削除と並列取得|26年10月にまとめています。

プロセスを使う意味があるのは、取得後の処理がCPUを食う場合です。たとえば大きなHTMLをBeautifulSoupで解析する、正規表現で大量に整形する、といった処理です。この記事はそのケースを前提にしています。

以前の書き方と、Python 3.14での書き方(if __name__ と mp_context)

以前の書き方(forkだから動いていた)

import requests
from bs4 import BeautifulSoup
from concurrent.futures import ProcessPoolExecutor

session = requests.Session()
HEADERS = {}

def fetch(url):
    res = session.get(url, headers=HEADERS, timeout=20)
    return BeautifulSoup(res.text, "html.parser").title.string

urls = open("urls.txt").read().split()
HEADERS["User-Agent"] = "example-collector/1.0"

with ProcessPoolExecutor(max_workers=4) as ex:
    print(list(ex.map(fetch, urls)))

Python 3.14のLinuxでこれを動かすと、子プロセスがこのファイルを読み込み直します。その結果、一番下のwith ProcessPoolExecutorが子の中でも実行されてしまい、次のエラーで止まります。

RuntimeError: An attempt has been made to start a new process before the
current process has finished its bootstrapping phase.

ガードを足すだけでは解決しません。HEADERS["User-Agent"]への代入がガードの内側に入ると、子プロセスからは空の辞書に見えます。エラーは出ずに、User-Agentなしでアクセスするスクリプトになってしまいます。

今の書き方(forkserver前提で、どの環境でも同じ動きにする)

実行環境は次のとおりです。

  • Python 3.14(python3 -Vで確認)
  • pip install requests beautifulsoup4
  • Ubuntuではシステム全体へのpip installが制限されています。そのためpython3 -m venv .venvで仮想環境を作り、その中に入れます
  • バージョンはpip show requests beautifulsoup4で確認し、動いた組み合わせをrequirements.txtに固定しておきます

BeautifulSoupの引数名も変わってきています。bs4 text=非推奨→string=と版固定|26年10月もあわせて確認してください。

# /home/username/collector/collect.py
import csv
import multiprocessing as mp
import sys
import time
from concurrent.futures import ProcessPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
from urllib.parse import urlsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

BASE_DIR = Path(__file__).resolve().parent
USER_AGENT = "example-collector/1.0 (+https://example.com/contact)"
BASE_INTERVAL = 2.0
MAX_WORKERS = 2

_session = None


def init_worker(user_agent):
    global _session
    _session = requests.Session()
    _session.headers["User-Agent"] = user_agent


def fetch_and_parse(url, interval):
    time.sleep(interval)
    res = _session.get(url, timeout=20)
    res.raise_for_status()
    soup = BeautifulSoup(res.text, "html.parser")
    h1 = soup.find("h1")
    return {
        "url": url,
        "title": soup.title.string.strip() if soup.title and soup.title.string else "",
        "h1": h1.get_text(strip=True) if h1 else "",
    }


def allowed_urls(urls):
    parsers = {}
    result = []
    for url in urls:
        parts = urlsplit(url)
        root = f"{parts.scheme}://{parts.netloc}"
        if root not in parsers:
            rp = RobotFileParser(root + "/robots.txt")
            rp.read()
            parsers[root] = rp
        if parsers[root].can_fetch(USER_AGENT, url):
            result.append(url)
        else:
            print(f"skip (robots.txt): {url}", file=sys.stderr)
    return result, parsers


def main():
    lines = (BASE_DIR / "urls.txt").read_text(encoding="utf-8").splitlines()
    urls, parsers = allowed_urls([u.strip() for u in lines if u.strip()])

    delays = [rp.crawl_delay(USER_AGENT) for rp in parsers.values()]
    interval = max([BASE_INTERVAL] + [float(d) for d in delays if d]) * MAX_WORKERS

    method = "forkserver" if "forkserver" in mp.get_all_start_methods() else "spawn"
    ctx = mp.get_context(method)

    rows, failed = [], 0
    with ProcessPoolExecutor(
        max_workers=MAX_WORKERS,
        mp_context=ctx,
        initializer=init_worker,
        initargs=(USER_AGENT,),
    ) as ex:
        futures = {ex.submit(fetch_and_parse, url, interval): url for url in urls}
        for fut in as_completed(futures):
            try:
                rows.append(fut.result())
            except Exception as e:
                failed += 1
                print(f"failed: {futures[fut]}: {e}", file=sys.stderr)

    out = BASE_DIR / "output" / f"{datetime.now():%Y%m%d}.csv"
    out.parent.mkdir(exist_ok=True)
    with out.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["url", "title", "h1"])
        writer.writeheader()
        writer.writerows(rows)

    print(f"start method={method} ok={len(rows)} failed={failed} 出力: {out}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main())

各ブロックがしていること

  • BASE_DIR = Path(__file__).resolve().parent:urls.txtと出力先を、スクリプトの置き場所を基準に決めます。systemdから起動したときにカレントディレクトリが想定と違っても、読み書きする場所がずれません。
  • 定数はファイルの外側に書くだけにする:USER_AGENTなどの定数は、子プロセスが読み込み直しても同じ値になります。実行中に値を書き換えない限り、外側に置いて問題ありません。外側に書いてはいけないのは、「プールを起動する」「ファイルを読む」「DBにつなぐ」といった副作用のある処理です。これらは子の数だけ繰り返し実行されます。
  • init_workerとinitializer=:子プロセスが起動したときに1回だけ呼ばれる関数です。ここで子ごとにrequests.Sessionを作ります。親からグローバル変数を引き継げないので、必要な値はinitargsで明示的に渡します。forkのころに「なんとなく共有されていたもの」を、引数で渡す形に置き換えるのが書き換えの中心です。
  • fetch_and_parse:子プロセスで実行される本体です。ファイルの一番外側(トップレベル)で定義しておく必要があります。mainの中で定義した関数やlambdaは、子プロセスに渡せません。raise_for_status()で4xxや5xxを例外にして、失敗として数えられるようにしています。
  • allowed_urls:取得前に、各サイトのrobots.txtで許可されたURLだけに絞ります。この処理は親プロセスで1回だけ行います。
  • mp.get_context(method)とmp_context=ctx:start methodをこのプールにだけ明示します。mp.set_start_method()はプログラム全体の設定を変え、しかも1回しか呼べません。mp_contextなら影響がこのプールの中に収まります。get_all_start_methods()でforkserverが使えるかを確かめているのは、forkserverが無いWindowsでspawnに切り替えるためです。これで手元のMacとサーバーのLinuxが、どちらも「親の変数を引き継がない」同じ前提で動きます。
  • as_completedと失敗の数え方:終わったものから順に結果を受け取ります。1件が失敗しても残りの結果は捨てず、件数を数えてからCSVに書き出します。
  • sys.exit(main()):失敗が1件でもあれば終了コード1で終わります。systemdはこの終了コードで成功か失敗かを記録します。0で終わると、半分取れていなくても「成功」と残ります。

forkに戻すという選択肢について

mp_context=mp.get_context("fork")と書けば、以前の挙動に戻せます。ただしこれは一時しのぎです。

forkは、スレッドを使っているプロセスで使うと固まる危険があります。そのためPython 3.12から、その状況ではDeprecationWarning(非推奨の警告)が出るようになりました。requestsやブラウザ操作のライブラリは、内部でスレッドを使うことがあります。

既定がforkserverに変わったのも、この危険を避けるためです。戻すのは、書き直すまでのつなぎに限ってください。

長時間回す処理なら、max_tasks_per_child(子プロセス1つが処理する件数の上限)でワーカーを定期的に作り直し、メモリが膨らむのを防げます。この引数はforkでは使えないので、forkserverやspawnを明示しておくことが前提になります。

取得先サイトへの配慮:robots.txt・並列数・アクセス間隔

並列化すると、取得先から見たアクセス頻度が並列数の分だけ上がります。速くするための並列化が、そのまま相手サーバーへの負荷になります。上のコードでは次の3点で抑えています。

  1. robots.txtの確認:urllib.robotparserで、許可されていないURLは取得しません。Crawl-delay(アクセス間隔の指定)があれば、それとBASE_INTERVALの大きい方を使います。
  2. 並列数ぶん間隔を伸ばす:各ワーカーが待つ時間を「間隔 × MAX_WORKERS」にしています。2プロセスで同じサイトを取っても、サイトに届くのはおおよそ2秒に1回のままです。同じサイトしか取らないならMAX_WORKERS=1で十分なことも多いです。並列化は、解析処理が重いときや、取得先が複数のサイトに分かれているときに使います。
  3. 連絡先付きのUser-Agent:どこからのアクセスかを名乗ります。example.comの部分は、自分の問い合わせ先に置き換えてください。

robots.txtで許可されていても、利用規約でスクレイピングを禁じているサイトはあります。たとえばX(旧Twitter)は、2026年7月時点の開発者規約で、明示的な許可のないスクレイピングを禁止しています。違反するとアクセス停止の対象になります。

集めたデータを公開や翻訳に使う場合は、著作権法の複製権(第21条)・公衆送信権(第23条)・翻訳権(第27条)に触れる可能性もあります。取得の前に規約を読み、取得後の使い道まで決めておいてください。

アクセス制限やbot対策に引っかかった場合は、回避する方向ではなく、公式APIや提供元との合意に切り替えるのが筋です。

systemd timerで毎日回す:service・timer・終了コード

ユーザー単位のsystemd timerで、毎朝6時に実行する例です。

# /home/username/.config/systemd/user/collect.service
[Unit]
Description=daily collector

[Service]
Type=oneshot
WorkingDirectory=/home/username/collector
ExecStart=/home/username/collector/.venv/bin/python /home/username/collector/collect.py
TimeoutStartSec=30min
# /home/username/.config/systemd/user/collect.timer
[Unit]
Description=run collector daily

[Timer]
OnCalendar=*-*-* 06:00:00
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target
systemctl --user daemon-reload
systemctl --user enable --now collect.timer
sudo loginctl enable-linger username
systemctl --user list-timers
journalctl --user -u collect.service -n 50
  • ExecStartはvenvのpythonとスクリプトを絶対パスで書く:systemdの環境では、シェルのPATHやactivateが効きません。venvの中のpythonを直接指定すれば、仮想環境に入れたパッケージが使われます。forkserverの子プロセスも同じインタプリタで起動します。
  • Type=oneshotとTimeoutStartSec:1回実行して終わる処理であることを示し、固まったときの上限時間を決めます。上限を超えるとsystemdが親子のプロセスをまとめて止めるので、ワーカーが残り続けることもありません。
  • Persistent=true:電源断などで6時を逃した場合、次に起動したときに1回分を実行します。
  • RandomizedDelaySec:起動時刻を少しずらします。毎日ぴったり同じ時刻に取得先へアクセスすることを避けられます。
  • enable-linger:これを設定しないと、ログアウトした時点でユーザー単位のtimerが止まります。
  • 終了コード:スクリプトが1で終われば、systemctl --user status collect.serviceにfailedと残ります。journalctlには、start method=forkserverの行と失敗したURLが出ます。「新しいPythonで本当にforkserverで動いたか」を、ログで後から確かめられます。

Pythonを上げた直後は、timerを待たずにsystemctl --user start collect.serviceで一度手動実行してください。そのあとjournalctlで、okとfailedの件数を確認しておくと安心です。

毎日動かし続ける場所:手元PC・共有サーバーのcron・VPSが止まる条件

書き方を直しても、動かす場所が止まれば収集も止まります。それぞれが止まる条件は次のとおりです。

実行場所

向いている条件

止まる条件

手元のPC(launchd・systemd timer)

週数回で足りる、PCを毎日開いている

スリープ中・電源オフの間は実行されない。launchdは復帰後に発火するので、実行時刻が読めない

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

単発のrequests+BeautifulSoupで、数分で終わる

多くのサービスで、同時に動かせるプロセス数やCPU時間に制限がある。ProcessPoolExecutorで子プロセスを増やすと、途中で強制終了されることがある。使えるPythonのバージョンもサービス側が決める(上限は各社の規約・マニュアルで確認する)

VPS・自宅の常時稼働機(systemd timer)

並列処理やブラウザ操作が必要、毎日決まった時刻に回したい

OSのアップグレードでPythonが変わる(今回の3.14がまさにこれ)。そのほか、ディスクがいっぱいになる、支払いが止まる、停電する、など

今回のようにProcessPoolExecutorで並列に解析する処理は、共有サーバーの制限とぶつかりやすいです。現実的なのはVPSか、自宅の常時稼働機になります。

その場合の最大のリスクは、OSのアップグレードでPythonが入れ替わることです。対策は次の2つです。

  • venvは古いPythonを指したままなので、アップグレード後に作り直す
  • 終了コードで失敗が残るようにしておき、「半分しか取れていないのに成功扱い」の状態を作らない

関連する選択肢

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

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

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

まとめ

Python 3.14では、Linuxのmultiprocessingの既定がforkからforkserverに変わりました。子プロセスは、親の変数を引き継がずにメインのスクリプトを読み込み直します。

ProcessPoolExecutorで並列取得するスクリプトは、次のように書けば、どの環境でも同じように動きます。

  • プールの起動をif __name__ == "__main__"の中に入れる
  • 子プロセスで必要な値はinitializerとinitargsで渡す
  • mp_contextでforkserver(Windowsではspawn)を明示する

forkに戻す書き方は、スレッドと組み合わせると危険なので一時しのぎに留めてください。並列化した分は、robots.txtと並列数に合わせたアクセス間隔で相手サーバーへの負荷を抑えます。

毎日動かすなら、VPSなどでsystemd timerを使い、venvのpythonを絶対パスで指定し、失敗したら終了コード1で終わるようにします。そうすれば、OSのアップグレードでPythonが変わっても、止まったことにすぐ気づけます。