ES /docs

TransferManager::uploadFile | response path: /tmp/workspace/33745/videos/44851/original/44851_00041.

RCA: TransferManager::uploadFile | HTTP 500 Internal Server Error

Overview#

What Happened#

2026-04-20 08:07~08:42 UTC 사이 cupixworks-capture-postprocessor-agent 서비스에서 pano 이미지 업로드 중 upstream 스토리지 서비스가 HTTP 500을 반환하여 19건의 에러가 발생했다. EU-central-1 리전의 workspace 33745(video 44851), workspace 33696(video 44805) 두 건이 영향을 받았으며, 동일 시간대에 cupixworks-worker의 VoxelService 호출에서도 503 에러가 다수 발생하여 백엔드 인프라 전반의 일시적 장애로 판단된다.

Quick Facts#

Field Value
exception.class TransferManager::uploadFile
exception.message response path: /tmp/workspace/33745/videos/44851/original/44851_00041.jpg, code: 500, message: Internal Server Error
top_frame transfer.manager.ts:99
env production, eu-central-1

Affected Teams#

Team / Domain Error Count Impact
hassan-allam (EU) 19 Pano 이미지 업로드 실패로 캡처 후처리 중단. workspace 33745, 33696의 비디오 프레임 업로드가 완료되지 않음

Timeline#

  1. 07:20:00 UTC — 최초 ETIMEDOUT 신호 (workspace 33746/video 44852, 별도 건)
  2. 08:07:16 UTC — Workspace 33745/video 44851에서 첫 번째 HTTP 500 에러 발생
  3. 08:07~08:12 UTC — 14건의 연속 500 에러 (bulk JPG 업로드 실패)
  4. 08:25~08:29 UTC — ETIMEDOUT 에러 발생 (upstream 연결 자체 타임아웃)
  5. 08:28 UTCcupixworks-worker에서 VoxelService 503 에러 시작
  6. 08:41~08:43 UTC — Workspace 33696/video 44805로 500 에러 확산
  7. 08:43:15 UTC — 마지막 관측 에러

Error Log#

Datadog Logs

text
TransferManager::uploadFile | response path: /tmp/workspace/33745/videos/44851/original/44851_00041.jpg, code: 500, message: Internal Server Error

Impact#

  • Service: cupixworks-capture-postprocessor-agent
  • Team: hassan-allam
  • 발생 횟수: 19
  • 최초 발생: 2026-04-20T08:07:16.595Z
  • 최근 발생: 2026-04-20T08:42:33.538Z

업로드 실패 시 failTask가 전체 transfer queue를 중단시키므로(_running = false, _stop = true), 하나의 파일 업로드 실패가 해당 캡처의 전체 pano 업로드 작업을 중단시킨다. Workspace 33745와 33696의 캡처 후처리가 완료되지 않았을 가능성이 높다.

Root Cause Summary#

Upstream 스토리지 서비스(presigned URL 기반 PUT 업로드 대상)가 일시적으로 HTTP 500 Internal Server Error를 반환하면서 pano 이미지 업로드가 실패했다. 동일 시간대에 cupixworks-worker의 VoxelService도 503을 반환하고 있어, 백엔드 인프라(스토리지/컴퓨트 계층)의 일시적 장애가 근본 원인이다. cupixworks-api 자체는 정상 동작하고 있었으므로, API 서버 문제가 아닌 스토리지 계층의 문제로 확인된다. Agent의 retry 로직(5회, 10초 간격)이 존재하지만, 인프라 장애가 ~35분간 지속되어 retry 예산을 소진한 것으로 보인다.

Technical Analysis#

Code Path#

  • Entry point: TransferManager.uploadNewPanos()transfer.manager.ts:230에서 UploadNewPanosContainer를 생성하고 각 task를 queue에 추가
  • Task 초기화: UploadNewPanosTask.init()upload-new-panos.task.ts:23에서 Tesla API를 호출하여 pano를 생성하고 upload_url(presigned URL)을 획득
  • 업로드 실행: TransferManager.transferTask()transfer.manager.ts:193에서 task의 action이 Upload이면 uploadFile() 호출
  • Failure point: TransferManager.uploadFile()transfer.manager.ts:81-107에서 PUT 요청의 응답이 200이 아닌 경우 reject
