Amplify Gen2 (Vue) で画像管理機能のベースを作る


# 前書き

こんにちは、インフラエンジニアの小野島です！

Amplify Gen2 のキャッチアップがてら以下のようなアプリケーションを作ってみます

- Google ログイン機能がある
- 画像をアップロードできる
- ユーザーごとに自分の画像の管理ができる

今回作成するのは基本的な要素だけですが、自作の画像を公開できるようなプラットフォームを意識して作業を進めています

仮のサービス名は mypic としています

この記事は作業ログ形式の文章となっております、一緒に作業している感覚でお読みください

# Amplify のセットアップ

基本的に https://docs.amplify.aws/vue/start/quickstart/ の通りに実行

Amplify コンソールから GitHub リポジトリを作成

![Create GitHub Repo. from Amplify Console](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/b98d6784-110a-ce12-26ed-148b4c9b6789.png)

AWS が公開している to-do リスト作成アプリケーションのサンプルリポジトリをもとにリポジトリが作成されます

検証段階なので Private に設定

![Create GitHub Repo.](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/6d1e4eaf-fb77-eeec-6a79-9430c1baa679.png)

リポジトリ検出のために GitHub App と Amplify の連携を許可します

![Authorize Amplify](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/5d2f4ca9-79b4-fb24-8a17-a69ab49bbae5.png)

Amplify 向けに構築されたデプロイ対象リポジトリを選択します

他のアプリケーションや Terraform, Ansible と一緒に管理する場合はモノレポを選択することもできるようです

![Select Repo.](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/df8305ab-b319-a4d9-e095-f041f6a06ae1.png)

その他設定は今回デフォルトのままとしデプロイ実施

![Deploy Settings](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/ce611bde-8698-77bc-79f8-6b7363493052.png)

Amplify プロジェクトが作成され初回デプロイが走ります

![Starting Deploy](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/e81adeab-aa55-e5ef-de04-a9532730154f.png)

ドメインが発行されるのでデプロイ途中でアクセスすると、この段階ではまだ簡素なデザインです

![Default Website](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/529fc1f9-fa68-d92f-1fbb-d5ffcb1108bb.png)

6分ほど待ってデプロイ完了

![Deploy Completed](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/cb52ea0f-ca89-626f-ca29-823cde6ab82a.png)

いい感じのデザインになり、to-do リスト作成機能が実装されていました

![Initial Website](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/bdf850b2-6b3e-d102-7cde-241ab70baf1f.png)

言うまでもなくインフラ構築はセットアップに埋め込まれており、インフラを意識しないコンセプトを感じます

# 作成されたリソースを眺めてみる

初回デプロイでは主に以下のリソースが作成されており、これは各サービスのコンソールからも確認できます

- S3 (メタデータ管理用)
- DynamoDB
- Cognito
- Appsync
- Lambda

これらは CloudFormation を使って作成されているので、CloudFormation のコンソールからも確認することができます

(~~CloudFront 使ってないんだ......~~ Amplify が内部で CloudFront を使っているようです)

当然ですが DynamoDB の料金体系はオンデマンドになっており、開発から小規模展開までのコスト設計は意識しなくてよさそうだなと感じました

# ログイン機能の追加

機能追加の前に、ローカルでの開発には作成したリソースの ID 情報等が必要なので Amplify の出力ファイルをダウンロードして配置します

```
vim amplify_outputs.json
```

`terraform output` のようなものですね

とりあえずログイン機能がほしいのでドキュメントに沿って以下を実施

```
npm add @aws-amplify/ui-vue
code -r src/App.vue
```

`src/App.vue` にはドキュメント通り追記

```
<script>
+ import { Authenticator } from "@aws-amplify/ui-vue";
+ import "@aws-amplify/ui-vue/styles.css";
// ... other imports
</script>

<template>
  <main>
+   <authenticator>
+     <template v-slot="{ signOut }">
+       <Todos />
+       <button @click="signOut">Sign Out</button>
+     </template>
+   </authenticator>
  </main>
</template>
```

またドキュメントには書かれていませんでしたが、.vue 拡張子を認識できていなかったので下記の記事を参考に `src/shims.d.ts` を追加

https://qiita.com/raratyurara/items/3ec1d78bebb76bde641a


動かしてみる

```
npm run dev
```

これだけでログイン機能 + ToDo 機能が実装されました

