ES /docs

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#

  1. 2024-09-18 14:58 KST — 최초 발생 (first_seen). Representative sample 의 StringToSign 날짜 20240918 와 일치.
  2. 2024-09-18 ~ 2026-08-04 — ~23개월간 지속 재발, 149건 집계. 전역 credential AKIAQBGWD5FH5URQYYOO 로 서명한 PUT 이 계속 거부됨.
  3. 2026-08-04 11:27 KST — 최근 발생 (last_seen). APM span 타임스탬프 2026-08-04T02:27:46Z (UTC) 와 정확히 일치 → representative 는 stale 아님.

Error Log#

Datadog Logs

text
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 adapter cupixworks-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-8config.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-21thumbnails 파라미터가 있으면 모델에 대입, CarrierWave 업로드 트리거
app/concerns/parameter.rb:20-22ruby
if params[:thumbnails].present? && @model.respond_to?(:thumbnails)
  @model.thumbnails = params[:thumbnails]
end
  • Uploader mount: app/models/concerns/thumbnailable.rb:12-16thumbnails 컬럼이 있는 모델은 ThumbnailUploader 를 mount
app/models/concerns/thumbnailable.rb:12-16ruby
mount_uploader :thumbnail, ThumbnailUploader

if column_names.include?('thumbnails')
  mount_uploaders :thumbnails, ThumbnailUploader
end
  • Uploader: app/uploaders/thumbnail_uploader.rb:7-18storage :fog, initialize 에서 per-instance credential 을 merge 하지만 이는 store 단계용이며, cache 단계는 전역 설정을 사용
app/uploaders/thumbnail_uploader.rb:7-18ruby
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/initializers/carrierwave.rb:3-8ruby
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건):

text
service:cupixworks-excon status:error   (now-14d)
→ 1408 spans total; Excon::Error::Forbidden = 365

로그 검색으로는 재현 불가(0건 확인):

text
service:cupixworks-api "SignatureDoesNotMatch"   → 0 logs
"AKIAQBGWD5FH5URQYYOO"                            → 0 logs

403 Forbidden span 의 S3 <Code> 분포 (14d):

text
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 아님을 입증):

text
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:

text
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 경로별 분포가 드러난다:

text
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 만 담당):

app/uploaders/avatar_uploader.rb:21-29 (cover/logo 도 동일 패턴)ruby
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
app/models/concerns/thumbnailable/v2.rb:8-11 (thumbnail 이 merge! 하는 model.fog_credentials — access key/secret 없음)ruby
fog_credentials: {
  region: $AWS[:s3][:static_bucket_region],
  endpoint: v2_s3_endpoint
},

대비 — credential-safe 패턴 (STS 단기 토큰, 서버측 CarrierWave 는 이 경로를 우회):

app/services/cupix/storage_service.rb:194-197ruby
{
  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_credentialsregion 미지정은 사실 이 에러는 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 쿼리로 확인.
  • 인프라/운영 담당과 조율 필요 — 코드 배포로 해결되는 항목이 아니므로 자동 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::StorageService storage_service.rb:194-197 aws_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/logo initialize)은 region/endpoint 만 담고 access key/secret 은 담지 않기 때문이다. double-bucket path 정리·region 일관성은 ef652994(400 AuthorizationHeaderMalformed)에는 유효하지만 이번 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 추세:

text
service:cupixworks-excon status:error @error.type:Excon::Error::Forbidden

썸네일 업로드 대상 static 버킷 PUT 403 추세:

text
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:17merge!(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 에 상속됨을 보이는 uploader initialize / v2_settings 코드, 대비군인 STS storage_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 분포로 관련 에러 범위를 실측.