middleman-blogをAMP対応させる

2017.05.23 / web middleman

前回このブログをHTTP2対応させた話をしましたが、その際にMovableTypeからmiddleman-blogに移行しています。 移行そのものの話も書きたいのですが、今回はmiddleman-blogのエントリをAMP対応させたのでその話を書きます。

AMP対応の細かなフローは公式サイトのチュートリアルを見れば理解できると思います。要は、

  • AMP用のJavaScriptを読み込む
  • 自前のJavaScriptはロード、実行できない
  • 外部スタイルシートはロードできない
  • 自作スタイルシートはhead要素内に差し込む
  • imgタグはamp-imgタグなど独自のタグに置き換える

あたりがポイントになります。この対応をmiddleman-blogのエントリに対して適用させます。

AMP対応が完了するとValidationが通り、このような結果になります。

全体設計

カテゴリやアーカイブページなど、AMP対応はやろうと思えばいくらでもできますが、今回は固有の記事エントリのみをAMP対応させることにしました。 本ブログは、固有記事エントリのパーマリンクは

  • yyyy/mm/name.html

の形式ですが、AMP対応のページは

  • yyyy/mm/name.html.amp

と、拡張子をampにさせています。 実際は、この命名はなんでもよくて、 /amp/ のようにディレクトリをURLの中で切る方法もあると思いますが、今回は「amp形式のリソース」と見なして拡張子に付けることにしています。

あとは、この固有記事とAMP対応ページをlink要素で相互に関連付けます。

固有ページ

<link href='https://blog.katsuma.tv/2017/05/http2.html.amp' rel='amphtml'>
  

AMP対応ページ

<link href='https://blog.katsuma.tv/2017/05/http2.html' rel='canonical'>
  

config.rb

AMP対応ページを作るということは、構造としてはmiddleman-blogのpermalinkと似たものを別にもう1つ作ることになります。 最初はmiddleman-blogそのものに手をいれることや、Middlemanの拡張を作ることも考えましたが、今回は config.rb のみを操作して次の戦略で実現しました。

  1. ready ブロックでpermalink URLを持つリソースを抽出
  2. proxy を利用して同一リソースで別templateを利用するページを作成
  3. after_build ブロックで2.のページをビルド

1, 2 では例えばこのような形で実現できます。


  amp_paths = []
  
  ready do
    sitemap.resources.select { |resource|
      resource.path.end_with?(".html") && resource.is_a?(Middleman::Blog::BlogArticle)
    }.each do |article|
      proxy_path = "#{article.date.year}/#{article.date.month.to_s.rjust(2, '0')}/#{article.destination_path.split("/").last}.amp"
      proxy proxy_path, "layout_amp.html", locals: { article: article }
  
      amp_paths << proxy_path
    end
  end
  

amp_paths はビルド対象なパスを保存しておき、buildフェーズ(3)で再利用します。


  after_build do
    amp_paths.each do |path|
      modify_html_as_amp_format("build/#{path}")
    end
  end
  

modify_html_as_amp_format

  • img要素をamp-img要素に書き換え
  • 外部CSSをhead内に挿入

の2つの処理を行います。


  def modify_html_as_amp_format(path)
    html = File.read(path)
  
    html = use_amp_img(html)
    html = use_inline_style(html)
  
    File.write(path, html)
  end
  

(このエントリ書いてて思ったけどデバッグ面倒くさいので、buildフェーズではなくてrender前あたりで実施してもいいかも、と思い始めた。。)

amp-img要素への書き換え

  1. img要素のsrc属性値からFastImageを利用して画像のサイズを取得
  2. amp-img 要素に書き換え、src属性を置換、1.で取得したサイズを利用

のような形で書き換えています。


  IMG_LINK_REGEXP = /<img\s[^>]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/i
  
  def use_amp_img(html)
    html.scan(IMG_LINK_REGEXP).each do |img_sources|
      src = img_sources[0]
      scanned_src = src.start_with?('/') ? 'build' + src : src
      sizes = FastImage.size(scanned_src)
      if sizes
        html.gsub!(IMG_LINK_REGEXP, "<amp-img src='#{src}' with='#{sizes[0]}' height='#{sizes[1]}' />")
      end
    end
    html
  end
  

