JupyterHub on K8S

JupyterHub on K8S

一 前记

当前,Jupyter已经成为数据科学和机器学习最知名也是使用最广泛的开源方案。我们在各种各样的数据科学和机器学习平台中看到它的影子,随便举几个例子。这些平台大多都是基于Jupyter搭建或者二次开发的。

Machine Learning Models & Algorithms | Amazon SageMaker on AWSaws.amazon.com图标Google CoLabcolab.research.google.com
Baidu AI studioaistudio.baidu.com

Jupyter本身包含很多组件。对于个人用户,使用JupyterLab + Notebook就足够了。但是如果把Jupyter当成一个公司级的平台来看待的话就远远不够了。这时候需要考虑的事情就比较多了,比如多用户、资源分配、数据持久化、数据隔离、高可用、权限控制等等。而这些问题恰恰是K8S、YARN、Mesos这一类平台的特长。因此把Jupyter和K8S结合起来使用就非常顺理成章。

*参考资料:https://zero-to-jupyterhub.readthedocs.io/en/stable/index.html

JupyterHub 介绍

JupyterHub是一个多用户的Jupyter门户,在设计之初就把多用户创建、资源分配、数据持久化等功能做成了插件模式。其工作机制如下图所示。一个最简单的JupyterHub的搭建过程可以参考:

陈震:JupyterHub+Anaconda单机部署zhuanlan.zhihu.com图标



既然JupyterHub是个框架,因此出现了各种各样的插件。比如可以单机部署利用OS用户实现多用户和数据隔离;也可以使用OAuth完成用户鉴权等。当然,将整个JupyterHub和K8S结合起来,是最完美的姿势。

三 Kubernetes/K8S 搭建

K8S是什么就不在这里叙述了。这里记录一下搭建K8S环境的过程。K8S本身非常复杂,设计到大量的优化和运维工作。如果有成熟可用的K8S集群的话,建议直接使用。

如果你现在对K8S以及相关工具链还不熟悉, 这里是你需要知道的:

jupyterhub/zero-to-jupyterhub-k8sgithub.com图标

如果自己搭建K8S集群,参考这里:

陈震:K8S集群搭建zhuanlan.zhihu.com图标

四 HELM 安装

HELM是K8S上的包管理器,类似Ubuntu的Apt-get或者Python阵营的pip工具。通过HELM可以比较方便的安装K8S中的各种组件和第三方系统。HELM中有几个重要的概念:

HELM安装过程相对简单。按照以下流程执行即可。

# 下载并安装
curl https://raw.githubusercontent.com/kubernetes/helm/master/scripts/get | bash
# 为helm创建k8s用户
kubectl --namespace kube-system create serviceaccount tiller
# 给用户admin权限
kubectl create clusterrolebinding tiller --clusterrole cluster-admin --serviceaccount=kube-system:tiller
# 初始化helm
helm init --service-account tiller

但是,HELM也需要从外网下载各种Docker镜像。这里仍然需要解决GWF的问题。方法同K8S的安装:

陈震:K8S集群搭建zhuanlan.zhihu.com图标

安装完成后,执行以下命令,同时出现Client和Server的信息,表示安装成功

helm version
Client: &version.Version{SemVer:"v2.11.0", GitCommit:"2e55dbe1fdb5fdb96b75ff144a339489417b146b", GitTreeState:"clean"}
Server: &version.Version{SemVer:"v2.11.0", GitCommit:"2e55dbe1fdb5fdb96b75ff144a339489417b146b", GitTreeState:"clean"}
*参考资料:https://zero-to-jupyterhub.readthedocs.io/en/stable/setup-helm.html

五 存储/PV 配置

K8S有一整套的存储机制。几个基本概念如下。K8S支持多种存储方案,比如local、NFS、CephFS等等,目前的支持

Storage Classeskubernetes.io图标

  • PV: A PersistentVolume (PV) is the actual volume where the user’s data resides. It is created by Kubernetes using details in a PVC.
  • PVC: A PersistentVolumeClaim (PVC) specifies what kind of storage is required. Its configuration is specified in your config.yaml file.
  • StorageClass: A StorageClass object is used to determine what kind of PersistentVolumes are provisioned for your users. Most popular cloud providers have a StorageClass marked as default

简单理解上面的概念,StorageClass定义了存储的种类和方式。PVC是POD在申请使用存储时发出的一次申请请求,请求中指明了以哪种StorageClass方式来分配存储资源。PV是申请成功后,真实在使用的存储资源。

通过我们实际例子更容易理解。我们这里使用了NFS作为存储方式。NFS服务的搭建过程网上比较多,这里不赘述了。

