LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

サムネイルを生成する画像変換サーバーの改善(Go,Rust)

はじめに

インフラグループに所属している春日です。 弊社が運営しているWebサービスは基本的にテキスト情報がメインですが、写真やロゴなどの画像も表示しています。

画像をWebサイトに表示する際、デザインが崩れないようにあらかじめ決められた枠内 (高さ、幅) に収めて表示する場合が多いです。 しかし画面ごとに枠のサイズが異なる場合、枠ごとのサムネイル画像を事前に用意しておく手法は手間がかかる上に追加のストレージコストが発生してしまいます。

そのため弊社の一部のWebサービスではサムネイル画像をリアルタイムに生成して表示しています。 内製の画像変換サーバーを利用しており、そのバックエンドはAWS S3や提携先のWebサービスなどで複数の指定ができます。

graph TB
  client(Client)
  cdn(CDN)
  lb(Load Balancer)
  server(Image Processing Web Server)
  s3[(AWS S3 Bucket)]
  web(External Web Service)

  client-->cdn-->lb-->server
  server-->s3
  server-->web

使い方としてはURLのパスにバックエンドの画像ファイルを、クエリストリングに高さや幅などの変換パラメータを指定する形です。

https://example.com/path/to/image.jpg?w=300&h=200

2012年頃にPHP + ImageMagickで実装されていたものが2016年頃にImageMagickに依存しない形でGoで書き直され、さらに今回は新たにRust版を実装しました。

もともとRust版は実験的に開発を始めた経緯がありますが、最終的に2025年現在ではGo版とRust版の両方を本番環境で利用しています。

本記事では画像変換サーバーを運用している中で出てきた課題と、その改善について振り返ります。

Go版実装におけるメモリアロケーションの削減

Go版の画像変換サーバーはしばらくメンテナンスが止まっていたのですが、改善する必要があるとの判断に至った最大要因がOOMによる沈没リスクでした。

画像変換は軽い処理ではないため、CDNでキャッシュする前提で運用しています。 しかしオリジンにアクセスが集中すると高確率でOOM起因のコンテナ強制停止が多発する現象に悩まされていました。

Goのベンチマークやプロファイリングツールで確認したところ、リサイズ処理に使用しているライブラリがボトルネックになっていることがわかりました。 そのライブラリは10年前にメンテナンスが止まっており、その意味でも他のライブラリに乗り換える必要がありました。

以下は改善前と改善後のベンチマーク結果です。メモリのアロケーション数が4000分の1になっていることがわかります。

Benchmark Iterations Time per op (ns) Bytes per op Allocs per op
BenchmarkMainHandler-4 18 65,948,565 13,081,544 1,573,244
BenchmarkMainHandler-4 51 22,192,565 4,511,918 399

具体的なライブラリについては後続の節で触れます。

他には bytes.Buffer オブジェクトを sync.Pool でプールしてGCへの負荷をさらに下げようと試みたのですが、 コード改修量の割には上記のライブラリ変更以上のパフォーマンス改善が見られなかったため断念しました。

Rust版実装を本番投入してサーバー構成を簡素化

Go版の画像変換サーバーはJPEG, PNG, GIFなどのラスター画像のみ変換できます。 AWS EKS上で動いている弊社の一部のWebサービスではベクター画像のSVGファイルを配信する必要があり、そのためにNginxをサイドカーで走らせていました。

以下のような構成で、AWS S3バケット内にSVGファイルが置かれていました。

graph TD
  subgraph Pod
    fanlin(Image Processing Web Server)
    nginx(Nginx)
  end
  s3[(AWS S3 Bucket)]
  web(External Web Service)

  nginx-->fanlin-->web
  nginx-->s3

また、以下のような設定もNginx側に入っていました。

  • レスポンスヘッダーの設定
    • CDN側にN時間キャッシュする
    • ブラウザ側にN時間キャッシュする
  • SEO要件の設定
    • 画像データが存在しない場合でもフォールバック画像とともに 200 ステータスを返す
    • バックエンドごとにフォールバック時の挙動を変える

そこで構成をシンプル化して運用負荷を削減するため、「Go版画像変換サーバー」+「サイドカーNginx」の構成を「Rust版画像変換サーバー」に一本化しました。

graph TD
  subgraph Pod
    fanlin(Image Processing Web Server)
  end
  s3[(AWS S3 Bucket)]
  web(External Web Service)

  fanlin-->s3
  fanlin-->web 

Rust版の画像変換サーバーではSVG画像にリクエストが来たらそのままファイルを返すように実装しています。 そして一部のバックエンドのパスに対して画像が存在しない場合も 200 を返せるオプション機能も追加しました。

この構成変更により画像変換サーバーのコンテナイメージを自前でビルドする必要がなくなり、 画像変換サーバー側であらかじめ用意してある公式イメージをそのまま利用できるようになりました。

また、画像変換サーバーの設定もConfigMapのマニフェストをメンテナンスするだけで良くなり、 キャッシュ設定もCDN側に集約できました。

なぜGo版の画像変換サーバーを拡張せずにRust版を作成したのか、ですが、その理由の1つに言語特性の違いによるパフォーマンス高速化の狙いがありました。

以下はRust版への移行前後における、ロードバランサーと画像変換サーバー間のパーセンタイルごとのレイテンシーメトリクスです。

