Google App Engineで普通のWebサイトを格安にホスティングする

# 第2世代ランタイムへの移行（2024/02/04 追記）

2017-2018年に本記事で紹介した [App Engine スタンダード環境第1世代ランタイム（PHP 5.5他）のサポートは2024年1月30日に終了](https://cloud.google.com/appengine/docs/standard/lifecycle/support-schedule?hl=ja#php)しており、本記事の内容で新たにデプロイすることはできません。

後継となるスタンダード環境第2世代ランタイムの PHP 8.2 への移行の例を記します。[公式の移行ガイドはこちらです](https://cloud.google.com/appengine/migration-center/standard/migrate-to-second-gen/php-differences?hl=ja)。

## ハンドラへのスクリプトの指定方法の変更

各ハンドラで個別の PHP スクリプトを指定することはできなくなり、entrypoint にて指定した PHP スクリプト（以下 `index.php` とします）でのみトラフィックを処理できるようになります。

そのため、従来各ハンドラで指定していた PHP スクリプトを `index.php` から呼び出すように変更します。各ハンドラに指定した `404.php` で返していたエラーも、`index.php` で返すようにします。

```app.yaml
runtime: php82
instance_class: F1
automatic_scaling:
  min_idle_instances: automatic
  max_idle_instances: 1
  min_pending_latency: 3000ms
  max_pending_latency: automatic
entrypoint: serve index.php
app_engine_apis: true
handlers:
- url: /.well-known/(.+)
  static_files: well-known/\1
  upload: well-known/(.+)
- url: /
  script: auto
  secure: always
- url: /(.+\.php)
  script: auto
  secure: always
- url: /((index|contact)\.html)
  static_files: \1
  upload: (.+\.html)
  secure: always
- url: /(.+\.(css|js|jpg|png))
  static_files: \1
  upload: (.+\.(css|js|jpg|png))
  secure: always
```

```index.php
<?php
$path = parse_url($_SERVER['REQUEST_URI'])['path'] ?? '/';
if ($path === '/') {
    require 'index.html'
} elseif (str_starts_with($path, '/vendor/')) {
    http_response_code(404);
} elseif (preg_match('/^\/+([\w.\-\/]+\.php)$/', $path, $matches)) {
    if (str_contains($matches[1], '..')) {
        http_response_code(404);
    } else {
        require $matches[1];
    }
} else {
    http_response_code(404);
}
```

## パッケージの更新

PHP 8.2 に対応するよう、Google Cloud PHP Client を更新します。

```sh
composer update
```

従来の AppEngine サービスの多く（後述の Mail など）は第2世代ランタイムでも利用可能ですが、パッケージのインストールと、`app.yaml`での`app_engine_apis: true`の指定が必要です。[公式のガイドはこちらです](https://cloud.google.com/appengine/docs/standard/php-gen2/services/access?hl=ja)。

```
composer require google/appengine-php-sdk
```

## gs:// ストリーム

POST されたファイルを Cloud Storage に保存している例で、ファイルを`move_uploaded_file()`によりgs:// ストリームに保存していますが、gs:// ストリームはデフォルトでは有効ではなくなったので、明示的に登録する必要があります。

```contact.php
$storage = new StorageClient(['projectId' => $projectId]);
$storage->registerStreamWrapper();
```

## mail() によるメール送信

`mail()`によるメール送信を行っている場合、AppEngine の Mail サービスで処理させるため、明示的に `php.ini` で設定する必要があります。

```php.ini
extension = mailparse.so
sendmail_path = "php ./vendor/google/appengine-php-sdk/src/Runtime/SendMail.php -t -i"
```

## Memcache クラスの変更

Memcache クラスを利用している場合、PHP 組み込みの Memcache クラスではなく、 AppEngine API のクラスを利用するように変更します。

```
use Google\AppEngine\Api\Memcache\Memcache;
```

---

**以下、2017-2018年当時の記事です。現在は当てはまらない内容もあり得ますのでご注意ください。**

# はじめに

## 経緯

静的コンテンツを中心に、PHPによるお問い合わせフォームが付いているような、シンプルかつ実用的なWebサイトを、格安、かつ拡張性・信頼性のある形で提供出来ないかと思い、Google App Engineでのホスティングを試してみました。

## 筆者の環境

* お名前.com
* Windows 10 Pro 64ビット
* Chrome

# 技術選定

## Standard Environment / Flexible Environment

App Engineには、1台あたりのCPUやメモリの構成に既定のものを用いるStandard Environmentと、より細かく設定できるFlexible Environmentがあります。Standard Environmentで対応しているPHPのバージョンは5.5のみですが、Flexible EnvironmentではPHPのより新しいバージョンにも対応しているようです。ただし、Flexible Environmentには無料利用枠が無いので、今回はStandard Environmentを使用することにします。

## データの保存先

App Engineは分散環境なので、PHPスクリプトからローカルファイルへのアクセスはできません。データの保存先としては、以下のような候補があります。

* Cloud Storage
  * いわゆるファイル単位でデータを保存するためのストレージです。Amazon S3と同等のサービスです。PHPからは、専用のAPIの他、file_put_contents()やその他一部のPHP関数でアクセスできます（[App Engineドキュメント参照](https://cloud.google.com/appengine/docs/standard/php/googlestorage/advanced)）。
* Cloud Datastore
  * Key-Value型のNoSQLデータベースです。
* Cloud SQL
  * MySQL/PostgreSQL互換のRDBMSです。

Cloud SQLには無料利用枠が無く、またインスタンスを起動しておくだけで最小でも\$0.0150/時(=\$10.8/月)かかってしまうので、Cloud DatastoreおよびCloud Storageを使うようにしました。

## アプリケーションフレームワーク

以下の理由から、Laravel等のアプリケーションフレームワークは利用しません。

* PHPのバージョンが5.5である点、ローカルファイルへのアクセスができない点から、既存のフレームワークを利用するには一工夫が必要。
* フレームワーク上のいわゆるルーティング等でPHPスクリプトが動くと、App EngineインスタンスのCPU時間が消費されてしまい、コスト増につながる。
* 今回はコストを抑えるため、Google Cloud Platform上のRDBMSは一切利用できない。

## 通知先

Webサイト上でいわゆる投稿があった際の通知先として、まず思いつくのがメールです。メールはPHPの標準のmail()関数や、専用のAPIで送れます。ただ、App Engineの提供するメールサーバは、課金アカウントであっても100通/日までしか送れず、割り当てを増やすこともできないので、実用的ではありません。また、専用APIで送れる管理者向けメールの制限は5000通/日となっていますが、1メールのサイズが16KBまでに制限されています。（[App Engineドキュメント参照](https://cloud.google.com/appengine/quotas)）

本格的にメールを送る場合は、App Engineと提携している[SendGrid](https://cloud.google.com/appengine/docs/standard/python/mail/sendgrid)などを用いる方が良いでしょう。

なお今回は、メール以外の方法で通知を行うことにしました。（本題から逸れるので、実装は省略します。）

## ネームサーバ

無料利用枠はありませんが、Google Cloud PlatformでCloud DNSが提供されています。ドメインレジストラなどが提供する無料のネームサーバを用いても良いのですが、料金がわずかなのと、パフォーマンスや、将来的にAPIでDNSを設定できるメリットから、Cloud DNSを利用することにしました。

# 価格

[Google Cloud Platformの無料利用枠](https://cloud.google.com/free/)、および料金は以下のとおりです。アクセス数の少ないWebサイトでしたら、Cloud DNSの料金が毎月100円以下かかるのみになると思われます。

* App Engine
  * デフォルトで使用されるF1インスタンスを基準として、28インスタンス時間まで無料です。つまり、通常1インスタンスで動作しており、2インスタンスにスケールアウトされる時間が4時間以内なら無料です。
  * App Engineのデフォルトバケットとして利用されるCloud Storageに対して、容量5GBまで無料です。
  * 送信トラフィック1GB/日まで無料です。
* Cloud Storage
  * 容量5GBまで無料です。
  * 書き込み5000回/日、読み取り50000回/日まで無料です。
* Cloud Datastore
  * 容量1GBまで無料です。
  * 書き込み20000回/日、読み取り50000回/日まで無料です。
* Cloud DNS
  * ゾーン毎に\$0.20/月
  * クエリ100万件ごとに$0.40/月

> You might want to use the default bucket which provides the first 5GB of storage for free.
> - https://cloud.google.com/appengine/docs/standard/python/googlecloudstorageclient/setting-up-cloud-storage

# インフラ設定

## Google Cloud Platformへの登録

[Google Cloud Platform](https://cloud.google.com)を開きます。必要に応じて無料トライアルを開始してください。なお、プロジェクトのオーナーや課金管理者は後から変更出来ますので、これから作成する成果物が、ここで使用するGoogleアカウントに永続的に関連づけられるわけではありません。

## デフォルトのプロジェクトの削除

無料トライアルを開始した場合は、デフォルトでプロジェクトが作成されているはずですが、必要なプロジェクトは所望のIDで作成するので、[IAMと管理]-[設定]-[削除]から既存のプロジェクトを削除します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/63ed82cc-09e6-1606-530b-93615d67b765.png)

## プロジェクトの作成

プロジェクトを作成します。プロジェクトには、Google Cloud Platform全体を通じて一意なIDを付与する必要があります。プロジェクトに対して1つ以上のドメイン名が対応づけられることになります。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/959034bc-f3cf-7326-0dbd-3dab10f38f23.png)

プロジェクトを作成すると、ダッシュボードが以下のようになります。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/8cb584c9-dabe-05bc-b429-e0b6d37ff92b.png)

## App Engineアプリの作成

[App Engine]からアプリを作成します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/655abde8-c31e-29b4-444b-40cd9d5a3b14.png)

アプリを動かすリージョンを選択します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/b02d4167-fbc9-b16a-8a83-058942f8d0ce.png)

なお、リージョンにまたがってアプリを配置することはできません。マルチリージョンでのコンテンツ配信などが必要な場合、Cloud Storageなどを併用することになります。

> App Engine is regional, which means the infrastructure that runs your apps is located in a specific region and is managed by Google to be redundantly available across all the zones within that region.
> - https://cloud.google.com/appengine/docs/locations


## Cloud SDKのインストール

[Cloud SDK](https://cloud.google.com/sdk/)をインストールします。gcloudコマンドを実行する際には、管理者として実行する必要がある場合があります。

## コンテンツの作成

手元の環境に作業ディレクトリを用意し、以下のファイルを作成します。

```yaml:app.yaml
runtime: php55
api_version: 1
handlers:
- url: /
  static_files: index.html
  upload: index.html
```

```html:index.html
<html>
<body>
Hello Google App Engine.
<a href="contact.html">Contact</a>
</body>
</html>
```

## gcloudコマンドの設定

現在のgcloudコマンドの設定を確認します。

```shell-session
>gcloud config list
[core]
account = {{your account name}}
disable_usage_reporting = True
project = data9824-static-web-example

Your active configuration is: [default]
```

必要に応じて、accountやprojectを設定します。

```shell-session
>gcloud config set project data9824-static-web-example
Updated property [core/project].
```

## デプロイ

作業ディレクトリから、アプリをデプロイします。

```shell-session
>gcloud app deploy
```

App Engineのダッシュボードに以下のように表示され、デプロイされたのが確認できます。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/05f3b3a6-6821-0840-b50f-2e16e74cde03.png)

デプロイされたアプリは、 https://{プロジェクト名}.appspot.com/ からブラウズできます。

## Cloud DNSの設定

[ネットワーキング]-[Cloud DNS]から、これから設定するカスタムドメインに対応するDNSゾーンを作成します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/a370b825-6620-a90f-9e06-439c4ccb20fc.png)

NSレコードに設定されているネームサーバを、ドメインレジストラ側でネームサーバとして設定します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/bf1acf39-01ca-6ce5-24f1-b9687fb9b69b.png)

## カスタムドメインの追加

[App Engine]-[設定]から、カスタムドメインを追加して、所望のドメインでアクセス出来るようにします。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/36cf1b77-f9d3-db31-a6e8-39d0a5025eac.png)

