工作原因,需要用到 TensorFlow-GPU, 之前一直是机器装好了 Nvidia 驱动、CUDA、cudnn 后紧着一个 TensorFlow 版本一直用, 有创建虚拟环境的需求就用 virtualenv 新建一个环境,再装上 TensorFlow-GPU 和一堆依赖。

这个过程虽然操作不难,但重复工作着实无聊,而且 virtualenv 管理虚拟环境并不方便, 而更重要的是虚拟环境并不能解决同一 OS 上 TensorFlow 和 CUDA 的多版本共存问题

最近组内的服务器重装系统,刚好最近也没那么忙了,环境重建也是由我处理, 就顺手多学点容器技术,自己琢磨了一套感觉用着比较舒服的解决方案。


背景

为什么需要多版本共存

安装的 TensorFlow 版本不同,所需的 CUDA 版本也有可能不一样, 所以不同项目依赖了不同版本的 TensorFlow,就可能需要安装对应版本的 CUDA。

但是因为 CUDA 的安装会修改软链接 /usr/local/cuda/ 指向的路径,也就会改变了当前系统的默认 CUDA。 所以在没有人为干涉的情况下,系统同时只能识别到一个版本的 CUDA,也就是最后安装的 CUDA。

因此依赖其他版本 CUDA 的 TensorFlow 就无法正常使用 GPU 了。

时下的解决方案

此前遇到 TensorFlow-GPU 共存的问题通常有这几个解决方案:

  1. 按照正常流程安装需要的 TensorFLow1/TensorFlow2 以及所有对应版本的 CUDA, 通过脚本在切换虚拟环境时指定要使用的 CUDA。
  2. 不论是 TensorFLow1 还是 TensorFlow2,大版本几乎没有不向下兼容的情况, 所以尽量安装高版本的 TensorFlow1,然后根据已安装的 TensorFlow1 依赖的 CUDA 版本, 在其他虚拟环境安装同样依赖该 CUDA 版本的 TensorFlow2。
  3. 使用 TensorFlow 官方提供的 Docker 镜像。

现有方案的缺陷

  1. 需要安装多个CUDA、写脚本……具体我没有成功实施过,单纯是安装 CUDA 就因为网络等原因失败多次, 具体有什么缺陷,不太好说。
  2. 一旦遇到需要更高版本的 TensorFlow2 或者 更低版本的 TensorFlow1 的项目,这个方案就无法应对。
  3. 这里我认为最简单、最合理的方式是第 3 个。但是仍然存在局限性:
    • 每个环境独立的项目都要新建一个容器,也可能需要下载其他版本 TensorFlow 的镜像, 而每一个镜像都有 3~4GB 那么大。 (btw,重装系统前,我们的服务器是双系统的,而 Ubuntu 的主分区只有 200GB 不到,别说镜像文件了, 平时训练个 BERT 就该清理旧的 checkpoint 了,所以尽量少的镜像和容器,让容器能够复用对我来说相当重要)
    • 如果有远程开发的需求,则需要管理多个容器的端口映射,即使是使用 Docker-Compose 我也不觉得有多方便。 更何况还要专门记下每个项目使用的容器对应的端口号。

亿点废话

曾经在网络上搜过多版本共存的问题,答案不多,能数得过来的基本都是第一种修改环境变量的方式。 当时网络上并没见到有人提到第二种方式,不过第二种也并不是我本人直接想到的。 只是一次巧合,我发现两个 conda 环境分别装了 tensorflow-gpu-1.14 和 tensorflow-gpu-2.1.0, 而且两个都能正常使用 GPU,于是专门查了下才确认这两个版本都是支持我们服务器上装着的 CUDA-10.0。

虽说是巧合,但也算是临时的解决方案吧,确实能一定程度上解决问题,也那么用了很长时间, 直到研究依赖了 TensorFlow-GPU>=2.4.0text-to-text-transfer-transformer。 当时由于时间关系,就简单了解了 TensorFlow 官方文档上通过 Docker 使用 TensorFlow 的方式临时搭了个环境。

有一说一,虽然因为公司严格的网络规则导致各种各样麻烦的问题,以至于环境搭建非常困难, 但在环境独立的容器根本不用考虑太多环境、库冲突等问题,还是很舒服的。

事前准备

  1. 首先需要确认你的显卡是支持 CUDA 的 NVIDIA 显卡,在 支持 CUDA® 的 GPU 卡 可以查看
  2. 安装 NVIDIA® GPU 驱动程序

安装 Docker Engine

