MAATRIX / Блог / JupyterHub на трёх аналитиков: сервер тетрадок, о котором никто не просил

JupyterHub на трёх аналитиков: сервер тетрадок, о котором никто не просил

MAATRIX

Поставить JupyterHub на сервер и дать команде аналитиков общий адрес вместо десятка локальных Jupyter Notebook — дело одного вечера: установщик разворачивает всё за несколько команд, каждый сотрудник логинится своим системным пользователем и получает изолированный ноутбук. Но через пару недель эксплуатации всплывают вопросы, на которые коробочная установка ответа не даёт: почему один аналитик с тяжёлым pandas-скриптом положил Jupyter всем остальным, куда делись файлы после перезапуска контейнера и как выдать GPU троим одновременно, не отжав её друг у друга. Разберём, что в JupyterHub действительно работает из коробки, а что требует осознанной донастройки, — на конец августа 2026 года.

Что JupyterHub даёт из коробки

Базовая идея JupyterHub простая: hub-процесс поднимает и завершает отдельные Jupyter-серверы для каждого пользователя, authenticator решает, кому открыт вход, а spawner отвечает за то, как именно поднимается пользовательский сервер — локальным процессом, systemd-юнитом, Docker-контейнером или подом в Kubernetes.

Для небольшой команды, которая ставит JupyterHub на один сервер, есть готовый дистрибутив The Littlest JupyterHub (TLJH) — он ставит hub, Python-окружение и nginx одной командой:

curl -L https://tljh.jupyter.org/bootstrap.py | sudo python3 - --admin analyst_lead

После установки TLJH из коробки уже работает:

  • аутентификация через системные учётные записи Linux (PAM) — создали пользователя useradd, он логинится своим паролем;
  • изоляция процессов — у каждого аналитика собственный Jupyter-сервер и домашний каталог /home/username, работа одного не видна другому;
  • панель администратора (/hub/admin) — список активных пользователей, кнопки «остановить сервер», «сделать администратором»;
  • HTTPS через встроенный nginx и Let's Encrypt, если у сервера есть домен;
  • остановка серверов неактивных пользователей вручную из панели.

Это закрывает сценарий «общий сервер вместо laptop-Jupyter для пятерых» практически полностью. Проблемы начинаются, когда команда растёт до 10–15 человек и появляются тяжёлые вычисления — встаёт вопрос, кто и сколько ресурсов реально потребляет.

Управление ресурсами: почему лимиты нужно включать руками

Ключевой факт, который стоит понять сразу: JupyterHub без дополнительной настройки не ограничивает потребление CPU и памяти. Один ноутбук с df.groupby().apply() на большом датафрейме способен съесть всю оперативную память сервера — и тогда упадёт не только он, а весь hub вместе с ноутбуками остальных через OOM killer, который не разбирает, чей процесс убивать первым.

Лимиты добавляются на уровне spawner'а. Для TLJH это делается через встроенный интерфейс лимитов ресурсов, который под капотом использует cgroups:

sudo tljh-config set limits.memory 4G
sudo tljh-config set limits.cpu 2
sudo tljh-config reload

Это установит лимит по умолчанию для всех — 4 ГБ памяти и 2 ядра на пользователя. Дальше почти всегда нужна дифференциация: аналитику, который гоняет тяжёлые ETL-джобы, нужно больше, чем тому, кто просто строит графики. Это делается через группы пользователей и traitlets-конфиг в jupyterhub_config.py:

# jupyterhub_config.py
import re

def limit_by_group(spawner):
    username = spawner.user.name
    if username in HEAVY_USERS:
        spawner.mem_limit = '16G'
        spawner.cpu_limit = 4
    else:
        spawner.mem_limit = '4G'
        spawner.cpu_limit = 1

c.Spawner.pre_spawn_hook = limit_by_group