ドメインの所有権の確認が必要になるので、Googleの指示に従い、[ネットワーキング]-[Cloud DNS]から先ほど追加したゾーンに、TXTレコード、もしくはCNAMEレコードを設定します。このレコードは、今後も定期的にドメインの所有権の確認に用いられますので、削除しないようにしてください。

ドメインの所有権が確認出来たら、カスタムドメインを登録します。

そして、[App Engine]-[設定]-[カスタムドメイン]で指定されるAレコード、AAAAレコードを、[ネットワーキング]-[Cloud DNS]から、ゾーンに設定します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/34ed58de-18c9-0fce-ebc8-d2c7f3be20a6.png)

なお、私の場合はネームサーバにCloud DNSを用いることにしましたが、レジストラが提供するネームサーバ等を利用する場合は、レジストラ側で各レコードを設定してください。

## SSLサーバ証明書の設定

### Let's Encryptの証明書を利用する場合

[App Engine]-[設定]-[カスタムドメイン]で、[マネージドセキュリティを有効にする]とすることで、Let's Encryptの証明書が設定され、自動更新されます。

これにより、所望のドメインでSSLによるアクセスができるようになります。

### その他のSSL証明書を利用する場合

[App Engine]-[設定]から、SSLサーバ証明書、および秘密鍵を登録します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/9a2a716e-0150-9925-0991-3165658f07f9.png)

