Docker Compose 한 줄로 빌드부터 컨테이너 구동까지 해결하기
서론
이번에 진행했던 프로젝트에서 배포를 담당했었다. 배포라고 하면 소스코드가 업데이트될때마다 자동으로 배포해주는 CI/CD 구축을 하는 것이 꽃이라고 할 수 있겠...지만, 당시의 나는 CI/CD 툴에 대해 알아보고 공부하기에는 시간이 턱없이 부족한 상황이었다. 그래서 수동 배포를 비주기적으로 하는 것이 최선이었다.
처음에는 로컬에서 docker image를 만들어 docker hub에 push하고, 그것을 EC2 서버에서 받아 컨테이너를 구동시키는 방식을 채택했었다. 물론 그런 방식으로 배포를 하려면 배포할 때마다 로컬에서 프론트와 백 코드를 빌드한 후, 각각 이미지로 만드는 과정을 거쳐야 해서, 불편한 점이 이만저만이 아니었다. 로컬에서 빌드를 다시 해야하니 하던 작업도 무조건 멈춰야 했고, docker push도 하나하나 해줘야 해서 내 시간이 갉아먹힌다는 느낌이 들었다고 해야하나... 그랬다보니, 더 좋은 방법을 찾기 위해 시간을 좀 더 투자하게 되었다.
나의 목표는 'EC2 서버에 clone해놓은 코드를 기반으로 docker image를 만들고, 그것을 구동시키는 명령을 단 한줄로 해결하겠다' 였다. 실제로 해보았을 때, Dockerfile을 이용해 프론트엔드의 코드를 빌드하여 nginx 기반의 image를 만드는 건 그리 어렵지 않았다. 다만 백엔드의 코드를 빌드하여 image를 만드는 부분에서 좀 많이 헤맸다. 자료도 별로 없고, 생각보다 gradle에 대해서 알아야 잘 진행되는 부분이 많았기 때문이다. 이 글에서는 내가 헤맸던 부분과, 결국 성공했던 Dockerfile, compose.yml 파일 원본에 대해 소개하고자 한다.
개발환경
이 포스트에서 docker를 통해 빌드하고 container화한 프론트엔드, 백엔드 프로그램은 다음을 기반으로 하고 있다.
- Frontend
- React
- node js 20.15.0
- nginx
- Backend
- Spring(Java)
- gradle 8.8
- Liberica 17.0.12
Dockerfile과 docker compose
내가 성공한 방식에 대해 설명하기 전, 먼저 Dockerfile과 docker compose의 개념에 대해 한 번 짚고 넘어가보자. 다들 알겠지만, docker는 프로세스 격리 기술을 이용해 다양한 어플리케이션을 독자적인 환경에서 실행시킬 수 있게 해주는 오픈 소스 프로젝트이다. docker를 이용해 소프트웨어를 실행시키는 과정은 간단하게 다음과 같이 나뉜다고 할 수 있겠다.
- docker image를 만든다
- 그 image를 기반으로 container로서 실행시킨다
Dockerfile, docker compose는 각각 1, 2번 과정과 관련된 개념이라고 할 수 있다. 먼저, Dockerfile이란, docker image를 구성하기 위해 작성한 명령어들을 모아둔 텍스트 문서이다. 부모 이미지가 되는 이미지의 이름을 써두는 FROM 절로 시작해야 하는 등, 다양한 규칙과 옵션이 존재하니 자세한 내용이 궁금하다면 공식 레퍼런스를 참고하자. Dockerfile을 작성했다면 지정한대로 docker image를 작성할 수 있게 된다.
또, docker compose는, 여러 docker container를 정의하고 실행시킬 수 있게 해주는 도구이다. 실행할 때 사용할 이미지명이나 개방할 포트 번호, 환경변수 등, 다양한 container 설정을 미리 정의할 수 있어, 여러모로 docker 실행을 편리하게 해주는 편이다. 이 docker compose의 섹션 중에는 build라는 섹션이 있는데, 이 부분에 어떤 Dockerfile을 통해 이번 실행에서 사용할 image를 설정할건지를 정할 수 있다. (공식 문서 참고)
Dockerfile을 이용한 Build
docker compose를 이용해 빌드부터 실행까지 하기 위해서는, docker compose 파일 내의 build 섹션에서 사용할 Dockerfile에 빌드 과정을 잘 정의해두어야 한다. 지금부터는 그 방식에 대해 알아보자.
multi-stage build
하나의 Dockerfile 내에서 빌드와 이미지 생성을 동시에 행하기 위해서는, 해당 Dockerfile 내에서 빌드 명령에 대한 정의, 그리고 이미지 생성을 위한 정의를 동시에 작성해두어야 한다. 하나의 Dockerfile당 하나의 환경만 사용할 수 있다면, 빌드용 Dockerfile과 이미지 생성을 위한 Dockerfile을 각각 작성해야겠지만, Dockerfile에서는 multi-stage 방식의 build를 지원하고 있다. multi-stage build 방식을 채용하면, 하나의 Dockerfile 내에서 각기 다른 base image를 이용해 단계별로 동작을 정의함으로써, 빌드와 이미지 구성을 한 파일 내에 정의할 수 있고, 또 빌드를 할 때에만 필요한 파일이나 의존성을 이미지를 구성할 때에는 제외하는 등의 활용을 할 수 있다!
라고 설명하기는 했지만, 말로만 설명하면 감이 잘 안 오는 개념이기도 하니, 바로 내가 작성한 Dockerfile을 보도록 하자.
Front-end Dockerfile
아래의 파일은, 내 React 프로젝트의 최상단에 위치하고 있는 Dockerfile의 내용이다.
FROM node:20.15.0 AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
COPY .env ./
RUN export $(cat .env | xargs)
RUN npm run build
FROM nginx
COPY --from=build /app/build /usr/share/nginx/html
Dockerfile은 FROM절을 통해 부모 이미지를 정의하고, 그 이미지를 기반으로 작업한다... 고 위에서도 설명해두었지만, 위의 예시는 잘 보면 FROM절이 두 줄이다. 특히 맨 첫번째의 FROM절은 AS build라는 구문을 통해, 해당 stage의 이름을 정의하고 있다.
즉, 위의 Dockerfile은 다음과 같은 두 stage로 나뉘고 있다. 첫번째 stage는 React 프로젝트를 빌드하기 위한 stage이며, 두번째는 nginx를 기반으로 정적 React build file을 구동시키는 image 작성을 위한 stage이다.
FROM node:20.15.0 AS build # build를 위한 stage 명시
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
COPY .env ./
RUN export $(cat .env | xargs)
RUN npm run build
FROM nginx # 실행을 위한 image 구성을 위한 stage 명시
COPY --from=build /app/build /usr/share/nginx/html
Build를 위한 stage
우선은 build stage부터 하나하나 뜯어보자.
FROM node:20.15.0 AS build # build를 위한 stage 명시
WORKDIR /app # 작업에 사용할 디렉토리 명시
COPY package.json . # 패키지를 정의한 .json을 가져온 후
RUN npm install # 그 내용을 기반으로 필요한 의존성 파일 설치
FROM절을 이용해 build를 위한 프로그램을 불러온 후, 작업에 사용할 디렉토리를 명시하고 있다. 여기에서는 빌드를 위해 node.js의 기능을 이용할 것이기 때문에 node image를 기반으로 하였다. 다운로드할 패키지를 정의해둔 파일, package.json 파일을 이미지 내에 복사해온 후, npm install을 통해 패키지를 모두 다운받으라고 명령하고 있다.
COPY . . # install 이후 프론트엔드의 모든 코드를 image 내부 공간으로 복사
COPY .env ./ # .env 파일 복사 (사실 필요없는 부분)
RUN export $(cat .env | xargs) # .env 파일 내부의 내용 그대로 환경변수 정의
RUN npm run build # build 실행
패키지 install이 끝난 이후에는, 프론트엔드 프로젝트 내의 모든 파일을 image 내로 불러온다. 만일 환경변수 설정이 따로 필요하다면, 그것도 이 과정에서 해 주면 된다. 나의 경우에는 .env 파일 내에 환경변수가 정의되어 있어서, 그 파일을 복사해온 후 환경변수를 설정해주는 과정을 거쳤다. 모든 준비가 끝났다면, npm run build 명령어를 통해 파일을 빌드하면 된다.
container 구성을 위한 stage
빌드가 끝났다면, 빌드한 파일을 기반으로 앱을 구동시킬 image를 구성할 차례이다. 이 파일에서는 가장 흔하게 쓰이는 웹 서버 소프트웨어, nginx를 기반으로 이미지를 구성하고 있다.
FROM nginx # nginx 기반으로 image 구성
COPY --from=build /app/build /usr/share/nginx/html # 빌드한 파일을 nginx image 내부로 복사
nginx의 기본 root 경로, /usr/share/nginx/html 내로 빌드 결과물을 모두 옮기고 있는 모습을 볼 수 있다. 원래 Dockerfile 내의 COPY 명령어는 Dockerfile이 위치하고 있는 로컬 디렉토리 내부를 대상으로 하지만, --from 옵션을 통해 이전 stage의 이름인 'build'를 지정함으로써, 복사 원본 출처를 이전 stage 내부로 명시하고 있다.
이렇듯, 이전 stage에서 빌드 결과물만 복사해옴으로써, 정적 파일을 통해 페이지를 노출할 때에는 필요없는 요소들을 구동용 container에서 제외시킬 수 있다.
Back-end Dockerfile
이어서 SpringBoot 프로젝트를 jar file로 빌드한 후, 그것을 실행시킬 container를 만드는 Dockerfile을 소개한다. 이 파일도 이전 것과 마찬가지로, 프로젝트 최상단 루트에 위치하고 있다.
FROM bellsoft/liberica-openjdk-debian:17.0.12 AS builder
COPY gradlew .
COPY gradle gradle
COPY build.gradle .
COPY settings.gradle .
COPY src src
RUN chmod +x ./gradlew
RUN ./gradlew bootJar -x test
# build end
FROM bellsoft/liberica-openjdk-debian:17.0.12
COPY --from=builder build/libs/*.jar app.jar
COPY .env ./
RUN export $(cat .env | xargs)
ENTRYPOINT ["java","-jar","/app.jar"]
여기에서도 FROM 절이 2개인 것을 볼 수 있다. 윗부분의 stage는 build를 위한 stage, 아래는 실제 구동을 위한 stage라고 할 수 있겠다.
gradlew를 이용한 자바 프로젝트 build
우리 프로젝트에서는 gradle을 이용해 의존성 관리, 빌드를 진행하고 있었다. 따라서 Dockerfile에서 빌드를 진행할 때에도 gradle wrapper를 이용해야겠다고 생각했다.
gradle이나 gradle wrapper에 대한 자세한 설명은 이 포스트에서는 생략한다. 빌드 툴인 gradle에 대해 더 잘 알고 싶다면 공식 문서를 참고하자. 아무튼, 우리의 목적은, 베이스가 되는 liberica openjdk 이미지 내에서, 오류없이 SpringBoot 프로젝트를 빌드하는 것이다.
COPY gradlew . # gradle wrapper 실행 파일 복사
COPY gradle gradle # gradle 폴더 복사
COPY build.gradle . # build 관련 설정 파일 복사
COPY settings.gradle . # gradle 설정 파일 복사
COPY src src # 소스코드 폴더 복사
우선 빌드에 필요한 gradle과 관련된 파일, 그리고 소스 파일을 이미지 내로 복사해온다.
RUN chmod +x ./gradlew # gradlew 파일에 실행권한 부여
RUN ./gradlew bootJar -x test # 이후 bootJar 명령어를 통해 gradle wrapper build 실행
그 다음 gradlew 파일을 실행할 수 있는 권한을 부여한 후, bootJar 옵션을 이용해 빌드를 진행하면 빌드가 완료된다.
-x test라는 옵션은 ./gradlew build 명령어를 통해 빌드를 할 때, 실행을 위한 테스트를 생략시키는 옵션이다. 이런 옵션을 굳이굳이 넣었던 이유는, .env 파일에 여러 환경변수가 설정되어있는 탓에, 빌드 환경에서는 도저히 어떻게 해도 build 과정 중 테스트 과정을 통과할 수 없었기 때문이다. -x test 옵션을 이용하면, build 과정 중 테스트를 생략하기 때문에, 실행에 성공할 수 없는 환경 (DB연결 불가능, 환경변수 부재 등)에서도 빌드를 해낼 수 있다. 근데 이번 기회에 gradlew 명령어에 대해 찾아보니, bootJar는 test같은 과정을 빼고 오로지 jar file을 만드는데에 집중한 빌드 명령이라고 한다. 아마 -x test 옵션은 빼도 괜찮지 않을까 싶다.
아무튼 build stage는 이런 식으로 구성하면, gradle wrapper를 이용한 build를 무난하게 해낼 수 있다. 개인적으로 이 부분에서 많이 헤맸었다. 아마 gradle의 build 과정을 잘 몰라서 그랬던 것 같다. 심지어 'jdk 17을 찾을 수 없다'는 오류도 계속해서 마주했었는데, 이건 맨 처음의 FROM절에 정의된 liberica openjdk에 버전 정의를 하지 않아 생긴 문제였다. Dockerfile을 작성할 때에는 꼼꼼하게 작성하는 게 좋을 것 같다...
jar file 실행
빌드가 끝났으니, 다음으로는 빌드한 jar file을 잘 실행시킬 stage로 넘어가보자.
FROM bellsoft/liberica-openjdk-debian:17.0.12 # stage parent image 지정
COPY --from=builder build/libs/*.jar app.jar # build한 jar file을 옮기기
COPY .env ./ # .env file 복사
RUN export $(cat .env | xargs) # 이 환경에 .env file 내부의 환경변수 세팅
ENTRYPOINT ["java","-jar","/app.jar"] # jar file 실행
아까와 같은 liberica openjdk image를 기반으로, 빌드한 jar file을 가져와 실행시키고 있는 모습이다. env file에 여러 환경변수를 담아두었기 때문에 export 명령어를 이용해 환경변수도 세팅해주고 있다는 것을 어렵지 않게 알 수 있을 것이다. 또, 여기에서도 프론트엔드에서의 작업과 마찬가지로, 실행에 필요한 jar file만을 이전 stage에서 복사해서 가져오고 있다.
docker compose file 구성
Dockerfile에서 build와 실행할 container image에 대한 정보를 잘 작성해두었으니, 다음으로는 docker compose 설정 파일을 구성할 차례이다.
server:
container_name: server
hostname: server
build:
context: ./project/backend
dockerfile: Dockerfile
ports:
- '18080:80'
depends_on:
- mysql
environment:
- SPRING_DATASOURCE_URL=${SPRING_DATASOURCE_URL}
- SPRING_DATASOURCE_USERNAME=${SPRING_DATASOURCE_USERNAME}
- SPRING_DATASOURCE_PASSWORD=${SPRING_DATASOURCE_PASSWORD}
- SPRING_DATA_REDIS_HOST=${SPRING_DATA_REDIS_HOST}
- SPRING_DATA_REDIS_PORT=${SPRING_DATA_REDIS_PORT}
client:
container_name: client
hostname: client
build:
context: ./project/frontend
dockerfile: Dockerfile
ports:
- '3010:80'
사실 구성이라고 할 것도 없고, build 섹션에서 올바른 context, dockerfile을 입력하기만 하면 된다. 추가로 환경변수를 설정해야한다면 environment 설정도 해 주자.
설정이 끝났다면, 아래의 명령어 한 줄만으로 빌드와 이미지 생성, 컨테이너 올리기가 가능해진다!
docker compose up --build -d
마치며
이렇게 docker compose up과 동시에 빌드 및 이미지 생성까지 하는 법에 대해서 알아보았다. 정작 정리하고보니 간단한 내용인데, 이걸 해내기 위해서 꽤 많은 시간을 썼었다는 게 신기하기도 하고 허무하기도 하다...
글을 쓰면서, docker나 gradle의 공식문서를 조금 읽어봤는데 제법 유익했다. 역시 공부할 건 언제나 산더미인 것 같다. 앞으로도 힘내서 멋진 개발자가 되어보자고.