프로시저 프레임워크

프로시저 프레임워크 (PV2)

이 문서는 HBase의 프로시저 프레임워크(Pv2)를 설명해요. Pv2는 분산 상태 전이를 프로세스 장애에도 견디게 만들어 주는 시스템이에요. Region이나 Table 같은 HBase 엔티티에 대한 작업을 상태 머신으로 실행하고 싶을 때 사용해요. 원래 구현은 HBASE-12439를 참고하세요.

출처: 문서

본문

HBASE-12439에서 원래 구현을 볼 수 있어요.

Pv2로 상태 머신을 만들고 실행할 수 있어요. 이것은 Matteo가 HBase의 분산 상태 전이가 프로세스 장애에도 견디도록 만들기 위해 구축했어요. Pv2 이전에는 상태 전이 처리가 코드베이스에 흩어져 있었고 구현도 전이 유형과 컨텍스트에 따라 달랐어요. Pv2는 Apache Accumulo의 FATE에서 영감을 받았어요.

초기 Pv2 측면은 이미 한동안 HBase에 포함되어 배포되고 있지만, 더 복잡한 시나리오를 다루면서 계속 진화하고 있어요. 지금의 것은 강력하지만 동작이 복잡하고 불완전해서 정리와 강화가 필요해요. 이 문서에서는 시스템에 대한 개요를 제공해서 여러분이 활용할 수 있게(그리고 다듬는 데 기여할 수 있게) 하려고 해요.

이 시스템이 어색한 이름 Pv2를 가진 이유는 HBase에 이미 스냅샷에서 사용되는 Procedure 개념(snapshot에서 사용되는 hbase-server의 org.apache.hadoop.hbase.procedure versus hbase-procedure의 org.apache.hadoop.hbase.procedure2)이 있었기 때문이에요. Pv2는 Procedure를 대체합니다.

프로시저

Procedure는 HBase 엔티티에 대한 변환(transform)이에요. HBase 엔티티의 예로는 Region과 Table이 있어요. 프로시저는 ProcedureExecutor 인스턴스에 의해 실행돼요. 프로시저의 현재 상태는 ProcedureStore에 보관돼요.

ProcedureExecutor는 프로시저 내부에서 일어나는 일에 대해 아주 기본적인 관점만 가지고 있어요. 그 관점에서는 프로시저가 제출되고 나면 ProcedureExecutor가 프로시저가 끝날 때까지 계속 *#execute(Object)*를 호출해요. 실패나 재시작의 경우 execute는 여러 번 호출될 수 있으므로, 프로시저 코드는 매번 실행할 때 같은 결과를 내는 멱등(idempotent)이어야 해요. 프로시저 코드는 실패 시 단계를 되돌릴 수 있도록 rollback도 구현할 수 있어요. execute() 호출은 다음 중 하나의 결과를 만들 수 있어요:

  • *execute()*가 반환:
    • null: 완료됐다는 것을 나타내요.
    • this: 할 일이 더 남았다는 뜻이므로, 현재 프로시저 상태를 영속화하고 다시 *execute()*를 호출해요.
    • sub-procedures의 Array: 진행 전에 완료까지 실행해야 하는 프로시저 집합을 나타내요(그 후 프레임워크가 우리 execute를 다시 호출하길 기대해요).
  • *execute()*가 예외를 던짐:
    • suspend: 프로시저의 실행이 중단(suspend)됐고 외부 이벤트에 의해 재개될 수 있음을 나타내요. 프로시저 상태는 영속화돼요.
    • yield: 프로시저가 스케줄러에 다시 추가돼요. 프로시저 상태는 영속화되지 않아요.
    • interrupted: 현재 yield와 같아요.
    • 위 목록에 없는 exception: 프로시저 state가 FAILED로 바뀌어요(그 후 프레임워크가 rollback을 시도할 것으로 기대돼요).