ポイントはamp-img要素はレンダリングの高速化のために画像サイズを事前に伝える必要があります。 (一応回避する方法もありますが、ここでは言及しません) そこで、今回はFastImageを利用してサイズを取得しています。 ただし、指定画像が外部ドメインに存在する場合は、buildのたびにインターネットを介した通信が走るので、キャッシュさせるなり高速化の手法は考える余地はありますね。

CSSのインライン化

AMPページではCSSのロードが許可されないので、head内で定義しておく必要があります。 amp用のCSSを用意することも可能ですが、今回は非AMPページでも利用しているCSSを利用することにしました。

やるべきことは単純でCSSを呼び出しているlink要素を参照しているCSSの中身で差し替えます。


  STYLE = File.read("build/stylesheets/bundle.css")
  STYLESHEET_LINK_REGEXP = /<link href="\/stylesheets\/bundle\.css" rel="stylesheet" \/>/
  
  def use_inline_style(html)
    html.gsub(STYLESHEET_LINK_REGEXP, "<style amp-custom>#{STYLE}</style>")
  end
  

上の例ではCSSを1ファイルにかためている前提なので単純な書き方ですが、 複数のlink要素が存在する場合は、正規表現でCSSファイルをマッチさせ、そのたびにhead要素に実体を差し込む必要がありますね。

CSSの容量

ちなみに、AMP対応ページではhead内に存在できるCSSは上限が50000 bytesです。 この上限を超えた内容を差し込むとValidationエラーになるので注意が必要です。

The author stylesheet specified in tag 'style amp-custom' is too long - we saw 96941 bytes whereas the limit is 50000 bytes. AUTHOR_STYLESHEET_PROBLEM
  line 5, column 13
  

本ブログはCSSフレームワークとしてBulmaを利用していますが、この容量制限にひっかかったので、 利用に必要な最小限のCSS moduleのみをロードさせることでこの容量問題を回避させました。

nginxの設定

このままだと本番環境(今回はnginx)にデプロイしたところで、ブラウザからアクセスすると .amp ファイルがダウンロードされることになります。 そこでmime.typesに手をいれて .amp 拡張子をHTML扱いさせることにします。


  types {
      text/html                             html htm shtml amp;
      ...
  }
  

まとめ

config.rbに手をいれることで、middleman-blogのpermalinkページをAMP対応させました。

実際にはこの記事を執筆化している時点ではGoogleにインデクシング化されていないので、効果が本当にあるかどうかは不明ですが、 この記事がmiddlemanユーザーにとって大まかな指針の1つになれば、と期待しています。

blog.katsuma.tvをhttp2対応をしました

2017.05.17 / web

またもや1年ぶりの更新ですが、唐突に本ブログをHTTP2対応させました。それに伴いURLが変更になっています。といってもhttpからhttpsへの変更ですね。

経緯としては、ずっとMovableTypeで運用していたこのブログを、Middlemanに移してgithubでコード管理に変更したいな、、とずっと思っていたのですが 今年のGWにようやくまとまった時間がとれたので勢いで移行することができました。 その際に、どうせ自分のサーバ(さくらのVPS)で運用させるのなら手を入れられるだけとことん入れてみたいな、、と思い調査をしはじめました。

https対応

2017年におけるSSL証明書の相場感もよくわかっていなかったのですが、今は無料で手に入るのですね。いい時代。 今回はLet’s Encryptを利用することにしました。 慣習的に /usr/local 以下に入れるパターンが多そうなので従ってます。

  cd /usr/local/
  git clone https://github.com/certbot/certbot
  cd certbot/
  ./certbot-auto -n

なお、証明書発行の際には、DNSを設定して発行対象のドメイン=作業環境になっている必要があります。 DNSが浸透する前に証明書発行処理を行ったことで発行に失敗し、少しハマることがありました。

発行処理は、nginxなどWebサーバプロセスが起動していない状態であれば、スタンドアローンモードで実行すれば特に何も迷わずに進めることができるかと思います。

  ./certbot-auto certonly --standalone -t

