第2の人生の構築ログ

自分の好きなことをやりつつ、インカムもしっかりと。FIRA60 (Financial Independence, Retire Around 60) の実現を目指します。SE を生業としていますが、自分でプログラミングしながら自分が欲しいと思うアプリケーションを作ることが楽しみです。旅行と温泉、音楽と読書は欠かすことができません。

GCPの無料枠を使った静的ウェブサイトの公開

AWS と比べるとマイナーな Google Cloud Platform (GCP) を使って静的な Web コンテンツを無料で公開するというお話です。
Cloud Storage (AWS の S3 の位置づけのサービス) にファイルを置くだけで簡単に Web で公開できます。設定だけちゃんとやっておけば、ファイルを置くだけで Cloud Storage 自体が Web サーバの役割をはたしてくれます。更に、無料枠で公開しておけばちょっとしたサイトでありましたら、無料で公開できます。

また、特に何もしなくとも(追加料金無しで) CDN が利用可能となっているのがよいですね。(逆にこれがあるのでコンテンツはキャッシュされますので注意が必要です。これについては後半部分のCache-Control でふれます。)

一般公開で閲覧可能なオブジェクトはデフォルトで Cloud Storage ネットワークにキャッシュされるので、特に設定しなくても、Cloud Storage は基本的に Content Delivery Network(CDN)のように機能します。

静的ウェブサイトの例とヒント  |  Cloud Storage  |  Google Cloud

ここでは、以下の4つの作業(+ 1)を行い、storage-test.designrecipe.jp の FQDN のサイトを Google Cloud Platform の Stroage にアップロードした静的コンテンツを使って公開するということを行います。

  1. ドメインの転送
  2. バケットの作成
  3. コンテンツのアップ
  4. インデックスページとエラーページを設定する
  5. 【Appendix】Cache-Control (キャッシュコントール)

基本的な作業内容は以下をベースにしています。

cloud.google.com

作業を行うにあたっての前提

上記の作業を行うにあたって、前提として以下の作業が既に完了していることとします。

1. ドメインの転送

利用したいドメインを c.storage.googleapis.com に CNAME しておく必要があります。
CNAME する要件は GCP のものですが、CNAME の設定自体は一般的な DNS の設定となります。

ここでは、http://storage-test.designrecipe.jp でサイトを公開することにしますので、DNS に以下のような設定を行います。(設定方法は利用しているサービスで異なるかもしれませんので、各種サービスに倣ってください。)

cname storage-test c.storage.googleapis.com.

これで、http://storage-test.designrecipe.jp へのアクセスは DNS で http://c.storage.googleapis.com に向き先を変えられ、http://c.storage.googleapis.comをホストするサーバで HTTP Header の Host の値である storage-test.designrecipe.jp の値をみて該当ホストに振り分ける、という処理になっているはずです。(正確には、http://c.storage.googleapis.com も更に CNAME されていますので、「最終的に名前解決されたサーバで、」となります。)

DNS 設定後の情報は以下のようになっています。

$ dig storage-test.designrecipe.jp
...(snip)...

;; ANSWER SECTION:
storage-test.designrecipe.jp. 120 IN    CNAME   c.storage.googleapis.com.
c.storage.googleapis.com. 1096  IN      CNAME   storage.l.googleusercontent.com.
storage.l.googleusercontent.com. 238 IN A       172.217.27.80

...(snip)...

2. バケットの作成

Cloud Storage はオブジェクト(テストファイルや画像ファイルなどのこと)ストレージです。そのオブジェクトを格納するための入れ物として、バケットというものをまず用意する必要があります。

単にファイルのストレージとして利用する場合であれば、任意のバケットの名前を付ければいいのですが、今回 Web で公開し、かつ、FQDN (ホスト名) でアクセスできるようにします。そのためには、FQDN にどのバケットが関連するのか GCP 側で知る必要がありますので、バケット名には、DNS に設定した CNAME に一致する名前をつける必要があります。

