마이크로 서비스 프로젝트 300개 관리하기

지난 7월 18,19일 이틀에 걸쳐 진행된 Open Infrastructure & Cloud Native Days Korea 2019 행사에 발표자로 참여하면서 몇가지 세션을 들었습니다. 중국 개발자와 같이 발표를 해야 했기 때문에 발표 준비로 많은 세션을 듣지 못했지만 몇가지 세션을 들으면서 다음과 같은 점을 느꼈습니다.

  • 많은 회사 또는 개발자들이 Docker나 Kubernetes 환경에 관심을 가지고 있다.
  • 라인, 카카오, 네이버, SKT 등 큰 회사들은 Docker나 Kubernetes 환경에서 운영하고 있다.
  • 작은 회사들은 관심은 많지만 아직 많이 적용되지 않는 것 같다.
  • Docker, Kubernetes와 항상 같이 나오는 것이 마이크로 서비스 아키텍처이다.
  • 여러 세션들의 예제가 10개 정도에 머무르고 있어 구체적인 실 사례 운영에 대한 공유가 부족하다.

물론 예제라서 그런 경향도 있겠지만 아직까지 국내 환경에서 규모가 큰 서비스들이 마이크로 서비스 환경으로 전환된 사례가 많지 않을 것 같다는 추측도 해 보았습니다.

이번 글에서는 300개 이상의 작은 서비스로 구성된 서비스를 관리하는 데 있어 어떻게 프로젝트를 관리하고 있는지와 빌드, 배포 등에서 어떤 문제가 있었고 이를 어떻게 해결해 나가고 있는지에 대해 소개하겠습니다(1).

마이크로 서비스로의 시작

2016년 중반부터 서비스를 작게 만들기 시작했으니 대략 3년 정도 지났다고 볼 수 있겠네요. 왜 이런 선택을 했는지는 "Micro Service, Docker로 할 수 밖에 없었던 사연" 글에 자세하게 나와 있는데 요약하자면

  • 시장의 변화가 빠르게 진행되고 있기에 이에 맞는 시스템 아키텍처가 필요
  • 개발자 한명 한명의 설계, 구현 능력이 높지 않음
  • 전체 개발 조직에서 3 ~ 4명 정도만 설계 등이 가능
  • 이런 상태에서 시장에 빠르게 기능을 출시하고 검증받고, 다시 개선하기 위해서는 작은 기능을 빨리 개발하고 출시하는 전략으로 추진
  • 즉, 원래부터 MSA를 하려고 한 것은 아님, 어쩔수 없는 선택, 하다보니 마이크로 서비스로 운영하고 있는 상황

이렇게 시작된 서비스가 이제는 거의 300개 이상의 프로젝트가 유지되는 서비스로 진화했습니다.

number_gitlab_project

실제 운영되는 Kubernetes 환경에서는 200이상의 서비스와 400개 이상의 pod가 운영되고 있습니다(2).  k8s 운영 현황에 대한 상세 내용은 다음 슬라이드 참고하세요.

이번 글은 이 슬라이드 내용중 빌드, 배포 관련 내용에 대한 상세 부연 설명을 위한 글이라고 볼 수 있습니다.

프로젝트 개수가 많은 경우 고려할 사항

이렇게 많은 git 프로젝트를 관리하는 것이 상당한 부담이 될 것이라 생각할 수 있는데 개발 팀이 30 ~ 40명 정도 된다면 한 명의 개발자가 관리하는 프로젝트는 대략 10개 정도 수준입니다. 마이크로 서비스의 소스 코드 크기가 대략 일반 프로젝트의 하나의 모듈보다 작다고 하면 실제 10개 정도의 서브 모듈을 관리하는 수준이다보니 개발자 한명 한명에게는 프로젝트 관리 부담이 크지 않습니다.

