wkhtmltopdf / wicked_pdfとの思い出と勝手に後継を作った話

以前、Ruby on Rails な Webアプリで PDF を出力する際に、wkhtmltopdf / wicked_pdf という HTML を PDF 化するソリューションがよく使われていた。
wkhtmltopdf はさまざまな問題で 2023 年にアーカイブされてしまったが、個人的にとても好きなソリューションだったので、CSS3 Flexbox, Grid や後述する脆弱性の問題を解消したものを作って復活させた。

github.com

名前は、Second Generation な wkhtmltopdf なので、リスペクトを込めて sghtmltopdf とした。

ドキュメントサイトも用意してみた。詳しい仕様はこちらで。 waka.github.io

こういう HTML を そのまま PDF として出力できる。

請求書サンプル

HTTP サーバ経由で使える docker イメージと Rails アプリから使うのを想定した Rubygem を公開している。

Rails アプリでの使い方は、wicked_pdf と同じ。移行時に使い勝手を変えなくて済むようにしている。
wicked_pdf の良さでもある ActionView の DSL で PDF が出せることにこだわった。

# Gemfile
gem "sghtmltopdf"

# 同一サーバ内で出力する場合
## app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  def show
    render pdf: "invoice", template: "invoices/show", layout: "pdf", page_size: "A4", margin_top: "20mm"
  end
end

# リモートサーバで出力する場合
## config/initializers/sghtmltopdf.rb 等で
Sghtmltopdf.configure { |c| c.server_url = "http://{REMOTE_SERVER_URL}:8080" }

## app/controllers/invoices_controller.rb
class InvoicesController < ApplicationController
  def show
    render pdf: "invoice",
      template: "invoices/show",
      layout: "pdf",
      page_size: "A4",
      margin_top: "20mm",
      server_url: "http://{REMOTE_SERVER_URL}:8080" # initializerに書かずoptionでもリモートサーバを指定可
  end
end

wkhtmltopdf / wicked_pdf の思い出

Webアプリケーションで PDF を出力しないといけない場面は、請求書や領収書、レポートや一覧など割とよくある。
自分が最初に触れたのは前職で請求関連の機能から請求書を PDF ダウンロードするという機能で、それまでぶっちゃけ PDF に対してはあまり分かってないというのもあり、何となく怖いなという印象を持っていた。

そこで使われていたのが wkhtmltopdf という QtWebkit を組み込んだ HTML を PDF 化するソフトウェアで、自社の開発は Rails アプリだったので wicked_pdf という wkhtmltopdf のラッパー gem を通して PDF を生成していた。
wicked_pdf は wkhtmltopdf が公式で出しているものではなく、mileszs さんが個人で開発している Rubygem。Rails のレンダラー拡張を提供しているため、Rails View の DSL で PDF を出力できる。

# Gemfile
gem "wkhtmltopdf-binary"
gem "wicked_pdf"

# controller
class InvoicesController < ApplicationController
  def show
    format.pdf do
      render pdf: "invoice",  template: "invoices/show", layout: "pdf", page_size: "A4", margin_top: "20mm"
    end
  end
end

wicked_pdf は show_as_html というオプションがあり、これを使うとなんと PDF ではなく HTML で出力してくれるのでブラウザ上で Developer Console からスタイルを確認しながら開発できる。
普段の Rails アプリの開発から特別なことを覚える必要なく PDF が作れることにとても感動した。

wkhtmltopdf + wicked_pdf のおかげで、すっかり PDF が怖くなくなったため、その後名著であるオライリーの PDF 構造解説を読んで vim で PDF をある程度手書きできるようになったり、フロントエンドで PDF を描画する際によく使われる PDF.js で描画バグが起きた時もこの辺かなみたいなアタリがつけやすくなった。

