KubeflowによるNaaS構築入門(#16)

Audio Overview(English/日本語) English (recommended)

日本語(非推奨)

はじめに

コードを迅速にプロトタイピングしたい時やデータを可視化して共有したい時に、手軽に利用できるGoogle Colabのようなサービスをself-hostedで提供したいと考えています。

特に研究室サーバーではホストマシン上にPythonがインストールされておらず、JupyterLabを利用したい場合はDockerコンテナを起動する必要があります。

環境の分離という観点では正しい運用ですが、設定の煩わしさからGoogle Colabに勝るユーザー体験は提供できていません。Docker / Podmanに慣れていないメンバーや研究室に新規参入したメンバーでも研究活動をbootstrapできるようなプラットフォームが理想的です。

本記事では、Kubeflow Notebooksを利用してオンプレミスKubernetesクラスタにNotebook as a Serviceを構築する方法ユーザー体験を向上させるための工夫について紹介します。

1. Kubeflow Notebooksの概要

Kubeflowは、Kubernetes上でAI/MLを簡単に、柔軟に、そして拡張可能に実行するための、機械学習ライフサイクル全体をサポートするオープンソースのプロジェクト群です。

Kubeflow NotebooksはそんなKubeflowコンポーネントの1つで、Webベースの開発環境を提供できます。

www.kubeflow.org

Kubeflow NotebooksのWeb UIのスクリーンショット

実際にはKubernetesクラスタ上でPodを起動していますが、以下の特徴があります。

  • JupyterLabRStudiocode-serverのネイティブサポート
  • ユーザーがクラスタ上で直接ノートブックコンテナを作成可能
  • 管理者がカスタムノートブックイメージを提供できる
  • アクセス制御をKubeflowのRBACで管理できるためノートブックの共有が容易

特にブラウザ上でVS Codeが利用できるcode-serverをサポートしている点や、必要なパッケージがプリインストールされたカスタムイメージを利用できる点が魅力的だと感じました。

2. JupyterHubとの比較

JupyterHubは、シングルユーザー用Jupyter notebook(またはJupyterLab)サーバーを複数インスタンス起動・管理・プロキシする機能を提供することで、マルチユーザーをサポートしています。

JupyterHubをデプロイする方法としては、以下の2種類が用意されています。

公式ドキュメントによると、TLJHは単一マシンでコンテナを利用しない場合、Z2JHは複数マシンでコンテナを利用する場合の利用が推奨されています。

Kubeflow NotebooksとZ2JHを比較します。

Kubeflow Notebooks JupyterHub (Z2JH)
アーキテクチャ Notebook ControllerNotebookカスタムリソースの作成をトリガーにStatefulSet / Service / VirtualServiceを作成 HubがKubespawnerを利用してシングルユーザー用のノートブックサーバーをPodとして起動し、Configurable HTTP Proxyがルーティングテーブルを更新
認証 Istio Ingress Gateway + OAuth2-Proxy (OIDC client) + Dex (OIDC IdP) Ingress + Configurable HTTP Proxy + Authenticator
IDEサポート JupyterLab、RStudio、code-serverをネイティブサポート JupyterLabとJupyter notebookを標準サポート(Jupyter Server ProxyでRStudioやcode-serverも利用可能)
カスタムイメージ ユーザーがノートブック作成時にイメージ名を指定可能 ユーザーが選択可能なプロファイルを管理者がKubeSpawnerの設定として定義
リソース指定 ユーザーがノートブック作成時にCPU、メモリ、GPU、ボリュームを指定可能

JupyterHub(Z2JH)はHelmで容易にインストールできますが、ユーザーが自由にリソースやカスタムイメージを指定できないことや、ノードメンテナンス時にPodが自動で再作成されないことに注意が必要です。

Kubeflow NotebooksはKubeflow Platformのコンポーネントが多いため学習コストが大きくなりますが、StatefulSetの恩恵を受けつつ、Web UIでユーザーが自由にリソース・カスタムイメージを指定できます。

以上の理由から、今回はNaaSユーザーにとってカスタマイズ性が高いKubeflow Notebooksを採用しました。

3. Kubeflowのインストール

#14で構築したオンプレミスKubernetesクラスタにKubeflow Notebooksをインストールします。

www.kubeflow.org

Kubeflow Notebooksはstandaloneコンポーネントとしてインストールできないので、Kubeflow Platformとして全てのコンポーネントをインストールします。

github.com

執筆時点で最新の安定版であるKubeflow Platform v1.10.0を利用します。コンポーネント数が多いため、READMEの通りにシングルコマンドによるインストールを実行しました。

$ while ! kustomize build example | kubectl apply --server-side --force-conflicts -f -; do echo "Retrying to apply resources"; sleep 20; done
# 省略

インストール完了後に2つの問題が発生しました。

1つ目の問題として、Istioを利用して公開していたArgo CDなどのサービスがRBAC: access deniedエラーを返すようになりました。最初は2つのIngress Gatewayがデプロイされていることが原因だと考えていましたが、Tetrateのドキュメントにもあるように複数のIngress Gatewayをデプロイすることは可能です。

権限エラーであることからも分かるように、実際はKubeflow Platformのマニフェストallow-nothingAuthorizationPolicyを設定していることが原因でした。Kubeflow Platform用のIngress Gatewayだけでなく、既存のIngress Gatewayへのトラフィックも許可するALLOWポリシーを追加する必要があります。

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: istio-ingressgateway-private
  namespace: istio-system
spec:
  action: ALLOW
  selector:
    matchLabels:
      istio: ingressgateway-private  # 適宜変更
  rules:
  - {}

これによって1つ目の問題は解決しました。

2つ目の問題として、Kubeflow関連のいくつかのPodでコンテナの作成に失敗していました。

$ kubectl get pods -n kubeflow | awk '$3!="Running"'
NAME                                                     READY   STATUS             RESTARTS          AGE
katib-db-manager-6987b965b7-tfnwp                        0/1     Error              193 (6m24s ago)   15h
katib-mysql-5dfcbbc87f-xjnkb                             0/1     Pending            0                 15h
metadata-grpc-deployment-6c44975f56-f2ffs                1/2     CrashLoopBackOff   190 (2m32s ago)   15h
metadata-writer-6fbc8d8c4f-x4m8b                         1/2     CrashLoopBackOff   143 (39s ago)     15h
minio-6748f5ff9d-5xslh                                   0/2     Pending            0                 15h
ml-pipeline-857d4dd86-qggb6                              1/2     CrashLoopBackOff   238 (4m5s ago)    15h
mysql-6c6bb95f89-89r29                                   0/2     Pending            0                 15h

CLIでの原因調査

$ kubectl logs ml-pipeline-857d4dd86-qggb6 -n kubeflow
I0504 06:39:26.476455       7 client_manager.go:170] Initializing client manager
I0504 06:39:26.476614       7 client_manager.go:171] Initializing DB client...
I0504 06:39:26.476660       7 config.go:57] Config DBConfig.MySQLConfig.ExtraParams not specified, skipping
[mysql] 2025/05/04 06:39:26 connection.go:49: unexpected EOF
# 省略

$ kubectl describe pod mysql-6c6bb95f89-89r29 -n kubeflow
# 省略
  Warning  FailedScheduling  2m30s (x202 over 16h)  default-scheduler  0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims. preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

$ kubectl get pvc -n kubeflow
NAME             STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
katib-mysql      Pending                                                     <unset>                 16h
minio-pvc        Pending                                                     <unset>                 16h
mysql-pv-claim   Pending                                                     <unset>                 16h

$ kubectl describe pvc mysql-pv-claim -n kubeflow
# 省略
  Normal  FailedBinding  100s (x4002 over 16h)  persistentvolume-controller  no persistent volumes available for this claim and no storage class is set

$ kubectl get pv
No resources found

$ kubectl get sc
No resources found

PersistentVolumeClaim(PVC)がPersistentVolume(PV)をバインドできず、MySQLやMinIOなどのPodがPending状態のままでした。そのため、これらのストレージに依存するサービスで接続エラーが発生していました。

再確認したところ、READMEのPrerequisitesのうち以下の項目を見落としていました。

ボリュームタイプとしては、すぐに用意できるhostPathlocalを初期候補として検討しました。ただし、手動でPVを個別作成する手間を省くため以下のProvisionerを導入しました。

github.com

Local Path Provisionerによって、PVCに対応したPVがノード上に自動的に作成されます。

今回のPVCのマニフェストにはstorageClassNameが指定されていないため、ターゲットとなるStorageClassをデフォルトに指定することで全てのPodが無事にRunning状態となりました。

$ kubectl get sc
NAME         PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  7h29m

$ kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
storageclass.storage.k8s.io/local-path patched

$ kubectl get pvc -n kubeflow
NAME             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
katib-mysql      Bound    pvc-fee92883-d2ef-451c-aa71-c432e935703f   10Gi       RWO            local-path     <unset>                 17h
minio-pvc        Bound    pvc-39c3a5ee-6e29-44ca-b689-820f437c782d   20Gi       RWO            local-path     <unset>                 17h
mysql-pv-claim   Bound    pvc-c8423a2c-e2a6-42ce-a05f-a55bf42f82f9   20Gi       RWO            local-path     <unset>                 17h

$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                     STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-39c3a5ee-6e29-44ca-b689-820f437c782d   20Gi       RWO            Delete           Bound    kubeflow/minio-pvc        local-path     <unset>                          2m1s
pvc-c8423a2c-e2a6-42ce-a05f-a55bf42f82f9   20Gi       RWO            Delete           Bound    kubeflow/mysql-pv-claim   local-path     <unset>                          2m2s
pvc-fee92883-d2ef-451c-aa71-c432e935703f   10Gi       RWO            Delete           Bound    kubeflow/katib-mysql      local-path     <unset>                          119s

$ kubectl get pods -n kubeflow | awk '$3!="Running"'
NAME                                                     READY   STATUS    RESTARTS          AGE

これによって2つ目の問題も解決し、無事にKubeflow Platformのインストールが完了しました。

4. Kubeflow Notebooksの動作検証

Web UI上でKubeflow Notebooksの動作検証を行います。

ただしREADMEで言及されている通り、localhost以外ではHTTPでKubeflowにアクセスできません。別のIngress経由でistio-ingressgatewayサービスをHTTP公開する方法もありますが、6章でサービス公開のセットアップをする予定なのでしばらくはkubectl portforwardコマンドを検証用に利用します。

普段コントロールプレーンノード(以下の例ではpolaris)にSSH接続して作業している場合は、SSHのローカルポートフォワーディングと組み合わせて利用できます。

azuma@macbook
$ ssh -L 8080:localhost:8080 polaris

root@polaris
$ kubectl port-forward svc/istio-ingressgateway -n istio-system 8080:80
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080

MacBookWebブラウザからlocalhost:8080でKubeflowのWeb UIにアクセスできます。

www.kubeflow.org

最新版とスクリーンショットが少し異なっていますが、概ね上記の公式ドキュメント通りにノートブックを作成できます。

最もシンプルな設定として、GPUもWorkspace Volumeも無いJupyterLabノートブックを作成します。

ノートブックの一覧画面

ノートブック接続後の画面

LauncherからPython 3 (ipykernel)を選択して、JupyterLabが問題なく利用できることを確認しました。

次に、PVCをボリュームとして利用できることを検証します。

Web UI上でWorkspace VolumeとData Volumesを設定

ノートブックを作成すると、PVCに対応したPVがLocal Path Provisionerによって自動的に作成されていることが分かります。

$ kubectl get pvc -n kubeflow-user-example-com
NAME             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
test-datavol-1   Bound    pvc-c2b5305e-217d-4134-b78a-3b246a07060b   5Gi        RWO            local-path     <unset>                 3m46s
test-workspace   Bound    pvc-bc09bf68-dc42-4d94-8d50-82fba5d1adeb   5Gi        RWO            local-path     <unset>                 3m46s

