Rhythm Up - 역대글
▶ Docker 배포 최적화
지난 프로젝트에서 GitHub Actions와 Docker를 활용해 배포 작업을 진행했다. 초기에는 별다른 문제 없이 서비스를 배포했으며, 새로운 서비스도 동일한 방식으로 자동 배포를 설정했다. 그러나 예상치 못한 큰 문제에 직면하게 되었다.
배포된 Docker Image의 크기 문제이다.
Docker Build를 통해 생성된 이미지의 크기가 무려 4.6GB에 달했던 것이다. 단순히 Build를 했을 때는 나올 수 없는 크기였지만, 배포 과정에서 이미지 크기가 비정상적으로 커지면서 예상치 못한 용량 문제가 발생했다.
또한,GitHub Actions의 작업 시간이 6분, 7분이 되는 것을 확인하였다. 배포된 이미지의 크기가 크기 때문에 발생한 문제일 것 같다고도 생각되지만 우선 별게의 문제로 생각하고 해결 방법을 찾기 시작했다.
문제를 파악한 뒤, 이렇게 커진 Docker 배포 최적화를 위한 방법들을 고민하게 되었고, 최종적으로 문제를 해결할 수 있었다. 이번 글에서는 Docker Image의 용량이 커지는 원인을 분석하고, 이를 해결하기 위한 최적화 과정을 공유하려고 한다.
Docker 배포에 대한 기본적인 내용은 이전 글에서 다뤘으니, 해당 글을 참고해주시면 좋겠습니다.
과도하게 큰 node_modules
Docker 이미지의 용량을 확인해본 결과, 가장 큰 부분을 차지하는 것이 바로 node_modules였다.
배포 과정에서 RUN yarn install --production 명령어를 사용해서 프로덕션 환경에 필요한 패키지만 설치하도록 했지만, 여전히 불필요한 라이브러리를 포함하고 있었다.
예를 들어, styled-components와 같은 라이브러리는 이미 빌드 단계에서 필요한 코드로 번들링이 되어있기 때문에 실행 시점에서는 더이상 필요하지 않다. 하지만 node_modules 디렉토리에는 여전히 이러한 라이브러리가 포함되어 있다.
문제의 원인
- 프로덕션 환경에서도 모든 라이브러리를 포함
개발 환경 관련 패키지(devDependencies)를 제외하지만, 런타임에서 반드시 필요하지 않은 라이브러리까지 포함 - 라이브러리 의존성 관리 한계
일부 라이브러리는 런타임에 사용되지 않음에도 불구하고 의존성 트리를 통해 자동으로 포함되면서 불필요한 용량 증가를 초래했다.
단순히 필요한 라이브러리만 설치하는 방식으로는 한계가 있었다. 이를 해결하기 위해 빌드 아티팩트만 남기고, 런타임에 불필요한 라이브러리를 제거하는 방법이 필요했다.
꼭 필요한 요소만! 그러나 너무 지나친 다이어트...
Next.js는 프로덕션 배포를 위해 필요한 파일만 복제해 standalone 폴더에 자동으로 생성하는 기능을 제공한다.
Next.js can automatically create a standalone folder that copies only the necessary files for a production deployment including select files in node_modules
이 기능은 output의 standalone 옵션을 사용해서 활성화 할 수 있으며, .next 폴더 안에 standalone 폴더를 생성하면서 실행에 필요한 node_modules와 프로덕션 코드를 포함하고 있다.
이 방식은 프로덕션에 필요한 파일만 포함하여 Docker Image의 크기를 획기적으로 줄여줄 수 있는 장점이 있다.
그러나 문제가 발생했다.
나의 개발 환경은 순수 Next.js 기반이 아니라 Custom Server를 활용하여 Socket.io를 처리하고 있었다.
즉, 단순히 app 폴더(클라이언트)만 빌드하면 되는 상황이 아니었고, 서버 코드를 포함해야 했다.
정리하면 standalone 옵션으로 생성된 node_modules는 클라이언트 실행 환경에 필요한 라이브러리만 포함하고 있다. 그래서 서버에서 Socket.io를 실행하려고 할 때, 필요한 라이브러리가 node_modules에 없어서 오류가 발생한다.
next.js의 Discussions에 나와 같은 고민을 한 사람이 있었다. 거기에 해결 방법이 될 부분도 있었다.
정리하면, 빌드 후 서버에 필요한 라이브러리를 직접 주입하는 방식을 제안했다.
예를 들어 :
- 빌드 후 누락된 라이브러리를 파악하여 수동으로 추가
- node_modules의 특정 라이브러리들을 직접 복사
하지만 근본적인 문제가 있는데 :
- 의존성 관리의 복잡성
- Socket.io 같은 라이브러리는 추가적인 의존성을 가지는 경우가 많다.
- 누락된 의존성까지 하나씩 파악하고 모두 추가하는 작업은 매우 비효율적이다.
- 유지보수의 어려움
- 라이브러리가 업데이트 되거나, 새로운 의존성이 추가될 때마다 이를 다시 확인하고 추가하는 작업을 해야 한다.
- 장기적으론 유지보수 비용을 크게 증가시킬 수 있다.
라이브러리를 직접 주입하는 방식은 임시 해결책이 될 수 있지만, 실질적으로 모든 의존성을 수동으로 관리하는 것은 현실적이지 않다.
serverExternalPackage와 해결을 위한 시도
이번에 소개할 방법은 Next.js의 정상적인 사용법이 아닐 수 있다. 테스트를 진행하면서 발견한 일종의 꼼수로, 정확히 어떻게 동작하는지 완전히 이해하지는 못했지만, 문제 해결에는 효과적이었다.
serverExternalPackages란?
Next.js는 번들링 과정에서 특정 패키지를 제외하고, 서버 실행 시 해당 패키지를 node_modules에서 직접 로드하도록 설정할 수 있는 옵션인 serverExteranlPackages를 제공한다.
이 옵션 하나만 사용해서는 문제를 해결할 수 없었다. standalone 빌드 과정에서 클라이언트와 서버 코드 간에 필요한 의존성 관리가 완벽히 이루어지지 않았기 때문이다.
문제 해결
serverExternalPackages를 설정한 후, 클라이언트 환경에서 서버 패키지를 import 하는 방식으로 문제를 우회했다.
1. serverExternalPackages 설정
import { NextConfig } from "next";
const config: NextConfig = {
// ...
serverExternalPackages: ["socket.io"],
};
export default config;
서버에서 사용하는 패키지의 명칭을 배열 형식으로 제공해주고 있다. 해당 라이브러리는 번들링 시 포함하지 않는다.
2. 서버에서 필요한 라이브러리 파일 생성
// /lib/server/modules.ts
import "socket.io";
클라이언트 환경에서 서버 패키지를 간접적으로 import 하기 위해, 별도의 모듈을 작성했다.
이 파일은 서버에서 사용하는 라이브러리를 정의한 모듈로 필요한 패키지를 단순히 import하는 역할이다.
3. 클라이언트 환경에서 해당 모듈 import
클라이언트 진입점인 app/layout.ts에서 이 파일을 import하여 클라이언트 빌드 과정에 포함되도록 한다.
// app/layout.ts
// ...
import "@/lib/server/modules.ts";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={pretendard.variable}>
// ...
</body>
</html>
);
}
Dockerfile 수정
FROM node:20.16-alpine3.19 AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
WORKDIR /build
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY . .
RUN yarn build
FROM base AS runner
WORKDIR /app
COPY --from=builder /build/public ./public
COPY --from=builder /build/.next/standalone ./
COPY --from=builder /build/.next/static ./.next/static
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules/next ./node_modules/next
EXPOSE 3001
CMD ["yarn", "start"]
배포 방식이 변경됨으로 Dockerfile도 수정이 되었다. 핵심은 다음과 같다.
FROM base AS runner
WORKDIR /app
COPY --from=builder /build/public ./public
COPY --from=builder /build/.next/standalone ./
COPY --from=builder /build/.next/static ./.next/static
COPY --from=builder /build/dist ./dist
COPY --from=builder /build/node_modules/next ./node_modules/next
기존에는 .next 폴더의 모든 파일을 가지고 왔다면 이번엔 standalone 폴더와 static 폴더만 가지고 왔다.
standalone 옵션을 통해 배포를 위한 클라이언트 파일이 구성되어 있기 때문이다.
추가로 node_modules 폴더에 있는 next 파일만 가지고 왔다.
Custom Server로 구성되어서 배포 버전의 next 의존성과 다른 문제가 있는지 정상적으로 동작하지 않아서 추가해주었다.
총총.
이 방법은 긴급 상황에서의 임시 해결책으로 유용할 수 있다.
나도 현재 상황에선 Custom Server와 Standalone 연계 설정에 대한 내용이 적어서 다음과 같은 방법을 사용했지만 이후 프로젝트를 진행하면서 해결할 수 있다면 변경해서 작업할 계획이다. 하지만 비슷한 문제를 겪는 개발자에게는 참고가 되길 바란다.
'도커' 카테고리의 다른 글
Nginx HTTPS (2) | 2024.12.28 |
---|---|
[Docker] Node 환경 만들기 (2) | 2022.03.13 |
[Docker] 도커? (2) | 2022.03.11 |