Turning Old Server into AI Master - Part 2 (The Software)

If you haven't read Part One, you can here - https://www.pdavies.io/blog/turning-old-server-into-ai-master/

I have taken some old Dell servers and am turning it into an AI beast to run at home and play around with. Part one deals with the hardware and the issues I had building the server; this post will be the software setup. I will not tell you how I installed Ubuntu; I think that these days, enough sites are telling you that.

Please note that I am not an expert at AI/ML, and this is/has been a learning experience for me thus what I do below may not be the best and better way of doing things, but this is just what I have pieced together so far from Chatgpt/Cluade.ai and different blogs, doc and sites.

Nvidia Software

I installed an Nvidia Tesla P100 GPU into this server, so I first installed the drivers and CUDA software.

Go to the Cuda download page and select the driver environment suitable to your system.

The first part of doing it apt-get -y install cuda-toolkit-12-8 took a long time, so beware!

Need to get 3,726 MB of archives.
After this operation, 8,869 MB of additional disk space will be used.

Now I have a really simple Ansible code for doing this, and before anyone says anything is its hardcoded to versions and blah blah and no if I were doing this for a production system I wouldn't have hard coded!

---
- name: Download NVIDIA CUDA keyring package
  get_url:
    url: https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb
    dest: /tmp/cuda-keyring_1.1-1_all.deb
    mode: '0644'
  register: keyring_download

- name: Install NVIDIA CUDA keyring package
  apt:
    deb: /tmp/cuda-keyring_1.1-1_all.deb
    state: present
  become: yes
  when: keyring_download.changed

- name: Update apt cache
  apt:
    update_cache: yes
  become: yes

- name: Install CUDA toolkit
  apt:
    name: cuda-toolkit-12-8
    state: present
  become: yes

- name: Install CUDA drivers
  apt:
    name: cuda-drivers
    state: present
  become: yes

- name: Clean up downloaded packages
  file:
    path: /tmp/cuda-keyring_1.1-1_all.deb
    state: absent
  become: yes

IMPORTANT: Restart the server after installation is complete!

If you don't restart and try to check out the GPU status using nvidia-smi you will get something like this:

But if you have restarted, then you'll get all the details:

Docker/Portainer

I installed Docker as I plan on running a few things in Docker to make it easier to manage; again, I have Ansible do this for me, and you can find loads of examples, so I won't get into that!

Portainer is just a management UI for Docker to make it easier again. I already have a Portainer server running, so I just added the Portainer agent for this.

Visual Studio Code Server (Coder)

My wife may also want to play around with this thing, so, I will install coder. It is just a Visual Studio Code server for multi-user access vs. code-server, which is single-user!

Hours go past, and..... I find....

Coder is not just for VS code servers; it's an awesome development platform tool, but I won't go into that today. Also, I found that I didn't need this for what I was planning; I needed a Juptyer notebooks server, and then I could connect my local VS Code to Jupter to run my code.

So, guess what is next 😄

JupyterHub

JupyterHub vs. Jupyter Notebook

These are related but distinct tools in the Jupyter ecosystem that serve different purposes:

Jupyter Notebook

Jupyter Notebook is a single-user application that allows you to create and share documents containing:

  • Live code
  • Visualizations
  • Narrative text
  • Equations

Key characteristics:

  • Runs locally on your computer
  • Serves one user at a time
  • Primary interface is a web browser connecting to a local server
  • Files are saved as .ipynb notebooks
  • Ideal for individual data analysis, experimentation, and sharing results

JupyterHub

JupyterHub is a multi-user server that manages and proxies multiple instances of the single-user Jupyter Notebook server.

,

Key characteristics:

  • Designed for teams, classes, or organizations
  • Centrally deployed on a server
  • Manages authentication and user sessions
  • Spawns individual notebook servers for each user
  • Can handle resources, permissions, and quotas
  • Supports various authentication systems (like GitHub OAuth, LDAP, etc.)
  • Requires more administration and setup

So now we know the difference, I went with Hub because I want multi-user. So let's look at the ansible role I built. Now I am sure there are already roles built to deploy this, but I wanted to do it to help get a true understanding of how it's working.

Lets start with the templates:

I want this to run as a systems process so I have created a service unit file

[Unit]
Description=JupyterHub
After=network.target

