本ページはプロモーションが含まれています。
LINE Messaging APIのWebhookでボットを作ったけれど、どこで動かせばいいのか迷っていませんか。エックスサーバーのような共有レンタルサーバーで足りるのか、月額制のVPSを借りてFastAPIを常駐させるべきなのか。判断の材料は「メッセージが来たときだけ応答すればいいか」です。この記事では、Python CGIとFastAPIそれぞれの動くコード、root作業の有無、応答時間の測り方、環境ごとに止まる条件を並べて、1つ選ぶならどれかを示します。
LINE Webhook受信に必要な3条件と、エックスサーバー・VPS・自宅PCの比較
LINE Messaging APIのWebhookは、ユーザーがメッセージを送るとLINEのサーバーからこちらのURLへPOSTリクエストが届く仕組みです。受ける側に必要なのは次の3つです。
- 外部から届くHTTPSのURLがある:LINE DevelopersコンソールにWebhook URLとして登録できる公開URLが要ります。
- 署名を検証する:リクエストヘッダー
X-Line-Signatureの値を、チャネルシークレットを鍵にしたHMAC-SHA256(鍵付きハッシュ)で照合し、LINE以外からの偽リクエストを捨てます。 - すぐに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": "ここにチャネルアクセストークン"
}
設置手順と、共有サーバーで詰まるところ
- スクリプトを
webhook.cgiなどの名前で公開ディレクトリ配下に置く(CGIとして実行される拡張子と置き場所は、エックスサーバーのマニュアルで確認する) - 改行コードをLFにする(Windowsで編集したCRLFのままだと、シバン行が正しく読まれず実行できない)
chmod 755 webhook.cgi、設定ファイルはchmod 600 config.jsonにするmkdir -p /home/username/linebot/logsでログ置き場を作る- 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では自分で行います。最低限、次の作業が必要です。
- OSのパッケージ更新と、Nginx・certbot・python3-venvのインストール(
sudo apt install nginx certbot python3-certbot-nginx python3-venv。Debian系の場合) - ファイアウォールの設定(SSHとHTTP/HTTPSだけを開け、uvicornのポート8000は外に開けない)
- Nginxのリバースプロキシ設定(外から受けたリクエストを内側のuvicornへ渡す)と、証明書の取得(
sudo certbot --nginx -d bot.example.com) - 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更新の放置 |
|
自宅で常時稼働するマシンを持っている場合の運用と、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更新までを自分の作業として引き受けてください。毎日動かし続けるのに必要なのは、どの環境が「何で止まるか」を知って、先に手を打っておくことです。