Post

프로젝트 Docker로 배포하기

프로젝트를 Docker 컨테이너로 배포하는 과정에 대해서 설명합니다.

1. Docker를 사용하기로 한 이유

3월 초, 운영하고 있는 서비스를 다른 서버로 이전한 적이 있었다(링크) 링크에 있는 글을 보면 알수 있지만 새로운 VM에서 프로젝트 환경을 세팅하기 위해서 git, node, npm, java등을 설치하고 Nginx도 설치해서 웹서버 설정 정보도 다시 적용해주고 SSL을 사용하기 위해서 기존에 사용하던 공개키, 비밀키를 복사해 가져오는 등 여러가지 작업이 필요했다. 당시 처음 서버를 이전해보는 것이라 반나절 쯤 걸렸던 것 같다.

내가 운영하고 있는 서비스는 한달에 1.6만명 정도의 사용자가 있기 때문에 비용이 소모되고 계속해서 서버를 이전하게 되는 것은 필연적이다. 3월초에 서버를 이전하고 나서 매번 서버를 이전할 때마다 이런 과정을 거쳐야하는걸까? 라는 생각이 자리잡았다. 반나절을 고생해서 겨우 세팅했는데 이걸 매번 다시해야 한다고? 물론 처음할 때와는 다르게 소모되는 시간은 적어지겠지만 그래도 세팅이 번거롭다는 것은 매한가지이다.

해결방법을 찾던 중 예전에 듣기만 하고 넘겨버린 Docker라는 키워드가 다시 눈에 들어왔다. 그때는 개발환경 세팅을 편하게 해주는 도구 정도로 알고 있었다. 필요가 없었고 내 성향이 필요하지 않은데 남들이 쓴다고 나도 따라 사용하는것에 거부감이 있었기 때문에(일종의 홍대병) 시도하지 않았다. 하지만 반복해서 개발환경을 세팅해야하는 지금, Docker의 도움이 절실해졌다.

2. 프로젝트 환경

내 프로젝트는 Nginx에서 요청을 받아 /api 요청이면 Spring Boot WAS로 요청을 전달하고, 그 이외의 요청은 리액트를 빌드해서 나온 index.html을 응답하는 형식으로 되어었다. Docker를 처음 사용하면서 프로젝트의 도커 컨테이너 구성을 어떻게 해야할지 고민이 되었다. Spring Boot를 빌드해서 나온 jar 파일과 react를 빌드한 결과를 이미지로 담아서 배포해야하나?

처음에는 백엔드와 프론트엔드를 똑같은 도커이미지에 담으면 되는 것으로 생각했다. 하지만 하나의 컨테이너에는 하나의 프로세스가 작동하는 것이 좋다고 한다. 그래야 나중에 교체가 필요할 때 원하는 컨테이너만 갈아끼울 수 있다.

위의 그림과는 다르게 Nginx와 React를 다른 도커 컨테이너로 구성해도 된다. 나는 /api로 오는 요청이 아니면 전부 index.html를 응답으로 반환하고 싶었기 때문에 그 과정을 단순화 하기 위해서 Nginx와 react, 빌드결과를 같은 컨테이너 안에 담았다.

3. Dockerfile 작성

Dockerfile은 스프링 부트 애플리케이션이 들어가는 backend와 nginx와 react가 들어가는 frontend 부분으로 나뉜다.

3-1. backend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM openjdk:17-oracle as builder
WORKDIR /backend
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY src src
RUN microdnf install findutils
RUN chmod +x ./gradlew
RUN ./gradlew clean build

FROM openjdk:17-oracle
COPY /build/libs/gongnomok-app.jar ./app.jar
ENTRYPOINT [ "java", "-jar", "/app.jar" ]
VOLUME [ "/tmp" ]

스프링 부트 백엔드 애플리케이션이 있는 폴더로 들어가 Dockerfile을 만들고 위와같이 작성해주었다. 한줄씩 읽으면서 어떤 과정을 거치는지 살펴보자

FROM openjdk:17-oracle as builder

  • 애플리케이션이 java 17을 기반으로 하고 있기 때문에 도커 허브에서 openjdk:17-oracle 이미지를 기반으로 한다는 의미입니다.

WORKDIR /backend

  • 도커 컨테이너의 작업 디렉토리를 /backend로 전환합니다.

