Docker上で走るNuxt.JS + SSR のサイトをVarnishを使って爆速化した話。

Docker

あるコーポレートサイトをリリースしたのですが、最初のレスポンスが返ってくるのがちょっと遅いのでなんとかしたい。

サイトの構成は以下のとおり。

  • サーバはVPS( 2GB: 1 CPU, 50GB Storage )
  • Dockerで構成(いろいろ拡張の予定があるので、Docker swarmを利用)
  • サイトはHeadless CMS + Nuxt.JS + SSRで構成
  • /blog にWordpress(ブログ)を設置

尚、Headless CMS は、これとは別のサーバにインストールしてある。利用したCMSは、cockpitというOSSを採用。Cockpitは、いろいろカスタマイズが効いてかゆいところに手が届くので、マジでお勧め。

※cockpit公式のDockerイメージは、アップロードするファイル名に日本語が入っていると、日本語部分がバッサリ削除されます。対処法はこちら

Nuxt.JS + SSR を採用した理由は、画像やテキストを思い立った時にいつでも変更出来て、尚且つデザインにもしっかりこだわりたいという要望に応えるため。サイト上のほぼすべてのテキストと画像を差し替え可能にしてみた。

ローカルの開発環境では、そこそこのレスポンスだったので、行けるかなと思ったけど、実際にVPSで動かしてみたら、ちょっと重い。レスポンスが返ってくるまでに、2秒ちょいかかる。

そんなにアクセスが集中するタイプのサイトではないけれど、ApacheBenchで計測してみたら、1秒あたり0.9レスポンスしか返せない。Nuxtなので、最初の1ページさえ読み込まれてしまえば、あとはサクサクなんだけど、Googleからの評価も気になるし、やはり出来るだけ速くしたい。

SPAでなくSSRを採用した理由は、OGPまわりをちゃんとしたかったのが理由なので、ヘッダーまわりだけサーバーサイドで処理して、あとはクライアントサイドでHeadlessCMSから取得させてみようかと思ったりもしたけど、そんなことしても、ユーザーは結局画面が組みあがるのを待つことになるので、本質的な解決にはならないから却下。

という訳で、キャッシュを使うことにした。

キャッシュは、Nginxの設定をごにょごにょするのは面倒くさい好きでないし、そもそも今回のサイトではNginxを使っていないので、Varnishを使うことにした。

Varnishは、速いのはもちろんだけど、動的にキャッシュをクリアしたりできる点がとても便利。特定のパスはキャッシュを使わないよう設定したり、指定したページのキャッシュだけをクリアしたり、全ページクリアなんてこともできる。

今回は、キャッシュ期間をガッツリ長く設定しておき、CMSで内容を編集した後に、キャッシュを全クリアして更新するというシンプルかつ豪快(手抜きか?)な仕様にしてみた。

Varnishの導入はとっても簡単、Dockerならね。

という訳で、Docker(Docker Swarm)の設定ファイルはこんな感じに。

version: "3.4"

services:

  traefik:
    image: traefik:1.7-alpine
    command:
      - "--logLevel=error"
      - "--entryPoints=Name:http Address::80 Redirect.EntryPoint:https"
      - "--entryPoints=Name:https Address::443 TLS"
      - "--defaultentrypoints=http,https"
      - "--web"
      - "--web.address=:8080"
      - "--acme"
      - "--acme.storage=certs.json"
      - "--acme.entrypoint=https"
      - "--acme.httpchallenge.entrypoint=http"
      - "--acme.onHostRule=true"
      - "--acme.email=fugafuga@hogehoge.com"
      - "--docker"
      - "--docker.endpoint=unix:///var/run/docker.sock"
      - "--docker.swarmMode"
      - "--docker.watch"
    ports:
      - 80:80
      - 443:443
    networks:
      - overlay
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./certs/certs.json:/certs.json
    deploy:
      placement:
        constraints:
          - node.role == manager
      restart_policy:
        condition: on-failure

  varnish:
    image: varnish:6.2
    networks:
      - overlay
    volumes:
      - ./varnish/default.vcl:/etc/varnish/default.vcl:ro
    tmpfs:
      - /usr/local/var/varnish:exec
    deploy:
      labels:
        - traefik.enable=true
        - traefik.backend=varnish
        - traefik.frontend.rule=xxxxxxx.com
        - traefik.frontend.entryPoints=https
        - traefik.frontend.passHostHeader=true
        - traefik.docker.network=overlay
        - traefik.protocol=http
        - traefik.port=80

  web:
    image: hogehoge.com:5000/fugafuga/web:latest
    networks:
      - overlay
    deploy:
      restart_policy:
        condition: on-failure

  wordpress:
    image: wordpress:php7.4-apache
    networks:
      - overlay
      - internal
    volumes:
      - ./wordpress:/var/www/html/blog
    working_dir: /var/www/html/blog
    environment:
      WORDPRESS_DB_HOST: xxxxx
      WORDPRESS_DB_USER: xxxxx
      WORDPRESS_DB_PASSWORD: xxxxx
      WORDPRESS_DB_NAME: xxxxx
    deploy:
      restart_policy:
        condition: on-failure
      labels:
        - traefik.enable=true
        - traefik.backend=wordpress
        - traefik.frontend.rule=Host:xxxxxxx.com;PathPrefix:/blog
        - traefik.frontend.entryPoints=https
        - traefik.frontend.passHostHeader=true
        - traefik.docker.network=overlay
        - traefik.protocol=http
        - traefik.port=80

  mysql:
    image: mysql:5.7
    networks:
      - internal
    volumes:
      - ./mysql:/var/lib/mysql
    environment:
      MYSQL_DATABASE: xxxxx
      MYSQL_USER: xxxxx
      MYSQL_PASSWORD: xxxxx
      MYSQL_ROOT_PASSWORD: xxxxx
    deploy:
      restart_policy:
        condition: on-failure