[Service]
User=root
# Source the GitHub OAuth environment variables if enabled
{% if jupyterhub_github_oauth_enabled | default(false) %}
EnvironmentFile={{ jupyterhub_folder }}/github_oauth.env
{% endif %}
Environment="PATH={{ jupyterhub_folder }}/venv/bin:/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin"
ExecStart={{ jupyterhub_folder }}/venv/bin/jupyterhub -f {{ jupyterhub_folder }}/jupyterhub_config.py
WorkingDirectory={{ jupyterhub_folder }}
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Next, the config file, and this is the real guts of it all. I put the end config file I have and explain below and if you like the ansible template I will add that below:

/opt/jupyterhub/jupyterhub_config.py

import os
import pwd
import grp
import shutil

from oauthenticator.github import LocalGitHubOAuthenticator

def create_user_dir_hook(spawner):
    """Create user directory with correct permissions before spawning the notebook server."""
    # Get username
    username = spawner.user.name

    # Define the directory path
    notebook_dir = f'/mnt/storage/jupyterhub/notebooks/{username}'

    # Create directory if it doesn't exist
    if not os.path.exists(notebook_dir):
        os.makedirs(notebook_dir, exist_ok=True)

    # Get user ID and group ID
    try:
        uid = pwd.getpwnam(username).pw_uid
        gid = pwd.getpwnam(username).pw_gid
    except KeyError:
        # If the user doesn't have a local system account, use a default
        # This might happen with GitHub OAuth where system users aren't created
        return

    # Set ownership
    os.chown(notebook_dir, uid, gid)

    # Set permissions (rwx for user, r-x for group and others)
    os.chmod(notebook_dir, 0o755)

    # Optionally, you could add starter notebooks
    # shutil.copy('/path/to/welcome.ipynb', os.path.join(notebook_dir, 'welcome.ipynb'))
    # os.chown(os.path.join(notebook_dir, 'welcome.ipynb'), uid, gid)


c = get_config()

# Basic JupyterHub settings
c.JupyterHub.ip = '0.0.0.0'
c.JupyterHub.port = 8000
c.JupyterHub.hub_ip = '0.0.0.0'
c.JupyterHub.bind_url = 'https://hub.pdavies.io'

# GitHub OAuth settings
c.JupyterHub.authenticator_class = 'oauthenticator.github.LocalGitHubOAuthenticator'
c.LocalGitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
c.LocalGitHubOAuthenticator.client_id = os.environ['GITHUB_CLIENT_ID']
c.LocalGitHubOAuthenticator.client_secret = os.environ['GITHUB_CLIENT_SECRET']

c.LocalGitHubOAuthenticator.allowed_organizations = ['TechSphereAu']

# Admin users
c.Authenticator.admin_users = set(['wonderphil'])

# Spawner configuration
c.JupyterHub.spawner_class = 'systemdspawner.SystemdSpawner'
c.SystemdSpawner.default_shell = '/bin/bash'
c.SystemdSpawner.cmd = ['/mnt/storage/opt/jupyterhub/venv/bin/jupyterhub-singleuser']
c.Spawner.notebook_dir = '/mnt/storage/jupyterhub/notebooks/{username}'
c.Spawner.default_url = '/lab'
c.Spawner.pre_spawn_hook = create_user_dir_hook

# Create system users if using local spawner
c.LocalAuthenticator.create_system_users = True

# Security
c.JupyterHub.cookie_secret_file = '/mnt/storage/opt/jupyterhub/jupyterhub_cookie_secret'

# Specify overrides from variables
c.SystemdSpawner.isolate_tmp = True
c.SystemdSpawner.isolate_devices = True
c.Spawner.http_timeout = 300
c.Spawner.debug = True
c.JupyterHub.debug_db = False
c.JupyterHub.log_level = 'DEBUG'
c.SystemdSpawner.use_sudo = False

First we have a "pre spawn hook" and the reason for this is I am having all the files stored on different drive and instead of doing symlinks or changing the system to change home locations, I have a simple hook that creates the folder and sets the correct permissions.

Next I have basic Hub config and this is to allow nginx to be reverse proxy for it:

# Basic JupyterHub settings
c.JupyterHub.ip = '0.0.0.0'
c.JupyterHub.port = 8000
c.JupyterHub.hub_ip = '0.0.0.0'
c.JupyterHub.bind_url = 'https://hub.pdavies.io'

Next is the Oauth setting, so I don't need to manage users I am using our github org, this also includes who is admin:

# GitHub OAuth settings
c.JupyterHub.authenticator_class = 'oauthenticator.github.LocalGitHubOAuthenticator'
c.LocalGitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
c.LocalGitHubOAuthenticator.client_id = os.environ['GITHUB_CLIENT_ID']
c.LocalGitHubOAuthenticator.client_secret = os.environ['GITHUB_CLIENT_SECRET']

c.LocalGitHubOAuthenticator.allowed_organizations = ['TechSphereAu']

# Admin users
c.Authenticator.admin_users = set(['wonderphil'])

Next and this was the important part to get right, as when it's wrong you get all sorts of errors and you hub server wont spaw and servers for you.

# Spawner configuration
c.JupyterHub.spawner_class = 'systemdspawner.SystemdSpawner'
c.SystemdSpawner.default_shell = '/bin/bash'
c.SystemdSpawner.cmd = ['/mnt/storage/opt/jupyterhub/venv/bin/jupyterhub-singleuser']
c.Spawner.notebook_dir = '/mnt/storage/jupyterhub/notebooks/{username}'
c.Spawner.default_url = '/lab'
c.Spawner.pre_spawn_hook = create_user_dir_hook

# Create system users if using local spawner
c.LocalAuthenticator.create_system_users = True

c.SystemdSpawner.isolate_tmp = True
c.SystemdSpawner.isolate_devices = True
c.Spawner.http_timeout = 300
c.Spawner.debug = True
c.SystemdSpawner.use_sudo = False

This basically allows me to spin up a notebook server for every user and separate it. Because I have deployed into a Python virtual environment, I needed to give the path to the jupyterhub-singleuser exe. I also tell it where to create a "home/notebook" dir for said user, tell it about the pre-hook and then some other setting to ensure they do not know anything between users.

This probably took the longest to get right, and while troubleshooting, I figured few things that are helpful:

> systemctl list-units | grep jupyter
jupyter-wonderphil-singleuser.service                                                                                 loaded active running   /mnt/storage/opt/jupyterhub/venv/bin/jupyterhub-singleuser
  jupyterhub.service                                                                                                    loaded active running   JupyterHub

Once you find the name of the server in this case jupyter-wonderil-singleuser.service you can run the following to see the issues with it:

systemctl status jupyter-wonderphil-singleuser.service 
# This was the most useful

# second was journalctl but I found this hard to keep track off
journalctl -xeu jupyter-wonderphil-singleuser.service

The biggest problem I had was the local user wonderphil not having access to the file system, so make sure if you have an issue you sudo -u <user> and try to access files and folders, run the spawner command and see what happens.

ok back to Ansible, the second last template I had was to house the github creds, put in separate file so less chance of others see it, I know its not the greatest but its better then nothing at this point.

GITHUB_CLIENT_ID={{ github_client_id }}
GITHUB_CLIENT_SECRET={{ github_client_secret }}
OAUTH_CALLBACK_URL={{ github_callback_url }}

and last template was for Nginx, now I have a main Nginx server to route all my traffic, so I just had basic nginx config to deploy:

upstream hub-{{ deployment_environment }} {
  server {{ main_server }}:{{ host_port }};
}

server {
  listen 80;
  server_name {{ hub_domain }};
  return 301 https://$host$request_uri;
}


server {
  listen 443 ssl;
  server_name {{ hub_domain }};

  access_log  /var/log/nginx/access-{{ deployment_environment }}-hub.log json_log;
  error_log  /var/log/nginx/error-{{ deployment_environment }}-hub.log;

  ssl_certificate /etc/letsencrypt/live/{{ certificate_name }}/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/{{ certificate_name }}/privkey.pem;

  ssl_protocols TLSv1.2 TLSv1.3;
  ssl_prefer_server_ciphers on;

  ssl_dhparam /etc/ssl/dhparams.pem; # openssl dhparam -out /etc/nginx/dhparam.pem 4096
  ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-SHA384;
  ssl_ecdh_curve secp384r1; # Requires nginx >= 1.1.0
  ssl_session_timeout  10m;
  ssl_session_cache shared:SSL:10m;
  ssl_session_tickets off; # Requires nginx >= 1.5.9
  ssl_stapling on; # Requires nginx >= 1.3.7
  ssl_stapling_verify on; # Requires nginx => 1.3.7
  resolver 8.8.8.8 valid=300s;
  resolver_timeout 5s;

  # Security headers
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
  add_header X-Content-Type-Options nosniff;
  add_header X-XSS-Protection "1; mode=block";

  # Proxy headers
  proxy_set_header Host $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;

  # WebSocket support
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";

  # Proxy timeouts
  proxy_read_timeout 86400;
  proxy_send_timeout 86400;

  # hubHub proxy
  location / {
    proxy_pass http://hub-{{ deployment_environment }};

    # Important for hubHub to handle redirects properly
    proxy_redirect http://hub-{{ deployment_environment }}/ $scheme://$host/;
  }

  # Static files caching (optional)
  location ~* \.(?:jpg|jpeg|gif|png|ico|svg|css|js)$ {
    proxy_pass http://hub-{{ deployment_environment }};
    proxy_cache_valid 30m;
    expires 30m;
    add_header Cache-Control "public";
  }
}