しかしこの wkhtmltopdf は、QtWebkit が事実上開発終了してしまい、QtWebkit に依存していた wkhtmltopdf も 2023 年にアーカイブされてしまった。
2026 年 8 月現在は、SSRF の問題が未解決のままになっているし、旧バージョンの QtWebkit の脆弱性もそのままなので、完全に信頼できる HTML しか扱ってはいけません。もしまだ使っていたら別のソリューションに乗り換えよう。

現状の PDF 出力ソリューション

現状 HTML を PDF 化する際のソリューションとしてよくあるものは、Headless Chrome に HTML を渡して PDF 出力させるやつでしょう。
Ruby / Rails だったら Ferrum というHeadless Chrome のラッパーgem があったり、Lambda などで Puppeteer 経由で Headless Chrome を操作するといった選択肢がある。

また、HTML からの変換でなく、テンプレートを専用デザイナーで作成して、アプリで値を埋め込んで出力するようなソリューションもある。
テンプレート系だと pdfme という TypeScript 製の OSS が個人的にめちゃめちゃ良く出来てると思っていてオススメ。日本語フォントに対応しているし、WebFont も読み込める。日本のエンジニアが作ってるので日本語サポートされる安心感があるという点も推したい。

やっぱり wkhtmltopdf が忘れられない

Puppeteer や pdfme もいいんだけど、やっぱり wkhtmltopdf / wicked_pdf で経験した アプリ開発の DSL のまま PDF を出力できる体験が忘れられない。
というわけで、今の技術でリバイスした wkhtmltopdf として、sghtmltopdf を作った。

wkhtmltopdf は QtWebkit を組み込んでいたけど、sghtmltopdf は PDF に特化するのでブラウザの機能一式は不要。HTML / CSS のパーサーと、PDF に特化したレンダリングエンジンがあればよい。
Servo プロジェクトが成果物を Rust crate として公開してくれているので、それを活用したレンダリングエンジンを作ることにした。
sghtmltopdfでは、HTMLパーサとして html5ever、CSSパーサとして cssparser、CSS セレクタのマッチングエンジンとして selectors crate を使っている。

Rust で作るので、Ruby からは必然的に FFI 経由で呼び出すことになる。Rust FFI するための rb-sys と magnus という crate が公開されているので、それを使っている。
rb-sys(というかmagnus) は採用事例も多そうなのと、コミュニティで運営されているので、メンテナンスは期待できそう。

昨今のセキュリティイシューの増加もあり precompiled gem で配布するようにしたのと、Rubygemsへの publish も Trusted publishing を使うようにしている。
precompiled gem に同梱している Rust のネイティブ拡張は 10MB もないので、サイズも小さい。

wkhtmltopdf の SSRF の問題も sghtmltopdf ではリモート取得をデフォルトで無効にしていて、 --allow-remote-assets オプションで有効化するようにしている。

機能的にただ同じ使い勝手のものをなぞるだけでは進化がないので、wkhtmltopdf や Headless Chrome での PDF 出力ソリューションを触ってきて以前から思っていた以下のような課題を解消させた。

  • wkhtmltopdf / Headless Chrome 共通で感じていた課題
    • サーバレス環境で動かす際、Lambda Layer や サイドカー構成を取る必要が出てきて環境構築が大変
    • 巨大な HTML を渡すと、ブラウザプロセスで極端に処理時間 / メモリ消費が増えてサーバ台数が増える
  • wkhtmltopdf に感じていた課題
    • 古い QtWebkit に依存していたため、CSS3 への対応が早期で止まっていた(Flexbox / Grid / カスタムプロパティ / マージンボックスといった現在メジャーなものとして使うプロパティが使えない)
    • Webフォントの読み込みを待たずに PDF が出力されることがある
    • テーブルの途中で改ページされると、ヘッダーを引き継ぐことができず帳票の見栄えが落ちる
    • CLI のみの提供で、HTTP サーバとして動かすことはできないため Lambda や ECS 等で PDF 出力用のマイクロサービスを用意することができない(自前で HTTP サーバから叩くラッパースクリプトを用意する必要がある)

