로컬에서는 파일의 경로를 찾지만, 도커 환경에서는 Spring Boot 컨테이너에서 logback-spring.xml
파일 경로 못찾는 에러가 발생했다.
Caused by: java.io.FileNotFoundException: src/main/resources/logback-spring.xml (No such file or directory)
at java.base/java.io.FileInputStream.open0(Native Method)
at java.base/java.io.FileInputStream.open(FileInputStream.java:216)
at java.base/java.io.FileInputStream.<init>(FileInputStream.java:157)
at ch.qos.logback.core.joran.GenericXMLConfigurator.doConfigure(GenericXMLConfigurator.java:92)
우선 문제가 발생한 logback 파일을 사용하는 SimpleSockerServer 의 내부 구성 코드를 파악해보니, FileInputStream을 활용해 파일에 접근하고 있었다.
우선 src/main/resources/
내부에 있는 파일들은 Jar 파일의 BOOT-INF/classes/
에 위치한다. 따라서 혹시나 빌드 과정에서 파일이 포함되지 않았는지 확인해보자.
나와 같은 경우는 스프링 부트 컨테이너 내부로 접속해서, JAR 파일 압축을 풀어 확인해봤더니 logback-spring.xml 이 위치하고 있었다.
sudo docker exec -it {컨테이너 ID} /bin/bash
1번에서 파일이 잘 있는 것을 확인했으니, 컨테이너에서 제대로 인식을 하지 못하는 원인만 남아있었다. 근데 곰곰히 생각해보니 완전 기본적인 것을 놓치고 있었던 것이다.
File
클래스는 파일 시스템 내의 리소스를 읽는다. 즉 FileReader
가 데이터를 가져오는 스트림인 FileInputStream
이 OS 파일의 경로를 읽기 때문에 파일 시스템 경로를 참조해야 한다.
따라서, 파일 시스템 경로를 참조하는 경우가 아니라면 일반적으로 프로젝트 루트 디렉터리 기준 경로 또는 classpath:logback-xml
과 같은 클래스패스 경로를 통해 파일을 참조할 수 있다.
예를 들어, CSV 파일을 읽을때는 아래와 같이 읽을 수 있다. CSVReader
는 Reader(문자 스트림) 기반으로 동작하므로 InputStreamReader
로 감싼 getResourceAsStream()
결과를 바로 전달할 수 있기 때문이다.
InputStream inputStream = getClass().getResourceAsStream("/user.csv");
CSVReader csvReader = new CSVReader(new InputStreamReader(inputStream));
// CSVReader csvReader = new CSVReader(new FileReader(Objects.requireNonNull(getClass().getResource("/user.csv")).getFile()));
🔗 classpath: 접두사
JVM이 리소스를 찾을 때 사용하는 특별한 경로 접두사이다. 도커 컨테이너에서는 JAR 내부의 파일을 직접 참조하는 게 일반적이기 때문에, OS 시스템에 독립적인classpath:
을 사용하는게 더 권장된다.
하지만 당연히 logback-spring.xml
파일은 압축된 JAR 파일 내부에 포함된 리소스기 때문에, 파일 시스템 상에 직접 존재하지 않아 파일 경로를 찾지 못한다는 오류가 발생했다.
일반적인 해결 방법은 두가지가 있다.
FileReader
가 파일 경로를 읽을 수 있도록 한다.FileReader
를 사용하지 않고, InputStream → InputStreamReader → BufferedReader
흐름을 활용해 가져온다.InputStream inputStream = getClass().getClassLoader().getResourceAsStream("logback-spring.xml");
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream))) {
}
사실상 해당 방식이 FileReader
보다 코드가 조금 길어지는거 빼고는 1)JAR 내부 / 파일 시스템의 리소스 모두 접근이 가능하고 2)명시적으로 인코딩도 지정해줄 수 있어 훨씬 범용적인 방식이다.
하지만 2번 방식은 현재와 같이 SimpleSockerServer
라이브러리를 활용하는 특수한 상황에서는 해결이 되지 않기 때문에, 1번 방식으로 어떻게 해결할 수 있는지 확인해보자. 빌드 과정에서 해당 파일을 도커 컨테이너의 파일 시스템으로 복사해주면 문제를 해결할 수 있다.
도커 파일 시스템은 크게 컨테이너 레이어와 이미지 레이어로 나누어진다.
docker run
이후 만들어지는 Read-Write 레이어docker build
시 만들어지는 Only Read 레이어이다. 이때 도커에서는 Copy-On-Write
전략에 의해, 쓰기 작업은 상위 컨테이너 레이어로 복사되어 이루어진다. 따라서 하나의 이미지로부터 복수의 컨테이너를 실행시켜도 정상적으로 동작하게 된다.
실제 컨테이너 사이즈를 출력했을 때 나오는 크기는 컨테이너 레이어의 크기를 의미한다.
sudo docker ps -s -- size 출력
Size
: 컨테이너 레이어의 데이터의 크기Virtual Size
: 컨테이너 레이어 + 이미지 레이어 데이터 크기깃허브 액션을 통해서 배포를 하고 있었기 때문에, CI 서버 내에 파일을 업로드
할 때 logback-spring.xml
을 추가해주면 된다.
- name: Build with Gradle
run: SPRING_PROFILES_ACTIVE=test ./gradlew :${{ env.MODULE_NAME }}:clean :${{ env.MODULE_NAME }}:build
shell: bash
- name: Upload build artifact (JAR and Dockerfile)
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
./${{ env.MODULE_NAME }}/build/libs/*.jar
./${{ env.MODULE_NAME }}/build/resources/main/logback-spring.xml
./${{ env.MODULE_NAME }}/*.Dockerfile
그리고 DockerFile에 COPY 명령어를 통해 CI 서버에 있던 logback-spring.xml
파일을 도커 이미지의 config/ 디렉토리로 복사하자.
FROM eclipse-temurin:17-jdk-jammy
...
COPY build/resources/main/logback-spring.xml config/logback-spring.xml
ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar", "--spring.profiles.active=${PROFILE}"]
COPY
: 도커 이미지 빌드 시, 해당 파일을 이미지 내부로 포함시킨다.즉 아까 위에서 언급한 파일 시스템을 참고해보면, 다음과 같이 복사 과정이 일어나게 된다.
logback-spring.xml
파일이 새로운 이미지 레이어에 저장된다.나와 같이 사용하는 라이브러리에서 FileInputStream을 사용하고 있어 바꾸지 못하는 경우가 아니라면, 1번 방식은 파일이 1개가 불필요하게 더 생성된다는 단점이 있다.
따라서 2번 방식으로 FileReader를 사용하지 않고 InputStreamReader로 읽어서 해결하는게 가장 좋은 방식으로 생각된다.
참고 자료
https://docs.docker.com/reference/dockerfile/#copy
https://docs.docker.com/get-started/docker-concepts/building-images/understanding-image-layers/#create-a-base-image