Skip to content
BaiRuic's Blog
Go back

面向数据科学家的Docker指南

Updated:

Docker是什么?解决了什么问题?

Docker是Linux容器的一种封装,它可以将编写的Python应用程序及其环境依赖统统打包在一个文件里面。运行这个文件,就会生成一个虚拟容器。程序在这个虚拟容器里运行,就好像在真实的物理机上运行一样。有了 Docker,就不用担心任何环境问题。

举一个最常见的例子吧,这几年国内的数据科学竞赛如雨后春笋,这些竞赛由于没有像Kaggle一样的平台,所以一般都是要求参赛选手在本地建模,然后提交测试结果到在线系统进行评分。为了防止参赛选手作弊,出题方一般会要求排名靠前的选手提交源代码来进行复现结果。但是,试想一下,每个选手所写代码的平台首先可能不一样,如Ubuntu, Windows等,再者各选手用的包也会不一样,以及各种包的版本也会现差异,这时候,做为出题方单单想要复现个结果就很费时费力了。这时候就能体现docker的魔力了,只需要要求参赛选手提交一个Docker镜像即可直接复现,完全不用在乎选手是在什么平台下用了什么版本的什么包了,这就是docker解决环境问题的典型案例。在这个案例中,Docker充当了提供环境的功能,除此之外,Docker还有像组件微服务架构这样的作用。

对于Docker的安装,官方文档给了详细的教程。Windows平台个人推荐直接安装Docker Desktop,然后优先选择WSL 2作为后端,这样在wsl和win中便可以无缝使用docker了。

本文的脉络如下:动手前,先介绍几个关键的概念;然后通过一个简单的动手案例来理解镜像、容器以及随之的一些命令;之后便是两个最常用的应用场景,通过这两个常见的应用场景,学会docker-compose和Dockerfile的写法,以掌握这两种用法。

一些需要拎清的概念

对容器和镜像做以下几点说明:

👉

容器本质上是进程,只不过做了隔离和资源限制,方便管理,对于容器内部的程序来说就好像是一个独立的操作系统。这就是为什么称Docker是Linux容器的一种封装。

既然镜像是个”文件“,那这个文件名的格式是怎样的呢?通常用<image_name>:<tag>来标识不同的镜像,image_name即为镜像的名字,<tag>是镜像的标签,名字好理解,那标签是干嘛用的呢?标签通常可以用来标识镜像的不同版本、配置或特性。因此,后续不管是下载 (docker pull xx:xx) 镜像还是运行(docker run xx:xx)镜像都要记住<image_name>:<tag>这个格式。

前面讲到,一个容器可以看做是做了隔离的独立的“操作系统”。那这个“操作系统”如何与外界来通信呢?这就有了Volume(卷)和Network(网络)这两个重要的概念:

一句话概括,就是:Volume(卷)用于在宿主机和容器之间共享数据,Network(网络)用于连接 Docker 容器。

简单创建一个Python容器并发布该镜像

本小节先以python镜像为例,先拉取一个官方镜像,然后运行该镜像为容器之后顺便好配置环境,再将该容器打包为一个新的属于自己的镜像,最后将打包好的镜像推送到docker hub上。有什么用呢?维护一个自己的镜像,可以除去反复配环境的诅咒!

首先去Docker官方维护的Python镜像页面,找自己想要的Python镜像,如果不知道选什么,就直接选**python:<version>**吧,以3.8为例执行docker pull python:3.8 拉取该镜像,大概900M左右的大小。

docker pull python:3.8

然后运行docker images便可以查看到这个镜像,如下:

REPOSITORY   TAG   IMAGE ID       CREATED        SIZE
python       3.8   415775fb4b1f   2 months ago   998MB

执行 docker run -itd --name py38 -v ~/dataset:/data python:3.8 bash以运行该镜像。不出意外,此时该容器已在后台运行了,可以通过docker ps 来查看正在运行的容器:

CONTAINER ID   IMAGE        COMMAND   CREATED          STATUS          PORTS     NAMES
06a9615824cd   python:3.8   "bash"    10 minutes ago   Up 10 minutes             py38

对于上述docker run命令,有如下解释:

👉

docker run的参数巨多,不要想着全记下来,用到的时候去查就好了,经常用的也不过是设置Volumn,设置端口映射,设置容器名称,设置容器网络这些。

此时,docker已经在后台运行了,如果想要进入docker的命令行界面,可以执行docker exec -it py38 /bin/bash,之后,便身处这个”操作系统“里面了。对docker exec有如下解释:

进入容器内的终端之后,可以安装git, conda 等常用的环境。以conda为例,可以通过如下脚本快速安装:

mkdir -p ~/miniconda3
wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda3/miniconda.sh
bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3
rm -rf ~/miniconda3/miniconda.sh
~/miniconda3/bin/conda init bash

然后还可以创建环境以及自定义的一些配置了。在配置完成自己的环境之后,现在把上述打包为一个镜像供以后直接使用。

执行命令docker commit -a "bairch" -m "my py env" 06a9615824cd mypy38:v1将该容器保存为镜像,当看到生成sha256值时表示已打包完成。

brc@DESKTOP-72HHG9G:~$ docker commit -a "bairch" -m "my py env" 06a9615824cd mypy38:v1
sha256:c2a3088898089f05803ef3dc5630a8c5bb17bdbcd43090bc753bb325fa2b01c5

此时,可以通过docker images来查看当前已有的镜像,如下,电脑中除了原先从docker官网pull到的镜像之外,还有了一个新的名为mypy38标签为v1的镜像,即为刚才创建的镜像。

brc@DESKTOP-72HHG9G:~$ docker images
REPOSITORY   TAG    IMAGE ID       CREATED              SIZE
mypy38       v1     c2a308889808   About a minute ago   1.84GB
python       3.8    415775fb4b1f   2 months ago         998MB

对刚刚创建镜像的docker commit命令的各部分有如下详细解释:

创建完镜像之后,便可以试一下,执行docker run -itd mypy38:v1在后台运行刚刚创建的镜像文件。然后通过docker ps 来查看正在运行的容器中已经有了刚刚启动的容器,此时注意该容器的NAMES为dreamy_stonebraker,这是因为我在docker run的时候没有通过--name <container name>为该容器指定一个名称,因而显示了默认名称。

brc@DESKTOP-72HHG9G:~$ docker ps
CONTAINER ID   IMAGE       COMMAND   CREATED         STATUS         PORTS     NAMES
28051d25b4ee   mypy38:v1   "bash"    5 minutes ago   Up 5 minutes             dreamy_stonebraker

通过docker exec -it 28051d25b4ee bash进入到该容器的命令行,就可以看到刚刚安装好的各种环境配置。

接下来可以尝试把这个创建的镜像推送到docker hub,供别人使用。

既然要推送到docker hub,自然要有账户,先去官网注册一个账号,然后在本地 通过docker login 登录,同时,可以在docker hub创建一个空白仓库。

接着使用 docker tag mypy38:v1 bairch/mypy38:v1 命令给本地镜像打上 Docker Hub 用户名和标签。该命令的组成为docker tag <local_image id>:<tag> <hub-username>/<repo-name>:<tag>

然后docker push 直接提交

brc@DESKTOP-72HHG9G:~$ docker push bairch/mypy38:v1
The push refers to repository [docker.io/bairch/mypy38]
f32c3e8a81ae: Pushed
02d14c5fe2c0: Mounted from library/python
f1c817089aa8: Mounted from library/python
aa19347b89d4: Mounted from library/python
a0814d1f5387: Mounted from library/python
ac7146fb6cf5: Mounted from library/python
209de9f22f2f: Mounted from library/python
777ac9f3cbb2: Pushed
ae134c61b154: Mounted from library/python
v1: digest: sha256:39b0bc01e69eed1023dd4fb89e4463f504596292ca51bcf2c72edd2633dbdc32 size: 2220

此时可以打开docker-hub 查看刚刚已推送的镜像

Untitled 24

通过docker-compose安装容器(以MySql为例)

在Docker中通过docker-compose来拉取镜像并运行一个容器,这应该是最常用的场景了,除了工作之外,还有很多好玩的镜像(比如Alist, jellyfin, nextcloud等)都可以采样该方式来安装。

Untitled 25

Docker Compose是通过YAML配置文件来定义和运行多容器 Docker 应用程序的工具。也就是说,通过一份配置文件来定义和管理多个相关联的容器容器。

Docker-Compose将所管理的容器分为三层,分别是工程(project),服务(service)以及容器(container)。Docker-Compose运行目录下的所有文件(docker-compose.yml,extends文件或环境变量文件等)组成一个工程,若无特殊指定工程名即为当前目录名。一个工程当中可包含多个服务,每个服务中定义了容器运行的镜像,参数,依赖。 Docker-Compose 通过一个配置文件来管理多个Docker容器,在配置文件 (docker-compose.yml) 中,所有的容器通过services来定义,然后使用docker-compose脚本来启动、停止和重启应用和应用中的服务以及所有依赖服务的容器。

首先创建一个项目文件夹proj_mysql,然后按照如下格式创建各子文件夹

mysql_serve
	├── conf
	│   └── my.cnf               # MySQL配置文件
	├── db                       # 数据库数据文件目录
	├── docker-compose.yml       # docker-compose.yml文件
	└── logs                     # 日志存放目录

接下来编辑项目目录中的docker-compose.yml文件为如下:

version: '3' # 表示使用的是 Docker Compose 文件的版本. 这里使用的是版本3的语法,它定义了Docker Compose 文件的结构和支持的功能。
services:  # 这是一个关键字,用于定义服务的部分。在这个部分下,可以列出所有的服务以及它们的配置。本配置下只有mysql, 但是如果同时还有别的服务需求,都可以放到servers下。
  mysql:   # 一个服务的名称,也可以叫阿猫阿狗
    restart: always # 表示在容器退出时,Docker 会自动重启这个容器
		privileged: true # 标识容器拥有特权模式
    image: mysql:8.0.32 # 表示使用的 MySQL 镜像的名称和标签
    container_name: mysqlServer33106 # 为该容器指定了一个名称
    volumes: # 定义容器和宿主机之间的数据卷映射关系;格式为<宿主机地址>:<容器内地址>,其中宿主机地址是相对项目目录路径,容器内地址是绝对路径
      - ./db:/var/lib/mysql # 将容器内的地址映射到宿主机上,这样当容器退出了,数据库db文件还在
      - ./conf:/etc/mysql/conf.d # 同上,不会因为容器挂掉,mysql配置文件就丢失
      - ./logs:/logs # 同上,可以查看容器日志
    command: # 定义了在容器启动时要执行的命令。此处定义了MySQL服务的一些配置参数
      --character-set-server=utf8mb4
      --collation-server=utf8mb4_general_ci
      --explicit_defaults_for_timestamp=true
    environment: # 定义了容器的环境变量,此处包括了数据库的root用户密码、用户、密码等信息。
      MYSQL_ROOT_PASSWORD: "Abc1234Q"
      MYSQL_USER: "user1"
      MYSQL_PASSWORD: "Abc1234Q"
      MYSQL_INITDB_SKIP_TZINFO: "Asia/Shanghai"
		ports: # 定义了容器的端口映射关系,此处将主机的33106端口映射到容器内的3306端口
      - 33106:3306  # <宿主机端口>:<容器端口>
    network_mode: "bridge" # 定义了容器的网络模式为桥接模式

通过上述配置文件的注释大概就知道这份配置文件主要在配置啥了,不外乎是端口映射、卷映射、环境变量、容器网络、镜像源等。

接下来就是添加mysql的配置文件了,(没有特殊需求可以不用自定义配置,直接使用默认即可)编辑 my.cnf 文件如下:

###### [mysql]配置模块 ######
[mysql]
# 设置MySQL客户端默认字符集
default-character-set=utf8mb4
socket=/var/lib/mysql/mysql.sock


###### [mysqld]配置模块 ######
[mysqld]
port=3306
user=mysql

sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION

datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock

# MySQL8 的密码认证插件
default_authentication_plugin=mysql_native_password

# 禁用符号链接以防止各种安全风险
symbolic-links=0

# 允许最大连接数
max_connections=1000

# 服务端使用的字符集默认为8比特编码的latin1字符集
character-set-server=utf8mb4

# 创建新表时将使用的默认存储引擎
default-storage-engine=INNODB

# 表名存储在磁盘是小写的,但是比较的时候是不区分大小写
lower_case_table_names=0
max_allowed_packet=16M 

# 设置时区
default-time_zone='+8:00'

# binlog 配置
log-bin = /logs/mysql-bin.log
expire-logs-days = 90
max-binlog-size = 500M

# server-id 配置
server-id = 1


###### [client]配置模块 ######
[client]
default-character-set=utf8mb4

接下来就可以直接启动了,执行docker-compose up -d 将会在后台启动该服务。通过docker-compose ps来查看启动的容器。

至此,一个MySQL服务其实已经建好了,可以通过SQL客户端尝试连接一下数据库了。

👉

docker-compose ps用于查看docker-compose管理的服务的状态,而docker ps用于查看主机上所有正在运行的Docker容器的状态。

需要注意的是,MySQL服务器默认是没有远程访问权限的,需要手动给MySQL用户配置远程访问权限。具体操作如下:

# 进入正在运行的MySQL容器内部
docker exec -it <mysql_container_name> /bin/bash
# 使用root权限登录到MySQL服务器
mysql -u root -p
# 授予远程访问权限给特定的用户。其中的%表示允许任何地址连接
alter user '<user name>'@'%' identified with mysql_native_password by '<login password>';

若想要停止该服务,进入该项目目录执行docker-compose down即可,该命令会停止并移除通过docker-compose.yml启动的所有容器,同时还会移除相关的网络和卷,即可以直接停止整个应用。

补充

其实MySQL的这个例子并不好,毕竟里面只有一个服务,没有发挥docker-compose的真正威力(docker-compose是可以同时管理多个服务的)。

下面以gitea为例,来看看gitea的docker-compose配置文件:

version: "3"

networks:
  gitea:
    external: false

services:
  server:
    image: gitea/gitea:latest
    container_name: gitea
    environment:
      - USER_UID=131
      - USER_GID=138
      - GITEA__database__DB_TYPE=mysql
      - GITEA__database__HOST=db:3306
      - GITEA__database__NAME=gitea
      - GITEA__database__USER=gitea
      - GITEA__database__PASSWD=gitea
    restart: always
    networks:
      - gitea
    volumes:
      - ./gitea:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "222:22"
    depends_on:
      - db

  db:
    image: mysql:8
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=gitea
      - MYSQL_USER=gitea
      - MYSQL_PASSWORD=gitea
      - MYSQL_DATABASE=gitea
    networks:
      - gitea
    volumes:
      - ./mysql:/var/lib/mysql

在上述配置文件中,services下存在两个服务,分别的server和db,这两个服务即为两个容器,每个容器内的配置其实和上面的MySQL配置差不多,需要注意的是:

打包Python项目为镜像

将已有的Python应用打包为一个镜像发布,这应该是用的次多的场景了。本小节将学习通过DockerFile来将已有的应用构建为镜像。

Dockerfile本质上是一个文本文件,其中明确定义了如何为我们的项目构建Docker镜像。Dockerfile里面一般包括如下几步:

  1. FROM: 所有Dockerfile的第一个指令都必须是 FROM ,用于指定一个构建镜像的基础源镜像,如果本地没有就会从公共库中拉取
  2. WORKDIR:指明应用在容器中的工作目录。
  3. COPY: 复制本机文件或目录到指定的容器目录,格式为
  4. RUN:用于运行安装任务从而向映像中添加额外的内容。比如说安装Python依赖包等。
  5. CMD:容器启动后运行的命令

假设现有如下应用:

fastapp  
    ├── requirements.txt
    ├── Dockerfile
    └── app
        └── app.py
        └── <other .py files>

app.py如下:

from typing import Union
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/items/{item_id}")
def read_item(item_id: int, q: Union[str, None] = None):
    return {"item_id": item_id, "q": q}

现在按照上述对Dockerfile的介绍来编写fastapp目录下的Dockerfile文件,如下:

FROM python:3.9

WORKDIR /fastapp # 在容器内创建一个工作目录

COPY ./requirements.txt /fastapp/requirements.txt

RUN pip install -r requirements.txt # 在容器的/app目录下安装依赖

COPY ./app /fastapp/app

CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "80"] # 在容器的/app目录下运行python main.py命令
👉

注:

  • Dockerfle中是无法写注释的,此处是为了方便
  • 最后一行中的"uvicorn", "app.app:app" 可能会有疑惑,哪来这么多app?第一个app,表示当前工作目录下的app目录,第二个app表示app.py文件;第三个app是指app.py中的对象app.
  • 在构建镜像时,Docker创建了所谓的“层(layers)”。每一层都记录了Dockerfile中的命令所导致的更改,以及运行命令后镜像的状态。
  • Docker在内部缓存这些层,这样在重新构建镜像时只需要重新创建已更改的层。例如,这里使用了 python:3.9 的基础镜像,相同容器的所有后续构建都可以重用它,因为它不会改变。需要注意的是,每当重新构建某一层时,Dockerfile 中紧随其后的所有层也都需要重新构建。例如,我们首先复制 requirements.txt 文件,然后再复制应用程序的其余部分。这样之前安装的依赖项只要没有新的依赖关系,即使应用程序中的其他文件发生了更改,也不需要重新构建这一层。这一点在创建 Dockerfiles 时一定要注意。因此,通过将 pip 安装与应用程序其余部分的部署分离,可以优化容器的构建过程。

有了Dockerfile就可以执行 docker build -t docker-flask:0.1 .来构建镜像了,具体进度如下:

brc@DESKTOP-72HHG9G:~/project/fastapp$ docker build -t dfast-app:0.1 .
[+] Building 14.4s (10/10) FINISHED                                                                                   docker:default
 => [internal] load .dockerignore                                                                                               0.0s
 => => transferring context: 2B                                                                                                 0.0s
 => [internal] load build definition from Dockerfile                                                                            0.0s
 => => transferring dockerfile: 259B                                                                                            0.0s
 => [internal] load metadata for docker.io/library/python:3.9                                                                   0.8s
 => [1/5] FROM docker.io/library/python:3.9@sha256:30678bb79d9eeaf98ec0ce83cdcd4d6f5301484ef86873a711e69df2ca77e8ac             0.0s
 => [internal] load build context                                                                                               0.0s
 => => transferring context: 311B                                                                                               0.0s
 => CACHED [2/5] WORKDIR /fastapp                                                                                               0.0s
 => [3/5] COPY ./requirements.txt /fastapp/requirements.txt                                                                     0.1s
 => [4/5] RUN pip install -r requirements.txt                                                                                  13.1s
 => [5/5] COPY ./app /fastapp/app                                                                                               0.1s
 => exporting to image                                                                                                          0.2s
 => => exporting layers                                                                                                         0.2s
 => => writing image sha256:e4c65f0dac1caf5a8b4a82c5e84412c15dd03f54240ea15d5f69169c838ad3fe                                    0.0s
 => => naming to docker.io/library/dfast-app:0.1                                                                                0.0s

What's Next?
  View a summary of image vulnerabilities and recommendations → docker scout quickview

上述命令中的-t的全称是--tag,显然是给创建的镜像标识的。后面的.表示用当前路径下的Dockerfile来创建镜像。

有了镜像,就可以直接通过docker run创建并且启动容器了,如下:

brc@DESKTOP-72HHG9G:~/project/fastapp$ docker run --name myfastapp -p 800:80 dfast-app:0.1
INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:80 (Press CTRL+C to quit)
INFO:     172.17.0.1:35006 - "GET /items/5?q=somequery HTTP/1.1" 200 OK
INFO:     172.17.0.1:35006 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO:     172.17.0.1:35010 - "GET /items/5?q=som2 HTTP/1.1" 200 OK

上述命令,首先给这个容器命名为myfastapp, 然后将宿主机的端口800映射到了容器的80端口,80是因为在Dockerfile内设置好容器内的fastapi服务在80端口启动。

至此,已经完成了已有应用的镜像构建了。

补充

在开发应用程序的过程中,更重要的是要快速重新构建和测试,以检查验证过程中的每个中间步骤。为此,程序的开发人员需要依赖于fastapi等框架提供的自动重启功能(Debug模式下,修改代码自动重启)。而这一功能也可以在容器中使用。

为了启用自动重启,在启动Docker容器时将主机中的开发目录映射到容器中的app目录。这样fastapi就可以监听主机中的文件变化(通过映射)来发现代码更改,并在检测到更改时自动重启应用程序。

当然,这需要在构建镜像的时候,就在Dockerfile中写好fastapi以reload的模式(即debug模式)运行。如下:

CMD ["uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "80", "--reload"]

然后,docker run的时候,做好卷的映射,如下:

docker run --name myfastapp -v $PWD/app:/fastapp/app -p 800:80 dfast-app:0.1

通过把$PWD/app目录映射到容器的/fastapp/app目录,这样容器内看到的目录app中的内容就是宿主机上的app目录中的内容,从而实现了,只要宿主机下代码变化,容器内的应用便自动重启。

一些技巧

打开/etc/default/docker文件(需要sudo权限),在文件的底部追加如下一行:
DOCKER_OPTS="--registry-mirror=https://registry.docker-cn.com"
sudo usermod -aG docker $USER

一些常用的docker命令

# 查看所有的docker容器,不加参数-a时,仅查看当前正在运行的容器
docker ps -a
# 查看所有的镜像
docker images
# 删除某个镜像
docker rmi <image id>
# 删除某个容器
docker rm <contaimer id>
# 运行某个停止的容器
docker start <container id>
# 停止某个容器
docker stop <container id>
# 进入某个运行容器的命令行
docker exec -it <container id> /bin/bash

总结

本文首先介绍了Docker的几个重要概念,比如容器、镜像、Volumn等;然后以三个常用场景为例,了解了Docker的一些用法。最后,又写了几个技巧性的东西。后面再有的东西,就可以边google边使用了。

参考


Share this post:

Previous Post
面向Pythoneer的JS教程
Next Post
自动微分是什么