Back

버전 하나 올리려다 두 번 멈춰 선 클러스터

테스트 클러스터의 보안 패치 작업 중, 마스터 노드 앞단의 HAProxy를 2.2.5에서 최신 LTS인 2.6으로 올리려 했다. 이 HAProxy는 평범한 Deployment가 아니라 여러 마스터 노드 각각에서 뜨는 정적 Pod였다. 호스트 IP에서 직접 포트를 여는 구조라 노드들이 서로 독립적으로 동작한다. 그래서 한 대씩 순차로 갈아끼우면 나머지는 살아있으니 무중단이라고 판단했다.

버전 선택은 고민이 좀 있었다. 현재 2.2 라인은 이미 EOL 상태였고, 같은 라인 마지막 패치(2.2.34)로 가는 건 “끝난 라인 안에서의 마이크로 패치”라 보안 가치가 작아 보였다. 활성 LTS인 2.6으로 진입하면 향후 몇 년치 패치를 받으니 그게 정공법이라고 봤다. 설정 파일 호환성을 점검하니 깨질 만한 부분이 없었고, HA 구조라 순차 적용하면 무중단이라 “위험 낮음”으로 판정했다.

그런데 점검에서 한 가지를 빠뜨렸다. 컨테이너 이미지가 어떤 유저로 실행되는가. 이 누락이 기동 실패와 롤백 사고로 두 번 번졌다. 다만 그 사건들은 표면이고, 멈추는 게 옳았던 진짜 이유는 그보다 한 겹 아래에 있었다. 그 얘기는 마지막에 한다.

1차 시도: non-root가 80/443을 못 연다

첫 노드의 manifest에서 image 한 줄을 2.6.29로 바꿨다. Pod이 재기동되는데, kubectlconnection refused로 멈췄다. 방금 갈아끼운 그 한 대가 하필 클러스터 접근의 유일한 통로여서, 그게 안 뜨니 진단 도구까지 같이 막힌 것이다.

kubectl을 포기하고 노드에 SSH로 직접 들어가 컨테이너 상태를 봤다. 재시작 5회, CrashLoopBackOff. 로그에 원인이 박혀 있었다. cannot bind socket (Permission denied) for [0.0.0.0:80].

알고 보니 HAProxy 공식 이미지는 2.4부터 root가 아닌 일반 유저로 실행되도록 바뀌어 있었다. 기존 2.2는 root라서 80/443 같은 낮은 번호 포트를 그냥 열 수 있었는데, 2.6은 일반 유저라 권한이 없어 포트를 못 열고 죽은 것이다. 설정 호환성만 들여다봤지, 막상 “누가 실행하는가”가 바뀐 건 보지 못했다.

즉시 롤백했다. image를 2.2.5로 되돌리니 그 노드가 정상 기동하고 kubectl도 복구됐다. 1차 위기 종료.

2차 시도: capability를 줬는데도 죽는다

원인이 분명하니 해법도 교과서적으로 보였다. 일반 유저가 낮은 번호 포트를 열려면 그 권한(NET_BIND_SERVICE)을 따로 부여하면 된다. 같은 클러스터의 다른 컴포넌트들이 이미 똑같이 80/443을 쓰고 있어서, 이 정도면 되겠지 싶었다. manifest에 권한 설정을 넣고 다시 적용했다. 또 connection refused, 또 죽어 있었다. 컨테이너가 너무 빨리 죽어 ID도 못 잡고 노드에 남은 로그 파일을 직접 뒤졌더니, 1차와 똑같은 포트 권한 에러였다.

권한을 분명히 줬는데도 안 먹혔다. 같은 설정이 다른 곳에선 되는데 여기선 왜 안 되는지 그 자리에서 확정하지 못했다. 호스트 커널 설정이나 컨테이너 런타임 정책 같은, 노드 자체를 건드려야 확인되는 영역이 의심됐다. 그리고 노드를 건드리는 건 버전을 올리는 작업과는 전혀 다른 무게의 일이다.

