ISUCON10 の予選に参加した
unasuke, lime1024 とともにチーム「たんぽぽの上の刺身」として、Ruby 実装で参加した。最終スコアは 1302 点。 明確に役割を決めてはいなかったが、結果として自分は主にインフラ周りをみていた。
今年はリモートで参加したが特に不便は感じなかった。Zoom つなぎっぱなしで、あとは GitHub, Scrapbox, Discord でコミュニケーションをとっていた。思いの外画面共有は一度もしなかった。
やったこと
1 週間くらい前
- 人々と ISUCON8 の予選問題をやりつつ ISUCON 便利ツールについて調べた
当日
開始まで
- 緊張してきたので縄跳びをする
12:20 - 15:00
- マニュアルを読む
- unasuke がデプロイの準備とかしていた
- alp, pt-query-digest, rack-mini-profiler, rack-lineprof, New Relic をいれた
- New Relic は新 UI に慣れないのもあってあんまり使いこなせなかった
- わりと他のツールで確認できる情報で事足りていた
- alp, pt-query-digest の結果は GitHub の Issues に貼っていた
- ブラウザで見れるようにしていなかったので rack-mini-profiler は出番がなかった
- 面倒くさがらずにやったほうがよかったと思う
- New Relic は新 UI に慣れないのもあってあんまり使いこなせなかった
- app サーバを 3 台に増やしてみる
- そんなに効かない
- top を見るととにかく DB がボトルネックになっている
15:00 - 18:00
- とりあえず nginx で bot を弾く
POST /api/{chair,estate}
の N+1 をなおす- nginx の error ログを見ていたら
a client request body is buffered to a temporary file
が出ていたので適当にclient_body_buffer_size
を増やす
18:00 - 21:00
- JOIN していないしテーブルごとに DB わけられそうな気がしてくる
- アプリはそんなに見ていなかったので人々に確認したら lime1024 が即答してきて助かった
- 今回、他の人にアプリをほとんど任せられたのはやりやすかった
- ソシャゲの開発経験がなかったら思いつかなかったかもしれない、ソシャゲはとにかくパーティショニングする
- app (web), chair DB, estate DB の構成にして 720 点
- アプリはそんなに見ていなかったので人々に確認したら lime1024 が即答してきて助かった
- unasuke が app サーバを puma に変更して 840 点
- これそんなに効くと思っていなかった
- アクセスログを見ると bot がいたので再び nginx で弾くようにした
- 多分スコアが低いと出てこないんだと思う
- New Relic とか切ってベンチマークガチャして 1302 点
2 年ぶりの参加で、以前よりは手を動かせたと思う反面、改めてまだまだ力不足であるとも感じた。最近は金の弾丸で殴れることが多くあんまりこういうことをする機会がないのだけれど、日頃からやってないと難しい。あと最近の MySQL 全然追えてなかった、Generated Columns 便利すぎる。
UTF-8 の文字列をできる限り Shift_JIS に変換したい
Shift_JIS の CSV で連携する外部サービスがあり、DB では UTF-8 でテキストを持っていたため文字コードを変換する必要が生じた。 ところが UTF-8 に存在する多くの文字は Shift_JIS に対応がないため変換することができない1。
そこで、事前に NFKC 形式で Unicode 正規化することで変換可能な文字を増やすことを試みた。 まずは Unicode 正規化の前提として、Unicode の正準等価と互換等価について説明する。
以降の U+16進数
という表記は Unicode のコードポイント (文字に ID のようなものが割り当てられている) を示す。
また、コードポイントに対応する文字の詳細は https://codepoints.net/ といったサイトで確認することができる。
正準等価
例として、ひらがなの「が」について考える。Unicode では「が」を表現する方法が 2 つある。
- 文字通り「が」を示す
U+304C
を用いる - 「か」と濁点を組み合わせて
U+304B U+3099
とする
これらは文字としては同じ「が」を示すが、違うコードで表現されている。このように同じ文字を別のコードで表現するものを正準等価と言う。
また、このとき前者は Shift_JIS に変換できるが、後者は濁点 U+3099
の対応が Shift_JIS になく変換することができない。
ちなみに単体の濁点は U+309B
に別に存在していて、これは Shift_JIS に変換することができる。
U+3099
はあくまで他の文字と結合するための存在 (結合文字と呼ばれる) であり、複数のコードポイントを結合して 1 つの文字を表現するような概念は Shift_JIS にはないため結合文字の対応がないのである。
互換等価
次に、単位の cm について考える。これも 2 つの表現がある。
- アルファベット 2 文字で
U+0063 U+006D
とする - 1 文字で「cm」を表す記号
U+339D
を用いる- https://codepoints.net/U+339D
- Unicode 特有の概念ではないが、複数の文字を 1 文字の領域に詰めた文字を組文字と言う。他には
U+3320
の「㌠」などが挙げられる。
これらは意味としては同じものを指すが、文字列としては異なる表現をされている。このようなものを互換等価と言う。
また、このとき後者は Shift_JIS に対応がない。
Unicode 正規化
さて、Unicode 正規化に話を戻す。Unicode 正規化には 4 種類の正規化形式があるのだが、ここでは先に挙げた NFKC 形式についてのみ考える。 NFKC 形式で正規化すると、互換等価性に基づいて文字が分解され、更に正準等価性に基づいて文字が結合される。
前述の例に当てはめると、組文字の「cm」U+339D
はアルファベットに分解されて U+0063 U+006D
となる。
また、複数のコードポイントを結合した文字 (結合文字列と呼ばれる) は合成される。「か」と濁点の組み合わせである U+304B U+3099
は「が」を表す U+304C
となる。
これはつまり、組文字や結合文字列は正規化によって Shift_JIS に変換することが可能になるということだ。
困ったこと
他にも、例えば全角文字は半角文字に変換される。これは全角文字と半角文字が互換等価であるためだ。
全角の円記号U+FFE5
は半角の円記号 U+00A5
に変換されるのだが、Shift_JIS では半角の円記号が定義されていない。
正規化によって元々は変換できていたものが変換できなくなってしまったのである。
これについては Shift_JIS に変換する際にフォールバックを設定することで回避した。 今のところ問題になっているのは円記号のみで、増えたとしても多くはないであろうことからフォールバックを個別に設定していけばよいと判断した。
まとめ
文字コードを変換する際、NFKC 形式で Unicode 正規化することで Shift_JIS に変換できる文字を増やすことができた。 Shift_JIS に限った話ではないので他の文字コードにも応用できると思われる。
参考
用語の日本語訳は https://www.unicode.org/terminology/term_en_ja.html を参考にした。
-
正確には Unicode から JIS X 0208↩
Rails アプリケーションを Fargate に移行した
これが
こうなった
インフラ構成
リバースプロキシをなくした
リバースプロキシを採用する場合、主な役割としては以下が想定される。
このうち 1 と 2 については ALB で代替することができる。現に移行前の時点でこれらは CLB でも行われていた。また 3 については CDN のほうがよりうまく扱えるはずだ。
そこで、リバースプロキシ (今回は nginx) でやっていた処理を Rails に移植してリバースプロキシをなくすことにした。Rails でやることは増えるが、構成をシンプルにできるメリットのほうが大きいと判断した。また、複数のコンテナを協調させる難易度が高いということも理由として挙げられる。
移植した処理内容については後述する。
静的ファイルはコンテナに含める
静的ファイルを S3 等のストレージから配信する場合、ポータビリティの低下といった問題が挙げられる。アプリケーションが Docker イメージだけで完結しなくなり、またそれに伴いデバッグが複雑になってしまう。そこで、CloudFront を前段に配置し、静的ファイルは Rails のコンテナに持たせることにした。Rails で静的ファイルを配信することのパフォーマンス面については後述する。
CloudFront は Rails とは別ドメインにする。Rails が example.com
とすると CloudFront が assets.example.com
というような構成だ。静的ファイルは assets.example.com
を介して、その他は example.com
から取得する。このとき CloudFront のオリジンは example.com
になる。全てのリクエストを CloudFront 経由にするパターンもあるが、CDN の設定をなるべくシンプルにしておきたいことから採用はしなかった。少し話が逸れてしまうがポータビリティの観点から CDN はコンテンツ配信に専念させたいと思っていて、アプリケーションの動作に CDN の機能が必須になってしまうような状況は避けたい。
一応、動的ページに CloudFront 経由でアクセスされることも想定されるが、Rails は Cache-Control: max-age=0, private, must-revalidate
を返す (つまり CF にはキャッシュされない) ため特には問題にならないと考えている。この場合、検索エンジンにクロールされると重複コンテンツと判定される恐れがあるため view に <link rel="canonical">
を追加しておく。
また、静的ファイルを別ドメインから配信する場合は CORS を有効にする必要がある。今回は rack-cors を用いた。
リバースプロキシでやっていた処理の移植
静的ファイルの配信
CloudFront を前段に配置することを前提に、以下の要件で配信をしたい。
/assets
以下はキャッシュする (fingerprint があるため)- それ以外は更新がないか常に確認させる
Rails 標準の仕組み (config.public_file_server
) ではリクエストパスに応じて Cache-Control
を制御するといったことができず、要件を満たすことができない。そのため config.public_filer_server
を参考に Rack middleware を作成した。
class Rack::MyStatic def initialize(app, root: 'public') @assets = ActionDispatch::Static.new( app, root, headers: { 'Cache-Control' => 'public, max-age=315360000' } ) @public = ActionDispatch::Static.new( app, root, headers: { 'Cache-Control' => 'no-cache' } ) end def call(env) req = Rack::Request.new(env) case when req.path.start_with?('/assets/') @assets.call(req.env) else @public.call(req.env) end end end
Rails へのリクエスト数は増えるが、静的ファイルの大多数を占める /assets
以下は CloudFront でキャッシュすることができる。実際、この変更の前後に New Relic を確認したところ、特にパフォーマンスに影響はなかった。
また、移行前の構成では nginx が gzip 圧縮をしていたがこれは CloudFront で行う。
www ドメインを naked ドメインにリダイレクトする
www.example.com
を example.com
にリダイレクトするやつ。rack-rewrite を用いた。
Dockerfile
base と呼ばれる、依存パッケージや gem をインストールしたイメージを起点に、development や production のイメージを作成する。イメージを共通にしたいところではあるが、development と production は必要になるパッケージも異なるため完全に共通にすることは難しいと考えている。例えば development では E2E テストに使用するため Chrome が必要になる。
また、今回移行したアプリケーションには RAILS_ENV=staging
の環境が存在しているのだが、staging と production でも Dockerfile を分けている。CloudFront から静的ファイルを取得するために config.action_controller.asset_host
を指定していて、その値が静的ファイルに埋め込まれるためだ (つまりRAILS_ENV
によって静的ファイルに差分がある)1。これはなんとかしたいと思っている。ジャストアイデアだが全てのリクエストが CloudFront を介するようにすれば asset_host
を指定する必要もなくなるし RAILS_ENV=staging
もなくせるのかもしれない。
CI/CD
CircleCI をつかっている
テスト
先述の development のイメージを用いてテストを実行している。都度イメージをビルドしているのだが、ここで考える必要があるのがキャッシュによるビルド高速化だ。今回は CircleCI の Docker Layer Caching を採用した。正直高いとは思うが、手っ取り早い。また、DLC に加えてビルドしたイメージを ECR に push/pull している。DLC は状況によっては効かない可能性があり (例えば大量のブランチを同時に push した場合など2)、保険としてビルド済みのイメージを --cache-from
に渡している。イメージの push はブランチ単位で行い、pull はカレントブランチか存在しなければ master のイメージで行う。CircleCI の save_cache/restore_cache に近いイメージだ。
docker image push IMAGE_NAME:CURRENT_BRANCH_NAME
docker image pull IMAGE_NAME:CURRENT_BRANCH_NAME || docker image pull IMAGE_NAME:master docker image build --cache-from IMAGE_NAME:CURRENT_BRANCH_NAME --cache-from IMAGE_NAME:master
このキャッシュ戦略はあくまでレイヤーキャッシュを用いているため、 以下のような Dockerfile で Gemfile に変更があった場合、以降のレイヤーではキャッシュを使うことができず bundle install
をやり直すことになってしまう。
COPY Gemfile Gemfile.lock ./ RUN bundle install
この問題は、Buildkit の Cache Mount をつかって gem のキャッシュをすることで軽減できる気がしている。
デプロイ
https://github.com/eagletmt/hako を使っている。db:migrate
のような処理は hako oneshot
(RunTask
) で実行する。
hako の環境は Docker イメージで用意していて3、これにリポジトリに存在する hako の definition file を渡して実行する。順当にやるとボリュームをマウントすることになると思うが CircleCI の docker executor では使えないため4、Dockerfile を用意して COPY
するか docker cp
をつかって回避する。
秘匿値
SSM Parameter Store で管理していて、Task Definition の secrets
パラメータを用いて環境変数に展開している。
rails console
Fargate の場合、SSH ログインができないため rails console 等つかうことができない。そもそも本番環境で rails console なんてつかわないほうがいいとは思うが、やんごとなき理由で rails console が必要なときのために SSM Session Manager (エージェントを介してシェルアクセスを提供する機能) で Fargate インスタンスにログインできるようにした。大まかな流れは以下となる。
- SSM Activation を作成する
hako oneshot
で ECS Task を作成するaws ssm start-session
でログインして作業をする- 後処理
- ECS Task の終了
- SSM Managed Instance の削除
- SSM Activation の削除
SSM Agent はイメージに含めていないためアドホックにインストールする。今回は ECS Task を作成する際に CMD
に SSM Agent をインストール/起動するスクリプトを指定するようにした。
注意点として、SSM Activation は毎回作成する必要がある。Activation には有効期限を設定しなければならないのだが、設定できる値は最長で 30 日後となっている。つまり Activation を 30 日以上使いまわしたい場合は再作成の必要がある。期限の管理をするのも大変なので都度作成するようにしたが、もっといい方法がある気もしている。
余談だが Session Manager は Session の出力内容 (操作ログ) を CloudWatch Logs や S3 に転送することもできる。script(1)
を使ってやっていたようなことが簡単に実現できて非常に便利だ。
ログ
CloudWatch Logs に転送している。ログを確認するときは CloudWatch Logs Insights や https://github.com/knqyf263/utern5 を使う。
終わりに
移行にあたっては、Route53 の Weighted Routing をつかって新環境に段階的にトラフィックを流すようにした。いわゆるカナリアリリースである。幸いにも移行は成功して、その後もトラブルなく運用できている。
Fargate というよりはほとんど Docker と ECS の話だったが、なにはともあれ Fargate によってインスタンスの管理から解放された。
Rails に初コントリビュートした
うれしい。
Rails::Application#config_for
は Rails.env
に応じて YAML ファイルを Hash として読み込むメソッドである。
# config/example.yml development: key: val
config_for(:example)[:key] #=> "val"
また、各環境を通して共通の設定を記述することができる。
# config/example.yml shared: foo: foo development: bar: bar
config_for(:example) #=> {:foo=>"foo", :bar=>"bar"}
これまで、この共通値は shallow merge されていたが、これを deep merge するようにした。
非互換な変更になるため、PR にあたってはユースケースベースで話すことを意識した (表参道.rb でめっちゃ相談した、各位ありがとうございます)。 具体的には他の gem を使って設定値の管理をしているが Rails Way に移行したい、また共通の設定値が深くネストしているみたいな話をした。
あと念のためオプションで挙動を切り替えることも提案したが、特に言及されることなくスッとマージされた。
YAMAHA WLX202 のバグを踏んだ
経緯
Wi-Fi の調整をしていて、そのときは問題ないのだが、ある程度の時間をおくと 5 GHz 帯だけ繋がらなくなる現象が発生していた。2.4 GHz を捨てようとしていたので 5 GHz が落ちるとわりと困る。AP を再起動するとなおるからしばらくはそれでお茶を濁していたのだが、ある日、一応ファームウェアのリリースノートを確認しにいくとこんな記述が。
PMF機能が有効な5GHz 無線インターフェースで、PMF対応の無線端末が以下のいずれかの動作をすると無線出力が停止してしまうことがあるバグを修正した。
- 同一サブネットに所属するAPにローミングしたとき
- WPA-EAP (RADIUS認証)を使用する環境で、RADIUSユーザーのセッションタイムアウト発生による再認証が失敗したとき
http://www.rtpro.yamaha.co.jp/RT/relnote/ap/Rev.16.00/relnote_16_00_09.txt より
PMF機能が有効な5GHz 無線インターフェースで、接続している無線端末との通信が300秒以上行われなかったことによる無線端末切断が行われたとき、無線出力が停止してしまうことがあるバグを修正した。
http://www.rtpro.yamaha.co.jp/RT/relnote/ap/Rev.16.00/relnote_16_00_10.txt より
これやんけ!!
ということでファームウェアを更新して解決した。まさか AP のバグとは思わないし、ある程度の時間をおかないと現象が発生しないということも相まって解決するまでにだいぶ時間を要してしまった。
まとめ
ファームウェアはちゃんとあげよう
オフィスのリモート会議の環境を整えた
これまで Revolabs FLX UC 500 をつかっていたが、人数が増えたり (10+) 会議室が広くなるにしたがって部屋の端にいる人たちの声を拾いにくくなってしまった 1。あと諸々あって以下の機材を導入した。
YAMAHA YVC-1000 (スピーカーフォン)
https://sound-solution.yamaha.com/products/uc/yvc-1000/index
この製品はマイクを 5 台まで数珠つなぎにすることができる。とりあえず 2 台で運用しているが 10 人強の会議ではまったく問題なく、将来もっと人数が増えたときにはマイクを増設することもできる。
Logicool PTZ Pro 2 (カメラ)
Logicool PTZ Pro 2 Video Conference Camera & Remote
リモコンでパンやチルト、ズームすることができる。
Zoom (ビデオ会議サービス)
Video Conferencing, Web Conferencing, Webinars, Screen Sharing - Zoom
機材ではないけど。他のサービスに比べて品質がよく感じる2。あと Speaker View がとても気に入っていて、これはなにかというと参加者全員の映像を同じサイズでタイル状に表示する機能だ。Zoom 以前は発言者のみが大きく表示され、発言者以外の様子がわからないという問題があった。
Shure SM58SE (ボーカルマイク)
YVC-1000 は音声入力端子 (RCA) を備えており、マイクを接続すると本体スピーカーで拡声しつつ Zoom でリモート参加者にも配信することができる。全社会議のような大きな会議で使用している。
余談だが、スイッチ付きのモデルにしたところ話者が知らないうちにスイッチを切ってしまうことが多々あり、慣れない人が使う場合はスイッチ無しにしたほうがいいのかもしれない。
Shure QLXD24/SM58-JB (ワイヤレスマイク)
大きな会議の質疑応答などで前まで出てきてもらうのは大変なので、ワイヤレスマイクを受け渡すようにした。
選定にあたっては、B 帯 (800 MHz 帯) のデジタルの機器で探すことにした。まず周波数帯だが、今回のようなユースケースでは B 帯と 2.4 GHz 帯の二択に絞られる3。2.4 GHz 帯は Wi-Fi 等と干渉する可能性があり、IT 企業においては厳しいと判断した。また、アナログは混信やノイズがはいりやすいといった問題がある4。
ちなみに、YVC-1000 の音声入力はステレオなので R と L があり、それぞれにマイクをつなぐことで 2 本まではミキサーなしでミキシング (?) することができる。今回は有線マイクと無線マイクをこの方法で接続した。強引な気もするが、YVC-1000 のマニュアルに書いてあるのでそういうものらしい。
Slack でチャンネルが作成されたら通知する
いつの間にか新しいチャンネルがつくられていること、ままある。
仕組み
- Slack の Events API (Event Subscriptions) で
channel_created
を GAS に通知する - GAS で Slack に投稿する
同じやりかたで emoji を通知したりもできる。
GAS のスクリプト
var webhookUrl = 'https://hooks.slack.com/services/xxxxx/xxxxx/xxxxx'; function doPost(e) { var data = JSON.parse(e.postData.getDataAsString()); if (data.type === 'url_verification') { var content = { 'challenge': data.challenge }; return ContentService.createTextOutput(JSON.stringify(content)).setMimeType(ContentService.MimeType.JSON); } if (data.type === 'event_callback' && data.event.type === 'channel_created') { var payload = { 'text': 'New channel <#' + data.event.channel.id + '> was created!' }; UrlFetchApp.fetch(webhookUrl, { 'method': 'post', 'contentType': 'application/json', 'payload': JSON.stringify(payload) }); } }