build.gradle 라인에서 만드는 그 Dockerfile 파일

요구사항에 대해
1. Amazon Corretto 17 이미지를 베이스 이미지로 사용하세요.
이와 관련하여 Amazon Corretto 17 Library Index Digest가 있는 링크를 주었다.
https://hub.docker.com/layers/library/amazoncorretto/17/images/sha256-8929bdc3e2be20250ae46b3bc1dc361fcd637cd189ec02174251b0e499a22fad
hub.docker.com
여기에 들어가서 무엇을 해야하는가?
Amazon Corretto 17 이미지를 베이스로 사용하라고 했으니 이 베이스를 가져와야한다.

위 링크에서 amazoncorretto:17 이미지의 정확한 digest(다이제스트, sha256) 이 다이제스트로 이미지를 고정(pinning)하기 위해 이러한 작업들을 한다. 저 인덱스 다이제스트를 끌어다 쓰면 "오늘의 amazoncorretto:17이 내일 바뀌지 않는다".
저걸 복사해서 Dockerfile에 적어야한다. 어떻게 적는가?
1. Index Digest 복사
2. Digest로 직접 pull하는 방법이 있다
docker pull amazoncorretto:17@sha256:8929bdc3e2be20250ae46b3bc1dc361fcd637cd189ec02174251b0e499a22fad
이렇게 태그 + 다이제스트 형태로 pull하면 정확한 빌드를 받게된다.
3. 혹은 Dockerfile에서 고정하여 사용할 수 있다.
나는 이 방법을 선택했다.
# Amazon Corretto 17 이미지를 베이스로 사용하기, 이미지 이름 + 태그 + 다이제스트 조합
FROM amazoncorretto:17@sha256:51842f6c1745a8fb131acadd6788950531d52b37af3caa0c193c14e05ab046b9
Dockerfile에서 쓰이는 FROM 명령어를 통해 고정하여 사용했다.
이렇게 하면 연속 통합 및 연속 배포(CI/CD)나 로컬 빌드가 항상 동일한 베이스 이미지로 시작된다.
4. 아키텍처가 여러개일 때
위 허브 이미지에서는 OS/ARCH 정보도 보여주고 있다. Layers의 amazonlinux: ... 이게 그거임
멀티플랫폼 이미지면 디바이스에 맞는 아키텍처가 풀리는데, 특정 아키텍처를 고정하고 싶다면
# 해당 리포의 bash
docker pull -platform=linux/amd64 amazoncorretto:17@sha256:8929bdc3e2be20250ae46b3bc1dc361fcd637cd189ec02174251b0e499a22fad
#또는 build할 때
docker build --platform=linux/amd64 -t myapp .
이런식으로 아키텍처를 고정할 수 있다고 한다.
2. 작업 디렉토리를 설정하세요. (/app)
작업 디렉토리를 설정할때 Dockerfile에서 사용하는 명령어는 WORKDIR 이다. WorkDirectory의 줄임말이겠지?
# 작업 디렉토리 설정하기
WORKDIR /app
3. 프로젝트 파일을 컨테이너로 복사하세요.
단, 불필요한 파일은 .dockerignore를 활용해 제외하세요.
프로젝트 파일을 컨테이너로 '복사' 한다. 복사하는 명령어는 COPY임
예를들어 전체 프로젝트를 컨테이너 안으로 복사하려면
WORKDIR /app
COPY . .
이렇게 적으면 현재 디렉토리 (.) 안에 있는 모든 파일(.)이 컨테이너 /app 폴더에 복사된다.
그리고 .dockerignore을 활용해서 불필요한 파일을 제외하라고 했다.
내 로컬 프로젝트 폴더에 있는 파일들을 Docker 컨테이너 안으로 복사하는데, 빌드/실행에 필요없는 것들이 복사되면 속도도 느리고 용량도 많이 차지할 것이다.
따라서 .dockerignore 파일에 내가 가져오고 싶지 않은 것들을 적어둔다.
# .gitignore이랑 비슷하게 작성하기
# 빌드 산출물들
/build
/out
/bin
.gradle
# VCS, 버전 관리 시스템
.git
.gitignore
# IDE, 통합 개발 환경
.idea
*.iml
.vscode
# 기타 등등
*.log
.DS_Store
이렇게 적어두면 docker build를 할 때 해당 파일이나 폴더들은 복사 대상에서 제외된다.
* .dockerignore을 통해 제외해야할 파일/폴더의 기준?
컨테이너 실행에 꼭 필요한 소스/설정/리소스만 남기고, 나머지는 전부 제외하는게 좋다.
- 반드시 제외해야 하는것
1. 빌드 산출물 : /build, /out, /bin, /target
이미지를 빌드할 때 새로 생성되므로 불필요하다. 포함하면 이미지만 커지게됨
2. Gradle/Maven 캐시 : .gradle, .mvn
의존성 캐시는 Docker 내부 빌드 캐시로 처리한다. 로컬 캐시를 그대로 넣으면 일관성이 깨진다고 함
3. 버전 관리 시스템 파일 : .git, .gitignore, .svn
앱 실행과 무관하며 용량만 늘린다.
4. IDE/에디터 관련 파일 : .idea, .vscode,*.iml, *.swp
개발 환경 전용이라서 컨테이너와 무관하다.
5. OS 임시파일 : .DS_Store, Thumbs.db, *.log, *.tmp
필요없다
- 선택적으로 제외할 수 있는것 : 테스트 리소스(/src/test/), 문서나 예제(/docs/, /examples/) 등
- 포함해야하는 것
1. src/main/** (소스코드)
2. build.gradle.kts, settings.gradle.kts, gradlew, gradle/ (빌드 도구들)
3. application.yml, application.properties (설정 파일들)
4. 리소스(src/main/resources/**)
4. Gradle Wrapper를 사용하여 애플리케이션을 빌드하세요.
Gradle Wrapper를 사용하여 애플리케이션을 빌드하라는 의미 : 프로젝트에 포함된 Gradle Wrapper 스크립트를 사용해서 빌드
* Gradle Wrapper
- 프로젝트 루트에 있는 gradlew(리눅스나 맥) 혹은 gradlew.bat(윈도우), gradle/ 디렉토리를 의미함
- Gradle을 전역에 설치하지 않아도 Wrapper 스크립트가 정해진 버전의 Gradle을 자동으로 받아 실행해준다
- 그래서 내 컴퓨터에서는 7.6버전인데 CI/CD에서는 8.5네? 같은 버전 차이 문제를 막을 수 있다.
Dockerfile에 이렇게 적으면 된다
#gradlew를 실행 가능하게 만들기
RUN chmod +x ./gradlew
#Gradle Wrapper로 빌드하기
RUN ./gradlew clean bootJar --no-daemon -x test
* bootJar는 Spring Boot 애플리케이션을 실행 가능한 JAR 파일로 만들어주는 Gradle 작업(task)임
5. 80 포트를 노출하도록 설정하세요.
컨테이너 안에서 실행되는 애플리케이션이 80번 포트를 사용하도록 알리기 위해 설정한다.
실제로 80번 포트를 열어주는게 아니라, 컨테이너를 실행할 때 호스트와 포트 맵핑을 쉽게 할 수 있도록 도와주는 힌트역할을 함
Dockerfile에 EXPOSE 명령어를 활용하여 작성한다.
EXPOSE 80
가령 컨테이너를 실행할 때
docker run -p 8080:80 myapp
이렇게 실행할 때가 있는데, -p 8080:80은 호스트 8080 -> 컨테이너 80으로 연결해주는 역할을 한다.
따라서 사용자는 브라우저에서 http://localhost:8080으로 접속하지만
컨테이너 내부에서는 80번으로 통신하게 된다.
6. 프로젝트 정보를 환경 변수로 설정하세요.
실행할 jar 파일의 이름을 추론하는데 활용됩니다.
PROJECT_NAME: discodeit
PROJECT_VERSION: 1.2-M8
Docker 컨테이너 안에서 환경변수를 정의해두고, 그 값으로 실행할 JAR파일 이름을 자동으로 맞추라는 뜻임
이건 아직 무슨 소린지 모르겠다..만 일단 Dockerfile에 ENV 명령어를 활용해서 적어두자
# 프로젝트 환경 변수 설정하기, 실행할 jar파일의 이름을 추론하는데 사용함
ENV PROJECT_NAME=discodeit \
PROJECT_VERSION=1.2-M8
이렇게 하면 Spring Boot Gradle 빌드 결과가 보통 build/libs/discodeit-1.2-M8.jar 형태로 나온다고 한다.
PROJECT_NAME - PROJECT_VERSION.jar의 패턴을 따른다고 함
이렇게 하면 Dockerfile에서 이름을 하드코딩하지 않고 환경변수로 추론하면, 버전이 바뀌어도 Dockerfile은 그대로 쓸 수 있음
이후에 Dockerfile에 ENTRYPOINT 명령어를 활용해서 적는다
# 애플리케이션 실행 명령어 설정하기, 환경변수로 정의한 프로젝트 정보 활용하기
ENTRYPOINT ["/bin/sh", "-c", "java -jar $(ls /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}*.jar"]
ENTRYPOINT 명령어를 활용해서 컨테이너를 실행했을 때 자동으로 java -jar /app/build/libs/discodeit-1.2-M8.jar을 실행한다.
java -jar $(ls /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}*.jar 이렇게 적혀있기 때문
환경변수로 처리한 모습이다
나중에 버전이 1.3이나 다른 버전으로 바뀐다면 위의 ENV에 PROJECT_VERSION=1.3-M8 이렇게 바꿔주면 된다
7. JVM 옵션을 환경 변수로 설정하세요.
JVM_OPTS: 기본값은 빈 문자열로 정의
컨테이너 안에서 Java를 실행할 때 추가할 수 있는 JVM 옵션(메모리 설정, GC 옵션 등)을 환경변수로 받아서 실행에 반영하라는 의미임. 단, JVM_OPTS의 기본값은 빈 문자열로 정의하라고 했다.
Dockerfile에 ENV 명령어를 활용하여 작성한다
# JVM 옵션을 환경변수로 설정하기, JVM_OPTS=기본값은 빈 문자열로 정의
ENV JVM_OPTS=""
그럼 현재 Dockerfile에 있는 환경변수는 총 3개가 된다
ENV PROJECT_NAME=discodeit \
PROJECT_VERSION=1.2-M8
JVM_OPTS=""
그리고 ENDPOINT 명령어를 사용한 실행명령에 JVM_OPTS 환경변수를 적용해준다
ENTRYPOINT ["/bin/sh", "-c", "java $JVM_OPTS -jar $(ls /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}*.jar"]
- > JVM_OPTS가 비어있으면 $JVM_OPTS에는 아무것도 들어가지 않아서 java -jar ... 이 실행됨
값이 있다면, 만약 실행할 때 docker run -e JVM_OPTS="-Xms512m -Xmx1024m" myapp 이렇게 했다고 하자
그럼 Docker 컨테이너 안에서는 java -Xms512m -Xmx1024m -jar /app/build/libs/discodeit-1.2-M8.jar 이 실행된다.
* 이렇게 하는 이유
Dockerfile을 바꾸지 않고도 메모리 옵션, GC옵션 등 런타임 JVM설정을 자유롭게 바꾸기 위해서이다
CI/CD환경, 운영환경에 따라 다른 설정을 손쉽게 적용할 수 있다
8. 애플리케이션 실행 명령어를 설정하세요.
이때 환경변수로 정의한 프로젝트 정보를 활용하세요.
Dockerfile에 ENDPOINT 명령어를 통해 적은게 실행 명령어이다.
# 애플리케이션 실행 명령어 설정하기, 환경변수로 정의한 프로젝트 정보 활용하기
ENTRYPOINT ["/bin/sh", "-c", "java $JVM_OPTS -jar $(ls /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}*.jar"]
여기까지가 Dockerfile 구성하기인데, 이미지 빌드를 할 때 에러가 나왔다.
8. Docker 이미지를 빌드하고 태그(local)을 지정하세요.
이건 이미지를 빌드하는데, 태그를 local로 지정하는것이다. Docker 이미지를 빌드하는 명령어는?
docker build -t discodeit:local .
이 명령어는 프로젝트 루트, Dockerfile이 있는 위치에서 cmd로 실행해야한다. 파워 어쩌고로 하면 안될 수 있으니 주의
이걸로 이미지를 빌드하고 로컬을 확인해보면

이렇게 순서대로 실행되는걸 알 수 있다..
그리고 docker images 명령어를 통해 확인해보면

discodeit 이미지가 local TAG를 달고 있는것을 확인할 수 있다
9. 빌드된 이미지를 활용해서 컨테이너를 실행하고 애플리케이션을 테스트하세요.
prod 프로필로 실행하세요.
데이터베이스는 로컬 환경에서 구동중인 PostgreSQL 서버를 활용하세요.
http://localhost:8081로 접속 가능하도록 포트를 맵핑하세요.
이제 컨테이너를 실행해야하는데, 이게 좀 길다..
# linux의 경우 host.docker.internal을 해석할 수 있도록 run 밑에 add-host를 추가해야함
# --add-host=host.docker.internal:host-gateway ^
docker run -d --name discodeit ^
-e SPRING_PROFILES_ACTIVE=prod ^
-e SPRING_DATASOURCE_URL="jdbc:postgresql://host.docker.internal:5432/discodeit" ^
-e SPRING_DATASOURCE_USERNAME="discodeit_user" ^
-e SPRING_DATASOURCE_PASSWORD="discodeit1234" ^
-e SERVER_PORT=80 ^
-e JVM_OPTS="" ^
-p 8081:80 ^
discodeit:local
이걸 실행하는 과정에서 에러가 발생했다.
(HTTP code 500) server error - ports are not available: exposing port TCP 0.0.0.0:8081 -> 127.0.0.1:0: listen tcp 0.0.0.0:8081: bind: An attempt was made to access a socket in a way forbidden by its access permissions.
호스트의8081포트가 이미 점유되었거나, 내 윈도우에서 8081포트가 예약/차단되었을 때 발생하는 에러라고 하는데,
나는 그런 기억도 없고 예전 실습에서 8081포트가 잘 실행됐었다. 그런데 어째서?
뭔가 잘못된것 같아서 컴퓨터를 껐다가 다시 실행한 다음 Docker run을 해보니 정상작동 했다..
Docker 업데이트를 안해서 그랬나?
아무튼 Docker run -d 를 통해 백그라운드로 도커 컨테이너를 실행했으니 이를 확인해봐야한다.
docker logs -f myappname
이걸 통해서 백그라운드에서 실행되고 있는 컨테이너 상태를 확인할 수 있다.
또 하나의 에러가 발생했었다.
Error: Unable to access jarfile /app/discodeit-1.2-M8.jar
이 에러는 컨테이너 안에서 지정한 JAR파일인 /app/discodeit-1.2-M8.jar을 못찾기 때문에 발생했다.
ENTRYPOINT ["/bin/sh", "-c", "java $JVM_OPTS -jar $(ls /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}*.jar"]
이게 현재 endpoint로 실행 명령어인데, dockerfile에 COPY대상으로
COPY . .
이렇게 적어뒀다. 그럼 모든 프로젝트 파일을 컨테이너로 복사하는 것이기 때문에 build/libs/... 산출물도 같이 들어가긴 한다.
그런데 문제는
1. COPY . . 가 실행되는 시점에 아직 gradlew bootJar이 빌드되어있지 않으면, build/libs 폴더가 비어있을 수 있다.
따라서 /app/discodeit-1.2-M8.jar 자체가 존재하지 않게 됨
2. Gradle이 만드는 파일 이름이 discodeit-1.2-M8-plain.jar 같은 형태라면, 실행 명령에서 찾는 /app/discodeit-1.2-M.8.jar 와 달라서 Unable to access가 발생하는 것이다.
그래서 나는 복사/이름고정 없이 build/libs 안의 JAR을 직접 실행하게끔 endpoint를 수정했다.
# 맨 끝에 | head -n 1 이 추가됐다
ENTRYPOINT ["/bin/sh","-c","exec java $JVM_OPTS -jar $(ls /app/build/libs/${PROJECT_NAME}-${PROJECT_VERSION}*.jar | head -n 1)"]
그 후에
docker image # 이미지 찾기
docker rm -f myappname # 실행중인 컨테이너 삭제
docker rmi myappname:local # 로컬 이미지 삭제
docker build -t myappname:local . # 빌드
docker run -d --name discodeit ^ # 다시 실행
-e SPRING_PROFILES_ACTIVE=prod ^
-e SPRING_DATASOURCE_URL="jdbc:postgresql://host.docker.internal:5432/discodeit" ^
-e SPRING_DATASOURCE_USERNAME="discodeit_user" ^
-e SPRING_DATASOURCE_PASSWORD="discodeit1234" ^
-e SERVER_PORT=80 ^
-e JVM_OPTS="" ^
-p 8081:80 ^
discodeit:local
귀찮아서 run은 그냥 복붙했다.
그리고 로그를 확인해보니
25-08-26 05:42:07.591 [main] ERROR o.s.o.j.LocalContainerEntityManagerFactoryBean [ | | ] - Failed to initialize JPA EntityManagerFactory: [PersistenceUnit: default] Unable to build Hibernate SessionFactory; nested exception is org.hibernate.tool.schema.spi.SchemaManagementException: Schema-validation: missing column [content] in table [messages]
와 같이 엔티티와 실제 DB 테이블의 칼럼이 일치하지 않는 Hibernate가 스키마 검증에서 실패하는 에러가 발생했는데,
이건 내 schema.sql 등을 통해 생성한 테이블의 칼럼을 에러가 뜰때마다 바꿔주면 해결되는 문제였다.