ES /docs

AwsS3Manager#uploadBySignedUrl S3 503 미처리 — 재시도 없음

RCA: PanoPostprocessorService mask upload ECONNRESET

Overview#

What Happened#

2026-06-09 18:14 KST 시점 production us-west-2 환경의 cupixworks-pano-postprocessor-instance에서 pano id 87672493에 대한 mask 이미지를 S3 signed URL로 업로드하던 중 read ECONNRESET가 발생했다. MaskWork.changeMaskAwsS3Manager.uploadBySignedUrl 경로에서 axios PUT 요청이 TLS socket 레벨에서 끊겼고, 호출부에는 retry 로직이 없어 한 번의 transient network 단절이 그대로 단일 pano의 mask 처리 실패로 이어졌다. 동일 capture pipeline 내 다른 panos는 정상 동작했고, 27분 뒤 같은 service의 다른 pano(87684967)에서는 503 Request failed with status code 503가 같은 호출 경로에서 1회 발생했다 (다른 클러스터).

Quick Facts#

Field Value
exception.class Error
exception.message read ECONNRESET
exception.code ECONNRESET
top_frame app.cjs:6586 (AwsS3Manager.uploadBySignedUrl)
origin axios@1.16.1 PUT to S3 signed URL (https)
env production / us-west-2

Affected Teams#

Team / Domain Error Count Impact
secc / pano-postprocessor 1 pano 87672493 1건이 mask 업로드 단계에서 실패하여 Pano.state = Error로 마킹됨. 동일 capture의 다른 panos 처리에는 영향 없음.

Timeline#

  1. 2026-06-09 18:14:06 KSTMaskWork.changeMaskAwsS3Manager.uploadBySignedUrl 호출 중 TLS socket이 ECONNRESET으로 끊김 (Datadog error log)
  2. 2026-06-09 18:14:06 KSTPanoPostprocessorService::run의 catch 블록이 에러를 잡고 panoPostprocessorManager.updatePanoState(panoId, Error) 호출, erroredPanoIds에 추가
  3. 2026-06-09 18:41:08 KST — 같은 서비스/같은 호출 스택에서 다른 pano(87684967)가 503 Request failed with status code 503로 실패 (별개 클러스터, 동일 fix가 완화 가능)

Error Log#

Datadog Logs