今回、storage-test.designrecipe.jp を使っておりますので、この名前をバケット名に使います。

$ gsutil mb \
         -p project123 \
         -l us-central1 \
         -c regional \
         gs://storage-test.designrecipe.jp

上記のコマンドですが、指定しているオプションは以下の通りです。

  • -p はプロジェクトの名称を適切に設定します。
  • -l はバケットを作成するロケーションです。基本的には好きなところを選べばいいのですが、無料枠を使いたい場合には、「北バージニア [us-east4] を除く米国リージョン」を指定します。
  • -c はストレージクラス(ストレージタイプ)のことで、Multi-Regionalがベターだと思うのですが、無料枠を使いたい場合には、「Regional」を指定する必要があります。
  • CNAME したホスト名(FQDN) storage-test.designrecipe.jp を最後に gs://storage-test.designrecipe.jp と指定します。

ちなにに、以下は2019/05現在の Cloud Storage の無料枠の定義です。

5 GB の Regional Storage(1 か月あたり、北バージニア [us-east4] を除く米国リージョンのみ)

5,000 回のクラス A オペレーション(1 か月あたり)

50,000 回のクラス B オペレーション(1 か月あたり)

1 GB の北米から全リージョン宛ての下りネットワーク(1 か月あたり、中国とオーストラリアを除く)

さて、ここで注意点があります。バケットの名称に FQDN を使用する場合には、使用されたドメインの所有確認が行われている必要があります。所有確認が行われていない場合、以下のようなエラーをみることになりますので、事前に設定を行っておきます。

$ gsutil mb -p project123 -l us-central1 -c regional gs://storage-test.designrecipe.jp
Creating gs://storage-test.designrecipe.jp/...
AccessDeniedException: 403 The bucket you tried to create requires domain ownership verification. Please see https://cloud.google.com/storage/docs/naming?hl=en#verification for more details.

cloud.google.com

3. コンテンツのアップ

Web コンテンツをアップします。

まずは、index.html404.html を用意します。

$ echo "Hello, GCP Storage." > index.html
$ echo "not found page." > 404.html

先程作成したバケットにコンテンツをアップします。

$ gsutil cp \
         -a public-read \
         ./index.html \
         ./404.html \
         gs://storage-test.designrecipe.jp
Copying file://./index.html [Content-Type=text/html]...
Copying file://./404.html [Content-Type=text/html]...
- [2 files][   36.0 B/   36.0 B]
Operation completed over 2 objects/36.0 B.

cp の -a オプションを使って public-read を与えています。 -aオプションは、canned_acl と言われるもので、バケット、もしくは、オブジェクトに ACL をセットする簡単な方法です。

 CANNED ACLS
   The simplest way to set an ACL on a bucket or object is using a "canned
   ACL". The available canned ACLs are:

値の public-read はその名の通り、どのユーザにも公開する権限です。以下は、gsutil help acls の出力からの抜粋ですが、必要なことがよく纏められて書いてありますので、そのまま貼り付けておきます。 (NOTEで触れられていますが、クドいですが、Cache-Control は気にしておく必要があります。後半部分に記載します。)

  public-read
    Gives all users (whether logged in or anonymous) READ permission. When
    you apply this to an object, anyone on the Internet can read the object
    without authenticating.

    NOTE: By default, publicly readable objects are served with a Cache-Control
    header allowing such objects to be cached for 3600 seconds. If you need to
    ensure that updates become visible immediately, you should set a
    Cache-Control header of "Cache-Control:private, max-age=0, no-transform" on
    such objects. For help doing this, see 'gsutil help setmeta'.

    NOTE: Setting a bucket ACL to public-read will remove all OWNER and WRITE
    permissions from everyone except the project owner group. Setting an object
    ACL to public-read will remove all OWNER and WRITE permissions from
    everyone except the object owner. For this reason, we recommend using
    the "acl ch" command to make these changes; see "gsutil help acl ch" for
    details.

