ローカルディスクへのファイル保存は動いたけど、本番では S3 に保存したい。AWS SDK v2spring-cloud-aws どちらを使えばいいか迷っている方向けに、選択基準から署名付き URL 生成まで解説します。MultipartFile の基本ハンドリングは こちらの記事 を参考にしてください。

AWS SDK v2 vs spring-cloud-aws 3.x

AWS SDK v2 は AWS 公式ライブラリで Spring 非依存。依存が軽量で細かい制御がしやすいです。

spring-cloud-aws 3.x は AutoConfiguration で S3Client を自動生成してくれます。設定が少なくて済む一方、Spring Boot 本体とのバージョン管理に注意が必要です。

シンプルな S3 操作だけなら AWS SDK v2 が無難 です。この記事もそちらを主軸に進め、spring-cloud-aws の差分は補足で触れます。

依存関係の追加

BOM でバージョンを一元管理しましょう。下記は執筆時点のバージョンです。最新版は Maven Central で確認してください。

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>software.amazon.awssdk</groupId>
      <artifactId>bom</artifactId>
      <version>2.25.60</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<dependencies>
  <dependency>
    <groupId>software.amazon.awssdk</groupId>
    <artifactId>s3</artifactId>
  </dependency>
</dependencies>

Gradle の場合はこちらです。

implementation platform('software.amazon.awssdk:bom:2.25.60')
implementation 'software.amazon.awssdk:s3'

spring-cloud-aws を使う場合は専用 BOM と starter を追加します。BOM を省くとバージョン競合が起きやすいので必ず使いましょう。

implementation platform('io.awspring.cloud:spring-cloud-aws-dependencies:3.1.1')
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'

ローカル開発の認証設定

aws configure~/.aws/credentials を作成するのが手軽です。

[default]
aws_access_key_id = YOUR_ACCESS_KEY
aws_secret_access_key = YOUR_SECRET_KEY
region = ap-northeast-1

環境変数でも代用できます。

export AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY
export AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY
export AWS_DEFAULT_REGION=ap-northeast-1

SdkClientException が出る場合は、クレデンシャルが見つからないかリージョン設定が間違っていることがほとんどです。本番ではアクセスキーをコードに埋め込まず、IAM ロールを使いましょう。環境ごとの切り替えは Spring Profiles の記事 を参考にしてください。

S3Client と S3Presigner を Bean として定義する

@Configuration
public class S3Config {

    @Value("${aws.s3.region}")
    private String region;

    @Bean
    public S3Client s3Client() {
        return S3Client.builder()
                .region(Region.of(region))
                .build();
    }

    @Bean(destroyMethod = "close")
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
                .region(Region.of(region))
                .build();
    }
}

S3PresignerAutoCloseable を実装しています。@Bean(destroyMethod = "close") を指定しておくと、アプリ終了時に Spring が確実にリソースを解放してくれます。Spring Boot のバージョンによっては AutoCloseable を自動検出しますが、明示しておく方が安全です。

バケット名とリージョンは application.yml に外出しします。

aws:
  s3:
    region: ap-northeast-1
    bucket-name: my-app-bucket

spring-cloud-aws の場合は S3Client が AutoConfiguration で自動生成されます。ただし S3Presigner は AutoConfiguration の対象外 のため、spring-cloud-aws を使う場合も S3Config クラス内の s3Presigner() Bean は省略できません。リージョンは spring.cloud.aws.region.static で設定します。

spring:
  cloud:
    aws:
      region:
        static: ap-northeast-1
      credentials:
        # ローカル開発用。本番は IAM ロールを使用し、access-key/secret-key は設定しない
        access-key: YOUR_ACCESS_KEY
        secret-key: YOUR_SECRET_KEY

アップロード・ダウンロード・署名付き URL の実装

S3ClientS3Presigner を同じ Service にインジェクションして S3 操作をまとめましょう。

アップロード

