はじめに
個人で開発・運用しているDiscord Bot「MaidBot」(Discord.js v14)を、Railwayから無料のVPS(Google Cloud PlatformのAlways Free枠)に移行した記録です。単なる移行手順だけでなく、移行後に機能を追加しまくっていく中で実際に踏んだバグ2つ——特に**「Botが一切起動しなくなった」原因が、テンプレートリテラルの中に書いた1個のバックスラッシュ抜けだった**という話を中心に書きます。
MaidBotは経済システム(株式・仮想通貨・会社経営)、レベル機能、モデレーション機能などを持つ、それなりに大きい個人開発Botです。この記事の時点で main.js だけで2000行を超え、機能拡張用に十数個のモジュールファイルに分割しています。
興味があれば下記から実際に自分のサーバーに導入できます。何か聞きたいことがあればサポートサーバーにどうぞ。
なぜ移行したのか
RailwayでBotを動かしていましたが、無料枠の制約や、自分でインフラを触って理解を深めたいという気持ちもあり、無料のVPSに引っ越すことにしました。
候補に挙がったのは:
- Oracle Cloud Always Free — スペックは一番良い(A1 Flexで最大4 OCPU/24GB)が、人気で「Out of host capacity」エラーに当たりやすい
- Google Cloud Platform e2-micro — スペックは控えめ(共有vCPU・メモリ1GB程度)だが、期限のない永久無料枠で、確保のしやすさで安定している
最終的に、確実にインスタンスを確保できるGCPのe2-microを選びました。リージョンはus-west1(オレゴン)。日本から見ると多少pingは伸びますが、Discord Botの用途なら体感できるほどの差ではありません。
移行作業、最初のハマりどころ
TokenInvalidエラー
VMを立ててgit clone→npm install→pm2 start main.jsまではすんなり進みましたが、起動直後にこれが出ました。
Uncaught DiscordjsError Error [TokenInvalid]: An invalid token was provided.
原因は単純で、Railwayは環境変数をプラットフォーム側が自動的にプロセスに注入してくれるため、main.jsにはrequire('dotenv').config()の記述が一度も入っていませんでした。VPSではそんな親切機能はないので、.envファイルを作っても、それを読み込むコード自体がなければprocess.env.TOKENはundefinedのままです。
npm install dotenv
main.jsの先頭に1行足すだけで解決しました。
require('dotenv').config();
「動いていた環境の暗黙の前提」に気づかず移行すると、こういう「以前は動いていたのに」系のバグを踏みやすいというのは、個人的に良い学びでした。
apt-getのロック待ち
VM初回起動直後にapt-get installすると、こういうエラーが出ることがあります。
E: Could not get lock /var/lib/apt/lists/lock. It is held by process 1024 (apt-get)
初回起動時にOS側の自動アップデート処理(unattended-upgrades)が裏で動いていることが多いので、少し待つかロック解放をポーリングして待つのが安全です。
while sudo fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do
sleep 5
done
HTTPS化とOAuth2
BotにはOAuth2認証(役職付与)とWebダッシュボードの機能があるので、https://のドメインが必要でした。GCPの静的IP予約→DNSのAレコード設定→nginxをリバースプロキシとして立てて、certbotで無料のSSL証明書(Let's Encrypt)を取得、という定番の構成です。
server {
listen 80;
server_name your-domain.example.com;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo certbot --nginx -d your-domain.example.com
ここでハマったのは、Discord Developer PortalのOAuth2 Redirectsに登録するURLと、コード側のredirect_uriは1文字でも違うと弾かれるという点です。末尾のスラッシュの有無レベルで普通に失敗するので、コピペで揃えるのが確実です。
機能追加ラッシュで見つかった実バグ2つ
ここからが本題です。ダッシュボードや自動モデレーション機能をガンガン追加していく中で、実際に踏んだバグの話をします。
バグ1: /serverlockの権限が二度と戻らない
サーバー全体をロックするコマンドで、こういうコードを書いていました。
// ロック時は0、解除時はroleの現在値に戻す…つもりだった
await role.setPermissions(isLock ? 0n : role.permissions).catch(() => {});
一見普通に見えますが、これはアンロックが構造的に機能しないバグでした。ロック時に権限を0に上書きしてしまうと、Discord.jsのキャッシュ上のrole.permissionsも0に更新されます。なので、次に解除コマンドを実行したとき、role.permissionsはすでに0になっており、「0を0に戻す」という無意味な操作になってしまいます。つまり一度ロックしたら二度と解除できないという、洒落にならないバグでした。
修正は、ロック前に元の権限を必ずどこかに保存しておくことです。
// ロック前に元の権限を保存してから0にする
const snapshot = {};
for (const [id, role] of allRoles) snapshot[id] = role.permissions.bitfield.toString();
conf.serverLockSnapshot = snapshot;
saveData(SERVERS_FILE, servers);
for (const [, role] of allRoles) await role.setPermissions(0n).catch(() => {});
// 解除時はスナップショットから復元する
for (const [id, saved] of Object.entries(conf.serverLockSnapshot || {})) {
const role = guild.roles.cache.get(id);
if (role) await role.setPermissions(BigInt(saved)).catch(() => {});
}
「更新後の値を、更新前の値のつもりで参照している」というのは、状態を書き換える系のコードでは割とやりがちな罠だと思います。
バグ2: バックスラッシュ1個抜けでBotが起動しなくなった日
一番印象に残っているのがこれです。ダッシュボードのHTML/JSは、Node.js側で1つの巨大なテンプレートリテラルとして生成しています。
const DASHBOARD_HTML = `<!DOCTYPE html>
<html>...
<script>
// ここにブラウザ側で動くJSを丸ごと文字列として埋め込んでいる
</script>
</html>`;
ある日、この中の説明文に、Markdown記法のつもりでコードっぽく見せようとしてこう書きました。
// 説明文の中に何気なく書いたコード
`※参加しただけの一般メンバーには適用されません。\`/auth\`の役職認証か…`
一見普通の文章に見えますが、ここでバッククォート`をエスケープし忘れていました。これが何を引き起こすかというと、外側の巨大なテンプレートリテラル(DASHBOARD_HTML全体)が、この`の時点で途中で閉じてしまうんです。そのあと/authという文字列が本来の役割(ただの文章の一部)を失い、JavaScriptの式の一部として解釈されようとし、さらにその次の`でまた別のテンプレートリテラルが開始してしまう、というカオスな状態になります。
結果として出たエラーがこれでした。
ReferenceError: auth is not defined
at Object.<anonymous> (/home/xxx/maidbot/dashboard.js:198:24)
これの怖いところは、node --check(構文チェック)では検出できないことです。壊れた入れ子構造は、たまたま文法的には成立してしまうコードに化けていて、実行(評価)して初めて「authという変数は存在しない」というエラーになるからです。しかもこの評価はrequire('./dashboard.js')した瞬間、つまりBotの起動シーケンスの一番最初で起きるので、Discord側には何も表示されず、ただpm2のプロセスが延々と再起動を繰り返すだけになります。「コマンドに一切反応しない」という報告だけを頼りに、ログを遡ってようやく原因にたどり着きました。
対策は単純で、該当箇所のバッククォートを外すだけです。
`※参加しただけの一般メンバーには適用されません。/authの役職認証か…`
再発防止として、以降はnode --checkだけでなく、実際にrequire()して(discord.jsの最低限のモッククラスを自作して)評価まで通すテストをするようにしました。
// discord.jsの必要最低限だけを実装したモックを使い、
// 実際にファイルをrequireして「評価まで通るか」を確認する
class ChainableBuilder {
constructor() {
const target = {};
return new Proxy(target, {
get(t, prop) { return prop in t ? t[prop] : (...args) => proxy; }
});
}
}
「構文的に正しい」と「実行時に壊れていない」は別物だという、当たり前だけど忘れがちな教訓でした。
おまけ: 自分の勘違いで「バグだ」と騒いだ話
ついでに正直に書いておくと、この直前に「NGワードの複数行入力が、実は改行で正しく分割できていないバグがある」と思い込んで大騒ぎしたことがありました。二重にネストしたテンプレートリテラルのエスケープを目視で数えていて、バックスラッシュの表示上の記号(\\)と実際の1バイトを何度も混同してしまい、結局Pythonでバイト単位のダンプと実際のブラウザ相当の評価テストをやり直したら、最初から正しく動いていたというオチでした。
目視でのエスケープ確認はミスりやすいので、疑わしいときは実際に評価して確認する、に尽きます。
まとめ
- Railwayのような「よしなにやってくれる」PaaSから自前のVPSに移すと、dotenvのようにプラットフォームが暗黙にやってくれていたことが突然表面化する
- 状態を書き換えるコードは「更新後の値を、更新前の値のつもりで使ってしまう」バグを埋め込みやすい。ロック/アンロックのような可逆操作は、変更前のスナップショットを明示的に保存するのが安全
- 巨大なテンプレートリテラルの中でMarkdown風の記法(バッククォート)を使うときは要注意。
node --checkは構文チェックであって、この種の「壊れているのに文法的には成立する」コードは見逃す - 個人開発でも、疑わしい箇所は目視でなく実際に評価して確認する習慣が地味に効く
無料のVPS(GCP e2-micro)は、この規模のDiscord Botなら十分実用に耐えています。同じように移行を考えている人の参考になれば幸いです。
MaidBot自体を試してみたい方は、こちらから自分のサーバーに導入できます。不具合報告や要望はサポートサーバーで受け付けています。