土曜日, 6月 21, 2025
- Advertisment -
ホームニューステックニュース【個人開発】モノリシックなdiscord botをマイクロサービス化してみた #Python - Qiita

【個人開発】モノリシックなdiscord botをマイクロサービス化してみた #Python – Qiita



【個人開発】モノリシックなdiscord botをマイクロサービス化してみた #Python - Qiita

5年ほど前に作った個人開発のアプリ(discordのbot)を久しぶりに改修しようとしたところ、メンテが不可能になっていたのでマイクロサービス化に挑戦してみました。その忘備録を兼ねた記事です。

まず、どんなアプリケーションを作っていたのか簡単に説明させていただきます。
外部仕様としてはシンプルで、ユーザー/サーバーごとにdiscord上の通話を検知して、その通話データを蓄積・可視化できるというものです。

mosaic_20250620042213.png
通話の開始検知

mosaic_20250620042518.png
通話の終了検知

mosaic_20250620042915.png
統計情報の可視化

まとめると

  • サーバー(discordのグループの単位)上での通話の開始/終了を検知
    • 通知先テキストチャンネルの設定
  • ユーザーごとの通話への参加/退出を検知
  • 統計情報の可視化
    • 通話終了時のタイムライン画像の生成
    • 統計情報のグラフ画像の生成

などが主な機能です。

開発しようと思った動機としては、当時これに類するbotがこの世になくて自分が欲しくなったからです。(主にタイムラインの生成)
導入自体は不特定多数にオープンしていたわけではなく、人伝で要望があれば招待リンクを渡すようにしてほぼほぼ身内で閉じるように運用していました。(10サーバーぐらいに導入していただいてました)

数年動かしていたのですがここ最近挙動が不安定になり、メンテナンスしようと思って数年ぶりにリポジトリを開きました。
久しぶりに自分のコードを見返した時の第一印象は 「なんだこれ」 でした。
当時の自分が何を考えてこんなコードを書いたのか全く理解できない状態で、正直触るのが怖くなってしまいました。
具体的にどんな問題があったか説明させていただきます。

移行前の問題

複雑すぎるイベント処理

まず、メインの処理が1つのファイルに2000行近く詰め込まれていました。
disnake.pyというdiscord apiのwrapperライブラリを使っていたのですが、その中の通話イベントをフックしている処理では

class EndEvent(commands.Cog):
    def __init__(self, bot):
        self.bot: discord.ext.commands.Bot = bot
        self.JST: timezone = timezone(timedelta(hours=+9), 'JST')

    #True/False when vc ended
    def is_vc_ended(self, member, before, after) -> bool:
        # 通話の終了判定ロジック
        # ...

    def make_vc_end_notify_embed(self, member: discord.Member, channel_ended: discord.VoiceChannel, vc_time: timedelta, participants, calls_number: int):
        # メッセージの作成処理
        # ...

    def update_guild_vctotal(self, session, member: discord.Member, vc_state: VCState, guild_vc_info: GuildVCInfo, time_ended: dt):
        # dbの更新処理
        # ...
    
    # こんな感じの処理が延々と続く..

特に通話終了時の処理は、巨大なファイルになっていて、以下のような処理が全て一箇所に詰め込まれていました:

  • 通話終了の判定
  • タイムライン画像の生成
  • データベースの複数テーブル更新
  • Discord通知メッセージの送信
  • 一時ファイルの削除

しかも、これらの処理が同期的に実行されるため、画像生成に時間がかかると他の通話イベントの処理が遅れてしまうという問題もありました。

画像生成処理の重さ

統計画像生成を担うコードも巨大なファイルになっており、matplotlib、seaborn、plotly等の重いライブラリを使用していました。

# 画像生成処理の例
async def save_image_total(member_infos, guild: discord.Guild):
    sns.set()
    # ... 複雑な描画処理
    plt.savefig(f'./.image/{guild.id}/total.png', bbox_inches='tight')

この処理がDiscordイベントハンドラ内で同期実行されるため、画像生成中は他のイベントが処理されない状態になっていました
複数のサーバーで同時に通話が終了した場合、画像生成が詰まって通知が遅れることもありました。

全く正規化されていないテーブル設計

通話の状態管理テーブルは、データベース設計の基本原則を無視した典型的な悪い例でした。

