Jupyter Lab on Kubernetesでの深層学習環境の構築

はじめに

今回はjupyter-labを用いてKubernetes(以下k8s)cluster上に深層学習環境を構築していきたいと思います.さらに,そのjupyter-labをL7ロードバランサ であるingress nginxで制御することによって,パスベースルーティングを行います.

パスベースルーティングを行うことで複数人がk8s clusterでjupyter-labを使用する際に非常に便利になります.

今回作成する環境は図1のようになります.

current_constitution
図1:今回作成する環境

図1ではA・B・C三つのnamespaceに3つのjupyterをデプロイしていますが,以下ではnamespace tenzenにjupyterを1つデプロイします.個数の増やし方は名前やデプロイするnamespaceが変わるだけで他は全て同じ方法なので,各環境に合わせて個数を増減させてください.また,便宜上以下ではnamespace Aではなくnamespace tenzenとしてデプロイを行っています.

前回までのお話

以前Kubernetes(k8s) v1.16とNvidia-Docker2を用いたマルチノードDeepLearning環境の構築 Part2 - てんぜんの生存日誌では図2のような構成でk8s Cluster上に深層学習環境を構築しました.

old_env
図2 : 以前構築した環境

記事を読んでもらえばわかるのですが,コンテナをroot権限で起動して,root権限でコンテナへsshして作業しています.root権限で制限なく作業できるのは便利なのですが,セキュリティを考えた時非常に危険です.また,k8s Master Nodeにsshした後Podにsshする多段sshの形になっているため非常に不格好なアクセス方法になってしまっています.

このままではコンテナ,k8sのいいところを生かし切れていないためこの方法を改善していきます.

Ingress Nginx

この節ではIngressリソースについて触れた後,マニフェストの準備を行い,実際にデプロイしていきます.

Ingressとは

複数人で使用することを想定しているため,Ingress Nginxでパスベースルーティングを行います.Ingressリソースの詳細については GitLab Runner on Kubernetesに敗北した話 - てんぜんの生存日誌を参照してください.

マニフェストの準備

今回はMetalLBとIngress Nginxの組み合わせで実装を行います.

まずはじめにIngressリソースで使用するロードバランサとしてMetalLBを準備します.下記のGItHubリポジトリからマニフェストをダウンロードしてきてください

github.com

さらに,下記のようなMetalLB用のconfigMapである「metallb_confingmap.yaml」を作成してください. 「xxx.xxx.xxx.xxx-xxx.xxx.xxx.xxx」にはロードバランサで使用するIPアドレスの範囲を指定してください.

apiVersion: v1
kind: ConfigMap
metadata:
  namespace: metallb-system
  name: config
data:
  config: |
    address-pools:
    - name: metallb-ip-range
      protocol: layer2
      addresses:
      - xxx.xxx.xxx.xxx-xxx.xxx.xxx.xxx

ロードバランサの準備ができたので,Ingress Nginxを準備していきます. 下記のGitHubリポジトリからIngress Nginx用のマニフェストを2つダウンロードしてきます.

  1. mandatory.yaml

github.com

  1. cloud-generic.yaml

github.com

ダウンロードしてきたマニフェストで変更する部分は特にありません.

デプロイ

先ほどダウンロードしてきたり作成したマニフェストをデプロイしていきます.

$ kubectl apply -f metallb.yaml
$ kubectl apply -f metallb_confingmap.yaml
$ kubectl apply -f mandatory.yaml
$ kubectl apply -f cloud-generic.yaml

以上でIngress Nginxの準備は完了です.

Jupyter-Lab

この節ではJupyterについて軽く紹介した後,コンテナイメージの作成,マニフェストの準備,デプロイの順番で行っていきます.

Jupyterとは

JupyterはWEBアプリケーションとして動作するIDEのようなものです. 特徴としては,出力結果のグラフや画像をリアルタイムで表示させながらプログラミングをすることができる点です.そのためデータサイエンティストに人気があります.

jupyter.org

Jupyterには主に三種類あります.

  • Jupyter-Notebook : 一番シンプルなJupyterで,基本的なプログラミング機能・画像やグラフの表示機能・MarkDownでのドキュメント記入機能などを有しています.

  • Jupyter-Lab : Jupyter-Notebookの拡張版で,Jupyter-Notebookで使用できる機能に加えて,ターミナルが使えたりフォルダ管理がGUIで行えたり,拡張パッケージで様々な新機能を追加できたりできます.

  • Jupyter-Hub : 複数のJupyter-NoteBookを制御して認証機能などをまとめて管理できます.