Если вместо TLJH используется Zero to JupyterHub на Kubernetes, лимиты задаются через KubeSpawnermem_guarantee/mem_limit и cpu_guarantee/cpu_limit в values.yaml, плюс singleuser.profileList — список профилей («Маленький: 2 ГБ / 1 ядро», «Большой: 16 ГБ / 4 ядра»), из которых пользователь выбирает при запуске сервера. Это удобнее жёстких лимитов по группам: аналитик сам решает, сколько ресурсов ему нужно сегодня.

Отдельная грабля — лимиты процессов и открытых файлов. Тяжёлые библиотеки вроде Dask или многопоточный numpy разводят десятки потоков на пользователя; без ulimit и PidsMax в systemd-юните один пользователь может упереться в лимит процессов всего сервера и получить труднообъяснимые ошибки fork: Resource temporarily unavailable. Механика та же, что и для ограничения контейнеров через cgroups — стоит свериться, если на сервере рядом крутятся и другие сервисы.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер для JupyterHub

Аутентификация и разграничение прав

PAM-аутентификация TLJH прекрасно работает для команды из 5–10 человек: администратор один раз создаёт системных пользователей, и это всё. Но у неё три ограничения, которые видны сразу, как только команда растёт:

  • Нет единого источника пользователей. Если в компании уже есть каталог (Active Directory, OpenLDAP, Google Workspace), PAM-пользователей всё равно приходится заводить отдельно — двойная бухгалтерия при найме и увольнении.
  • Нет ролей, кроме «пользователь» и «администратор». Тонкой настройки «этот аналитик только читает общие данные, а data-инженер может перезапускать hub» из коробки нет.
  • Управление доступом руками через SSH. Отключить сотрудника — значит зайти на сервер и заблокировать системного пользователя, а не щёлкнуть переключатель в веб-панели.

Практическое решение — заменить authenticator. Для команд с внешним каталогом обычно берут ldapauthenticator или oauthenticator (Google, GitHub, Keycloak, Authentik):

# jupyterhub_config.py — аутентификация через LDAP
c.JupyterHub.authenticator_class = 'ldapauthenticator.LDAPAuthenticator'
c.LDAPAuthenticator.server_address = 'ldap://ldap.internal.company:389'
c.LDAPAuthenticator.bind_dn_template = [
    'uid={username},ou=analysts,dc=company,dc=internal'
]
c.LDAPAuthenticator.use_ssl = True
c.LDAPAuthenticator.allowed_groups = [
    'cn=data-team,ou=groups,dc=company,dc=internal'
]

Если своего LDAP-каталога ещё нет, но нужна централизованная аутентификация сразу для нескольких сервисов (не только JupyterHub, но и Grafana, GitLab, VPN) — есть смысл поднять его один раз, шаги разобраны в статье как установить и настроить OpenLDAP на VPS.

Роли в JupyterHub тоже настраиваются тоньше, чем «админ/не админ» — через RBAC-систему (role-based access control) можно выдавать конкретным группам права только на просмотр списка серверов или только на управление своими токенами:

c.JupyterHub.load_roles = [
    {
        'name': 'team-lead',
        'scopes': ['admin:servers', 'admin:users!group=data-team'],
        'groups': ['data-team-leads'],
    }
]

Это закрывает сценарий «нужен человек, который может перезапускать чужие зависшие серверы, но не должен иметь полный root-доступ к hub'у».

Персистентное хранилище между сессиями

Из коробки TLJH и Z2JH хранят домашний каталог пользователя локально — на диске того же сервера (TLJH) или на persistent volume, который переживает перезапуск контейнера (Z2JH на Kubernetes). В обоих случаях данные между сессиями сохраняются сами по себе и обычно не требуют вмешательства.

Проблемы начинаются на трёх направлениях.

Общие данные между аналитиками. Домашний каталог у каждого свой, а датасеты, на которых работает вся команда, нужны всем одновременно. Решение — смонтировать общий раздел (NFS-шара, отдельный диск с правами группы) в определённую точку внутри каждого пользовательского контейнера:

c.Spawner.volumes = {'shared-data': '/data'}
c.Spawner.volume_mounts = [
    {'name': 'shared-data', 'mountPath': '/home/shared'}
]