ProcedureExecutor는 프레임워크의 프로시저 상태 개념을 프로시저 자체에 새겨 넣어요. 예를 들어 제출 시 프로시저를 INITIALIZING으로 표시해요. 실행하러 갈 때 상태를 RUNNABLE로 옮겨요. 완료되면 프로시저는 상황에 따라 FAILED 또는 SUCCESS로 표시돼요. 이 글을 쓰는 시점의 모든 상태 목록은 다음과 같아요.

  • INITIALIZING 생성 중인 프로시저, 아직 executor에 추가되지 않음
  • RUNNABLE executor에 추가되어 실행 준비가 된 프로시저.
  • WAITING 하위 프로시저가 완료되기를 기다리는 중
  • WAITING_TIMEOUT 타임아웃이나 외부 이벤트를 기다리는 중
  • ROLLEDBACK 프로시저가 실패해서 롤백됨.
  • SUCCESS 프로시저 실행이 성공적으로 완료됨.
  • FAILED 프로시저 실행이 실패함, 롤백이 필요할 수 있음.

각 실행 후 프로시저 상태는 ProcedureStore에 영속화돼요. 프로시저에 훅이 호출되어 사용자 정의 상태를 보존할 수 있어요. 장애 이후 ProcedureExecutor는 ProcedureStore의 내용을 재생해 크래시 전 상태를 복원해요. 이렇게 해서 프로시저 프레임워크가 프로세스 장애에 견디게 돼요.

구현

구현에서 프로시저는 변환을 더 세분화된 작업으로 나누는 경향이 있고, 이 작업 중 일부는 하위 프로시저로 위임되지만 대부분은 프로시저 내부에서 *단계(step)*로 처리돼요. 각 execute 호출은 단일 단계를 수행하는 데 사용되고, 그다음 프로시저는 프레임워크로 제어를 돌려줘요. 프로시저는 처리 과정에서 자신이 어디에 있는지 자체적으로 추적해요.

실행에서 하위 작업 즉 step을 구성하는 것은 프로시저 작성자가 정하지만, 일반적으로 더 이상 분해할 수 없는 작은 작업 조각이며 처리를 종료 상태로 진행시킨다. 프로시저를 큰 단계 몇 개가 아니라 작은 단계 여러 개로 구성하면 프로시저 프레임워크가 처리 과정에서 어디에 있는지에 대한 통찰을 제공할 수 있어요. 또한 프레임워크가 실행을 더 공정하게 할 수 있게 해 줘요. 위에서 말했듯 각 단계는 (실패/재시작으로) 여러 번 호출될 수 있으므로 단계는 멱등으로 구현해야 해요.

프로시저 자체가 유지하는 상태와 프레임워크의 상태를 혼동하기 쉽지만, 둘을 구분해서 유지하려고 노력하세요.

롤백

롤백은 프로시저 또는 그 하위 프로시저 중 하나가 실패했을 때 호출돼요. 롤백 단계는 execute() 단계 동안 생성된 리소스를 정리해야 해요. 실패와 재시작의 경우 rollback()이 여러 번 호출될 수 있으므로 역시 코드는 멱등이어야 해요.

메트릭

프로시저 제출 시와 완료 시 메트릭을 수집하기 위한 훅이 있어요.

  • updateMetricsOnSubmit()
  • updateMetricsOnFinish()

개별 프로시저는 이 메서드를 재정의해 프로시저별 메트릭을 수집할 수 있어요. 이 메서드의 기본 구현은 ProcedureMetrics 인터페이스를 구현하는 객체를 얻으려 하는데, 이 인터페이스는 다음과 같은 제네릭 메트릭 집합을 캡슐화해요.

  • SubmittedCount (Counter): 한 유형으로 제출된 프로시저 인스턴스의 총 수.
  • Time (Histogram): 프로시저 인스턴스의 런타임 히스토그램.
  • FailedCount (Counter): 실패한 프로시저 인스턴스의 총 수.

개별 프로시저는 이 객체를 구현하고 이 제네릭 메트릭 집합을 정의할 수 있어요.

배깅(Baggage)

프로시저는 짐(baggage)을 실을 수 있어요. 한 예는 프로시저가 마지막으로 도달한 step(이전 섹션 참고)이에요. 프로시저는 현재 어디에 있는지 표시하는 enum을 영속화해요. 다른 예로는 프로시저가 현재 작업 중인 Region이나 Server 이름이 있을 수 있어요. 각 execute 호출 후 Procedure#serializeStateData가 호출돼요. 프로시저는 무엇이든 영속화할 수 있어요.

결과/상태와 쿼리

(Matteo의 ProcedureV2 and Notification Bus 문서에서)

