一応、ブログに残しておく。
Aurora/RDS用プロダクション→ステージング レプリケーションツールを書いた
これは何?
Aurora/RDSでプロダクション→ステージングのデータのレプリケーションを行うツールです。
「開発環境のデータをできるだけ本番に近づける - クックパッド開発者ブログ」という記事があって、同じことをAurora/RDSで行うために作りました。
Aurora/RDSでの問題
mysql.rds_set_external_masterを使う場合
- レプリケーションフィルターが使えない
REPLICATE_WILD_IGNORE_TABLE
とかが使えないので、ステージングに流したくないテーブルの情報もレプリケーションされます
- エラーがスキップしにくい
- mysql.rds_skip_repl_errorはあるんですがslave-skip-errorsの用にまとめて複数のエラーのスキップができないため、event schedulerで毎分呼び出す、みたいな苦しい運用に
DMSを使う場合
- いろいろ制限がある
- https://docs.aws.amazon.com/ja_jp/dms/latest/userguide/CHAP_Source.MySQL.html
- AUTO_INCREMENTがマイグレーションされないとか、対応していない方があるとか、いろいろと
- なんだかんだでマイグレーションが止まる
binrpt
MySQLのレプリカサーバのようにrowフォーマットのbinlogを読み込んで、SQL(INSERT・UPDATE・DELETE)に変換するか、流れてきたDDLをレプリカに対して実行します。
特徴
- テーブルのフィルタリングができる
- エラーは無視
- 接続が切れたら再接続
ラフにレプリケーションをできるようにしてるので、逆に厳密なレプリケーションには向かないです。
misc: go-mysqlがすごい
binlogを読み込むのにgo-mysqlを使っています。
googleabilityが悪くて「go-mysql」で検索してするとgo-sql-driver/mysqlが出てきたりするんですが
- MySQLのクライアントライブラリ
- binlogを継続的に読み込む(go-mysql-elasticsearchで使われているようです)
- サーバとしてクライアントを受け付けられる
と、ニッチな気がする方向で強力でした。
MySQL Binlog APIのライブラリって、まだMySQL Labsからダウンロードできるんでしたっけ…? MySQL Binlog APIはベータ版であまり完成度が高くなく、実装がめんどくさかった(日本語まわりでバグもあった)記憶があって「binlogで自前レプリケーションはつらい」という印象だったんですが、go-mysqlはかなりサクッと使えて、わりあい頑丈なので(再接続なども実装済み)驚いてます
追記
go-mysqlのMySQLクライアントとしての動作、メモリまわりが怪しい… あと https://github.com/siddontang/go-mysql/pull/466 これバグってないかな
SpringOnion: Railsの遅そうなEXPLAINをログ出力するgem
SpringOnionというRailsの遅そうなEXPLAINをログ出力するgemを作りました。
某kamopoさんのMySQLCasualLog.pmの移植?です。
以前の移植とは異なって、Arproxyを必要とせず、単体で動きます。
何年前の話だよといわれそうですが、未だにスロークエリと戦っていたりするので…
Rails 3以前に搭載されていた、遅かったクエリを自動的にEXPLAINする機能とは異なり、フィルタで選別されたすべてのクエリに対してEXPLAINを実行し、まずそうなキーワードが出現したらログに出力します。
また、EXPLAINの実行先は開発用のDBだけでなく任意のDBに向けられます(例: ステージング環境のDBなど)。
使い方
Gemfile
にSpringOnionを追加して
group :development, :test do gem 'spring_onion' end
enviroments/*.rb
やinitializers
にSpringOnionのEXPLAIN実行先DB情報を記述します。
if Rails.env.development? || Rails.env.test? # EXPLAINを実行するDB SpringOnion.connection = Mysql2::Client.new(host: 'my-host', ...) # ActiveRecordの接続先と同じにする場合は↓な感じで #SpringOnion.connection = ActiveRecord::Base.connection.raw_connection end
SpringOnion.enabled = true
にするかSPRING_ONION_ENABLED=1
で、遅そうなEPLAINがログ出力されるようになります。
実行例
#!/usr/bin/env ruby require 'active_record' require 'spring_onion' ActiveRecord::Base.establish_connection( adapter: 'mysql2', host: 'db', username: 'root', database: 'sakila' ) SpringOnion.enabled = true # or `SPRING_ONION_ENABLED=1` SpringOnion.connection = ActiveRecord::Base.connection.raw_connection SpringOnion.source_filter_re = // class Actor < ActiveRecord::Base self.table_name = 'actor' self.primary_key = 'actor_id' end Actor.all.to_a
root@d786c737d8a4:/mnt# bundle exec ./test.rb SpringOnion INFO 2020-07-19 03:19:05 +0000 {"sql":"SELECT `actor`.* FROM `actor`","explain":[{"line":1,"select_type":"SIMPLE","table":"actor","partitions":null,"type":"ALL","possible_keys":null,"key":null,"key_len":null,"ref":null,"rows":16,"filtered":100.0,"Extra":null}],"warnings":{"line 1":["slow_type"]},"backtrace":["/mnt/lib/spring_onion/explainer.rb:6:in `execute'","/mnt/test.rb:21:in `<top (required)>'"]}
SPRING_ONION_JSON_PRETTY=1
で見やすく。
root@d786c737d8a4:/mnt# SPRING_ONION_JSON_PRETTY=1 bundle exec ./test.rb SpringOnion INFO 2020-07-19 03:19:43 +0000 { "sql": "SELECT `actor`.* FROM `actor`", "explain": [ { "line": 1, "select_type": "SIMPLE", "table": "actor", "partitions": null, "type": "ALL", "possible_keys": null, "key": null, "key_len": null, "ref": null, "rows": 16, "filtered": 100.0, "Extra": null } ], "warnings": { "line 1": [ "slow_type" ] }, "backtrace": [ "/mnt/lib/spring_onion/explainer.rb:6:in `execute'", "/mnt/test.rb:21:in `<top (required)>'" ] }
上記の場合、"type": "ALL"
が引っかかってwarningが出ています。
warning
warningにはMySQLCasualLog.pmと同じルールを設定していますが、新しく追加することも可能です。
例えば、rows
が10,000行を超えていたら、warningするなど。
SpringOnion.warnings[:too_many_rows] = lambda do |exp| exp['rows'] > 10000 end
フィルタ
SQLとbacktraceのソースコードのパスでフィルタリング可能です。
たとえばSPRING_ONION_SQL_FILTER_RE=actor bundle exec ./test.rb
と実行すると、actor
が含まれるクエリだけEXPLAINします。
また、SPRING_ONION_SOURCE_FILTER_RE=my_path bundle exec ./test.rb
と実行すると、パスにmy_path
が含まれるソースコード経由で実行されたクエリだけEXPLAINします。(デフォルトは/app/
)
その他
ログ出力先は標準出力がデフォルトですが、ファイルなどに変更可能です。
SpringOnion.logger = Logger.new('/foo/bar/zoo.log')
CSVとかTSVをJSON LinesにするツールをGoとRustで書いた
CSVとかTSVをJSON LinesにするツールをGoとRustで書いた。
Go版
$ printf 'foo,bar\nbar,zoo' | xjsonl -keys a,b {"a":"foo","b":"bar"} {"a":"bar","b":"zoo"}
Rust版
$ printf 'foo,bar\nbar,zoo' | xjr -k a,b {"a":"foo","b":"bar"} {"a":"bar","b":"zoo"}
モチベーションとか
qrnを作った関係でテキストをJSON Linesに変換するツールが欲しくなったのだが、あまり上記のようなツールが見つからず、joで頑張るのもめんどくさい、ということで自作した。
かなり小さいツールなので、Rust学習のためにポートするにはちょうどいいかと考えてRust版も作成。
Rust入門
The Rust Programming LanguageやRust By Exampleを読んでいたのだけれど、いまいち頭に入らず。 最終的に「プログラミング言語Rust入門」という書籍をKindleで購入。
全部がカバーされているわけではないけれど(マクロの書き方とかアトリビュートの詳細とかジェネリクスの細かいところなどはなかった)、足がかりとしてはよい本だったと思う。
Rust版を作りながら思ったこと
- unwrapまわりが最初わかりにくかった
- https://qiita.com/nirasan/items/321e7cc42e0e0f238254 がとても参考になった
- Goから入った人間としては「そんなにパニックしていいのか?」と思ったが、log.Fatalfを使ってたりするので、同じなのかも。unwrapよりはexpectでわかりやすいメッセージを表示した方が良さそうだけど
- dbg!マクロは最初の方で知りたかった…
- 参照にしないとたまにコンパイルエラーになったりするあたり、新鮮味を感じた
- strとStringがあるのがややめんどくさい
- とりあえずstructに併せてStringを標準的に使うようにしたけれど、正しいのかわからない
- 引数は&strにしてみた
- Stringのマクロかリテラルが欲しい
- とりあえずstructに併せてStringを標準的に使うようにしたけれど、正しいのかわからない
- ファイル分けの標準がいまいちよくわからなかったので、Goっぽくはしてみた
- モジュールまわりは解説を読むよりは https://github.com/shadowmint/rust-starter が参考になった
- main.rsとlib.rsを共存させるよりはmain.rs内で
mod hogehoge;
と書いてしまった方が、CLIを作るときは楽なんじゃないかなーと思ったけれど、そういう例は見つからず… - →素直にlib.rsを書け、という気になってきた
- ジェネリクスに慣れていないせいで、最初「キャストの方法は?」と探しまくった
- stdinとファイルを同じように扱うのに http://saba1024.hateblo.jp/entry/2017/12/20/011335 のような方法をとっていたけれど、ジェネリクス使った方がスマートなのかなーという感想
- なので方針としては「dyn潰すマン」
Box<dyn error::Error>
はジェネリクスでなんとかしようとしたけれど、できず。これはそのままでいいのかな?
- クロージャがやや使いにくい気がした。定義ごとに個別の型になっているせい?なんとなく、引数で受け取るときは
fn()
使うよりもFn()
を使っておいた方が無難な気がした - テストはモジュール内で定義されていたほうがきれいな気がしたので https://github.com/winebarrel/xjr/tree/master/src/xjr のようにしてみたが、いいのか悪いのかわからず
- mod.rsに実装書くのはアリなのかな?
- クロスコンパイルはcrossでサクッとできた
- splitにややはまった
"".split(",") -> ""
- Rubyが特殊な仕様なのかも
- trim_rightがdeprecatedでこだわりを感じるなど
- Goに比べると実行ファイルがとても小さいのでよかった
- 所有権については難しそうなイメージの割にほとんど考えることはなかった
- ネストが深くなりがちなのはmatchのせいだろうか?
- xjrは見直してみるとなんかGoっぽいかんじになってしまった
その他、Rustで参考にしたサイト
- cargo test で 標準出力をコンソールに流す - Qiita
- Rust の Fn, FnMut, FnOnce の使い分け – kanejaku.org
- Rustでエラーを合成する - Qiita
- Panic を恐れるべからず - 何とは言わない天然水飲みたさ
- 2019 年の非同期 Rust の動向調査 - Qiita
- Rustプロジェクトのディレクトリ構成 - Qiita
- (後で読む) Getting Started - Asynchronous Programming in Rust
Rust所感
qrnというDBベンチマークツールを作った
qrnというDBベンチマークツールを作りました。
これは何?
羅列されたクエリを実行するだけのDBベンチマークツールです。 今のところMySQLにしか対応していませんが、PostgreSQLへの対応はそんなに難しくないと考えています。
羅列したクエリを実行するだけなので、クエリのログ(MySQLならgeneral log)をほぼそのままテストデータにすることができます。 逆に同じクエリを異なるパラメーターで実行するようなことはできないので、そういうむきであればJdbcRunnerなどを使った方がいいと思います。
Installation
https://github.com/winebarrel/qrn/releases から最新版をダウンロードしてください。
Usage
$ echo '{"query":"select 1"}' >> data.jsonl $ echo '{"query":"select 2"}' >> data.jsonl $ echo '{"query":"select 3"}' >> data.jsonl $ qrn -data data.jsonl -dsn root:@/ -nagents 4 -rate 5 -time 10 -histogram / run 184 queries (20 qps) 57µs - 115µs - 115µs - 173µs - 173µs - 231µs --- 231µs - 289µs ------------ 289µs - 346µs ---------- 346µs - 404µs ------------------------------------------ 404µs - 462µs -------------------------------------------------------- 462µs - 520µs ------------------------------ 520µs - 760µs ---------- { "Started": "2020-05-13T11:18:14.224848+09:00", ... }
主要なオプション
-dsn
DSN-data
テストデータ。JSON Lines形式-nagents
並列数。1 agentが1コネクション・1 goroutineで実行-rate
1 agentが1秒に実行するクエリ数の上限。たとえば2 agentでrate 5を指定すると、期待されるQPSは10QPS-time
テストの実行時間(秒)
テストデータ
上記の通りテストデータはJSON Linesです。
デフォルトで"query"
キーのクエリを実行しますが、-key
オプションで変更できます。
クエリ以外のキーは無視します。
テストデータはメモリにロードしないので、大きめのデータでもクライアント側のメモリを圧迫することはないと思います。 逆に、10万qpsとか高いスループットが必要な場合、それが足を引っ張る…かも(そこまで試せていないです)。
MySQLの場合、general logをJSON Linesに変換する必要があるので、以下のような変換ツールを作って、logrotateのタイミングでjsonlに変換するようにしています。
/var/log/general.log { # ... postrotate # ... /usr/local/bin/mysql-general-log-parser /var/log/general.log-$(date +%Y%m%d%H) | gzip > /var/log/general.log-$(date +%Y%m%d%H).jsonl.gz' endscript }
#!/usr/bin/env ruby require 'json' module MysqlGeneralLogParser HEADER_REGEXP = /\A(?<time>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+[A-Z]+)\s+(?<id>\d+)\s+(?<command>\w+)\b(?<argument>.*)\z/m Chunk = Struct.new('Chunk', :time, :id, :command, :argument) def parse(io:) chunk = nil io.each_line do |line| if HEADER_REGEXP =~ line yield(chunk) if chunk chunk = Chunk.new chunk.time = Regexp.last_match[:time] chunk.id = Regexp.last_match[:id] chunk.command = Regexp.last_match[:command] chunk.argument = Regexp.last_match[:argument] elsif chunk chunk[:argument] << line end end yield(chunk) if chunk end module_function :parse end def main MysqlGeneralLogParser.parse(io: ARGF) do |chunk| puts chunk.to_h.to_json end end main
結果の出力
テスト結果はJSONで標準出力に出力されます。
{ "Started": "2020-05-13T11:18:14.224848+09:00", "Finished": "2020-05-13T11:18:24.559912+09:00", "Elapsed": 10, "Queries": 189, "NAgent": 4, "Rate": 5, "QPS": 18.287694303306097, "ExpectedQPS": 20, "Response": { "Time": { "Cumulative": "78.389862ms", "HMean": "392.47µs", "Avg": "414.761µs", "P50": "418.565µs", "P75": "462.099µs", "P95": "532.099µs", "P99": "735.68µs", "P999": "760.585µs", "Long5p": "632.823µs", "Short5p": "218.38µs", "Max": "760.585µs", "Min": "182.384µs", "Range": "578.201µs", "StdDev": "90.961µs" }, "Rate": { "Second": 2411.0260584461803 }, "Samples": 189, "Count": 189, "Histogram": [ { "57µs - 115µs": 1 }, { "115µs - 173µs": 1 }, { "173µs - 231µs": 4 }, { "231µs - 289µs": 14 }, { "289µs - 346µs": 12 }, { "346µs - 404µs": 48 }, { "404µs - 462µs": 63 }, { "462µs - 520µs": 34 }, { "520µs - 760µs": 12 } ] } }
よく見そうな値は
- QPS: クライアント側でのスループット
- ExpectedQPS: agent数×rateで算出した期待されるQPS。QPSとExpectedQPS乖離が大きいほどキャパオーバーが大きい(と思われる)
- Response: 実行した全クエリの応答時間の統計値です。
JSON以外のメッセージについては標準エラー出力に出力されます。
-histogram
オプションをつけるとヒストグラムが出力されます
57µs - 115µs - 115µs - 173µs - 173µs - 231µs --- 231µs - 289µs ------------ 289µs - 346µs ---------- 346µs - 404µs ------------------------------------------ 404µs - 462µs -------------------------------------------------------- 462µs - 520µs ------------------------------ 520µs - 760µs ----------
-html
オプションをつけると、ヒストグラムのHTMLファイルが出力されます。
このへんは https://github.com/jamiealquiza/tachymeter の機能ですね。
モチベーションとか
とあるサービスで使われているDBのベンチマークを取ろうとしたとき、人間がテストデータを書くと、データによって性能の偏りが大きく「サービスで使われているDBのワークロードを再現するのは難しい」「general logをそのままテストデータに使えないかな」と思っていたのと、ほかのベンチマークツールを使っているといろいろいじりたいことが多くて(出力結果など)「自前でベンチマークツールを作っておくと便利そう」と思ったあたりがモチベーションです。
misc
v1.2.0から、まあまあしっかりするようになりました-rate
オプションは身の回りで使った感じではまあまあの精度でqpsを制限できているのですが、もう少し厳密にできればいいんですが、逆にそこまで必要かなともテストデータはファイル読み込みなのでシャッフルが難しく、いまのところagentごとに読み込み開始位置をランダムにしているだけです。とくに問題はないのですが、もう少し賢くしたいです
追記
スクリプトでのデータ記述に対応しました
https://github.com/winebarrel/qrn#use-script-as-data
$ cat data.js for (var i = 0; i < 10; i++) { query("select " + i); } $ qrn -script data.js -dsn root:@/ -nagents 8 -time 15 -rate 5
追記2
PostgreSQLに対応しました
追記3
general.logをパースしてjsonlにするやつを作りました
SendGridのv3 Mail Send APIのRuby Clientを書いた
年末で少し時間があったので、書こう書こうと思っていたSendGridのv3 Mail Sendを書きました。
V3 Mail Send API Overview - SendGrid Documentation | SendGrid
READMEのUsageを見ればわかるとおり、ほぼAPIに即した薄いクライアントです。
とりあえず、現状で公式クライアントは利用しにくいので、自作した感じです。 非公式とはいっても公式がスキーマを公開しているので、rakeタスクで公式のスキーマを取得して、メール送信ポストリクエストのボディーのバリデーションをJSON Schemaで行うようにしています。
require 'kani_laser' client = KaniLaser::Client.new(api_key: 'ZAPZAPZAP') # see https://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/index.html client.send_mail( personalizations: [ { to: [ { email: 'john@example.com' } ], subject: 'Hello, World!' } ], from: { email: 'from_address@example.com' }, content: [ { type: 'text/plain', value: 'Hello, World!' } ] )
で、公式クライアント
SendGrid公式のRubyクライアントは、あまりインターフェースがよいとは思えなくて
require 'sendgrid-ruby' include SendGrid from = SendGrid::Email.new(email: 'test@example.com') to = SendGrid::Email.new(email: 'test@example.com') subject = 'Sending with Twilio SendGrid is Fun' content = SendGrid::Content.new(type: 'text/plain', value: 'and easy to do anywhere, even with Ruby') mail = SendGrid::Mail.new(from, subject, to, content) sg = SendGrid::API.new(api_key: ENV['SENDGRID_API_KEY']) response = sg.client.mail._('send').post(request_body: mail.to_json) puts response.status_code puts response.body puts response.parsed_body puts response.headers
- いくつかクラスを利用しているけれどすべてJSON生成のヘルパーで必須ではなく冗長
- 送信用のメソッドが
#_
で、ちょっとわかりにくすぎると思う…(メタプログラミングを避けた?あー、sendメソッドが使いにくかったせいか) - method_missingを使ったかなり薄いラッパーでメソッドやパラメータのチェックが弱い。ヘルパークラスも空白などを除去しているだけでチェックの役に立っていない
- 気になる書き方がいくつか(固定文字列のJSONをわざわざパースしてHashにしている・必須引数に空白のデフォルト値を設定している)
といった感じで、うーん…とクビをかしげる状態になっています。 v1からv2への変更で大幅に修正が入っていて、なぜこのようなわかりにくいインターフェースになったのか、ぜんぜん経緯を理解できていないです。v3 APIへの対応リソースが足りなかったんだろうか?
一方でOpen API specificationも公開していて、今回私の書いたKaniLaserはそこからschemaをとってきてリクエストパラメーターのJSONをJSON Schemaでパリデーションするようにしています。
リクエストパラメーターのバリデーションができれば、インターフェースはシンプルになりそうですが、公式クライアントはなんか頑張っている?せいで煩雑です。なぜ
API定義はしっかりしているように見えるので、そこからどうとでもクライアントは生成できると思うのですが、なんでこんな状況になっているのかよくわからない…
わからない。全くわからない。
公式がちょちょいとやれば、メタ情報から各種言語のクライアントを自動生成できそうだけれど、なぜやってないんだろう? oas.jsonからクライアントを自動生成できそうだけど。 やっぱりリソースの問題かなぁ、Ruby優先度低いのかなぁ…うーん…
蛇足
https://sendgrid.api-docs.io/ を参照すればちょちょいのちょい…と思っていたんですが、oas.jsonのdefinitions
、足りなくないですかね。email_object
とか。
最終的に https://github.com/sendgrid/sendgrid-oai/ のマスターをスキーマのソースにするようにしたんだけど、これ、正しいのかなぁ
カニレーザー
ridgepole: v0.7.6でignoreという属性をつけられるようにした
ridgeporeのv0.7.6でignoreという属性をカラム・インデックス・FKにつけられるようにした。
あんまり周知していなかったせいで、全然知られていないが、ridgepoleでもexecuteがつかえる。
https://github.com/winebarrel/ridgepole#execute
create_table "authors", force: :cascade do |t| t.string "name", null: false end create_table "books", force: :cascade do |t| t.string "title", null: false t.integer "author_id", null: false end add_index "books", ["author_id"], name: "idx_author_id", using: :btree execute("ALTER TABLE books ADD CONSTRAINT fk_author FOREIGN KEY (author_id) REFERENCES authors (id)") do |c| # Execute SQL only if there is no foreign key c.raw_connection.query(<<-SQL).each.length.zero? SELECT 1 FROM information_schema.key_column_usage WHERE TABLE_SCHEMA = 'bookshelf' AND CONSTRAINT_NAME = 'fk_author' LIMIT 1 SQL end
冪等性を担保するために少しだけ拡張していて、ブロック内の返り値がtrueの場合のみ、executeを実行するようにしている。 ブロックにはコネクションを渡しているので、存在チェックをすることで重複してexecuteが実行されないようにすることができる。
…が、たとえばN-gramのフルテキストインデックスのように、Railsが対応していないカラムやインデックスをexecuteで作成しようとするとめんどくさいことになる。
例えば
create_table "books", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.string "title", null: false t.index ["title"], name: "ft_index", type: :fulltext end execute("CREATE FULLTEXT INDEX ft_index ON books(title) WITH PARSER ngram") do |c| rows = c.raw_connection.query('SHOW INDEX FROM books', as: :hash) !rows.map{|r| r.fetch('Key_name') }.include?('ft_index') end
のような感じでインデックスを貼ろうと思うと、先に「N-gramではないインデックス」が作成されてしまって、「N-gramのインデックス」はexecuteでは作成されなくなる。
executeのブロック内のクエリで頑張って「N-gramではない場合は再作成」というようにすれば、「N-gramのインデックス」を貼ることはできるが、流石にめんどくさいので、カラムやインデックスにignore
という属性をもたせることにした。
ignore
を使うとこんな感じになる
# -*- mode: ruby -*- # vi: set ft=ruby : create_table "books", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| t.string "title", null: false t.index ["title"], name: "ft_index", type: :fulltext, ignore: true end execute("CREATE FULLTEXT INDEX ft_index ON books(title) WITH PARSER ngram") do |c| rows = c.raw_connection.query('SHOW INDEX FROM books', as: :hash) !rows.map{|r| r.fetch('Key_name') }.include?('ft_index') end
…先ほどとほぼ同じだけれど、ignore: true
がついたカラム・インデックス・FKについては、ridgepoleは無視、全く変更に関与しないようにしている(つもり)。
なので、このスキーマ定義はRailsでは対応していない「N-gramのインデックス」を冪等性を保ったまま作成することができる(はず)。
テストが通ったらリリースする予定。