QiitaのHTTPS化のために画像プロキシCamoを導入した話

:::note warn
(※ 2025年現在、Qiita では Camo の利用を停止し、別の画像プロキシを使っています)
:::


こんにちは、風邪を引いてAdvent Calendarに遅刻しました :mask: @tomoasleep です。
今年9月末に、ようやくQiitaもHTTPS化出来ました :tada: 
その取り組みについて何回かに分けて取り上げていこうと思います。
この記事ではQiitaに導入した画像プロキシ Camo (https://github.com/atmos/camo) について紹介します。

## なぜ画像プロキシを導入したか

[HTTPS化の報告のブログ](http://blog.qiita.com/post/165860481859/qiita-https) でも軽く説明をしたのですが、HTTPS化したページではjs, css, 画像などを全てHTTPSで配信する必要があります。HTTPで配信した場合はMixed Contentと呼ばれ、読み込みがブロックされたり、警告マークが表示されたりしてしまいます。

(より詳細な解説は [GoogleのMixed Contentの解説](https://developers.google.com/web/fundamentals/security/prevent-mixed-content/what-is-mixed-content?hl=ja) をご覧ください。)

Qiitaでは記事に任意の画像を埋め込めるようになっていて、それを直接配信する仕様になっています。
このままだとHTTPS化しても多くの記事ページでMixed Contentになってしまいます。
その対策のため、Qiitaではユーザーの埋め込んだ画像の配信の際に、HTTPSのプロキシサーバーを経由するようにしました。

## 画像プロキシCamo

今回Qiitaに導入する画像プロキシとして、Camo (https://github.com/atmos/camo) をForkして利用しています。
CamoはGitHubに在籍していたエンジニアが開発したNode.js製の画像プロキシで以下の特徴があります。

- GitHubなどでの利用されている(た？)
- 導入が簡単
    - Heroku用の設定がある
    - (一応) Dockerfileも用意されている
    - 画像URLを置換する html-pipeline のフィルタが用意されている
- 実装がシンプル

QiitaのHTTPS化は、10月のChrome 62リリース[^chrome62]に間に合わせるために急ぎでやっていたので、
簡単に導入できるCamoはかなり便利でした。
 
## CamoFilterで記事内の画像URLを置換する

記事中の画像でCamoを配信するため、Markdownから記事のHTMLを生成する際に、画像URLをCamo用のURLに置き換えます。
html-pIpeline ではURLを置換する [CamoFilter](https://github.com/jch/html-pipeline/blob/v2.7.1/lib/html/pipeline/camo_filter.rb) が用意されていて、これを使うと画像URLを全てCamo経由にするよう置換処理が行なえます。 [^camofilter-bug]

Qiitaでは記事のHTMLの生成に[qiita-markdown](https://github.com/increments/qiita-markdown)を利用しているのですが、qiita-markdownは内部的にhtml-pipelineを利用しているので無理なく利用できます。

qiita-markdownとhtml-pipelineを利用してCamo用に置換するコードのサンプルがこんな感じです。

```processor.rb
class MarkdownProcessor
  def call(input, context = {})
    HTML::Pipeline.new(filters, context).call(input, context)
  end

  def filters
    [
      ::Qiita::Markdown::Filters::Greenmat,
      ::Qiita::Markdown::Filters::UserInputSanitizer,
      ::HTML::Pipeline::CamoFilter,
      ::Qiita::Markdown::Filters::ImageLink,
      ::Qiita::Markdown::Filters::Footnote,
      ::Qiita::Markdown::Filters::CodeBlock,
      ::Qiita::Markdown::Filters::Checkbox,
      ::Qiita::Markdown::Filters::Toc,
      ::Qiita::Markdown::Filters::Emoji,
      ::Qiita::Markdown::Filters::SyntaxHighlight,
      ::Qiita::Markdown::Filters::Mention,
      ::Qiita::Markdown::Filters::ExternalLink,
      ::Qiita::Markdown::Filters::FinalSanitizer,
    ]
  end
end
```

## 導入と運用

Qiitaでの構成としては、Camoのホスティング先としてHerokuを利用して、その前段にCloudFrontを設置する構成になっています。
Camo自体はキャッシュを行わないので、前段のCloudFrontでキャッシュを行う構成です。

![Camo (for presentation) (1).png](https://qiita-image-store.s3.amazonaws.com/0/7048/8041fe16-b76e-ae65-93db-e8c0f55e55a0.png)

既存の記事の画像をCamo経由で配信するにあたって、まず一部の既存の記事を画像をCamoで表示するようにして、正しく表示できたことを確認したら、割合を徐々に増やしていくという手法で本番環境へのリリースを行いました。
動作確認にあたっては、CloudFrontのアクセスログを集計して、エラー数が突出している画像URLをリストアップして検証したりなど、地道な作業をやってました :sweat_smile: 

実際、10 ~ 20%の記事の画像をCamo経由にしたタイミングでいくつか問題を発見でき、影響範囲が小規模なうちに修正を行えました。

- 相対スキーマURL (`//qiita.com`) 指定の画像が正しく表示できない
- 一部の画像のサイズが、当初指定していた画像サイズ上限を上回っていた

ヘルスチェックについては、当初はCloudFrontの4xxレスポンスの割合を利用していたのですが、画像URLの指定が間違っている記事などが原因でfalse-positiveになってしまうことが多かったため、ヘルスチェック用に幾つか画像URLを用意しそこにアクセスできるかをチェックするという形に変更しました。
詳しくは https://qiita.com/iogi/items/676b8c5cbbb6ec874c37 をご覧ください :information_desk_person: 

## まとめと今後の課題

この記事ではどのようにしてQiitaで画像プロキシを導入していったかという流れを紹介しました。

Camoを利用することで大きな混乱無くスピーディに画像のHTTPS対応を行うことが出来ました。
その反面、しばらく運用した限りでは、キャッシュのヒット率が悪い、レイテンシが大きい、アプリケーションスタックがやや古いなどのいくつか課題があると感じています。
これらについては今後とも改善を行っていくつもりです :muscle: 
 
[^chrome62]: Chrome 62ではHTTPのサイトで警告する機能の追加がアナウンスされていました。 https://developers.google.com/web/updates/2017/10/nic62
[^camofilter-bug]: ただしこの実装には相対スキーマ指定のURL (`//qiita.com`) を変換出来ない不具合があるので、Qiitaではこれを修正して利用しています。