JupyterHub w/ DockerSpawner: Unable to get image selection drop-down to appear on login

Dear Friends:

I’ve have JupyterHub successfully running (which I built from scratch via a Dockerfile and docker-compose.yml that I created from scratch). I’m using DockerSpawner. It all works very well (thanks to you guys).

However, I can’t seem to get an image selection-menu to appear upon login (i.e. for a user to be able to select an image). Upon login, the jupyterhub/singleuser image is automatically launched, with no selection-menu appearing to offer an opportunity to select one.

I think I’m missing something(s) or overriding something due to a lack of understanding somewhere.

Here is some configuration information to help, and thank you very much in advance!

Docker images available on the bare-metal HOST:

root@HOST# docker images
REPOSITORY                                  TAG                 IMAGE ID            CREATED             SIZE
acme/jupyterhub                             1.0                 4fe8efebb49a        2 days ago          931MB
jupyter/all-spark-notebook                  latest              172d25132463        10 days ago         4.05GB
jupyter/minimal-notebook                    latest              d7e600ddb99c        10 days ago         1.57GB
jupyterhub/singleuser                       1.2                 500ec59dd69a        2 weeks ago         607MB
postgres                                    latest              c96f8b6bc0d9        5 weeks ago         314MB

Enter the running JupyterHub Docker container as root:

root@HOST# docker exec -it --workdir /root --user root jupyterhub /bin/bash

Jupyter component versions:

   # Activate the virtual-environment used to run JupyterHub ...
bash-5.0# source /opt/jupyterhub.d/pyvenv.d/bin/activate

   # Dump the various versions of Jupyter components ...
(pyvenv.d) bash-5.0# pip freeze | grep -i jupyter
jupyter==1.0.0
jupyter-client==6.1.7
jupyter-console==6.2.0
jupyter-core==4.6.3
jupyter-telemetry==0.1.0
jupyterhub==1.2.1
jupyterlab-pygments==0.1.2

Unix processes:

UID        PID  PPID  C STIME TTY          TIME CMD

root       1       0  0 20:00 ?        00:00:00 bash /opt/jupyterhub.d/usr/bin/jupyterhub.sh
root       269   265  0 20:00 ?        00:00:01 /opt/jupyterhub.d/pyvenv.d/bin/python3 /opt/jupyterhub.d/pyvenv.d/bin/jupyterhub --config /opt/jupyterhub.d/etc/conf.d/jupyterhub_config.py
root       273   269  0 20:00 ?        00:00:00 node /opt/jupyterhub.d/pyvenv.d/bin/configurable-http-proxy --ip  --port 443 --api-ip 127.0.0.1 --api-port 8001 --error-target http://jupyterhub:8080/hub/error --ssl-key /opt/jupyterhub.d/etc/ssl.d/ide.example.com.key --ssl-cert /opt/jupyterhub.d/etc/ssl.d/ide.example.com.crt

