13
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-12-18

前書き

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

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

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

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

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

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

Amplify のセットアップ

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

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

Create GitHub Repo. from Amplify Console

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

検証段階なので Private に設定

Create GitHub Repo.

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

Authorize Amplify

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

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

Select Repo.

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

Deploy Settings

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

Starting Deploy

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

Default Website

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

Deploy Completed

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

Initial Website

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

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

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

  • 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 を追加

動かしてみる

npm run dev

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

Created Login Form

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

git commit -am "added authenticator"
git push

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

AAutomatic Deploy

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

sandbox を使ってみる

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

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

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

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

aws configure

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

npx ampx sandbox

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

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

Manage Sandbox

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

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

ドキュメント によると --identifier で名前を付けられるようなので、運用する際には指定してみようと思います

Google ログインの追加

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

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

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

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

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/"],
+   }
  },
});

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

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

Real-time Deploy on Sandbox

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

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

(裏の動きまでちゃんと見ていないですが。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

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

npm run dev

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

Login Form with Goole Auth

ドメインの指定でハマる

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

スクリーンショット 2024-11-21 081306.png

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

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

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

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

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

例によってドキュメントに沿って実施

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

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 のデプロイが完了したら、ローカル開発のストレージは作成完了です

実際の機能をドキュメントに沿って実装していきます

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 で取り込みます

vim src/App.vue
      <template v-slot="{ signOut }">
        <Todos />
+       <ImageUpload />
        <button @click="signOut">Sign Out</button>
      </template>

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

Add Upload Form

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

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

画像を取得する機能をドキュメントに沿って実装していきます

これも 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 がブラウザへキャッシュを指示してくれるようにします

なおドキュメントにも記載のある通り、CDN 側での S3 コンテンツキャッシュは未実装とのことでした

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

App.vue で取り込みます

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

できました

Get Images

本番へ反映してみる

できたコードを push します

git commit -a
git push

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

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

Secret Erorr

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

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

Register Secrets

再デプロイ

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

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

Successfully Upload and Download

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

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

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

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

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

Network of logined page

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

Network of logined page reloaded

繰り返しになりますが、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() となっています

ドキュメントでも

(only for getting started)

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

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

SRE な感想

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

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

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

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

  • S3 コンテンツの CDN キャッシュ
  • DynamoDB のインデックス設計
  • スケーラブルなサービスの暖気 (ちょうどこんなアップデートが)
  • DynamoDB をプロビジョニングへ変更
  • AppSync をプロビジョニングへ変更

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

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

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


免責事項

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

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

13
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?