Load balancer latency
Load balancer latency

テイルレイテンシーはそこまで変わってないですが、パーセンタイル90あたりから速くなっているのがわかります。

逆にメモリ使用量は増えました。

Memory utilization
Memory utilization

というのも移行前のNginxではストリーミング方式でAWS S3バケットの画像ファイルを配信していましたが、Rust版の画像変換サーバーでは一度メモリに読み込んで処理しているからです。

そして移行後に何回かスパイクしている理由ですが、Rust版は初期の頃は標準アロケータである libc の malloc を利用していて断片化問題が顕著に発生したためです。

デプロイする度に急激にスパイクしてしまい、その後ジリジリ肥えていく現象を解消するため途中から jemalloc に切り替えました。

印刷用カラー(CMYK)をWEB用カラー(RGB)に変換した際の色合いの維持

CMYKとRGBのカラー変換に関する事例はクックパッド様の記事が参考になります。

techlife.cookpad.com

弊社の一部のWebサービスでも、紙媒体用に用意された画像がそのままアップロードされるケースがあり、 低頻度で「表示されている画像の色合いがおかしい」系の問い合わせが発生していました。

この問題に対しGo版の画像変換サーバーでは、しばらく機械学習ベースのカラー変換処理で対応してきましたが、依然として色合いが変わってしまうケースが散見されました。

そこで今回、ICCプロファイルを指定した上でカラーマネジメントモジュールを用いてカラー変換する処理を新たに追加しました。

www.color.org

github.com

これによりクックパッド様の記事に掲載されている画像例と同様の精度のカラー変換が実現できました。 なお、この変換処理はGo版とRust版の両方に実装しています。

利用している画像変換ライブラリについて

世の中には独自にカリカリチューニングした有料の画像変換サービスが存在しますが、我々の内製画像変換サーバーは基本的にOSSライブラリを組み合わせて使って処理しています。 以下にメインで使っているライブラリを紹介します。

Go

github.com

Go版の画像変換サーバーでメモリアロケーション数を削減した際に上記ライブラリに移行しました。画像の基本的な変換処理はこのライブラリで完結しています。

メンテナンスが5年前で止まっている点が少し気にはなりますが、静的サイトジェネレーターの Hugo 内部でも利用されており、 Goの公式 image パッケージとの親和性も高いです。

リサイズなどの簡単な画像変換処理の場合は libvips ベースのライブラリの方が少ないメモリ使用量で爆速と評判です。 しかしそれに移行する場合はGoの公式 image パッケージの利用も諦める必要があり、今回の改修では見送りました。

個人的にはGoの公式 net/http パッケージを捨てて fasthttp に乗り換えた上で libvips ベースのライブラリと組み合わせてどれくらいパフォーマンスが改善されるか試してみたい気持ちはあります。

他には imaging というライブラリもあるらしく、以下のサイボウズ様の記事で知りました。

blog.cybozu.io

あとは細かい処理で以下のライブラリを利用しています。

Rust

github.com

Goで実装しているときは各種ライブラリをかき集めて使う必要がありましたが、Rustだとデファクト・スタンダードと言えそうな包括的ライブラリが存在していたので、それを利用しています。

HTTPの処理は tokio ベースの axum を利用しており、axum と image クレートの2つでほぼ仕事が完了しています。

SVG画像の場合はそのまま返す、CMYKカラーの場合はICCプロファイルを指定してRGBに変換する、などの細かな処理だけ追加で実装しました。

Little CMSのバインディングライブラリは以下のクレートを使用しています。 github.com

余談ですが我々の画像変換サーバーはバックエンドを複数設定できるので、リクエストに対してどのバックエンドから画像をダウンロードしてくるかのルーティング処理が必要です。

画像処理と直接は関係ないですが、上記ルーティング処理におけるパス文字列の最長一致検索処理では以下にお世話になっています。

github.com

内部で radix tree データ構造が用いられていて高速です。かつ、ルーティング周りのコードがシンプルになりました。

Go版の画像変換サーバーのルーティング実装は素朴にループでマッチングしており、最初はこちらも以下のライブラリを使う形に改修しようと試みました。

github.com

しかし私の手元でのベンチマーク結果を見ると20件程度のルーティングでは素朴なループ処理によるマッチングの方が高速だったため、コードの修正量と天秤にかけた上で断念しました。

最後に

以上、サムネイル生成における画像変換処理周りのちょっとした改善に関するお話でした。 ここまでお読みいただき、ありがとうございます。

最初にGo版サーバーの改善に着手しつつ、試験的にRust版も実装し始めて結果的にGo版とRust版の二重メンテ状態にはなってしまいましたが、 しばらくこの状態で運用し続けてみて新たな課題が出てきたときにあらためて理想形を考え直す予定です。

今回のような改修はオンプレからのクラウド移行を終えて、優先度の関係で若干放置気味だったアプリケーションの改善に着手できる余裕が生まれた背景もございます。

キャッシュ前提の画像配信におけるオリジン側の改修以外にも、まだまだ改善の余地はありそうです。 今後は全体的な通信量を削減する目的で画像データサイズと見た目の品質担保を両立させるためのエンコード設定チューニングなどもやっていきたいと考えています。

インフラグループでは引き続き横断で弊社サービスのシステムの安定化や運用改善を実施していきます。