롤백의 함정: image만 되돌리면 안 된다

다시 롤백해야 했다. 1차 때와 똑같이 sed로 image 줄만 2.2.5로 되돌렸다.

sudo sed -i 's|docker.io/haproxy:2.6.29|docker.io/haproxy:2.2.5|' \
  /etc/kubernetes/manifests/load-balancer.yaml

그런데 이번엔 2.2.5도 죽었다. 1차 때는 같은 명령으로 멀쩡히 복구됐던 버전인데.

이유는 이랬다. 2차 시도에서 나는 두 가지(이미지, 권한 설정)를 바꿨는데, 롤백은 image 한 줄만 되돌렸다. 권한 설정은 manifest에 그대로 남아, 원래 root로 돌던 2.2.5마저 일반 유저로 강제 실행돼 똑같이 포트를 못 연 것이다. 절반만 롤백된 설정이 멀쩡하던 버전까지 끌고 들어갔다. 결국 사고 전에 백업해둔 원본 manifest를 통째로 덮어쓰고서야 정상으로 돌아왔다. 바꾼 것이 둘이면 되돌릴 것도 둘이다.

후퇴선: EOL 라인이어도 마지막 패치는 의미가 있다

위기를 두 번 겪고 나니 판단이 섰다. 2.6 점프는 권한이 왜 안 먹히는지가 미해결이고, 그걸 파려면 버전 작업과는 별개인 노드 자체를 건드려야 한다. 메이저 점프는 보류하고, 처음에 “보안 가치가 작다”며 제쳐뒀던 같은 2.2 라인 마지막 패치(2.2.34) 를 다시 봤다.

다시 보니 첫 인상이 틀렸다. 2.2.5와 2.2.34 사이에 쌓인 보안 패치가 생각보다 많았고, 우리 환경에 영향이 작은 것들을 빼도 패치할 이유는 분명했다. “끝난 라인이니 가치 0”이라던 판단이 과했던 것이다. 결정적으로 2.2.34는 여전히 root로 실행되니, 이번 사고의 원인이었던 권한 문제가 아예 안 생긴다. 메이저 점프의 위험을 피하면서 누적 패치만 흡수하는 후퇴선이었다.

image 한 줄만 2.2.34로 바꿔 노드를 하나씩 순차 적용. 전부 재시작 0회로 정상 기동했다. 향후 클러스터 재설치 시 되돌아가지 않도록 버전 변수 값도 같이 갱신했다.

더 파보는 게 맞았을까

작업을 마치고 스스로에게 물었다. 포트 권한이 왜 안 먹혔는지 그 자리에서 끝까지 파는 게 맞지 않았나. 보류는 도망친 게 아닌가.

아니었다. 눈에 띄는 사건은 “유저 권한 때문에 포트를 못 열었다”, “롤백이 멀쩡한 버전까지 죽였다”였지만, 그 둘을 진짜 위기로 키운 건 한 겹 아래에 있었다. 클러스터에 접근하는 유일한 통로(kubectl)가, 지금 내가 재기동시키는 그 한 대를 거치고 있었다는 점이다. 진단할 대상과 진단에 쓸 도구가 같은 곳에 묶여 있으면, 원인을 파는 내내 통로가 또 끊길 위험을 안고 가야 한다. 그래서 멈춘 게 맞았다.

덕분에 이 작업이 남긴 건 패치 하나가 아니라 순서가 있는 숙제였다. 먼저 통로를 여러 대로 분산시켜 한 대가 죽어도 클러스터에 닿게 만들고, 그게 풀린 다음에야 노드 설정을 점검하고 2.6을 다시 시도하는 것. 메이저 점프를 못 한 건 실패처럼 보이지만, 실제로 건진 건 버전 지식이 아니라 HAProxy와 무관한 더 근본적인 약점이었다. 끝까지 밀어붙이는 대신 그걸 별개의 숙제로 떼어둔 것. 그게 이번에 제일 잘한 판단이었다.