この記事は 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を書くために、AnySan と AnySan::Provider::Slackが使われており、更にそこから AnyEvent::SlackRTM が実際のSlackに対する通信を行っている作りになっていました。
Slackのrtm.start
API廃止
ところが2022年9月、AnyEvent::SlackRTM (v1.1まで)が使っているAPIである rtm.start
が廃止されることになりました。
このAPIは最初期からあった関係か「WebSocket接続の最初にそのworkspaceにいるメンバー全員(!!)の情報と、WebSocketの接続エンドポイントを返す」という豪気な作りになっており、workspaceに人がたくさんいるとレスポンスが極めて遅いという特徴がありました (なので廃止されたんでしょう)。
実際にそれでAPIがタイムアウトしてbotが起動できないことがあり、HTTPクライアントのタイムアウトを伸ばすPRを同僚が送って凌いでいたりしました。
代替としては rtm.connect
というAPIを使うことができます。このAPIは、単純にWebSocketの接続エンドポイントを返すもので高速です。AnyEvent::SlackRTM にもこれに切り替えるPRが送られていたのですが、作者がもうPerlをあまり使っていないらしく、しばらく反応がありませんでした。
何度かコメントした結果、取り込んでリリースしてもらえたのですが、作者曰く
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で持つ rtm.start
廃止後、頻繁にbotが「分身」するようになった
後者はかなり深刻で、不意にbotが内部的に1→2→4→8と分身してそれぞれがメッセージに反応するため、1つ話しかけると複数のbotが一斉に返事をする (だけならまだしも、開発環境を重複して起動したりCIを重複して実行したりする) ようになってしまいました。
とりあえずプロセスを再起動すればリセットされて復活するので、定期的に再起動して凌ぐというワークアラウンドで凌いでいたのですが、不意に分身するたびに手動で再起動するのはあまりにもひどい運用ですね。
解決法 proxy を書く
分身まわりの挙動はAnyEvent::WebSocket::Client (か更にそこで使っているモジュール)を頑張って追えば、直せたかもしれません。しかしbotの行動には発言者をメンバーリストで解決して、その発言者の情報を元になにかするコードがあったため、そこもなんとかして解決しないとbotの改修が必要になってしまいます。
しかも今からAnySanを頑張って直しても、おそらくモジュールのメンテナも使っていなさそうだし…
と悩んだ結果、「Slackのrtm.start
のフリをするProxyを書く方が早いんじゃないか(Goで)」と思い立ち、1日頑張って書いたら動いたので投入しました。よかったですね。
sock2rtm
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の中で解決することが一番いいのですが、最近の趨勢だとなかなかアクティブなメンテナンスが期待しづらいこともあります。その場合は、外側で一層包んであげて解決することもできるんじゃないか、というお話でした。