ファイルアップロードの理想像を求めて 2. 署名付きURL編

Agaroot IT Partners(AITP)のtomoです。

以前投稿した記事の続き、ファイルアップロードシステムについての記事第2弾になります。
以前の記事では従来のシステムの問題点を解決するためのアイデアやソースコードを紹介しました。
本記事ではファイルをアップロードする際の処理にフォーカスします。特にS3の署名付きURLについてより詳しく説明する予定です。

この記事はこのような方におすすめ

  • 大容量ファイルのアップロード処理を実装したい方
  • セキュリティなどの観点から、WEBサーバなどに外部からアップロードされたファイルを配置したくない方

要約

  • S3の署名付きURLを利用すれば、ファイルを直接S3にアップロードできる

ファイルアップロード処理の主な課題と解決方法

以前投稿した記事で記載した内容とも一部重複しますが、復習も兼ねてファイルアップロード処理を行う際の、解決すべき主な課題とその解決方法について説明できればと思います。

主な課題

  • アップロードリクエストにおけるファイルサイズ制限の問題
    アップロード時のファイルサイズ制限により、一部のファイルはアップロードそのものが困難になるでしょう。
  • ストレージの逼迫
    WEBサーバーなどのストレージがファイルのせいで逼迫してしまいます。
  • ウイルスチェック未処理のファイルがWEBサーバーに配置されるリスク
    マルウェアの可能性のあるファイルはなるべくWEBサーバーに配置したくありません。

解決方法

上記の課題を解決するにはどのような方法が考えられるでしょうか? 結論から話すと、クラウドストレージに直接ファイルをアップロードを行うことで解決できるかと思います。

クラウドストレージにファイルを配置すれば、WEBサーバーなどのストレージの逼迫の問題は起きませんし、WEBサーバーにウイルスチェック未処理のファイルが配置される事態を避けることができます。

AWSで実現するとすれば、もちろんクラウドストレージはS3になるかと思います。
S3であれば、ストレージ全体の容量とファイル数は事実上無制限です。
アップロード時のファイルサイズ制限も、通常は5GBまでとなります。5GBもあれば大抵のファイルは十分にアップロード可能でしょう。
S3のアップロードについて詳細をご覧になりたい方は、以下のページをご参照ください。
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/upload-objects.html

署名付きURL

ファイルをS3にアップロードする方法の1つがS3の署名付きURLの利用です。
署名付きURLとは、S3バケットへのGetObject(ファイルの閲覧やダウンロード)やPutObject(ファイルのアップロード)を実行できるURLです。URLを作成したIAMユーザーやIAMロールの権限で実行することができます。

署名付きURLを用いれば、以下の2ステップでファイルをアップロードが可能になります。

  1. フロントエンドなどからAPIを呼び出す
    そのAPIの中で署名付きURLを作成、フロントエンドに返却します。
  2. 返却されたURLを用いてファイルをアップロードする
    PutObjectを行う際にはメソッドをPUT、リクエストボディをファイルにすれば大丈夫です。

まだイメージが掴めていない方も多いかと思いますが、実際に実装や動作確認を行えば理解も早いかと思います。
ということで、本記事では署名付きURLを用いたシステムの実装方法を説明していきます。

CDKで実現するには

CDKでS3バケットそのものや、署名付きURLを作成するAPIを作る方法を紹介できればと思います。コードの全体は前回の記事にて紹介していますので、本記事ではその中から重要な点を抜粋していきます。

S3バケットの作成