![Created Login Form](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/99882014-c101-b6b5-74a5-ce77c0a0721e.png)

ドキュメントに指示されるがままに push

```
git commit -am "added authenticator"
git push
```

すると自動でデプロイが走り、発行済みのドメイン側でも変更が反映されました

![AAutomatic Deploy](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/78d75f41-bf8d-b879-ea67-ccfa3b4c7b18.png)

CI/CD まで意識せずセットアップされており、コーディングに集中できることが実感できました

# sandbox を使ってみる

現状だとローカル操作でもデプロイ済みの DynamoDB にデータが追加されてしまうので、sandbox 機能を使ってみます

sandbox は開発者の個人環境という位置づけで、先ほどデプロイしたリソース一式を複製するイメージです

ただしローカル開発を想定しているようで、結果的にドメインは発行されませんでした

AWS の認証情報が必要になる(エラーが出た)ので追加します

```
aws configure
```

早速サンドボックスを起動

```
npx ampx sandbox
```

ローカルのコンソールに進捗が表示され、しばらく待つと追加の DynamoDB 等が作成されました

サンドボックスの管理はコンソールから実施できます

![Manage Sandbox](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/0af06634-9a48-737c-5b04-a9c1a7cc9900.png)

ただこれ以上の情報はないので、sandbox 名をローカルで入力しておけると良いなと感じました

(デフォルトでマシン名が入るようだが、わかりづらい)

