以 Docker in Docker 的方式部署 JupyterHub

期末临近,出于对处理大学物理实验数据的需求,我决定部署一个计算环境。众所周知 iPython 同时具备良好的计算能力和交互能力,而基于 iPython 的 JupyterHub 自然是一个优秀的选择。

然而,JupyterHub 为每一个用户创建 server 时需要依赖 Docker。而官方提供的将 JupyterHub 运行于 Docker 的解决方案仅支持将宿主环境的 /var/run/docker.sock 透传进容器,这既不安全也不友好。

由于我曾经部署过 GitLab Runner 的 Docker in Docker (DinD),对这项技术有一定的了解,于是我决定尝试以 DinD 的方式部署 JupyterHub。

请注意,由于官方没有提供任何 Docker in Docker 支持,我也并未在网络上找到任何已有的实践案例,因此后期维护可能需要大量容器化相关知识。本博文的步骤需要对一些组件配置进行重度修改,请量力而行。

我们使用官方的 jupyterhub/jupyterhub-deploy-docker 作为我们修改的基础。同时该仓库部署的 JupyterHub 版本过旧,我们同时会进行升级。请在 repo 目录进行操作。为便于 nginx 反代,我没有使用 TLS。

由于改动过大,有些部分将会仅贴出修改后的内容并讲解思路。

环境准备

我们当然可以使用它的 Makefile 来初始化环境,但我们需要更加定制化的部署,因此不使用它。
首先,我们需要生成 PostgreSQL 数据库的密码并申请 GitHub OAuth Token(如果你使用其它 SSO,请参阅 JupyterHub 官方文档)。
在 GitHub OAuth 申请处,请填写回调地址为你 JupyterHub 访问的公开地址和 /hub/oauth_callback,例如 https://example.com/hub/oauth_callback
然后执行以下代码:

mkdir secrets
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" > secrets/postgres.env
nano secrets/oauth.env

在弹出的编辑器中,为 secrets/oauth.env 填写以下内容:

GITHUB_CLIENT_ID=【你申请得到的 ID】
GITHUB_CLIENT_SECRET=【你申请得到的密钥】
OAUTH_CALLBACK_URL=【回调地址】

然后我们修改当前目录下的 .env 文件:

nano .env

内容为:

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

# Use this file to set default values for environment variables specified in
# docker-compose configuration file.  docker-compose will substitute these
# values for environment variables in the configuration file IF the variables
# are not set in the shell environment.

# To override these values, set the shell environment variables.
JUPYTERHUB_VERSION=1.4.1

# Name of Docker machine
DOCKER_MACHINE_NAME=jupyterhub

# Name of Docker network
DOCKER_NETWORK_NAME=bridge

# Single-user Jupyter Notebook server container image
DOCKER_NOTEBOOK_IMAGE=jupyterhub/singleuser:1.4.1

# the local image we use, after pinning jupyterhub version
LOCAL_NOTEBOOK_IMAGE=jupyterhub/singleuser:1.4.1

# Notebook directory in the container.
# This will be /home/jovyan/work if the default
# This directory is stored as a docker volume for each user
DOCKER_NOTEBOOK_DIR=/home/jovyan/work

# Docker run command to use when spawning single-user containers
DOCKER_SPAWN_CMD=start.sh jupyter-labhub

# Name of JupyterHub container data volume
DATA_VOLUME_HOST=jupyterhub-data

# Data volume container mount point
DATA_VOLUME_CONTAINER=/data

# Name of JupyterHub postgres database data volume
DB_VOLUME_HOST=jupyterhub-db-data

# Postgres volume container mount point
DB_VOLUME_CONTAINER=/var/lib/postgresql/data

# The name of the postgres database containing JupyterHub state
POSTGRES_DB=jupyterhub

# The public port mapped to configurable http proxy
PUBLIC_PORT=20215

# The path on host that should be mapped to `/srv/jupyterhub` inside the container
DYNAMIC_CONFIG_PATH=/data/jupyterhub

