JupyterHub on Kubernetes: 如何打造 Tubi 数据科学平台

avatar
HR @Tubi

我们最早引入 Tubi Data Runtime (TDR) [1]的时候,它还只是个 Python 库,早期用户就喜欢上了它里面的特性。但是如果把它放到生产环境我们还有两个问题需要解决:1. 维护本地 Python 环境并不简单,即使是天天用 Python 的数据科学家们亦是如此;2. 本地工作流意味着我们无法保证组与组之间版本一致,无法分享链接给其他人,这使得有效协作异常困难。除非我们解决了上述痛点,否则不可能实现我们秉持的 Tubi 内部人人都是数据分析师的愿景。

概览

鉴于我们已经在 Jupyter notebook 上做了很多数据分析和模型建设工作,为了将 TDR 打造成为一个统一的可扩展平台,JupyterHub 是我们自然而然的选择。理论上看起来这很简单——用户拿到一个 JupyterHub URL,然后启动其专属 Jupyter 服务,从此就可以愉快地工作了。

图片

简化版 TDR 架构

但是该架构忽略了几个关键问题,比如:

  1. 如何认证授权?

  2. 如何存储和分享 notebook?

  3. 如何访问跨 VPC AWS 资源,比如 S3, Redshift 和 RDS 等等?

  4. 如何定制 JupyterHub 使得 TDR 核心特性和扩展也包含在内?

本文将详细介绍我们是如何解决这些问题的。另外,我们也将揭示 Tubi 在以下几个高级特性上的尝试:深度学习、节点亲和性和 Kubernetes 集群自动伸缩等。

===

基础特性

AWS (Amazon Web Services) 是 Tubi 创立至今一直在用的主要云服务提供商,所以为了部署 Kubernetes 应用,kops [2],kubectl [3] 和 helm[4]成为我们早期的和最佳的选择。JupyterHub 官方提供了 Helm chart [5],有了这个我们部署 JupyterHub 云原生应用变得相当简单。但是,仍然需要解决很多问题才能使它变成生产级的 Tubi 数据平台。

认证 / 单点登录

作为服务,认证是不可或缺的部分。Tubi 使用多年的单点登录服务(SSO [6])是 Okta [7]。它支持 OAuth 2.0[8]且有丰富的文档供开发参考,这样我们很容易就能配置和使用 OAuth。在 JupyterHub 的 values.yaml[9]文件中加上如下几行配置即可加上 Okta 单点登录。

auth:
  type: custom
  custom:
    className: oauthenticator.generic.GenericOAuthenticator
    config:
      login\_service: "Okta"
      client\_id: "{{ okta\_client\_id }}"
      client\_secret: "{{ okta\_client\_secret }}"
      token\_url: https://{{ tubi\_okta\_domain }}/oauth2/v1/token
      userdata\_url: https://{{ tubi\_okta\_domain }}/oauth2/v1/userinfo
      userdata\_method: GET
      userdata\_params: {'state': 'state'}
      username\_key: preferred\_username

Okta SSO 配置

现在有了 Okta SSO,我们只需点击 Okta 上的 TDR 图标就能登录到 TDR 页面了。

图片TDR on Okta

深度定制 Docker 镜像

JupyterHub 官方 Helm chart 里使用的单用户 [10]镜像是标准的 JupyterLab。为了把我们自己的 TDR 核心功能和 JupyterLab 扩展加入到单用户镜像中,我们需要编译自己的 Docker 镜像。我们使用 Alpine Linux [11]作为基础镜像,这使得我们的镜像相比官方镜像既小巧又兼容。相比现在的版本,早期的几个镜像版本用了 Ubuntu 且有更多的层(layer)。在将层数从 48 减到 21,并且把基础镜像切到 Alpine 之后,镜像大小减少了 50%。值得一提的一点是,Alpine 用了不同的包管理工具 apk [12],而大部分人可能更熟悉 apt / apt-get 或 yum。

共享存储