まずはS3バケット自体を作成していきましょう。以下コードをご覧ください。

    const inputBucket = this.createBucket("cdk-test-app-input-bucket");
    // 中略
 private createBucket = (name: string): Bucket => {
    const shortStackId = cdk.Fn.select(2, cdk.Fn.split("/", this.stackId));
    const suffix = cdk.Fn.select(4, cdk.Fn.split("-", shortStackId));

    const bucket = new Bucket(this, name, {
      encryption: BucketEncryption.S3_MANAGED,
      enforceSSL: true,
      bucketName: `${name}-${suffix}`,
      publicReadAccess: false,
      blockPublicAccess: {
        blockPublicAcls: true,
        blockPublicPolicy: true,
        ignorePublicAcls: true,
        restrictPublicBuckets: true,
      },
      versioned: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    return bucket;
  };

Bucketインスタンスを新規作成することでS3バケットが立ち上がります。

Bucketインスタンスを新規作成する際、3つ目の引数(Props)の中でバケット名(bucketName)を指定する必要があります。世界で一意の名前にする必要があるので、スタックIDを含めるなどして対応しましょう。

バケット名以外のProps内の指定はせずともS3バケット作成可能なのですが、以下の項目についてはセキュリティなどの観点から指定しています。問題ない限り、同じ設定をされることを推奨します。

  • encryption
  • enforceSSL
  • publicReadAccess
  • blockPublicAccess

各プロパティの詳細は、下記をご参照ください。
https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_s3.Bucket.html

APIの作成

今度は署名付きURLを作成、返却するAPIを作ります。

CDKスタックでのAPIリソースの作成と、Lambda関数のコードを順に見ていきましょう。

CDKスタック

まずは下記のコードでAPI GatewayのREST APIを作成します。
さらにそのAPIにfileというリソースを追加していきます。
これにより、「{トップURL}/file」というパスでリクエストが受け付けられるようになります。

    const api = new RestApi(this, "CdkTestAppApi");

    const fileResource = api.root.addResource("file");

次に、Lambda関数を作成していきます。(特にTypeScriptで)Lambda関数を作成するには、NodejsFunctionのインスタンスを新規作成します。新規作成する際には、3つ目の引数(Props)で以下項目などを適宜指定します。

  • entry: Lambda関数のコードとなるJavaScriptかTypeScriptのファイルパス
  • handler: 処理を行うハンドラーと言われる関数の名前(handler)
  • functionName: Lambda関数名
  • environment: 環境変数

今回は作成したLambda関数の中でS3バケットにPutObject(アップロード)を行う処理を行うので、作成したLambda関数にS3バケットへの書き込み権限を追加します。

    const fileGetLambda = this.createLambda(
      "fileGet",
      join(__dirname, "services", "file", "Get.ts"),
      { INPUT_BUCKET_NAME: inputBucket.bucketName }
    );
    inputBucket.grantWrite(fileGetLambda);
    // 中略
  private createLambda = (
    id: string,
    entry: string,
    environment?: { [key: string]: string },
    handler?: string
  ): NodejsFunction =>
    new NodejsFunction(this, id, {
      entry,
      handler: handler || "handler",
      functionName: id,
      environment,
    });

最後に、下記コードでLambda関数とAPIとを紐づけます。今回はファイルをアップロードする前の署名付きURLを取得するので、GETメソッドを使うこととしましょう。

    const fileGetLambdaIntegration = new LambdaIntegration(fileGetLambda);
    fileResource.addMethod("GET", fileGetLambdaIntegration);

Lambda関数

まずは、そもそもどのようにLambda関数の処理を実装していくのか説明できればと思います。

Lambdaではハンドラーと呼ばれる、エクスポートされて外部から呼び出せるようにした関数を実装していきます。
ハンドラーの関数名は何でもいいのですが、通例ではhandlerとします。
ハンドラーは引数にEventを受け取ります(任意でContextを受け取ることもできます)。Eventの型は、Lambda関数が何から呼び出されるのかによって変わってきます。例えばAPI Gatewayから呼び出される場合にはAPIGatewayProxyEventV2となります。
戻り値も同じように、Lambda関数が何から呼び出されるのかによって型が変わってきます。API Gatewayから呼び出される場合にはAPIGatewayProxyResultV2とします。

export const handler: APIGatewayProxyHandlerV2 = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  const result: APIGatewayProxyResultV2 = {
    statusCode: 200,
    body: JSON.stringify({}),
  };
// 中略
  return result;
};

次に、S3などAWSリソースをAWS SDK(v3)を用いて操作する方法について紹介します。以下のコードをご覧ください。
AWS SDKでは、まず操作を実行するClientというオブジェクトのインスタンスと、操作を定義するCommandというオブジェクトのインスタンスを作成します。
ClientではリージョンやIAM認証情報などの指定、Commandでは各操作の内容の指定が行えます。
最後にClientsendメソッドを実行すれば、操作が実行されます。

/**
AWS SDKを用いる方法について説明するため改めて作成したコードで、
今回のシステム実装のためのコードではないのでご注意ください
**/
const client = new S3Client({});
const command = new PutObjectCommand({
  Bucket: 'demo-bucket',
  Key: 'demo-key.txt',
  Body: 'This is a demo content.' 
});
const result = await client.send(command);

S3バケットにファイルをアップロードする場合には、上記の通りS3ClientPutObjectCommandを実行すれば良いです。

ただし今回のシステムでは即座に操作を実行するのではなく、S3の署名付きURLを作成するのでsendメソッドは用いません。
代わりにgetSignedUrl関数を使って署名付きURLを使います。必要に応じて、expiresInでURLの有効期限を秒単位で指定してください。
getSignedUrl関数の詳細は下記ページをご参照ください。今回の実装でも参考にさせていただきました。
https://aws.amazon.com/jp/blogs/developer/generate-presigned-url-modular-aws-sdk-javascript/

今回の実装では署名付きURL作成処理を下記のように実装しております。

const client = new S3Client({});
const INPUT_BUCKET_NAME = process.env.INPUT_BUCKET_NAME!;
// 中略
  const command = new PutObjectCommand({
    Bucket: INPUT_BUCKET_NAME,
    Key: event.queryStringParameters!.filename,
  });
  const url = await getSignedUrl(client, command, {
    expiresIn: 60,
  });

上記コードについて、触れていなかった2つの点について説明させてください。

  • S3バケット名は環境変数から取得
    S3バケット名(INPUT_BUCKET_NAME)は環境変数(Lambda関数を作成する際に指定したenvironmentの値)から取得しております。
  • キーはクエリパラメーターから取得
    このAPIはGETメソッドなので、S3のキー指定をクエリパラメーターから取得するようにしました。
    クエリパラメーターはEventqueryStringParametersというインスタンス変数から取得可能です。これで「{トップURL}/file/?filename={指定したいファイル名}」でファイル名を指定することができます。

今回はファイルアップロード処理の実装方法を説明することに焦点を当てるため行いませんでしたが、必要に応じて下記処理なども追加してください(もしかしたらまたいつか別記事で方法を説明するかもしれません)。

  • ファイル名チェック(特に「/」が含まれていないこと)
  • メディア種別チェック
  • キー重複チェック(既存ファイルが同じキーで保存されていないかのチェック)
  • CORS対応

最後に、取得したURLをレスポンスボディの中に含めましょう。

  result.body = JSON.stringify({ url });

これで、レスポンスボディが以下のような形式になります。

{"url": "作成されたS3署名付きURL"}

作成したシステムを試してみよう

上記の通り実装を行ってデプロイも終わったら、早速ファイルがアップロードできるかどうか試してみましょう。テキストファイルや画像ファイルなどを用意した上で、以下コマンドを実行してみてください。

$ curl "{APIのトップURL}/file/?filename={ファイル名}"
$ curl -X PUT "{署名付きURL}" --upload-file {ファイルパス}

S3バケットを見てみると、アップロードされたファイルが存在するかと思います。

まとめ

Lambda関数の実装方法など、基本となる点も含めて説明したこともあって長文となり失礼いたしました。

説明こそ長文ではございましたが、実装自体は短めで(自分で言うのも何ですが)わかりやすくシンプルな内容だったかと思います。
このようなシンプルな内容ですが、これでS3に直接アップロードを行うことができるようになりました。ファイルアップロードの呪縛からの解放です。

次の記事では、S3にアップロードされたファイルをウイルスチェックを行い、問題なければファイルを公開する方法を説明できればと思います。
今回の記事の内容と合わせて実践することで、理想のファイルアップロードシステムが実現できるかと思います。乞うご期待ください👋

関連するタグ