参照 Install Docker Engine | Docker Documentation 操作即可

安装 Docker-Compose

Docker-Compose 可以方便我们管理多个容器,非常推荐使用!

  1. 下载 Docker-Compose 的二进制文件
1
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.1/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
  1. 添加可执行权限
1
sudo chmod +x /usr/local/bin/docker-compose

安装 nvidia-container-toolkit

要让 TensorFlow 能够在 Docker 容器中使用 GPU,需要在启动容器时指定 --gpus 参数, 而这个参数需要有 nvidia-container-toolkit 支持才行。

  1. 设置 Nvidia-Docker 安装源
1
2
3
distribution=$(. /etc/os-release;echo $ID$VERSION_ID) \
    && wget --no-check-certificate -qO - https://nvidia.github.io/nvidia-docker/gpgkey | sudo apt-key add - \
    && wget --no-check-certificate -qO - https://nvidia.github.io/nvidia-docker/$distribution/nvidia-docker.list | sudo tee /etc/apt/sources.list.d/nvidia-docker.list
  1. 更新软件源并安装 nvidia-container-toolkit
1
sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
  1. 重启 Docker
1
sudo systemctl restart docker 

这一步单看 TensorFlow 的文档是看不出这一步的,由于没有经验,再加上公司网络安全的原因, 折腾了好久,最终答案可以参考 这个 Issue。 虽然指令和 issue 里的有点区别,其实安装思路基本是固定的。

烦人的坑

这里也算是为了自己吧,记录一下因为网络踩到的坑: 公司由于 Linux 系统无法安装公司要求的证书,https:// 链接均无法正常访问,参考以下几种对策:

  • 使用 wget --no-check-certificate 下载证书
  • 在 windows 下好证书再拷贝到 linux 机器上执行 sudo apt-key
  • 设置代理,本质其实和第二个一样

下载 TensorFlow 的 Docker 镜像

这里以 tensorflow-gpu-1.15.5tensorflow-gpu-latest 为例:

1
2
docker pull tensorflow/tensorflow:1.15.5-gpu
docker pull tensorflow/tensorflow:latest-gpu

骚操作的正式开始

编写 Dockerfile

自己重做镜像主要是为了以后重建容器方便,TensorFlow1 和 TensorFlow2 都做镜像, 内容除了作成的镜像名和端口映射外完全一致。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
FROM tensorflow/tensorflow:1.15.5-gpu-py3

# 这个脚本主要是安装软件,详见下文
COPY ./init_env1.sh ./sources.list /
RUN chmod u+x /init_env*.sh 
RUN /init_env1.sh

# 这个脚本主要是添加用户、设置 ssh
COPY ./init_env2.sh /
RUN chmod u+x /init_env*.sh 
RUN /init_env2.sh

EXPOSE 22
# 设置一个挂载目录,将所有数据挂载到宿主机
VOLUME /AI
WORKDIR /AI
# 将默认用户修改为新建的用户
USER ai

Note: Dockerfile 的编写应尽量避免过多的使用 COPY, RUN 等指令,能合并就合并。 因为每一行指令都会创建一个 layer,layer 越多,作成的镜像越大 我这里分开这么多写是因为编写该镜像过程中,init_env1.sh 更新源、安装软件太耗时间。 且 init_env2.sh 有多次修改,而 init_env1.sh 没怎么修改过,为了节约镜像打包的时间专门分开处理。

Dockerfile 需要用到的脚本

init_env1.sh

用于安装之后可能用到的相关软件,因为 TensorFlow 提供的镜像并没有这些。 可根据需要修改、添加其他软件。