JupyterHub 允许用户随时登录登出,而且用户每次使用时分配的 Pod 都不同。这些 Pod 本身是无状态的,所以为用户存储和加载 notebook 变得很有必要,为此我们内部使用了 AWS EFS [13]作为中心存储。在每个用户的 Jupyter Pod 里,我们分别都挂载了两个目录:1)SSO 登录名同名的目录供个人使用;2)共享目录供所有人使用。在使用过程中,我们发现 AWS EFS 并非完美方案:

  1. 缺少版本管理。如 notebook 没有历史记录不能回滚。

  2. 缺少细粒度权限控制。如 notebook 不能被只读方式共享,所有人都有同样的读写权限。

上面提到每个 Pod 都有私人和共享两个挂载目录。根据官方指南 [14]里的例子,很容易就能挂载一块持久化卷 (PV [15])。而我们的情况是,同一块持久化卷必须被多次挂载,不同路径被挂载成不同的目录。GitHub 上的这个 thread [16]对这个问题有更详细的描述和讨论。经过一些尝试,我们找到了同一块持久化卷多次挂载同一 Pod 的方案 [17]:

singleuser:
  storage:
    homeMountPath: '/home/tubi/notebooks/{username}'
    type: "static"
    static:
      pvcName: "efs-persist"
      subPath: 'home/{username}'
    extraVolumeMounts:
      - name: home
        mountPath: /home/tubi/notebooks/shared
        subPath: shared/notebooks

存储挂载配置

上面配置中 extraVolumeMounts 是个数组,所以只要我们有需求我们可以挂载任意多个 EFS 目录到同一个 Pod 中。但请留意接下来的两点:1) 无需在存储中再次声明 extraVolumes [18],我们只需重复利用已经定义好的持久化卷;2) 已定义好的 extra volume 名称是 home [19],可以从 “kubectl describe pod/” 命令返回结果中的 Volumes 部分看到这样的信息。

Volumes:
  home:
    Type:        PersistentVolumeClaim (a reference to a PersistentVolumeClaim in the same namespace)
    ClaimName:   efs-persist
    ReadOnly:    false

Pod 描述中的 Volumes 部分

同一用户启动多个 Jupyter 服务

默认情况下,每个用户只启动一个默认单用户服务。但是有时候,我们可能需要同时启动多个单用户服务,这只需设置 “c.JupyterHub.allow_named_servers=True” 我们就可以同时管理默认服务和命名服务。需要注意的是,1) 多个页面打开同一服务时,后打开的页面会被跳转到自动生成的新的命名空间中;2) 当用户同时打开多个服务,分享链接时请保证其他人也有同样的命名服务,否则会遇到对方无法打开页面的尴尬。

图片

命名服务和默认服务

除了前面比较详细描述的一些特性外,我们还解决了这些问题:VPC 互连、跨 AWS 账号资源访问(如 Redshift,S3,RDS)以及常用内部服务的访问。这些都是因为 TDR 由 Tubi 主账号下的一个子账号管理,它部署在单独的 VPC 中,里面没有任何数据和服务。

到目前为止,TDR 的架构变得更加完善。

图片带上基本特性后的 TDR 架构

===

高级特性

深度学习

GPU

除之前描述的基本特性外,我们仍需要一些专有工具用于支持 Tubi 内部深度学习应用。编译 TensorFlow 和 PyTorch 比较复杂,最重要的是它使得镜像体积急剧增长,而对于大部分用户来说他们不需要这些库,所以我们为深度学习单独编译了一个镜像。这两个库的编译都依赖 CUDA[20]和 cuDNN [21],继而依赖 glibc [22]。因为 Alpine 用了不同的 libc 实现 musl [23],我们也不得不放弃它。

GPU 设备对于训练深度学习模型来说非常重要。如果 Kubernetes 集群准备使用 GPU 设备,我们需要依赖 NVIDIA 设备插件 [24]。如果你想了解设备插件是如何实现和工作的,特别是如何暴露 GPU 设备给 Pod 使用的,可以参考 Kubernetes 官方文档 [25]和该插件源码 [26]。该插件指南 [27]之前提到的镜像使用了旧版本 NVIDIA 驱动和 CUDA 库 (v9.1),但是最近的深度学习框架比如 TensorFlow (2.0+) 和 PyTorch (1.4+) 都要求 CUDA v10.0+。为此我们向 kops 社区略尽绵薄之力,把基于这个改动的设备插件镜像放在 Docker Hub [28] 供有缘人使用。至此,我们已经有两种 Docker 镜像——CPU 和 GPU,当用户登录 TDR 时,他们可以选择其中一个使用。

