보안관제자동화 에이전트 회고 1주차 - k3s + OTel 스택으로 개발계 구성하기
목차
개발계로 쓸 노드는 하나였고, 그 위에 이미 다른 워크로드(추론 서버)가 떠서 리소스를 쓰고 있었다. 이 노드는 사내망 안에 있었고, 이 인프라를 맡아서 관리할 별도 인력은 없었다. 기능을 만드는 사람이 곧 이 인프라를 세팅하고 유지하는 사람이었다. 아래 선택들은 전부 이 네 가지 조건(단일 노드, 리소스 공유, 사내망, 전담 인력 부재) 중 하나 이상에서 갈렸다.
인바운드는 막혀 있었지만 완전한 폐쇄망은 아니었다. 외부 LLM 호출과 GitHub Actions 연동이 필요해서, 이 두 목적을 위한 아웃바운드(egress)는 승인을 받아 열어뒀다. 밖에서 이 노드로 들어오는 경로는 없지만, 이 노드가 밖으로 나가는 경로는 필요한 목적에 한해 허용돼 있었다. 이 승인 덕분에 이미지를 tar로 말아서 USB로 들고 들어가는 완전 격리망 방식의 번거로움을 피할 수 있었다. self-hosted 러너가 GitHub와 직접 통신하고, 앱이 외부 LLM API를 직접 호출하는 구조 자체가 이 egress 없이는 성립하지 않는다.
왜 k3s인가
전담 인프라 인력 없이 기능 개발자가 직접 클러스터를 세팅하고 유지해야 하는 조건이면, 설치 후에 손댈 컴포넌트 수 자체를 줄이는 게 맞다. 인그레스·로드밸런서·스토리지 프로비저너를 각각 따로 골라서 설치하고 그 조합이 서로 맞물리는지 검증하는 작업은, 그 일만 전담하는 사람이 있을 때는 합리적이지만 겸임하는 사람에게는 유지보수 부담을 그대로 전가한다.
k3s는 서버를 띄우는 순간 Traefik이 인그레스 컨트롤러로 기본 배포된다. 공식 문서는 이걸 이렇게 적는다.
Traefik is deployed by default when starting the server.
로드밸런서도 마찬가지다. ServiceLB(옛 Klipper LoadBalancer)가 호스트 포트를 활용해 LoadBalancer 타입 서비스를 클라우드 제공자 없이도 쓸 수 있게 해준다.
By default, K3s provides a load balancer known as ServiceLB (formerly Klipper LoadBalancer) that uses available host ports.
인그레스와 로드밸런서, 여기에 로컬 스토리지 프로비저너까지 세 개를 따로 골라서 서로 맞물리는지 검증하는 작업을 안 해도 된다는 뜻이다. 풀 쿠버네티스였다면 이 조합을 직접 구성하고, 버전이 올라갈 때마다 세 컴포넌트 각각의 호환성을 따로 챙겨야 했을 것이다. 단일 노드라 애초에 멀티노드 스케줄링이나 고가용성이 필요 없었던 것도 있지만, 결정적인 이유는 “이 인프라를 계속 돌볼 사람이 기능 개발도 같이 한다”는 조건이었다. 유지보수 대상을 하나(k3s 자체 업그레이드)로 좁히는 것과 셋(인그레스·로드밸런서·스토리지 각각의 업그레이드 주기)으로 유지하는 것의 차이는, 그 유지보수를 전담할 사람이 없을 때 훨씬 크게 벌어진다.
왜 클라우드 레지스트리 대신 로컬 레지스트리인가
빌드도 배포도 같은 노드 안에서 끝난다. 러너가 이미지를 빌드해서 같은 노드의 로컬 레지스트리로 push하고, k3s가 거기서 insecure pull로 받아 간다. 클라우드 레지스트리를 썼다면 사내망 안에서 끝날 수 있는 작업에 외부 왕복을 하나 더 넣는 셈이다. 전제도 있다. 이미지를 다른 노드에서도 당겨써야 하는 상황이 오면 로컬 레지스트리는 바로 한계에 부딪힌다. 지금은 노드가 하나뿐이라 그 한계가 아직 문제가 아니었을 뿐이다.
왜 GitHub 호스팅 러너가 아니라 self-hosted인가
배포 대상이 사내망 안의 특정 노드다. GitHub 호스팅 러너는 매번 새 클라우드 VM에서 뜨기 때문에, 애초에 이 사내망 안쪽에 닿을 방법이 없다. 이 노드에 직접 접근 가능한 러너가 그 노드 위에서 도는 것 말고는 선택지가 없었다.
self-hosted 러너를 그 노드에 직접 설치하면 이 문제가 풀린다. GitHub의 공식 문서는 self-hosted 러너의 네트워크 요구사항을 이렇게 못박는다.
The host machine must be able to make outbound HTTPS connections over port 443.
인바운드 포트를 여는 게 아니라, 러너가 GitHub 쪽으로 나가는 연결만 유지하면 된다는 뜻이다. 반대 방향(GitHub나 외부에서 이 노드로 들어오는) 경로는 애초에 필요하지 않다. 포트포워딩도, 터널도 필요 없이 사내망 안에서만 동작하면서 GitHub Actions와 연결된다. 사내망이라는 조건을 유지한 채로 외부 CI와 연결되는 방법이 이거였다.
왜 이 관측 스택 조합인가
이 노드는 추론 서버가 이미 리소스를 쓰고 있는 위에 관측 스택까지 얹어야 하는 조건이었다. 로그·메트릭·트레이스를 각각 받는 컴포넌트(Loki·Prometheus·Tempo)를 Grafana로 묶고, 앞단에 OTel Collector를 세워 표준 프로토콜로 받아 세 백엔드로 나눠 보내는 구성을 골랐다.
이미 돌고 있는 워크로드와 리소스를 나눠 써야 한다는 조건이 무거운 올인원 관측 스택(ClickHouse 기반 같은)을 쓸 이유를 없앴다. 상대적으로 가벼운 조합을 택한 이유가 정확히 이거다. 메모리가 빠듯해지면 보존 기간(retention)을 줄이는 걸 완화책으로 같이 남겨뒀다. OTel Collector를 수집 계층에 세운 건 특정 벤더 SDK에 종속되지 않기 위해서다. 지금은 이 조합이지만, 노드가 늘거나 리소스 제약이 풀리면 백엔드는 표준 프로토콜을 통해 바꿀 수 있게 열어뒀다.
판단 기준
전담 인력 없이 기능 개발자가 인프라도 같이 돌본다면, 컴포넌트를 더 세밀하게 고를 자유보다 설치 후 유지보수 대상의 수를 줄이는 쪽이 맞다. k3s처럼 배터리 포함형 배포판이 이 조건에서 이긴 이유다.
사내망이라는 조건은 레지스트리와 러너 선택을 같은 방향으로 밀었다. 외부로 나갔다 들어오는 경로를 만들지 않는 쪽, 아웃바운드만으로 끝나는 쪽. self-hosted 러너의 폴링 구조가 이 조건과 그대로 맞아떨어졌다.
다른 워크로드와 리소스를 공유해야 한다는 조건은 “얼마나 많은 기능”보다 “얼마나 가벼운가”를 선택 기준으로 밀어올린다. 무거운 올인원보다 가벼운 조합과 보존 기간 조절이 같은 문제를 다르게 푼다.
이 정리는 스택 자체의 우열이 아니다. 같은 스택도 조건이 바뀌면 답이 바뀐다. 노드가 여러 개가 되거나 전담 인프라 인력이 생기면, 이 글의 결론 각각이 다시 검토 대상이 된다. 실제로 이 시점에 고른 배포 경로(로컬 레지스트리·러너 기반 자동 배포) 자체도 이후 몇 주 동안 실제로 쓰이지 못한 채로 있다가 다른 방식으로 교체됐다. 여기 적은 건 1주차 시점의 조건에서 무엇을, 왜 골랐는가이지 그 선택이 끝까지 유지됐다는 뜻은 아니다.
References
- k3s Networking Services (Traefik, ServiceLB): https://docs.k3s.io/networking/networking-services
- GitHub Actions self-hosted runners reference (network requirements): https://docs.github.com/en/actions/reference/runners/self-hosted-runners