다만, 관리 차원에서 필요한 영역에서는 프로젝트 갯수가 증가하면 관리 비용 또는 시간이 많이 증가하고 귀찮아 지는데 대표적인 것들이 다음과 같습니다.

  • 프로젝트 이름 짓기: 개발할 때 클래스, 함수, 변수 등의 이름 짓기가 가장 어려운데 서비스가 작게 쪼개 지면서 프로젝트 이름 짓는 것도 쉽지 않은 일이 되었습니다. 이 문제는 특별한 해결책이 있다기 보다 그때 그때 의견을 모아 이름을 짓고, 운영 중에라도 필요하면 수정하는 방식을 택하고 있습니다.
  • Jenkins 프로젝트 관리: 실제 서비스의 jenkins 프로젝트 갯수는 소스 코드 프로젝트 갯수 * 배포 환경 갯수인데 필자가 운영하는 서비스는 Dev, QA, Production 세개의 환경에서 운영되고 있기 때문에 jenkins 프로젝트 갯수는 대략 1000개 가까이 됩니다.
  • Kubernetes 배포 설정 파일 관리: Kubernetes의 리소스 설정 등을 위해서 환경 설정 ymal 파일을 작성해야 하는데 모든 프로젝트에 이 파일을 작성할 경우 리소스 관리하기가 어려워집니다.
  • Docker Image 크기: 운영 시 Docker image가 커지면 실제 운영되는 서버의 디스크가 낭비되고, 레지스트리에 업로드 하는 등의 시간도 많이 소요됩니다.
  • 빌드 시 빌드 환경 및 공통 모듈 다운로드 시간: Docker 환경에서 빌드 시 일반적인 환경과 동일하게 빌드할 경우 시간이 많이 걸리거나 환경이 다양한 경우 빌드 환경 구성에 문제가 있을 수 있습니다.
  • Kubernetes Ingress 관리: Kubernetes 외부에서Kubernetes에서 운영되고 있는 서비스의 특정 API URL을 호출하는 해당 서비스의 도메인을 외부에 노출해야 하는데  너무 많은 경우 생성, 관리 등이 어려움

위에서 발생한 이런 문제들을 하나씩 어떻게 해결해 나가고 있는지 살펴보겠습니다.

프로젝트 소스 레포지토리

현재는 자체 설치한 gitlab 을 소스 코드 레포지토리로 사용하고 있습니다. 그리고 모든 서비스는 별도의 프로젝트로 구성되어 있습니다. 이렇게 하는 이유는 각각의 서비스별로 독립성을 유지하기 위함 입니다. 이렇게 구성하다보니 gitlab의 프로젝트 개수가 300개 이상이 되었는데 개발자 한명이 대략 5 ~ 10개 정도 관리하고 있다고 볼 수 있습니다.

그리고 화면 프로젝트와 API 프로젝트도 완전히 분리하여 관리하고 있습니다. 이것은 마이크로 서비스의 특성 상 화면하나가 여러 마이크로 서비스의 API를 호출하기 때문에 특정 화면 프로젝트가 특정 서버 사이드 기능이 포함될 수 없기 때문에 하나의 프로젝트로 관리하는 것이 애매하기 때문입니다.  이것이 gitlab에서 프로젝트 개수를 증가시키는 원인이기는 하지만 기능 개선 및 추가가 용이한 측면에서 보면 프로젝트 개수가 많은 것은 큰 이슈가 아니라고 생각하여 이런 기준으로 관리하고 있습니다.

프로젝트 간의 소스코드 의존성

"기본적으로 서비스간의 소스 코드 의존성은 없다" 라는 원칙으로 개발을 진행하고 있습니다. 많은 개발 프로젝트에서 공통적인 영역을 두려고 합니다. 예를 들어 Order 서비스에서 정의한 Order 타입(여기서는 도메인 객체가 아닌 값 전달을 위한 타입)을 다른 프로젝트에서 공유하려고 합니다.  이것은 코드의 중복을 방지하고 오너쉽을 가지는 영역에서 한번의 변경으로 모든 영역에 반영되도록 하기 위함인데 마이크로 서비스 아키텍처의 목적을 다시 생각해보면 이렇게 만드는 것은 이 목적에 반대 되는 결정이라고 볼 수 있습니다.

마이크로 서비스는 서로간의 의존성을 API 인터페이스로만 두고 있는데 소스 코드 수준에서 의존성을 둘 경우 하나의 서비스의 변경 때문에 모든 서비스에 영향을 주게 됩니다. 이렇게 되면 변경 후 배포 시 어떤 범위까지 영향을 미칠지를 예측하기 어렵고, 변경/배포를 더욱 어렵게 하는 원인기 되기도 합니다.

