AnySan+AnyEvent::SlackRTMを使ったbotを延命させるproxy、sock2rtm

この記事は Perl Advent Calendar 2022 14日目の記事です。

最初に3行でまとめ

  • Slackのrtm.start APIが廃止された
  • AnySan+AnyEvent::SlackRTMが正常に動作しなくなった
  • それを解決するproxyをGoで書いたよ

プロダクトの開発・運用のお供に Slack bot、いると思います。とある会社で7〜8年前に作られたPerlによるWebサービス(ゲームサーバー、2タイトル)でもご多分に漏れず、Slackで動作しているbotが大変重用されています。

特にブランチごとの開発環境の立ち上げ、終了、マスターデータの操作などはSlackでのbotへのコマンドで操作するのがもっぱらになっていて、これができないと開発も運用もほぼ止まってしまうような状態です。

これらのプロジェクトではPerlで非同期に動作するbotを書くために、AnySanAnySan::Provider::Slackが使われており、更にそこから AnyEvent::SlackRTM が実際のSlackに対する通信を行っている作りになっていました。

Slackのrtm.start API廃止

api.slack.com

ところが2022年9月、AnyEvent::SlackRTM (v1.1まで)が使っているAPIである rtm.start が廃止されることになりました。

このAPIは最初期からあった関係か「WebSocket接続の最初にそのworkspaceにいるメンバー全員(!!)の情報と、WebSocketの接続エンドポイントを返す」という豪気な作りになっており、workspaceに人がたくさんいるとレスポンスが極めて遅いという特徴がありました (なので廃止されたんでしょう)。

実際にそれでAPIタイムアウトしてbotが起動できないことがあり、HTTPクライアントのタイムアウトを伸ばすPRを同僚が送って凌いでいたりしました。

github.com

代替としては rtm.connect というAPIを使うことができます。このAPIは、単純にWebSocketの接続エンドポイントを返すもので高速です。AnyEvent::SlackRTM にもこれに切り替えるPRが送られていたのですが、作者がもうPerlをあまり使っていないらしく、しばらく反応がありませんでした。

github.com

何度かコメントした結果、取り込んでリリースしてもらえたのですが、作者曰く

I'm not doing a lot of Perl these days, and I am not writing any bots at all, unfortunately.

とのことで、今後のメンテナンスはちょっと期待しづらい感じです。

rtm.startが廃止されてどうなったか

さて、rtm.connectを使えばそれで問題ないかというと、実際にこれに切り替えた場合にいくつか問題が発生したのでした。

  • AnySan::Provider::Slack はrtm.startでメンバーリストが返されるのをメモリ上にhashで持つ
    • この情報を使ってメッセージの送信者(IDが含まれている)からnicknameを解決している
    • AnySanのAPIでは、IDではなくnicknameを受け取るようになっている (もともとIRC用だったので概念がnicknameベース)
    • メンバーリストがないとメッセージの送信者が取れないため、それに依存した処理ができなくなった
  • rtm.start 廃止後、頻繁にbotが「分身」するようになった
    • これは以前はなかった挙動なのですが、どうやらbot中で数秒程度ブロッキングする処理をしてしまうとWebSocketが切断された時のような挙動を示すようです
    • 加えてAnyEvent::WebSocket::Client あたりの再接続の挙動が原因なのか、再接続が起きると内部的に2個、おなじbotが重複するような挙動をするようになりました→分身

後者はかなり深刻で、不意にbotが内部的に1→2→4→8と分身してそれぞれがメッセージに反応するため、1つ話しかけると複数のbotが一斉に返事をする (だけならまだしも、開発環境を重複して起動したりCIを重複して実行したりする) ようになってしまいました。

とりあえずプロセスを再起動すればリセットされて復活するので、定期的に再起動して凌ぐというワークアラウンドで凌いでいたのですが、不意に分身するたびに手動で再起動するのはあまりにもひどい運用ですね。

解決法 proxy を書く

分身まわりの挙動はAnyEvent::WebSocket::Client (か更にそこで使っているモジュール)を頑張って追えば、直せたかもしれません。しかしbotの行動には発言者をメンバーリストで解決して、その発言者の情報を元になにかするコードがあったため、そこもなんとかして解決しないとbotの改修が必要になってしまいます。

しかも今からAnySanを頑張って直しても、おそらくモジュールのメンテナも使っていなさそうだし…

と悩んだ結果、「Slackのrtm.startのフリをするProxyを書く方が早いんじゃないか(Goで)」と思い立ち、1日頑張って書いたら動いたので投入しました。よかったですね。

sock2rtm

github.com

Goで書かれた「Slack Socket Modeでメッセージを受信してSlack RTMのように振る舞うproxy」です。

使いたい人がいるかは分かりませんが、READMEは日本語で書いてあるので使う場合は読んでみてください。

/start/{チャンネルID 複数指定する場合は , 区切り} に接続すると、そのチャンネルにいるメンバーを取得してrtm.startと同様のレスポンスを返します。WebSocket APIも提供しているため、レスポンスの中のWebSocket URLに接続すると、botがjoinしているチャンネルのメッセージが(Slack RTM同様の形式で)降ってきます。

つまりAnyEvent::SlackRTM を使用する場合は、$AnyEvent::SlackRTM::START_URL をこのAPIのURLに書き換えればOKなのです!

Socket Mode側の再接続はGoのSlackクライアントがよしなにしてくれるのですが、WebSocket側で再接続が発生すると相変わらず分身することがあります。

いろいろ調べた結果、AnyEvent::WebSocket::Connection::closeが呼ばれたらそこで自死する(そしてsupervisorに再起動してもらう)のが手っ取り早かったため、以下のようなコードをbotの最初に入れて解決しました。

BEGIN {
    if ($ENV{SLACK_START_URL} ne '') {
        # sock2rtmのエンドポイントに差し替える
        # https://metacpan.org/dist/AnyEvent-SlackRTM/source/lib/AnyEvent/SlackRTM.pm#L14
        warn "Set SLACK_START_URL to $ENV{SLACK_START_URL}";
        $AnyEvent::SlackRTM::START_URL = $ENV{SLACK_START_URL};

        # 再接続時に複数のwebsocket接続が発生して分身する現象がある
        # close()を上書きすることで、websocket切断時にプロセスが死ぬようになる
        # start_serverから起動することで、プロセスが再起動されることを期待する
        sub AnyEvent::WebSocket::Connection::close {
            die "websocket connection closed";
        }
    }
};

なお、それでもなぜか、稀に分身してしまうことがあります。sock2rmには/metricsというエンドポイントがあり、JSON形式でメトリクスを取得できます。websocket.current_connectionsがsock2rtmに接続しているクライアントのコネクション数(つまりbotの分身数)なので、これをモニタリングして再起動を走らせる、などもできます。こういう小物であっても可観測性は大事ですね。

{
  "slack": {
    "hello": 1,
    "connecting": 1,
    "connected": 1,
    "disconnect": 0
  },
  "websocket": {
    "total_connections": 9,
    "current_connections": 1
  },
  "messages": {
    "received_from_slack": 0,
    "delivered_to_websocket": 0,
    "unsupported_from_slack": 0,
    "write_errored_to_websocket": 0
  }
}

さいごに

最近は新規でPerlを使った開発をすることは少なくなりましたが、運用中のプロダクトは引き続き運用していかなければいけません。問題が起きた場合、もちろんPerlの中で解決することが一番いいのですが、最近の趨勢だとなかなかアクティブなメンテナンスが期待しづらいこともあります。その場合は、外側で一層包んであげて解決することもできるんじゃないか、というお話でした。