くりにっき

フルスタックキュアエンジニアです

emoy_webhookをDockeriseした

emoy_webhookについて

Slackにemojiを追加した時に通知してくれる君です

sue445.hatenablog.com

github.com

プリッカソン以降もRubyやgemのバージョンを定期的に最新にしてそれなりにメンテしていました。

Dockeriseした発端

emoy_webhookは Deploy to Herokuボタン の仕組みを利用して配布していたのですが、Herokuの無料プランが終了するため *1 別の方法で配布する必要が出てきました。

色々考えた結果アプリケーションをDockerイメージとして配布するのがいいと判断してDockeriseしました。

こだわりポイント

Dockerイメージの配布方法

Docker Hubはpull limitがある *2 のでGitHubのContainer Registryを使っています。

普段GCPCloud Run をよく使っているのですが、Cloud Runが(GCPの方の)Container RegistryArtifact Registry のDockerイメージしか対応していないため、Artifact RegistryでもDockerイメージを配布するようにしました。

Artifact RegistryだけでDockerイメージを配布することも考えたんですが、Artifact Registryの方をpullされるとその分だけ自分にGCPの費用の発生してアレなのでGitHub Container Registryも使うようにしています。(まあGCPの転送料金も微々たるものだと思うけど...)

複数のアプリのバージョンを一箇所で集中管理してCIから一括バージョンアップできるようにした

モチベーション

  • dependabotだとライブラリの自動バージョンアップはできるのだが、RubyやGoなどのランタイムのバージョンまでは対応してないので一箇所で管理できるようにしたかった
  • 実は一箇所で管理すること自体は既に https://github.com/sue445/ci-config-itamae でやってたのだが、手元に動かす方式だと忘れるのでCIで動かすようにしたかった
  • CI化は割とすぐに終わったのだが、パブリックリポジトリの設定とプライベートリポジトリの設定が混在してたのでこのリポジトリもプライベートリポジトリでずっと運用していた。しかしリポジトリの数が多すぎて*1 ActionsのQuotaを使い切りそうになったのでパブリックリポジトリの設定とプライベートのリポジトリの設定を分離して前者を全公開にした

リポジトリ

github.com

動いてる風景

対応形式

どんな感じで設定するのかはGitHub Actionsの設定ファイルを見ればだいたい分かると思います。

仕組み

  1. 対象のリポジトリgit clone
  2. cloneしたリポジトリに対して Itamae を流し、Gemfile.circleci/config.yml などをいい感じに修正
  3. ファイルが修正されて差分が発生すればPullRequestを投げる

といった一連の流れを全てGitHub Actions上で行っています

リポジトリに適用してるItamaeのレシピファイル

https://github.com/sue445/myapp_version_upgrader/tree/main/cookbooks

完全に自分のアプリの構成に依存してるので他の人が流用したい場合には色々手を入れる必要があると思います。

こだわりポイント

CI用にGitHub Appを作った

GitHub Actionsのデフォルトのトークン( secrets.GITHUB_TOKEN )は自分自身のリポジトリの権限しか持っておらず、かといってパーソナルアクセストークンだと利用するリポジトリの範囲を絞れなくて嫌だったので専用のGitHub Appを作りました。

GitHub Appについて詳しくは下記エントリを参照。

sue445.hatenablog.com

*1:パブリックとプライベート合わせて30リポジトリくらいで大半がパブリックリポジトリ

シレン5の運命の地下を99F踏破した

原始99F踏破した時の記録

sue445.hatenablog.com

運命の地下って?

  • 原始と同様の持ち込み不可&未識別ダンジョン
  • 原始との違いは下記
    • 原始は昼ダンジョンだが運命の地下は昼夜の切り替わりがある
      • 夜だと視界が狭くなったり敵は凶暴になる
    • 原始よりも固定店が少ない
    • 異種合成のマゼルン種が夜にしか出ない

以下、「運命」と呼称

原始99Fクリアするのに半年以上かかったと思うんだけど、運命99Fはそんなに苦労しなかったと思う。(1週間位?)

立ち回り

wiki参照