networks:
  internal:
    internal: true
  overlay:
    external: true

※実際には、各コンテナに割り当てるリソース( メモリやCPU)を制限する設定もしてますが、まだチューニング中なのでそこは省略。

ファイルの永続化や設定ファイル設置のため、以下のディレクトリやファイルをホストOSにマウント。

  • ./certs/certs.json ※LetsEncryptの証明書ファイル
  • ./varnish/default.vcl ※Varnishの設定ファイル(後述)
  • ./wordpress ※Wordpress
  • ./mysql ※MySQL

※certs.jsonは空のファイルを用意しておく必要があります。はまりポイントはパーミッション。確か500にしておく必要があったと思う。
※走らせる前に、Dockerコマンドで「overlay」という名前のoverlayネットワークを作っておく必要があります。
※wordpress、mysqlは空のディレクトリを用意しておきます。パーミッションは環境に合わせて適宜設定してください。

webというラベルの付いたコンテナは、nodeイメージをベースにしてNuxt.JSでビルドしたウェブサイトのイメージファイル。「hogehoge.com:5000/fugafuga/web:latest」なんてDockerイメージファイルは存在しないので念のため。

サーバの入り口のリバースプロキシとしては、NginxやApacheではなく、Traefikを使って、ドメインやパスによる振り分け、SSL関係を手間なく簡単に済ませる構成とした。

Traefikが超絶楽すぎるので、もう一生NginxやApacheの設定ファイルは触りたくない(と言いつつも、wordpressコンテナにはApacheが使われてたりして)ただし、処理能力はNginxより若干落ちる。でもここがボトルネックになることはそうないよね。

本題から外れるけど、Traefikは本当に便利。例えばドメインの設定は上記のdocker-compose.yml内に書くだけでOK。振り分け先のコンテナのlabelに「traefik.frontend.rule=xxxxxx.com」とドメインを指定してコンテナを起動すれば、あとはTraefikが勝手にLetsEncryptの証明書を取得して、ドメイン宛のアクセスをそのコンテナに振り分けてくれる。コンテナを複数用意して、それぞれにドメインの設定を記述するだけでマルチドメインのサーバが出来上がってしまう。パスなどの条件(正規表現も使えるし、パスの置換なども出来る)によって、振り分け先のコンテナを変えることも可能。今回は、/blogから始まるパスは、Wordpressコンテナに振り分けている。そもそもがロードバランサーなので、同じコンテナを複数起動すれば負荷の分散もしてくれるし、標準でsocketにも対応してるし、マジで最高。

※Traefikは、managerノードでなく、workerノードで走らせるほうが安全ですが、今回はVPS1台構成なのでやむ無くmanagerノードで走らせてます。

(ちょっとTraefikに熱くなりすぎた…)

先程の設定ファイルについて、簡単に説明すると、Traefikがすべての入力を受け持ち(開放するポートは、80と443)、パスが/blogに先頭一致するアクセスは、wordpressコンテナに振り分け、それ以外はvarnishコンテナに振り分けるよう設定している。

そして、Varnishの設定ファイル(default.vcl)は、以下のとおり。

vcl 4.0;

backend default {
  .host = "web";
  .port = "3000";
}