認証局によっては、認証用にファイルをデプロイする必要があることがあります。以下に例を示します。

以下のように`app.yaml`を設定の上、`well-known/acme-challenge/{your file}`に必要なファイルを配置しています。ドットで始まる`.well-known`はデプロイ対象からデフォルトで除外されてしまいますので、ドットの付かない`well-known`をデプロイしています。ご注意ください。

```yaml:app.yaml
runtime: php55
api_version: 1
handlers:
- url: /
  static_files: index.html
  upload: index.html
- url: /.well-known/(.+)
  static_files: well-known/\1
  upload: well-known/(.+)
```

# コンテンツの作成

## PHPパッケージの取得

[Google Cloud Client Library for PHP](https://github.com/googlecloudplatform/google-cloud-php)のパッケージを取得します。なお、本記事の例では[Google APIs Client Library for PHP](https://github.com/google/google-api-php-client)は用いていません。

```shell-session
>composer require google/cloud google/cloud-datastore google/cloud-storage
```

## API呼び出しのための認証情報の作成

[API Manager]-[認証情報]-[認証情報の作成]-[サービスアカウントキー]から、App Engineにアクセスするためのキーを作成し、JSON形式で取得し、作業ディレクトリに`credentials.json`として保存します。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/bf43ff81-ca22-80e9-d224-42d73f7ae722.png)