그리고 각 프로젝트 관점에서도 보면 Order 서비스에서 정의한 Order와 자신의 서비스에서 사용하는 Order는 다른 모양일 가능성이 많습니다. Sub Set 일수도 있고, 어떤 경우 특정 데이터는 JSON 문자열로 처리할 수 도 있습니다. 즉 각 서비스에 맞는 타입을 각자 정의해서 사용하는 것이 가장 좋다는 것입니다. 프로젝트가 수백개, 수천개가 되는 상황에서 소스 코드간 의존성은 monolithic 아키텍처가 가지고 있는 문제를 그대로 가지고 있기 때문에 굳이 마이크로 서비스로 만드는 장점이 없어지게 됩니다.

Jenkins 프로젝트

Jenkins 프로젝트 생성 시에는 Jenkins에서 제공하는 기존 프로젝트를 참고하여 생성하는 기능을 이용하기도 하지만 개발자가 프로젝트 생성 권한이 없기 때문에 Jenkins 관리자가 매번 이렇게 관리하는 것은 귀찮은 작업입니다. Jenkins 프로젝트를 생성하기 위해 아래 화면과 같은 자체 개발한 도구를 이용하여 Jenkins 프로젝트를 생성하고 있습니다.

create_jenkins_project

이것만으로 부족하지만 한번 생성한 이후 배포 스크립트에 대한 수정 등은 개발자도 가지도 있기 때문에 관리자 입장에서 1000개 가까운 프로젝트를 모두 관리할 필요는 없습니다.

Kubernetes 배포 파일에 대한 관리

Kubernetes 환경에서 배포를 하기 위해서는 배포 설정 정보가 있는 yaml 파일이 필요합니다. 이 파일에는 주로 replica, cpu, memory 등의 리소스에 대한 제한 등을 설정하는데 각 프로젝트의 소스 코드 내에 이 파일을 관리하기에는 애매한 부분이 있습니다.  운영 상황에 따라 replica의 개수나 CPU, Memory 등을 변경하여 다시 배포해야 할 경우가 생기는 데 이때 마다 개발자가 소스 코드를 변경하고, commit, pull request 등의 번거로운 작업을 한다는 것은 귀찮은 작업이라 할 수 있습니다.  그리고 이런 운영 환경에 대한 리소스 제어를 개발자가 하는 것도 역할로 보면 이상하기도 하고요.

이 문제는 Kubernetes 배포 관련 정보를 실제 배포 시점에 생성하고 kubectl 배포 명령까지 실행하는 별도의 Docker container를 이용하여 해결하고 있습니다.

다음은 Jenkins 빌드 스크립트 중 일부인데 첫번째 부분으로 Kubernetes 배포 관련 정보를 설정하는 부분입니다.

1
2
3
4
5
6
7
8
9
10
11
export DOCKER_APP_NAME='test-api'
export PUSH_TAG='prod'
export KUBE_APP=$DOCKER_APP_NAME
export KUBE_NAMESPACE='srxcloud'
export KUBE_REPLICAS=2
export KUBE_RESOURCES='{"limits": {"cpu": "500m","memory": "512Mi" }, "requests": {"cpu": "10m","memory": "32Mi" }}'
export KUBE_PORTS='{"containerPort": 7000}'
export KUBE_SERVICE_TYPE='ClusterIP'
export KUBE_SERVICE_PORTS='{"port": 7000}'
export KUBE_API="https://10.173.208.129"
export KUBE_SVC_ANNOTATIONS='{"nginx.gateway.type": "api","nginx.gateway.url":"test-api"}

이 정보는 자체 만든 Kubernetes 배포를 수행하는 Docker container에서 사용하는데 Docker container는 이 정보를 이용하여 yaml 파일을 생성한 후 파일을 이용하여 Kubernetes에 배포를 합니다. 아래 스크립트는 이를 수행하는 jenkins 스크립트 입니다.