这里我们为了不用每次修改合法用户列表以及配置文件都需要重新构建镜像,选择将配置文件透传进容器中,外部配置数据文件夹路径由环境变量 DYNAMIC_CONFIG_PATH 控制。
JupyterHub 对外暴露的端口由 PUBLIC_PORT 环境变量控制。
这里我们使用 VERSION=1.4.1 版本构建镜像,而不是原先的 0.9.x。请注意如果你需要自己修改版本,需要将 LOCAL_NOTEBOOK_IMAGE 等的版本号一并修改。
这里我们选择使用 Jupyter Lab,如果你想使用 Jupyter Notebook,请将 DOCKER_SPAWN_CMD 改为 start-single-user.sh
你可以发现这里我们多出了几个环境变量,这是因为我们接下来会对 Docker 相关的配置文件进行修改以使用它们。有些环境变量的意义将在后文概述。

Docker 配置相关修改

Dockerfile

首先对 Dockerfile.jupyterhub 进行修改。
在原装的 Dockerfile 中,它使用了 conda 来管理所有依赖。但是在最新版本的 JupyterHub Onbuild 镜像中,conda 不再与它的代码相兼容,因此我们选择使用 pip 管理依赖。而且,它使用了 PostgreSQL,但镜像中缺失了对 PostgreSQL 的支持,因此我们还需要安装。同时我们需要使用 Docker in Docker,为了可以让 DinD 和 hub 共享 docker.sock,我们选择将其置于存储卷中并共享,因此引发了一些路径变化需要在镜像中进行处理。

最终的 Dockerfile.jupyterhub 内容如下:

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# Modified by Victor Huang <i@qwq.ren>
ARG JUPYTERHUB_VERSION
FROM jupyterhub/jupyterhub-onbuild:$JUPYTERHUB_VERSION

# Install dockerspawner, oauth, postgres
RUN apt update && \
    apt install -y libpq-dev && \
    apt clean

RUN pip3 install --no-cache-dir \
        psycopg2-binary \
        oauthenticator \
        dockerspawner
RUN ln -s docker-run/docker.sock /var/run/docker.sock

Docker-Compose

然后我们需要对 docker-compose.yml 进行修改。
这里我们进行了较多修改。我们需要加入一个 docker 组件,并为它创建两个卷组,分别对应 /var/run/docker-run/var/lib/docker,其中我们配置 docker.sock 位于 /var/run/docker-run。对于 JupyterHub,我们需要让它与 dockerhub-db “相链接”。为了方便观察日志,hub 需要开启 tty。以及我们需要透传我们的动态配置目录。

完整配置文件如下:

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# Modified by Victor Huang <i@qwq.ren>
# JupyterHub docker-compose configuration file
version: "2"

services:
  docker:
    image: docker:20.10-dind
    restart: always
    privileged: true
    container_name: jupyterhub-dind
    command: ["--storage-driver=overlay2", "-H", "unix://var/run/docker-run/docker.sock"]
    hostname: docker
    volumes:
      - docker_run:/var/run/docker-run
      - docker_lib:/var/lib/docker
      - "jupyterhub-data:${DATA_VOLUME_CONTAINER}"

  hub-db:
    image: postgres:9.5
    container_name: jupyterhub-db
    restart: always
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      PGDATA: ${DB_VOLUME_CONTAINER}
    hostname: hub-db
    env_file:
      - secrets/postgres.env
    volumes:
      - "jupyterhub-db-data:${DB_VOLUME_CONTAINER}"

  hub:
    depends_on:
      - hub-db
      - docker
    build:
      context: .
      dockerfile: Dockerfile.jupyterhub
      args:
        JUPYTERHUB_VERSION: ${JUPYTERHUB_VERSION}
    restart: always
    tty: true
    image: jupyterhub
    container_name: jupyterhub
    volumes:
      # Bind Docker socket on the host so we can connect to the daemon from
      # within the container
      - "docker_run:/var/run/docker-run"
      # Bind Docker volume on host for JupyterHub database and cookie secrets
      - "jupyterhub-data:${DATA_VOLUME_CONTAINER}"
      - "${DYNAMIC_CONFIG_PATH}:/srv/jupyterhub"
    ports:
      - "${PUBLIC_PORT}:8888"
    links:
      - hub-db
      - docker
    environment:
      # All containers will join this network
      DOCKER_NETWORK_NAME: ${DOCKER_NETWORK_NAME}
      # JupyterHub will spawn this Notebook image for users
      DOCKER_NOTEBOOK_IMAGE: ${LOCAL_NOTEBOOK_IMAGE}
      # Notebook directory inside user image
      DOCKER_NOTEBOOK_DIR: ${DOCKER_NOTEBOOK_DIR}
      # Using this run command (optional)
      DOCKER_SPAWN_CMD: ${DOCKER_SPAWN_CMD}
      # Postgres db info
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_HOST: hub-db
    env_file:
      - secrets/postgres.env
      - secrets/oauth.env
    command: >
      jupyterhub -f /srv/jupyterhub/jupyterhub_config.py

