Node.js
SSL
proxy

Togetterを支えるお手軽画像配信サーバ(1)Camoで実現するセキュアな画像プロキシサーバ

こんにちは。トゥギャッター株式会社でバックエンド中心にエンジニアをしている @MintoAoyama です。

Togetter はツイートを始めとした様々な情報を組み合わせてコンテンツを作り出すキュレーションサービスです。
2009年に誕生してから今年で8年目に突入し、現在も月間6000万PV・1200万UUを超える規模で成長を続けています。

すごいぞTogetter

海外版の Chirpstory も政治家・公共団体・ジャーナリストなど影響力のあるユーザに支えられ、成長を続けています。

スクリーンショット 2016-10-24 0.35.04.png

そんなTogetter・Chirpstoryを支えるシステムの1つに 画像配信サーバ があります。もう少し詳しくすると リアルタイム変換機能付き 画像プロキシサーバ です。

この仕組み、特に当サービスにおいては地味に重要な役割を持っているのですが、社内に関心を持って貰える人が居なかった(※)ので、とりあえずここに整理することにしました。
なお、「お手軽」と言っても、EC2でいう t2.micro (1vCPU・1GBメモリ) 相当の低スペックサーバでも1日400万reqをらくらく処理できる 程度のコスパの良い構成になっていますので、それなりに実用的かと思います。
ニッチなテーマですが、同じような要件で困っている人は参考にしていただければ幸いです。

分かる方にはタイトルだけで中身が分かる、以下の3部構成で書きたい気持ちがあります。タイトルだけでは良く分からない方はご期待下さい。

  1. Camoで実現するセキュアな画像プロキシサーバ ← 本記事
  2. Nginx で Proxy Cache + SSL + HTTP/2 配信(仮)
  3. Image Filter OR ngx_small_light でリアルタイム画像変換(仮)

※ 必要性や実現方法などを丁寧にドキュメント化していなかったというのもあるので、これを期に関心を持ってもらえたら幸いだと思ってたりします。

なぜ画像プロキシサーバが必要なのか

外部サイトのリソースが必要になる幾多のケース