CSS3 への対応や改ページ対応は、レイアウトエンジンの基礎を作った後はプロパティごとに物量で勝負。ゴールが見た目なので、E2E テストを定義しながら LLM の助けを借りて実現できた。
冒頭に載せた領収書サンプルは カスタムプロパティ / Flexbox や @page(マージンボックス)をがっつり使った CSS にしているので、wkhtmltopdf 時代に見送っていた人がいたらぜひ使ってみてほしい。

wkhtmltopdf はマージンボックスが使えずヘッダーを --header-html オプションとして渡すのだけど、別テンプレートとして扱われるので、CSS でヘッダーのレイアウトを指定することができない。なので結果を見ながら手動で調整しないといけなかった。
マージンボックスが使えるようになることで、CSS でヘッダー/コンテンツ/フッターのレイアウト管理ができるのと、副産物としてページ数を counter() 関数で自動セットできるようになる。

@page {
  margin: 25mm 20mm;
  @top-center { content: string(chapter-title); }
  @bottom-right { content: counter(page) " / " counter(pages); }
}
@page :first {
  @top-center { content: none; }  /* 表紙だけヘッダー無し */
}
h1 { string-set: chapter-title content(); }

フッターの右側に自動でページ数を挿入したい場合、マージンボックスだとこういう風に書ける。
wkhtmltopdf だと、JavaScript で頑張って HTML に挿入するしかなかったのが不要に。

表の最中に改ページが起きたときにテーブルのヘッダーが引き継がれないのも、帳票がお客様にお渡しする成果物の場合見栄えがよくないのでどうにかしたい問題だったけど、これも PDF に特化したレンダリングエンジンを作ることで解消できた。これはブラウザの出力ではどうしようもできない課題だったので対応できて嬉しい。
Web フォントの読み込み対応や、ゴシック/明朝/モノそれぞれのフォントをオプションで指定できるようにしたり、細かな使い勝手もこだわった。

また、前々からやってみたかったこととして、sghtmltopdf では PDF のストリーミングでの書き出しを提供している。正確には通常の同期的な書き出しモードに加えて、オプションで HTML をチャンク単位で読みながらページ確定のタイミングで書き出してしまうモードを用意した。
文書全体を見ないと決まらない要素、例えば body タグ直下が巨大なテーブルタグで構成されている場合、テーブルタグの仕様上最後まで読み終わらないとレイアウトが決まらないのでストリーミングモードは使えないのだけど、body タグ直下が複数の div タグで構成されている場合、div タグごとをチャンクとして読み込んで書き出せるようになる。
書き出すタイミングが速くなることでチャンクをメモリから解放するタイミングも速くできるので、巨大な文書の構成によっては劇的に消費メモリ量を抑えることが可能になった。

こんな感じでめちゃめちゃデカい PDF を書き出しながら Rack のストリーミングレスポンスで返したり、S3 にマルチパートアップロードするような使い方を想定している。

# Rack のストリーミングレスポンスで返す
class InvoicesController < ApplicationController
  include ActionController::Live

  def show
    response.headers["Content-Type"] = "application/pdf"
    html = render_to_string(template: "invoices/show", layout: "pdf")
    Sghtmltopdf.render(html) { |bytes| response.stream.write(bytes) }
  ensure
    response.stream.close
  end
end

# S3 にマルチパートアップロードする
class InvoicesController < ApplicationController
  def upload_pdf
    parts, buffer = [], +"".b

    flush = lambda do
      part = s3.upload_part(bucket: bucket, key: key, upload_id: upload.upload_id,
      part_number: parts.size + 1, body: buffer)
      parts << {part_number: parts.size + 1, etag: part.etag}
      buffer.clear
    end

    Sghtmltopdf.render(html, server_url: server_url) do |bytes|
      buffer << bytes
      flush.call if buffer.bytesize >= 5 * 1024 * 1024
    end

    flush.call unless buffer.empty?
    s3.complete_multipart_upload(bucket: bucket, key: key, upload_id: upload.upload_id, multipart_upload: {parts: parts})

    render :ok
  end
