Hanbit the Developer

AWS와 Docker를 활용한 Spring + React.JS CI/CD 구축: Docker, Docker Compose(2) 본문

Back-end

AWS와 Docker를 활용한 Spring + React.JS CI/CD 구축: Docker, Docker Compose(2)

hanbikan 2024. 8. 11. 15:12

Docker와 Docker Compose 도입

이전 글의 과정으로 CI를 도입하니 Docker와 CD를 활용해 편하게 배포를 하고 싶어졌습니다. 우선 도커부터 도입하려고 합니다.

Docker는 애플리케이션을 컨테이너화하여 어디서나 동일하게 동작할 수 있게 해줍니다. 저는 GitHub Action 환경, ec2 환경을 통해 배포를 해야하기 때문에 도커를 도입하기에 적절해 보였습니다. 또한 저의 경우 프론트엔드와 백엔드를 모두 관리해야 했기 때문에, Docker Compose를 도입하게 되었습니다.

0. Docker 기본 개념

도커 기본 개념을 간략히 설명하겠습니다.

  • Dockerfile: 애플리케이션을 어떻게 실행할지를 정의합니다.
  • Image: 실행 가능한 애플리케이션 패키지입니다.
  • Container: 실제로 동작 중인 애플리케이션 인스턴스입니다.

비유를 통해 쉽게 이해할 수 있는데, Dockerfile은 소스코드, Image는 컴파일하여 만들어진 exe 파일, Container는 실제로 실행 중인 프로세스로 생각하면 쉽습니다.

1. ./frontend/Dockerfile

React.JS 도커파일입니다.

# Dockerfile
FROM node:20.16.0-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

FROM node:20.16.0-alpine
RUN npm install -g serve
WORKDIR /app
COPY --from=build /app/build .
EXPOSE 3000
ENTRYPOINT ["serve", "-s", ".", "-l", "3000"]
  • FROM
    • node 20.16.0 버전을 기반 이미지로 설정합니다. 도커에서 제공하는 노드 이미지는 이곳에서 보실 수 있습니다.
    • 제공 이미지가 정말 많아서 선정 과정이 필요합니다. 우선 노드 버전은 현재 기준 LTS 버전인 20.16.0으로 선정하였습니다.
    • 뒤에 -alpine과 같은 태그에 대해 간단히 알아보겠습니다. node:20.16.0은 기본 버전이고,-alpine과 -slim은 경량화 버전입니다. -bookworm과 -bullseye는 debian 기반 이미지입니다.
    • 문제가 되지 않는다면 빠르고 가벼운 경량화 버전을 선택하고 싶었습니다.(실제로 이미지 크기를 비교했을 때, 스탠다드는 393MB, alpine은 51MB였습니다.) 따라서 alpine과 slim 중 선택을 하면 됩니다. alpine는 slim보다 더 가볍지만 네이티브 모듈(C, C++ 같은 로우레벨 언어로 작성된 모듈)을 사용할 수는 없습니다. slim은 debian 기반이며 네이티브 모듈을 지원하지만 alpine보단 용량이 큽니다. 저는 alpine을 사용해도 문제가 없는 상황이어서 더 가벼운 alpine을 선택했습니다.
  • FROM ~ AS build: 제 도커 파일에 FROM을 2번 사용한 것을 확인하실 수 있습니다. 이는 멀티 스테이지 빌드 기능을 사용하고자 한 것입니다. 이후 더 자세히 설명하겠지만, 첫 번째 빌드 단계에 build라는 이름을 부여한 것입니다. 두 번째 빌드 단계에 이 build라는 이름을 사용할 예정입니다.
  • WORKDIR /app: 도커 작업 디렉토리를 /app으로 설정합니다. 이후 COPY ./file/A ./file를 수행하게 되면 호스트 머신에서의 ./file/A 파일을 컨테이너 내의 ./app/file에 복사하게 됩니다. 굳이 이렇게 지정해주는 이유는, 컨테이너에 기본적으로 운영체제 관련 파일이 여럿 있는데 app에 코드와 관련된 파일을 두어 충돌을 방지하고 관리를 수월하게 하기 위함입니다. 또한 널리 쓰이는 관례이기도 합니다.
  • COPY package*.json ./ & RUN npm install: 의존성 정보가 명시된 패키지 파일을 컨테이너에 복사한 뒤, 의존 라이브러리를 설치합니다.
  • COPY . . & RUN npm run build: 모든 파일을 컨테이너에 복사한 뒤 애플리케이션을 빌드합니다.

