7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS S3でファイルを保存する方法【NestJS対応】バケット作成から画像アップロード・CDN配信まで

7
Posted at

Amazon S3とは?

aws.jpg

Amazon S3 は、オブジェクトストレージサービスであり、以下の特徴を持っています。

  • 業界最高レベルのスケーラビリティ、可用性、セキュリティ、パフォーマンスを提供
  • データレイク、Webサイト、クラウドネイティブアプリ、バックアップ、アーカイブ、機械学習、分析など、さまざまな用途で利用可能
  • 99.999999999%(11ナイン)の耐久性を持ち、世界中の数百万のユーザーに利用されている

👉 簡単に言うと
**「どんなファイルでも、安全に・無限に近い容量で保存できるクラウドストレージ」**です。


S3の基本構造(ざっくり理解)

S3は以下のようなシンプルな構造です:

  • Bucket(バケット):ファイルを保存する箱
  • Object(オブジェクト):実際のファイル(画像・動画など)
  • Key:ファイルのパス(例:images/avatar.png

Use Cases(ユースケース)

S3は非常に幅広い用途で使われます。代表的なユースケースを紹介します。


1. データレイクの構築(Build a Data Lake)

ビッグデータ分析、AI、機械学習(ML)、HPC(高性能計算)などの用途で、
大量データを保存・分析する基盤として利用されます。

👉 データを一元管理し、価値あるインサイトを引き出すことが可能


2. クラウドネイティブアプリの実行(Run Cloud-Native Applications)

モバイルアプリやWebアプリの静的ファイル(画像・JS・CSSなど)をS3に配置し、
スケーラブルな構成で配信できます。

👉 高可用性かつ自動スケールするアプリ構築が可能


3. バックアップとリストア(Backup and Restore Critical Data)

重要なデータのバックアップ保存先として利用されます。

  • RTO(復旧時間目標)
  • RPO(復旧時点目標)

などの要件を満たしつつ、安全にデータを保管できます。

👉 災害対策(DR)としても非常に重要


4. 低コストでのアーカイブ(Archive Data at the Lowest Cost)

使用頻度の低いデータを S3 Glacier などに移動することで、
コストを大幅に削減できます。

👉 長期保存(ログ・監査データなど)に最適


バケット作成(詳細手順)

S3を使うために、まず「バケット」を作成します。

手順:

  1. AWSコンソール → S3を開く
  2. 「バケットを作成」をクリック
  3. 以下を設定:
  • バケット名:グローバルで一意(例:my-app-bucket-123

  • リージョンap-northeast-1(東京)

  • パブリックアクセス設定

    • 開発環境 → 一部許可でもOK
    • 本番 → 基本はBlock推奨

4.「作成」をクリック


バケットポリシー(公開する場合)

画像を公開したい場合は、以下のポリシーを設定:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicRead",
      "Effect": "Allow",
      "Principal": "*",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::my-app-bucket-123/*"]
    }
  ]
}

NestJSでS3にアップロードする

実務では、バックエンド(NestJS)からS3にアップロードすることが多いです。


1. パッケージインストール

npm install @aws-sdk/client-s3

2. S3サービスを作成


2.1 S3設定(config)

AWSの接続設定をまとめます。

import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class S3ConfigService {
  constructor(private readonly configService: ConfigService) {}

  private requireNonEmpty(key: string): string {
    const value = this.configService.getOrThrow<string>(key);
    if (!value || value.trim() === '') {
      throw new Error(`Configuration "${key}" is required but was empty.`);
    }
    return value;
  }

  get region(): string {
    return this.requireNonEmpty('AWS_S3_REGION');
  }

  get accessKeyId(): string {
    return this.requireNonEmpty('AWS_S3_ACCESS_KEY_ID');
  }

  get secretAccessKey(): string {
    return this.requireNonEmpty('AWS_S3_SECRET_ACCESS_KEY');
  }

  get bucketName(): string {
    return this.requireNonEmpty('AWS_S3_BUCKET_NAME');
  }

  get endpoint(): string | undefined {
    const value = this.configService.get<string>('AWS_S3_ENDPOINT');
    return value && value.trim() !== '' ? value : undefined;
  }
}

2.2 ヘルパー(helper)

ファイルキーやURL生成ロジックを分離します。

import {
  S3Client,
  PutObjectCommand,
  type PutObjectCommandInput,
  type S3ClientConfig,
} from '@aws-sdk/client-s3';
import { v4 as uuidv4 } from 'uuid';

export const createS3Client = (config: S3ClientConfig): S3Client =>
  new S3Client(config);

export const createPutObjectCommand = (
  input: PutObjectCommandInput,
): PutObjectCommand => new PutObjectCommand(input);

export const generateUuid = (): string => uuidv4();


2.3 S3サービス(service)

実際のアップロード処理を行います。

import { Injectable, BadRequestException } from '@nestjs/common';
import type { S3Client } from '@aws-sdk/client-s3';
import { S3ConfigService } from '../common/config/s3.config';
import {
  createS3Client,
  createPutObjectCommand,
  generateUuid,
} from './s3.helpers';

export type UploadType = 'image' | 'video';

/** S3 key prefix segment: `public/uploads/...`. */
export type UploadScope = 'public';

interface UploadedFile {
  buffer: Buffer;
  mimetype: string;
  size: number;
  originalname: string;
}

@Injectable()
export class UploadService {
  private readonly s3Client: S3Client;

  constructor(private readonly s3Config: S3ConfigService) {
    const region = this.s3Config.region;

    const client = createS3Client({
      region,
      credentials: {
        accessKeyId: this.s3Config.accessKeyId,
        secretAccessKey: this.s3Config.secretAccessKey,
      },
      endpoint: this.s3Config.endpoint,
      forcePathStyle: !!this.s3Config.endpoint,
    });
    this.s3Client = client;
  }

  async uploadFile(
    file: UploadedFile | undefined,
    type: UploadType,
    scope: UploadScope,
  ): Promise<{ url: string; type: UploadType }> {
    if (!file) {
      throw new BadRequestException('upload.file_missing');
    }

    this.validateFile(file, type);

    const key = this.buildObjectKey(file, type, scope);

    const command = createPutObjectCommand({
      Bucket: this.s3Config.bucketName,
      Key: key,
      Body: file.buffer,
      ContentType: file.mimetype,
    });

    await this.s3Client.send(command);

    const url = this.buildPublicUrl(key);

    return { url, type };
  }

  private validateFile(file: UploadedFile, type: UploadType): void {
    const isImage = file.mimetype.startsWith('image/');
    const isVideo = file.mimetype.startsWith('video/');

    if (type === 'image') {
      if (!isImage) {
        throw new BadRequestException('upload.unsupported_mime_type');
      }

      const maxBytes = 5 * 1024 * 1024;
      if (file.size > maxBytes) {
        throw new BadRequestException('upload.image_size_exceeded');
      }
    }

    if (type === 'video') {
      if (!isVideo) {
        throw new BadRequestException('upload.unsupported_mime_type');
      }

      const maxBytes = 10 * 1024 * 1024;
      if (file.size > maxBytes) {
        throw new BadRequestException('upload.video_size_exceeded');
      }
    }
  }

  private buildObjectKey(
    file: UploadedFile,
    type: UploadType,
    scope: UploadScope,
  ): string {
    const now = new Date();
    const year = now.getFullYear();
    const month = String(now.getMonth() + 1).padStart(2, '0');
    const extension = this.getFileExtension(file.originalname);
    const uniqueId = generateUuid();

    return `${scope}/uploads/${type}/${year}/${month}/${uniqueId}.${extension}`;
  }

  private getFileExtension(filename: string): string {
    const parts = filename.split('.');
    const rawExtension = parts.length > 1 ? parts[parts.length - 1] : undefined;

    return rawExtension?.toLowerCase() ?? 'bin';
  }

  private buildPublicUrl(key: string): string {
    if (this.s3Config.endpoint) {
      const endpoint = this.s3Config.endpoint.replace(/\/$/, '');
      return `${endpoint}/${this.s3Config.bucketName}/${key}`;
    }

    return `https://${this.s3Config.bucketName}.s3.${this.s3Config.region}.amazonaws.com/${key}`;
  }
}

3. ControllerでアップロードAPI

import {
  BadRequestException,
  Body,
  Controller,
  Post,
  UploadedFile,
  UseInterceptors,
} from '@nestjs/common';
import {
  ApiBadRequestResponse,
  ApiBody,
  ApiConsumes,
  ApiOkResponse,
  ApiOperation,
  ApiTags,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { SwaggerTag } from '../common/constants/swagger-tag.constant';
import { UploadRequestDto } from './dto/upload-request.dto';
import { UploadResponseDto } from './dto/upload-response.dto';
import { UploadService } from './upload.service';

const uploadMultipartBody = {
  description: 'Upload payload.',
  schema: {
    type: 'object' as const,
    required: ['type', 'file'],
    properties: {
      type: { type: 'string', enum: ['image', 'video'] },
      file: { type: 'string', format: 'binary' },
    },
  },
};

@ApiTags(SwaggerTag.UPLOADS)
@Controller('uploads')
export class UploadsController {
  constructor(private readonly uploadService: UploadService) {}

  @Post()
  @ApiOperation({
    summary: 'Upload file',
    description: 'Upload an image or video file to object storage.',
  })
  @ApiConsumes('multipart/form-data')
  @ApiBody(uploadMultipartBody)
  @ApiOkResponse({
    description: 'File uploaded successfully.',
    type: UploadResponseDto,
  })
  @ApiBadRequestResponse({ description: 'Invalid upload request.' })
  @UseInterceptors(FileInterceptor('file', { storage: memoryStorage() }))
  async upload(
    @UploadedFile() file: Express.Multer.File | undefined,
    @Body() body: UploadRequestDto,
  ): Promise<UploadResponseDto> {
    if (!body.type) {
      throw new BadRequestException('upload.type_invalid');
    }
    const result = await this.uploadService.uploadFile(
      file,
      body.type,
      'public',
    );
    const response = new UploadResponseDto();
    response.url = result.url;
    response.type = result.type;
    return response;
  }
}

4. フロントからアップロード

POST /upload
Content-Type: multipart/form-data

🔥 実務Tips(重要)

  • 本番では ACL public-readは非推奨
  • CloudFront + Private S3 がベスト
  • → または Presigned URL

7
4
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
7
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?