ES /docs

FloorplanService::uploadFile | response path: /tmp/workspace/86196/86196.png, code: 403, message: Fo

RCA: FloorplanService::uploadFile | 403 Forbidden

Overview#

What Happened#

2026-05-07 22:47~23:05 UTC, cupixworks-any-floorplan-agent 서비스에서 floorplan 파일을 S3에 업로드하는 과정에서 HTTP 403 Forbidden 에러가 2건 발생했다. 동일 사용자(philip.hall@cupix.com)가 업로드한 고해상도 PDF 도면(LOD 7, 32768x32768 pixels) 처리 중 presigned URL이 만료되어 S3 PUT 요청이 거부된 것이 원인이다.

Quick Facts#

Field Value
exception.class HTTP 403 (S3 Presigned URL Rejected)
exception.message FloorplanService::uploadFile | response path: /tmp/workspace/86196/86196.png, code: 403, message: Forbidden
top_frame floorplan-service.ts:297
env production, us-west-2

Affected Teams#

Team / Domain Error Count Impact
redwoodeg (Redwood Electric Group) 2 고해상도 PDF 도면 2건 처리 실패, 사용자 Floorplan 사용 불가

Timeline#

  1. 2026-05-07T21:02:58Z — Floorplan 86214 생성 (user: philip.hall@cupix.com)
  2. 2026-05-07T20:45:40Z — Floorplan 86196 첫 번째 처리 시도 시작
  3. 2026-05-07T21:03:57Z — Floorplan 86214 처리 시작 (presigned URL 발급)
  4. 2026-05-07T22:46:31Z — Floorplan 86196 두 번째 처리 시도 (SQS 재전송)
  5. 2026-05-07T22:47:27Z — Floorplan 86196 uploadFile 403 실패
  6. 2026-05-07T23:05:01Z — Floorplan 86214 uploadFile 403 실패 (presigned URL TTL 2h 초과)

Error Log#

Datadog Logs

text
FloorplanService::uploadFile | response path: /tmp/workspace/86196/86196.png, code: 403, message: Forbidden

Impact#

  • Service: cupixworks-any-floorplan-agent
  • 발생 횟수: 2
  • 최초 발생: 2026-05-07T22:47:27.334Z
  • 최근 발생: 2026-05-07T23:05:01.811Z
  • 영향 받은 floorplan: 86196, 86214 (동일 사용자, 동일 세션)
  • 사용자 영향: 도면 처리 실패 → processing_failed 상태로 전환, Slack 알림 발송됨

Root Cause Summary#

Floorplan agent가 Tesla API에서 presigned S3 upload URL을 발급받은 후, GraphicsMagick을 이용한 PDF → PNG 변환(translateFloorplan)이 2시간 이상 소요되어 presigned URL의 TTL(2시간)을 초과했다. LOD 7(32768x32768 pixels, 300 DPI)의 대형 PDF 도면 변환은 극도로 높은 연산 부하를 유발하며, 현재 presigned URL은 변환 완료 전에 만료된다.

Technical Analysis#

Code Path#

  • Entry point: floorplan-service.ts:51 (run method)
  • Presigned URL 획득: cpfloorplan.ts:62 (API 응답에서 upload_url 저장)
  • 변환 시작: floorplan-service.ts:81 (translateFloorplan)
  • 업로드 시도: floorplan-service.ts:82 (uploadFile)
  • Failure point: floorplan-service.ts:296-297 (S3 응답 403)

1. Presigned URL 발급 (Tesla API)

app/models/concerns/storagable/resource.rb:111-131ruby
def presigned_upload_url(revision, force: false)
  revision ||= self.revision + 1

  if !force && revision < self.revision
    raise Cupix::Errors::Parameter.new(code: 'ARG10001', reason: "Invalid revision: #{revision}")
  end

  client = Cupix::StorageService.client(storage_option: storage_option)
  signer = Aws::S3::Presigner.new(client: client)
  bucket_name = storage_option.s3_source_bucket_name
  expires_in = 2.hour.to_i  # ← 7200초 = 2시간 TTL

  signer.presigned_url(
    :put_object,
    bucket: bucket_name,
    key: object(revision).key,
    storage_class: 'ONEZONE_IA',
    expires_in: expires_in,
    acl: 'bucket-owner-full-control'
  )