# 查看当前存在的storageclass,这里定义了storageclass的名字和provisioner(存储供应方,稍后解释)
$ kubectl get storageclass
NAME                            PROVISIONER        AGE
managed-nfs-storage (default)   jhub.prophet/nfs   7d1h


# 查看当前存在的PVC,这里表明发生了两次存储请求,均使用上面的storageclass定义的方式来申请。两次申请的大小不一样,分别是1G和10G
$ kubectl get pvc
NAME         STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS          AGE
claim-1      Bound    pvc-428af1a4-ec79-11e8-836e-fa163ea46448   10Gi       RWO            managed-nfs-storage   8m45s
hub-db-dir   Bound    pvc-eb9db9e5-ec73-11e8-836e-fa163ea46448   1Gi        RWO            managed-nfs-storage   46m


# 上面的两次申请分别对应了两个实际的PV。PV的名字和PVC中Volume的名字一致。这里还可以看到这两个PV的读写属性和回收策略等
$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM             STORAGECLASS          REASON   AGE
pvc-428af1a4-ec79-11e8-836e-fa163ea46448   10Gi       RWO            Delete           Bound    jhub/claim-1      managed-nfs-storage            8m43s
pvc-eb9db9e5-ec73-11e8-836e-fa163ea46448   1Gi        RWO            Delete           Bound    jhub/hub-db-dir   managed-nfs-storage            42m

上面有个概念Provisioner没有体现。下面以创建的实际过程为例说明。参考文档:

kubernetes-incubator/external-storagegithub.com图标

第一步:从Github下载对应的配置文件

$ git clone https://github.com/kubernetes-incubator/external-storage.git
$ cd external-storage/nfs-client/

第二步:创建RBAC权限控制

$ NS=$(kubectl config get-contexts|grep -e "^\*" |awk '{print $5}')
$ NAMESPACE=${NS:-default}
$ sed -i'' "s/namespace:.*/namespace: $NAMESPACE/g" ./deploy/rbac.yaml
$ kubectl create -f deploy/rbac.yaml

第三步:创建NFS provisioner。也就是创建一个Deployment,充当NFS存储的供应方。修改deploy/deployment.yaml文件,将下方尖括号内的值修改为NFS的实际信息,PROVISIONER_NAME修改为自定义的值。

kind: Deployment
apiVersion: extensions/v1beta1
metadata:
  name: nfs-client-provisioner
spec:
  replicas: 1
  strategy:
    type: Recreate
  template:
    metadata:
      labels:
        app: nfs-client-provisioner
    spec:
      serviceAccountName: nfs-client-provisioner
      containers:
        - name: nfs-client-provisioner
          image: quay.io/external_storage/nfs-client-provisioner:latest
          volumeMounts:
            - name: nfs-client-root
              mountPath: /persistentvolumes
          env:
            - name: PROVISIONER_NAME
              value: <PROVISIONER NAME AS YOU WANT>
            - name: NFS_SERVER
              value: <YOUR NFS SERVER HOSTNAME>
            - name: NFS_PATH
              value: <NFS EXPORT PATH>
      volumes:
        - name: nfs-client-root
          nfs:
            server: <YOUR NFS SERVER HOSTNAME>
            path: <NFS EXPORT PATH>

第四步:创建StorageClass。修改deploy/class.yaml文件,修改Provisioner名字为上面刚定义的。

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: managed-nfs-storage
provisioner: <PROVISIONER NAME DEFINED ABOVE>
parameters:
  archiveOnDelete: "false"

第五步:测试一下。编辑deploy/test-claim.yaml,修改成对应的storageclass名字。同时定义pvc名字为test-claim

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: test-claim
  annotations:
    volume.beta.kubernetes.io/storage-class: "managed-nfs-storage"
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 1Gi

编辑deploy/test-pod.yaml,修改claimName为上面定义的test-claim

kind: Pod
apiVersion: v1
metadata:
  name: test-pod
spec:
  containers:
  - name: test-pod-cz
    image: gcr.io/google_containers/busybox:1.24
    command:
      - "/bin/sh"
    args:
      - "-c"
      - "touch /mnt/SUCCESS && exit 0 || exit 1"
    volumeMounts:
      - name: nfs-pvc
        mountPath: "/mnt"
  restartPolicy: "Never"
  volumes:
    - name: nfs-pvc
      persistentVolumeClaim:
        claimName: test-claim

部署测试,通过刚开始的方式查看StorageClass、PVC、PV的状态看是否成功。同时查看NFS服务器上是否创建了对应的目录和文件。

