AwsS3Manager::uploadDirectoryByCredential | upload failed - {"message":"read ECONNRESET","errno":-10
RCA: AwsS3Manager::uploadDirectoryByCredential ECONNRESET (me-central-1)
Overview#
What Happened#
2026-06-26 03:04 KST에 cupixworks-pano-postprocessor-instance (pano postprocessor agent) 가 me-central-1 리전 S3 버킷 (s3.me-central-1.amazonaws.com) 으로 resized pano 타일 디렉터리를 업로드하던 중 read ECONNRESET (errno -104, TimeoutError) 으로 단일 파일 업로드가 실패했다. 동일 인스턴스에서 같은 시각 직전 (17:44, 17:52 UTC) 에는 동일 코드 경로가 S3 InternalError (HTTP 500, retryable=true) 도 함께 기록했다. 두 종류 모두 AWS SDK 가 retryable:true 로 표시하는 일시적 오류이지만, uploadDirectoryByCredential 의 try/catch 가 에러를 삼키고 로그만 남긴 채 다음 파일로 넘어가기 때문에 일부 타일이 누락된 상태로 후속 단계 (checkTileUploading) 가 호출된다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | TimeoutError (AWS SDK v2) |
| exception.message | read ECONNRESET |
| top_frame | aws-s3.manager.ts:130 (s3.upload(...).promise()) |
| runtime | Node.js / aws-sdk v2 (import * as AWS from 'aws-sdk') |
| env | production, region me-central-1 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| gad / pano postprocessor | 2 (이번 클러스터) + 다수 (7일간 동일 코드경로 반복) | me-central-1 테넌트의 pano 타일 일부가 S3 에 업로드되지 않을 수 있음. resize 결과물 누락 → 뷰어에서 저해상도/타일 빈 영역 |
Timeline#
- 2026-06-26 02:44 KST — 동일 코드경로에서 S3
InternalError(requestIdZZSZ9D2T4KQC69FZ, statusCode 500, retryable=true) 3건 발생. - 2026-06-26 02:52 KST — 동일 코드경로에서 S3
InternalError(requestId1QZM0KXRW0PAC192) 2건 추가 발생. - 2026-06-26 03:04 KST —
read ECONNRESET(me-central-1) 2건 발생 — 이번 클러스터의 first_seen / last_seen. - 2026-06-26 03:04 KST 직후 — 동일 인스턴스에서
MaskWork::maskPanos경고 로그가 정상적으로 계속 출력 → 프로세스 자체는 죽지 않고 다음 pano 로 진행.
Error Log#
AwsS3Manager::uploadDirectoryByCredential | upload failed - {"message":"read ECONNRESET","errno":-104,"code":"TimeoutError","syscall":"read","time":"2026-06-25T18:04:31.986Z","region":"me-central-1","hostname":"s3.me-central-1.amazonaws.com","retryable":true,"statusCode":500}
Impact#
- Service:
cupixworks-pano-postprocessor-instance - Team: gad
- 발생 횟수: 2 (이번 클러스터). 7일 검색 기준 같은 코드경로에서 동일 패턴 30건+
- 최초 발생: 2026-06-26 03:04 KST
- 최근 발생: 2026-06-26 03:04 KST
- Region: us-west-2 (인스턴스), me-central-1 (S3 대상)
Root Cause Summary#
AwsS3Manager.uploadDirectoryByCredential 가 me-central-1 S3 엔드포인트로 객체를 업로드하는 도중 소켓이 끊겨 read ECONNRESET 이 발생한 것이 직접 원인이다. AWS 측은 이 오류를 retryable:true 로 분류하지만, 현재 코드는 단일 파일 업로드 실패를 try/catch 로 잡아 logger.error 만 남기고 그대로 다음 파일로 넘어간다. 즉 (1) 네트워크/원격 S3 의 일시적 불안정이라는 외부 트리거와 (2) per-file 재시도/누락 추적이 없는 업로드 루프가 결합되어, 외부 결함이 그대로 데이터 누락 + 에러 로그로 노출되고 있다. 같은 인스턴스의 같은 코드경로가 직전 20분 사이 InternalError (S3 500) 도 반복 기록한 점은 me-central-1 S3 가 해당 시간대에 일시적으로 불안정했음을 뒷받침한다.
Technical Analysis#
Code Path#
- Entry point (caller):
applications/agents/packages/cupix-pano-postprocessor/src/work/resize-work.ts:138—ResizeWork.uploadResizedImage - 핵심 호출:
aws-s3.manager.ts:108—AwsS3Manager.uploadDirectoryByCredential - 실패 지점:
aws-s3.manager.ts:130-134—s3.upload(...).promise() - catch / swallow:
aws-s3.manager.ts:138-140
호출 흐름:
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);
};
uploadResizedImage 는 uploadDirectoryByCredential 이 throw 하지 않는 한 곧바로 checkTileUploading 을 호출한다. 업로드 누락 여부와 무관하게 후속 단계로 진행된다는 의미다.
S3 client 구성 — maxRetries / httpOptions.timeout 미지정:
async uploadDirectoryByCredential({ credential, directory }: { credential: TESLA.UploadCredentials, directory: string }): Promise<void> {
const s3 = new AWS.S3({
apiVersion: '2006-03-01',
signatureVersion: 'v4',
accessKeyId: credential['aws_access_key_id'],
secretAccessKey: credential['aws_secret_access_key'],
sessionToken: credential['aws_session_token'],
endpoint: credential['endpoint'],
s3ForcePathStyle: true,
});
aws-sdk v2 의 기본 maxRetries 는 3이고 기본 socket timeout 은 2분이므로, 로그의 code: "TimeoutError" + errno: -104 (ECONNRESET) 는 default 재시도 3회를 모두 소진한 뒤에도 소켓이 끊어졌음을 뜻한다. 따라서 단순 재시도 횟수 부족이 아니라, me-central-1 으로 가는 long-running keep-alive 소켓이 특정 시점에 RST 를 받는 양상이다.
업로드 루프와 에러 처리:
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));
}
}));
};
const chunkSize = 10;
for (let i = 0; i < filesToUpload.length; i += chunkSize) {
const filesChunk = filesToUpload.slice(i, i + chunkSize);
await uploadChunk(filesChunk);
}
기대 동작: 일시적 네트워크 오류 시 application-level 에서 재시도하거나, 실패한 파일을 누적해 부분 실패를 상위로 신호.
실제 동작: 첫 실패에서 logger.error 만 남기고, uploadedFileCount 만 증가하지 않은 채 루프가 끝난다. 호출자 uploadResizedImage 는 실패한 파일 목록이나 카운트를 받지 못하므로 그대로 checkTileUploading 을 호출한다. 즉 에러 로그 ↔ 데이터 정합성이 분리되어 있다.
또한 logger.error('... - %s', JSON.stringify(error)) 는 AWS SDK 의 AWSError 인스턴스를 JSON.stringify 로 직렬화한다. 메모리에 보관된 메모리 노트 (fix logger) 처럼 Error 객체를 로거에 직접 넘기지 않고 수동 직렬화하므로 stack trace 가 잘려서 디버깅이 어렵다.
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-pano-postprocessor-instance status:error "ECONNRESET"
service:cupixworks-pano-postprocessor-instance "uploadDirectoryByCredential"
이번 클러스터의 원문 로그 (2건, 동일 메시지):
{
"timestamp": "2026-06-26 03:04:31 KST",
"status": "error",
"message": "AwsS3Manager::uploadDirectoryByCredential | upload failed - {\"message\":\"read ECONNRESET\",\"errno\":-104,\"code\":\"TimeoutError\",\"syscall\":\"read\",\"time\":\"2026-06-25T18:04:31.986Z\",\"region\":\"me-central-1\",\"hostname\":\"s3.me-central-1.amazonaws.com\",\"retryable\":true,\"statusCode\":500}"
}
같은 인스턴스 / 같은 코드경로의 직전 S3 InternalError (관련 컨텍스트):
{
"timestamp": "2026-06-26 02:52:54 KST",
"status": "error",
"message": "AwsS3Manager::uploadDirectoryByCredential | upload failed - {\"message\":\"We encountered an internal error. Please try again.\",\"code\":\"InternalError\",\"region\":null,\"time\":\"2026-06-25T17:52:54.737Z\",\"requestId\":\"1QZM0KXRW0PAC192\",\"statusCode\":500,\"retryable\":true}"
}
{
"timestamp": "2026-06-26 02:44:28 KST",
"status": "error",
"message": "AwsS3Manager::uploadDirectoryByCredential | upload failed - {\"message\":\"We encountered an internal error. Please try again.\",\"code\":\"InternalError\",\"region\":null,\"time\":\"2026-06-25T17:44:28.085Z\",\"requestId\":\"ZZSZ9D2T4KQC69FZ\",\"statusCode\":500,\"retryable\":true}"
}
7일 검색 결과 동일한 uploadDirectoryByCredential 실패 메시지가 30건 이상 (대부분 InternalError, 일부 ECONNRESET) 으로 분포 — me-central-1 와 다른 리전 모두에서 산발적으로 발생. 즉 이 코드경로가 외부 일시 오류에 대해 noise 를 꾸준히 생성하고 있다.
S3 응답에 requestId 와 extendedRequestId 가 포함된 경우는 (1) 서버 측에서 요청을 받아 500 으로 응답한 것이고 (2) AWS 측 로그에서 추적 가능하다. 반면 이번 ECONNRESET 케이스는 requestId 가 없다 — 응답을 받기 전 소켓이 끊겼다는 신호.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | me-central-1 S3 측의 일시적 네트워크/서버 결함이 직접 원인. 클라이언트 코드는 정상이나, application-level retry/누락 추적이 없어 외부 결함이 그대로 데이터 누락 + 에러 로그로 노출됨 | 같은 인스턴스 / 같은 메서드에서 20분 사이 InternalError (HTTP 500, retryable=true, requestId 존재) → ECONNRESET (requestId 없음) 으로 오류 모드 변화. 두 오류 모두 AWS SDK 가 retryable:true 로 분류. AWS SDK v2 기본 maxRetries=3 가 소진된 뒤의 에러 |
— | Confirmed |
| H2 | aws-sdk v2 의 기본 maxRetries 가 부족해서 발생 — 단순히 maxRetries 만 늘리면 해결됨 |
SDK 기본값 3 회 재시도. me-central-1 같은 원거리 리전은 RTT 가 크고 long-running socket 의 RST 확률이 더 높음 | 7일간 패턴을 보면 같은 코드경로가 다양한 리전에서 산발적으로 실패 — 단순 횟수 부족보다는 누락 처리 부재가 본질. 또한 base retry 만 늘리면 chunk 단위 (10 동시) 의 큐 head-of-line blocking 이 길어짐 | Rejected (부분 기여만 인정) |
| H3 | 인증/credential 만료 — sessionToken 이 만료되어 연결이 끊어졌다 | endpoint, sessionToken, accessKeyId 가 cupixApi 에서 발급된 단기 STS credential 사용 |
오류 객체에 ExpiredToken / AccessDenied 가 아닌 TimeoutError / ECONNRESET 만 존재. 같은 작업 안에서 다른 파일 업로드는 성공한 것으로 추정 (uploadedFileCount 증가 가능) |
Rejected |
| H4 | pano postprocessor 인스턴스의 네트워크 / DNS / NAT timeout 이상 | errno: -104 는 커널 레벨 RST |
같은 인스턴스에서 동시간 MaskWork::maskPanos 가 정상 출력되어 다른 외부 호출은 살아있고, RST 가 특정 리전 (me-central-1) S3 호스트에만 집중. host 측 일반적 네트워크 결함이라기보다 특정 원격 endpoint 문제 |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 단기 noise 감소:
aws-s3.manager.ts:108-117의new AWS.S3({...})에 명시적으로maxRetries와httpOptions: { connectTimeout, timeout },retryDelayOptions: { base }를 설정해 application-level 일시 오류에 대한 회복력을 높인다. 단, 단순 재시도 횟수 증가만으로는 H2 에서 본대로 한계가 있으므로 아래 단기 개선과 함께 진행한다. - 호출자 (
resize-work.ts:138-143 uploadResizedImage) 가 부분 실패를 인지하지 못하는 상태를 막기 위해, 즉시 임시로라도uploadDirectoryByCredential이 실패 파일 목록을 반환하거나 임계치 (예: 1건이라도 실패) 시 throw 하도록 변경. 그렇지 않으면checkTileUploading이 잘못된 결과로 진행된다. - 로그 가독성:
logger.error('... - %s', JSON.stringify(error))대신 logger 가 Error 객체를 그대로 처리하도록 변경 (이 코드베이스 컨벤션). 메모리 노트fix logger항목과 동일 패턴.
단기 개선 (1주 이내)#
- per-file 재시도 + exponential backoff + jitter 를 application 레벨에 추가해
retryable:true오류를 1차로 흡수. AWS SDK 의 default retry 와 별개로 한 번 더 감싸는 것이 효과적 (특히ECONNRESET처럼 socket 이 끊긴 경우 SDK 의 reuse 된 keep-alive 연결을 버리고 새 연결로 재시도). - 실패한 파일 목록을 caller 까지 전달.
uploadResizedImage는 실패가 있을 경우checkTileUploading호출 대신 재처리 큐로 다시 보낸다. - 동시성 제어: 현재 chunk 10병렬 직렬화 구조는 한 chunk 안의 felt failure 가 다음 chunk 시작을 지연시킨다.
pLimit(10)처럼 글로벌 동시성 제한으로 바꾸어 흐름을 매끄럽게 한다 (downloadParallel은 이미pLimit사용 중 — 일관성도 확보). - me-central-1 같은 원거리 리전에 대해 HTTPS keep-alive agent 와 connection pool 을 명시적으로 구성 (
AWS.S3의httpOptions.agent).
장기 개선 (재발 방지)#
- pano postprocessor 의 upload step 을 "best-effort log" 모델에서 "transactional with checkpoint" 모델로 전환: 모든 타일 업로드 성공이 검증된 뒤에만
checkTileUploading을 호출하도록 invariant 를 명시. 부분 성공 상태는 별도 status 로 영속화. - 동일한
AwsS3Manager가 여러 agent (mesh, voxel, deviation, floorplan, forge, bim-revision 등 — 위 Grep 결과 다수 패키지에서 사용) 에 복제 사용되고 있어, 공통 모듈로 통합하고 retry/observability 정책을 한 곳에서 관리. - S3 작업 실패율 / 지역별 분포를 Datadog dashboard 에 노출해 외부 의존성 degradation 을 조기 감지.
Monitoring#
writing-datadog-monitoring-queries 가이드에 따라 dashboard timeseries widget 에 그대로 들어갈 수 있는 형태로 작성. 모든 쿼리는 일반 search syntax 만 사용 (no pipe, no | stats, no count by(...) aggregator suffix).
S3 upload failure 의 시간당 발생 추이 (이번 사고와 동일 코드경로):
logs("service:cupixworks-pano-postprocessor-instance status:error \"AwsS3Manager::uploadDirectoryByCredential\"").index("*").rollup("count").by("@error.code").last("4h")
ECONNRESET 만 별도 추세 (특정 리전 endpoint 의 socket-level 결함 모니터링):
logs("service:cupixworks-pano-postprocessor-instance status:error \"ECONNRESET\"").index("*").rollup("count").last("4h")
리전별 분리 (me-central-1 외 다른 리전과의 차이 확인):
logs("service:cupixworks-pano-postprocessor-instance status:error \"AwsS3Manager::uploadDirectoryByCredential\"").index("*").rollup("count").by("@region").last("24h")
알림 임계: 30분 안에 같은 코드경로의 에러가 N건 (예: 5건) 이상이면 알림 → 단일 외부 일시 오류는 무시되고 패턴화된 degradation 만 잡힘.
Risk Assessment#
- Risk level: medium — 단일 인시던트로 서비스 전체가 중단되지는 않으나, me-central-1 테넌트의 pano 타일이 누락된 상태로 후속 단계 (
checkTileUploading) 가 호출되어 데이터 정합성 손상 가능성이 있음. 사용자 가시적 영향은 일부 pano 의 저해상도/타일 결손. - 예상 복잡도: standard — 재시도/실패 전파 로직 보강과 S3 client 구성 수정은 한 파일 (
aws-s3.manager.ts) + 호출자 한 곳 (resize-work.ts) 범위. 공통 모듈화는 별도 task.