Table of contents
Open Table of contents
1. 이미지에 다 넣으면 끝 아닌가?
Kubernetes에서 Pod를 띄우는 일 자체는 어렵지 않다. 이미지를 빌드하고, 매니페스트를 쓰고, kubectl apply 하면 된다. 많은 서비스는 정말 여기서 끝난다.
이 믿음은 많은 경우 맞다. 컨테이너 이미지는 애플리케이션과 실행에 필요한 유저 영역 의존성을 묶어서 환경 차이를 줄여주기 때문이다. 그래서 우리는 쉽게 이렇게 생각한다.
필요한 것만 이미지에 다 넣으면 어디서든 같은 방식으로 실행된다.
문제는 GPU다. vLLM 공식 이미지에는 Python, PyTorch, CUDA Runtime이 들어 있지만, 그것만으로 GPU가 바로 동작하지는 않는다.
이미지 안에 필요한 라이브러리를 다 넣어도 GPU는 바로 동작하지 않는다.
이 글은 GPU를 예시로 삼아, 컨테이너가 어디까지를 추상화하고 어디서부터 호스트에 의존하는지 추적한다. 핵심은 GPU보다도, 컨테이너 추상화가 실제로 어디에서 멈추는가에 있다.
2. 컨테이너는 무엇을 감싸고, 무엇을 감싸지 않을까
이 문제를 이해하려면 OS가 크게 두 층으로 나뉜다는 점부터 봐야 한다.
OS의 두 영역:
유저 영역:
일반 프로그램이 실행되는 공간.
nginx, Python, vLLM 같은 프로그램은 전부 여기서 돈다.
커널 영역:
하드웨어를 직접 제어하는 OS의 핵심.
네트워크, 파일시스템, 메모리 관리 등을 담당한다.
유저 영역의 프로그램이 커널 기능이 필요하면
syscall(시스템 콜)이라는 정해진 창구를 통해 요청한다.
컨테이너는 이 유저 영역을 묶어서 실행 단위를 만드는 기술이다. 애플리케이션, 라이브러리, 설정 파일을 이미지에 담고, 실행 시에는 호스트의 커널을 공유한다. Docker 공식 문서도 컨테이너를 “호스트 커널을 공유하는 격리된 프로세스”로 설명한다. VM처럼 자기 커널을 가진 독립된 시스템이 아니다.
그래서 대부분의 서비스는 이미지로 충분하다. 프로그램이 필요한 것은 주로 유저 영역의 실행 환경이고, 커널과의 접점은 비교적 안정적인 syscall 인터페이스를 통해 처리되기 때문이다. 컨테이너는 유저 영역을 포장하는 데는 강하지만, 커널과 하드웨어까지 독립적으로 감추는 추상화는 아니다.
평소에는 이 경계가 잘 드러나지 않는다. 웹 서버나 API 서버는 이 모델 안에서 무리 없이 돌아간다. GPU를 붙이는 순간에야, “이미지로 끝난다”는 감각이 어디까지 유효한지가 드러난다.
3. GPU는 왜 이 믿음을 깨뜨릴까
GPU 워크로드는 컨테이너 추상화의 바깥에 남아 있는 요소들을 선명하게 드러낸다. vLLM이 GPU에서 추론을 실행하는 경로를 단순화하면 다음과 같다.
vLLM이 GPU를 사용하는 경로:
vLLM (Python)
→ PyTorch (libtorch)
→ libcudart.so (CUDA Runtime) ← 이미지에 포함 가능
→ libcuda.so (CUDA Driver) ← 호스트 드라이버와 맞아야 함
→ /dev/nvidia0 (디바이스 파일) ← 호스트에서 생성됨
→ nvidia.ko (커널 모듈) ← 호스트 커널에 종속됨
→ PCIe → GPU 하드웨어
이 경로에서 위쪽은 이미지에 담을 수 있지만, 아래로 갈수록 실행 시점의 호스트 의존성이 커진다. 겉보기에는 전부 “라이브러리”처럼 보이지만, 실제로는 성격이 다르다. 어떤 것은 이미지에 넣을 수 있고, 어떤 것은 호스트가 제공해야 하며, 어떤 것은 커널과 강하게 묶여 있다.
3.1 CUDA Runtime이 있다고 GPU가 되는 것은 아니다
컨테이너 이미지 안에는 libcudart.so 같은 CUDA Runtime 라이브러리를 넣을 수 있다. 실제로 많은 GPU 관련 이미지는 이런 런타임을 포함한다. 그래서 이미지를 보면 필요한 것이 다 들어 있는 것처럼 보인다.
하지만 CUDA Runtime은 GPU를 직접 제어하는 최종 계층이 아니다. 실제 GPU 장치와 통신하려면 그 아래의 드라이버 계층이 필요하다.
많은 사람이 “CUDA가 이미지에 있으니 GPU도 쓸 수 있겠지”라고 생각한다. 하지만 그 판단은 유저 영역까지만 본 것이다. GPU는 그 아래의 드라이버와 장치 연결까지 함께 맞아야 한다.
3.2 nvidia.ko는 이미지 독립성의 해법이 아니다
nvidia.ko는 커널 모듈이다. 일반 프로그램처럼 syscall 인터페이스만 사용하는 것이 아니라, 커널 내부에 들어가서 동작한다.
일반 프로그램 (.so):
프로그램 → syscall → 커널
비교적 안정적인 인터페이스 위에서 동작
커널 모듈 (.ko):
모듈 → 커널 내부 함수 직접 사용
커널 버전에 따라 다시 빌드가 필요할 수 있음
nvidia.ko는 커널 모듈이기 때문에 이미지 안에 미리 넣어 해결할 수 있는 성격이 아니다. 어떤 호스트 커널 위에서 실행될지 빌드 시점에 알 수 없고, 컨테이너가 호스트 커널에 임의로 모듈을 로드할 수도 없다. GPU 사용 경로의 가장 아래 계층은 처음부터 이미지 바깥에 있다.
3.3 libcuda.so는 유저 영역이지만, 호스트 드라이버와 연결된다
libcuda.so는 유저 영역 라이브러리지만, 일반 애플리케이션 라이브러리처럼 완전히 이미지 안에서 닫히는 성격은 아니다. 실제로는 호스트 드라이버 스택과 맞물려 동작하며, 운영 환경에서는 호스트와 맞는 버전이 실행 시점에 연결되는 방식이 일반적이다.
유저 영역 라이브러리라고 해서 모두 이미지 안에서 독립적으로 닫히는 것이 아니다. GPU 드라이버 라이브러리는 유저 영역에 있지만, 여전히 호스트에 강하게 의존한다.
3.4 /dev/nvidia0은 일반 파일이 아니라 디바이스 노드다
GPU와 실제로 통신하려면 /dev/nvidia0, /dev/nvidiactl, /dev/nvidia-uvm 같은 디바이스 노드가 필요하다. 이들은 일반 파일처럼 보이지만, 데이터를 저장하는 것이 아니라 커널 드라이버로 연결되는 장치 엔트리다.
이 파일들은 호스트에서 드라이버가 로드될 때 생성된다. 컨테이너는 호스트와 별도의 /dev 디렉토리를 가지므로, 호스트의 디바이스 파일이 자동으로 보이지 않는다.
3.5 파일이 있어도 권한이 없으면 열 수 없다
디바이스 노드와 드라이버 라이브러리를 컨테이너 안에 전부 연결하더라도, 컨테이너가 그 장치에 접근할 권한이 없으면 GPU를 사용할 수 없다. 컨테이너는 기본적으로 호스트 장치에 대한 접근이 제한되어 있다. 파일을 연결하는 것과 실제로 그 장치를 열 수 있는 것은 별개다.
4. 정말 그런지 L4 GPU로 검증해봤다
이 설명이 맞는지 확인하기 위해, AWS g6.xlarge(NVIDIA L4) 인스턴스에서 직접 실험했다. 호스트에는 NVIDIA 드라이버만 설치하고, GPU Operator나 nvidia-container-runtime 없이 기본 runc만 사용했다. 자동화 계층을 걷어내고 실제로 무엇이 필요한지 최소 구성에서 보기 위해서다.
모든 실험은 privileged 없이 실행하고, 마지막 실험에서만 privileged를 켰다. 각 Pod는 동일한 테스트 스크립트를 ConfigMap으로 마운트해서 실행했다.
테스트 스크립트 (exp-test.py)
import torch, os, ctypes
print("=== CUDA check ===")
print("torch.cuda.is_available():", torch.cuda.is_available())
print("torch.cuda.device_count():", torch.cuda.device_count())
print("\n=== Error trace ===")
try:
torch.cuda.init()
print("torch.cuda.init(): OK")
except Exception as e:
print("torch.cuda.init() error:", type(e).__name__, str(e))
print("\n=== libcuda.so check ===")
try:
ctypes.CDLL("libcuda.so.1")
print("libcuda.so.1: loaded OK")
except OSError as e:
print("libcuda.so.1:", e)
print("\n=== /dev/nvidia check ===")
for d in ["/dev/nvidia0", "/dev/nvidiactl", "/dev/nvidia-uvm"]:
status = "exists" if os.path.exists(d) else "NOT FOUND"
print(d + ": " + status)
if torch.cuda.is_available():
print("\n=== GPU info ===")
print("torch.cuda.get_device_name(0):", torch.cuda.get_device_name(0))
t = torch.tensor([1.0, 2.0, 3.0]).cuda()
print("GPU tensor:", t)
실험 환경
호스트:
OS: Ubuntu 24.04.4 LTS
Kernel: 6.17.0-1009-aws
GPU: NVIDIA L4 (23034 MiB)
Driver: 570.211.01
K8s: MicroK8s v1.34.5
containerd: v1.7.28
Runtime: runc (nvidia-runtime 없음)
사전 준비:
sudo apt install -y nvidia-driver-570
sudo modprobe nvidia
sudo mkdir -p /opt/nvidia-libs
sudo cp /usr/lib/x86_64-linux-gnu/libnvidia* /opt/nvidia-libs/
sudo cp /usr/lib/x86_64-linux-gnu/libcuda* /opt/nvidia-libs/
실험 1: 아무것도 마운트하지 않음
torch.cuda.is_available(): False
libcuda.so.1: cannot open shared object file: No such file or directory
/dev/nvidia0: NOT FOUND
→ "Found no NVIDIA driver on your system"
드라이버 라이브러리도 없고 디바이스 노드도 없다. GPU 접근 경로의 첫 단계에서 막힌다.
실험 1 Pod 매니페스트
apiVersion: v1
kind: Pod
metadata:
name: exp1-no-mount
spec:
containers:
- name: test
image: vllm/vllm-openai:latest
command: ["python3", "/scripts/exp-test.py"]
volumeMounts:
- name: script
mountPath: /scripts
volumes:
- name: script
configMap:
name: exp-script
restartPolicy: Never
실험 2: /dev/nvidia* 만 마운트
torch.cuda.is_available(): False
libcuda.so.1: cannot open shared object file: No such file or directory
/dev/nvidia0: exists
→ "Found no NVIDIA driver on your system"
디바이스 노드가 보여도, 드라이버 라이브러리가 없으면 에러 메시지는 실험 1과 같다. GPU 접근 경로에서 라이브러리 로드가 디바이스 접근보다 먼저이기 때문이다.
실험 2 Pod 매니페스트
apiVersion: v1
kind: Pod
metadata:
name: exp2-dev-only
spec:
containers:
- name: test
image: vllm/vllm-openai:latest
command: ["python3", "/scripts/exp-test.py"]
volumeMounts:
- name: script
mountPath: /scripts
- name: dev-nvidia0
mountPath: /dev/nvidia0
- name: dev-nvidiactl
mountPath: /dev/nvidiactl
- name: dev-nvidia-uvm
mountPath: /dev/nvidia-uvm
volumes:
- name: script
configMap:
name: exp-script
- name: dev-nvidia0
hostPath:
path: /dev/nvidia0
- name: dev-nvidiactl
hostPath:
path: /dev/nvidiactl
- name: dev-nvidia-uvm
hostPath:
path: /dev/nvidia-uvm
restartPolicy: Never
실험 3: 호스트 드라이버 라이브러리만 마운트 (/dev 없이)
torch.cuda.is_available(): False
libcuda.so.1: loaded OK
/dev/nvidia0: NOT FOUND
→ "No CUDA GPUs are available"
이번에는 에러 메시지가 달라졌다. 라이브러리 로드는 성공했지만 디바이스 노드가 없어서 GPU에 도달하지 못했다. 실패하는 레이어가 다르면 에러도 다르다.
실험 3 Pod 매니페스트
apiVersion: v1
kind: Pod
metadata:
name: exp3-libs-only
spec:
containers:
- name: test
image: vllm/vllm-openai:latest
command: ["python3", "/scripts/exp-test.py"]
env:
- name: LD_LIBRARY_PATH
value: "/usr/lib/x86_64-linux-gnu/nvidia"
volumeMounts:
- name: script
mountPath: /scripts
- name: nvidia-libs
mountPath: /usr/lib/x86_64-linux-gnu/nvidia
readOnly: true
volumes:
- name: script
configMap:
name: exp-script
- name: nvidia-libs
hostPath:
path: /opt/nvidia-libs
restartPolicy: Never
실험 4: /dev + .so 마운트, privileged 없음
torch.cuda.is_available(): False
libcuda.so.1: loaded OK
/dev/nvidia0: exists
→ "No CUDA GPUs are available"
파일은 전부 있다. 라이브러리도 로드됐고, 디바이스 노드도 보인다. 그런데도 GPU가 인식되지 않았다. 컨테이너의 장치 접근 권한이 차단하고 있기 때문이다. 파일을 연결하는 것만으로는 부족하고, 장치에 실제로 접근할 수 있는 권한까지 열려야 한다.
실험 4 Pod 매니페스트
apiVersion: v1
kind: Pod
metadata:
name: exp4-dev-libs-noprivileged
spec:
containers:
- name: test
image: vllm/vllm-openai:latest
command: ["python3", "/scripts/exp-test.py"]
env:
- name: LD_LIBRARY_PATH
value: "/usr/lib/x86_64-linux-gnu/nvidia"
volumeMounts:
- name: script
mountPath: /scripts
- name: dev-nvidia0
mountPath: /dev/nvidia0
- name: dev-nvidiactl
mountPath: /dev/nvidiactl
- name: dev-nvidia-uvm
mountPath: /dev/nvidia-uvm
- name: nvidia-libs
mountPath: /usr/lib/x86_64-linux-gnu/nvidia
readOnly: true
volumes:
- name: script
configMap:
name: exp-script
- name: dev-nvidia0
hostPath:
path: /dev/nvidia0
- name: dev-nvidiactl
hostPath:
path: /dev/nvidiactl
- name: dev-nvidia-uvm
hostPath:
path: /dev/nvidia-uvm
- name: nvidia-libs
hostPath:
path: /opt/nvidia-libs
restartPolicy: Never
실험 5: /dev + .so + privileged
torch.cuda.is_available(): True
torch.cuda.device_count(): 1
torch.cuda.get_device_name(0): NVIDIA L4
GPU tensor: tensor([1., 2., 3.], device='cuda:0')
디바이스 노드, 드라이버 라이브러리, 장치 접근 권한이 모두 갖춰졌을 때 비로소 GPU가 인식되었다. 같은 이미지에서 vLLM으로 openai/gpt-oss-20b 모델 서빙과 실제 추론 요청까지 정상 동작했다.
실험 5 Pod 매니페스트
apiVersion: v1
kind: Pod
metadata:
name: exp5-dev-libs-privileged
spec:
containers:
- name: test
image: vllm/vllm-openai:latest
command: ["python3", "/scripts/exp-test.py"]
securityContext:
privileged: true
env:
- name: LD_LIBRARY_PATH
value: "/usr/lib/x86_64-linux-gnu/nvidia"
volumeMounts:
- name: script
mountPath: /scripts
- name: dev-nvidia0
mountPath: /dev/nvidia0
- name: dev-nvidiactl
mountPath: /dev/nvidiactl
- name: dev-nvidia-uvm
mountPath: /dev/nvidia-uvm
- name: nvidia-libs
mountPath: /usr/lib/x86_64-linux-gnu/nvidia
readOnly: true
volumes:
- name: script
configMap:
name: exp-script
- name: dev-nvidia0
hostPath:
path: /dev/nvidia0
- name: dev-nvidiactl
hostPath:
path: /dev/nvidiactl
- name: dev-nvidia-uvm
hostPath:
path: /dev/nvidia-uvm
- name: nvidia-libs
hostPath:
path: /opt/nvidia-libs
restartPolicy: Never
privileged: true는 실험을 단순화하기 위한 설정이다. 운영 환경에서는 장치 접근 권한을 더 세밀하게 제어해야 한다.
실험 결과 정리
| 실험 | /dev | .so | 장치 접근 권한 | 에러 메시지 | GPU 인식 |
|---|---|---|---|---|---|
| 1. 마운트 없음 | - | - | - | Found no NVIDIA driver | False |
| 2. /dev만 | O | - | - | Found no NVIDIA driver | False |
| 3. .so만 | - | O | - | No CUDA GPUs are available | False |
| 4. /dev + .so | O | O | - | No CUDA GPUs are available | False |
| 5. /dev + .so + privileged | O | O | O | - | True |
세 가지가 모두 갖춰져야 GPU가 동작했다. 드라이버 라이브러리가 없으면 “Found no NVIDIA driver”, 라이브러리는 있지만 디바이스 노드나 접근 권한이 없으면 “No CUDA GPUs are available”로 에러가 달라진다. 실패하는 레이어에 따라 에러 메시지가 다르다는 것은, GPU 접근 경로에 실제로 여러 계층이 있다는 사실을 반영한다.
이 결과는 L4 단일 GPU, 기본 runc 환경에서의 실험이다. GPU 종류, 드라이버 버전, 멀티 GPU, MIG 구성에 따라 세부 동작은 달라질 수 있다.
5. 결론
GPU 워크로드에서는 “이미지를 어떻게 만들었는가”만큼이나 “노드가 무엇을 준비하고 있는가”가 중요하다. 이번 실험에서도 같은 이미지가 디바이스 노드, 호스트 드라이버 라이브러리, 장치 접근 권한이 모두 갖춰졌을 때만 GPU를 인식했다. 파일만 연결해서도, 권한만 열어서도 각각 부족했다.
컨테이너는 유저 영역을 일관되게 포장하는 데는 강하지만, 하드웨어 제어 계층까지 독립된 시스템으로 만들지는 못한다. Kubernetes에서 GPU가 별도 runtime, device plugin, operator 계층과 함께 다뤄지는 이유도 여기에 있다.
“Docker로 만들면 어디서든 된다”는 말은 절반만 맞다. 정확히는, 호스트가 커널과 장치 연결을 제공하는 범위 안에서 어디서든 된다. GPU는 컨테이너가 호스트 커널과 장치에 의존한다는 사실을 가장 선명하게 드러내는 사례다.
References
- What is a container? | Docker Docs — 컨테이너를 “호스트 커널을 공유하는 격리된 프로세스”로 설명
- Device Plugins | Kubernetes — GPU, NIC, FPGA 같은 자원을 vendor-specific setup이 필요한 장치로 다룸