지난 시간에 띄웠던 스프링, DB를 유지한 상태에서 다른 버전의 DB와 스프링을 컨테이너화하여 도커로 함께 띄운다.
도커 이미지는 도커 컨테이너를 만들기 위한 모든 정보를 가지다 보니 용량이 매우 크다. 여기서 파일 하나가 추가되었다고 이미지 전체를 새로 다운로드하는 것은 비효율적일 것이다. 이를 개선하기 위해 존재하는 것이 도커의 Layer 개념이다. 도커는 유니온 파일 시스템(UFS)를 통해 여러 레이어를 하나의 파일 시스템으로 사용할 수 있도록 한다.
도커는 이 특성을 활용하여 서로 다른 이미지 간에 레이어를 공유할 수 있도록 설계되었다. 기존 이미지가 존재할 때 업데이트된 이미지를 다운받는다면 기존 이미지로부터 변경된 사항에 대한 정보가 담긴 레이어만 다운받는 것이다.
위에서는 이미지 다운로드에 대한 예시를 들었지만 이미지 빌드 시에도 이미지 레이어 개념은 활용된다. 이전과 동일한 명령어로 빌드되는 레이어에 대해서는 캐시해둔 이전 빌드 결과물을 재사용하여 빌드 시간을 단축하고 리소스 사용을 최적화한다.
이미지 레이어의 이러한 특성 덕분에 도커의 효율적인 사용이 가능해진다.
MySQL 5.7, SpringBoot를 우분투에 올려둔 상태에서 MySQL 8.0, SpringBoot를 도커 컨테이너로 올려야 한다. 이번 실습에서 MySQL의 버전 차이를 둔 이유는 도커를 사용하면 동일 프로그램을 여러 개 동시에 올릴 수 있는지 확인하는 것이 주된 목적이다. 따라서 버전차이까지는 두지 않지만 대신 DB에 들어있는 데이터에 차이를 두어 두 프로세스가 동시에 구성될 수 있는지를 확인해보려고 한다.
이번 실습은 지난 주에 구축한 환경에 이어 도커 컨테이너를 추가로 띄우는 것이 목표다. 하지만 지난 실습을 로컬 환경에 도커로 우분투를 띄워 진행하다 보니 이번 주에는 도커 속에 도커(DinD)를 띄워야 하는 상황이 되었는데, 이 이슈를 붙잡다가 너무 복잡해서 결국 포기하고 aws에 환경을 새로 구축했다. 서버 세팅은 지난 주와 동일하다.
아래는 ec2에 새로 환경을 만들고 이번 실습을 진행하면서 생긴 자잘한 이슈를 해결할 때 참고한 자료들이다.
# 우분투 시스템 패키지 업데이트
sudo apt-get update
# 의존성 패키지 설치
sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common
# 도커 공식 GPG 키 추가
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
# 도커 공식 apt 저장소 추가
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
# 우분투 시스템 패키지 업데이트
sudo apt-get update
# 도커 설치
sudo apt-get install docker-ce docker-ce-cli containerd.io
# 베이스 이미지 가져오기
FROM ubuntu:20.04
# 작업 디렉토리 선정
WORKDIR /app
# jdk 설치
RUN apt-get update && apt-get install -y openjdk-17-jdk
# 호스트에 있는 프로젝트 파일 복사
COPY . .
# 프로젝트 빌드
RUN ./gradlew clean build -x test
# jar 파일 변수 선언
ARG JAR_FILE=build/libs/app-0.0.1-SNAPSHOT.jar
# 최상단으로 jar 파일 가져오기
RUN cp ${JAR_FILE} /app/app.jar
# 포트 개방 명시
EXPOSE 8080
# 프로젝트 실행
ENTRYPOINT ["java","-jar","/app/app.jar"]
docker run -d -it -p 8081:8080 --name <container_name> <image_name>:<tag>
이번 실습이 특수한 경우여서 외부 포트와 내부 포트가 다르게 매핑되어있지만 보통의 경우에는 동일하게 매핑한다. Dockerfile에서 EXPOSE 8080
과 같이 포트가 명시되어 있다면 -P
옵션을 통해 기본 포트로 간편하게 매핑할 수 있다.
# 이미지 불러오기
FROM mysql:8.0.22
# 초기 쿼리 스크립트 복사 (자동 실행)
COPY init.sql /docker-entrypoint-initdb.d/
# 포트 개방
EXPOSE 3307
도커파일에 mysql password같은 민감한 정보를 담는 것은 위험할 수 있기 때문에 컨테이너 실행 시 명령어로 주입하기로 했다.
/docker-entrypoint-initdb.d/
MySQL 시작 시 초기 설정을 위해.sh
,.sql
을 실행해야 할 수 있다. MySQL Docker Image는 초기 데이터베이스 설정을 위해 위의 디렉토리를 사용한다. 이 디렉토리에 스크립트 파일을 배치하면 MySQL 컨테이너가 처음 시작될 때 해당 스크립트들이 자동으로 실행된다.
단, 이 디렉토리에 있는 스크립트는 MySQL 데이터 디렉토리가 비어있을 때, 즉 초기 컨테이너 시작 시에만 실행된다. 이미 데이터가 존재한다면 스크립트가 실행되지 않는다.
스크립트들은 파일명의 알파벳 순서에 따라 실행된다.
sudo docker run -d -p 3307:3306 \
-e MYSQL_ROOT_PASSWORD='0000' \
-e MYSQL_DATABASE='dbname' \
-e MYSQL_USER='user' \
-e MYSQL_PASSWORD='0000' \
--name <container_name> <image_name>:<tag>
포트 관련 설명은 이전과 동일하다.
Dockerfile의 명령어가 이전과 동일하다면 캐싱해두었던 해당 명령어(레이어) 산출물을 그대로 사용한다. 이 점 때문에 2회 이상 빌드 시 최초보다 훨씬 빌드 시간이 단축된다.
단적인 예로, WAS 실행을 위해 jdk를 설치하는 명령어를 사용했을 때 해당 레이어에 대해 최초에는 92초가 걸렸지만 2회차부터는 0초가 걸렸다.
앞에서 스프링 도커 컨테이너를 띄울 때 ubuntu 이미지를 사용했다. 하지만 서버용으로 배포되는 가벼운 버전의 jdk가 있다고 한다. slim, slim-stretch, stretch, alpine 등 다양한 버전이 있지만 여기서는 slim과 alpine 버전을 사용해봤다.
# 베이스 이미지 가져오기
FROM openjdk:17-jdk-slim
or
FROM openjdk:17-jdk-alpine
...
확실히 원본 버전 이미지에 비해 3~400MB 가량 가벼워진 것을 확인할 수 있다.
스프링 Dockerfile의 이미지 빌드 과정을 보면 크게 아래 두 가지 과정으로 나눌 수 있다.
생각해보면 이 두 과정이 이미지에 전부 포함될 필요는 없다. 프로젝트 빌드는 따로 수행하고 완성된 jar 파일만 가져와 서버를 올리면 된다. 도커의 멀티스테이지 빌드를 활용하면 이게 가능해진다.
이미지를 만들면서 빌드 등에는 필요하지만 최종 컨테이너 이미지에는 필요없는 환경을 제거할 수 있도록 단계를 나누어 기반 이미지를 만드는 방법이다.
멀티스테이지 빌드를 사용하면 위 그림과 같이 이미지 빌드 과정에서 jar를 빌드해내기는 하지만 최종 이미지에는 해당 과정이 생략(삭제)되어 있다. 이러한 과정을 통해 컨테이너 크기를 경량화시킬 수 있도록 도와준다. 단 이미지 빌드 과정에서는 여러 (베이스 이미지를 포함한)스테이지 용량이 전부 필요하다.
멀티스테이지 빌드에서는 FROM
을 여러 번 사용하여 스테이지를 구분한다. 그리고 메인 스테이지가 아닌 스테이지에는 FROM <image_name> AS <stage_name>
와 같이 스테이지 이름을 지정해줘야 한다. 또한 각 스테이지는 완전히 독립된 환경이기 때문에 WORKDIR
도 새로 지정해줘야 한다.
아래는 멀티스테이지를 적용한 스프링 Dockerfile이다.
# 베이스 이미지 가져오기 - Builder Stage
FROM openjdk:17-jdk-alpine AS builder
# 작업 디렉토리 선정
WORKDIR /app
# 호스트에 있는 프로젝트 파일 복사
COPY . .
# 프로젝트 빌드
RUN ./gradlew clean build -x test
# 베이스 이미지 가져오기 - Runner Stage
FROM openjdk:17-jdk-alpine
# 작업 디렉토리 선정
WORKDIR /app
# jar 파일 변수 선언
ARG JAR_FILE=build/libs/app-0.0.1-SNAPSHOT.jar
# Builder Stage로부터 jar 파일 복사
COPY --from=builder /app/${JAR_FILE} /app/app.jar
# 실행 권한 부여
RUN chmod 755 /app/app.jar
# 8080 포트 개방
EXPOSE 8080
# 프로젝트 실행
ENTRYPOINT ["java","-jar","/app/app.jar"]
여기서 핵심은 COPY --from=builder /app/${JAR_FILE} .
이다. builder
스테이지에서 빌드한 jar 파일을 메인 스테이지로 복사해오는 것이다. 이렇게 되면 메인 스테이지에서는 빌드를 위한 파일 없이 서버를 띄우기 위한 jar 파일만을 가질 수 있고 용량이 줄어들게 된다.
실제로 멀티스테이지 빌드를 적용한 이미지를 보면 기존 이미지에 비해 용량이 절반 가까이 줄어든 것을 확인할 수 있다.
jdk-slim보다 가벼운 자바 이미지
JDK에는 JRE와 함께 개발자용 도구가 포함되어 있어 빌드를 할 수 있도록 돕는다. 자바 프로그램을 실행시키기만 한다면 JDK까지는 가지 않고 JRE만 있어도 충분한 것이다. 그리고 이는 멀티스테이지 빌드를 사용할 때 추가로 이미지 용량을 최적화할 수 있음을 의미한다. Builder Stage에서는 jdk를 사용하고 Runner Stage에서는 jre를 사용할 수 있다. 실제로 Runner Stage의 Base Image를openjdk:17-jdk-alpine
에서eclipse-temurin:17-jre
로 변경한 결과 이미지 용량이 70MB 가량 가벼워짐을 확인했다. (379MB -> 310MB) (openjdk는 작성일 기준으로 17 버전 jre 이미지를 Docker Hub에서 공식 제공하지 않는다.)
Dockfile이 있다고는 하지만 그래도 매번 변경사항이 있을 때마다 여러 옵션을 넣으면서 bash 명령어를 각 이미지에 대해 수행해야 하는 것은 많이 번거롭다. 이를 개선하기 위해 도커 컴포즈를 도입했다. 도커 컴포즈는 쉽게 생각하면 docker run ~~~
를 자동화할 수 있도록 도와주는 도구다. Dockfile과 같이 텍스트 파일로 손쉽게 설정 가능하다.
아래는 mysql과 spring에 대해 도커 컴포즈를 적용한 예시이다. 자세한 문법은 다음 포스트를 참고하자.
version: "3.9"
services:
mysql:
build: ./mysql_docker
volumes:
- db:/var/lib/mysql
image: songsunkook/roomescape_db:latest
ports:
- 3307:3306
restart: unless-stopped
container_name: roomescape_db
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${MYSQL_DATABASE}
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
networks:
- roomescape
spring:
depends_on:
- mysql
build: ./spring_docker
image: songsunkook/roomescape_was:latest
ports:
- 8081:8080
restart: unless-stopped
container_name: roomescape_was
networks:
- roomescape
volumes:
db:
networks:
roomescape:
다만 도커 컴포즈를 사용하여 환경변수를 주입하면 민감한 정보가 도커 컴포즈 파일에 함께 기입되는 문제가 있다. 이는 .env
파일로 분리하여 개선할 수 있는데, 이에 관해서는 다음 포스트를 참고하자.
이제 더이상 mysql 이미지를 빌드해서 컨테이너를 올리고 spring 이미지를 빌드해서 컨테이너를 올리지 않아도 된다. 단 한 줄만 치면 도커 컴포즈가 자동으로 여러 이미지를 빌드하고 컨테이너를 올려준다.
# 이미지 빌드 및 컨테이너 시작
docker-compose up
# 기존 컨테이너 제거 (이미지는 제거하지 않음)
docker-compose down
# 이미지 재빌드
docker-compose build
# 이미지 재빌드 후 컨테이너 시작
docker-compose up --build
# 특정 서비스 재배포
docker-compose up --build <service_name>
# 컨테이너 재생성
docker-compose up --force-recreate
도커는 컨테이너 기반으로 프로세스를 동작시키는데, 컨테이너가 제거되면 그 안의 데이터가 함께 날아가버린다. 그럼 이미지 재빌드 후 컨테이너를 다시 올릴 때마다 데이터가 날아가버린다는 것인데, DB와 같이 데이터를 저장해야 하는 컨테이너에게는 치명적이다. 이러한 컨테이너의 문제점은 볼륨(Volume)을 사용하면 해결할 수 있다.
볼륨은 도커에 의해 관리되는 가상 하드디스크다. 별도의 볼륨 설정 없이 컨테이너를 띄우면 각 컨테이너마다 독립적인 볼륨이 할당된다. 하지만 컨테이너가 삭제되면 해당 볼륨이 함께 삭제된다. 그래서 컨테이너를 지울 때마다 데이터가 함께 날아가는 것이다.
컨테이너가 삭제될 때 볼륨이 유지되면 데이터가 보존될까? 그렇다! 하지만 사용하던 볼륨을 컨테이너가 새로 올라올 때 인식하고 다시 사용할 수 있어야 한다. 그 방법을 알아보자.
도커 볼륨의 동작 원리
컨테이너 내부 데이터를 도커 스토리지 디렉토리(/var/lib/docker/volumes/
) 내에 새로운 디렉토리를 만들어 저장하고 관리한다. 볼륨을 컨테이너에 탑재하면 이 디렉토리가 컨테이너 탑재되며, 도커에 의해 관리되고 호스트 파일 시스템과는 분리된다.
참고 자료
docker volume <command> [options] [arguments]
기본 구문은 위와 같다.
아래는 도커 볼륨에서 사용 가능한 command와 option들이다.
# <command>
create: 새 볼륨 생성
ls: 모든 볼륨 나열
inspect: 볼륨 세부 정보 확인
rm: 볼륨 제거
prune: 사용하지 않는 모든 볼륨 제거
# [options]
--driver: 사용할 볼륨 드라이버 지정
--name: 볼륨 이름 지정
-v or --volume: 볼륨 마운트
--mount: 더 구체적인 설정이 가능한 볼륨 마운트
볼륨 마운트는 컨테이너에서 사용할 볼륨을 지정한다는 뜻이다. 보통 컨테이너 생성 시 아래 구문처럼 사용한다.
docker run --it -v /host_path:/container_path image
docker run --it --mount source=myvol,target=/container_path image
이전의 도커 컴포즈 코드를 살펴보면 볼륨과 관련한 부분이 있다.
services:
mysql:
volumes:
- db:/var/lib/mysql
volumes:
db:
도커 컴포즈에서는 위와 같이 서비스별로 사용할 볼륨을 지정할 수 있다. name:path
형태로 표현하며, name
부분은 볼륨이름, path
부분은 컨테이너 내부에서 볼륨을 마운트할 경로가 된다. 실제 볼륨명은 다른 볼륨과 이름이 겹치지 않도록 compose명_volume명
으로 저장된다. (ex. Infra_db
)
추가로 컴포즈 파일에는 볼륨을 사용할 서비스 외에도 volumes:
로 사용되는 볼륨들을 다시 한 번 명시해줘야 한다. 여기에 명시하지 않으면 컨테이너가 삭제될 때 함께 삭제되는 익명 볼륨으로 취급된다.
도커는 컨테이너 간 통신을 관리하고 격리하기 위한 기능으로 도커 네트워크를 제공한다. 컨테이너 간의 통신이 필요한 경우가 있는데(ex. WAS와 DB) 이 때 도커 네트워크가 사용된다. 도커 네트워크는 같은 호스트 내에서 실행중인 컨테이너 간의 통신을 돕는 논리적 네트워크 개념이다.
도커 네트워크는 학습해야 할 내용이 너무 커서 자세한 내용은 다음 기회에.. 🥲
도커 네트워크에 관해서는 자세히 설명된 포스트가 있으니 여기를 참고하자.
네트워크의 경우는 컴포즈 파일에 networks:
로 다시 한 번 명시해주는 것이 좋지만, 꼭 명시해야 하는 것은 아니다. 하지만 명시하지 않을 경우 각 서비스에 사용할 network를 지정해뒀어도 해당 네트워크를 사용하지 못하고 Docker Compose에서 기본적으로 제공하는 default
네트워크에 연결된다.
컨테이너 간의 통신을 위해서는 서로 동일한 네트워크를 사용해야 한다. 도커 컴포즈에서는 기본적으로 동일한 네트워크를 사용하고, 위의 설정 파일에서도 동일 네트워크를 명시적으로 사용하고 있다.
이번 경우에는 WAS에서 DB에 url을 통해 접근하고 있는데, 여기에 수정이 필요하다.
# Before
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3307/room_escape?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
username: ""
password: ""
# After
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://roomescape_db:3306/room_escape?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul&allowPublicKeyRetrieval=true
username: ""
password: ""
처음에는 두 컨테이너가 동일한 호스트에 올라가 있고 MySQL이 외부 포트 3307로 열려 있으니 당연히 localhost:3307
로 연결될 줄 알았다. 하지만 도커 네트워크로 연결된 컨테이너끼리는 컨테이너 내부 포트를 경유하며 localhost
가 아닌 컨테이너명을 지정해줘야 한다고 한다.
요청이 들어오는 url에 따라 보여주는 페이지를 분기시키려고 한다. api.ssglog.kro.kr
로 들어오는 요청을 로컬 WAS로, docker.ssglog.kro.kr
로 들어오는 요청은 도커 WAS로 연결할 것이다.
이를 위해 도메인을 만들어서 nginx 및 certbot 구성까지 완료했다(관련 내용은 여기로).
이번에는 nginx에서 default 설정파일을 사용하지 않고 추가로 docker 설정 파일을 만들고 심볼릭 링크를 걸어 여기에 작성했다.
certbot이 자동으로 작성해준 코드를 제외하고 직접 작성한 코드만 보면 다음과 같다.
# default 파일
# 로컬(호스트) 환경에서 올린 WAS
server {
server_name api.ssglog.kro.kr;
location / {
proxy_pass http://localhost:8080;
}
}
# docker 파일
# 도커 컨테이너로 올린 WAS
server {
server_name docker.ssglog.kro.kr;
location / {
proxy_pass http://localhost:8081;
}
}
요청이 어떤 url로 들어오느냐에 따라 server_name으로 server를 분기하여 대응하는 포트로 넘겨줬다. 8080은 로컬에서 올린 WAS, 8081은 도커로 올린 WAS이다. 로컬 WAS는 로컬 MySQL을 사용하고, 도커 WAS는 도커 MySQL을 사용한다.
로컬 WAS는 로컬 DB를 사용하고 도커 WAS는 도커 DB를 사용한다. 다시 말해 api.ssglog.kro.kr
로 접속한 페이지의 데이터와 docker.ssglog.kro.kr
로 접속한 페이지의 데이터는 서로 격리되어야 한다.
다음은 각 페이지로 접속한 화면 일부다.
api.ssglog.kro.kr
docker.ssglog.kro.kr
각 url로 페이지에 온전히 접속되며 출력된 데이터는 서로 다른 내용을 담고 있다. url에 따라 서로 다른 WAS를 통해 서로 다른 DB에 접근하여 서로 다른 결과를 보여주고 있는 것이다.
이미지와 레이어(layer) 구조
도커의 레이어(Layer)에 대해 알아보자
Ubuntu 20.04에서 swap 메모리 설정하기
EC2 프리티어 용량 늘리기
Ubuntu 22.04 Docker 설치
개발용 데이터베이스 띄우기(MySQL, PostgreSQL, MongoDB)
Docker에 Spring Boot 프로젝트 띄우기
Docker File을 이용하여 Docker Image만들기
(Baeldung)Difference Between Openjdk Docker Images
Docker MultiStage - Spring Boot
Dockerfile - Multi-stage build(멀티스테이지 빌드)
Dockerfile 최적화 Multi-Stage Build(멀티스테이지 빌드)
효율적인 도커 이미지 만들기 #1 - 작은 도커 이미지
도커 컴포즈란?
도커 컴포즈(Docker compose) - 개념 정리 및 사용법
Docker Compose에서 각 서비스 컨테이너에 쓰이는 환경변수를 다루는 방법
Docker Compose 사용해 web, db 컨테이너 연결하기
Docker 2. 설정(volume, port, network, compose)
도커 볼륨 명령어 (Docker Volume command)
Docker Network에 대해