Если раздел монтируется через NFS, стоит заранее понимать нюансы блокировок при параллельной записи — этот вопрос разобран в статье про NFS для Linux-сервера: для сценария «десять аналитиков читают один датасет, изредка кто-то пишет результат» NFS подходит хорошо, а для активной параллельной записи в один файл — уже не лучший выбор без дополнительных мер.

Квоты на пользователя. Ничего не мешает аналитику скачать в домашний каталог сырой датасет на 200 ГБ и забыть про него. Без квот это диск, который однажды кончается для всех сразу — включая базу метаданных hub'а, если она на том же разделе. Классические квоты на ext4/xfs настраиваются через quotacheck/edquota, а для Docker-volume нужен отдельный подход — он разобран в статье про дисковые квоты для пользователей и контейнеров.

Резервное копирование. JupyterHub не бэкапит пользовательские данные сам по себе — это ответственность администратора, как и для любого другого сервиса с состоянием. Бэкап домашних каталогов и базы hub'а (SQLite или PostgreSQL с метаданными о пользователях и серверах) ставится по обычной схеме cron + rsync/restic; если данные лежат в Docker-volume, подойдёт связка из статьи как настроить бэкап Docker volume на VPS.

GPU-шеринг между пользователями

Это единственная область, где коробочного решения по сути нет: ни TLJH, ни Z2JH сами по себе не умеют делить одну видеокарту между пользовательскими серверами так, чтобы каждому доставалась своя часть памяти и вычислений. По умолчанию первый, кто в ноутбуке вызовет torch.cuda, займёт всю видимую GPU-память, а следующий аналитик получит CUDA out of memory.

Три рабочих подхода, от простого к сложному:

  1. Разделение по времени вручную. Самый грубый вариант — договориться в команде, кто и когда занимает GPU, и следить за этим через nvidia-smi. Работает для команды из двух-трёх человек с редкими тяжёлыми вычислениями, дальше не масштабируется.
  1. Ограничение видимости GPU через переменную окружения. Если видеокарт несколько, каждому пользователю или профилю можно жёстко назначить свою через CUDA_VISIBLE_DEVICES в spawner-конфиге — тогда конфликтов за память не будет, но и совместного использования одной карты тоже:
def set_gpu(spawner):
    username = spawner.user.name
    gpu_map = {'analyst1': '0', 'analyst2': '1'}
    spawner.environment.update({
        'CUDA_VISIBLE_DEVICES': gpu_map.get(username, '')
    })

c.Spawner.pre_spawn_hook = set_gpu
  1. Настоящее разделение одной GPU между несколькими процессами — через NVIDIA MPS (Multi-Process Service) или тайм-слайсинг в Kubernetes через nvidia-device-plugin. Это позволяет нескольким серверам одновременно использовать одну карту с ограничением по памяти на процесс, но требует настройки на уровне драйвера и не входит ни в один установщик JupyterHub. Общая механика и ограничения такого подхода разобраны в статье как раздать одну GPU нескольким сервисам — она не про JupyterHub конкретно, но описывает тот же набор инструментов (MPS, лимиты памяти, мониторинг).

Если GPU нужна одновременно и регулярно больше чем трём-четырём аналитикам, часто дешевле не городить шеринг одной карты, а взять сервер с несколькими GPU и закрепить их по профилям — это снимает половину проблем ценой более высокого разового бюджета на железо.

Что не заметно сразу: idle-серверы и мониторинг

Ещё одна вещь, которую JupyterHub не решает по умолчанию, — пользовательские серверы не останавливаются сами, когда аналитик закрыл вкладку и ушёл на встречу. Ядро Jupyter продолжает держать выделенную память, даже если веб-страница давно не отвечает. При десятке пользователей с лимитом 4–8 ГБ каждый это быстро съедает весь сервер простаивающими серверами.

Решение — сервис jupyterhub-idle-culler, который штатно поставляется вместе с JupyterHub и запускается как отдельный managed-сервис в конфиге:

c.JupyterHub.services = [
    {
        'name': 'idle-culler',
        'admin': True,
        'command': [
            'python3', '-m', 'jupyterhub_idle_culler',
            '--timeout=3600',       # час без активности
            '--cull-every=300',     # проверка раз в 5 минут
            '--max-age=28800',      # жёсткий потолок — 8 часов
        ],
    }
]

Это не убивает ноутбук молча посреди долгого обучения — таймаут считает именно неактивность (отсутствие обращений к kernel), а не время работы. Для --max-age стоит закладывать запас с учётом реальных задач команды — это ориентир, а не универсальное число, его правильнее подбирать по логам за первые пару недель эксплуатации.

Отдельно стоит вынести мониторинг самого hub'а — сколько пользователей активно, сколько памяти занято, не упёрлась ли база hub'а в диск. JupyterHub отдаёт Prometheus-метрики из коробки по /hub/metrics, но дашборд и алерты на них никто заранее не готовит — это отдельная задача, обычно решаемая связкой Prometheus + Grafana поверх существующего мониторинга сервера.

TLJH или Zero to JupyterHub: что выбрать для команды

Оба варианта официальные, но рассчитаны на разный масштаб и разную готовность возиться с инфраструктурой.

КритерийTLJH (The Littlest JupyterHub)Zero to JupyterHub (Kubernetes)
ИнфраструктураОдин сервер, systemd-спаунерKubernetes-кластер, KubeSpawner
Порог входаНизкий — один bootstrap-скриптВысокий — нужен рабочий k8s-кластер
МасштабированиеВертикальное — апгрейд одного сервераГоризонтальное — добавление узлов
Изоляция ресурсовcgroups через systemdcgroups через Kubernetes-подов
Профили ресурсовЧерез tljh-config и хукиprofileList — гибкий выбор пользователем
GPU-шерингВручную (env-переменные, MPS)nvidia-device-plugin, тайм-слайсинг
Подходит дляКоманды до 15–20 человек, один серверКоманды от 20+ человек, растущая нагрузка

Для большинства команд аналитиков разумная стратегия — начать с TLJH на одном достаточно мощном сервере: он закрывает основную часть сценариев с минимумом операционных затрат, а миграцию на Kubernetes откладывать до момента, когда один сервер физически перестаёт вмещать нагрузку. Переезд с TLJH на Z2JH возможен, но это отдельный проект — переносится не конфиг, а вся модель развёртывания, поэтому стоит один раз честно оценить ожидаемый рост команды, а не мигрировать по факту упора в потолок.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер для JupyterHub

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли использовать JupyterHub с обычным Jupyter Notebook вместо JupyterLab?

Да, интерфейс настраивается через c.Spawner.default_url — можно указать классический Notebook или сразу открывать конкретный файл, это не связано с многопользовательской частью.

Что будет, если сервер с JupyterHub перезагрузится?

Hub поднимается заново при следующем логине; серверы, которые были запущены на момент перезагрузки, автоматически не восстанавливаются — пользователю нужно зайти и запустить сервер снова. Данные не теряются, если домашние каталоги на персистентном диске.

Нужен ли отдельный сервер под базу данных hub'а?

Для команды до 20-30 человек штатной SQLite-базы обычно достаточно — она хранит только метаданные о пользователях и серверах, а не сами вычисления. При росте нагрузки имеет смысл перейти на PostgreSQL через c.JupyterHub.db_url.

Как ограничить, кто может ставить новые Python-пакеты в общем окружении?

Из коробки любой пользователь может сделать pip install в своё окружение — это никак не ограничено JupyterHub. Для изоляции зависимостей нужен свой conda/venv-профиль на пользователя или DockerSpawner с индивидуальными образами.

Стоит ли выносить JupyterHub на отдельный сервер от остальной инфраструктуры?

Да, особенно если рядом сервисы с гарантированным SLA — аналитик способен случайно занять всю память или диск тяжёлым вычислением, и лучше, чтобы это не роняло заодно продакшен-базу или почту.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →