Введение
Любой, кому доводилось работать в SPSS (мне, к сожалению, приходилось), знает чувство тоски, которое охватывает при первом запуске этого продукта, а так как запускается он долго, то и ощущение длится столько же. Отдельного внимания заслуживает и интерфейс этого продукта, ведь сложно сделать его менее интуитивно понятным. Меня из плена этого ПО когда-то вывело открытие pandas, с которым аналогичный анализ можно было собирать намного быстрее и с удобным сохранением скриптов, то есть еще и с возможностью быстро воспроизвести результаты.
Одному человеку для того же статанализа хватит обычного ПК, но вот если необходимо проводить более масштабные исследования или организовать совместную работу группы исследователей, тут уже требуется привлечение более мощного железа и программного обеспечения, например, JupyterLab на GPU-сервере. О нем мы сегодня и расскажем.
Работа с данными состоит из экспериментов, а не из написания программ в привычном смысле. Требуется посмотреть на промежуточный результат, поменять одну строчку и посмотреть снова. Программа, запускаемая целиком от начала до конца, для такого подходит плохо, ведь каждый запуск стартует с пустой памяти, а загрузка набора данных или весов модели занимает от минут до десятков минут. Ядро JupyterLab держит загруженное между выполнением ячеек, и цикл правки сокращается до секунд. Во многом поэтому ноутбуки вытеснили все остальные решения на исследовательской стадии.
Закажите сервер с готовыми решениями для Data Science, научных вычислений и машинного обучения.
Зачем JupyterLab на GPU-сервере?
Помимо уже перечисленных причин, такой сервер будет полезен в ситуациях, когда данные нельзя выносить за периметр компании, также он будет незаменим для исследований, в рамках которых необходимо одинаковое окружение для всех участников. JupyterLab на GPU-сервере будет хорошим выбором для тех, у кого исследования предполагают длительную непрерывную обработку данных, так как. сервер, в отличие от ПК, обеспечивает длительную стабильную работу. Иными словами, этот сервер полезен по большей части для команд и он беспечивает единую среду исследования.
Видеокарта появляется в этой конструкции не ради красоты. Основная операция внутри нейронной сети — это перемножение матриц, то есть тысячи одинаковых независимых умножений и сложений. Центральный процессор устроен под другое, у него десяток-другой сложных ядер, заточенных под ветвящуюся последовательную логику, тогда как видеокарта состоит из тысяч простых вычислителей, делающих одно и то же над разными числами. Разница на таком классе задач измеряется десятками раз. Добавьте сюда пропускную способность памяти, у видеокарты она составляет сотни гигабайт в секунду против примерно сотни у оперативной памяти сервера, а обучение упирается в перекачку данных не меньше, чем в сами вычисления.
Есть и недостатки, и главный из них — стоимость. Сервер с графическим процессором стоит дорого, и имеет смысл брать его осознанно и под определенный объем задач. Если их не так много, то можно рассмотреть почасовую аренду. В статье мы пройдем путь от чистой системы до работающего многопользовательского стенда.
Требования к серверу
Ключевые точки отсчета при выборе сервера для JupyterLab — это количество одновременно работающих пользователей, размер моделей и объем данных. Дальше мы разбираем выбор на примере собственных тарифов, у других компаний они могут отличаться, но не сильно.
Видеокарта и объем ее памяти
Выбирать карту стоит по объёму видеопамяти, а не по заявленной производительности, потому чтомодель вместе с обучающими данными либо целиком помещается в память карты, либо нет. Варианты ужать модель существуют в большом количестве (квантование, LoRA и т. д.), но мы эти варианты рассматривать не будем, ведь они выходят за рамки тематики статьи, а просто сделаем важную оговорку: объем видеопамяти определяет, насколько сложными приемами придётся пользоваться и сколько времени уйдет на обучение.
Двадцати четырёх гигабайт хватает для дообучения небольших моделей, для задач компьютерного зрения и для обычного анализа данных, причём в этом сегменте цены различаются заметно, поскольку сервер с RTX 3090 обходится в 25 000 рублей в месяц или 42 рубля в час, тогда как за RTX 4090 с той же памятью стоит от 27 000 рублей в месяц. Тридцать два гигабайта у RTX 5090 стоят уже около 46 900 рублей в месяц, а 48 гигабайт у A6000 обходятся в 58 000 рублей.
Профессиональные карты стоят заметно дороже игровых. Одинаковый объем памяти у них встречается только на младших моделях, скажем, A5000 и RTX 3090 несут по двадцать четыре гигабайта при близкой цене, и там выбор делают ради коррекции ошибок памяти, которой у игровой карты нет. Дальше идут объемы, которых в игровой линейке не найти, поскольку сорок восемь гигабайт у A6000 обходятся в 58 000 рублей в месяц, семьдесят два гигабайта у RTX PRO 5000 стоят 99 000 рублей, а девяносто шесть гигабайт у RTX PRO 6000 идут по 185 000 рублей. Карты дата-центра, скажем, A100 за 120 000 рублей или H100 за 145 000, берут не только за объем, но и за память с высокой пропускной способностью, которая у них измеряется единицами терабайт в секунду. Стоит помнить и о возможности разделить одну карту на несколько изолированных частей, игровые карты для таких целей не подходят, годятся только профессиональные с технологией разделения на инстансы (MIG).
На нашем стенде стоит игровая карта, поэтому делить её между пользователями средствами драйвера не выйдет. Дальше по тексту встречаются конкретные команды, версии пакетов и замеры, и все они сняты на этой конфигурации:
|
Тип оборудования |
Модель |
Обоснование выбора |
|
Видеокарта |
1x RTX 3090 на 24 гигабайта |
Самая дешевая позиция с таким объемом памяти, архитектура Ampere проходит по текущей ветке CUDA |
|
Процессор |
Ryzen 5900X, 12 ядер и 24 потока |
От четырех до восьми ядер на карту, при меньшем количестве загрузчик данных не успевает ее кормить |
|
Оперативная память |
64 гигабайтf |
Удвоенная видеопамять плюс запас на два-три одновременно работающих ядра JupyterLab |
|
Диск |
1 терабайт NVMe |
Наборы данных, кеш загруженных моделей, окружения пользователей и промежуточные результаты |
|
Операционная система |
Ubuntu 26.04 LTS, ядро 7.0.0-29 |
Свежая версия с длительной поддержкой |
|
Тарификация |
Почасовая |
Стенд брался под разовую настройку, при постоянной работе выгоднее долгосрочная аренда |
Подготовка сервера
Заказываем сервер и дожидаемся его сдачи. Первое, что нам нужно, это обеспечить безопасность машины до того момента, как на ней появится веб-интерфейс. Соответственно, выполняем обновление системы, настраиваем часовой пояс, учетные записи и межсетевой экран:
apt update && apt full-upgrade -y # обновление пакетов до текущих версий
timedatectl set-timezone Europe/Moscow # задаёт часовой пояс системы
apt install -y git curl htop tmux rsync ufw bc # базовый набор утилит
Образы серверов приезжают минимизированными, о чём система сообщает прямо при входе строкой про удалённые пакеты. Отсюда отсутствие rsync и ufw, которые в прошлых версиях стояли по умолчанию, а без настройки часового пояса файловый браузер JupyterLab будет показывать время ошибочно, мелочь, но неприятно.
Далее добавим обычные системные учетные записи, поскольку вход в веб-интерфейс пойдёт через тот же механизм, что и вход по SSH:
useradd -m -s /bin/bash analyst1 # -m создает домашний каталог, -s задает оболочку
useradd -m -s /bin/bash analyst2
echo 'analyst1:ПарольПервый' | chpasswd # задает пароль
echo 'analyst2:ПарольВторой' | chpasswd
passwd -S analyst1 && passwd -S analyst2 # показывает состояние пароля
Без параметра -m домашний каталог не создастся, а без параметра -s оболочка возьмётся из системных настроек и нередко окажется урезанной, что вылезет в терминале внутри JupyterLab. Последняя команда показывает состояние пароля, где во втором поле обязана стоять буква P, поскольку значение L означает заблокированную учётную запись, при которой вход не пройдет.
Задавать пароли через chpasswd удобно для стенда, но на боевой машине пароль остается в истории команд оболочки, там разумнее интерактивный вызов passwd.
Межсетевой экран настраиваем до того, как поднят хаб:
ufw default deny incoming # входящие закрыты по умолчанию
ufw default allow outgoing # исходящие разрешены
ufw allow 22/tcp # доступ по SSH
ufw allow 80/tcp # веб-интерфейс через прокси
ufw allow 443/tcp # он же с шифрованием
ufw --force enable # включение, текущее соединение не обрывается
Хаб работает на 8000 порту, и его не открываем. Снаружи машина показывает обычные веб-порты, а к самому хабу обращается только локальный прокси.
Установка драйверов NVIDIA и CUDA
До установки драйвера можно использовать наш “магический скрипт”, но мы покажем, как выполнить базовые проверки и поставить его вручную:
Для начала проверим, какая у нас система и что вообще на ней творится.
lspci | grep -i nvidia # видит ли система карту на шине
mokutil --sb-state # состояние Secure Boot
uname -r # под это ядро будет собран модуль
Вывод на нашем стенде:
2b:00.0 VGA compatible controller: NVIDIA Corporation GA102
[GeForce RTX 3090] (rev a1)
2b:00.1 Audio device: NVIDIA Corporation GA102 High Definition
Audio Controller (rev a1)
SecureBoot disabled
Platform is in Setup Mode
7.0.0-29-generic
Драйвер собирает модуль ядра прямо на машине, и при активной проверке подписи модуль не загрузится, а после перезагрузки карта пропадёт. Модуль драйвера будет собран под версию ядра, если обновить ядро без пересборки модуля, система перестанет видеть при следующем старте.
Для того чтобы избежать подобных проблем, поставим драйвер из репозитория NVIDIA, а не через файл-установщик, так он будет точно обновляться вместе с системой:
wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2604/x86_64/cuda-keyring_1.1-1_all.deb
dpkg -i cuda-keyring_1.1-1_all.deb # добавляет ключ и репозиторий
apt update # обновляет список пакетов
apt search nvidia-open | head # проверяем доступные версии
Находим:
nvidia-open/unknown 610.57.04-1ubuntu1 amd64
NVIDIA Driver meta-package, Open GPU kernel modules, latest version
Метка unknown сообщает о том, что репозиторий NVIDIA не привязан к кодовому имени выпуска Ubuntu. В пакете nvidia-open содержится драйвер с открытыми модулями ядра, и он является основным для карт поколения Ampere. Ставим его и перезагружаемся:
apt install -y nvidia-open # драйвер с открытыми модулями ядра
reboot #перезагрузка
После перезагрузки системы проверяем:
nvidia-smi
Результат:
+---------------------------------------------------------------+
| NVIDIA-SMI 610.57.04 KMD Version: 610.57.04
| CUDA UMD Version: 13.3 |
+---------------------------------------------------------------+
| 0 NVIDIA GeForce RTX 3090 On | 00000000:2B:00.0 Off |
| 0% 32C P8 11W / 350W | 1MiB / 24576MiB | 0% Default |
| | N/A MIG M. |
+---------------------------------------------------------------+
| No running processes found |
+---------------------------------------------------------------+
Новая шапка вывода содержит три поля, в них указаны версия утилиты и модуля ядра, а также максимальная версия CUDA, которую поддерживает драйвер. Коррекция ошибок памяти недоступна, и это нормально для игрового графического процессора, как и пустое поле MIG M.
И как последний штрих поставим поставим службу для сохранения состояния драйвера:
systemctl enable --now nvidia-persistenced
Без этой службы драйвер будет выгружаться во время простоя карты, что приведет к задержке на инициализацию при последующем запуске.
Нужен ли системный набор инструментов CUDA
Вопрос не самый простой. Давайте проверим, нужен ли нам CUDA Toolkit для нашей задачи:
nvcc --version
Результат:
-bash: nvcc: command not found
Компилятора в системе нет, но наша карта работает, в чём мы убедимся дальше. Готовые сборки PyTorch приносят копии библиотек CUDA (пакеты с приставкой nvidia), и от системы им нужен только драйвер. Если упростить, то системный набор инструментов необходим в основном для сборки собственных вычислительных ядер и при установке сторонних пакетов из исходников, а вот для работы с готовыми библиотеками он не требуется. Библиотека cuDNN тоже не требует отдельной установки и доставляется вместе с PyTorch пакетом nvidia-cudnn-cu13.
Установка Python и виртуальных окружений
Ubuntu 26.04 приезжает с Python 3.14.
python3 -V # версия интерпретатора в системе
Python 3.14.4
Команда apt policy python3 покажет версию 3.14.3, и расхождение тут нормальное, ведь python3 является метапакетом со своей нумерацией, а сам интерпретатор приходит пакетом python3.14 и обновляется отдельно.
Попытка поставить что-нибудь напрямую заканчивается отказом:
error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.
If you wish to install a non-Debian-packaged Python package,
create a virtual environment using python3 -m venv path/to/venv.
Поведение описано в предложении PEP 668. Системный интерпретатор принадлежит менеджеру пакетов, на нём же написана часть системных утилит, и установка сторонних пакетов поверх них приводит к сбоям в работе самой системы. Сообщение упоминает флаг --break-system-packages, снимающий запрет вместе с защитой. Ставим пакеты, которыми создаются виртуальные окружения:
apt install -y python3-venv python3-pip python3-dev # окружения, установка, сборка пакетов
В нашей схеме будут использоваться два окружения. Хаб расположен по пути /opt/jupyterhub, а у каждого из двух пользователей (в дальнейшем будем называть их аналитиками, так просто они у нас названы в системе и так будет проще) свое окружение в домашнем каталоге. Такой подход позволяет избежать проблем, например, когда обновление библиотеки у одного пользователя закрывает вход для других. Создание окружения для аналитика:
sudo -u analyst1 bash -c 'python3 -m venv ~/venvs/ml'
Команду следует выполнять от имени пользователя, в этом случае права на файлы будут принадлежать ему.
Для нашей задачи достаточно штатного механизма venv, но если планируете использовать неродные для Python зависимости, то вам понадобится Conda.
Установка JupyterLab
Несколько лет назад на этом этапе было бы необходимо создать конфигурацию командой jupyter lab --generate-config и назначить пароль через jupyter server password, но при работе через хаб эти шаги не нужны. JupyterLab и так устанавливается дважды, в окружение хаба и в окружение каждого пользователя.
Установка библиотек для машинного обучения
Ставим библиотеки в окружение пользователя:
sudo -u analyst1 bash -c '~/venvs/ml/bin/pip install --upgrade pip'
sudo -u analyst1 bash -c '~/venvs/ml/bin/pip install torch'
Получаем около 2 гигабайт:
nvidia-cublas-13.1.1.3 nvidia-cudnn-cu13-9.20.0.48
nvidia-cufft-12.0.0.61 nvidia-curand-10.4.0.35
nvidia-cusolver-12.0.4.66 nvidia-cusparse-12.6.3.3
nvidia-nccl-cu13-2.29.7 nvidia-nvjitlink-13.3.33
triton-3.7.1 torch-2.13.0
Как раз на этом этапе получаем CUDA, от отдельной установки которого мы отказались ранее.
Проверка доступа к графическому процессору (GPU) в PyTorch
Для проверки настроек выполняем команду:
sudo -u analyst1 ~analyst1/venvs/ml/bin/python -c \
"import torch; print(torch.__version__, torch.cuda.is_available(), \
torch.cuda.get_device_name(0))"
И получаем результат:
UserWarning: Failed to initialize NumPy: No module named 'numpy' 2.13.0+cu130 True NVIDIA GeForce RTX 3090
Метка cu130 свидетельствует о том, что сборка сделана под ветку CUDA 13. Значение True подтверждает доступность карты. Компилятора nvcc в системе нет. Предупреждение о NumPy — результат того, что свежий PyTorch не тянет его обязательной зависимостью. Ставим NumPy и регистрируем ядро, чтобы оно появилось в списке вариантов при создании ноутбука:
sudo -u analyst1 bash -c '~/venvs/ml/bin/pip install numpy pandas matplotlib scikit-learn ipykernel jupyterlab-git nbdime'
sudo -u analyst1 bash -c '~/venvs/ml/bin/python -m ipykernel install --user --name ml --display-name "Python (ml)"'
В результате выполнения второй команды получаем:
Installed kernelspec ml in /home/analyst1/.local/share/jupyter/kernels/ml
На стенде встали:
- numpy 2.5.2;
- pandas 3.0.5;
- scikit-learn 1.9.0;
- ipykernel 7.3.0;
- jupyterlab-git 0.54.1;
- nbdime 4.0.4.
Третья версия pandas обладает своими особенностями, например, копирование при записи по умолчанию, пропало предупреждение о копиях и так далее. Код, созданный для второй версии pandas, может нуждаться в доработках.
TensorFlow
Поставить TensorFlow на Ubuntu 26.04 мы пробовали четырьмя способами, и ни один не сработал. Установка в виртуальное окружение сразу прервалась. Система приезжает с Python 3.14, а готовых сборок под этот интерпретатор у TensorFlow нет:
ERROR: Could not find a version that satisfies the requirement
tensorflow[and-cuda] (from versions: none)
ERROR: No matching distribution found for tensorflow[and-cuda]
Далее мы попробовали сторонний репозиторий deadsnakes, хотя это не самый безопасный способ, так как авторы репозитория предупреждают, что своевременные обновления при проблемах безопасности не гарантируются, на боевом сервере подобный вариант может быть использован на свой страх и риск.
add-apt-repository -y ppa:deadsnakes/ppa
apt install -y python3.13 python3.13-venv
sudo -u analyst1 bash -c 'python3.13 -m venv ~/venvs/tf13'
sudo -u analyst1 bash -c '~/venvs/tf13/bin/pip install "tensorflow[and-cuda]"'
Установка прошла, TensorFlow 2.21.0 развернут, но устройство не регистрируется.
Cannot dlopen some GPU libraries.
Skipping registering GPU devices...
2.21.0 []
Обходной путь через окружение на предыдущей версии интерпретатора тоже не дал положительного результата. Штатный репозиторий Ubuntu 26.04 не поддерживает старые версии Python:
apt install -y python3.13
Error: Unable to locate package python3.13
Официальный образ контейнера дает тот же результат на двух метках: latest-gpu и 2.21.0-gpu, хотя карта в контейнер пробрасывается штатным инструментарием NVIDIA.
Если вам нужен именно TensorFlow, то имеет смысл использовать систему постарше и драйвер из двенадцатой ветки. PyTorch к свежему железу и свежей системе адаптирован, TensorFlow пока нет.
Установка JupyterHub
Хабу необходим вспомогательный прокси, так как маршруты к пользовательским серверам меняются на ходу при каждом входе и остановке:
apt install -y nodejs npm nginx # nodejs и npm нужны прокси, nginx смотрит наружу
npm install -g configurable-http-proxy # через него хаб раздает маршруты пользователя
Установка npm из репозитория Ubuntu включает 538 пакетов и занимает 494 мегабайта на диске. Среди пакетов содержатся драйверы Vulkan, шрифты, библиотеки X11 и графический эмулятор терминала на сервер, у которого нет экрана. Пакет тянет весь набор инструментов для разработки на JavaScript. Смягчается это либо флагом --no-install-recommends (отказ от рекомендаций), либо установкой Node из репозитория NodeSource.
python3 -m venv /opt/jupyterhub # создание окружения для хаба
/opt/jupyterhub/bin/pip install --upgrade pip # обновляет установщик пакетов
/opt/jupyterhub/bin/pip install jupyterhub jupyterlab # сам хаб и рабочая среда
/opt/jupyterhub/bin/pip install jupyterhub-systemdspawner # запуск сессий с лимитами
/opt/jupyterhub/bin/pip install jupyterhub-idle-culler # остановка простаивающих сессий
В результате мы получили такие версии ПО:
- JupyterHub 5.5.1;
- JupyterLab 4.6.3;
- jupyter-server 2.20.0;
- ipykernel 7.3.0;
- systemdspawner 1.0.2;
- idle-culler 2.0.0;
- pamela 1.2.0.
Окружение занимает 347 мегабайт, ни один пакет не собирался из исходников.
Многопользовательский доступ
Настраиваем минимальную конфигурацию:
c.JupyterHub.bind_url = 'http://127.0.0.1:8000' # слушает только петлю
c.Spawner.default_url = '/lab' # открывать JupyterLab, а не старый вид
c.Spawner.cmd = ['/opt/jupyterhub/bin/jupyterhub-singleuser'] # чем запускать сервер
И сразу получаем ошибку: запуск проходит, интерфейс открывается, аутентификация работает, но пользователи не могут войти в веб-интерфейс и получают сообщение:
Сообщение на скриншоте не соответствует действительности, и учетные данные введены верно, ошибку удается можно отследить в логах:
[W JupyterHub auth:758] User 'analyst1' not allowed.
[W JupyterHub base:998] Failed login for 'analyst1'
[W JupyterHub log:192] 403 POST /hub/login
Причина такой ошибки кроется в изменении поведения по умолчанию пятой версии JupyterHub. В ней пустой список разрешенных пользователей означает запрет на вход для всех. Ранее хаб пускал любого пользователя, прошедшего проверку пароля.
Исправляем проблему:
c.Authenticator.allowed_users = {'analyst1', 'analyst2'} # кому разрешён вход
c.Authenticator.admin_users = {'analyst1'} # видит и останавливает чужие сессии
Альтернативный вариант предполагает использование параметра allow_all, он открывает доступ любому пользователю, вошедшему в систему по SSH. Этот вариант может подойти для небольших команд, но крайне опасен для сервера с внешним адресом.
При успешной авторизации в журнале отразится вся цепочка событий:
[I JupyterHub base:988] User logged in: analyst1
[I JupyterHub spawner:2068] Spawning /opt/jupyterhub/bin/jupyterhub-singleuser
[I JupyterHub base:1143] User analyst1 took 4.715 seconds to start
[I JupyterHub proxy:331] Adding user analyst1 to proxy /user/analyst1/ => http://127.0.0.1:40833
Процесс работает от имени пользователя, проверяется командой ps:
analyst1 /opt/jupyterhub/bin/python3
/opt/jupyterhub/bin/jupyterhub-singleuser
На нашем чистом стенде процесс запуска занял 4.7 секунды, на уже нагруженном сервере это время может значительно увеличиться.
Служба systemd
После запуска хаба будет полезен юнит, способный пережить как разрыв соединения, так и перезагрузку:
[Unit]
Description=JupyterHub
After=network-online.target # старт после того, как поднялась сеть
Wants=network-online.target
[Service]
User=root # права нужны для паролей и лимитов
WorkingDirectory=/etc/jupyterhub # рядом лягут файл с ключом и база данных
Environment="PATH=/opt/jupyterhub/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
ExecStart=/opt/jupyterhub/bin/jupyterhub -f /etc/jupyterhub/jupyterhub_config.py
Restart=always # перезапуск при падении
RestartSec=5 # пауза, чтобы цикл не забивал журнал
[Install]
WantedBy=multi-user.target
Рабочий каталог указан специально, так как хаб создает файл с ключом для подписи cookie и базу данных SQLite, о чем предупреждает при первом запуске:
[I JupyterHub app:1885] Writing cookie_secret to /etc/jupyterhub/jupyterhub_cookie_secret
И еще одно важное предупреждение при старте объясняет, зачем в дальнейшем обратный прокси.
[W JupyterHub proxy:748] Running JupyterHub without SSL. I hope there is SSL termination happening somewhere else…
Автоматическая остановка простаивающих сессий
Ядро JupyterLab держит загруженные данные в памяти до тех пор, пока не будет остановлено. И это может приводить к пустой трате ресурсов, например, если пользователь не работает на машине, но не закончил сессию. И такие ситуации можно предусмотреть:
c.JupyterHub.services = [
{
'name': 'idle-culler',
'command': [
'/opt/jupyterhub/bin/python3',
'-m', 'jupyterhub_idle_culler',
'--timeout=3600',
],
}
]
c.JupyterHub.load_roles = [
{
'name': 'list-and-cull',
'scopes': [
'list:users',
'read:users:activity',
'read:servers',
'delete:servers',
],
'services': ['idle-culler'],
}
]
Явный путь к интерпретатору поставлен из осторожности. Документация предлагает переменную sys.executable, она указывает на тот интерпретатор, которым запущен хаб, то есть работает правильно. Требует эта переменная строки import sys в начале файла настроек, и без нее конфигурация не загрузится.
Ограничение ресурсов
Штатный механизм запуска пользовательских серверов не предполагает наложения ограничений на используемые ресурсы. С одной стороны, это удобно для одного пользователя, но с другой может привести к быстрому исчерпанию ресурсов сервера. Замена механизма, работающего через systemd, позволяет установить ограничения через контрольные группы ядра:
c.JupyterHub.spawner_class = 'systemdspawner.SystemdSpawner' # только он умеет лимиты
c.SystemdSpawner.mem_limit = '8G' # потолок памяти на человека
c.SystemdSpawner.cpu_limit = 4.0 # процессорное время в пересчете на ядра
c.SystemdSpawner.isolate_tmp = True # свой временный каталог, чужие файлы не видны
После настройки стоит проверить, дошли ли лимиты до системы или остались в файле настроек:
systemctl show jupyter-analyst1-singleuser --property=MemoryMax,CPUQuotaPerSecUSec,MemoryCurrent,TasksCurrent # показывает действующие лимиты
MemoryCurrent=136945664
TasksCurrent=4
CPUQuotaPerSecUSec=4s
MemoryMax=8589934592
В результате получаем ограничение памяти на 8 гигабайт и квоту процессора в четыре ядра. Пустая среда весит 136,9 мегабайт. Юнит существует только пока пользователь работает, и при отсутствующей сессии команда покажет значение infinity.
Со стороны пользователя ограничения выглядят иначе. Ячейка, набирающая память по 500 мегабайт за шаг, дойдет до 8 гигабайт и остановится, браузер сообщит о смерти ядра, но причин не назовет, что может заставить пользователя предположить, что сломан его код.
Memory cgroup out of memory: Killed process 8697 (python)
total-vm:13635980kB, anon-rss:8342092kB, UID:1000
oom-kill:constraint=CONSTRAINT_MEMCG,
oom_memcg=/system.slice/jupyter-analyst1-singleuser.service
Строка CONSTRAINT_MEMCG означает, что сработал наш лимит, а не общая нехватка памяти, свободными на машине оставались больше пятидесяти гигабайт. Квота процессора работает по-другому. Значение cpu_limit превращается в разрешенное процессорное время за секунду реального, и лимит в четыре ядра не привязывает работу к четырем конкретным. Планировщик раскидывает процессы по всем двадцати четырем потокам, и в мониторинге это выглядит как загрузка машины на шестую часть.
Проверим. Запускаем в ноутбуке нагрузку в двадцать четыре потока и смотрим потраченное процессорное время за тридцать секунд:
A=$(systemctl show jupyter-analyst1-singleuser -p CPUUsageNSec --value); sleep 30; B=$(systemctl show jupyter-analyst1-singleuser -p CPUUsageNSec --value); echo "cores used: $(echo "scale=2; ($B - $A) / 30000000000" | bc)"
cores used: 3.99
А так нагрузка выглядит со стороны системы:
top -bn1 | head -3
top - 16:55:59 up 1 day, 1:16, 1 user, load average: 0.03, 0.03, 0.01
Tasks: 414 total, 25 running, 389 sleeping, 0 stopped, 0 zombie
%Cpu(s): 15.8 us, 0.4 sy, 0.0 ni, 83.8 id
Пятнадцать процентов при двадцати четырех потоках и есть заявленные четыре ядра.
Лимиты графического процессора
При запуске пользовательского сервера хаб подставляет переменную, раздающую карты:
GPU_MAP = {'analyst1': '0', 'analyst2': ''}
def assign_gpu(spawner):
spawner.environment['CUDA_VISIBLE_DEVICES'] = GPU_MAP.get(spawner.user.name, '')
c.Spawner.pre_spawn_hook = assign_gpu
При двух картах в GPU_MAP стояли бы значения ноль и один, и каждый пользователь видел бы свою. У нас карта одна, поэтому второму аналитику досталось пустое значение и работа на процессоре.
Проверка под первым пользователем выглядит так:
import torch
print(torch.cuda.is_available(), torch.cuda.get_device_name(0))
# True NVIDIA GeForce RTX 3090
Второй пользователь при использовании этой же команды получит ошибку импорта. Причина ошибки в том, что у него нет окружения PyTorch, но переменную он увидит:
import os
print(os.environ.get('CUDA_VISIBLE_DEVICES'))
# пустая строка
Этот способ имеет границы применения. Пользователь может переопределить переменную прямо из своего ноутбука:
import os
os.environ['CUDA_VISIBLE_DEVICES'] = ''
import torch
print(torch.cuda.is_available())
# False
Раз пользователь может стереть значение переменной, он может и вписать туда чужую карту. Хаб не запрещает доступ к устройствам, а лишь подсказывает библиотеке, какие из них брать.
Настройка безопасности
Обратный прокси
До этого момента мы работали с доступом через туннель, и это явно неудобно, такой подход годится только для тестирования или небольших коллективов. Для продуктовой среды нужен обычный адрес. Ставим nginx перед хабом:
server {
listen 80;
server_name jupyter.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
client_max_body_size 2G;
}
}
Строки proxy_http_version, Upgrade и Connection обязательны. Хоть без них интерфейс загрузится и файлы будут видны, но ядро не запустится, поскольку связь с ним идет по постоянному соединению. Параметр client_max_body_size нужен для загрузки наборов данных через файловый браузер.
Затем включаем нашу конфигурацию, отключаем конфигурацию default и перезагружаем nginx:
ln -sf /etc/nginx/sites-available/jupyterhub /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
Теперь хаб предупреждает, что соединение не защищено:
Сертификат Let's Encrypt
Для выпуска нужна А запись в зоне домена, указывающая на адрес сервера. Проверить можно командой:
dig +short jupyter.example.com # должен ответить адресом сервера
Перед выпуском сертификата можно провести репетицию, потому что одинаковых сертификатов выдают ограниченное количество в неделю:
apt install -y certbot python3-certbot-nginx
certbot certonly --nginx -d jupyter.example.com --dry-run
Репетиция умеет только получать сертификат, настройки веб-сервера она не предполагает:
--dry-run currently only works with the 'certonly' or 'renew' subcommands ('run')
После успешной репетиции выпускаем уже действующий сертификат:
certbot --nginx -d jupyter.example.com
Сертификат выпущен, но установить его в конфигурацию сразу у нас не вышло:
Successfully received certificate.
Could not install certificate
Could not automatically find a matching server block for jupyter.example.com.
Set the `server_name` directive to use the Nginx installer.
В конфигурации у нас стояло подстановочное имя server_name _, а certbot ищет блок с конкретным именем домена. Сертификат при этом уже выпущен и лимит потрачен, повторять выпуск не нужно, достаточно вписать имя домена в конфигурацию, перезагрузить nginx и повторить установку сертификата отдельной командой:
sed -i 's/server_name _;/server_name jupyter.example.com;/' /etc/nginx/sites-available/jupyterhub
nginx -t && systemctl reload nginx
certbot install --cert-name jupyter.example.com
Проверяем:
certbot certificates
Certificate Name: jupyter.example.com
Key Type: ECDSA
Expiry Date: 2026-11-09 (VALID: 89 days)
и
curl -sI http://jupyter.example.com | head -2
HTTP/1.1 301 Moved Permanently
Server: nginx/1.28.3 (Ubuntu)
Указано и время действия сертификата в 90 дней, и 301 код подтверждает, что при переходе по адресу без шифрования будет нужное перенаправление.
Мониторинг и логирование
Пользовательские серверы являются отдельными юнитами, поэтому наиболее удобным вариантом нам показался systemd:
systemctl list-units 'jupyter-*' # активные сессии
systemd-cgls -u jupyter-analyst1-singleuser.service # дерево процессов
systemd-cgtop показывает общую картину, и вместе с пользовательскими серверами видны системные службы. Первый проход этой команды показывает нули, а значения считаются как разница между двумя замерами. Состояние карты проверяется штатной утилитой:
nvidia-smi # разовый снимок
watch -n1 nvidia-smi # обновление раз в секунду
Журналы разнесены:
journalctl -u jupyterhub -f # сам хаб
journalctl -u jupyter-analyst1-singleuser # сессия пользователя
dmesg -T | grep -iE "oom-kill|killed process" # сообщения ядра
Подобное разнесение метрик полезно при разборе ошибок администратором.
Полноценный сбор метрик мы здесь не разбираем, поскольку это отдельная задача со своей инфраструктурой. Хаб отдаёт метрики в формате Prometheus по адресу /hub/metrics, причём документация упоминает /metrics, а на деле обращение к нему возвращает перенаправление. Доступ закрыт, и ключа обычного пользователя мало, требуется отдельно выданное право read:metrics. Среди доступных метрик есть jupyterhub_running_servers с числом запущенных пользовательских серверов и jupyterhub_server_spawn_duration_seconds со временем их запуска.
Резервное копирование
Домашние каталоги могут занимать много места, и копировать их целиком — это весьма ресурсозатратный подход. Полный объем /home на нашем стенде занимал 7.9 гигабайта, а копия с исключениями — всего 348 килобайт. Разница приходится на окружения и кеши, которые легко поставить заново с помощью одной команды установки, соответственно, и в архив они не попадают.
#!/bin/bash
set -euo pipefail # остановка при первой ошибке
# исключаем окружения, кеши, служебные копии ноутбуков,
# файлы работающих ядер и скомпилированные модули
rsync -a --delete \
--exclude 'venvs/' \
--exclude '.cache/' \
--exclude '.ipynb_checkpoints/' \
--exclude '.local/share/jupyter/runtime/' \
--exclude '__pycache__/' \
/home/ /backup/home/
Расписание удобнее вести таймером systemd, а не через cron, поскольку у таймера есть журнал, состояние и возможность запустить обработчик при сбое. Юнитов нужно два: служба с самим скриптом и таймер с расписанием.
Первый:
# /etc/systemd/system/backup-notebooks.service
[Unit]
Description=Backup notebooks
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-notebooks.sh
Второй:
# /etc/systemd/system/backup-notebooks.timer
[Unit]
Description=Backup notebooks daily
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.target
Включаем таймер и проверяем время следующего запуска:
systemctl daemon-reload
systemctl enable --now backup-notebooks.timer # включает ежедневное копирование
systemctl list-timers backup-notebooks --no-pager # время следующего запуска
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-08-12 00:00:00 CEST 6h - - backup-notebooks.timer backup-notebooks.service
Параметр Persistent догоняет пропущенный запуск, если машина была выключена в назначенное время. Копия на том же сервере спасает от ошибочного удаления файла. На случай потери всего сервера необходимо использовать отдельное хранилище. Историю изменений ноутбуков ведёт система контроля версий.
Траблшутинг
Все случаи, перечисленные ниже, встречались нам на практике.
Ошибка импорта при установленной библиотеке
ModuleNotFoundError: No module named 'torch'
Каждое ядро смотрит в своё окружение. Ядро Python 3 (ipykernel) приходит из окружения хаба, где библиотек для расчётов нет. Работать необходимо в ядре Python (ml), зарегистрированном из пользовательского окружения.
Пароль верный, но не получается войти в веб-интерфейсе
Ошибка встречается при попытке ввести данные пользователя в веб-интерфейсе. Браузер сообщает о неверных данных, в журнале стоит User ... not allowed. Этот кейс подробно разобран в разделе про многопользовательский доступ.
Учетная запись заблокирована
Симптомы у этой ошибки аналогичные, но причина в том, что пароль не задан.
passwd -S имя_пользователя
имя_пользователя P 2026-08-11 0 99999 7 -1
Буква P в выводе свидетельствует об установленном пароле, L — сообщает о блокировке. Флаг --disabled-password при создании пользователя оставляет как раз состояние L.
Служба активна, но не работает
Статус показывает active (running), но хаб недоступен.
Active: activating (auto-restart) (Result: exit-code)
Process: 8928 ExecStart=... (code=exited, status=1/FAILURE)
Параметр Restart=always поднимает упавшую службу заново, из-за чего снаружи всё выглядит нормально, но номер процесса в журнале изменяется каждые несколько секунд.
Причина видна в логах:
[ConfigProxy] error: listen EADDRINUSE: address already in use 127.0.0.1:8000
tornado.httpclient.HTTPClientError: HTTP 403: Forbidden
От прежнего запуска остался живой прокси, и новый старт спотыкается об занятые порты. Вдобавок хаб получает отказ доступа к чужому прокси, поскольку ключ у прежнего запуска был другой.
Найти виновника по имени не выйдет, в списке процессов прокси значится как node.
ss -tlnp | grep -E ':8000|:8001'
LISTEN 127.0.0.1:8000 users:(("node",pid=9340,fd=24))
Проще и надежнее чистить по портам:
systemctl stop jupyterhub
fuser -k 8000/tcp 8001/tcp # убивает то, что держит порты хаба
systemctl start jupyterhub
Здоровая служба показывает один номер процесса, девять задач и занимает около 128 мегабайт.
Нехватка памяти
Браузер сообщает о смерти ядра, не указывая причину ошибки, а она лежит в сообщениях ядра операционной системы. Эта ошибка подробно разобрана в разделе про ограничение ресурсов.
Альтернативы и дополнения
Совместная интерактивная работа с данными далеко не всегда требует отдельного сервера с графическим процессором. Есть и иные варианты.
Google Colab против собственного сервера
Бесплатный уровень Google Colab позволяет закрыть возможные учебные задачи, провести разовые эксперименты и демонстрации и не требует технической подготовки для администрирования железа. Из последнего преимущества вытекает и главный недостаток: сессии живут на протяжении ограниченного времени, карта выдается пользователю по остаточному принципу или может быть вообще не предоставлена. Другим недостатком является необходимость подключать данные через Google Диск.
Платные уровни снимают часть ограничений, но и при них остается проблема конфиденциальности данных, если она строго необходима, лучше выбирать свой сервер, с ним вы сами контролируете данные, но и отвечаете за его безопасность.
VS Code Server
Если вы пишете код модулями, а не ячейками, то могут подойти два разных решения. Code-server ставится на сервер с графическим процессором и позволяет открывать редактор в браузере. Механизм удалённых туннелей от Microsoft соединяет установленный у себя редактор с сервером через посредника и требует учетной записи GitHub или Microsoft, что опять-таки может помешать выбрать этот вариант для корпоративных нужд.
Для тех, кто работает на R, есть RStudio Server с бесплатной и платной версиями. Устанавливается он на сервер с видеокартой и открывается через браузер.
Что в итоге?
Настройка занимает примерно один рабочий день с момента сдачи сервера, если использовать не последнюю версию ОС, времени потребуется меньше.
Процесс будет проще, если помнить о нескольких существенных моментах. Системный набор инструментов CUDA почти никогда не требуется, библиотеки для расчетов приносят свои копии сами. Пятая версия JupyterHub никого не пускает, пока не задан список разрешенных пользователей. Служба в состоянии running иногда не работает вовсе, падает и перезапускается по кругу. Ограничение памяти выглядит для пользователя смертью ядра без объяснения причин. Раздача карт переменной окружения распределяет доступ, а не защищает его.
Закажите сервер с готовыми решениями для Data Science, научных вычислений и машинного обучения.