やりたいこと
- モバイルアプリから、S3にファイルをアップロードしたい
- 動画など、サイズが大きいファイルもある
- 外部からはアップロードさせたくないので、S3はパブリックアクセスをブロックする設定にしておく
- なるべくサーバーレスで構築したい
方式検討
案1: S3に直接アップロード
大抵の言語には、S3アップロードのためのSDKがあると思います。これを使うというのが一番最初に思いつく方法です。
メリット
一番シンプルです。
デメリット
今回の要件ではS3はパブリックアクセスを無効にしているため、ファイルをアップロードするためには認証情報(アクセスキー)が必要です。つまり、モバイルから直接アップロードを実装する場合、認証情報はモバイルのコード内に埋め込まなければなりません。この点がセキュリティ的に致命的です。
また、モバイルのコード内にバケット名やキーも埋め込まなければいけない点、アップロードされるファイルのバリデーションなどが(サーバー側で)できない点もデメリットです。
自分一人しか使わないアプリでもない限り、この方法は採るべきではありません。
案2: API Gateway + Lambda
次に思いつくのは、アップロード用のAPIを用意する方法です。
AWSでAPIを手軽に作るとなれば、まず検討するのはAPI Gateway + Lambdaの構成でしょう。
モバイルからAPIに対して、ファイルを含むリクエストを送り、Lambda内でS3にアップロードを行うという構成になります。
メリット
Lambdaを使うので、認証やバリデーションの処理も一緒に行えるのがメリットです。
モバイルから見るとただのAPIなので、モバイルのコードがAWSインフラに依存しない点もよいです。
デメリット
API Gateway, Lambdaには、それぞれ扱えるリクエストのサイズの上限があります。
API Gatewayは10MB、Lambdaは6MBです。
(↑「ペイロードサイズ」欄)
(↑「呼び出しペイロード」欄)
今回は動画ファイルをアップロードしたいという要件があります。
動画ファイルは、物によりますが1分あたり100MB程度になるため、この方法は採用できません。
案3: 署名済みURLを発行する
S3には、署名済みURLという機能があり、一定時間だけ有効なURLを発行できます。
サーバーでアップロード用の署名済みURLを発行してモバイルに返し、モバイルはそのURLに対してPUTリクエストでファイルをアップロードする、という構成です。
URLを発行をするAPIは、案2のようにAPI Gateway + Lambdaでやってもよいですし、AppSyncのGraphQL APIを使ってもよいです。
今回は、既にAppSyncでAPIを構築していたので、AppSync + Lambdaリゾルバーという形をとることにしました。
メリット
LambdaやAppSyncを前段に挟むので、認証やバリデーションが行えます。
1回目のリクエストではファイル自体は送らないので、認証エラーなどの場合に余計な通信が発生しないのもメリットです。
サイズ制限も問題ありません。ファイル自体はS3に直接送るので、API GatewayやLambdaのサイズ制限を気にする必要はありません。
デメリット
少し複雑な構成になります。
モバイルから見ても、2回サーバーにリクエストを送らなければいけないのが煩雑かもしれません。
AWSインフラ(というかS3)への依存度も、案2と比べると上がります。S3から別のストレージに切り替える場合、モバイル側のコードも変更が必要になるでしょう。
結論:案3を採用
案1と案2には致命的なデメリットがあり採用できないため、消去法的に案3を採用することになります。
なお、既にECSやEC2でAPIを構築してある場合は、シンプルにそのAPIに機能追加でよいと思います。今回はインフラをサーバーレスで作っているため、こういう回りくどい方法になりました。
実装
上述の通り、URLを発行するAPIはAppSync + Lambdaで実装します。
まず、CDK(TypeScript)のコードから。
AppSync APIを作成する部分は割愛。
/**
* ファイルアップロードURL生成用のLambda関数を作成する
* @param api AppSync API
* @param bucket アップロード先のS3バケット
*/
createUploadLambda(
api: appsync.GraphqlApi,
bucket: Bucket
) {
const lambdaRole = new iam.Role(this, "ExecutionLambdaRole", {
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
iam.ManagedPolicy.fromAwsManagedPolicyName(
"service-role/AWSLambdaBasicExecutionRole"
),
],
});
lambdaRole.addToPolicy(
new iam.PolicyStatement({
actions: ["s3:PutObject"],
resources: [
`${bucket.bucketArn}/*`,
],
})
);
const createUploadUrlLambda = new NodejsFunction(
this,
"CreateUploadUrlLambda",
{
entry: path.join(__dirname, "lambda", "create-upload-url", "index.ts"),
handler: "handler",
runtime: lambda.Runtime.NODEJS_20_X,
role: lambdaRole,
environment: {
REGION: cdk.Stack.of(this).region,
BUCKET: bucket.bucketName,
EXPIRES_IN: "3600",
},
}
);
// ここから下はAppSync特有の処理
// AppSyncのデータソースとしてLambda関数を登録する
const createUploadUrlDs = api.addLambdaDataSource(
"CreateUploadUrlDs",
createUploadUrlLambda
);
const createUploadUrlFunction = new appsync.AppsyncFunction(
this,
"createUploadUrlFunction",
{
api: api,
dataSource: createUploadUrlDs,
name: "CreateUploadUrlFunction",
requestMappingTemplate: MappingTemplate.lambdaRequest(),
responseMappingTemplate: MappingTemplate.lambdaResult(),
}
);
new appsync.Resolver(this, "CreateUploadUrlResolver", {
api: api,
typeName: "Mutation",
fieldName: "createUploadUrl",
pipelineConfig: [createUploadUrlFunction],
requestMappingTemplate: MappingTemplate.fromString("$util.toJson({})"),
responseMappingTemplate: MappingTemplate.fromString(
"$util.toJson($ctx.prev.result)"
),
});
今回はAppSyncを使用するのでスキーマファイルも必要です。
# アップロード先のキーを決める情報
input CreateUploadUrlInput {
file_name: String!
resource_id: String!
}
# レスポンス
type PresignedUrl {
bucket: String
key: String
presignedUrl: AWSURL
}
type Mutation {
createUploadUrl(input: CreateUploadUrlInput!): PresignedUrl
}
Lambdaの中身は以下のようになります。
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AppSyncResolverHandler } from "aws-lambda";
import path from "node:path";
const client = new S3Client({
region: process.env.REGION,
});
const getPresignedUrl = async (
bucket: string,
key: string,
expiresIn: number
): Promise<string> => {
const objectParams = {
Bucket: bucket,
Key: key,
StorageClass: "INTELLIGENT_TIERING",
};
const signedUrl = await getSignedUrl(
client,
new PutObjectCommand(objectParams),
{ expiresIn }
);
console.log(signedUrl);
return signedUrl;
};
export const handler: AppSyncResolverHandler<any, any> = async (event) => {
const { file_name, resource_id, target } = event.arguments.input;
const { REGION, BUCKET, EXPIRES_IN } = process.env;
if (
!REGION ||
!BUCKET ||
!EXPIRES_IN ||
isNaN(Number(EXPIRES_IN))
) {
throw new Error("invalid environment values");
}
const key = buildKey(file_name, resource_id);
const expiresIn = Number(EXPIRES_IN);
const url = await getPresignedUrl(BUCKET, key, expiresIn);
return {
bucket: BUCKET,
key: key,
presignedUrl: url,
};
};
// keyを組み立てる
const buildKey = (file_name: string, resource_id: string): string => {
// .mp4
const extension = path.extname(file_name);
// hoge
const stem = path.basename(file_name, extension);
// YYYYMMDDhhmmsszzz
const ts = new Date().toISOString().replace(/\D/g, "").slice(0, -3);
return `${resource_id}/${stem}_${ts}${extension}`;
};
動作確認
GraphQLエンドポイントに対して、以下のリクエストを投げます。
mutation MyMutation {
createUploadUrl(input: {file_name: "hoge.mp4", resource_id: "1234"}) {
bucket
key
presignedUrl
}
}
レスポンスが以下。
{
"data": {
"createUploadUrl": {
"bucket": "my-bucket-name",
"key": "1234/hoge_20240524102405.mp4",
"presignedUrl": "https://my-bucket-name.s3.ap-northeast-1.amazonaws.com/1234/hoge_20240524102405.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=XXXXXXXX&X-Amz-Date=20240524T102405Z&X-Amz-Expires=3600&X-Amz-Security-Token=XXXXXXXX&X-Amz-Signature=XXXXXXXX&X-Amz-SignedHeaders=host&x-amz-storage-class=INTELLIGENT_TIERING&x-id=PutObject"
}
}
}
返ってきたpresignedUrlに対してPUTリクエストを投げることでアップロードできます。
curl -X PUT -H "Content-Type: video/mp4" --data-binary @hoge.mp4 https://my-bucket-name.s3.ap-northeast-1.amazonaws.com/1234/hoge_20240524102405.mp4?...
参考