添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

本文对 Docker 数据挂载容易产生的各种问题进行分析,应用几种常见解决方案,抽丝剥茧,提出最佳实践。本文中的样例命令全部可以运行复现。

1. 初探:Docker volume 绑定挂载导致的问题

Docker 提供了数据卷 绑定挂载 的机制(volume bind mounts)来将主机上的文件 (file) 或者目录 (directory) 挂载进容器 (container)。也就是 docker run 命令中熟知的 -v 参数。根据 Docker 官方文档,绑定挂载一般适合于 三种场景

  • 共享主机 配置文件 。譬如将主机的 DNS 配置文件 /etc/resolv.conf 挂载到容器里省去配置。
  • 共享项目 源代码 构建产物 。譬如将 maven 项目的 target 目录挂载到容器内。
  • 当主机的特定目录结构和容器内需要的目录结构完全一致的时候。
  • 但是实际使用时, 会遇到如下文件权限问题

  • 容器向挂载的目录写入文件或者目录后,主机上没有权限访问。因为 Docker 内部默认总是使用 root 用户运行。
  • 譬如执行如下命令创建一个容器,挂载当前目录到容器内,并在容器内向主机当前目录创建 tmp.txt:

    $ docker run --rm \
        -v "$PWD":/project \
        debian \
        bash -c "touch /project/tmp1.txt"
    $ ls -l tmp1.txt
    -rw-r--r-- 1 root root 0 Sep 28 01:55 tmp.txt

    主机当前目录出现了容器内创建的 tmp.txt,但是其权限、用户和组均是 root,其他用户不可写。

    2. 初级解决方案:使用 –user 参数指定容器运行时用户

    常见解决方法是可以通过 Docker 提供的 User 命令、 --user 参数 来指定容器内部的用户和组的 id,譬如:

    $ docker run --rm \
        --user=$UID:$(id -g $USER) \
        -v "$PWD":/project \
        debian \
        bash -c "touch /project/tmp2.txt"
    $ ls -l tmp2.txt
    -rw-r--r-- 1 current_user current_user 0 Sep 28 02:09 tmp.txt

    可以看到输出,current_user 处会显示主机当前用户的名字,所以解决了主机用户对挂载的卷没有权限的问题。

    3. –user 参数的缺陷

    3.1 诡异的提示—— I have no name!

    使用 user 参数有一些缺陷,如果你进入容器内部的 terminal,会显示如下内容:

    $ docker run --rm \
        -it \
        --user=$UID:$(id -g $USER) \
        -v "$PWD":/project \
        debian \
        bash -c "touch /project/tmp3.txt && bash"
    I have no name!@6cc07662a201:/$ exit
    

    bash 的用户名会显示 I have no name!,这是因为我们通过 --user 参数指定了容器内部的用户 id,但该 id 不存在于容器内的 /etc/passwd 文件中。

    3.2 丧失 Root,特权全无!

    这点很好理解,运行时切换为普通用户后,丧失了 root 权限后,自然要承担丧失 root 权限带来的麻烦,其中有一点不能忍!

    对于容器内部的用户,除了绑定挂载的路径,其他任意路径都没有写权限。

    这是不可接受的,因为容器运行过程中我们可能会进行一些临时文件的写入,这些临时文件我们并不想要写到主机的挂载目录,但如今除了挂载路径之外的任何路径(例如/tmp/etc等)容器都没有写入权限,如果想某个路径写文件,你就得把这个路径挂载到宿主机!

    举个例子展示下,我们在主机上创建 models 目录。

    $ mkdir models
    $ ls -ld models/
    drwxr-xr-x 3 current_user current_user 4096 Sep 28 02:28 models/

    我们使用 Docker 挂载 models 目录,然而在 Docker 容器内部除了 models 文件夹都没有访问权限:

    $ docker run --rm \
        --user=$UID:$(id -g $USER) \
        -v "$PWD/models":/project/models \
        debian \
        bash -c "touch /project/tmp.txt"
    touch: cannot touch '/project/tmp.txt': Permission denied

    如上,/project/models 中都是能创建文件的,但上层目录 /project/ 中创建文件会失败,因为 /project 没有被单独挂载。

    如果不嫌弃挂载的路径太多,这倒可以通过增加挂载路径解决:
    (需保证$PWD的权限是775及以上,一般/home的权限达不到,所以不要在/home下执行如下命令)

    $ docker run --rm \
        --user=$UID:$(id -g $USER) \
        -v "$PWD":/project \
        -v "$PWD/models":/project/models \
        debian \
        bash -c "touch /project/tmp.txt"
    

    这样倒是不会报错了,但这样容器运行过程往 /project 写的临时文件都会出现在宿主机上。

    可见,user 参数并不能解决所有问题。它存在两个问题:

  • (1)指定的用户不存在于容器内的 /etc/passwd 中,shell 无法显示用户名
  • (2)user 参数会指定容器运行时刻的用户和主机一致,因此持有主机挂载的用户目录,但容器内非挂载的目录均无权限
  • 4. Docker 挂载绑定最佳实践——神秘的 docker-entrypoint.sh

    我们需要一种手段,既可以像 user 参数一样在容器运行时可以将用户切换到和主机相同的用户,又希望 Docker 容器保留 root 用户,并给主机用户想要访问的目录授权(对特定目录 chownchmod 等)。(普通用户和 root 反复横跳😂)

    Docker 官方文档对 Entrypoint 介绍时给出了一种最佳实践(官方认证的最佳实践)。

    首先编写如下的 Dockerfile:

    FROM debian
    RUN apt-get update && apt-get -y --no-install-recommends install \
        ca-certificates \
        curl \
        dirmngr \
    RUN gpg --keyserver ha.pool.sks-keyservers.net --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4
    RUN curl -o /usr/local/bin/gosu -SL "https://github.com/tianon/gosu/releases/download/1.4/gosu-$(dpkg --print-architecture)" \
        && curl -o /usr/local/bin/gosu.asc -SL "https://github.com/tianon/gosu/releases/download/1.4/gosu-$(dpkg --print-architecture).asc" \
        && gpg --verify /usr/local/bin/gosu.asc \
        && rm /usr/local/bin/gosu.asc \
        && chmod +x /usr/local/bin/gosu
    COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
    RUN chmod a+x /usr/local/bin/docker-entrypoint.sh
    WORKDIR /project
    ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]

    该 Dockerfile 中安装了一个 gosu 的工具,并设置了程序的 Entrypoint。由于 Docker 内使用 sudo 可能导致一些不可预知的 TTY 和信号转发问题,所以 Docker 官方推荐了使用 gosu 这个工具,用于保持容器在 root 用户下运行,并用 sudo 来切换到指定用户。

    其中 docker-entrypoint.sh 内容如下:

    #!/bin/bash
    # 获取主机用户id
    USER_ID=${LOCAL_USER_ID:-9001}
    # 给主机用户授权制定的非绑定挂载目录
    chown -R $USER_ID /project
    # 创建和主机用户相同uid的用户,名为user
    useradd --shell /bin/bash -u $USER_ID -o -c "" -m user
    usermod -a -G root user
    export HOME=/home/user
    exec /usr/local/bin/gosu user "$@"

    可以看到 docker-entrypoint.sh 中创建了一个名为 user 的用户,该用户的 uid 由 docker run 的参数传入,这里利用了 linux 系统的一个特点,容器内外用户权限的记录和用户的名字无关,只和 uid 有关,因此容器内我们将用户命名为 user 没有影响。docker-entrypoint.sh 最后一行调用 gosu 来切换到 user 用户并执行 Dockerfile 中的用户命令。

    有了如上两个脚本,我们构建镜像并执行:

    $ docker build -t test_volume .

    运行容器时指定 LOCAL_USER_ID 参数:

    $ docker run --rm \
        -e LOCAL_USER_ID=$(id -u $USER) \
        -v "$PWD/models":/project/models \
        test_volume \
        sh -c "touch tmp.txt && touch models/model.txt"
    $ ls -l models/model.txt
    -rw-r--r-- 1 current_user current_user 0 Sep 28 06:41 models/model.txt

    可见不仅容器内往挂载目录 /project/models 写入的文件 model.txt 所有者是主机用户,而且在容器内往非挂载目录 /project/tmp.txt 写入文件也不会遇到权限问题。

    5. 总结

    Docker 运行时容器内默认使用 root 用户运行,但是我们不是总是想要用 root 用户,因为有时候我们希望容器计算产生一些文件,并通过 volume 的绑定挂载在主机上获取。特别是我们用 jenkins 等工具写一些持续集成的脚本时候。容器内用 root 用户运行会导致产生的文件也是 root 用户的,主机上没有读取权限。因此我们需要让容器在运行的时候切换到主机上的用户。

    Docker 对于这种情况仍然没有提供足够便利的基础设施,我们采用了 Docker 官方目前推荐的一个方式,通过编写一个 docker-entrypoint.sh 脚本作为 Dockerfile 的 Entrypoint,脚本中创建和主机上相同 uid 的用户,并通过 gosu 工具切换到该用户执行命令。uid 需要在 docker run 阶段通过参数传入。我们在脚本中设置了缺省 uid ,上面的脚本随机选择了一个 9001,注意要将该缺省值避免设置成和 Docker 镜像中存在的用户冲突的 uid。

    参考链接:

  • Deni Bertovic :: Handling Permissions with Docker Volumes
  • Best practices for writing Dockerfiles | Docker Documentation
  •