WebAssemblyモジュールを含むwebアプリをAWSを使って公開してみる

# この記事について

発端は、Rustを勉強する為に、ルービックキューブアプリをRust＋WebAssemblyで作成し始めた事になります。

それから、勉強した事を記事にまとめつつ今に至ります。

- [Rust + seed でWebAssemblyを体験してみる。](https://qiita.com/etnk/items/35de53198e1c9f1d6959)
- [Rustの参照渡しを使いこなすためにその１](https://qiita.com/etnk/items/795f52ffcc6001cb0723)
- [Rustの参照渡しを使いこなすためにその２－Vecとfor－](https://qiita.com/etnk/items/eac576506322c3bb3e04)
- [javaScriptとWebAssembly（seed使用）を相互連携してみる。](https://qiita.com/etnk/items/d5c9ec3d6c7b91618546)
- [VueからWebAssembly（seed使用）を利用してみる。](https://qiita.com/etnk/items/3541e492862770c17341)

今回は結構良い所まで実装出来たので、webサイトとして公開する事をやってみます。

参考：現時点でのアプリ画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866006/d75d2bae-3db7-43dc-4f20-60ece280a310.png)

# 目次

結構長くなってしまったので、今回の記事の流れを記載します。
右ペインでも見れると思いますが、まとめ的に記載します。

- 独自ドメインを使用しての公開の為の前準備
    - 独自ドメイン取得（無料ドメインサイト使用）
    - Route53にHostedZone作成（AWSコンソール）
    - ネームサーバーの設定（無料ドメインサイト側）
    - 動作確認（AWSサンプル使用）
    - ドメイン委譲してサブドメインのHostedZone確保（AWSコンソール）
- [サンプルテンプレート](https://github.com/aws-samples/amazon-cloudfront-secure-static-site#user-content-amazon-cloudfront-secure-static-website)を解読して方針決め
- [サンプルテンプレート](https://github.com/aws-samples/amazon-cloudfront-secure-static-site#user-content-amazon-cloudfront-secure-static-website)の修正
    - カスタムリソース部分削除
    - S3のバケット名を指定できる様に
    - ACMを分離してメインスタックはap-northeast-1で作成する
    - wasmモジュールなどの為にCSP（Content Security Policy）設定
- デプロイ実行
    - 変数設定
    - ACMスタックデプロイ（us-east-1）
    - 本体スタックデプロイ（ap-norteast-1）
    - Vueとwasmモジュールのビルド
    - S3へビルド成果物配置
    - CloudFrontのキャッシュクリア

※2022年4月30日追記
ドメイン自体は無料ですが、AWS側のHostedZone作成で以下のコストが毎月かかります。完全無料を目指す方はご注意を。

> $0.50 per Hosted Zone for the first 25 Hosted Zones

# 方針

AWS上で独自ドメインを使って配信したいと思います。今回のアプリで必要なのはstaticリソースだけです。AWSが提供しているサンプルソースによさそうなものがありました。こちらを基本に修正していこうと思います。

https://github.com/aws-samples/amazon-cloudfront-secure-static-site#user-content-amazon-cloudfront-secure-static-website

## パラメーター確認

CloudFormationを使ってデプロイしているようです。
上記githubリポジトリの「Launch on AWS」ボタンを押すとAWSコンソールでCloudFormationの作成画面に行きます。[main.yaml](https://github.com/aws-samples/amazon-cloudfront-secure-static-site/blob/master/templates/main.yaml)がその名の通りメインのテンプレートである事が解ります。テンプレートを見てもgithubリポジトリの説明を見ても解りますが、いくつかのパラメーターが必要とされています。

- SubDomain: 次のパラメーターのDomainNameの前につけるサブドメイン部分です。`www`を推奨しています。テンプレートでのデフォルトも`www`になっています。ここはわざわざ変える必要は無いと思います。
- DomainName: Route53の`Hosted zone`を指すドメイン名の指定です。
- HostedZoneId: 使用されるドメインを含むRoute53の`Hosted Zone Id`です。
- CreateApex: [CloudFormation上の使用部分](https://github.com/aws-samples/amazon-cloudfront-secure-static-site/blob/master/templates/cloudfront-site.yaml#L53-L59)を見るとCloudFrontにSubDomain含まないDomain Apexをつけるかどうかの様です。世のサイトによくあるwwwをつけてもつけてなくても同じサイトにいくやつですね。

当然といえば当然ですが、ドメインを取得する事が必要です。

# 独自ドメインを使用しての公開の為の前準備

こちらのサイトが参考になります。

https://it-study.info/freenom-domain-route53-web-server/

大きく分けて以下の手順になると思います。こちらのページのトレースになってしまいますが順次行っていきます。

- 独自ドメイン取得
- Route53にHostedZone作成
- ネームサーバーの設定

## 独自ドメイン取得

独自ドメインは、AWS上でも取得出来たりしますが、今回は参考サイト通りに[無料ドメインサイト](https://www.freenom.com/ja/index.html)を利用する事にします。

1. 使用したいセカンドドメイン名（例：`mydomain`）を入れて「利用可能状況をチェックします」をクリック
1. 先に指定したセカンドドメイン.選択したいトップドメイン（例：`mydomain.tk`）を入力し、再度「利用可能状況をチェックします」をクリック
1. 「チェックアウト」を押す
1. 有効期限は一旦「3 Months free」を選択して「Continue」を押す（他の部分は選択しませんでした）
1. メールアドレスを入力するか、GoogleやFaceBookのソーシャルログインを使用（私はメールを使用しました）
1. 指定したメールアドレスに確認用リンクが送られてきているので、そのリンクに飛ぶ
1. First Name、Last Name、Address 1、City、Country、State/Region、Passwordを入力して「Complete Order」をクリック
1. 指定したメールアドレス、Passwordを使ってログイン

※2022年4月24日現在、1のステップでトップドメインのリストそれぞれの横にある「今すぐ入手」をクリックすると入手不可になってしまいます。[Freenomでドメイン取得しようとして利用不可になる場合の対応](https://qiita.com/larvestiny/items/e0e65c6c74f6c641a88b)が必要そうです。上の手順はこちらを反映しています。

一時的に使うドメインはこの手順で使った様に無料で良いと思いますが、長く使うなら安いトップドメインでも良いからちゃんと買っておいて固定しておいた方が良さそうです。この無料ドメインサイトでも、１年以上長く使う場合は有料になる模様です。１年毎に更新すれば良さそうではあるので、手間を惜しむか年1000円前後を惜しむかの違いになるかと思います。

https://xblood.hatenablog.com/entry/2017/12/31/232441

※2022年7月30日追記
Freenomからメールが来ると思っていて油断してたら３ヵ月の有効期間が切れてしまったようです。更新しようと思っていたら、同じドメイン名は有料になっていました。確かに通常語で構成されていたドメイン名でしたが、使っていた「.tk」以外は無料のままでした。無料なので文句は言いづらいですね。お金払うぐらいならAWSなど別の所で買おうと思っている所です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866006/276330ab-19f7-b6de-014e-bbb40a6070d9.png)


## Route53にHostedZone作成

1. AWSのコンソールにログイン
1. Route53 → ホストゾーンの作成
1. ドメイン名に取得したドメイン名（例：`mydomain.tk`）を入力
1. `パブリックホストゾーン`を選択
1. 「作成」
1. 作成したホストゾーンの詳細が表示される。レコードのリストにある、タイプNSの「値/トラフィックのルーティング先」の値をメモ
1. 同画面で「ホストゾーンの詳細」をクリックして詳細を開き、「ホストゾーン ID」の値もメモ

## ネームサーバーの設定

1. Freenomへログイン
1. 上部メニューの「Services」→「My Domains」
1. 作成したドメイン名（例：`mydomain.tk`）の右にある「Manage Domain」をクリック
1. 内側のタブの「Management Tools」→「Nameservers」をクリック
1. 「Use custom nameservers」を選択
1. 先ほどメモした、Route53で作成したホストゾーン、タイプNSレコード（例：`ns-**.awsdns-**.com.`）を４つ分入力
1. 「Change Nameservers」をクリック

## 動作確認

ここまでで、サンプルのCloudFormationを構築する為のパラメーターの準備は出来ました。取得したドメインがちゃんと動くかの確認も含め、サンプルCloudFormationのスタックを作成してみます。

1. AWSコンソールにログインしておく
1. そのブラウザにて、[Use the CloudFormation console](https://github.com/aws-samples/amazon-cloudfront-secure-static-site#use-the-cloudformation-console)にある「Launch on AWS」ボタンを押す
1. 「次へ」
1. 以下のパラメーターを入力して「次へ」をクリック
    1. スタックの名前: my-static-site-sample-stack （任意）
    1. SubDomain: www （デフォルトのまま） 
    1. DomainName: 作成したドメイン名（例：`mydomain.tk`）
    1. CreateApex: no
    1. HostedZoneId: AWS Route53で作成した時にメモした「ホストゾーン ID」の値
1. スタックオプションの設定画面、特に何もせずに「次へ」をクリック
1. レビュー（確認）画面、以下のチェックボックスにチェックを入れて「スタックの作成」をクリック
    1. AWS CloudFormation によって IAM リソースがカスタム名で作成される場合があることを承認します。
    1. AWS CloudFormation によって、次の機能が要求される場合があることを承認します:
1. www付きのドメイン（例：`https://www.mydomain.tk`）へアクセス

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/866006/6be1133f-adc2-da11-02a0-036971e01e2c.png)

こんな画面が出てきたら成功です。独自ドメインの取得は無事に出来たようです。

## ドメイン委譲してサブドメインのHostedZone確保

アプリ毎にHostedZoneを確保しておきたい所です。以下のサイトが参考になります。こちらの例では異なるAWSアカウント間での委譲をしていますが、同一アカウント内でも出来ます。サブドメイン付き（例：`cubetrain.mydomain.tk`）で作成しておきます。

https://dev.classmethod.jp/articles/route53-transfer-hostedzones/

※2022年4月30日追記
１HostedZoneにつき$0.5/月の料金がかかる事に気づいたので、後ほど１HostedZoneで済む様に対応予定です。

# サンプルテンプレートを解読して方針決め

先に紹介したAWSのgithubリポジトリに戻って修正箇所を考察してみます。

https://github.com/aws-samples/amazon-cloudfront-secure-static-site#customizing-the-solution

## Customizing the Solution部分の解読

※`https://github.com/awslabs/aws-cloudformation-templates`からプロジェクトをダウンロードと書いてありますが、`https://github.com/aws-samples/amazon-cloudfront-secure-static-site`の間違いだと思います。

`make package-static`を実行して、成果物を生成する様です。

Makefileを見てみます。

```makefile:抜粋
build-static:
	cd source/witch/ && npm install --prefix nodejs mime-types && cp witch.js nodejs/node_modules/

package-static:
	make build-static
	cd source/witch && zip -r ../../witch.zip nodejs
```

`source/witch.js`で使用しているモジュールをインストールして、それらをwitch.zipへ圧縮する処理の様です。
そして、それは[カスタムリソース用のLambdaのLayer](https://github.com/aws-samples/amazon-cloudfront-secure-static-site/blob/master/templates/custom-resource.yaml#L43)として使用されています。そして、そのカスタムリソースが動作する時の処理フォルダはwwwになる模様です。
CloudFormationスタックを作成する時、カスタムリソースが作成され、その作成処理としてwitch.jsが動作するという形ですね。
そうする事で、wwwフォルダにあるソースが、[リソース用S3バケット](https://github.com/aws-samples/amazon-cloudfront-secure-static-site/blob/master/templates/custom-resource.yaml#L25)へコピーされるという動作の様です。

## 修正方針

今回のプロジェクトでは２種類のビルドが発生します。

- RustのWebAssemblyモジュールビルド
- WebAssemblyモジュールビルド生成物package.jsを含んでVueソースビルド

ビルド処理をして特定のS3バケットにアップするという処理が既に存在しています。その処理はスクリプトで行う事になると思います。今回は成果物の指定S3バケットへのアップもそのスクリプトに含めようと思います。

- CloudFormationによる、CloudFront、S3などの関係リソースの生成
- スクリプトによる、プログラムソースのビルドと、成果物S3の配置

の組み合わせでサイトをデプロイする方針にします。

# 実対応

## CloudFormation Templateの修正

まず、[templatesフォルダ](https://github.com/aws-samples/amazon-cloudfront-secure-static-site/blob/master/templates/)の中身をコピーします。

### カスタムリソース部分削除

前述方針では、コピー処理するカスタムリソースが必要ないので、そこを削除します。

- custom-resource.yamlのresourcesブロックから、カスタムリソースに関係するCopyCustomResource、CopyFunction、CopyLayerVersion、CopyRole部分を削除
- main.yaml、custom-resource.yamlのOutputsブロックからCopyFunctionを削除

### S3バケット名指定化

現状のままではS3バケット名が指定されておらず、「amazon-cloudfront-secure-static-site-s3bucketroot-1uu6uyhogefuga」の様なランダム文字列を含む自動命名されたバケットが作成されます。
※S3バケット名はグローバル一意ですから、このgithubリポジトリの様にサンプルで公開されものではこの対応が妥当かと思います。

今回は、自分でS3バケットにアップロードしたいのでS3バケット名を自分で決めたい所です。S3バケット名をパラメーターで指定できる様にします。あと、自分の好みですが、CloudFormationの削除時にはS3も削除したいので、DeletionPolicyを`Delete`に変更しておきます。

```yaml:main.yaml
# 追加部のみ記載
Parameters:
  # パラメーター指定部追加
  S3BucketName:
    Description: Bucket name for main static resources.
    Type: String
Resources:
  CustomResourceStack:
    Properties:
      # 子スタックへパラメーターを渡す部分追加
      Parameters:
        S3BucketName: !Ref S3BucketName
```

```yaml:custom-resource.yaml
# 追加部のみ記載
Parameters:
  S3BucketName:
    Description: Bucket name for main static resources.
    Type: String

  S3BucketLogs:
    DeletionPolicy: Delete
    Properties:
      BucketName: !Sub "${S3BucketName}-logs"

  S3BucketLogs:
    DeletionPolicy: Delete
    Properties:
      BucketName: !Ref S3BucketName
```
### ファイル名変更

カスタムリソース使わなくなったのでファイル名が不適切です。変更します。

旧：custom-resource.yaml
新：s3-bucket.yaml

```yaml:main.yaml
Resources:
  S3BucketStack: # 「CustomResourceStack」から変更
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: ./s3-bucket.yaml # ファイル名変更

# その他、Outputs部分などにある「CustomResourceStack」を「S3BucketStack」に変更
```

### ACM部分対応

サンプルの中ではAWSの証明書「ACM Certificate」のリソースも作成していますが、ACMはus-east-1リージョンでしか作成できません。
メイン部分のリソースはap-northeast-1で作成したいので、CloudFormationのスタックも別作成する事にします。ワイルドカード証明書を手動で一つ作っておいて後で使いまわす事も考えましたが、デメリットもありそうなので個別にCloudFormationで作成します。

https://goworkship.com/magazine/wildcard-certificate/

acm部分だけus-east-1で作成し、それをメイン部分から読み込む様にします。

#### acm-certificate.yaml修正

別のCloudFormationから値を取得出来る様にExportブロックを追加します。

```yaml:acm-certificate.yaml
Outputs:
  CertificateArn: 
    Description: Issued certificate
    Value: !Ref Certificate
    Export:
      Name:
        'Fn::Sub': '${AWS::StackName}-CertificateArn'
```

### cloudfront-site.yaml修正

CloudFrontキャッシュクリアに必要になるので、DistributionIdをExportする様に修正します。後にmain.yaml側でさらにExportします。

```yaml
Outputs:
  CloudFrontDistributionId:
    Description: CloudFront distribution id
    Value: !GetAtt CloudFrontDistribution.Id
```

ContentSecurityPolicy部分を修正します。丸ごと外しても良いですが、ちゃんと設定する事にします。
Vue3上でVuetify（※2022年4月29日現在β版）を使ってますが、フォントとかで外部サイトを見たりしている様で、その指定をします。
**また、WebAssemblyを使う為には`script-src`に`'unsafe-eval'`を指定しておく必要があります。**

```yaml
            ContentSecurityPolicy: 
              ContentSecurityPolicy:
                Fn::Join:
                - ''
                - - "default-src 'self';"
                  - "img-src 'self';"
                  - "object-src 'self';"
                  - "script-src 'self' 'unsafe-inline' 'unsafe-eval';"
                  - "style-src 'self' 'unsafe-inline';"
                  - "style-src-elem 'self' 'unsafe-inline' https://fonts.googleapis.com;"
                  - "font-src 'self' https://fonts.gstatic.com;"
              Override: true
```

### main.yaml修正

以下の部分を削除します

- Rulesブロック（us-east-1のみ許可する様にしているため）
- Parameter-HostedZoneIdブロック
- Resources-AcmCertificateStackブロック

内部で参照していた部分を前述Exportした値から取得する形に修正します。
リージョン異なるので直接ImportValueが使えません。スクリプトで取得してCloudFormationのパラメーターで渡す形にします。

```yaml:main.yaml
Parameters:
  # 追加
  CertificateArn:
    Description: CertificateArn for cloudfront
    Type: String

  CloudFrontStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: ./cloudfront-site.yaml
      Parameters:
        # 変更
        CertificateArn: !Ref CertificateArn
```

CloudFrontのDistributionIdをExportしてスクリプトから使用できる様にします。
```yaml:main.yaml
Outputs:
  # 追加
  CFDistributionId:
    Description: CloudFront distribution
    Value: !GetAtt CloudFrontStack.Outputs.CloudFrontDistributionId
```

## デプロイ

### CloudFormationコマンドの為のS3バケット作成

デプロイ時に、CloudFormationの処理にS3バケットが必要になるので、AWSコンソールなどから自分用のバケットを作成しておきます。

### 変数設定

実行時に色々指定しますが、事前に変数に設定しておきます。

```bash:
APP_RESOURCE_BUCKET_NAME=<CloudFormationで作成する、webページのstaticリソース用バケット名>
CFN_ACM_STACK_NAME=cubutrain-acm-stack
CFN_MAIN_STACK_NAME=cubutrain-main-stack
CFN_TEMP_BUCKET_NAME=<先に作っておいたCloudFormation処理用バケット名>
CFN_DOMAIN_NAME=cubetrain.mydomain.tk
CFN_SUB_DOMAIN=www
CFN_HOSTEDZONEID=<先に作っておいたHostedZoneのID>
```

### ACMスタックのデプロイ

先ほど分離した`acm-certificate.yaml`単体でCloudFormationスタックを作成します。

```bash
aws --region us-east-1 cloudformation package \
    --template-file templates/acm-certificate.yaml \
    --s3-bucket ${CFN_ACM_STACK_NAME} \
    --output-template-file packaged.template \
    --no-fail-on-empty-changeset \
    --parameter-overrides \
        DomainName=${CFN_DOMAIN_NAME} \
        SubDomain=${CFN_SUB_DOMAIN} \
        HostedZoneId=${CFN_HOSTEDZONEID} \
        S3BucketName=${APP_RESOURCE_BUCKET_NAME}
```

### 本体スタックのデプロイ

#### 本体CloudFormationのpackaging

`AWS Serverless Application Model`を使用しているので、まずpackageが必要という事です。リージョンをap-northeast-1にしておきます。

```bash
aws --region ap-northeast-1 cloudformation package \
    --template-file ../aws/templates/main.yaml \
    --s3-bucket ${CFN_TEMP_BUCKET_NAME} \
    --output-template-file ../deploywork/packaged.template
```

指定したS3バケットに一時的ファイルが出来、ローカルの実行フォルダにpackaged.templateが生成されます。ちなみに、packaged.templateの中ではS3バケットのファイルが子スタックのソースとして使用されています。

#### 本体CloudFormationのdeploy

先にus-east-1に作っていたACMのArnをawsコマンドを使って取得しておきます。

```bash:
ACM_ARN=`aws --region us-east-1 cloudformation describe-stacks --stack-name ${CFN_ACM_STACK_NAME} | jq -r '.Stacks[] | .Outputs[] | select(.OutputKey == "CertificateArn") | .OutputValue '`
```

基本的にサンプルにあるdeployコマンドを実行します。変更点は以下の通りです。
- 先に追加しておいた`CertificateArn`パラメーターを追加
- `--no-fail-on-empty-changeset`も追加して、変更が無い時にエラー扱いされない様に
- リージョンを`ap-northeast-1`に変更

```bash:
aws --region ap-northeast-1 cloudformation deploy \
    --stack-name ${CFN_MAIN_STACK_NAME} \
    --template-file ../deploywork/packaged.template \
    --capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
    --no-fail-on-empty-changeset \
    --parameter-overrides \
        DomainName=${CFN_DOMAIN_NAME} \
        SubDomain=${CFN_SUB_DOMAIN} \
        HostedZoneId=${CFN_HOSTEDZONEID} \
        S3BucketName=${APP_RESOURCE_BUCKET_NAME} \
        CertificateArn=${ACM_ARN}
```

### wasmモジュールのビルド

[前回の記事で作成したビルドスクリプト](https://qiita.com/etnk/items/3541e492862770c17341#packagejs%E3%81%AE%E9%85%8D%E7%BD%AE)をそのまま利用します。
これにより、Vueのビルドで必要なpackage.jsファイルも配置されます。

### vueモジュールのビルド

ビルド実体スクリプトを作っておきます。

```bash:vue/cubetrain/build.sh
#!/bin/bash
set -euxo pipefail
cd "$(dirname "$0")"

yarn install && yarn build
```

dockerコンテナを使って、そのスクリプトを実行してビルドします。

```bash
cd ../vue/cubetrain && docker run -it --rm -v "$(pwd):/vue/cubetrain" node:17.8 /vue/cubetrain/build.sh && cd ../../scripts
```

### ビルド成果物のS3バケットへのアップロード

```bash
aws s3 cp ../vue/cubetrain/dist/ s3://${APP_RESOURCE_BUCKET_NAME}/ --recursive
aws s3 cp ../wasm/pkg/ s3://${APP_RESOURCE_BUCKET_NAME}/wasm/ --recursive
aws s3 cp ../wasm/pkg/package_bg.wasm s3://${APP_RESOURCE_BUCKET_NAME}/wasm/package_bg.wasm --content-type "application/wasm"
```

**wasmモジュールはアップロードする時に「application/wasm」をContentTypeとして指定する必要があります！！！**
これをしておかないと、デフォルトの「binary/octet-stream」としてアップロードされ、ブラウザで読み込んだ時に以下のようなエラーが開発者ツールのコンソールで出てきます。

```console
`WebAssembly.instantiateStreaming` failed because your server does not serve wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:
 TypeError: Failed to execute 'compile' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'.
```

### CloudFrontのキャッシュクリア

awsコマンドで、invalidationを実行します。メインスタックにExportしておいたCloudFrontのDistributionIdを取得し、そのIDを指定して実行します。

```console
CLOUDFRONT_DISTID=`aws cloudformation describe-stacks --stack-name ${CFN_MAIN_STACK_NAME} | jq -r '.Stacks[] | .Outputs[] | select(.OutputKey == "CFDistributionId") | .OutputValue '`
aws cloudfront create-invalidation --distribution-id ${CLOUDFRONT_DISTID} --paths "/*"
```

# 余談

今回はAWSで行ったので必要ないですが、本来なら取得したドメインでhttpsで公開するにはSSL証明書が必要で取得が面倒だしお金がかかります。さらに定期的な証明書の更新が必要です。AWSのACM便利ですね。

https://www.cpi.ad.jp/purpose/ssl/

あと、最初はstaticリソース配置の部分だけ変えれば大丈夫だろうと思っていたのですが、wasmモジュール特有の問題があったりで結構大変でした。

実際のwebアプリはこちらになります。一旦公開すると、細かい事に気を付けたくなりますねｗ
※2022年7月30日、ドメイン有効期限切れによりURL更新

https://www.cubetrain.silverbox.link/

ソースコードに興味のある方はこちらへ。

https://github.com/silverbox/cubetrain