CREATE TABLE VC_STATE (
    index BIGINT PRIMARY KEY AUTO_INCREMENT,
    guild_id BIGINT,
    guild_name VARCHAR(60),                    -- 非正規化、ギルド名が毎回保存
    voice_channel_id BIGINT,
    voice_channel_name VARCHAR(200),           -- 非正規化、チャンネル名が毎回保存
    start_message_id BIGINT,                   -- Discord固有の情報が混在
    voice_channel_vc_start_time DATETIME
);

guild_nameがサーバーの名前に相当するのですが、サーバーの名前に変更があった場合、すべてのレコードを一括で更新する必要がある という教科書に載せたいレベルのよくない事例が発生していました。

データベース処理の分散

データベース操作が各通話のイベントファイルに散らばっていて、同じようなクエリが複数箇所で重複していました。
例えば、サーバー情報の取得処理がstart.py、end.py、join.py、exit.pyの至る所に散在しているような状態です。

# 同じような処理が各ファイルに散在
guild_vc_info: GuildVCInfo = session.query(GuildVCInfo).filter(
    GuildVCInfo.id == member.guild.id
).first()

トランザクション管理も一貫性がなく、一部の処理でコミットし忘れやロールバック漏れが発生する可能性もありました。

テストの困難さ

全ての機能が密結合しているため、個別の機能をテストするのが非常に困難でした。
例えば「タイムライン画像生成だけテストしたい」と思っても、Discord bot全体を起動してデータベースに接続し、実際のギルド情報を用意する必要がある状態でした。(E2Eテストするしかない状態)

スケーラビリティの欠如

10サーバー程度の導入でも以下のような問題が顕在化していました:

  • 複数の通話が同時終了すると画像生成で詰まる
  • メモリ使用量が時間とともに増加する
  • 一つのサーバーで重い処理が発生すると他のサーバーの応答も遅くなる

決断のきっかけ

そんな状況で、当初は身内向けの限定公開で運用していたのですが、思いのほか好評で「もっと多くの人に使ってもらいたい」と思うようになりました。
しかし、不特定多数のサーバーに導入してもらうためには、現在のアーキテクチャでは明らかに限界がありました
ちょうど業務でマイクロサービスアーキテクチャに触れる機会があったので、「個人開発の規模でもマイクロサービス化は有効なのか?」という疑問を実際に検証してみたくなったのも大きな理由です。
正直、最初は「個人開発にマイクロサービスなんて過剰かな?」と思っていましたが、実際にやってみた結果はなかなか良いものでした。

旧アーキテクチャの問題を踏まえて、新しい設計では以下の原則を重視しました。

単一責任の原則

まず最も重要視したのは、各サービスが単一の責任のみを持つようにすることでした。
旧アーキテクチャでは一つのイベントハンドラが何でもやりすぎていたので、機能を以下のように明確に分離しました。

watcher (Discord Event Monitor)

  • 責任: Discord 音声チャンネルのリアルタイム監視のみ
  • 技術: discord.py + Pika (RabbitMQ)
  • 機能:
    • 通話イベントの検知
    • メッセージキューへのイベント送信

worker (Event Processor)

  • 責任: イベント処理とデータ永続化のみ
  • 技術: Pika (RabbitMQ) + SQLAlchemy
  • 機能:
    • 通話セッションのDBテーブル管理
    • ユーザーの通話セッションのテーブル管理
    • セッション開始/終了の判定と greeter(後述) への通知

greeter (Notification Manager)

  • 責任: Discordへの通知とユーザーインタラクションのみ
  • 技術: discord.py + Pika (RabbitMQ)
  • 機能:
    • 通話開始/終了通知の送信
    • 各種設定の受付 (通知先設定, タイムゾーン設定..)
    • publisher(後述) への画像生成依頼

publisher (Timeline Generator)

  • 責任: 画像生成のみ(Discord アクセスなし)
  • 技術: Selenium + Jinja2 + Pika (RabbitMQ)
  • 機能:
    • HTML テンプレートからの画像生成
    • Base64 エンコードされた画像データの返却

これにより最終的には以下の様な構成へと変貌しました。
Editor _ Mermaid Chart-2025-06-19-203732.png