$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                                      STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-bc09bf68-dc42-4d94-8d50-82fba5d1adeb   5Gi        RWO            Delete           Bound    kubeflow-user-example-com/test-workspace   local-path     <unset>                          3m53s
pvc-c2b5305e-217d-4134-b78a-3b246a07060b   5Gi        RWO            Delete           Bound    kubeflow-user-example-com/test-datavol-1   local-path     <unset>                          3m53s
# 省略

Web UI上で設定した通りのパスにWorkspace VolumeとData Volumesがそれぞれマウントされていることをノートブックからも確認できました。

最後に、GPUが利用できることを検証します。

WebUI上でGPUsを設定

カスタムイメージとしてjupyter-pytorch-cuda-full:v1.10.0を選択してノートブックを作成すると、以下のようなエラーが出てコンテナが作成できませんでした。

RunContainerError: failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error running prestart hook #0: exit status 1, stdout: , stderr: Auto-detected mode as 'legacy' nvidia-container-cli: requirement error: unsatisfied condition: cuda>=12.4, please update your driver to a newer version, or use an earlier cuda container: unknown

jupyter-pytorch-cudaはDockerfile内でCUDA 12.4に対応するPyTorchをインストールしており、cuda>=12.4を要求しています。一方、全てのワーカーノードでDriverバージョンは535.216.03であり、CUDA 12.4には>=550.54.14が必要です(参考)。

解決策として、新しいバージョンのDriverにアップデートするか、古いCUDAコンテナを利用するかの2通りの方法が考えられます。前者はノードメンテナンスが必要となり、他のメンバーの研究を中断してしまうため、今回は後者で対応することにしました。

Kubeflowのv1.9Dockerfileでは要求がcuda>=12.1になっていたため、コンテナイメージのタグを変更することにしました。マニフェストspawner_ui_config.yamlというUIの設定ファイルがあるので以下のように修正して差分を適用します。

  image:
    # the default container image
-    value: ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.10.0
+    value: ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.9.0

    # the list of available container images in the dropdown
    options:
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-cuda-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-gaudi-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-cuda-full:v1.10.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-cuda-full:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-full:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-cuda-full:v1.9.0

ついでにlogos-configmap.yamlで、VSCodeRStudioのアイコン・ロゴも追加するとUXが改善します。

コンテナイメージを変更して、ロゴまで追加した結果

jupyter-pytorch-cuda-full:v1.9.0コンテナイメージを使ってGPUを2枚に設定したJupyterLabノートブックを作成したところ、無事にGPUが利用可能であることを確認できました。

PyTorchでGPUが利用できることを確認

以上でKubeflow Notebooksの動作検証は完了です。

5. marimoカスタムイメージの利用

#13でも紹介しているように、最近はJupyter notebookやJupyterLabの課題を解決するPythonノートブックとして再現性重視でGitフレンドリーなmarimoが注目されています。

marimo.io

