최근 Docker Desktop for Mac 환경에서 JDK 17을 사용하는 Spring Boot 애플리케이션이 실행되지 않는 문제를 발견했다.
이상한 점은 Windows 환경에서는 정상 실행되지만, Mac에서는 실행 후 바로 종료된다는 것이다.
"환경 차이 때문인가?" 🤔 하지만 Docker는 OS와 무관하게 동일하게 실행되어야 하는데, 왜 이런 문제가 발생한 걸까?
정확한 원인을 찾기 위해 Deep Dive 해보았다.
🔍 문제상황
Mac에서 실행한 Docker Container의 로그를 확인한 결과,
JVM이 컨테이너 내부 리소스를 감지하는 과정에서 NullPointerException 발생했다.
java.lang.NullPointerException: Cannot invoke "jdk.internal.platform.CgroupInfo.getMountPoint()" because "anyController" is null
"왜 NPE가 발생하는 거지?" 🤔
"설마 Docker 컨테이너에 메모리 제한이 걸려있나?"
"JVM 설정이 뭔가 잘못됐나?"
여러 의문이 들었고, 하나씩 검증해보기로 했다.
🤔 Docker container 메모리 제한 확인
Docker 컨테이너는 실행할 때 각 컨테이너별로 자원 할당을 설정할 수 있다.
혹시 이 설정이 적용된 건 아닌지 확인하기 위해 아래 명령어를 실행했다.
docker exec -it <container_id> java -XX:+PrintFlagsFinal | grep -iE 'heap|memory'
실행결과:
size_t MaxHeapSize = 2055208960 {product} {ergonomic}
별다른 JVM 설정을 하지 않았는데 heap 메모리가 제한된 것처럼 보인다.
Docker 컨테이너 자체에 리소스 제한이 있는지 추가로 확인해보았다.
docker inspect <container_id> | grep -i memory
실행결과:
"Memory": 0,
"MemoryReservation": 0,
"MemorySwap": 0,
"MemorySwappiness": null,
📌
“Memory”: 0
값은 Docker container에 별다른 리소스 제한이 설정되지 않았다는 의미다.
그런데 JVM에서는 왜 제한이 걸린 것처럼 보일까? 🤔
검색을 통해 알아보니,
Docker Desktop for Mac은 내부적으로 Linux VM을 실행해 동작하며, 이 VM의 cgroup 설정이 JVM이 인식하는 것과 다를 수 있다는 것을 발견했다.
이게 정말 문제의 원인일까? 더 깊이 살펴보았다.
🤔 Docker Desktop 의 cgroup 확인
먼저, JVM이 cgroup 정보를 올바르게 읽을 수 있는지 확인했다.
아래 명령어를 실행하면 컨테이너 내부에서 사용 중인 cgroup 버전과 마운트 정보를 확인할 수 있다.
docker exec -it <container_id> cat /proc/self/mountinfo | grep cgroup
실행결과:
215 214 0:39 / /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw
이 결과로 볼 때, 컨테이너는 cgroup v2를 사용하고 있다.
Docker Desktop이 어떤 cgroup 버전을 사용하는지 추가 확인해보았다.
docker info | grep -i cgroup
실행결과:
Cgroup Driver: cgroupfs
Cgroup Version: 2
여기까지 확인했을 때 중요한 점은
- Docekr Desktop for Mac은 cgroup v2를 사용
- 하지만! Driver는 cgroupfs(cgroup v1스타일) 사용
그런데...JVM도 cgroup v2, Docker Desktop도 cgroup v2라면...잘 동작해야하는거 아닌가?
또 다른 의문이 생겼다. 😂
💡 JDK 17의 Cgroup 설정 방식
이제 글 초반에 언급했던 NPE 에러 로그를 다시 살펴보자.
에러가 발생하는 위치를 확장해보면 아래와 같다.
Caused by: java.lang.NullPointerException: Cannot invoke "jdk.internal.platform.CgroupInfo.getMountPoint()" because "anyController" is null
at java.base/jdk.internal.platform.cgroupv2.CgroupV2Subsystem.getInstance(CgroupV2Subsystem.java:81) ~[na:na]
at java.base/jdk.internal.platform.CgroupSubsystemFactory.create(CgroupSubsystemFactory.java:113) ~[na:na]
at java.base/jdk.internal.platform.CgroupMetrics.getInstance(CgroupMetrics.java:167) ~[na:na]
at java.base/jdk.internal.platform.SystemMetrics.instance(SystemMetrics.java:29) ~[na:na]
이 에러가 발생하는 CgroupSubsystemFactory.java
코드를 보면 원인을 알 수 있다!!!!CgroupSubsystemFactory.java
가 갑자기 왜 나오나?! 라고 생각할 수 있겠지만,
- JDK 9 버전 이상부터는
UseContainerSupport
옵션이 기본적으로 활성화된다. - 이 옵션은 JVM이 컨테이너 환경을 감지하고 리소스 제한을 적용하는 기능이다.
- 해당 옵션을 제외하지 않는다면,
CgroupSubsystemFactory.java
클래스를 실행하여 리소스 설정을 한다.
위의 목적으로 아래 코드들에서는 cgroup과 관련된 설정을 읽어온후 리소스에 대한 설정을 저장한다.
에러는 create 메서드에서 시작하지만, 좀 더 중요한 메서드, 코드 위주로 설명하도록 하겠다.
determineType() 메서드
현재 시스템의 cgroup설정을 파악하고 저장하기 위해서 아래 디렉토리들의 정보를 확인하고, 필요한 정보들을 저장하는 역할을 수행한다.
Optional<CgroupTypeResult> optResult = null;
try {
optResult = determineType("/proc/self/mountinfo", "/proc/cgroups", "/proc/self/cgroup");
}
...
CgroupTypeResult result = optResult.get();
...
Map<String, CgroupInfo> infos = result.getInfos();
여기서 주어진 디렉토리들은 각각 다음과 같은 역할을 한다.
- /proc/cgroups: 시스템에서 사용가능한 cgroup 목록 제공
- /proc/self/mountinfo: 현재 프로세스가 접근할 수 있는 파일시스템, 마운트 정보 제공.(v1인지, v2인지 판단하기 위해 사용)
- /proc/self/cgorup: 현재 프로세스가 속한 cgroup 경로 확인
여기서 눈여겨볼 것은 /proc/cgroups 파일을 읽고 컨트롤러 목록을 수집하는 부분이다.
아래 코드에서는 사용가능한 cgroup을 수집하고(cpu, memory, blkio 등) infos에 저장한다.
final Map<String, CgroupInfo> infos = new HashMap<>();
List<String> lines = CgroupUtil.readAllLinesPrivileged(Paths.get(cgroups));
for (String line : lines) {
if (line.startsWith("#")) {
continue;
}
CgroupInfo info = CgroupInfo.fromCgroupsLine(line);
switch (info.getName()) {
case CPU_CTRL: infos.put(CPU_CTRL, info); break;
case CPUACCT_CTRL: infos.put(CPUACCT_CTRL, info); break;
case CPUSET_CTRL: infos.put(CPUSET_CTRL, info); break;
case MEMORY_CTRL: infos.put(MEMORY_CTRL, info); break;
case BLKIO_CTRL: infos.put(BLKIO_CTRL, info); break;
}
}
그러나...
🚨 현재 docker container의 cgroups 정보에는 memory 컨트롤러가 없다.
docker exec -it <container-id> cat /proc/cgroups
#subsys_name hierarchy num_cgroups enabled
cpu 0 21 1
cpuacct 0 21 1
blkio 0 21 1
devices 0 21 1
freezer 0 21 1
net_cls 0 21 1
perf_event 0 21 1
net_prio 0 21 1
hugetlb 0 21 1
pids 0 21 1
rdma 0 21 1
determineType() 메서드를 수행한 이후 코드를 살펴보자.
private static final String MEMORY_CTRL = "memory";
...
Map<String, CgroupInfo> infos = result.getInfos();
if (result.isCgroupV2()) {
// For unified it doesn't matter which controller we pick.
CgroupInfo anyController = infos.get(MEMORY_CTRL);
CgroupSubsystem subsystem = CgroupV2Subsystem.getInstance(anyController);
return subsystem != null ? new CgroupMetrics(subsystem) : null;
} else {
CgroupV1Subsystem subsystem = CgroupV1Subsystem.getInstance(infos);
return subsystem != null ? new CgroupV1MetricsImpl(subsystem) : null;
}
determineType()
메서드를 통해 설정된 infos를 기준으로 memory에 대한 controller를 조회하지만,
infos에는 해당값이 없기 때문에 🚨CgroupInfo는 null🚨이 된다.
이 이후로 별다른 null 처리가 없어서,CgroupSubsystem subsystem = CgroupV2Subsystem.getInstance(anyController)
해당 코드에서 NPE가 발생한다.
결국 Spring Boot 어플리케이션이 실행되지 않고 종료되는 이슈가 발생한다 (or JVM으로 실행되는 어플리케이션 모두).
📌 NPE원인은 찾았지만 진짜진짜진짜진짜최종 원인은?
Docker Desktop git issue에서 발췌해온 내용인데, 현재 JDK 17 + Docker Desktop for Mac 을 사용한다면, 공통적으로 겪고있는 상황이라고 한다. 어플리케이션이 정상적으로 동작하지 않는 원인은 크게 두가지 인데,
- JDK17에서 memory에 대한 설정이 없는 경우에 대한 Null 처리가 제대로 되어있지 않음 (해당 부분은 픽스됨)
- Docker Desktop for Mac이 사용하는
Linux Kernel 버전 6.12부터 /proc/cgroups 에 memory가 다른 곳으로 이동
함
이 모든것이...Linux Kernel의 문제였다니! 😂
Windows에서 정상동작했던 이유는, Docker Desktop이 Hyper-V 기반의 LinuxKit VM을 사용하고, Mac용 Docker Desktop과는 다른 Linux Kernel버전을 사용하고 있어서이다.
✅ 어떻게 해결할까?
- Docker Desktop for Mac 버전을 다운그레이드 하여 사용한다. 현재 최신버전 이전은 다른 버전의 Linux Kernel을 사용하기 때문에, 현재와 같은 에러가 발생하지 않을 가능성이 높다. release note 확인
- Docker Desktop for Mac을 삭제하고, podman, rancher와 같은 다른 대체제로 전환하여 사용한다. (podman에서 실행해보니 정상적으로 잘 동작했음)
- JVM 옵션으로 UseContainerSupport 옵션을 제외시켜서, JVM이 구동될 때, 컨테이너 리소스 제한을 따르지 않도록 설정하면, 문제가 되고있는 클래스 (CgroupSubsystemFactory.java)를 참조하지 않고 바로 호스트의 리소스 설정을 따라 실행된다.
'DevOps > docker' 카테고리의 다른 글
[docker] docker-compose 업데이트! 무엇이 바뀌었고 언제까지 바꿔야 하는가? (0) | 2022.10.06 |
---|