Let's encrypt で HTTP/2 なブログを運用する

世はまさに総 HTTPS 時代!ということで、このブログも長らくほったらかしにしていたので、たまにはなんかしないとなと HTTP/2 化をやってみます。

まずは HTTPS を使えるようにする

HTTP/2 は現状 HTTP/2 over TLS しかサポートされていないため、まともに使おうと思うと HTTPS に対応しなければなりません。が、 SSL 証明書を購入するとなるとお金がかかります(正直1万円程度だからさっと買っても良いんだけど)。

んじゃあ無料で使える SSL 証明書ってないのか、というと、現代では Let’s encrypt という無料で証明書を発行してくれるサービスがあります。めっちゃくちゃ便利なので、ぜひ活用していきましょう。

Let’s encrypt から証明書を手に入れる

Let’s encrypt で証明書を発行する際、認証方式として HTTP-01 もしくは DNS-01 が選択できます。

HTTP-01 を利用する場合、事前に Web サーバーを構築してうんぬん……みたいな処理が必要になるため、どうしても少しだけ手間です。なので、今回は単純にコマンド一発で証明書の発行、保存まで出来る方法を取るべく、 DNS-01 での認証を行います。

DNSimple と DSLimple と dehydrated を利用した自動認証

認証チャレンジを行う部分については、シンプルにことが済む dehydrated を利用します。一個の小さな shell script で作られており、また、フックを利用してさまざまな更新を自動化することが可能です。

zeny.io のドメインは DNSimple で運用され、かつコードベースで言えば DSLimple を利用した DSL で管理されているので、自動化も簡単だろう、ということで DNS-01 方式を選びました。

dehydrated の hook script を組む

dehydrated は フックする shell script を追加することで、認証時にコマンドを挟み込む事ができます。

$ ./bin/dehydrated -c -d zeny.io -k hook.sh

↑ にかかれている hook.sh がまさにそれです。このシェルスクリプトに DSLimple の呼び出しを挿入することで、ドメインの自動更新を行わせます。

#!/usr/bin/env bash

function deploy_challenge {
  echo "==> Deploy challenge"

  # ここで dehydrated から渡された値を環境変数にセット
  export ACME_DOMAIN="${1}" ACME_TOKEN_FILENAME="${2}" ACME_TOKEN_VALUE="${3}"

  bundle exec dslimple apply -y
  sleep 5
}

function clean_challenge {
  local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"
  echo "==> Clean challenge"

  # clean 時は何もセットせずに実行
  bundle exec dslimple apply -y
  sleep 5
}

function deploy_cert {
  local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
  echo "==> Deploy cert"

  # 新しい証明書が発行されたときはこの関数が呼ばれるので、アップロードなどはここで
}

function unchanged_cert {
  local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
  echo "==> Unchanged cert"

  # 証明書に変更がなかったときの関数。特になにもすることがない?
}

HANDLER=$1; shift; $HANDLER $@

ここでの肝は ACME_DOMAINACME_TOKEN_VALUE を環境変数としてセットした状態で DSLimple を呼び出すことです。後に改造する DSLimple の DSL のときに、その環境変数を見て、 DNS レコードを操作します。

また、新たに SSL 証明書が発行された場合は deploy_cert 関数が実行されるため、忘れずに証明書をどこかに保存するなりするコードを挟み込みましょう。僕の場合はその部分に S3 へのアップロードコードが書いてあります。

DSLimple で自動的に ACME challenge レコードを追加する

DNS-01 での認証の場合、指定したドメイン + _acme-challenge の TXT レコードに先程 dehydrated から渡された ACME_TOKEN_VALUE を設定してやる必要があります。

また、 clean 時にそのレコードを消しておかないと認証に失敗するため、 ACME_* な環境変数がなければ、削除されるように書いておく必要もあります。ということでこんなコードを書きました。

domain 'zeny.io' do
  # ... 他のレコード群 ...

  if ENV['ACME_DOMAIN'] =~ /zeny\.io\Z/
    subdomain = ENV['ACME_DOMAIN'].sub(/\.?zeny\.io\Z/, '')

    parts = ['_acme-challenge', subdomain].reject(&:empty?)
    txt_record(parts.join('.'), ttl: 1) { ENV['ACME_TOKEN_VALUE'] }
  end
end

ACME_DOMAIN から最後の zeny.io 部分を取り除き、先頭に _acme-challenge を追加する、といった具合です。この形のコードにしておくことで zeny.io で認証しても www.zeny.io で認証しても sub.domain.www.zeny.io で認証しても、問題なく実行できるようになります。