text
PanoPostprocessorService::run | mask pano id:87672493 | error {"stack":"Error: read ECONNRESET
    at AxiosError.from (/tmp/agent/dist/node_modules/.pnpm/axios@1.16.1/node_modules/axios/dist/node/axios.cjs:1268:24)
    at RedirectableRequest.handleRequestError (/tmp/agent/dist/node_modules/.pnpm/axios@1.16.1/node_modules/axios/dist/node/axios.cjs:3733:25)
    at RedirectableRequest.emit (node:events:536:35)
    at RedirectableRequest.emit (node:domain:489:12)
    at eventHandlers.<computed> (/tmp/agent/dist/node_modules/.pnpm/follow-redirects@1.16.0/node_modules/follow-redirects/index.js:56:24)
    at ClientRequest.emit (node:events:524:28)
    at ClientRequest.emit (node:domain:489:12)
    at emitErrorEvent (node:_http_client:101:11)
    at TLSSocket.socketErrorListener (node:_http_client:504:5)
    at TLSSocket.emit (node:events:536:35)
    at Axios.request (/tmp/agent/dist/node_modules/.pnpm/axios@1.16.1/node_modules/axios/dist/node/axios.cjs:4992:41)
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async AwsS3Manager.uploadBySignedUrl (/tmp/agent/dist/app.cjs:6586:22)
    at async MaskWork.changeMask (/tmp/agent/dist/app.cjs:6849:5)
    at async /tmp/agent/dist/app.cjs:6992:17","message":"read ECONNRESET","name":"Error","code":"ECONNRESET","config":{}}

Impact#

  • Service: cupixworks-pano-postprocessor-instance
  • Team: secc
  • 발생 횟수: 1
  • 최초 발생: 2026-06-09 18:14 KST
  • 최근 발생: 2026-06-09 18:14 KST

영향 범위는 단일 pano(87672493). 해당 pano는 Pano.state = Error로 표기되어 후속 infer/resize 단계에서도 정상 처리되지 못한다. 다른 panos·다른 captures는 영향을 받지 않으며, capture 전체 postprocess는 partial success로 종료된다 (hasError가 catch 외부에서는 set되지 않으므로 PanoPostprocessorStateEnum.Done 처리됨).

Root Cause Summary#

S3 signed URL에 axios.put으로 mask 이미지를 업로드하는 AwsS3Manager.uploadBySignedUrltransient network failure(TCP RST / TLS socket abort)에 대한 retry 없이 단일 시도로 구성되어 있어, S3 또는 그 사이 네트워크 경로에서 발생한 일시적인 연결 종료가 그대로 사용자(pano) 영향으로 전파된다. ECONNRESET은 retryable한 transient 에러이며, 같은 service에서 직전 14일 동안 AwsS3Manager.downloadParallel / uploadDirectoryByCredential 등 다른 S3 호출 경로에서도 동일한 transient 단절이 반복적으로 관찰된다(아래 Log Evidence 참조). 즉 단일 인시던트가 아닌 retry 미적용으로 인한 만성적인 노이즈 패턴이다.

Technical Analysis#

Code Path#

Entry: pano-postprocessor-service.ts:130-141 — capture 내 모든 panos에 대해 pLimit(10)로 병렬 mask 처리.

applications/agents/packages/cupix-pano-postprocessor/src/pano-postprocessor-service.ts:130-141typescript
const maskTasks = cpPanos.map((cpPano) => {
    return PARALLEL_TASK_LIMIT(async () => {
        try {
            await this.maskWork.changeMask(cpPano, maskTypeOption);
        } catch (error) {
            logger.error('PanoPostprocessorService::run | mask pano id:%d | error %s', cpPano.panoId, stringifyError(error));
            await this.panoPostprocessorManager.updatePanoState(cpPano.panoId!, TESLA.UpdatePanoRequest.StateEnum.Error);
            erroredPanoIds.add(cpPano.panoId!);
        }
    });
});

changeMask가 signed URL 발급 → 업로드 → checkUploadingupdateMaskType을 순차 호출:

applications/agents/packages/cupix-pano-postprocessor/src/work/mask-work.ts:90-100typescript
async changeMask(cpPano: CPPano, maskType: 'face' | 'facebody') {
    if (!cpPano.panoId || !cpPano.maskImagePath || !fs.existsSync(cpPano.maskImagePath)) return;

    const mask = await this.createMask(cpPano.panoId, maskType);

    if (!mask) return;

    await this.awsS3Manager.uploadBySignedUrl(mask.uploadUrl, cpPano.maskImagePath);
    await this.checkUploading(cpPano.panoId, maskType);
    await this.updateMaskType(cpPano.panoId, mask.maskId);
}

Failure point: aws-s3.manager.ts:86-106axios.put이 한 번만 호출되고 catch도 retry도 없음.

applications/agents/packages/cupix-pano-postprocessor/src/manager/aws-s3.manager.ts:86-106typescript
async uploadBySignedUrl(signedUrl: string, imagePath: string) {
    const agent = new https.Agent({ keepAlive: true });
    const fileSize = fs.statSync(imagePath).size;

    logger.debug('AwsS3Manager::uploadBySignedUrl | mask image path:%s mask file size:%d KB', imagePath, (fileSize / 1024).toFixed(2));

    const fileStream = fs.createReadStream(imagePath);
    const response = await axios.put(signedUrl, fileStream, {
        httpsAgent: agent,
        headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': fileSize }
    });

    if (response.status !== 200) {
        logger.error('AwsS3Manager::uploadBySignedUrl | upload fail - %s %s', imagePath, JSON.stringify(response.statusText));
        return;
    }
    // ...
}

기대 동작: transient한 TCP/TLS 단절은 application 레벨에서 짧은 backoff 후 재시도되어 사용자 영향이 없어야 한다.

실제 동작: 첫 시도에서 ECONNRESET → axios가 즉시 reject → changeMask 전체가 reject → 상위 try/catch가 pano를 Error 상태로 마킹.

또한 aws-s3.manager.ts:86에서 매 호출마다 새 https.Agent({ keepAlive: true })를 생성한다. keepAlive하나의 agent 안에서 socket을 재사용할 때 의미가 있는데, 여기서는 호출당 새 agent라 connection pooling 효과가 없다. 이로 인해 새 TLS handshake 비용이 매 업로드마다 발생하고, 짧은 lifetime의 socket이 ECONNRESET에 더 노출된다.

Log Evidence#

검색 쿼리 (Datadog):

text
service:cupixworks-pano-postprocessor-instance status:error @environment:production "PanoPostprocessorService::run"

이 클러스터의 단일 발생 (timestamp는 KST 표시):

text
2026-06-09 18:14:06 KST  status=error
PanoPostprocessorService::run | mask pano id:87672493 | error
{"message":"read ECONNRESET","name":"Error","code":"ECONNRESET","config":{}}
  at async AwsS3Manager.uploadBySignedUrl (app.cjs:6586:22)
  at async MaskWork.changeMask (app.cjs:6849:5)

같은 서비스, 같은 호출 스택에서 27분 뒤 발생한 별개 에러 (다른 fingerprint, 별개 클러스터):

text
2026-06-09 18:41:08 KST  status=error
PanoPostprocessorService::run | mask pano id:87684967 | error
{"message":"Request failed with status code 503","name":"AxiosError","code":"ERR_BAD_RESPONSE","status":503}
  at async AwsS3Manager.uploadBySignedUrl (app.cjs:6586:22)
  at async MaskWork.changeMask (app.cjs:6849:5)

같은 서비스의 다른 S3 경로(uploadDirectoryByCredential, downloadParallel)에서도 14일 내 transient 네트워크 단절 반복 — 광범위한 패턴임을 시사 (샘플):

text
2026-06-04 18:03:09 KST  AwsS3Manager::uploadDirectoryByCredential | upload failed
{"message":"read ECONNRESET","errno":-104,"code":"TimeoutError","syscall":"read",
 "region":"me-central-1","hostname":"s3.me-central-1.amazonaws.com",
 "retryable":true,"statusCode":500}

2026-06-05 01:11:56 KST  AwsS3Manager::downloadParallel | download failed
  - pano: /tmp/workspace/capture_707768/download/pano_87113606.jpg, error: read ECONNRESET
  (6회 반복 — pLimit(10) 동시 요청 컨텍스트에서 burst)

2026-06-09 18:58:52 KST  AwsS3Manager::uploadDirectoryByCredential | upload failed
{"message":"We encountered an internal error. Please try again.",
 "code":"InternalError","statusCode":500,"retryable":true}

검색 쿼리:

text
service:cupixworks-pano-postprocessor-instance status:error "ECONNRESET"

총 22건 / 14일 — 모두 S3 호출 경로(upload 또는 download). AWS SDK 사용 경로(uploadDirectoryByCredential)는 응답 JSON에 "retryable": true를 명시하고 있어 SDK 자체가 retryable로 분류한 transient 에러임이 확인된다. 동일한 transience 특성을 axios 기반 uploadBySignedUrl에도 적용해야 한다.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 S3 signed URL 업로드 도중 transient TCP/TLS 단절(ECONNRESET)이 발생했고, uploadBySignedUrl에 retry가 없어 1회 실패가 그대로 pano 실패로 전파됨 스택 트레이스에 TLSSocket.socketErrorListener, code: ECONNRESET, aws-s3.manager.ts:86-106에 retry/catch 없음, 14일 내 동일 service S3 경로에서 22건의 ECONNRESET (retryable: true로 마킹된 SDK 응답 포함) Confirmed
H2 Signed URL 만료(서명 expired)로 인한 인증 실패 스택은 application/HTTP layer가 아닌 TLSSocket layer에서 socketErrorListener로 끊긴 패턴이며, 만료 시 S3는 일반적으로 403 응답을 반환함. 503/ECONNRESET은 만료와 무관 Rejected
H3 업로드 파일 크기 과다로 timeout mask PNG는 일반적으로 작음 (uploadDirectoryByCredential 로그 기준 다른 산출물도 수십~수백 MB 수준이고, 단일 pano mask는 그보다 훨씬 작음). 또한 timeout은 ETIMEDOUT/ECONNABORTED로 표시되며 본 건은 ECONNRESET Rejected
H4 pLimit(10) 병렬 부하로 로컬 socket 고갈 매 호출마다 새 https.Agent({ keepAlive: true })를 만들고 즉시 버려 keepAlive 효과가 무력화됨 (aws-s3.manager.ts:87) 단일 instance 내 10 동시성은 socket 고갈을 유발하기에는 낮음. 다만 새 TLS handshake 빈도를 늘려 transient ECONNRESET 노출도를 높일 수는 있음 Inconclusive (기여 가능, 단독 원인 아님)
H5 S3 측 일시 장애 (region 단위) 6/9 09:58Z InternalError 500 동시 발생 등 같은 시간대 다른 S3 호출에서도 transient 에러 관측 us-west-2 광역 장애 보고는 본 RCA 검색 범위에서 확인되지 않음. 다른 captures는 같은 시간대 정상 처리됨 Rejected as primary cause (기여 요인일 수 있음)

Fix Recommendation#

즉시 조치 (Critical)#

  • 파일: applications/agents/packages/cupix-pano-postprocessor/src/manager/aws-s3.manager.ts:86-106 (uploadBySignedUrl)
  • 접근: transient 에러(ECONNRESET, ETIMEDOUT, ECONNABORTED, HTTP 5xx, HTTP 429)에 대해 짧은 exponential backoff retry를 적용한다 (예: 3회, 0.5s/1s/2s 정도). 이미 의존성에 사용되는 axios에 대해 axios-retry 또는 명시적 재시도 루프 중 팀 컨벤션에 맞는 방식 채택. 4xx(403/404 등)는 재시도하지 않고 즉시 fail-fast.
  • 동일한 패턴을 downloadByPanoApi(aws-s3.manager.ts:153-176) 및 uploadDirectoryByCredential(aws-s3.manager.ts:108-151)에도 일관 적용. 후자는 AWS SDK 호출이므로 maxRetries/retryDelayOptions 설정 검토.
  • 파일: 같은 메서드 line 87 — https.Agent({ keepAlive: true }) 생성 위치를 메서드 내부에서 클래스 필드로 끌어올려 호출 간 socket 재사용을 가능하게 한다 (현재는 매 호출마다 새 agent라 keepAlive 무의미).

단기 개선 (1주 이내)#

  • 같은 패키지에서 동일 stack(uploadBySignedUrl)으로 5xx 클러스터(별개 fingerprint, 같은 file:line)도 발생 중. retry 도입과 동시에 클러스터들을 묶어 회귀 테스트한다.
  • PanoPostprocessorService::run의 catch 블록에서 transient 실패와 영구 실패를 구분해 metric을 분리(pano.postprocess.mask.transient_failure vs pano.postprocess.mask.permanent_failure). 현재는 둘 다 pano.postprocess.summary.errored에 합산되어 retry 효과 측정이 어려움 (pano-postprocessor-service.ts:185).
  • uploadBySignedUrl이 retry 후에도 실패한 경우에 한해 pano를 Error 상태로 마킹하도록 호출 측 로직 정리.

장기 개선 (재발 방지)#

  • 모든 외부 HTTP/S3 호출에 대한 공통 retry/timeout policy를 @agents/utils 또는 공유 레이어에 표준화. 현재는 각 패키지(cupix-pano-postprocessor, cupix-pix-genie-preprocessor-agent 등)에 aws-s3.manager.ts가 거의 복제 분포되어 있어 한쪽만 고치면 동일 이슈가 다른 패키지에서 재발한다 — 코드 위치 grep 결과 uploadBySignedUrl 정의가 4곳에 존재.
  • S3 endpoint 별 health/Latency dashboard 추가, transient 비율이 임계 초과 시 throttle.

Monitoring#

추가/검증할 메트릭과 알림.

  • pano-postprocessor mask 업로드 실패율 (시계열):
text
sum:pano.postprocess.mask.failure{service:cupixworks-pano-postprocessor-instance}.as_count()

(주의: 위 메트릭은 단기 개선에서 새로 도입을 권장하는 것. 도입 전까지는 아래 log-based count로 대체 가능.)

  • 로그 기반 mask 업로드 ECONNRESET/5xx count (release dashboard 호환 — count/stats/threshold 미사용):
text
logs("service:cupixworks-pano-postprocessor-instance status:error \"AwsS3Manager.uploadBySignedUrl\"").index("*").rollup("count", "*").by("service").last("1h")
  • 동일 서비스의 모든 S3 transient 패턴 추세:
text
logs("service:cupixworks-pano-postprocessor-instance status:error (\"ECONNRESET\" OR \"status code 503\" OR \"InternalError\")").index("*").rollup("count", "*").by("service").last("1h")

알림 (monitor에서만 사용, dashboard 위젯 아님):

  • uploadBySignedUrl 실패가 1시간 동안 5건 이상이면 secc 팀에 alert.
  • retry 도입 후 동일 임계치를 그대로 두면 retry로 흡수되는 transient는 alert 없이 지나가고, 진짜 사고만 통과해야 한다.

Risk Assessment#

  • Risk level: low (단일 occurrence, 동일 capture의 다른 panos는 영향 없음, partial success로 capture 종료)
  • 단, 수정하지 않으면 동일 패턴이 만성적으로 노이즈를 발생시키며(14일 22건의 ECONNRESET), 사용자 가시 영향(개별 pano Error 상태)을 누적함.
  • 예상 복잡도: trivial (axios retry 도입 + agent 재사용. 외부 contract 변경 없음.)