end

2. 실행 흐름 (Floorplan Agent)

packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:70-83typescript
if (cpFloorplan.uploadUrl != undefined
    && cpFloorplan.localFilePath != undefined
    && cpFloorplan.cpFloorplanSource.downloadUrl != undefined
    && cpFloorplan.cpFloorplanSource.originalFilePath != undefined
) {
    await this.updateFloorplanState(targetId, TESLA.FloorplanState.Processing);
    await this.downloadFileWithHeader(
        cpFloorplan.cpFloorplanSource.downloadUrl,
        cpFloorplan.cpFloorplanSource.originalFilePath,
        cpFloorplan.cpFloorplanSource.headers
    );
    await this.translateFloorplan(cpFloorplan);  // ← LOD 7에서 2시간+ 소요
    await this.uploadFile(cpFloorplan.uploadUrl, cpFloorplan.localFilePath);  // ← 이 시점에 URL 만료
    await this.cupixApi.floorplan.checkUploading(cpFloorplan.id);
}

기대 동작: translateFloorplan 완료 후 presigned URL로 S3 업로드 성공 실제 동작: 변환이 2시간 이상 소요 → presigned URL 만료 → S3가 403 반환

3. uploadFile 메서드 (실패 지점)

packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:278-307typescript
private uploadFile = (url: string, path: string, headers?: any): Promise<void> => new Promise((resolve, reject) => {
    this.setErrorCode(ErrorCode.Agent.FloorplanFailedToUpload);
    logger.debug('FloorplanService::uploadFile | start path: %s, url: %s', path, url);
    const cupixAuth = this.cupixAuth;
    const sendReq = request.put(url, {
        headers: headers != undefined ? headers : {
            'X-CUPIX-AUTH': cupixAuth.accessToken
        },
        body: fs.readFileSync(path)
    });

    sendReq
        .on('response', res => {
            if (res.statusCode !== 200) {
                logger.error('FloorplanService::uploadFile | response path: %s, code: %d, message: %s', path, res.statusCode, res.statusMessage);
                cupixAuth.handleError(res);
                reject();  // ← 403 시 reject, 에러 정보 없이 reject
            }
        })
});

4. 변환 파라미터 (LOD 7)

packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:160-163typescript
const tileLod = this.determineTileLod(cpFloorplan);
const width = Constants.DefaultTilePixel * Math.pow(2, tileLod);   // 256 * 2^7 = 32768
const height = Constants.DefaultTilePixel * Math.pow(2, tileLod);  // 256 * 2^7 = 32768
const dpi = Constants.DefaultDpi;  // 300

32768x32768 pixels = 약 1.07 billion pixels. GraphicsMagick으로 300 DPI PDF를 이 크기로 래스터라이즈하는 것은 메모리와 CPU 집약적 작업이다.

Log Evidence#

Datadog 검색 쿼리:

text
service:cupixworks-any-floorplan-agent "86214"

Floorplan 86214 타임라인 (presigned URL 만료 확인):

text
2026-05-08 06:03:57 KST  [200] GET /api/v1/floorplans/86214 (presigned URL 발급 시점)
2026-05-08 06:03:57 KST  [200] PUT /api/v1/floorplans/86214 (state → processing)
2026-05-08 06:03:57 KST  FloorplanService::translateFloorplan | source: .../35021.pdf, width: 32768, height: 32768, dpi: 300, LOD: 7
2026-05-08 08:05:01 KST  FloorplanService::uploadFile | response path: .../86214.png, code: 403, message: Forbidden

경과 시간: 06:03:57 → 08:05:01 = 2시간 1분 4초 (presigned URL TTL 2시간 초과)

Floorplan 86196 타임라인 (2번 시도):

text
# 첫 번째 시도
2026-05-08 05:45:40 KST  BaseService::runByMessage | id: 86196
2026-05-08 05:45:42 KST  FloorplanService::translateFloorplan | source: .../34993.pdf, width: 32768, height: 32768, dpi: 300, LOD: 7
# (약 2시간 후 presigned URL 만료로 실패 → SQS 재전송)