## コンテンツの作成

以下のとおりコンテンツを作成します。

```yaml:app.yaml
runtime: php55
api_version: 1
instance_class: F1
automatic_scaling:
  min_idle_instances: automatic
  max_idle_instances: 1
  min_pending_latency: 3000ms
  max_pending_latency: automatic
handlers:
- url: /.well-known/(.+)
  static_files: well-known/\1
  upload: well-known/(.+)
- url: /vendor/*
  script: 404.php
  secure: always
- url: /
  static_files: index.html
  upload: index.html
  secure: always
- url: /(.+\.php)
  script: \1
  secure: always
- url: /((index|contact)\.html)
  static_files: \1
  upload: (.+\.html)
  secure: always
- url: /(.+\.(css|js|jpg|png))
  static_files: \1
  upload: (.+\.(css|js|jpg|png))
  secure: always
- url: /.*
  script: 404.php
  secure: always
```

```html:contact.html
<html>
<body>
<form action="contact.php" method="post" enctype="multipart/form-data">
	<textarea name="content"></textarea>
	<input name="attached" type="file">
	<input type="submit">
</form>
</body>
</html>
```

```php:contact.php
<?php

require 'vendor/autoload.php';

use Google\Cloud\Datastore\DatastoreClient;
use Google\Cloud\Storage\StorageClient;

putenv('GOOGLE_APPLICATION_CREDENTIALS=credentials.json');

// Store data
$projectId = /* 作成したプロジェクトのID */;
$datastore = new DatastoreClient(['projectId' => $projectId]);
$key = $datastore->key('ContactMessage');
$key = $datastore->allocateId($key);
$storage = new StorageClient(['projectId' => $projectId]);
$bucket = $storage->bucket($projectId . '.appspot.com');
$object = NULL;
$now = new DateTime();
if (isset($_FILES['attached'])) {
    if ($_FILES['attached']['size'] > 10 * 1024 * 1024) {
        http_response_code(400);
        exit;
    }
    $object = $bucket->object('attached/' . $now->format('YmdHis') . '-' . $key->path()[0]['id']);
    if (!move_uploaded_file($_FILES['attached']['tmp_name'], $object->gcsUri())) {
        $object->delete();
        $object = NULL;
        http_response_code(400);
        exit;
    }
    $object->update([
        'contentDisposition' => 'attachment; filename=' . $_FILES['attached']['name']
    ]);
}
$entity = [
    'created' => $now,
    'content' => $_REQUEST['content']
];
if ($object !== NULL) {
    $entity['attached'] = $object->gcsUri();
	$entity['attachedUrl'] = 'https://storage.cloud.google.com/' . $bucket->name() . '/' . $object->name();
}
$message = $datastore->entity(
    $key,
    $entity,
    [
        'excludeFromIndexes' => [
            'content'
        ]
    ]
);
$datastore->insert($message);
?>
<html>
<body>
Done!
</body>
</html>
```