DBへの依存をworkerだけに切り離すことに成功し、サービスごとに明確に責務を分けました🎉

非同期処理とメッセージキューの導入

RabbitMQによるイベントドリブンアーキテクチャ
旧アーキテクチャで最も大きな問題だった「重い処理によるブロッキング」を解決するため、RabbitMQを使った完全非同期処理に変更しました。

// watcher → worker への通話開始イベントの例
// workerはメッセージを受け取り次第DBへの書き込みなどの処理をはじめる
{
    "event_type": "call_started",
    "guild_id": "123456789",
    "channel_id": "987654321",
    "user_id": "111222333",
    "timestamp": "2025-01-01T12:00:00Z"
}

これにより、画像生成に時間がかかっても他の通話イベントの処理が止まらないようになりました。
また、以下のようにサービス間はメッセージキューでやり取りをするため

# 通話の開始/終了、ユーザーの参加/退出を検知 -> workerにdb書き込み依頼
voice_events: watcher → worker 
# dbを書き込み終わったらユーザーに通知するようgreeterに依頼
notifications: worker → greeter  
# 通話の終了メッセージに添付するイメージの生成をpublisherに依頼
timeline_requests: greeter → publisher

各サービス間の通信を完全に疎結合にすることで、一つのサービスが停止しても他のサービスには影響しない設計にしました。

画像生成処理の完全分離

旧アーキテクチャで最も厄介だった画像生成処理を、独立したサービスとして完全に分離したのがpublisherです。
新アーキテクチャでは、画像生成処理要求がgreeterから来た際(通話の終了時)に非同期に処理するようにしました。

# 実装の一部
class PublisherApp:
    def __init__(self):
        self.html_renderer = HTMLRenderer()
    
    async def handle_timeline_request(self, timeline_data):
        """メッセージキュー経由でタイムライン生成要求を受信"""
        try:
            # Chrome/Seleniumによる画像生成
            image_data = await self.html_renderer.generate_timeline(
                timeline_data.participants,
                timeline_data.duration,
                timeline_data.segments
            )
            
            # Base64エンコードしてgreeterに返却
            return {
                "image_data": image_data,
                "session_id": timeline_data.session_id
            }
        except Exception as e:
            # 画像生成に失敗しても他のサービスには影響なし
            logger.error(f"Timeline generation failed: {e}")
            return None

また、特にユーザー体験的な部分でより柔軟な選択肢がとれるように HTMLテンプレート + CSS による画像生成 に切り替えました。(以前は、matplotを使ったグラフ生成)
これにより:

  • デザインの柔軟性: CSSで自由自在にスタイリング可能
  • 保守性の向上: デザイン変更時にPythonコードを触る必要なし
  • 高品質な出力: Chrome/Seleniumによるブラウザレンダリング

が可能になりました。
実際の出力は以下のような感じです。
mosaic_20250620064543.png

データベース設計の改善

旧アーキテクチャで散らばっていたデータベース処理を、workerサービスに集約しました。
これにより:

  • データアクセスロジックの重複を排除
  • トランザクション管理の一元化
  • データベーススキーマ変更時の影響範囲を限定

が実現され、無駄に重複したクエリなどが消滅しました。

-- 通話セッション情報
voice_channel_sessions (id, guild_id, channel_id, start_time, end_time)

-- ユーザーの参加/退出記録  
user_sessions (id, session_id, user_id, join_time, leave_time)

-- 通話開始メッセージ管理
call_start_messages (id, session_id, channel_id, message_id)

旧アーキテクチャの複雑なテーブル構成をシンプルに整理し、正規化を適切に行いました

Docker化による環境統一

マイクロサービス化の恩恵を最大限活用するため、各サービスを独立したDockerコンテナとして動かすようにしました。(本番のDBはクラウド上にホスティングしています)

# docker-compose.ymlの例
services:
  watcher:
    build: ./watcher
    depends_on:
      - rabbitmq
    ...
      
  worker:  
    build: ./worker
    depends_on:
      - rabbitmq
      - postgres
    ...
      
  greeter:
    build: ./greeter
    depends_on:
      - rabbitmq
    ...
      
  publisher:
    build: ./publisher
    depends_on:
      - rabbitmq
    ...
      
  # インフラサービス
  rabbitmq:
    image: rabbitmq:xx
    ...
    
  postgres:
    image: postgres:xx
    ...