transfer.manager.ts:81-107typescript
uploadFile = (url: string, path: string, headers: any): Promise<void> => new Promise((resolve, reject) => {
    const fileSize =  CPUtils.getFileSize(path);
    headers = { ...headers, 'Content-Length': fileSize };

    logger.debug('TransferManager::uploadFile | begin path: %s, url: %s, size: %d', path, url, fileSize);
    const fileStream = fs.createReadStream(path);
    const sendReq = request.put(url, {
        headers: headers,
        body: fileStream
    });

    sendReq
        .on('response', async res => {
            if (res.statusCode === 200) {
                logger.debug('TransferManager::uploadFile | complete path: %s', path);
                await CPUtils.sleep(100);
                resolve();
            } else {
                logger.error('TransferManager::uploadFile | response path: %s, code: %d, message: %s', path, res.statusCode, res.statusMessage);
                reject(this.cupixAuth.handleError(res));  // ← 에러 로깅 후 reject
            }
        })
        .on('error', err => {
            logger.error('TransferManager::uploadFile | path: %s, error: %s', path, JSON.stringify(err));
            reject(this.cupixAuth.handleError(err));
        });
});
  • Retry 흐름: TransferManager.retryTask()transfer.manager.ts:173-191에서 checkStatusCode()가 500을 retry 대상으로 판단(4xx만 제외)하고, isRetry()MaxRetries(5) 미만인지 확인 후 10초 후 재시도
transfer.manager.ts:168-191typescript
private checkStatusCode = (error: any): boolean => {
    if (error?.statusCode != undefined && error.statusCode > 400 && error.statusCode < 500) return false;
    return true;  // 500은 retry 대상 (4xx만 제외)
};

retryTask = (task: BaseTask, error: any): void => {
    if (this.checkStatusCode(error) && task.isRetry()) {
        const idx = this._transferring.indexOf(task);
        this._transferring.splice(idx, 1);
        logger.debug('TransferManager::retryTask | add task after %d seconds - path: %s, url: %s, count: %d/%d',
            Constants.RetryInterval/1000, task.target, task.url, task.retryCount, Constants.MaxRetries);
        setTimeout(() => {
            task.retry()
                .then(() => {
                    this.addTask(task);
                })
                .catch(error => {
                    this.retryTask(task, error);
                });
        }, Constants.RetryInterval);
    } else {
        this.failTask(task, error);  // retry 소진 시 전체 queue 중단
    }
};
  • 전체 중단: TransferManager.failTask()transfer.manager.ts:158-166에서 _running = false, _stop = true로 설정하여 전체 transfer queue를 즉시 중단
transfer.manager.ts:158-166typescript
private failTask = (task: BaseTask, error: any): void => {
    this._running = false;   // 전체 queue 중단
    this._stop = true;       // 새 task 처리 방지
    const idx = this._transferring.indexOf(task);
    this._transferring.splice(idx, 1);
    this._failed.push(task);
    task.finalize(TaskType.Failed, error);
    logger.error('TransferManager::failTask | path: %s, url: %s, count: %d/%d', task.target, task.url, task.retryCount, Constants.MaxRetries);
};

기대 동작: presigned URL로 PUT 요청 시 스토리지 서비스가 200을 반환하고 파일이 저장됨. 실제 동작: 스토리지 서비스가 500을 반환. 5회 retry 후에도 실패하여 failTask가 호출되고, 해당 캡처의 모든 남은 pano 업로드가 중단됨.

Log Evidence#

Datadog에서 사용한 주요 쿼리:

text
service:cupixworks-capture-postprocessor-agent status:error @environment:production "TransferManager"
text
service:cupixworks-capture-postprocessor-agent status:warn @environment:production "500"
text
service:cupixworks-worker status:error @environment:production "503"

HTTP 500 에러 로그 (대표 샘플):

text
2026-04-20T08:07:16.595Z TransferManager::uploadFile | response path: /tmp/workspace/33745/videos/44851/original/44851_00041.jpg, code: 500, message: Internal Server Error
2026-04-20T08:08:23.000Z TransferManager::uploadFile | response path: /tmp/workspace/33745/videos/44851/original/44851_00723.jpg, code: 500, message: Internal Server Error
2026-04-20T08:09:45.000Z TransferManager::uploadFile | response path: /tmp/workspace/33745/videos/44851/original/44851_00870.jpg, code: 500, message: Internal Server Error

CupixAuth::handleError 경고 로그 (500 에러와 쌍으로 발생):

json
{
  "message": "CupixAuth::handleError | Undefined response: {\"statusCode\":500,\"request\":{\"method\":\"PUT\"}}"
}

ETIMEDOUT 에러 (연결 타임아웃):

json
{
  "message": "CupixAuth::handleError | Undefined response: {\"stack\":\"Error: read ETIMEDOUT\\n    at TLSWrap.onStreamRead...\",\"errno\":-110,\"code\":\"ETIMEDOUT\",\"syscall\":\"read\"}"
}

동시간대 VoxelService 503 에러 (cupixworks-worker):

text
2026-04-20T08:28:XX.XXXZ Cupix::VoxelService::captured_area! — 503 Service Unavailable

