5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Herokuが有料化するのでdiscord botをGAEに移した

Posted at

クラウドの料金やサービスに関する記述は全て記述時のものです。最新の情報は適宜確認してください。

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系では動きません)

healthcheck.py
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))
main.py
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())

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でデプロイして動くのかをたしかめました。

app.yaml
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.yaml
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を書く

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?