如何把自己的Python应用容器化

本文总阅读量

现在云原生越来越流行, 容器化势在必行, 作为一个开发人员, 多多少少也要接触一些容器相关的操作, 其中最基础的操作是如何把自己的应用构建为一个Docker容器, 并管理。 本文以starlette框架和后台的开发常见3件套NginxMySQLRedis为底创建一个简单的Web后台演示项目, 并一步一步介绍如何编写成一个容器以及容器的运行。

注: 2021-02-07增加了示例代码
注: 2021-12-12部分内容进行重写

1.创建项目

第一步会先创建一个Python后台项目, 这个项目包含3个接口, 一个接口用来测试服务是否正常, 另外一个是测试MySQL调用, 最后一个是测试Redis调用。

首先是创建一个后台项目以及依赖:

1
2
3
4
5
6
7
8
9
10
11
12
➜  example_python git:(master) mkdir python_on_docker          
➜ example_python git:(master) cd python_on_docker
# 一个项目配套一个虚拟环境, 如果熟悉, 建议用porety来处理项目的python和venv配套环境问题, 这里为了演示方便, 使用了venv
➜ python_on_docker git:(master) python3.7 -m venv venv
➜ python_on_docker git:(master) source venv/bin/activate
➜ python_on_docker git:(master) touch __init__.py # 每个Python项目要确保有一个__init__.py文件

# 可以看到多了个venv的环境, 目前已经切到venv, 开始安装依赖
(venv) ➜ python_on_docker git:(master) pip install starlette aiomysql aioredis uvicorn
(venv) ➜ python_on_docker git:(master) pip install cryptography # aiomysql需要该模块提供加密算法
# 生成pip安装的依赖文件
(venv) ➜ python_on_docker git:(master) python -m pip freeze > requirements.txt

之后创建项目主文件example.py, 该文件主要提供API服务,里面包含上面所说的3个接口, 示例代码如下(源码):

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
68
69
70
71
72
73
74
75
76
77
78
79
from typing import Optional, Tuple

import aiomysql
import aioredis
from starlette.applications import Starlette
from starlette.config import Config
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.routing import Route


config: Config = Config(".env")
mysql_pool: Optional[aiomysql.Pool] = None
redis: Optional[aioredis.Redis] = None


async def on_start_up():
"""连接MySQL和Redis"""
global mysql_pool
global redis

mysql_pool = await aiomysql.create_pool(
host=config("MYSQL_HOST"),
port=config("MYSQL_PORT", cast=int),
user=config("MYSQL_USER"),
password=config("MYSQL_PW"),
db=config("MYSQL_DB"),
)
redis = aioredis.Redis(
await aioredis.create_redis_pool(
config("REDIS_URL"),
minsize=config("REDIS_POOL_MINSIZE", cast=int),
maxsize=config("REDIS_POOL_MAXSIZE", cast=int),
encoding=config("REDIS_ENCODING")
)
)


async def on_shutdown():
"""关闭MySQL和Redis连接"""
await mysql_pool.wait_closed()
await redis.wait_closed()


def hello_word(request: Request) -> PlainTextResponse:
"""测试接口调用接口"""
return PlainTextResponse("Hello Word!")


async def mysql_demo(request: Request) -> JSONResponse:
"""测试MySQL调用接口"""
count: int = int(request.query_params.get("count", "0"))
async with mysql_pool.acquire() as conn:
async with conn.cursor() as cur:
await cur.execute("SELECT %s;", (count, ))
mysql_result_tuple: Tuple[int] = await cur.fetchone()
return JSONResponse({"result": mysql_result_tuple})


async def redis_demo(request: Request) -> JSONResponse:
"""测试Redis调用接口"""
count: int = int(request.query_params.get("count", "0"))
key: str = request.query_params.get("key")
if not key:
return JSONResponse("key is empty")
result: int = await redis.incrby(key, count)
await redis.expire(key, 60)
return JSONResponse({"count": result})


app: Starlette = Starlette(
routes=[
Route('/', hello_word),
Route('/mysql', mysql_demo),
Route('/redis', redis_demo)
],
on_startup=[on_start_up],
on_shutdown=[on_shutdown]
)

项目文件创建完毕, 接着再创建一个配套的配置文件.env(starlette的config会自动加载.env的配置):

