쿠버네티스 삽질기 (2) - 서비스 설계와 배포
2024년 6월 09일이전 글에서 Kubespray로 쿠버네티스 환경 구축과 대략적으로 쿠버네티스에 올릴 서비스 설계와 배포 전략을 정리했습니다. 이번 글에서는 서비스를 만들고 여러 인프라 도구로 배포까지의 과정을 진행해보도록 하겠습니다.
우선, 서비스 개발부터 살펴보겠습니다. 이전 글에서 언급했듯이 서비스의 기능을 pod 단위로 관리할 수 있다면(묶을 수 있다면) 보다 유연하게 인프라를 관리할 수 있을 겁니다. 그렇다고 기존 컨테이너 환경에서 단순히 pod로 교체한다고 해서 쿠버네티스의 빛을 보지 못할 가능성이 큽니다. 따라서 한 컨테이너에 있는 여러 기능을 pod 단위로 나누어 관리하기로 했습니다.
백엔드 관점으로 본다면 pod 단위를 도메인 단위로 생각했습니다. 혹은 같은 기능을 하는 비즈니스 코드 모음을 pod 단위로 생각하고 pod로 묶었습니다.
서비스 기능을 pod 단위로 나눈다는 것은 서비스 기능 간 의존성을 줄여야 한다는 의미와 같습니다. 한 예시를 들자면, 회원 인증(auth)을 처리할 때 회원 정보(member)가 필요합니다. 회원 인증 기능은 회원 정보에 의존성을 가지며, 인증과 회원을 각각의 pod로 배포하기 위해 이 의존성을 제거해야 합니다. 의존성 주입으로 두 도메인(혹은 클래스) 사이의 직접적인 의존성을 줄여주지만, repository와 같은 도메인 간 코드는 공유할 수 밖에 없습니다.
JAVA// AuthService.java
// AuthService.java 코드는 auth 도메인에 속한 코드이다
// 아래의 로직이 동작하기 위해서는 MemberRepository.java 코드가 필요하다
@Service
@RequiredArgsConstructor
public class AuthService {
private final MemberRepository: memberRepository
public LoginResponse login(LoginRequest req) {
FindMemberIdByAccountAndPassword request = new FindMemberIdByAccountAndPassword(
req.getAccount(),
req.getPassword());
Member member = memberRepository
.findByAccountAndPassword(request)
.orElseThrow(() -> new HttpException(ErrorCode.MEMBER_NOT_FOUND));
return LoginResponse.of(member);
}
}
// MemberRepository.java
// MemberRepository.java 코드는 member 도메인에 속한 코드이다
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByAccountAndPassword(String account, String password);
}
즉, auth 도메인에 member 도메인을 공유하고 pod로 따로 배포한 상황에서 member에 대한 spec이 변경되면 해당 코드를 사용하고 있는 코드를 변경해야 하는 불편함이 생깁니다. 이러한 의존성 문제를 해결하면서도 다른 도메인의 데이터를 공유받기 위해 내부 통신을 사용하였습니다. 클라이언트, 서버 간 통신과 유사하게 내부 통신에 필요한 정보(url 주소, 요청 및 응답 타입)를 미리 정의하고 HTTP 통신하여 데이터를 주고 받았습니다. 로그인 요청에 대한 전체적인 통신 순서는 다음과 같습니다.
- 클라이언트의 로그인 요청
- 회원 정보 확인을 위한 auth pod에서 member pod로 요청
- member pod에서 DB 조회 후 해당 결과값 반환
- 회원 정보를 받은 auth pod에서 나머지 로직 실행