비동기 연산의 경우, 클라이언트가 요청할 때까지 결과를 보관해야 해요. 결과의 "get"을 받으면 레코드 삭제를 예약할 수 있어요. 일부 연산의 경우 결과가 "불필요"할 수 있는데 특히 실패의 경우에 그래요(예: create table이 실패하면 연산 결과를 쿼리하거나 그냥 list table로 생성됐는지 확인할 수 있어요). 그래서 어떤 경우에는 타임아웃 후 삭제를 예약할 수 있어요. 클라이언트 측에서 연산은 "Procedure ID"를 반환하는데, 이 ID로 프로시저가 완료될 때까지 기다리고 결과/예외를 받을 수 있어요.

Admin.doOperation() { longprocId=master.doOperation(); master.waitCompletion(procId); }

연산 수행 중 master가 다운되면 백업 master가 반쯤 진행된 연산을 이어받아 완료해요. 클라이언트는 실패를 알아차리지 못해요.

하위 프로시저

하위 프로시저는 프로시저 인스턴스(부모 프로시저)의 #execute(Object) 메서드가 만들어 반환하는 Procedure 인스턴스예요. 하위 프로시저는 Procedure 타입이므로 자신의 하위 프로시저를 만들 수 있어요. 재귀적이므로 프로시저 스택은 프레임워크가 유지해요. 프레임워크는 프로시저 스택의 모든 하위 프로시저와 그 하위 프로시저들이 성공적으로 끝날 때까지 부모 프로시저가 진행하지 못하게 해요.

ProcedureExecutor

ProcedureExecutor는 ProcedureStore와 ProcedureScheduler를 사용하고 제출된 프로시저를 실행해요. 지원하는 기본 연산 중 일부는:

  • abort(procId): 완료되지 않은 프로시저를 중단
  • submit(Procedure): 실행을 위해 프로시저 제출
  • retrieve: Procedure 인스턴스와 결과를 얻는 get 메서드 목록
  • register/ unregister 리스너: 프로시저 관련 알림 청취용

ProcedureExecutor가 시작되면 이전 실행에서 ProcedureStore에 영속화된 프로시저 인스턴스를 로드해요. 완료되지 않은 모든 프로시저는 마지막 저장 상태에서 재개돼요.

Nonce

RPC와 함께 들어온 nonce를 executor에 제출 시 프로시저에 전달할 수 있어요. 이 nonce는 영속화 시 프로시저와 함께 직렬화돼요. 크래시가 나면 재로드 시 클라이언트가 같은 프로시저를 두 번째로 실행하려 할 경우(거부될 것)를 대비해 nonce를 nonce-to-pid 맵에 다시 넣어요. 기본 Procedure와 nonce가 기본 데이터 멤버인 방법을 참고하세요.

Wait/Wake/Suspend/Yield

'suspend'는 조건이 바뀔 때까지 더 이상 진행할 수 없어서 프로시저 처리를 중단한다는 뜻이에요. 즉 RPC를 보내고 응답을 기다려야 할 때 그래요. 동작 방식은 프로시저가 내부 깊숙한 곳에서 suspend 예외를 현재 처리 단계의 끝으로 GOTO하듯 던지는 거예요. Suspend는 프로시저를 스케줄러에 다시 넣기도 해요. 문제는 suspend 시에도 나가는 길에 약간의 계산을 하기 때문에 종료하는 데 시간이 걸릴 수 있다는 점이에요(WAL에서 상태를 업데이트해야 해요).

RS의 보고를 받으면 RegionTransitionProcedure#reportTransition가 호출돼요. Assign과 Unassign의 경우, RPC를 보낸 서버의 이 이벤트 응답이 중단된 Assign/Unassign을 깨워요.

잠금

프로시저 잠금은 동시성에 관한 것이 아니에요! Table이나 Region 같은 HBase 엔티티에 프로시저가 읽기/쓰기 접근을 하도록 해서, 현재 실행 중인 프로시저가 HBase 엔티티 상태를 수정하는 동안 다른 프로시저가 그것을 배제할 수 있게 하는 것이 목적이에요.

잠금은 선택 사항이고 프로시저 구현자가 정하지만, 엔티티가 프로시저에 의해 운영되고 있다면 모든 변환은 같은 잠금 방식을 사용하는 프로시저를 통해 이뤄져야 해요. 그렇지 않으면 혼란이 생겨요.

