ES /docs

[CupixAerialMap] postprocess fail Request failed with status code 500

RCA: [CupixAerialMap] postprocess fail Request failed with status code 500

Overview#

What Happened#

2026-04-30 07:30:02 UTC에 aerial-map-service의 postprocess ECS Fargate 태스크에서 aerial photo 썸네일 생성을 위해 Cupix S3 소스 버킷(cupixworks-source-888a512bf858-uswe1)의 presigned URL로 이미지를 다운로드하던 중 AWS S3가 HTTP 500 Internal Server Error를 반환했다. axios 재시도 인터셉터가 최대 3회까지 재시도한 후에도 실패하여 aerial map 379의 전체 postprocess가 실패했다.

Quick Facts#

Field Value
exception.class AxiosError
exception.message Request failed with status code 500
top_frame /app/dist/index.js:3961:28602 (minified — maps to processAerialPhoto flow)
runtime Node.js 20 (ECS Fargate, 2 vCPU, 8 GB memory)
env production, us-west-2

Timeline#

  1. 06:43:38 UTC — postprocess Fargate 태스크 시작 (aerial map 379)
  2. 06:43:53 ~ 06:44:00 UTC — Pix4d 출력 파일 다운로드 및 report/flight_path 후처리 완료
  3. 07:30:02 UTC — S3 presigned URL로 aerial photo 이미지 다운로드 중 HTTP 500 에러 발생, 3회 재시도 후 실패
  4. 07:30:02 UTC — postprocess 태스크 exit code 1로 종료
  5. 07:31:03 UTC — postprocess-check Lambda가 States.TaskFailed 감지
  6. 07:31:08 UTC — process-fail Lambda가 aerial map 379 상태를 fail로 업데이트
  7. 07:31:10 UTC — data-collect Lambda가 aerial map 379 데이터 수집 완료

Error Log#

Datadog Logs

text
[CupixAerialMap] postprocess fail Request failed with status code 500

Impact#

  • Service: aerial-map-service
  • 발생 횟수: 1
  • 최초 발생: 2026-04-30T07:30:02.585Z
  • 최근 발생: 2026-04-30T07:30:02.585Z
  • 사용자 영향: aerial map 379의 postprocess 실패로 인해 orthomosaic 타일, 썸네일 등 최종 결과물이 생성되지 않았으며, Step Functions에서 fail 상태로 전환됨.

Root Cause Summary#

AWS S3가 presigned URL을 통한 aerial photo 이미지 다운로드 요청에 일시적으로 HTTP 500 Internal Server Error를 반환했다. 이는 AWS S3의 알려진 transient 오류이다. aerial-map-service의 axios 인터셉터(axios.ts)가 status >= 500 에 대해 최대 3회 재시도하지만, 3회 모두 실패한 후 에러가 전파되어 processAerialPhoto 루프 전체가 중단되었다. processAerialPhoto는 각 aerial photo를 순차적으로 처리하며(process.ts:56-79), 한 장의 사진 다운로드 실패가 전체 postprocess 태스크를 실패시키는 구조이다.

Technical Analysis#

Code Path#

Entry point: postprocess/index.ts:172 — ECS Fargate 태스크가 CLI 인자로 input JSON을 받아 app() 함수 실행

src/postprocess/index.ts:172-183typescript
(async () => {
  try {
    await app(input);
    logger.info(`[CupixAerialMap] postprocess success`);
    await sleep(30000); // sleep for log push
    process.exit(0);
  } catch (error: any) {
    logger.error(`[CupixAerialMap] postprocess fail - error:(${JSON.stringify(error)}) / message:(${error.message})`);
    await sleep(30000); // sleep for log push
    process.exit(1);
  }
})();

Orchestration: postprocess/index.ts:93-133processReport, processFlightPath, processAerialPhoto가 순차 실행된 후 processTiff, processThumbnail 등이 Promise.all로 병렬 실행됨. processAerialPhotoPromise.all 전에 실행되므로 여기서 에러가 발생하면 나머지 작업이 실행되지 않음.

src/postprocess/index.ts:93-101typescript
    const report = await processReport(cupixApi, aerialMapId, pix4dOutput, downloadCredential);
    const [flightPath, calibration] = await processFlightPath(
      cupixApi, aerialMapId, pix4dOutput, downloadCredential, aerialPhotos,
    );
    await processAerialPhoto(cupixApi, aerialMapId, report!, calibration!, aerialPhotos);