Togetterはキュレーション(更に懐かしい言葉で言うとマッシュアップサービスであるため、外部サービスのリソースを利用することが必須となっています。特に画像リソースは、コンテンツを作成する際に重要な要素になります。

Togetterにおいては特に以下のような画像です。

  • ツイート内の画像(ついっぷる・Twitpicを含む)
  • ツイート内では画像として認識されてないが、URL先で画像として提供されていることが分かっているもの(Instagram)
  • 外部サイトのリンクを挿入した際のOGP(Open Graph Protocol)で示されたサムネイル画像
  • 任意で挿入された外部サイトの画像

これはTogetterに限らず、Wikiやブログサービスの類にも当てはまりますね。OGP画像などは非常に良く使われるリソースだと思います。

外部サイトのリソースをどのように利用する?

これらの画像を自分のサイト内に利用する際、まず始めに思い付きそうなのは、サイト内にそのままリンクを張り、インラインで表示する方法です。(!)

<tr style="text-align:center;">
  <td>
    <a href="" class="image" title="外部サイトの画像">
      <img alt="外部サイトの画像" src="//外部サイト/良い画像.JPG" width="250" height="250">
    </a>
  </td>
</tr>

しかしそのような方法は直リン(直リンク・ホットリンク)と呼ばれ、「外部サイトを運営している(オリジン)サーバに負担が掛かる」行為になり、迷惑を掛ける可能性があります。
例えば Togetter であれば、1日のうちに数十万PVになる記事も珍しくないのですが、そのような記事の中に同一のサイトの画像が10個貼られていれば単純計算で1日数百万のリクエストが飛ぶことになります。
また、そもそもそのような行為を禁止するため、リファラをチェックし配信を行わないような対策を行うサーバもあるため、直リン行為そのものができない場合も多いです。

また、仮に表示されたとしても、オリジンサーバのスペックやリージョンによっては期待したレイテンシが得られず、快適な表示が行われないケースが考えられます。

はたまた、仮に全てのコンテンツの画像を外部サイトに迷惑を掛けない形で配信しようと、自らのサーバでホスティングを試みた場合、「ホスティングするコンテンツの量が膨大」「画像の追加対応が手間・膨大」「オリジナルの画像の更新や削除への対応が手間・膨大」などの問題があり、あまり現実的ではありません。

HTTPS化の妨げになる外部サイトのリソース

画像がHTTPSで提供されていない場合、サイト全体のHTTPS化を妨げる」という事情もあります。

HTTPSプロトコルで提供するWebサイトではサイト内で利用する画像・CSS・JSなどの全てのリソースをHTTPSプロトコルで提供しなければ安全が保証できないため、ブラウザによっては「Mixed Content」という扱いで利用者に対して警告が出たりブロックされたりします。
(参考:混在コンテンツのブロック | Firefox ヘルプ / Preventing Mixed Content  |  Web  |  Google Developers

これらの問題を解決する方法の1つとして、画像プロキシという仕組みがあります。

GitHubを支えた Node.js製の画像プロキシサーバ Camo

GitHubのREADMEを始めとしたWikiページ内での外部リソースの表示のために開発・運用されていた画像プロキシサーバが atmos/camo です。

atmos/camo: an http proxy to route images through SSL

実はCamoそのものはSSLプロキシ機能を提供していません。
Camoが提供する最も重要な役割は、プロキシ用URLに共通鍵から生成したダイジェストを含めて暗号化することなのです。

画像プロキシサーバを運用する上で懸念されることとして、プロキシ用URLに含まれるオリジン画像のURLを自由に書き換えられた結果、サービサーが想定していないリソースを配信してしまうことなどがあります。
共通鍵で暗号化することで、有効なプロキシ用URLを生成できるのは共通鍵を知るサービサーに限定することが可能になるのです。

http://example.org/<digest>?url=<image-url>
http://example.org/<digest>/<image-url>

上記がプロキシ用URLになります。 <digest> は共通鍵から生成した40桁のHMAC形式のダイジェストです。なお、2行目のフォーマット内の <image-url> は16進数で表現されます。

Camoをインストールしてみる

まずは Node.js がインストールされていなければ、そちらからお願いします。
GitHubのREADMEに記載されているバージョンは 0.10.29 となっていますので、まずはそちらでの動作確認をおすすめします。(めっちゃ古いんですけど…。)

最新のソースコードを git clone 。

$ git clone https://github.com/atmos/camo.git
$ cd camo

とりあえず動作確認してみたいなら、以下のようなコマンドでフォアグラウンド実行できます。

$ node server.js
SSL-Proxy running on 8081 with pid:6099 version:2.3.0.

デフォルトポートは8081です。変更したい場合は環境変数で指定してあげましょう。

$ PORT=5000 node server.js
SSL-Proxy running on 5000 with pid:6156 version:2.3.0.

オリジン画像が http://s.togetter.com/static/1.13.61/web/img/icon/tg_ogp_default4.png であれば、それを元にダイジェストを作成してプロキシ用URLを特定します。

スクリーンショット 2016-10-24 2.40.42.png

デフォルトの共通鍵は「0x24FEEDFACEDEADBEEFCAFE」ですので、以下のように生成できます。

$ node
> var crypto = require('crypto');
undefined
> var key = '0x24FEEDFACEDEADBEEFCAFE';
undefined
> var hmac = crypto.createHmac('sha1', key);
undefined
> var url = 'http://s.togetter.com/static/1.13.61/web/img/icon/tg_ogp_default4.png';
undefined
> hmac.update(url);
Hmac { _handle: {}, _options: undefined }
> var digest = hmac.digest('hex');
undefined
> var url_encode = encodeURIComponent(url);
undefined
> 'http://localhost:5000/' + digest + '?url=' + url_encode
'http://localhost:5000/552244804596daae1c453f4048dc856062725035?url=http%3A%2F%2Fs.togetter.com%2Fstatic%2F1.13.61%2Fweb%2Fimg%2Ficon%2Ftg_ogp_default4.png'

http://localhost:5000/552244804596daae1c453f4048dc856062725035?url=http%3A%2F%2Fs.togetter.com%2Fstatic%2F1.13.61%2Fweb%2Fimg%2Ficon%2Ftg_ogp_default4.png

以下のように実際にアクセスできれば成功です。

スクリーンショット 2016-10-24 2.41.50.png

なお、本番運用の際には共通鍵を安全なものに変更して起動してください。起動の際は 環境変数 CAMO_KEY を指定します。

$ PORT=5000 CAMO_KEY=123456789012345678901234 node server.js
SSL-Proxy running on 5000 with pid:8455 version:2.3.0.

また、本番運用の際にはアプリケーションのdaemon化も行う必要があると思います。私はよく forever を使っています。

$ npm install -g forever

$ forever list
info:    No forever processes running

$ PORT=5000 CAMO_KEY=123456789012345678901234 forever start -a --uid camo server.js

$ forever list
info:    Forever processes running
data:        uid              command             script                      forever pid id logfile                             uptime
data:    [0] camo /usr/local/bin/node /home/camo/server.js 927     932    /root/.forever/camo.log 0:0:0:2.701

Camoで本番運用して起こった問題と「これから」

外部サイトのリソースは「生もの」です。どのようなコンテンツが入ってくるのか予想しづらく、十分な想定が必要になります。
これまでに遭遇した問題の中から代表的なものを紹介します。

Content-Type が想定外になりプロキシされない問題

ある日、特定のサイトから提供されるOGP画像だけが表示できない問題が発生しました。

cc8c6672-3881-11e5-9394-155564f3079d.png

調べた結果、Content-Type が application/octet-stream だったため、Camoの処理上で画像と判断されず Not Found 扱いになっていたことが分かりました。
この問題は、 /mime-types.json に "application/octet-stream" を加えることで対応できました。

また別のある日、他のサイトでも同様の問題が発生しました。

調べた結果、 Content-Type が未定義(undefined)だったため、Camoの処理上で画像と判断されず Not Found 扱いになっていました。
こればかりはCamoのコードを修正する必要がありました。未定義でもプロキシ対象とするだけでなく、ファイルパスの拡張子部分から Content-Type を推測し再設定することで変換後のURLに拡張子が無くてもブラウザで自然に表示されるように修正しました。

難儀ですね…。

Accept 及び Accept-Encoding がクライアントに依存してしまう問題

ある日、こちらが提供しているプロキシ用URLをOGPとして更に利用するサイトがあり、そちらで表示エラーが発生していました。

9df1e380-49d1-11e5-83c3-3f4fbe494552.png
※ 見た目でどこのサイトか分かるかもしれませんが気にしないでください。

詳しく確認すると、そのサイトではこちらがプロキシした画像を更にプロキシ・変換しており、その最中に Internal Server Error になっていました。

調べた結果、以下の事が分かりました。

  • オリジン画像はgzip圧縮に対応していた
  • そのサイトはOGP画像を表示する際、キャッシュ無しでリアルタイムでクロップしている上、gzip圧縮に対応していなかった
  • Camoは Accept-Encoding を(画像に問い合わせを行ってくる)クライアントに依存していた(クライアントが未指定の場合はデフォルト値がある)
  • Camoでプロキシした画像をNginxでキャッシュする際、キャッシュのキーをリクエストURLで行っていたため、キャッシュ作成(アクセス2回目)以降はリクエストヘッダに関わらず内容が同一のものになっていた

つまり「初めのリクエストがgzipに対応したクライアント(Webブラウザ、botなど)だった場合、レスポンスがgzip圧縮に対応したオリジン画像だった場合はそれらがキャッシュされ、2回目以降のアクセスでgzip圧縮に対応していないクライアントで処理エラーが発生する」という流れが発生していました。アクセスのタイミングで事象が異なる、再現性の微妙な問題です。(気付いた瞬間に血の気が引きました…)

ひとまず、Camo側に Accept 及び Accept-Encoding をクライアントに依存しないよう修正を加えました(環境変数で指定すれば強制、指定がなければ従来通りになる作り)。

オリジン画像だけでなく、それを利用するサイトのことも考慮しなければならないのが画像配信サーバの注意点ですね…。

Nodeのバージョンアップ対応

上記のような問題が起こった際、「当然 Pull requests を送ったんだろう」という声があるかと思いますが、実は atmos/camo は最近活動が活発ではありません…。
現状Openな v4.2.1へのアップデートを試みる Pull requests でも 「俺はもうGitHubで働いてないからー」みたいな不穏なコメントがあるので、バージョンアップや独自の変更を加えたい場合はとりあえずforkして、問題があればそこで手直しをするのが無難かと思います。Togetterでも雑ではありますがforkを行っている状況です

…私の当面の目標は、 matsumoto-r/ngx_mruby あたりで同等の実装を行ってみることです…。(できるのかな???)


次回、これらCamoでセキュアにプロキシした画像を更に本番向けに構成する「Nginx で Proxy Cache + SSL + HTTP/2 配信(仮)」を投稿します。よしなに。