[CupixAerialMap] preprocess fail Invalid URL
RCA: [CupixAerialMap] preprocess fail Invalid URL
Overview#
What Happened#
2026-07-11 03:06:42 KST, production aerial-map-service ECS/Fargate preprocess task (aerial map 530, team weoneil) 가 병렬 photo 업로드 루프에서 download_url 이 null 인 aerial photo 를 만나 axios URL 파서에서 TypeError [ERR_INVALID_URL] 을 throw 했다. 이 클러스터의 로그([CupixAerialMap] preprocess fail Invalid URL)는 preprocess/index.ts:148 의 inner catch 가 원본 error 를 rethrow 하기 직전에 남긴 첫 번째 에러 라인이며, 7 초 뒤 outer IIFE catch 가 [CupixAerialMap] preprocess fail - error:({"input":"null","code":"ERR_INVALID_URL"}) / message:(Invalid URL) 로그(sibling cluster 3c48c1b1)를 남기고 exit code 1 로 종료했다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | TypeError [ERR_INVALID_URL] (Node.js URL 파서) |
| exception.message | Invalid URL ({"input":"null","code":"ERR_INVALID_URL"}) |
| top_frame | applications/aerial-map-service/src/code/src/preprocess/index.ts:148 (inner catch), 실제 throw 지점 preprocess/index.ts:89 |
| runtime | Node.js on ECS Fargate (cupix-aerial-map-preprocess:prod, 1024 CPU / 2048 MB) |
| deploy | image sha256:0a00e602c0acad71bece8834eaf12e41923f381a9869c7d1a1842d86f2dc6647 |
| env | production, us-west-2 (task 0345f9217ffa4dd1bc65cbbc2c6b28d0) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| weoneil (aerial map 530) | 1 | Aerial map preprocessing 실패, Pix4D project 생성됐으나 image 업로드 미완료 (orphan project) |
Timeline#
- 2026-07-11 03:01:53 KST — Fargate task 기동 (
method: nadir,outputs: [orthomosaic, pointcloud, mesh, dsm],resolution: high). - 2026-07-11 03:04:01 KST —
preprocess/index.ts:52downloadStreamByUrl(firstAerialPhoto.downloadUrl)성공, EXIF 파싱 (Hasselblad L3D-100c). - 2026-07-11 03:04:04 KST — Pix4D project
cupix-production-weoneil-530-1783706644905생성. - 2026-07-11 03:06:42 KST — 병렬 업로드 루프의 axios 가
nullURL 에서ERR_INVALID_URLthrow →preprocess/index.ts:148inner catch 가[CupixAerialMap] preprocess fail Invalid URL로그 (이 클러스터). - 2026-07-11 03:06:49 KST — Rethrow 된 error 를 outer IIFE catch(
preprocess/index.ts:174-178) 가 잡아preprocess fail - error:(...) / message:(Invalid URL)로그 (sibling cluster3c48c1b1). - 2026-07-11 03:07:48 KST — Step Functions
preprocess-check가States.TaskFailed/ExitCode: 1감지. - 2026-07-11 03:07:56 KST —
cupix-process-fail-production-vdco가pix4d process fail: fail기록. - 2026-07-11 03:08:06 KST — Data collect finalize.
Error Log#
[CupixAerialMap] preprocess fail Invalid URL
Impact#
- Service:
aerial-map-service - 발생 횟수: 1 (동일 ECS task 에서 sibling cluster
3c48c1b1-6622-4e52-bb9b-7c95b74104761건 추가 — 같은 실패의 outer catch 로그) - 최초 발생: 2026-07-11 03:06:42 KST
- 최근 발생: 2026-07-11 03:06:42 KST
- 비즈니스 영향: weoneil 팀 aerial map 530 preprocessing 실패, Pix4D project 는 생성됐으나 image 업로드 미완료로 orphan 상태.
Root Cause Summary#
cupixApi.getAerialPhotosByAerialMapId(530) 가 반환한 aerial photo 목록에 download_url 이 null 인 항목이 최소 한 개 포함되어 있었다. preprocess/index.ts:82-101 의 pLimit(10) 병렬 업로드 루프는 검증 없이 aerialPhoto.downloadUrl 을 downloadBufferByUrl(downloadUrl) → axios({ url: null }) 로 전달했고, Node.js/axios URL 파서가 TypeError [ERR_INVALID_URL] 을 throw 했다. 이 클러스터의 로그 문자열(preprocess fail Invalid URL)은 inner try/catch(preprocess/index.ts:144-153) 가 error instanceof AerialMapError 검사에 실패하면서 else 브랜치(line 148) 로 진입해 `[CupixAerialMap] preprocess fail $\{error.message\}` 템플릿을 그대로 로그로 남긴 결과다. 첫 사진(sortedAerialPhotos[0]) 은 유효했기 때문에 EXIF 파싱과 Pix4D 프로젝트 생성까지는 성공했고, 실패는 이후 병렬 업로드에 진입한 두 번째 이상의 photo 중 하나의 null URL 때문이다. 이 클러스터와 sibling 3c48c1b1 은 동일 실행의 두 catch 지점에서 나온 로그이므로 root cause 는 동일하다.
Technical Analysis#
Code Path#
Entry point: applications/aerial-map-service/src/code/src/preprocess/index.ts:168 (IIFE 시작)
app(input)호출 →preprocess/index.ts:48cupixApi.getAerialPhotosByAerialMapId(530).preprocess/index.ts:50-52— 첫 사진(sortedAerialPhotos[0]) 의 URL 로 EXIF 파싱 성공.preprocess/index.ts:82-101—pLimit(10)병렬 업로드 태스크 생성.- Failure point:
preprocess/index.ts:89downloadBufferByUrl(downloadUrl)—downloadUrl === null인 항목에서 axios 가ERR_INVALID_URLthrow. preprocess/index.ts:144-153inner catch —error instanceof AerialMapError === false이므로 else 브랜치(147-150) 진입,line 148이 이 클러스터의 로그를 emit,AMB710을saveError로 저장한 뒤 rethrow.preprocess/index.ts:174-178outer IIFE catch — rethrow 된 error 를JSON.stringify(error)로 포맷팅해 sibling cluster 로그(3c48c1b1) 를 emit,process.exit(1).
이 클러스터를 emit 하는 지점:
} catch (error: any) {
if (error instanceof AerialMapError) {
await cupixApi.saveError(aerialMapId, error.code, error.reason);
} else {
logger.error(`[CupixAerialMap] preprocess fail ${error.message}`);
await cupixApi.saveError(aerialMapId, ERROR_CODE['AMB710'].code, ERROR_CODE['AMB710'].reason);
}
throw error;
}
error.message === "Invalid URL" 이므로 실제 로그 문자열은 "[CupixAerialMap] preprocess fail Invalid URL" 이 되며, 이 클러스터의 representative error 와 정확히 일치한다.
병렬 업로드 루프 — 실제 throw 지점:
const limit = pLimit(10);
const uploadTasks = sortedAerialPhotos.map((aerialPhoto) =>
limit(async () => {
logger.debug(`[CupixAerialMap] download and upload aerial photo: ${aerialPhoto.id}`);
const downloadUrl = aerialPhoto.downloadUrl;
const imageKey = `${credential.key}/${PIX4D_PHOTO_FILENAME_PREFIX}_${aerialPhoto.id}`;
imageKeys.push(imageKey);
const buffer = await downloadBufferByUrl(downloadUrl);
const exifStream = Readable.from(buffer);
const photoExif = await readPhotoExif(exifStream);
const uploadStream = Readable.from(buffer);
await uploadS3StreamByKey({ credential, imageKey, stream: uploadStream });
// ...
}),
);
downloadUrl 이 string 이라는 타입 가정 하에 검증 없이 axios 로 전달:
export const downloadBufferByUrl = async (downloadUrl: string) => {
const response = await axios({ method: 'get', url: downloadUrl, responseType: 'arraybuffer' });
return Buffer.from(response.data);
};
export const downloadStreamByUrl = async (downloadUrl: string) => {
const response = await axios({ method: 'get', url: downloadUrl, responseType: 'stream' });
return response.data;
};
Outer IIFE catch (sibling cluster 3c48c1b1 을 emit) — 이 클러스터의 rethrow 를 잡는 지점:
(async () => {
try {
await app(input);
logger.info(`[CupixAerialMap] preprocess success`);
await sleep(30000);
process.exit(0);
} catch (error: any) {
logger.error(`[CupixAerialMap] preprocess fail - error:(${JSON.stringify(error)}) / message:(${error.message})`);
await sleep(30000);
process.exit(1);
}
})();
기대 동작: 모든 aerial photo 는 유효한 download_url (S3 presigned URL) 을 가진다. 실제 동작: 최소 하나의 사진이 download_url: null 을 반환해 axios URL 파서가 throw. 어떤 photo ID 가 null URL 이었는지는 logger.debug (index.ts:85) 라인이 debug 레벨이라 Datadog 에 저장되지 않아 이 로그만으로는 특정 불가 — uncertain, needs verification (Cupix Watch / Kibana 또는 DB 조회 필요).
Log Evidence#
Datadog 쿼리 (재현용):
service:aerial-map-service "preprocess fail Invalid URL"
from: 2026-07-10T17:30:00Z
to: 2026-07-10T19:00:00Z
이 쿼리로 확인된 이 클러스터의 유일한 로그:
2026-07-10T18:06:42Z ERROR [CupixAerialMap] preprocess fail Invalid URL
같은 ECS task 의 인접 로그 시퀀스 (service:aerial-map-service "CupixAerialMap" between 2026-07-10T18:05:00Z and 2026-07-10T18:10:00Z):
2026-07-10T18:06:42Z ERROR [CupixAerialMap] preprocess fail Invalid URL ← 이 클러스터 (inner catch, index.ts:148)
2026-07-10T18:06:49Z ERROR [CupixAerialMap] preprocess fail - error:({"input":"null","code":"ERR_INVALID_URL"}) / message:(Invalid URL) ← sibling cluster 3c48c1b1 (outer catch, index.ts:175)
2026-07-10T18:07:48Z INFO [CupixAerialMap] preprocess-check - event({"Error":"States.TaskFailed", ... "ExitCode":1 ...})
2026-07-10T18:07:56Z INFO [CupixAerialMap] cupix-process-fail-production-vdco - pix4d process fail: fail
2026-07-10T18:08:06Z INFO [CupixAerialMap] cupix-data-collect-production-vdco - aerial map(530) data collected
Step Functions preprocess-check 이벤트 payload 에 포함된 ECS task 정보:
{
"TaskArn": "arn:aws:ecs:us-west-2:002596530511:task/cupix-aerial-map-ecs-production/0345f9217ffa4dd1bc65cbbc2c6b28d0",
"ExitCode": 1,
"StopCode": "EssentialContainerExited",
"StoppedReason": "Essential container in task exited",
"Image": "002596530511.dkr.ecr.us-west-2.amazonaws.com/cupix-aerial-map-preprocess:prod",
"ImageDigest": "sha256:0a00e602c0acad71bece8834eaf12e41923f381a9869c7d1a1842d86f2dc6647",
"Overrides": {
"aerialMap": { "id": 530, "processing_option": { "method": "nadir", "resolution": "high" } },
"team": { "domain": "weoneil" }
}
}
이 클러스터의 로그(03:06:42)와 sibling cluster 의 로그(03:06:49)의 7 초 간격은 cupixApi.saveError(aerialMapId, 'AMB710', ...) HTTP 호출 + rethrow → outer catch 진입에 걸린 시간으로 설명된다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 이 클러스터는 preprocess/index.ts:148 inner catch 가 ERR_INVALID_URL (message: "Invalid URL") 을 잡아 emit 한 로그이며, root cause 는 aerial photo 의 downloadUrl === null |
로그 문자열이 `[CupixAerialMap] preprocess fail $\{error.message\}` 템플릿과 정확히 일치. Sibling cluster 3c48c1b1 의 payload \{"input":"null","code":"ERR_INVALID_URL"\} 가 URL 파서 실패임을 명시. 두 로그가 같은 ECS task(0345f9217...) 에서 7 초 간격으로 발생 |
— | Confirmed |
| H2 | 이 클러스터가 별개 원인 (예: 다른 axios 호출, 다른 URL 파싱 지점) 에서 발생 | Invalid URL 은 preprocess/index.ts 여러 axios 호출 중 어느 것에서도 나올 수 있음 |
같은 task 의 sibling log 가 7 초 뒤 outer catch 에서 ERR_INVALID_URL payload 로 나옴. Inner catch 는 rethrow 하므로 두 로그는 동일 error 인스턴스에서 파생. Grep 결과 preprocess fail Invalid URL 은 preprocess/index.ts:148 한 곳에서만 emit 가능 |
Rejected |
| H3 | preprocess/index.ts:52 downloadStreamByUrl(firstAerialPhoto.downloadUrl) 가 실패한 경우 (첫 사진의 URL null) |
input: "null" 문자열이 정확히 매칭 |
03:04:01 KST 에 camera maker: Hasselblad 로그 확인 → line 52 통과. 실패는 그 뒤 2 분 30 초 후 병렬 루프에서 발생 |
Rejected |
| H4 | S3 presigned URL 만료로 인한 axios 실패 | 병렬 업로드 지연 시 만료 이론적 가능 | 만료된 URL 은 4xx HTTP 응답이지 파서 단계의 ERR_INVALID_URL 아님. input: "null" payload 는 URL 파서 argument 가 null 이었음을 명시 |
Rejected |
| H5 | Aerial photo 중 하나가 state != 'uploaded' 이라 backend 가 download_url: null 을 반환 |
api.ts:1024-1035 mapping 에서 download_url pass-through, backend 스펙상 미완료 photo 는 URL null 가능 |
실제 photo state / DB 조회 필요 — 로그만으로 확정 불가 | Inconclusive (원인 가능성 있음, backend 조회 필요) |
Fix Recommendation#
즉시 조치 (Critical)#
applications/aerial-map-service/src/code/src/preprocess/index.ts:86-89병렬 업로드 진입 전 각aerialPhoto.downloadUrl유효성 검증. 방향:- Null/empty 이면 신규 error code (예:
AMB711 Missing download URL for aerial photo) 로AerialMapErrorthrow → inner catch 의error instanceof AerialMapError브랜치를 타서 사용자향 오류 메시지 확보. - 실패한
aerialPhoto.id를 error 메시지에 포함 (`Missing download URL for aerial photo $\{aerialPhoto.id\}`) 하여 debug 로그 없이도 특정 가능.
- Null/empty 이면 신규 error code (예:
applications/aerial-map-service/src/code/src/preprocess/index.ts:52downloadStreamByUrl(firstAerialPhoto.downloadUrl)도 동일 검증 추가 (지금은 index 0 이 우연히 유효했을 뿐).applications/aerial-map-service/src/code/src/common/api.ts:1024-1035getAerialPhotosByAerialMapIdmapping 에서download_urlnull 항목을 로그 남기고 필터링하거나 명시적 에러로 승격 — 백엔드 스펙 확인 후 결정.
단기 개선 (1주 이내)#
applications/aerial-map-service/src/code/src/preprocess/index.ts:148inner catch 의 로그를`[CupixAerialMap] preprocess fail ($\{error.name\} $\{error.code ?? ''\}): $\{error.message\}`형태로 확장 — 현재는error.message만 출력해ERR_INVALID_URL같은 code 를 이 라인만으로는 확인 불가 (outer catch 로그를 봐야 함).preprocess/index.ts:175outer catch 의JSON.stringify(error)는 Error instance 를 제대로 직렬화하지 못한다 (stack,namenon-enumerable).error.stack,error.name,error.code,error.message를 명시적으로 로깅.IAerialPhoto.downloadUrl타입(type/type.ts:174-181) 을string | null로 좁혀 컴파일 타임 null-check 강제.logger.debug("download and upload aerial photo: ${aerialPhoto.id}")(index.ts:85) 를 info 레벨로 승격하거나, 배치 시작 시[CupixAerialMap] uploading N photos: [ids...]info 로그 추가 → Datadog 만으로 실패 photo 특정 가능하게.
장기 개선 (재발 방지)#
- Backend (tesla)
aerial_photosAPI 응답 스펙 확정:state != 'uploaded'항목 서버 측 필터링, 또는download_urlnull 포함 시 422 반환. - Preprocess 시작 전 aerial_map 의 photo state 를 검사하는 preflight Step Functions choice state 도입.
- 실패 시 orphan Pix4D project 를 정리하는 compensating step (
preprocess/index.ts:75에서 생성한projectId를 실패 경로에서 삭제).
Monitoring#
- 이 클러스터 지속 발생 확인용 timeseries:
service:aerial-map-service status:error "preprocess fail Invalid URL"
- 전체
ERR_INVALID_URL추적 (sibling cluster 포함):
service:aerial-map-service status:error "ERR_INVALID_URL"
- Preprocess 실패 전반:
service:aerial-map-service status:error "[CupixAerialMap] preprocess fail"
- Downstream Step Functions 실패 참조:
service:aerial-map-service "pix4d process fail" @environment:production
- 알림 임계:
[CupixAerialMap] preprocess fail1 시간 내 3건 이상이면 Slack #alerts.
Risk Assessment#
- Risk level: medium — 단일 팀(
weoneil) 단일 aerial map (530) 실패로 시스템 전체 위협은 아니나, 사용자 재시도 시에도 동일 photo 의 null URL 이면 계속 실패. 사용자 개입 (photo 재업로드) 필요할 수 있음. - 예상 복잡도: standard — 3~4 개 지점에 null guard 추가 및 error code 신설. Backend 필터링 병행 시 조정 필요. Sibling cluster
3c48c1b1의 fix 와 동일한 코드 변경으로 함께 해결됨.