这篇其实是一份拖了很久的笔记。最早的做法在 2019 年就已经跑在生产上,但一直存在脑子里没写下来;后来各组件版本变了,配置上也有了些小调整,正好一起整理出来。
问题:GPU 怎么分给人
在一个人不多的团队里,给算法工程师分配 GPU 是件很具体的事。大厂有成熟的 k8s 加上各种 GPU 虚拟化方案,但那是一整套体系——从用户认证,到临时存储、持久化存储、日志、资源配额、用量统计、任务队列,这些配套齐了,才谈得上用 k8s 的调度去做 GPU 分配。
2019 年我们的情况是:机器少、人少、项目和任务都单一。那时没有资源协调的压力,所以只在服务器上做了 LDAP 认证,GPU 直接裸机用。
到了 2020 年,机器和人都多了起来,不同的人对任务和环境的诉求也开始分化。这时候裸机就不行了:GPU 利用率上不去,数据安全没有边界,环境还会互相冲突——即便有 Anaconda 和 virtualenv,也顶不住。
于是我们决定用 Docker 容器来做 GPU 的交付和隔离。这意味着容器内要起 SSH 服务(算法工程师大多习惯用 VS Code 直连服务器的 SSH),Notebook 的登录要沿用原来的 LDAP 认证——身份不能因为换了交付方式就另起一套。
整体规划
- 用
nvidia-container-toolkit结合 Docker 把 GPU 交付进容器; - 建内部镜像库,覆盖常见发行版和 CUDA 版本;
- 基于内部镜像库,为每个工程师 build 各自的容器镜像;
- 每个工程师的工程目录就是 LDAP 账户的
/home目录,一般在/home/username下; - 工程师能访问的数据,通过挂载宿主机目录透传进容器;
- 容器内的时间、认证、权限(sudoers)统一管理。
关键点在最后两条:数据和身份都来自宿主机,容器只是一层隔离的运行环境。工程师换个镜像、重建容器,看到的还是同一个家目录、同一个登录账号。这就是我们说的"可复现"。
实际操作
镜像 build 阶段装好三样东西:openssh-server(并生成 SSH 服务需要的 key 文件)、jupyterhub 加 jupyterhub-ldapauthenticator 以及 Node.js、npm 包 configurable-http-proxy、还有 LDAP 认证模块 nss-pam-ldapd。
然后创建容器:
docker run -d --rm=false --net=host --restart=unless-stopped --name username --shm-size=8g (cat /etc/hosts|awk -F ' ' '{if(NR>2){print "--add-host "$2":"$1}}') -it -v /data-directory:/data-directory -v /home/username:/home/username -v /var/log/username:/var/log/username -v /etc/jupyterhub/username_config.py:/etc/jupyterhub/username_config.py -e TZ=Asia/Shanghai -v /etc/pam.d:/etc/pam.d:ro -v /etc/nslcd.conf:/etc/nslcd.conf:ro -v /etc/nsswitch.conf:/etc/nsswitch.conf:ro -v /var/run/nslcd:/var/run/nslcd -v /etc/passwd:/etc/passwd:ro -v /etc/group:/etc/group:ro -v /etc/shadow:/etc/shadow:ro -v /etc/gshadow:/etc/gshadow:ro -v /etc/sudoers:/etc/sudoers:ro --runtime nvidia --privileged image:tag /home/username/start.sh
这条命令里有几个挂载点和参数值得单独说清楚,因为它们决定了网络和身份能不能对上。
--net=host 选的是宿主机网络。如果改用桥接,就得自己管好容器内的 hosts 文件,并把 SSH、Notebook 用的端口(在 /home/username/start.sh 和 /etc/jupyterhub/username_config.py 里配置)用 -p 显式暴露出来。
--name username 直接用 LDAP 里的用户名做容器名,便于识别;同一个人的多个容器可以在后面跟编号。
(cat /etc/hosts|awk -F ' ' '{if(NR>2){print "--add-host "$2":"$1}}') 在 host 网络模式下,把宿主机 hosts 文件的内容注入容器的 hosts,否则容器暴露的端口会让 JupyterHub 的 HTTP proxy 反代失败。桥接模式下不需要这段,但建议用 --add-host 确保 localhost=127.0.0.1。
-v /etc/jupyterhub/username_config.py:/etc/jupyterhub/username_config.py 把这个用户的 JupyterHub 配置透传进容器。
-e TZ=Asia/Shanghai 让容器内外时区一致,否则日志时间对不上。
最关键的是这一长串只读挂载:
-v /etc/pam.d:/etc/pam.d:ro -v /etc/nslcd.conf:/etc/nslcd.conf:ro -v /etc/nsswitch.conf:/etc/nsswitch.conf:ro -v /var/run/nslcd:/var/run/nslcd -v /etc/passwd:/etc/passwd:ro -v /etc/group:/etc/group:ro -v /etc/shadow:/etc/shadow:ro -v /etc/gshadow:/etc/gshadow:ro -v /etc/sudoers:/etc/sudoers:ro
它把宿主机上认证相关的文件以只读方式挂进容器。有了这些,容器里的 PAM 和 nss 直接复用宿主机对接好的 LDAP——不用在每个容器里再配一遍 LDAP 客户端,身份也就天然一致。/var/run/nslcd 要挂进来,容器内才能走到宿主机的 nslcd socket。
start.sh 的内容:
#!/bin/bash
set -e
ldconfig
#python3 auto_restart.py >> ./nohup.out #用于故障时候排错
/usr/sbin/sshd -p 端口号 &&
jupyterhub --config=/etc/jupyterhub/username_config.py --no-ssl >> /var/log/username/username.log
JupyterHub 的配置:
c.JupyterHub.ip = 'IP,如果容器网络模式是host,那么直接填写host IP,如果是birdge,则为127.0.0.1即可或不配置此项'
c.JupyterHub.port = 端口号
c.Spawner.ip = 'IP,如果容器网络模式是host,那么直接填写host IP,如果是birdge,则为127.0.0.1即可或不配置此项'
c.JupyterHub.authenticator_class = 'ldapauthenticator.LDAPAuthenticator'
c.LDAPAuthenticator.server_address = "ldap://服务器IP或域名:ldap服务端口号,默认389"
c.LDAPAuthenticator.bind_dn_template = 'uid={username},ou=people,dc=test,dc=com'
authenticator_class 用的是 ldapauthenticator.LDAPAuthenticator,bind_dn_template 要和你 LDAP 的 DN 结构对上。这样工程师登录 Notebook 用的,就是他登录服务器 SSH 的同一个账号密码。
启动 sshd 之前,先生成 SSH 服务的 key 文件:
ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key
ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key
ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key
然后在 start.sh 里拉起:
/usr/sbin/sshd -p 端口号 #### centos路径
这套方案换来的东西
工程师拿到的是一个带 GPU、能用 VS Code 直连、能开 Notebook 的隔离环境,登录凭据和家目录跟裸机时代完全一样,没有额外的学习成本。而我们在运维侧省掉了每台机器、每个容器各自维护一套认证的麻烦——身份只在 LDAP 一处管理,加人、停权、改密码都在一个地方生效。GPU 的利用率和数据边界,也从"全靠自觉"变成了容器实打实的隔离。