COPY gradlew .

  • 현재 애플리케이션 폴더에 있는 gradlew 파일을 컨테이너의 .(/backend)로 카피합니다.
  • 아래 카피하는 과정은 이 경우와 같습니다. 전부 현재 애플리케이션 폴더에 있는 파일을 컨테이너로 복사하는 과정입니다.

RUN microdnf install findutils

RUN chmod +x ./gradlew

  • gradlew의 권한을 실행가능으로 변경

RUN ./gradlew clean build

  • gradlew 빌드해서 jar 파일을 만듭니다.

COPY /build/libs/gongnomok-app.jar ./app.jar

  • 빌드된 jar 파일은 ./build/libs 디렉토리 안에 존재합니다.
  • 빌드된 jar 파일을 현재 위치 .(/backend) 안에 app.jar 라는 이름으로 복사합니다.

ENTRYPOINT [ "java", "-jar", "/app.jar" ]

  • 이미지가 실행될 때 실행되는 명령어 입니다. java -jar app.jar 명령으로 스프링 애플리케이션을 실행합니다.

3-2. frontend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM node:20.11.0-bullseye as builder
WORKDIR /frontend
COPY . .
RUN npm install
RUN npm run build

FROM nginx:1.18.0-alpine
RUN rm /etc/nginx/conf.d/default.conf
RUN rm -rf /etc/nginx/conf.d/*
COPY ./default.conf /etc/nginx/conf.d/

COPY --from=builder frontend/dist /usr/share/nginx/html
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"]

FROM node:20.11.0-bullseye as builder

  • 기반이 되는 노드 이미지를 지정합니다.
  • 로컬에서 사용하는 node 버전이 20.11.0 이였기 때문에 그에 맞는 이미지를 지정하였습니다.

WORKDIR /frontend

  • 도커 컨테이너의 작업 디렉토리를 /backend로 전환합니다.

COPY . .

  • 현재 호스트 작업 폴더의 내용을 .(/frontend) 로 복사합니다.

RUN npm install RUN npm run build

  • 필요한 의존성을 설치하고 애플리케이션을 빌드합니다.

FROM nginx:1.18.0-alpine

  • 기반이 되는 Nginx 엔진 이미지를 설치합니다. 기존에 사용하던 Nginx 버전이 1.18.0 이였기 때문에 따라서 설치하였습니다.

RUN rm /etc/nginx/conf.d/default.conf

  • 기본 Nginx 설정정보를 담고있는 default.conf를 삭제합니다.

COPY ./default.conf /etc/nginx/conf.d/

  • 미리 작성해준 Nginx 설정정보 (호스트의 ./default.conf)를 /etc/nginx/conf.d 디렉토리로 복사합니다.

COPY --from=builder frontend/dist /usr/share/nginx/html

  • vite를 이용해서 빌드하였기 때문에 빌드결과와 에셋들이 dist 폴더에 저장되었습니다.
  • 이 폴더를 nginx의 html폴더로 복사해줍니다.
  • 빌드된 결과를 컨테이너로 이동시키는 것이기 때문에 꼭 위와 같은 경로가 아니더라도 Nginx를 통해서 해당경로를 바라보게 할 수 있습니다.

EXPOSE 80 443

  • 80포트와 443 포트를 외부에 공개합니다.

CMD ["nginx", "-g", "daemon off;"]

4. docker compose 작성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
version: "1.1.2"
services:
  back:
    build:
      context: ./backend
      dockerfile: Dockerfile
    image: sjhn/gongnomok-frontend:1.1.2
    restart: always
    ports:
      - 8080:8080
  front:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    image: sjhn/gongnomok-backend:1.1.2
    restart: always
    ports:
      - 80:80

도커 컴포즈를 위해서 docker-compose.yml 파일을 작성해주었다. 서비스는 back, front 라는 두개의 이름으로 나뉘고 각각의 build에는 Dockerfile이 존재하는 위치(context)와 도커파일의 이름(Dockerfile)을 지정해준다. image는 도커파일을 빌드했을 때 만들어지는 이미지의 이름과 태그를 지정할 수 있다. ports 옵션으로는 호스트의 어느 포트를 이미지의 어떤 포트와 연결해줄지 지정해주는 부분이다. back 서비스의 경우 호스트의 8080포트를 이미지의 8080포트와 연결해주고, front는 호스트의 80포트를 이미지의 80포트와 연결해주고 있다.

5. docker compose 빌드

1
docker compose build

6. 도커 허브에 푸시

docker images 명령으로 생성된 이미지를 확인해보면 docker-compose.ymlimage 옵션에 지정한 이름대로 이미지가 잘 생성된 것을 확인할 수 있다.

이렇게 만들어진 이미지를 각각 도커 레포지토리에 push 해주면된다. 물론 레포지토리는 미리 만들어져 있는 상태여야 한다.

1
2
docker push sjhn/gongnomok-backend:태그
docker push sjhn/gongnomok-frontend:태그

7. 트러블 슈팅

7-1. xargs is not available

에러의 이름에서도 알 수 있듯이 xargs 라는 것을 사용할 수 없기 때문에 발생하는 문제이다. Gradle 7.5부터 xargs가 존재하는지 명시적으로 확인하는 옵션이 추가되었기 때문에 이러한 에러가 발생한다고 한다.

링크의 이슈를 봤을 때 deprecated된 JDK 도커이미지를 사용했을 때 발생하는 문제라고 한다. 이슈에 답변을 사람이 몸소 실험을 해본결과 deprecated되지 않은 JDK이미지에는 xargs가 포함되어 있었다고 한다. 해당 이미지 목록은 이슈 링크에 있다.

또는 스택오버플로우 질문을 보면 JDK 도커 이미지를 변경하지 않고도 해결할 수 있는 방법이 적혀있다.

openjdk:17-oracle 이미지는 Oracle Linux를 기반으로 하고 있기 때문에 다음 명령이 필요하다

1
RUN microdnf install findutils

alpine 기반 이미지에서는 다음과 같다.

1
RUN apk update && apk add findutils

7-2. 오래된 esbuild 버전

Dockerfile의 RUN npm run build 명령을 수행하던 중 다음과 같은 에러메세지가 발생했다.

[ERROR] Cannot start service: Host version “0.19.12” does not match binary version “0.20.2”

여러 이슈들을 찾아보니 꾸준히 이어졌던 문제인듯 하다. 오래된 esbuild가 설치되어있기 때문에 발생하는 문제이고 node_modules 폴더를 삭제한 다음에 npm install을 재실행해주면 문제가 해결된다고 한다. 나같은 경우는 RUN npm install이 실행되기 전에 폴더에 있는 데이터를 카피해(COPY . .) 버전이 충돌하는 문제를 방지했다.

7-3. 맥북 M1 이미지 빌드 플랫폼 호환성 에러

이미지도 잘 만들고, 도커 허브 레포지토리에 푸시도 잘하고, 이제 가상머신에서 도커를 설치하고 도커 이미지를 pull 받기만 하면된다. 그러나 이미지를 실행시키자 다음과 같은 에러가 발생했다. 분명히 로컬환경에서 이미지를 실행시켰을 때는 아무런 문제도 발생하지 않았는데도 말이다.

WARNING: The requested image’s platform (linux/arm64/v8) does not match the detected host platform (linux/amd64/v3) and no specific platform was requested standard_init_linux.go:228: exec user process caused: exec format error

읽어보면 이미지의 플랫폼(linux/arm64/v8)이 호스트 플랫폼(linux/amd64/v3)와 일치하지 않는다는 내용이다.

이때는 도커 이미지를 빌드할 때 다음 옵션을 함께 삽입해 주면된다.

1
--platform linux/amd64

지금 처럼 도커 컴포즈를 사용해서 빌드하는 경우 docker-compose.yml 파일에 빌드될 플랫폼 정보를 함께 적어줄 수 있다.

1
2
3
4
5
6
7
8
version: "1.1.2"
services:
  back:
    platform: linux/amd64
    ...
  front:
    platform: linux/amd64
    ...

주의할 점으로는

  1. 각자의 호스트 머신에 따라서 달리지기 때문에 호스트 머신의 아키텍쳐 정보를 확인하고 맞는 플랫폼을 작성하자.
  2. 기반이 되는 도커 이미지가 해당 아키텍쳐를 지원하는지 확인하자.
  3. 로컬 환경과 다른 아키텍쳐를 기준으로 빌드했을 때 당연히 로컬환경에서는 이미지를 실행할 수 없다.

이 정도가 있습니다.

참고자료

This post is licensed under CC BY 4.0 by the author.