1
2
3
4
5
6
7
8
9
10
11
12
# 按自己的配置信息更改配置
MYSQL_DB="mysql"
MYSQL_HOST="127.0.0.1"
MYSQL_PORT="3306"
MYSQL_USER="root"
MYSQL_PW=""

REDIS_URL="redis://localhost"
REDIS_POOL_MINSIZE=1
REDIS_POOL_MAXSIZE=10
REDIS_ENCODING="utf-8"

到现在为止, 目录里只有example.py主文件, .env配置文件以及requirements.txt的依赖文件, 现在开始启动应用查看应用是否能正常启动(注意修改mysql和redis的配置文件, 目前假设已经在本地安装好mysql和redis):

1
2
3
4
5
6
7
# 使用python -m uvicorn可以防止调用到外部的uvicorn
python -m uvicorn example:app
# 以下为终端输出
INFO: Started server process [4616]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

通过终端的输出可以看到我们的服务已经启动并监听本机的8000端口,接下来测试接口可否正常使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➜  curl http://127.0.0.1:8000
Hello Word!
➜ curl http://127.0.0.1:8000/mysql
{"result":[0]}
➜ curl http://127.0.0.1:8000/mysql\?count\=10
{"result":[10]}
➜ curl http://127.0.0.1:8000/mysql\?count\=50
{"result":[50]}
➜ curl http://127.0.0.1:8000/redis\?key\=test
{"count":0}
➜ curl http://127.0.0.1:8000/redis\?key\=test\&count\=2
{"count":2}
➜ curl http://127.0.0.1:8000/redis\?key\=test\&count\=2
{"count":4}
➜ curl http://127.0.0.1:8000/redis\?key\=test\&count\=2
{"count":6}
➜ curl http://127.0.0.1:8000/redis\?key\=test\&count\=2
{"count":8}

通过输出可以发现, 我们的测试结果正常, 接口可以正常使用,前菜到此结束, 接下来是开始利用Docker部署Python Web应用之旅.

2.为项目创建镜像并运行

目前我们还没碰过Docker, 从这里开始, 就开始使用Docker了, 但在使用之前要确保自己安装了Docker, 每个平台都有不同的安装方法且资料很多, 官方资料也很详细, 这里就不多做描述了。