1
2
3
4
5
6
7
8
cp /sources.list /etc/apt/sources.list
rm -Rf /var/lib/apt/lists/* 
# 更新源和软件之前,一定要禁止 CUDA 的更新(不然要这 TensorFlow 的镜像有何用)
apt-mark hold cuda*
apt-mark hold machine-learning
apt-get update 
# 安装所需软件
apt-get -y install openssh-server openssh-sftp-server vim sudo net-tools

init_env2.sh

用于设置 SSH 以及登录用户

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
config_file="/etc/ssh/sshd_config"
sftp_server="/usr/lib/openssh/sftp-server"

# modify configs start
# 禁止 root 用户通过 ssh 登录
if grep -q "^PermitRootLogin" $config_file;then
   sed -i '/^PermitRootLogin/s/yes/no/' $config_file
else
   sed -i '$a PermitRootLogin no' $config_file
fi

# 这里设置是为了让 WinSCP 等客户端可以通过 sftp 连接容器
if grep -q "^Subsystem" $config_file;then
   sed -i '/^Subsystem/s/$sftp_server/internal-sftp/' $config_file
else
   sed -i '$a Subsystem  sftp  internal-sftp' $config_file
fi
# modify configs end

# 添加用户:ai,设置用户目录:/AI,修改密码:123456
# 注意了解 `useradd` 和 `adduser` 的区别
useradd -d /AI -U -m ai && echo "ai:123456" | chpasswd
# 为 ai 用户设置 sudo 权限
sed -i '$a ai   ALL=(ALL) NOPASSWD: ALL' /etc/sudoers

Note: 为什么不使用 root 用户?因为即使是在 Docker 容器里,直接使用 root 用户也是存在安全隐患的, 如果容器挂载了宿主机的目录,那么在容器内使用 root 用户操作挂载目录实际上和在宿主机使用 root 用户操作该目录是一样。 就连 TensorFlow 的容器也会在你进入容器时判断当前用户的身份,如果是 root 则会建议你使用非 root 用户

sources.list

软件源设置,这里用的阿里云,当然也可以替换成别的源。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse

打包镜像

在 Dockerfile 所在目录执行:

1
docker build . -t tf:1.15.5

新建容器并启动

通过以下 Docker 命令就可以启动支持 GPU 的容器

1
docker run -it --name tf1 --gpus all -p 10022:22 -v /home/ai/tf_project:/AI tf:1.15.5

如果只需要一个虚拟环境/一个版本的 TensorFlow,到这里就已经足够了,但这并不是本文的重点。 接下来继续讲骚操作,技巧其实很简单:活用 VOLUME 挂载目录。

安装 Miniconda/Anaconda

conda 安装很简单,从官网下载对应脚本, 添加可执行权限后执行,按照交互提示操作就行,这里不再赘述。

使用 Docker-Compose 管理容器

※ 假设装好的 Miniconda 所在根目录为 /home/ai/miniconda

  1. 创建任意目录作为项目目录,比如 /home/ai/ai_docker
  2. 目录下新建文件 docker-compose.yml,和 Dockerfile 一样,文件名固定
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# 由于大多数都是 Docker 指令中能看到的,功能相同,下面只会对一些特殊的指令做解释。
# 注意这是本文件使用的 Docker-Compose 版本,决定你能在这个文件使用哪些指令
version: "3.9"

services:
  # 服务名,随意
  tensorflow1:
    # 指定镜像
    image: tf:1.15.5
    container_name: tf1
    ports:
      - "10022:22"
    restart: always
    # 指定用户,在下一节环境变量配置
    user: ${CURRENT_UID}
    # 使容器保持后台运行
    tty: true
    networks:
      - ai-bridge
    # 使容器能使用 GPU
    deploy:
      resources:
        reservations:
          devices:
          - driver: nvidia
            capabilities: [gpu]
    # 目录挂载,骚的根源
    volumes:
      # 可以直接将原来的用户目录先挂载过来,授权 ro
      # 目的:如果有需要和容器环境一起使用但不依赖 TensorFlow 版本的软件,如:个别分词器,
      # 直接装在宿主机即可,容器内只需要配置环境变量
      - /home/ai:/home/ai:ro
      # 将宿主机的 Miniconda 目录挂载到容器的同等目录下,授权 rw
      # 目的:共享宿主机的 conda 环境,不同项目只要依赖的 TensorFlow 版本相同,
      # 就只需要创建 conda 环境克隆已有环境的 TensorFlow,在新环境装项目依赖即可。
      # 
      # 此外,远程开发甚至可以不为任何容器分配端口映射,配合下一条挂载项,直接连接宿主机的项目
      # 和 conda 环境即可。当然,如果没什么意外,前面安装 SSH 等软件的步骤都可以省去。
      - /home/ai/miniconda3:/home/ai/miniconda3:rw
      # 将项目目录挂载到容器的默认工作目录,同时这里也是前面指定的、容器里的用户目录。
      - /home/ai/tensorflow_volume:/AI

  # 与上一节仅服务名、镜像、端口映射不同,其他一致。添加新容器时也一样
  tensorflow2:
    image: tf:latest
    container_name: tf2
    ports:
      - "20022:22"
    restart: always
    user: ${CURRENT_UID}
    tty: true
    networks:
      - ai-bridge
    deploy:
      resources:
        reservations:
          devices:
          - driver: nvidia
            capabilities: [gpu]
    volumes:
      - /home/ai:/home/ai:ro
      - /home/ai/miniconda3:/home/ai/miniconda3:rw
      - /home/ai/tensorflow_volume:/AI

networks:
  ai-bridge:
    driver: bridge
  1. 目录下新建文件 .env
1
2
写入当前用户的 uid 和 gid
CURRENT_UID=1000:1000

Note: 如何查看当前用户的 id?执行 id -uid -g 就好了

  1. 启动容器
1
2
cd /home/ai/ai_docker
docker-compose up -d

使用 docker-compose ps 查看容器状态,是 Up 就启动成功了。

骚操作的后续补充

为容器初始化 conda

虽然完全可以进入容器后再激活对应的 conda 环境,但用得久了着实感觉繁琐。 可以通过用户登录会自动调用 .bashrc.profile 等脚本的机制来实现。

  1. 关闭 conda 的自动初始化环境 因为上面配置了容器的 conda 环境实际上是和宿主机共用的,为了避免登录脚本激活容器后 conda 又切换回默认环境,首先关闭该设置
1
2
3
cat <<EOF >>~/.condarc
auto_activate_base: false
EOF
  1. 修改宿主机 .bashrc,添加环境切换函数 安装 conda (并启用自动初始化)后,在宿主机的 .bashrc 找到类似下面的代码
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# >>> conda initialize >>>
# !! Contents within this block are managed by 'conda init' !!
__conda_setup="$('/home/ai/miniconda3/bin/conda' 'shell.bash' 'hook' 2> /dev/null)"
if [ $? -eq 0 ]; then
    eval "$__conda_setup"
else
    if [ -f "/home/ai/miniconda3/etc/profile.d/conda.sh" ]; then
        . "/home/ai/miniconda3/etc/profile.d/conda.sh"
    else
        export PATH="/home/ai/miniconda3/bin:$PATH"
    fi
fi
unset __conda_setup
# <<< conda initialize <<<

在上述代码之后添加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
tf1(){
  if [ -z "$1" ]; then
    env=tf1
  else
    env=$1
  fi
  docker exec -it -e env=$1 -e TF_VERSION=TensorFlow1 tf1 bash
}
tf2(){
  if [ -z "$1" ]; then
    env=tf2
  else
    env=$1
  fi
  docker exec -it -e env=$1 -e TF_VERSION=TensorFlow2 tf2 bash
}
  1. 配置宿主机默认 conda 环境 在宿主机 .bashrc 最后添加
1
conda activate base
  1. 在容器 .bashrc 添加
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# >>> auto activate env >>>
if [ -n "$env" ]; then
  conda activate $env  # 进入容器自动激活指定的 conda 环境 
else
  # 如果没有指定 conda 环境,则自动进入容器自身 tf 版本对应的 conda 环境
  tf1=`pip freeze | grep tensorflow-gpu==1.15.5`  # 查询当前容器默认环境的 tf1 版本,1.15.5 修改成自己安装的版本
  tf2=`pip freeze | grep tensorflow-gpu==2.1.0`  # 查询当前容器默认环境的 tf1 版本,2.1.0 修改成自己安装的版本
  if [ -n "$tf1" ]; then
    conda activate tf1
  elif [ -n "$tf2" ]; then
    conda activate tf2
  fi
fi
# <<< auto activate env <<<

至此,通过 tf1tf2 就能直接进入容器并激活相对的 conda 环境了。 举个例子:

  • 进入 TensorFlow1 容器:tf1
  • 进入 TensorFlow2 容器:tf2
  • 进入 TensorFlow1 容器,并激活名为 BERT 的虚拟环境:tf1 BERT
  • 进入 TensorFlow2 容器,并激活名为 T5 的虚拟环境:tf1 T5

简单的使用说明

TODO 2021/09/14 目前还是没觉得哪些操作需要记下来

使用 VSCode 远程开发

安装插件 Remote - SSH 连接宿主机即可,即使不查资料也不难操作。 由于写本文的时间已经太晚了而且这一节不算重点,暂且告一段落了。。。

问题小记

  1. 使用 bash 作为容器的默认 shell 工具时,sftp 登录时可能会遇到 “收到了过大的数据包” 之类的报错。 可以参考以下网址,在 .bashrc 文件中打印语句前通过 [[ $- == *i* ]] 判断当前终端是否是交互终端。

一些已知存在但仍不知如何解决的小问题

TODO 2021/09/14 本来是有一两个问题来着,其中最影响效率的问题在 问题小记 记下来了。别的暂时没什么了

参考链接