Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in aws

amazon S3

Tung Bui Thanh (TungDaDev)10 mins read

Aws s3.webp

Trong AWS, Amazon S3 (Simple Storage Service) thuộc hàng dịch vụ lâu đời nhất, mà hệ thống nào cũng đụng tới: Data Lake, Backup/Disaster Recovery, cho tới chỗ chứa ảnh, video, tài liệu của hàng triệu người dùng.

AWS cam kết độ bền dữ liệu (Durability) tới 99.999999999% (11 số 9), nên có thể coi S3 là chỗ cất dữ liệu gần như không bao giờ mất.

Có điều, khi đưa S3 vào một hệ thống phân tán tải cao, dev hay vấp mấy chỗ sau:

  • Backend nghẽn mạng, tràn RAM vì cho client upload file đi xuyên qua server ứng dụng, thay vì dùng Direct-to-S3 Presigned URLs.
  • File upload dở dang ngốn cả nghìn USD tiền rác mỗi tháng, chỉ vì không cấu hình AbortIncompleteMultipartUpload.
  • Dính KMS Throttling Quota khi mã hoá hàng triệu object bằng SSE-KMS.
  • Không nắm Strong Read-After-Write Consistency và giới hạn 3,500 PUT / 5,500 GET mỗi giây trên mỗi prefix, đặt key sai kiểu nên bị S3 trả 503 Slow Down.

Bài này đi lần lượt từ cách S3 lưu dữ liệu cho tới code mẫu Spring Boot 3 dùng được cho production.


# bản chất lưu trữ: object vs block vs file storage

Muốn chọn đúng kiểu lưu trữ trên AWS thì trước hết phải phân biệt được 3 loại này:

# mô hình strong consistency của S3

Trước tháng 12/2020, S3 chạy theo kiểu Eventual Consistency: vừa ghi hoặc xoá xong mà đọc lại ngay thì có thể vẫn nhận dữ liệu cũ.

Giờ thì AWS đã chuyển toàn bộ sang Strong Read-After-Write Consistency:

  • Một lệnh PUT hoặc DELETE vừa trả 200 OK thì lệnh GET nào sau đó, gọi từ đâu cũng vậy, đều đọc được ngay dữ liệu mới nhất, không phải chờ. Lệnh LIST cũng thấy ngay object mới.
  • Có điều strong consistency không phải là khoá: hai client cùng PUT một key thì bản ghi sau cùng thắng. Cần chặn ghi đè thì dùng conditional write (If-None-Match: *, If-Match với ETag), S3 hỗ trợ từ 2024.

# kiến trúc direct-to-s3 presigned upload

Lỗi hay gặp nhất là làm một endpoint kiểu: POST /api/v1/files/upload nhận MultipartFile.

# presigned URL hoạt động thế nào?

  • Backend dùng IAM credential của nó để tính chữ ký (AWS Signature Version 4 - SigV4) rồi gắn vào URL.
  • URL này cho quyền PUT (hoặc GET) tạm thời, chỉ trên đúng một object key, sống trong thời gian ngắn (ví dụ 15 phút; SigV4 cho tối đa 7 ngày, và URL cũng hết hiệu lực sớm hơn nếu credential tạm dùng để ký hết hạn trước).
  • Header nào được ký (như Content-Type, Content-Length) thì client phải gửi đúng y giá trị đó, sai là S3 trả 403 SignatureDoesNotMatch.
  • Client dùng URL đó đẩy dữ liệu thẳng lên S3. Backend của bạn không tốn một byte RAM hay băng thông nào cho việc truyền file.

# presigned multipart upload cho file lớn

Người dùng upload video vài trăm MB hay vài GB qua 4G thì mạng chập chờn là chuyện thường. Upload nguyên một cục mà đứt ở 99% là phải làm lại từ đầu.

Multipart Upload cắt file thành nhiều phần (Parts, mỗi part từ 5MB - 5GB, riêng part cuối được nhỏ hơn 5MB, tối đa 10,000 part) rồi upload song song. Part nào lỗi thì upload lại riêng part đó:

Client chạy trên browser thì nhớ cấu hình CORS cho bucket: cho phép method PUT từ domain của bạn và khai báo ExposeHeaders: ["ETag"]. Thiếu dòng này thì JavaScript không đọc được header ETag của từng part, tới bước complete là bí.


# code thật: Spring Boot 3 & AWS SDK v2

# cấu hình S3 client bất đồng bộ

package com.company.storage.config;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
 
@Configuration
public class AwsS3Configuration {
 
    @Value("${aws.region:ap-southeast-1}")
    private String awsRegion;
 
    @Bean
    public S3AsyncClient s3AsyncClient() {
        // Tự động nạp Credentials từ IAM Role (EKS IRSA) mà không hardcode AccessKey!
        return S3AsyncClient.builder()
            .region(Region.of(awsRegion))
            .credentialsProvider(DefaultCredentialsProvider.create())
            .build();
    }
 
    @Bean
    public S3Presigner s3Presigner() {
        return S3Presigner.builder()
            .region(Region.of(awsRegion))
            .credentialsProvider(DefaultCredentialsProvider.create())
            .build();
    }
}

# service sinh presigned upload URL

package com.company.storage.service;
 
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
 
import java.time.Duration;
import java.util.UUID;
 
@Service
@RequiredArgsConstructor
public class S3PresignedUploadService {
 
    private final S3Presigner s3Presigner;
 
    @Value("${aws.s3.bucket}")
    private String bucketName;
 