Spring Boot 프레임워크 예시로 들면 다음의 코드로 나타낼 수 있습니다. 내부 통신을 위하여 선언적 HTTP Client 도구인 Spring Cloud OpenFeign을 사용하였습니다.
JAVA// AuthService.java
// MemberServiceClient으로 MemberRepository 의존성을 없앨 수 있다
@Service
@RequiredArgsConstructor
public class AuthService {
private final MemberServiceClient memberServiceClient;
public LoginResponse login(LoginRequest req) {
FindMemberIdByAccountAndPassword request = new FindMemberIdByAccountAndPassword(
req.getAccount(),
req.getPassword());
MemberInternal member = memberServiceClient.findMemberIdByAccountAndPassword(request);
return LoginResponse.of(member);
}
}
// MemberServiceClient.java
@FeignClient(name = "member", url = "MEMBER_INTERNAL_URL")
public interface MemberServiceClient {
@PostMapping("/findMemberByAccountAndPassword")
MemberInternal findMemberIdByAccountAndPassword(@RequestBody FindMemberIdByAccountAndPassword request);
}
// MemberInternalService.java
@Service
@RequiredArgsConstructor
public class MemberInternalService {
private final MemberRepository memberRepository;
public Member findMemberByAccountAndPassword(final String account, final String password) {
return memberRepository.findByAccountAndPassword(account, password)
.orElseThrow(() -> new HttpException(ErrorCode.MEMBER_NOT_FOUND));
}
}
Spring Boot 프레임워크 내에서는 OpenFeign의 도움으로 내부 통신을 할 수 있습니다. 하지만 다른 서버 프레임워크와의 내부 통신을 하기 위해서는 해당 프레임워크에 맞는 도구를 사용해야 합니다. 모든 서버 프레임워크의 내부 통신 spec을 통일하기 위해 gRPC 통신을 사용하는 것이 하나의 방법이 될 수 있다고 생각합니다.
내부 통신으로 도메인 간 의존성을 줄이는 건 효과적이었지만, 도메인 간 내부 통신 코드가 중복되는 문제가 있었습니다. 이는 내부 통신 spec이 변경된다면 모든 도메인 코드를 모두 수정해야 하는 문제가 있었습니다. 이를 해결하기 위해 내부 통신 코드를 모듈화하고 필요한 도메인에 implement하는 방법을 채택하였습니다. 내부 통신 코드와 같은 여러 도메인에서 쓰이는 공통 코드를 모아 빌드하고 해당 모듈을 불러온다면 앞선 문제를 효과적으로 해결할 수 있었습니다. 이 방법은 공통 모듈 코드와 버전링을 따로 관리해야 하고 필수적으로 다른 서버에 해당 모듈을 불러와야 서비스가 정상적으로 동작합니다. 또한 Spring Boot 프레임워크에 국한된 방법이라는 치명적인 단점도 있습니다.
공통 모듈은 단순히 내부 통신을 하기 위한 클래스 및 타입 정의 모음입니다. Spring Boot 기준으로 공통 모듈을 만들기 위해 새로운 프로젝트를 만들고 gradle dependencies에 필요한 모듈만 받는 의존성을 최소화하여 설정합니다. 의존성에는 Spring Web, OpenFeign, Lombok 정도만 추가하였습니다.
KOTLINdependencies { implementation("org.springframework:spring-web") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.cloud:spring-cloud-starter-openfeign") testImplementation(platform("org.junit:junit-bom:5.9.1")) testImplementation("org.junit.jupiter:junit-jupiter") compileOnly("org.projectlombok:lombok:1.18.32") annotationProcessor("org.projectlombok:lombok:1.18.32") testCompileOnly("org.projectlombok:lombok:1.18.32") testAnnotationProcessor("org.projectlombok:lombok:1.18.32") }
모든 도메인에서 공통적으로 쓰는 클래스와 특정 도메인의 내부 통신 클래스를 구분하기 위해 디렉터리를 나누어 관리하였습니다. 모든 도메인에서 사용하는 클래스는 HTTP error code, HTTP error response, HTTP exception을 특정 도메인은 내부 통신 요청, 응답 클래스가 있습니다. 자세한 코드는 여기서 확인할 수 있습니다.
BASH> tree -L 2 . . ├── common # 모든 도메인에서 사용하는 클래스 모음 │ ├── ErrorCode.java │ ├── ErrorResponse.java │ └── HttpException.java └── member # 특정 도메인에서 사용하는 클래스 모음 ├── FindMemberIdByAccountAndPassword.java └── MemberInternal.java
코드를 작성했다면 모듈로 만들고 다른 도메인에서 해당 모듈을 불러와야합니다. Spring Boot 프로젝트를 빌드하면 jar 파일이 만들어지는데 이 파일을 불러오면 됩니다. 저는 internal repository를 만들고 release tag로 공통 모듈 버전링을 관리하였습니다.

KOTLINdependencies { ... implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar")))) }
어느정도 서비스 개발 윤곽이 잡혔으니 배포 전략을 구체화 해보겠습니다. 온프레미스 환경에 구축하였으며 GitLab, Jenkins, ArgoCD 도구를 사용하였습니다. 쿠버네티스 구축은 이전 글을 참고해주시고, GitLab과 Jenkins는 공식 문서 및 블로그를 참고하여 apt
로 설치하였습니다.
- 사용한 도구
- Kubernetes 1.28 (Kubespray 2.24)
- ingress-NGINX 1.10.1
- MetalLB 0.14.5
- ArgoCD 2.10.7 (non-HA)
- GitLab CE 16.10
- Jenkins 2.440
- Kubernetes 1.28 (Kubespray 2.24)
- 인프라 환경
- Intel NUC8I3BEH (i3-8109, 32gb)
- Ubuntu 22.04 (amd64)
- k8s, ArgoCD 설치
- Raspberry Pi 5 (8gb)
- Raspberry Pi OS (bookworm, arm64)
- GitLab, Jenkins 설치
- Intel NUC8I3BEH (i3-8109, 32gb)
GitLab 구축 후 Git 구성을 server, infra, internal 3개의 repository를 만들어서 코드를 관리했습니다. server repository는 서버 코드를 관리하고 여러 repository가 아닌 하나의 repository로 관리하도록 구성하였습니다. infra repository는 ArgoCD와 연동할 k8s yaml 코드와 인프라 관련 코드를 관리합니다. internal repository는 앞서 언급한 서버 공통 모듈 코드를 관리하는 repository입니다. 참고로 server와 internal repository를 따로 둔 이유는 internal release를 따로 관리하기 위해 분리했습니다.
BASH> ls server infra internal > cd server && tree -L 1 . . ├── auth # 인증(로그인, 로그아웃) 관련 프로젝트 ├── jwt # jwt 인증 관련 프로젝트 ├── member # 회원 관련 프로젝트 └── post # 게시글 관련 프로젝트
다음으로는 Jenkins를 활용하여 자동 배포가 되도록 구성했습니다. Jenkins pipeline을 구성하는데 ‘어떻게 특정 도메인 코드가 push 될 때 해당 도메인만 배포가 되도록 할 수 있을까?’ 문제를 만났습니다. Jenkins으로만 해당 문제를 해결하기 까다롭다고 생각하여 중간에 GitLab CI를 추가하였습니다. GitLab CI는 특정 디렉터리에 코드가 push 되면 해당 도메인만 배포가 되도록 Jenkins job을 실행시키도록 구성하였습니다.
YAMLstages:
- build
variables:
GIT_STRATEGY: none
.template: &template
stage: build
script:
- curl --fail
-u $JENKINS_USER:$JENKINS_API_TOKEN
-X POST "$JENKINS_URL/$JENKINS_JOB/build?token=$JENKINS_JOB_TOKEN" 2>/dev/null
tags:
- test
build_member:
<<: *template
variables:
DOMAIN_NAME: "member"
only:
changes:
- server/member/**/*
build_auth:
<<: *template
variables:
DOMAIN_NAME: "auth"
only:
changes:
- server/auth/**/*
Jenkins를 쓰지 않고 GitLab CI로만 자동 배포 시스템을 구축해도 되지만 Jenkins를 위주로 사용하는 것이 목표라 GitLab CI, Jenkins 둘 다 사용하기로 했습니다.
도메인 별로 job이 존재하며 job이 실행되면 다음의 과정이 진행됩니다.
- Git Checkout
- Git checkout 시 배포하려는 디렉터리만 가져오기 위해 sparse checkout을 사용했습니다
- 공통 모듈 다운
- 공통 모듈 파일 존재 유무에 따라
curl
명령어를 실행합니다
- 공통 모듈 파일 존재 유무에 따라
- 빌드 및 테스트
- gradle 도구를 사용하여 빌드를 진행합니다
- 빌드 후 효과적인 docker 이미지 빌드를 위해 layered jar를 적용합니다
- docker 이미지 빌드
- docker registry로 이미지 push
- k8s yaml 수정
- docker 이미지 태그를 수정합니다
위와 같이 파이프라인을 구축하였으나 예상치 못한 문제가 발생했습니다. 정상적으로 docker 이미지 빌드 후 k8s에서 해당 이미지를 받고 실행하면 pod가 종료되었습니다. 로그를 확인해보니 exec format error
에러를 확인할 수 있었습니다. 뒤늦게 docker 이미지를 빌드한 Raspberry Pi 아키텍처(arm64)와 k8s로 배포한 Intel NUC(amd64) 아키텍처가 서로 달라 발생한 문제임을 인지했습니다.
Raspberry Pi 환경에서 에뮬레이터를 돌려 docker 이미지를 만들어야할지, Intel NUC에 Jenkins를 설치해야할지 머리가 아팠는데 docker cli에 여러 아키텍처로 docker 이미지를 빌드해주는 buildx
명령어를 발견했습니다. 해당 명령어를 조사하면서 docker 도구에 독립적이면서 OCI를 만족하는 docker 이미지 빌드 도구임을 알았습니다. 내부 구조가 어떻게 생겼는지 상당히 흥미로웠는데 기회가 된다면 정리해 보도록 하겠습니다. buildx를 사용하기 위해 builder container를 생성 후 buildx 명령어로 arm64 환경에서 amd64 docker 이미지를 만들수 있습니다. docker 이미지 빌드가 되면 기존대로 docker registry로 push 하면 됩니다. 참고로 docker registry는 따로 설치하고 않고 AWS ECR을 활용했습니다.
BASH# builder 생성 > docker buildx create --platform linux/amd64 --name amdbuilder --use > docker buildx ls NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS amdbuilder-jenkins* docker-container \_ amdbuilder-jenkins0 \_ unix:///var/run/docker.sock running v0.13.2 linux/amd64*, linux/arm64, linux/arm/v7, linux/arm/v6 default docker \_ default \_ default running v0.13.1 linux/arm64, linux/arm/v7, linux/arm/v6 # builder container 확인 > docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 759f1a35b8aa moby/buildkit:buildx-stable-1 "buildkitd --allow-i…" 46 hours ago Up 46 hours buildx_buildkit_amdbuilder-jenkins # amd64로 이미지 빌드 > docker buildx build \ --platform linux/amd64 \ --load \ -t REPOSITORY:TAG
Job의 마지막 단계로 ArgoCD로 pod가 배포될 수 있게 infra repository에 작성된 deployment yaml 파일의 docker 이미지 태그를 수정해줍니다. 전체적인 Jenkins pipeline 스크립트는 아래와 같습니다. Jenkins의 기능과 plugin 사용이 익숙치 않아 쉘 스크립트 위주로 작성하였습니다.
GROOVYpipeline {
agent any
options {
timeout(time: 10, unit: 'MINUTES')
}
environment {
AWS_ECR = credentials('AWS_ECR_MEMBER')
AWS_REGION = 'ap-northeast-2'
INTERNAL_VERSION = '0.1'
IMAGE_VERSION = '0.2'
}
stages {
stage('Checkout') {
steps {
checkout scm: [
$class: 'GitSCM',
branches: [[name: '*/main']],
doGenerateSubmoduleConfigurations: false,
extensions: [[
$class: 'SparseCheckoutPaths',
sparseCheckoutPaths:[[
$class:'SparseCheckoutPath', path:'server/member'
]]
]],
submoduleCfg: [],
userRemoteConfigs: [[
credentialsId: 'GITLAB',
url: 'http://172.30.1.26/root/astarion.git'
]]
]
dir('infra') {
checkout scm: [
$class: 'GitSCM',
branches: [[name: '*/main']],
extensions: [[$class: 'LocalBranch', localBranch: "main"]],
userRemoteConfigs: [[
credentialsId: 'GITLAB',
url: 'http://172.30.1.26/astarion/k8s.git'
]]
]
}
}
}
stage('Download internal.jar') {
when {
expression {
return !fileExists ("server/member/libs/internal-${INTERNAL_VERSION}.jar")
}
}
steps {
dir('server/member/libs') {
sh '''
curl \
-o internal-$INTERNAL_VERSION.jar \
-L https://github.com/redundant4u/astarion-internal/releases/download/$INTERNAL_VERSION/internal.jar
'''
}
}
}
stage('Build Spring Boot') {
steps {
dir('server/member') {
sh './gradlew build --daemon --build-cache --parallel'
sh 'java -Djarmode=layertools -jar build/libs/member-0.0.1-SNAPSHOT.jar extract'
}
}
}
stage('Build Docker Image') {
steps {
dir('server/member') {
sh '''
docker buildx build \
--platform linux/amd64 \
--load \
-t $AWS_ECR:$IMAGE_VERSION \
-f Dockerfile .
'''
}
}
}
stage('Push Docker Image') {
steps {
sh 'aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin "$AWS_ECR"'
sh 'docker push $AWS_ECR:$IMAGE_VERSION'
}
}
stage('Update deployment yaml') {
steps {
dir('infra/member') {
sh "sed -i 's/astarion-member:.*/astarion-member:$IMAGE_VERSION/' deployment.yml"
withCredentials([gitUsernamePassword(credentialsId: 'GITLAB')]) {
sh 'git add deployment.yml'
sh 'git commit -m "cicd: update image version"'
sh 'git push -u origin main'
}
}
}
}
}
}
Jenkins pipeline을 작성할 때 아래의 이슈를 제외하고는 큰 무리없이 작성할 수 있었습니다.
- Jenkins가 돌아가는 서버에
git config
설정이 추가적으로 필요하다git config --global user.email
,git config --global user.name
- Jenkins에서 git push를 하기 위해서는 branch 이름이 commit id가 아니여야 한다
- Jenkins pipeline에서 checkout scm으로 git 내용을 가지고 오면 branch 이름이 사용자가 설정한 이름이 아닌 해당 commit id로 checkout을 한다
- git push 할 때 branch 이름이 commit id라면 branch 이름이 일치하기 않으므로
error: src refspec main does not match any
에러가 발생한다 - checkout 시 commit id branch 이름을 피하기 위해
extensions: [[$class: 'LocalBranch', localBranch: "main"]]
옵션을 추가해준다
위 pipeline으로 빌드하고 배포하는데 문제는 없었지만 의문점과 아쉬운 점이 존재했습니다.
- 상수값 사용
INTERNAL_VERSION
변수화- 버전링 정책 고민 필요
IMAGE_VERSION
변수화- Spring Boot 빌드 시 version 변수화
deployment.yml
내용 수정 과정의 번거로움sed
명령어로deployment.yml
내용을 직접적으로 수정하는데 다른 방법이 없는지?deployment.yml
내용이 달라지면 어떻게 유연하게 대응해야하는게 좋을까- 개발, 운영 환경 구분은 어떻게 해야하는가?
아쉬운 점을 다음 개선 사항에서 면밀히 살펴보기로 하고 마지막으로 ArgoCD 부분을 살펴보도록 하겠습니다.
Jenkins로 deployment.yml 파일의 도커 이미지 태그를 업데이트 했다면 ArgoCD가 이를 감지하고 pod 배포를 진행합니다. ArgoCD 웹 대시보드를 통해 git 연동을 진행하였으며 손쉽게 설정할 수 있었습니다. 다만 초반에 서버, 인프라 코드를 한 repository에 넣어두었는데 ArgoCD 연동을 위한 인프라 repository를 분리하였습니다. ArgoCD project를 git 디렉터리 별로 생성할 수 있어서, git 구조를 namespace, ingress와 같은 오브젝트는 최상위에 위치시키고 도메인 별로 하위 디렉터리를 만들어서 관리하였습니다.

Jenkins와 ArgoCD 도구 덕분에 기본적인 쿠버네티스 배포 파이프라인을 구축할 수 있었습니다. 간략하게 두 도구에 대해 느낀점을 정리해보자면 다음과 같습니다. 오랜만에 Jenkins 도구를 다시 써보았는데 여러 장점을 볼 수 있었습니다. GitHub Actions와 달리 자체적인 빌드 서버를 가지다보니 캐싱의 효과를 자연스럽게 얻을 수 있었던 점이 가장 좋았습니다. Jenkins를 실행시키지 않아도 빌드 서버에 접속하여 파이프라인 테스트를 확인할 수 있는 점도 좋았습니다. 다만 익숙치 않은 Groovy 문법 사용, 수많은 plugin으로 인한 혼란, 빌드 서버를 별도로 준비해야하는 점이 단점으로 다가왔습니다.
ArgoCD 도구는 처음 써보았는데, git 연동으로 자동으로 리소스를 생성해주는것이 정말 간편했습니다. CLI 환경을 선호하는 편인데 ArgoCD의 웹 대시보드로 쿠버네티스 상태를 시각적으로 확인할 수 있어 일일이 kubectl
명령어를 사용하지 않아도 되는 점이 생각 이상으로 편했습니다. ArgoCD 구축과 설정 과정도 복잡한 부분이 없어 쿠버네티스 환경을 이해하고 있다면 진입장벽이 낮다고 생각됩니다.
이제 정말 기본적인 서비스와 배포 시스템이 갖추어졌습니다. 꾸준하게 여러 상황을 만들어보면서 쿠버네티스 환경을 씹고 뜯고 맛보면서 즐겨보도록 하겠습니다.