実行完了すると /etc/letsencrypt/ 以下に証明書がドメイン毎に保存されます。 今回はWebサーバとしてnginxを利用したので、以下のような設定で発行された証明書を指定できます。

  server {
      server_name  blog.katsuma.tv;
      root        /path/to/root/blog.katsuma.tv/public;
  
      # ここを指定
      listen      443 ssl;
      ssl_certificate     /etc/letsencrypt/live/blog.katsuma.tv/fullchain.pem;
      ssl_certificate_key /etc/letsencrypt/live/blog.katsuma.tv/privkey.pem;
  
      access_log  /path/to/blog.katsuma.tv/logs/access.log  main;
      error_log   /path/to/blog.katsuma.tv/logs/logs/error.log   warn;
  }

ついでにhttpの従来のリクエストはhttpsのプロトコルへリダイレクトさせます。

  server {
      server_name blog.katsuma.tv;
      listen 80;
      return 301 https://$host$request_uri;
  }

http2対応

これだけでもhttps対応は可能なのですが、ついでにhttp2対応も行います。 listenの箇所を変更するだけでOKです。

  listen 443 ssl http2;

おお、便利。。と思いきや実はこれだけではhttp2対応できません。

Chrome51からTLS上のネゴシエーションプロトコルがALPNに対応していないとhttp2をしゃべってくれなくなりました。こんなの初めて知ったぞ。。

ALPNは、OpenSSL1.0.2以上がインストールされていればOKです。 たしかにOKなのですが、さくらのVPSの環境ではyumで入るOpenSSLは1.0.1eが最新で、1.0.2に上げるためにはソースコードからコンパイルが必要になります。 ここで、コンパイルしてインストールしなおしを選んでもいいのですが、OpenSSLのようにいろんなソフトウェアから利用されているものをいきなりバージョン上げるのはあまり意欲がわきません。

LibreSSLを利用したnginxのビルド

そこでOpenSSLのバージョンを上げるのはやめて、LibreSSLを利用することにします。 LibreSSLはニュースサイトで一度見たくらいでスルーしていたのですが、安定性も特に問題なさそうとのことなのと、 OpenSSLと分離することで最悪問題がおきても被害を最小限に留められるかと考えました。

実際はnginxコンパイル時にwith-opensslオプションでLibreSSLを指定すればOKです。 nginxのビルドはミスっても最悪yumで入れ直せばいいのでリスク的には許容できそう。

ひとまずnginxのビルドオプションはyumで入っているもの(/path/to/nginx -V で確認できます)に基本あわせて、 with-openssl の箇所だけを変更します。ちなみに今回ビルドしなおしたnginxのバージョンは 1.13.0 です。

  cd nginx-1.30.0
  ./configure \
  --prefix=/usr/local/nginx \
  --modules-path=/usr/lib64/nginx/modules \
  --conf-path=/usr/local/nginx/nginx.conf \
  --error-log-path=/var/log/nginx/error.log \
  --http-log-path=/var/log/nginx/access.log \
  --pid-path=/var/run/nginx.pid \
  --lock-path=/var/run/nginx.lock \
  --http-client-body-temp-path=/var/cache/nginx/client_temp \
  --http-proxy-temp-path=/var/cache/nginx/proxy_temp \
  --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \
  --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \
  --http-scgi-temp-path=/var/cache/nginx/scgi_temp \
  --user=nginx \
  --group=nginx \
  --with-compat \
  --with-file-aio \
  --with-threads \
  --with-http_addition_module \
  --with-http_auth_request_module \
  --with-http_dav_module \
  --with-http_flv_module \
  --with-http_gunzip_module \
  --with-http_gzip_static_module \
  --with-http_mp4_module \
  --with-http_random_index_module \
  --with-http_realip_module \
  --with-http_secure_link_module \
  --with-http_slice_module \
  --with-http_ssl_module \
  --with-http_stub_status_module \
  --with-http_sub_module \
  --with-http_v2_module \
  --with-mail \
  --with-mail_ssl_module \
  --with-stream \
  --with-stream_realip_module \
  --with-stream_ssl_module \
  --with-stream_ssl_preread_module \
  --with-openssl=../libressl-2.5.3 \
  --with-ld-opt='-lrt'
  make
  sudo make install

これでようやくhttp2対応ができました。

安全性をより強化

SSL Labsでは特定の環境の安全性を診断してくれます。 どうせなら高得点を目指したい。以下、そのためのTips。