clean_challenge の際には環境変数が設定されていませんから、単純に dslimple apply -y で削除できる、という点でも素敵です。

あとはこれらを CI 上に設置して、毎日実行にするだけで

かんせーい!といった具合になります。僕の場合は Circle CI 上で実行させながら、 Google App Script で毎日朝9時ぐらいに API 経由で実行を kick する、といったようなことをしています。

なぜ毎日実行するかというと、 Let’s encrypt が発行してくれる証明書は息が短く、 90 日程度で(dehydrated のデフォルトは 30 日)失効してしまいます。なので、更新忘れて失効した!?といったトラブルをなくすべく、毎日更新にしています。

protip dehydrated の使い方がよくわからん!

dehydrated のリポジトリ内にあるドキュメントを参考にすると、うまくいくかと思います。

もしくは、 dehydrated が letsencrypt.sh だった時代に書かれたブログなどもおすすめです。

protip プライベートキーの長さ

dehydrated はデフォルトで 4096 bit の長さでプライベートキーを自動で生成しますが、その長さだと AWS Certificate Manager などでは受け付けてくれません(最長 2048 bit)。

dehydrated 実行時のディレクトリに config という名前でシェルスクリプトを配置し、 KEYSIZE="2048" と記述することで、プライベートキーの長さを変更することが出来るので、覚えておくと良いでしょう。

HTTP/2 サーバーを立てる

Let’s encrypt から自動で SSL 証明書が手に入るようになったのは万々歳ですが、やりたいのは HTTPS 化ではなく、 HTTP/2 化なので、それに対応した Web サーバーを立てるなり、別の Web サービスを利用するなりの対策が必要になります。

h2o を利用する場合

h2o の設定

h2o を利用して HTTP/2 化を進めます。

今回 HTTP/2 化したいのは、このブログ、ということで、特に動きのあるサイトではありません。単純な静的 HTML だけなので、簡単に設定ファイルを記述してしまいます。

hosts:
  "*:80":
    listen:
      port: 80
    paths:
      /:
        redirect:
          status: 301
          url: "https://zeny.io/"
  "*:443":
    listen:
      port: 443
      ssl:
        key-file: /secret/certs/privkey.pem
        certificate-file: /secret/certs/fullchain.pem
    paths:
      /:
        header.set: "Strict-Transport-Security: max-age=31536000; preload; includeSubdomains"
        header.merge: "Cache-Control: max-age=86400, must-revalidate"
        file.dir: /var/www/html
        file.dirlisting: OFF
        file.etag: ON
        file.send-compressed: ON

*:80 で HTTP 通信をしてきたら HTTPS へリダイレクト、あとは HSTS ヘッダをつけるのと、 Cache-Control をつけている程度です。 privkey.pemfullchain.pem は dehydrated が出力してくれたものを採用します。

Docker コンテナ化

どういった形で配置するか悩みましたが、 Docker 形式で配置することにしました。幸い、 h2o 本家公認の Docker コンテナがあるので、それを使えば簡単に作ることが出来ます。

FROM lkwg82/h2o-http2-server:v2.0.2

ADD h2o.conf /etc/h2o
ADD certs /secret/certs
ADD build /var/www/html

certs には dehydrated が出力したものを。 build は Middleman が生成している HTML 群です。 Alpine ベースのコンテナなので、ごくごく小さなサイズで生成できるかと思います。

あとはこの Docker コンテナを配置して完了です。

AWS Cloud Front を利用する場合

このサイトはもともと S3 でホスティングされているので、特に手間を考えなければ単純に Cloud Front で配信してしまうのが簡単です。前はできなかったけど、今なら Cloud Front が HTTP/2 に対応しているので簡単です。

AWS Certification Manager に証明書をインポートする

前準備として、 us-east-1 に生成された証明書をアップロードしておく必要があります。

アップロード先のリージョンが us-east-1 なのは、後に Cloud Front で利用するためです。 ELB で利用する場合は、その ELB を構築する先のリージョンに証明書をインポートする必要があります。

Cloud Front の構築

ここはまぁ Web 上に情報がいっぱいあるので、その辺を参考にするのがいいかと思います。

完成!

という具合で、長くかかりましたが、なんとか AWS 上で HTTP/2 配信出来るようになりました……お疲れ様でした。

証明書更新の自動化

Cloud Front で運用できるところまではできたので、次はこれらの証明書の更新を自動化する部分を作ります。

と言いたいところですが、記事が長くなりすぎたので別の記事で……