TLDR
- docker build cache, gitlab-runner cache로 인해 빌드 서버 용량 15GB를 차지하고 있었다. 서버 가용 공간이 없어서 빌드가 계속 실패하는 문제가 발생했다.
- 해당 빌드 서버를 백엔드 팀과 프론트 팀에서 사용하고 있고, 개인 프로젝트에서도 사용하고 있었다. 빌드 서버를 사용한지 2주만에 벌어진 일이다.
- 팀원과 거의 동시에 MR을 하게 되었고, 팀원 변경 사항을 반영하고 다시 MR을 하려고 대기 중인 파이프라인을 중단시켰다. 그런데 이때부터 빌드가 안 된 것이다. 수동으로 파이프라인을 종료하면서 설정이 꼬였다고 생각했는데, 애석하게도 이것과는 상관이 없었다.
에러 로그
* What went wrong:
Could not add entry ':bootJar' to cache executionHistory.bin (/home/gitlab-runner/builds/64Gb_ydb/0/cloud_track/class_02/web_project3/team06/backend/.gradle/8.7/executionHistory/executionHistory.bin).
> java.io.IOException: No space left on device
- 사실, 이때까지도 서버의 공간이 부족하리라는 생각은 절대 못 했다! 굵직한 라이브러리 몇 개와 서버 스왑 영역을 제외하고도 대략 15기가 이상의 여유는 있었기 때문이다.
- 다시 파이프라인을 동작하니 아래와 같은 에러가 출력되었다. 예상치 않게 작업이 중단된 러너가 해당 프로세스를 잡고 있어서 생긴 문제였다.
Gradle could not start your build.
> Cannot create service of type TaskExecuter using method ProjectExecutionServices.createTaskExecuter() as there is a problem with parameter #9 of type ReservedFileSystemLocationRegistry.
> Cannot create service of type ReservedFileSystemLocationRegistry using method ProjectExecutionServices.createReservedFileLocationRegistry() as there is a problem with parameter #1 of type List<ReservedFileSystemLocation>.
> Could not create service of type ExecutionHistoryStore using ExecutionGradleServices.createExecutionHistoryStore().
> Cannot lock execution history cache (/home/gitlab-runner/builds/64Gb_ydb/0/cloud_track/class_02/web_project3/team06/backend/.gradle/8.7/executionHistory) as it has already been locked by this process.
- 해당 gradle 프로세스를 찾아 강제로 종료시키면, 다시 공간이 없다는 문제가 나온다.
ps -aux | grep gradle
kill -SIGKILL gradle
- 서버의 사용 공간을 조회하니 사용 공간이 없다?! 다 어디 갔지,,
~~ubuntu~~@ip-172-31-35-138:~$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/root 29G 29G 0 100% /
- 어디에서 용량을 많이 쓰는지 검색해 본 결과, docker build cache 용량이 상당하다는 글을 보고 내 빌드 캐시를 찾아보기로 했다.
docker system df
- docker 빌드 캐시가 13GB 정도 쌓여있었다..
도커 빌드 캐시란 무엇인가?
- 도커 빌드 캐시란 도커가 여러 번 빌드 할 때 빌드가 빠르게 실행되도록 도와준다. 이를 이해하기 위해선 먼저 도커 레이어를 이해해야 한다.
- 도커 이미지는 여러 개의 레이어로 구성된다. 레이어는 불변이며 변경되면 새로운 레이어가 생성된다. 각 레이어는 해시값으로 식별된다.
- 아래 그림을 보면 Dockerfile의 각 줄에 작성한 명령어가 레이어로 구성되며, main.c의 내용이 수정되면 기존에 레이어 캐시가 무효가 되고, 새로운 레이어 캐시를 만들게 된다.
- Dockerfile의 내용으로 이미지 레이어가 어떻게 구성되어 있는지 알 수 있다. 예를 들어 아래 Dockerfile을 살펴보자.
FROM gradle:jdk17 as builder
WORKDIR /build
COPY . /build
RUN gradle build --exclude-task test
FROM openjdk:17-slim
WORKDIR /app
COPY --from=builder /build/build/libs/*-SNAPSHOT.jar app.jar
EXPOSE 8080
USER nobody
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=dev", "app.jar"]
- 위에서부터 명령어에 따라 도커 이미지 레이어가 형성된다.
- 도커 빌드를 하고, 또 빌드하게 되면 기존의 이미지 레이어 캐시(즉, 빌드 캐시)를 사용하기 때문에 굉장히 빠르게 되는 걸 볼 수 있다. (초기 빌드 대략 2분, 두 번째는 0.1초)
- Dockerfile 명령어를 위에서부터 레이어를 만들어 차곡차곡 쌓는다고 볼 수 있다. 변경이 되지 않으면 만들어진 레이어를 사용한다. 그럼, 위의 명령어를 수정하면 어떻게 될까? 즉, 캐시할 수 있는 부분이 없게 된다. 아래와 같이 수정해 보고 빌드 해보자.
FROM gradle:jdk17 as builder
WORKDIR /build3
COPY . /build3
...
COPY --from=builder /build3/build/libs/*-SNAPSHOT.jar app.jar
...
- WORKDIR을 /build3 이름으로 바꾼것 뿐인데, 빌드를 해보면 2분 걸리는 걸 알 수 있다.
- 그럼 /build로 수정하고 빌드해보자. 0.1초가 걸리고 다시 /build3으로 수정해도 0.1초가 걸리는 것을 알 수 있다. 즉 도커는 이미지를 한 번 만들면 이를 재사용할 수 있도록 도커 빌드 캐시에 저장한다.
테스트를 통해 새로 알게된 사실
- 도커 파일을 작성할 때 변경이 잦은 부분과 그렇지 않은 부분을 나누어 캐시할 수 있는 레이어를 많이 확보하는 게 중요하다. 물론 이미지 크기와 레이어는 적을수록 좋다.
- 도커를 빌드하고 나면 Image와 Build Cache가 쌓인다. 위의 예시에선 이미지 크기가 485MB였는데 첫 번째로 빌드할때는 485MB가 디스크에 추가되고, 두 번째로 빌드했을 때 Disk에 추가 된 건 대략 80MB이다. 왜 이런 차이가 발생할까? 기존 레이어를 재활용하기 때문이다. 기반 이미지를 별도의 컨테이너마다 각자 가지고 있는 게 아니라 공통된 부분을 공유한다.
- 도커 이미지 레이어 캐시와는 별도로 변경이 없다면 같은 레이어 해시값을 가진다. 따라서 빌드 결과물은 공유해서 디스크 공간은 절약하지만, 중간에 빌드 과정을 기록하는 빌드 캐시는 각각 별도로 유지하는 걸 볼 수 있다. 즉 레이어를 얼마나 재활용할 수 있게 설계하느냐에 따라 추가되는 빌드 캐시 공간이 달라진다.
- build를 할 때
—-no -cache
옵션을 주어 캐시를 사용하지 않을 수 있다. 이 말을 build cache를 만들지 않는다고 오해할 수 있는데, 도커가 빌드할 때 기존 캐시된 레이어를 사용하지 않겠다는 것으로 빌드 시간이 더 오래 걸린다. 해당 옵션을 주고 진행해도 build cache가 추가되는 걸 확인할 수 있다.
- 어떤 이미지의 최신 변경 사항을 확인하려고 할 때
--no -cache
옵션이 필요할 수 있다. 캐시된 레이어는 최신 변경 사항을 반영한 상태가 아니기 때문이다.
- 위의 Dockerfile에서 아래에 위치한 EXPOSE를 8080에서 9090으로 수정해보자. 그렇다면 그 위에 있는 이미지 레이어를 재활용해야 한다고 생각할 수 있다. 하지만, 실제로 해보면 그렇게 동작하지 않는다. 스크립트 시작 부분에 . /build 로 파일을 복사할 때 변경된 Dockerfile이 포함되고, 이에 따라 초기 이미지 레이어를 캐시할 수 없기 때문이다.
- 공식 문서를 보면, 아래 사진 처럼 파일을 모두 복사하지 말고 필요한 부분만 복사하여 레이어 캐시를 활용하라고 한다. 위의 예시처럼 2단계 멀티스테이징을 적용할 경우 2단계에서 부분적으로 호스트 파일(ex: 도커파일)을 가져올 수 없기 때문에 레이어 캐시를 재활용할 수 없다.
그래서?! crontab
- 매일 새벽 5시에 빌드 캐시를 삭제하도록 한다. 하루에 빌드를 30번 정도하면 주기를 더 짧게해야 겠지만, 지금은 이정도로 충분해보인다.
crontab -e
0 5 * * * /usr/bin/docker builder prune -f
- 새벽 5시에 확인해 봤더니, 크론이 돌지 않았다. 서버 시간대를 확인해 보니 기본 설정은 UTC 시간을 쓰고 있다. UTC 시간을 기준으로 크론탭 시간을 변경해도 되지만, 서울 기준으로 시간을 설정해 주었다.
참고 자료