AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/7/114/105.png
RCA: AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/.../tile/...png
Overview#
What Happened#
2026-06-25 새벽(KST) cupixworks-any-floorplan-agent 의 AwsS3Manager::uploadDirectoryToS3 가 floorplan tile 디렉토리를 S3 로 업로드하는 도중 개별 tile 파일별로 failed: <path> error 로그를 폭발적으로 출력했다. 약 6시간 동안 266,828건 발생했으며 실패한 path 만 남고 실제 원인 (예: HTTP 503, ECONNRESET, credentials expired) 은 로그에 누락되어 있다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | (없음 — catch (error) 로 삼킨 뒤 message-only 로 기록) |
| exception.message | AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/{floorplanId}/tile/{lod}/{x}/{y}.png |
| top_frame | packages/base/src/manager/aws-s3.manager.ts:156 |
| runtime | Node.js (winston logger, AWS SDK v2) |
| env | production, regions: us-west-2 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| chrischoi / floorplan-agent | 266,828 | tile 업로드 단계의 부분 실패. 일부 floorplan은 같은 workspace id 로 SQS 재처리되어 결국 state: done 까지 도달 (FloorplanService::run | floorplan state: done, resource_state: uploaded 로그). 그러나 재시도가 6시간 지속되어 SQS 큐 적체 및 비용/로그량 증가. |
Timeline#
- 2026-06-25 01:46 KST —
first_seen. 첫AwsS3Manager::uploadDirectoryToS3 \| failed발생 (workspace 89282 외 다수). - 2026-06-25 01:49–01:54 KST — workspace 89282 가 동일하게 SQS 로 재실행됨 (
BaseService::runByMessage \| id: 89282두 번 이상). tile 업로드는 매번 같은 경로에서 실패. - 2026-06-25 01:52 KST — 동일 시간대에
FloorplanService::uploadTile - createTileUploadCredentials error - HttpError: HTTP request failed,HTTP request failederror 로그 동시 발생. - 2026-06-25 07:49 KST —
last_seen. workspace 89292 에서 동일 패턴으로 LOD 7 tile 의 95~99 좌표 PNG 가 실패.
Error Log#
AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/7/114/105.png
Impact#
- Service:
cupixworks-any-floorplan-agent - Team: chrischoi
- 발생 횟수: 266,828
- 최초 발생: 2026-06-25 01:46 KST
- 최근 발생: 2026-06-25 07:49 KST
Root Cause Summary#
AwsS3Manager.uploadDirectoryToS3 는 tile 디렉토리의 모든 파일을 Promise.all chunk 로 병렬 업로드하며, 각 파일별로 try/catch 후 logger.error 만 호출하고 throw 하지 않는다 (packages/base/src/manager/aws-s3.manager.ts:155-157). 따라서 S3 측 일시 오류 (token 만료, 503/throttle, ECONNRESET 등) 가 발생하면 한 batch 안의 여러 파일이 동시에 실패하고, 그 모든 실패가 path 단위로 error 로그를 찍는다. 한 floorplan (LOD 6, 16384×16384) 의 tile 디렉토리는 수천~수만 개 PNG 파일을 포함하므로, 한 번의 transient 장애가 곧바로 만 단위 error 로그로 증폭된다. 또한 호출 측인 FloorplanService.uploadTile 은 이 함수의 정상 종료 후 checkTileUploading 을 호출하므로 부분 실패가 상위로 신호되지 않고 다음 단계로 넘어간다. 즉 본 cluster 는 부분 실패 처리와 에러 증폭 로깅이 결합된 결과이며, 같은 시간대의 createTileUploadCredentials error - HttpError: HTTP request failed (warn/error, 01:52 KST 등) 로그가 동일 윈도우에서 발생한 점이 transient downstream 원인을 시사한다.
Technical Analysis#
Code Path#
- Entry point:
packages/cupix-tesla-floorplan-agent/src/floorplan-service.ts:309—uploadTile - credentials 발급:
floorplan-service.ts:323—cupixApi.floorplan.createTileUploadCredentials - 디렉토리 업로드 진입:
floorplan-service.ts:350—awsS3Manager.uploadDirectoryToS3({...}) - 파일 단위 실패 지점:
packages/base/src/manager/aws-s3.manager.ts:155-157— per-filecatch에서logger.error만 호출
const uploadChunk = async (filesChunk: string[]) => {
await Promise.all(filesChunk.map(async (filePath) => {
try {
await this.checkToken();
if (this.s3 == undefined) {
logger.error('AwsS3Manager::uploadDirectoryToS3 | s3 is not initialized');
return;
}
let bucketKey = path.posix.join(params.bucketKeyPath, path.relative(targetDirectoryPath, filePath));
if (params.excludeFileExtension && params.excludeFileExtension == true) bucketKey = bucketKey.replace(path.extname(bucketKey), '');
logger.silly('AwsS3Manager::uploadDirectoryToS3 | uploading %s to %s', filePath, bucketKey);
const contentEncoding = filePath.endsWith('pbz') ? 'gzip' : undefined;
const fileStream = fs.createReadStream(filePath);
await this.s3.upload({
Bucket: params.bucketName,
Key: bucketKey,
ACL: params.acl,
Body: fileStream,
ContentEncoding: contentEncoding
}).promise();
uploadedFileCount++;
logger.silly('AwsS3Manager::uploadDirectoryToS3 | done: %s, uploaded: %d/%d', bucketKey, uploadedFileCount, filesToUpload.length);
} catch (error) {
logger.error('AwsS3Manager::uploadDirectoryToS3 | failed: %s', filePath, error);
}
}));
};
const awsS3Manager = new AwsS3Manager(s3Credentials.bucket_region);
awsS3Manager.setApiCreateCredentials(() => this.cupixApi.floorplan.createTileUploadCredentials(cpFloorplan.id));
awsS3Manager.setCredentials({
accessKeyId: s3Credentials.aws_access_key_id,
secretAccessKey: s3Credentials.aws_secret_access_key,
sessionToken: s3Credentials.aws_session_token,
expires_at: s3Credentials.expires_at,
endpoint: s3Credentials.endpoint
});
await awsS3Manager.uploadDirectoryToS3({
bucketName: s3Credentials.bucket_name,
bucketKeyPath: s3Credentials.basepath,
targetDirectoryPath: resultDir,
acl: s3Credentials.acl,
excludeFileExtension: true
});
await this.cupixApi.floorplan.checkTileUploading(cpFloorplan.id);
기대 동작 vs 실제 동작
- 기대: 일부 파일이 transient 사유로 실패하면 retry/abort 로직을 통해 상위에 에러 신호 전달, 한 batch 의 실패는 묶어서 단일 진단성 로그로 남긴다.
- 실제: 각 파일을 독립적으로 catch 하여
logger.error만 호출하고 정상 종료. 한 batch 의 transient 장애가 batch 의 모든 파일 만큼 곱해진 error 로그를 만들고, 상위는 실패를 인지하지 못한 채checkTileUploading으로 진행. - 추가 문제:
logger.error('...failed: %s', filePath, error)는 format 문자열에 placeholder 가 1개뿐이라error인자가 메시지에 포함되지 않는다. winstonsplat가error를 메타 필드로 보존하긴 하지만 (packages/utils/src/cplogger.ts:21-34), Datadog 에 수집된 message 에는 실패한 path 만 남고 어떤 sdk error (CredentialsError, NetworkingError, NoSuchKey 등) 인지 식별 불가.
Log Evidence#
Datadog 쿼리 (cluster file 의 ## Datadog 섹션과 동일):
service:cupixworks-any-floorplan-agent status:error @environment:production "AwsS3Manager::uploadDirectoryToS3"
대표 에러 메시지 (동일한 burst):
2026-06-25 01:49:28 [error] AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/7/99/98.png
2026-06-25 01:49:28 [error] AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/blank.png
2026-06-25 01:49:28 [error] AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/7/99/95.png
2026-06-25 01:49:28 [error] AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/7/99/97.png
동일 시간대 transient downstream 신호:
2026-06-25 01:52:32 [error] HTTP request failed
2026-06-25 01:54:31 [error] FloorplanService::uploadTile - createTileUploadCredentials error - HttpError: HTTP request failed
2026-06-25 01:56:51 [error] FloorplanService::uploadTile - createTileUploadCredentials error - HttpError: HTTP request failed
2026-06-25 00:01:13 [warn] read ECONNRESET
2026-06-25 00:01:13 [warn] CupixAuth::handleError | Undefined response: {"stack":"Error: read ECONNRESET ...","message":"read ECONNRESET","errno":-104,"code":"ECONNRESET","syscall":"read"}
재처리 패턴 (같은 workspace 가 SQS 메시지로 두 번 이상 처리됨):
2026-06-25 01:49:28 [error] AwsS3Manager::uploadDirectoryToS3 | failed: /tmp/workspace/89282/tile/...
2026-06-25 01:49:29 [info] BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/89282
2026-06-25 01:53:25 [info] BaseService::runByMessage | id: 89282
2026-06-25 01:54:31 [info] FloorplanService::tileFloorplan | tiling done and now save to `/tmp/workspace/89282/tile`
2026-06-25 01:54:31 [info] BaseService::cleanUpAnythingRelatedModel | path: /tmp/workspace/89282
성공한 floorplan 예시 (부분 실패가 상위로 신호되지 않아 done 상태로 진행):
2026-06-25 03:55:32 [info] FloorplanService::run | floorplan state: resource_uploaded, resource_state: uploaded
2026-06-25 03:55:33 [info] FloorplanService::run | floorplan state: done, resource_state: uploaded
2026-06-25 03:59:33 [info] FloorplanService::run | floorplan state: done, resource_state: uploaded
logger 의 splat 처리 — Error 객체는 메타 필드로 옮겨지지만 message string 에는 포함되지 않음:
// example: catch (err) { logger.error("Failed to process:", err); }
// example: catch (err) { logger.error("message %s", err); }
const splatKey = Symbol.for('splat');
const splat = info[splatKey];
if (Array.isArray(splat)) {
for (let i = 0; i < splat.length; i++) {
const v = splat[i];
if (v instanceof Error) {
splat[i] = {
name: v.name,
message: v.message,
stack: v.stack,
};
}
}
}
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | tile 파일별 transient S3/네트워크 실패가 Promise.all chunk 안에서 batch 단위로 발생하고, per-file catch 가 throw 없이 logger.error 만 호출하여 batch 의 모든 파일이 error 로그로 증폭된다 |
동일 timestamp(초 단위)에 같은 workspace 의 수십~수백 path 가 failed: 로 동시에 찍힘 (01:49:28 burst). 같은 시간대 createTileUploadCredentials error - HttpError: HTTP request failed, ECONNRESET 등 downstream transient 신호. aws-s3.manager.ts:155-157 에서 throw 없이 logger.error 만 호출. 일부 floorplan 은 state: done 까지 진행되어 상위에 부분 실패가 전파되지 않음 |
— | Confirmed |
| H2 | 로컬 파일 시스템에서 tile 파일이 누락되어 fs.createReadStream 단계에서 실패 |
path 가 /tmp/workspace/{id}/tile/{lod}/{x}/{y}.png 로 LOD 디렉토리 패턴과 일치하며 tiling 직후 같은 workspace 에서 발생 |
tiling 직전 FloorplanService::tileFloorplan | tiling done and now save to /tmp/workspace/{id}/tile 가 같은 workspace 에서 성공 로그를 남김. 동일 path 패턴이 여러 workspace 에서 같은 좌표(7/99/9x)에서 반복되어 파일 누락보다 batch 동시 실패에 가까움 |
Rejected |
| H3 | floorplan 외부 의존성 (S3 me-central-1 등) 전체 outage | 동시 시간대 HTTP request failed 가 있음 |
status-board for-cluster 결과 active: null, recent: []. 영향 region 은 us-west-2 단일, 같은 시간대 다른 floorplan 은 done 상태로 성공 |
Rejected |
| H4 | S3 credentials 만료/갱신 race (checkToken 이 Promise.all 안에서 동시에 갱신 시도) |
checkToken 이 lock 없이 _createCredentials() 호출, Promise.all 내부에서 동시 진입 가능 (aws-s3.manager.ts:61-80) |
직접 증거 없음 — sdk error 메시지가 로그에 누락되어 CredentialsError 확정 불가. 별도 hypothesis 로 검증 필요 | Inconclusive |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
packages/base/src/manager/aws-s3.manager.ts:155-157 - 방향:
logger.error('...failed: %s', filePath, error)의 format 문자열에 error 포맷 placeholder 를 추가하거나 ('...failed: %s, error: %j') Error 객체를logger.error(error, 'failed: %s', filePath)형태로 전달해 winstonsplat/errorSafeFormat가 stack/message 를 보존하도록 한다. memory.md 의 "fix logger pattern" 규칙에 따라JSON.stringify(error)수동 직렬화 대신 Error 객체를 그대로 넘기는 방식을 선호. 우선 진단을 살리는 것이 본 cluster 의 근본 분류 (network vs auth vs S3 API) 에 필수.
단기 개선 (1주 이내)#
aws-s3.manager.ts:130-159에 retry-with-backoff 도입: per-filePromise.all안에서 transient 분류 (5xx, throttle, network) 만 N회 재시도. AWS SDK v2maxRetries: 5가 이미 설정되어 있으나 (aws-s3.manager.ts:19-23)s3.upload의 retry 는 sdk 가 처리하더라도 fs stream 단계 실패는 별도 처리 필요.- per-file
catch에서 batch 단위 실패 집계 후 단일 요약 로그 + 임계치 초과 시uploadDirectoryToS3자체를throw하여 상위 (FloorplanService.uploadTile) 가checkTileUploading호출을 막을 수 있도록 한다. 현재는 부분 실패가 신호되지 않아 tile 누락 floorplan 이done으로 표시될 위험이 있음. floorplan-service.ts:357직전에 업로드 성공/실패 카운트를 받아 임계치 미달 시 에러 throw → SQS 재처리 유도.
장기 개선 (재발 방지)#
cupix-tesla-floorplan-agent(및 동일 패턴을 가진cupix-tesla-potree-agent,cupix-tesla-bim-revision-agent,cupix-tesla-forge-agent,cupix-tesla-voxel-agent,siteinsights/base-postprocessor-runner) 에서AwsS3Manager.uploadDirectoryToS3를 사용하는 호출자 모두 부분 실패 처리가 동일한 위험을 가짐. 공용 manager 측에서 retry + abort 정책을 표준화.checkToken의 동시 갱신 race 가능성을 in-flight promise singleton 으로 보호 (H4 검증 후 적용).
Monitoring#
업로드 실패율 추세 (timeseries widget — count_by_* facets):
logs("service:cupixworks-any-floorplan-agent status:error \"AwsS3Manager::uploadDirectoryToS3 | failed\"").index("*").rollup("count").by("environment")
같은 burst 에서 영향받은 floorplan(workspace) 수:
logs("service:cupixworks-any-floorplan-agent status:error \"AwsS3Manager::uploadDirectoryToS3 | failed\"").index("*").rollup("cardinality", "@workspace_id")
credentials 발급 실패 추세 (downstream signal):
logs("service:cupixworks-any-floorplan-agent status:error \"createTileUploadCredentials error\"").index("*").rollup("count")
(현재 @workspace_id tag 가 없다면 fix 시 message 에 workspace_id 를 추가하고 facet 등록 권장)
알림 후보:
- 같은 floorplan id 에 대해 5분 내 동일 path 가 1,000개 이상 error 로그 → tile 업로드 partial failure 의심.
FloorplanService::uploadTile - createTileUploadCredentials errorwarn/error 1분당 5회 이상 → cupixworks-api credentials endpoint 장애 알림.
Risk Assessment#
- Risk level: medium — 직접 데이터 손실은 미관찰이나 floorplan 의 부분 tile 결손이
done상태로 진행될 수 있어 사용자 보기 화질/누락 가능성 있음. 로그량/비용 증폭이 즉시 영향. - 예상 복잡도: standard — logger 포맷 1줄 수정은 trivial, retry/abort 도입은 standard.