Nginxを改造して欲しかった画像サーバーを作った


スマートフォンサイトに向けての最適化だったり、
Webページのレイアウトの変更だったりで、
同じ画像でも様々なサイズを利用することがある。

サイトの運用を続けていると、
同じ画像でも数通りのファイルを保存していて、
ファイル数が大量になってIOがキツくなってきた。

数多くなるとIO辛いから、
ファイル増やさないように気をつけてね、
と社内で言い続けていたら…

Webサイトでは、本来小さい画像で良いところに
大きい画像を使用して、
JavaScriptでリサイズする処理が多用されはじめ、
今度は転送量が増加しはじめて、
これ以上増えるとデータセンターの月額費用が
増加してしまいそうに…。

なんとかせねば!と対応をはじめるわけです。

そこでまず最初にやったのが、
本当は速いImageMagick
と同じように、Apacheモジュールを自作して、
ImageMagickでリサイズする構成。

構成1

画像がアップされた時に、
RailsからWebDavで画像サーバーにファイルをアップ。

画像サーバーでは、

/resize/100x100q80/hoge/hoge.jpg
/crop/100x100q80/hoge/hoge.jpg

などとして、パスの前にresizeかcropかを明記し、
width×heightと、必要であればJPEGのクオリティ指定も。
Apacheで動的に処理させるので、
保存する画像は1枚だけで良いし、
サイズやクオリティの調整で転送量も調整できる。

これはすばらしい!と思いながら、
こちらへの移行を開始しはじめたのだが…。

大量に処理をさせると、CPU負荷が辛くなってきた。
そこでキャッシュを入れる仕組みを検討する。

また、画像処理については、
データセンターのサービスでVMWare Vcenterを
安く借りられるプランがあったので、
そちらを利用して分散することにする。

そして、
簡単!リアルタイム画像変換をNginxだけで行う方法
を参照して、画像処理はNginxの、
http_image_filter_moduleを使用するように。

考えていた構成はこんな感じだ。

構成2

しかし、この構成には一つ問題があり、
採用に踏み切れなかった。

画像のキャッシュ削除の方法がないのである。

Nginxのcache_purgeモジュールなどで削除する方法はあるが、
リサイズしたすべてのファイルを抽出することはできない。

Nginxはキャッシュファイル名をmd5化する。
md5は不可逆なので、キャッシュファイル名から、
元のファイル名に関連するものを
ピックアップするのは不可能だ。

違う方法も検討したけど、
Nginxのcache managerは古いファイルを自動削除してくれるので、
余計なファイルが大量生成されるのを防ぐために、
どうしてもNginxのキャッシュの仕組みを利用したいなぁと。

そこで、ファイルの更新があった場合に、
/var/spool/nginx/update というディレクトリを作り、
その下に元ファイルのmd5データを入れておき、
キャッシュファイルの更新日時がそれより古い場合は、
キャッシュHITさせないように、
Nginxの処理を変更させてやろうと思った。

具体的な処理は次のような感じだ。

ロジック

最初はNginxのモジュールを書こうと思ったのだが、
色々調べているうちに、
src/http/ngx_http_upstream.c
に直接手を入れた方がやりやすそうだったので、
ソースコードをいじることにした。
(セットアップしていたのが少し古くて1.5.6ベース)

※改造したngx_http_upstream.cはこちら

まずキャッシュファイル名の生成に使うので、
ngx_md5.hをincludeしてやる。

#include <ngx_md5.h>

独自関数の定義。

static ngx_int_t ngx_http_upstream_sanzero_cache(ngx_http_request_t *r);

ngx_http_upstream_cachの中で
NGX_DECLINEDを返せばキャッシュを読まなくなるので、
750行目あたりの、
rc = ngx_http_file_cache_open(r);
の後に独自のチェックへの処理を追加してやる。


if (ngx_http_upstream_sanzero_cache(r) == NGX_DECLINED) {
  u->cache_status = NGX_HTTP_CACHE_BYPASS;
  return NGX_DECLINED;
}

独自の関数の処理。