조금 더 짧은 코드로 구성할 수 있지만 이렇게 단계를 나누는 데는 이유가 있습니다. 도커는 빌드를 한 뒤 다시 빌드를 하면, 처음부터 빌드를 하지 않고 수정된 부분부터 빌드합니다. 제 코드처럼 의존성 설치와 코드 빌드를 나누게 되면, 의존성 파일이 변경되지 않는 한 COPY . .부터 빌드를 시작합니다. 의존성이 변하는 일은 그렇게 많지 않으므로 효율적입니다.

  • FROM node:20.16.0-alpine: 두 번째 빌드 단계에서도 노드를 사용합니다.
  • COPY --from=build /app/build .: 첫 번째 빌드의 결과물에서 빌드된 파일을 가져옵니다. 이렇게 함으로써 최종 이미지를 경량화할 수 있습니다. 멀티 스테이지 빌드를 활용하여 배포에 필요한 파일만을 컨테이너에 올리게 된 것입니다.
  • serve: nginx로도 배포를 할 수 있지만 배포 포트를 유동적으로 변경하는 데 용이한 serve를 사용했습니다. 저는 3000 포트로 변경하고자 해서 serve를 사용했습니다.
  • ENTRYPOINT: 컨테이너 실행 시 항상 실행되어야 하는 명령을 설정합니다.

ENTRYPOINT vs CMD ENTRYPOINT 대신 CMD를 사용해도 됩니다. 이렇게 하면 도커를 실행할 때 이곳에 설정한 명령 대신 다른 명령을 수행할 수 있습니다. 예를 들어 docker run my-app java -version로 도커를 실행시키면 기존에 설정한 명령 대신 java -version을 수행합니다. 이렇게 동적으로 명령어를 수행해야 하는 경우에는 CMD를 사용하면 됩니다.

2. ./server/Dockerfile

Spring Boot 도커 파일입니다.

FROM openjdk:17-jdk-slim AS build
WORKDIR /app
COPY . .
RUN ./gradlew clean build -x test

