クラウドの料金やサービスに関する記述は全て記述時のものです。最新の情報は適宜確認してください。
TL;DR
- Heroku有料化後のDiscord botを無料で置ける場所を考えた
- botに簡単な非同期httpサーバを実装して、Google App Engineにデプロイした
- 今のところ無料で安定稼働してる
discord botの置き場問題
私は趣味で小規模なdiscord botを2年ほど運用しています。
インフラにはデプロイの手軽さと、無料で使えるということからherokuを使ってきたので、有料化するというニュースには驚きました。
なるべくお金はかけたくないので、移行先を考えました。
移行に当たっての考慮ポイントは以下の通りです。
- なるべく低料金(できれば無料)であること
- 運用負荷が小さいこと
- pythonが動作すること
- なるべく使ったことがないインフラであること
- 勉強のためです
AWSでも良かったのですが、botではSpreadSheet APIを使っていたので、GCPが親和性が高いと考え、今回はGCPのサービスの中から選ぶことにしました。
コンテナ化
コンテナにしておけばGCPがダメでも大体どこのインフラでも動くだろうと思ったので、まずコンテナ化に取り組みました。
素直にDockerfile
を書くだけです。
FROM python:3.10-slim-bullseye
RUN apt-get update \
&& apt-get -y install locales \
&& localedef -f UTF-8 -i ja_JP ja_JP.UTF-8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:ja
ENV LC_ALL ja_JP.UTF-8
ENV TZ JST-9
ENV TERM xterm
COPY src/ /root/src
COPY requirements.txt /root/src
WORKDIR /root/src
RUN pip install --upgrade pip \
&& pip install --upgrade setuptools \
&& pip install -r requirements.txt
EXPOSE 8080
CMD ["python", "main.py"]
Cloud Runにデプロイ
コンテナ化したので、GCPでコンテナを簡単に動かせて無料枠もあるCloud Runをまず検討しました。
Cloud Runの無料枠は執筆時点で、リクエストの処理中にのみ CPU を割り当てるサービスで180,000 vCPU秒、CPU が常に割り当てられるサービスとジョブで240,000 vCPU秒です。このほか、メモリとリクエストにも課金されます。
discord botは一般的なWebアプリと異なり、リクエストが来た時だけ稼働させれば良いのではなく、常に常駐させておく必要があります。このためCPUが常に割り当てられるサービスを選択する必要があります。
- リクエストの処理中にのみCPUを割り当てるサービスでも稼働はなぜかできるのですが、実際にやってみたらBotのレスポンスが非常に遅くなりました。
- GoogleからProbeが飛んでくるので稼働できたのかな?
HTTPサーバの起動
Cloud Runは基本的にWebサービス向けに作られているので、デプロイされたコンテナはなんらかのポートでHTTPリクエストを待ち受ける必要があります。$PORT
環境変数(デフォルトで8080番)で指定されたポートへHTTPリクエストが到達しない場合、アプリが落ちたと判断されて再起動されるという仕組みになっています。
そもそもの話、HTTPリクエストを受け付けないdiscord botのようなサービスでは本来ならば起動すらできません。
そこで、非同期のHTTPサーバを立ててバックグラウンドで実行することにしました。
一応、先人がいたのでその辺の情報を参考にしつつ進めました。
あくまでもWorkaroundなのである程度規模の大きいBotでは他の方法を検討してください。
実装のサンプルはここにおきました。
下の例はdiscord.py v2.0.0以上での例です。(v1系では動きません)
from aiohttp import web
import socket
import os
import asyncio
class healthcheck(object):
def __init__(self, port=8080):
self.host = "0.0.0.0"
self.port = int(os.getenv("PORT", port))
async def handle(self, request):
text = "Hello"
return web.Response(text=text)
def mk_socket(self, reusePort=False):
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
if reusePort:
SO_REUSEPORT = 15
sock.setsockopt(socket.SOL_SOCKET, SO_REUSEPORT, 1)
sock.bind((self.host, self.port))
return sock
async def start_server(self):
reusePort = False
app = web.Application()
app.add_routes([web.get('/', self.handle)])
runner = web.AppRunner(app)
await runner.setup()
sock = self.mk_socket(reusePort=reusePort)
srv = web.SockSite(runner, sock)
await srv.start()
print("Listening on port {}".format(self.port))
import os
import discord
import healthcheck
class CustomClient(discord.Client):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True
super().__init__(intents=intents)
async def on_ready(self):
print('Logged in as {}. user_id={}'.format(self.user.name,self.user.id))
print('-'*20)
async def on_message(self,message):
if message.author == self.user:
return
await message.channel.send("foo")
async def main():
hc_server = healthcheck.healthcheck()
await hc_server.start_server()
cc = CustomClient()
async with cc:
await cc.start(os.environ.get('ENV_VAR_DISCORD_ID'))
if __name__ == "__main__":
asyncio.run(main())
-
ENV_VAR_DISCORD_ID
環境変数にはDiscord botのトークンが入ります - discord.py v2.0.0から起動時に
Intent
を指定する必要があります
Cloud Runへのデプロイの方法はいろんなところに書いてあるので省略します。
料金高すぎ
CPUを常に割り当てるサービスとして稼働させて、レスポンスも十分早かったのですが、すぐに無料枠が終了し、一日200円ぐらいかかるようになってしまいました。
事前に計算すればすぐにわかることではあり、1ヶ月あたり概算で$3600\times 24\times 30=2592000$CPU秒なので全く無料枠には収まりません。
GAEへの移行
下の無料枠一覧表を眺めた結果、App EngineのFインスタンスが1日あたり28時間無料(執筆時)だったのでこれを使うことにしました。
App Engineにはdockerコンテナをそのまま動かせるフレキシブル環境もありますが、執筆時には無料枠はなかったのでスタンダード環境を選択しました。
スタンダード環境では使える言語が限られていますが、運よく?pythonがサポートされていました。
公式資料を参考にしながら進めてみました。
app.yaml書く
ランタイムに関する情報はapp.yaml
に書いていきます。
環境変数がべた書き去れてしまうので、secret managerからビルド時に引っ張ってくるなどの回避策が必要なようです。
めんどくせぇので、git commit
しないように気をつけつつ、 まずは環境変数を直書きして、gcloud
でデプロイして動くのかをたしかめました。
runtime: python310
instance_class: F1
entrypoint: python src/main.py
env_variables:
ENV_VAR_SECRET: #秘密の環境変数
automatic_scaling:
min_instances: 1
max_instances: 1
min_idle_instances: 1
max_idle_instances: 1
automatic_scaling
は、勝手にインスタンスが増えて課金されないように書いています。本当は手動スケーリングしたかったのですが、Bインスタンスしか手動スケーリングできないこと、Bインスタンスは1日9時間までしか無料じゃないことを考慮してこうなってます…
gcloud app deploy
でデプロイします。簡単ですね。
Cloud Runで動かすためにHTTPサーバを実装していたので、何も考えずにそのままデプロイできました。
自動シャットダウン問題
とりあえずApp EngineでBotを動かすことができ、レスポンスも問題なかったのですが、全くアクセスがないと一定時間でシャットダウンされるみたいでした。
公式資料によると
インスタンスはリクエスト処理のためにオンデマンドで作成され、アイドル時には自動的にシャットダウンされます。
とあります。特にアイドル時間は書いてないのですが、ログを確認すると最後のリクエストから大体24時間ぐらいでシャットダウンされるようでした。
自動スケーリング以外ならシャットダウンまでの時間を指定できるようでしたが、Fインスタンスは自動スケーリングしかないので、cron jobでアクセスをかけてみます。 cron jobを定義するにはアプリケーションのルートディレクトリにcron.yaml
を置くだけでいいらしい。 簡単ですね。
Python 2 用 cron によるタスクのスケジューリング
cron:
- description: prevent shutting down idle instances
url: /
schedule: every 1 hours
retry_parameters:
min_backoff_seconds: 5
max_doublings: 5
上の例では1時間ごとにモックHTTPサーバのトップページへアクセスします。
cron jobをデプロイするにはgcloud app deploy cron.yaml
です。簡単ですね。
こちらの構成で今までのところ安定稼働しています。
実を言うとログを見ると1日の中で落ちている時間もあるようなのですが、必ず1時間ごとに復活するのでOKとしています。無料で使わせてもらってるので…
TODO
デプロイ時に環境変数を自動で展開してくれるいいかんじのCIを書く