Docker中创建镜像很简单, 只需要通过一个Dockerfile文件来告诉Docker如何制作镜像即可, Dockerfile主要包括两个用途, 一个是对当前镜像的描述; 一个是指导Docker完成应用的容器化(创建一个包含当前应用的镜像),Dockerfile能实现开发和部署两个过程的无缝切换, 同时Dockerfile还能帮助新手快速熟悉这个项目(Dockerfile对当前的应用及其依赖有一个清晰准确的描述,并且非常容易阅读和理解,因此,要像重视你的代码一样重视这个文件,并且将它纳入到源控制系统当中.

简单的了解后开始编写对应的Dockerfile文件, 文件如下(源码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 拉取python的基础镜像, 具体使用python -V查看刚才自己是哪个版本的
FROM python:3.7.4-alpine
# 设置当前镜像的维护者
LABEL maintainer="so1nxxxx@gmail.com"
# 设置工作目录
WORKDIR /data/app
# 复制本地依赖, 每个COPY会新建一个镜像层
COPY . .

# 设置环境变量
# 不要生成pyc文件
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# 安装依赖, 由于aioredis依赖的hiredis以及cryptography需要编译, 所以这里需要add这些包, 这样依赖,容器就会变得很大...
RUN apk add --update gcc musl-dev python3-dev libffi-dev openssl-dev build-base && pip install --upgrade pip && pip install -r requirements.txt

# 指明监听的端口
EXPOSE 8080

# 运行的命令
CMD ["uvicorn", "--host", "0.0.0.0", "example:app"]

Dockerfile文件中的命令虽然有点多, 但是不复杂, 理解后就会发现这些命令可读性很高, 以下是各个命令的解读:

  • FROM: 每个Dockerfile文件第一行都是FROM指令, FROM指令指定的镜像,都会作为当前镜像的一个基础镜像层,后续命令产生的镜像都会作为新增镜像层添加到基础镜像层之上。这里我们使用python:xxx-alpine的镜像都是官方的镜像, 这个镜像的Python是建立在一个alpine Linux的镜像上面, alpine Linux体积非常小, 不过麻雀虽小, 但五脏俱全。 使用FROM指令引用官方基础镜像是一个很好的习惯,这是因为官方的镜像通常会遵循一些最佳实践,并且能帮助使用者规避一些已知的问题。除此之外,使用FROM的时候选择一个相对较小的镜像文件通常也能避免一些潜在的问题.

  • LABEL: Dockerfile中通过标签(LABLE)方式指定了当前镜像的维护者。每个标签其实是一个键值对(Key-Value),在一个镜像当中可以通过增加标签的方式来为镜像添加自定义元数据.

  • WORKDIR: 指明在镜像中的工作目录

  • COPY: 复制本地目录, 将应用相关文件从构建上下文复制到了当前镜像中,并且新建一个镜像层来存储.

  • ENV: 设置该镜像运行时的环境变量

  • RUN: 执行命令, RUN指令会在FROM指定的alpine基础镜像之上,新建一个镜像层来存储这些安装内容.

  • EXPOSE: 指明监听的端口, 实际上并没有任何作用

  • CMD: 启动时运行的命令

    这些命令都很简单, 不过我在COPYRUN命令注释中, 都说到了新建了一个镜像层, 在Docker镜像中, 每多一个镜像层就意味这这个镜像会占用更多存储空间, 同时也会更难用, 更慢。 所以大家一般都会追求构建的镜像都尽量的小, 不喜欢因为几个命令导致镜像占用过多的空间。 那我们该怎么区分命令, 判断哪些命令会新增一个镜像层呢?

关于如何区分命令是否会新建镜像层的一个基本原则是,如果指令的作用是向镜像中增添新的文件或者程序,那么这条指令就会新建镜像层, 如果只是告诉Docker如何完成构建或者如何运行应用程序,那么就只会增加镜像的元数据。

此外, 不同的Dockerfile写法都会导致镜像层数量的不同, 比如在Dockerfile中每一个RUN指令基本都会新增一个镜像层, 我们可以通过使用&&连接多个命令或者使用反斜杠(\)换行的方法,将多个命令包含在一个RUN指令中, 这样就可以减少镜像层的产生。

不过有些时候要把RUN拆分的, 因为Docker自带了一个缓存机制, 如果这个RUN执行时构建的镜像层在缓存中时, Docker会直接引用, 这样构建速度就会比较快, 而在把所有RUN合并到同一条时, 基本上就很难命中缓存了(需要注意的是, docker在执行到第一句不命中缓存的命令后, 后面的所有命令是都不会通过缓存构建的)。

现在Dockerfile编写完了, 在构建镜像之前, 我们先检查我们的目录:

1
2
(venv) ➜  python_on_docker git:(master) ls -a
Dockerfile example.py requirements.txt __pycache__ venv .env

发现目录下面有__pycache__venv文件, 这两个文件我是不想编进镜像里的, 但是开发的时候需要用到, 这时就可以使用Docker中一个类似于gitignore的机制, 我们通过.dockerignore文件编写我们要忽略的文件即可让Docker在构建镜像时忽略这些文件:

1
2
__pycache__/
.venv

现在,Dockerfile.dockerignore文件创建完了, 可以通过以下的命令开始构建自己的镜像了:

1
2
# -t后面是标签, 可以自己填写, .代表当前目录
➜ docker image build -t app:latest .

构建完镜像后就可以查看当前镜像了

1
2
3
4
# 查看当前的镜像
➜ version_1 git:(master) docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 3351ee7a79ac About a minute ago 435MB

可以看到我们的镜像已经创建成功了, 不过显示这个简单的镜像占用了435MB的空间, 这是不合理的, 我们可以通过docker image inspect xxx查看镜像有多少层, 有哪些卷和配置信息, 进一步解决镜像过大的问题(由于返回的数据会比较多, 这里就不展示了)。此外, 我们还可以通过history命令查看我们的镜像构建情况, 了解是哪一个步骤导致镜像变大的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜  version_1 git:(master) docker history app
IMAGE CREATED CREATED BY SIZE COMMENT
3351ee7a79ac About a minute ago /bin/sh -c #(nop) CMD ["uvicorn" "--host" "… 0B
f7fedcb216b0 About a minute ago /bin/sh -c #(nop) EXPOSE 8080 0B
190fd056b947 About a minute ago /bin/sh -c apk add --update gcc musl-dev pyt… 313MB
66901ff8b9d4 5 minutes ago /bin/sh -c #(nop) ENV PYTHONUNBUFFERED=1 0B
7e85b2fa504e 5 minutes ago /bin/sh -c #(nop) ENV PYTHONDONTWRITEBYTECO… 0B
a2714bff8c12 5 minutes ago /bin/sh -c #(nop) COPY dir:26dace857b0be9773… 23.7MB
dc4d69bd98e5 5 minutes ago /bin/sh -c #(nop) WORKDIR /data/app 0B
db1533598434 5 minutes ago /bin/sh -c #(nop) LABEL maintainer=so1nxxxx… 0B
f309434dea3a 16 months ago /bin/sh -c #(nop) CMD ["python3"] 0B
<missing> 16 months ago /bin/sh -c set -ex; wget -O get-pip.py "$P… 6.24MB
<missing> 16 months ago /bin/sh -c #(nop) ENV PYTHON_GET_PIP_SHA256… 0B
<missing> 16 months ago /bin/sh -c #(nop) ENV PYTHON_GET_PIP_URL=ht… 0B
<missing> 16 months ago /bin/sh -c #(nop) ENV PYTHON_PIP_VERSION=19… 0B
<missing> 17 months ago /bin/sh -c cd /usr/local/bin && ln -s idle3… 32B
<missing> 17 months ago /bin/sh -c set -ex && apk add --no-cache --… 86.4MB
<missing> 17 months ago /bin/sh -c #(nop) ENV PYTHON_VERSION=3.7.4 0B
<missing> 17 months ago /bin/sh -c #(nop) ENV GPG_KEY=0D96DF4D4110E… 0B
<missing> 17 months ago /bin/sh -c apk add --no-cache ca-certificates 551kB
<missing> 17 months ago /bin/sh -c #(nop) ENV LANG=C.UTF-8 0B
<missing> 17 months ago /bin/sh -c #(nop) ENV PATH=/usr/local/bin:/… 0B
<missing> 17 months ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 17 months ago /bin/sh -c #(nop) ADD file:fe64057fbb83dccb9… 5.58MB

可以看到, 第3条命令的apk ...那里占用的空间最大, 这是因为在RUN的时候需要安装一些编译依赖后才能安装我们的想要的Python库, 但这些依赖都非常大。 幸好Docker针对这种情况提供了多阶段构建的功能(还有建造者模式, 但不如多阶段构建), 多阶段构建方式是一个Dockerfile文件包含了多个FROM指令, 在这个文件中每一个FROM指令都是一个新的构建阶段(Build Stage),并且每个新的构建接单都可以方便地复制之前阶段完成的构件, 也就是可以先通过依赖构建一个比较重的Docker镜像, 然后基于该镜像构建一个用户真正想要的镜像, 最终只保留最后构建的镜像。

于是我们可以把我们的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
#####################
# 编译依赖的配置文件#
#####################

# 第一阶段
# 设置该阶段的别名为builder
FROM python:3.7.4-alpine as builder
# 设置工作目录
WORKDIR /data/app
# 复制本地依赖
COPY . .

# 设置环境变量
# 不要生成pyc文件
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# 安装依赖并编译文件到/data/python_wheels
RUN apk add --update gcc musl-dev python3-dev libffi-dev openssl-dev build-base && pip install --upgrade pip && pip wheel --no-cache-dir --no-deps --wheel-dir /data/python_wheels -r requirements.txt

#####################
# 线上使用的配置文件#
#####################

# 第二阶段
# 拉取python的基础镜像, 具体使用python -V查看刚才自己是哪个版本的
FROM python:3.7.4-alpine
# 设置当前镜像的维护者
LABEL maintainer="so1nxxxx@gmail.com"
# 设置工作目录
WORKDIR /data/app
# 复制本地依赖
COPY . .

# 将第一构建阶段生成的编译文件复制到容器对应的路径下面
COPY --from=builder /data/python_wheels /data/python_wheels
# 通过wheels安装python依赖
RUN pip install --no-cache /data/python_wheels/*

# 指明监听的端口
EXPOSE 8080

# 运行的命令
CMD ["uvicorn", "--host", "0.0.0.0", "example:app"]
```
所示的`Dockerfile`文件中有两个`FROM`, 一个`FROM`代表一个单独的构建阶段, 第一个阶段是根据当前的`Python`环境, 安装并编译需要的依赖, 然后根据`requirements`生成`Python`的 wheel文件, 生成的位置是`/data/python_wheels`。第二个`FROM`还是跟刚才的一样, 直到`COPY`语句,这里是一个`COPY --from`指令,它从之前的阶段构建的镜像中仅复制生产环境相关的依赖,而不会复制生产环境不需要的依赖, 这个语句的意思是从builder构建阶段的`/data/python_wheels`复制到当前构建阶段的`/data/python_wheels`。 接下来`RUN`语句也发生改变, 由于在第一阶段已经编译好了依赖, 这里直接使用依赖进行安装即可. 后面的就跟前面一样, 没有什么变化, `Dkckerfile`文件编写好了, 开始构建自己的镜像:
```bash
# -t后面是标签, 可以自己填写, .代表当前目录
➜ docker image build -t app_1:latest .

构建完成后查看构建完的镜像:

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
➜  version_2 git:(master) docker image ls
REPOSITORY TAG IMAGE ID CREATED SIZE
app_1 latest a71a4a7db157 7 seconds ago 116MB
app latest 3351ee7a79ac 9 minutes ago 435MB

➜ version_2 git:(master) docker history app_1
IMAGE CREATED CREATED BY SIZE COMMENT
a71a4a7db157 43 seconds ago /bin/sh -c #(nop) CMD ["uvicorn" "--host" "… 0B
d4d38b71a1ba 43 seconds ago /bin/sh -c #(nop) EXPOSE 8080 0B
5fb10c8afea8 43 seconds ago /bin/sh -c pip install --no-cache /data/pyth… 15.3MB
e454bbe54adb 46 seconds ago /bin/sh -c #(nop) COPY dir:ff6195d46738a79a1… 2.13MB
d70a8a552490 46 seconds ago /bin/sh -c #(nop) COPY dir:fbe9ac8ac1636d3d7… 3.63kB
dc4d69bd98e5 14 minutes ago /bin/sh -c #(nop) WORKDIR /data/app 0B
db1533598434 14 minutes ago /bin/sh -c #(nop) LABEL maintainer=so1nxxxx… 0B
f309434dea3a 16 months ago /bin/sh -c #(nop) CMD ["python3"] 0B
<missing> 16 months ago /bin/sh -c set -ex; wget -O get-pip.py "$P… 6.24MB
<missing> 16 months ago /bin/sh -c #(nop) ENV PYTHON_GET_PIP_SHA256… 0B
<missing> 16 months ago /bin/sh -c #(nop) ENV PYTHON_GET_PIP_URL=ht… 0B
<missing> 16 months ago /bin/sh -c #(nop) ENV PYTHON_PIP_VERSION=19… 0B
<missing> 17 months ago /bin/sh -c cd /usr/local/bin && ln -s idle3… 32B
<missing> 17 months ago /bin/sh -c set -ex && apk add --no-cache --… 86.4MB
<missing> 17 months ago /bin/sh -c #(nop) ENV PYTHON_VERSION=3.7.4 0B
<missing> 17 months ago /bin/sh -c #(nop) ENV GPG_KEY=0D96DF4D4110E… 0B
<missing> 17 months ago /bin/sh -c apk add --no-cache ca-certificates 551kB
<missing> 17 months ago /bin/sh -c #(nop) ENV LANG=C.UTF-8 0B
<missing> 17 months ago /bin/sh -c #(nop) ENV PATH=/usr/local/bin:/… 0B
<missing> 17 months ago /bin/sh -c #(nop) CMD ["/bin/sh"] 0B
<missing> 17 months ago /bin/sh -c #(nop) ADD file:fe64057fbb83dccb9… 5.58MB

可以看到现在的镜像大小已经减少很多了, 差不多只剩4分之一,非常完美! 如果还想让镜像更小一点, 那可以在使用bliud命令时, 添加--squash选项, 这样Dockerbuild的时候就会把所有镜像层合并为一个, 但这也是有缺点的, 因为合并的镜像层无法共享镜像层, 而且镜像在pushpull的时候开销会变得很大。

镜像终于创建完了, 终于可以启动容器查看我们构建的镜像的运行效果了:

1
2
# 执行容器运行命令, --name参数可以自己定, -p 参数指定容器的端口(第二个)绑定到本机的端口(第一格), 最后一个参数为image id每次生成的镜像都不同
➜ docker container run -d --name docker_app_1 -p 8000:8000 app_1

调用了启动命令后, 通过ps命令你给查看容器运行情况:

1
2
3
4
5
# 启动后查看容器启动失败, 很正常, 配置用的是`127.0.0.1`而容器没有安装mysql和redis,肯定是连不上的 
# 查看容器状态
➜ version_2 git:(master) docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3070cf77c951 app_1 "uvicorn --host 0.0.…" 7 seconds ago Exited (0) 6 seconds ago docker_app_1

通过输出可以发现镜像运行失败, 但是不知道为啥失败, 只能通过运行日志查看为什么失败:

1
2
3
4
5
6
7
8
9
10
11
12
# 查看运行日志
➜ version_2 git:(master) docker logs -f -t --tail 10 docker_app_1
2021-02-07T09:01:16.062239955Z await pool._fill_free_pool(False)
2021-02-07T09:01:16.062241993Z File "/usr/local/lib/python3.7/site-packages/aiomysql/pool.py", line 168, in _fill_free_pool
2021-02-07T09:01:16.062250734Z **self._conn_kwargs)
2021-02-07T09:01:16.062253106Z File "/usr/local/lib/python3.7/site-packages/aiomysql/connection.py", line 75, in _connect
2021-02-07T09:01:16.062255305Z await conn._connect()
2021-02-07T09:01:16.062257318Z File "/usr/local/lib/python3.7/site-packages/aiomysql/connection.py", line 523, in _connect
2021-02-07T09:01:16.062259455Z self._host) from e
2021-02-07T09:01:16.062275244Z pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on '127.0.0.1'")
2021-02-07T09:01:16.062277250Z
2021-02-07T09:01:16.062279127Z ERROR: Application startup failed. Exiting.

从报错日志可以看到, 镜像启动失败是由于连不上127.0.0.1地址导致报错了, 可是本机上面已经安装了MySQL啊, 为什么还会连不上呢? 这是因为Docker容器运行的时候会选择一个网络模式, 共有hostbridgenone三种网络可供配置:

  • bridge 该模式是Docker的默认选项. bridge即桥接网络,以桥接模式连接到宿主机, 这时候容器内的应用访问127.0.0.1是指容器本身的网络(在本机通过ifconfig命令可以看到有个类似于docker0的展示), 该模式下如果要连接到宿主机的网络, 只能把127.0.0.1改为本机的局域网ip;

  • host host是宿主网络,即Docker与宿主机共用网络, 在该模式下, Docker容器内的应用使用网络时跟平时一样正常使用即可, 同时该模式的网络性能也是最好的, 如果在使用bridge模式时发现有网络瓶颈, 或者应用对网络延迟和并发有极高的要求时, 记得切为host网络模式。

  • none则表示无网络,容器内应用将无法联网.

    了解了Docker的网络模式后, 我们可以通过把网络模式改为host来解决连不上的问题了, 具体操作是通过去掉-p 8000:8000选项, 增加--net=host选项来启动容器 (如果旧容器存在, 记得删除掉, 不然会占用空间):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    # 启动容器
    ➜ version_2 git:(master) docker container run -d --name docker_app_1 --net=host app_1
    cd1ea057cdb6ec6ee3917d13f9c3c55db2a2949e409716d1dbb86f34bb1356e5

    # 查看启动日志, 正常!
    ➜ version_2 git:(master) docker logs -f -t --tail 10 docker_app_1
    2021-02-07T09:06:35.403888447Z INFO: Started server process [1]
    2021-02-07T09:06:35.403903761Z INFO: Waiting for application startup.
    2021-02-07T09:06:35.437776480Z INFO: Application startup complete.
    2021-02-07T09:06:35.438466743Z INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

    # 调用接口发现启动成功
    ➜ curl 127.0.0.1:8000
    127.0.0.1, Hello Word!

    则此, 我们终于把python的web应用构建成镜像, 并正常启动镜像了。

3.单引擎模式部署和管理多容器应用

在懂了如何制作容器之后, 就可以开始准备成为Yaml工程师了, 上面我们只构建一个Python应用的镜像, 然后连接了本地的MySQLRedis服务, 现在我们也可以把MySQLRedis这两个服务一起容器化, 不过这时候每个服务都通过一个特定Dockerfile来配置并使用的话就太麻烦了。

如果有一个像Dockerfile的文件, 文件里写了如何安装这3个镜像, 然后我们执行一个命令就可以把这三个服务都安装到服务器上, 那就很方便了, 在Docker中是由Docker Compose提供了这个功能。

如果用过Ansible, 就知道会有一个palybook的yaml配置文件, 只要在控制主机上存放一个palybook文件, 就可以控制其它机器执行任何操作, 比如为其它主机安装应用等到, 而Docker Compose有点类似, 基于该功能可以做到在一台机器上同时管理多个Docker容器。

Docker Compose通过一个声明式的配置文件描述整个应用,从而使用一条命令完成部署。应用部署成功后,还可以通过一系列简单的命令实现对其完整生命周期的管理。此外,配置文件还可以置于版本控制系统中进行存储和管理, 这个工具会跟docker一起安装。

接下来就是实战了, 本次实例相比于之前的服务多了个Nginx服务, 而且Nginx是需要一个配置文件都,所以需要先为Nginx单独配置一个Dockerfile文件。

首先, 在目录创建一个nginx文件夹, 然后在文件夹里面编写配置文件nginx.conf(源码):

Conf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
upstream app_server {
server app:8000;
}

server {

listen 80;

location / {
proxy_pass http://app_server;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
proxy_redirect off;
}

}

这时可能发现配置文件里的upstream里面有个奇怪的配置app:8000,我们先不管, 继续创建NginxDockerfile(源码)文件:

1
2
3
4
5
6
FROM nginx:1.19.0-alpine

# 移除Nginx的默认配置文件
RUN rm /etc/nginx/conf.d/default.conf
# 使用我们编写的配置文件
COPY nginx.conf /etc/nginx/conf.d

现在, Nginx的容器文件已经准备完毕, 开始编写我们的docker-compose.yml文件, 假设我们现在的单服务器需要有Python的Web服务, Nginx, RedisMySQL服务(源码):

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
68
69
70
71
72
73
74
75
76
# version是必须指定的,而且总是位于文件的第一行。它定义了Compose文件格式(主要是API)的版本。建议使用最新版本。
version: "3.5"
# 用于定义不同的应用服务
services:
redis:
# 基于redis:alpine镜像启动一个独立的名为redis的容器。
image: "redis:alpine"
# 使得Docker可以将服务连接到指定的网络上。这个网络应该是已经存在的,或者是在networks一级key中定义的网络。
networks:
- local-net
# 指定Docker将容器内(-target)的6379端口映射到主机(published)的63790端口。
ports:
- target: 6379
published: 63790
mysql:
image: mysql
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci #设置utf8字符集
restart: always
networks:
local-net:
environment:
# 通过环境变量设置 mysql需要的用户名和密码等
MYSQL_DATABASE: 'test'
MYSQL_USER: 'root'
MYSQL_PASSWORD: ''
MYSQL_ROOT_PASSWORD: ''
MYSQL_ALLOW_EMPTY_PASSWORD: 'true'

# 指定Docker将容器内(-target)的3306端口映射到主机(published)的33060端口。
ports:
- target: 3306
published: 33060
# 设置MySQL容易存储内容在哪个目录
volumes:
- type: volume
source: local-vol
target: /example_volumes
app:
# 指定Docker基于当前目录(.)下Dockerfile中定义的指令来构建一个新镜像。该镜像会被用于启动该服务的容器。
build: .
# 指定Docker在容器中执行的命令, 由于我们的Dockerfile中已经有了, 这里就注释掉
# command:
# 指定Docker将容器内(-target)的8000端口映射到主机(published)的8000端口。
ports:
- target: 8000
published: 8000
networks:
- local-net
# 挂载到本地的卷
volumes:
- type: volume
source: local-vol
target: /example_volumes
# 声明需要依赖上面的服务, 只有上面的服务起来, 这个才能起
depends_on:
- mysql
- redis
nginx:
build: ./nginx

# 指定Docker将容器内(-target)的80端口映射到主机(published)的8001端口。
networks:
- local-net
ports:
- target: 80
published: 8001
depends_on:
- app

# networks用于指引Docker创建新的网络。默认情况下,Docker Compose会创建bridge网络。这是一种单主机网络,只能够实现同一主机上容器的连接。当然,也可以使用driver属性来指定不同的网络类型。
networks:
local-net:
driver: bridge

volumes:
local-vol:

文件创建完了, 如果直接启动, 会发现虽然容器都会将端口映射到主机上面, 但是由于全部服务都配置了一个local-net网络, 它是bridge模式的网络, 所以容器里面访问127.0.0.1是访问不到其他容器了, 不过这几个容器应用都可以通过local-net网络建立连接, 只要通过访问容器服务名就可以直接访问到对应的容器(国内的教程很多都没说, 巨坑), 所以上面的nginx.conf配置才有app:8000这个选项, 指的是让Nginx与我们的Python应用(服务名为app)的8000端口连接。除此之外, 我们还需要改下我们的.env配置文件, 把他们的host进行修改:

1
2
3
4
5
6
7
8
9
10
11
# MySQL访问的是mysql, 相当于访问自己的网络, 与127.0.0.1是类似的
MYSQL_DB="mysql"
MYSQL_HOST="mysql"
MYSQL_PORT="3306"
MYSQL_USER="root"
MYSQL_PW=""

REDIS_URL="redis://redis"
REDIS_POOL_MINSIZE=1
REDIS_POOL_MAXSIZE=10
REDIS_ENCODING="utf-8"

然后还需要改下app的Dockerfile启动命令, 让他5秒后启动, 防止有些服务还没起来自己就先运行了:

1
CMD CMD sh -c 'sleep 5 && uvicorn --host 0.0.0.0 example:app'

万事俱备, 终于可以通过执行docker-compose up -d命令来启动我们的容器群了, 在这个命令中d是后台运行的意思, 然后自通过几个命令查看运行情况:

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
# 使用docker-compose up命令来查看应用的状态。输出中会显示容器名称、其中运行的Command、当前状态以及其监听的网络端口。
➜ version_3 git:(master) docker-compose ps
Name Command State Ports
-----------------------------------------------------------------------------------------------
version_3_app_1 uvicorn --host 0.0.0.0 exa ... Up 0.0.0.0:8000->8000/tcp, 8080/tcp
version_3_mysql_1 docker-entrypoint.sh mysql ... Up 0.0.0.0:33060->3306/tcp, 33060/tcp
version_3_nginx_1 /docker-entrypoint.sh ngin ... Up 0.0.0.0:8001->80/tcp
version_3_redis_1 docker-entrypoint.sh redis ... Up 0.0.0.0:63790->6379/tcp


# 使用docker-compose top命令列出各个服务(容器)内运行的进程。
# 其中PID编号是在Docker主机上(而不是容器内)的进程ID。
➜ version_3 git:(master) docker-compose top
version_3_app_1
UID PID PPID C STIME TTY TIME CMD
-------------------------------------------------------------------------------------------------------------------------
root 1802 1786 0 16:05 ? 00:00:00 /usr/local/bin/python /usr/local/bin/uvicorn --host 0.0.0.0 example:app

version_3_mysql_1
UID PID PPID C STIME TTY TIME CMD
---------------------------------------------------------------------------------------------------------------------------------
deepin-+ 1047 1018 0 16:05 ? 00:00:00 mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

version_3_nginx_1
UID PID PPID C STIME TTY TIME CMD
------------------------------------------------------------------------------------------------
root 1355 1339 0 16:05 ? 00:00:00 nginx: master process nginx -g daemon off;
systemd+ 1467 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1468 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1469 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1470 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1471 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1472 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1473 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1474 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1475 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1476 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1477 1355 0 16:05 ? 00:00:00 nginx: worker process
systemd+ 1478 1355 0 16:05 ? 00:00:00 nginx: worker process

version_3_redis_1
UID PID PPID C STIME TTY TIME CMD
------------------------------------------------------------------
deepin-+ 1048 1014 0 16:05 ? 00:00:00 redis-server

# 查看目前的网络
# 查看network的详细信息 docker network inspect version_3_local-net
➜ version_3 git:(master) docker network ls
NETWORK ID NAME DRIVER SCOPE
b39273f15fb3 bridge bridge local
23ef7eb0fba0 host host local
ab8439cd985c none null local
5bcd17ecd747 version_3_local-net bridge local

# 查看卷
# 查看详情 docker volume inspect version_3_local-vol
➜ version_3 git:(master) docker volume ls
DRIVER VOLUME NAME
local version_3_local-vol

通过上述的几个命令可以发现服务是正常运行的, 如果这时候要停止, 可以使用docker-compose stop命令, 它会停止应用,但并不会删除资源, 不过对于已停止的应用,可以使用docker-compose rm命令来进行删除, 这会删除应用相关的容器和网络,但是不会删除卷和镜像。 而直接使用docker-compose down这一个命令就可以停止和关闭应用, 然后应用被删除,仅留下了镜像、卷和源码。

4.总结

至此, Python应用的容器化就已经介绍完毕, 但是这只是一个简单的开始, 后面需要慢慢的了解多机的容器应用的怎么控制和执行的。

查看评论