0. 모노레포 개선 배경
재사용성 향상 및 배포 일원화 등의 이점을 가져오기 위해 우리팀은 멀티레포로 서비스마다 흩어져있던 코드들을 2020년에 모노레포를 구축했다. 클라이언트 영역의 코드들을 패키지 단위로 관리하기 위한 Yarn Workspace, 플랫폼화 된 SSR환경 구축 및 성능 최적화나 개발 편의성을 적은 리소스로 얻어 서비스 개발에 집중할 수 있도록 Next.js 프레임워크를 도입했다.
그런데 처음 항공을 시작으로 호텔, 이벤트, 출장 등 여러 서비스들이 추가되면서 점차 규모가 커졌음에도 불구하고 레포의 기반이되는 세팅 부분은 크게 변경되지 않은채 계속해서 사용되고 있었다. 나는 이 모노레포를 개선하면 팀원들의 개발 경험이 크게 좋아지겠다고 생각해서 다음 네 가지 작업을 계획했다.
- 패키지 추가/수정에 유연한 구조로 변경 DONE
- 빌드 및 도커 이미지 최적화 _DONE
- CI 개선 TODO
- 패키지 매니저 개선 TODO
1. 패키지 추가/수정에 유연한 구조로 변경
제일 먼저 모노레포의 장점 중 ‘서비스를 쉽게 추가/운영할 수 있다’는 점을 되살리고 싶었다.
처음에는 서비스가 추가되더라도 적은 리소스로 서비스 Release를 할 수 있었다. 하지만 팀의 커뮤니케이션이 파트 단위로 쪼개지고, 각 서비스별로 변경점이 많아지기 시작하면서 내가 담당하는 패키지에는 변경점이 없음에도 불구하고 배포에 어려움을 겪는 경우가 점차 많아지기 시작했다. 그래서 다른 파트와 반복되고 불필요한 커뮤니케이션 없이 독립적으로 배포할 수 있도록 다음과 같이 구조를 개선했다.
1-1. Dynamic Docker Base Image
# AS-IS
FROM example.registry.com/shared/alpine:3.18/node:20.11.0
기존에는 핵심이 되는 세팅(ex. Next.js, Node.js)은 모두 동일하게 가져가야한다고 의견이 모아져서 고정된 base image를 사용했다. 그래서 어느 패키지에서 라이브러리 버전을 올리거나 새로운 것을 추가하려면 항상 다른 패키지를 고려해야만 했고, 단순한 업그레이드인 경우에도 모든 패키지 담당자들의 리소스가 들어가야만 했다.
# TO-BE
ARG WORKSPACE=blog
ARG OS_TYPE=alpine
ARG OS_TAG=3.18
ARG NODE_VERSION=20.11.0
FROM example.registry.com/${WORKSPCE}/${OS_TYPE}/${OS_TAG}/node:${NODE_VERSION}-latest
우선 Dockerfile 코드의 변경없이 배포 파라미터만 변경해서 base image를 변경할 수 있도록 Docker ARG를 static하게 선언했다. Docker version 17.05 - Allow using build-time args (ARG) in FROM을 참고해서 빌드할 때 관련 ARG를 정의해두면 유동적으로 Docker image를 불러올 수 있게 변경했다. 만약 빌드 할 때 ARG를 정의하지 않으면 Dockerfile에 정의된 값을 default로 사용하게 된다.
그 다음 사용 중이던 private registry 안에 업무 조직 단위의 workspace를 두고 이미지를 pull 하도록 변경했다. 그래서 새로운 업무 조직이 생기거나, 조직 구조가 변경되더라도 기존 서비스들은 영향을 받지 않도록 의존성을 제거했다.
1-2. OS와 Node Version Upgrade
더 이상 지원되지 않는 Node version v12, v14를 사용하고 있어서 이번 기회에 쉽게 버전을 올릴 수 있도록 작업했다.
사용 중이던 private registry 안에 업무 조직 단위의 workspace를 두고 이미지를 pull 하도록 변경했다.
편하게 위 작업을 진행하려면 docker image build & push
작업 또한 간편해야 한다고 생각했다. 그래서 단순히 버전을 올리는 것 보다는 간단한 명령어로 registry에 이미지를 올릴 수 있도록 만들었다.
-
Dockerfile
ARG UBI_TAG=latest FROM redhat/ubi8:${UBI_TAG} USER root ARG NODE_VERSION # Install packages # ... # Install nvm ARG NVM=DIR={원하는 nvm path로 정의} ARG NVM_VERSION={원하는 default version으로 정의} RUN mkdir ${NVM_DIR} RUN curl --silent -o- https://raw.githubusercontent.com/creationix/nvm/v${NVM_VERSION}/install.sh | bash # Add Node And NPM to path for commands ENV NODE_PATH=${NVM_DIR}/v${TAG}/lib/node_modules ENV PATH ${NVM_DIR}/versions/node/v${TAG}/bin:${PATH} # Install node and yarn RUN source ${NVM_DIR}/nvm.sh && \ nvm install ${TAG} && \ nvm use ${TAG} && \ npm install -g yarn
-
Makefile
DOCKER_REGISTRY ?= sample.registry.com WORKSPACE = $(WORKSPACE) NODE_VERSION = $(NODE_VERSION) UBI_TAG = $(UBI_TAG) DOCKER_PATH := $(DOCKER_REGISTRY)/${WORKSPACE} BUILD_DATE = $(shell date +%Y%m%d) .PHONY: build-node-image build-node-image: @echo 'Building node docker image' docker build \ -t $(DOCKER_PATH)/ubi8/$(UBI_TAG)/node:$(NODE_VERSION)-$(BUILD_DATE) \ -t $(DOCKER_PATH)/ubi8/$(UBI_TAG)/node:$(NODE_VERSION)-latest \ --build-arg UBI_TAG=$(UBI_TAG) \ --build-arg NODE_VERSION=$(NODE_VERSION) \ ./ .PHONY: push-node-image push-node-image: @echo 'publishing node docker image' docker push $(DOCKER_PATH)/ubi8/$(NAVIX_TAG)/node:$(TAG)-$(BUILD_DATE) docker push $(DOCKER_PATH)/ubi8/$(NAVIX_TAG)/node:$(TAG)-latest
위 두 개 파일이 있는 경로에서 다음과 같이 명령어를 실행하면 docker image를 저장소에 올릴 수 있다.
# Build & Push Node Image
make WORKSPACE={input_workspace} NODE_VERSION={input_node_version} UBI_TAG={input_ubi_tag} build-node-image
make WORKSPACE={input_workspace} NODE_VERSION={input_node_version} UBI_TAG={input_ubi_tag} push-node-image
# examples
make WORKSPACE=blog NODE_VERSION=18.18.2 UBI_TAG=8.9-1107 build-node-image
make WORKSPACE=blog NODE_VERSION=18.18.2 UBI_TAG=8.9-1107 push-node-image
결과를 확인해보면 {이미지명}-{현재날짜}
, ${이미지명}-latest
두 개가 생기는데, docker image pull 하는 곳에서 latest
태그로 항상 최신 이미지를 불러올 수 있게 세팅하고자 했다.
1-3. node-sass -> sass
SCSS를 다루기 위해 사용 중이던 Node-sass의 경우 Node 버전에 의존성을 가지고 있어 Node버전을 올리는데 항상 번거로움이 있었습니다(라이브러리가 deprecated 된 것도 문제). 그래서 sass로 변경해서 빌드시간도 빨라지고, Node 의존성도 제거했다.
이 과정에서 대표적인 오류 수정사항은 다음과 같다.
- 변수에 css 문법상 ‘하나의 값’이 아닌 ‘여러 값의 조합’으로 인식되는 경우는 더 이상 적용되지 않는다.
/* AS-IS */
$media-types-pc: screen and (min-width: 1200px);
.something {
@media #{$media-types-pc} { ... }
}
/* TO-BE */
$media-type-pc: 1200px;
@mixin media-pc {
@media screen and (min-width: $media-type-pc) {
@content;
}
}
.something {
@include media-pc { ... }
}
- 괄호 안에 계산식이 들어간 경우
sass 차원에서 계산을 하게 되는 경우에도
calc, div, floor, ceil
등의 명령을 써야만 한다.
/* AS-IS */
.something {
line-height: (18 / 13);
}
/* TO-BE */
.something {
line-height: calc(18 / 13);
/* or */
line-height: div(18, 13);
}
1-4. CI - Build Test Github Workflow
위 작업에도 불구하고 새로운 작업 내용이 다른 패키지에 영향을 줄 가능성은 여전히 존재했다. 그래서 패키지에 영향을 줄 수 있는 영역에서의 변경이 있을 경우 영향 받는 패키지들에 대한 빌드 테스트를 Github Workflow에 추가했다. 이 내용은 3번 주제인 CI개선에서 자세하게 작성하려고 한다.
2. 빌드 및 도커 이미지 최적화
- Build: 17분
- Deploy: 3분
호텔 서비스를 배포할 때 평균 20분 정도 소요되면서 hotfix를 해야하는 경우에도 빠르게 대응하지 못하는 경우가 많았다. 그러다보니 정기배포 날에 배포 담당자의 대기 시간이 지나치게 길어지고, 기획자와 QA를 진행할 때 커뮤니케이션 비용이 계속해서 늘어나는 불편함이 있어서 개선이 필요했다.
그 원인으로 최적화되지 않은 빌드, 배포 라고 정의하고 문제를 하나씩 해결했다.
2-1. Dockerfile 개선
기존 Dockerfile은 빌드 과정에 사용된 모든 파일을 배포 이미지에 올려서 사용하고 있었다.
# Base Image
FROM example.registry.com/shared/alpine:3.18/node:20.11.0
ARG PROJECT_NAME=blog
# Install Global Packages
RUN yum install -y ...
# Install Node Packages
...
RUN yarn install --production=false
# Copy And Build Workspace
COPY packages/${PROJECT_NAME} /app/packages/${PROJECT_NAME}/
RUN yarn workspace @my-monorepo/${PROJECT_NAME} build
최종적으로 사용되는 이미지에는 필요한 파일만 올라가도록, 그리고 Docker Layer 작업이 이전과 동일할 경우 Cache가 적용되도록 Docker Multi-stage builds 를 적용했다.
Docker Multi-stage 장점을 요약하면 다음과 같다.
- 여러 개의
FROM
을 통해 스테이지별로 base 설정 가능하다. - 이전 스테이지에서 리소스를 가져올 수 있다.
- 각 스테이지는 새로 빌드할 수 있다.
그래서 다음과 같이 스테이지를 나누었다.
FROM example.registry.com/shared/alpine:3.18/node:20.11.0 AS base
FROM base AS deps
FROM base AS builder
FROM base AS runner
이렇게 나누어 두 가지 이점을 얻었다.
- 스테이지가 별렬적으로 동작해서 소요시간 줄어듦
- 실제 서버에 사용되는
runner
스테이지에는 런타임에 필요한 리소스들만 남기는 것이 가능해져 이미지 크기가 가벼워짐
그 다음 dockerfile:1.2
에 추가된 캐시 마운트 를 적용했다. 로컬에서 의존성을 설치하거나 빌드를 할 때와 달리, Dockerfile 내에서는 일반적으로 처음부터 다시 실행하게 된다. 이 때문에 cached layer를 활용하지 못하게 되어 빌드 시간이 크게 늘어날 수 밖에 없다. 따라서 의존성을 설치할 때 캐시 마운트를 활용해서 로컬에서 실행하는 것과 비슷한 효과를 내도록 했다.
Kubernetes를 사용할 경우 Ephemeral Volumes 를 활용할 수 있다. 하지만 한정적인 메모리, Pod이 삭제되면 사용 중이던 데이터가 모두 함께 사라진다는 점, 그리고 여러 개의 Pod 간에 데이터 공유가 어렵다는 점에서 Persistent Volumes 를 사용했다. Github Runner와 서비스 Pod를 동일한 클러스터에 두고, PV를 활용하면 캐시를 공유할 수 있는데, 추후 CI/CD를 개선할 것도 고려했다.
이 기능을 사용하려면 Dockerfile 첫 줄에 # syntax=docker/dockerfile:1.2
를 선언해야 한다.
ENV YARN_CACHE_FOLDER="{yarn cache 저장할 경로}"
ENV BUILD_CACHE_FOLDER="{build cache 저장할 경로}"
RUN mkdir -p ${YARN_CACHE_FOLDER}
RUN --mount=type=cache,target=${YARN_CACHE_FOLDER},id=install-modules \
yarn install --production=false
RUN --mount=type=cache,target=${BUILD_CACHE_FOLDER},id=project-build \
yarn workspace @my-monorepo/${PROJECT_NAME} build
Change the cache path for yarn 설명에 YARN_CACHE_FOLDER
환경 변수에 yarn cache 경로를 세팅할 수 있다고 나와있다. 나는 이 경로에 PV를 넣어서 Pod을 여러개 띄우더라도 캐싱이 지속적으로 적용되도록 설정했다.
동일한 코드로 배포했을 때 다음과 같이 캐시가 정상적으로 적용되는 것을 확인할 수 있었다.
#1 [deps 3/3] RUN --mount=type=cache,target={yarn cache 저장할 경로},id=install-modules yarn install --production=false
#1 CACHED
#2 [builder 7/7] RUN --mount=type=cache,target={build cache 저장할 경로},id=project-build yarn workspace @my-monorepo/blog build
#2 CACHED
2-2. 빌드 최적화
가장 먼저 babel에서 swc로 변경되어 빌드가 더욱 최적화된 Nextjs v14로 버전을 업그레이드했다. 이 작업으로 dev와 real 환경 모두 빌드 속도가 빨라졌을 뿐만 아니라, dev 환경에서의 HMR이 엄청나게 빨라져서 개발 경험이 더욱 좋아졌다.
거기에 JS, TS 코드 베이스 모노레포를 위한 고성능 빌드 시스템인 Turborepo 를 적용했다. 모노레포 성능 향상 및 관리를 우리가 직접하기 보다는 계속해서 발전되고 있는 Turborepo를 이용해서 개발에 더 집중할 수 있는 환경을 구축하고자 했다. (업무 위탁이랄까?)
다양한 선택지가 있었지만
- CI 고도화 진행 시 스크립트 의존성 분리 기능
- local, self-hosted-runner, 배포 시스템에서의 빌드 캐시 공유 기능 제공
- 태스크 단위 병렬 실행 및 최적화
- 빌드에 필요한 요소만으로 이루어진 모노레포 하위 집합 생성 기능 제공
네 가지 기능 및 장점을 가져올 수 있겠다고 판단해서 Turborepo를 선택했다.
이번에 가장 먼저 적용한 것은 Pruned subsets 기능이다.
turbo prune @my-monorepo/blog @my-monorepo/shared --docker
처럼 turbo prune 명령어를 실행하면
out
- /full
- /packages
- // 실행에 필요한 파일들
- /json
- /packages
- /common/package.json, /${PROJECT_NAME}/package.json
- package.json
- yarn.lock
- yarn.lock
out
이라는 디렉토리가 생성된다.
그 안에는 모듈 설치 정보만 담긴 json
, 실행에 필요한 모든 코드가 들어있는 full
두 개의 디렉토리가 있다.
prune을 통해 생성된 정보를 Dockerfile에 적용했다.
# deps
COPY --from=prune ./out/.yarnrc ./out/json ./
# builder
COPY --from=prune ./out/.yarnrc ./out/yarn.lock ./out/yurbo.json ./
COPY --from=prune ./out/full ./
기존에 Dockerfile 내에서 빌드하고자 하는 패키지들에 대해서 모듈과 코드를 분리해서 복잡하게 COPY하던 작업을 훨씬 간단하게 가져올 수 있게 되었다.
또한 prune으로 Monorepo subsets를 얻는 작업과 Next 버전 올릴 때 같이 적용한 output: standalone 옵션이 크게 시너지 효과를 냈다.
이 옵션으로 Next App을 빌드하면 웹앱을 실행하는데 필요한 최소 코드만 추출되는데, prune으로 코드가 가지치기 되어있는 상황에서 모듈 설치 및 빌드를 실행하면서 빌드 결과물 크기가 작아졌을 뿐만 아니라 전보다 더 빠르게 동작했다.
거기에 standalone의 결과물인 minimal server.js
로 웹앱을 실행해서 pod이 새로 띄워졌을 때 이전보다 더 빠르게 복구가능하게 되었다.
개선 결과는 다음과 같다.
이전 버전 | 현재 버전 | diff | |
---|---|---|---|
최종 빌드 시간 | 16m 51s | 5m 27s 🔥🔥🔥 | -11m 24s |
Image size | 981.5MB | 462MB | -519.5MB 🔥🔥🔥 |