HSTSの対応

Strict-Transport-Security ヘッダをサーバから送ることでHTTPSでの通信を強制させることができます。

  add_header Strict-Transport-Security "max-age=15768000; includeSubdomains";

認証方式の指定

SSLv2, v3あたりの認証方式は古いので明示的に無効にさせます。 また、強度が弱い暗号化方式を無効化させ、ForwardSecrecyに対応させるためにECDHを指定させます。

  ssl_ciphers  'ECDH !aNULL !eNULL !SSLv2 !SSLv3';

まとめるとこんなかんじです。

  server {
      server_name blog.katsuma.tv;
      listen 80;
      return 301 https://$host$request_uri;
  }
  
  server {
      server_name  blog.katsuma.tv;
      root        /path/to/root/blog.katsuma.tv/public;
  
      listen      443 ssl http2;
      ssl_ciphers  'ECDH !aNULL !eNULL !SSLv2 !SSLv3';
      add_header Strict-Transport-Security "max-age=15768000; includeSubdomains";
  
      ssl_certificate     /etc/letsencrypt/live/blog.katsuma.tv/fullchain.pem;
      ssl_certificate_key /etc/letsencrypt/live/blog.katsuma.tv/privkey.pem;
  
      access_log  /path/to/blog.katsuma.tv/logs/access.log  main;
      error_log   /path/to/blog.katsuma.tv/logs/logs/error.log   warn;
  }

これで本ブログサイトもhttp2対応した上で、安全性もA+のスコアを取ることができました。

まとめ

  • Let’s encryptを利用して証明書を無料で取得した
  • LibreSSLを利用してnginxをビルドしなおした
  • HSTS対応、認証方式の見直しを行った
  • 結果、http2対応した上でより安全性を向上させる環境を構築することができた

今回、久々にインフラ触りました。 後半のSSL Labのスコア上げは正直惰性でしたが、http2環境構築のための作業はなかなか大変なところはあったものの、かなり勉強になって楽しかったですね。

TensorFlow / TF Learn v0.9のDNNClassifier / TensorFlowDNNClassifierの罠

2016.07.18 / tensorflow

(2016.07.18 19:00 追記: 誤解のある表現が多かったのでタイトル含め加筆しています)

唐突ですが、1年ぶりの更新を機に、最近興味を持って触っているTensorFlow / TF Learn(skflow) の話をします。

背景

2016.06.27にTensorFlow v0.9がリリースされています。

「モバイルサポートが充実したよ〜」が今回のウリなのですが、v0.8から本体に梱包されてるTF Learn(skflow)において、 シンプルなDeep Neural NetworkモデルのClassifierを扱うときに便利なDNNClassifier周辺で 罠が多いことが分かったので備忘録としてメモを残しておきます。 ちなみに、下記のコードや調査はすべてTensorFlow Learn(TF Learn)ベースのものです。 いろいろな罠の話をしていますが、TensorFlow本体の話では無いのでご注意ください。

結論からいうとv0.9は実用段階では無さそうです。

v0.9はmodelをsave/restoreできない

いきなり致命的な問題です。要するに学習したmodelを使いまわせないというもの。どうしてこうなったのか。。 当然のように、IssueやStackOverflowでは同様の質問が乱立しています。

これはv0.8まであったTensorFlowDNNClassifierがv0.9からDeprecatedになって DNNClassifierの利用を推奨される流れで入ったバグのようです。問題を整理すると、

  • v0.9のDNNClassifierTensorFlowDNNClassifierにあったsave, restoreメソッドが落ちてる
  • v0.9のTensorFlowDNNClassifiersave, restoreメソッドはv0.8から引き続き生きている
    • ただし、v0.9のTensorFlowDNNClassifier.saveはcheckpointファイルを作るものの、modelファイルを保存しないバグがある
    • 結果としてv0.9のTensorFlowDNNClassifier.saveの結果をrestoreすることができない
    • 詰む

というわけで、modelのsave, restoreをする必要がある場合、2016/07/18時点ではv0.8の利用が必要になります。 ただし、いろんなIssueで話題になってるので、この問題は近いうちに修正されるでしょう。

v0.9のDNNClassifierはパフォーマンスが悪い