static ngx_int_t ngx_http_upstream_sanzero_cache(ngx_http_request_t *r)
{
  ngx_str_t                    *key;
  ngx_md5_t                     md5;
  ngx_uint_t                    n;
  ngx_uint_t                    i;
  ngx_file_info_t               fi;
  ngx_file_info_t               cache_fi;
  ngx_http_cache_t             *c;
  ngx_regex_compile_t           rc;
  ngx_regex_compile_t           rc2;
  ngx_http_file_cache_t        *cache;
  int                           ovector[21];
  int                           ovector2[24];
  int                           result;
  int                           result2;
  time_t                        now;
  size_t                        j;
  size_t                        level;
  size_t                        len;
  u_char                        errstr[NGX_MAX_CONF_ERRSTR];
  u_char                        errstr2[NGX_MAX_CONF_ERRSTR];
  u_char                        original_key[NGX_HTTP_CACHE_KEY_LEN];
  u_char                       *p;
  uint32_t                      crc32;

#if (NGX_PCRE)
  ngx_str_t pattern = ngx_string("^/(resize|crop)/([0-9]+)x([0-9]+)(q([0-9]+))?(/.+)$");
  ngx_str_t pattern2 = ngx_string("^(.*)?(/resize|/crop)/([0-9]+)x([0-9]+)(q([0-9]+))?(/.+)$");

  ngx_memzero(&rc, sizeof(ngx_regex_compile_t));

  rc.pattern = pattern;
  rc.pool = r->pool;
  rc.options = NGX_REGEX_CASELESS;
  rc.err.len = NGX_MAX_CONF_ERRSTR;
  rc.err.data = errstr;

  if (ngx_regex_compile(&rc) != NGX_OK) {
    return NGX_ERROR;
  }

  result = ngx_regex_exec(rc.regex, &r->unparsed_uri, ovector, 21);

  if (result < 7) {
    return NGX_OK;
  }

  c = r->cache;
  key = c->keys.elts;

  len = 0;

  ngx_memzero(&rc2, sizeof(ngx_regex_compile_t));

  rc2.pattern = pattern2;
  rc2.pool = r->pool;
  rc2.options = NGX_REGEX_CASELESS;
  rc2.err.len = NGX_MAX_CONF_ERRSTR;
  rc2.err.data = errstr2;

  if (ngx_regex_compile(&rc2) != NGX_OK) {
    return NGX_ERROR;
  }

  ngx_crc32_init(crc32);
  ngx_md5_init(&md5);

  for (i = 0; i < c->keys.nelts; i ++) {
    result2 = ngx_regex_exec(rc2.regex, (ngx_str_t *)&key[i], ovector2, 24);

    if (result2 >= 8) {
      int original_len = key[i].len - ovector2[14] + ovector2[4];
      u_char *original_path = ngx_pstrdup(r->pool, &key[i]);
      u_char *original_data = ngx_pnalloc(r->pool, original_len);

      if (original_data == NULL) {
        return NGX_ERROR;
      }

      if (ovector2[4] > 0) {
        ngx_memcpy(original_data, key[i].data, ovector2[4]);
      }

      original_path += ovector2[14];

      ngx_memcpy(original_data + ovector2[4], original_path, key[i].len - ovector2[14]);

      ngx_str_t original_key_str = { original_len, original_data };

      ngx_crc32_update(&crc32, original_data, original_len);
      ngx_md5_update(&md5, original_key_str.data, original_key_str.len);
    }
  }

  ngx_crc32_final(crc32);
  ngx_md5_final(original_key, &md5);

  cache = c->file_cache;

  ngx_str_t update_path = ngx_string("/var/spool/nginx/update");

  ngx_str_t original_file_name = {
    update_path.len + 1 + cache->path->len + 2 * NGX_HTTP_CACHE_KEY_LEN,
    ngx_pnalloc(r->pool, update_path.len + 1 + cache->path->len + 2 * NGX_HTTP_CACHE_KEY_LEN + 1)
  };

  if (original_file_name.data == NULL) {
    return NGX_ERROR;
  }

  ngx_memcpy(original_file_name.data, update_path.data, update_path.len);

  p = original_file_name.data + update_path.len + 1 + cache->path->len;
  p = ngx_hex_dump(p, original_key, NGX_HTTP_CACHE_KEY_LEN);
  *p = '\0';

  len = original_file_name.len;
  j = update_path.len + 1;

  original_file_name.data[update_path.len + cache->path->len] = '/';

  for (n = 0; n < 3; n ++) {
    level = cache->path->level[n];

    if (level == 0) {
      break;
    }

    len -= level;
    original_file_name.data[j - 1] = '/';

    ngx_memcpy(&original_file_name.data[j], &original_file_name.data[len], level);
    j += level + 1;
  }

  if (ngx_file_info(original_file_name.data, &fi) == NGX_FILE_ERROR) {
    return NGX_OK;
  }

  now = ngx_time();

  if (now - fi.st_mtime > cache->inactive) {
    ngx_delete_file(original_file_name.data);
    return NGX_OK;
  }

  if (ngx_file_info(c->file.name.data, &cache_fi) == NGX_FILE_ERROR) {
    return NGX_OK;
  }

  if (cache_fi.st_mtime > fi.st_mtime) {
    return NGX_OK;
  }
#else
  return NGX_OK;
#endif

  return NGX_DECLINED;
}

そして、/var/spool/nginx/update
にファイルを置くための独自モジュールを作成。

ngx_http_sanzero_purge_module.c

nginx.confにはこんな感じで書けばOK。


proxy_temp_path /var/spool/nginx/temp;
proxy_cache_path /var/spool/nginx/cache keys_zone=cache:512m levels=2:2:2 inactive=1d max_size=100g;

upstream resize {
  server 192.168.0.1
  server 192.168.0.2
  server 192.168.0.3
  server 192.168.0.4
}

server {
  listen 80;

  location ~ /purge(/.*) {
    # 削除を許すIPアドレス
    allow 192.168.0.0/24

    deny all;

    proxy_cache cache;
    proxy_cache_key "$host:/$1";

    sanzero_purge;
  }

  location / {
    allow all;

    proxy_cache cache;
    proxy_cache_key "$host:/$request_uri";
    proxy_cache_valid 200 1d;

    proxy_pass http://resize;
  }
}

これで /purge/hoge/hoge.jpg にGETリクエストを送ると、
/hoge/hoge.jpg 由来のキャッシュは、
順次更新されるようになる。

最終的な構成はこんな感じだ。

構成3

あとは削除されたファイルは、
/var/spool/nginx/update
に残り続けることがあるので、
キャッシュの有効期間(例えば1日)以前のものは、
findコマンドなどで定期的に消してやれば良い。

運用の仕方によりそうだけど、
更新、削除されるファイルはそれ程多くないだろうから、
こちらの処理はそんなに重たくはならないだろう。

今のところ順調に稼働しているので、
これでしばらく画像のインフラに悩まず過ごせそうだ。