    public PresignedUploadResponse generateSecureUploadUrl(String originalFileName, String contentType, long fileSize) {
        // 1. Validate MIME type và kích thước tối đa (ví dụ max 20MB)
        if (!contentType.startsWith("image/") && !contentType.equals("application/pdf")) {
            throw new IllegalArgumentException("Định dạng file không được phép!");
        }
        if (fileSize <= 0 || fileSize > 20L * 1024 * 1024) {
            throw new IllegalArgumentException("File vượt quá 20MB!");
        }
 
        // 2. Tạo Storage Key chống trùng lặp: documents/UUID/<tên file đã làm sạch>
        //    Không đưa nguyên tên file của user vào key (ký tự lạ, "../", tên quá dài...)
        String safeName = originalFileName.replaceAll("[^A-Za-z0-9._-]", "_");
        String uniqueKey = String.format("documents/%s/%s", UUID.randomUUID(), safeName);
 
        // 3. Khởi tạo PutObjectRequest với Content-Type và Content-Length ràng buộc.
        //    Hai header này nằm trong chữ ký, client PUT khác kích thước/kiểu đã khai báo
        //    là S3 trả 403. Không set contentLength thì URL cho upload file bao nhiêu cũng được.
        PutObjectRequest objectRequest = PutObjectRequest.builder()
            .bucket(bucketName)
            .key(uniqueKey)
            .contentType(contentType)
            .contentLength(fileSize)
            .build();
 
        // 4. Ký Presigned Request với thời hạn sống 10 phút
        PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
            .signatureDuration(Duration.ofMinutes(10))
            .putObjectRequest(objectRequest)
            .build();
 
        PresignedPutObjectRequest presigned = s3Presigner.presignPutObject(presignRequest);
 
        return new PresignedUploadResponse(uniqueKey, presigned.url().toString(), 10 * 60);
    }
}

TIP

Đặt key prefix cho tải cao: Giới hạn 3,500 ghi / 5,500 đọc mỗi giây tính trên từng prefix đã được S3 chia partition, và số prefix thì không giới hạn. Từ 2018 AWS không còn khuyên random hoá prefix cho mọi trường hợp nữa, nhưng nếu bạn ghi vài chục nghìn file mỗi giây thì vẫn nên rải ra nhiều prefix (vd để hash lên trước: logs/a1b2/2026-04-12/...) thay vì dồn hết vào một prefix theo ngày. S3 scale partition dần dần chứ không tức thì, trong lúc đó có thể trả 503 Slow Down, nên client phải retry có backoff (AWS SDK làm sẵn). Nguồn: Optimizing Amazon S3 performance.


# bảo mật zero-trust & cái bẫy hiệu năng của SSE-KMS

Lưu dữ liệu nhạy cảm trên S3 thì Encryption at Rest là chuyện bắt buộc:

CAUTION

Coi chừng nghẽn KMS quota: Lưu hàng triệu object bằng SSE-KMS mà quên bật S3 Bucket Keys thì mỗi lần PUT object là một lệnh GenerateDataKey, mỗi lần GET là một lệnh Decrypt bắn sang AWS KMS, tất cả dùng chung quota mã hoá của cả account trong region đó (mặc định 10,000 req/s, một số region 20,000 hoặc 100,000). Traffic lên cao là ăn ngay KMS.ThrottlingException. Bật Bucket Key (BucketKeyEnabled, trong Terraform là bucket_key_enabled = true) thì S3 sinh một key cấp bucket dùng trong một khoảng thời gian, nhiều object dùng chung nên số lần gọi KMS giảm hẳn. Nguồn: AWS KMS request quotas.


# chi phí: cái bẫy AbortIncompleteMultipartUpload

Dùng Multipart Upload mà client đang upload nửa chừng thì mất mạng hoặc tắt trình duyệt, các part đã lên S3 sẽ nằm đó mãi, không tự mất đi.

  • Mấy part này nằm ẩn (in-flight parts), không hiện trong danh sách object bình thường.
  • AWS vẫn tính tiền lưu trữ cho chúng, tháng nào cũng tính.
  • Dự án lớn thì tiền "rác" từ upload dở dang có thể lên tới vài nghìn USD mỗi tháng mà chẳng ai hay.

# lifecycle rule nhất định phải có

{
  "Rules": [
    {
      "ID": "AutoAbortIncompleteUploadsAfter7Days",
      "Status": "Enabled",
      "Filter": {},
      "AbortIncompleteMultipartUpload": {
        "DaysAfterInitiation": 7
      }
    }
  ]
}

Bucket S3 nào trong công ty cũng nên có rule tự huỷ Multipart Upload chưa xong sau 7 ngày, để tiền cloud khỏi rỉ ra từng chút.


# tổng kết & checklist

Hạng mụcProduction cần cóBỏ qua thì sao
Upload PatternLuôn dùng Presigned URLs để client đẩy thẳng lên S3.Backend nghẽn băng thông, tràn RAM, sập server.
Large FilesDùng Presigned Multipart Upload cho file > 100MB.Đứt mạng là người dùng phải upload lại từ đầu.
CredentialsDùng IAM Roles for Service Accounts (IRSA) trên EKS.Dễ lộ AccessKey/SecretKey nếu hardcode trong source code.
Cost HygieneBật Lifecycle Rule AbortIncompleteMultipartUpload (7 ngày).Mất cả nghìn USD tiền lưu mấy part bỏ dở nằm ẩn.
KMS ThrottlingLuôn bật S3 Bucket Keys khi dùng SSE-KMS.Request lỗi hàng loạt vì vượt quota gọi AWS KMS.
Public SafetyBật Block All Public Access ở mức account/bucket.Dữ liệu nội bộ lộ hết ra Internet.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.