개요
운영 환경에서 S3 버킷을 여러 서비스(ECS, EC2, 온프레미스 서버, 사내 PC)에서 접근해야 하는 상황에서, 보안과 운영 편의성을 모두 만족하는 접근 제어 체계를 설계하고 구축했다.
핵심 원칙:
- 퍼블릭 액세스 완전 차단
- IAM Role/User 기반 최소 권한 부여
- TLS(HTTPS) 강제
- Presigned URL을 통한 외부 공유
퍼블릭 액세스 차단 + IAM Role 접근
S3 퍼블릭 액세스 차단을 활성화하면서도 내부 서비스 접근은 정상 동작하도록 설계했다.
퍼블릭 액세스 차단 → "Principal": "*" 에만 영향
IAM Role 기반 접근 → "Principal": "arn:aws:iam::..." 은 차단되지 않음
| 항목 | 동작 여부 |
|---|---|
| ECS Task Role에서 S3 접근 | 허용됨 (HTTPS 사용 시) |
| Task Role로 서명한 Presigned URL | 허용됨 (HTTPS 사용 시) |
| 익명(퍼블릭) 접근 | 차단됨 |
환경별 접근 권한 설계
1. ECS 서비스 → S3
컨테이너 내부 애플리케이션이 S3에 접근하려면 Task Role에 권한을 부여해야 한다.
ECS Task Definition
└── Task Role (컨테이너 내 코드가 AWS API 호출 시 사용)
└── S3 접근 정책 연결
Task Execution Role이 아닌 Task Role에 부여해야 한다. Execution Role은 이미지 Pull, 로그 전송 등 ECS 인프라 작업용이다.
2. EC2 서버 → S3
EC2 인스턴스에 연결된 IAM Role에 정책을 추가한다.
3. 온프레미스 서버 → S3
AWS 외부 서버이므로 EC2 Instance Role을 사용할 수 없다. IAM User를 생성하고 Access Key를 발급받아 사용한다.
4. 사내 PC → S3
테스트/로그 확인/수동 파일 관리 용도로 IAM User Access Key + AWS CLI를 사용한다.
IAM 정책 설계 (최소 권한)
특정 Prefix 하위만 접근 가능하도록 제한한다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowListSpecificPrefix",
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::[bucket-name]",
"Condition": {
"StringLike": {
"s3:prefix": ["outbound/*"]
}
}
},
{
"Sid": "AllowReadWriteSpecificPrefix",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::[bucket-name]/outbound/*"
}
]
}ListBucket은 버킷 레벨 + prefix 조건으로 제한- Object 권한은 특정 prefix 하위만 허용
- 불필요한 권한(DeleteObject 등)은 필요 시에만 추가
버킷 정책 설계
운영 환경 (Presigned URL 전용)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowPresignedFromAppRole",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::[account-id]:role/[task-role-name]"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::[bucket-name]/outbound/*",
"Condition": {
"Bool": { "aws:SecureTransport": "true" }
}
},
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::[bucket-name]",
"arn:aws:s3:::[bucket-name]/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
}
]
}개발 환경 (IP 제한 + Role 허용)
개발 환경에서는 사내 IP 대역 허용 + 특정 Role 허용 + 그 외 전부 차단하는 구조다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowInternalIPsReadList",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::[bucket-name]",
"arn:aws:s3:::[bucket-name]/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "true" },
"IpAddress": {
"aws:SourceIp": [
"[office-ip-1]/32",
"[office-ip-2]/32"
]
}
}
},
{
"Sid": "AllowPresignedFromAppRole",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::[account-id]:role/[task-role-name]"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::[bucket-name]/outbound/*",
"Condition": {
"Bool": { "aws:SecureTransport": "true" }
}
},
{
"Sid": "DenyOtherIPsExceptAppRole",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::[bucket-name]",
"arn:aws:s3:::[bucket-name]/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "true" },
"NotIpAddress": {
"aws:SourceIp": ["[office-ip-1]/32", "[office-ip-2]/32"]
},
"StringNotLike": {
"aws:PrincipalArn": [
"arn:aws:iam::[account-id]:role/[task-role-name]",
"arn:aws:sts::[account-id]:assumed-role/[task-role-name]/*"
]
}
}
},
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::[bucket-name]",
"arn:aws:s3:::[bucket-name]/*"
],
"Condition": {
"Bool": { "aws:SecureTransport": "false" }
}
}
]
}핵심 구조:
- 사내 IP → 읽기/목록 허용
- 특정 Role → Presigned URL용 GetObject 허용
- 그 외 IP + 그 외 Principal → 전부 Deny
- HTTP(비SSL) → 전부 Deny
Presigned URL: STS 세션 만료 문제와 장기키 전환
문제 발생
ECS Task Role(STS 기반)로 Presigned URL을 생성했을 때, URL 유효기간을 7일로 설정해도 STS 세션 토큰이 먼저 만료되어 ExpiredToken(400) 에러가 발생했다.
[기존 구조 - 문제]
ECS Task Role (STS 기반)
↓ 임시 자격증명 (세션 만료 있음)
↓ Presigned URL 생성
↓ STS 세션 만료 시 URL 조기 사망
→ 주말/월요일 걸치는 다운로드 실패
해결: IAM User 장기키로 전환
[현재 구조 - 해결]
IAM User 장기키
↓ 세션 만료 없음
↓ Presigned URL 생성
↓ URL 만료시간 = 실제 유효시간 (최대 7일)
IAM User 생성 (prod/dev 분리)
IAM Console → Users → Create user
User name: [service]-presign-signer-prod / dev
Access type: Programmatic access (Access Key)
태그: Service=[service], Purpose=PresignSigner, Env=prod/dev
Presigned 전용 최소 권한 정책
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowGetPutForPresignPrefixesOnly",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": [
"arn:aws:s3:::[bucket-name]/[prefix-1]/*",
"arn:aws:s3:::[bucket-name]/[prefix-2]/*"
]
}
]
}검증 방법
생성된 Presigned URL에서 다음을 확인한다:
| 항목 | 장기키 서명 (정상) | STS 서명 (문제) |
|---|---|---|
X-Amz-Expires | 604800 (7일) | 604800 |
X-Amz-Security-Token | 없음 | 있음 |
X-Amz-Security-Token이 있으면 아직 STS로 서명되고 있는 것이다.
코드에서 장기키 명시
// Presigned URL 전용 클라이언트 (장기키 명시)
S3Presigner presigner = S3Presigner.builder()
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(accessKeyId, secretAccessKey)
))
.region(Region.AP_NORTHEAST_2)
.build();
DefaultCredentialsProvider를 사용하면 ECS 환경에서 Task Role(STS)로 서명될 수 있으므로, Presigned URL 생성 시에는 반드시StaticCredentialsProvider로 장기키를 명시해야 한다.
온프레미스 서버 접근 설정
AWS 외부 서버에서 S3에 접근하는 경우:
# AWS CLI 프로파일 설정
aws configure --profile [profile-name]
# 업로드 테스트
aws s3 cp file.txt s3://[bucket-name]/outbound/ --profile [profile-name]
# 다운로드 테스트
aws s3 cp s3://[bucket-name]/outbound/file.txt ./downloaded.txt --profile [profile-name]
# Presigned URL 생성
aws s3 presign s3://[bucket-name]/outbound/file.txt \
--expires-in 3600 --profile [profile-name]Python에서 프로파일 지정:
session = boto3.Session(profile_name='[profile-name]')
s3 = session.client('s3')보안 운영 통제
장기키는 편리하지만 유출 시 리스크가 있으므로 다음 통제를 적용한다:
- prod/dev 키 완전 분리
- Prefix 단위 최소 권한 (버킷 전체 접근 불가)
- TLS 강제 (
aws:SecureTransport) - Access Key 정기 로테이션
- CloudTrail로 키 사용 이력 추적
- 장기키를 컨테이너 환경변수로 전역 설정하지 않음 (다른 AWS 호출까지 장기키로 갈 수 있음)
정리
- 퍼블릭 액세스 차단 + IAM Role 기반 접근으로 보안 확보
- ECS Task Role / EC2 Instance Role / IAM User(온프레미스)별 최소 권한 설계
- 버킷 정책: IP 제한 + Role 허용 + TLS 강제 + 나머지 Deny
- STS 세션 만료로 인한 Presigned URL 조기 사망 문제를 장기키 전환으로 해결
- prod/dev 분리, Prefix 제한, StaticCredentialsProvider 명시로 보안 통제
배운 점
접근 제어에서 어려운 부분은 막는 게 아니라 필요한 것만 남기는 일이다. 퍼블릭 차단은 스위치 하나지만, 그 뒤에 ECS, EC2, 온프레미스, 사내 PC가 각자 어떤 자격으로 들어오는지를 정리하는 데 시간이 훨씬 많이 들었다.
Presigned URL이 만료 시간보다 먼저 죽는 문제는 특히 기억에 남는다. URL의 유효 기간이 아니라 서명에 쓰인 자격증명의 수명이 진짜 상한이라는 걸 그때 알았다. 설정값이 아니라 그 값을 지탱하는 조건을 봐야 한다.
여기서 세운 원칙은 이후 점검으로 이어졌다. Access Key에서 IAM Role 기반 인증으로 전환, S3 퍼블릭 액세스 차단 가이드, S3 Presigned URL 관련 자원 보안 점검에 각각 정리해 뒀다.
이어 읽기
- Access Key에서 IAM Role 기반 인증으로 전환: 왜, 어떻게: 장기키를 걷어내는 방향
- S3 Presigned URL 관련 자원 보안 점검: 이 설계를 실제로 감사한 기록
- S3 퍼블릭 액세스 차단 가이드: 차단 설정의 판단 기준