Closed6

Discord Botの再接続

高田 晴彦高田 晴彦

今回扱うDiscord Bot

https://github.com/tfandkusu/discord_atumaru_bot

  • コマンドでBotがメッセージを投稿
  • Botが投稿したメッセージに対するリアクションでメッセージの文面を変更したり削除したりできる

使用ライブラリ

https://github.com/Rapptz/discord.py

インフラ

Google Compute Engine(GCE)の無料枠でDockerイメージを動かしている。
Discord BotはHTTPSサーバーではなく、Web Socketクライアントとして動作するので、いつものGoogle App Engine スタンダード環境は使えない。

高田 晴彦高田 晴彦

問題

  • 突然オフラインになる。
  • 私がGCEインスタンス再起動という対応をするまでは自動でオンラインに復旧しない

目指したい状態

  • 突然のオフラインは許容する
    • Web Socketクライアントという性質上、それが単一障害点になることは避けられない。
  • オフラインになったら、10分以内に自動でオンラインに復旧する 。
高田 晴彦高田 晴彦

原因と考えられる現象

ネットワークが切断されるとDiscord Botはオフラインになる。
オフラインになったと思われる時刻にネットワークの問題と思われるログが残っていた。

ネットワーク切断をローカルで再現する

BotをローカルPCのDockerで動かす。

docker compose run discord_atumaru_bot poetry run python main.py

NETWORK IDを確認する

docker network ls
NETWORK ID     NAME                                   DRIVER    SCOPE
128719f5aaf1   discord_atumaru_bot_default            bridge    local

CONTAINER IDを確認する

docker container ls
CONTAINER ID   IMAGE                                     COMMAND                  CREATED              STATUS              PORTS     NAMES
e3c3912fe563   discord_atumaru_bot_discord_atumaru_bot   "poetry run python m…"   About a minute ago   Up About a minute             discord_atumaru_bot_discord_atumaru_bot_run_6762d136a433

ネットワークを切断する

# docker network disconnect <NETWORK ID> <CONTAINER ID>
docker network disconnect 128719f5aaf1 e3c3912fe563

ネットワークを切断したらどうなるか

Botがコマンドに反応しなくなる。
しばらくすると、Botがオフラインになる。

Pythonスクリプトは終了しない。

高田 晴彦高田 晴彦

ネットワークが切断されたら、Pythonスクリプトが終了されるようにする

discord.pyにreconnect引数があり、デフォルトTrueになっていることを発見

reconnect引数をFalseにした。

client.run(token, reconnect=False)

ネットワーク切断を起こすと、Pythonスクリプトが終了されるようになった。

in connect
    proto = await self._create_connection(req, traces, timeout)
  File "/root/.cache/pypoetry/virtualenvs/discord-atumaru-bot-9TtSrW0h-py3.9/lib/python3.9/site-packages/aiohttp/connector.py", line 892, in _create_connection
    _, proto = await self._create_direct_connection(req, traces, timeout)
  File "/root/.cache/pypoetry/virtualenvs/discord-atumaru-bot-9TtSrW0h-py3.9/lib/python3.9/site-packages/aiohttp/connector.py", line 1011, in _create_direct_connection
    raise ClientConnectorError(req.connection_key, exc) from exc
aiohttp.client_exceptions.ClientConnectorError: Cannot connect to host discord.com:443 ssl:default [Temporary failure in name resolution]
高田 晴彦高田 晴彦

Exponential Backoffを実装する

Botがネットワークエラーになったら、自動で再接続するようにしたい。しかし、DiscordやGCPの障害の可能性もあるため、再接続するインターバルは回数毎に長くしたい。

Pythonスクリプトの改修

再接続の時は、Clientを再度作成する。作成するときは asyncio.new_event_loop() で作成したイベントループをClientに渡す。理由はdiscord.Client.run()は標準だと内部でasyncio.get_event_loop()を使うがasyncio.get_event_loop()は1スレッド1つしかイベントループを作らず、discord.py内部ではclose処理が行われているため、新しく作らないと Event loop is closed エラーが発生してしまう。


class BotTask:
    "ネットワーク接続が切れたときの再接続対応クラス"

    def __init__(self):
        # 再接続を行った回数
        self.retry_count = 0

    def start_with_retry(self):
        "再接続対応でBotを開始する"
        while True:
            try:
                # Botを開始する
                self._start()
                # 正常終了の時はスクリプトを終了する
                break
            except Exception as e:
                self.retry_count += 1
                print(e)
                # エラーが発生したときは
                print("retry %d" % self.retry_count)
                # 2のリトライ回数乗×5秒待つ
                interval = 5*2**self.retry_count
                print("interval %d" % interval)
                # 待ち時間が4時間超えたら、スクリプトを終了する
                if interval >= 4*60*60:
                    break
                time.sleep(interval)

    def _start(self):
        # クライアントを作成
        client = self._make_client()
        # 環境変数からトークンを得る
        token = os.environ['DISCORD_TOKEN']
        # Botを実行
        client.run(token, reconnect=False)

    def _make_client(self):
        "Discordのクライアントを作成する"
        # リアクション削除の取得に必要
        intents = discord.Intents.default()
        intents.members = True
        # Clientクラス内部で作られるイベントループは1スレッド1つ。
        # そしてエラーになると、それが閉じられるので、毎回新しく作成する
        loop = asyncio.new_event_loop()
        # それをクライアント作成時に渡す
        client = discord.Client(intents=intents, loop=loop)

        @client.event
        async def on_ready():
            # 接続出来たのでretry_countを戻す
            self.retry_count = 0
            print('We have logged in as {0.user}'.format(client))

        # 省略

        return client


task = BotTask()
task.start_with_retry()
高田 晴彦高田 晴彦

動作確認

ネットワークが切断されてエラー落ちしたが、ネットワーク普及したので再接続されたケース

We have logged in as 【テスト】集まるBot#3704

ここでdisconnect

Cannot connect to host discord.com:443 ssl:default [Temporary failure in name resolution]
retry 1
interval 10

ここでconnect

We have logged in as 【テスト】集まるBot#3704

ずっとネットワークが切断されたケース

最大再接続インターバルを30秒に設定

We have logged in as 【テスト】集まるBot#3704
Cannot connect to host discord.com:443 ssl:default [Temporary failure in name resolution]
retry 1
interval 10
Cannot connect to host discord.com:443 ssl:default [Temporary failure in name resolution]
retry 2
interval 20
Cannot connect to host discord.com:443 ssl:default [Temporary failure in name resolution]
retry 3
interval 40
このスクラップは2021/06/12にクローズされました