end

個人的には課題の中で特に帳票系で数万行ある大量の表を PDF 出力しないといけないユースケースでのパフォーマンスは前職でもかなり悩まされたので、「基本的に高速であること」「負荷分散しやすい」の2点に注力している。
sghtmltopdf は PDF に特化したレンダリングエンジンを持っているため描画ロジックをパフォーマンスチューニング出来ることが強みで、適用するスタイルオブジェクトの使いまわしやページツリーのメモリ解放のタイミングを早めたりすることで、既存ソリューションに対して大幅な省メモリ且つ高速化が叶った。

段落主体の文書と表主体の文書それぞれで、wkhtmltopdf と Headless Chrome と PDF 出力のパフォーマンスを比較した。

段落メインの文書:

要素数 sghtmltopdf sghtmltopdf(ストリーミング) wkhtmltopdf ヘッドレスChrome
5,000 26MB / 0.11秒 9MB / 0.10秒 44MB / 0.49秒 543MB / 1.32秒
20,000 80MB / 0.46秒 14MB / 0.34秒 86MB / 2.60秒 943MB / 7.45秒
60,000 230MB / 1.99秒 25MB / 1.31秒 199MB / 42.02秒 1,525MB / 105.77秒

表メインの帳票:

行数 sghtmltopdf sghtmltopdf(ストリーミング) wkhtmltopdf ヘッドレスChrome
5,000 49MB / 0.56秒 48MB / 0.60秒 62MB / 1.55秒 1,372MB / 5.12秒
20,000 173MB / 2.44秒 173MB / 2.36秒 163MB / 14.60秒 6,222MB / 39.94秒

狙い通り結構速度改善はできたかなと思う。ストリーミングモードで段落主体の文書を書き出すと消費メモリが大幅に削減されるのも想定通り。
20000 行の表を 2 秒前後で書き出せるので、請求書や領収書といったページ数が少ない帳票であれば、Rails アプリから直接 PDF を書き出してもほとんど詰まることはないんじゃないかと思う。
Webアプリと同じプロセスで書き出すのが不安であったり、Ruby / Rails 以外の環境で使う場合でも、HTTP サーバモード及び docker イメージを提供しているので、Lambda や ECS によるマイクロサービスが簡単に用意できるのでスケールアウトもしやすいはず。

帳票なので JS の実行はクリティカルな要件ではないだろうと思いファーストリリースでは入れてないけど、そのうち QuickJS あたりを組み込んで script タグを実行可能にするとかもやるかもしれない。


自分もそうだけど PDF ってたまに異常に関心が強い人がいたりするので、そのうち PDF developers 勉強会とかやって知見を共有しあいたい。

来年の服を買う

昨日は去年買った服のブランドの26年の秋冬の受注会に行って服を買ってきた。 26年の秋冬なので、届くのは半年以上先だけど。

最近の円安からの原価高騰により、在庫を減らして価格を抑えるために、ほとんどを受注会で売り上げるドメブラが増えてきているらしい。
自分が好きなドメブラが2つあるんだけど、どちらも基本受注会による販売がメインになった。
受注会以外の手段、つまり普通にセレクトショップで買おうとすると、そっちは販売数がとても少なくなっている上に転売勢も参入してくるのですぐに売り切れてしまう。

子供が生まれてからはお金の都合上ほとんどユニクロとかGAPになっていたけど、大きくなったし生活にも多少ゆとりが出てきたので、おととしくらいからまた季節ごとに服を見にいったり買ったりするようになってきた。
セレクトショップが様々なブランドからセレクトした商品から選ぶのではなくて、気に入ったブランドを探してそのブランドの商品から探すように買い方が変化してきているんだなあと思った。

Xで見かけたこのポストは個人的にとても共感した。