ok after that comes the tasks in Ansible to deploy everything, pretty self explanatory so will share but won't go into detail:

---
- name: Install required packages
  apt:
    name:
      - python3
      - python3-pip
      - python3-dev
      - python3-venv
      - nodejs
      - npm
      - git
    state: present
  become: true

- name: Install configurable-http-proxy
  npm:
    name: configurable-http-proxy
    global: true
  become: true

- name: Create JupyterHub directory
  file:
    path: "{{ jupyterhub_folder }}"
    state: directory
    owner: root
    group: root
    mode: '0755'
  become: true

- name: Create virtual environment
  command: "python3 -m venv {{ jupyterhub_folder }}/venv"
  args:
    creates: "{{ jupyterhub_folder }}/venv"
  become: true

- name: Install JupyterHub and dependencies in virtual environment
  pip:
    name:
      - jupyterhub
      - jupyterlab
      - notebook
      - oauthenticator  # For GitHub authentication
      - jupyterhub-systemdspawner
    state: present
    virtualenv: "{{ jupyterhub_folder }}/venv"
    virtualenv_command: python3 -m venv
  become: true

- name: Create JupyterHub config file
  template:
    src: jupyterhub_config.py.j2
    dest: "{{ jupyterhub_folder }}/jupyterhub_config.py"
    owner: root
    group: root
    mode: '0644'
  become: true
  notify: Restart JupyterHub

- name: Create GitHub OAuth environment file
  template:
    src: github_oauth.env.j2
    dest: "{{ jupyterhub_folder }}/github_oauth.env"
    owner: root
    group: root
    mode: '0600'
  become: true
  when: jupyterhub_github_oauth_enabled | default(false)

- name: Create JupyterHub systemd service
  template:
    src: jupyterhub.service.j2
    dest: /etc/systemd/system/jupyterhub.service
    owner: root
    group: root
    mode: '0644'
  become: true
  notify: Restart JupyterHub

- name: Enable and start JupyterHub service
  systemd:
    name: jupyterhub
    state: started
    enabled: true
    daemon_reload: true
  become: true

For completeness here is the template for the config file as well:

import os
import pwd
import grp
import shutil

{% if jupyterhub_github_oauth_enabled | default(false) %}
from oauthenticator.github import LocalGitHubOAuthenticator
{% endif %}

def create_user_dir_hook(spawner):
    """Create user directory with correct permissions before spawning the notebook server."""
    # Get username
    username = spawner.user.name

    # Define the directory path
    notebook_dir = f'/mnt/storage/jupyterhub/notebooks/{username}'

    # Create directory if it doesn't exist
    if not os.path.exists(notebook_dir):
        os.makedirs(notebook_dir, exist_ok=True)

    # Get user ID and group ID
    try:
        uid = pwd.getpwnam(username).pw_uid
        gid = pwd.getpwnam(username).pw_gid
    except KeyError:
        # If the user doesn't have a local system account, use a default
        # This might happen with GitHub OAuth where system users aren't created
        return

    # Set ownership
    os.chown(notebook_dir, uid, gid)

    # Set permissions (rwx for user, r-x for group and others)
    os.chmod(notebook_dir, 0o755)

    # Optionally, you could add starter notebooks
    # shutil.copy('/path/to/welcome.ipynb', os.path.join(notebook_dir, 'welcome.ipynb'))
    # os.chown(os.path.join(notebook_dir, 'welcome.ipynb'), uid, gid)


c = get_config()