1
2
3
4
5
6
7
8
9
10
11
# deploy
echo ">>>>>deploy"
docker run --rm -t -e KUBECONFIG="/usr/local/kube/deploy.conf" \
-v $kubeconfig:/usr/local/kube/deploy.conf \
siriuszg/k8s-kubectl:v1.8.10 \
"${KUBE_APP}" "${KUBE_NAMESPACE}" \
"${KUBE_REPLICAS}" "${KUBE_IMAGE}" \
"${KUBE_RESOURCES}" "${KUBE_ENVIRONMENT}" \
"${KUBE_PORTS}" "${KUBE_SERVICE_TYPE}" \
"${KUBE_SERVICE_PORTS}" "${KUBE_API}" \
"${KUBE_SVC_ANNOTATIONS}"

위 스크립트 중 "siriuszg/k8s-kubectl" 이 부분이 사용하는 Docker container 이름인데 이 Docker container의 구현은 다음 github 프로젝트를 참고하세요.

Docker Image 크기 문제

하나의 서버에 5 ~ 10개 정도의 Container만 실행된다면 이미지의 크기가 큰 문제가 되지 않습니다. 필자가 운영하는 서비스는 여러 개발 언어가 사용되지만 주로 많이 사용되는 언어는 golang 입니다. golang의 경우 딱 필요한 메모리 만큼만 사용할 수 있기 때문에  하나의 컨테이너 당 100MB 이하로 설정해서 사용하고 있습니다. 16GB 메모리의 서버라면 대략 100개 이상의 컨테이너를 실행할 수 있습니다. 실제 저희 서버스도 6 ~ 7대로 300개 정도의 Pod를 운영하고 있으니 서버 한대당 50개 정도의 Pod가 실행되고 있습니다(2).

이런 상황에서 하나의 Container의 Image 사이즈가 크면 불필요한 디스크 공간을 많이 차지하게 됩니다. 하나 당 1GB만 되어도 대략 50 ~ 100GB 정도가 필요합니다. 그리고 Image 사이즈가 크면 배포 시에도 레지스트리 등록, 이미지 pull 시 네트워크로 많은 양이 트래픽이 전송되어야 합니다(3).  이런 이유가 아니더라도 다양한 이유로 Docker 커뮤니티에서도 Image의 크기를 가능한 최적화해서 사용하는 것을 권장하고 있습니다(4).

필자가 운영하는 서비스에서 가장 많이 사용하는 언어인 golang의 경우 기본 제공하는 golang Image를 사용하게 되면 약 1GB 정도의 이미지가 생성되는데 이것은 이 이미지 내에 golang 컴파일을 위한 환경까지 모두 포함하고 있기 때문입니다. golang의 경우 컴파일을 하게 되면 하나의 바이너리 파일만 생성되고 이 파일만 있으면 컴파일한 환경과 동일한 환경에서 실행 가능하기 때문에 굳이 컴파일 환경까지 포함할 필요는 없습니다.

이를 위해 Docker에서 제공하는 Multi-Staging Build 방법을 사용해서 프로젝트를 컴파일 하고 Image를 만들고 있습니다. Multi-Staging Build에 대한 방법은 다음 Docker 문서에도 잘 나와 있습니다.

