기술

Jenkins + Kubernetes 기반 서비스별 선택 배포를 어떻게 설계했는가

AAnonymous
10분 읽기

들어가며

이 글은 하나였던 Omnilude 백엔드를 AI와 함께 MSA로 전환한 과정 다음에 이어지는 글입니다. 앞선 글에서 서비스 경계를 왜 나눴는지 이야기했다면, 이번에는 그 경계가 실제 배포 단위로 어떻게 이어졌는지를 정리해보려 합니다.

MSA 이야기는 서비스 이름을 몇 개로 나눴는지에서 끝나기 쉽습니다. 하지만 실제로 운영 체감이 달라지는 순간은 코드 폴더가 아니라 배포 단위가 바뀔 때입니다.

이 글에서는 지금의 구조가 어떤 문제를 줄였는지, 어떤 식으로 동작하는지, 그리고 아직 무엇이 부족한지를 함께 정리해두려 합니다.

왜 코드 분리보다 배포 분리가 먼저 체감됐나

하나의 큰 백엔드였을 때 가장 불편했던 것은 기능이 섞여 있다는 사실 자체보다, 작은 수정 하나가 늘 전체 배포 압력으로 번진다는 점이었습니다.

블로그 기능을 조금 손봐도 큰 앱 전체를 다시 빌드해야 했고, 이미지도 다시 만들고, 결국 같은 덩어리를 다시 올려야 했습니다. 이런 구조에서는 자주 바뀌는 영역의 실험 속도가 느려집니다. 더 큰 문제는 연결을 오래 유지해야 하는 서비스까지 같은 배포 영향권에 들어간다는 점이었습니다.

예를 들어 backbone-service처럼 WebSocket, SSE, 이벤트 스트림과 가까운 영역은 일반적인 기능 수정과 같은 압력으로 흔들리면 운영 체감이 바로 나빠집니다. 그래서 저에게 서비스 분리는 결국 누가 어느 API를 갖느냐보다 무엇을 따로 배포할 수 있느냐의 문제에 더 가까웠습니다.

Jenkins에서 배포 대상을 고르는 방식

서비스 운영은 Kubernetes 위에서 하고 있고, CI/CD는 Jenkins로 구성했습니다.

이 글에서는 구현 세부보다 CI/CD 관점에서 배포 단위를 어떻게 끊었는지에 초점을 맞춰 설명하겠습니다.

현재 파이프라인에서 배포의 출발점은 BUILD_MODE와 서비스별 boolean 파라미터입니다. all로 전체 배포를 할 수도 있고, auto에서는 직접 체크한 서비스만 배포하거나, 아무것도 선택하지 않으면 Git 변경 사항으로 대상을 자동 감지합니다. 즉, 수동으로 전체 또는 부분 배포를 할 수도 있고, 자동 트리거에서는 변경 파일 기준으로 대상을 좁힐 수도 있습니다.

선택 가능한 서비스는 auth-service, backbone-service, storage-service, ai-service, game-service, blog-service, legacy-service의 7개입니다. 즉, 저장소는 하나지만 배포 대상은 처음부터 서비스 단위로 고를 수 있게 만든 셈입니다.

자동 감지 로직도 단순한 편입니다. git diff로 바뀐 파일을 보고 modules/{service} 아래에서 어떤 서비스가 수정됐는지 추립니다. 다만 여기서 중요한 예외가 하나 있습니다. 공통 모듈인 common(jspring)이 바뀌면 공통 기반이 흔들린 것으로 보고 전체 서비스를 다시 빌드합니다. 이 규칙이 있어야 common 변경이 서비스별 부분 배포에 가려지지 않습니다.

저는 이 지점이 꽤 중요하다고 생각합니다. 선택 배포는 "필요한 것만 빠르게"라는 장점이 있지만, 공통 규약 변경을 과소평가하면 오히려 더 위험해질 수 있기 때문입니다.

아티펙트는 서비스 단위로

실제 빌드 흐름도 같은 철학을 따릅니다. 먼저 common을 로컬 Maven에 publish한 뒤, 선택된 서비스만 순차적으로 bootJar까지 빌드합니다. 여기서 병렬 빌드 대신 순차 빌드를 택한 이유도 현실적입니다. Q 클래스 생성과 Kotlin daemon 충돌을 줄이는 편이 더 안정적이었기 때문입니다.

이미지 빌드는 kaniko 컨테이너에서 수행합니다. 이 단계 역시 병렬이 아니라 순차 실행입니다. 빌드 속도만 보면 병렬이 더 멋져 보일 수 있지만, 실제로는 리소스 충돌과 실패 지점을 줄이는 편이 지금 단계의 Omnilude에 더 잘 맞았습니다.