volumes:
  jupyterhub-data:
  jupyterhub-db-data:
  docker_run:
  docker_lib:

配置文件修改

直接控制 JupyterHub 行为的配置文件即为 jupyterhub_config.py。这里我首先讲解一下我修改的思路。

在我启动 JupyterHub 时,就出现了无法正常启动用户 Server 的问题。这是因为我们使用 DinD,Docker 环境和 Hub 位于不同的网络环境中,根据 Docker Daemon 报告的容器 IP 是无法访问的。因此这里我们需要让我们的 Hub 去连接 DinD 容器,并将运行在 DinD 中的容器端口暴露出来。同时我们还需要将 Hub 的 hostname 注册到 DinD 中运行的容器的 hosts 里,因为两者运行在不同的 Docker Daemon 下,因此容器间 hostname 通信无法实现。
在成功启动 Server 后,我还发现通过 nginx 反向代理后会出现 Redirect loop 的问题,查阅资料后得知原来 JupyterHub 通过反向代理时需要请求它的 configurable-http-proxy 中间件,该地址通过 c.JupyterHub.bind_url 属性控制。
按照它默认的配置,用户是不能在自己的容器中正常使用 sudo 的。查阅资料得知我们不但需要传入 GRANT_SUDO=yes 环境变量,还需要给 Docker 传参 --user=root,这里可以通过 kwargs 进行传参。
以上问题在修改后的配置文件中都得到了解决。

最终修改后的配置文件如下:

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.
# Modified by Victor Huang <i@qwq.ren>
# Configuration file for JupyterHub
import os
import socket
from jupyterhub.utils import random_port
from dockerspawner import DockerSpawner
from tornado import gen

DIND_LINK_NAME = 'docker'
CONTAINER_DEFAULT_PORT = 8888

c = get_config()


# We rely on environment variables to configure JupyterHub so that we
# avoid having to rebuild the JupyterHub container every time we change a
# configuration parameter.
hub_hostname = socket.gethostname()
hub_ip_connect = socket.gethostbyname(hub_hostname)
# Spawn single-user servers as Docker containers
#c.JupyterHub.spawner_class = 'dockerspawner.DockerSpawner'
# Spawn containers from this image
c.DockerSpawner.container_image = os.environ['DOCKER_NOTEBOOK_IMAGE']
# JupyterHub requires a single-user instance of the Notebook server, so we
# default to using the `start-singleuser.sh` script included in the
# jupyter/docker-stacks *-notebook images as the Docker run command when
# spawning containers.  Optionally, you can override the Docker run command
# using the DOCKER_SPAWN_CMD environment variable.
class DinDSpawner(DockerSpawner):
    @gen.coroutine
    def get_ip_and_port(self):
        return self.host_ip, self.host_port

    @gen.coroutine
    def start(self, *args, **kwargs):
        self.host_port = random_port()
        spawn_cmd = os.environ.get('DOCKER_SPAWN_CMD', "start-singleuser.sh")
        self.extra_create_kwargs.update({"ports": {("%i/tcp" % CONTAINER_DEFAULT_PORT): None } })
        self.extra_create_kwargs.update({"command": spawn_cmd})
        self.extra_host_config.update({"port_bindings": {("%i/tcp" % CONTAINER_DEFAULT_PORT): {"HostIp": "0.0.0.0", "HostPort": ("%i" % self.host_port) } } })

        # start the container
        ret = yield DockerSpawner.start(self, *args, **kwargs)
        return ret

#c.DockerSpawner.extra_create_kwargs.update({ 'command': spawn_cmd })
# Connect containers to this Docker network
c.JupyterHub.spawner_class = DinDSpawner
c.DockerSpawner.use_internal_ip = True
c.DockerSpawner.host_ip = socket.gethostbyname(DIND_LINK_NAME)
c.DockerSpawner.extra_create_kwargs.update({"user": "root"})
# Pass the network name as argument to spawned containers
network_name = os.environ.get('DOCKER_NETWORK_NAME', 'bridge')
c.DockerSpawner.network_name = network_name
c.DockerSpawner.extra_host_config.update({ 'network_mode': network_name, "extra_hosts": {"hub": hub_ip_connect, hub_hostname: hub_ip_connect} })
# Explicitly set notebook directory because we'll be mounting a host volume to
# it.  Most jupyter/docker-stacks *-notebook images run the Notebook server as
# user `jovyan`, and set the notebook directory to `/home/jovyan/work`.
# We follow the same convention.
notebook_dir = os.environ.get('DOCKER_NOTEBOOK_DIR', '/home/jovyan/work')
c.DockerSpawner.notebook_dir = notebook_dir
# Mount the real user's Docker volume on the host to the notebook user's
# notebook directory in the container
c.DockerSpawner.volumes = { 'jupyterhub-user-{username}': notebook_dir }
# volume_driver is no longer a keyword argument to create_container()
# c.DockerSpawner.extra_create_kwargs.update({ 'volume_driver': 'local' })
c.DockerSpawner.environment.update({ 'GRANT_SUDO': 'yes' })
# Remove containers once they are stopped
c.DockerSpawner.remove_containers = True
# For debugging arguments passed to spawned containers
c.DockerSpawner.debug = True

# User containers will access hub by container name on the Docker network
c.JupyterHub.hub_ip = '0.0.0.0'
c.JupyterHub.hub_port = 8000
c.JupyterHub.bind_url = 'http://0.0.0.0:8888'
c.JupyterHub.hub_ip_connect = hub_ip_connect
# TLS config
#c.JupyterHub.port = 443
#c.JupyterHub.ssl_key = os.environ['SSL_KEY']
#c.JupyterHub.ssl_cert = os.environ['SSL_CERT']

# Authenticate users with GitHub OAuth
c.JupyterHub.authenticator_class = 'oauthenticator.GitHubOAuthenticator'
c.GitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']

# Persist hub data on volume mounted inside container
data_dir = os.environ.get('DATA_VOLUME_CONTAINER', '/data')

c.JupyterHub.cookie_secret_file = os.path.join(data_dir,
    'jupyterhub_cookie_secret')

c.JupyterHub.db_url = 'postgresql://postgres:{password}@{host}/{db}'.format(
    host=os.environ['POSTGRES_HOST'],
    password=os.environ['POSTGRES_PASSWORD'],
    db=os.environ['POSTGRES_DB'],
)

# Whitlelist users and admins
c.Authenticator.allowed_users = allowed_users = set()
c.Authenticator.admin_users = admin = set()
c.JupyterHub.admin_access = True
pwd = os.path.dirname(__file__)
with open(os.path.join(pwd, 'userlist')) as f:
    for line in f:
        if not line:
            continue
        parts = line.split()
        # in case of newline at the end of userlist file
        if len(parts) >= 1:
            name = parts[0]
            allowed_users.add(name)
            if len(parts) > 1 and parts[1] == 'admin':
                admin.add(name)

这里我参考了 DockerSpawner APIDockerspawn with host network mode · Issue #187 · jupyter/docker-stacksSharing an additional port from user container to hub · Issue #275 · jupyterhub/dockerspawnerdocker – Adding additional host to Juypterhub DockerSpawner – Stack OverflowUsing a reverse proxy — JupyterHub 1.4.1 documentationGranting users admin permissions on their container · Issue #154 · jupyterhub/dockerspawner

启动前的准备

如果你完全按照我的方案来配置,动态配置文件目录应当位于 /data/jupyterhub
此时我们需要将配置文件拷贝过去,然后创建一个 userlist:

mkdir -p /data/jupyterhub
cp jupyterhub_config.py /data/jupyterhub/
nano /data/jupyterhub/userlist

在打开的编辑器中,你可以加入你在初始化阶段就允许登录的 GitHub 用户名,例如:

qwqVictor admin

如果带有 admin,该用户是一位管理员。

启动

在以上所有步骤完成后,你便可以启动了!

docker-compose up -d

如果你按照我的配置进行,你应当可以在 http://localhost:20215 找到你的 JupyterHub 了。

JupyterHub 部署于 Docker in Docker 是一个全新的方案,通过 Docker 化它对于运维人员更加友好。无需传入 /var/run/docker.sock 也很大地提高了服务器安全性。如果你通过这篇博文的方案成功完成了部署,不妨在评论区留言。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注