FROM openjdk:17-jdk-slim
WORKDIR /app
COPY .env .env
COPY --from=build /app/build/libs/*.jar app.jar
ENTRYPOINT ["java","-jar","/app/app.jar"]
  • RUN ./gradlew clean build -x test: 빌드를 하되 테스트는 진행하지 않습니다.
  • 멀티 스테이징 빌드를 통해 이미지를 경량화했습니다.

이미지를 경량화해야 하는 이유

  1. 빠른 속도: 이미지 크기가 작기 때문에 네트워크를 통해 더 빠르게 전송되며, 시작 시간 또한 더 빨라집니다.
  2. 리소스 절약: 디스크 공간을 절약하며 메모리 및 CPU 사용량이 감소합니다.
  3. 보안성 강화: 불필요한 소스 코드가 포함되지 않으므로 보안 취약점이 발생할 가능성이 낮아집니다.

3. ./docker-compose.yml

저는 프론트엔드와 백엔드에 대해 여러 도커파일을 관리하기 때문에 도커 컴포즈를 도입하기에 적절합니다. 도커 컴포즈를 사용하면 얻을 수 있는 이점은 다음과 같습니다:

  1. 편리성: 여러 컨테이너를 하나의 파일로 관리하므로 편리합니다.
  2. 빠른 속도: 서비스를 재시작할 때 변경되지 않은 컨테이너는 재사용하기 때문에 빠르게 실행할 수 있습니다.
  3. 환경 변수 사용: 환경 변수를 사용할 수 있어 여러 환경에서 다르게 실행할 수 있습니다.
version: '3'
services:
  server:
    build:
      context: ./server
    image: ${SERVER_DOCKER_URL}
    env_file:
      - ./server/.env
    ports:
      - "8080:8080"

  frontend:
    build:
      context: ./frontend
    image: ${FRONTEND_DOCKER_URL}
    env_file:
      - ./frontend/.env
    ports:
      - "3000:3000"
  • server: , frontend: : 서비스의 이름을 server, frontend로 지정합니다. 서비스명은 도커 컴포즈 파일에서 유용하게 사용됩니다. 예를 들어 프론트엔드 실행 이후에 서버가 실행되어야 한다면, server 서비스에 depends_on: - frontend를 추가하여 의존 관계를 설정할 수 있습니다.
  • build: 해당 서비스의 Dockerfile 위치를 지정해줍니다. 저는 도커파일이 ./server/Dockerfile, ./frontend/Dockerfile 위치에 있으므로 ./server, ./frontend로 지정했습니다.
  • image ${ENV}: 서비스에 이미지 이름을 설정합니다. 저는 보안을 위해 환경변수에서 이미지명을 가져왔습니다.(이미지명은 각각 registrydomain/myapp:server, registrydomain/myapp:frontend 형식)
  • env_file: 해당 이미지를 컨테이너로 올릴 때 적용할 환경 변수입니다.
  • ports: host_port:container_port 형식으로 포트 매핑을 설정합니다. 예를 들어 80:3000으로 지정한 경우 외부에서 80 포트로 접근하면 컨테이너 내에서 3000포트로 실행 중인 애플리케이션에 접근할 수 있습니다.

Spring Boot를 실행하는 환경에서 MySQL을 동시에 돌린다면 mysql 서비스 또한 추가해야 합니다. 이 경우 server 서비스 depends_on에 mysql를 추가하고, volumes나 networks 등의 추가 설정을 해야 합니다.

4. build & push / pull & run

개발 환경에서 아래 명령어를 통해 도커 컴포즈에 등록된 모든 이미지를 빌드하고 푸시합니다.

docker-compose build
docker-compose push

배포 환경에서 아래 코드를 통해 도커 컴포즈에 등록된 모든 이미지를 받은 뒤 실행합니다. -d 옵션은 detached 모드로 컨테이너를 실행하여 백그라운드에서 실행하게 해줍니다. 도커를 처음 도입할 때는 이 옵션을 제거하고 실행하여 에러가 발생하지 않는지 확인하는 것을 권장합니다.

docker-compose pull
docker-compose up -d

Mac과 EC2의 플랫폼 차이 문제

그러나 EC2에서 docker-compose up을 호출하자 예상치 못한 문제가 발생했습니다.

server-1    | exec /usr/java/openjdk-17/bin/java: exec format error
frontend-1  | exec /usr/local/bin/docker-entrypoint.sh: exec format error
frontend-1 exited with code 1
server-1 exited with code 1

이는 ARM 기반 Mac M1 개발 환경에서 이미지를 빌드하였으나, 제 EC2 환경이 AMD 기반이었기 때문입니다.

따라서 아래 코드를 통해 이미지를 linux/amd64 플랫폼으로 빌드하도록 명시하여 문제를 해결했습니다. 빌드와 푸시를 동시에 하도록 --push 옵션도 추가해주었습니다.

sudo docker buildx build --no-cache --push --platform linux/amd64 -t registrydomain/myapp:frontend ./frontend
sudo docker buildx build --no-cache --push --platform linux/amd64 -t registrydomain/myapp:server ./server
이후에 CD를 적용할 때는 docker compose build로 쉽게 빌드할 수 있었습니다. GitHub Actions와 EC2 모두 AMD 기반 리눅스 환경이기 때문입니다.

마무리

EC2 환경에서 잘 돌아가는 것을 확인할 수 있었습니다!

References

https://jadehan.tistory.com/58

https://forums.docker.com/t/differences-between-standard-docker-images-and-alpine-slim-versions/134973

https://docs.docker.com/build/building/multi-stage/

https://velog.io/@oneook/Docker로-React-개발-및-배포하기

https://docs.docker.com/compose/intro/features-uses/

https://stackoverflow.com/questions/66920645/exec-format-error-when-running-containers-build-with-apple-m1-chip-arm-based