じゃぁsave, restoreはおいといて、ひとまずコードをTensorFlowDNNClassifierからDNNClassifierに 移そうか、、と思うのですが、v0.9のDNNClassifierはパフォーマンスがかなり悪いです。

DNNClassifier, TensorFlowClassifierで学習とテストデータ、およびstepを固定し、 fitにかかる時間とclassification_reportを計測してみると以下のような結果に。

DNNClassifier

               precision    recall  f1-score   support
            0       0.77      0.96      0.86      4265
            1       0.86      0.45      0.59      2231
  avg / total       0.80      0.79      0.76      6496
  
  elapsed_time: 7359.658695936203 [sec]
  

TensorFlowDNNClassifier

               precision    recall  f1-score   support
            0       0.77      0.92      0.84      4265
            1       0.75      0.47      0.58      2231
  avg / total       0.76      0.76      0.75      6496
  
  elapsed_time: 68.16537308692932 [sec]
  

まず、DNNClassifierTensorFlowDNNClassifierと比較してprecisionが10%ほど向上。 おそらくデフォルトのハイパーパラメータが一部異なるのでしょう。原因は不明確ですが、ひとまず結果が良くなる分はまだ良いです。

問題は、elapsed_timeが68秒から7359秒とハチャメチャに長くなってる点。なんだこれは。。 これだと使い物にならなさすぎるので、Stackoverflowに投げてみたものの、2016/07/18時点ではまだ回答はありません。

このとおり、TensorFlowDNNClassifierは近い将来DNNClassifierに乗り換える必要があるものの、 このパフォーマンス差はだと乗り換えは辛いです。100倍以上遅くなってるけど、これどうなるんでしょ・・・? ちなみに、DNNClassifierは、CPU使用率もTensorFlowDNNClassifierと比較すると20%くらい高くて、 正直何もいいことが無い印象です。

v0.8のTensorFlowDNNClassifierはv0.9と比較すると遅い

これまでの通り、v0.9は辛い状態かつmodelをsaveできない状態なので、 v0.9でチューニングしたハイパーパラメータでv0.8を利用してmodelをsaveさせることにします。

ところが、v0.8のTensorFlowDNNClassifierはv0.9と比較すると 約3倍遅い結果に。

つまり、v0.9では実はTensorFlowDNNClassifierはDeprecatedになりながらも 内部では全体的にパフォーマンスが向上してるようですね。 もう、ここまでくるとDeprecaedにするのもやめてくれ、、、と思い始めます。

v0.8のTensorFlowDNNClassifierはOptimizerを指定しているとmodelをsaveできない

v0.8でsave/restoreの調査を進めてて気づいた問題なのですが、

  optimizer = tf.train.AdagradOptimizer(learning_rate=learning_rate)
  classifier = learn.TensorFlowDNNClassifier(hidden_units=units, n_classes=n_classes, steps=steps, optimizer=optimizer)
  classifier.fit(features, labels)
  classifier.save(model_dir)
  

こんなかんじのコードをv0.8で実行すると、最後のsave時にJSONのシリアライズに失敗して ValueError("Circular reference detected")が出てコケます。 ちなみにsaveを呼び出さない場合はコケないのでこれも地味に辛いです。

回避方法としては、「Optimizerは使わない」を選ぶしか無いのかな。この場合、learning rateを調整できないのが辛いですね。

  classifier = learn.TensorFlowDNNClassifier(hidden_units=units, n_classes=n_classes, steps=steps)
  

ちなみにv0.9ではOptimizerを指定したTensorFlowDNNClassifiersaveを呼び出してもエラーにはなりません。 ただし、modelデータの保存もできてないので、このバグが修正されたときにOptimizerのバグも治っているかどうかは不明です。

まとめ

トラップだらけなのですが、modelのsave/restoreが必要な場合、

  1. v0.8 + TensorFlowDNNClassifier を利用
  2. save/restoreのバグが修正されるであろう、v0.9.1相当のリリースを待つ
  3. DNNClassifierのパフォーマンスが上がってたらTensorFlowDNNClassifierから乗り換える。上がってなかったらしばらく使う

のような感じでしょうか。

まだまだTensorFlowの知識は少ないので、誤った情報がある場合はぜひ教えていただきたいです。