안녕하세요, 리브입니다!
우리의 기존 서버 구조는 다음과 같았습니다.(편의상 모니터링 구조는 제외하고 올립니다.) 하지만 서버 구조를 변경하여 Nginx와 Spring 애플리케이션을 도커 컨테이너로 실행하기로 결정했습니다.

도커 컨테이너로 서버를 구조화하면 다음과 같은 장점이 있습니다.
첫째로 격리된 환경을 제공하여 서로 다른 프로그램 간의 충돌을 방지하고, 예상치 못한 문제를 예방할 수 있습니다.
또한, 동일한 도커 이미지를 사용함으로써 일관된 환경을 보장하여 개발, 테스트, 프로덕션 환경에서 동일한 설정을 유지할 수 있습니다. 이를 통해 애플리케이션의 배포와 관리 과정에서 효율성을 높일 수 있습니다.
이러한 구조 변경에 필요한 구성 요소로는 먼저 스프링 애플리케이션을 도커 이미지로 만들기 위한 Dockerfile이 필요합니다. 이 파일은 애플리케이션을 컨테이너화하여 동일한 환경에서 실행될 수 있게 합니다. 또한, 여러 컨테이너를 관리하고 실행하기 위한 Docker Compose를 사용하여 단일 명령어로 모든 컨테이너를 시작, 중지, 재시작할 수 있으며, 개발, 테스트, 프로덕션 환경에서 동일한 설정을 적용할 수 있습니다. 마지막으로, CD 파이프라인 파일도 도커 환경에 맞게 조정하여 자동화된 빌드 및 배포 프로세스를 구현할 수 있습니다.
Dockerfile은 도커 이미지를 생성하기 위한 명령어를 정의한 스크립트 파일입니다. 애플리케이션을 배포하기 위한 일련의 단계를 코드로 명시하여, 누구나 동일한 이미지를 생성할 수 있게 합니다. 일관된 환경 세팅을 제공하여 배포 및 개발 과정에서의 효율성을 높입니다.
아래와 같이 명령어를 직접 사용하여 이미지를 생성할 수도 있지만, Dockerfile을 사용하면 git과 같은 버전 관리 시스템을 통해 이미지 생성 과정을 체계적으로 관리할 수 있습니다. 이는 명령어를 읽는 것보다 가독성이 좋고, 관리가 용이하다는 매력이 있죠.
docker pull openjdk:21
docker create --name temp-container openjdk:21
docker cp path/to/your/app.jar temp-container:/app.jar
docker run -d -p 8080:8080 --name final-container \
-e SPRING_PROFILE=default \
openjdk:21 \
java -Dspring.profiles.active=${SPRING_PROFILE} -jar /app.jar
# +) 후에 도커 커밋 및 푸시하면 레지스트리에 현재 상태의 컨테이너를 업로드 할 수 있음
# 베이스 이미지로 사용할 이미지
# 특정 레지스트리를 명시하지 않았기 때문에 Docker 이미지의 기본 공개 저장소인 Docker Hub에서 이미지를 가져옵니다.
FROM openjdk:21
# JAR_FILE이라는 이름의 인수 정의
ARG JAR_FILE=build/libs/*.jar
# 정의한 JAR_FILE을 Docker 이미지의 루트 디렉토리(`/`)에 `app.jar`라는 이름으로 복사
COPY ${JAR_FILE} app.jar
# *Docker 컨테이너 사용할 포트 명시(단순 문서화 역할)
EXPOSE 8080
# Docker 컨테이너가 시작될 때 실행할 명령어
ENTRYPOINT ["java", "-Dspring.profiles.active=${SPRING_PROFILE}", \\
"-jar", "/app.jar"]
Dockerfile에서 EXPOSE라는 명령어를 사용해 주었는데요.
The EXPOSE instruction doesn't actually publish the port. It functions as a type of documentation between the person who builds the image and the person who runs the container, about which ports are intended to be published. To publish the port when running the container, use the -p flag on docker run to publish and map one or more ports, or the -P flag to publish all exposed ports and map them to high-order ports.
🐳 도커 공식문서
도커 공식문서를 참고하면 실제로 포트를 개방하는 역할을 하지는 않습니다.
이미지 빌더와 이미지를 사용하는 실행자에게 8080 포트가 외무에 공개될 것이라는 걸 문서화하는 역할을 합니다.
그리고 ENTRYPOINT에서 "-Dspring.profiles.active=${SPRING_PROFILE}"와 같이 프로덕션 서버, 개발 서버에 따라 달라질 수 있는 스프링 프로필을 환경 변수로 받아 설정할 수 있도록 했습니다. 이 환경 변수는 compose.yml에서 Dockerfile로 전달해 줄거예요.
Docker Compose는 여러 컨테이너를 단일 명령어로 쉽게 관리할 수 있도록 도와주는 도구입니다. 이를 통해 모든 컨테이너를 한 번에 시작, 중지, 재시작할 수 있으며, 개발, 테스트, 프로덕션 환경에서 동일한 설정을 사용하여 일관된 환경을 유지할 수 있습니다. Docker Compose를 통해 복잡한 애플리케이션 환경을 간편하게 관리하고, 설정의 일관성을 보장할 수 있습니다.
아래와 같은 명령어를 파일로 관리함으로써 가독성 향상 및 유지보수 편의성까지 제공하죠.
# 네트워크 생성
docker network create nginx-app-net
# application(스프링) 컨테이너 실행
docker run -d --name develup-app --network nginx-app-net -p 8080:8080 -p 8082:8082 -e TZ="Asia/Seoul" -e SPRING_PROFILE=dev --restart always ${BACKEND_APP_IMAGE_NAME}
# nginx 컨테이너 실행
docker run -d --name nginx --network nginx-app-net --link develup-app:application -p 80:80 -p 443:443 -v /home/ubuntu/custom.conf:/etc/nginx/conf.d/default.conf -v /etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem:/etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem -v /etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem:/etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem nginx
services: # Docker Compose가 관리할 여러 컨테이너를 정의하는 섹션
nginx: # Docker Compose에서 컨테이너를 참조하는 데 사용
image: nginx # 사용할 이미지, 태그를 명시하지 않으면 `nginx:latest` 이미지를 사용합니다.
depends_on: # 1. 이 컨테이너가 시작되기 전에 먼저 시작해야 하는 컨테이너
- application
networks:
- nginx-app-net # 2. 컨테이너가 연결될 네트워크
ports:
- "80:80" # 호스트의 포트 80을 컨테이너의 포트 80에 매핑
- "443:443" # 호스트의 포트 443을 컨테이너의 포트 443에 매핑
volumes: # 3
- /home/ubuntu/custom.conf:/etc/nginx/conf.d/default.conf # 호스트의 Nginx 설정 파일을 컨테이너의 설정 파일로 매핑
- /etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem:/etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem # SSL 인증서 파일을 컨테이너로 매핑
- /etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem:/etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem # SSL 개인 키 파일을 컨테이너로 매핑
application:
image: ${BACKEND_APP_IMAGE_NAME} # 사용할 백엔드 애플리케이션 이미지 (환경 변수로 설정됨)
networks:
- nginx-app-net
ports:
- "8080:8080"
- "8082:8082"
environment: # 4
TZ: "Asia/Seoul" # 컨테이너 내에서 사용할 시간대 설정
SPRING_PROFILE: dev # Spring 애플리케이션의 프로파일 설정
restart: always # 컨테이너가 종료되면 항상 재시작
container_name: develup-app # 5. 컨테이너의 이름 설정
networks:
nginx-app-net: # 사용자 정의 네트워크 생성(여러 컨테이너가 같은 네트워크를 통해 상호작용 할 수 있도록 함 )
1 depends_on depends_on: # 1. 이 컨테이너가 시작되기 전에 먼저 시작해야 하는 컨테이너
- application
depends_on 옵션은 이 옵션이 설정된 컨테이너가 시작되기 전에 먼저 시작되어야 하는 컨테이너를 알려주는 역할을 합니다. 저희 compose.yml에서는 nginx에 해당 옵션이 달려 있으므로 applicaion이 먼저 시작된 뒤 nginx가 시작됩니다.
단, 시작 순서만 정의할 뿐 서비스가 실제로 정상적으로 동작하고 준비된 상태인지 여부를 검증하지는 않습니다.
2 networks networks:
- nginx-app-net # 2. 컨테이너가 연결될 네트워크
networks 옵션은 컨테이너 간 통신을 관리하고, 네트워크 격리를 통해 서비스의 네트워크 환경을 효율적으로 구성할 수 있게 합니다. 기본적으로 같은 compose.yml에 정의된 컨테이너들은 default로 같은 네트워크 상에 위치하지만 위와같이 명시함으로써 상호 의존이 필요 없는 서비스 간의 불필요한 상호작용을 방지할 수 있습니다.
또한, 네트워크를 명시하여 compose 파일의 가독성을 높이고 네트워크 구성을 명확화했습니다.
3 volumes volumes: # 3
- /home/ubuntu/custom.conf:/etc/nginx/conf.d/default.conf # 호스트의 Nginx 설정 파일을 컨테이너의 설정 파일로 매핑
- /etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem:/etc/letsencrypt/live/api.devel-up.co.kr/fullchain.pem # SSL 인증서 파일을 컨테이너로 매핑
- /etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem:/etc/letsencrypt/live/api.devel-up.co.kr/privkey.pem # SSL 개인 키 파일을 컨테이너로 매핑
volumes 옵션을 통해 nginx 설정 파일을 컨테이너에 전달할 수 있도록 매핑했습니다. 우리 서버의 /home/ubuntu/custom.conf 파일이 컨테이너의 /etc/nginx/conf.d/default.conf 파일과 동기화되는 것이에요.
4 environment environment: # 4
TZ: "Asia/Seoul" # 컨테이너 내에서 사용할 시간대 설정
SPRING_PROFILE: dev # Spring 애플리케이션의 프로파일 설정
environment 옵션을 통해 컨테이너의 환경 변수를 설정할 수 있습니다. TZ은 컨테이너에서 사용할 시간대를 설정해줍니다. 그리고 SPRING_PROFILE은 우리가 Dockerfile을 작성할 때 "-Dspring.profiles.active=${SPRING_PROFILE}"라고 명시해 둔 부분에 적용됩니다.
5 container_namecontainer_name: develup-app
containername 옵션은 말 그대로 컨테이너의 이름을 지정해주는 옵션입니다. 사실 명시하지 않아도 Docker는 자동으로 생성된 컨테이너 이름을 부여하는데요. 보통 `<프로젝트명><서비스명>_<번호>형식을 따라 아래를 보면 nginx 컨테이너의 이름이backend-nginx-1로 설정된 것을 볼 수 있습니다. 우리가 compose.yml에서 services의 바로 하단에 nginx, application으로 명시했지만 그 이름으로 생성되지는 않아요. 하지만 명시해주면 명시한 이름 그대로 컨테이너가 생성됩니다. nginx의 설정 파일에서 proxy_pass를 설정할 때 컨테이너 이름으로 접근할 수 있기 때문에 application`은 이름을 지정해주었습니다.
그리고 우리가 한 컨테이너(nginx)에서 다른 컨테이너(spring)를 이름으로 접근할 수 있는 이유는 두 컨테이너가 동일한 네트워크를 사용하고 있기 때문이에요.
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f1ebfa112d38 nginx "/docker-entrypoint.…" 57 minutes ago Up 56 minutes 0.0.0.0:80->80/tcp, :::80->80/tcp, 0.0.0.0:443->443/tcp, :::443->443/tcp backend-nginx-1
1d4b969325b3 {{ spring 파일 IMAGE }} "java -Dspring.profi…" 57 minutes ago Up 56 minutes 0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:8082->8082/tcp, :::8082->8082/tcp develup-app
# ...
location / {
proxy_pass <http://develup-app:8080>; # 이 부분에서 컨테이너 이름인 `develup-app`으로 접근할 수 있어요.
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ...
# 1. 도커 로그인
- name: 🐳 Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 2. 도커 이미지 빌드 및 푸시
- name: 🐳 Docker Image Build and Push
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ secrets.DOCKER_REPOSITORY_NAME }}:${{ github.sha }}
platforms: linux/arm64
deploy:
name: 🚀 Server Deployment
needs: build
runs-on: [ self-hosted, develup ]
defaults:
run:
working-directory: backend
# 3. 도커 컴포즈에 전달될 환경 변수
env:
BACKEND_APP_IMAGE_NAME: ${{ secrets.DOCKER_REPOSITORY_NAME }}:${{ github.sha }}
steps:
- uses: actions/checkout@v4
# 4. 도커 컴포즈 업
- name: 🐳 Docker Compose up
run: docker compose -f compose.yml up -d
# 5. 사용하지 않는 이미지 정리
- name: 🐳 Clean Unused Image
run: docker image prune -af
우리는 CD(Continuous Deployment, 지속적 배포)를 위해 github actions를 사용하고 있는데요. 이 파일까지 수정해줘야 완벽하게 서버 구조를 변경할 수 있습니다. ✨
기존 CD 워크플로에서 변경된 부분만 설명하겠습니다. 😊
1 도커허브 로그인먼저 우리 이미지를 도커허브에 푸시하기 위해서는 로그인을 해야겠죠.
docker/login-action@v3를 액션을 사용해서 로그인을 해줍니다. 도커허브에 로그인하기 위해서는 username과 token이 필요해요. token은 도커허브 홈페이지에서 다음과 같은 순서로 받을 수 있습니다.


2 도커 이미지 빌드 및 푸시 # 2. 도커 이미지 빌드 및 푸시
- name: 🐳 Docker Image Build and Push
uses: docker/build-push-action@v6
with:
context: ./backend
push: true
tags: ${{ secrets.DOCKER_REPOSITORY_NAME }}:${{ github.sha }}
platforms: linux/arm64
도커 이미지 빌드 및 푸시는 별도 명령어를 사용하지 않고 docker/build-push-action@v6 액션을 사용했습니다. context는 default로 git checkout을 해줍니다.(참고로 우리는 서브모듈 체크아웃도 필요하기 때문에 별도로 actions/checkout을 해주었습니다.) Dockerfile이 위치한 디렉토리를 적어줍니다.
3 도커 컴포즈에 전달될 환경 변수 # 3. 도커 컴포즈에 전달될 환경 변수
env:
BACKEND_APP_IMAGE_NAME: ${{ secrets.DOCKER_REPOSITORY_NAME }}:${{ github.sha }}
compose.yml파일에서 application(스프링) 이미지 이름을 환경 변수로 받아서 사용할거라고 했었죠. 그 환경변수는 다음과 같이 env로 명시해주면 docker compose up을 할 때 전달이 됩니다.
4 도커 컴포즈 업 # 4. 도커 컴포즈 업
- name: 🐳 Docker Compose up
run: docker compose -f compose.yml up -d
이제 드디어 도커 컨테이너를 실행합니다! -f 옵션은 도커 컴포즈 파일의 경로를 지정합니다. 기본적으로 compose.yml 파일을 찾아 실행해주지만 프로덕션 서버에서 compose.yml 파일을 사용한다는 것을 명시하기 위해 지정해주었습니다.
그리고 -d 옵션은 컨테이너를 백그라운드 모드로 실행하는 옵션입니다. 즉, 터미널 세션이 종료되거나 터미널을 다른 작업에서 사용하는 것과 무관하게 실행할 수 있게 해주는 옵션입니다. 👍
5 사용하지 않는 이미지 정리 # 5. 사용하지 않는 이미지 정리
- name: 🐳 Clean Unused Image
run: docker image prune -af
다음은 도커에서 사용하지 않는 이미지를 정리해줍니다. 불필요한 이미지를 갖고 있으면 디스크 공간을 차지하기 때문에 삭제해주는 과정입니다. -a 옵션은 모든 사용하지 않는 이미지를 삭제하고, -f는 삭제 작업을 진행하기 전에 사용자 확인을 생략하고 강제로 실행하는 옵션입니다. 만약 중요한 이미지가 있는 경우는 사용하지 않는게 좋겠죠.