この3つを比較してみるとJupyter-Hubが非常に管理などにおいても優れているような印象を受けるかもしれませんが,下記の公式ドキュメントにある通りユーザがJupyterで使用するコンテナイメージはJupyter-Hub側であらかじめ指定したものの中からしか選べません.

zero-to-jupyterhub.readthedocs.io

そのためユーザが自由にコンテナイメージを選択したくても,ユーザ側の機能だけでは実現できません.今回は研究開発環境で使用する前提で環境構築を行っているので,ユーザ側で自由にコンテナイメージを選択もしくは自分で作成したDockerfileを元にビルドしたコンテナイメージを使用できないと使い物になりません.そこでそれぞれユーザのnamespaceを作成し,その中で別々にJupyter-Labサーバを作成する構成にしました.余談ですが,e-learningなどでユーザが利用するコンテナイメージが固定化されている場合は,Jupyter-Hubを使うのが管理面などから考えてもベストかもしれません.

コンテナイメージの作成

Jupyterに関する様々なイメージが以下のDockerHubのJupyter公式リポジトリで公開されています.

hub.docker.com

しかしながら上記イメージではGPUを使えなかったり,深層学習ライブラリであるKerasやPytorchを使用することができません.そのため,私が厳選した深層学習で必要なライブラリを詰め込んだオリジナルのDockerfileを作成してビルドします.

また,今回作成するコンテナイメージには以下のライブラリが含まれています.

  • nvidia/cuda:10.0
  • cudnn7
  • curl
  • git
  • unzip
  • imagemagick
  • bzip2
  • vim
  • libsm6
  • libgl1-mesa-dev
  • build-essential
  • libssl-dev
  • pyenv
  • nodesource
  • node.js
  • anaconda3-4.4.0
  • opencv-python3.4.7.28
  • tensorflow-gpu1.13.1
  • addict
  • keras
  • torch
  • torchvision
  • progressbar
  • jupyterlab2.0.0
  • @lckr/jupyterlab_variableinspector

上記のパッケージをインストールする以下のようなDockerfileを作成してください.以下のDockerfileではroot権限でコンテナを作成しないようにするため,ユーザーを作成してユーザのhome以下に各種ライブラリをインストールしています.そのため「ENV USER_NAME tenzen」のところは自分の使用したいユーザ名に書き換えてください.

FROM nvidia/cuda:10.0-cudnn7-devel-ubuntu16.04
LABEL maintainer="tenzen"

ENV USER_NAME tenzen
ENV UID 1000
RUN useradd -m -u ${UID} ${USER_NAME}

ENV HOME /home/${USER_NAME}
WORKDIR ${HOME}
ENV PYENV_ROOT ${HOME}/.pyenv
ENV PATH $PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH

RUN apt-get update \
 && apt-get install -y curl \
    git \
    unzip \
    imagemagick \
    bzip2 \
    vim \
    libsm6 \
    libgl1-mesa-dev \
    build-essential \
    libssl-dev \
 && git clone https://github.com/pyenv/pyenv.git .pyenv \
 && curl -sL https://deb.nodesource.com/setup_12.x | bash - \
 && apt-get install -y --no-install-recommends nodejs \
 && pyenv install anaconda3-4.4.0 \
 && pyenv global anaconda3-4.4.0 \
 && pyenv rehash \
 && pip install --upgrade pip \
 && pip install opencv-python==3.4.7.28 \
 && pip install tensorflow-gpu==1.13.1 --ignore-installed --user \
 && pip install addict \
 && pip install keras \
 && pip install torch \
 && pip install torchvision \
 && pip install tqdm \
 && pip install torchsummary \
 && pip install progressbar \
 && pip install jupyterlab==2.0.0 \
 && jupyter labextension install @lckr/jupyterlab_variableinspector \
 && chown -R ${USER_NAME}: /home/${USER_NAME}/.local/

作成したDockerfileはビルドして自分が普段使用しているDocker Registryにpushして,Kubernetesで使用できるようにしてください.

マニフェストの準備

まずはじめ以前Rook CephFSでの障害ドメインのカスタマイズ - てんぜんの生存日誌で作成したRook CephFSを用いて,jupyterlab用の永続ボリュームをダイナミックプロビジョニングで作成します.以下のマニフェスト「pvc_tenzen.yaml」を参考に作成してください.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: tenzen-pvc
  namespace: tenzen
