ES /docs

AwsS3Manager::uploadDirectoryByCredential | upload failed - {"$fault":"client","$metadata":{"httpSta

RCA: AwsS3Manager::uploadDirectoryByCredential upload failed (HTTP 499)

Overview#

What Happened#

2026-07-29 01:40 KST 무렵 eu-central-1 리전의 cupixworks-pano-postprocessor-instance 에서 리사이즈된 pano 이미지를 tile 스토리지로 업로드하는 AwsS3Manager::uploadDirectoryByCredential 호출 3건이 HTTP 499 ($fault: client) 로 실패했다. 실패한 개별 파일 업로드는 chunk 단위 try/catch 안에서 삼켜져 상위 run 흐름은 정상 종료(errored:0) 로 보고했지만, 실제로는 tile 리소스에 일부 파일이 누락된 채 처리가 완료되었다.

Quick Facts#

Field Value
exception.class AwsS3Manager::uploadDirectoryByCredential (catch 절 내부 error 객체)
exception.message UnknownError (name: Unknown)
top_frame applications/agents/packages/cupix-pano-postprocessor/src/manager/aws-s3.manager.ts:139
runtime Node.js agent, aws-sdk v2.1599.0
env production, region eu-central-1, tenant cupix

Affected Teams#

Team / Domain Error Count Impact
cupixworks-pano-postprocessor-instance / crcc-sama 3 pano tile 파일 일부 누락 (프론트엔드에서 리사이즈 이미지 로드 실패 가능성)

Timeline#

  1. 2026-07-29 01:40:01 KST — capture 44772 등에서 resize_panos 완료 (dd_total:5 dd_completed:5 dd_errored:0)
  2. 2026-07-29 01:40:02 KST — S3 업로드 첫 실패: httpStatusCode:499, requestId:18C6819A5BB27AB4 (first_seen)
  3. 2026-07-29 01:40:04 KST — 5개 sibling 인스턴스에서 PanoPostprocessorService::run | end + terminateService | force shutdown after 10 seconds 동시 발생
  4. 2026-07-29 01:40:08 KST — 두 번째 실패: requestId:18C6819BE007FD51
  5. 2026-07-29 01:40:20 KST — 관련 다운로드 실패 관측: AwsS3Manager::downloadParallel | download failed - Request failed with status code 499
  6. 2026-07-29 01:40:26 KST — 세 번째 실패: requestId:18C681A0192AE2DF (last_seen)

Error Log#

Datadog Logs

text
AwsS3Manager::uploadDirectoryByCredential | upload failed - {"$fault":"client","$metadata":{"httpStatusCode":499,"requestId":"18C6819A5BB27AB4","extendedRequestId":"11c2281c8d85aa06d04530ccf3a9851b70f95d1f41392e82ccefb8cfbe4eb062","attempts":1,"totalRetryDelay":0},"name":"Unknown","message":"UnknownError"}

Impact#

  • Service: cupixworks-pano-postprocessor-instance
  • Team: crcc-sama
  • 발생 횟수: 3
  • 최초 발생: 2026-07-29 01:40 KST
  • 최근 발생: 2026-07-29 01:40 KST
  • 범위: eu-central-1 리전, tenant cupix, 24초 구간에 3건 (동일 endpoint extendedRequestId:11c22...b062)

부수 효과: 개별 파일 업로드 실패가 try/catch 로 삼켜져 상위 runerrored:0 으로 성공 보고. 결과적으로 tile 파일이 부분 누락된 상태로 checkTileUploading 이 호출된다.

Root Cause Summary#

pano tile 업로드 경로 AwsS3Manager.uploadDirectoryByCredential 가 tile 스토리지 endpoint 로 개별 파일 s3.upload().promise() 를 실행하는 중, 클라이언트 측(SDK)에서 요청이 abort 되어 httpStatusCode:499, $fault:"client", name:"Unknown", message:"UnknownError" 형태의 에러가 반환되었다. 3건 모두 동일한 extendedRequestId (endpoint 식별자) 에 대해 attempts:1, totalRetryDelay:0 — retry 없이 즉시 실패했다. 코드는 이 에러를 chunk 단위 Promise.all(map)try/catch 로 삼키므로 (aws-s3.manager.ts:138-140), 상위 resize 태스크와 run 흐름은 성공으로 간주되어 dd_errored:0 을 보고했다. 즉 (a) 개별 파일 업로드 실패가 pano/capture 상태에 반영되지 않고, (b) JSON.stringify(error) 로 직렬화된 에러 페이로드가 name/messageUnknown/UnknownError 로만 남겨 실패 원인 진단이 불가능해진 두 가지 결함이 동시에 드러났다. 트리거는 tile 업로드 endpoint 로의 커넥션이 SDK 측에서 abort 된 것으로 추정되지만, stack 과 원본 exception 클래스가 로그에 남지 않아 정확한 원인 (proxy timeout, socket reset, endpoint DNS resolution 실패 등) 은 uncertain — needs verification.

Technical Analysis#

Code Path#

Entry point: applications/agents/packages/cupix-pano-postprocessor/src/pano-postprocessor-service.ts:47init → run → resize step → uploadResizedImage.

Resize step 은 pano 별로 uploadResizedImage 를 호출:

applications/agents/packages/cupix-pano-postprocessor/src/work/resize-work.ts:138-143typescript
uploadResizedImage = async (cpPano: CPPano): Promise<void> => {
	if (!cpPano.panoId) return;
	const credentials = await this.cupixApi.pano.createTileUploadCredentials(cpPano.panoId);
	await this.awsS3Manager.uploadDirectoryByCredential({ credential: credentials, directory: cpPano.resizeDir!});
	await this.cupixApi.pano.checkTileUploading(cpPano.panoId);
};

Failure point: aws-s3.manager.ts:130-140 — chunk 별 s3.upload().promise() 가 reject 되면 catch 로 잡혀 JSON.stringify(error) 로만 로깅되고 재던지지 않는다.

applications/agents/packages/cupix-pano-postprocessor/src/manager/aws-s3.manager.ts:124-142typescript
const uploadChunk = async (filesChunk: string[]) => {
	await Promise.all(filesChunk.map(async (filePath) => {
		try {
			const bucketKeyPath = credential.basepath!;
			const bucketKey = path.posix.join(bucketKeyPath, path.relative(directory, filePath));

			await s3.upload({
				Bucket: credential.bucket_name!,
				Key: bucketKey,
				Body: fs.createReadStream(filePath)
			}).promise();

			logger.debug('AwsS3Manager::uploadDirectoryByCredential | uploaded file - %s', filePath);
			uploadedFileCount++;
		} catch (error) {
			logger.error('AwsS3Manager::uploadDirectoryByCredential | upload failed - %s', JSON.stringify(error));
		}
	}));
};

기대 동작: 파일 업로드 실패 시 (i) 최소한 원본 error.name, error.message, error.stack 을 보존해야 하고, (ii) 해당 pano 의 tile 리소스가 불완전함을 상위로 알려 uploadResizedImageresize 태스크 catch 에서 PanoState.Error 처리하도록 해야 한다.

실제 동작: JSON.stringify(error) 는 Error 객체의 non-enumerable 속성 (name, message, stack) 을 직렬화하지 못하므로 {} 만 남을 위험이 있으며, 이번 사례에서는 aws-sdk 가 SDK v3 스타일 enumerable 필드 ($fault, $metadata) 를 붙여준 덕분에 그 부분만 로그에 남았다. 상위 resize 태스크의 catch 는 await this.resizeWork.uploadResizedImage(cpPano) 가 정상 return 하므로 실행되지 않고, runerrored:0 로 metric 을 찍는다.

로거 관례상 이 팀은 @agents/utils cplogger (errorSafeFormat 이 Error 를 자동 처리) 를 사용하며, JSON.stringify(error) 는 팀 컨벤션 위반이다.

applications/agents/packages/cupix-pano-postprocessor/src/pano-postprocessor-service.ts:163-179typescript
// resize
start = Date.now();
const resizeTasks = cpPanos.map((cpPano) => {
	return PARALLEL_TASK_LIMIT(async () => {
		try {
		    await this.resizeWork.resizePano(cpPano);
			await this.resizeWork.uploadResizedImage(cpPano);
			await this.resizeWork.checkStitched(cpPano);
		} catch (error) {
			logger.error('PanoPostprocessorService::run | resize pano id:%d | error %s', cpPano.panoId, stringifyError(error));
			await this.panoPostprocessorManager.updatePanoState(cpPano.panoId!, TESLA.UpdatePanoRequest.StateEnum.Error);
			erroredPanoIds.add(cpPano.panoId!);
		}
	});
});

이 catch 는 uploadDirectoryByCredential 가 throw 하지 않기 때문에 절대 실행되지 않는다.

Log Evidence#

Datadog 쿼리:

text
service:cupixworks-pano-postprocessor-instance @environment:production "AwsS3Manager::uploadDirectoryByCredential"

세 건의 실패 로그 (Datadog UTC millisecond):

json
{
  "timestamp": "2026-07-28T16:40:02.675Z",
  "status": "error",
  "message": "AwsS3Manager::uploadDirectoryByCredential | upload failed - {\"$fault\":\"client\",\"$metadata\":{\"httpStatusCode\":499,\"requestId\":\"18C6819A5BB27AB4\",\"extendedRequestId\":\"11c2281c8d85aa06d04530ccf3a9851b70f95d1f41392e82ccefb8cfbe4eb062\",\"attempts\":1,\"totalRetryDelay\":0},\"name\":\"Unknown\",\"message\":\"UnknownError\"}"
}
{
  "timestamp": "2026-07-28T16:40:08.844Z",
  "status": "error",
  "message": "AwsS3Manager::uploadDirectoryByCredential | upload failed - {\"$fault\":\"client\",\"$metadata\":{\"httpStatusCode\":499,\"requestId\":\"18C6819BE007FD51\",\"extendedRequestId\":\"11c2281c8d85aa06d04530ccf3a9851b70f95d1f41392e82ccefb8cfbe4eb062\",\"attempts\":1,\"totalRetryDelay\":0},\"name\":\"Unknown\",\"message\":\"UnknownError\"}"
}
{
  "timestamp": "2026-07-28T16:40:26.961Z",
  "status": "error",
  "message": "AwsS3Manager::uploadDirectoryByCredential | upload failed - {\"$fault\":\"client\",\"$metadata\":{\"httpStatusCode\":499,\"requestId\":\"18C681A0192AE2DF\",\"extendedRequestId\":\"11c2281c8d85aa06d04530ccf3a9851b70f95d1f41392e82ccefb8cfbe4eb062\",\"attempts\":1,\"totalRetryDelay\":0},\"name\":\"Unknown\",\"message\":\"UnknownError\"}"
}

주변 흐름 (성공적으로 종료된 것으로 보고된 run):

text
2026-07-28T16:40:01Z  info   PanoPostprocessorService Elapsed Time - dd_step:resize_panos&dd_elapsed_time:5998ms
2026-07-28T16:40:01Z  info   PanoPostprocessor Metric - dd_metric:pano.postprocess.summary&dd_total:5&dd_completed:5&dd_errored:0
2026-07-28T16:40:02.675Z  error  AwsS3Manager::uploadDirectoryByCredential | upload failed (499)
2026-07-28T16:40:04.322Z  info   PanoPostprocessorService::run | end
2026-07-28T16:40:04.322Z  info   PanoPostprocessorService::terminateService | force shutdown after 10 seconds
2026-07-28T16:40:20Z      error  AwsS3Manager::downloadParallel | download failed - Request failed with status code 499

관찰 포인트:

  • dd_errored:0 — 상위 파이프라인은 실패를 감지하지 못함
  • 3건 모두 extendedRequestId 가 동일 → 동일 endpoint/bucket
  • attempts:1, totalRetryDelay:0 → aws-sdk 재시도 이전에 즉시 종료
  • name:"Unknown", message:"UnknownError" → 원본 exception class/message 손실
  • downloadParallel 도 같은 시간대 499 로 실패 → tile endpoint 전반의 클라이언트 abort 현상

Status board 상 동일 서비스에 대해 같은 날 3개의 형제 cluster (be9c3f17, 4dfce574, a5ec76bc) 가 grouping 되어 2026-07-28-svc-cupixworks-pano-postprocessor-instance--unknown-1 인시던트로 resolved 처리됨.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 tile 업로드 endpoint 로의 client-side connection abort (proxy/socket timeout, DNS 문제, keepalive drop 등) 로 aws-sdk v2 가 499 로 실패, 코드는 이 에러를 삼켜 상위에 전파하지 않음 3건 모두 $fault:client httpStatusCode:499 attempts:1; 같은 시간대 downloadParallel 도 499 로 실패; 동일 extendedRequestId 반복; aws-s3.manager.ts:138-140 의 catch 가 error 를 rethrow 하지 않음 원본 exception class/stack 이 JSON.stringify 로 손실되어 abort 의 정확한 하위 원인은 확정 불가 Confirmed (에러 전파 결함), Inconclusive (root network 원인)
H2 terminateService 의 10초 force-shutdown 타이머가 진행 중인 S3 요청을 강제 종료 에러 후 ~2초 뒤 terminateService 로그가 뜸 terminateService 는 setTimeout(forceShutdown, 10000) 만 예약할 뿐 현재 요청을 취소하지 않음 (pano-postprocessor-service.ts:55-60); 첫 에러가 terminateService 로그보다 1.6초 앞섬 Rejected
H3 pano 리사이즈 결과물이 빈 파일이라 SDK 가 pre-flight 에서 실패 resize 성공 로그(dd_completed:5) 존재 없음 Rejected
H4 AWS S3 (eu-central-1) 리전 장애 3건이 24초 안에 집중 AWS Health Dashboard 미확인 (uncertain — needs verification); $fault:client 는 서버 5xx 가 아니라 클라이언트 abort 를 의미 Inconclusive
H5 에러 로깅 자체 버그로 실제 실패는 없음 없음 attempts:1, totalRetryDelay:0, httpStatusCode:499 는 SDK 내부에서 실제 request 후 채워지는 필드. 같은 시간대 downloadParallel 도 499 로 실패해 endpoint-level 이슈 존재 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 에러 로깅 개선: applications/agents/packages/cupix-pano-postprocessor/src/manager/aws-s3.manager.ts:139JSON.stringify(error) 를 팀 컨벤션대로 교체. @agents/utils cploggererrorSafeFormat 이 Error 를 자동 전개하므로 logger.error('AwsS3Manager::uploadDirectoryByCredential | upload failed - file: %s', filePath, error) 형태로 원본 Error 를 splat 인자로 전달 (memory: TSLA-13527/fe6bf05c 컨벤션). %s 는 붙이지 않음. 그래야 name/message/stack 이 실제로 남아 다음 재발 시 root cause 를 특정할 수 있다.
  • 실패 파일 카운트를 상위로 전파: uploadDirectoryByCredentialuploadedFileCount 를 계산하므로, filesToUpload.length !== uploadedFileCount 인 경우 함수 종료 시점에 throw new Error(...) 하거나 반환값으로 실패 정보를 노출. 그러면 uploadResizedImageresize catch 가 발동해 PanoState.Error 로 마킹된다.

단기 개선 (1주 이내)#

  • retry 정책 도입: 3건 모두 attempts:1, totalRetryDelay:0 이므로 SDK 기본 retry 가 동작하지 않고 있다. new AWS.S3({...}) 옵션에 maxRetries, retryDelayOptions.base 를 명시하고, 특히 499/네트워크 abort 계열에 대해 지수 백오프 재시도를 켠다.
  • downstream 동시성 결함: 같은 시간대 downloadParallel 의 개별 실패도 try/catch 로 삼켜져(aws-s3.manager.ts:75-77) 상위에 전파되지 않는다. 동일한 실패-무전파 패턴이 다운로드 경로에도 있어 이번과 유사한 상황에서 blur/mask 결과가 조용히 누락될 위험. 파일 카운트 검증을 다운로드에도 적용.
  • cross-agent 공통화: packages/basepackages/cupix-pix-genie-preprocessor-agent 에도 AwsS3Manager 사본이 있으므로 동일한 에러 처리 규칙을 정리해 확산 방지.

장기 개선 (재발 방지)#

  • tile 업로드 결과 검증: checkTileUploading 이 실제로 어떤 무결성 체크를 하는지 재검토. 클라이언트에서 성공 보고를 하더라도 S3 상 파일 개수/크기 검증을 서버 측에서 수행해 partial 업로드를 감지.
  • agent 종료 순서 재검토: run 완료 후 terminateService 가 즉시 setTimeout 을 걸어 10초 뒤 process.exit(1) 한다. 현재는 upload 실패와 무관하지만, 향후 retry/back-off 를 도입하면 이 타이머가 실제로 정상 완료를 커팅할 위험이 있으므로 pending 작업 completion 을 기다리는 graceful shutdown 로 재설계.
  • agents 전역 에러 직렬화 표준: memory 에 이미 정리된 cplogger splat 규칙을 packages/base 의 공용 헬퍼로 옮겨 개별 매니저에서 JSON.stringify(error) 를 쓸 여지를 제거.

Monitoring#

목적: tile 업로드 partial 실패와 499 에러 재발을 조기 감지.

Datadog 쿼리 예시:

text
sum:trace.log.error{service:cupixworks-pano-postprocessor-instance,error_type:aws_s3_upload_failed}.as_count()

(위 쿼리는 tag 부여를 전제로 함 — 즉시 조치의 로거 수정 시 error_type 또는 dd_stage:upload 같은 커스텀 태그를 함께 추가)

로그 카운트 기반 (즉시 사용 가능):

text
logs("service:cupixworks-pano-postprocessor-instance @environment:production \"AwsS3Manager::uploadDirectoryByCredential | upload failed\"").index("*").rollup("count").by("region").last("5m") > 5

partial upload 감지용 (fix 후):

text
logs("service:cupixworks-pano-postprocessor-instance @environment:production \"uploadDirectoryByCredential | files length mismatch\"").index("*").rollup("count").last("15m") > 0
  • 알림 임계: 5분 창에서 리전별 5회 초과 시 crcc-sama 채널로 알림.
  • 대시보드에 pano.postprocess.summaryerrored 필드 timeseries 를 함께 배치 — 즉시 조치의 상위 전파 fix 후에는 실패 시 errored > 0 로 잡혀야 한다.

Risk Assessment#

  • Risk level: medium — 사용자 관점에서 pano tile 이 부분 누락되어 특정 해상도 이미지가 로드되지 않을 수 있으나, cover/low/mid/high 중 일부 실패이므로 완전 무결한 실패는 아니다. 하지만 상위 파이프라인이 이를 성공으로 보고하는 것이 더 큰 문제.
  • 예상 복잡도: standard — 로거 컨벤션 교체 + 실패 카운트 상위 전파 두 가지 pure-code 수정. 팀 컨벤션(cplogger splat) 은 memory 에 축적된 선례가 있어 리뷰 리스크 낮음. retry 정책 도입은 별도 트랙.