public String upload(MultipartFile file, String userId) throws IOException {
    String originalFilename = file.getOriginalFilename() != null
            ? file.getOriginalFilename() : "file";
    String key = userId + "/" + UUID.randomUUID() + "_" + originalFilename;

    PutObjectRequest request = PutObjectRequest.builder()
            .bucket(bucketName)
            .key(key)
            .contentType(file.getContentType())
            .contentLength(file.getSize())
            .build();

    // contentLength を明示しないと fromInputStream() でエラーになる
    s3Client.putObject(request,
            RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
    return key;
}

キーは userId/uuid_filename 形式でユニーク性を確保しています。アップロード後はキーを DB に保存しておきましょう。

ダウンロード

public ResponseEntity<byte[]> download(String key) {
    try {
        GetObjectRequest request = GetObjectRequest.builder()
                .bucket(bucketName).key(key).build();

        try (ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(request)) {
            byte[] content = s3Object.readAllBytes();
            HttpHeaders headers = new HttpHeaders();
            String ct = s3Object.response().contentType();
            headers.setContentType(ct != null
                    ? MediaType.parseMediaType(ct)
                    : MediaType.APPLICATION_OCTET_STREAM);
            return ResponseEntity.ok().headers(headers).body(content);
        }
    } catch (NoSuchKeyException e) {
        return ResponseEntity.notFound().build();
    } catch (IOException e) {
        throw new RuntimeException("ダウンロードに失敗しました", e);
    }
}

ResponseInputStreamtry-with-resources で必ずクローズしましょう。クローズしないと HTTP コネクションがリークします。contentTypenull の場合は APPLICATION_OCTET_STREAM をデフォルト値にしています。

NoSuchKeyException はキー名のタイポやアップロード時と取得時でキーが一致していないことが原因のことが多いです。

目安として 10〜20 MB 超のファイルを扱う場合は StreamingResponseBody への移行を検討 してください。全データを byte[] に読み込むと OOM リスクがあります。切り替えのイメージはこちらです。

public ResponseEntity<StreamingResponseBody> downloadAsStream(String key) {
    GetObjectRequest request = GetObjectRequest.builder()
            .bucket(bucketName).key(key).build();
    ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(request);
    StreamingResponseBody body = out -> {
        try (s3Object) { s3Object.transferTo(out); }
    };
    String ct = s3Object.response().contentType();
    MediaType mediaType = ct != null
            ? MediaType.parseMediaType(ct) : MediaType.APPLICATION_OCTET_STREAM;
    return ResponseEntity.ok().contentType(mediaType).body(body);
}

署名付き URL の生成

public String generatePresignedUrl(String key, Duration expiration) {
    GetObjectRequest getObjectRequest = GetObjectRequest.builder()
            .bucket(bucketName).key(key).build();

    PresignedGetObjectRequest presigned = s3Presigner.presignGetObject(r ->
            r.signatureDuration(expiration)
             .getObjectRequest(getObjectRequest));
    return presigned.url().toString();
}

有効期限は 5〜15 分程度が適切です。長くしすぎると URL が流出した際のリスクが上がります。

本番環境の IAM ロール設定

本番ではアクセスキーではなく IAM ロールを使いましょう。

  • EC2 はインスタンスプロファイルにポリシーをアタッチ
  • ECS はタスクロールにポリシーをアタッチ
  • Lambda は実行ロールにポリシーをアタッチ

AWS SDK v2 は DefaultCredentialsProvider が自動的に IAM ロールを検出するので、コード変更は不要です。以下は最小権限構成のサンプルです(以下は Statement 要素 1 件分のサンプルです。完全なポリシードキュメントの形式は AWS ドキュメントを参照してください)。

{
  "Effect": "Allow",
  "Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
  "Resource": "arn:aws:s3:::my-app-bucket/*"
}

Docker コンテナ化の記事 と組み合わせて ECS デプロイを進めている場合、タスクロールの設定も忘れずに。

まとめ

  • S3ClientS3Presigner を Bean 定義。S3Presigner は spring-cloud-aws 利用時も手動定義が必要
  • アップロードは PutObjectRequest + RequestBody.fromInputStream()contentLength は必須
  • ダウンロードは try-with-resources でストリームを確実にクローズ。contentType は null ガードを忘れずに
  • 10〜20 MB 超のファイルは StreamingResponseBody でメモリ効率よくレスポンスを返す
  • 署名付き URL は s3Presigner.presignGetObject() で有効期限付き URL を発行
  • ローカルは ~/.aws/credentials、本番は IAM ロール

非同期でアップロード処理を切り離したい場合は 非同期処理の記事 も参考にしてみてください。