seesaawiki.jp

技は下記

  • 感電破
  • 自動ナオッテルー
  • ドコ? カイ弾
  • ルームやりすごし閃光
  • ルームカイフク閃光
  • おいでよ混乱破
  • 道具ナレナレ破x2

基本的には

  1. フロア降りた直後においでよ混乱破で敵を呼び寄せる
  2. 感電破で敵を殲滅
  3. ドコ? カイ弾で階段の位置を把握して直行
  4. 道中の敵は他の技でなんとかする
  5. 道中のアイテムは拾うが不用意に探索はしない(感電破で敵を倒してもアイテム入手できるのでそんなに困らなかった)

のような流れ

序盤

  • 松明がほとんど出なくて無灯火で夜フロアを突っ切った
  • 食料もあまり出なくて草で食いつないだ
  • 25Fの固定店でイチかゼロの盾と爆発隠の盾をゲットして38Fのダークマゼモンで異種合成
    • 途中でドラゴン草も見つけたので盾の印にした
    • この辺りが道具欄を圧迫してきつかった...

中盤

  • 50F前後で動かずの盾と夜の盾を入手
  • 51Fにモンスターハウスがあってフロア移動の罠があったので固定店は無視した

終盤

  • 昼フロアの開幕直後に敵に囲まれた時は正直ヤバいと思ったけど、飛びつきの杖で空中に飛びつき&ワープした先の袋小路の部屋で土塊の杖で入口を塞いで夜待ちした
    • シレンがいる部屋に敵が沸かないという仕様があるため、入口さえ塞げばパコレプキンのような壁抜けがいない限り安全にターン経過できる
    • これ以外でも土塊の杖は夜待ちで重宝したので今回のMVP
  • 夜の99Fで敵を殲滅した直後に昼に変わって敵が復活したのはやばかった
    • 階段まで距離があったので底抜けの壺を割って落とし穴を作って突破した

ISUCON12に1人チームで出て予選敗退した話 #isucon

去年のやつ

sue445.hatenablog.com

今年参加するためにやったこと

自分の得意分野は手の速さよりもツール作成による自動化なので、去年の予選敗退直後から今年のISUCONに向けて準備していました。

途中2ヶ月間くらいはAWS Certified Solutions Architect - Professional(AWS SAP-C01)の試験勉強 *1 をしてたので完全に手が止まってましたが、それ以外の期間はISUCON用のツールを作ったり過去問で素振りをしていたので、準備期間は9~10ヶ月くらいだと思います

作ったもの一覧

rubocop-isucon

github.com

ISUCONのRubyの参照実装に対して静的解析をかけるためのrubocopプラグインです。

去年の予選敗退直後に作り始めたのでこれが開発期間が一番長いです。

ISUCONの過去問のRubyの参照実装を全部読んで、汎用化できそうな改善ポイントをrubocopのcopで実装しています。

手元にISUCONのRubyの参照実装だけを集めたリポジトリを置いて何回も静的解析をかけていました。

rubocopの中でSQLのASTをparseしてN+1クエリを自動修正してる辺りはかなりの面白ポイントなので、来年のRubyKaigiのCFPに応募しようと思っています。(このgemだけで1時間話せるくらいのボリュームはある)

オチ

mysql2-nested_hash_bind

sue445.hatenablog.com

作ったものの今回は特に出番がなかった...

datadog_thread_tracer

sue445.hatenablog.com

雑に並列処理をするのに重宝。

ISUCON終了直前にDatadogを外しやすくするためにこういうラッパを作っていました。

https://github.com/sue445/isucon12-qualify/blob/main/ruby/config/thread_helper.rb

itamae-plugin-recipe-rust

sue445.hatenablog.com

Ruby 3.2のYJITを使うためにRustが必要なので作った。

itamae-plugin-recipe-datadog

github.com

競技環境でDatadogをインストールするためのプラグイン

これだけは唯一僕が0から作ったやつではないのですが、ISUCONの素振りしてて困ったポイントをパッチ投げたら気づいたらメンテナになっていました。