# 두 번째 시도
2026-05-08 07:46:31 KST  BaseService::runByMessage | id: 86196
2026-05-08 07:46:33 KST  FloorplanService::translateFloorplan | source: .../34993.pdf, width: 32768, height: 32768, dpi: 300, LOD: 7
2026-05-08 07:47:27 KST  FloorplanService::uploadFile | response path: .../86196.png, code: 403, message: Forbidden

두 번째 시도는 변환 시간이 54초로 짧은데(캐시된 출력 파일 존재 가능), 여전히 403 실패. 이는 첫 번째 시도의 실패로 인해 resource state가 변경되어 유효한 presigned URL이 재발급되지 않았거나, S3 bucket policy와 presigned URL의 signed parameter 불일치가 원인일 수 있다.

에러 후 처리 결과 확인:

json
{
  "event_id": 743652322,
  "action": "update",
  "reason": "processing_failed",
  "model_type": "Floorplan",
  "model_id": 86214
}

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Presigned URL TTL(2h) 초과로 URL 만료 86214: 06:03:57→08:05:01 = 2h1m4s > 2h TTL. 86196: 첫 시도도 ~2h 소요 86196 두 번째 시도는 54초 내 실패 (fresh URL이면 만료 아님) Confirmed (primary)
H2 S3 bucket ACL/ObjectOwnership 정책 변경 403은 ACL 관련 S3 거부 시 나타남 Infrastructure: object_ownership = "ObjectWriter" (ACL 활성). resource.rb 최근 변경 없음 Rejected
H3 Agent session token 만료로 presigned URL 발급 실패 2h 이상 실행 시 세션 만료 가능 API GET 호출 성공 ([200] GET /api/v1/floorplans/86214), presigned URL은 API 서버 측 AWS credentials로 생성 Rejected
H4 대용량 파일(32768x32768 PNG) S3 single PUT 제한 초과 S3 single PUT은 5GB 한도 32768x32768 PNG는 ~100-500MB 수준으로 5GB 미만 Rejected
H5 두 번째 시도에서 resource_state 변경으로 nil upload_url 반환 첫 시도 실패 후 resource_state가 done 등으로 변경될 수 있음 코드 확인: updateErrorState는 floorplan state만 변경, resource_state는 여전히 uploading으로 serializer가 upload_url 반환함. 또한 nil이면 uploadFile 자체가 호출되지 않음 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • app/models/concerns/storagable/resource.rb:121 — presigned URL TTL을 현재 2.hour.to_i에서 6.hours.to_i 또는 12.hours.to_i로 증가. S3 presigned URL 최대 TTL은 7일(IAM user) 또는 12시간(STS credentials)이므로 credential 유형 확인 필요.

단기 개선 (1주 이내)#

  • floorplan-service.ts:82translateFloorplan 완료 후 uploadFile 호출 직전에 Tesla API에서 fresh presigned URL을 재발급 받는 로직 추가. 변환 시간이 길어도 항상 유효한 URL 사용 보장.
  • 대안: translateFloorplan 완료 후 cupixApi.floorplan.get(targetId)를 다시 호출하여 최신 upload_url을 획득한 뒤 uploadFile에 전달.

장기 개선 (재발 방지)#

  • LOD 7 이상의 대형 도면에 대해 translateFloorplan 타임아웃 또는 프로그레스 모니터링 도입. 변환 시간이 presigned URL TTL에 근접할 경우 사전 경고 또는 URL 갱신 로직 트리거.
  • GraphicsMagick 성능 최적화 또는 더 높은 사양의 인스턴스 타입 사용 검토 (현재 32768x32768 변환에 2시간+ 소요는 비정상적으로 느림).
  • 파일 크기/해상도에 따른 적응형 presigned URL TTL 설정 고려.

Monitoring#

  • 추가 메트릭: translateFloorplan 소요 시간을 측정하는 로그 또는 메트릭 추가
  • Datadog 알림: 변환 시간이 1시간 초과 시 경고
  • 쿼리 예시:
text
service:cupixworks-any-floorplan-agent "FloorplanService::uploadFile" status:error

Risk Assessment#

  • Risk level: medium
  • 예상 복잡도: standard
  • 영향 범위: LOD 7(또는 그 이상) 크기의 대형 PDF 도면에만 해당. 일반적인 도면(LOD 5-6)은 변환 시간이 presigned URL TTL 내에 완료되므로 영향 없음.