動的画像リサイズでページ閲覧速度を向上（レスポンシブ対応）

Web ページ表示速度の重要性は昔から言われ続けていて、さまざまな最適化がされてきていると思います。今回は手を付けやすく効果が大きいと思われる画像配信の最適化手法をメモしておきます。

今回の取り組みによって [Google PageSpeed Insights](https://developers.google.com/speed/pagespeed/insights/)でのスコア向上や、ネットワーク転送量の大幅な削減ができます。

また、この取組は @FumiyaShibusawa に検証、実装をやってもらっています。感謝 👯

## 要件

- レスポンシブな構成
    - 閲覧するデバイスによって表示される画像の大きさが異なる
- 画像の表示サイズは事前には判断できない
- ユーザが画像アップロード、レイアウトの修正をすることができるシステム（CMS）

雑な要件ですが、要は事前に決め打ちで画像をリサイズをすることができないようなシステムです。

## 対応

- HTML レンダリング時に `<img>` の表示領域を計算し、それを画像 URL にクエリストリングとして付与する
    - 例 : http://img.example.com/001.jpg?w=200&h=120
- 画像配信サーバは、クエリストリングのパラメータに基づいてリサイズし返却する
    - webp 対応ブラウザの場合は、webp へ変換する
- リサイズ画像が配信されるまではダミー画像をあてておく

事前に大きさを決め打ちできないのなら、実際に表示させてみてから、その大きさを基にリサイズをするという方針です。

### インフラ構成図

インフラの構成はいたって一般的です。CloudFront -> ALB -> EC2(nginx) -> S3という流れ。

![cloudfront ALB nginx S3](https://qiita-image-store.s3.amazonaws.com/0/2152/5d1e2566-c0fb-7c5c-3053-4a4be3e5f3fa.png)

### HTML 例

最初に出力する HTML では画像はすべて `data-original` 属性へ寄せて、 `src` はダミー画像を指しておきます。

```html
<ul>
  <li><img src="./dummy.gif"
           data-original="http://img.example.com/001.jpg"></li>
</ul>
```

HTML レンダリング時に JavaScript で `<img>` の width/height を計算し、動的に URL を差し替えます。ついでに Retina な端末対応を `srcset` を使って指定しておきます。

```html
<ul>
  <li><img src="http://img.example.com/001.jpg?w=200&h=120"
           srcset="http://img.example.com/001.jpg?w=400&h=240 2x"
           data-original="http://img.example.com/001.jpg"></li>
</ul>
```

次に、この対応をするための JavaScript のコード例です。

## 画像の大きさを算出する

レスポンシブな Web デザインであったり、ユーザが任意に画像アップロード・ページレイアウトを修正できる CMS のようなシステムの場合、事前に画像の大きさを知ることは難しいシーンがあります。そこで、実際に `<img>` が表示されたときの大きさを JavaScript で取得することで最適な大きさへリサイズしてみます。

```javascript
document.addEventListener("DOMContentLoaded", function(){
  document.querySelectorAll('.for-resize').forEach(function(img) {
    var width = img.offsetWidth
    var src_url = img.dataset.original + '?dw=' + width
    var src_2x_url = img.dataset.original + '?dw=' + width * 2
    img.setAttribute("src", src_url)
    img.setAttribute("srcset", src_2x_url + ' 2x')
  })
})
```

やっていることはいたって単純で `DOMContentLoaded` イベント時に、`for-resize` という class がついている `<img>` の大きさを取得して、URLを差し替えているだけです。実際には、画像が表示できなかったときの対応などエラーハンドリングをする必要がありますが、ここでは分かりやすさ優先のため処理を抜き出しています。

### ダミー画像の大きさ

実際の画像の代わりに最初はダミー画像を表示させていますが、ここで注意が必要なのは 1px x 1px などの小さい画像をあてると正常に画像の大きさを取得できないことがあるということです。`<img>` の表示領域をなりゆきで決めるようなスタイルの場合です。これを防ぐためには、ある程度の大きさのダミー画像をあてることで回避できます。

今回は 400px の透過 GIF をダミー画像としました。この大きさは、サイトのレイアウトに応じて適宜変更をしてください。

## nginx で画像リサイズ

画像の最適サイズがわかったので次は nginx を使ってリサイズをします。[ngx_small_light](https://github.com/cubicdaiya/ngx_small_light)という優れたモジュールがあるので、これを使います。

### ImageMagick インストール

ngx_small_light はデフォルトで ImageMagick を利用します。利用している OS は Ubuntu です。

```shell
$ sudo apt-get update
$ sudo apt-get build-dep imagemagick
$ mkdir /tmp/imagemagick
$ cd /tmp/imagemagick
$ apt-get source imagemagick
$ cd imagemagick-6.*.*.*/
$ debuild -uc -us
$ sudo dpkg -i ../*magick*.deb

# webp 対応しているかを確認
$ convert -list format
```

### nginx / ngx_small_light インストール

```shell
$ cd /usr/local/src
$ wget http://nginx.org/download/nginx-1.14.0.tar.gz
$ tar zxvf nginx-1.14.0.tar.gz
$ wget https://github.com/cubicdaiya/ngx_small_light/archive/master.zip
$ unzip master.zip

$ cd ./ngx_small_light-master
$ ./setup

$ cd ../nginx-1.14.0
$ ./configure --modules-path=/usr/local/nginx/modules --add-dynamic-module=../ngx_small_light-master
$ make
$ sudo make install
```

### nginx 設定

ngx_small_light はデフォルトのままで良い感じに動くので設定項目は少ないですが、今回は2点だけいじっています。

- ブラウザが webp 対応の場合は出力フォーマットを webp へ
- クエリストリングでのリサイズパラメータ受け取り

```conf
worker_processes  2;

error_log  /var/log/nginx/error.log;
pid        /run/nginx.pid;

load_module /usr/local/nginx/modules/ngx_http_small_light_module.so;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;
    proxy_cache_path /var/cache/nginx/cache levels=1:2 keys_zone=images:1m max_size=1g inactive=24h;
    proxy_temp_path  /var/tmp;

    # ブラウザが webp 対応の場合は出力フォーマットを webp へ
    map $http_accept $small_light_option {
        default   "";
        "~*webp"  "&of=webp";
    }
    
    server {
        listen 80 default_server;
        location / {
            root /usr/share/nginx/html;
        }
    }

    server {
        listen       80;
        server_name  img.example.com;
        location / {
            proxy_pass http://example.s3.amazonaws.com;
        }
        location ~* \.(png|webp|gif|jpg|jpeg)$ {
            add_header Vary Accept;
            add_header X-Nginx-Cache $upstream_cache_status;
            proxy_cache images;
            proxy_cache_key "$scheme://$host$request_uri$is_args$args$small_light_option";
            proxy_pass http://localhost:8080;
            proxy_cache_valid 200 302 304 24h;
        }
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }

    server {
        listen 8080;
        server_name _;
        set $args $args$small_light_option;
        small_light on;
        # クエリストリングでのリサイズパラメータ受け取り
        small_light_getparam_mode on;
        location / {
            proxy_pass http://example.s3.amazonaws.com;
        }
        location ~ small_light[^/]*/(.+)$ {
            set $file $1;
            rewrite ^ /$file;
        }
    }
}
```

## その他注意点

- nginx はスケールアウト可能な状態にしておいたほうが良い
    - 大きな画像をリサイズするのに、それなりに CPU リソースを使う
- CloudFront などの CDN をかましたほうが良い
    - nginx が複数台構成だとキャッシュヒット率が下がってしまうので、キャッシュは CDN へ集約する
    - CDN 側でクエリストリングの並び替えなどで正規化してヒット率を向上させておく
- JavaScript が無効だとダミー画像しか表示されないので、適さないサービスもあり要検討
    - 例えば、Google の画像検索にはインデックスされない可能性が高い
- DoS などの攻撃に耐えられるようにパラメータの平準化が必要
    - 1px ずつ違うリサイズパラメータを大量に送ることでリサイズ負荷を簡単にかけることが可能
    - どんなパラメータが送られても、予め規定のサイズに集約されるような工夫が必要
        - 例 0px〜100px だったら 100px、101px〜200px だったら、200px に寄せるなど

以上で、実際に表示される画像の大きさへ自動リサイズして表示速度を向上させられる Tips でした。