← 목록

S3 버킷 접근 제어 및 Presigned URL 운영 전략

퍼블릭 액세스 차단 + IAM Role 기반 접근 제어, ECS/EC2/온프레미스 환경별 S3 권한 설계, STS 세션 만료 문제 해결을 위한 장기키 Presigned URL 전략

2025-02-17AWSSecurityDevOpsS3

개요

운영 환경에서 S3 버킷을 여러 서비스(ECS, EC2, 온프레미스 서버, 사내 PC)에서 접근해야 하는 상황에서, 보안과 운영 편의성을 모두 만족하는 접근 제어 체계를 설계하고 구축했다.

핵심 원칙:


퍼블릭 액세스 차단 + 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/*"
    }
  ]
}

버킷 정책 설계

운영 환경 (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" }
      }
    }
  ]
}

핵심 구조:


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-Expires604800 (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')

보안 운영 통제

장기키는 편리하지만 유출 시 리스크가 있으므로 다음 통제를 적용한다:


정리


배운 점

접근 제어에서 어려운 부분은 막는 게 아니라 필요한 것만 남기는 일이다. 퍼블릭 차단은 스위치 하나지만, 그 뒤에 ECS, EC2, 온프레미스, 사내 PC가 각자 어떤 자격으로 들어오는지를 정리하는 데 시간이 훨씬 많이 들었다.

Presigned URL이 만료 시간보다 먼저 죽는 문제는 특히 기억에 남는다. URL의 유효 기간이 아니라 서명에 쓰인 자격증명의 수명이 진짜 상한이라는 걸 그때 알았다. 설정값이 아니라 그 값을 지탱하는 조건을 봐야 한다.

여기서 세운 원칙은 이후 점검으로 이어졌다. Access Key에서 IAM Role 기반 인증으로 전환, S3 퍼블릭 액세스 차단 가이드, S3 Presigned URL 관련 자원 보안 점검에 각각 정리해 뒀다.


이어 읽기