spec:
  accessModes:
  - ReadWriteMany
  resources:
    requests:
      storage: 200Gi
  storageClassName: csi-cephfs

次に,下記のようなjupyterlab用のConfigmap・Service・Depoloymmentを含んだ「jupyterlab_deployment.yaml」を作成します.

kind: Service
apiVersion: v1
metadata:
  name: jupyterlab-tenzen-clusterip
  namespace: tenzen
spec:
  type: ClusterIP
  selector:
    app: jupyterlab-tenzen
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8888
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: jupyterlab-config
  namespace: tenzen
data:
  jupyterlab-pass: "aiueo"
  jupyterlab-user-name: "tenzen"
  jupyterlab-user-id: "1000"
  jupyterlab-base-url: "/tenzen/"
  jupyterlab-home-dir: "/tenzen-pvc"
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: jupyterlab-deployment
  namespace: tenzen
  labels:
    app: jupyterlab-tenzen
spec:
  replicas: 1
  selector:
    matchLabels:
      app: jupyterlab-tenzen
  template:
    metadata:
      labels:
        app: jupyterlab-tenzen
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                - key: hardware-type
                  operator: In
                  values:
                    - "NVIDIAGPU"
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 90
              preference:
                  matchExpressions:
                    - key: gpu-type
                      operator: In
                      values:
                        - "RTX2080Ti"
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
      containers:
      - name: jupyterlab-tenzen
        image: hogehoge
        ports:
        - containerPort: 8888
        env:
        - name: JUPYTERLAB_PASS
          valueFrom:
            configMapKeyRef:
              name: jupyterlab-config
              key: jupyterlab-pass
        - name: UID
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-user-id
        - name: USER_NAME
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-user-name
        - name: BASE_URL
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-base-url
        - name: HOME_DIR
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-home-dir
        command:
        - "sh"
        - "-c"
        - | 
            PASSWORD=$(python -c 'from notebook.auth import passwd;print(passwd("'${JUPYTERLAB_PASS}'"))')
            jupyter-lab --port=8888 --ip=0.0.0.0 --no-browser --NotebookApp.token='' --NotebookApp.base_url=${BASE_URL} \
            --NotebookApp.password=${PASSWORD} --NotebookApp.notebook_dir=${HOME_DIR}
        volumeMounts:
          - mountPath: "/tenzen-pvc"
            name: tenzen-pvc
          - mountPath: /dev/shm
            name: dshm
        resources:
            limits:
              nvidia.com/gpu: 2
              cpu: 10000m
              memory: 64Gi
      volumes:
        - name: tenzen-pvc
          persistentVolumeClaim:
              claimName: tenzen-pvc
              readOnly: false
        - name: dshm
          emptyDir:
            medium: Memory

上記のマニフェストでわかりにくいところについて少し説明していきます.

  • 以下のようなjupyterlabのdeployment用configmapでは環境変数として使用する7つのdataを設定しています.
    • jupyterlab-pass : jupyterlabへ設定する認証用のパスワードを指定します.
    • jupyterlab-user-name : jupyterlabコンテナ内で使用する一般ユーザの名前を設定しています.先ほどの作成したDockerfileで設定したユーザ名と同じものを設定してください.
    • jupyterlab-base-url : jupyterlabをパスベースルーティングする際にパスを設定しています.
    • jupyterlab-home-dir : jupyterlabの作業用ディレクトリを設定しています.例ではマウントしたPVCを指定しています.
apiVersion: v1
kind: ConfigMap
metadata:
  name: jupyterlab-config
  namespace: tenzen
data:
  jupyterlab-pass: "aiueo"
  jupyterlab-user-name: "tenzen"
  jupyterlab-user-id: "1000"
  jupyterlab-base-url: "/tenzen/"
  jupyterlab-home-dir: "/tenzen-pvc"
  • affinity.nodeAffinityにおいてpodをスケジューリングするNode選んでいますう.nodeselectorというフィールドも存在しますが,今回はより柔軟に設定できるAffinityを使用していきます.今回はNodeにあらかじめつけている「hardwaretype」と「gpu-type」の2つのラベルを用いてスケジューリングを行っています.まだラベルをつけていない方は以下のコマンドでラベルをつけてください.また例ではgpu-typeの方ではRTX2080Tiのラベルをつけていますが,使用環境で搭載されているGPUを設定してください.

    • nodeへのラベル付与の方法
