Excon::Error::Forbidden: Expected(200) <=> Actual(403 Forbidden) excon.error.response :body => "<?xml version=\"1.0\" en
RCA: Excon::Error::Forbidden 403 SignatureDoesNotMatch (S3 thumbnail upload)
Overview#
What Happened#
cupixworks-api (실제 앱 = tesla) 가 CarrierWave 를 통해 pointcloud 썸네일을 S3 cupix-tesla-static 버킷의 tmp/cache 경로로 PUT 업로드할 때, S3 가 403 Forbidden(SignatureDoesNotMatch 및 소수의 InvalidAccessKeyId)를 반환한다. 실패는 항상 전역 CarrierWave 설정(config/initializers/carrierwave.rb)이 사용하는 access key AKIAQBGWD5FH5URQYYOO 로 서명된 요청에서 발생하며, production 환경 다수 테넌트(walmart, gilbaneco, pclconstruction, accoes, tpc 등)에 걸쳐 결정론적으로 재현된다. 코드 결함이 아니라 전역 fog credential 이 stale/rotated/deactivated 되어 서명이 맞지 않는 credential 문제다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Excon::Error::Forbidden |
| exception.message | Expected(200) <=> Actual(403 Forbidden) — S3 <Code>SignatureDoesNotMatch</Code> (일부 InvalidAccessKeyId) |
| top_frame | config/initializers/carrierwave.rb:3-8 (전역 config.fog_credentials) |
| runtime | fog-aws → Excon (CarrierWave storage :fog cache/store) |
| env | production, us-west-2 |
| AWSAccessKeyId | AKIAQBGWD5FH5URQYYOO |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| cupixworks-api (tesla) — thumbnail upload | 14d 기준 365 spans (SignatureDoesNotMatch 330 + InvalidAccessKeyId 35) | pointcloud 썸네일 업로드가 조용히 실패 (사용자에게 500 노출 없음 — 아래 Impact 참조) |
Timeline#
- 2024-09-18 14:58 KST — 최초 발생 (
first_seen). Representative sample 의 StringToSign 날짜20240918와 일치. - 2024-09-18 ~ 2026-08-04 — ~23개월간 지속 재발, 149건 집계. 전역 credential
AKIAQBGWD5FH5URQYYOO로 서명한 PUT 이 계속 거부됨. - 2026-08-04 11:27 KST — 최근 발생 (
last_seen). APM span 타임스탬프2026-08-04T02:27:46Z(UTC) 와 정확히 일치 → representative 는 stale 아님.
Error Log#
Expected(200) <=> Actual(403 Forbidden)
excon.error.response
:body => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you provided. Check your key and signing method.</Message><AWSAccessKeyId>AKIAQBGWD5FH5URQYYOO</AWSAccessKeyId><StringToSign>AWS4-HMAC-SHA256-PAYLOAD\n20240918T055830Z\n20240918/us-west-2/s3/aws4_request\n...
Impact#
- Service:
cupixworks-api(APM adaptercupixworks-excon, 실제 앱 = tesla) - 발생 횟수: 149 (ET 집계); 14d span 기준 365 (SignatureDoesNotMatch 330 + InvalidAccessKeyId 35)
- 최초 발생: 2024-09-18 14:58 KST
- 최근 발생: 2026-08-04 11:27 KST
Root Cause Summary#
전역 CarrierWave 설정 config/initializers/carrierwave.rb:3-8 의 config.fog_credentials 가 사용하는 AWS access key AKIAQBGWD5FH5URQYYOO (및 그 secret) 가 S3 에서 유효하지 않아, 이 credential 로 서명한 썸네일 업로드 PUT 이 403 Forbidden 으로 거부된다. S3 응답이 두 종류로 나뉜다 — (1) SignatureDoesNotMatch (330건): access key 는 존재하지만 서명에 쓰인 secret key 가 일치하지 않음(secret rotation 불일치), (2) InvalidAccessKeyId (35건): access key 자체가 S3 records 에 존재하지 않음(key deactivate/삭제). 두 경우 모두 credential 자체의 문제이지 tesla 코드 로직의 결함이 아니다. 업로드 경로는 Parameter#set_parameters:21 @model.thumbnails = params[:thumbnails] 가 트리거하는 CarrierWave mount_uploaders :thumbnails 의 cache/store 단계이며, 이 단계가 per-instance credential 이 아닌 전역 config.fog_credentials 를 사용하기 때문에 전역 credential 이 stale 하면 결정론적으로 실패한다.
Technical Analysis#
Code Path#
- Entry point:
app/concerns/parameter.rb:20-21—thumbnails파라미터가 있으면 모델에 대입, CarrierWave 업로드 트리거
if params[:thumbnails].present? && @model.respond_to?(:thumbnails)
@model.thumbnails = params[:thumbnails]
end
- Uploader mount:
app/models/concerns/thumbnailable.rb:12-16—thumbnails컬럼이 있는 모델은ThumbnailUploader를 mount
mount_uploader :thumbnail, ThumbnailUploader
if column_names.include?('thumbnails')
mount_uploaders :thumbnails, ThumbnailUploader
end
- Uploader:
app/uploaders/thumbnail_uploader.rb:7-18—storage :fog,initialize에서 per-instance credential 을 merge 하지만 이는 store 단계용이며, cache 단계는 전역 설정을 사용
storage :fog
# ...
def initialize(*)
super
self.asset_host = model.asset_host
self.fog_credentials.merge!(model.fog_credentials)
self.fog_directory = model.fog_directory
- Failure point:
config/initializers/carrierwave.rb:3-8— 전역config.fog_credentials. access key/secret 을 ENV 또는Rails.application.credentials에서 읽으며, 이 credential (AKIAQBGWD5FH5URQYYOO) 이 S3 에서 유효하지 않아 서명 검증 실패
config.fog_credentials = {
provider: 'AWS',
aws_access_key_id: ENV['AWS_ACCESS_KEY_ID'] || Rails.application.credentials[Rails.env.to_sym][:aws][:access_key_id],
aws_secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'] || Rails.application.credentials[Rails.env.to_sym][:aws][:secret_access_key],
path_style: true
}
- 기대 동작: 유효한 access key/secret 로 서명 → S3 가 200 반환, 썸네일 업로드 성공.
- 실제 동작:
AKIAQBGWD5FH5URQYYOO의 secret 불일치(SignatureDoesNotMatch) 또는 key 부재(InvalidAccessKeyId) → S3 403. fog-aws 가 idempotent retry 를 시도해 동일 경로가 초 단위로 반복(사용자 재시도 아님). - 참고 — double-bucket path (
/cupix-tesla-static/cupix-tesla-static/uploads/tmp/...): 버킷명이 경로에 중복되는 것은 cache 경로가 전역fog_directory+ path-style 로 구성되며 나타나는 기존 코드 스멜(ef652994 episode 와 동일)이나, 이번 403 의 원인은 아니다. 원인은 credential 이다.
Log Evidence#
APM span 검색 (Excon 에러는 로그가 아니라 excon adapter span 으로만 존재 — Datadog 로그 검색은 0건):
service:cupixworks-excon status:error (now-14d)
→ 1408 spans total; Excon::Error::Forbidden = 365
로그 검색으로는 재현 불가(0건 확인):
service:cupixworks-api "SignatureDoesNotMatch" → 0 logs
"AKIAQBGWD5FH5URQYYOO" → 0 logs
403 Forbidden span 의 S3 <Code> 분포 (14d):
SignatureDoesNotMatch : 330 (AWSAccessKeyId = AKIAQBGWD5FH5URQYYOO)
InvalidAccessKeyId : 35
double-bucket path (/cupix-tesla-static/cupix-tesla-static/...) : 330
single-bucket path : 35
env: production 365
최근 span (last_seen 일치, representative 가 stale 아님을 입증):
time (UTC): 2026-08-04T02:27:46Z
http.method: PUT status: 403
http.url: /cupix-tesla-static/cupix-tesla-static/uploads/tmp/1785810457-984351683396298-0602-0634/hrcg.746653_1484541_pointcloud_thumbnail_3.jpg
error.message: <Code>SignatureDoesNotMatch</Code>...<AWSAccessKeyId>AKIAQBGWD5FH5URQYYOO</AWSAccessKeyId>
<StringToSign>AWS4-HMAC-SHA256-PAYLOAD\n20260804T022746Z\n20260804/us-west-2/s3/aws4_request\n...
InvalidAccessKeyId sample:
http.url: /cupix-tesla-static/uploads/tmp/1785523193-141720118534869-0631-3964/accoes.745973_1479892_pointcloud_thumbnail_2.jpg
error.message: <Code>InvalidAccessKeyId</Code><Message>The Access Key Id you provided does not exist in our records.</Message>
<BucketName>cupix-tesla-static</BucketName>
관련 에러 범위 확인 (revision #1) — service:cupixworks-excon status:error (now-14d) 전체를 attributes.error.type 별로 분해하면, 이번 credential 403 이 다른 excon 변형과 구분되고 uploader 경로별 분포가 드러난다:
error.type 분포 (14d, 1352 spans):
Excon::Error::Forbidden : 352 ← 이 클러스터 (credential 403)
Excon::Error::MovedPermanently : 455 (별개 ET — region 301)
Excon::Error::NotFound : 335 (별개 ET — avatar HEAD 404, cb076d70 noise)
Excon::Error::BadRequest : 145 (별개 ET)
Excon::Error::ServiceUnavailable: 62 (별개 ET — S3 SlowDown 503, 0f81412b)
Excon::Error::InternalServerError: 2 / OpenSSL::SSL::SSLError: 1
Forbidden 352 의 upload-path / bucket / key 분포:
upload path : thumbnail 352 / avatar 0 / cover 0 / logo 0 (전부 thumbnail)
S3 <Code> : SignatureDoesNotMatch 315 / InvalidAccessKeyId 37
AWSAccessKeyId : AKIAQBGWD5FH5URQYYOO 315 / (InvalidAccessKeyId 는 key 미표기)
핵심: credential 403 은 전부 cupix-tesla-static 버킷의 thumbnail 경로에서만 발생한다. avatar/cover/logo 는 *-hosting-* 버킷을 쓰며(avatar_uploader.rb:18 default_hosting_bucket_name), 이들의 excon 에러 335건은 전부 Excon::Error::NotFound(avatar HEAD 404 = 별개 noise)로 credential 문제가 아니다. 즉 지금 이 키가 거부되는 서명 대상은 static 버킷뿐이지만, 4개 uploader 모두 access key/secret 은 동일한 전역 credential 을 상속한다.
전역 credential 이 모든 uploader 에 상속됨을 코드로 확인 (per-instance override 는 region/endpoint/directory 만 담당):
def initialize(*)
super
self.fog_credentials['region'] = $AWS[:s3][:default_bucket_region] # region 만 set
self.asset_host = "https://#{$AWS[:cloudfront][:default_hostname]}"
self.fog_attributes = { cache_control: 'max-age=315576000' }
end
fog_credentials: {
region: $AWS[:s3][:static_bucket_region],
endpoint: v2_s3_endpoint
},
대비 — credential-safe 패턴 (STS 단기 토큰, 서버측 CarrierWave 는 이 경로를 우회):
{
aws_access_key_id: token.access_key_id,
aws_secret_access_key: token.secret_access_key,
aws_session_token: token.session_token,
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 전역 config.fog_credentials 의 access key/secret 이 stale/rotated/deactivated 되어 서명 불일치 |
S3 <Code>SignatureDoesNotMatch</Code> 330건 + InvalidAccessKeyId 35건, 모두 동일 key AKIAQBGWD5FH5URQYYOO; 다수 테넌트에 걸쳐 결정론적 재현; env=production 100% |
— | Confirmed |
| H2 | ef652994 처럼 region 미지정(AuthorizationHeaderMalformed) 문제 |
전역 config.fog_credentials 에 region 미지정은 사실 |
이 에러는 SignatureDoesNotMatch/InvalidAccessKeyId 이며 StringToSign 의 scope 가 20260804/us-west-2/s3 로 region 이 이미 us-west-2 로 올바름 → region 문제 아님 |
Rejected |
| H3 | 사용자 입력/클라이언트 오류로 인한 서명 실패 | — | 서명은 서버(tesla)가 전역 credential 로 계산; 클라이언트가 관여하지 않음. 동일 경로 초당 반복은 fog-aws idempotent retry | Rejected |
| H4 | representative(2024-09-18) 가 stale 하고 현재는 다른 에러 | ET 가 변형을 한 이슈로 묶는 경향 | 최근 span(2026-08-04T02:27:46Z, last_seen 일치)이 동일 SignatureDoesNotMatch·동일 key·동일 경로 재현 → representative trustworthy |
Rejected |
Fix Recommendation#
즉시 조치 (Critical) — 인프라/운영, 코드 변경 아님#
- tesla 코드 결함이 아니다. access key
AKIAQBGWD5FH5URQYYOO및 그 secret 을 점검하라:- IAM 콘솔에서 해당 access key 의 상태(Active/Inactive/삭제) 확인 →
InvalidAccessKeyId는 key 부재를 의미. - production 배포 환경변수(
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) 및Rails.application.credentials[:production][:aws]의 access/secret 조합이 실제 IAM key 와 일치하는지 확인 →SignatureDoesNotMatch는 secret 불일치를 의미. - key rotation 이 누락/부분 적용되었다면 유효한 credential 로 갱신·재배포. 갱신 후 재발 중단 여부를 아래 Monitoring 쿼리로 확인.
- IAM 콘솔에서 해당 access key 의 상태(Active/Inactive/삭제) 확인 →
- 인프라/운영 담당과 조율 필요 — 코드 배포로 해결되는 항목이 아니므로 자동 code-fix 대상에서 제외한다.
단기 개선 (1주 이내)#
- 전역
config.fog_credentials(carrierwave.rb:3-8) 를 IAM instance role/IRSA 기반 credential 로 전환하는 방안 검토 → 정적 access key/secret 을 제거하면 rotation 불일치로 인한 서명 실패가 구조적으로 사라진다 (인프라 조율 필요). - 썸네일 업로드 실패를 감지 가능한 위치에서
warn로그로 남기는 방안 검토 — 현재 excon 403 은 로그가 아니라 APM span 으로만 남아 알람 사각지대에 있다.
장기 개선 (재발 방지)#
진짜 관련 에러 = credential 로 서명한 모든 fog 업로드 PUT. 이번 클러스터의 root cause 는 전역 config.fog_credentials 의 access key/secret 이므로, 재발 방지의 판정 기준은 "이 정적 credential 을 쓰는 모든 경로가 커버되는가" 이다. tesla 의 4개 fog uploader(ThumbnailUploader/AvatarUploader/CoverUploader/LogoUploader, 모두 storage :fog)는 initialize 에서 region/endpoint/directory 만 override 하고 access key/secret 은 전부 전역 config.fog_credentials 를 그대로 상속한다 (아래 근거 참조). 따라서 credential 노후화 위험은 썸네일 한 경로가 아니라 fog 업로드 전체에 걸쳐 있다.
- 정적 access key/secret 제거가 유일하게 "모든" 관련 에러를 커버하는 방식: 전역
config.fog_credentials(carrierwave.rb:3-8) 를 IAM instance role/IRSA 또는 STS 세션 토큰 기반으로 전환한다. tesla 는 이미 클라이언트 업로드 경로에서 STS 단기 토큰을 발급하는 패턴을 보유한다 (Cupix::StorageServicestorage_service.rb:194-197aws_access_key_id: token.access_key_id+aws_session_token). 서버측 CarrierWave 업로드만 이 안전 패턴을 우회해 정적 키를 쓰므로, 이를 동일 패턴으로 흡수하면 secret rotation 불일치(SignatureDoesNotMatch)·key deactivate(InvalidAccessKeyId)로 인한 서명 실패가 4개 uploader 전체에서 구조적으로 사라진다. 이것이 단기 개선의 IAM role 전환 항목과 동일선상이며, 진짜 관련 에러를 모두 처리하는 방식이다. - per-model 설정 단일화(구 권장안)만으로는 관련 에러가 해소되지 않는다 — per-model 설정(
thumbnailable/v1.rb:8-11,v2.rb:8-11, 그리고 avatar/cover/logoinitialize)은 region/endpoint 만 담고 access key/secret 은 담지 않기 때문이다. double-bucket path 정리·region 일관성은 ef652994(400AuthorizationHeaderMalformed)에는 유효하지만 이번 credential 403 에는 무효하다. 따라서 double-bucket/구조 정리는 코드 스멜 청소 항목으로 남기되, 재발 방지의 핵심은 credential 소스 전환이다.
참고 — 이번 클러스터의 "진짜 관련" 범위 (span 실측, 아래 Log Evidence 확장 참조): 최근 14d 에서 credential 403(SignatureDoesNotMatch/InvalidAccessKeyId)은 전부 cupix-tesla-static 버킷의 thumbnail 경로에서만 발생하고 avatar/cover/logo(모두 *-hosting-* 버킷)에서는 0건이다. 즉 지금 이 키(AKIAQBGWD5FH5URQYYOO)가 거부되는 대상은 static 버킷 서명뿐이다. 그러나 IAM role 전환은 버킷·uploader 무관하게 정적 키 자체를 제거하므로, 향후 다른 버킷/uploader 의 키가 노후화되어도 재발하지 않는다 — "모든 관련 에러 처리"의 상위 집합을 커버한다.
Monitoring#
배포/credential 갱신 후 excon 403 span 추세:
service:cupixworks-excon status:error @error.type:Excon::Error::Forbidden
썸네일 업로드 대상 static 버킷 PUT 403 추세:
service:cupixworks-excon status:error @http.status_code:403 @http.method:PUT
Risk Assessment#
- Risk level: medium (사용자에게 500 이 노출되진 않으나 썸네일 업로드가 조용히 실패해 UX/데이터 완결성 저하; 원인이 credential 이라 코드 배포로 즉시 해결 불가)
- 예상 복잡도: standard (인프라 credential 점검/rotation)
Noise Verdict#
noise — 원인이 전역 fog credential(AKIAQBGWD5FH5URQYYOO)의 stale/rotation 불일치라는 인프라 설정 문제이며 tesla 코드로 고칠 수 있는 결함이 아니다.
Revision History#
Revision 1#
Feedback: 장기 개선 방식으로 진짜 관련 에러를 모두 처리할 수 있는지 점검. (reanalysis — 기존 장기 개선 권장안이 root cause 와 연관된 모든 에러를 실제로 커버하는지 재검증 요청.)
판정:
| 피드백 항목 | 판정 | 근거 |
|---|---|---|
| 기존 장기 개선(double-bucket path 정리 + per-model 설정 단일화)이 진짜 관련 에러(credential 403)를 모두 처리하는가 | 부분 수용 | 처리하지 못한다. per-model 설정(thumbnailable/v1.rb:8-11, v2.rb:8-11, avatar_uploader.rb:21-29 및 cover/logo 동일)은 region/endpoint/directory 만 담고 aws_access_key_id/aws_secret_access_key 는 담지 않는다. 이 키/시크릿은 4개 fog uploader 전부 전역 config.fog_credentials(carrierwave.rb:3-8)에서 상속하며, ThumbnailUploader#initialize:17 의 merge!(model.fog_credentials) 도 region/endpoint 만 덮는다. 따라서 per-model 단일화로는 credential 노후화(이번 root cause)가 해소되지 않는다. 다만 "재발 방지를 재검토해야 한다"는 피드백의 취지는 정확하므로 장기 개선을 credential 소스 전환(IAM role/IRSA/STS) 중심으로 재작성 → 부분 수용. |
| 진짜 관련 에러의 범위(어떤 uploader/버킷/에러가 이 credential 문제에 속하는가) | 수용 | 14d span 실측(service:cupixworks-excon status:error, attributes.error.type 분해): Excon::Error::Forbidden 352 는 전부 thumbnail→cupix-tesla-static 경로, key AKIAQBGWD5FH5URQYYOO(SignatureDoesNotMatch 315 / InvalidAccessKeyId 37). avatar/cover/logo 는 0건이며 이들의 excon 에러 335 는 전부 Excon::Error::NotFound(avatar HEAD 404, cb076d70 별개 noise). MovedPermanently 455/BadRequest 145/ServiceUnavailable 62 도 각각 별개 ET. 즉 credential 403 의 현재 실측 범위는 static 버킷 thumbnail 뿐이나, 정적 키가 4개 uploader 에 공통 상속되므로 IAM role 전환이 상위 집합을 커버. |
| double-bucket path/region 구조 정리를 재발 방지 핵심으로 유지 | 거부 | 이번 403 의 root cause 는 credential(secret 불일치/key 부재)이지 region/path 가 아니다. StringToSign scope 20260804/us-west-2/s3 로 region 은 이미 올바르며(H2 Rejected 와 일치), double-bucket path 는 ef652994(400 AuthorizationHeaderMalformed)의 원인일 뿐 credential 403 과 무관. 따라서 구조 정리는 코드 스멜 청소 항목으로 강등하고 핵심에서 제외. |
변경 사항:
## Fix Recommendation→### 장기 개선 (재발 방지)를 재작성: "진짜 관련 에러 = 정적 credential 로 서명하는 모든 fog 업로드 PUT" 로 범위를 정의하고, 재발 방지 핵심을 정적 access key/secret 제거(IAM role/IRSA/STS)로 변경. per-model 설정 단일화만으로는 커버 불가함을 코드 근거와 함께 명시. double-bucket/구조 정리는 코드 스멜 청소로 강등.### Log Evidence에 "관련 에러 범위 확인" 블록 추가: 14d 전체 excon 에러의error.type분포, Forbidden 352 의 upload-path/bucket/key 분포, 전역 credential 이 4개 uploader 에 상속됨을 보이는 uploaderinitialize/v2_settings코드, 대비군인 STSstorage_service.rb:194-197.
추가 조사 내용:
- tesla repo 재탐색:
app/uploaders/{thumbnail,avatar,cover,logo}_uploader.rb(4개storage :fog),app/uploaders/concerns/signed_content_uploader.rb(credential 미관여, path prefix 만),app/models/concerns/thumbnailable.rb:135-141+thumbnailable/v1.rb:5-15·v2.rb:5-15(per-model 설정이 region/endpoint 만 담음 확인),config/initializers/carrierwave.rb:3-8(전역 static 키),app/services/cupix/storage_service.rb:194-197(STS 단기 토큰 credential-safe 패턴). - Datadog span 재조사:
service:cupixworks-excon status:error(now-14d) 를attributes.error.type로 분해해 credential 403 을 다른 excon 변형과 분리하고, upload-path/bucket/key 분포로 관련 에러 범위를 실측.