Amazon S3: ảnh sản phẩm của shop-app rời khỏi ổ đĩa EC2

5 câu hỏi~50 phútTừ khóa: bucket, object, storage class, lifecycle, versioning, SSE-S3/KMS, Block Public Access, bucket policy, presigned URL, multipart, event notification, Transfer Acceleration · SAA: Domain 1 Performance / Domain 4 Cost

Tiếp đi: S3. Bài trước shop-app chạy 2 instance trong ASG, và mình phát hiện ảnh sản phẩm người bán upload đang lưu vào /opt/shop-app/uploads trên từng máy. Máy A có ảnh, máy B không; ASG thay máy là mất luôn ảnh. Giờ mình phải lưu ở đâu?

Bạn vừa đụng đúng bài học kinh điển: app stateless thì file phải ra ngoài. Trên AWS, nơi đó là Amazon S3 (Simple Storage Service): object storage bền (durability 99.999999999%, "11 số 9"), rẻ, không giới hạn dung lượng, truy cập qua HTTP. S3 xuất hiện ở gần như mọi câu đề SAA nên bài này đi khá kỹ.

1. Bài toán: ảnh sản phẩm trên đĩa EC2

POST /api/products/42/images  (multipart/form-data, 2 MB)
  → MultipartFile.transferTo(new File("/opt/shop-app/uploads/42/front.jpg"))
  → DB lưu path "/uploads/42/front.jpg"
GET /uploads/42/front.jpg → Spring serve static từ đĩa

Chạy một máy thì ổn. Sau bài 22.5:

ALB ─┬─→ EC2-A  /opt/shop-app/uploads/42/front.jpg   (có)
     └─→ EC2-B  /opt/shop-app/uploads/42/            (trống) → 404

2. Vấn đề

3. Giải pháp: S3 và các khái niệm đề thi

Người bán ──(1) POST /api/products/42/images (metadata) ──→ shop-app
          ←─(2) presigned PUT URL (hạn 5 phút) ────────────┘
          ──(3) PUT ảnh thẳng lên S3 ──→ s3://shop-app-assets-$ACCOUNT_ID/products/42/front.jpg
                                              │
Khách  ←──(4) GET qua CloudFront (bài 22.10) ──┘   (hoặc presigned GET nếu ảnh riêng tư)
                                              │
                                        (5) S3 event → SQS → Lambda shop-image-resize (bài 22.11/22.12)
Khái niệmGiải thích ngắnTừ khóa đề thi
BucketThùng chứa, tên duy nhất toàn cầu, nằm trong một region; tối đa 100 mặc định (xin tăng được)globally unique, regional, flat namespace
ObjectKey (đường dẫn, không có folder thật) + data (0 B – 5 TB) + metadata + version IDkey prefix, 5 GB/PUT đơn → multipart ≥ 100 MB
Storage classMức giá/độ sẵn sàng khác nhau cho cùng durability 11 số 9 (trừ One Zone)Standard, Intelligent-Tiering, Standard-IA, One Zone-IA, Glacier Instant/Flexible/Deep Archive, Express One Zone
Lifecycle ruleTự chuyển class hoặc xóa theo tuổi object / theo prefix / tagtransition, expiration, noncurrent version, abort incomplete multipart
VersioningGiữ mọi phiên bản; xóa chỉ tạo delete markerMFA Delete, protect from accidental deletion
EncryptionSSE-S3 (mặc định, AES-256), SSE-KMS (key của bạn, audit CloudTrail), DSSE-KMS, SSE-C (bạn gửi key), client-sideencrypt at rest, audit key usage → SSE-KMS; bucket key giảm phí KMS
Block Public Access4 công tắc chặn ACL/policy public ở cấp bucket và account; mặc định bật"accidentally made public" → BPA
Bucket policyResource-based policy JSON; phân quyền cross-account, buộc TLS, buộc mã hóaaws:SecureTransport, Principal cross-account, Deny ưu tiên
ACLCơ chế cũ; mặc định tắt (Object Ownership = Bucket owner enforced)hiếm khi là đáp án đúng
Presigned URLURL ký bằng credential của app, ai có URL dùng được trong N giây, cho GET/PUTtemporary access, upload directly without exposing credentials
Event notificationObject created/removed → SQS / SNS / Lambda / EventBridgeprocess after upload, EventBridge cho filter phức tạp
ReplicationCRR (khác region) / SRR (cùng region), cần versioning hai đầu, chỉ object mới (Batch Replication cho cũ)compliance, DR, lower latency other region
Transfer AccelerationUpload tới edge location gần user rồi đi backbone AWS về bucketglobal users uploading large files
Object LockWORM: Governance / Compliance mode, legal holdregulatory, cannot be deleted even by root
ConsistencyStrong read-after-write cho mọi thao tác (từ 12/2020)không còn "eventual consistency for overwrite PUT"

S3 là dịch vụ regional nhưng tên bucket toàn cầu, và endpoint có dạng https://shop-app-assets-123.s3.ap-southeast-1.amazonaws.com/products/42/front.jpg. Nếu bị khóa vì "bản đồ dịch vụ" thì xem lại bài tổng quan; bài này chỉ đi sâu S3.

4. Cấu hình từng bước (CLI + Java)

Bước 1: Bucket với mọi khóa an toàn bật sẵn.

export AWS_REGION=ap-southeast-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text --profile shop-lab)
BUCKET=shop-app-assets-$ACCOUNT_ID

# Ngoài us-east-1 phải có LocationConstraint
aws s3api create-bucket --bucket $BUCKET --region $AWS_REGION \
  --create-bucket-configuration LocationConstraint=$AWS_REGION --profile shop-lab
aws s3api put-bucket-tagging --bucket $BUCKET \
  --tagging 'TagSet=[{Key=Project,Value=shop-app},{Key=Env,Value=lab}]' --profile shop-lab

# Block Public Access: cả 4 công tắc
aws s3api put-public-access-block --bucket $BUCKET --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true --profile shop-lab

# Versioning
aws s3api put-bucket-versioning --bucket $BUCKET \
  --versioning-configuration Status=Enabled --profile shop-lab

# Mã hóa mặc định SSE-KMS với key của shop (alias/shop-key, bài 22.14); chưa có key thì bỏ qua, S3 đã SSE-S3 mặc định
aws s3api put-bucket-encryption --bucket $BUCKET --server-side-encryption-configuration '{
  "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms", "KMSMasterKeyID": "alias/shop-key"},
             "BucketKeyEnabled": true}]}' --profile shop-lab

Bước 2: Lifecycle. Ảnh sản phẩm bán được thì xem nhiều, hết mùa thì ít; ảnh gốc độ phân giải cao chỉ cần giữ để resize lại.

cat > lifecycle.json <<'EOF'
{
  "Rules": [
    {
      "ID": "originals-to-cold",
      "Filter": {"Prefix": "originals/"},
      "Status": "Enabled",
      "Transitions": [
        {"Days": 30,  "StorageClass": "STANDARD_IA"},
        {"Days": 180, "StorageClass": "GLACIER_IR"}
      ],
      "NoncurrentVersionTransitions": [{"NoncurrentDays": 7, "StorageClass": "GLACIER"}],
      "NoncurrentVersionExpiration": {"NoncurrentDays": 90}
    },
    {
      "ID": "tmp-uploads-expire",
      "Filter": {"Prefix": "tmp/"},
      "Status": "Enabled",
      "Expiration": {"Days": 1}
    },
    {
      "ID": "abort-multipart",
      "Filter": {"Prefix": ""},
      "Status": "Enabled",
      "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 3}
    }
  ]
}
EOF
aws s3api put-bucket-lifecycle-configuration --bucket $BUCKET --lifecycle-configuration file://lifecycle.json --profile shop-lab

Hai lưu ý thực tế: object phải ở Standard ≥ 30 ngày trước khi sang IA; object < 128 KB chuyển sang IA không tiết kiệm (IA tính tối thiểu 128 KB). Rule abort-multipart luôn nên có: upload đứt giữa chừng để lại part "vô hình" vẫn tính tiền.

Bước 3: Bucket policy chỉ cho role của app, buộc TLS. Role shop-app-ec2-role đã có identity policy (bài 22.4); thêm bucket policy là lớp thứ hai: với cùng account, chỉ cần một trong hai Allow và không có Deny; với khác account phải cả hai.

cat > bucket-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowShopAppRole",
      "Effect": "Allow",
      "Principal": {"AWS": "arn:aws:iam::$ACCOUNT_ID:role/shop-app-ec2-role"},
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
      "Resource": ["arn:aws:s3:::$BUCKET", "arn:aws:s3:::$BUCKET/*"]
    },
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": ["arn:aws:s3:::$BUCKET", "arn:aws:s3:::$BUCKET/*"],
      "Condition": {"Bool": {"aws:SecureTransport": "false"}}
    },
    {
      "Sid": "DenyUnencryptedUploads",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::$BUCKET/*",
      "Condition": {"StringNotEquals": {"s3:x-amz-server-side-encryption": "aws:kms"}}
    }
  ]
}
EOF
aws s3api put-bucket-policy --bucket $BUCKET --policy file://bucket-policy.json --profile shop-lab

Chú ý ListBucket áp trên ARN bucket (không có /*), còn GetObject áp trên /*. Viết nhầm là lỗi 403 kinh điển.

Bước 4: Java SDK v2 trong Spring Boot.

<!-- pom.xml -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>software.amazon.awssdk</groupId>
      <artifactId>bom</artifactId>
      <version>2.29.0</version>   <!-- dùng bản mới nhất khi bạn đọc -->
      <type>pom</type><scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
<dependencies>
  <dependency><groupId>software.amazon.awssdk</groupId><artifactId>s3</artifactId></dependency>
  <dependency><groupId>software.amazon.awssdk</groupId><artifactId>s3-transfer-manager</artifactId></dependency>
  <dependency><groupId>software.amazon.awssdk.crt</groupId><artifactId>aws-crt</artifactId><version>0.31.3</version></dependency>
</dependencies>
# application-aws.yml
shop:
  s3:
    bucket: shop-app-assets-${AWS_ACCOUNT_ID}
    region: ap-southeast-1
    presign-ttl: 5m
@Configuration
@EnableConfigurationProperties(S3Props.class)
public class S3Config {

    @Bean
    S3Client s3Client(S3Props props) {
        // Không truyền credential: DefaultCredentialsProvider lấy từ role EC2 (IMDSv2),
        // ở local lấy từ profile shop-lab trong ~/.aws/credentials
        return S3Client.builder()
                .region(Region.of(props.region()))
                .build();
    }

    @Bean
    S3Presigner s3Presigner(S3Props props) {
        return S3Presigner.builder().region(Region.of(props.region())).build();
    }

    @Bean
    S3TransferManager transferManager(S3Props props) {
        // CRT client: multipart tự động, song song, dùng cho file lớn
        S3AsyncClient async = S3AsyncClient.crtBuilder()
                .region(Region.of(props.region()))
                .targetThroughputInGbps(5.0)
                .minimumPartSizeInBytes(8L * 1024 * 1024)
                .build();
        return S3TransferManager.builder().s3Client(async).build();
    }
}

@ConfigurationProperties("shop.s3")
public record S3Props(String bucket, String region, Duration presignTtl) {}
@Service
public class ProductImageService {

    private final S3Client s3;
    private final S3Presigner presigner;
    private final S3Props props;

    public ProductImageService(S3Client s3, S3Presigner presigner, S3Props props) {
        this.s3 = s3; this.presigner = presigner; this.props = props;
    }

    /** Cách 1: server upload (file nhỏ, đã qua validate ở app) */
    public String upload(long productId, MultipartFile file) throws IOException {
        String key = "products/%d/%s.%s".formatted(productId, UUID.randomUUID(),
                StringUtils.getFilenameExtension(file.getOriginalFilename()));
        s3.putObject(PutObjectRequest.builder()
                        .bucket(props.bucket()).key(key)
                        .contentType(file.getContentType())
                        .serverSideEncryption(ServerSideEncryption.AWS_KMS)   // khớp bucket policy
                        .metadata(Map.of("product-id", String.valueOf(productId)))
                        .build(),
                RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
        return key;   // DB lưu key, không lưu URL
    }

    /** Cách 2: presigned PUT, client upload thẳng lên S3 */
    public PresignedUpload presignUpload(long productId, String contentType) {
        String key = "tmp/%d/%s".formatted(productId, UUID.randomUUID());
        PutObjectRequest put = PutObjectRequest.builder()
                .bucket(props.bucket()).key(key)
                .contentType(contentType)
                .serverSideEncryption(ServerSideEncryption.AWS_KMS)
                .build();
        PresignedPutObjectRequest signed = presigner.presignPutObject(
                PutObjectPresignRequest.builder()
                        .signatureDuration(props.presignTtl())
                        .putObjectRequest(put).build());
        // client phải gửi đúng Content-Type và header x-amz-server-side-encryption đã ký
        return new PresignedUpload(key, signed.url().toString(), signed.signedHeaders());
    }

    /** Presigned GET cho ảnh riêng tư (hóa đơn, CMND người bán...) */
    public URL presignDownload(String key) {
        return presigner.presignGetObject(GetObjectPresignRequest.builder()
                .signatureDuration(Duration.ofMinutes(2))
                .getObjectRequest(GetObjectRequest.builder()
                        .bucket(props.bucket()).key(key)
                        .responseContentDisposition("inline").build())
                .build()).url();
    }

    public record PresignedUpload(String key, String url, Map<String, List<String>> headers) {}
}
@RestController
@RequestMapping("/api/products/{id}/images")
public class ProductImageController {
    private final ProductImageService images;
    private final ProductService products;
    // constructor...

    @PostMapping("/presign")
    @PreAuthorize("@productAuth.isOwner(#id)")
    public ProductImageService.PresignedUpload presign(@PathVariable long id,
                                                       @RequestParam String contentType) {
        if (!Set.of("image/jpeg", "image/png", "image/webp").contains(contentType))
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "unsupported type");
        return images.presignUpload(id, contentType);
    }

    @PostMapping("/confirm")
    @PreAuthorize("@productAuth.isOwner(#id)")
    public void confirm(@PathVariable long id, @RequestParam String key) {
        // client báo đã PUT xong; app HEAD object, kiểm tra size/type rồi mới ghi DB
        products.attachImage(id, key);
    }
}

Vì sao có bước confirm: S3 không biết nghiệp vụ của bạn. Presigned URL cho phép client PUT, nhưng client có thể không PUT, hoặc PUT file 50 MB rác. App chỉ ghi DB sau khi headObject xác nhận; rác trong tmp/ được lifecycle xóa sau 1 ngày; contentLength giới hạn bằng presigned POST policy nếu cần chặt hơn.

Bước 5: Test bằng CLI.

# Upload với CLI (SDK dưới mũ, multipart tự động > 8 MB)
aws s3 cp ./front.jpg s3://$BUCKET/products/42/front.jpg --sse aws:kms --sse-kms-key-id alias/shop-key --profile shop-lab
aws s3 ls s3://$BUCKET/products/42/ --profile shop-lab
aws s3api head-object --bucket $BUCKET --key products/42/front.jpg --profile shop-lab
# "ServerSideEncryption": "aws:kms", "VersionId": "..."

# Presign bằng CLI để thử nhanh (GET, 300s)
aws s3 presign s3://$BUCKET/products/42/front.jpg --expires-in 300 --profile shop-lab
curl -s -o /tmp/x.jpg -w "%{http_code}\n" "<url vừa in>"   # 200; sau 5 phút → 403

5. Cách hoạt động

Presigned URL không gọi về AWS khi tạo. Nó chỉ là chữ ký SigV4 tính bằng credential hiện tại của app:

app (role shop-app-ec2-role, credential tạm từ IMDS, hết hạn sau ~6h)
  │  HMAC-SHA256(secret, canonical request: PUT /tmp/42/uuid, host, x-amz-date, expires=300, content-type...)
  ▼
https://shop-app-assets-123.s3.ap-southeast-1.amazonaws.com/tmp/42/uuid
   ?X-Amz-Algorithm=AWS4-HMAC-SHA256
   &X-Amz-Credential=ASIA.../20250101/ap-southeast-1/s3/aws4_request
   &X-Amz-Date=...&X-Amz-Expires=300&X-Amz-SignedHeaders=content-type;host;x-amz-server-side-encryption
   &X-Amz-Security-Token=...          ← token của role tạm
   &X-Amz-Signature=...

client PUT → S3 tính lại chữ ký với secret của credential đó
          → đúng, chưa hết hạn, credential còn sống, IAM/bucket policy cho phép → 200
          → sai header đã ký (content-type khác) → 403 SignatureDoesNotMatch

Hệ quả quan trọng: presigned URL không sống lâu hơn credential ký nó. Role EC2 có credential xoay vòng ~6 giờ, nên presign "7 ngày" bằng role sẽ chết sớm hơn. Cần link dài ngày (email hóa đơn) thì dùng IAM user key chuyên dụng, hoặc tốt hơn là CloudFront signed URL (bài 22.10).

Quyền truy cập được quyết định thế nào (cùng account):

Request → có Deny ở bất kỳ đâu (SCP, identity, bucket policy, BPA)?  → DENY
        → có Allow ở identity policy HOẶC bucket policy?               → ALLOW
        → không                                                         → DENY (mặc định)
Public? → Block Public Access bật → mọi policy/ACL public bị vô hiệu, kể cả khi bạn cố ý

Versioning và delete marker:

PUT front.jpg  → v1
PUT front.jpg  → v2 (v1 vẫn còn, tính tiền)
DELETE front.jpg → tạo delete marker (v3); GET → 404; v1, v2 vẫn còn
"Khôi phục" = DELETE delete marker (cần quyền DeleteObjectVersion)
Xóa thật    = DELETE ?versionId=v1 (MFA Delete bật thì phải có MFA, chỉ root/CLI làm được)

Multipart upload cho file lớn (ảnh gốc 4K, video):

CreateMultipartUpload → UploadId
UploadPart #1 (8 MB) ─┐
UploadPart #2 (8 MB) ─┼─ song song, retry từng part
UploadPart #3 (3 MB) ─┘
CompleteMultipartUpload (danh sách ETag) → object xuất hiện (atomic)
(hoặc AbortMultipartUpload / lifecycle abort sau 3 ngày)

SDK v2 S3TransferManager làm việc này tự động; bạn chỉ gọi uploadFile.

6. So sánh

Storage class — bảng này đáng học thuộc:

ClassAZPhí lấy (retrieval)Thời gian lấyTối thiểuDùng cho
Standard≥3KhôngmsKhôngDữ liệu truy cập thường xuyên, ảnh sản phẩm đang bán
Intelligent-Tiering≥3Không; phí giám sát/objectms (tier Archive opt-in: phút–giờ)KhôngKhông đoán được access pattern, object > 128 KB
Standard-IA≥3Có (per GB)ms30 ngày, 128 KBBackup, ảnh gốc ít xem nhưng cần ngay
One Zone-IA1Cóms30 ngàyDữ liệu tái tạo được (thumbnail), rẻ hơn IA ~20%
Glacier Instant Retrieval≥3Cao hơn IAms90 ngàyArchive truy cập quý một lần nhưng cần ngay (ảnh y tế)
Glacier Flexible Retrieval≥3Có; Expedited 1–5 phút, Standard 3–5 giờ, Bulk 5–12 giờ (free)phút–giờ90 ngàyBackup năm, chấp nhận chờ
Glacier Deep Archive≥3Có; Standard 12 giờ, Bulk 48 giờgiờ180 ngàyLưu trữ pháp lý 7–10 năm, rẻ nhất
Express One Zone1 (directory bucket)Không< msKhôngML training, latency một chữ số ms, đắt

Đề thi hay bẫy: "cần lấy trong vài phút" → Glacier Flexible Expedited (không phải Deep Archive); "cần ngay lập tức nhưng hiếm khi" → Glacier Instant; "không biết pattern" → Intelligent-Tiering; "tái tạo được / không quan trọng mất khi AZ hỏng" → One Zone-IA.

Cách cấp quyền truy cập object

Bucket policy / IAMPresigned URLCloudFront + OACPublic bucket
Ai dùngPrincipal AWS (role, user, account)Bất kỳ ai có URL, trong N giâyPublic qua CDN, bucket vẫn privateMọi người
Dùng cho shop-appApp EC2/Lambda đọc ghiUpload từ browser, tải file riêng tưẢnh sản phẩm public (bài 22.10)Không khuyến nghị; website tĩnh thì qua CloudFront
Cache / tốc độ—Không cache được (URL thay đổi)Edge cache, HTTPS, custom domainKhông cache

S3 vs EBS vs EFS (tóm; chi tiết bài 22.16)

S3EBSEFS
LoạiObject, HTTP APIBlock, gắn 1 EC2 (io2 multi-attach cùng AZ)File (NFS), nhiều EC2, nhiều AZ
Dùng khiFile tĩnh, backup, data lake, logRoot disk, DB tự dựngShared home, CMS legacy cần filesystem
Giá tương đốiRẻ nhất/GBTrung bình, trả theo provisionedĐắt nhất/GB (có IA)

7. Khi nào dùng / không

Team frontend báo ảnh sản phẩm bị 403 và bảo mình "cứ mở public bucket cho nhanh, ảnh sản phẩm có gì mà giấu". Mình thấy cũng có lý, ảnh public thật mà. Mở ACL public-read được không?

Bạn đúng ở một nửa: ảnh sản phẩm là public về nội dung. Nhưng "public bucket" là chuyện khác: nó là cách phục vụ, và cách này kém ở cả ba mặt bảo mật, hiệu năng, chi phí.

Vì sao không mở public bucket:

Cách đúng cho ảnh public: CloudFront + Origin Access Control (OAC)

Khách ──HTTPS──→ cdn.shop.example.com (CloudFront, cache ở edge Singapore/HCM)
                        │ miss → ký request SigV4 bằng OAC
                        ▼
                 s3://shop-app-assets-… (bucket vẫn private, BPA vẫn bật)
                 bucket policy: Allow s3:GetObject cho Principal cloudfront.amazonaws.com
                                Condition AWS:SourceArn = arn của distribution, prefix products/*

Bucket policy thêm statement này (chỉ prefix public):

{
  "Sid": "AllowCloudFrontOAC",
  "Effect": "Allow",
  "Principal": {"Service": "cloudfront.amazonaws.com"},
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::shop-app-assets-ACCOUNT_ID/products/*",
  "Condition": {"StringEquals": {"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/EDFDVBD6EXAMPLE"}}
}

Frontend nhận URL https://cdn.shop.example.com/products/42/front.jpg, nhanh hơn, rẻ hơn, và bucket không public. Bài 22.10 dựng phần này. Trong lúc chờ, cho lab hôm nay, dùng presigned GET với TTL dài (1 giờ) để frontend hiển thị được; đó là "mở tạm" an toàn.

Còn 403 của frontend hôm nay có thể do: (1) role app thiếu s3:GetObject trên đúng prefix; (2) object được upload bằng SSE-KMS nhưng role không có kms:Decrypt trên alias/shop-key — lỗi rất hay gặp khi bật SSE-KMS; (3) presigned URL gửi lên thiếu header đã ký. Kiểm tra bằng:

aws s3api get-object --bucket $BUCKET --key products/42/front.jpg /tmp/x.jpg --profile shop-lab   # lỗi gì?
aws kms describe-key --key-id alias/shop-key --profile shop-lab                                      # key policy có role chưa?

Mình tưởng S3 rẻ, nhưng tháng này hóa đơn S3 cao hơn EC2. Bucket chỉ có khoảng 20 GB ảnh. Mình bật versioning và có một job đồng bộ chạy aws s3 sync mỗi phút. Tiền đi đâu vậy?

Chính xác hai thứ bạn vừa nhắc là hai thủ phạm điển hình. Giá lưu trữ S3 (khoảng vài cent/GB/tháng, 20 GB gần như miễn phí) không phải vấn đề. Hóa đơn S3 gồm 5 nhóm:

NhómTính theoHay gây bất ngờ khi
StorageGB-tháng, theo classVersioning giữ mọi bản cũ; multipart dở dang; log bucket không lifecycle
RequestsSố PUT/COPY/POST/LIST (đắt hơn) và GET/SELECT (rẻ hơn), tính trên 1000 requestaws s3 sync / ls mỗi phút → hàng triệu LIST; app poll bucket; nhiều file nhỏ
Data transfer outGB ra Internet hoặc sang region khác (vào S3 miễn phí; sang EC2 cùng region miễn phí; sang CloudFront miễn phí)Phục vụ ảnh thẳng từ S3 ra Internet không qua CloudFront
RetrievalGB lấy ra từ IA/Glacier, phí sớm (xóa trước 30/90/180 ngày)Lifecycle đẩy sang IA nhưng app vẫn đọc thường xuyên
Management / KMSInventory, Analytics, Intelligent-Tiering monitoring; mỗi GET/PUT SSE-KMS = 1 KMS requestSSE-KMS không bật Bucket Key → phí KMS gấp nhiều lần phí S3

Chẩn đoán:

# Dung lượng thật gồm cả noncurrent version (CloudWatch BucketSizeBytes, metric hàng ngày)
aws cloudwatch get-metric-statistics --namespace AWS/S3 --metric-name BucketSizeBytes \
  --dimensions Name=BucketName,Value=$BUCKET Name=StorageType,Value=StandardStorage \
  --start-time $(date -u -d '-3 day' +%FT%TZ) --end-time $(date -u +%FT%TZ) --period 86400 --statistics Average --profile shop-lab

# Bao nhiêu version cũ?
aws s3api list-object-versions --bucket $BUCKET --query 'length(Versions[?IsLatest==`false`])' --profile shop-lab

# Multipart dở
aws s3api list-multipart-uploads --bucket $BUCKET --profile shop-lab

# Cost Explorer: lọc Service = S3, group by Usage Type → thấy "Requests-Tier1" (PUT/LIST) hay "TimedStorage" lớn hơn

Sửa:

Số liệu giá thay đổi theo region, luôn đối chiếu pricing page; nhưng cấu trúc 5 nhóm thì không đổi và là thứ phỏng vấn muốn nghe.

Cho mình bài lab để tự tay làm phần này nhé.

Lab 22.6: Bucket shop-app-assets với BPA, versioning, lifecycle, bucket policy và presigned URL từ Spring Boot

Mục tiêu

Chi phí & thời gian

Chuẩn bị

Các bước

Bước 1 – Tạo bucket, BPA, versioning, mã hóa: chạy Bước 1 ở mục 4.
Console: S3 → Create bucket → Region ap-southeast-1 → Object Ownership: ACLs disabled → Block all public access (giữ tick) → Bucket Versioning: Enable → Default encryption: SSE-KMS, chọn alias/shop-key, Bucket Key: Enable → Tags.

aws s3api get-public-access-block --bucket $BUCKET --profile shop-lab
aws s3api get-bucket-versioning --bucket $BUCKET --profile shop-lab      # "Status": "Enabled"
aws s3api get-bucket-encryption --bucket $BUCKET --profile shop-lab

Bước 2 – Lifecycle: chạy Bước 2 ở mục 4.
Console: bucket → Management → Create lifecycle rule → Prefix originals/ → Transition current versions 30 ngày Standard-IA, 180 ngày Glacier Instant → Noncurrent 7 ngày Glacier Flexible, expire 90 ngày; rule thứ hai prefix tmp/ expire 1 ngày; rule thứ ba toàn bucket, Delete incomplete multipart 3 ngày.

aws s3api get-bucket-lifecycle-configuration --bucket $BUCKET --query 'Rules[].[ID,Status]' --output table --profile shop-lab

Bước 3 – Bucket policy: chạy Bước 3 ở mục 4. Tạm thêm vào statement AllowShopAppRole một principal nữa là ShopAdminRole (role bạn assume ở profile shop-lab) để chính bạn có thể upload file test; sẽ gỡ ở Thử thêm.
Console: bucket → Permissions → Bucket policy → Edit → dán JSON.

aws s3api get-bucket-policy --bucket $BUCKET --query Policy --output text --profile shop-lab | python -m json.tool

Bước 4 – Upload và presign bằng CLI: chạy Bước 5 ở mục 4.

Bước 5 – Spring Boot: chạy app, gọi:

# presigned PUT
curl -s -X POST "http://localhost:8080/api/products/42/images/presign?contentType=image/jpeg" \
  -H "Authorization: Bearer $TOKEN" | tee presign.json
URL=$(jq -r .url presign.json); KEY=$(jq -r .key presign.json)

# upload thẳng lên S3 bằng URL đó (phải gửi đúng header đã ký)
curl -s -o /dev/null -w "%{http_code}\n" -X PUT "$URL" \
  -H "Content-Type: image/jpeg" -H "x-amz-server-side-encryption: aws:kms" \
  --data-binary @front.jpg
# 200

# confirm → app HEAD object, ghi DB
curl -s -X POST "http://localhost:8080/api/products/42/images/confirm?key=$KEY" -H "Authorization: Bearer $TOKEN"

Nếu TOKEN chưa có vì bạn chưa cấu hình auth cho lab, tạm permitAll hai endpoint này trong profile lab.

Bước 6 – Event notification → SQS (nhìn trước bài 22.11):

QUEUE_URL=$(aws sqs create-queue --queue-name shop-s3-events-lab \
  --tags Project=shop-app,Env=lab --query QueueUrl --output text --profile shop-lab)
QUEUE_ARN=$(aws sqs get-queue-attributes --queue-url $QUEUE_URL --attribute-names QueueArn \
  --query Attributes.QueueArn --output text --profile shop-lab)

# Queue policy: cho S3 (từ đúng bucket) gửi message
aws sqs set-queue-attributes --queue-url $QUEUE_URL --attributes "{\"Policy\": \"$(cat <<EOF | jq -c . | sed 's/"/\\\\"/g'
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"s3.amazonaws.com"},
 "Action":"sqs:SendMessage","Resource":"$QUEUE_ARN",
 "Condition":{"ArnLike":{"aws:SourceArn":"arn:aws:s3:::$BUCKET"}}}]}
EOF
)\"}" --profile shop-lab

aws s3api put-bucket-notification-configuration --bucket $BUCKET --notification-configuration "{
  \"QueueConfigurations\": [{\"QueueArn\": \"$QUEUE_ARN\", \"Events\": [\"s3:ObjectCreated:*\"],
     \"Filter\": {\"Key\": {\"FilterRules\": [{\"Name\": \"prefix\", \"Value\": \"products/\"}]}}}]}" --profile shop-lab

Console: bucket → Properties → Event notifications → Create → prefix products/ → All object create events → Destination SQS queue. (Nếu lệnh set policy khó gõ, dán policy trong Console SQS → Access policy.)

Kiểm tra kết quả

# 1. BPA chặn thật: cố đặt ACL public → lỗi AccessControlListNotSupported hoặc AccessDenied
aws s3api put-object-acl --bucket $BUCKET --key products/42/front.jpg --acl public-read --profile shop-lab

# 2. HTTP không TLS bị Deny (endpoint http)
curl -s -o /dev/null -w "%{http_code}\n" "http://$BUCKET.s3.ap-southeast-1.amazonaws.com/products/42/front.jpg"   # 403

# 3. Upload không khai SSE-KMS bị Deny
aws s3 cp front.jpg s3://$BUCKET/products/42/plain.jpg --profile shop-lab   # AccessDenied (bucket policy DenyUnencryptedUploads)

# 4. Versioning: overwrite rồi xem 2 version
aws s3 cp front.jpg s3://$BUCKET/products/42/front.jpg --sse aws:kms --sse-kms-key-id alias/shop-key --profile shop-lab
aws s3api list-object-versions --bucket $BUCKET --prefix products/42/front.jpg \
  --query 'Versions[].[VersionId,IsLatest,LastModified]' --output table --profile shop-lab

# 5. Presigned GET hết hạn
U=$(aws s3 presign s3://$BUCKET/products/42/front.jpg --expires-in 15 --profile shop-lab)
curl -s -o /dev/null -w "%{http_code}\n" "$U"; sleep 20; curl -s -o /dev/null -w "%{http_code}\n" "$U"   # 200 rồi 403

# 6. Event đã vào SQS
aws sqs receive-message --queue-url $QUEUE_URL --max-number-of-messages 5 --profile shop-lab \
  | jq -r '.Messages[].Body | fromjson | .Records[]? | [.eventName, .s3.object.key, .s3.object.size] | @tsv'
# ObjectCreated:Put  products/42/front.jpg  204812

Thử thêm

A. Xóa nhầm và khôi phục.

aws s3 rm s3://$BUCKET/products/42/front.jpg --profile shop-lab        # tạo delete marker
aws s3 ls s3://$BUCKET/products/42/ --profile shop-lab                  # không thấy nữa
DM=$(aws s3api list-object-versions --bucket $BUCKET --prefix products/42/front.jpg \
  --query 'DeleteMarkers[?IsLatest].VersionId' --output text --profile shop-lab)
aws s3api delete-object --bucket $BUCKET --key products/42/front.jpg --version-id $DM --profile shop-lab
aws s3 ls s3://$BUCKET/products/42/ --profile shop-lab                  # trở lại

B. Gỡ ShopAdminRole khỏi bucket policy, chạy Spring Boot trên EC2 của ASG (role shop-app-ec2-role): upload vẫn được; chạy local với profile shop-lab thì 403. Đây là minh họa rõ nhất cho "resource policy quyết định principal nào vào được".

C. Lifecycle thử nhanh: không chờ 30 ngày được, nhưng có thể upload thẳng vào class khác và xem phí lấy ra:

aws s3 cp front.jpg s3://$BUCKET/originals/42/raw.jpg --storage-class GLACIER_IR --sse AES256 --profile shop-lab
aws s3api head-object --bucket $BUCKET --key originals/42/raw.jpg --query StorageClass --profile shop-lab
aws s3 cp front.jpg s3://$BUCKET/originals/42/deep.jpg --storage-class DEEP_ARCHIVE --sse AES256 --profile shop-lab
aws s3 cp s3://$BUCKET/originals/42/deep.jpg /tmp/ --profile shop-lab
# InvalidObjectState: phải restore-object trước (Tier Bulk/Standard), chờ giờ

D. Storage Lens: Console S3 → Storage Lens → default dashboard (dữ liệu xuất hiện sau 24–48h) để thấy noncurrent version và incomplete multipart toàn account.

Dọn tài nguyên

Bucket có versioning không xóa được khi còn version/delete marker; aws s3 rb --force chỉ xóa version hiện tại. Thứ tự:

# 1. Tắt event notification, xóa queue tạm
aws s3api put-bucket-notification-configuration --bucket $BUCKET --notification-configuration '{}' --profile shop-lab
aws sqs delete-queue --queue-url $QUEUE_URL --profile shop-lab

# 2. Xóa mọi version và delete marker (python one-liner qua boto3 nếu nhiều; ít thì CLI)
aws s3api list-object-versions --bucket $BUCKET --output json --profile shop-lab \
  --query '{Objects: Versions[].{Key:Key,VersionId:VersionId}}' > /tmp/v.json
[ "$(jq '.Objects|length' /tmp/v.json)" != "0" ] && aws s3api delete-objects --bucket $BUCKET --delete file:///tmp/v.json --profile shop-lab
aws s3api list-object-versions --bucket $BUCKET --output json --profile shop-lab \
  --query '{Objects: DeleteMarkers[].{Key:Key,VersionId:VersionId}}' > /tmp/dm.json
[ "$(jq '.Objects|length' /tmp/dm.json)" != "0" ] && aws s3api delete-objects --bucket $BUCKET --delete file:///tmp/dm.json --profile shop-lab

# 3. Multipart dở
aws s3api list-multipart-uploads --bucket $BUCKET --profile shop-lab   # nếu có: abort-multipart-upload từng cái

# 4. Xóa bucket (giữ lại nếu bạn làm tiếp 22.7–22.13: các bài sau dùng bucket này cho jar và ảnh)
aws s3api delete-bucket --bucket $BUCKET --profile shop-lab

Thứ tốn tiền nếu quên: KMS key (khoảng 1 USD/tháng, chỉ xóa ở bài 22.14 khi xong chương), object ở Glacier/Deep Archive xóa sớm bị tính phí tối thiểu 90/180 ngày (vài cent với file test). S3 Standard vài MB thì không đáng kể.

Phỏng vấn và đề thi SAA hỏi về S3 thì sao?

Q1. Thiết kế upload ảnh từ browser lên S3 trong Spring Boot thế nào?

🇻🇳 Tiếng Việt:

Em không cho file đi qua server. Browser gọi API của em xin presigned PUT URL; app kiểm tra quyền và content-type, dùng S3Presigner ký URL có hạn 5 phút với credential của role EC2, trả về cùng key. Browser PUT thẳng lên S3. Sau đó browser gọi confirm, app HEAD object để xác nhận kích thước và loại rồi mới lưu key vào DB. Object vào prefix tmp có lifecycle xóa sau 1 ngày để dọn upload bỏ dở. Bucket bật Block Public Access, mã hóa SSE-KMS, policy chỉ cho role app. Ảnh public phục vụ qua CloudFront với OAC, không mở bucket.

🇬🇧 English:

I keep the file off the application server. The browser asks my API for a presigned PUT URL; the app checks authorization and content type, signs a 5-minute URL with S3Presigner using the EC2 role credentials, and returns it with the object key. The browser PUTs directly to S3. Then it calls a confirm endpoint, where the app does a HEAD on the object to verify size and type before storing the key in the database. Objects land under a tmp prefix with a one-day lifecycle expiration to clean up abandoned uploads. The bucket has Block Public Access on, SSE-KMS encryption and a policy allowing only the app role. Public images are served through CloudFront with OAC, never from a public bucket.

Q2. Presigned URL có bảo mật không? Hạn tối đa bao lâu?

🇻🇳 Tiếng Việt:

Presigned URL là chữ ký SigV4 trên một request cụ thể: bucket, key, method, header đã ký, thời hạn. Ai có URL dùng được, nên nó giống bearer token: chỉ trả qua HTTPS cho người đã xác thực, TTL ngắn, không log URL đầy đủ. Hạn tối đa 7 ngày với IAM user key, nhưng URL không sống lâu hơn credential ký nó: role EC2 xoay vòng khoảng 6 giờ nên URL chết theo. Cần link dài ngày em dùng CloudFront signed URL hoặc user key chuyên dụng. Quyền thật vẫn do IAM và bucket policy quyết định, presign không vượt quá quyền của người ký.

🇬🇧 English:

A presigned URL is a SigV4 signature over one specific request: bucket, key, method, signed headers and expiry. Anyone holding it can use it, so it behaves like a bearer token: return it only over HTTPS to an authenticated user, keep the TTL short, and never log the full URL. The maximum is 7 days with IAM user keys, but a URL never outlives the credentials that signed it; EC2 role credentials rotate roughly every 6 hours, so URLs signed with them expire with them. For long-lived links I use CloudFront signed URLs or a dedicated user key. The actual permission still comes from IAM and the bucket policy; presigning cannot grant more than the signer has.

Q3. Chọn storage class và lifecycle cho dữ liệu của shop-app ra sao?

🇻🇳 Tiếng Việt:

Ảnh đang bán để Standard vì đọc nhiều. Ảnh gốc độ phân giải cao chuyển Standard-IA sau 30 ngày rồi Glacier Instant sau 180 ngày vì hiếm đọc nhưng khi cần phải có ngay. Log và backup DB sau 90 ngày sang Glacier Flexible, sau 1 năm Deep Archive, giữ 7 năm theo yêu cầu kế toán. Dữ liệu em không đoán được pattern thì dùng Intelligent-Tiering. Luôn có rule xóa noncurrent version và abort multipart dở. Em tránh chuyển object nhỏ hơn 128 KB sang IA vì không tiết kiệm.

🇬🇧 English:

Images currently on sale stay in Standard because they are read often. High-resolution originals move to Standard-IA after 30 days and Glacier Instant Retrieval after 180 days: rarely read, but needed immediately when they are. Logs and database backups go to Glacier Flexible Retrieval after 90 days and Deep Archive after a year, retained for 7 years for accounting compliance. For data whose access pattern I cannot predict I use Intelligent-Tiering. I always add rules to expire noncurrent versions and abort incomplete multipart uploads, and I avoid transitioning objects under 128 KB to IA because it does not save money.

Q4. Cross-account: team Data ở account khác cần đọc bucket log của bạn. Làm thế nào?

🇻🇳 Tiếng Việt:

Khác account thì cần cả hai phía cho phép: bucket policy bên em Allow principal là role của account Data trên prefix cần thiết, và identity policy của role đó cũng Allow s3:GetObject. Nếu bucket dùng SSE-KMS, key policy cũng phải cho role đó kms:Decrypt. Object Ownership để Bucket owner enforced để object bên kia ghi vào vẫn thuộc em. Phương án khác là em tạo role trong account mình cho họ AssumeRole, hoặc dùng S3 Access Point để quản lý nhiều consumer.

🇬🇧 English:

Across accounts both sides must allow it: my bucket policy allows the Data account's role on the needed prefix, and that role's identity policy also allows s3:GetObject. If the bucket uses SSE-KMS, the key policy must grant that role kms:Decrypt as well. I keep Object Ownership at Bucket owner enforced so anything they write still belongs to me. Alternatives are creating a role in my account for them to assume, or using S3 Access Points when there are many consumers.

Góc thi SAA-C03

Câu 1: Công ty lưu 50 TB hồ sơ kiểm toán trên S3, theo quy định phải giữ 7 năm, mỗi năm chỉ truy xuất 1–2 lần và có thể chờ tới 12 giờ. Giải pháp MOST cost-effective?
A. S3 Standard-IA
B. S3 Glacier Instant Retrieval
C. S3 Glacier Deep Archive
D. S3 Intelligent-Tiering

Đáp án: C. Hiếm truy cập, chấp nhận chờ 12 giờ, giữ dài hạn → Deep Archive rẻ nhất. A đắt gấp nhiều lần cho dữ liệu không đọc. B dành cho cần lấy tức thì (ms). D phí giám sát trên 50 TB và vẫn không rẻ bằng Deep Archive khi pattern đã biết rõ. Nếu đề nói "retrieve within minutes" thì chọn Glacier Flexible với Expedited.

Câu 2: Ứng dụng web cho người dùng toàn cầu upload video 2 GB lên một bucket ở ap-southeast-1. Người dùng ở châu Âu báo upload chậm và hay đứt. Cách cải thiện với LEAST change to the application?
A. Tạo bucket ở mỗi region và sửa app chọn bucket gần nhất
B. Bật S3 Transfer Acceleration và dùng multipart upload
C. Chuyển bucket sang us-east-1
D. Dùng EFS thay S3

Đáp án: B. Transfer Acceleration đưa upload tới edge gần user rồi đi backbone AWS; multipart chia nhỏ, retry từng part, chống đứt. A nhiều thay đổi app và dữ liệu phân mảnh. C chỉ chuyển vấn đề sang user châu Á. D EFS không phải object storage qua Internet.

Câu 3: Một bucket chứa dữ liệu nhạy cảm. Công ty yêu cầu: mọi object phải mã hóa bằng key do công ty quản lý và có log việc dùng key; không object nào được truy cập qua HTTP không mã hóa; dữ liệu không được public dù cấu hình nhầm. Tập hợp nào thỏa mãn? (Chọn 1)
A. SSE-S3 + bucket policy Deny aws:SecureTransport=false + ACL private
B. SSE-KMS với customer managed key + bucket policy Deny aws:SecureTransport=false + bật S3 Block Public Access ở cấp account
C. SSE-C + CloudFront + ACL private
D. Client-side encryption + bucket versioning + MFA Delete

Đáp án: B. "Key do công ty quản lý + audit dùng key" → SSE-KMS CMK (CloudTrail ghi mỗi lần dùng key). "Không HTTP" → điều kiện aws:SecureTransport. "Không public dù nhầm" → Block Public Access. A SSE-S3 không cho audit key; ACL không chống nhầm policy. C SSE-C bạn phải gửi key mỗi request, không audit, vận hành nặng. D versioning/MFA Delete chống xóa, không liên quan 3 yêu cầu.

Cách nhớ ngắn gọn

Từ khóa đềNghĩ đến
accidentally public, prevent public accessS3 Block Public Access (account level)
audit encryption key usage, company-managed keySSE-KMS với customer managed key
enforce encryption in transitBucket policy Deny aws:SecureTransport=false
temporary access, upload without AWS credentialsPresigned URL
unknown access patternIntelligent-Tiering
retrieve within minutes / hours / 12+ hoursGlacier Flexible Expedited / Flexible Standard / Deep Archive
recreatable data, lowest cost, infrequentOne Zone-IA
protect against accidental deletionVersioning (+ MFA Delete)
cannot be deleted by anyone for N years, WORMObject Lock Compliance mode
global users upload large filesTransfer Acceleration + multipart
copy to another region for DR / complianceCross-Region Replication
process file after upload, stop pollingS3 Event Notification → SQS/Lambda/EventBridge
query CSV/JSON in S3 with SQLAthena (bài 22.17)

Tự kiểm tra

Vì sao lưu key object vào DB thay vì URL?

URL phụ thuộc bucket, region, CDN, chữ ký. Lưu key, sinh URL lúc đọc (presigned hoặc CloudFront) để đổi hạ tầng không phải migrate dữ liệu.

Presigned URL ký bằng role EC2 có thể sống 7 ngày không?

Không. URL hết hiệu lực khi credential ký nó hết hạn; role EC2 xoay vòng ~6 giờ. Link dài ngày dùng CloudFront signed URL hoặc IAM user key riêng.

Bật SSE-KMS xong app bị 403 khi GET dù bucket policy đã Allow. Thiếu gì?

Role app cần kms:Decrypt trên key (trong key policy hoặc identity policy). Và nhớ bật Bucket Key để không bị phí KMS theo từng request.

ListBucket và GetObject áp trên ARN nào?

ListBucket trên arn:aws:s3:::bucket; GetObject/PutObject trên arn:aws:s3:::bucket/*. Viết chung một ARN là 403.

Bucket có versioning, xóa thế nào cho sạch khi dọn lab?

Phải xóa mọi Versions và DeleteMarkers (list-object-versions + delete-objects), abort multipart dở, tắt notification, rồi mới delete-bucket.

🧪 Thực hành bài này: Intermediate: Ứng dụng HA: ALB + ASG + RDS + S3 + SQS (chưa triển khai) · lab labs/aws