ファイルアップロードの理想像を求めて 1. システム概要編

Agaroot IT Partners(AITP)のtomoです。

システム開発において、「ファイルをアップロードする」「アップロードされたファイルが一般ユーザーに閲覧されるようにする」といった要件が生じることも多いのではと思います。例えばSNSアプリで各ユーザーがアイコンをアップロードし、そのアイコンが画面に表示されるなど。

そういったファイルのアップロードについて、現時点で私が考えているベストプラクティスを紹介できればと思います。

なお、今回は取り扱う内容が多いので以下の通りに3本の記事に分ける予定です。

  1. (本記事) システム概要編
    レガシーシステムの課題のほか今回作成するシステムの構成、ソースコードなどについてお伝えします。
  2. 署名付きURL編
    ファイルをアップロードする際の処理にフォーカスします。特にS3の署名付きURLについてより詳らかに説明する予定です。
  3. cdk-serverless-clamscan編
    ファイルがアップロードされた後そのファイルが一般ユーザーに閲覧されるまでの処理にフォーカスします。特にcdk-serverless-clamscanについてより詳らかに説明する予定です。

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

  • ファイルアップロードのシステム構成を検討されている方
  • ファイルアップロードによる負荷上昇にお悩みの方
  • アップロードされたファイルにマルウェアがないかご心配の方

要約

  • 今回紹介するファイルアップロードのシステム構成では、高負荷にも耐えられる(はず)
  • ファイルを一般公開する前にウイルスチェックも実施
  • 後ほど執筆、公開する2本の記事でシステムの詳細を述べますので乞うご期待(本記事では触りだけ)

レガシーシステムの問題点

今回作成するシステムについて触れる前によくありそうな(あくまでも私見に基づいた話なので実際にはそうでないかもしれません)、従来のシステムの構成やその問題点について触れていきたいと思います。

システム構成図

主な処理の流れ

  1. WEBサーバーにファイルアップロード
    ファイル所有者がWEBサーバーにファイル送信を行います。ファイル送信はWEBサーバーで稼働しているApacheやNginxなどのWEBサーバーソフトウェア、PHPやPythonなどの言語やフレームワークなどのシステムによって成立していることでしょう。
  2. (オプション) ウイルスチェック
    必要に応じてウイルスチェックが行われるかと思います。WEBサーバーにアップロードされたファイルをアップロード時またはCronなどによるスケジュール駆動で実施します。ウイルスチェックでウイルスが検知されたファイルはその詳細がログなどに出力された後、削除されるなどして少なくとも一般ユーザーに閲覧されないようにされるかと思います。
    Cronなどによるスケジュール駆動によりウイルスチェックを行う場合、バッチサーバーを別途設ける場合もあるでしょう。
  3. ファイル閲覧
    ファイルを閲覧したいユーザーによってファイル表示のリクエストがWEBサーバーに送信、ファイルが表示されるかと思います。

問題点

  • ファイルアップロード時の容量制限
    WEBサーバーソフトウェアや言語などの設定により数MB~10数MBしかアップロードできない場合も多いでしょう。大抵のテキストファイルや画像ファイルなどならば問題ないかもしれませんが、高画質の画像ファイルや動画ファイルなどをアップロードする場合はこの容量制限がネックになるでしょう。
    またそれ以上の容量のファイルアップロードを許容するように設定すると、サーバーのスペックやファイルアップロードの頻度などによってはサーバーのストレージ逼迫などを引き起こしかねません。
  • ウイルスチェック未処理のファイル
    WEBサーバー上にアップロードする場合、どうしてもウイルスチェック未処理のファイルがサーバーに存在する時間が発生します。アップロード処理時にウイルスチェックを行うならばともかく、スケジュール駆動で行う場合には、ウイルスチェック未処理のファイルをユーザーが閲覧する可能性も高まるでしょう。
  • ウイルスチェックをファイルアップロード時に行う場合のコード保守性
    ウイルスチェックをファイルアップロード時に行い、かつチェック処理をファイルアップロード時の処理と同じソースコードに実装する場合、ソースコードの保守性が下がる原因になりかねません。個々の関数、ソースコードで行う処理が多ければ多いほど可読性という意味でもテストのしやすさという意味でも不利に働いてしまうでしょう。
  • ファイル表示によるWEBサーバー負荷
    アップロードされたファイルの表示も、ファイルの容量や表示回数などによってはWEBサーバーの負荷増大につながる可能性があります。
    ファイルがユーザーによってアップロードされたり表示されたりするシステムのユースケースとしては、SNSなど、¥ファイルへのアクセスが多くなりがちなものも多いかと思います。こういった負荷が、他処理も行っているWEBサーバーの負荷を高めてしまいかねません。

今回作成するシステム

実現したいこと

ここからは、レガシーシステムの問題点を踏まえた上で、どのような形に改善していきたいか、理想をつらつらと書いていこうかと思います。

  • WEBサーバーとファイルサーバーを分ける
    WEBサーバーとファイルサーバーを分ければ、WEBサーバーのストレージ逼迫を回避することができます。
    また、ファイルアップロードをフロントエンドから(WEBサーバーを介さず)直接ファイルサーバーに行うようにすれば、アップロード時の容量制限やWEBサーバー(大抵ファイルサーバーよりも多くの、ソースコードなどの機密情報などがある)にウイルスチェック未処理のファイルが配置されることのリスクを回避することができます。
  • ウイルスチェックが完了するまでファイルは閲覧できないようにする
    ウイルスチェック未処理の、問題があるかもしれないファイルはユーザーにより閲覧できないようにしたいです。
    もっといえば、たとえば閲覧リクエストを受けたアプリ側でチェック処理済みかどうか確認する実装を加えるよりも、ファイルサーバーの方で、ノーコードでこの問題を解決できた方が嬉しいです。
    そのためにはファイルサーバーを2つ作成し、ファイルアップロード時には1つ目のインプット用ファイルサーバーに配置、その後ウイルスチェックが行われて、問題なければもう一つの、アウトプット用のファイルサーバーに複製されるようにしたいです。
  • CDNを用いる
    上記の通り、WEBサーバーとファイル表示を行うアウトプット用ファイルサーバーを分けるので、ファイル表示時の負荷がWEBサーバーにも影響を与えることはありません。
    さらに、アウトプット用ファイルサーバーへの負荷を減らすためCDNを用いることができれば、高負荷にも耐えられるシステム構成が実現できます。
  • サーバーレス&IaCを実現したい
    ファイルアップロードの処理のシステムに限った話ではありませんが、できればサーバーレス&IaCを実現しインフラ管理の負担を低減したいところです。
  • 原子性の確保
    これまたファイルアップロードの処理のシステムに限った話ではありませんが、1回の処理、ソースコードで行う内容は可能な限り最低限にとどめたいです。
    より具体的にはファイルアップロード~ウイルスチェック~公開用ファイルサーバーへのファイル複製を無理に1つのソースコード、関数で行おうとせず、それぞれを別処理に分けて各処理をイベント駆動で繋いでいくように(処理Aが終わったらそれを合図に処理Bが行われ、処理Bが終わったらそれを合図に処理Cが行われていく、といったイメージで)したいです。
    そうすることにより可読性が高くテストも行いやすい保守性の高いシステムになるでしょう。

システム構成図

今回作成する予定のシステムは全体で以下のような構成になる予定です。
本記事では詳細の説明は省きますが後ほど執筆、公開予定の2本の記事を通して内容の説明ができればと思います。

ソースコード

今回はCDK(TypeScript)を用います。
システム構成図と同じく詳細は後ほど公開予定の2本の記事にて説明できればと思います。
アプリ名はcdk-test-appとしていますが、この点は必要に応じて適宜変えていただいてもちろん問題ございません。

なお、一部下記ページを参考にしております。

package.json

{
  "name": "cdk-test-app",
  "version": "0.1.0",
  "bin": {
    "cdk-test-app": "bin/cdk-test-app.js"
  },
  "scripts": {
    "build": "tsc",
    "watch": "tsc -w",
    "test": "jest",
    "cdk": "cdk"
  },
  "devDependencies": {
    "@types/jest": "^29.4.0",
    "@types/node": "18.14.6",
    "aws-cdk": "2.78.0",
    "jest": "^29.5.0",
    "ts-jest": "^29.0.5",
    "ts-node": "^10.9.1",
    "typescript": "~4.9.5"
  },
  "dependencies": {
    "@aws-sdk/client-s3": "^3.335.0",
    "@aws-sdk/s3-request-presigner": "^3.335.0",
    "@types/aws-lambda": "^8.10.115",
    "aws-cdk-lib": "2.78.0",
    "aws-lambda": "^1.0.7",
    "cdk-serverless-clamscan": "^2.4.240",
    "constructs": "^10.0.0",
    "source-map-support": "^0.5.21"
  }
}

CDKスタック

import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
import { RestApi, LambdaIntegration } from "aws-cdk-lib/aws-apigateway";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { LambdaDestination } from "aws-cdk-lib/aws-lambda-destinations";
import { join } from "path";
import { ServerlessClamscan } from "cdk-serverless-clamscan";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import { S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";
import * as iam from "aws-cdk-lib/aws-iam";

export class CdkTestAppStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const inputBucket = this.createBucket("cdk-test-app-input-bucket");

    const outputBucket = this.createBucket("cdk-test-app-output-bucket");

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

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

    const fileGetLambda = this.createLambda(
      "fileGet",
      join(__dirname, "services", "file", "Get.ts"),
      { INPUT_BUCKET_NAME: inputBucket.bucketName }
    );
    inputBucket.grantWrite(fileGetLambda);
    const fileGetLambdaIntegration = new LambdaIntegration(fileGetLambda);
    fileResource.addMethod("GET", fileGetLambdaIntegration);

    const fileMoveLambda = this.createLambda(
      "fileMove",
      join(__dirname, "workers", "file", "Move.ts"),
      { OUTPUT_BUCKET_NAME: outputBucket.bucketName }
    );

    inputBucket.grantRead(fileMoveLambda);
    outputBucket.grantPut(fileMoveLambda);
    new ServerlessClamscan(this, "S3ClamAV", {
      buckets: [inputBucket],
      onResult: new LambdaDestination(fileMoveLambda),
    });

    const cloudFrontIdentity = new cloudfront.OriginAccessIdentity(
      this,
      "CdkTestAppIdentity"
    );
    const outputBucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [
        new iam.CanonicalUserPrincipal(
          cloudFrontIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${outputBucket.bucketArn}/*`],
    });
    outputBucket.addToResourcePolicy(outputBucketPolicyStatement);

    new cloudfront.CloudFrontWebDistribution(this, "CdkTestAppDist", {
      originConfigs: [
        {
          s3OriginSource: {
            s3BucketSource: outputBucket,
            originAccessIdentity: cloudFrontIdentity,
          },
          behaviors: [
            {
              isDefaultBehavior: true,
              viewerProtocolPolicy:
                cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
            },
          ],
        },
      ],
    });
  }

  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;
  };

  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関数

import {
  APIGatewayProxyHandlerV2,
  APIGatewayProxyEventV2,
  APIGatewayProxyResultV2,
} from "aws-lambda";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const client = new S3Client({});
const INPUT_BUCKET_NAME = process.env.INPUT_BUCKET_NAME!;

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

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

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

  return result;
};
import { Handler } from "aws-lambda";
import { S3Client, CopyObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

const client = new S3Client({});
const OUTPUT_BUCKET_NAME = process.env.OUTPUT_BUCKET_NAME!;

export const handler: Handler = async (event: any): Promise<any> => {
  const result = event.responsePayload.status === "CLEAN";

  console.log(event);
  console.log(`ウイルスチェック結果: 検知${result ? "なし" : "あり"}`);

  if (result) {
    const command = new CopyObjectCommand({
      Bucket: OUTPUT_BUCKET_NAME,
      Key: event.responsePayload.input_key,
      CopySource: `${event.responsePayload.input_bucket}/${event.responsePayload.input_key}`,
    });
    await client.send(command);
  }

  return result;
};

前述の通り、システム構成やCDKコードの詳細は後ほど公開予定の2本の記事にて説明する予定です。ぜひ、続編をお楽しみにして頂ければ幸いです👋

p.s.
以下の通り続編を公開しました。よければご一読ください。

関連するタグ