이미지명도 서비스별로 따로 매핑합니다. 예를 들면 auth-servicejongmin-auth, ai-servicejongmin-ai, blog-servicejongmin-blog처럼 나뉩니다. 이렇게 해야 레지스트리에서도 배포 단위를 그대로 읽을 수 있고, 나중에 특정 서비스만 추적하기가 쉬워집니다.

폴더 구조를 운영 단위로

Jenkins가 빌드 대상을 고른다면, Kubernetes는 그 결과를 실제 운영 단위로 고정합니다. 현재 구조는 kubernetes/services/{service}/{dev|prd} 형태로 나뉘어 있습니다. 각 서비스는 자기 DeploymentService를 갖고, ingress는 ingress/{env} 아래에서 환경별로 따로 관리합니다.

이 구조의 장점은 설명이 쉽다는 점입니다. 어떤 서비스가 어떻게 배포되는지 보려면 해당 서비스 폴더만 보면 됩니다. 코드 경계와 매니페스트 경계가 대체로 맞아 있기 때문에, 운영 관점에서도 소유권이 덜 흐려집니다.

라우팅도 같은 방식입니다. dev-api.omnilude.comapi.omnilude.com 아래에서 path prefix 기준으로 각 서비스를 연결합니다. 즉, 서비스 분리는 저장소 안의 개념이 아니라 실제 API 소유권과 ingress 라우팅까지 이어지는 구조입니다.

기본 안정성 장치도 여기서 들어갑니다. 각 서비스의 Deployment에는 readinessProbe, livenessProbe가 들어가 있고, 운영 환경 일부 서비스에는 HPA, PDB, topology spread constraint도 붙어 있습니다. 아직 모든 서비스가 같은 수준의 운영 가드레일을 갖춘 것은 아니지만, 최소한 서비스별로 어떤 안정성 장치를 어디까지 적용했는지는 분리해서 관리할 수 있는 상태가 됐습니다.

배포 흐름은 단순하게

지금의 배포 단계는 생각보다 직선적입니다.

  • Jenkins가 이번 배포 대상을 정합니다.
  • 선택된 서비스만 빌드하고 이미지로 만듭니다.
  • kubectl apply로 서비스별 매니페스트를 반영합니다.
  • kubectl rollout restart로 새 버전을 올립니다.
  • kubectl rollout status로 각 서비스가 올라올 때까지 기다립니다.
  • 시작, 성공, 실패 상태를 Slack으로 보냅니다.

여기서 중요한 것은 화려함보다 읽기 쉬움입니다. 지금 단계에서는 누가 봐도 "무엇을 올렸고, 어디서 실패했고, 무엇을 기다리는지"가 분명한 쪽이 더 낫다고 판단했습니다. 복잡한 배포 전략은 나중에도 얹을 수 있지만, 현재 구조를 설명하기 어려우면 운영이 오히려 더 불안정해질 수 있기 때문입니다.

적용 이후 달라진 점

이 선택 배포 구조 덕분에 가장 크게 달라진 것은 "작은 변경이 전체 재배포를 강제하지 않는다"는 점이었습니다. 실험 속도가 더 중요했던 ai-service, 제품 기능을 빠르게 다듬고 싶은 blog-service, 비교적 독립적인 게임 쪽 작업을 각각 다른 템포로 다룰 수 있게 됐습니다.

또 하나는 설명 가능성입니다. 누군가 Omnilude의 현재 운영 구조를 물으면 이제는 서비스 목록, 이미지명, K8s 폴더, ingress 경로, Slack 알림 흐름까지 한 줄로 이어서 말할 수 있습니다. 저는 개인 프로젝트에서도 이런 운영 구조의 설명 가능성이 꽤 중요하다고 봅니다. 운영 구조는 돌아가는 것만으로 끝나지 않고, 왜 그렇게 나뉘어 있는지 설명할 수 있어야 다음 개선도 이어지기 때문입니다.

다만 현재 구조가 완성형은 아닙니다. 아직 자동 롤백, 배포 후 smoke test, 메트릭 기반 배포 게이트는 붙어 있지 않습니다. 현재 수준은 rollout status 확인과 Slack 요약까지가 중심입니다.

마무리

지금의 구조는 거대한 CI/CD 플랫폼이라고 부를 만한 단계는 아니지만, 최소한 레포는 하나지만 배포는 서비스별로 끊는다는 기준은 실제 Jenkins 파라미터, 이미지명, Kubernetes 폴더, ingress 경로, Slack 알림까지 일관되게 연결되어 있습니다.

다음 단계는 분명합니다. 이 구조 위에 자동 롤백, smoke test, 더 나은 운영 알림 같은 가드레일을 실제로 얹는 일입니다. 이 부분은 아직 진행 중인 과제라서, 실제 구축이 끝나면 다음 글에서 이어서 정리해보겠습니다.