Web のコンソールでも確認してみます。

f:id:dr_taka_n:20190513175426p:plain

問題ないようですので、実際にhttp://storage-test.designrecipe.jp/index.htmlでアクセスしてみます。

f:id:dr_taka_n:20190513175527p:plain

OK です。

ちなみに、この状態でファイル(index.html)を指定せずに、http://storage-test.designrecipe.jp/ でアクセスするとどうなるでしょうか。

f:id:dr_taka_n:20190513175822p:plain

Access Denied になっています。インデックスのページを設定します。

4. インデックスページとエラーページを設定する

gsutilweb サブコマンドを使います。

$ gsutil web set -m index.html -e 404.html gs://storage-test.designrecipe.jp
Setting website configuration on gs://storage-test.designrecipe.jp/...

-m は、main_page_suffixですので、メインページの指定です。-eerror_page ですので、エラー時(404)のページを指定します。

先程と同様に http://storage-test.designrecipe.jp/ でアクセスします。

f:id:dr_taka_n:20190513182144p:plain

OK です。今回は Access Denied ではなく、ちゃんと index に指定したページが表示されました。

適当な存在しないページの URI (http://storage-test.designrecipe.jp/abc.html) でアクセスします。

f:id:dr_taka_n:20190513182201p:plain

こちらも OK です。ちゃんと 404 のページが表示されています。

Web サーバを準備することなしにあっという間に Web コンテンツを公開することができました。スバラシイですね。

5. Cache-Control (キャッシュコントロール)

さて、先ほどから後ほどとしていた Cache-Control についてです。

help の記載にもありましたように、特に指定を行わずにコンテンツをあげた場合には、3600 秒、つまり1時間は同じ URI でアクセスを行った場合、仮にサーバ側のコンテンツを変更したとしても、キャッシュされた変更前のコンテンツが利用者には表示されます。

キャッシュはやった方がお得(キャッシュが効いている間はレスポンスに本文が含まれない、または、利用者の近い場所のサーバからコンテンツが配信されるなで描画が速くなります)ですが、ブラウザ、または、CDN などの中間サーバに一度キャッシュが保存された場合には、その賞味期限が切れるまで特に何もしなければコンテンツは昔のものがそのまま表示されてしまうので、コンテンツの更新時の運用はちょっと考える必要があります。

ここでは、単純にキャッシュしたくなければ、"Cache-Control:private, max-age=0, no-transform" を指定するのも手ですが、public のまま、max-age を短くして試してみます。

このキャッシュが何で制御されているかといいますと、HTTP header の中の情報で制御されています。キャッシュはなかなか奥が深く、しっかり書くとこの記事で全ては書けなくなってしまうので、ここでは Cache-Control だけを扱います。Cache-Controlを適切に記載することでキャッシュポリシーの規定を行えます。
この情報(正確にはこの情報とそれに付随してGCPが付加しているその他のheader)を見て、ブラウザ、間に入っている CDN、プロキシなどはキャッシュのコントロールを行います。一度アクセスを行った後は一定期間 Web へのアクセスを行わずローカルのファイルを参照したり、初めてのアクセスであってもオリジンとなるサーバまでコンテンツを取りに行かず、CDN などに残されているキャッシュで折り返したりします。通常、public がついていれば、CDN などでキャッシュを行うことを意図しており、private の場合は、ブラウザでのキャッシュ(逆に CDN などはキャッシュしない)を指定することになっています。

デフォルトで設定される該当の Cache-Control の値は以下の値です。

$ curl -v -s http://storage-test.designrecipe.jp 2>&1 | grep -e Cache-Control -e Expires -e Last
< Cache-Control: public, max-age=3600
< Expires: Mon, 13 May 2019 23:18:16 GMT
< Last-Modified: Thu, 14 Feb 2019 11:15:24 GMT

これを変更するためには、HTTP Header の情報を cp コマンドの前に追加します。

$ gsutil -h "Cache-Control:public,max-age=300" \
         cp -a public-read test.html \
         gs://storage-test.designrecipe.jp

上記では、max-age 300秒(5分)としていますので、リクエストの時刻を起点にレスポンスを300秒キャッシュに保存し、再利用することをできることを示しています。

$ curl -v -s http://storage-test.designrecipe.jp/test.html 2>&1 | grep -e Cache-Control -e Expires -e Last
< Cache-Control: public,max-age=300
< Expires: Mon, 13 May 2019 22:52:26 GMT
< Last-Modified: Mon, 13 May 2019 22:41:29 GMT

ちゃんと反映されています。

さて、今回時間をベースにキャッシュをコントロールしますが、ここで重要な HTTP header は、Last-Modified Response header と、If-Modified-Since Request header です。

Last-Modifiedはサーバにあるコンテンツの最終更新日時の時間です。一度そのコンテンツを取得していれば、同じコンテンツのリクエストの場合(同じURI)には、前回取得したLast-Modifiedのコンテンツの最終更新日時の値をIf-Modified-Since Request header に入れてリクエストします。サーバ側では、そのIf-Modified-Sinceの日時とサーバにあるコンテンツの最終更新日時を比較し、最終更新日時が更新されていれば、Response code 200 を返しつつ、実体の新しいコンテンツを返します。最終更新日時が更新されていない場合は、Response code 304 (Not Modified) を返し、実体は返しません。(本文は返しません。)

例をみてみます。以下では、

  • Response code は 200 (実体が返っています)
  • Response の Last-Modified header は "Mon, 13 May 2019 13:55:31 GMT"
    • サーバにあるコンテンツの最終更新日時です。
  • Response の Expires は、"Mon, 13 May 2019 14:00:49 GMT"
    • 実はこれ、リクエストのあった日時に Cache-Control で指定した max-age の長さが加算されたものになっています(リクエストの日時が起点)。GCP 側で Cache-Control の値を見て勝手に付与しているようです。この日時までは仮にコンテンツに更新があっても新しいコンテンツは返されず、キャッシュの情報が参照されます。
      (間違ってコンテンツをアップロードした時などに、すぐにコンテンツをアップロードし直しても反映されない場合などはこの設定が効いている可能性があります。)
  • Request の If-Modified-Since header は "Mon, 13 May 2019 13:39:01 GMT"

f:id:dr_taka_n:20190514072620p:plain f:id:dr_taka_n:20190514072737p:plain

もう一度リクエストを行ってみると、

f:id:dr_taka_n:20190514072927p:plain

  • Response code は 304 (Not Modified)
    • If-Modified-Since の日時とサーバのコンテンツの日時を比較し、変更がないので、変更がないよ、というレスポンスだけを返し、実体は返しません。
  • Request の If-Modified-Since header は "Mon, 13 May 2019 13:39:01 GMT" のまま
    • コンテンツは変わっていないので、先に受け取った Last-Modifiedの値のままです。

サーバでコンテンツの更新が無い限り、304 を返し、コンテンツの実体は返しません。

CDN で管理されるキャッシュはその CDN の仕様によって若干変わるところはあると思いますが、GCP の場合は、

  • Cache-Controlpublic を指定した場合、max-age に与えられた時間は初回のリクエストを起点にキャッシュされる。
  • その時間を過ぎた後は、If-Modified-Since の値をチェックし、コンテンツに変更がなければ 304 を返却。変更があれば、200 で実体を返す。

という一般的な動作になっているようです。

このことを理解した上でコンテンツを運用しないとすぐに更新したいコンテンツなどがあった場合には、意図したコンテンツを利用者に見せられない状態が発生することになります。

キャッシュに関しては以下のページに非常によく纏まっています。

developers.google.com