幸福度のベースライン底上げされる感じ分かるし、明日の天気予報見て何着ようかなーってなっている時間は前向きでいい。
昔から服を見たり買ったりするのが自分にとっては数少ないストレス解消の手段になっているようで、買わなくても見てるだけでもちょっと気分転換になっている。

海外のブランドは本当価格高くなりすぎじゃない?以前と全く同じ商品が1.5倍くらいになってるんだけど・・
という理由で、個人的にデザインや糸づくりから縫製まですべて日本の国内で完結していて、価格も頑張ってくれているブランドを応援している。
しかし、国内の縫製工場は機材の老朽化と職人さんの高齢化でいつまで続けられるか分からないところも多いらしいので、購買という形で応援したい。
ユニクロやGAPみたいな大量生産で安価でどこでも買えるものも便利なんだけど、数少ない良心的なドメブラがあることが自分にとっては豊かさなのだ。

ナニワヤのローストビーフはいいぞという話

うちは毎年、年末年始や記念日を家で過ごす場合、結構高確率で麻布十番にある「スーパーナニワヤ」というスーパー内の精肉店で売っているローストビーフを買って食べることが多い。
ナニワヤのローストビーフはこれまでの人生で一番美味しいローストビーフといっても全然過言でないくらい美味しいのである。

赤身なのである程度量を食べてもしつこくならないし、そこそこ厚めに切っても歯で噛み切れる柔らかさで食べやすくて最高。
ついでにお惣菜コーナーでポテトサラダを買って帰って一緒に食べるのが定番のパターン。(本当はマッシュポテトがいいけど売ってないので仕方がない)

ナニワヤのローストビーフとの出会いは、前職のオフィスがまだ麻布十番にあった頃、夜遅くにオフィスで飲んでいたら社長がおつまみ代わりに買ってきてくれたのが最初で、食べてみたらものすごい美味しくて、「何これ!どこのなんすか!?????」と聞いたら「十番商店街にあるスーパーだよ」と教えてもらったのがきっかけ。

この情報を聞けただけでも前職に入社した甲斐があったというものである。
話を聞いた当時はシュッと買えるほどにはお金がなかったので、それなりに稼げるようになってからホクホク買いに行くようになったのであった。

スーパーナニワヤは麻布十番駅から7,8分くらい歩いた十番商店街の外れ、というかちょっと抜けたところにある、めちゃめちゃ昔ながらのスーパーという感じ。
中に精肉店があるので、そこでローストビーフが買える。

以前は14時過ぎにならないとローストビーフが焼きあがらないので14時くらいに行って焼きあがりを待って買うという感じだったのだけど、お店の人に聞いたところ最近は午前中も提供されているらしい。
モモ肉の塊肉で提供されていて、1パック200gから300gくらい。
本当に人気なので、週末は夕方以降だともう売り切れていることがあるので注意。以前16時くらいに行ったらもう売り切れてたのは悲しい思い出。
特に年末、この記事を書いている前日の12/30に買いにいったら14時過ぎ時点で20分ほど行列に並ぶ必要があったので、買うのは大変だけど、その大変さの価値はある。

よいお年を。

lexicalエディタでMermaid記法で図を描けるようにするプラグインを作った

facebookが公開しているlexicalエディタというリッチテキストエディタのライブラリがあって、便利に使っている。

lexical.dev

エディタstateを管理するためのコアライブラリといくつかのプラグインが提供されていて、プラグインの仕様に沿ったReactコンポーネントを作ることでエディタ上で実現したい機能を拡張していくことができる。
プラグインの仕様がとても良くできているし、selection APIに介入したりdecorateができたりと非常に柔軟な拡張ができるので、2025年12月時点ではReactを使ったフロントエンドに高機能なリッチテキストエディタを導入したい場合はおすすめできる。

最近MermaidでER図を作る機会があり、lexicalエディタでも描けるといいなーと思ったのでプラグインを作って公開してみた。 せっかくなのでコードは全てClaude Codeに書かせてみた。

https://www.npmjs.com/package/lexical-mermaid