图片

登录选择页面

为了达到以上效果,还需增加一点额外配置。

singleuser:
  profileList:
    - display\_name: "Default"
      description: |
        Tubi Data Runtime
      default: True
      kubespawner\_override:
        image: <private-docker-registry>/tubi-data-runtime
        extra\_resource\_limits:
          nvidia.com/gpu: "0"
    - display\_name: "GPU"
      description: |
        Tubi Data Runtime with GPU Support. 1 GPU ONLY.
      kubespawner\_override:
        image: <private-docker-registry>/tubi-data-runtime-gpu
        extra\_resource\_limits:
          nvidia.com/gpu: "1"

登录选择配置

TensorBoard

开发深度学习模型中,我们遇到一个 TensorBoard 渲染问题:在 notebook cell 中执行完 “%tensorboard” 后页面毫无反应、空白一片。这里的原因是 TensorBoard 会在本地启动一个服务并且默认监听 6006 端口,但这个端口无法被 Jupyter 服务直接访问,如 https://tdr-domain:6006。幸运的是 Jupyter Server Proxy [29] 允许我们在本地运行任意进程,且通过这个链接访问到该进程——”https://tdr-domain/hub/user-redirect/proxy/{{proxy}}/”(注意:这个 URL 中最后的斜线是必须的,虽然官方文档 [30]并未给出但缺它不可)。我们的折中方案是打补丁:默认情况下,cell 命令 “%tensorboard” 会渲染 TensorBoard 到当前 cell 中,通过修改 tensorboard/notebook.py 中这几行代码 [31]即可达成。

diff --git a/tensorboard/notebook.py b/tensorboard/notebook.py
index fe0e13aa..ab774377 100644
--- a/tensorboard/notebook.py
+++ b/tensorboard/notebook.py
@@ -378,8 +378,17 @@ def \_display\_ipython(port, height, display\_handle):
      <script>
        (function() {
          const frame = document.getElementById(%JSON\_ID%);
-          const url = new URL("/", window.location);
-          url.port = %PORT%;
+          var baseUrl = "/";
+          try {
+            // parse baseUrl in JupyterLab
+            baseUrl = JSON.parse(document.getElementById('jupyter-config-data').text || '').baseUrl;
+          } catch {
+            try {
+              // parse baseUrl in classic Jupyter
+              baseUrl = $('body').data('baseUrl');
+            } catch {}
+          }
+          const url = new URL(baseUrl, window.location) + "proxy/%PORT%/";
          frame.src = url;
        })();
</script>

TensorBoard 补丁

下图展示了我们打完补丁后运行 TensorBoard 官方示例 [32]的效果。

图片

TensorBoard in TDR

节点亲和性

从前文中我们了解到 TDR 支持 CPU 和 GPU 版本的 Jupyter notebook 服务。在深度学习部分中,我们没有设置任何节点亲和性规则,这么做的后果是调度一团糟。CPU Pod 可以运行在 CPU 或 GPU 节点上,而 GPU Pod 只能运行在 GPU 节点上。我们来看一看 Tubi 内部一个典型且天天发生的应用场景。大多数用户倾向于用 CPU Pod,不经意间这其中有些 Pod 会被分配到 GPU 节点上,而此时新启一个 GPU Pod 可能会遇到资源不足的错误,因为 GPU 节点的资源已经被其他 CPU Pod 占用了。所以我们加入了 node_affinity_preferred[33]和 node_affinity_required (它们其实是 Kubernetes 中相应概念 [34]的别名)配置(我们使用的两个实例组分别叫 cpu 和 gpu)。

