今月中旬に沖縄県那覇市で RubyKaigi 2024 を開催した。COVID-19 対応をしていた RubyKaigi Takeout 2020, RubyKaigi Takeout 2021, RubyKaigi 2022, RubyKaigi 2023 とは異なり、今回は配信を伴わないオフラインのみの開催だった。
わたしは Organizer の一人として Sponsor Relations 業などをしつつ、Wi-Fi の支度をしたり、サイネージの支度をしたりしていた。Wi-Fi の話はこれまでもいくつか書いている のでまた今度として、今回はサイネージの話をかきます。
RubyKaigi ではいくつかのサイネージの映像を用意して会場のあちこちに表示している。各セッション会場の横に添えて字幕やチャット, LT タイマーを流すサブスクリーン、お知らせやセッション案内を廊下に設置したモニタや休憩時間 (幕間) のプロジェクタで流す映像が主。サブスクリーンは RubyKaigi 1st season からあったりなかったりする取り組みで、廊下スクリーンは RubyKaigi 2019 くらいからの取り組み(のはず)。2020 は YouTube, 2021-2023 は 独自サイト takeout-app による配信 で実施していて、2022-2023 の会場サイネージも takeout-app 上に用意した機能を投影することで実装していた。
しかし前述の通り RubyKaigi 2024 ではオフラインのみの開催となり takeout-app のバックエンドも動かさないことにしたので、今回サイネージについては takeout-app に依らない方法で用意する必要があった。takeout-app 時代のサイネージの実装もある程度引き継いでいるため、その部分を含めた軽い解説を残してみる。
コード
https://github.com/ruby-no-kai/signage-app
takeout-app の app/javascript をコピーしてきてバックエンド差し替えをしたり、デプロイ用の Terraform モジュール等々が入ってます。
Web フロントエンド
全てのスクリーンは Web ページとして実装して Web ブラウザの全画面表示をすればいい感じの出力が得られるようにしている。これは Web に慣れているというのもあるけれど、Chromebook といった安価で管理しやすい端末を利用できるとか、(配信があった期間は) OBS の Browser Source が利用できるとかの利点がある。
RubyKaigi 向けの実装であるところ、とうぜん自然な選択肢として React で実装することになる… という関係でリポジトリの言語比率は TypeScript が大半を占めている。まぁこれを Hotwire (Stumlus) で実装するのただの苦行って気がするし (注: わたしは Hotwire を信じていません)、Web フロントエンドに実装が偏る都合あきらめています。
ページ内ではほぼ vw 単位を使って viewport の横幅に合わせたスケールになるようにしていて、デザイナーさんには 1080p で作ってもらって 1080p の時のサイズを vw にしてます。
takeout-app では user facing のページなどもあったため lazy loading, code splitting を比較的丁寧にやっていた。今回は user facing のページはなくビルド成果物の配信速度についてはケアする必要がないため Webpack → Vite に入れ替えつつ全面的にオミットしました。
機能
たいしたものではないのでさらっと紹介するだけ
- Venue announcements: サイネージ上に表示するアナウンスとなるリソース。Venue は takeout-app の名残り (配信ページと区別していた)。表示順、サブスクリーンのみ/幕間のみ/廊下モニタのみの指定、や URL に対する QR コード生成などをサポート。
- Screen: トラック{A,B,C}の{幕間,サブスクリーン} や廊下モニタといった場所・用途ごとに細かく設定を投げるためのリソース。メッセージの固定表示や蓋絵モード、スポンサーロゴ表示・非表示など。LT タイマーの状態もここに埋め込み。
- Kiosk: サイネージとして稼動している各デバイス、というかブラウザセッションを識別するためのリソース。管理画面から個別に死活監視なりリロードコマンドなり送れるようにしている
いろいろあり当日もガンガンコードを書いてデプロイしていたので、デプロイして遠くからリロードコマンドを投げると画面が変わったり、あとは単純に管理画面でポチポチすると目の前にある複数の絵が変わるのはいつやっても楽しいなと思います。
バックエンド差し替え
先述の takeout-app 時代では takeout-app のバックエンドである Rails アプリ (とその DB) にサイネージ以外の配信ページを含めた制御を行うためのデータを全て入れていたところ、今回は takeout-app がまるまる存在しないため、フロントエンド視点ではバックエンドの入れ替えが必要になっていた。
takeout-app は Rails というか ActiveRecord で素朴にやりたいために PostgreSQL をストレージとしていて、Rails の app サーバプロセスと PostgreSQL インスタンスといったランニングコストがかかってしまう設計を選択していた。これは配信サーバなども含めるとどのみち会期に近い時期だけ起動して終わったら削除すればいい… という点で許容できたところ、RubyKaigi 2024 からは user facing の配信もないし、会期のたびに作成 → 構築 → 削除はまあまあ手間なのでなんとかしたかった。
いろいろ考えた挙句、 Web ブラウザから DynamoDB を直接叩く 設計にした。API サーバを Ruby で書いて Lambda に置くとかも考えたけれど、基本的には dynamodb:Query の結果をそのままレンダリングするだけなら特に間に余計なものを挟む利点もなかったので検討から外れた。
リアルタイム同期は AWS IoT Core の MQTT に変更。 takeout-app では配信ページに Chime SDK によるチャットがあったため UI 上不可視のチャットメッセージとして相乗りしていたけど、チャットがない今 Chime SDK である必要は特にないし、AWS IoT Core の方が IAM ポリシでの制御がやりやすい・Lambda function や SQS で MQTT メッセージを購読できるのが便利。AWS IoT Core、IoT 向けかと思いきやただの MQTT サーバとして割と便利に使うことが出来るし、Web ブラウザからの利用も整えられているので Pubsub 用途に結構おすすめです。
どちらも時間課金のリソースではないため会期ごとに再構築は不要となり手間が減った。唯一例外として字幕や Discord bot (後述) があるけれど、それについても Terraform でオンオフできるようにしたため多きな手間ではない。
Terraform によるバックエンドのリソース作成
RubyKaigi で自分が管理しているリソースはだいたい Terraform で管理されているので、今回の signage-app もそれに倣います。
https://github.com/ruby-no-kai/signage-app/tree/main/tf
takeout-app ではリポジトリに .tf ファイルを同居させていたらアプリケーションコードとインフラのライフサイクルが微妙に食い違うことによる面倒くささ (.tf からアプリコードや CI によるビルド成果物への依存が発生するとめんどくさい) があったため、今回はそれをなるべく避けたかった。
というところで、signage-app には terraform module だけ置いて実際の apply は https://github.com/ruby-no-kai/rubykaigi-net/tree/master/tf/signage-app から行うようにしてみた。これはこれで ugly なところはあるけれど、takeout-app のそれよりはマシなはず。必要があれば本番環境は ref を固定して開発環境は HEAD を apply するとかができると信じているけどまだ試してはいません。
フロントエンドのビルド成果物をデプロイするための S3 バケット、CloudFront distribution の作成まで行っていて、S3 バケットにフロントエンドが読みにいく各種定数を入れた JSON オブジェクトも作成してみている。
こういう設計にしているので、誰かが気軽に試したり他イベントで改変してつかってみるのもそんなに苦労はしないんじゃないかなと思います。
ログイン
サイネージとしての出力を得る分には anonymous にアクセスできれば良いが、管理画面へのアクセスは当然スタッフのみにアクセスを制限したい。今回 AWS リソースを直接ブラウザから利用することになったので Amazon Cognito を使うことにした。別に自前で sts:AssumeRole をして AWS 資格情報を払出す実装も不可能ではないけれど、これも運用する物を減らすという一環で。
これくらいの用途なら Cognito 使えばシュッとできるだろ、と思っていたら意外にもそうでもなく結構苦労したので、この節後半には AWS 悪口コーナーがあります。
IdP
IdP には RubyKaigi スタッフ向けに用意している環境がある https://github.com/sorah/himari が OIDC IdP として利用できるのでそのまま採用しました。
これについては RubyKaigi 2023 の LT でも話しているけれど、Ruby でありがちな Omniauth のエコシステムを利用して外部のプロバイダでログイン、たとえば GitHub でログインさせて所属 org/team 情報を組み合わせて ID トークンのクレームを決めたり認可コード/ID トークン発行可否を決めたり…を Ruby スクリプトでシュッと実装して OIDC IdP として動作させる、みたいなことがサーバーレスで出来る小さい仕掛けです。
GitHub はそもそも OIDC プロバイダじゃないし (Actions Workflow は除く)、Google とか利用するにも標準の ID トークンに含まれるクレームが認可のために十分じゃなかったりするので、そういうところで小回りが効く存在だと思います (会社とか業務とかでまじめにやるならおとなしく Microsoft Entra ID を使ったら安価に全部解決するので不要です)。
Cognito User Pool
Amazon Cognito については Cognito Identity を利用すれば最低限やりたいことは実装可能, というか Google や任意 OIDC IdP の ID トークンを元に AWS 資格情報の払出しが可能。
ただ、任意 OIDC IdP を Identity Pool で直接利用する場合は AWS IAM の OpenID Connect Provider として登録する必要があり、前述の IdP を issuer として登録することになる。aud 値含め 1 AWS アカウントで 1 つまでしか同一 issuer は登録することが出来ないため、Cognito User Pool を経由することにした。
これはそもそも himari 側でログイン可否を判定しているため、将来的に似たような himari 由来の Cognito 利用が発生した時によくない状態を作り込みそう、というような理由。というか、Cognito Identity Pool 経由だと sts:AssumeRoleWithWebIdentity には Cognito Identity Pool 由来の ID Token を必ず使うことになるため、実際にこの provider の情報を使って sts:AssumeRoleWithWebIdentity する訳ではない。おそらく任意の Provider を登録する仕組みが既に IAM にあるから流用したろ! という所なんだろうけど、そうすると余計な信頼関係を登録することになって微妙だな~と思う。
RubyKaigi の場合は GitHub → himari → Cognito User Pool と、認証ソースから 2 段階もチェインすることになりやや複雑か、という状況だけどこの制限を迂回するためにはしかたありません。
(というか、AWS IAM に OIDC Provider として登録することになるなら Cognito Identity 通さないで直で sts:AssumeRoleWithWebIdentity すればいいんだよね…)
フロントエンドでのログイン
フロントエンドでは https://github.com/authts/react-oidc-context を使って unauthenticated role ではアクセスできないページの場合 Cognito User Pool の authorization code grant フローを開始するようにした。このライブラリがいいのかどうかは正直あんまり調べてないので分かりません。
フロントエンドが直接 user pool に対する client として動くため、secret を渡した上で PKCE を使って authorization code grant を利用して ID token を受け取り localStorage に入れています。draft-ietf-oauth-browser-based-apps を頭に入れているといろいろ微妙だなぁと思いますが Cognito User Pool ではこれ以外だと implicit grant になってしまうので仕方ありません。せめて Cognito User Pool 側に PKCE 強制オプションがあると良かったんだけどなぁ。
今回の用途ではまぁリスクは無視できるだろうという事で無視したけど、実際に真面目に使おうとしたら困るな~って思うこともあるかもしれない。
Identity Pool からの Role Chaining
User Pool から ID token さえ得られれば後は Identity Pool 経由で AWS 資格情報を得るだけ! と思いきや、残念ながら Cognito Identity Pool にもお節介機能が複数実装されておりこのまま利用することは出来ませんでした。どうして……。以下 AWS 悪口コーナーです。
まず AWS IoT Core は何故か Cognito Identity 由来の authenticated role な Principal を特別視 していて、cognito-identity:GetId などで発行される IdentityId (= IAM ポリシにおける ${cognito-identity.amazonaws.com:sub}
) に対してリソースポリシをいちいち紐付けする必要がある。足を撃ち抜きづらくするにももっとマシな手段あっただろ。
(これ、公式の example や各所の情報を見るに、みんなこのためだけにリクエストに乗っている IdentityId に対して動的に iot:AttachPolicy を実行する Lambda function を用意して Web ブラウザから叩いてるっぽくてびっくりした。なんで Lambda function を増やすみたいな余計なコードやリソースの運用しなきゃいけないのか全く理解できない。いや、Step Function でも良いので別に Lambda function である必要はないんだけど…。どのみち、これだと延々にアタッチされたポリシーという概念が AWS IoT Core に増えていきませんか? いつか Quota に当たりそうだしそもそも (authenticated role に限るとは言え) ゴミが増え続けるの、どうなの? 一方で、個別の IdentityId で柔軟な制御をしたいという点では AWS IAM に閉じると実現が難しいというのも分かるので、この仕組みが存在することに理解は示しつつも、脱出手段がないのがつらい。これを知らないことによる謎の Access Denied に数時間費しました。AWS サービスが Cognito を特別視してるのは予想外だった。)
というのもあって、いや…それなら最初から Cognito 使わずに自分で AWS 資格情報を払い出すサーバーとしての Lambda function を作りますが? てなりかけた。なお、takeout-app もブラウザから Chime SDK といった AWS リソースを直接叩いていたけれど、それは takeout-app の Rails バックエンド側で ID Token を作ってそれで sts:AssumeRoleWithWebIdentity した結果を返す API を持たせていました。なので今日まで近代 Cognito を利用しておらず気付いていなかった制限。
とにかく運用するものを増やしたくないので、この制限を迂回する = Cognito 由来の資格情報じゃなきゃこの制限は迂回できる、と考えて 2 段階目の IAM Role を用意して Cognito Identity 由来の資格情報からそこへ sts:AssumeRole して AWS IoT Core ではその Role の資格情報を使お! と思ったら、 Cognito Identity Pool の Enhanced (simplified) flow では異常に狭い Session Policy が sts:AssumeRoleWithWebIdentity に渡されており、sts:AssumeRole がそもそもどう頑張っても許可されませんでした。これも AWS の複雑さ故、事故を防ぐためにこんな仕様にしてるんだろうな、と理解はしつつも制限を外させてくれ…。となった。というか Session Policy ついてるとは思わないじゃんか、謎の Access Denied に数時間費しました。
幸い、これは利用する API コールを少なくシンプルにした Enhanced flow のみの制約と ↑ のドキュメントに記載されていて、初期からある Classic Flow では適用されないため、Classic Flow に退行しつつ、ついでに DynamoDB 含め Cognito 経由で得た最初の AWS 資格情報は 2 段目への sts:AssumeRole のみに利用して、全ての権限を 2 段目の IAM Role に引越ししました [commit] 。めんどくさすぎる…。Classic flow は AWS SDK for JavaScript v3 に便利実装ないので、そこから実装する必要があった。
2 段目の Role へ移行する際は ${cognito-identity.amazonaws.com:sub}
そのままの値を必ず sts:TagSession でタグ付けするするよう IAM Role の trust policy を書いています。これによって DynamoDB や IoT Core でのアクセス管理も ${aws:PrincipalTag/…}
として引越しができた。便利。全ての制約から解き放たれて足を撃ち抜き放題になりました。気をつけて IAM ポリシを書きましょう。
DynamoDB
https://github.com/ruby-no-kai/signage-app/blob/main/dynamodb.md に軽くまとめてある。partition key pk
, sort key sk
と汎用的に用意して、sk
を primary key とする GSI をつける典型的な構成。
hot partition になることは認識しつつ、たいしたリクエスト数じゃないので色々無視している。基本的に保存されている sessions, sponsors, venue announcements などはほぼ全てのページで全件取得することになるので、特定 patition key 以下の全アイテムを取得するというのが頻繁に走ることにはなってしまっている。これを真面目にやるなら差分のリアルタイム反映とかキャッシュとかを実装していくことになるが、takeout-app (900 ユーザーが同時利用) ではなく signage-app (たかだか 10 ~ 12 クライアントくらい) なので無視。
sessions, sponsors については https://github.com/ruby-no-kai/signage-app/tree/main/data にあるスクリプトでデータ連携用に出力された YAML などを読み込んでインポートできるようになっている。これも対した件数じゃないので雑に dynamodb:UpdateItem, DeleteItem しているだけ。
AWS IoT Core を利用したリアルタイム反映
フロントエンドでは takeout-app 同様 useSWR を利用していて、前述のように cache や thundering herd について深く考えなくて良くなったため MQTT 経由で mutate()
を雑に実行できるようにしてある。管理画面で更新した際に MQTT に対応する useSWR の key を mutate する指示を全体に iot:Publish することで全部更新される仕掛け。useSWR の mutate() API は当該の key に対応するリソースの再取得を指示できるので、MQTT 経由でリロードが達成できる。シンプル。
その他にも、一応定期的に Heartbeat を各サイネージのページから iot:Publish するようにしたりとか、それを拾って DynamoDB 上の対応するクライアント (Kiosk と呼称) のデータを更新して Heartbeat を逆にサーバ側から返す Lambda function だったりも用意して軽い相互的な死活監視を実現していた。それに加えて、Reload や識別のための toast メッセージを出すコマンドも用意していて、たびたびボタンを押していた。
字幕
takeout-app と同じように、字幕は AWS MediaLive + Amazon Transcribe。ほぼ仕様は変わらないが、映像配信は不要なため MediaLive チャンネルの video descriptor は空になった。また、Chime SDK がないため、かわりに IoT Core へ publish されている。
https://github.com/ruby-no-kai/signage-app/tree/main/caption
ここだけ EC2 や MediaLive といった時間課金のリソースが不可避で、Terraform module でオンオフを制御できるようにした。
takeout-app からの差分としては最近の terraform-aws-provider が MediaLive の各種リソースをサポートした点があるが、MediaLive の設定内容はわりと冗長で、同じことを設定内で繰り返す必要があるためここは引き続き CloudFormation テンプレートを Jsonnet で生成したほうが楽だと思い takeout-app での terraform 実装を維持。terraform apply すると Jsonnet で MediaLive 用の CFn テンプレートが作られて一緒に管理されます。便利。
QoL 向上としては、RTMP パラメータシートを Terraform で HTML テンプレートからレンダリングして S3 に(random_id なキーで) 置いて URL を terraform output で取れるようにしたこと。映像・音響クルーに情報を引き渡すのが楽になった (これまで手でぽちぽち生成した Stream Key とかを esa にコピペして share ボタンを押す、っていう手順が自動化された)。
チャット
配信ページ上で Chime SDK で提供していたチャットは Discord へ移行したため、Discord bot で発言を拾って AWS IoT Core へ publish、それを対応するサブスクリーンで表示できる仕掛けを入れています。これも会期中だけ字幕用の EC2 とかで同居して雑に動かしている。
https://github.com/ruby-no-kai/signage-app/tree/main/chat
(今思えば https://streamkit.discord.com/overlay とかでもいいのかも)
やらなかったこと
AWS IoT の MQTT 以外の (Device Ghost とか) がうまくつかえたらなあ、とかそういえば AppSync ってのもあったなあ、とかいろいろ思うところはあるけれど、DynamoDB と IoT Data Plane はかなりプリミティブで何も考えずに使えるのでこれでいいかな……。という気持ちです。
Amplify も CloudFormation を使ったいろいろてんこもりだったり CDK だったりと考える要素が多いため利用しないことに。CDK は一時期 TypeScript で補完を効かせながら書けるの便利じゃんと思ったけど terraform module のような composite な物体がどんどん出てきて実際に作成されるリソースの見通しが非常に悪いのが当たり前になり not for me になってしまい…。AWS は Building block の提供はうまいけれど組み合わせた製品は基本的に誰もつかってなくて情報がなかったり、カオスだったり、余計なお世話が多かったり、慣れているなら直接 terraform にベタ書きして利用したいと常日頃から思ってそのようにしている。
Amplify 使うとよさそうかも、というのは Cognito を前提としている @aws-amplify/auth とかがあったけど、 Amplify Gen 2 のドキュメント を読んでも任意 OIDC プロバイダ向けの使い方書いてないとか、フロントエンド向けコードと AWS CDK のコードがごちゃ混ぜに区別しづらく書かれているので訳がわからないとかでやっぱり Amplify はダメだと思いました。やりたい事はただの OAuth2 Authorization Code Grant なんだが…。Amplify のフレームワーク自体のコンセプトを知らないとこのへんの設定がどう作用するのかも理解できないんだろうな~と思いきやそれが分かるいい感じのドキュメントはなさそうだった。こんな Quick Start で雰囲気でなんかうまいこと動きますみたいなプロダクト使いたくなすぎじゃない?
Amplify のような一体型製品を検討するなら、Vercel や Firebase や Cloudflare も比較に上げることになりそんなことしてる時間ないしだるいって感じです。一方で Amplify も裏側でごにょごにょリソース立ち上げてだるいなあの一方で自分でやるとそれはそれでだるいし Cognito の制限たちなんやねん、と思ったのでいやーこういうところが AWS、うーん AWS なんだよなあ… 誰もブラウザで使わなそう…と思いました。
今回はプリミティブなサービスを直接利用するので事足りるなと思ったのでこうしましたが、継続的に開発したりなんなりっていう例ではまた違うと思うのでその時はもうすこし真面目に考えます。AWS のファンだしリソース散り散りにしたくないから AWS で考えてしまうけど、体験としてはまあ他のプラットフォームの方がこの手の用途だといいのかもね。
まとめ
takeout-app の頃もそうだけど RubyKaigi の直前はだいたい TypeScript をずっと書くことになる。苦労したけどおもしろかったです、Cognito。