インフラエンジニアの中野(etsxxx)です。 今回はリブセンス発の画像変換OSS『fanlin』をWebPに対応させ、一部サービスの画像配信においてWebPへの自動アップグレードを開始した話をします。
WebPって?
2010年にGoogleが発表した画像フォーマットです。Webにおいて画像といえばJPEG
とかPNG
、GIF
が有名ですね。それぞれの使い分けと特徴は以下のような感じでしょうか。
JPEGの特徴
- 非可逆圧縮
- 圧縮クオリティの数字(0-100)が落ちるほど細部が失われ、グラデーションが汚くなる
- 写真を対象に利用するのが基本
- 大きな画像では圧縮効率が良い
PNGの特徴
- 可逆圧縮
- 色数と情報量で圧縮効率が変わる
- アルファ値(透過率)を色情報に持てるので、Webパーツでの利用に便利
- イラストやロゴなど、メリハリのあるものに向いている
GIFの特徴
- 可逆圧縮
- PNGに近い特性だが制限が多い。現代では同じ目的ならPNGを使うことが多い
- アニメーションGIFという簡易動画フォーマットが利用できる
WebPの特徴
上記のようなメジャーフォーマットと比較すると、WebPの特徴は以下となります。
- 可逆圧縮と非可逆圧縮が選べる
- どちらでもアルファ値(透過率)を色情報に持てる
- 非可逆圧縮ではJPEGより効率的。同程度の画質ならファイルサイズが小さくなる
- 一応アニメーションも利用できる(この記事の時点ではfanlinは非対応)
- 2019年11月現在、Safariは対応していない
WebPへの自動アップグレードの実現
上記のような特徴から、WebPはJPEGの上位互換として利用するのが最も向いていると言えるでしょう。 JPEGとだけ比較するなら、通信量を削減でき、その分高速化も期待でき、なおかつ高画質な画像配信が実現できるということになります。
※ 透過PNGの置き換えもできますが、よく使い所を分かっていて選ばないと、期待と異なる出力になります。
ただし、現在は対応しているブラウザが限られており、全面的にJPEGをWebPに置き換えるのは向いていません。 WebPをWebサービスに組み込む上では
- WebPに対応しているブラウザのみにWebPを配信
- 未対応ブラウザにはこれまで通りJPEGを配信
という分岐処理をするのが理想的です。 本記事ではこの分岐処理を「WebPへの自動アップグレード」と呼んでいます。
「自動」の実装箇所
WebPへの自動アップグレードを行う上で、JPEGを出力するかWebPを出力するかの分岐の実装は、ざっくりと2方式から選択できます。
- RubyやPHPなど、アプリケーションコードで
img
タグをpicture
タグに置き換え、両フォーマットの画像URLをブラウザに伝える(=JPEGとWebPで画像ファイルのURLを分ける) - 画像ファイルへのリクエストに対してどちらのフォーマットで返すか分岐する(=JPEGとWebPで画像ファイルのURLを揃える)
今回の記事の時点では、後者の方式を選択しています。 その理由は、 画像配信システムだけを更新すればサービス全体を瞬時にWebP対応できるため です。
なお、このWebPへの自動アップグレードの実現においてはもちろんfanlinにWebP変換を実装するところから始めたのですが、こちらに関しての紹介は割愛します。
画像ファイルへのリクエストにおける分岐の実装
画像ファイルへのリクエストにおいてWebP対応ブラウザーかどうかの判別は「Accept」ヘッダーを見れば簡単にできます。
Chromeが送るAcceptヘッダーの例:
accept: image/webp,image/apng,image/*,*/*;q=0.8
一目瞭然ですね。このAcceptヘッダーに応じて画像フォーマットを選択すれば良いわけです。
分岐後のフォーマット選択は、fanlinにおいてはGETパラメータ「?webp=true
」をつけるかどうかで実現できます。
リブセンスではfanlinの前にNginxを置いているため、以下のような設定でブラウザの判別と画像フォーマットの分岐を実現しています。
Nginxの設定例(抜粋、実際とは微妙に異なります):
map $http_accept $accept_webp { default "false"; ~*image/webp "true"; } server { location / { set $args "${args}&webp=${accept_webp}"; proxy_pass http://127.0.0.1:8080; } }
サーバーサイドキャッシュの構成
変換を伴う画像配信においてはサーバーサイドキャッシュ(以下、単にキャッシュ)を組み合わせるのは基本だと思います。fanlinはGo実装で高速とは言え、やはり画像処理はキャッシュ無しでは重たい処理です。特にWebPはJPEGに比べると処理が遅いため、尚更キャッシュが必要です。
今回は同一URLで画像フォーマットを自動分岐させる選択をしたため、URLだけを基準としたキャッシュでは、WebP非対応のブラウザにWebPのキャッシュを返答してしまうリスクがあります。つまり、キャッシュでも分岐が必要です。
シンプルな分岐は Acceptヘッダーによる分岐 です。ただし、上述の通りAcceptヘッダーには情報量が多く、ブラウザのマイナーバージョン変更でも変わることがあり得るため、キーとして利用するとキャッシュヒット効率が良いとは言えません。
本記事執筆時点では、先程のAcceptヘッダーの判定をフラグに利用して「WebPに対応しているか否か」で分岐する一種の正規化の実装を選択しました。
Nginxでの実装例:
proxy_cache_key $scheme$proxy_host$uri$is_args$args::$accept_webp;
Fastlyでの実装例:
### リクエスト受信時にAcceptヘッダーに基づくX-Accept-WebPヘッダー追加 - name: set X-Accept-WebP content: |2- if (req.http.Accept ~ "image/webp") { set req.http.X-Accept-WebP = "yes"; } else { set req.http.X-Accept-WebP = "no"; } type: recv ### Fastly内キャッシュにおいてはX-Accept-WebPヘッダーで正規化 - name: Overwrite beresp Vary content: |2- set beresp.http.Vary = "Accept-Encoding, X-Accept-WebP"; type: fetch ### クライアント(ブラウザやProxy)に返答するときは、一般的な要素のAcceptヘッダーをVary条件にする - name: Overwrite resp Vary content: set resp.http.Vary = "Accept-Encoding, Accept"; type: deliver
このような実装により、「WebP対応ブラウザーかどうか」でキャッシュを2種類に分けるようにして、「キャッシュが混ざること」と「必要以上に分離されてしまうこと」を避けています。
リリース後の状況
結論から言うと、システム全体で見たときには通信量も平均レスポンス時間も変化があるようには見えません。
※ 11/12頃にリリース。下図では意図的にY軸の値を消していますが、上に行くほど時間が掛かっているというグラフです。
この理由としては、WebP非対応ブラウザのアクセスがまだまだ多いこと、リブセンスのサービスにおいては巨大な画像を扱うことが少ないことなどが挙げられます。 WebPの方が画像変換処理が重いことを考えると、変化がないことは「キャッシュが期待通り動作している」と言え、むしろ良いことと言えるかも知れません。
システム全体ではなく局所で見た場合、WebP対応ブラウザー(例えばChrome)では画像ファイルのサイズがJPEGの時より20%ほど削減されており、(目を凝らせば)画質も向上しています。 今後画像の量が多くなったとき、サービスへのリクエストが増えたときには、この「20%」は、システム維持費としてもモバイルユーザーのパケット消費量としても、意味のある数字となることでしょう。
通信量が削減されたことでレスポンスも速くなることを期待したいところでしたが、元々画像サイズが小さいサービスが多かったためか、目に見えた変化は感じ取れません・・・ (通信網の安定性の方が変化を感じます)
以上、WebP対応させた話を長々と書かせて頂きました。 リリース後の状況は少し肩透かしだったかもしれませんが、僅かでもUXの改善に繋がる変化になっているとは言えるのではないでしょうか。