$ kubectl label node mynode hardwaretype=NVIDIAGPU
$ kubectl label node mynode gputype=RTX2080Ti
  • spec.template.spec.affinity.nodeAffinity.requiredDuringSchedulingIgnoredDuringExecution : これ以下にはスケジューリングの際必ず満たしてほしい条件を記述します.例ではhardwaretypeがNVIDIAGPUになっているNodeを選択しています.

  • spec.template.spec.affinity.nodeAffinity.preferredDuringSchedulingIgnoredDuringExecution :これ以下にはスケジューリングの際できれば満たしてほしい条件を記述します.この項目には重みを設定でき,例では重み90に設定しています.また,条件としてはgpu-typeがRTX2080TiのNodeを選択しています.

    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                - key: hardware-type
                  operator: In
                  values:
                    - "NVIDIAGPU"
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 90
              preference:
                  matchExpressions:
                    - key: gpu-type
                      operator: In
                      values:
                        - "RTX2080Ti"
  • spec.template.spec.securityContextではroot権限ではなくDockerfileで指定した一般ユーザのUID:1000でコンテナを作成するように設定しています.また,spec.template.spec.containers.imageでは先ほど作成したDockerfileをビルドした後Container Registryへpush際つけた名前を指定してください.

    • spec.template.spec.securityContext.runAsNonRoot : root権限でコンテナを作成するか選択できます.trueで非root権限,falseでroot権限になります.
    • spec.template.spec.securityContext.runAsUser : 非root権限でコンテナを作成する場合に使用する一般ユーザのUIDを指定できます.
                        - "RTX2080Ti"
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
      containers:
      - name: jupyterlab-tenzen
        image: hogehoge
        ports:
        - containerPort: 8888
        env:
  • spec.template.spec.containers.envでは先ほどconfigmapで指定した値を読み込んでコンテナ内に環境変数として設定指定しています.

    • spec.template.spec.containers.env.name : 環境変数の名前
    • spec.template.spec.containers.env.valueFrom.configMapKeyRef.name : 読み込むconfigmapの名前
    • spec.template.spec.containers.env.valueFrom.configMapKeyRef.key : 読み込んだconfigmapで指定したkeyの名前,valueを書いてはいけない.
        - containerPort: 8888
        env:
        - name: JUPYTERLAB_PASS
          valueFrom:
            configMapKeyRef:
              name: jupyterlab-config
              key: jupyterlab-pass
        - name: UID
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-user-id
        - name: USER_NAME
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-user-name
        - name: BASE_URL
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-base-url
        - name: HOME_DIR
          valueFrom:
              configMapKeyRef:
                name: jupyterlab-config
                key: jupyterlab-home-dir
        command:
  • spec.template.spec.containers.commandでシェルスクリプト実行してjupyterlabを起動しています.その際オプションとしてspec.template.spec.containers.envで環境変数として渡した値を使用しています.

    • PASSWORD=$(python -c 'from notebook.auth import passwd;print(passwd("'${JUPYTERLAB_PASS}'"))') : pythonを起動してconfigmapで指定した値をハッシュ化してパスワードを作成しています.
    • jupyter-lab : jupyterlabを起動します.
      • --port=8888 : spec.template.spec.containers.ports.containerPortで開放したポートを指定しています.
      • --ip=0.0.0.0 : 全てのIPアドレスを受け入れるようでアクセスできるように設定しています.
      • --no-browser : 起動時にブラウザを起動しないように設定しています.
      • --NotebookApp.token='' : tokenを使用しないようにシングルクォーテーションで囲むます.
      • --NotebookApp.base_url=${BASE_URL} : configmapで指定したパスベースルーティングで使用するパスを環境変数から読み込んでいます.
      • --NotebookApp.password=${PASSWORD} : 先ほどpythonで作成したパスワードを環境変数から読み込んでいます.
      • --NotebookApp.notebook_dir=${HOME_DIR} : configmapで指定した作業用ディレクトリを環境変数から読み込んでいます.
                key: jupyterlab-home-dir
        command:
        - "sh"
        - "-c"
        - | 
            PASSWORD=$(python -c 'from notebook.auth import passwd;print(passwd("'${JUPYTERLAB_PASS}'"))')
            jupyter-lab --port=8888 --ip=0.0.0.0 --no-browser --NotebookApp.token='' --NotebookApp.base_url=${BASE_URL} \
            --NotebookApp.password=${PASSWORD} --NotebookApp.notebook_dir=${HOME_DIR}
        volumeMounts:
  • 下記のIssueで報告されているようにPytorchなどで複数のGPUを使って学習を回すとshered memory(shm)が足りなくなって動きません.そこでshmを追加でマウントしてあげることでこの問題を解決します.

