BlockChain

Hyperledger Besu 란? — Helm 5번으로 깔고 검증자 2개의 함정을 만났다

한 줄로

Besu 는 Helm 차트 5번으로 깔린다. Fabric 이 kubectl hlf 20단계였던 것과 대비된다.

그리고 검증자를 4개 요청해도 합의에 참여하는 건 2개뿐이다. 에러 없이.

Fabric 과 무엇이 다른가

Fabric 글과 나란히 놓으면 차이가 뚜렷하다.

  Fabric Besu
설치 방식 kubectl hlf 20단계 Helm 5번
스크립트 1,178줄 약 300줄
자바 클래스 6개 (849줄) 2개 (493줄)
스크립트 개수 7개 10개
합의 오더러 1개 QBFT

Besu 쪽이 설치는 단순하고 스크립트는 더 많다. Helm 차트가 복잡도를 흡수했고, 대신 배포 후
운영(스마트 컨트랙트 배포·호출·조회·포트포워딩)에 스크립트가 붙었기 때문이다.

설치 순서

1. genesis     ← 제네시스 블록 생성 (Job)
2. bootnode    × N   (peer 발견 담당)
3. validator   × N   (블록 생성)
4. rpc-1       × 1   (읽기 전용)
5. blockscout        (블록 탐색기)
6. NodePort 서비스

각 단계가 helm install ... --wait 이다. 앞이 끝나야 다음이 간다.

genesis 가 먼저인 이유는 명확하다. 모든 노드가 같은 제네시스 블록(0번 블록) 을 공유해야
같은 체인이 된다. 이게 다르면 노드끼리 아예 대화가 안 된다.

제네시스 Job 이 하는 일

제네시스는 Helm 훅 Job 으로 만든다. 끝나면 스스로 사라진다.

"helm.sh/hook-delete-policy": "hook-succeeded"

이 Job 이 검증자 키를 만들고, 각각을 쿠버네티스 Secret 으로 저장한 뒤,
노드들이 서로를 찾을 수 있도록 static-nodes.json 에 접속 주소를 적어 ConfigMap 으로 발행한다.

합의 알고리즘은 QBFT 다. 지분 증명이나 작업 증명이 아니라, 미리 정해진 검증자들이
투표로 블록을 확정
하는 방식이다. 허가형 체인(참여자가 정해진 체인)에 맞는 선택이다.

“unlock-account” 는 키 생성이 아니다

스크립트 이름이 01-install-besu-with-unlock-account-by-agency.sh 다. 계정을 만드는 것처럼
읽히는데, 실제로는 미리 만들어둔 계정 풀에서 하나를 꺼내 배정한다.

unlock-accounts.json
  UNDESIGNED1  →  {회사명}   ← 배정되면 키 이름이 바뀐다
  UNDESIGNED2
  ...

가장 낮은 번호의 미배정 계정을 회사에 배정하고, 원래 키는 지운다. 그 계정은 제네시스에서
미리 잔액을 받아둔 상태라 바로 스마트 컨트랙트를 배포할 수 있다.

폐쇄망에서 계정 생성·펀딩을 매번 하기 어려우니 미리 만들어 쟁여두는 전략이다.

다만 이 파일을 고치는 과정에 잠금이 없다. 두 회사를 동시에 만들면 같은 계정을 배정받을 수
있다.
그리고 회사를 지워도 계정은 반환되지 않는다 — 풀이 단방향으로 줄어든다.

여기서 걸린 것 — 검증자 4개를 요청해도 2개만 일한다

이게 이번 조사에서 가장 중요한 발견이다.

API 는 검증자를 1~4개 받는다.

@Min(value = 1, message = "Validator 개수는 최소 1개 이상이어야 합니다.")
@Max(value = 4, message = "Validator 개수는 최대 4개까지 가능합니다.")
private Integer validatorNodeCount;

그런데 제네시스 설정 파일에는 이렇게 박혀 있다.

nodes:
  count: 2

하드코딩이다. 그리고 설치 스크립트가 이 값을 덮어쓰는 곳이 한 군데도 없다.
(grep 으로 확인했다 — nodes.count--set 하는 줄이 없다.)

제네시스는 이 숫자만큼만 검증자 키를 만든다. 그러면 3·4번 검증자는 어떻게 되나.

뜨긴 뜬다. 노드 차트에 “키가 없으면 새로 만든다” 는 훅이 있어서, 검증자 자격이 아닌
일반 멤버 신원을 스스로 만들어 붙는다.

결과는 이렇다.

요청 실제
검증자 4개 파드 4개 · 합의 참여 2개
화면 표시 “검증자 4”
에러 없음

