ローカルディスクへのファイル保存は動いたけど、本番では S3 に保存したい。AWS SDK v2 と spring-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();
}
}
S3Presigner は AutoCloseable を実装しています。@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 の実装
S3Client と S3Presigner を同じ 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);
}
}
ResponseInputStream は try-with-resources で必ずクローズしましょう。クローズしないと HTTP コネクションがリークします。contentType が null の場合は 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 デプロイを進めている場合、タスクロールの設定も忘れずに。
まとめ
S3ClientとS3Presignerを 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 ロール
非同期でアップロード処理を切り離したい場合は 非同期処理の記事 も参考にしてみてください。