[ドキュメント](https://docs.amplify.aws/react/deploy-and-host/sandbox-environments/features/#work-with-multiple-named-sandboxes) によると `--identifier` で名前を付けられるようなので、運用する際には指定してみようと思います

# Google ログインの追加

実用的なアプリケーションを意識すると SNS ログイン機能はほしいところです

以下ドキュメントに沿って Google 認証を実装してみます

https://docs.amplify.aws/vue/build-a-backend/auth/concepts/external-identity-providers/

※Google の OAuth client ID 取得手順は割愛します

`email: true` は無いと後々エラーになるので残しつつ、Google 認証に関する記述を追加

```console
vim amplicy/auth/resource.ts
```

```resource.ts
export const auth = defineAuth({
  loginWith: {
    email: true,
+   externalProviders: {
+     google: {
+       clientId: secret('GOOGLE_CLIENT_ID'),
+       clientSecret: secret('GOOGLE_CLIENT_SECRET'),
+       scopes: ['email'],
+       attributeMapping: {
+         email: 'email'
+       }
+     },
+     callbackUrls: ['http://localhost:5173/', "https://main.d1q82ko8i5l0ez.amplifyapp.com/"],
+     logoutUrls: ['http://localhost:5173/', "https://main.d1q82ko8i5l0ez.amplifyapp.com/"],
+   }
  },
});
```

なお `callbackUrls` と `logoutUrls` は sandbox では `localhost` だけで十分ですが、push 後に本番ドメインも必要になるので先に書いておきます

sandbox 環境では `resource.ts` の変更がリアルタイムで反映されます

![Real-time Deploy on Sandbox](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/3aeca208-839b-abcc-0f4f-11fd503db370.png)

助かる反面デプロイに分単位の時間がかかるので、無暗にトリガーしないようにしたほうがよさそうと感じました

またやりたいことに対してはデプロイの時間が長いなとは感じました 

(裏の動きまでちゃんと見ていないですが。Terraform でもこのくらいかかるのかな)

`amplify_outputs.json` が生成されるのをまって作業再開

secret 関数が取得できるよう変数を登録 (対話式です、`read GOOGLE_CLIENT_ID` みたいな感じ)

```
npx ampx sandbox secret set GOOGLE_CLIENT_ID
npx ampx sandbox secret set GOOGLE_CLIENT_SECRET
```

もちろんこれも AWS のリソース (SSM Parameter Store) に保存されます

![Stored Google Secret](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/5296e66f-5f13-2134-7264-ca0a3cc2c9f0.png)

一通り準備できたので起動

`npm run dev`

難なく Google 認証ボタンが追加されました

![Login Form with Goole Auth](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/063ed6ce-5473-1d77-9d18-61d7be2eadd3.png)

# ドメインの指定でハマる

Google アカウントでのログインテストをしようとしたところ、"different origin" と怒られてしまいました

![スクリーンショット 2024-11-21 081306.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/bae24173-e978-7c51-9e7b-968f6e19e1bf.png)

これは VSCode の `Your service runnning on port **** is available` ポップアップにつられて `Open in Browser` ボタンを押すと `localhost` ではなく `127.0.0.1` を使って接続するためでした

これだけのことで小一時間悩みました

# 画像のアップロード機能を追加する

今回は mypic というサービスを想定しているので、まずは画像アップロード機能を追加してみます

まずはストレージを追加します

例によって[ドキュメント](https://docs.amplify.aws/vue/build-a-backend/storage/set-up-storage/)に沿って実施

追加リソースが必要なので `storage/resource.ts` に追加します

```console
mkdir amplify/storage
vim amplify/storage/storage.ts
```

```resource.ts
import { defineStorage } from '@aws-amplify/backend';

export const storage = defineStorage({
    name: 'amplifyTeamDrive',
    access: (allow) => ({
      'profile-pictures/{entity_id}/*': [
        allow.guest.to(['read']),
        allow.entity('identity').to(['read', 'write', 'delete'])
      ],
      'picture-submissions/*': [
        allow.authenticated.to(['read','write']),
        allow.guest.to(['read', 'write'])
      ],
    })
  });
```

ここはドキュメントをそのままコピペしてしまいます

コードの通り、path に応じて権限が設定されているのがわかります

なお sandbox で実行しているので push はスキップします

当然ですがこの権限は S3 側には設定されていません

ちょっと本筋と逸れますが、S3 のバケットポリシーを見てみましょう

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": {
                "AWS": "*"
            },
            "Action": "s3:*",
            "Resource": [
                "arn:aws:s3:::amplify-amplifyvuetemplat-amplifyteamdrivebucket28-ABCDefgh1234",
                "arn:aws:s3:::amplify-amplifyvuetemplat-amplifyteamdrivebucket28-ABCDefgh1234/*"
            ],
            "Condition": {
                "Bool": {
                    "aws:SecureTransport": "false"
                }
            }
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::123456789012:role/amplify-amplifyvuetemplat-CustomS3AutoDeleteObjects-wxyz1234ABCD"
            },
            "Action": [
                "s3:PutBucketPolicy",
                "s3:GetBucket*",
                "s3:List*",
                "s3:DeleteObject*"
            ],
            "Resource": [
                "arn:aws:s3:::amplify-amplifyvuetemplat-amplifyteamdrivebucket28-ABCDefgh1234",
                "arn:aws:s3:::amplify-amplifyvuetemplat-amplifyteamdrivebucket28-ABCDefgh1234/*"
            ]
        }
    ]
}
```

乱数乱文字列は上書きしましたが、デフォルトの Amplify ロールからの操作権限しかないことがわかります

該当する IAM Role のポリシーを見てみましょう

```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::amplify-d1q82ko8i5l0ez-ma-amplifyteamdrivebucket28-kuud4recn5ce/profile-pictures/${cognito-identity.amazonaws.com:sub}/*",
                "arn:aws:s3:::amplify-d1q82ko8i5l0ez-ma-amplifyteamdrivebucket28-kuud4recn5ce/picture-submissions/*"
            ],
            "Effect": "Allow"
        },
        {
            "Condition": {
                "StringLike": {
                    "s3:prefix": [
                        "profile-pictures/${cognito-identity.amazonaws.com:sub}/*",
                        "profile-pictures/${cognito-identity.amazonaws.com:sub}/",
                        "picture-submissions/*",
                        "picture-submissions/"
                    ]
                }
            },
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::amplify-d1q82ko8i5l0ez-ma-amplifyteamdrivebucket28-kuud4recn5ce",
            "Effect": "Allow"
        },
        {
            "Action": "s3:PutObject",
            "Resource": [
                "arn:aws:s3:::amplify-d1q82ko8i5l0ez-ma-amplifyteamdrivebucket28-kuud4recn5ce/profile-pictures/${cognito-identity.amazonaws.com:sub}/*",
                "arn:aws:s3:::amplify-d1q82ko8i5l0ez-ma-amplifyteamdrivebucket28-kuud4recn5ce/picture-submissions/*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": "s3:DeleteObject",
            "Resource": "arn:aws:s3:::amplify-d1q82ko8i5l0ez-ma-amplifyteamdrivebucket28-kuud4recn5ce/profile-pictures/${cognito-identity.amazonaws.com:sub}/*",
            "Effect": "Allow"
        }
    ]
}
```

権限の制御は IAM Role で実施していることがわかりました

本筋に戻りましょう、`backend.ts` で import を追加します

```
vim amplify/backend.ts
```

```backend.ts
 import { data } from './data/resource';
+import { storage } from './storage/resource';
 
 defineBackend({
   auth,
   data,
+  storage,
 });
 ```

これで sandbox のデプロイが完了したら、ローカル開発のストレージは作成完了です

実際の機能を[ドキュメント](https://docs.amplify.aws/vue/build-a-backend/storage/upload-files/)に沿って実装していきます

```console
vim src/components/ImageUpload.vue
```

そのままだと動かないので最低限動くようにコードを補完します

`amplify/storage/resource.ts` で権限を分けているので、今回はゲストが読み込みのみ可能な path を選択します

```ImageUpload.vue
<template>
  <div class="file-uploader">
    <input type="file" id="file" />
    <button id="upload">Upload</button>
  </div>
</template>

<script>
import { onMounted } from "vue";
import { uploadData } from "aws-amplify/storage";

export default {
  name: "FileUpload",
  setup() {
    onMounted(() => {
      const file = document.getElementById("file");
      const upload = document.getElementById("upload");

      upload.addEventListener("click", () => {
        const fileReader = new FileReader();
        fileReader.readAsArrayBuffer(file.files[0]);

        fileReader.onload = async (event) => {
          console.log("Complete File read successfully!", event.target.result);
          try {
            await uploadData({
              data: event.target.result,
              path: ({identityId}) => `profile-pictures/${identityId}/${file.name}`,
            });
          } catch (e) {
            console.log("error", e);
          }
        };
      });
    });
  },
};
</script>
```

App.vue で取り込みます

```console
vim src/App.vue
```

```
      <template v-slot="{ signOut }">
        <Todos />
+       <ImageUpload />
        <button @click="signOut">Sign Out</button>
      </template>
```

スタイルは崩れたままですが機能は実装できていそうです

![Add Upload Form](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/21e44436-1eab-15d6-21e0-3e8fa18a2b79.png)

確認も兼ねて画像表示機能を追加します

# 自分のアップロードした画像を取得してみる

画像を取得する機能を[ドキュメント](https://docs.amplify.aws/vue/build-a-backend/storage/download-files/)に沿って実装していきます

これも Get Started と違ってコピペでは動かないので頑張って最低限のコードを書きます

権限制御は後回しにして、とりあえず自分のアップロードした画像を表示するように実装します

```MyPage.vue
<template>
  <div class="image-gallery">
    <h2>Uploaded Images</h2>
    <div v-for="(image, index) in images" :key="index">
      <img :src="image.url" />
    </div>
  </div>
</template>

<script>
import { list, downloadData } from "aws-amplify/storage";

export default {
  name: "ImageGallery",
  data() {
    return {
      images: [],
    };
  },
  async mounted() {
    try {
      // 画像リストを取得
      const fileListResponse = await list({
        path: ({ identityId }) => `profile-pictures/${identityId}/`,
      });

      // list の戻り値から items を取得
      const files = fileListResponse.items || [];
      console.log("File list: ", files);

      // 各画像の署名付き URL を取得
      const imagePromises = files.map(async (file) => {
        const downloadResult = await downloadData({
          path: file.path,
        }).result;
        const blob = await downloadResult.body.blob();
        const imageUrl = URL.createObjectURL(blob);
        return {
          url: imageUrl,
        };
      });

      // 非同期処理を全て解決
      this.images = await Promise.all(imagePromises);
      console.log("Images with signed URLs: ", this.images);
    } catch (error) {
      console.error("Error fetching images: ", error);
    }
  },
};
</script>

<style scoped>
.image-gallery {
  text-align: center;
  margin-top: 20px;
}

.image-gallery img {
  max-width: 300px;
  height: auto;
  border: 1px solid #ccc;
  border-radius: 5px;
}
</style>
```

`getUrl` ではなく `downloadData` を使うことで amplify がブラウザへキャッシュを指示してくれるようにします

なお[ドキュメント](https://docs.amplify.aws/vue/build-a-backend/storage/download-files/#frequently-asked-questions)にも記載のある通り、CDN 側での S3 コンテンツキャッシュは未実装とのことでした

> Image compression or CloudFront CDN caching for your S3 buckets is not yet possible.

App.vue で取り込みます

```console
vim src/App.vue
```

```App.vue
        <ImageUpload />
+       <MyPage />
        <button @click="signOut">Sign Out</button>
```

できました

![Get Images](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/32b052fa-bf5d-9306-0cdd-2efddd194af6.png)

# 本番へ反映してみる

できたコードを push します

```console
git commit -a
git push
```

Amplify 本番稼働ブランチでデプロイが進行します

Google 認証キーが無いと怒られてしまいました

![Secret Erorr](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/ff5f456c-c62c-4f34-f76a-72a9ea123eb9.png)

本番用の認証情報を追加で作成して、ホスティングのシークレットに登録します

手順はドキュメントの https://docs.amplify.aws/vue/deploy-and-host/fullstack-branching/secrets-and-vars/#set-secrets を参考

![Register Secrets](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/9efe1ff3-bf65-cac6-6a9c-6d937e2b2e25.png)

再デプロイ

デプロイが完了し、cognito のドメインが発行されているので Google の OAuth クライアントへ登録

本番ドメインでもアップロードと表示ができました！

![Successfully Upload and Download](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/1c45dc5d-f8f6-739c-04c6-9ea09229fe5c.png)

# キャッシュされているか確認してみる

キャッシュはデフォルトで以下のドキュメントの通りになっているそうです

https://docs.aws.amazon.com/ja_jp/amplify/latest/userguide/caching.html

S3 コンテンツは先述の通り CDN キャッシュされませんが、静的アセットは1年とのこと

どのコンテンツがキャッシュされているか確認してみます

ログイン後のページをキャッシュクリアして更新したところ、画像の通りディスクキャッシュがされていませんでした

![Network of logined page](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/ca71b0b3-0d47-c1a9-21ff-a47270deec88.png)

一方でこのログイン後のページをリロードした際には `downloadData` を使っているのでディスクキャッシュを使用してくれるようです

![Network of logined page reloaded](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3938927/e6a72451-7211-cabc-decf-bf34d707a8d5.png)

繰り返しになりますが、Amplify Gen2 ではコンテンツの CDN キャッシュを提供していないとのことです

容量の大きいコンテンツを扱う場合は CDN キャッシュだけ別の方法を検討したほうがいいかもしれません

(シンプルに CDN + S3 を作るだけで十分だと思います)

# DynamoDB のテーブルを見てみる

DynamoDB の設計は今回触っていない `amplify/data/resource.ts` に定義されています

```
import { type ClientSchema, a, defineData } from "@aws-amplify/backend";

const schema = a.schema({
  Todo: a
    .model({
      content: a.string(),
    })
    .authorization((allow) => [allow.publicApiKey()]),
});
```

content 列があるだけのシンプルなテーブルです

なおパーティションキーはデフォルトで `id` となっており、`identifier()` メソッドで定義できるようです

ただし権限が `publicApiKey()` となっています

[ドキュメント](https://docs.amplify.aws/react/build-a-backend/data/customize-authz/#global-authorization-rule-only-for-getting-started)でも

> (only for getting started)

とあるように、適切な権限ではなようです

またこの `resource.ts` は AppSync の定義も兼ねているので、DynamoDB と Appsync 双方の認証に関する挙動はドキュメントを読み込んだ方がいいなと感じました

# SRE な感想

インフラエンジニアとして SRE に取り組んでいるので、結びとしてその視点からの感想をいくつか

まず小規模なサービスをすぐ展開したい、というニーズには最高だと感じました

一方でアクセスや機能追加が増えてくるとクォータやコスト、負荷性能の問題は避けられないでしょう

ぱっと思いつくだけでも負荷周りでは以下事項を要検討かなと思います

- S3 コンテンツの CDN キャッシュ
- DynamoDB のインデックス設計
- スケーラブルなサービスの暖気 (ちょうど[こんなアップデート](https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-dynamodb-warm-throughput-ondemand-provisioned-tables/)が)
- DynamoDB をプロビジョニングへ変更
- AppSync をプロビジョニングへ変更

実際にサービスとして運用するなら以下も検討したいところです

- S3 のバージョニング
- DB のリカバリ戦略
- NoSQL から RDBMS への移行
- 各種バックアップ
- DDoS 対策

---

ご清覧ありがとうございました！

---

# 免責事項

- この記事の内容は個人の見解であり、所属する組織の意見を代表するものではありません。

- 一部のコードの出力に生成 AI を使用しています