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

LINE Messaging APIのWebhookでボットを作ったけれど、どこで動かせばいいのか迷っていませんか。エックスサーバーのような共有レンタルサーバーで足りるのか、月額制のVPSを借りてFastAPIを常駐させるべきなのか。判断の材料は「メッセージが来たときだけ応答すればいいか」です。この記事では、Python CGIとFastAPIそれぞれの動くコード、root作業の有無、応答時間の測り方、環境ごとに止まる条件を並べて、1つ選ぶならどれかを示します。

LINE Webhook受信に必要な3条件と、エックスサーバー・VPS・自宅PCの比較

LINE Messaging APIのWebhookは、ユーザーがメッセージを送るとLINEのサーバーからこちらのURLへPOSTリクエストが届く仕組みです。受ける側に必要なのは次の3つです。

  1. 外部から届くHTTPSのURLがある:LINE DevelopersコンソールにWebhook URLとして登録できる公開URLが要ります。
  2. 署名を検証する:リクエストヘッダー X-Line-Signature の値を、チャネルシークレットを鍵にしたHMAC-SHA256(鍵付きハッシュ)で照合し、LINE以外からの偽リクエストを捨てます。
  3. すぐに200を返す:処理に時間をかけすぎず、ステータス200で応答します。

「来たときだけ応答」なら、プロセスを常駐させる必要は必ずしもありません。リクエストが来るたびにPythonを起動するCGI(Webサーバーが外部プログラムを呼び出す古くからある方式)でも、上の3条件は満たせます。環境ごとの違いは次のとおりです。

項目

エックスサーバー(共有・Python CGI)

月額制VPS(FastAPI常駐)

自宅PC・自宅サーバー

HTTPSのURL

サーバー側の無料SSLを使える

自分でNginxと証明書を設定する

ルーターのポート開放と証明書の設定が要る

常駐プロセス

使えない(リクエストごとに起動)

使える(systemdで常駐)

使える

root権限

無い・不要

要る(初期設定と更新)

要る

応答後のバックグラウンド処理

基本的にできない

できる

できる

OS・Webサーバーの更新

事業者が行う

自分で行う

自分で行う

止まりやすい原因

CGIの実行時間・リソース制限、契約切れ

プロセス異常終了、更新放置、ディスク逼迫

電源・スリープ、回線のIP変更、ルーター設定

料金は2026年11月時点の公式ページで確認する

料金はプラン改定やキャンペーンで変わるため、この記事では金額を書きません。契約前に各社の公式料金ページで次を確認してください。

  • 契約期間(1か月・12か月・36か月など)によって月額換算が変わるか
  • 表示が税込か税抜か、初期費用があるか
  • VPSはメモリ容量ごとのプラン差。FastAPIとNginxだけなら最小構成から始め、足りなくなってから上げる
  • 独自ドメインを使う場合のドメイン費用(VPSは別途必要になることが多い)

すでに共有レンタルサーバーを契約しているなら、追加費用はかかりません。この「追加0円で試せる」点が、最初の1台として共有サーバーを勧める理由の1つです。

エックスサーバーのPython CGIでLINE Webhookを受ける書き方

以前の書き方:cgiモジュールのFieldStorage

以前はPythonのCGIスクリプトといえば、標準ライブラリの cgi モジュールを使うのが定番でした。

import cgi
form = cgi.FieldStorage()
print("Content-Type: text/html\n")

しかし cgi モジュールはPython 3.13で標準ライブラリから削除されています。サーバー側のPythonが更新されると、この書き方のスクリプトは import cgi の時点で動かなくなります。そもそもLINEのWebhookはフォームではなくJSONの本文で届くので、FieldStorageを使う必然性もありません。

今の書き方:標準入力から本文をそのまま読む

今は、CGIの仕様どおり環境変数 CONTENT_LENGTH の長さだけ標準入力から読み、自分でJSONとして解釈します。外部パッケージを使わないので、共有サーバーで pip install せずに動きます。cgi モジュールに依存しないため、Python 3.13以降でも同じコードで動きます。

まずSSHでログインし、サーバー上のPythonの場所とバージョンを確認します。

which python3
python3 --version