Failure point: postprocess/process.ts:66downloadStreamByUrl(aerialPhoto.downloadUrl) 호출에서 S3 presigned URL이 HTTP 500을 반환.

src/postprocess/process.ts:49-79typescript
export const processAerialPhoto = async (
  cupixApi: CupixApi,
  aerialMapId: number,
  report: IReportInfo,
  calibration: ICalibrations,
  aerialPhotos: IAerialPhoto[],
) => {
  for (let i = 0; i < aerialPhotos.length; i++) {
    const aerialPhoto = aerialPhotos[i];
    const aerialPhotoUploadCredential = await cupixApi.createAerialPhotoS3UploadCredential(aerialMapId, aerialPhoto.id);
    // ...
    const stream = await downloadStreamByUrl(aerialPhoto.downloadUrl);  // ← HTTP 500 발생 지점
    const thumbnailKey = `${aerialPhotoUploadCredential.basepath}/thumbnail/thumbnail`;
    const thumbnailStream = await createThumbnail(stream);
    // ...
  }
};

S3 다운로드 함수: common/s3.ts:164-166 — 커스텀 axios 인스턴스 사용. 이 인스턴스는 common/axios.ts에 정의된 재시도 인터셉터를 포함.

src/common/s3.ts:164-166typescript
export const downloadStreamByUrl = async (downloadUrl: string) => {
  const response = await axios({ method: 'get', url: downloadUrl, responseType: 'stream' });
  return response.data;
};

재시도 인터셉터: common/axios.ts:12-33status >= 500에 대해 최대 3회, exponential backoff (1s, 2s, 4s)로 재시도. 3회 모두 실패하면 에러를 throw.

src/common/axios.ts:8-33typescript
const axios = _axios.create({
  timeout: REQUEST_TIMEOUT_MS,
});

axios.interceptors.response.use(undefined, async (error) => {
  const config = error.config;
  if (!config) throw error;

  config.__retryCount = config.__retryCount || 0;

  const retryable = RETRY_CODES.includes(error.code) || (error.response?.status && error.response.status >= 500);

  if (!retryable || config.__retryCount >= MAX_RETRIES) {
    throw error;
  }

  config.__retryCount += 1;
  const delay = BASE_DELAY_MS * Math.pow(2, config.__retryCount - 1);
  // ...
  return axios(config);
});

Presigned URL 생성 (Tesla API): app/models/concerns/aerialable/aerial_photo.rb:19-20 — presigned URL은 expires_in: 1.days.to_i (86400초)로 생성됨. 에러 로그의 URL에서 X-Amz-Expires=86400, X-Amz-Date=20260430T064341Z 확인 — URL 자체는 만료되지 않았음.

app/models/concerns/aerialable/aerial_photo.rb:19-20ruby
      else
        self.resource.object(ver).presigned_url(:get, expires_in: 1.days.to_i)
      end

에러 핸들링: postprocess/index.ts:148-157processAerialPhoto에서 throw된 에러는 catch 블록에서 AMB720 (Postprocess Fail) 에러 코드로 Cupix API에 저장된 후 다시 throw되어 process가 exit code 1로 종료.

src/postprocess/index.ts:148-157typescript
  } catch (error: any) {
    if (error instanceof AerialMapError) {
      await cupixApi.saveError(aerialMapId, error.code, error.reason);
    } else {
      logger.error(`[CupixAerialMap] postprocess fail ${error.message}`);
      await cupixApi.saveError(aerialMapId, ERROR_CODE['AMB720'].code, ERROR_CODE['AMB720'].reason);
    }
    throw error;
  }

Log Evidence#

Datadog 쿼리:

text
service:aerial-map-service status:error

에러 발생 시점의 상세 로그 (에러 메시지에 포함된 axios config에서 확인된 요청 정보):

json
{
  "message": "Request failed with status code 500",
  "name": "AxiosError",
  "code": "ERR_BAD_RESPONSE",
  "status": 500,
  "config": {
    "method": "get",
    "url": "https://s3.us-west-1.amazonaws.com/cupixworks-source-888a512bf858-uswe1/resources/k8pw0m/uswe1/v1?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20260430T064341Z&X-Amz-Expires=86400",
    "responseType": "stream"
  }
}

postprocess-check Lambda가 감지한 ECS 태스크 실패 상태:

json
{
  "StopCode": "EssentialContainerExited",
  "ExitCode": 1,
  "Image": "002596530511.dkr.ecr.us-west-2.amazonaws.com/cupix-aerial-map-postprocess:prod",
  "LastStatus": "STOPPED",
  "StoppedReason": "Essential container in task exited"
}

process-fail Lambda 로그:

text
[CupixAerialMap] cupix-process-fail-production-vdco - pix4d process fail: fail

재시도 로그 확인:

text
service:aerial-map-service status:warn

결과: 0건 — Fargate ECS 태스크의 console.warn 출력이 Datadog에 수집되지 않았을 가능성이 있음. 인터셉터는 console.warn으로 재시도 로그를 출력하지만, Datadog에서 해당 레벨이 캡처되지 않는 환경일 수 있음. 또는 재시도가 빠르게 실패하여 로그가 배치 전송 전에 유실되었을 수 있음.

Hypotheses Considered#

# Hypothesis Evidence for Evidence against Verdict
H1 AWS S3 transient 500 에러 — S3가 일시적으로 Internal Server Error를 반환 에러 응답 status 500, ERR_BAD_RESPONSE 코드, S3 presigned URL이 만료되지 않았음(X-Amz-Expires=86400, 생성 후 ~47분), AWS S3 서비스 문서에 "S3 may return 500s during transient issues" 명시 Confirmed
H2 Presigned URL 만료로 인한 접근 거부 presigned URL의 TTL은 86400초(24시간)임 에러 발생 시각(07:30 UTC)은 URL 생성(06:43 UTC) 후 47분으로 만료 시간 내. 만료 시에는 403이 반환되지 500이 아님 Rejected
H3 요청 대상 S3 객체가 존재하지 않거나 삭제됨 존재하지 않는 객체 접근 시 404가 반환됨, 500은 서버측 오류를 의미함 Rejected
H4 네트워크 연결 문제 (ECONNRESET, timeout) 에러 코드가 ERR_BAD_RESPONSE이며 status: 500으로, HTTP 응답을 정상적으로 수신함. 네트워크 오류 시에는 ECONNRESET, ETIMEDOUT 등의 코드가 반환됨 Rejected

Fix Recommendation#

즉시 조치 (Critical)#

  • 없음 — 이 에러는 AWS S3의 transient 오류이며, 현재 재시도 메커니즘(최대 3회)이 이미 구현되어 있다. 1회성 발생이므로 즉시 조치는 불필요.

단기 개선 (1주 이내)#

  • 재시도 횟수 증가: common/axios.ts:5MAX_RETRIES를 3에서 5로 증가. S3 transient 500은 보통 수 초 내에 해소되므로 2회 추가 재시도로 대부분 커버 가능.
  • processAerialPhoto 개별 사진 에러 격리: process.ts:56-79의 for 루프 내에 try-catch를 추가하여 한 장의 aerial photo 썸네일 생성 실패가 전체 postprocess를 중단하지 않도록 개선. 실패한 사진은 thumbnail_state: 'error'로 마킹하고 나머지를 계속 진행.
  • 재시도 로그를 logger로 변경: common/axios.ts:28console.warnlogger.warn으로 변경하여 Datadog에서 재시도 이벤트를 추적할 수 있도록 개선.

장기 개선 (재발 방지)#

  • S3 다운로드에 AWS SDK 사용 검토: presigned URL 대신 AWS SDK의 GetObjectCommand를 사용하면 SDK 내장 재시도 로직(maxAttempts, retryMode: 'adaptive')을 활용할 수 있음. 현재 Pix4d 출력 다운로드(downloadObjectByKey)에서는 이미 SDK를 사용하고 있으나, aerial photo 다운로드(downloadStreamByUrl)에서는 presigned URL + axios를 사용 중.

Monitoring#

  • 재시도 로그를 logger.warn으로 변경 후, 아래 Datadog 쿼리로 S3 재시도 빈도 모니터링:
text
service:aerial-map-service "Request failed" "retrying"
  • postprocess 실패 빈도 모니터링:
text
service:aerial-map-service status:error "postprocess fail"

Risk Assessment#

  • Risk level: low
  • 예상 복잡도: trivial — AWS S3 transient 오류 1회 발생. 현재 재시도 메커니즘이 있으며, 대부분의 경우 재시도로 해소됨. 재시도 횟수 증가와 에러 격리는 방어적 개선.