[CupixAerialMap] preprocess fail - error:({"input":"null","code":"ERR_INVALID_URL"}) / message:(Inva
RCA: [CupixAerialMap] preprocess fail - ERR_INVALID_URL
Overview#
What Happened#
2026-07-11 03:06 KST, production aerial-map-service ECS/Fargate task 가 aerial map 530 (team weoneil) 를 preprocess 하다가 axios ERR_INVALID_URL (input: "null") 로 실패하고 exit code 1 로 종료했다. Step Functions state machine 은 preprocess-check 단계에서 States.TaskFailed 로 감지해 cupix-process-fail-production-vdco 경로로 라우팅했고, aerial map 은 처리 실패 상태로 저장되었다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | TypeError [ERR_INVALID_URL] (Node.js URL parser) |
| exception.message | Invalid URL ({"input":"null","code":"ERR_INVALID_URL"}) |
| top_frame | applications/aerial-map-service/src/code/src/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 (subnet subnet-065f0fc20edc819ed, task 0345f9217ffa4dd1bc65cbbc2c6b28d0) |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| weoneil (aerial map 530) | 1 | Aerial map preprocessing 실패, Pix4D 처리 시작되지 않음 |
Timeline#
- 2026-07-11 03:00:42 KST — Step Functions navigator 가 aerial map 530 을 preprocess 경로로 라우팅 (
cupix-navigate-production-vdco). - 2026-07-11 03:01:53 KST — Fargate 태스크 기동,
method: nadir,outputs: [orthomosaic, pointcloud, mesh, dsm],resolution: high. - 2026-07-11 03:04:01 KST — 첫 번째 aerial photo EXIF 파싱 성공 (
camera maker: Hasselblad, model: L3D-100c) —preprocess/index.ts:52의downloadStreamByUrl(firstAerialPhoto.downloadUrl)은 정상 통과. - 2026-07-11 03:04:04 KST — Pix4D project 생성됨 (
cupix-production-weoneil-530-1783706644905). - 2026-07-11 03:06:42 KST — 병렬 photo 업로드 루프에서 첫 번째 로그
[CupixAerialMap] preprocess fail Invalid URL(clusterb2583dda). - 2026-07-11 03:06:49 KST — 상위 IIFE catch 블록의
[CupixAerialMap] preprocess fail - error:({"input":"null","code":"ERR_INVALID_URL"}) / message:(Invalid URL)(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 - error:({"input":"null","code":"ERR_INVALID_URL"}) / message:(Invalid URL)
Impact#
- Service:
aerial-map-service - 발생 횟수: 1 (동일 태스크에서 sibling cluster
b2583dda-d82b-44ad-8e1b-a458fb5b95961건 추가 — 같은 실행의 두 개 다른 error 로그 라인) - 최초 발생: 2026-07-11 03:06:49 KST
- 최근 발생: 2026-07-11 03:06:49 KST
- 비즈니스 영향: weoneil 팀 aerial map 530 processing 실패, Pix4D project 는 생성되었으나 image 업로드가 완료되지 않아 orphan project 발생.
Root Cause Summary#
CupixApi.getAerialPhotosByAerialMapId 가 반환한 aerial photo 목록에 download_url 이 null 인 항목이 포함되어 있었다. preprocess/index.ts:89 의 병렬 업로드 루프가 이 값을 그대로 downloadBufferByUrl(downloadUrl) → axios({ url: null }) 로 넘겼고, Node.js/axios URL 파서가 TypeError [ERR_INVALID_URL] 을 던지면서 Fargate 태스크가 exit code 1 로 종료됐다. 첫 번째 사진(sortedAerialPhotos[0]) 의 downloadUrl 은 유효했기 때문에 EXIF 파싱과 Pix4D 프로젝트 생성까지는 성공했고, 이후 병렬 업로드 단계에서 두 번째 이후 사진 중 하나의 null URL 이 실패 원인이 되었다. 코드 경로 어디에도 downloadUrl 이 문자열인지 확인하는 검증이 없다.
Technical Analysis#
Code Path#
Entry point: applications/aerial-map-service/src/code/src/preprocess/index.ts:168 (IIFE 시작)
app(input)호출 →preprocess/index.ts:48에서cupixApi.getAerialPhotosByAerialMapId(530)로 aerial photo 목록 조회.preprocess/index.ts:49-52— 첫 사진에 대해downloadStreamByUrl로 EXIF 읽기 성공 (Hasselblad L3D-100c로그 확인).preprocess/index.ts:82-101—pLimit(10)로 병렬 업로드 태스크 생성, 각 photo 의downloadUrl을downloadBufferByUrl에 그대로 전달.- Failure point:
preprocess/index.ts:89의downloadBufferByUrl(downloadUrl)—downloadUrl === null인 항목을 만나 axios 가ERR_INVALID_URL을 throw. preprocess/index.ts:144-152의try/catch는 error 를 잡아AerialMapError가 아니므로AMB710으로saveError후 rethrow.preprocess/index.ts:174-178IIFE catch 가 rethrow 된 error 를JSON.stringify(error)+error.message로 포맷팅해 로그 출력,process.exit(1).
Photo 목록을 채우는 API mapping:
return photos.map((photo: IAerialPhotoData) => {
return {
id: photo.attributes.id,
name: photo.attributes.name,
state: photo.attributes.state,
downloadUrl: photo.attributes.download_url,
capturedAt: photo.attributes.captured_at,
longitude: photo.attributes.longitude,
latitude: photo.attributes.latitude,
altitude: photo.attributes.altitude,
};
});
download_url 을 그대로 pass-through 한다 — null / undefined 검증 없음. Type IAerialPhoto.downloadUrl: string (아래) 은 런타임 값을 강제하지 못한다.
export interface IAerialPhoto {
id: number;
name: string;
state: string;
downloadUrl: string;
capturedAt: string;
longitude: string;
latitude: string;
altitude: string;
}
병렬 업로드 루프 — 실패 지점:
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 });
// ...
}),
);
axios 호출에서 URL 검증 없음:
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;
};
상위 catch — JSON.stringify(error) 는 TypeError 인스턴스에서 enumerable property (input, code) 만 직렬화하므로 로그에 {"input":"null","code":"ERR_INVALID_URL"} 이 남음:
(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. 첫 사진은 유효했으므로 (readPhotoExif 성공) 문제는 batch 내 후속 사진 중 하나 — 어떤 사진 ID 인지는 로그에서 확인 불가 (logger.debug 라인이 debug 레벨이라 Datadog 에 저장되지 않음).
Log Evidence#
Datadog 쿼리 (재현용):
service:aerial-map-service "CupixAerialMap"
from: 2026-07-10T17:30:00Z
to: 2026-07-10T19:00:00Z
핵심 로그 시퀀스:
2026-07-10T18:01:53Z INFO [CupixAerialMap] preprocess fargate memory limit: 2048 MB
2026-07-10T18:01:53Z INFO [CupixAerialMap] aerial map method: nadir
2026-07-10T18:01:53Z INFO [CupixAerialMap] aerial map outputs: ["orthomosaic","pointcloud","mesh","dsm"]
2026-07-10T18:01:53Z INFO [CupixAerialMap] aerial map resolution: high
2026-07-10T18:04:01Z INFO [CupixAerialMap] camera maker: Hasselblad, camera model: L3D-100c, captured at: 2026-07-09T16:32:41.000Z
2026-07-10T18:04:04Z INFO [CupixAerialMap] create pix4d project - project name: cupix-production-weoneil-530-1783706644905
2026-07-10T18:06:42Z ERROR [CupixAerialMap] preprocess fail Invalid URL
2026-07-10T18:06:49Z ERROR [CupixAerialMap] preprocess fail - error:({"input":"null","code":"ERR_INVALID_URL"}) / message:(Invalid URL)
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
Step Functions 이벤트에 포함된 ECS 태스크 정보:
{
"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",
"Overrides": {
"aerialMap": { "id": 530, "processing_option": { "method": "nadir", "resolution": "high" } },
"team": { "domain": "weoneil" }
}
}
주목할 gap: 18:04:04 (Pix4D project 생성) 와 18:06:42 (Invalid URL) 사이 약 2 분 30 초 동안 병렬 업로드가 진행되었으며, 이 구간의 logger.debug("download and upload aerial photo: ${aerialPhoto.id}") (index.ts:85) 는 debug 레벨이라 Datadog 에 없다. 어떤 photo ID 가 null URL 이었는지는 debug 로그 (Cupix Watch / Kibana) 나 DB 조회 없이는 특정 불가 — uncertain, needs verification.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | Aerial photo 중 하나의 download_url 이 null 이어서 axios 가 ERR_INVALID_URL throw |
에러 payload {"input":"null","code":"ERR_INVALID_URL"}, preprocess/index.ts:89 가 검증 없이 URL 전달, API mapping api.ts:1029 도 pass-through, 첫 사진(index 0) 은 정상 통과했음이 EXIF 로그로 증명 |
— | Confirmed |
| H2 | 첫 사진(sortedAerialPhotos[0]) 의 URL 이 null 이라 preprocess/index.ts:52 downloadStreamByUrl 에서 실패 |
input:"null" 문자열이 정확히 매칭 |
03:04:01 KST 에 camera maker: Hasselblad 로그가 있어 52 line 은 성공, EXIF 파싱까지 완료. 실패는 그 뒤 2 분 후 발생 |
Rejected |
| H3 | S3 presigned URL 이 만료되어 axios 가 실패 | 병렬 업로드가 느려서 만료 가능성 이론적으로 존재 | 만료된 URL 이면 401/403 HTTP 에러이지 ERR_INVALID_URL 아님. 이 에러는 파싱 실패이지 네트워크 실패가 아님 |
Rejected |
| H4 | Pix4D credential.key 가 null 이라 imageKey 문자열 조합에서 발생 |
${credential.key}/${...} 도 코드에 존재 |
에러 code 는 ERR_INVALID_URL 로 URL 파서에서 발생. imageKey 는 S3 업로드 (uploadS3StreamByKey) 에 쓰이며 그전에 downloadBufferByUrl 에서 이미 실패 |
Rejected |
| H5 | Aerial photo 가 state != 'uploaded' (예: pending, failed) 여서 backend 가 download_url null 을 반환 |
api.ts:1028 에서 state 필드도 가져오지만 검증 없이 반환. Cupix API 규약상 미완료 photo 는 URL 이 null 가능 |
실제 photo state 를 확인하려면 backend DB 조회 필요 — uncertain, needs verification | Inconclusive (원인 가능성 있음, 검증 필요) |
Fix Recommendation#
즉시 조치 (Critical)#
applications/aerial-map-service/src/code/src/preprocess/index.ts:89병렬 업로드 진입 전 각aerialPhoto.downloadUrl유효성 검증. 방향:- Null/empty string 이면 명시적
AerialMapError(예: 신규 codeAMB711Missing download URL for aerial photo) throw 하여 원인 미상의ERR_INVALID_URL을 사용자향 에러 코드로 승격. - 실패한 photo ID 를 error 메시지에 포함시켜 debug 로그 없이도 어느 사진인지 특정 가능하게.
- Null/empty string 이면 명시적
applications/aerial-map-service/src/code/src/common/api.ts:1024-1035getAerialPhotosByAerialMapId반환 mapping 에서도download_url이 null 인 항목을 필터링 또는 로그 남기고 반환. 방향은 백엔드 스펙 확인 후 결정 (필터 vs error).preprocess/index.ts:52의firstAerialPhoto.downloadUrl도 동일한 검증 필요 (현재는 우연히 인덱스 0 이 유효해서 통과했을 뿐).
단기 개선 (1주 이내)#
applications/aerial-map-service/src/code/src/preprocess/index.ts:175상위 catch 의JSON.stringify(error)는 Error instance 를 제대로 직렬화하지 못한다 (stack, name 이 non-enumerable).error.stack,error.name,error.code,error.message를 명시적으로 로깅하도록 개선 → RCA 시 debug 로그 없이도 photo ID / URL 원본을 확인 가능하게.IAerialPhoto.downloadUrl타입을string | null로 좁혀 컴파일 타임에 사용처마다 null check 강제.- 실패한 aerial photo ID 를
cupixApi.saveErrorpayload 에 포함시켜 사용자에게 "photo N number 재업로드 필요" 를 안내.
장기 개선 (재발 방지)#
- Backend (tesla)
aerial_photosAPI 응답에서state != 'uploaded'인 항목이 preprocess input 에 포함되지 않도록 서버 측 필터링. 또는download_url이 null 인 photo 를 포함할 때 backend 에서422를 반환. - Preprocess 시작 전 aerial_map 의 photo state 를 검사하는 preflight 단계 도입 (Step Functions choice state).
- 병렬 업로드 loop 진입 시
[CupixAerialMap] uploading ${n} photos, first_id=..., last_id=...info 로그 추가 — 현재 debug 뿐이라 Datadog 만으로는 배치 크기와 순서 파악 불가.
Monitoring#
- ECS Fargate 태스크 실패율 알림 (
cupix-aerial-map-preprocess-task-def-production). service:aerial-map-service status:error타임시리즈 위젯:
service:aerial-map-service status:error @environment:production "[CupixAerialMap] preprocess fail"
ERR_INVALID_URL만 별도로 추적:
service:aerial-map-service status:error "ERR_INVALID_URL"
- 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) 에서 발생했고 배치 처리 실패로 사용자 경험 저하지만 시스템 전체 안정성 위협은 아님. Preprocess 재시도 시 동일 photo 가 여전히 null URL 이면 계속 실패 가능 — 사용자 개입 필요할 수 있음. - 예상 복잡도: standard — 3~4 개 지점에 null guard 추가 및 error code 신설. Backend 측 필터링을 함께 하면 조정 필요.