Unix Environment (some values set by ./docker-compose.yml or by ./.env file:

SSL_CRT=/opt/jupyterhub.d/etc/ssl.d/ide.example.com.crt
POSTGRES_HOST=jupyterhub-db
DOCKER_NETWORK_NAME=jupyterhub-backend
DOCKER_NOTEBOOK_IMAGE=jupyter/minimal-notebook:latest
HOSTNAME=791c63772cbf
DB_VOLUME_CONTAINER=/var/lib/postgresql/data
USERLIST_FILE=/opt/jupyterhub.d/etc/conf.d/userlist.txt
JUPYTERHUB_DOCKER_MACHINE_NAME=jupyterhub
PWD=/root
GITLAB_HOST=https://gitlab.example.com:443
container=oci
HOME=/root
LANG=en_US.UTF-8
FGC=f32
VIRTUAL_ENV=/opt/jupyterhub.d/pyvenv.d
JUPYTERHUB_IMAGE_FQ_NAME=acme/jupyterhub:1.0
DATA_VOLUME_CONTAINER=/data
NODE_VIRTUAL_ENV=/opt/jupyterhub.d/pyvenv.d
NPM_CONFIG_PREFIX=/opt/jupyterhub.d/pyvenv.d
JUPYTER_ENABLE_LAB=yes
npm_config_prefix=/opt/jupyterhub.d/pyvenv.d
POSTGRES_USER=jhubadmin
DOCKER_NOTEBOOK_DIR=/home/jovyan/work
SSL_KEY=/opt/jupyterhub.d/etc/ssl.d/ide.example.com.key
NODE_PATH=/opt/jupyterhub.d/pyvenv.d/lib/node_modules
OAUTH_CALLBACK_URL=https://ide.example.com:443/hub/oauth_callback
PATH=/opt/jupyterhub.d/pyvenv.d/lib/node_modules/.bin:/opt/jupyterhub.d/pyvenv.d/bin:/opt/jupyterhub.d/pyvenv.d/
bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DB_VOLUME_HOST=jupyterhub-db-data
FEDORA_VERSION=32
DATA_VOLUME_HOST=jupyterhub-data
POSTGRES_DB=jupyterhub
DATABASE_DOCKER_MACHINE_NAME=jupyterhub-db
DOCKER_SUBNET_CIDR='172.10.0.0/16'
GITLAB_URL=https://gitlab.example.com:443

JupyterHub configuration file /opt/jupyterhub.d/etc/conf.d/jupyterhub_config.py:

# ===========================================================================
# SEE: https://jupyterhub.readthedocs.io/en/stable/api/app.html
# Configuration file for JupyterHub. This file is evaluated inside the
# JupyterHub container, once is boots. All environment variables referenced
# below are injected by way of docker-compose.yml(5). (See next comment).
# ===========================================================================
import os, secrets
c = get_config()
# ===========================================================================


# ===========================================================================
# Spawn containers from this image.
# REF: https://github.com/jupyter/docker-stacks
# ===========================================================================
# c.DockerSpawner.image = os.environ['DOCKER_NOTEBOOK_IMAGE']
# ===========================================================================
c.JupyterHub.allow_named_servers = True
c.DockerSpawner.image_whitelist = {
   "minimal"     : "jupyter/minimal-notebook:latest",
   "all-spark"   : "jupyter/all-spark-notebook:latest",
   "single-user" : "jupyterhub/singleuser:1.2",
}


# ===========================================================================
# 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.
# ===========================================================================
#spawn_cmd = "start-singleuser.sh --SingleUserNotebookApp.default_url=/lab"
##spawn_cmd += " --SingleUserNotebookApp.disable_user_config=True"
#spawn_cmd = os.environ.get('DOCKER_SPAWN_CMD', spawn_cmd)
#c.DockerSpawner.extra_create_kwargs.update({ 'command': spawn_cmd })
# ===========================================================================


# ===========================================================================
# Connect containers to this Docker network. Pass the network name as
# argument to spawned Notebook containers.
# ===========================================================================
network_name = os.environ['DOCKER_NETWORK_NAME']
c.DockerSpawner.use_internal_ip = True
c.DockerSpawner.network_name = network_name
c.DockerSpawner.extra_host_config = { 'network_mode': network_name }
# ===========================================================================


# ===========================================================================
# 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`.
# ===========================================================================
notebook_dir = os.environ.get('DOCKER_NOTEBOOK_DIR') or '/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' })
# ===========================================================================


# ===========================================================================
# Remove containers once they are stopped.
# ===========================================================================
c.DockerSpawner.remove_containers = True
# ===========================================================================


# ===========================================================================
# For debugging arguments passed to spawned containers.
# ===========================================================================
c.DockerSpawner.debug = True
# ===========================================================================


# ===========================================================================
# User Notebook containers will access jupyterhub by container-name on
# backend Docker network.
# ===========================================================================
from jupyter_client.localinterfaces import public_ips
# ===========================================================================
c.JupyterHub.hub_port = 8080
c.JupyterHub.hub_ip = 'jupyterhub'
c.JupyterHub.hub_connect_ip = 'jupyterhub'
# ===========================================================================


