跳转至

Dockerfile 的编写(下)

接下来,我们完成一个实际的任务:将暑培后端作业打包为 Docker 镜像。我们将以 Django 作业示例代码为例,Golang 作业仅提供 Dockerfile 样例

为什么选择讲解 Django

Golang 是编译型语言,我们只需要编译得到可执行文件就可以了(与之前使用 gcc 编译类似);而 Python 是动态语言,与之前的例子不同,而且 Django 的部署一般需要用到 uWSGI,相对麻烦。

如果没有学习 Django 也没有关系,重点是掌握如何根据部署过程写 Dockerfile、运行容器

Django 后端样例

本课程使用的代码位于 /external/django-backend,基于 Django 作业官方示例修改而成。具体的修改有:

  • uwsgi.ini.bak 中的 socket 改为 http
  • uwsgi.ini.bak 更名为 uwsgi.ini
  • 将数据库配置文件 LeaderBoard/my.cnfconfig/config.json 代替

为方便与本教程同步,建议使用本教程仓库下的代码而非原始的样例代码。

从部署过程到 Dockerfile

写 Dockerfile 其实就是在容器中进行一遍部署(即配置环境、运行程序)的工作。我们先看 Django 部署的步骤:

  1. 配置环境
    1. 安装特定版本的 Python(在这里我们使用 3.8)
    2. 安装项目依赖(requirements.txt
    3. 安装 uWSGI
    4. 创建/编辑配置文件 config/config.json(原来是 LeaderBoard/my.cnf
  2. 运行程序
    1. 运行 python3 manage.py makemigrations
    2. 运行 python3 manage.py migrate
    3. 启动 uWSGI

接下来,我们根据这个流程写 Dockerfile。

安装特定版本的 Python:通过指定基础镜像,我们获得了特定版本的 Python 环境:

FROM python:3.8

在继续进行之前,我们需要先将所有文件拷贝到容器内部:

COPY . .

使用 .dockerignore

在向容器内部拷贝时可以通过 .dockerignore 文件排除无关文件,如 git 目录、虚拟环境等,以缩小容器体积。

安装项目依赖:使用 RUN 安装依赖:

RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --no-cache-dir -r requirements.txt

关于此命令的一些解释

-i https://pypi.tuna.tsinghua.edu.cn/simple 使用 TUNA 源;--no-cache-dir 禁用缓存,以缩小容器体积。

安装 uWSGI:同样地,使用 RUN。在这里我们使用 pip 安装:

RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --no-cache-dir uwsgi

在构建镜像的过程中,对于每一条指令,镜像会相应地加上一层。如果每一条 bash 命令都写一条 RUN 的话,镜像层数将非常多,构建时间和镜像大小都会受到影响。因此,一般使用一条 RUN 利用 && \ 连接多条命令,而不是使用多条 RUN

RUN pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --no-cache-dir -r requirements.txt && \
    pip install -i https://pypi.tuna.tsinghua.edu.cn/simple --no-cache-dir uwsgi

查看镜像各层的信息

可以使用 dive 查看镜像各层对应的命令及大小。

创建/编辑配置文件 config/config.json配置文件一般包含敏感信息(如数据库的用户名与密码等),不能放入镜像中,因此这一步不能在构建镜像的过程中完成。我们稍后会介绍通过挂载目录完成此步骤的方法。

运行程序:运行 Django 程序包含几个步骤,这些步骤都在容器运行而不是容器构建时进行,因此应成为 CMD。一种较为简便的在启动时执行多条命令的方法为:将它们写到一个 shell 脚本中。在我们的例子中,我们将三个步骤写到 start.sh 中:

#!/bin/sh
python3 manage.py makemigrations &&
python3 manage.py migrate &&
uwsgi --ini uwsgi.ini

使用 EXPOSE 以暴露容器的 80 端口:

EXPOSE 80

这一条命令并不是必须的,但推荐加上。EXPOSE 是对于容器开放端口的一种说明,可以使使用者了解容器需要发布的端口。注意,EXPOSE 不会将容器的端口发布到宿主机,这一步仍需要通过 docker run-p-P 进行。

然后,使用 CMD 执行脚本:

CMD ["/bin/sh", "start.sh"]

这样,我们的 Docker 镜像就写好了。最终的 Dockerfile 见 /external/django-backend/Dockerfile

构建镜像:

docker build . --tag backend-django

运行容器

关于此部分内容

此部分内容介绍的方法稍显复杂,但对于理解 Docker 有较大的帮助。之后我们会使用 Docker Compose 来简化这一流程。

运行数据库

在运行后端之前,我们首先使用 Docker 运行一个 MySQL 服务器。

Docker 容器内的数据随着容器的销毁而消失,因此数据库的数据不能保存在容器内部。我们使用 Docker 的数据卷(Volume)功能。

什么是数据卷

数据卷是一个可供一个或多个容器使用的特殊目录,可以提供很多有用的特性,如:

  • 数据卷可以在容器之间共享和重用
  • 对数据卷的修改会立马生效
  • 对数据卷的更新,不会影响镜像
  • 数据卷默认会一直存在,即使容器被删除

首先,创建一个数据卷:

docker volume create db-vol

然后,启动 mysql 容器,并将此数据卷挂载到 /var/lib/mysql,即数据库数据文件存放的目录:

docker run \
    -d \
    --name db \
    --mount source=db-vol,target=/var/lib/mysql \
    -e MYSQL_ROOT_PASSWORD=my-secret-pw \
    mysql

其中,-e 的作用为设置环境变量,my-secret-pw 改为你想要设置的 root 账户的密码。

接下来,进入 MySQL:

docker exec -it db mysql -p

然后,为后端创建一个数据库 leaderboard

mysql> CREATE DATABASE leaderboard;
Query OK, 1 row affected (0.01 sec)

关于数据库的访问

每一个 Docker 容器都有自己的网络命名空间(Namespace),容器的网络是与其他容器/宿主机的网络隔离的,因此在数据库容器的 0.0.0.0:3306 上监听的 MySQL 服务是无法在后端容器中通过 127.0.0.1:3306 访问的。

docker run 创建容器时默认使用 Docker 的网桥模式(Bridge)创建网络,在这种模式下,除了分配隔离的网络命名空间之外,Docker 还会为所有的容器设置 IP 地址。当 Docker 服务器在主机上启动之后会创建新的虚拟网桥 docker0,随后在该主机上启动的全部服务在默认情况下都与该网桥相连。

因此,一种简单的访问数据库容器的办法是通过它在 bridge 网络(Docker 为 bridge 模式的容器创建的默认网络)上的 IP 地址进行访问。我们通过如下命令查看 IP 地址的分配:

docker network inspect bridge

查看其中打印出的 JSON 的 Containers 查看所连接容器的网络信息,查找 Namedb 的一项:

...
"Containers": {
    "7dd1830526a7196c1d1751dcbbd38a756949306995062e33b12ebcb294f2d672": {
        "Name": "db",
        "EndpointID": "1c4ab5928e6ba0864c775637b55f200a8699b3700d8c9d6f346035ca12ff1eaf",
        "MacAddress": "02:42:ac:11:00:03",
        "IPv4Address": "172.17.0.3/16",
        "IPv6Address": ""
    },
    ...
},
...

其中的 IPv4Address 就是数据库容器被分配到的 IPv4 地址,在后端容器中通过 172.17.0.3 即可访问数据库。

看不懂?

这一部分看不懂没关系,只需要了解到通过 docker network inspect bridge 可以查询容器被分配到的、可以被其他容器访问到的 IP 地址就可以了。我们将在下一节介绍更加简单的方法。

运行后端

前面提到,数据库访问信息是不能放在镜像中的。我们通过挂载宿主机上的目录来解决。

在宿主机上某个位置创建一个新目录作为存放配置文件的目录,在其中新建一个名为 config.json 的文件,并将数据库访问信息写入。注意修改 db_hostdb_pass 为数据库容器的 IP 地址和数据库 root 账户的密码:

{
    "db_host": "172.17.0.3",
    "db_port": 3306,
    "db_user": "root",
    "db_pass": "my-secret-pass",
    "db_name": "leaderboard",
    "db_charset": "utf8mb4"
}

真实环境下不要使用 root 账户

数据库 root 账户具有一切权限,不应当用于后端访问。在这里我们为了方便直接使用 root 账户,真实情况下应当创建一个新的账户并授予相应的权限。

最后,运行后端容器,注意 /abs_path/to/config/dir 需要改为之前创建的本机上的配置文件目录的绝对地址,9000 改为任意一个在本机上没有被占用的端口(一般 9000 就可以)。

docker run \
    -d
    --name backend \
    -p 9000:80 \
    -v /abs_path/to/config/dir:/config \
    backend-django

Note

初次运行时可以不加 -d,方便检查运行是否有问题。

我们通过 -v 本机配置文件目录绝对地址:/config 来将本机上的配置文件目录映射到容器内的 /config,从而使得容器内可以通过 /config/config.json 访问到本机上的配置文件,这样就在不将配置文件放入镜像的情况下在容器中访问到配置文件。

映射目录而不是文件的原因

docker run-v 一般情况下只能映射目录而不能映射单个文件,因此我们新建了一个放置配置文件的目录,并将其映射到容器内部。

-p 9000:80 选项的作用在前面提到过,其作用是将容器的 80 端口发布到宿主机的 9000 端口,从而能够通过宿主机的 9000 端口访问容器的 80 端口。

运行容器后,在宿主机上访问 GET http://127.0.0.1:9000/,如果正常返回响应即运行成功:

curl http://127.0.0.1:9000
{"code": 0, "msg": "hello"}

因为涉及到两个容器的运行及访问以及目录和数据卷的挂载,以上方法较为繁琐。接下来,我们介绍使用 Docker Compose 简化这一流程的方法。