두 ProcedureExecutor Worker 스레드가 실제로 같은 프로시저 인스턴스를 동시에 처리할 수도 있어요. 그런 경우 스레드는 하나의 프로시저의 서로 다른 부분을 실행하도록 되어 있어요 — 서로 겹치지 않는 변경들(이게 'suspend'라는 프로시저 프레임워크 개념 주변에서 어색해져요. 아래에서 더 설명).

잠금은 선택적으로 프로시저의 수명 동안 유지될 수 있어요. 예를 들어 Region을 이동한다면 Region이 완료(또는 실패)할 때까지 HBase Region에 배타적 접근을 원할 거예요. 이것은 {@link #holdLock(Object)}와 함께 사용돼요. {@link #holdLock(Object)}가 true를 반환하면 프로시저 executor가 acquireLock()을 한 번 호출하고 그 후 프로시저가 끝날 때까지 {@link #releaseLock(Object)}를 호출하지 않아요(보통은 {@link #execute(Object)}의 각 호출 주변에서 release/acquire를 호출해요).

잠금은 프로시저의 수명 동안 유지될 수도 있어요. 즉 Assign 프로시저가 시작되면 할당 중인 region에 다른 프로시저가 끼어들기를 원하지 않아요. 프로시저 수명 동안 잠금을 유지하는 프로시저는 Procedure#holdLock을 true로 설정해요. AssignProcedure가 그렇게 하고 Split과 Move도 그렇게 해요(Region 이동 중간에 Splitting을 원하지 않잖아요).

잠금은 프로시저의 수명 동안 유지될 수 있어요.

일부 잠금은 계층 구조를 가져요. 예를 들어 region 잠금을 잡으면 이를 포함하는 table과 namespace에 대한 (읽기) 잠금도 가져서 다른 프로시저가 호스팅 table(또는 namespace)에 배타적 잠금을 얻는 것을 막아요.

프로시저 유형

StateMachineProcedure

각 #execute(Object) 메서드 호출을 상태 머신에서 한 상태에서 다른 상태로 전이하는 것으로 생각할 수 있어요. 추상 클래스 StateMachineProcedure는 기본 Procedure 클래스의 래퍼로, 프로시저로 상태 머신을 구현하는 구조를 제공해요. 각 상태 전이 후 현재 상태가 영속화되어, 크래시/재시작의 경우 크래시/재시작 전 프로시저의 이전 상태에서 상태 전이를 재개할 수 있어요. 개별 프로시저는 초기 상태와 종결 상태를 정의해야 하고, 상태 전이를 위한 훅 *executeFromState()*와 *setNextState()*가 제공돼요.

RemoteProcedureDispatcher

새로운 RemoteProcedureDispatcher(+ 하위 클래스 RSProcedureDispatcher) 프리미티브는 Procedure 기반 Assignments의 'remote' 구성 요소를 실행하는 일을 담당해요. 이 디스패처는 '서버'를 알고 있어요. 시간/카운트 기준으로 시간에 따라 할당을 집계해서 RPC당 하나가 아니라 프로시저를 배치로 보낼 수 있어요. 프로시저 상태는 온라인/오프라인 region을 보고하는 RegionServer 하트비트의 응답으로 돌아와요(ZK를 통한 알림은 더 이상 없음). 응답은 'process'하도록 AMv2에 전달돼요. 인메모리 상태와 대조해 확인해요. 불일치가 있으면 RS 쪽에 문제가 있었다고 가정하고 RegionServer를 펜스(fence) 처리해요. 타임아웃은 재시도를 유발해요(아직 구현 안 됨!). 프로시저 머신은 엔티티 잠금과 무엇이 직렬이고 무엇이 동시에 실행될 수 있는지에 대한 지능을 사용해 어떤 Region/Table에서든 한 번에 하나의 연산만 발생하도록 보장해요(잠금은 이전에는 zk 기반이었어요 — table에 대한 znode를 zk에 넣는 방식 — 이제 이 프로젝트의 일부로 프로시저 기반으로 전환됨).

참고 자료

  • Matteo가 프로시저 프레임워크가 어떤 모습일지와 처음에 해결하는 문제에 대해 만든 슬라이드 덱이 Pv2 이슈에 첨부되어 있어요.
  • Matteo의 문제와 Pv2가 로드맵과 함께 어떻게 해결하는지에 대한 좋은 문서(Pv2 JIRA에서). Notification Bus, log splitting의 Pv2 전환 등은 로드맵으로 돌아가서 다뤄야 해요.

더 알아보기 (Learn more)