結果とマイクロサービス化による恩恵

これにより、以下のような恩恵を得ることができました。

個別サービスのスケーリングが可能

これが一番のメリットだと個人的に思っていて、性能が足りないサービスをピンポイントでスケーリングすることが可能になりました。

# 画像生成が重い場合、publisherだけスケールアップ
docker compose up --scale publisher=3

# 多数のサーバーで使われる場合、watcherを増やす
docker compose up --scale watcher=5

スケールさせた時のシステムの構成は下図のようになるイメージです。
Editor _ Mermaid Chart-2025-06-19-215154.png

障害時の影響範囲を限定

旧アーキテクチャでは画像生成でエラーが発生するとbot全体が停止していましたが、現在は画像生成以外の機能は継続して動作します。

開発環境と本番環境の差異を排除

特にpublisherサービスではChrome/Seleniumという環境依存が激しいライブラリを使用しているため、Dockerによる環境統一の恩恵は大きかったです。

柔軟なデプロイ

マイクロサービス化により、機能ごとに段階的なデプロイができるようになりました

docker compose up -d watcher worker postgres rabbitmq  # コア機能先行
docker compose up -d greeter                           # 通知機能追加  
docker compose up -d publisher                         # 画像生成機能追加

ログとモニタリングの改善

各サービスごとに独立したログ管理ができるため、問題の特定と解決が格段に楽になりました。旧アーキテクチャでは「どの処理でエラーが起きたか分からない」状態でしたが、現在はサービス単位で問題を切り分けできます。

個人開発でもマイクロサービスは十分有効

実際にやってみた結果、個人開発だからこそマイクロサービス化の恩恵が大きいということが分かりました。
理由としては:

  • 一人で全てを把握する必要があるため、責任境界が明確な方が管理しやすい
  • 機能追加時に既存コードを壊すリスクが格段に減る
  • 問題が起きた時の調査範囲を限定できる
  • コンテキストが小さくなるのでLLMやAIエディタと相性がいい

このあたりが個人的には嬉しかったです。
今時のPCは性能が十分なのでローカルでコンテナを立て放題なのも追い風ですね。

パブリック展開への準備

現在は身内のサーバーで検証しながら開発中です。最終的には誰でも使えるパブリックなbotとして展開することを目指しています。
移行前の機能もいくつかまだ移せてないのでそれも適宜やりつつ。

マイクロサービス化により各サービスを独立してスケールできるため、パブリック展開時の急激な負荷増加にも対応しやすくなったかなと感じています。

Webダッシュボードページ

旧アーキテクチャではDiscord上での画像表示のみでしたが、新アーキテクチャではReact + FastAPIによる本格的なWebダッシュボードも鋭意開発中です。
mosaic_20250620071802.png

Kubernetesへの挑戦

現在はDocker Composeで運用していますが、将来的にはKubernetes(k8s)での運用も検討しています。
正直なところ、現在の規模ではk8sは過剰かもしれないですが:

  • コンテナオーケストレーションの知識をキャッチアップしたい
  • 将来のパブリック展開時により柔軟なスケーリングを実現したい
  • 障害耐性とセルフヒーリング機能を向上させたい

という理由で挑戦してみたいと思っています。(特に通話の検知は24/365運用となるので、システムの可用性が非常に重要)
マイクロサービス化により各サービスが疎結合になっているため、k8sへの移行も比較的スムーズに行えると思ってます。

もし個人開発で同じように古いコードのメンテナンスで困っている方がいたら、思い切ってアーキテクチャから見直してみることをお勧めしたいです。
最初は学習コストがかかりますが、長期的に見ると確実なリターンのある投資だと思います。
また、今回の経験を通じて「個人開発だから適当でいい」という考えを改めました。
むしろ個人開発だからこそ、将来の自分のために丁寧な設計を心がけるべきだと感じています。
この記事が、同じような課題を抱えている個人開発者の方の参考になれば幸いです。
最後まで読んでいただき、ありがとうございました!

マイクロサービスアーキテクチャ 第2版
モノリスからマイクロサービスへ ―モノリスを進化させる実践移行ガイド





Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -