BlueByte
AccessDeniedFixed

AWS S3 PutObject가 허용 정책이 있는데도 AccessDenied로 실패

작성 Haneul Seo2026년 8월 25일 업데이트6 min

안녕하세요, BlueByte입니다. PutObject가 AccessDenied로 계속 실패하는데 정책에 s3:PutObject가 분명히 보인다면, 허용을 더 넣어도 소용없습니다 — 무언가가 쓰기를 거부하고 있고, 거부는 항상 이깁니다. 오늘은 실제로 결정을 내리는 정책 하나를 찾아 그것을 고치는 순서로 가보겠습니다.

여기서 AccessDenied가 실제로 말하는 것

호출자에게 s3:PutObject가 있는데도 호출이 실패합니다:

An error occurred (AccessDenied) when calling the PutObject operation:
Access Denied

AWS에서는 허용이 뒤집힐 수 있습니다. 평가 사슬 어딘가가 쓰기를 거부하고 있고, 허용을 더 추가해도 거부는 못 이깁니다. 그러니 할 일은 "더 부여하기"가 아니라, 어떤 정책이 결정하는지 찾아 그것을 바로잡는 것입니다.

허용이 있는데도 뒤집히는 이유

요청은 허용이 있고 평가되는 어디에도 거부가 없을 때만 성공합니다 — 자격 정책, 버킷 정책, 권한 경계, 조직의 SCP. 유효한 자격 허용이 있는데도 실패하는 흔한 이유는 이렇습니다.

  • 호출자가 다른 계정이고 버킷 정책이 그를 부여하지 않음 — 자격 허용은 계정 경계를 스스로 넘지 못함.
  • 어딘가에 명시적 Deny — 예: aws:SecureTransport가 true가 아니면 거부(평문 HTTP 차단), 또는 SCP의 거부.
  • 쓰기에 bucket-owner-full-control 같은 ACL을 쓰는데 버킷이 객체 소유권 = 버킷 소유자 강제라 ACL이 비활성화됨.
  • 버킷을 보호하는 KMS 키가 호출자에게 kms:GenerateDataKey를 거부해 암호화 쓰기가 실패.
  • 요청의 키·버킷이 정책의 Resource와 안 맞음.

시뮬레이터에게 어떤 구문이 결정하는지 묻기

정책 네 개를 머릿속으로 읽을 필요 없습니다 — IAM 정책 시뮬레이터에게 어떤 구문이 이기는지 물어보세요:

aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::111122223333:role/uploader \
  --action-names s3:PutObject \
  --resource-arns arn:aws:s3:::my-bucket/path/key

결과가 허용/거부 구문을 짚어줍니다. 허용이라는데 실제 호출이 실패하면 시뮬레이터가 포함하지 않은 버킷 정책이나 SCP에 거부가 있는 것 — 그다음 그걸 확인하세요. 실패하는 CLI 호출에 --debug를 붙이면 대상 리소스와 KMS 단계 여부가 보입니다.

결정을 내리는 그 정책을 고치기

  1. 교차 계정 호출자 — 버킷 정책에 주체를 명시적으로 추가:
{ "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::111122223333:role/uploader" },
  "Action": "s3:PutObject", "Resource": "arn:aws:s3:::my-bucket/*" }
  1. 명시적 거부 — 찾아서 좁힙니다. 흔한 게 비TLS 거부이니 클라이언트가 HTTPS를 쓰는지 확인하세요.

  2. ACL 쓰기가 소유권에 막힘 — ACL을 빼고 버킷 정책 부여에 의존하거나, 정말 ACL이 필요할 때만 "버킷 소유자 강제"를 바꾸세요.

실제 사례: 계정 경계를 넘은 쓰기

계정 B의 업로더 역할이 계정 A의 버킷에 쓰는데 s3:PutObject가 있는데도 AccessDenied가 납니다. 역할에 대한 시뮬레이터는 "허용"이라 자격 정책에서 눈을 돌리게 합니다. 계정 A의 버킷 정책을 읽어보니 계정 B를 부여하는 구문이 없습니다 — 자격 허용이 계정 경계를 넘지 못한 것입니다. 계정 B 역할에 대한 명시적 Allow를 버킷 정책에 추가하니 쓰기가 성공합니다. 역할은 아무것도 안 바뀌었고, 빠진 부여가 처음부터 리소스 쪽에 있었습니다.

객체가 실제로 들어왔는지 확인

정확히 그 쓰기를 재시도하고, 진짜 들어왔는지 봅니다:

aws s3api put-object --bucket my-bucket --key path/key --body ./file
aws s3api head-object --bucket my-bucket --key path/key

head-object가 성공하면 오류 메시지만 바뀐 게 아니라 수정된 정책으로 쓰기가 실제로 통과한 것입니다.

다시 겪지 않으려면

소유권 설정이 조용히 쓰기를 막지 않도록 ACL보다 버킷 정책 부여를 선호하고, SCP 거부를 문서화하고, 버킷이 암호화됐으면 키에 kms:GenerateDataKey를 부여하고, 새 교차 계정 접근을 설정할 때 운영에서 거부를 발견하지 말고 시뮬레이터를 함께 쓰세요.

퍼블릭 액세스·리전 오류와의 구분

브라우저에서 GetObject의 AccessDenied는 대개 퍼블릭 액세스 차단이라는 다른 설정입니다. 잘못된 리전의 NoSuchBucket이나 403은 정책 문제가 전혀 아니니 — 정책을 건드리기 전에 엔드포인트와 버킷 이름을 확인하세요. 다음에 허용이 분명한데 쓰기가 거부되면, 허용이 아니라 거부에서 시작하세요.

관련 질문

IAM 정책이 분명히 s3:PutObject를 허용하는데 왜 거부되나요?

허용은 필요조건이지 충분조건이 아닙니다. 명시적 거부가 있는 버킷 정책·권한 경계·SCP, 또는 교차 계정 허용 부재가 이를 뒤집습니다.

퍼블릭 액세스 차단을 켠 직후 깨졌습니다. 관련 있나요?

그럴 수 있습니다. 퍼블릭 액세스 차단과 버킷 소유자 강제는 ACL을 비활성화합니다. 업로더가 ACL에 의존했다면 버킷 정책 부여로 바꾸세요.

정책 시뮬레이터는 허용이라는데 실제 호출은 거부됩니다.

시뮬레이터가 평가하지 않은 정책 — 대개 버킷 정책이나 SCP — 에 거부가 있습니다. 그것들을 직접 확인하고 CLI 호출에 --debug를 붙여 정확한 리소스를 확인하세요.

객체 키가 중요한가요?

네. 버킷 정책이 Resource를 접두사로 한정하면 다른 키로의 쓰기는 거부됩니다. 쓰는 키가 정책의 Resource와 맞는지 확인하세요.

버킷이 KMS로 암호화돼 있습니다. 그게 AccessDenied를 낼 수 있나요?

네. KMS 암호화 버킷의 PutObject는 키에 대한 kms:GenerateDataKey도 필요합니다. 키 정책이 호출자를 거부하면 S3 권한이 맞아도 AccessDenied로 실패합니다.

참고 자료

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

같은 카테고리 다른 글

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)

V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트

컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.

Docker