학습 작업을 손으로 배분하는 것의 한계
GPU 서버가 세 대쯤 되면 누가 어느 서버를 쓰는지 메신저로 조율하게 된다. 서버가 늘거나 사람이 늘면 이 방식은 곧 무너진다. 학습 작업을 파드로 던지고 스케줄러가 빈 GPU를 찾아 배치하게 만드는 편이 결국 더 싸게 먹힌다.
구성은 마스터 1대 + 워커 3대다. 마스터는 GPU가 없어도 되고 메모리도 적게 잡는다. 워커만 GPU를 단다.
1
2
3
4
마스터 172.16.10.90 (GPU 없음, 16GB)
워커 1 172.16.10.100 (GPU, 64GB)
워커 2 172.16.10.101 (GPU, 64GB)
워커 3 172.16.10.102 (GPU, 128GB)
모든 노드에 먼저 해둘 것
NTP 동기화
클러스터 노드 간 시각이 어긋나면 인증서 검증과 리더 선출이 간헐적으로 실패한다. 증상이 “가끔 안 된다”로 나타나서 원인 찾기가 유독 오래 걸린다.
1
2
3
sudo apt install -y ntp
sudo systemctl enable --now ntp
timedatectl status
스왑 해제
kubelet은 스왑이 켜져 있으면 아예 기동을 거부한다. 메모리 압박 상황에서 스왑으로 넘어가면 파드의 메모리 제한이 의미를 잃기 때문이다.
1
2
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab # 재부팅 후에도 유지
/etc/fstab 수정을 빼먹으면 재부팅 한 번에 노드가 NotReady로 떨어진다. 그리고 그 재부팅은 대개 몇 달 뒤 아무 맥락 없는 순간에 일어난다.
커널 모듈과 sysctl
1
2
3
4
5
6
7
8
9
10
11
12
cat <<'EOT' | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOT
sudo modprobe overlay && sudo modprobe br_netfilter
cat <<'EOT' | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOT
sudo sysctl --system
도커 cgroup 드라이버를 systemd로
kubelet의 기본 cgroup 드라이버가 systemd인데 도커는 cgroupfs가 기본이다. 둘이 다르면 노드가 부하 상태에서 불안정해진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
cat <<'EOT' | sudo tee /etc/docker/daemon.json
{
"exec-opts": ["native.cgroupdriver=systemd"],
"log-driver": "json-file",
"log-opts": { "max-size": "100m" },
"storage-driver": "overlay2",
"default-runtime": "nvidia",
"runtimes": {
"nvidia": {
"path": "nvidia-container-runtime",
"runtimeArgs": []
}
}
}
EOT
sudo systemctl daemon-reload && sudo systemctl restart docker
default-runtime을 nvidia로 두는 것이 중요하다. 이렇게 해두지 않으면 GPU 디바이스 플러그인 자체가 GPU를 못 보는데, 정작 파드 로그에는 “device not found”만 찍혀 원인이 드러나지 않는다.
클러스터 기동
마스터에서:
1
2
3
4
5
6
7
sudo kubeadm init \
--apiserver-advertise-address=172.16.10.90 \
--pod-network-cidr=10.244.0.0/16
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
--pod-network-cidr은 쓸 CNI에 맞춰야 한다. Flannel이면 10.244.0.0/16, Calico면 192.168.0.0/16이 기본값이다. 여기서 사내 네트워크 대역과 겹치면 파드에서 사내 서버로 가는 트래픽이 통째로 사라진다. 이건 나중에 고치려면 클러스터를 다시 만들어야 하므로 처음에 확인해야 한다.
CNI 설치 후 워커에서 join:
1
2
sudo kubeadm join 172.16.10.90:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>
토큰은 24시간 뒤 만료된다. 잃어버렸으면 마스터에서 다시 만든다.
1
kubeadm token create --print-join-command
GPU를 스케줄 가능한 자원으로 만들기
NVIDIA 디바이스 플러그인을 데몬셋으로 올리면 각 노드의 GPU가 nvidia.com/gpu 자원으로 보이기 시작한다.
1
2
kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.0/nvidia-device-plugin.yml
kubectl describe node 172.16.10.100 | grep -A5 Capacity
이제 학습 잡은 이렇게 던진다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: batch/v1
kind: Job
metadata:
name: detector-train
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: trainer
image: registry.internal/trainer:cu118
command: ["python", "train.py", "--cfg", "/cfg/exp01.yaml"]
resources:
limits:
nvidia.com/gpu: 2
volumeMounts:
- { name: dataset, mountPath: /data }
- { name: ckpt, mountPath: /ckpt }
volumes:
- name: dataset
nfs: { server: 172.16.10.90, path: /export/dataset, readOnly: true }
- name: ckpt
nfs: { server: 172.16.10.90, path: /export/ckpt }
backoffLimit: 0으로 두는 이유가 있다. 기본값에서는 학습이 실패하면 쿠버네티스가 알아서 재시도하는데, 설정 오류나 OOM으로 죽는 잡이 같은 조건으로 여섯 번 다시 돌면서 GPU를 계속 붙잡는다. 학습 잡은 자동 재시도가 도움이 되는 경우가 거의 없다.
GPU는 limits에만 쓸 수 있고 requests에는 못 쓴다. 그리고 GPU는 분할되지 않으므로 nvidia.com/gpu: 2는 정확히 GPU 2장을 통째로 점유한다. 한 장을 두 잡이 나눠 쓰는 구성이 필요하면 MPS나 MIG를 따로 붙여야 한다.
데이터셋을 어디에 둘 것인가
이 구성에서 가장 오래 고민한 부분이다. 결론은 NFS 한 대에 몰고 읽기 전용으로 마운트하는 것이었다. 노드마다 데이터를 복사해두면 읽기는 빠르지만 데이터셋이 갱신될 때마다 세 곳을 맞춰야 하고, 결국 어느 노드가 어떤 버전을 갖고 있는지 아무도 모르게 된다.
NFS가 느려서 학습이 데이터 로딩에 묶이면, 그때 노드 로컬 SSD에 캐시를 두는 방식을 추가한다. 순서를 반대로 하면 안 된다. 정합성을 먼저 확보하고 속도는 나중에 붙인다. 반대로 하면 속도는 얻었는데 어떤 데이터로 학습한 모델인지 말할 수 없게 된다.