# Basic JupyterHub settings
c.JupyterHub.ip = '{{ jupyterhub_ip | default("0.0.0.0") }}'
c.JupyterHub.port = {{ jupyterhub_port | default("8000") }}
c.JupyterHub.hub_ip = '{{ jupyterhub_ip | default("0.0.0.0") }}'
c.JupyterHub.bind_url = '{{ jupyterhub_bind_url | default("http://:8000") }}'




{% if jupyterhub_github_oauth_enabled | default(false) %}
# GitHub OAuth settings
c.JupyterHub.authenticator_class = 'oauthenticator.github.LocalGitHubOAuthenticator'
c.LocalGitHubOAuthenticator.oauth_callback_url = os.environ['OAUTH_CALLBACK_URL']
c.LocalGitHubOAuthenticator.client_id = os.environ['GITHUB_CLIENT_ID']
c.LocalGitHubOAuthenticator.client_secret = os.environ['GITHUB_CLIENT_SECRET']


{% if jupyterhub_github_allowed_users | default([]) %}
c.LocalGitHubOAuthenticator.allowed_users = set([{% for user in jupyterhub_github_allowed_users %}'{{ user }}'{% if not loop.last %}, {% endif %}{% endfor %}])
{% endif %}

{% if jupyterhub_github_allowed_organizations | default([]) %}
c.LocalGitHubOAuthenticator.allowed_organizations = [{% for org in jupyterhub_github_allowed_organizations %}'{{ org }}'{% if not loop.last %}, {% endif %}{% endfor %}]
{% endif %}

{% if jupyterhub_github_allowed_teams | default([]) %}
c.LocalGitHubOAuthenticator.allowed_github_teams = [{% for team in jupyterhub_github_allowed_teams %}'{{ team }}'{% if not loop.last %}, {% endif %}{% endfor %}]
{% endif %}

{% else %}
# Default PAM authentication
c.JupyterHub.authenticator_class = 'jupyterhub.auth.PAMAuthenticator'
{% endif %}

# Admin users
c.Authenticator.admin_users = set([{% for admin in jupyterhub_admins | default([]) %}'{{ admin }}'{% if not loop.last %}, {% endif %}{% endfor %}])

# Spawner configuration
c.JupyterHub.spawner_class = '{{ jupyterhub_spawner_class | default("jupyterhub.spawner.LocalProcessSpawner") }}'
c.SystemdSpawner.default_shell = '{{ jupyterhub_default_shell | default("/bin/bash") }}'
c.SystemdSpawner.cmd = {{ jupyterhub_spawner_cmd | default("None") }}
c.Spawner.notebook_dir = '{{ jupyterhub_notebook_dir | default("~/notebooks") }}'
c.Spawner.default_url = '{{ jupyterhub_default_url | default("/lab") }}'
c.Spawner.pre_spawn_hook = create_user_dir_hook

# Create system users if using local spawner
{% if jupyterhub_create_system_users | default(true) %}
c.LocalAuthenticator.create_system_users = True
{% endif %}

# Security
c.JupyterHub.cookie_secret_file = '{{ jupyterhub_cookie_secret_file }}'
{% if jupyterhub_ssl_key is defined and jupyterhub_ssl_cert is defined %}
c.JupyterHub.ssl_key = '{{ jupyterhub_ssl_key }}'
c.JupyterHub.ssl_cert = '{{ jupyterhub_ssl_cert }}'
{% endif %}

# Specify overrides from variables
{% if jupyterhub_config_overrides is defined %}
{% for key, value in jupyterhub_config_overrides.items() %}
c.{{ key }} = {{ value }}
{% endfor %}
{% endif %}

Once all deployed I was able to go to hub.pdavies.io, login and I get this:

I do a quick test by opening a new notebook and running a hello world command:

Connect VS Code to JupyterHub and this is the good part, having this server and using its compute power without having to do everything on it. So on my local Mac, I open Vs Code and install JupyterHub extension by Microsoft

And just following the quick start guide I was able to connect to my AI server running JupyterHub and run commands:

First, create a new notebook

Then,a selecting the kernel

Then run the basic command

Done, now the great thing is this code is stored on my local, I can then interact with it with all my local tools, have it run on the server built for AI/ML with little effort from here on in.

So what's next? well time to start coding and build something, apart from that I think probably need to figure out how to install different packages, have different virtual environments for different projects etc and lastly I want to figure out how I can ensure I am actually using the GPU, do I need other config, are there tools for monitoring that etc

Make sure you read the next blog, as from this point there is a redesign of how JupyterHub is setup and getting GPU to work!