UpdateAttribute 로 {"base_ymd":"20260305"} 를 만들어 Command Arguments 에 실어 보냈는데, 스크립트가 API 본문에 넣은 값은 따옴표가 벗겨진 {base_ymd:20260305} 였다. 서버는 이렇게 답했다.
{ "status": "error", "message": "invalid character 'b' looking for beginning of object key string" }
'b' 는 base_ymd 의 첫 글자다. 키를 여는 " 가 와야 할 자리에 b 가 왔다는 뜻이다. Python 의 bytes 리터럴 b'...' 로 오해하기 쉬운데 그 문제가 아니다.
원인은 두 겹이다. 하나는 Expression Language 안의 따옴표 충돌이다. ${part_ymd:toDate("yyyyMMdd")...} 처럼 EL 인자를 큰따옴표로 쓰면 JSON 을 감싸는 큰따옴표와 구분되지 않는다. EL 함수 인자에는 작은따옴표를 쓴다. 다른 하나는 인자가 구분자로 잘려 프로세스에 전달되는 과정에서 따옴표가 값의 일부로 남지 않는다는 점이다. 앞의 것을 고쳐도 뒤의 것은 남는다.
ExecuteStreamCommand 에는 Command Arguments Strategy 가 있고 기본값은 구분자로 자르는 옛 방식이다.[1]
| 전략 | 지정 방법 | 성격 |
|---|---|---|
| Command Arguments Property (기본) | Command Arguments 한 칸에 몰아 쓰고 Argument Delimiter 로 자른다 |
값에 구분자가 섞이면 깨진다 |
| Dynamic Property Arguments | command.argument.1 · command.argument.2 … 동적 속성을 순번대로 둔다 |
인자 하나가 속성 하나라 자를 일이 없다 |
값에 구분자로 쓸 수 없는 문자가 섞여 들어갈 수 있다면 Dynamic Property Arguments 가 정공법이다. 이름이 command.argument.<순번> 패턴이 아닌 동적 속성은 환경변수로 전달되므로 이름을 잘못 적으면 조용히 환경변수가 된다.[1:1] 이 전략은 NiFi 2.12.0 에서 확인했고, 1.x 계열의 어느 마이너부터 들어왔는지는 확인하지 못했다 (확인 필요).
아래는 구분자 방식을 그대로 두고 값 쪽을 안전하게 만드는 방법이다. ExecuteProcess 에는 전략 속성이 없으므로 이쪽을 쓴다.
따옴표가 살아남지 못하는 것이 문제라면 따옴표가 없는 형태로 바꿔 넘기면 된다. UpdateAttribute 하나에서 날짜 계산 · JSON 조립 · 인코딩을 한 번에 한다.
${now():toNumber():minus(86400000):toDate():format('yyyyMMdd','Asia/Seoul'):prepend('{"base_ymd":"'):append('","part_ymd":"'):append(${now():format('yyyyMMdd','Asia/Seoul')}):append('"}'):base64Encode()}
단계별로 이렇게 접힌다.
① now():toNumber():minus(86400000):toDate():format(...) → 20260308
② prepend('{"base_ymd":"') → {"base_ymd":"20260308
③ append('","part_ymd":"') → {"base_ymd":"20260308","part_ymd":"
④ append(${now():format(...)}) → {"base_ymd":"20260308","part_ymd":"20260309
⑤ append('"}') → {"base_ymd":"20260308","part_ymd":"20260309"}
⑥ base64Encode() → eyJiYXNlX3ltZCI6IjIwMjYwMzA4Iiw...
prepend · append · base64Encode 는 모두 문자열 함수이고, append(${다른속성}) 처럼 함수 인자 자리에 다른 식을 끼워 넣는 것도 EL 이 정식으로 지원한다.[2] 키를 더 붙이려면 append('","새키":"'):append(${변수}) 를 이어 쓴다.
속성을 둘로 나눠 conf_json 을 만들고 다음 속성에서 ${conf_json:base64Encode()} 를 부르는 구성도 같은 결과를 낸다. 한 줄로 몰면 읽기 어렵고 나눠 두면 중간값을 Provenance 에서 눈으로 볼 수 있으니, 운영 중 손댈 일이 잦은 흐름은 나눠 두는 편이 낫다.
날짜 계산에서 주의할 것이 둘 있다. minus(86400000) 은 하루를 86,400,000 밀리초로 고정해 빼는 계산이라 서머타임이 있는 타임존에서는 하루가 그 값이 아닌 날에 어긋난다. 그리고 타임존을 주지 않으면 JVM 기본 타임존을 따르므로 서버가 UTC 이면 한국시간 기준 날짜와 하루가 틀어진다. toDate 와 format 은 둘 다 타임존을 선택 인자로 받으므로 명시한다.[2:1]
#!/bin/bash
JOB_NAME=$1
CONF_JSON_B64=$2
CONF_JSON=$(echo "$CONF_JSON_B64" | base64 -d | tr -d '\n')
echo "JOB_NAME=[$JOB_NAME] CONF_JSON=[$CONF_JSON]" >> /tmp/nifi_debug.log
tr -d '\n' 이 없으면 디코드 결과 끝에 개행이 붙어 JSON 파서가 거부하는 경우가 있다. 값을 대괄호로 감싸 로그에 남기면 앞뒤 공백·개행이 눈에 보인다.
디코드가 안 된 채 인코딩 문자열이 그대로 넘어가거나, NiFi 쪽 base64Encode() 가 평가되지 않아 ${...} 문자열이 통째로 전달되면 다음이 뜬다.
Error: invalid json format: invalid character '$' looking for beginning of value
$ 가 JSON 에 들어갔다는 뜻이므로 인코딩 단계가 아니라 치환 단계 를 먼저 본다.
디코드한 JSON 을 CDE CLI 의 --config-json 으로 넘긴다. Airflow 잡은 이 값을 dag_run.conf 로 받는다.
CDE="/opt/cde/bin/cde"
cde_run() {
echo "yes" | $CDE "$@" --tls-insecure
}
RESULT=$(cde_run job run --name "${JOB_NAME}" --config-json "${CONF_JSON}" --wait)
EXIT_CODE=$?
--config-json 을 작은따옴표로 감싸면 안 된다. --config-json '${CONF_JSON}' 은 셸이 변수를 치환하지 않아 문자 그대로 ${CONF_JSON} 이 CLI 에 넘어가고 위의 '$' 오류가 난다. 큰따옴표를 쓴다.
--config 는 deprecated 이고 --config-json-file 로 파일 경로를 줄 수도 있다. 인자 안의 따옴표가 계속 말썽이면 mktemp 로 임시 파일에 써서 파일 경로로 넘기는 쪽이 확실하다.
--wait 가 없으면 제출 성공 여부 만 돌아오고, 있으면 잡이 끝날 때까지 기다렸다가 실행 결과 를 종료 코드로 돌려준다.
CDE CLI 는 ~/.cde/ 를 고정 경로로 읽는다. NiFi 는 서비스 계정으로 돌기 때문에 사람이 쓰는 계정 홈에 설정을 만들어 두면 NiFi 에서만 인증이 실패한다. 계정의 실제 홈을 먼저 확인한다.
getent passwd nifi
# nifi:x:39965:39965:NiFi:/var/lib/nifi:/usr/sbin/nologin
/home/nifi 가 아니라 /var/lib/nifi 인 경우가 흔하다. 로그인 셸이 nologin 이라 직접 로그인할 수 없고 sudo -u nifi 로만 접근한다. 여기를 잘못 짚으면 설정 파일을 만들어 놓고도 virtual cluster endpoint not specified 가 계속 뜬다.
sudo mkdir -p /var/lib/nifi/.cde
sudo install -o nifi -g nifi -m 600 /home/${ADMIN_USER}/.cde/credentials /var/lib/nifi/.cde/credentials
sudo install -o nifi -g nifi -m 600 /home/${ADMIN_USER}/.cde/config.yaml /var/lib/nifi/.cde/config.yaml
sudo -u nifi /opt/cde/bin/cde job list
실행 파일 자체는 옮기지 않는다. cde 바이너리는 시스템 경로에 두고 모든 계정이 공용으로 쓰며, 계정별로 갈라야 하는 것은 자격증명과 설정뿐이다.
credentials 의 키 이름은 CDE_ACCESS_KEY_ID 가 아니라 cdp_access_key_id 다. 이름이 틀리면 파일을 읽고도 인증 정보를 못 찾아 대화형 비밀번호 프롬프트로 떨어진다. Private Cloud 에서는 리전을 자동으로 못 맞춰 Can not infer region for Cluster ID ... 가 나므로 cdp-endpoint 를 명시한다. 값은 CDP 콘솔 URL 의 도메인 부분이다.
[default]
cdp_access_key_id=${CDP_ACCESS_KEY_ID}
cdp_private_key=${CDP_PRIVATE_KEY}
vcluster-endpoint: ${CDE_VCLUSTER_ENDPOINT}
cdp-endpoint: ${CDP_CONSOLE_ENDPOINT}
tls-insecure: true
tls-insecure: true 를 설정 파일에 넣어도 WARN: Plaintext or insecure TLS connection requested ... Continue? yes/no 프롬프트가 사라지지 않는 경우가 있다. NiFi 에서 부르는 스크립트는 입력을 받을 사람이 없으므로 echo "yes" | 를 앞에 붙인 래퍼 함수로 감싸 둔다. 위 cde_run() 이 그 형태다. 사설 CA 인증서를 --tls-ca-certs 로 등록하면 --tls-insecure 자체를 뺄 수 있는데, 이것은 서버를 신뢰하는 문제 이지 내가 누구인지 증명하는 문제 가 아니므로 액세스 키는 그대로 필요하다.
CDE CLI 는 종료 코드를 재시도 가능 여부로 갈라 놓았다. cde --help 하단에 전체 목록이 있다.
| 구간 | 의미 | 예 |
|---|---|---|
0 |
성공 | |
1 ~ 69 |
재시도 불가 | 3 잘못된 요청, 4 인증 오류, 6 Job Not Found |
70 이상 |
재시도 가능 | 71 타임아웃, 72 Bad Gateway, 73 서비스 이용 불가, 77 요청 과다 |
재시도 불가는 같은 요청을 몇 번 더 보내도 같은 결과가 나온다. 설정이나 잡 이름을 고쳐야 풀린다. 이 경계를 그대로 스크립트에 옮긴다.
MAX_RETRY=3
RETRY_DELAY=30
ATTEMPT=1
while [ $ATTEMPT -le $MAX_RETRY ]; do
RESULT=$(cde_run job run --name "${JOB_NAME}" --config-json "${CONF_JSON}" --wait)
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
exit 0
elif [ $EXIT_CODE -ge 70 ] && [ $ATTEMPT -lt $MAX_RETRY ]; then
sleep $RETRY_DELAY
ATTEMPT=$((ATTEMPT + 1))
else
exit $EXIT_CODE
fi
done
exit $EXIT_CODE
$? 는 직전 한 명령 의 종료 코드다. 사이에 echo 하나만 끼어들어도 그 echo 의 코드로 덮인다. 받아 둘 것이라면 명령 바로 다음 줄에서 변수에 담는다.
ExecuteStreamCommand 는 표준 출력을 FlowFile 로, 종료 코드를 관계 분기로 바꾼다. 관계 이름은 original · output stream · nonzero status 이며, 0 이 아닌 코드로 끝나면 nonzero status 로 나가면서 penalize 된다.[1:2] 스크립트가 stdout 으로 JSON 한 줄을 뱉고 원래 코드로 종료하면 성공·실패 분기와 상세 사유를 함께 넘길 수 있다.
case $EXIT_CODE in
0) printf '{"code":0,"message":"성공","result":"%s"}\n' "${RESULT}" ;;
4) printf '{"code":4,"message":"인증 오류"}\n' ;;
6) printf '{"code":6,"message":"Job Not Found"}\n' ;;
*) printf '{"code":%d,"message":"알 수 없는 오류"}\n' "${EXIT_CODE}" ;;
esac
exit $EXIT_CODE
받는 쪽은 output stream 뒤에 EvaluateJsonPath($.code · $.message)를 두고, nonzero status 는 실패 처리 흐름으로 보낸다. 출력을 FlowFile 내용 대신 속성으로 받고 싶으면 Output Destination Attribute 를 지정한다. 다만 Max Attribute Length 기본값이 256 이라 조금만 길어도 잘리므로 함께 올린다.[1:3]
ExecuteStreamCommand 는 FlowFile 한 건마다 한 번 실행된다. 큐에 33건이 있으면 같은 잡이 33번 제출된다. 적재는 건별로 하되 잡 트리거는 한 번이어야 하는 흐름이라면 앞단에서 묶어야 한다 — NiFi failure Retry 표준값과 ExecuteStreamCommand 운영 주의점 에 정리돼 있다.
ExecuteStreamCommand 2.12.0 — Command Arguments Strategy · command.argument.<순번> 동적 속성 · 관계 original / output stream / nonzero status · Max Attribute Length 기본값 256. https://nifi.apache.org/components/org.apache.nifi.processors.standard.ExecuteStreamCommand/ — 2026-09-21 확인 ↩︎ ↩︎ ↩︎ ↩︎
Expression Language Guide — base64Encode · prepend · append · toDate · format 의 선택 타임존 인자 · Embedded Expressions. https://nifi.apache.org/docs/nifi-docs/html/expression-language-guide.html — 2026-09-21 확인 ↩︎ ↩︎