✌️

研究室で余ってた計算機サーバーを活用してGPUクラスタを構築してみた with kubernetes

2024/05/13に公開

1. 概要

本記事では、kubernetes を用いて、オンプレミスの計算機サーバー間で分散学習を行う方法・手順について紹介します。
kubernetesによりGPUクラスタを作成し、PytorchのDDPサンプルコードを実行することを目的とします。

環境

  • master(control plane)1台 × worker 2台でクラスタを構築します。
    (簡略化のために最小構成にしてます)
  • master, worker 共に ubuntu22.04 を使用
    • すべてのノードにGPUが1台ずつ搭載
      (masterはGPU不要)
    • GPU は NVIDIA を使用

2. master ノードのセットアップ

Kubernetes の動作要件を満たすように設定

  • 指定したカーネルモジュール(overlay, br_netfilter)をシステム起動時に自動的にロードされるようにする
cat <<EOF | sudo tee /etc/modules-load.d/containerd.conf
overlay
br_netfilter
EOF

sudo modprobe overlay
sudo modprobe br_netfilter
  • kubernetes のネットワーク設定
cat <<EOF | sudo tee /etc/sysctl.d/kubernetes.conf
net.bridge.bridge-nf-call-iptables  = 1
net.ipv4.ip_forward                 = 1
net.bridge.bridge-nf-call-ip6tables = 1
EOF

sudo sysctl --system
  • swap を無効にする
sudo swapoff -a
sudo sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab

※Dockerのインストールを済ませておく

kubernetes 関連コンポーネントのインストール

kubelet, kubeadm, kubectl のインストール

curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key |sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list
sudo apt-get update
sudo apt-get install -y kubelet kubeadm kubectl
sudo apt-mark hold kubelet kubeadm kubectl

kubeadm で Kubernetes クラスタを構築

  • kubeadm は実用最小限の Kubernetes クラスターを作成してくれる。

kubeadm init でクラスタ初期化

オプションの--pod-network-cidrで Pod に割り当てる IP アドレスの範囲を指定する。
今回は以降でコンテナ間通信の仮想ネットワークとして flannel を使用するため、flannel のデフォルトネットワーク10.244.0.0/16に合わせる。

sudo kubeadm init --pod-network-cidr=10.244.0.0/16

kubeadm init 後の output に worker がクラスタに参加するためのコマンド(kubeadm join ...)が表示されるので控えておく。

  • kube-api に接続するために以下を実行する
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
  • kubectl get nodeでノードの状態が確認できれば OK。(この時点ではまだネットワーク関連の設定が完了していないため、マスターノードは NotReady 状態となっている)

flannel で仮想ネットワークを構築

  • 設定ファイルを GitHub から取得し、実行する
wget https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml

kubectl apply -f kube-flannel.yml

kubectl get nodeでノードが Ready になっていることを確認する

 

参考サイト

3. worker ノードのセットアップ

master ノードと同様にセットアップを行う

NVIDIA やコンテナ関連のコンポーネントのインストールと containerd の設定

  1. NVIDIA driver のインストール
  2. docker, containerd のインストール
  3. nvidia-container-runtime のインストール、/etc/docker/daemon.jsonより、ランタイムオプションを設定
/etc/docker/daemon.json
{
    "runtimes": {
        "nvidia": {
            "path": "/usr/bin/nvidia-container-runtime",
            "runtimeArgs": []
        }
    }
}
  1. containerd の config 設定

    • デフォルトランタイムの設定

      sudo apt-get install -y nvidia-container-toolkit
      
      sudo containerd config default | sudo tee /etc/containerd/config.toml
      sudo nvidia-ctk runtime configure --runtime=containerd
      

      /etc/containerd/config.tomlより、default_runtime_namenvidiaに設定する

    • systemd バックエンドな cgroup を有効にする
      /etc/containerd/config.tomlより、 nvidia runtime options の書き換え

      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.nvidia.options]
         省略...
         SystemdCgroup = true
      
    • containerd 再起動
      sudo systemctl restart containerd.service

※NVIDIA driver 設定後は再起動が必要

クラスタに参加

  • kubeadm joinコマンドを実行する
    • トークンには有効期限があり、通常は 24 時間で利用できなくなるらしい
    • 失効した場合は、master ノードでkubeadm token create --print-join-commandを実行すると再発行できる
  • master ノードでkubectl get nodeを実行し、worker ノードがクラスタに参加したことを確認する

4. GPU-device-driver で worker の GPU を認識できるようにする

現状kubectl describe node your-node-nameを実行しても、gpuをリソースとして確認できないと思います。
なので、以下のようにnvidia-device-plugin を起動することで、gpuを認識できるようにします。

$ kubectl create -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.15.0/deployments/static/nvidia-device-plugin.yml

kubectl describe node your-node-nameを実行してgpuが認識されたことを確認してください。

例:
...(省略)...
Capacity:
 cpu:             12
 memory:          131945888Ki
 nvidia.com/gpu:  1
 pods:            110
Allocatable:
 cpu:             12
 memory:          131843488Ki
 nvidia.com/gpu:  1
 pods:            110
...(省略)...

 

参考サイト

5. 分散学習用のマニフェストを作成

環境は整ったので、分散学習用のマニフェストを作成します。

  • pod間通信を行うためのServiceと分散学習用のJobを作成します
  • JOB_COMPLETION_INDEXが0のpodをマスターとして分散学習を行います(torchrunを使用)