# ===========================================================================
# TLS config
# NOTE: 'c.JupyterHub.port = 443' refers to the Container-side port, not the
# Host-side port. The Host-side requires mapping to 8443 port, which we do
# within 'docker-compose.yml'. Reason: Port 443 is taken by GitLab nginx.
# ===========================================================================
c.JupyterHub.port = 443
c.JupyterHub.ssl_key = os.environ['SSL_KEY']
c.JupyterHub.ssl_cert = os.environ['SSL_CRT']
# ===========================================================================


# ===========================================================================
# This JupyterHub auth uses GitLab OAuth on https://gitlab example.io:443
# ===========================================================================
from oauthenticator.gitlab import GitLabOAuthenticator
c.JupyterHub.authenticator_class = GitLabOAuthenticator
c.GitLabOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
c.GitLabOAuthenticator.client_id          = os.environ['OAUTH_CLIENT_ID']
c.GitLabOAuthenticator.client_secret      = os.environ['OAUTH_CLIENT_SECRET']
# ===========================================================================


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


# ===========================================================================
# TBD...
# ===========================================================================
#c.JupyterHub.cookie_secret_file = os.path.join(data_dir, 'jupyterhub_cookie_secret')
os.environ['JPY_COOKIE_SECRET'] = secrets.token_hex(16)
c.JupyterHub.cookie_secret = bytes.fromhex(os.environ['JPY_COOKIE_SECRET'])
# ===========================================================================


# ===========================================================================
# - POSTGRES_HOST is injected via docker-compose.yml (environment: section).
# - POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB are also injected via
#   docker-compose.yml (env_file: -./etc/secrets.d/postgres.env)
# ===========================================================================
#c.JupyterHub.db_url = 'postgresql://postgres:{password}@{host}/{db}'.format(
c.JupyterHub.db_url = 'postgresql://{user}:{password}@{host}/{db}'.format(
    user=os.environ['POSTGRES_USER'],
    password=os.environ['POSTGRES_PASSWORD'],
    host=os.environ['POSTGRES_HOST'],
    db=os.environ['POSTGRES_DB'],)
# ===========================================================================


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


# ================================================================================
# Uncommented-out settings (i.e. to explicitly set them).
# ================================================================================
c.JupyterHub.active_server_limit = 20
c.Spawner.disable_user_config = True # Increases security.
c.JupyterHub.cookie_max_age_days = 14 # Maybe change to 7.
c.JupyterHub.shutdown_on_logout = True
c.Authenticator.admin_users = set('jdoeAdmin',) # Set of users w/ admin rights.
c.Spawner.default_url = '/lab' # Starts JupyterLab by default. \o/
# ================================================================================

If you made it this far, thank you for your help. =:)

UPDATE:

The above wasn’t an issue after all. A user-selectable drop-down menu doesn’t necessarily appear when the user initially logs in.

Rather, a Notebook image may automatically launch, and will be either the image specified by c.DockerSpawner.image; or if that is unset, then apparently the jupyterhub/singleuser fallback image is used (and be docker pulled from hub.docker.com if necessary).

I suspect this “automatic” image launch behavior may depending on the value of the URL specified by: "start-singleuser.sh [ other-options ] --SingleUserNotebookApp.default_url=[/lab | ??? ]". But I will test that.

Anyway, selecting File -> Hub Control Panel -> Stop My Server and then Start My Server will make the user-selectable drop-down menu appear (albeit with those extra steps).

But since I want to make the menu appear on initial login, I have to test if my above hypothesis is true, and if it is, what to replace /lab with to make the menu appear initially (… i.e. try something like /spawn).

I hope this helps others.

Did you find the fix for this (i.e. drop down list on first use)?

I’ve noticed it before on a quick test of JupyterHub, but I don’t tend to work in that context very often and didn’t pursue a fix at the time…

–tony

Hi Tony:

I submitted a bug about this (using the same text) HERE, and someone responded that it may be related to a similar issue HERE. As you’ll see, the responsible developer and I conversed, and he uploaded a patched version of the oauthenticator Python library. I haven’t tried it yet (perhaps on Monday I will).

N. Milton

1 Like