sub vcl_recv {

  // キャッシュの全クリア
  if (req.url == "/refresh_all_zzzzzzzzzzzz") {
    ban("req.url ~ ^/.*");
    return(synth(200, "Banned"));
  }

  // /sys/へのアクセスはバックエンドへ流す
  if (req.url ~ "^/sys/") {
    return (pipe);
  }

}

sub vcl_backend_response {

  // キャッシュ期間を1週間とする
  set beresp.ttl = 1w;

}

javaScriptチックな文法だけど、微妙に違っていて、いろいろはまりどころが多いけど、なんとなくやっていることが分かるのでは。

バックエンドとして、webコンテナ(node.js + Nuxt.js)を指定する。ホスト名とポートを環境に合わせて指定。(Dockerは、コンテナの名前をそのままホスト名として指定できるので便利)

sub vcl_recv に、Varnishに届いたリクエストを、どう対処するかを記述する。今回は、ブラウザから簡単にキャッシュを全クリアできるよう、https://xxxxx.com/refresh_all_zzzzzzzzzzzz という秘密のURLを用意してみた。ブックマークしておいて、呼び出せばキャッシュが全クリアされるという仕様。「ban(“req.url ~ ^/.*”)」がその命令で、ルート以下すべてのパスのキャッシュが無効になる。(Curlなどが使えるクライアントさんの場合は、Method BAN などを割り当てる方が安心だと思うけど、さすがにCurl使いこなすクライアントさんはいないので)

さらに、キャッシュしては困る呼び出し(問合せのPOSTなど)は、Varnishを迂回させてやる。今回は、/sys/以下の呼び出しの場合、pipeメソッドを呼び出して、バックエンドにそのまま渡す設定に。
※pipeした処理は、そのレスポンスもVarnishをスルーして返却される。

vcl_backend_responseは、バックエンドからのレスポンスに対する追加の処理を記述する。レスポンスに、ttlを設定してやればキャッシュ期間を好きに設定できる。例えば「1w」と設定してやれば、Varnishに1週間キャッシュされるようになる。パスや拡張子によって、期間を変えることなんかも出来るので、用途に合わせて適切に設定して欲しい。

ここはハマりポイントなんだけど、Varnishの設定方法についてググると、古いバージョンの情報ばかりが出てくる。現在のVarnish6とは設定方法が異なるので注意が必要。公式のドキュメントはちょっと情報が不足気味なので、公式Githubの issue なんかを覗く方が有用な情報が得られたりするのでお勧め。

あと、webコンテナについては、node.jsのイメージをベースにNuxtでbuild済みイメージファイルを用意した。開発中は、ビルドに 環境変数を反映させるために、コンテナ起動時にbuild & startさせていたのだけど、buildに結構時間がかかるので、そのまま本番には使えない。サーバを再起動する度にビルドが走って2~3分何も表示されないのはさすがに困る。本番用には、Dockerfileに環境変数埋め込みで(バッドノウハウだね!)buildしたものを用意し、 コンテナを立ち上げればすぐにサイトが表示されるようにした。 (安全のため、非公開のGitBacketとDockerレジストリを用意して自動ビルさせてる)

docker stack deploy -c docker-compose.yml web

コマンド一発でサーバの設定&公開完了。Dockerはほんとに便利。
ちなみに僕は、VPSはLinodeがお気に入り。 2GB、1 CPU、50GB Storageなら約1000円/月程度とお財布にやさしくて好き。

そして、肝心のVarnish導入の効果はどうかというと、Apache benchで計測してみたところ、軽く10倍以上速くなった。圧倒的勝利。

同時接続数100、総アクセス数1000などという、おそらくこのサイトが一生経験することの無いであろう負荷を掛けてみたけど、さらりとこなしてくれた。カッコいい。まあ。Varnishがメモリ上のキャッシュデータを返却するだけなので、速くて当然なんだけどね。

ちなみにこの構成だと一番負荷がかかるのはTreafikなので、ここがボトルネックになる。耐性を上げたければTraefikを別ノードに移動してパワーアップしてやればOK。まあ必要ないと思うけど。

あ、ひとつ注意が必要なのは、nuxt.config.jsで、「 modern : true 」としてる場合はこの方法を使ってはいけない。この設定でNuxtは、ブラウザに合わせてモダンバンドルとレガシーバンドル を分けて出力するので、キャッシュしたら出し分けが機能しなくなってしまう。 IE11を切るか、きっちりPolyfillするかした場合にのみ使えるので注意いただきたい。

ちなみに今回は、IE11をバッサリ切った。(とても気持ちいい)

おわり!

コメント

タイトルとURLをコピーしました