job-manifest.yaml
# Service configuration for multinode.
apiVersion: v1
kind: Service
metadata:
  name: multinode-svc
spec:
  clusterIP: None  # ClusterIP set to None for headless service.
  ports:
  - name: nccl  # Port for torchrun master-worker node communication.
    port: 29500
    targetPort: 29500
  selector:
    job-name: multinode-job  # Selector for pods associated with this service.

---

apiVersion: batch/v1
kind: Job
metadata:
  name: multinode-job
spec:
  completionMode: Indexed
  completions: 2
  parallelism: 2
  template:
    spec:
      restartPolicy: Never
      subdomain: multinode-svc  # Subdomain for the headless service.
      containers:
      - image: "your-docker-image"
        name: multinode
        env:
        - name: MASTER_ADDR
          value: multinode-job-0.multinode-svc.default.svc.cluster.local  # Node with rank 0 is chosen as the master node.
        - name: MASTER_PORT
          value: '29500'
        - name: NNODES
          value: '2'  # Number of training nodes.
        - name: NGPUS
          value: '1'  # Number of GPUs in the machine.
        ports:
        - containerPort: 29500
          name: nccl
        command: ["sh", "-c", "torchrun --nnodes=$NNODES --node_rank=$JOB_COMPLETION_INDEX --nproc_per_node=$NGPUS --master_addr $MASTER_ADDR --master_port $MASTER_PORT  example.py"]
        resources:
          limits:
            nvidia.com/gpu: 1
Dockerfile
FROM pytorch/pytorch:latest

COPY ./practice/example.py ./
example.py
import argparse
import os
import sys
import tempfile
from urllib.parse import urlparse

import torch
import torch.distributed as dist
import torch.nn as nn
import torch.optim as optim

from torch.nn.parallel import DistributedDataParallel as DDP

class ToyModel(nn.Module):
    def __init__(self):
        super(ToyModel, self).__init__()
        self.net1 = nn.Linear(10, 10)
        self.relu = nn.ReLU()
        self.net2 = nn.Linear(10, 5)

    def forward(self, x):
        return self.net2(self.relu(self.net1(x)))


def demo_basic(local_world_size, local_rank):

    # setup devices for this process. For local_world_size = 2, num_gpus = 8,
    # rank 0 uses GPUs [0, 1, 2, 3] and
    # rank 1 uses GPUs [4, 5, 6, 7].
    n = torch.cuda.device_count() // local_world_size
    device_ids = list(range(local_rank * n, (local_rank + 1) * n))

    print(
        f"[{os.getpid()}] rank = {dist.get_rank()}, "
        + f"world_size = {dist.get_world_size()}, n = {n}, device_ids = {device_ids} \n", end=''
    )

    model = ToyModel().cuda(device_ids[0])
    ddp_model = DDP(model, device_ids)

    loss_fn = nn.MSELoss()
    optimizer = optim.SGD(ddp_model.parameters(), lr=0.001)

    optimizer.zero_grad()
    outputs = ddp_model(torch.randn(20, 10))
    labels = torch.randn(20, 5).to(device_ids[0])
    loss_fn(outputs, labels).backward()
    optimizer.step()


def spmd_main(local_world_size, local_rank):
    # These are the parameters used to initialize the process group
    env_dict = {
        key: os.environ[key]
        for key in ("MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE")
    }
    
    if sys.platform == "win32":
        # Distributed package only covers collective communications with Gloo
        # backend and FileStore on Windows platform. Set init_method parameter
        # in init_process_group to a local file.
        if "INIT_METHOD" in os.environ.keys():
            print(f"init_method is {os.environ['INIT_METHOD']}")
            url_obj = urlparse(os.environ["INIT_METHOD"])
            if url_obj.scheme.lower() != "file":
                raise ValueError("Windows only supports FileStore")
            else:
                init_method = os.environ["INIT_METHOD"]
        else:
            # It is a example application, For convience, we create a file in temp dir.
            temp_dir = tempfile.gettempdir()
            init_method = f"file:///{os.path.join(temp_dir, 'ddp_example')}"
        dist.init_process_group(backend="gloo", init_method=init_method, rank=int(env_dict["RANK"]), world_size=int(env_dict["WORLD_SIZE"]))
    else:
        print(f"[{os.getpid()}] Initializing process group with: {env_dict}")
        dist.init_process_group(backend="nccl")

    print(
        f"[{os.getpid()}]: world_size = {dist.get_world_size()}, "
        + f"rank = {dist.get_rank()}, backend={dist.get_backend()} \n", end=''
    )

    demo_basic(local_world_size, local_rank)

    # Tear down the process group
    dist.destroy_process_group()


if __name__ == "__main__":
    local_world_size = int(os.environ["LOCAL_WORLD_SIZE"])
    local_rank = int(os.environ["LOCAL_RANK"])
    spmd_main(local_world_size, local_rank)

kubectl apply -f job-manifest.yamlにて、Jobを起動すると、分散学習がスタートします。

 

参考サイト

6.まとめ

kubernetes を用いて GPU クラスタを作成し、分散学習を行う環境を構築しました。
今後は、分散学習における学習プロセスの最適化や、機械学習フローの自動化、クラスタ構成の冗長化等にチャレンジしてみたいと思います。

GitHubで編集を提案

Discussion