singleuser:
  profileList:
    - display\_name: "Default"
      description: |
        Tubi Data Runtime
      default: True
      kubespawner\_override:
        image: <private-docker-registry>/tubi-data-runtime
        node\_affinity\_preferred:
          - weight: 1
            preference:
              matchExpressions:
              - key: kops.k8s.io/instancegroup
                operator: NotIn
                values:
                - gpu
        extra\_resource\_limits:
          nvidia.com/gpu: "0"
    - display\_name: "GPU"
      description: |
        Tubi Data Runtime with GPU Support. 1 GPU ONLY.
      kubespawner\_override:
        image: <private-docker-registry>/tubi-data-runtime-gpu
        node\_affinity\_required:
        - matchExpressions:
          - key: kops.k8s.io/instancegroup
            operator: In
            values:
            - gpu
        extra\_resource\_limits:
          nvidia.com/gpu: "1"

节点亲和性配置

上面配置的节点亲和性表明 CPU Pod 倾向于分配在 CPU 节点上,而 GPU Pod 必须分配在 GPU 节点上。而且我们可以配置多条规则,以达到更精确的节点控制。

集群自动伸缩

在引入集群自动伸缩之前,我们经常遇到资源不足的警告,然后我们不得不加大 InstanceGroup[35]配置中 minSize 的值,重新部署,等待新的节点准备就绪。这种方法有两个缺陷:

  1. 花销变多。这些新增的节点往往来自于一段时间的临时性和爆发性用户请求,请求完成后这些节点绝大部分时间的利用率都非常低。

  2. 手动缩容可能删除和销毁正在运行的 Pod。一旦一个节点被选中要销毁,它上面运行着的 Pod 也会被强制销毁。

手动扩容缩容期间,我们的折中方案是在高峰请求来临之前提前准备好节点,而非高峰期间我们只能眼睁睁地看着机器闲置、资源浪费。

图片

Pod 使用模式

不少云服务提供商已经实现 [36]了 Kubernetes 集群自动伸缩功能,Tubi 的选择自然是 AWS [37] 版本,而且我们用了其中的自动发现设置[38]。为了在 AWS 成功配置集群自动伸缩,我们还做了几点工作:

  1. 所有节点加上自动伸缩所需的权限 [39]

  2. 所有节点新增两个 label,即 k8s.io/cluster-autoscaler/enabled 和 k8s.io/cluster-autoscaler/

  3. 修改 ssl certs[40]路径为 /etc/ssl/certs/ca-certificates.crt。这个问题在这 [41]有提及

加上集群自动伸缩和节点亲和性这两项修改后,我们的机器开销减少了 50%。而且,前面我们用 Alpine 编译的小镜像在这也得到了回报——当触发集群扩容时,一个节点从启动到就绪到拉取完镜像只需 2 分钟。

监控

Prometheus[42]是 Kubernetes 生态事实上的监控解决方案。用 kube-prometheus [43]库我们很快就给 TDR 部署了一套监控系统。但是出于安全考虑,这里包含的服务,如 Dashboard [44], Grafana [45], Prometheus[46]和 Alert Manager [47],仅限于公司内部 IP 地址访问。Grafana 可以使用 Prometheus 数据源,里面自带许多 Namespace, Node 和 Pod 相关的 dashboard。另外我们也有一个专门监控 JupyterHub 内 Pod 和 Node 相关的 TDR dashboard。

图片

TDR 监控(图中能看到多少 Pod 正在运行,什么时候启动的,运行了多久等信息)

至此,在加入了基本特性和高级特性之后,我们的 TDR 架构变得越来越清晰、越来越丰富。

图片

TDR 架构

===

小结

TDR 只是我们建设公司内部统一数据平台万里长征的第一步。未来,我们计划解决一些目前存在的比较大的问题,比如细粒度权限控制、任务调度、可复现性、模型调优,甚至模型服务。因为我们希望非技术专家也能使用这个平台,所以为他们打造易用和无缝的用户体验也会是我们的工作重点之一。

在 Tubi 工程团队中,我们总是在寻找并解决富有挑战的问题,我们提倡主动思考,重视对公司影响深远的想法和方案。如果你也对建设数据平台或云原生应用感兴趣的话,来加入我们吧!

图片

作者:Huihua Zhang, Tubi Data Engineer

点击 “阅读原文” 查看英文版

图片