僕がitamae-plugin-recipe-datadogのメンテナになった経緯は下記を参照。

sue445.hatenablog.com

isucon-snippets

github.com

これ自体は去年のISUCONから使ってたのですがprivateリポジトリじゃなくてもよさそうな気がしたのでpublicリポジトリにしました。

1年間の差分はこんな感じ。 https://github.com/sue445/isucon-snippets/compare/isucon11-qualify...isucon12-qualify

チェックリスト

esa-pages.io

  • 前日まで、当日(直前)、当日(競技中)でそれぞれチェックリストを作成
  • チェック項目が93個あるんだけど自動化したことによりいくつか項目が消えたのでこれでもまだ減った方
  • 競技中のチェック項目は競技中に手が止まった時に振り返るために用意してる

Datadog

今までのISUCONはNewRelicを使ってたのですが、業務ではDatadogを使ってるのでISUCONでも使い慣れたツールを使うことにしました。

実際に使ってる設定は https://github.com/sue445/isucon12-itamae/tree/main/cookbooks/datadog/templates/etc/datadog-agent/conf.d ですが、有償プランじゃないと使えない機能を使ってるので注意してください。

今月分はまだ課金が発生してないので分からないけど、先月分のDatadogの費用は462.30ドルだったので個人で使うにはそこそこ勇気がいる金額だと思います。 (素振りしてない時はサーバを完全に止めてDatadogの課金がなるべく発生しないようにしてたけどこの金額になった)

Datadogでalpを実装した。

ISUCONでは https://github.com/tkuchiki/alp を使ってnginxのアクセスログをいい感じに集計するのが定番ですが、1人チームだとログを集計する手間も惜しいのでnginxのログを自動でDatadogに送信して、alpと同等のテーブルをDatadogのダッシュボードで表示するようにしました。

仕組みはいたって簡単で、nginxのアクセスログをfluentdで加工してdogstatsdでDatadogにカスタムメトリクスとして送信しています

デメリット

  • アクセスログの正規化部分で正規表現をゴリゴリ使ってる *2 関係でベンチマーカーからのアクセスでログが大量に出力されるとtd-agentのCPU使用率が高まる。(だいたい20~30%くらい)
  • カスタムメトリクスはFreeプランで使えず、カスタムメトリクス自体もそこそこ費用がかかる *3

Datadogダッシュボード

競技中に複数のダッシュボードを使い分ける余裕は無いと判断して1つのダッシュボードに集約させました。

はてなブログの仕様で巨大スクショのオリジナル画像が見れないので縮小前のスクショのURLも置いておきます。 -> オリジナル画像

詳細なメトリクスは別ページで見れるようにしてるけど基本的にはこのダッシュボードを起点にパフォーマンスチューニングできるようにしてます。

