Job 관련 CRD를 계속 개발하다 보면 어느 순간 “왜 이게 이렇게 안 맞지?“라는 느낌을 받게 된다. 도구가 잘못된 건지, 설계가 잘못된 건지, 아니면 제가 뭔가 놓치고 있는 건지. 이 글은 그 느낌의 근원을 추적하다가 도달한 하나의 결론에 대한 이야기입니다.
회사에서 Job 관련 워크로드를 만들면서 부딪힌 마찰들이 모두 같은 뿌리에서 나왔다는 사실을 뒤늦게 깨닫게 되었습니다.
Kubernetes의 본질
Kubernetes를 사용하다 보면 “선언적"이라는 단어를 자주 듣는다. 하지만 선언적이라는 말보다 더 정확한 표현은 level-triggered reconciliation라고 봅니다.
여기서 짚어두자면. level-triggered는 현재 상태를 보고 동작하는 방식이고, edge-triggered는 상태 변화 이벤트를 받고 동작하는 방식이다. 이 구분이 이후 논의의 뼈대가 됩니다.
컨트롤러의 reconcile loop는 단순하게 작동합니다.
현재 상태(Current State) ≠ 원하는 상태(Desired State)
→ 수렴(Reconcile)
→ 다시 확인
→ 반복
코드로 보면 대강 이러합니다.
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
// 현재 상태를 읽습니다
var obj MyResource
if err := r.Get(ctx, req.NamespacedName, &obj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// desired state와 비교하고 수렴시킵니다
if err := r.reconcileDesiredState(ctx, &obj); err != nil {
// 실패하면 다시 시도합니다. 이게 당연한 패턴힙니다
return ctrl.Result{RequeueAfter: 10 * time.Second}, err
}
return ctrl.Result{}, nil
}
이 루프는 멈추지 않고 무한히 반복됩니다. Kubernetes의 본질은 desired state로의 지속적 수렴이다. 이 글에서는 이 성질을 편의상 “항상성(homeostasis)“이라고 부르겠습다. 단, 생물학적 항상성처럼 고정된 평형으로 돌아간다는 뜻은 아닙니다.
출처: Gemini 생성 이미지
Deployment는 rolling update로 desired state 자체가 움직이고, reconciler는 그 움직이는 목표를 끊임없이 따라갑니다. 항상성이라는 단어에서 느껴지는 움직이지 않는다는 느낌이 아니라, 목표가 어디로 움직이든 그 자리로 끌고 가는 힘이 본질입니다.
Deployment는 Pod N개가 항상 살아있어야 한다는 상태를 유지하고, StatefulSet은 순서와 identity를 가진 Pod들의 상태를 유지합니다. 따라서 같은 상태에서 reconcile을 100번 실행해도 결과가 같으로 멱등합니다.
Job은 다릅니다
Job spec을 보겠습니다.
apiVersion: batch/v1
kind: Job
metadata:
name: batch-processor
spec:
completions: 5 # 5번 성공적으로 완료되어야 합니다
parallelism: 2 # 동시에 2개씩 실행합니다
template:
spec:
containers:
- name: worker
image: my-worker:latest
restartPolicy: Never
“5번 성공적으로 완료된 상태"가 desired state이고, 달성되면 오브젝트는 종료됩니다.
여기서 이질감의 근원이 드러납니다.
종료는 유지되는 상태가 아니라 결과입니다.
Deployment와 Job의 reconciler 생명주기를 비교해보면 차이가 명확합니다.
Complete=True도 desired state 아닌가요?
사용자가 ‘5번 완료된 상태’를 선언했고, 그 상태에 도달하면 reconciler는 할 일이 없습니다. 이것도 일관된 선언적 모델 아닌가요? 종료 조건이 있다는 것만으로 Kubernetes 모델에 맞지 않는다고 하는 건 지나친 것 아닌가요?
일견 맞는 말입니다. 하지만 핵심을 비껴 갑니다. 진짜 균열은 종료 조건이 있다는 사실이 아니라, 완료라는 사실이 오브젝트 내부에만 기록된다는 점에 있습니다.
Deployment는 desired state(replicas: 3)와 실행 이력(현재 살아있는 Pod 3개)이 같은 평면에서 일치합니다. 그래서 삭제 후 재생성해도 같은 결과가 나옵니다. 반면 Job은 desired state(completions: 5)와 실행 이력(이미 5번 성공했음)이 한 오브젝트 안에 섞여 있습니다. 외부에서 보면 desired state만 보이고 실행 이력은 그 오브젝트와 함께 사라지는 것입니다.
쉽게 말해, Deployment의 desired state는 클러스터가 유지해야할 할 상태에 대한 선언이고, Job의 desired state는 작업 이력에 대한 선언입니다. 후자를 level-triggered로 표현하려면 “이미 수행한 작업"이라는 외부 진실의 원천이 필요한데, Kubernetes는 그것을 어디에도 두지 않는다. 그래서 다음과 같은 균열이 따라옵니다.
균열이 드러나는 지점들
이 근원적 불일치는 우연한 버그로 나타나지 않습니다. 구조적 필연으로 드러납니다. 다음을 보면 알 수 있습니다.
출처: Gemini 생성 이미지
삭제 후 재생성하면 작업이 다시 실행되어 멱등하지 하지 않다
# Deployment: 삭제 후 재생성 → "Pod N개" 결과 동일, 멱등
kubectl delete deployment my-app && kubectl apply -f my-app.yaml
# Job: 삭제 후 재생성 → 작업이 다시 실행됨, 부수효과 있으면 치명적
kubectl delete job batch-processor && kubectl apply -f batch-processor.yaml
예를 들면, ArgoCD가 자동으로 sync하다가 Job 오브젝트가 사라진 걸 감지하면 다시 만듭니다. 결과적으로 작업이 두 번 돕니다. 결제, 데이터 적재, 외부 API 호출이 들어 있으면 그대로 사고입니다. Deployment의 멱등성을 기대하고 만든 자동화 도구가 Job 위에서 멱등하지 않은 동작을 하는 것입니다.
한 가지 오해를 짚고 가겠습니다. Kubernetes Job이 exactly-once를 보장한다고 알고 있는 경우가 있는데, 사실이 아닙니다. Pod 실패, controller 재시작, 네트워크 단절 상황에서 동일 작업이 두 번 이상 실행될 수 있다는 것은 공식 문서도 인정합니다. Job은 exactly-once를 약속한 적이 없다. 종료-지향 워크로드처럼 보이지만, 멱등성은 처음부터 사용자 몫이었습니다.
그렇다면 Job은 왜 Kubernetes에 들어왔는가
저는 실용성 때문이라고 봅니다.
Job이 없으면 배치 워크로드는 클러스터 밖에서 돌아야 합니다. 그러면 스케줄링, 리소스 쿼터, RBAC, 네임스페이스 격리 등 Kubernetes 인프라 전체가 그 워크로드에 적용되지 않습니다.
그래서 타협한 것입니다. 철학적 순수성보다 실용성을 택한 것입니다. 이것 자체를 비판하려는 게 아닙니다. 다만 타협은 타협이라는 것은 알아야 합니다. Job은 Kubernetes의 자연스러운 확장이 아니라, 실용성을 위해 밀어 넣은 예외입니다.
직접 만들어보면 압니다
종료 지향 CRD를 직접 개발하면 이 균열이 추상론으로 끝나지 않는다는 것을 알게 됩니다. Kubernetes 확장 도구들은 모두 항상성 오브젝트를 전제로 설계되어 있어서, 종료-지향 워크로드를 CRD로 표현하는 순간 위화감을 느낄 수 있습니다.
출처: Unsplash
위화감은 한 곳에서 나옵니다. 바로 Kubernetes는 과거를 모른다는 것입니다.
reconciler는 현재 상태만 보고 판단합니다. 이 오브젝트가 이전에 실행된 적 있는지, 완료된 적 있는지, 얼마나 실행됐는지 모릅니다. Status도 Spec에 따라 복원된 캐시일 뿐이지 이것을 신뢰할 수 있는 SoT로 볼 수는 없습니다.
항상성 워크로드에서는 이게 오히려 장점이다. 과거가 어떻든 현재를 desired state로 수렴시키면 그만이기 때문입니다. 문제는 종료 지향 워크로드입니다. 이쪽에서는 실행 자체가 Side-effect다. 작업이 두 번 돌면 돌이킬 수 없는 일이 생길 수도 있기 때문입니다.
① 임의 삭제 후 재생성
Multi-staged Job을 운영하다 보면 누군가 실수로 삭제하는 일이 생길 수 있습니다. Manifest는 변하지 않았고 그럴 의도도 없었지만 Job은 다시 돕니다.
# Deployment: 삭제 후 재생성 → Pod N개, 항상 같은 결과
kubectl delete deployment my-app && kubectl apply -f my-app.yaml
# ChainJob: 삭제 후 재생성 → 작업이 다시 실행됨
kubectl delete chainjob my-job && kubectl apply -f my-job.yaml
Deployment는 현재 Pod가 몇 개 떠 있는가가 전부입니다. 삭제 전에 무슨 일이 있었든 상관없습니다. 반면 Multi-staged Job은 다릅니다. 이전에 이미 완료된 작업인지 아닌지가 중요한데 그 사실을 어디에도 보존하지 않습니다.
ArgoCD에는 이 문제를 우회하는 방법들이 있는 것 같슽니다. IgnoreExtraneous, Prune=false, Hook 리소스 분류 같은 것들인데, 이 방법들은 결국 “이 오브젝트는 GitOps의 항상성 모델로 관리하지 않겠다.“는 말입이다. 즉, 이 문제를 해결한 게 아니라, 이 문제에 대해서는 눈을 감겠다는 것입니다.
② 컨트롤러 재시작
컨트롤러가 재시작되면 reconciler는 모든 오브젝트를 처음부터 다시 평가합니다. 항상성 워크로드에서는 reconciler가 몇 번 실행되든 결과가 같으므로 문제가 없습니다.
문제는 종료 지향 워크로드에서 생깁니다. 만약 컨트롤러가 재시작되면 이미 실행 완료된 Job이라도 “이미 실행했다"는 사실이 status에 명시적으로 기록되어 있지 않으면 reconciler는 그것을 알 방법이 없습니다. 재시작 후 실행 조건을 다시 충족한다고 판단하고 작업을 다시 트리거할 수 있습니다.
이것을 막으려면 reconciler가 실행 이력을 직접 관리해야 합니다.
func (r *ChainJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var cj aipubv1alpha1.ChainJob
if err := r.Get(ctx, req.NamespacedName, &cj); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// 이미 실행된 적 있으면 다시 시작하지 않는다.
// Kubernetes는 이걸 보장하지 않는다. 직접 만들어야 한다.
if isAlreadyStarted(&cj) {
return ctrl.Result{}, nil
}
return r.startChainJob(ctx, &cj)
}
reconciler의 모든 진입점에서 이미 실행됐는가를 먼저 확인해야 하고 이런 것을 소홀히 하면 컨트롤러 재시작 같은 평범한 운영 활동이 사고로 이어질 수 있습니다.
③ 관측 자체가 틀린 경우
앞의 두 케이스는 운영적인 문제이지만, 세 번째 문제는 정상 동작 중에도 발생할 수 있습니다.
Kubernetes는 eventual consistency 모델입니다. 즉, 지금 당장은 틀릴 수 있지만 결국에는 맞아 떨어지는 모델로 설계되어 있습니다. reconciler가 보는 상태는 API 서버의 현재가 아니라 informer 캐시의 스냅샷이며 그 캐시는 항상 최신이 아닐 수 있습니다.
종료 지향 워크로드에서는 이것이 문제가 될 수 있습니다.
status에 완료를 기록하는 API 호출이 성공했지만 캐시에 반영되기 전 찰나에 reconciler가 다시 진입하면 미완료로 판단하고 작업을 다시 트리거할 수 있습니다. 하위 Job이 생성되었지만 Informer 캐시에 올라오기 전이라면, reconciler는 “Job이 없다"고 보고 다시 생성을 시도할 수도 있습니다.
CRD 설계의 첫 번째 질문
위의 세 케이스는 모두 한 가지를 말하고 있습니다. 종료 지향 워크로드의 reconciler는 어떤 경로로 진입하든, 어떤 상태를 관찰하든, 중복 실행에 안전해야 한다는 것이다. 멱등하게 설계되어야 한다는 점입니다.
당연한 말 같지만 항상성 워크로드에서는 이게 공짜로 따라옵니다. reconcile를 몇 번 돌려도 결과가 같슽니다. 그러나 종료-지향 워크로드에서는 직접 만들어야 합니다. 이것을 방어하기 위한 로직에 신경을 쓰지 않으면 문제가 생기고 Kubernetes는 그 책임을 설계자에게 넘겼습니다.
따라서 CRD를 설계하기 전에 던져야 할 첫 번째 질문은 이것입니다.
이 CRD가 표현하는 워크로드는 항상성 오브젝트인가, 비항상성 오브젝트인가?
만약 비항상성이라면, 시작하기 전에 다음을 인식해야 합니다.
- 설계 비용이 구조적으로 더 크다
- 멱등성과 dedup을 명시적으로 설계해야 한다 (K8s가 안 해준다)
- Kubernetes 도구를 어디까지 쓰고 어디서부터 자체 처리할지 결정해야 한다
이 판단을 설계 단계에서 내리느냐, 시행착오 이후에 내리느냐가 프로젝트 전체 품질을 가릅니다.
마무리
Job이 나쁜 오브젝트라는 얘기가 아닙니다. Kubernetes가 잘못 설계됐다는 얘기도 아닙니다. 다만, 어떤 시스템이든 철학적 경계가 있고, 그 경계를 알고 설계하는 것과 모르고 부딪히는 것은 결과가 크게 다릅니다. 제가 종료 지향 워크로드를 만들며 배운 교훈입니다.
번외: finalizers
직접 겪은 일은 아니지만, 구조적으로 따라오는 문제 같아서 짚고 넘어가겠습니다.
Finalizer는 “정리 작업이 끝날 때까지 삭제를 지연하라"는 지시입니다. 항상성 오브젝트는 사라지는 것이 예외적인 사건이라 사라지기 전에 뭔가를 정리하는 훅이 자연스럽습니다.
그런데 종료 지향 워크로드에서는 사라지는 것이 정상적인 라이프사이클입니다. 작업이 완료되면 오브젝트도 치워지는 것이 자연스러운 흐름인데 finalizer는 그 경로를 방해합니다. 외부 시스템이 응답하지 않거나 컨트롤러가 죽으면 finalizer로 인해 etcd에 누적됩니다. 결국 patch 명령을 내리는 것이 루틴이 됩니다.
kubectl patch job <job-name> -p '{"metadata":{"finalizers":null}}' --type=merge
손으로 일일이 finalizer를 떼어내야 하는 이 운영 패턴 자체가 이 메커니즘이 Kubernetes와 어울리지 않는다는 신호라고 생각합니다.