表示されたパスを、次のスクリプト1行目(シバン:実行に使うインタプリタの指定)に書きます。

#!/usr/bin/python3
# 1行目は which python3 の結果に置き換える
import base64
import hashlib
import hmac
import json
import os
import sys
import traceback
import urllib.request

CONFIG_PATH = "/home/username/linebot/config.json"
LOG_PATH = "/home/username/linebot/logs/error.log"
REPLY_URL = "https://api.line.me/v2/bot/message/reply"
MAX_BODY = 1024 * 1024


def load_config():
    with open(CONFIG_PATH, encoding="utf-8") as f:
        return json.load(f)


def verify(body: bytes, signature: str, secret: str) -> bool:
    digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode("ascii")
    return hmac.compare_digest(expected, signature)


def reply(reply_token: str, text: str, access_token: str) -> None:
    data = json.dumps({
        "replyToken": reply_token,
        "messages": [{"type": "text", "text": text}],
    }).encode("utf-8")
    req = urllib.request.Request(
        REPLY_URL,
        data=data,
        headers={
            "Content-Type": "application/json",
            "Authorization": "Bearer " + access_token,
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=5) as res:
        res.read()


def respond(status: str, body: str = "") -> None:
    header = f"Status: {status}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n"
    sys.stdout.buffer.write((header + body).encode("utf-8"))


def log_error() -> None:
    with open(LOG_PATH, "a", encoding="utf-8") as f:
        f.write(traceback.format_exc() + "\n")


def main() -> None:
    if os.environ.get("REQUEST_METHOD") != "POST":
        respond("405 Method Not Allowed")
        return

    length = int(os.environ.get("CONTENT_LENGTH") or 0)
    if length > MAX_BODY:
        respond("413 Payload Too Large")
        return
    body = sys.stdin.buffer.read(length)

    conf = load_config()
    signature = os.environ.get("HTTP_X_LINE_SIGNATURE", "")
    if not verify(body, signature, conf["channel_secret"]):
        respond("400 Bad Request")
        return

    for event in json.loads(body).get("events", []):
        if event.get("type") == "message" and event["message"].get("type") == "text":
            try:
                reply(event["replyToken"],
                      "受け付けました: " + event["message"]["text"],
                      conf["channel_access_token"])
            except Exception:
                log_error()

    respond("200 OK", "OK")


if __name__ == "__main__":
    try:
        main()
    except Exception:
        log_error()
        respond("500 Internal Server Error")

ブロックごとの役割は次のとおりです。

  • 定数部分:設定ファイルとログの置き場所を /home/username/linebot/ にしています。公開ディレクトリ(public_html)の外に置くことで、チャネルシークレットやアクセストークンがURLから読まれるのを防ぎます。
  • verify:受け取った本文を、チャネルシークレットを鍵にしてHMAC-SHA256でハッシュ化します。Base64にした値がヘッダーの署名と一致するかを hmac.compare_digest で比べます。通常の == ではなくこちらを使うのは、比較にかかる時間の差から値を推測される攻撃を避けるためです。
  • reply:返信用エンドポイントへ、受け取った replyToken を付けてテキストを送ります。timeout=5 を付けておけば、LINE側の応答が遅い場合もCGIが居座り続けません。
  • respond:CGIでは、最初にヘッダー行を出し、空行を1つ挟んでから本文を出力します。Status: 行でHTTPステータスを指定します。日本語の文字化けを避けるため、sys.stdout.buffer にバイト列で書き込みます。
  • main:POST以外は405で断ります。本文の長さに上限を設けてから読み、署名を検証し、テキストメッセージにだけ返信します。LINE Developersコンソールの「検証」ボタンは events が空のリクエストを送るので、この場合はループを通らずにそのまま200を返します。
  • 末尾の try:CGIは標準エラーがどこに出るかがサーバー依存で追いにくいため、例外の内容は自分のログファイルに残します。

設定ファイル /home/username/linebot/config.json は次の形です。値はLINE Developersコンソールで発行したものに置き換えます。

{
  "channel_secret": "ここにチャネルシークレット",
  "channel_access_token": "ここにチャネルアクセストークン"
}

設置手順と、共有サーバーで詰まるところ

  1. スクリプトを webhook.cgi などの名前で公開ディレクトリ配下に置く(CGIとして実行される拡張子と置き場所は、エックスサーバーのマニュアルで確認する)
  2. 改行コードをLFにする(Windowsで編集したCRLFのままだと、シバン行が正しく読まれず実行できない)
  3. chmod 755 webhook.cgi、設定ファイルは chmod 600 config.json にする
  4. mkdir -p /home/username/linebot/logs でログ置き場を作る
  5. LINE DevelopersコンソールにWebhook URL(例:https://example.com/line/webhook.cgi)を登録し、「検証」で成功することを確かめる

共有サーバーのCGIでつまずきやすいのは次の点です。

  • 重い処理を同期で行うと詰まる:CGIは200を返した時点でプロセスが終わるので、「先に200を返して、あとで処理する」という書き方ができません。外部APIの呼び出しや大きなファイルの処理を挟むと、そのぶん応答が遅れます。
  • コンパイルが必要なパッケージは入れにくい:pip install --user で入るものはありますが、OSのライブラリに依存するものはroot権限がないと入れられないことがあります。
  • Pythonのバージョンは事業者が決める:更新でパスやバージョンが変わる可能性があります。標準ライブラリだけで書いておけば影響を受けにくくなります。

同じ共有サーバーで、Webhookとは別に定期処理も動かしたくなったら、レンタルサーバーcron 5分おきPython実行|26年11月の書き方がそのまま使えます。

月額制VPSでFastAPIを常駐させる書き方とroot作業

実行環境とインストールするパッケージ

  • Python 3(FastAPIが対応するバージョンは、PyPIのFastAPIのページか公式ドキュメントで確認する)
  • pip install fastapi uvicorn httpx

この3つは、バージョンを固定しなくても下のコードは動きます。ただし運用を始めたら、pip show fastapi uvicorn httpx で入ったバージョンを確認し、pip freeze > requirements.txt で記録しておきます。こうしておくと、VPSを作り直したときに同じ環境を再現できます。

以前の書き方と今の書き方

以前は、Flaskと公式SDK(line-bot-sdk)の from linebot import LineBotApi, WebhookHandler を組み合わせる例が多く見られました。現在のline-bot-sdkは linebot.v3 配下の新しい名前空間へ移行していて、旧来の linebot 直下のクラスは非推奨扱いです。手元のSDKのバージョンは pip show line-bot-sdk で確認し、使い方は公式リポジトリの最新サンプルに合わせてください。

この記事では、CGI版と同じ署名検証を自分で書き、FastAPIと非同期HTTPクライアントのhttpxで組みます。SDKの仕様変更に左右されず、CGI版とロジックを揃えて比べられるからです。

import base64
import hashlib
import hmac
import json
import os

import httpx
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request

CHANNEL_SECRET = os.environ["LINE_CHANNEL_SECRET"]
ACCESS_TOKEN = os.environ["LINE_CHANNEL_ACCESS_TOKEN"]
REPLY_URL = "https://api.line.me/v2/bot/message/reply"

app = FastAPI()


def verify(body: bytes, signature: str) -> bool:
    digest = hmac.new(CHANNEL_SECRET.encode("utf-8"), body, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode("ascii")
    return hmac.compare_digest(expected, signature)


async def reply(reply_token: str, text: str) -> None:
    async with httpx.AsyncClient(timeout=5) as client:
        res = await client.post(
            REPLY_URL,
            headers={"Authorization": "Bearer " + ACCESS_TOKEN},
            json={"replyToken": reply_token, "messages": [{"type": "text", "text": text}]},
        )
        res.raise_for_status()


@app.post("/callback")
async def callback(
    request: Request,
    background_tasks: BackgroundTasks,
    x_line_signature: str = Header(default=""),
):
    body = await request.body()
    if not verify(body, x_line_signature):
        raise HTTPException(status_code=400, detail="invalid signature")

    for event in json.loads(body).get("events", []):
        if event.get("type") == "message" and event["message"].get("type") == "text":
            background_tasks.add_task(
                reply, event["replyToken"], "受け付けました: " + event["message"]["text"]
            )
    return {"status": "ok"}
  • 設定の読み込み:シークレットとトークンは環境変数から読みます。下で書くsystemdの EnvironmentFile から渡すので、コードにもGitにも値が残りません。
  • verify:CGI版と同じ処理です。
  • callback の引数:x_line_signature: str = Header(...) と書くと、FastAPIがアンダースコアをハイフンに読み替え、X-Line-Signature ヘッダーの値を渡してくれます。
  • background_tasks.add_task:CGIとの最大の違いがここです。返信処理を「レスポンスを返したあとに実行するタスク」として登録するので、LINEには先に200が返ります。返信に時間がかかっても、Webhookの応答は遅れません。

VPSで必要なroot作業

共有サーバーでは事業者が済ませてくれる作業を、VPSでは自分で行います。最低限、次の作業が必要です。

  1. OSのパッケージ更新と、Nginx・certbot・python3-venvのインストール(sudo apt install nginx certbot python3-certbot-nginx python3-venv。Debian系の場合)
  2. ファイアウォールの設定(SSHとHTTP/HTTPSだけを開け、uvicornのポート8000は外に開けない)
  3. Nginxのリバースプロキシ設定(外から受けたリクエストを内側のuvicornへ渡す)と、証明書の取得(sudo certbot --nginx -d bot.example.com)
  4. systemdのユニットファイルを置き、自動起動を有効にする

Nginxの設定例です(certbotが証明書の行を追記します)。

server {
    server_name bot.example.com;

    location /callback {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
    }
}

systemdのユニット /etc/systemd/system/linebot.service の例です。

[Unit]
Description=LINE webhook (FastAPI)
After=network-online.target
Wants=network-online.target

[Service]
User=username
WorkingDirectory=/home/username/linebot
EnvironmentFile=/home/username/linebot/.env
ExecStart=/home/username/linebot/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now linebot
journalctl -u linebot -f
  • --host 127.0.0.1:uvicornをVPS内部からしか届かない状態にして、外からはNginx経由でしか入れないようにします。
  • EnvironmentFile:LINE_CHANNEL_SECRET=... の形で書いたファイルです。chmod 600 にしておきます。
  • Restart=on-failure:プロセスが異常終了しても自動で起動し直します。
  • enable --now:今すぐ起動し、VPSの再起動後も自動で立ち上がるようにします。

応答時間を自分で実測する手順と、毎日動かすと止まる条件

実測スクリプト:空のイベントを署名付きで送る

応答時間はプラン・サーバーの混み具合・時間帯で変わります。他人の数値を流用するより、自分の環境で測るほうが確実です。そこでこの記事では具体的な数値を載せず、測り方を示します。LINEの「検証」と同じく events が空のリクエストに正しい署名を付けて送れば、返信APIを呼ばずにWebhookの受信部分だけを測れます。測る相手は自分のサーバーだけにしてください。

import base64
import hashlib
import hmac
import json
import statistics
import time
import urllib.request

URL = "https://example.com/line/webhook.cgi"   # VPSなら https://bot.example.com/callback
SECRET = open("/home/username/linebot/secret.txt", encoding="utf-8").read().strip()
COUNT = 20
INTERVAL = 3

body = json.dumps({"destination": "dummy", "events": []}).encode("utf-8")
signature = base64.b64encode(
    hmac.new(SECRET.encode("utf-8"), body, hashlib.sha256).digest()
).decode("ascii")

times = []
for _ in range(COUNT):
    req = urllib.request.Request(
        URL, data=body, method="POST",
        headers={"Content-Type": "application/json", "X-Line-Signature": signature},
    )
    start = time.perf_counter()
    with urllib.request.urlopen(req, timeout=10) as res:
        res.read()
    times.append((time.perf_counter() - start) * 1000)
    time.sleep(INTERVAL)

print(f"初回: {times[0]:.0f} ms")
print(f"中央値: {statistics.median(times):.0f} ms")
print(f"最大: {max(times):.0f} ms")
  • 署名の作成:LINEと同じ手順で署名を付けるので、署名検証を通ったあとの処理まで含めて測れます。
  • 計測ループ:20回を3秒間隔で送ります。共有サーバーは他の利用者と資源を分け合っているので、間隔を空けずに連打するのは避けます。
  • 出力:平均ではなく中央値と最大値を見ます。CGIはリクエストごとにPythonを起動するので、その起動時間が毎回加わります。最大値が大きく跳ねていないかを確認します。

結果は次の観点で比べます。

見る値

CGIで気にする点

FastAPIで気にする点

初回

しばらく間が空いたあとの1回目が遅くなるか

常駐しているので、初回だけ極端に遅くなることは起きにくい

中央値

Python起動とimportの時間が含まれる。importを減らすと縮む

Nginxとuvicornを経由する時間が中心

最大

サーバー混雑時に跳ねるか。時間帯を変えて複数回測る

メモリ不足でスワップしていないか

同じスクリプトを朝・昼・夜に1回ずつ実行して記録しておくと、共有サーバーで足りるかどうかを自分の数値で判断できます。

環境ごとに止まる条件

環境

止まる条件

防ぎ方

手元のPC

電源オフ・スリープ・OS更新による再起動、回線のグローバルIP変更、ルーターのポート開放設定の初期化

Webhook受信には向かない。開発中にトンネル系サービスで一時的に公開する用途に限る

共有サーバー(CGI)

契約・ドメインの更新切れ、CGIの実行時間やリソースの制限超過、Pythonの更新によるパスの変化

処理を軽く保つ、標準ライブラリだけで書く、エラーログを定期的に見る、自動更新の支払い方法を確認する

VPS(FastAPI常駐)

プロセスの異常終了、再起動後に自動起動しない、証明書の更新失敗、ディスク逼迫、OS更新の放置

Restart=on-failure と enable、sudo certbot renew --dry-run で更新を確認、ログのローテーション、定期的なパッケージ更新

自宅で常時稼働するマシンを持っている場合の運用と、VPSへ移すときの手順は自宅サーバーsystemd timer運用とVPS移行26年11月にまとめています。ただしWebhookは「外から常に届くこと」が前提です。自宅は回線とルーターが止まる要因を増やすため、この用途では選択肢から外すのが無難です。

ボットから外部サイトを取得するときの配慮

「メッセージを受けたら、どこかのサイトの情報を取ってきて返す」ボットにする場合は、取得先への配慮が必要です。ユーザーがメッセージを送るたびに相手のサイトへアクセスする作りにすると、利用者が増えた分だけ相手のサーバーに負荷がかかります。

  • 利用規約を先に読む:自動取得や、取得した内容の再配布を禁じているサイトは対象にしない。APIやRSSが提供されているなら、そちらを使う。
  • robots.txtを確認する:標準ライブラリの urllib.robotparser で、取得したいパスが許可されているかをプログラムから確認できる。
  • メッセージのたびに取得しない:cronなどで一定間隔ごとに1回だけ取得してファイルに保存し、ボットはその保存結果を返す。アクセス回数がユーザー数に比例しなくなる。
  • 間隔を空け、身元を示す:連続取得するときは数秒以上の間隔を空け、User-Agentに連絡先(例:example.comの問い合わせページ)を入れる。
  • 制限は回避しない:ログインが必要なページや、アクセス制限がかかったページを迂回して取りにいく作りにはしない。

関連する選択肢

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

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

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

まとめ:「来たときだけ応答」ならエックスサーバーのCGIを1つ選ぶ。VPSに移るのは3つの合図が出たとき

LINE Webhookを受けて返すだけのボットなら、まずはエックスサーバーのPython CGIを1つ選べば十分です。cgi モジュールは使わず、標準入力から本文を読んで標準ライブラリだけで書けば、Pythonの更新にも強くなります。root作業もOS更新も要りません。VPSに移るのは、次のどれかに当てはまったときです。

  • 返信前の処理が重く、先に200を返す書き方(バックグラウンド処理)が必要になった
  • コンパイルが必要なパッケージや常駐プロセスが要るようになった
  • 実測で中央値や最大値が許容できなくなった

VPSに移るなら、systemdでの自動起動、証明書の更新確認、OS更新までを自分の作業として引き受けてください。毎日動かし続けるのに必要なのは、どの環境が「何で止まるか」を知って、先に手を打っておくことです。