총 50건의 VoxelService 503 에러가 08:28 UTC 이후 발생하여, 단일 서비스가 아닌 백엔드 인프라 전반의 일시적 장애임을 확인.

cupixworks-api 상태: 동일 시간대 service:cupixworks-api status:error 쿼리 결과 0건. API 서버 자체는 정상이었으며, PUT /api/v1/captures/{id}/meta/video 요청도 모두 200으로 응답. 업로드 대상 서비스는 API 서버가 아닌 별도의 스토리지 서비스(presigned URL 기반).

영향받은 파일 목록 (14건, workspace 33745/video 44851): 파일 번호: 00041, 00723, 00787, 00870, 01115, 01212, 01285, 01364, 01546, 01762, 02011, 02036, 02048, 02239 — 순차적이지 않아 일부 파일만 간헐적으로 실패한 것으로 확인.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 Upstream 스토리지 서비스의 일시적 인프라 장애 동시간대 VoxelService 503 에러 50건 발생 (cupixworks-worker), cupixworks-api는 정상(에러 0건), 500/ETIMEDOUT 혼재 패턴, 35분간 지속 후 자연 해소 Confirmed
H2 Presigned URL 만료로 인한 업로드 실패 presigned URL은 시간 제한이 있음 HTTP 500은 인증 실패가 아닌 서버 내부 오류를 의미. presigned URL 만료 시 403이 반환되어야 하나, 실제로는 500 반환 Rejected
H3 Agent 측 파일 손상 또는 Content-Length 불일치 대용량 파일 스트리밍 시 size 불일치 가능성 14개 파일이 동시에 같은 에러를 반환하므로 개별 파일 문제가 아닌 서버 측 문제. ETIMEDOUT 에러도 혼재하여 네트워크/서버 계층 문제 확인 Rejected
H4 Rate limiting에 의한 업로드 거부 _maxParallelTransfer = 5로 동시 업로드. 스토리지 서비스가 rate limit 초과 시 500 반환 가능 일반적으로 rate limit은 429를 반환. 또한 VoxelService도 동시에 503을 반환하고 있어 단일 서비스의 rate limit이 아닌 인프라 전반 문제 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 이 에러는 upstream 인프라의 일시적 장애로 인한 것이므로, agent 코드의 즉각적인 수정은 불필요하다.
  • 인프라 팀에 2026-04-20 08:07~08:43 UTC 시간대의 스토리지 서비스 및 VoxelService 장애 원인을 확인 요청해야 한다.

단기 개선 (1주 이내)#

  • failTask의 전체 중단 로직 완화 (transfer.manager.ts:158-166): 현재 하나의 task 실패 시 전체 transfer queue가 중단된다. 개별 task 실패를 격리하고 나머지 task는 계속 진행하도록 변경하면, 간헐적 실패 시 성공 가능한 파일들은 업로드를 완료할 수 있다.
  • Exponential backoff 도입 (transfer.manager.ts:173-191): 현재 고정 10초 간격 retry인데, 인프라 장애 시에는 exponential backoff (10s → 20s → 40s → 80s → 160s)로 변경하면 총 retry 시간이 ~5분으로 늘어나 일시적 장애 복구를 기다릴 수 있다.

장기 개선 (재발 방지)#

  • Dead letter queue / 재처리 메커니즘: 업로드 실패한 캡처를 별도 큐에 넣어 인프라 복구 후 자동 재처리하는 구조를 도입.
  • 스토리지 서비스 health check: 업로드 시작 전 스토리지 서비스의 health를 확인하여, 장애 중에는 불필요한 시도를 줄이고 큐 메시지를 visibility timeout으로 보존.
  • 에러 레벨 세분화: upstream 일시적 장애(500, ETIMEDOUT)는 warn 레벨로 로깅하고, retry 소진 후 최종 실패만 error로 로깅하면 알림 노이즈를 줄일 수 있다.

Monitoring#

  • 업로드 실패율 모니터링:
text
service:cupixworks-capture-postprocessor-agent status:error "TransferManager::uploadFile" "code: 500"
  • Retry 소진 모니터링:
text
service:cupixworks-capture-postprocessor-agent status:error "TransferManager::failTask"
  • 상관 서비스 장애 동시 모니터링:
text
(service:cupixworks-capture-postprocessor-agent OR service:cupixworks-worker) status:error ("500" OR "503")

Risk Assessment#

  • Risk level: medium — 일시적 인프라 장애이나, 재발 시 캡처 후처리 완전 중단으로 이어짐
  • 예상 복잡도: standard — retry 로직 개선 및 failTask 격리는 기존 코드 구조 내에서 수정 가능