여기서 간단하게 설명하면 소스 코드를 빌드 하기 위해 golang 빌드 환경이 있는 Image를 이용하여 빌드를 실행하고, 이 Image에서 실행된 빌드 결과를 alpine Image와 같이 아주 경량화된 Image에 추가하여 Image를 빌드한다. 다음은 실제 사용되고 있는 Dockerfile 중 일부입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM pangpanglabs/golang:builder AS builder
WORKDIR /go/src/$PROJECT-NAME
COPY . .
# disable cgo
ENV CGO_ENABLED=0
# make application docker image use alpine
FROM alpine:3.10
# using timezone
RUN apk add -U tzdata
WORKDIR /go/bin/
# copy config files to image
COPY --from=builder /go/src/$PROJECT-NAME/config/*.yml ./config/
# copy execute file to image
COPY --from=builder /go/bin/$PROJECT-NAME ./
EXPOSE $PROJECT-PORT
CMD [$PROJECT-NAME]

위 Dockerfile에서 사용된 pangpanglangs/golang:builder는 다음 github 레포에서 확인해볼 수 있습니다.

이렇게 해서 생성된 이미지는 약 30MB 정도가 된다. 아래는 실제 필자의 랩탑에서의 이미지 목록입니다. 위의 colleague-service가 위에서 설명한 방식으로 빌드한 것이고 아래 cloud-portal-api 이미지가 golang 기본 이미지에 go get 등을 이용하여 필요한 라이브러리를 다운 받은 후 빌드하여 사용한 이미지 입니다. 대략 7배 정도 차이가 나는 것을 볼 수 있습니다.

docker_image_size

Docker in Docker Build

Jenkins 환경에서 빌드를 하는 경우 Jenkins 가 설치된 서버 또는 Jenkins 스크립트가 실행되는 서버에는 각 프로젝트의 빌드 환경이 구성되어야 합니다. 마이크로 서비스로 구성하는 경우 모든 프로젝트가 동일한 언어, 동일한 환경을 사용할 수 없습니다. 필자가 운영하는 서비스만 하더라도 golang이 메인 언어이기는 하지만 Java, Python, .Net, Ruby on Rails, React 등 다양하게 사용하고 있습니다. 이런 상황에서 다음 문제가 있습니다.

  • Jenkins 서버에 다양한 환경의 빌드를 구성하기 어렵다. Docker 기반으로 운영되는 환경이라면 Jenkins 서버 역시 Docker 컨테이너로 실행되는 경우가 많을 겁니다. 이렇게 되면 Jenkins 서버에 다양한 빌드 환경을 구성하기가 어려워 집니다(5).

이 문제를 해결하기 위해 사용하는 방식이 프로젝트 소스 코드 빌드를 위해 별도의 Docker container를 사용하는 방법입니다. 앞에서 설명한 Multi-Staging Build의 첫번째 단계도 이 방식이라고 할 수 있습니다. 즉 Jenkins가 실행되는 서버에서 바로 빌드하지 않고, Jenkins는 빌드를 위한 Docker container를 실행하고 이 Container 내부에서 빌드하는 방식이라고 할 수 있습니다.

이렇게 구성하면 또 다른 문제가 발생하는데 다음 문제입니다.

  • 최근의 프로그램 언어들은 빌드하는 시점에 온라인 상에서 필요한 라이브러리를 다운로드 받아서 빌드하는 기능을 제공하고 있습니다. Java의 경우 maven을 이용해서 빌드를 하는 경우가 대표적이라고 할 수 있습니다.
  • 하지만 프로젝트 처음 빌드시에는 관련 라이브러리를 모두 다운 받아야 하기 때문에 빌드 시간이 수분 이상에서 수십분 소요되는 경우도 자주 있습니다.
  • 일반 환경에서는 한번 빌드 한 이후부터는 로컬 디스크에 저장된 파일을 사용하고 버전 변경된 패키지나, 신규 추가된 패키지만 다운로드 받으면 되기 때문에 크게 불편함이 없습니다. 하지만 Docker Container에서 빌드를 할 경우 매번 Container 가 실행되기 때문에 이전에 다운로드 받아둔 로컬 캐쉬 파일이 존재하지 않습니다.
  • 따라서 매번 수분 ~ 수십 분 정도의 빌드 시간이 필요하게 됩니다.

이를 해결하기 위해 빌드를 위한 Container를 실행할 때 Jenkins 서버의 볼륨을 마운드 하고 마운트된 볼륨을 공통 라이브러리 저장 디렉토리로 사용하게 함으로써 이런 문제를 해결할 수 있습니다.

아래는 React 프로젝를 빌드하는 Jenkins 스크립트 중 일부 입니다.

1
2
3
4
5
docker run --rm \
-v /usr/local/develop/data/node-cache:/etc/node-cache \
-v "$WORKSPACE":/usr/src/app \
-e REACT_APP_ENV="production" \
siriuszg/node-build:8.15.1-alpine "npm" "build"

위에서 "siriuszg/node-build" 이미지가 실제 React 프로젝트를 빌드하기 위한 Docker 이미지인데 이 이미지 실행 시 볼륨 옵션을 보면 다음과 같이 되어 있습니다.

-v /usr/local/develop/data/node-cache:/etc/node-cache

그리고 실제 "siriuszg/node-build" 이미지에 보면 다음과 같은 설정이 있습니다.

RUN npm config set cache "/etc/node-cache"

"siriuszg/node-build"의 전체 코드는 다음 github 저장소에서 확인할 수 있습니다.

Kubernetes Ingress 추가

웹 화면에서 여러 서비스의 API를 호출하는 경우 Kubernetes의 Ingeress를 이용하는데 각 서비스별로 Ingress 설정 파일과 이를 이용해서 kubectl 명령을 이용하여 매번 생성해야 합니다. 서비스가 많고 도메인 등의 변경이 간혹 발생하는 상황이라면 Ingress 를 생성하는 yaml 파일을 생성하는 것도 여간 귀찮은 작업이 아닐 수 없습니다.

이것을 위해 앞에서 잠깐 설명한 자체 개발한 운영 관리 도구에 Ingress를 생성하는 기능을 만들어서 사용하고 있습니다.

create_kube_ingress

빌드, 배포 환경 접근에 대한 권한

수백개 이상의 프로젝트가 빌드, 배포되어야 하는 상황이기 때문에 몇명의 인프라 담당자 또는 배포 담당자가 모든 프로젝트에 관여하는 것은 실질적으로 불가능하다고 할 수 있습니다. 따라서 필자가 있는 조직에서는 개발자에게 아주 엄격하게 빌드 및 배포 환경에 대한 권한 제한을 두기 보다는 어느 정도 권한을 주어 한명이 관리할 수 있는 프로젝트의 개수를 줄여 주는 방향으로 관리하고 있습니다.

맺음말

최근 한국에 오래 머물러 있으면서 여러 회사에 중국에서 접한 내용을 전파하는 세미나를 수 차례 진행하고 있습니다. 주요 내용이 중국의 모바일 환경과 이에 대응하기 위한 시스템 구성 및 개발 프로세스, 아키텍처 등 입니다. 이 내용 중 마이크로 서비스 관련된 내용 중 핵심은 처음부터 너무 많은 것을 고민하지 말고, 작은 서비스를 개발하고, 배포, 운영하면서 서비스가 추가되거나 문제가 발생하거나 했을때 문제를 해결해 나가는 방식으로 진행하는 것을 강조하고 있습니다. 대부분의 조직에서 초기에 마이크로 서비스로 전환할 때에는 서비스가 많아야 5 ~ 10개 정도일 것입니다. 이런 단계에서는 서비스 매쉬라던가 심지어는 Kubernetes 까지 고려할 필요도 없다고 생각합니다.

제가 있는 개발팀도 초기에는 몇개 안되는 서비스 운영을 시작으로 지금은 300개 이상의 서비스가 서로 상호 연동되어 운영되면서 하나의 큰 서비스를 구성하고 있습니다. 이를 위해 처음부터 많은 것을 갖추고, 검증하고 시작한 것이 아니라 현재 시점에서 딱 필요한 기술이나 방법을 사용하고 계속 개선해 나가면서 지금까지 왔습니다.

각주

1) 이 글의 많은 내용은 필자가 직접 운영했던 경험이 아니라 이번 행사에서 실제 Kubernetes를 운영하는 중국 개발자와 공동 발표를 하면서 어떻게 운영하는지에 대한 경험을 공유 받고 제가 다시 한글로 정리하는 차원에서 작성한 글입니다. 혹시 내용 중에 기술적 착오나 문제가 있으면 알려주시면 교정하도록 하겠습니다.

2) 아주 특별하지 않으면 Pod 하나당 컨테이너 하나를 실행하고 있기 때문에 서버 한 대당 50개 정도의 컨테이너가 실행

3) Docker의 특성 상 이미 있는 이미지의 경우 변경 사항만 받기 때문에 많지 않지만, 최초 한번은 받아야 함

4) 이런 이유때문인지 공개된 많은 Docker Image를 보면 nc, curl 등과 같은 간단한 네트워크 도구도 없고, vim 도 없는 경우가 많이 있습니다.

5) 물론 자체 Jenkins 이미지를 만들어 필요한 빌드 환경이 구성된 Jenkins 서버를 운영할 수는 있습니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.