파드는 4개가 정상으로 보이고, 블록도 잘 만들어지고, 아무도 실패를 알려주지 않는다.
하지만 장애 내성은 2개짜리 네트워크 수준이다.

QBFT 에서 검증자 2개는 특히 나쁘다. 비잔틴 내성 공식상 하나만 죽어도 블록 생성이 멈춘다.
4개인 줄 알고 운영하다가 한 대 내렸을 때 체인이 서는 상황이 나올 수 있다.

프로젝트 문서가 “검증자 2개 이상” 을 권장하고 최대 4개를 열어둔 걸 보면, 4개를 쓸 의도는
분명히 있었다.
제네시스 값만 따라가지 못한 것으로 보인다.

부트노드에 RPC 가 열려 있다

RPC(외부에서 체인에 명령을 보내는 통로) 설정이 부트노드에만 붙는다.

--rpc-http-api=ETH,EVM,NET,WEB3,PERSONAL
--host-allowlist=*
--rpc-http-cors-origins=*

세 가지가 겹쳐 있다. PERSONAL 은 계정 관련 API 이고, * 두 개는 어느 호스트에서든,
어느 웹페이지에서든
호출을 허용한다는 뜻이다.

게다가 이 노드는 아까 배정받은 잔액 있는 계정을 unlock 한 상태로 들고 있다.

폐쇄망이라는 전제 위에서 내린 선택이겠지만, 망 안으로 한 발만 들어오면 그대로 열려 있는 문이다.
rpc-1 이라는 읽기 전용 노드를 따로 두면서 정작 쓰기 API 를 부트노드에 열어둔 것도 아쉽다.

포트포워딩이 백그라운드로 떠 있다

Blockscout(블록 탐색기)에 붙는 방식이 특이하다. NodePort 를 열어두고도 포트포워딩 프로세스
따로 띄운다.

nohup kubectl port-forward ... &

이게 떠 있는 채로 계속 산다. 관리는 PID 를 저장하는 게 아니라 ps 로 찾아서 죽이거나,
포트를 잡고 있는 프로세스를 통째로 죽이는 방식이다.

서버가 재시작되면 이 프로세스는 사라진다. 그런데 되살리는 장치가 없다. DB 에는
blockscoutConnected = true 가 남아 있으니, 기록과 실제가 어긋난다.

블록 수는 3분마다 따로 센다

체인이 몇 블록까지 갔는지는 스케줄러가 주기적으로 갱신한다.

@Scheduled(fixedDelayString = "${app.besu.block-count-sync.interval-ms:180000}")

3분마다 eth_blockNumber 를 호출해 값이 바뀌었을 때만 DB 에 쓴다.

여기서 재밌는 건 이 호출이 SSH 를 안 탄다는 점이다. 다른 작업은 전부 SSH 로 원격 스크립트를
돌리는데, 이것만 http://127.0.0.1:{포트} 로 직접 때린다.

WAS 와 클러스터가 같은 서버에 있다는 전제다. 실제로 그렇게 운영 중이니 지금은 맞다.
다만 WAS 를 다른 서버로 옮기거나 컨테이너에 넣는 순간, 이 기능만 조용히 멈춘다.

겪은 이슈 ① — 삭제가 영원히 안 끝났다

네트워크를 지우는데 스크립트가 멈춰버리는 일이 있었다.

fix(besu): uninstall 시 helm --wait 무한 대기 방지   (2026-04-20)

helm uninstall에 --wait 사용 시 PV finalizer가 남아있으면 Helm이
리소스 종료를 기다리며 무한 대기 → 스크립트 중단 → WAS가
DELETE_FAILED로 전환되는 문제.

교착이 어떻게 생겼는지가 흥미롭다.

finalizer 는 “이 리소스를 지우기 전에 할 일이 남았다” 는 표시다. 쿠버네티스는 이게
붙어 있으면 삭제를 보류한다. 볼륨(PV)에 흔히 붙는다.

그리고 helm uninstall --wait 는 “리소스가 다 사라질 때까지 기다린다” 는 옵션이다.

helm  : finalizer 가 풀려서 리소스가 사라지길 기다림
쿠버  : finalizer 를 풀어줄 누군가를 기다림
       ↓
      아무도 안 움직임

둘 다 상대가 먼저 움직이길 기다린다. 그런데 finalizer 를 강제로 걷어내는 코드는
스크립트의 뒷부분에 있었다. 그 앞에서 helm 이 멈춰 있으니 영영 실행되지 않는다.

해법이 재밌다. --wait 를 뺐다.