Jupyter notebook、JupyterLab、marimoのGitHubスター数の比較(リンク

Kubeflow Notebooksでも選択肢の1つとしてmarimoを利用できるようにカスタムイメージを作成します。

www.kubeflow.org

上記のKubeflow公式ドキュメントやREADMEによると、Common Base Imageを拡張したBaseイメージ(JupyterLab、code-server、RStudio)と、主にJupyterLabに対してPyTorchやTensorflowなどのパッケージをインストールして拡張したKubeflowイメージが提供されています。

一方、カスタムイメージを作成する際にはBaseイメージの拡張が推奨されており、以下の3つの要件を満たす必要があります。

  • HTTPインターフェースをポート8888で公開
  • jovyanと呼ばれるユーザーで実行
  • /home/jovyanにマウントされた空のPVCで正常に起動

Common Base Imageを拡張している他のBaseイメージを参考にしてmarimoのカスタムイメージを作成しました。変更は以下のPRから確認できます。

github.com

Kubeflowにコントリビュートするチャンスですが、イメージ追加以外にも修正なので一旦保留としました。具体的にはフロントエンドとバックエンドで、JupyterLab・code-server・RStudioの3種類がそれぞれjupytergroup-onegroup-twoとしてハードコーディングされており、フォームの送信やIstioによるURI書き換えの際に必要となります。参考程度に影響のあるファイルをいくつか紹介します。

marimoのアイコンをWeb UI上に表示して利用できるようにするには手間がかかりそうですが、イメージを利用するだけであれば2通りの方法があります。1つはフォーム上でCustomイメージとしてイメージ名を入力する方法、もう1つはJupyterLabのイメージグループに追加する方法です。本質的にそれほど大きな差はないので、ユーザー体験を考慮して後者の方法を採用します。

4章に続けて、マニフェストspawner_ui_config.yamlという設定ファイルを変更して適用します。

  ################################################################
  # Jupyter-like Container Images
  #
  # NOTES:
  #  - the `image` section is used for "Jupyter-like" apps whose
  #    HTTP path is configured by the "NB_PREFIX" environment variable
  ################################################################
  image:
    # the default container image
    value: ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.9.0

    # the list of available container images in the dropdown
    options:
    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0
    # 省略
+    - ghcr.io/kitsuyaazuma/kubeflow/notebook-servers/marimo:latest

Package kubeflow/notebook-servers/marimo · GitHub

marimoのカスタムイメージは上記のGitHubコンテナレジストリで公開しています。

JupyterLabのイメージグループの中からmarimoイメージを選択

ノートブックに接続してmarimoを利用

また、提供されているjupyter-pytorchjupyter-pytorch-cuda-fullなどのイメージが全てCondaを採用しているのに対し、marimoカスタムイメージではuvを採用しているためユーザー体験の向上が期待できます。

以上で、作成したmarimoのカスタムイメージをKubeflow Notebooksから利用できることを確認できました。

6. HTTPSによるサービス公開

4章でも言及しましたが、KubeflowではWebアプリでSecure Cookieを利用しているため、HTTPSのセットアップが推奨されています。KubeflowのサービスをHTTPSで公開するため、マニフェストリポジトリREADMEでも紹介されているようにIngressでIstio Ingress Gatewayを公開しながらTLS終端を行います。

Ingressの役割については以下のKubernetes公式ドキュメントが参考になります。

kubernetes.io

まずはNGINX Ingress Controllerをインストールします。

github.com

次にMetalLBをインストールして、NGINX Ingress ControllerをLoadBalancerサービスとして公開しましたが、NodePortを利用する場合は適宜読み替えてください。

(任意)MetalLBのインストール MetalLBのインストールすることで、type: LoadBalancerなサービスに外部IPアドレスを割り当てることができます。

kubernetes.github.io

metallb.io

公式ドキュメントに従ってMetalLBをインストールした後、IPAddressPoolL2Advertisementという2つのカスタムリソースを作成します。

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: example-ipaddresspool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.XXX-192.168.1.YYY
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: example-l2advertisement
  namespace: metallb-system
spec:
  ipAddressPools:
    - example-ipaddresspool

次に、手動で自己証明書を作成してSecretリソースを作成します。今回はローカルで信頼される証明書を発行できるmkcertを利用しました。

$ mkcert -install
The local CA is now installed in the system trust store! ⚡️

$ mkcert nlab.com
Created a new certificate valid for the following names 📜
 - "nlab.com"
The certificate is at "./nlab.com.pem" and the key at "./nlab.com-key.pem" ✅
It will expire on 16 August 2027 🗓

$ kubectl create -n ingress-nginx secret tls tls-secret \
  --key=nlab.com-key.pem \
  --cert=nlab.com.pem
secret/tls-secret created

Ingressリソースを作成してspec.tlsフィールドでTLS終端の設定を行います。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: nlab.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: example-service
                port:
                  number: 80
  tls:
    - hosts:
        - nlab.com
      secretName: tls-secret
---
# https://kubernetes.io/docs/concepts/services-networking/service/#externalname
apiVersion: v1
kind: Service
metadata:
  name: example-service
spec:
  type: ExternalName
  externalName: istio-ingressgateway.istio-system.svc.cluster.local
  ports:
    - port: 80

Ingressリソースを確認すると、MetalLBによって外部IPアドレスが割り当てられていることが分かります。

$ kubectl get ingress -n ingress-nginx
NAME              CLASS   HOSTS      ADDRESS         PORTS     AGE
example-ingress   nginx   nlab.com   192.168.1.XXX   80, 443   6m9s

外部IPアドレスは割り当てられましたが名前解決はできていないので、MacBook/etc/hosts192.168.1.XXX nlab.comのように追記してください。将来的には(Cloudflare DNSなどを利用する)ExternalDNSを導入することで、ユーザーの負担を軽減したいです。

アドレスバーにNot Secureという警告が出る場合

ブラウザからアクセスすると表示される警告画面

MacBookの場合、ルート証明書をダウンロードしてからKeychain Accessで信頼されたルート証明書として設定します。

root@polaris
$ mkcert -CAROOT
/root/.local/share/mkcert

azuma@macbook
$ scp polaris:/root/.local/share/mkcert/rootCA.pem .
$ open rootCA.pem

追加したルート証明書をダブルクリックする

Always Trustを選択する

https://{ドメイン名}でKubeflowにアクセス

以上で、KubeflowのサービスをHTTPSで公開することができました。

7. DexによるGitHub認証

これまではKubeflow Central Dashboardにアクセスすると、Dexのログイン画面が起動してデフォルトのユーザー名user@example.comとパスワード12341234でログインしていました。しかし実際に運用する場合は、たとえ研究室内であってもデフォルトユーザーを全員で使い回すことはあり得ません。

Dexはユーザーを他のIdPで認証するためのConnectorを実装しており、LDAPやOIDC、OAuth 2.0といったプロトコルベースのものから、GitHubGoogleMicrosoftのようなプラットフォーム固有のものまでサポートしています。詳細は公式ドキュメントを参照してください。

今回は、既に研究室のGitHub Organizationが存在していて大多数のメンバーが参加していることから、GitHub認証を利用することにしました。

dexidp.io

READMEと上記の公式ドキュメントに従って、マニフェストconfig-map.yamlを以下のように変更しました。

apiVersion: v1
kind: ConfigMap
metadata:
  name: dex
data:
  config.yaml: |
-    issuer: http://dex.auth.svc.cluster.local:5556/dex
+    issuer: https://nlab.com/dex
    # 省略
    staticClients:
    # https://github.com/dexidp/dex/pull/1664
    - idEnv: OIDC_CLIENT_ID
      redirectURIs: ["/oauth2/callback"]
      name: 'Dex Login Application'
      secretEnv: OIDC_CLIENT_SECRET
+    connectors:
+    - type: github
+      id: github
+      name: GitHub
+      config:
+        clientID: $GITHUB_CLIENT_ID
+        clientSecret: $GITHUB_CLIENT_SECRET
+        redirectURI: https://nlab.com/dex/callback
+        loadAllGroups: false
+        teamNameField: slug
+        useLoginAsID: false

$GITHUB_CLIENT_ID$GITHUB_CLIENT_SECRETは適宜置き換えてください。GitHub OrganizationのDeveloper Settingsから新しいOAuth Appを作成する必要があります。Authorization callback URLは上記のConfigMapのredirectURIと一致させる必要があります。

OAuth Appの設定例(Homepage URLは任意のURLで可)

Kubeflowの認証をカスタムする技術記事はほとんど無く、DexでConnectorを追加した場合に修正すべきマニフェストはREADMEにも記載されていません。今回はKeyCloak Integration Guideを参考にしながら、以下のようにOAuth2 ProxyとIstio Request Authenticationの設定を変更しました。

(差分)common/oauth2-proxy/base/oauth2_proxy.cfg

 provider = "oidc"
-oidc_issuer_url = "http://dex.auth.svc.cluster.local:5556/dex"
+oidc_issuer_url = "https://nlab.com/dex"
 scope = "profile email groups openid"

(差分)common/oauth2-proxy/components/istio-external-auth/requestauthentication.dex-jwt.yaml

   jwtRules:
-  - issuer: http://dex.auth.svc.cluster.local:5556/dex
+  - issuer: https://nlab.com/dex
+    jwksUri: http://dex.auth.svc.cluster.local:5556/dex/keys

IstioのRequest AuthenticationはデフォルトでIssuerの/.well-known/openid-configurationから公開鍵を取得しますが、ブラウザと違ってクラスター内のPodからはnlab.comが名前解決ができないので、明示的にjwksUriフィールドをdex.auth.svc.cluster.localと指定しなければいけない点がポイントでした。

Kubeflowの認証フロー(引用:kubeflow/manifests
v1.10は図中の「kubeflow 1.9 with oauth2 proxy」に該当

仕組みとしては、ユーザーがブラウザからKubeflowにアクセスするとIstioのEnvoyに到達し、ext_authzフィルターによってOAuth2 Proxyにリクエストが転送されます。OAuth2 ProxyはセッションCookieoauth2_proxy_kubeflow)を検証して、未認証であればDexを介して上流IdP(GitHubなど)とOIDC / OAuth2フローを実行してトークンレスポンス(IDトークンやリフレッシュトークンを含む)を取得します。OAuth2 ProxyはIDトークンをもとにCookieを発行し、以降のリクエストではそのCookieの検証を行います。最終的にはOAuth2 Proxyが付与する認証済みヘッダーをEnvoyが受け取り正当なユーザーとしてバックエンドのサービスへリクエストを通過させます。

GitHub認証でパスキーを利用して簡単にKubeflowにログイン

現時点でユーザーはログインしてもProfileがないためノートブックを作成できません。ProfileカスタムリソースはNamespaceをラップしたもので、1人のユーザーがProfileを所有しますが、コントリビューターに権限を付与することもできます。

www.kubeflow.org

公式ドキュメントに従って環境変数の値を変更し、デフォルトで無効化されているProfileの自動作成を有効化します。再度新しいユーザーでログインすると、Namespaceの作成画面が表示されるはずです。

デフォルトでユーザー名のNamespaceを作成

$ kubectl get namespace | grep kitsuyaazuma
kitsuyaazuma                Active   64s

GitHub認証を利用することで、DexのBuilt-In ConnectorでのStaticなユーザー管理を必要とせず、マルチユーザーにNaaSを提供できるようになりました。研究室用途であればこれでも十分ですが、

8. ExternalDNSによるCloudflare DNSの利用

6章では、ドメイン名(自己証明書を作成したnlab.com)を利用してKubeflowダッシュボードにHTTPSでアクセスできるようになりました。しかしユーザー視点では、名前解決のために/etc/hostsでホスト名とIPアドレスを関連付けなければならず、ブラウザの警告を消すために手動でルート証明書を信頼しなければなりません。

これらの問題を一気に解決するために、ExternalDNSを利用してCloudflare DNSのレコードを動的に更新しつつ、証明書管理も自動化します。

github.com

事前にCloudflareでドメインを登録して、サイドバーの「アカウントの管理」からAPIトークンを発行する必要があります。下記の公式チュートリアルに取り組んで、NginxにHTTPでアクセスできることを確認しましょう。注意点として、cert-managerが既にデフォルトでインストールした場合はAPIトークンのSecretをcert-manager Namespaceに作成しておきましょう(参考)。

github.com

チュートリアルのサービスにHTTPでアクセスできるかを確認

次に、cert-managerで証明書管理を自動化します。今回は、ClusterIssuerでACME認証局(例:Let's Encrypt)とのチャレンジ方式(例:DNS-01チャレンジ)を設定し、Certificateで証明書を発行するDNS名や保存するSecret名、参照するIssuerなどを設定します。

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    email: YOUR_EMAIL
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: issuer-account-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-key
              key: apiKey
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com
  namespace: ingress-nginx
spec:
  dnsNames:
  - YOUR_DOMAIN_NAME
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: letsencrypt
  secretName: example-com-tls

これに合わせて、Ingressrulestlsフィールド、7章で変更したKubeflow認証のissuerフィールド、GitHub OAuth Appのcallback URLを変更する必要があります。YOUR_DOMAIN_NAMEは研究室ホームページに利用したいという要望があったので、このタイミングでサブドメインkubeflow.YOUR_DOMAIN_NAMEを利用する方針に変更しました。

https://kubeflow.{ドメイン名}でKubeflowにアクセス

以上で、動的なDNSレコード更新と証明書管理の自動化を達成することができました。

まとめ

Kubeflow Notebooksを利用して研究室サーバーにNotebook as a Serviceを構築する方法を紹介しました。

執筆時点でこのNaaSをα版としてリリースしており、一部の研究室メンバーに利用して頂くことができました。今年度のうちにβ版リリース、そしてGAを目指します。また、機能の追加だけでなくドキュメントの整備も含めた総合的なユーザー体験の向上に努めていきます。

今後の課題としては以下の候補が挙げられます。

  • 直感的にBaseイメージを拡張してカスタムイメージをpushできるワークフローの構築
  • HarborDistributionによるプライベートレジストリのデプロイ
  • NFSボリュームの利用と動的プロビジョニング

Kubeflow Platformのコンポーネントは非常に多く、JupyterHub(Z2JH)と比較して学習コストがかかります。しかしKubeflowはKubernetesネイティブなアーキテクチャであるため、今まで触れてこなかった技術(私の場合はIstioやOIDC、TLS)に向き合う非常に良いチャンスになります。本記事が、社内のデータプラットフォームや逸般の研究室サーバーで参考になれば幸いです。

【告知】CloudNative Days 2025にて『研究室サーバーとKubeflowで実践するNotebook as a Service』というテーマでLT発表します。

Kubernetesネイティブな監視基盤への移行(#15)

はじめに

#5では、研究室サーバーの監視基盤としてPrometheus、Grafana、Alertmanagerを導入しました。この基盤で半年ほど問題なく運用できており、現在Grafanaダッシュボードは研究室の他のメンバーにも利用されています。

当時は引き継ぎやバージョンアップのコストを考慮して、各コンポーネントをバイナリインストール(Linuxサービス起動)ではなく、Dockerコンテナとして起動する意思決定を行いました。しかし、この方法でも依然として手動でのバージョンアップが必要であり、決してメンテナンス性が高いとは言えません。

一方、#14では研究室サーバーでオンプレミスのKuberentesクラスタを構築しました。本記事では、Prometheus Operatorなどを利用したKubernetesネイティブな監視基盤に移行する方法とそのメリットを紹介します。

1. Prometheus Operatorとは

Prometheus Operatorを利用することで、PrometheusやAlertmanagerなどをKubernetesネイティブなOperator patternでデプロイ・管理することができます。

github.com

目的として「KubernetesクラスタにおけるPrometheusベースの監視スタックの設定を簡素化し、自動化すること」を掲げていて、主に以下の3つの機能を提供しています。

  1. カスタムリソースの提供
  2. シンプルなデプロイ設定
  3. Prometheusターゲット設定

1つ目について、デプロイされるPrometheusAlertmanagerだけでなく、(prometheus.yamlで指定されるような)ProbeScrapeConfigPrometheusRuleなどといった設定用のカスタムリソースも定義されています。また、Serviceに関連付けられたPodを間接的に指定するServiceMonitorと、Podを直接指定するPodMonitorも特徴的です。

例えばPrometheusというCRDは、scrapeConfigSelectorserviceMonitorSelectorなどのフィールドによって、使用するScrapeConfigServiceMonitorを特定できます。詳細は以下の公式ドキュメントが図解付きで分かりやすいです。

prometheus-operator.dev

2つ目は冒頭で述べた課題を解決するもので、KubernetesのリソースとしてPrometheusのバージョンやレプリカ数などを管理できます。3つ目については、Kubernetesのラベルから監視ターゲットの設定を生成できるため、Prometheusの従来のYAML設定を必要としません。どちらも運用上のメリットが大きいです。

2. Prometheus Operatorのインストール

prometheus-operator.dev

さて、Prometheus Operatorのインストール方法ですが、公式ドキュメントでは3通りの方法が紹介されていました。Prometheus OperatorとCRDのみインストールする方法もこのうちの1つですが、より簡単にデプロイできる方法を検討します。

kube-prometheusはPrometheus Operatorと同じOrganizationによってメンテナンスされています。しかし今回はHelmを利用してインストールしたかったため、kube-prometheus-stackを利用します。どちらもPrometheus Operatorを利用したGrafanaダッシュボード付きのマニフェストです。

kubeadmを利用している場合、いくつか事前準備が必要なので公式ドキュメントをご参照ください。

既にArgo CDを導入してGitOpsを実践しているため、以下のようなマニフェストを用意しました。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: kube-prometheus-stack
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://prometheus-community.github.io/helm-charts
    chart: kube-prometheus-stack
    targetRevision: 70.4.2
    helm:
      valuesObject:
        grafana:
          grafana.ini:
            server:
              root_url: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
              serve_from_sub_path: true
        prometheus:
          prometheusSpec:
            externalUrl: "http://<NodeIP>:<NodePort>/prometheus"
            routePrefix: /prometheus
        alertmanager:
          alertmanagerSpec:
            externalUrl: "http://<NodeIP>:<NodePort>/alertmanager"
            routePrefix: /alertmanager
  destination:
    server: https://kubernetes.default.svc
    namespace: kube-prometheus-stack
  syncPolicy:
    automated: {}
    syncOptions:
    - CreateNamespace=true
    - ServerSideApply=true

<NodeIP><NodePort>は各自で置き換えてください。

ブラウザからGrafana / Prometheus / Alertmanagerにアクセスするとリダイレクトが発生するため、私たちのように/grafana / /prometheus / /alertmanagerでアクセスさせたい場合はvalues.yamlの値を上書きしてサブパスを指定する必要があります。

公式ドキュメントではPrometheusやAlertmanagerを公開する方法として、NodePortやKuberenetes APIIngressを利用する方法が紹介されていましたが、今回は引き続きIstioのIngress Gatewayを利用することにしました。

Gatewayのマニフェスト

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: http-gateway
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"

VirtualServiceのマニフェスト

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: kube-prometheus-stack-virtualservice
  namespace: kube-prometheus-stack
spec:
  hosts:
  - "*"
  gateways:
  - istio-system/http-gateway
  http:
  - match:
    - uri:
        prefix: /grafana
    route:
    - destination:
        host: kube-prometheus-stack-grafana
        port:
          number: 80
  - match:
    - uri:
        prefix: /prometheus
    route:
    - destination:
        host: kube-prometheus-stack-prometheus
        port:
          number: 9090
  - match:
    - uri:
        prefix: /alertmanager
    route:
    - destination:
        host: kube-prometheus-stack-alertmanager
        port:
          number: 9093

この設定によってhttp://<NodeIP>:<NodePort>/{grafana,prometheus,alertmanager}のようなパスベースのルーティングを行うことができ、<NodeIP>:<NodePort>さえ分かれば簡単にそれぞれのWeb UIにアクセスできます。

<NodeIP>:<NodePort>/grafanaでGrafanaのWeb UIにアクセス

<NodeIP>:<NodePort>/promethuesでPrometheusのWeb UIにアクセス

<NodeIP>:<NodePort>/alertmanagerでAlertmanagerのWeb UIにアクセス

そうは言ってもやはりドメイン名でアクセスしたいので、近いうちにMetalLBExternalDNSを導入する予定です。

3. NVIDIA GPU Operatorのインストール

お馴染みのNVIDIA/dcgm-exporterを利用してKubernetes上でもGPUメトリクスを監視する予定でしたが、READMEには以下のように書かれていました。

Note: Consider using the NVIDIA GPU Operator rather than DCGM-Exporter directly.

今度はNVIDIA/gpu-operatorのREADMEを確認すると以下のように紹介されています。

These components include the NVIDIA drivers (to enable CUDA), Kubernetes device plugin for GPUs, the NVIDIA Container Runtime, automatic node labelling, DCGM based monitoring and others.

DCGMだけでなく、#14でコンテナからGPUを利用するために導入したNVIDIA/k8s-device-pluginも含まれているようです。さらに、今まで手動でメンテナンスしていたNVIDIA GPU DriverやNVIDIA Container Toolkitも含まれているとのことで(インストール済みなので今回は無効化しますが)恩恵が大きいと感じました。

早速#14で導入したk8s-device-pluginは削除して、gpu-operatorを以下の公式ドキュメントに従ってインストールします。

docs.nvidia.com

注意点として、以下のような差分を一度にSyncしないようにしてください。k8s-device-pluginにもNode Feature Discovery(NFD)が含まれているため、一旦無効化してからgpu-operatorでNFDをインストールする必要があります(公式ドキュメント参照)。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
-  name: nvidia-device-plugin
+  name: gpu-operator
  namespace: argocd
spec:
  project: default
  source:
-    repoURL: https://nvidia.github.io/k8s-device-plugin
-    chart: nvidia-device-plugin
-    targetRevision: 0.17.1
+    repoURL: https://helm.ngc.nvidia.com/nvidia
+    chart: gpu-operator
+    targetRevision: v25.3.0
    helm:
      parameters:
-        - name: gfd.enabled
-          value: "true"
+        - name: driver.enabled
+          value: "false"
+        - name: toolkit.enabled
+          value: "false"
  destination:
    server: https://kubernetes.default.svc
-    namespace: nvidia-device-plugin
+    namespace: gpu-operator
  syncPolicy:
    automated: {}
    syncOptions:

nvidia-device-plugin-***nvidia-dcgm-exporter-***というPodが起動していることを確認しましょう。

続いて、GPUメトリクスに関してPrometheusとGrafanaのセットアップも行います。

docs.nvidia.com

上記の公式ドキュメントに従うのが最も手っ取り早いです。2章のマニフェストで変更が必要なPrometheus部分だけを示します。

        prometheus:
          prometheusSpec:
            externalUrl: "http://<NodeIP>:<NodePort>/prometheus"
            routePrefix: /prometheus
            serviceMonitorSelectorNilUsesHelmValues: false
            additionalScrapeConfigs:
            - job_name: gpu-metrics
              scrape_interval: 15s
              metrics_path: /metrics
              scheme: http
              kubernetes_sd_configs:
              - role: endpoints
                namespaces:
                  names:
                  - gpu-operator
              relabel_configs:
              - source_labels: [__meta_kubernetes_endpoints_name]
                action: drop
                regex: .*-node-feature-discovery-master
              - source_labels: [__meta_kubernetes_pod_node_name]
                action: replace
                target_label: kubernetes_node
              - source_labels: [__address__,__meta_kubernetes_endpoint_node_name]
                action: replace
                separator: ,
                target_label: instance

relabel_configs:の最後のアイテムは独自に付け加えました。理由はGrafanaダッシュボード上でホスト名による変数フィルタリングを行うためです。NVIDIA DCGM Exporterという人気のあるダッシュボードを利用する場合、フィルタリング変数はinstance(デフォルトは__address__つまり192.168.1.xxx:9400のようなフォーマット)となり、ダッシュボード利用者がワーカーノードのIPアドレスを暗記しなければなりません。

__address____meta_kubernetes_endpoint_node_name(ホスト名)をカンマ区切りで結合した文字列でinstanceラベルを置換することで、さらにダッシュボード側で正規表現を利用して表示用のtextとクエリ用のvalueを抽出できます。

/(?<value>[^,]+),(?<text>.+)/

ただし、これらの変更によって元のinstanceラベルも変更されてしまったのでダッシュボード設定のJSON Modelタブで以下のような書き換えも行う必要があります。

s/instance=~\"${instance}\"/instance=~\"${instance}.*\"/g

お馴染みのホスト名で変数フィルタリングを行う様子

Grafanaダッシュボードは管理者だけでなく研究室メンバーにも普及しているので可能な限り認知負荷を下げるようにダッシュボードをセットアップしました。

まとめ

今回は、Prometheus Operatorなどを利用してKubernetesネイティブな監視基盤に移行する方法を紹介しました。Kubernetes上でメトリクスを収集できましたが、古い監視基盤のAlertmanagerの設定はまだ移行できていないので徐々に進めていきたいです。

一般的な研究室ではワーカーノードが数台程度なので古い監視基盤でも問題にならない規模ですが、GitOpsで監視の設定を一元管理できる点と各コンポーネントのアップデートが現実的になる点で、Kubernetesネイティブな監視基盤に移行するメリットは大きいと考えました。

ただし、Kubernetesクラスタがある時点で逸般的な研究室である可能性は高いので、ファーストステップとして監視基盤を構築したい方は依然として#5の方法をオススメします。

研究室サーバーでKubernetesクラスタ構築(#14)

はじめに

以前から、研究室でノードを跨いだNaaS(Notebook as a Service)を作ってみたいという野望がありました。そのためにはまず、研究室サーバーでKubernetesクラスタを構築するのが最も手堅いと言えるでしょう。

ただしNaaSの提供は、コンテナ(DockerやPodman)で研究していたユーザーを完全に移行させるものではありません。ユーザーが選択可能かつメンテナーが居なくなった場合は従来のコンテナのみの運用に戻せるように細心の注意が必要です。

本記事では、既にDockerがインストールされているノードを活用してkubeadmでオンプレミスのKubernetesクラスタを構築するためのノウハウを共有します。

1. クラスタのノード構成

kubernetes.io

元々は、余っていたRaspberry Pi 4BかIntel NUCをコントロールプレーンノードとする予定でした。しかし、過去のおうちKubernetesの経験から、クラスタを安定稼働させるには高性能なコントロールプレーンノードが必要不可欠だと感じていました。

研究室の先生にダメ元で購入をお願いしたところ、既に素晴らしい性能のCPUラックサーバーを注文されていたとのことで、ありがたくコントロールプレーンノードとして利用させていただくことになりました。

人生初のラックサーバー取り付け

最終的には以下のようなノード構成となりました。

ホスト名 役割 OS CPU メモリ GPU
polaris コントロールプレーンノード Ubuntu 24.04 AMD EPYC 7543P
(32C/64T, 2.8GHz)
128GB N/A
aries ワーカーノード① Ubuntu 24.04 AMD EPYC 7542
(32C/64T, 2.9GHz)
256GB NVIDIA Quadro RTX 6000 × 4
taurus ワーカーノード② Ubuntu 24.04 Intel Core i9-10940X
(14C/28T, 3.3GHz)
64GB NVIDIA RTX 6000 Ada Generation × 3
gemini ワーカーノード③ Ubuntu 24.04 Intel Xeon Gold 5218R
(20C/40T, 2.1GHz)
192GB NVIDIA RTX A6000 × 5

先生のおかげで、非常に恵まれた環境でKubernetesクラスタを構築できることになりました。

2. コンテナランタイムのセットアップ

kubernetes.io

コンテナランタイムの候補としてcontainerdCRI-Oの間でかなり悩みましたが、以下の2つの理由からcontainerdを採用しました。

1つ目は、全てのワーカーノードにDockerがインストールされており、containerdも一緒にインストールされていることです。Kubernetesクラスタのアップグレードに合わせてDocker Engineのアップグレードが必要になる可能性がありますが、Docker Engineはメンテナンスで年に数回アップグレードしているため、そこまで大きな問題にはならないと考えました。

2つ目は、CRI-OとPodmanでconmonが競合する可能性があることです。今年からデフォルトでrootlessなコンテナエンジンであるPodmanの利用を推奨しており、全てのワーカーノードにインストールされています。コンテナ監視ツールであるconmonはCRI-OとPodmanの両方で使用されているため、バージョン統一が大変になると考えました。

ワーカーノードでcontainerdのバージョンを確認します。

$ containerd -v
containerd containerd.io 1.7.25 bcc810d6b9066471b0b6fa75f557a15a1cbf31bb

基本的にはDocker Engineのアップグレード時点で最新バージョンのcontainerdをインストールしているため、執筆時点で最新のKubernetes v1.32もサポートしているcontainerdのバージョンでした(参考)。

それから注意点として公式ドキュメントにも記載がありますが、今回はパッケージからcontainerdをインストールした場合に相当するので、設定ファイルである/etc/containerd/config.tomlに変更が必要でした。デフォルトではdisabled_plugins = ["cri"]とCRIが無効化されているので、Kubernetesからcontainerdを利用するためにはこれを有効化する必要があります。公式ドキュメントで紹介されていたように以下のコマンドで設定をリセットしてcontainerdを再起動しました。

$ containerd config default > /etc/containerd/config.toml
$ sudo systemctl restart containerd

ここで、containerdを再起動する必要があったため、ワーカーノードとしては偶然ユーザーが誰も利用していなかった1台のノードのみを利用することにしました。残りのノードはメンテナンスを周知してからクラスタに参加させる予定です。

3. kubeadmによるクラスタ作成

Kubernetesのバージョンは最新のv1.32を採用しました。

基本的には以下の公式ドキュメントの通りセットアップを行いました。

kubernetes.io

ただし、手順に沿ってkubeadm initコマンドとkubeadm joinコマンドを実行しても、コントロールプレーンノードがNotReadyという状態のままでした。

root@polaris:~# kubectl get nodes
NAME      STATUS     ROLES           AGE     VERSION
polaris   NotReady   control-plane   7m27s   v1.32.3
taurus    Ready      <none>          4m1s    v1.32.3

公式ドキュメントにも

You must deploy a Container Network Interface (CNI) based Pod network add-on so that your Pods can communicate with each other. Cluster DNS (CoreDNS) will not start up before a network is installed.

と記載がありますが、以下のようなコマンドでもノードの状態がなぜNotReadyなのかを確認することができます。

root@polaris:~# kubectl describe node polaris
Name:               polaris
Roles:              control-plane
# 省略
Conditions:
  Type             Status  LastHeartbeatTime                 LastTransitionTime                Reason                       Message
  ----             ------  -----------------                 ------------------                ------                       -------
  MemoryPressure   False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletHasSufficientMemory   kubelet has sufficient memory available
  DiskPressure     False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletHasNoDiskPressure     kubelet has no disk pressure
  PIDPressure      False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletHasSufficientPID      kubelet has sufficient PID available
  Ready            False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletNotReady              container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized
# 省略

原因はCNIプラグインがインストールされていなかったことでした。

CNIプラグインとしては、公式ドキュメントで紹介されている中でもCalicoCiliumFlannelなどが有名ですが、今回はCiliumを採用しました。Ciliumの機能については別の記事にまとめようと思います。

docs.cilium.io

Ciliumは上記の公式ドキュメントを参考にHelmでデプロイしました。

ただし、今後Istioを導入する場合は公式ドキュメントに従って--set socketLB.hostNamespaceOnly=true --set cni.exclusive=falseを指定する必要があるので注意が必要です。

これによって、無事に全てのノードがReady状態となりました。

root@polaris:~# kubectl get nodes
NAME      STATUS   ROLES           AGE   VERSION
polaris   Ready    control-plane   21m   v1.32.3
taurus    Ready    <none>          18m   v1.32.3

4. Argo CDの導入

Kubernetesにおけるデプロイ方法のデファクトスタンダードであるGitOpsを実現するためのツールとして、Argo CDを導入します。

argo-cd.readthedocs.io

上記の公式ドキュメントに従ってArgo CDのインストールから始めました。

ステップ3でArgo CDのAPIサーバーを公開するためには、Service Type Load Balancer / Ingress / Port Forwardingの中から1つを選択しなければいけません。MetalLBはインストールしておらず、デバッグ用途のkubectl port-forwardも不適切であることから、消去法でIngressを選択しました。Ingressの設定にもたくさんの選択肢がありますが、今回はIstioを採用しました。

事前準備として以下の2ステップが必要でした。

  1. Istioのインストール:Istio / Install with Helm
  2. Ingress Gatewayのインストール:Istio / Installing Gateways

2.では「Kubernetes YAML」でインストールしましたが、LoadBalancerではなくNodePortを利用したかったので以下のように変更してapplyしました。

apiVersion: v1
kind: Service
metadata:
  name: istio-ingressgateway
  namespace: istio-ingress
spec:
-  type: LoadBalancer
+  type: NodePort
  selector:
    istio: ingressgateway
  ports:
  - port: 80
    name: http
+    targetPort: 8080
+    nodePort: 30080
-  - port: 443
-    name: https
---
# 省略
[root@polaris argocd ]# kubectl apply -f ingress.yaml
# 省略
[root@polaris argocd ]# kubectl get all -n istio-ingress
NAME                                        READY   STATUS    RESTARTS   AGE
pod/istio-ingressgateway-5b6548c6d6-wvt7d   1/1     Running   0          6s

NAME                           TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
service/istio-ingressgateway   NodePort   10.111.154.125   <none>        80:30080/TCP   6s

NAME                                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/istio-ingressgateway   1/1     1            1           6s

NAME                                              DESIRED   CURRENT   READY   AGE
replicaset.apps/istio-ingressgateway-5b6548c6d6   1         1         1       6s

残りは下記の公式ドキュメントに従ってIstioの設定を行います。ただし、今回はHTTPSは使用しないので、設定手順の中のTLSの設定はカットしました。

argo-cd.readthedocs.io

ブラウザでhttp://{{ IP }}/argocdにアクセスするとWeb UIにアクセスできます。

お馴染みのArgo CDのログイン画面

ステップ4でadminユーザーの初期パスワードを取得してログインしてPrivate Repositoriesの設定を行う予定でしたが、ログイン後のリダイレクトURLが/argocd/argocd/applications(正しくは/argocd/applications)になってしまいました。調査したところ、既にIssueでバグとして報告されており、執筆時点でv2.13では修正が完了したもののv2.14では未完了でした。

github.com

今回利用していたのはv2.14.9であったため、バージョンを下げることにしました。

# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# 以下を変更
- https://raw.githubusercontent.com/argoproj/argo-cd/v2.13.6/manifests/install.yaml

patches:
- path: ./patch.yaml

この修正で無事に/argocd/applicationsにリダイレクトされるようになりました。

正しくリダイレクトされた画面

さてPrivate Repositoriesの設定ですが、PATやSSH Private Key Credentialなどの属人化した設定をしたくなかったので、公式ドキュメントに従ってGitHub App Credentialの設定を行いました。

インストールしたGitHub Appの概要

最後にWeb UI上でアプリケーションの作成を行い、Getting StartedのサンプルマニフェストGitHubリポジトリにpushすることによって動作確認を行いました。

guestbookアプリケーションがSyncされている様子

Getting Startedの内容はここまででほとんどカバーできました。

公式ドキュメントで紹介されていたApp of Apps Patternは既に導入しましたが、今後はSSOSync Optionsなども設定する予定です。

5. GPU device pluginの導入

主にコンテナからGPUを利用するため、NVIDIA公式のdevice pluginを導入します。

github.com

READMEに記載されている必須要件の中のnvidia-docker >= 2.0 || nvidia-container-toolkit >= 1.7.0は、ワーカーノードが既に要件を満たしていました。

root@taurus:/home/azuma# nvidia-container-toolkit --version
NVIDIA Container Runtime Hook version 1.17.4
commit: 9b69590c7428470a72f2ae05f826412976af1395

一方で、必須要件のうち「nvidia-container-runtimeがデフォルトの低レベルランタイムとして設定されている」については、公式ドキュメントに従ってnvidia-ctkコマンドで設定した上で以下のような変更を/etc/containerd/config.tomlに加える必要がありました。

# 省略
    [plugins."io.containerd.grpc.v1.cri".containerd]
-      default_runtime_name = "runc"
+      default_runtime_name = "nvidia"
# 省略

containerdをrestartしたら、いよいよDaemonSetをデプロイするだけでGPUを有効化できます。staticなYAMLファイルも提供されていましたが、今回は本番環境で推奨されていたHelmを利用しました*1

[root@polaris ~]# helm upgrade -i nvdp nvdp/nvidia-device-plugin \
  --version=0.17.1 \
  --namespace nvidia-device-plugin \
  --create-namespace \
  --set gfd.enabled=true
# 省略
[root@polaris ~ ]# kubectl get daemonset -n nvidia-device-plugin
NAME                                              DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR                 AGE
nvdp-node-feature-discovery-worker                1         1         1       1            1           <none>                        5m17s
nvdp-nvidia-device-plugin                         1         1         1       1            1           <none>                        5m17s
nvdp-nvidia-device-plugin-gpu-feature-discovery   1         1         1       1            1           <none>                        5m17s
nvdp-nvidia-device-plugin-mps-control-daemon      0         0         0       0            0           nvidia.com/mps.capable=true   5m17s
[root@polaris ~ ]# kubectl describe node taurus
# 省略
Capacity:  # 省略
  nvidia.com/gpu:     3
Allocatable:  # 省略
  nvidia.com/gpu:     3
# 省略

このHelmの設定もGitOpsとして宣言的に管理するため、GitHubリポジトリに追加しました(参考)。

最後に、READMEで紹介されている、GPUを利用するサンプルジョブをデプロイして成功すれば完成です。

Time-slicingやMPSのようなshared GPUのための設定も必要になりそうですが、NaaS構築と同じタイミングでニーズに合わせて設定する予定です。

まとめ

本記事では、Dockerがインストールされたノードを活用してkubeadmでオンプレミスのKubernetesクラスタを構築する方法を紹介しました。つまずきポイントがいくつかあったので、オンプレミスクラスタを構築してみたい方にとって少しでも参考になれば幸いです。

Kubernetes周りでデファクトスタンダードなツールは他にもたくさんあるのですが、のめり込んでしまうとキリがないので、まずはNaaS構築に向けて着実にマイルストーンを達成してこうと思います。

*1:READMEに記載されていた最も基本的なインストールコマンドであるhelm upgrade -i nvdp nvdp/nvidia-device-plugin --namespace nvidia-device-plugin --create-namespace --version 0.17.1は期待通りにデプロイされませんでした。原因はデフォルトで無効化されているgpu-feature-discoveryが、自動的に生成するラベルがDaemonSetのnodeAffinityとして設定されているためでした。GFDを有効にするか、手動でノードにnvidia.com/gpu.present=trueラベルを付与しましょう。

次世代notebook『marimo』入門(#13)

はじめに

最近、Jupyter notebookの課題を解決するためのPython notebookとしてmarimoが注目されています。

docs.marimo.io

2024年12月頃からXでちらほらmarimoの話題を目にするようになりました(執筆時点ではSNSにおける話題性は少し薄れてきた印象です)。GitHubスター数の増加からもmarimoの人気が伺えます。

Jupyter notebookとmarimoのGitHubスター数の比較(リンク

本記事では、次世代notebookであるmarimoに入門しつつ、Jupyter notebookとの比較を通してmarimoのメリットや使用感を簡単に紹介します。

1. marimoの概要

github.com

百聞は一見に如かず。

手元で以下のコマンドを実行するか、提供されているオンラインのplaygroundでまずは試してみましょう。

$ python -m venv venv && . ./venv/bin/activate && pip install marimo && marimo tutorial intro

チュートリアルを一通りやってみると、おおよその感覚が掴めます。

reactiveにnotebookが実行される様子

上図では、ドロップダウンやスライダーの変更によって別のセルにある表示も変更されていることが分かります。marimoではセルの順序や実行順序は関係なく変数を参照しているセルがあれば自動的に再実行されるため、再現性が担保されています。

公式ドキュメントでreactive Python notebookと表現されている通り、marimoではセルを1つ1つ実行するという概念すらも忘れてしまいそうです。私たちが手動で実行する必要があるのは、変更を加えようとしているまさにそのセルのみということになります。

marimoの特長は他にもありますが、そのうちいくつかを簡単に紹介します。

  • Pythonスクリプトとして実行可能で、argparseなどでコマンドライン引数も取れる
  • CLIからWebアプリとしてデプロイでき、セル出力のみを結合したVerticalレイアウト、編集可能なGridレイアウト、スライドショーのようなSlidesレイアウトを切り替え可能
  • WASM notebooksとしてPythonのインストールを必要とせずにデプロイ・共有が可能
  • 接続したデータベースに対して直接セルにSQLを書いてDataFrameを取得したり、その出力のDataFrameに対してSQLを書いたりできる
  • Pythonスクリプトとして保存されるため、Gitフレンドリー
  • GitHub Copilotなどによるコード補完やAIアシスタント(ただしOpenAIやAnthropic、Google AIのAPIキーが必要)が利用可能

marimoのAIに関するユーザー設定画面

GitHub Copilotについては、Jupyter notebookでもVSCode.ipynbファイルを開く場合は利用できますが、jupyter notebookコマンドで起動したWeb UIからは利用できません*1

イデアの迅速なプロトタイピングを可能にするという観点では、Python notebookもVibe Codingも共通のニーズに応えられるので、コード補完やAIアシスタントをサポートしていることはmarimoの大きな武器になっていると考えました。

2. marimoとJupyter notebookの比較

marimoの公式ドキュメントのFAQに「marimoはJupyterとどう違いますか?」という項目があります。

docs.marimo.io

公式ドキュメントと同じことは述べませんが、文章を読むのが億劫だと感じる方のために簡単な表にまとめました(適宜文脈を補っています)。

marimo Jupyter 備考
再現性 × marimoは状態一貫性を担保。Jupyterは削除されたセルの変数の参照や実行順序の自由さが再現性を損ねる要因。
インタラクティブ marimoは標準搭載。Jupyterはipywidgets等で実現可能だが設定が必要。
Gitとの相性 ○ (.py) △ (.ipynb) marimoはPythonスクリプトとして保存。JupyterはJSON形式でGit差分が見づらい。
再利用性 marimoはそのままPythonスクリプトとして実行可能。Jupyterはスクリプト実行に追加処理が必要。
パッケージ管理 marimoはパッケージ要件をnotebookに含めるためコマンド1つで仮想環境として再現可能。Jupyterは外部管理が一般的で再現性がない傾向。
共有可能性 marimoはコマンド1つでインタラクティブなWebアプリとして公開可能。Jupyterはvoilaなどの利用が必要。

marimoのドキュメントなので必然的にmarimoの短所は取り扱っていませんでした。とは言え、marimoが「Python notebookの課題を解決するための再開発」である点からもJupyter notebookの多くの課題が解決されていると言えるでしょう。

強いてmarimoがJupyter notebookに劣っている点を挙げるとするならば知名度が挙げられます。Jupyter notebookは大学の講義や研究機関でも広く採用されています。marimoはCLIやWeb UI上でJupyter notebookとの相互変換もサポートしていますが、今後どれだけ普及するかがカギになりそうです。

3. marimoのデプロイ戦略

今後、研究室内または社内で「marimoをデプロイしたい」というニーズが高まるかもしれません。

この章では、以下の公式ドキュメントを参考にmarimoのデプロイ方法を簡単に紹介します。

docs.marimo.io

代表的なデプロイ方法は以下の3種類です。

  1. edit server(編集可能なnotebook):marimo editコマンドで起動
  2. run server(read-onlyなWebアプリ):marimo runコマンドで起動
  3. programmatic(read-onlyなWebアプリ):FastAPIを利用してASGIサーバーとして起動

read-onlyなWebアプリを公開したい場合、基本的には2.の方法で十分だと考えました。ミドルウェアや認証などを含めたい場合は3.の方法でカスタマイズする必要がありそうです。

編集可能なnotebookのデプロイとしては、大きく以下の4種類が紹介されていました。

  1. With ssh-port forwarding, using marimo edit --headless.

  2. Via docker and our prebuilt containers.

  3. Via a deployment service such as Railway.

  4. Behind JupyterHub.

1.はSSH接続さえできればリモートからアクセスできるということで、例えばScience TokyoのTSUBAME4.0でもSSHポートフォワーディングを使えばJupyter notebook同様にmarimoを利用できることになります。

2.については研究室や会社でセルフサービス形式でデプロイする時に活用できそうです。Prebuiltコンテナイメージはありがたいですが、ベースイメージのPythonは固定されているので多様なニーズに応えるためには結局イメージをカスタムする必要があります。

4.については、おそらく新規というよりも既にJupyterHubを運用しているチーム向けです。jupyter-marimo-proxyというパッケージを利用してJupyter上でmarimoを起動できるようですが、公式がメンテナンスしているものではないので本番運用は難しいでしょう。

まとめ

本記事では、Jupyter notebookとの比較を通してmarimoのメリットや使用感を紹介しました。

執筆時点でmarimoの最新バージョンはv0.12.7ですが、v1.0に向けたロードマップも既に公開されています。

marimo.io

個人的にはほぼ全て達成されているように見えましたが、GitHubリポジトリマイルストーンを確認すると達成率は75%(執筆時点)とのことでした。

github.com

これからは1人のユーザーとしてmarimoを利用するとともに、何らかの形でmarimoに貢献していきたいです。

*1:JupyterLabでもjupyter-copilotというライブラリを導入することでGitHub Copilotを利用できるそうです

DockerからPodmanへの段階的移行(#12)

はじめに

#9ではPodmanというコンテナエンジンに入門しながら、rootlessコンテナの特徴について紹介しました。

alvinvin.hatenablog.jp

Podmanはセキュリティの観点から非常に優れている一方、知名度やメンテナンスコストの観点から研究室サーバーのPodman移行には消極的でした。

しかし最近、研究室サーバーでDockerからPodmanへの移行を後押しするようなインシデントが発生しました。本記事では、DockerからPodmanへの段階的移行とそれに向けた両者の共存戦略について紹介します。

1. rootlessコンテナの必要性

1-1. rootlessコンテナとは

rootlessコンテナは、非特権ユーザーがコンテナを作成、実行、管理できることを指します。

本記事ではrootlessコンテナについて解説を行いません。詳しく知りたい方は、#9や『Podman in Action』を参考にしてください。また、手前味噌ですが、LTでの発表資料も合わせて共有させていただきます。

speakerdeck.com

1-2. インシデントの概要と解決策

rootlessコンテナの概要が掴めたところで、今回研究室サーバーで発生してしまったインシデントを振り返ります。

端的に言うと、あるマシン上のイメージやコンテナがほとんど削除されてしまいました。事後調査によって、原因は非特権ユーザーがdocker system pruneコマンドを偶発的に実行してしまったことだと分かりました。

ここで強調したいのは、インシデントの責任が間違ってコマンドを実行してしまったユーザーではなく、最小権限の原則で設計できなかったインフラエンジニアにあるということです。

さらに今回は、誤って削除されたコンテナの中にバックアップの取れていないソースコード・データが含まれていました。研究室には、Dockerに初めて挑戦する学生や不慣れな学生も多いです。それにも関わらずバックアップに関するドキュメント整備を怠っていたのは私の責任でもあります。

以上のことから、2つの解決策を考えました。

  1. DockerからPodmanへの移行
  2. GitHubNASへのバックアップ方法のドキュメント化

1.についてはデフォルトでrootlessであるPodmanを導入することで、他のユーザーひいてはホストシステムに対するセキュリティレベルが大きく向上します。2.について、ソースコードGitHub*1、実験データはマウントしたホストのディレクトリやNASにそれぞれバックアップを取る方法を共有します。

これらにより、学生が研究のより本質的な部分に集中できるようになると考えています。

本記事では、1.のPodman移行にフォーカスして紹介します。

2. Podmanへの移行スケジュール

DockerからPodmanへの移行は段階的に行う必要があります。いくらCLI互換性があると言っても完全ではなく、既にDockerで進行中の研究も存在するためです。

移行スケジュールは下図の通りです。

段階的移行のスケジュール案

執筆時点からちょうど1年後の2026年1月を目標に、Dockerユーザーの大部分をPodmanに移行させます。

移行期間中はPodmanの利用が推奨されますが、Dockerも非推奨ながら利用可能です。この初期段階で、Podmanの利用方法やDockerにおけるリスクをドキュメント化します。

また、事前調査から何人かはDocker Composeユーザーであることが分かっているので、Podman Composeの互換性を検証するとともに移行ガイドを作成します。

2026年の3月には私が卒業見込みなので、Podman含むインフラ周りの引き継ぎを行い、移行が完全に終了したユーザーについてはAnsibleでdockerグループから削除を行います。

なお、移行期間中にPodmanで何らかの不都合が生じた場合は切り戻しも検討しています。

www.redhat.com

Podmanは昨年CNCFに寄贈されたため、今後さらに注目が集まることが予想されます。しかし、研究室運営をより長い目で見た時にDockerやその他のコンテナエンジンがより良い選択肢になっているかもしれません。

学生が安心して研究に取り組める環境を提供するために、認知負荷を最小限にしながらも時代に合わせて変化をサポートしていくことが重要だと考えました。

3. DockerとPodmanの共存戦略

移行段階でDockerとPodmanを共存させるためのポイントをいくつか紹介します。

3-1. Podmanのインストール

Podmanのインストール方法については、公式ドキュメント#9を参考にしてください。本記事で使用するバージョンは4.6.2です。

インストールが完了するとpodmanコマンドを利用できるようになります。Podmanを中心に利用したいけどdockerコマンドの方が馴染みがある場合はエイリアスを設定するのがオススメです。

$ echo "alias docker=podman" >> ~/.bashrc
$ source ~/.bashrc
$ docker
Manage pods, containers and images

Usage:
  podman [options] [command]
# 省略

dockerコマンドとpodmanコマンドはほぼCLI互換なので、以上で基本的なセットアップは完了です。

3-2. Podmanのストレージの設定

#1のDockerの場合と同様に、Podmanでもイメージやコンテナの増加によってプライマリディスクの容量が不足する可能性が高いです。よって、Podmanのストレージディレクトリを4TBのSSD上に変更します。

podman infoコマンドでPodmanのシステム情報のうち、ストレージに関する部分を表示してみます。

$ podman info -f json | jq -r '.store'
{
  "configFile": "/home/azuma/.config/containers/storage.conf",
  "containerStore": {
    "number": 4,
    "paused": 0,
    "running": 0,
    "stopped": 4
  },
  "graphDriverName": "overlay",
  "graphOptions": {},
  "graphRoot": "/home/azuma/.local/share/containers/storage",
  "graphRootAllocated": 502484135936,
  "graphRootUsed": 224025227264,
  "graphStatus": {
    "Backing Filesystem": "xfs",
    "Native Overlay Diff": "true",
    "Supports d_type": "true",
    "Using metacopy": "false"
  },
  "imageCopyTmpDir": "/var/tmp",
  "imageStore": {
    "number": 5
  },
  "runRoot": "/run/user/2401/containers",
  "volumePath": "/home/azuma/.local/share/containers/storage/volumes",
  "transientStore": false
}

例えばrootlessユーザーのストレージパスは/home/$USER/.local/share/containers/storageに設定されており、これらの設定を書き換えるには/home/$USER/.config/containers/storage.confに追記すれば良いことが分かります。

管理者がシステム全体に設定を適用したい場合はどうすれば良いでしょうか。答えはman 5 containers-storage.confコマンドあるいはmanページにあります。ストレージパスに関連する項目のみを簡単にまとめると以下の通りです。

  • graphroot(デフォルト:/var/lib/containers/storage

    コンテナストレージのメインディレクトリ。

  • rootless_storage_path(デフォルト:$XDG_DATA_HOME/containers/storageもしくは$HOME/.local/share/containers/storage

    rootlessユーザーの(コンテナの)ストレージパス。管理者が全ユーザーのストレージの場所を変更したい時に利用される。

  • imagestore(デフォルト:graphrootと同じ)

    イメージのストレージパス。

  • runroot(デフォルト:/run/containers/storage

    コンテナが生成する一時ファイルを保存するディレクトリ。rootlessユーザーはデフォルトで/run/user/2401/containers

よって、管理者がrootless_storage_pathSSD上の任意の(ただしDockerと重複しない)パスに変更すれば十分そうです。

ちなみにstorage.confの優先順位は低い方から/usr/containers//etc/containers/$HOME/.config/containers/$XDG_CONFIG_HOME/containers/XDG_CONFIG_HOMEがセットされている場合)です。

今回は元々/etc/containers/storage.confが存在しなかったので、[storage]の必須フィールドを指定しつつ以下のように作成しました。

[storage]

driver = "overlay"
runroot = "/run/containers/storage"
graphroot = "/var/lib/containers/storage"
rootless_storage_path = "/mnt/ssd4tb/$USER/containers/storage"

ここではマウントポイント(/mnt/ssd4tb/)の直下に各ユーザーのディレクトリが存在する前提でrootless_storage_pathを指定しました。ディレクトリを作成する際には、rootlessユーザーに権限を付与することを忘れないでください。

$ mkdir /mnt/ssd4tb
$ chown -R azuma /mnt/ssd4tb/azuma/
$ chgrp -R azuma /mnt/ssd4tb/azuma/

対象のフィールドを変更しない限りは設定を適用するためのpodman system resetは不要です。

This command must be run before changing any of the following fields in the containers.conf or storage.conf files: driver, static_dir, tmp_dir or volume_path. podman-system-reset — Podman documentation

rootlessユーザーに切り替えてPodmanのシステム情報を再度確認します。

$ podman info -f json | jq -r '.store'
{
  "configFile": "/home/azuma/.config/containers/storage.conf",
  "containerStore": {
    "number": 0,
    "paused": 0,
    "running": 0,
    "stopped": 0
  },
  "graphDriverName": "overlay",
  "graphOptions": {},
  "graphRoot": "/mnt/ssd4tb/azuma/containers/storage",
  "graphRootAllocated": 3936819662848,
  "graphRootUsed": 178059599872,
  "graphStatus": {
    "Backing Filesystem": "extfs",
    "Native Overlay Diff": "true",
    "Supports d_type": "true",
    "Using metacopy": "false"
  },
  "imageCopyTmpDir": "/var/tmp",
  "imageStore": {
    "number": 0
  },
  "runRoot": "/run/user/2401/containers",
  "volumePath": "/mnt/ssd4tb/azuma/containers/storage/volumes",
  "transientStore": false
}

無事にrootlessユーザーのgraphRootvolumePathSSD上のパスに変更されました。

3-3. Ansibleによる自動化

ユーザーやサーバーが追加される度に3-2.のオペレーションを行うのは面倒なので、以前#2で紹介したAnsibleで自動化します。

まず、ユーザー追加のPlaybookに以下のように追記します。

---
- name: Ensure groups are present
  ansible.builtin.group:
    name: "{{ item.name }}"
    gid: "{{ item.uid }}"
    state: "present"
  with_items: "{{ users }}"

- name: Ensure "docker" group is present
  ansible.builtin.group:
    name: "docker"
    state: "present"
    system: true

- name: Ensure "nas_access" group is present
  ansible.builtin.group:
    name: "nas_access"
    state: "present"
    system: true

- name: Ensure users are present
  ansible.builtin.user:
    name: "{{ item.name }}"
    uid: "{{ item.uid }}"
    password: "{{ item.password | password_hash('sha512') }}"
    update_password: "on_create"
    group: "{{ item.name }}"
    append: true
    groups:
      - "docker"
      - "nas_access"
    state: "present"
  with_items: "{{ users }}"

# =====以下を追記=====
- name: Ensure user directories exist on SSD
  ansible.builtin.file:
    path: "{{ ssd_path }}/{{ user.name }}"
    state: directory
    owner: "{{ user.name }}"
    group: "{{ user.name }}"
    mode: "755"

具体的には、ansible.builtin.fileモジュールでディレクトリを適切な権限で作成しています。SSDのマウントポイントはサーバーによって異なる可能性があるのでホスト変数として{{ ssd_path }}を利用しています。

dockerグループに全ユーザーを追加している既存の部分は分離して、これから徐々にグループからユーザーを削除していく予定です。

それから、各サーバーでstorage.confファイルを作成するPlaybookは以下の通りです。

---
- name: Check if the storage.conf file exists
  ansible.builtin.stat:
    path: "/etc/containers/storage.conf"
  register: storage_conf

- name: Create the storage.conf file
  ansible.builtin.blockinfile:
    path: "/etc/containers/storage.conf"
    block: |
      [storage]
      
      driver = "overlay"
      runroot = "/run/containers/storage"
      graphroot = "/var/lib/containers/storage"
      rootless_storage_path = "{{ ssd_path }}/$USER/containers/storage"
    create: true
    mode: "644"
  when: not storage_conf.stat.exists

ここでは、/etc/containers/storage.confファイルが存在しない場合にansible.builtin.blockinfileモジュールを使用して内容をファイルに書き込んでいます。

これらの変更をGitHubにpushしてジョブが完了したら、3-2.で設定したサーバー以外でもPodmanが正しく設定されていることを確認しましょう。

$ podman info | grep graphRoot
  graphRoot: /mnt/ssd4tb/azuma/containers/storage

$ podman run quay.io/podman/hello
Trying to pull quay.io/podman/hello:latest...
# 省略
!... Hello Podman World ...!

         .--"--.
       / -     - \
      / (O)   (O) \
   ~~~| -=(,Y,)=- |
    .---. /`  \   |~~
 ~/  o  o \~~~~.----. ~~
  | =(X)= |~  / (O (O) \
   ~~~~~~~  ~| =(Y_)=-  |
  ~~~~    ~~~|   U      |~~

# 省略

ちなみに、rootlessユーザーがイメージやコンテナを作成すればするほど、自身の{{ ssd_path }}/$USERディレクトリの容量が増加していく仕組みです。#3ではDockerオブジェクトにユーザーラベルを付与していましたが、Podmanでは各rootlessユーザーのオブジェクトが異なるストレージパスに保存されるので、ディスク使用量の把握も簡単になりそうです。

まとめ

本記事では、DockerからPodmanへの段階的移行とそれに向けた両者の共存戦略について紹介しました。

全員がPodmanを利用できる状況にはなりましたが、ドキュメントの整備やPodman Composeの検証はこれから進めていかなければなりません。

安心して研究に集中できる環境を提供するため、今後もフィードバックを得ながらResearchOps(造語)を継続していきたいです。

*1:私は普段、SSH Agent ForwardingしつつコンテナにSSH接続して、コンテナ内から定期的にGitHubにpushしています

Cloudflare Workers AIで翻訳モデルを実行(#11)

はじめに

ある日突然、あなたが翻訳APIを提供する立場になったとします。どのようなシステム設計を行いますか?

ゼロからモデルを訓練する人は少ないと思います。最も簡単なのは、DeepL APICloud Translation APIをそのまま利用してもらうか、それらをラップすることでしょうか。LLMを利用する場合は、プロンプトをカスタマイズして簡単にAPIを公開できるDifyのようなプラットフォームもあります。

私は翻訳APIを収益化しない用途*1で提供しているため、新たな選択肢としてCloudflare Workers AIに注目しています。本記事では、Cloudflare Workers AIで翻訳モデルを実行するための周辺知識と実際の手順を紹介します。

1. Cloudflare Workers AIとは

www.cloudflare.com

簡単に言うと、Cloudflareのエッジネットワーク上でモデルを実行できるAI推論用のプラットフォームです。

developers.cloudflare.com

Modelsからモデルの一覧を見ることができます。人気のあるオープンソースのモデルが多くサポートされていました。

Text GenerationとText-to-Imageのモデルが特に多い印象(執筆時点)

Workers AIの公式ドキュメントを一通り読んだので、ポイントをいくつか紹介します。

  • WorkersのFreeプランとPaidプランの両方に含まれており、モデルのタスク・サイズ・ユニットによって決まる
  • CLI / REST API / Dashboardの3通りの方法でデプロイできる
  • Text Generationには、ロール(役割)と内容を入力するScopedプロンプトと、単一の質問を入力すると自動的にScopedに変換するUnscopedプロンプトの2種類がある
  • LoRAファインチューニングやFunction callingもサポートしている
  • 既存のWorkerにWorkers AIをバインドできる
  • Text GenerationのPlaygroundで全てのモデルを実行できる

想像していたよりも多くのことがWorkers AIで実現できそうです。

Get startedでは、「Hello Worldの語源は?」をLLMのプロンプトに指定するようなアプリケーションを作成していました。

create-cloudflare-cli(C3)最高

"What is the origin of the phrase Hello, World"に対するレスポンス

無料枠内だと思いますが、Workers AIを利用するとローカル開発でも使用量が発生する可能性があるので注意してください。

2. HonoでWorkers入門

1章ではシンプルなTypeScriptを使用してWorkerを実行しました。一方で、最近話題のHonoというフレームワークもCloudflare Workers上で実行できるそうです。

hono.dev

せっかくなのでHonoに入門しつつ、翻訳APIのベースラインを作成します。

まずは公式ドキュメントの通り、僅か3ステップでローカル開発環境が整いました。

【比較】Hono(右)のContextオブジェクトが便利

ローカルサーバーで検証

本記事の目標は翻訳APIを提供することなので、DeepL APIのAPIリファレンスにあるリクエストパラメータを参考にしつつ以下のように実装しました(src/indext.ts)。

import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";

const app = new Hono();

const schema = z.object({
    text: z.array(z.string()).nonempty("Text is required"),
    source_lang: z
        .string()
        .optional()
        .default("EN")
        .refine(
            (lang) => !lang || /^[A-Z]{2}$/.test(lang),
            "Source language must be a valid language code or omitted"
        ),
    target_lang: z
        .string()
        .min(2, "Target language code is required")
        .regex(
            /^[A-Z]{2}(-[A-Z]{2,4})?$/,
            "Target language must be a valid language code"
        ),
});

app.post("/translate", zValidator("json", schema), (c) => {
    const data = c.req.valid("json");

    const translations = data.text.map((text) => {
        const translatedText = text.split("").reverse().join("");
        return {
            text: translatedText,
            detected_source_language: data.source_lang,
        };
    });
    return c.json({
        translations,
    });
});

export default app;

実際には翻訳は行わず、暫定的に反転させた文字列をレスポンスとしています。

また、Zod Validator Middlewareを利用してバリデーションを行いました。Zodには初めて触れましたが、非常に開発者体験が良いライブラリだと思いました。

Zodでのバリデーションに失敗するリクエストの例

"Hello, world!"(英語)をドイツ語に翻訳するリクエストの例

Honoには他にも色々なミドルウェアがあって楽しいので、今回は利用しませんが認証系を特にやってみたいです。

3. Workers AIで翻訳モデルを実行

さて、いよいよWorkers AIを利用して翻訳モデルを実行します。

wangler.tomlの以下の部分のコメントアウトを外して、yarn run wrangler typesで型を生成します。

[ai]
binding = "AI"
$ yarn run wrangler types
# 省略
 ⛅️ wrangler 3.99.0
-------------------

Generating project types...

interface Env {
        AI: Ai;
}

✨  Done in 2.07s.

$ yarn add @cloudflare/ai
# 省略

モデルとしては、執筆時点で唯一のTranslationモデルであるm2m100-1.2bを使用します。

developers.cloudflare.com

src/index.tsの2章との差分を以下に示します。

// 省略
+ import { Ai } from "@cloudflare/ai";

+ type Bindings = {
+     AI: any;
+ };

- const app = new Hono();
+ const app = new Hono<{ Bindings: Bindings }>();

// 省略

- app.post("/translate", zValidator("json", schema), (c) => {
+ app.post("/translate", zValidator("json", schema), async (c) => {
    const data = c.req.valid("json");
+     const ai = new Ai(c.env.AI);

-     const translations = data.text.map((text) => {
-         const translatedText = text.split("").reverse().join("");
-         return {
-             text: translatedText,
-             detected_source_language: detectedSourceLanguage,
-         };
-     });
+     const translations = await Promise.all(
+         data.text.map(async (text) => {
+             const response = await ai.run("@cf/meta/m2m100-1.2b", {
+                 text: text,
+                 source_lang: data.source_lang.toLowerCase(),
+                 target_lang: data.target_lang.toLowerCase(),
+             });
+             return {
+                 text: response.translated_text,
+                 detected_source_language: data.source_lang,
+             };
+         })
+     );
    return c.json({
        translations,
    });
});

export default app;

m2m100-1.2bのモデル出力としてはターゲット言語の翻訳テキストしか得られないので、DeepL APIのようにソース言語を検出することはできません。ソース言語の検出機能が必要な場合、Text Generationモデルに適切なプロンプトを入力する方法も考えられますが、レスポンスタイムや出力形式の懸念があるため一旦断念します。

"Hello, world!"(英語)を日本語に翻訳するリクエストの例

問題なく翻訳されたテキストがレスポンスとして返ってきました。

レスポンスタイムの計測の例

レスポンスタイムはおよそ2秒弱で、体感はDeepL APIと同じくらいでした。

最後にyarn deployで一瞬のうちにデプロイして完成です。

Cloudflareのダッシュボードにも表示される

4. AI Gatewayによるレート制限

3章でデプロイまで完了しましたが、ダッシュボードを確認すると着実にFreeプランの使用制限を蝕んでいることが分かります。

Workers AIはFreeプランで1日あたり最大10,000ニューロン

本記事では不特定多数の人に翻訳APIを提供する目的なので、ユーザーの悪意の有無に関わらずレート制限は設定するべきです。

2024年4月にGAになったAI Gatewayは、Workers AIの可視化(アナリティクス、ロギング)と制御(キャッシング、レート制限、リトライ・フォールバックなど)を可能にします。

developers.cloudflare.com

Workers BindingにおけるAI Gatewayのセットアップは上記の公式ドキュメントに記載されていますが、小さな落とし穴があったので簡単に紹介します。

まず、ダッシュボードからAI Gatewayを作成します。

検証用にfixed windowで1 req/minのレート制限を設定

次にsrc/index.tsを以下のように変更します。

// 省略
import { Ai } from '@cloudflare/workers-types'  // (1)

type Bindings = {
  AI: Ai  // (1)
}

const app = new Hono<{ Bindings: Bindings }>()

const schema = z.object({
  // 省略
})

app.post('/translate', zValidator('json', schema), async (c) => {
  const data = c.req.valid('json')

  let isRateLimited = false

  const translations = await Promise.all(
    data.text.map(async (text) => {
      try {
        const response = await c.env.AI.run(
          '@cf/meta/m2m100-1.2b',
          {
            text: text,
            source_lang: data.source_lang.toLowerCase(),
            target_lang: data.target_lang.toLowerCase(),
          },
          {
            gateway: {  // (2)
              id: 'translation-app',
            },
          }
        )
        return {
          text: response.translated_text,
          detected_source_language: data.source_lang,
        }
      } catch (error: unknown) {
        if (error instanceof Error) {
          if (error.name === 'AiError' && error.message === '2003: Rate limited') {
            isRateLimited = true  // (3)
          }
          return { error: error.message }
        }
        return { error: 'Unknown error' }
      }
    })
  )
  if (isRateLimited) {  // (3)
    return c.newResponse('Rate limited', { status: 429 })
  }
  return c.json({ translations })
})

export default app

ポイントは大きく分けて3つあります。

(1) My first Cloudflare Workers AIを参考に、3章ではtype BindingsAI: anyとした上で、@cloudflare/aiからimportしたAiconst ai = new Ai(c.env.AI)としていましたが、ai.run()の引数にGatewayOptionsを指定できなくなるので、@cloudflare/workers-typesからimportしています。

@cloudflare/ai/dist/sdk.d.tsの抜粋

export type AiOptions = {
    debug?: boolean;
    prefix?: string;
    extraHeaders?: object;
    overrideSettings?: object;
    fetchUrl?: string;
};
export declare class Ai {
    private binding;
    private options;
    private logs;
    lastRequestId: string;
    constructor(binding: any, options?: AiOptions);
    run<M extends ModelName>(model: M, inputs: ConstructorParametersForModel<M>): Promise<GetPostProcessedOutputsType<M>>;
    getLogs(): string[];
}

@cloudflare/workers-types/2023-07-01/index.tsの抜粋

export type AiOptions = {
  gateway?: GatewayOptions;
  prefix?: string;
  extraHeaders?: object;
};
export declare abstract class Ai<ModelList extends ModelListType = AiModels> {
  aiGatewayLogId: string | null;
  gateway(gatewayId: string): AiGateway;
  run<Name extends keyof ModelList>(
    model: Name,
    inputs: ModelList[Name]["inputs"],
    options?: AiOptions,
  ): Promise<ModelList[Name]["postProcessedOutputs"]>;
}
export type GatewayOptions = {
  id: string;
  cacheKey?: string;
  cacheTtl?: number;
  skipCache?: boolean;
  metadata?: Record<string, number | string | boolean | null | bigint>;
  collectLog?: boolean;
};

(2) gatewayidはCloudflareのダッシュボード上で確認できます。idのみ必須ですが、他にもskipCachecacheTtlなどを指定できます。

(3) Cloudflareのダッシュボード上で設定したレート制限を超えた場合、ステータスコード429'Rate limited'をレスポンスを返すようにしました(本当はエラーハンドリングにinstanceofを使いたかったですが、上手くいきませんでした)。

実際にローカルサーバーで検証してみます。

連続でリクエストを送信してレート制限を誘発する

タイムスタンプで理解するレート制限(fixed window)

レート制限とレスポンスキャッシュを適用することができましたが、Workers AIの使用制限は「ニューロン」で測定されます。

ニューロンは、AIの出力を測定する方法であり、常にゼロにスケールダウンする(使用量がゼロの場合、0ニューロン分の料金が課金される)。 1,000個のニューロンで何ができるかというと、130個のLLM応答、830個の画像分類、1,250個の埋め込みを生成できる。

Workers AI:Cloudflareのグローバルネットワーク上でサーバーレスGPUによる推論を実現より引用

ユーザーが1回のリクエストで長いテキストを入力する場合、大量のニューロンを消費する可能性が高いので、適度に文字列長(理想はトークン数)を制限しても良いと思いました。

まとめ

Cloudflare Workers AIで翻訳モデルを実行する方法を紹介しました。

私は今までCloudflareのドメイン管理とTunnelsしか利用していませんでしたが、Freeプランが充実していて開発者体験も最高なので今回もっと好きになりました。また、HonoとZodも今回初めて利用しましたがコンセプトがとても素敵なので今後も積極的に利用していきたいと思います。

P.S. 2024年最後の記事になりました。2025年もやっていき!

*1:改訂2版の公開が迫っているReactではじめるChrome拡張開発入門のハンズオン環境を提供するためです

VimからNeovimへの段階的移行(#10)

はじめに

VimmerVimからNeovimに移行したいと考えるきっかけは色々あると思います。

私は半年ほど前からVimを利用していますが、もっとシンタックスハイライトをよりカラフルにしたい」「カッコよくカーソル移動したい」といった欲望がありました。

nvim-treesitter/nvim-treesitter: Nvim Treesitter configurations and abstraction layerより引用

https://neovide.dev/assets/AnimatedCursor.gif
Features - Neovideより引用

一方で、それらを実現するプラグインがNeovimにしか対応していないことは少なくありません。

本記事では、VimからNeovimへの段階的移行とその魅力について紹介します。 移行方法の記事は既に多く存在していますが、あくまでも著者視点で楽しくお伝えできれば幸いです。

1. VimとNeovimの違い

多くの初心者が、「VimとNeovimって何が違うの?」という疑問を持っていると思います。

違いを説明しようと思いましたが、文章よりも断然分かりやすい動画を見つけてしまったので共有します。

youtu.be

Neovim公式ドキュメントにもいくつかFeaturesとして掲載されています。

個人的には、

  • 設定やプラグインLuaで書ける
  • LSPの組み込みサポート
  • モダンなターミナル機能(カーソルの形状やイベントフォーカス、bracketed-pasteなど)
  • ASTを生成する構文解析エンジン(Tree-sitter)による高速で正確なシンタックスハイライト
  • tmuxのようにUIをデタッチしてエディタセッションを実行し続けることが可能

などが印象的でした。

技術書典17で購入した『いまからはじめる Vim コマンド実践入門』でも、「カスタマイズしなくても使いやすい」という点でNeovimから使い始めることが推奨されていました。ただし、VimとNeovimそれぞれの優位性が挙げられていたので気になる方はぜひ読んでみてください。

私もVim初心者のときに真っさらな.vimrcからスタートして、快適にコーディングできるようになるまでかなりカスタマイズが必要でした。

Neoという接頭辞のイメージに引っ張られがちですが、NeovimがVim初心者にとって有力な選択肢となっているのは間違いなさそうです。

2. VimからNeovimへの移行

いきなりNeovimに完全移行するのはハードルが高いと感じるかもしれません。

今回は段階的移行を目指して、公式ドキュメントのTransitioning from Vimをやってみます。

$ brew install neovim
$ nvim

素のNeovimが起動する

次に、公式ドキュメント通りに「Neovimコマンドでinit.vimを作成」→「init.vimに内容追加」→「Neovimの再起動」を行います。

Neovimを使ってNeovimに移行しているのが面白いです。

適当なPythonファイルを編集した時の様子

Vimの設定をNeovimに引き継ぐことができました。私はvim-plugを利用していたので、Neovimを再起動してから:PlugInstallすることでエラーは全て消えました。

NeovimでもVimscriptで設定を書くことができます。~/.config/nvim/init.vimをよく見ると、Vimのruntimepathを指定したり、Vimの設定ファイルを実行したりしていることが分かります。

一旦は実家のような安心感を得ることができましたが、せっかくNeovimなのでLuaサポートの恩恵を受けたいですし、モダンなプラグインマネージャーにも変更したいです。

~/.config/nvim/init.luaを作成するにあたって、公式ドキュメントに以下のような記載があります。

Using Lua files on startup

Nvim supports using init.vim or init.lua as the configuration file, but not both at the same time. This should be placed in your config directory, which is typically ~/.config/nvim for Linux, BSD, or macOS, and ~/AppData/Local/nvim/ for Windows. Note that you can use Lua in init.vim and Vimscript in init.lua, which will be covered below.

init.viminit.luaの両方を同時には利用できませんが、init.luaでVimscriptを利用できるようです。

やや強引ですが、以下のようにinit.lua内でVimコマンドを実行することもできます。

$ mv ~/.config/nvim/init.vim ~/.config/nvim/_init.vim
$ echo "vim.cmd('source ~/.config/nvim/_init.vim')" > ~/.config/nvim/init.lua

ここから少しずつLuaの設定を増やしていく方向性もあり得るかもしれません。

私は結局、真っさらなNeovimから始めることにしました。理由は、Neovimで快適にコーディングできるようになるまでは使い慣れたVimでも十分だと思ったからです。

3. lazy.nvimのインストール

プラグインマネージャーとしてVimではvim-plugを利用していましたが、高速な起動とパワフルなUI、そして何よりNeovim専用のプラグインマネージャーであることに憧れていたのでlazy.nvimに移行します*1

github.com

lazy.nvimは公式ドキュメントのInstallationAdding Pluginsにしたがってインストールしました。

:Lazyコマンドで表示されるUIが綺麗

lazy.nvimをインストールするついでに、プラグインとしてdashboard-nvimダッシュボード)とmonokai-pro.nvim(カラースキーマ)を追加しました。テンションが上がるのでオススメです。

現時点でのディレクトリ構成は以下の通りです。

$ tree ~/.config/nvim
/Users/azuma/.config/nvim
├── init.lua
├── lazy-lock.json
└── lua
    ├── config
    │   └── lazy.lua
    └── plugins
        ├── dashboard.lua
        └── monokai-pro.lua

4 directories, 5 files

lua/plugins/*.luaプラグインが、lua/config/lazy.luaで以下のようにインポートされています。

-- Setup lazy.nvim
require("lazy").setup({
  spec = {
    -- import your plugins
    { import = "plugins" },
  },
  -- 省略
})

プラグインが大量にある場合は、ファイル分割のおかげで見通しが良くなりそうです。

公式ドキュメントにもありますが、specに{ "LazyVim/LazyVim", import = "lazyvim.plugins" }と追記すると、LazyVimというlazy.nvimのためのセットアップが手に入ります。

www.lazyvim.org

LazyVimを利用すると一瞬で本格的なIDEになりますが、プラグインを1つ1つ追加するワクワク感が無くなりそうだったのでやめました。

Neovim盆栽を楽しみます。

4. nvim-treesitterのインストール

冒頭でもお伝えしたように、Vimシンタックスハイライトが足りないと感じる場面があります。

Vim(左)とVS Code(右)で同じGoのファイルを開いたときの比較

私の.vimrcではsyntax enableシンタックスハイライトを有効化し、coc.nvimのためのGoのLSPであるcoc-goが詳細なシンタックスハイライトを提供してくれているはずです。それでもVSCodeにカラフルさで劣ってしまいます。

Neovimではnvim-treesitterというプラグインを利用できます。

github.com

そもそもTree-sitterはパーサー(構文解析器)を自動生成するツールであり、インクリメンタルな分析ができるC言語で書かれたライブラリです。nvim-treesitterはTree-sitterのインターフェースをNeovimで利用するためのシンプルで簡単な方法を提供してくれているそうです。

WikiのInstallation#lazy.nvimにしたがってインストールします。

このタイミングでLSP周りの設定も必要になりますが、以前利用していたcoc.nvimではなくNeovimのBuitin LSPを利用することにしました。慣れていない私には設定が少し複雑でしたが、以下の記事が基本的な内容から解説してくれていて非常に参考になりました。

zenn.dev

:LspInstall goplsでGoのLSPをインストールした後、シンタックスハイライト確認します。

NeovimでGoのファイルを開いたときの様子

NeovimでもVS Codeと同じくらいカラフルなシンタックスハイライトが付与されるようになりました。

5. Neovideのインストール

待ちに待ったカーソル移動アニメーションを実現する日が来ました!(知人に「それ要らなくない?」と言われました…これは浪漫です)

実はNeovideにはそれ以外にも本当に多くの機能があります。

neovide.dev

Smooth ScrollingとAnimated Windowsは特に便利そうです。

左がNeovim、右がNeovideです。

スムーズなスクロールはもちろんですが、もっと驚いたのは解像度です。Neovideではすごく鮮明にテキストが表示されています。

これからはalias vim=neovideとしたうえで、さらにカスタマイズも楽しんでいきたいです。

まとめ

本記事ではVimからNeovimに段階的に移行する方法を簡単に紹介し、いくつか印象的なプラグインGUIをインストールしてみました。

まだまだNeovimに入れるべきプラグインはたくさんありますが、盆栽がある程度落ち着いたらオススメのプラグインをまとめようと思います。

参考までに私のdotfilesリポジトリのURLも掲載しておきます。

github.com

*1:プラグインマネージャーの変遷については以下の記事が詳しいです。プラグインマネージャーの歴史と新世代のプラグインマネージャー dpp.vim