作ったダッシュボードはJSONにexportしてgistに上げてますが、そのままexportしただけなので細かいところを調整しないとダメかもしれないです。(あと、後述の https://github.com/sue445/isucon12-itamae を適用してること前提)

https://gist.github.com/sue445/05e139f07a6de340e8acac7e337c01ec

ISUCON数日前にDatadogのダッシュボードがバグって困った

DatadogのダッシュボードにService SummaryとしてDatadog APMのメトリクスを埋め込んでいるのですが、ISUCON数日前に急にエラーを出すようになってむっちゃ困りました。

下記のようなスクショを添えてサポートに報告したのですが現時点でまだ治っていません。(つらい)

ISUCON12の予選問題

isucon.net

isucon.net

自分が予選当日に書いたコード

プロビジョニング用のItamae

github.com

事前に作っておいてsshできるようになった瞬間に全台にapplyしてました。

競技用コード

github.com

  • 使用言語:Ruby
  • 最終スコア:2650

スコア用issue

github.com

PRで動作確認をしてマージする直前のスコアをこのissueに貼り付けてるので、実質mainブランチのスコアです

PRベースで振り返り

  • 各PRの冒頭に修正前のスコアを記載してるけどスコアがどれくらい増えたか見やすくて便利
  • 毎回スコアをコピペするのは面倒なんだけどデプロイしてる最中にやってるので時間のロスはなかった

初期セットアップ (3035 -> 1297)

github.com

  • 手元からデプロイできるところまでなので結構コミットは多め
  • 参照実装をGoからRubyに切り替えたことでスコアが下がった
  • この時点でDockerを剥がそうと思ったけど手こずったので別PRにした

rubocopでauto correct (1297 -> 1757)

github.com

  • rubocop-isuconとrubocop-performanceだけ有効化した状態でauto correct
  • rubocop-isuconの機能として、ローカルのMySQLにISUCONのスキーマを入れておけば SELECT * FROM tenantSELECTid,name,display_name,created_at,updated_atFROM tenant みたいな感じで自動変換してくれます
    • 使ってないカラムをSELECTするのは遅いので後から不要なカラムを削りやすくしてる
  • どのcopでどんな変更が行われてるかはCommitsを見てください

Docker剥がし (1757 -> 1536)

github.com

Ruby 3.1.2から3.2.0-devにしたけどスコアそんなに変わらなかった...

DatadogとSentryの有効化 (1536 -> 1842)

github.com

  • ここでスコアが増えたの本当に謎すぎる。(だいたいスコア下がるはず)

sort -> sort_by (1842 -> 1667)

github.com

  • Datadog見て最初に気になったのが GET /api/player/competition/:competition_id/ranking だったので見たところ、sortをsort_byにすることで最適化されるので手始めに実施

sqliteのテーブルにindexを貼った (1667 -> 1652)

github.com

  • sqliteでexplainとる方法がぱっとわからなかったので脳内explainを頼りにindexを貼った

サーバ追加 (1652 -> 2685)

sqliteがある関係でpumaを複数台にできなかったので

  • host01: nginx, MySQL
  • host02: puma
  • host03: auth

の構成になった。MySQLとpumaが分離できたのでMySQLinnodb_buffer_pool_sizeの設定もこの時点で行った

静的ファイルにexpiresを付与 (2685 -> 2542)

github.com

不要なカラムをSELECTしてたので削った (2542 -> 2432)

github.com

id以外使ってないことに気づいたので削った

複数のsqliteへの接続を並列化 (2432 -> 3291)

github.com

他にも並列化できそうな箇所ないか調べたんだけどなかった気がする

visit_historyにindexを追加 (3291 -> 1727)

github.com

  • クエリを見てたらrowsが偉いことになってたのでindexを追加
  • explain結果は改善してるんだけどスコアは下がって本当に謎

JSONエンコーダをojに差し替えた (1727 -> 2067)

github.com

こんなこともあろうかとスニペットに常備してた下記のモンキーパッチを有効化した

app.rb内のhelperを別ファイルに移動

github.com

この時点でsidekiqでの非同期処理を検討してたんだけど、sinatraとsidekiqで同じメソッドを使えるようにsinatraのhelperメソッドをIsuconHelperに移動した

sidekiq導入

github.com

sidekiqの導入と作成したworkerへの処理の移譲を同一PRでやると切り戻しが大変なので第1段階でsidekiqのデプロイだけ実装した

ランキングデータをsidekiqで作るようにしてredisに保存するようにした (2589 -> 1295)

github.com

  • エンドポイント単体だと処理時間が減ってたんだけど結果的にスコアが下がって謎
  • 1時間位かけて頑張って実装したんだけど、しばらくしたらこのPRが原因でbenchmarkerの初期化処理がコケるようになったので戻すことになって本当に悲しい

csvのBULK INSERT化 (1535 -> 2778)

github.com

  • MySQLへのINSERTのN+1クエリならrubocop-isuconで検出できるんだけど、sqliteへのINSERTだったので検出できずに気づくのが遅れた

dispense_idのUUID化 (2778 -> 4939)

github.com

  • むっちゃクエリが発行されてるところをDatadogで見つけたのでUUIDにした

player_scoreをsidekiqで生成してredisにのせる (4939 -> 2135)

github.com

jobが多すぎてqueue詰まりを確認したのでworkerを増やしたんだけど、そうすると今度はsidekiqからToo many open filesが出るようになって厳しい

github.com

SentryとDatadogを無効化しMySQLとnginxのログを止める (2135 -> 2650)

github.com

  • ラスト10分で行った作業
  • ほとんど増えなくて謎

敗因

  • sqlite
    • DatadogのAPMsqliteの処理をトレースしてくれないのでボトルネックが分かりづらかった
      • activerecord経由でsqliteにアクセスしてたらトレースできてた気がする
    • sqliteのdbのせいでアプリ本体を複数台構成にできなかった
      • sqliteを使ってないエンドポイントがあればそこだけ分離する選択肢はあったが、軽く見た感じ全エンドポイントがなんらかの形でsqliteに依存しててえぐかった
      • sidekiqのworkerもsqliteに依存してるのでサーバを分けることができなかった
    • とはいえ来年以降のためにsqlite対策するかと言われるとなあ...
  • ヤマが外れた
    • 今までの経験上予選は割とオーソドックスな構成なのでそこまで変な構成はこないと思いこんでた

良かったこと

  • 去年まではPRを作らずmainブランチに直commitする方針だったが、今年になってからデプロイスクリプトを改修してPRのブランチをデプロイできるようにした
    • 途中で方針転換する時の切り戻しが楽
  • Datadog
    • 普段から使ってるので完全に手になじんでた
    • NewRelicのAPMはmysql2に対応していなくてモンキーパッチを当てないとトレースできないのだが、DatadogのAPMはmysql2に対応してるのでその手のモンキーパッチが不要でよかった
  • 頑張ってDockerを剥がしてRuby 3.2.0-dev + YJITを実戦投入できた
  • Itamae
    • 今回Docker環境だったので競技環境にxbuildが入っていなかったのだが、自分のItamaeレシピでxbuildをインストールしてたので特に困らなかった
    • 1コマンドでいつもの環境が構築できるのが本当に便利
  • rubocop-isucon
    • mysql2系のcopはあまり見せ場がなかったがsinatra系のcop(ログの無効化)は秒で対応できて良かった
    • rubocop-isuconの開発を通してSQLのASTが読めるようになった
  • 色々OSS作ったので来年以降も使える
  • 去年は再起動試験に失敗して0スコアフィニッシュだったけど、今年は正数スコアでフィニッシュできてよかった
  • Copilotを使ったけど自分の脳内を先読みしてスニペットが作成されて便利だった

おまけ:isucon11-finalにパッチを投げた

素振りで去年のISUCON本選問題をやってたらバグを見つけたのでPRを投げておきました

github.com

itamae-plugin-recipe-rustを作った

github.com

モチベーション

Ruby 3.2.0-devでYJITを使うにはRustの処理系が必要なのですが*1Ruby 3.2.0の正式版がリリースされた時に https://github.com/itamae-plugins/itamae-plugin-recipe-rbenv でYJIT付きでビルドするために必要になりそうな気がしたので作りました。

自分のISUCON用Itamaeレシピにあったやつをgem化したのでISUCON合わせプロダクト第n弾の立ち位置です。(最近のISUCON環境だと最初からRustの処理系が入っているが、古い過去問でRuby 3.2.0-devのYJITを使うためにRustの処理系が必要だった)

使い方

itamae-plugin-recipe-rbenvとだいたい同じような使い方です

# `/.cargo/ にインストールする場合
include_recipe "rust::user"

# /usr/local/cargo にインストールする場合
include_recipe "rust::system"

datadog_thread_tracerを作った

ISUCON合わせで作ったやつ第n弾

github.com

モチベーション

ISUCONの素振りでパフォーマンスチューニングのために重い処理を

threads = []

threads << Thread.start do
  # 並列で実行したい処理1
end

threads << Thread.start do
  # 並列で実行したい処理2
end

threads.each(&:join)

みたいにスレッドで並列処理すると、

のようにスレッド内で実行した処理がddtraceでトレースできなくて困ったのでなんとかしたかったのが一番の理由です

実装までの経緯

公式ドキュメントを探してもどうやればスレッド内の呼び出しをトレースできるか見つからなかったのですが、 下記Issueをヒントにして実装しました。

github.com

このIssueだとddtrace v0系だけどv1系でシンタックスが大きく変わってるのでdatadog_thread_tracerはv1系前提で実装してます。

datadog_thread_tracerを使った時

下記のように書くことで

require "datadog_thread_tracer"

DatadogThreadTracer.trace do |t|
  t.trace do
    # 並列で実行したい処理1
  end

  t.trace do
    # 並列で実行したい処理2
  end
end

このようにトレースがDatadogで見れるようになります

頑張りポイント

rbsを書いた

最近のbundlerだと bundle gem した時に sig/ ディレクトリができていてrbsのスケルトンが生成されてるので真面目に書いてみました。

https://github.com/sue445/datadog_thread_tracer/tree/main/sig

mysql2-nested_hash_bindを作った

ISUCON合わせで作ったやつ第n弾です。*1

github.com

モチベーション

達人が教えるWebパフォーマンスチューニング 〜ISUCONから学ぶ高速化の実践:書籍案内|技術評論社(通称ISUCON本) を読んでた時に

SELECT
  `posts`.`id`,
  `posts`.`user_id`,
  `posts`.`body`,
  `users`.`account_name` AS `users.account_name`,
  `users`.`authority` AS `users.authority`,
  `users`.`del_flg` AS `users.del_flg`
FROM `posts`
INNER JOIN `users` ON `posts`.`user_id` = `users`.`id`

のようにカラム名ドットが含まれるSELECT文を https://github.com/jmoiron/sqlx に渡すと users の部分をいい感じにGoのstructに詰め替えてくれると書かれていたのですが、Rubyでも同じことをやりたくてモンキーパッチgemを作りました。

使い方

READMEが全てなんですが、 using Mysql2::NestedHashBind::QueryExtension を書いたスコープで Mysql2::Client#queryMysql2::Client#xquery にモンキーパッチを仕込みます。

require "mysql2-nested_hash_bind"

using Mysql2::NestedHashBind::QueryExtension

db = Mysql2::Client.new(
  host: ENV.fetch("MYSQL_HOST", "127.0.0.1"),
  port: ENV.fetch("MYSQL_PORT", "3306"),
  username: ENV.fetch("MYSQL_USERNAME"),
  database: ENV.fetch("MYSQL_DATABASE"),
  password: ENV.fetch("MYSQL_PASSWORD", ""),
  charset: "utf8mb4",
  database_timezone: :local,
  cast_booleans: true,
  symbolize_keys: true,
  reconnect: true,
)

rows = db.query(<<~SQL)
  SELECT
    `posts`.`id`,
    `posts`.`user_id`,
    `posts`.`body`,
    `users`.`account_name` AS `users.account_name`,
    `users`.`authority` AS `users.authority`,
    `users`.`del_flg` AS `users.del_flg`
  FROM `posts`
  INNER JOIN `users` ON `posts`.`user_id` = `users`.`id`
SQL

rows.first
#=> {:id=>1, :user_id=>445, :body=>"test", :users=>{:account_name=>"sue445", :authority=>false, :del_flg=>false}}

頑張りポイント

ISUCONで利用するということでパフォーマンスチューニングを頑張りました。

具体的には https://github.com/evanphx/benchmark-ipsベンチマークをとりつつ https://github.com/tmm1/stackprof でgemのボトルネックを調べてボトルネックになってた箇所を改善してます。

stackprofで見つかったボトルネックの1つに Symbol#to_s があったのでfreezeされたStringを返す Symbol#name を使おうとしたのですが、Symbol#name が使えるのがRuby 3.0以降だったのでこのgemもRuby 3.0以降でしか使えないようにしてます。

ベンチマークのコードとレポートは https://github.com/sue445/mysql2-nested_hash_bind/tree/main/benchmark に置いているのでどれくらいオーバーヘッドあるかはここを見てください。

*1:まだOSSにしてないものを含めたらこの手のツールを5〜6個作ってる気がするので何番目かなんて正直把握していない