```php:404.php
<?php
http_response_code(404);
?>
<html>
<body>
The requested page doesn't exist.
</body>
</html>
```

```ini:php.ini
google_app_engine.enable_functions = "php_sapi_name"
upload_max_filesize = "11M"
post_max_size = "11M"
```

コンテンツを作成したら、デプロイします。

```shell-session
>gcloud app deploy
```

ブラウザから作成したWebサイトを確認できます。contact.htmlから投稿してみてください。[データストア]で投稿した内容を確認できます。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/38624337-98ec-dc90-e5c5-de7ae6bcfba9.png)

[Storage]で投稿したファイルを確認できます。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/8d4ededd-7dfd-1376-becb-e6b6e5de8e07.png)

# 開発環境

## 開発用サーバ

ローカル環境にPython 2.7をインストールしておけば、開発用のサーバを起動できます。作業ディレクトリで以下のコマンドを実行してください。

```shell-session
python "C:\Program Files (x86)\Google\Cloud SDK\google-cloud-sdk\bin\dev_appserver.py" .
```

## プライベートGitリポジトリ

[Source Repositories]からプライベートGitリポジトリを作成でき、コンテンツの管理に利用できます。容量1GBまで無料で利用できます。

![](https://qiita-image-store.s3.amazonaws.com/0/12670/1df5bd39-aaeb-a670-89c6-14cfcfe3b04d.png)

# 注意事項

## スケーリング

デフォルト設定ではインスタンスが起動されすぎて、料金に影響を与えるので、`app.yaml`の`automatic_scaling`に記述したとおり、待機するインスタンスの数は最大でも1つ、リクエストの待機時間が3000msを超えたら新しいインスタンスを起動するようにしています。

## 認証

Apacheの.htaccessのように、Basic認証を簡単に設定することはできません。どうしても必要な場合はスクリプトで実装することになりますが、CPU時間の無駄な消費や、静的コンテンツの配信パフォーマンス等の観点から、お勧めできません。

開発中のサイトを外部に見せたくないという要件の場合は、`app.yaml`の`handlers`の各`url`に`login: admin`を記載することで、プロジェクトの管理者のみを認証することができます。

## リダイレクト

同様に、リダイレクトについても設定ファイルで簡単に設定することはできません。必要な場合はスクリプトで実装することになります。

ただし、HTTPからHTTPSへのリダイレクトは、`app.yaml`の`secure: always`の記載で行えます。

## ステータスコード404

App Engineでは、デフォルトの404応答を直接変更する方法はありません。そこで以下の方法で、存在しないHTMLファイルに対する独自の404応答を生成しています。

* `app.yaml`の`handlers`の末尾に404応答を生成するスクリプトを設定。
* `.html`で終わるURLについて、明示的にHTMLファイル名をURLのパターンとして記載することで、存在しないHTMLファイルに相当するURLにマッチしないようにする。

## php.ini

php.iniのカスタマイズが必要な場合、コンテンツのルートに配置します。

> The php.ini file should be placed in the base directory of an application (the same directory as the app.yaml file).
> - https://cloud.google.com/appengine/docs/standard/php/config/php_ini

## PHPスクリプトでのPOSTされたファイルの受信

32Mバイト未満のファイルを受け取る場合、アップロードされたファイルは、上記PHPスクリプトのように通常通り`$_FILES`を用いて取り扱えます（[記事参照](https://gae-php-tips.appspot.com/2015/03/09/direct-file-uploads-for-php-5-5/)）。それ以上のサイズのファイルを受け取るには、特別な方法をとる必要があります（[App Engineのドキュメント参照](https://cloud.google.com/appengine/docs/standard/php/googlestorage/user_upload)）。

## Cloud StorageのオブジェクトのURL

Cloud Storageのオブジェクトは、デフォルトでは公開設定になっていませんが、当該プロジェクトのアカウントでログインできるならば、以下のURLでアクセスできます（[Cloud Storageドキュメント参照](https://cloud.google.com/storage/docs/cloud-console)）。

`https://storage.cloud.google.com/(バケット名)/(オブジェクト名)`

## Cloud Storageのオブジェクトのレスポンスヘッダ

Cloud Storageのオブジェクトのレスポンスヘッダは、上記PHPスクリプトのようにAPIから設定可能です（[Cloud Storageドキュメント参照](https://cloud.google.com/storage/docs/gsutil/addlhelp/WorkingWithObjectMetadata)）。

# まとめ

一般的なレンタルサーバで静的コンテンツ中心のWebサイトを作った場合と同等のWebサイトを、App Engine上で構築できました。

格安に抑えるためにはCloud SQLを利用できない、すなわちマネージドなRDBMSを利用できないため、一般的なWebアプリケーションフレームワークの採用は難しそうですが、Key-Value型NoSQLや、オブジェクトストレージは利用できますので、必要に応じて自前で実装できそうです。(Compute Engineの無料インスタンスにRDBMSを構築する方法もありそうですが、マネージドサービスでは無いので、可用性や保守コストの問題があるかと思われます）

今回はアクセス数の少ないサイトを想定しましたが、オートスケールの機能があるため、アクセス数が極端に多くてもダウンしにくいサイトを比較的割安に運用できるのではないかと思います。

技術ドキュメントも充実していますが、やはりソフトウェアエンジニアでないと敷居は高そうですので、デザイナ中心のチームでは導入は難しいかもしれません。