안전하게 기다리라고 붙인 옵션이 오히려 교착을 만든 경우다. 커밋 메시지가 근거를 명확히
적어뒀다 — “스크립트 자체가 이후에 finalizer 를 강제 정리하므로 helm 이 별도로 대기할
필요가 없다.”

기다림을 두 곳에서 하면 서로 막을 수 있다. 누가 기다릴지 하나로 정해야 한다.
여기서는 스크립트가 그 역할을 맡기로 하고, helm 에게서 그 책임을 뗐다.

증상이 사용자에게 어떻게 보였는지도 적혀 있다. 스크립트가 안 끝나니 WAS 는 실패로 판단하고
DELETE_FAILED 로 바꾼다. 실제로는 삭제가 진행 중이었는데도.

겪은 이슈 ② — 없는 이미지를 나중에 발견했다

폐쇄망 특유의 문제도 있었다.

feat(besu): 네트워크 생성 전 Private Registry Helm 이미지 태그 검증   (2026-04-20)

레지스트리에 이미지가 없으면 어떻게 되나. 설치는 시작되고, 한참 뒤에 파드가 못 뜬다.

helm install  → 성공 (리소스는 만들어짐)
   ↓
파드 생성 시도 → 이미지 pull 실패
   ↓
ImagePullBackOff → 10분 폴링 끝에 ERROR

원인(이미지 없음)과 증상(네트워크 생성 실패)이 10분 떨어져 있다. 그 사이에 볼륨과
네임스페이스까지 만들어져서 정리할 것도 늘어난다.

그래서 설치 전에 레지스트리를 먼저 확인하도록 바꿨다. 차트가 쓸 이미지 태그가
사설 레지스트리에 실제로 있는지 조회하고, 없으면 시작조차 하지 않는다.

registry:2 편에서 본
containerd 매핑 검증과 같은 발상이다. 폐쇄망에서는 “이미지가 있다” 는 전제가
자주 깨지므로, 그걸 앞에서 확인하는 습관이 붙은 것으로 보인다.

실패를 앞으로 당기면 원인과 증상이 가까워진다.

Fabric 과 같은 함정도 있다

앞 글에서 본 것들이 여기도 그대로 있다.

  • 성공 알림이 설치 시작 전에 나간다. 비동기 큐에 넣자마자 발송하고, 실패 시 알림은 없다
  • 삭제 스크립트가 || true 로 덮여 아무것도 못 지워도 성공으로 보고된다
  • 삭제가 회사 단위다. besu-* 네임스페이스를 전부 지운다. 회사당 네트워크 1개 규칙이
    지켜지는 동안만 안전하다
  • 재시도가 없다. SSH 가 한 번 끊기면 그대로 ERROR

두 네트워크가 같은 뼈대 위에 올라가 있다는 뜻이다. 그래서 결함도 같은 모양으로 반복된다.

정리

  • Besu 는 Helm 5번으로 끝난다. Fabric 의 20단계와 대비되고, 대신 운영 스크립트가 많다
  • 제네시스가 먼저 — 모든 노드가 같은 0번 블록을 공유해야 같은 체인이 된다
  • unlock-account 는 키 생성이 아니라 미리 만들어둔 계정 풀에서 배정하는 것.
    잠금이 없고 반환도 안 된다
  • ⚠️ 검증자를 4개 요청해도 합의는 2개로 돈다. 제네시스의 count: 2 가 하드코딩이고
    스크립트가 덮어쓰지 않는다. 에러도 안 난다
  • ⚠️ 부트노드에 PERSONAL API 와 * 허용이 열려 있고, 그 노드가 잔액 있는 계정을 물고 있다
  • ⚠️ 포트포워딩이 재시작되면 안 돌아온다 — DB 기록과 실제가 어긋난다
  • 블록 수 동기화만 SSH 없이 127.0.0.1 로 직접 호출한다 — WAS 이전 시 조용히 멈춘다
  • ⚠️ 삭제가 교착에 빠진 적이 있다. helm --wait 와 PV finalizer 가 서로를 기다렸다.
    해법은 --wait빼는 것 — 기다림을 두 곳에서 하면 서로 막는다
  • ⚠️ 이미지가 없으면 10분 뒤에야 실패했다. 그래서 설치 전에 레지스트리를 먼저 확인하도록
    바꿨다. 실패를 앞으로 당기면 원인과 증상이 가까워진다

같이 읽을 글:
Fabric 네트워크는 어떻게 생기나 ·
회사마다 클러스터 하나씩 띄운다 ·
포트 할당 함수를 네 번 고쳤다