워크플로 오케스트레이터는 작은 태스크들을 엮어 하나의 계산을 수행하는 시스템이다. 각 태스크의 출력을 다른 태스크의 입력으로 연결하고, 언제 무엇을 실행할지 결정하며, 상태를 저장하면서 실행을 끝까지 몰고 간다. Conductor는 넷플릭스가 만들어 2016년 오픈소스로 공개한 이런 시스템으로, 현재 오픈소스 버전은 Conductor OSS 재단이 관리한다. 재단을 이끄는 Orkes는 넷플릭스에서 Conductor를 개발한 창업자들이 세운 회사이며, 추가 기능을 얹은 호스팅형 제품 Orkes Conductor도 제공한다. 분산 시스템 검증으로 잘 알려진 Jepsen이 이 Orkes Conductor 5.3.2를 비롯해 5.4.0, 5.5.0-rc1/rc2까지 분석한 결과가 이번 보고서의 내용이다.
Conductor에서 워크플로 정의는 이름과 버전으로 식별되는 JSON 구조이고, 이를 실제로 돌린 하나하나가 워크플로 실행이다. 실행은 고유 ID를 갖고, 내부 태스크는 실패 시 여러 번 재시도되며 각 시도마다 고유 execution ID와 재시도 횟수(retryCount)가 붙는다. 구조적으로는 상태를 갖지 않는 Conductor 서버, Postgres·Cassandra·Redis 같은 데이터베이스, 주로 Redis가 맡는 큐, 그리고 표준 워커와 사용자가 SDK로 작성하는 커스텀 워커의 다섯 요소로 나뉜다. 각 구성요소는 독립적으로 확장되고 언제든 죽었다 살아날 수 있다. 다만 5.3.2에서는 DB와 큐가 승인된 쓰기를 모두 보존해야 했고, 5.4.0에서야 메인 decider 큐를 제외한 큐에 유실 가능한 저장소를 쓰는 옵션이 들어왔다.
Conductor가 내세우는 보장은 세 가지다. 최소 한 번 이상의 메시지 전달, 시작된 워크플로는 결국 종료 상태로 수렴한다는 것, 그리고 하나의 태스크 인스턴스는 "보이는" 성공 실행이 최대 하나라는 것이다. 즉 서버가 완료를 기록하기 직전에 DB 연결이 끊기는 식으로 같은 태스크가 여러 번 성공적으로 돌 수는 있지만, 일단 어떤 출력이 보이기 시작하면 그 뒤로는 어떤 조회에서도 다른 출력이 나오지 않아야 한다. Jepsen은 Postgres를 기본 DB, Redis를 큐로 둔 10대 규모 클러스터를 꾸리고, 최대 30초 잠들었다 무작위 출력을 돌려주는 단일 태스크 워크플로를 대량으로 돌리며 프로세스 중지·종료와 네트워크 분단 같은 결함을 주입했다.
동시 실행은 피할 수 없다
가장 눈여겨볼 대목은 결함이 전혀 없는 건강한 클러스터에서도 같은 워크플로 실행의 같은 태스크가 서로 다른 워커에서 동시에 돌아가는 일이 관찰됐다는 점이다. 비동기 네트워크에서 상호 배제가 원리적으로 불가능하기 때문으로, 한 사례에서는 수명이 1밀리초도 안 되는 태스크가 두 워커에서 거의 동시에 시작돼 거의 동시에 끝났다. Conductor에는 lockLeaseTime으로 리스 시간을 정하는 분산 잠금이 있지만 빈도를 줄일 뿐 완전히 막지는 못한다. 기본값 1분, 초당 약 1,100개 워크플로 조건에서 동시 실행은 대략 여섯 시간에 한 번꼴로 나타났고, 리스를 1초로 줄이자 몇 분에 한 번으로 늘었다. 게다가 멈춘 태스크를 다시 큐에 넣는 sweeper에 TOCTOU 버그가 있어, SCHEDULED 상태로 읽은 태스크가 그사이 IN_PROGRESS로 바뀌어 큐에서 빠졌는데도 메시지가 없다며 재투입해 중복 전달을 키웠다. 이 문제(#1)는 5.5.6에서 고쳐졌다. 또 다른 워커가 이미 성공했는데도 어떤 워커가 retryCount=0을 관찰할 수 있다는 점도 지적됐다.
멈춘 워크플로와 엉뚱한 오류
5.3.2는 Postgres와 Redis 조합에서 일부 워크플로가 태스크 실행 전에 멈추는 현상도 자주 보였다. 겉으로는 RUNNING 상태에 특정 워커에서 태스크가 도는 것처럼 보이지만 실제로 그 워커의 execute()는 호출되지 않았고, 응답 타임아웃이 걸려야 재스케줄됐다. 서버가 Redis에서 격리되면 기본 응답 타임아웃인 한 시간만큼 멈추는 식이었다. 결국은 실행되므로 보장을 깨는 것은 아니며, Orkes는 responseTimeoutSeconds를 낮출 수 있다는 이유로 동작을 바꾸지 않았다(#2). 이 밖에 Redis가 죽었을 때 Java 클라이언트의 startWorkflow()가 드물게 null을 반환하는 문제(#3)는 클라이언트 6.0.2에서 완화됐고, 결함 없는 상태에서도 간헐적으로 터지던 "syntax error at or near null" 오류(#4)는 날짜 기준 파티션 테이블의 샤드 맵이 잠시 비는 경쟁 조건 탓으로 밝혀져 5.5.6에서 수정됐다.
실무자가 가져갈 교훈
이 분석의 핵심은 '버그가 몇 개 있었다'가 아니라, 짧은 태스크조차 동시에 두 번 실행될 수 있다는 사실을 설계 전제로 삼아야 한다는 데 있다. 잠금은 보조 수단일 뿐 중복 실행을 없애주지 않으므로, 워커의 비즈니스 로직은 반드시 멱등하게 작성해야 하며 retryCount만 보고 '내가 첫 실행'이라고 판단해서는 안 된다. 또한 서버와 큐가 분단되면 워크플로가 응답 타임아웃만큼 조용히 멈출 수 있으니, 지연에 민감한 작업이라면 responseTimeoutSeconds를 기본 한 시간보다 공격적으로 낮춰 두는 편이 안전하다. 보고된 문제의 상당수가 5.5.6과 Java 클라이언트 6.0.2에서 해결됐다는 점을 감안하면, Postgres·Redis 조합을 운영 중인 팀은 버전 업그레이드를 우선 검토할 만하다. 동시에 이 테스트가 단일 태스크 워크플로라는 단순한 부하에 한정됐다는 한계도 함께 기억해 둘 필요가 있다.