GItHubはこちら

github.com

READMEに詳しく載せているが、こんな感じでlexicalエディタ上でMermaidで描いた図と入力フォームを切り替えて使える。 Storybookが付いているので簡単に動きを試せます。

```mermaidMarkdown風に入力して改行するとMermaid記法入力コンポーネントが挿入されるようにもできる。

使い方はプラグイン(Reactコンポーネント)を置くだけ。

<LexicalComposer initialConfig={editorConfig}>
  <div className="editor-container">
    <RichTextPlugin
      contentEditable={<ContentEditable className="editor-input" />}
      placeholder={<div className="editor-placeholder">テキストを入力...</div>}
      ErrorBoundary={LexicalErrorBoundary}
    />
    <MermaidPlugin />{/* Mermaidの図&入力フォームコンポーネント */}
    <MermaidMarkdownPlugin />{/* Markdown風の入力受け付け */}
  </div>
</LexicalComposer>

入力フォームのスタイルはpropsで指定できるのと、INSERT_MERMAID_COMMAND コマンドを用意しているので、図のタイプごとに初期テンプレートを入れるみたいなこともできます。
興味があれば、詳しくはREADMEを見てみてください。

Claude Codeだけで作ってみた

これまでClaude Codeはちょっとしたタスクをやってもらうのに使っていたけど、これくらいのライブラリをClaude Codeに全部書かせてnpmに公開できる品質のものが作れるのかを試したかったので、自分はコードを書かない縛りで作ってみた。

ざっくりこんな感じで進めた。

  1. Claude.ai で今回必要そうな知識と設計の相談
    • 「lexicalエディタのプラグインの仕様を教えてください」
    • 「Mermaid.jsの仕様と内部の仕組みを教えてください」
    • 「lexicalエディタのMermaid.jsのプラグインを作りたいのですが、どういう設計が考えられますか?」
  2. npmプロジェクトを作ってClaude Codeを起動
    • 「lexicalというリッチテキストエディタのライブラリがあるのですが、Mermaidが書けるプラグインを作りたいです」
    • 「動作確認したいので、storybookを用意してください」
    • storybookを動かして見つかったバグを修正
  3. Claude Codeに追加したい機能をお願い
    • PNGエクスポートがしたいので図の右上にエクスポートするためのボタンを追加してください」
  4. Claude Codeに公開するにあたりリポジトリの整備をお願い
    • 「このライブラリはlexicalエディタを使っている人が組み込むのを前提としているので、package.jsonの依存関係を修正してください」
    • 「このリポジトリのREADME.mdを作ってください。README.mdは英語でお願いします」
    • 「eslintの設定ファイルとチェックコマンドを作ってください」
    • 「これまでの作業内容を踏まえてCLAUDE.mdを作ってください。CLAUDE.mdは英語でお願いします」

これだけ。

Claude Codeすごい。CLAUDE.md を最初に作らずとも、プロンプトの指示だけで先のGitHubに上げたくらいのコードであれば作れてしまうのがすごい。
「lexicalというリッチテキストエディタのライブラリがあるのですが、Mermaidが書けるプラグインを作りたいです」と指示したときの plan mode が思った通りの内容で感動した。
これくらいの規模のライブラリの新規実装であれば、CLAUDE.md 作らなくても十分な品質のコードが作れることが分かったのは収穫。
よくあるライブラリ構成のフロントエンドの場合、CLAUDE.md は作らなくても結構いけるよねと思ってたので合ってた。
もちろん大規模リポジトリやコミットする人のレベル感がばらけている場合は CLAUDE.md や slash commands は最初に作った方がいいと思う。

品質に一番効いた指示は、第一版ができた直後にstorybookを作らせたこと。
これによって、修正のたびにClaude Codeがstorybookを起動して動作を確認してブラウザ上のエラーが起きない状態にしてから完了通知をくれるので、挙動に満足できるかのチェックと生成されたコードのチェックだけに集中できるようになりました。

  • メジャーなフロントエンドライブラリはみんな使っているので学習されている情報がおそらく多く、plan modeの精度が高い
  • APIはモック、UIはstorybookを用意するなど、外部サービスに依存せず個別で高速に動く動作環境を簡単に起動できるので、生成AIの確認が正確且つ高速でストレスにならない

という点からフロントエンドは生成AIとの相性がいいなあと実感できた。
サーバサイドは見落としがあった際のデメリットが大きすぎるので、まだ補助的な使い方でいきたい感はあるけど

改めてstorybookは生成AI時代のフロントエンドとの相性がいいな。 デザインシステムレベルの細かいコンポーネントのカタログという固定観念があったんだけど、APIモック周りを整備した上で、コンテナコンポーネントレベルでもstorybook作って確認させればよさそう。

MySQLのレプリケーションプロトコルを使ったBinlogイベントを処理できるライブラリを作った

久々にライブラリを作って Rubygems に publish した。

github.com

ライブラリの名前は MysqlReplicator とした。これは MySQL の Binlog イベントを Ruby のプログラムで受け取って自由に処理を書くためのライブラリ。

MySQL にはレプリケーションプロトコルというのがあって、これを使うと自分の書いたプログラムが接続した MySQL のレプリカとして振る舞うことが可能になる。
要は Binlog イベントをプログラムで受け取って処理することができる。

どういうユースケースで使えるかというと、例えば Binlog イベントで INSERT / UPDATE / DELETE 文の実行結果を受け取って、Elasticsearch や DynamoDB といった別のデータベースにデータを同期する、といったことができる。

mysqldump を使って Binlog ファイルをストリーミング読み込みする手もあるのだけど、MySQL サーバを docker で動かす場合別の docker から読むのが大変なので、接続できればいいレプリケーションプロトコルの方が実装は大変だけど環境構築はやりやすいので、レプリケーションプロトコルを活用するためのライブラリを作った。

モチベーション

今年の7月に AWS OpenSearch が RDS からのデータ同期をサポートしたリリースがあって、これは自社サービスで使えそうならぜひ検討したいと思ったのがきっかけ。

今まで MySQL のデータを Elasticsearch に同期する場合、ジョブキューを用意して、何らかの原因でリクエストに失敗したらデッドレターキューに入れて再送して・・とインフラコストやアーキテクチャが大きくなってしまうのが課題感としてあって、同期処理を AWS 側でやってくれるんだったら最高だなと思った。

ただ、仮にこれを採用するとなった場合、今度は開発環境どうするという問題が起きる。なるべく開発環境の挙動とステージング/本番の挙動は揃えたい。

記事を追っていくと、どうもログベースで同期処理をかける仕組みのようだ。そういえば MySQL は Binlog を見れば追加/更新/削除した行データが分かるな、よしローカルの開発環境でも Binlog イベントを受け取って Elasticsearch にデータを投げるようにすれば挙動を揃えられるじゃんという。

自社のサービスは Ruby on Rails を採用しているので、Ruby でやりたい。
初めは mysql2 gem で出来ないか見てみたところ、mysql2 はレプリケーションプロトコルには対応していないことが分かった。

Rubygems で Binlog イベントを受け取れるライブラリはないかなと探したのだけど、無さそうだったので自作するしかなさそうだったというのと、レプリケーションに関する知識も深まりそうなので作ってみたくなったのがきっかけ。
アプリケーションロジックとは切り離された別プロセスになるのでアプリケーションへの影響もないし。

何か参考になるものはないかなーと調べてみると、過去に似たようなことを試していた事例は見つけたのだけど、今は亡き Bitbucket のリンクしかなく無念(mysql-replication-listener 自体もう10年以上メンテナンスされてないが・・) https://so-wh.at/entry/20120827/p1

ちなみに Golang の go-mysql はレプリケーションプロトコルに対応している模様。 go-mysqlを使ったレプリケーション この Qiita も書いているのは先のブログと同じ winebarrel さんだった。先駆者すぎる。

MySQL のレプリケーションプロトコルについて

最初に書いたように、自分のプログラムを MySQL サーバのレプリカとして振る舞わせる、Binlog イベントをリアルタイムで受け取れるようにするための仕組み。

これを実装するための前段として、プログラムから MySQL サーバの認証を通し、プログラムから SQL を実行できるようにする必要があるので、記事にしておいた。

Rubyでcaching_sha2_password認証を使ってMySQLに接続する
RubyのTCPソケットでMySQLにクエリを発行する

レプリケーションプロトコルを使ってやり取りするには、普段 MySQLサーバをレプリカとして設定する際と同様の命令をプログラムからパケット送信して実行すればいい。

こんな感じでプログラムからやることになります。

  1. SHOW MASTER STATUS クエリを実行し、Binlog のファイル名と読み取り位置を取得する
  2. SHOW VARIABLES LIKE "binlog_checksum"クエリを実行し、チェックサムのあり/なしを取得する
  3. COM_REGISTER_SLAVE コマンドを実行し、プログラムをレプリカとして登録する
  4. COM_BINLOG_DUMP コマンドを実行し、Binlog イベントをストリームで受け取れるようにする
  5. Binlog イベントのパケットを受信して、イベントタイプごとに処理をする

公式のドキュメントはここ
使うことになるイベントタイプはこの辺り。

  • ROTATE_EVENT
    • Binlog ファイルのローテーションが起きたときに発火されるイベント
  • FORMAT_DESCRIPTION_EVENT
    • レプリケーション接続の開始時に初期化のための情報を取得するために発火されるイベント
  • QUERY_EVENT
    • DDL や、BEGIN などのトランザクション制御文が発行された時に発火されるイベント
  • TABLE_MAP_EVENT
    • 行データの変更前に対象になるテーブル情報を取得するために発火されるイベント
  • WRITE_ROWS_V2_EVENT
    • 行データが挿入された時に発火されるイベント
    • 行データは複数になることもある
  • UPDATE_ROWS_V2_EVENT
    • 行データが更新された時に発火されるイベント
    • 行データは複数になることもある
    • 変更前と変更後の行データを両方受け取れる
  • DELETE_ROWS_V2_EVENT
    • 行データが削除された時に発火されるイベント
    • 行データは複数になることもある
  • XID_EVENT
    • トランザクションがコミットされた時に発火されるイベント

公式を見れば各種イベントタイプごとのパケットの仕様が書かれている...かと思いきや、共通のヘッダー部くらいしか載っていなかったりするので参考にならない。
MySQL のヘッダーファイルを見ると、各種イベントタイプの仕様がコメントでしっかり書かれているので、これを見るのが一番良いと思います。

https://github.com/mysql/mysql-server/blob/8.0/libbinlogevents/include/statement_events.h https://github.com/mysql/mysql-server/blob/8.0/libbinlogevents/include/rows_event.h

後は書かれている仕様通りに各種イベントタイプを処理するためのパーサーを愚直に書いていけばいいです。

MysqlReplicator を作っていて、地味にしんどかったのは MySQL の JSON 型は当たり前なんですけどバイナリーフォーマットで保存されているので、Binary JSON 用のパーサーも作らないといけなかったこと。この辺もいい感じに使える gem が多分ないので自作せざるを得なかった。
自作の JSON パーサーを書いている過程で、MySQL の JSON には Opaque 型という MySQL の型を JSON に含められる特殊な型があることが分かって勉強になった。
なお Opaque 型は使ったことないので MysqlReplicator では対応はしていない。

こういう感じで入れられるらしい。

-- JSONにDATE型を含める例
INSERT INTO tests (json) VALUES (JSON_OBJECT('created', CAST('2025-12-10' AS DATE)));

次は MysqlReplicator をベースにして、Elasticsearch への同期処理を作って Docker Image として動かせるようにしていく。