$ kubectl create -f deploy/test-claim.yaml -f deploy/test-pod.yaml
$ kubectl get storageclass
NAME                            PROVISIONER        AGE
managed-nfs-storage (default)   jhub.prophet/nfs   7d1h
$ kubectl get pvc
NAME         STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS          AGE
test-claim   Bound    pvc-ef2797fd-ec7e-11e8-836e-fa163ea46448   1Gi        RWX            managed-nfs-storage   16s
$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM             STORAGECLASS          REASON   AGE
pvc-ef2797fd-ec7e-11e8-836e-fa163ea46448   1Gi        RWX            Delete           Bound    jhub/test-claim   managed-nfs-storage            3s
$ ls -ltr
drwxrwxrwx 2 root root   6 11月 20 12:44 jhub-test-claim-pvc-ef2797fd-ec7e-11e8-836e-fa163ea46448


# 删除测试数据
$ kubectl delete -f deploy/test-pod.yaml -f deploy/test-claim.yaml

六 JupyterHub 安装

做了大量的准备工作之后,正式开始JupyterHub on K8S的安装。主要流程参考:

Zero to JupyterHub with Kubernetesz2jh.jupyter.org
# 生成一个随机字符串作为JupyterHub的安全Token
$ openssl rand -hex 32
# 新建一个config.yaml文件,如下配置
$ vi config.yaml


singleuser:
  storage:
    dynamic:
      storageClass: managed-nfs-storage
  defaultUrl: "/lab"
hub:
  extraConfig: |-
    c.Spawner.cmd = ['jupyter-labhub']
proxy:
  secretToken: "a967cc8fe2d26e6e1b10630728af1c7619afecbb65be5b875ec778c2dd7aa52b"

需要注意的几点:

  • 指定storageclass为之前定义的storageclass的名字
  • 如果希望默认使用JupyterLab,修改 c.Spawner.cmd = ['jupyter-labhub']
  • secretToken填写上面生成的随机字符串

依次执行以下命令,通过HELM安装JupyterHub,记得通过--value指定上面的配置文件。关于版本号这里有可能引起误解,0.7.0指的是jupyterhub HLEM chart的版本号而不是JupyterHub本身的版本号。

$ helm repo add jupyterhub https://jupyterhub.github.io/helm-chart/
$ helm repo update
Hang tight while we grab the latest from your chart repositories...
...Skip local chart repository
...Successfully got an update from the "stable" chart repository
...Successfully got an update from the "jupyterhub" chart repository
Update Complete. ⎈ Happy Helming!⎈
# Suggested values: advanced users of Kubernetes and Helm should feel
# free to use different values.
$ RELEASE=jhub
$ NAMESPACE=jhub

$ helm upgrade --install $RELEASE jupyterhub/jupyterhub \
  --namespace $NAMESPACE  \
  --version 0.7.0 \
  --values config.yaml

安装完成后,会在集群中创建一个jhub的namespace并出现2个pod和3个service。两个pod分别是jupyterhub和configure-proxy两个“进程”。同时还有一个proxy-public服务作为负载均衡服务出现。

$ kubectl get namespace
NAME          STATUS   AGE
default       Active   10d
jhub          Active   155m
kube-public   Active   10d
kube-system   Active   10d


$ kubectl get pods -n jhub
NAME                                                              READY   STATUS             RESTARTS   AGE
hub-6648f5bcb9-bh44x                                              1/1     Running            0          155m
proxy-5f648bd7d9-vcn4k                                            1/1     Running            0          155m


$ kubectl get service -n jhub
NAME                                                TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)                      AGE
hub                                                 ClusterIP      10.107.36.55     <none>        8081/TCP                     155m
proxy-api                                           ClusterIP      10.103.247.46    <none>        8001/TCP                     155m
proxy-public                                        LoadBalancer   10.110.141.198   <pending>     80:30188/TCP                 155m

这里很有可能出现的问题是proxy-public服务。这个服务的集群IP是10.110.141.198,监听端口是80,然而该服务没有正确的获取到External-IP,因此整个服务在集群外是无法访问的。为什么没有获得External-IP可能跟集群的配置有关,一种解决办法是做一个“外部-内部“的代理转发。而这个机制正是K8S的Ingress机制。

七 Ingress-Nginx安装配置

由于我对网络的东西不是特别懂,这块花了很长时间来调试。以下关于技术的描述可能不准确。

Ingress是什么,请参考以下两个链接。这里不细说。大体上就两个概念,Ingress和Ingress-Controller,两者配合起来实现代理、负载均衡、内外网转发等能力。Nginx Ingress是Ingress的一种实现,除此之外还有F5,HAProxy,Kong,Traefik等多种实现方式。我们这里使用Nginx Ingress模式。

Ingresskubernetes.io图标Kubernetes Nginx Ingress 教程mritd.me图标


通过HELM安装。安装非常简单,直接执行helm install stable/nginx-ingress即可。但是如果要起到实际的作用,需要做一些配置修改。Nginx-Ingress Chart对应的Github地址在这里:

helm/chartsgithub.com图标

其中有所有支持的配置项。Github上还提供了一个全量的配置文件示例values.yaml文件。打开这个文件作两处修改:

hostNetwork: true
rbac:
  create: true

hostNetwork表示Ingress-Contoller使用Host机器本机的网络,也可以指定externalIPS实现相同的效果。这个配置文件非常复杂,不过基本上是符合Nginx的配置的。保存后执行安装操作,指定values.yaml文件作为配置输入。执行成功后会看到一系列的输出信息。另外查看pod列表,会看到部署了两个pod,分别是nginx-ingress-controller和nginx-ingress-default-backend。其中后者作为一个默认后端,其实什么都不干,主要在非法请求时作为默认转发,返回错误信息。查看service列表,虽然External-IP这里仍然是pending状态,但是实际上已经绑定到本机IP上了。可以通过netstat -an | grep 80 确认下。

$ helm install stable/nginx-ingress -f values.yaml
NAME:   wandering-albatross
LAST DEPLOYED: Tue Nov 20 11:55:47 2018
NAMESPACE: jhub
STATUS: DEPLOYED

RESOURCES:
==> v1/Service
NAME                                               AGE
wandering-albatross-nginx-ingress-controller       0s
wandering-albatross-nginx-ingress-default-backend  0s
...
NOTES:
The nginx-ingress controller has been installed.
It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status by running 'kubectl --namespace jhub get services -o wide -w wandering-albatross-nginx-ingress-controller'
...
apiVersion: extensions/v1beta1
  spec:
    rules:
      - host: www.example.com
        http:
          paths:
            - backend:
                serviceName: exampleService
                servicePort: 80
              path: /
    # This section is only required if TLS is to be enabled for the Ingress
    tls:
        - hosts:
            - www.example.com
          secretName: example-tls

If TLS is enabled for the Ingress, a Secret containing the certificate and key must also be provided:
...
$ kubectl get pods -n jhub
NAME                                                              READY   STATUS             RESTARTS   AGE
hub-6648f5bcb9-bh44x                                              1/1     Running            0          177m
jupyter-1                                                         1/1     Running            0          139m
nfs-client-provisioner-76b6bb84f8-hfhm9                           1/1     Running            0          174m
proxy-5f648bd7d9-vcn4k                                            1/1     Running            0          177m
wandering-albatross-nginx-ingress-controller-58dcd9f588-77fnz     1/1     Running            0          147m
wandering-albatross-nginx-ingress-default-backend-6d466d74kl6cp   1/1     Running            0          147m
$ kubectl get service -n jhub
NAME                                                TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)                      AGE
hub                                                 ClusterIP      10.107.36.55     <none>        8081/TCP                     3h2m
proxy-api                                           ClusterIP      10.103.247.46    <none>        8001/TCP                     3h2m
proxy-public                                        LoadBalancer   10.110.141.198   <pending>     80:30188/TCP                 3h2m
wandering-albatross-nginx-ingress-controller        LoadBalancer   10.102.97.119    <pending>     80:31185/TCP,443:32158/TCP   151m
wandering-albatross-nginx-ingress-default-backend   ClusterIP      10.100.40.203    <none>        80/TCP                       151m

光有Controller还不够,还需要部署一个Ingress实例。这里我不是特别明白其中的原理,所以花了很长时间来调试。其实上面部署成功后的打印信息写的很清楚,但是被我给忽略了...

新建一个ingress.yaml文件,按照以下方式编辑。其中name自己定义。namespace同jupyterhub所在一致。转发规则backend直接填写proxy-public服务名字。端口填写80。指定该配置文件执行部署操作。查看Ingress列表发现已经出现了一个Ingress示例。但是ADDRESS这里仍然是空的,还没有搞太懂...不过这时候访问Hosts或者域名,应该可以看到JupyterHub的界面了!

太不容易了! \(≧▽≦)/激动

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  annotations:
    kubernetes.io/ingress.class: nginx
  name: jhub-ingress
  namespace: jhub
spec:
  rules:
    - host: <YOUR HOSTNAME or DOMAIN NAME>
      http:
        paths:
          - backend:
              serviceName: proxy-public
              servicePort: 80
            path: /


$ kubectl create -f ingress.yaml
$ kubectl get ingress
NAME           HOSTS                         ADDRESS   PORTS   AGE
jhub-ingress   XXXXXXXXXXXX.XXXX.XXXX.XXXX             80      153m

八 高级配置

接下来会初步解决以下问题。

用户授权

资源限制

资源回收

自定义镜像

Anaconda 用户自定义环境

GPU支持

CephFS支持

编辑于 2018-11-20

文章被以下专栏收录