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

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

## TL;DR
- Heroku有料化後のDiscord botを無料で置ける場所を考えた
- botに簡単な非同期httpサーバを実装して、Google App Engineにデプロイした
- 今のところ無料で安定稼働してる


## discord botの置き場問題
私は趣味で小規模なdiscord botを2年ほど運用しています。
インフラにはデプロイの手軽さと、無料で使えるということからherokuを使ってきたので、有料化するというニュースには驚きました。

https://twitter.com/heroku/status/1562817050565054469?ref_src=twsrc%5Etfw%7Ctwcamp%5Etweetembed%7Ctwterm%5E1562817050565054469%7Ctwgr%5E7903639f310bf0eb90ca7b751cb4a7c5841b22cb%7Ctwcon%5Es1_c10&ref_url=https%3A%2F%2Fgigazine.net%2Fnews%2F20220826-heroku-next%2F

なるべくお金はかけたくないので、移行先を考えました。
移行に当たっての考慮ポイントは以下の通りです。
- なるべく低料金（できれば無料）であること
- 運用負荷が小さいこと
- pythonが動作すること
- なるべく使ったことがないインフラであること
    - 勉強のためです

AWSでも良かったのですが、botではSpreadSheet APIを使っていたので、GCPが親和性が高いと考え、今回はGCPのサービスの中から選ぶことにしました。

## コンテナ化
コンテナにしておけばGCPがダメでも大体どこのインフラでも動くだろうと思ったので、まずコンテナ化に取り組みました。
素直に`Dockerfile`を書くだけです。
```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の無料枠](https://cloud.google.com/run/pricing?hl=ja)は執筆時点で、[リクエストの処理中にのみ CPU を割り当てるサービス](https://cloud.google.com/run/docs/configuring/cpu-allocation?hl=ja)で180,000 vCPU秒、[CPU が常に割り当てられるサービスとジョブ](https://cloud.google.com/run/docs/configuring/cpu-allocation?hl=ja)で240,000 vCPU秒です。このほか、メモリとリクエストにも課金されます。

discord botは一般的なWebアプリと異なり、リクエストが来た時だけ稼働させれば良いのではなく、常に常駐させておく必要があります。このためCPUが常に割り当てられるサービスを選択する必要があります。
- リクエストの処理中にのみCPUを割り当てるサービスでも稼働はなぜかできるのですが、実際にやってみたらBotのレスポンスが非常に遅くなりました。
    - GoogleからProbeが飛んでくるので稼働できたのかな？

### HTTPサーバの起動
Cloud Runは基本的にWebサービス向けに作られているので、デプロイされたコンテナはなんらかのポートでHTTPリクエストを待ち受ける必要があります。`$PORT`環境変数(デフォルトで8080番)で指定されたポートへHTTPリクエストが到達しない場合、アプリが落ちたと判断されて再起動されるという仕組みになっています。
そもそもの話、HTTPリクエストを受け付けないdiscord botのようなサービスでは本来ならば起動すらできません。

そこで、非同期のHTTPサーバを立ててバックグラウンドで実行することにしました。
一応、先人がいたのでその辺の情報を参考にしつつ進めました。
 - [How to Run Discord Bot on Cloud Run](https://emilwypych.com/2020/10/25/how-to-run-discord-bot-on-cloud-run/)
 - [discordhealthcheck](https://github.com/psidex/discordhealthcheck)


:::note info
あくまでもWorkaroundなのである程度規模の大きいBotでは他の方法を検討してください。
:::

実装のサンプルはここにおきました。

https://github.com/alkshmir/discord-bot-w-healthcheck

下の例は[discord.py](https://github.com/Rapptz/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())
```
- `ENV_VAR_DISCORD_ID`環境変数にはDiscord botのトークンが入ります
- [discord.py v2.0.0から起動時に`Intent`を指定する必要があります](https://discordpy.readthedocs.io/ja/latest/migrating.html#intents-are-now-required)

Cloud Runへのデプロイの方法はいろんなところに書いてあるので省略します。
### 料金高すぎ
CPUを常に割り当てるサービスとして稼働させて、レスポンスも十分早かったのですが、すぐに無料枠が終了し、一日200円ぐらいかかるようになってしまいました。

事前に計算すればすぐにわかることではあり、１ヶ月あたり概算で$3600\times 24\times 30=2592000$CPU秒なので全く無料枠には収まりません。

## GAEへの移行
下の無料枠一覧表を眺めた結果、App EngineのFインスタンスが１日あたり28時間無料(執筆時)だったのでこれを使うことにしました。

https://cloud.google.com/free/docs/free-cloud-features?hl=ja#app-engine

App Engineにはdockerコンテナをそのまま動かせる[フレキシブル環境](https://cloud.google.com/appengine/docs/flexible?hl=ja)もありますが、執筆時には無料枠はなかったので[スタンダード環境](https://cloud.google.com/appengine/docs/standard?hl=ja)を選択しました。
スタンダード環境では使える言語が限られていますが、運よく？pythonがサポートされていました。

公式資料を参考にしながら進めてみました。
- [App Engine スタンダード環境で Python 3 アプリを作成する](https://cloud.google.com/appengine/docs/standard/python3/create-app?hl=ja)
- [app.yaml 構成ファイル](https://cloud.google.com/appengine/docs/standard/python3/config/appref?hl=ja)

### app.yaml書く
ランタイムに関する情報は`app.yaml`に書いていきます。
環境変数がべた書き去れてしまうので、[secret managerからビルド時に引っ張ってくる](https://zenn.dev/waddy/scraps/ecf08c43875708)などの回避策が必要なようです。

めんどくせぇので、`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インスタンスしか手動スケーリングできない](https://cloud.google.com/appengine/docs/standard?hl=ja#instance_classes)こと、[Bインスタンスは1日9時間までしか無料じゃない](https://cloud.google.com/free/docs/free-cloud-features?hl=ja#app-engine)ことを考慮してこうなってます…

`gcloud app deploy`でデプロイします。簡単ですね。
Cloud Runで動かすためにHTTPサーバを実装していたので、何も考えずにそのままデプロイできました。

### 自動シャットダウン問題
とりあえずApp EngineでBotを動かすことができ、レスポンスも問題なかったのですが、全くアクセスがないと一定時間でシャットダウンされるみたいでした。
[公式資料](https://cloud.google.com/appengine/docs/legacy/standard/python/how-instances-are-managed)によると
> インスタンスはリクエスト処理のためにオンデマンドで作成され、アイドル時には自動的にシャットダウンされます。

とあります。特にアイドル時間は書いてないのですが、ログを確認すると最後のリクエストから大体24時間ぐらいでシャットダウンされるようでした。

自動スケーリング以外ならシャットダウンまでの時間を指定できるようでしたが、Fインスタンスは自動スケーリングしかないので、cron jobでアクセスをかけてみます。 cron jobを定義するにはアプリケーションのルートディレクトリに`cron.yaml`を置くだけでいいらしい。 簡単ですね。

[Python 2 用 cron によるタスクのスケジューリング](https://cloud.google.com/appengine/docs/standard/python/config/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`です。簡単ですね。

こちらの構成で今までのところ安定稼働しています。
実を言うとログを見ると１日の中で落ちている時間もあるようなのですが、必ず１時間ごとに復活するのでOKとしています。無料で使わせてもらってるので…

## TODO
デプロイ時に環境変数を自動で展開してくれるいいかんじのCIを書く