优化#

此页面包含有关提高 JupyterHub 部署的可靠性、灵活性和稳定性的信息和指南。许多描述的设置仅对更好的自动扩展体验有意义。

总而言之,为了获得良好的自动扩展体验,我们建议您

  • 启用持续镜像拉取器,为到达的用户准备额外的节点。

  • 启用Pod 优先级并添加用户占位符,以便在实际用户到达之前扩展节点。

  • 启用用户调度器,将用户紧密地打包在某些节点上,并让其他节点变为空并缩减。

  • 设置一个自动扩展节点池,并通过污染节点并要求容忍节点污染的用户 Pod 在这些节点上调度,将其专门用于用户 Pod。这样,只有用户 Pod 才能阻止缩减。

  • 设置适当的用户资源请求限制,以允许合理数量的用户共享一个节点。

一个合理的最终配置,用于高效的自动扩展,可能看起来像这样

scheduling:
  userScheduler:
    enabled: true
  podPriority:
    enabled: true
  userPlaceholder:
    enabled: true
    replicas: 4
  userPods:
    nodeAffinity:
      matchNodePurpose: require

cull:
  enabled: true
  timeout: 3600
  every: 300

# The resources requested is very important to consider in
# relation to your machine type. If you have a n1-highmem-4 node
# on Google Cloud for example you get 4 cores and 26 GB of
# memory. With the configuration below you would  be able to have
# at most about 50 users per node. This can be reasonable, but it
# may not be, it will depend on your users. Are they mostly
# writing and reading or are they mostly executing code?
singleuser:
  cpu:
    limit: 4
    guarantee: 0.05
  memory:
    limit: 4G
    guarantee: 512M

在用户到达之前拉取镜像#

如果用户 Pod 被调度到一个请求未在该节点上拉取的 Docker 镜像的节点上,用户将不得不等待。如果镜像很大,等待时间可能需要 5 到 10 分钟。这通常发生在两种情况下

  1. 引入了新的单用户镜像(helm upgrade

    启用hook-image-puller(默认情况下),在 Hub Pod 更新以使用新镜像之前,将引入的用户镜像拉取到节点。hook-image-puller 是一个技术名称,指的是如何使用 Helm hook 来实现这一点,一个更具信息性的名称应该是pre-upgrade-image-puller

    注意:启用此功能后,如果您引入了新的镜像,您的 helm upgrade 将花费很长时间,因为它将等待拉取完成。我们建议您在您的 helm upgrade 命令中添加 --timeout 10m0s 或类似内容,以使其有足够的时间。

    hook-image-puller 默认情况下处于启用状态。要禁用它,请在您的 config.yaml 中使用以下代码段

    prePuller:
      hook:
        enabled: false
    
  2. 添加节点(集群自动扩展器)

    Kubernetes 集群中的节点数量可能会增加,无论是通过手动扩展集群大小还是通过集群自动扩展器。由于新的节点将是全新的,其磁盘上没有任何镜像,到达该节点的用户 Pod 将被迫等待镜像拉取。

    启用continuous-image-puller(默认情况下启用),用户容器镜像将在添加新节点时被拉取。例如,新的节点可以手动添加或通过集群自动扩展器添加。continuous-image-puller 使用 daemonset 强制 Kubernetes 在节点存在后立即在所有节点上拉取用户镜像。

    默认情况下,连续镜像拉取器已启用。要禁用它,请在您的 config.yaml 中使用以下代码段

    prePuller:
      continuous:
        # NOTE: if used with a Cluster Autoscaler, also add user-placeholders
        enabled: false
    

    重要的是要意识到,如果连续镜像拉取器与集群自动伸缩器 (CA) 结合使用,并不能保证缩短用户的等待时间。它只有在 CA 在真实用户到达之前进行扩展时才有效,但 CA 通常无法做到这一点。这是因为它只有在当前节点上无法容纳一个或多个 Pod,但如果添加一个节点就可以容纳更多 Pod 时,才会添加一个节点,但在那时,用户已经在等待了。为了提前扩展节点,我们可以使用 用户占位符

将要拉取的镜像#

钩子镜像拉取器和连续镜像拉取器有各种来源影响它们将拉取哪些镜像,因为它为了提前准备可能需要镜像的节点而这样做。这些来源都可以在 Helm 图表提供的值中找到(可以通过 config.yaml 覆盖),位于以下路径下

相关镜像来源#

  • singleuser.image

  • singleuser.profileList[].kubespawner_override.image

  • singleuser.extraContainers[].image

  • prePuller.extraImages.someName

其他来源#

  • singleuser.networkTools.image

  • prePuller.pause.image

例如,使用以下配置,镜像拉取器将拉取三个镜像,以准备最终可能使用这些镜像的节点。

singleuser:
  image:
    name: jupyter/minimal-notebook
    tag: 2343e33dec46
  profileList:
    - display_name: "Minimal environment"
      description: "To avoid too much bells and whistles: Python."
      default: true
    - display_name: "Datascience environment"
      description: "If you want the additional bells and whistles: Python, R, and Julia."
      kubespawner_override:
        image: jupyter/datascience-notebook:2343e33dec46

prePuller:
  extraImages:
    my-other-image-i-want-pulled:
      name: jupyter/all-spark-notebook
      tag: 2343e33dec46

高效的集群自动伸缩#

一个 集群自动伸缩器 (CA) 将帮助您向集群添加和删除节点。但 CA 需要一些帮助才能正常运行。没有帮助,它既无法在用户到达之前进行扩展,也无法在不影响用户的情况下足够积极地缩减节点。

及时扩展(用户占位符)#

一个 集群自动伸缩器 (CA) 将在 Pod 无法容纳在可用节点上,但如果添加另一个节点就可以容纳时添加节点。但是,这可能会导致 Pod 等待时间过长,由于 Pod 可以代表用户,因此会导致用户等待时间过长。现在有一些选项可以解决这个问题。

使用 Kubernetes 1.11+(需要 Helm 2.11+),引入了 Pod 优先级和抢占。这允许具有更高优先级的 Pod 抢占/驱逐具有较低优先级的 Pod,如果这样做有助于更高优先级的 Pod 容纳在节点上。

这种优先级机制允许我们添加虚拟用户或 用户占位符,这些用户占位符具有较低的优先级,可以占用资源,直到具有(更高优先级)的真实用户需要它。此时,较低优先级的 Pod 将被抢占以腾出空间供高优先级 Pod 使用。现在被驱逐的用户占位符将能够向 CA 发出信号,表明它需要进行扩展。

用户占位符将具有与 Helm 图表在 singleuser.cpusingleuser.memory 下配置的相同的资源请求/限制。这意味着,如果您有三个用户占位符正在运行,真实用户只需要在 超过三个用户在小于使节点准备好使用的间隔时间内到达 时等待扩展,假设这些用户没有以 singleuser.profileList 中指定的调整后的资源请求生成。

例如,要使用三个用户占位符,这些占位符可以借助 Pod 优先级完成其工作,请添加以下配置

scheduling:
  podPriority:
    enabled: true
  userPlaceholder:
    # Specify three dummy user pods will be used as placeholders
    replicas: 3

有关用户占位符的进一步讨论,请参阅 @MinRK 的精彩帖子,他在其中分析了其在 mybinder.org 上的引入。

重要

根据您集群自动伸缩器的配置方式,可能需要进行进一步设置才能成功使用 Pod 优先级。已知此方法在 GKE 上有效,但我们不知道它在其他云提供商或 Kubernetes 上是否有效。有关更多详细信息,请参阅 配置参考

高效缩容#

扩展是容易的部分,缩容更难。要缩容节点,需要满足 某些技术标准。最重要的是,为了缩容节点,它必须没有不允许中断的 Pod。不允许中断的 Pod 例如真实用户 Pod、重要系统 Pod 和一些 JupyterHub Pod(没有允许的 PodDisruptionBudget)。例如,考虑许多用户在白天到达您的 JupyterHub。CA 会添加新的节点。由于某种原因,一些系统 Pod 与用户 Pod 一起出现在新节点上。在晚上,当 culler 从某些节点中删除了许多非活动 Pod 时。现在它们没有用户 Pod,但仍然有一个系统 Pod 阻止 CA 删除节点。

为了避免这些缩容失败,我们建议为用户 Pod 使用专用节点池。这样,所有重要的系统 Pod 都将在一个或有限的节点集中运行,因此自动伸缩用户节点可以从 0 扩展到 X,然后再从 X 扩展到 0。

本节关于高效缩容,还将解释用户调度器如何帮助您减少由于阻止用户 Pod 而导致的缩容失败。

为用户使用专用节点池#

要为用户 Pod 设置专用节点池,我们可以使用 污点和容忍。如果我们在节点池中的所有节点上添加污点,并在用户 Pod 上添加容忍来容忍被调度到有污点的节点上,我们实际上就将节点池专门用于用户 Pod。

要使用户 Pod 调度到为它们专门的节点上,您需要执行以下操作

  1. 设置一个节点池(具有自动伸缩)、一个特定标签和一个特定污点。

    如果您需要有关如何执行此操作的帮助,请参阅您的云提供商文档。节点池可能被称为节点组。

    • 标签:hub.jupyter.org/node-purpose=user

      注意:云提供商通常有自己的标签,与 Kubernetes 标签分开,但此标签必须是 Kubernetes 标签。

    • 污点:hub.jupyter.org/dedicated=user:NoSchedule

      注意:您可能需要将 / 替换为 _,因为云提供商的限制。用户 Pod 容忍这两种污点。

  2. 使用户 Pod 需要调度到上面设置的节点池中

    如果您不需要用户 Pod 调度到它们的专用节点,您可能会填满其他软件运行的节点。这会导致 helm upgrade 命令失败。例如,您可能在滚动更新期间用完了无法调度到自动伸缩节点池的非用户 Pod 的资源,因为它们需要这些资源。

    默认设置是使用户 Pod 优先调度到带有 hub.jupyter.org/node-purpose=user 标签的节点上,但您也可以使用以下配置使其必需

    scheduling:
      userPods:
        nodeAffinity:
          # matchNodePurpose valid options:
          # - ignore
          # - prefer (the default)
          # - require
          matchNodePurpose: require
    

注意:如果您最终使用专用的节点池来容纳用户,并且想要高效地缩减规模,您将需要了解 PodDisruptionBudget 资源,并进行更多工作以避免最终出现几乎空闲的节点无法缩减规模的情况。

禁用默认容忍度#

某些集群可能运行着 PodTolerationRestriction 准入控制器,它会阻止包含不在指定白名单中的容忍度的 Kubernetes 对象。如果您的集群运行着此控制器,并且您无法将其更新以包含 hub.jupyter.org/dedicated / _dedicated 容忍度,那么您可以通过将 scheduling.corePods.tolerationsscheduling.userPods.tolerations 设置为空列表,在所有图表 Pod 中禁用这些容忍度。

高效利用可用节点(用户调度器)#

如果您有用户在总活跃用户数量减少的同时启动新的服务器,您将如何释放一个节点以便缩减规模?

这就是用户调度器可以帮助您的地方。用户调度器的唯一任务是将新的用户 Pod 调度到利用率最高的节点。这可以与默认调度器进行比较,默认调度器始终尝试调度 Pod,以便利用率最低的节点。只有用户调度器才能在总用户数量减少但仍有少数用户到达的情况下,随着时间的推移释放利用率较低的节点。

注意:如果您不想缩减现有节点的规模,那么让用户分散并利用所有可用节点更有意义。只有在您拥有自动扩展的节点池时,才激活用户调度器。

要查看用户调度器的实际效果,请查看来自 mybinder.org 部署的以下图表。该图表来自首次启用用户调度器时,它显示了五个不同节点上活跃的用户 Pod 数量。启用用户调度器后,两个节点及时从用户 Pod 中释放出来并缩减规模。

User scheduler node activity

要启用用户调度器

scheduling:
  userScheduler:
    enabled: true

注意:为了使用户调度器正常工作,您需要在某个时间点关闭旧的用户 Pod。确保正确配置 culler

平衡“保证”与“最大”内存和 CPU#

您可以 选择“保证”和“限制” 用于用户可用的内存和 CPU。这使您可以更有效地利用云资源,但如何选择合适的“保证/限制”比率?以下是一个示例场景,可以帮助您确定策略。

考虑一个每个节点拥有 100G RAM 的 JupyterHub。预计该 Hub 的用户偶尔会使用 20G,因此您首先为每个用户提供 20G RAM 的保证。这意味着,用户每次启动会话时,节点上的 20G RAM 将被预留给他们。每个节点可以容纳 5 个用户(100G / 20G 每个用户 = 5 个用户)。

但是,您注意到,在实践中,大多数用户只使用了 1G RAM,并且非常偶尔会使用全部 20G。这意味着您的 Hub 通常有 80 到 90G RAM 被预留但未使用。您正在为通常不需要的资源付费。

使用资源限制保证,您可以更有效地利用云资源。资源限制定义最大值,而资源保证定义最小值。这两个数字的比率是限制与保证比率。在上述情况下,您的限制与保证比率1:1

如果您设置了 保证 为 1GB,限制 为 20GB,那么您的 限制与保证比率20:1。您的节点平均可以容纳更多用户。当用户启动会话时,如果节点上至少有 1GB 的 RAM 可用,则他们的会话将在那里启动。如果没有,则会创建一个新节点(您的成本会增加)。

但是,假设现在这个节点上有 50 个用户。从技术上讲,它仍然远低于节点的最大允许数量,因为每个用户只保证 1G RAM,而我们总共有 100G。如果其中 10 个用户突然加载了 10GB 的数据集,我们现在请求了 (10 * 10GB) + (40 * 1GB) = 140GB used RAM。糟糕,我们现在已经远远超过了 100GB 的限制,用户会话将开始崩溃。这就是当您的 限制与保证比率 太大时会发生的事情。

问题是什么?您的用户行为不适合您的 限制与保证比率。您应该 增加 每个用户的保证 RAM 量,这样通常每个节点上的用户数量会更少,并且他们不太可能通过同时请求 RAM 来使该节点的内存饱和。

选择正确的 限制与保证比率 是门艺术,而不是科学。我们建议 从 2 比 1 的比率开始,并根据您在集线器上是否遇到问题进行调整。例如,如果限制为 10GB,则从 5GB 的保证开始。使用 Prometheus + Grafana 等服务监控一段时间内的内存使用情况,并以此来决定是否调整您的比率。

显式分配给核心 Pod 容器的内存和 CPU#

Helm 图表创建了几个 k8s Pod,这些 Pod 通常在一个容器中运行,但有时也会运行多个容器。在本节中,您将了解如何通过 请求 显式指定它们保证的 CPU 和内存量,以及通过 限制 指定它们可以使用的 CPU 和内存量。

要补充此文本,请参阅 Kubernetes 文档中的相关部分.

要决定设置哪些请求和限制,一些背景知识是相关的

  1. 资源 请求 保证容器执行其工作所需的最低资源级别,而资源 限制 声明上限。

  2. 命名空间中的 LimitRange 资源可以为没有显式设置请求和/或限制的容器提供默认值。

    请务必通过执行 kubectl get limitrange --namespace <k8s-namespace> 检查您部署 JupyterHub 的命名空间的此类资源。

  3. 如果您设置了资源限制但省略了资源请求,那么 k8s 将假设 您暗示与您的限制相同的资源请求。不会对另一个方向做出任何假设。

  4. 争夺超出其请求的额外 CPU 的容器将以与其请求相关的强度进行争夺。如果两个具有 0.1 和 0.4 CPU 资源请求的容器在 1 CPU 节点上争夺 CPU,则一个将获得 0.2,另一个将获得 0.8。

  5. 过度订阅 CPU 会导致事情比本来应该慢,但这通常不是灾难性的,而过度订阅内存会导致容器进程被内存不足杀手 (OOMKiller) 终止。

    实际上,对于供应不足也是如此:CPU 上的限制较低意味着事情可能会很慢,而内存上的限制较低意味着事情会不断被杀死。

  6. 内存不足的容器将被 Linux 内存不足杀手 (OOMKiller) 杀死其进程。发生这种情况时,您应该通过使用 kubectl describe pod --namespace <k8s-namespace> <k8s-pod-name>kubectl logs --previous --namespace <k8s-namespace> <k8s-pod-name> 查看其踪迹。

    当容器的进程被杀死时,如果容器的 restartPolicy 允许,则容器将重新启动,否则 Pod 将被驱逐。

  7. 完全缺乏 CPU 的容器可能会以多种独特的方式出现问题,并且难以调试。各种超时可能是怀疑 CPU 饥饿的线索。

  8. 在将 Pod 调度到节点时,会考虑有效请求/限制。由于 Pod 的 init 容器在 Pod 的主容器启动之前按顺序运行,因此有效请求/限制的计算方法是 init 容器请求/限制中的最高值与主容器请求/限制的总和。

  9. 软件可能无法利用超过 1 个 CPU,因为它不支持跨多个 CPU 内核并发运行代码。在 Python 中运行 JupyterHub 的 hub pod 和在 NodeJS 中运行 ConfigurableHTTPProxy 的 proxy pod 就是这样的应用程序。

一些额外的技术细节

  1. 请求 0 CPU 的容器将被授予 Kubernetes 容器运行时支持的最小 CPU 量。

  2. CPU 内核共享通常在 100 毫秒的时间间隔内在容器之间强制执行。

  3. 管理 k8s Pod 及其容器需要少量开销 CPU 和内存,这将计入配额。

作为参考,您可以将它与 mybinder.org 部署中运行 BinderHub 的 JupyterHub Helm 图表 Pod 的 CPU 和内存使用情况进行比较,该部署依赖于此 JupyterHub Helm 图表。此类信息可在mybinder.org 的 Grafana 仪表板中找到。

以下是您可以在此 Helm 图表中配置的各种资源请求以及一些关于它们的说明。

配置

pod

1.0.0 之前的 cpu/memory 请求

注意

hub.resources

hub

200m, 510Mi

JupyterHub 和 KubeSpawner 在这里运行。可以使用少量资源进行管理,但在大量用户同时启动和停止服务器时,峰值可能会达到 1 个 CPU。

proxy.chp.resources

proxy

200m, 510Mi

容器运行 configurable-http-proxy。将需要少量资源。

proxy.traefik.resources

autohttps

-

容器仅执行 TLS 终止。将需要少量资源。

proxy.secretSync.resources

autohttps

-

边车容器是一个看门狗,监视文件以进行更改,并使用这些更改更新 k8s Secret。将需要最少的资源。

scheduling.userScheduler.resources

user-scheduler

50m, 256Mi

容器运行带有自定义配置的 kube-scheduler 二进制文件来调度用户 Pod。将需要少量资源。

scheduling.userPlaceholder.resources

user-placeholder

-

这是对默认行为的显式覆盖,以重用 singleuser.cpu.[guarantee|limit]singleuser.memory.[guarantee|limit] 中的值。如果您希望拥有多个用户占位符 Pod 以减少 Pod 调度复杂性,则可以将其增加到典型真实用户请求的倍数。

prePuller.resources

hook|continuous-image-puller

0, 0

此 Pod 的容器都在运行 echopause 命令,作为拉取镜像的技巧。将需要最少的资源。

prePuller.hook.resources

hook-image-awaiter

0, 0

容器只是轮询 k8s api-server。将需要最少的资源。

singleuser.cpu|memory.guarantee|limit

jupyter-username

0, 1G

配置语法不同,因为它属于 Spawner 基类而不是 Kubernetes。通常,保证一定量的内存而不是 CPU 对帮助用户相互共享 CPU 很有用。

示例资源请求#

以下是一些资源请求示例,这些示例对于服务可靠性和性能至关重要的部署来说可能是合理的,但核心 Pod 需要适合 2 个 CPU 节点。

# The ranges of CPU and memory in the comments represents the min - max values of
# resource usage for containers running in UC: Berkeley over 6 months.

hub: # hub pod, running jupyterhub/jupyterhub
  resources:
    requests:
      cpu: 500m # 0m - 1000m
      memory: 2Gi # 200Mi - 4Gi
proxy:
  chp: # proxy pod, running jupyterhub/configurable-http-proxy
    resources:
      requests:
        cpu: 500m # 0m - 1000m
        memory: 256Mi # 100Mi - 600Mi
  traefik: # autohttps pod (optional, running traefik/traefik)
    resources:
      requests:
        cpu: 500m # 0m - 1000m
        memory: 512Mi # 100Mi - 1.1Gi
  secretSync: # autohttps pod (optional, sidecar container running small Python script)
    resources:
      requests:
        cpu: 10m
        memory: 64Mi
scheduling:
  userScheduler: # user-scheduler pods (optional, running kubernetes/kube-scheduler)
    resources:
      requests:
        cpu: 30m # 8m - 45m
        memory: 512Mi # 100Mi - 1.5Gi
  userPlaceholder: # user-placeholder pods (optional, running pause container)
    # This is just an override of the resource requests that otherwise match
    # those configured in singleuser.cpu|memory.guarantee|limit.
    resources: {}
prePuller: # hook|continuous-image-puller pods (optional, running pause container)
  resources:
    requests:
      cpu: 10m
      memory: 8Mi
  hook: # hook-image-awaiter pod (optional, running GoLang binary defined in images/image-awaiter)
    resources:
      requests:
        cpu: 10m
        memory: 8Mi

由于这些是粗略估计的,请通过在此 GitHub 问题中提供反馈来帮助我们改进它们。

注意

如果您收集了 k8s 集群中 Pod 资源使用情况的指标(Prometheus)并拥有显示其使用情况的仪表板(Grafana),您可以随着时间的推移调整这些指标。