github.com

        volumeMounts:
...(省略)
          - mountPath: /dev/shm
            name: dshm
...(省略)
      volumes:
...(省略)
        - name: dshm
          emptyDir:
            medium: Memory

デプロイ

上記で準備したマニフェストをデプロイしていきます.

$ kubectl apply -f pvc_tenzen.yaml
$ kubectl apply -f jupyterlab_tenzen_deployment.yaml

以上でjupyterlabのマニフェスト準備は完了です.

L7ルーティング

この節では実際にどのようにパスベースルーティングを行っていくかについて説明した後,マニフェストの準備をしてデプロイしていきます.

概要

jupyterlabを各ユーザ用namespaceに閉じ込めているためこのままingressリソースを作成してもIngressとjupyterlabのserviceつまりclusteripと通信することができません.そこで下記issuesで提案されているServiceリソースのExternalNameを使ってnamespaceをまたいだ通信を行っていきます.

github.com

マニフェストの準備

以下のようなServiceリソースのExternalNameとingressリソースを含んだマニフェスト「jupyterlab_tenzen_ingress.yaml」を作成します.例では一つのパスしか用意していませんが,複数使う場合にはpathを追加していけばパス名でルーティングできます.注意点としてはhost名が必要ですので,DNSにhost名を登録しておいてください.簡易DNSについては GitLab Runner on Kubernetesに敗北した話 - てんぜんの生存日誌で紹介してるので,お困りの場合はそちらを参考にDNSを用意してください.

apiVersion: v1
kind: Service
metadata:
  name: jupyter-tenzen-externalname
  namespace: ingress-nginx
spec:
  type: ExternalName
  externalName: jupyterlab-tenzen-clusterip.tenzen.svc.cluster.local
---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
  name: jupyterlab
  namespace: ingress-nginx
  annotations:
    kubernetes.io/ingress.class: "nginx"
    ingress.kubernetes.io/secure-backends: "true"
spec:
  rules:
  - host: jupyterlab.tenzen.local
    http:
      paths:
      - path: /tenzen
        backend:
          serviceName: jupyter-tenzen-externalname
          servicePort: 80

上記マニフェストでわかりにくいところをかいつまんで説明していきます.

  • metadata.namespaceへはingressリソースと同じものを設定する必要があります.また,spec.externalNameにはjupyterlabで使用しているServiceリソース,今回の場合clusteripの名前をSRVレコードを用いて指定する必要があります.

    • spec.externalName : 宛先のServiceリソースを「[Service名].[Namespace名].svc.cluster.local」のテンプレートにしたがって記述してください.
apiVersion: v1
kind: Service
metadata:
  name: jupyter-tenzen-externalname
  namespace: ingress-nginx
spec:
  type: ExternalName
  externalName: jupyterlab-tenzen-clusterip.tenzen.svc.cluster.local
  • spec.rules.http.paths.pathにはjupyterlabのconfigmapのjupyterlab-base-urlで指定した値を設定する必要があります.
spec:
  rules:
  - host: jupyterlab.tenzen.local
    http:
      paths:
      - path: /tenzen

以上でマニフェストの準備は完了です.

デプロイ

デプロイしていきます.

$ kubectl apply -f jupyterlab_tenzen_ingress.yaml

アクセス確認

ブラウザでIngressリソースのhost欄に指定したhost名とpathを使ってアクセスします.今回の例で言うと「http://jupyterlab.tenzen.local/tenzen」です.正常にアクセスされると以下のように表示されるともいます.

jupyterkab_top_page
図3:JupyterLabのトップページ

jupyterlab-configmapのjupyterlab-passで設定したパスワードを入力するとログインでき,以下のように表示されるはずです.

jupyterlab_after_login
図4:JupyterLabログイン後ページ

以上でGPU深層学習環境のJupyter Lab on KubernetesIngress Nginxで制御することができました.

おわりに

今回はk8sの長所をできる限り使って深層学習環境を作成してみました.以前のみっともない多段SSHより格段によくなったのではないかと思っております.最後までお読みいただいてありがとうございました.またどこかでお会いしましょう.