使用 Docker 创建简单的 Web 应用

一、Flask 小程序

首先创建一个简单的 Flask 小程序,用来返回一个比较原始的 HTML 页面。该项目的文件结构如下:

1
2
3
4
avatar
├── Dockerfile
├── app
   └── avatar.py

其中的 Dockerfile 用于创建运行该项目的容器,app 目录下的 avatar.py 为程序的源文件。

avatar.py 的源代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from flask import Flask
app = Flask(__name__)
default_name = 'skitarniu'

# 创建关联于网站根 URL('/')的路由。当该 URL 被请求时,将返回 get_avatar() 函数的结果
@app.route('/')
def get_avatar():
name = default_name
header = '<html><head><title>Avatar</title></head><body>'
body = '''<form method="POST">
Hello <input type="text" name="name" value="{}">
<input type="submit" value="submit">
</form>
<p>You look like a:
<img src=""/>
'''.format(name)
footer = '</body></html>'
return header + body + footer

# 初始化 web 服务器
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

编辑 Dockerfile

用于构建容器的 Dockerfile 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
# 初始镜像为 Docker Hub 上的 Python:3.6 镜像
FROM python:3.6

# 执行 pip 命令安装 flask 框架
RUN pip install Flask==1.0.2
# 工作目录设置为容器中的 /app
WORKDIR /app
# 将本地主机上的项目源码文件夹复制到容器中
COPY app /app

# 容器运行时其内部执行的命令
CMD ["python", "avatar.py"]

构建容器并运行项目

可以使用 docker build 命令根据 Dockerfile 中的步骤创建容器的镜像文件,之后使用 docker run 命令利用刚刚创建的镜像加载容器并运行项目。

1
2
3
4
5
6
7
8
$ docker build -t avatar .
...
$ docker run -d -p 5000:5000 --name simple-flask avatar
e90b14c39b23fb97956af8128ae01c73b9bd5e8917578d755e477efd6337e740
$ curl localhost:5000
<html><head><title>Avatar</title></head><body><form method="POST">
<h3>Hello</h3>
...

flask

其中 docker run 命令的 -d 选项指定容器在后台运行;
-p 5000:5000 用来指定本地主机到容器的端口映射(即访问本地主机的 5000 端口等同于访问容器中 5000 端口上运行的服务);
--name simple_flask 用于指定容器的名字为 simple_flask ;
最后的 avatar 指定使用的镜像文件。

可以使用 docker logs <container_name> 命令查看后台运行的容器的输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
e90b14c39b23 avatar "python avatar.py" 11 minutes ago Up 11 minutes 0.0.0.0:5000->5000/tcp simple_flask
$ docker logs simple_flask
* Serving Flask app "avatar" (lazy loading)
...
* Debug mode: on
* Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
* Restarting with stat
* Debugger is active!
* Debugger PIN: 495-921-379
172.17.0.1 - - [10/Oct/2018 11:28:31] "GET / HTTP/1.1" 200 -
10.2.67.88 - - [10/Oct/2018 11:32:02] "GET / HTTP/1.1" 200 -

Bind Mounts

可以在执行 docker run 命令时使用 -v HOST_DIR:CONTAINER_DIR 选项,将本地主机上的项目目录映射到容器中,并覆盖容器中原目录下的内容。
这样当本地主机上的项目源码被修改后,更新的内容会直接同步至容器中的对应文件,就不需要重新构建容器了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ docker stop $(docker ps -lq)
e90b14c39b23
$ docker rm $(docker ps -lq)
e90b14c39b23
$ docker run -d -p 5000:5000 -v "$(pwd)"/app:/app avatar
899831690d5eb7e2e1f7882c00bfef7284f629518e66b581bd5979f3712bf241
$ curl localhost:5000
<html><head><title>Avatar</title></head><body><form method="POST">
<h3>Hello</h3>
...
$ sed -i 's/Avatar/Avatar_Modified/' app/avatar.py
$ curl localhost:5000
<html><head><title>Avatar_Modified</title></head><body><form method="POST">
<h3>Hello</h3>
...

以上的命令中,docker stopdocker rm 用于停止并删除当前的容器。
docker run 命令中的 -v "$(pwd)"/app:/app 选项用来将本地主机上的项目目录("$(pwd)"/app)关联给容器中的 /app 目录。
所以当使用 sed -i 命令将源码中的 Avatar 替换为 Avatar_Modified 之后,不需要重新构建,容器返回的 HTML 文档中的 标签已经变成新值。

二、uWSGI 服务器

WSGI(即 Web Server Gateway Interface)是 Web 服务器(如 nginx)和 Web 应用程序或框架(如 Flask)之间的一种通用接口。它就像是一个桥梁,一边连着 Web 服务器,一边连着 Web 应用程序。

很多框架都自带了 WSGI server(如 Flask 的 webserver),但更多是测试用途,发布时则使用生产环境的 WSGI server 或是联合 nginx 做 uwsgi 。

uWSGI 是一个 Web 服务器,实现了 WSGI、uwsgi、http 等协议。

这里使用 uWSGI 替代 Flask 自带的 webserver,可对之前的 Dockerfile 做如下修改:

1
2
3
4
5
6
7
FROM python:3.6

RUN pip install Flask==1.0.2 uWSGI==2.0.17.1
WORKDIR /app
COPY app /app

CMD ["uwsgi", "--http", "0.0.0.0:9090", "--wsgi-file", "/app/avatar.py", "--callable", "app", "--stats", "0.0.0.0:9191"]

重新构建 docker 镜像并运行容器:

1
2
3
4
5
6
7
8
$ docker build -t avatar .
...
$ docker run -d -p 9090:9090 -p 9191:9191 avatar
2ca0aed60d53803a7eaaf6ca9146c1786593f3d1d1c86b498ea4577cade854e8
$ curl localhost:9090
<html><head><title>Avatar_Modified</title></head><body><form method="POST">
<h3>Hello</h3>
...

上面的 uWSGI 是在 root 用户下运行的,存在安全隐患。需要将 Dockerfile 改为如下版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM python:3.6

# 创建用户和用户组,名为 uwsgi
RUN groupadd -r uwsgi && useradd -r -g uwsgi uwsgi
RUN pip install Flask==1.0.2 uWSGI==2.0.17.1
WORKDIR /app
COPY app /app

# 使用 EXPOSE 声明可供外部访问的端口号
EXPOSE 9090 9191
# USER 用于指定某个用户,其后的所有命令(包括 CMD 和 ENTRYPOINT)都将由该用户执行
USER uwsgi

CMD ["uwsgi", "--http", "0.0.0.0:9090", "--wsgi-file", "/app/avatar.py", "--callable", "app", "--stats", "0.0.0.0:9191"]

区分测试和生产环境

可以将 Dockerfile 中 CMD 调用的命令单独存放在一个 Shell 脚本中。如在 avatar 目录下新建 cmd.sh 文件并添加执行权限(chmod +x cmd.sh),再添加文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
set -e

if [ "$ENV" = 'DEV' ]; then
echo "Running Development Server"
exec python "avatar.py"
else
echo "Running Production Server"
exec uwsgi --http 0.0.0.0:9090 --wsgi-file /app/avatar.py \
--callable app --stats 0.0.0.0:9191
fi

此时的 Dockerfile 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
FROM python:3.6

RUN groupadd -r uwsgi && useradd -r -g uwsgi uwsgi
RUN pip install Flask==1.0.2 uWSGI==2.0.17.1
WORKDIR /app
COPY app /app
COPY cmd.sh /

EXPOSE 9090 9191
USER uwsgi

# CMD 选项改为包含一系列命令且拥有执行权限的脚本文件
CMD ["/cmd.sh"]

重新构建 Docker 镜像,在测试环境下运行时使用
$ docker run -e "ENV=DEV" -p 5000:5000 avatar
其中 -e 选项用于指定环境变量

在生产环境下运行时则使用
$ docker run -d -p 9090:9090 -p 9191 avatar

三、Docker Compose

Compose 工具用于快速地搭建和运行 Docker 开发环境。它使用 YAML 文件保存容器集群的配置信息。

安装 Compose

Ubuntu 系统下安装 Compose 可参考以下命令:

1
2
3
4
# 从 Github 上获取 Docker Compose 的二进制程序
$ sudo curl -L "https://github.com/docker/compose/releases/download/1.22.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
# 为获取到的 compose 程序添加执行权限
$ sudo chmod +x /usr/local/bin/docker-compose

编辑 docker-compose 文件

avatar 目录下新建一个名为 docker-compose.yml 的文件,该文件是 docker-compose 命令运行时参考的配置文件:

1
2
3
4
5
6
7
8
avatar:
build: .
ports:
- "5000:5000"
environment:
ENV:DEV
volumes:
- ./app:/app

其中第一行的 avatar 用于声明需要构建的容器的名称,同一个 YAML 文件中可以同时存在多个容器的定义;
第二行的 build: . 表示用于构建容器镜像文件的 Dockerfile 位于当前目录下;
ports 项等同于 docker run 命令中的 -p 选项,用于定义端口转发;
environment 项等同于 docker run 命令中的 -e 选项,用于定义容器中的环境变量;
volumes 项等同于 docker run 命令中的 -v 选项,用于定义存储卷。

运行项目

可直接使用 docker-compose up 命令构建容器并执行项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ docker-compose up
Building avatar
...
Successfully built 1f883bd34e9f
Successfully tagged avatar_avatar:latest
WARNING: Image for service avatar was built because it did not already exist. To rebuild this image you must use `docker-compose build` or `docker-compose up --build`.
Creating avatar_avatar_1 ... done
Attaching to avatar_avatar_1
avatar_1 | Running Development Server
avatar_1 | * Serving Flask app "avatar" (lazy loading)
...
avatar_1 | * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)
...

四、关联其他镜像(dnmonster)

dnmonster 镜像是一个整合在 Docker 容器中的 Node.js 应用,可以直接从 Docker Hub 上拉取到本地。它提供了一个 RESTful API,当访问 http://0.0.0.0:8080/monster/MY_ID 时,返回一个独一无二的“怪兽”头像。

1
2
3
4
$ docker pull amouat/dnmonster
...
$ docker run -d -p 8080:8080 amouat/dnmonster
...

此时打开浏览器访问 http://ip_address:8080/monster/some_string?size=200 ,结果如图所示:
monster

整合 dnmonster 镜像

前面的 flask 应用只包含一个最基本的功能,即访问它的主页时返回一个简单的 HTML 页面,页面中包含一个获取用户输入的表单,和一个 src 属性值为空字符串的 <img> 标签(“空白”图片)。

结合 dnmonster 镜像的使用,可以将表单中获取到的输入整合到图片标签 的 src 属性中(/monster/<user_input>

将该图片的 URL 路径 (/monster/<user_input>)绑定给另一个函数(get_avatar),该函数访问 dnmonster 容器中的 RESTful API,所以网页中最终显示的是从 dnmonster 容器获取到的头像图片,并随用户输入而更新。

app/avatar.py 文件的具体代码如下:

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
from flask import Flask, Response, request
import requests
import hashlib

app = Flask(__name__)
default_name = 'skitarniu'

# 声明网站主页将会处理 GET 和 POST 请求(因为表单的提交属于 POST 请求),主页绑定 mainpage 函数
@app.route('/', methods=['GET','POST'])
def mainpage():
name = default_name
# 表单提交时,获取用户输入的内容,调用 hashlib 库将其变成 hash 形式,保存在 name_hash 变量中
if request.method == 'POST':
name = request.form['name']
name_hash = hashlib.sha256(name.encode()).hexdigest()

header = '<html><head><title>Avatar_Modified</title></head><body>'
body = '''<form method="POST">
<h3>Hello</h3>
<input type="text" name="name" value="{0}">
<input type="submit" value="submit">
</form>
<p>You look like a:
<img src="/monster/{1}"/>
'''.format(name, name_hash)
# img 标签的 src 属性由 name_hash 的值确定,即网页中图片的源路径根据用户输入自行更新

footer = '</body></html>'

return header + body + footer

# 网页中图片的 URL 绑定给 get_avatar 函数,该函数通过 requests 库访问 dnmonster 容器中的 API 以获取“怪兽”图像
@app.route('/monster/<name>')
def get_avatar(name):
r = requests.get('http://dnmonster:8080/monster/' + name + '?size=200')
image = r.content
return Response(image, mimetype='image/png')

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

然后在 Dockerfile 里添加上前面用到的 requests 模块

1
2
3
4
5
6
7
8
9
10
11
12
FROM python:3.6

RUN groupadd -r uwsgi && useradd -r -g uwsgi uwsgi
RUN pip install Flask==1.0.2 uWSGI==2.0.17.1 requests==2.5.1
WORKDIR /app
COPY app /app
COPY cmd.sh /

EXPOSE 9090 9191
USER uwsgi

CMD ["/cmd.sh"]

输入以下命令运行项目:

1
2
3
4
5
6
$ docker build -t avatar .
...
$ docker run -d --name dnmonster amouat/dnmonster
48f77f0e0f7ac503b27786f73ea8d25fa4237eea042dfb5cc1331bb07001dae3
$ docker run -d -p 5000:5000 -e "ENV=DEV" --link dnmonster:dnmonster avatar
8cf94a54744f92c206917c474dc8619c417d7b7937fd6d38df3cd05d5813d5fd

其中 docker build 命令用于重新构建容器。
docker run -d --name dnmonster amouat/dnmonster 命令用于加载 dnmonster 容器并指定其名称(--name)为 dnmonster 。
docker run -d -p 5000:5000 -e "ENV=DEV" --link dnmonster:dnmonster avatar 命令中的 --link dnmonster:dnmonster 选项用于将 flask 应用容器和 dnmonster 容器关联起来。
其中第一个 dnmonster 用于指定关联容器的名称,第二个 dnmonster 用于指定该容器的别名。关联后 flask 应用容器就可以通过别名直接访问 dnmonster 容器,而无需获知其 IP 地址(所以源文件 app/avatar.pyget_avatar 函数才可以通过 http://dnmonster:8080/ 类似的 URL 访问 dnmonster 的 API)。

效果如下:
avatar
输入不同的字符串并提交,将得到不一样的头像图片。

使用 Docker Compose

上面的例子虽然可以正常运行,但运行项目时手动输入 docker run 命令过于繁琐。可以通过修改 docker-compose.yml 配置文件,借助 Compose 的“自动化”简化操作步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
avatar:
build: .
ports:
- "5000:5000"
environment:
ENV: DEV
volumes:
- ./app:/app
links:
- dnmonster

dnmonster:
image: amouat/dnmonster

其中 links 项定义了 avatar 容器到 dnmonster 容器的关联
dnmonster 及后面的内容则定义了 dnmonster 容器的配置信息,通过 image 项指定用于生成该容器的镜像文件。

停止并删除之前的容器,重新构建运行项目:

1
2
3
4
5
6
7
8
9
$ docker-compose build
...
$ docker-compose up -d
Creating avatar_dnmonster_1 ... done
Creating avatar_avatar_1 ... done
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1f85363c7ed4 avatar_avatar "/cmd.sh" 10 seconds ago Up 9 seconds 9090/tcp, 0.0.0.0:5000->5000/tcp, 9191/tcp avatar_avatar_1
5cca0f851ac5 amouat/dnmonster "npm start" 11 seconds ago Up 9 seconds 8080/tcp avatar_dnmonster_1

五、添加缓存支持(Redis)

当前的 flask 应用,每获取一次“怪兽”头像,dnmonster 服务就会收到一次比较消耗资源的请求。
由于通过特定的输入生成的图片是保持不变的,所以可以利用缓存对应用进行优化。

Redis 是一种基于内存Key-Value 类型的数据库,适合此处的应用场景。

最终的项目文件结构如下:

1
2
3
4
5
6
avatar
├── app
│   └── avatar.py
├── cmd.sh
├── docker-compose.yml
└── Dockerfile

app/avatar.py:

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
from flask import Flask, Response, request
import requests
import hashlib
import redis

app = Flask(__name__)
cache = redis.StrictRedis(host='redis', port=6379, db=0)
default_name = 'skitarniu'

@app.route('/', methods=['GET','POST'])
def mainpage():
name = default_name
if request.method == 'POST':
name = request.form['name']
name_hash = hashlib.sha256(name.encode()).hexdigest()
header = '<html><head><title>Avatar_Modified</title></head><body>'
body = '''<form method="POST">
<h3>Hello</h3>
<input type="text" name="name" value="{0}">
<input type="submit" value="submit">
</form>
<p>You look like a:
<img src="/monster/{1}"/>
'''.format(name, name_hash)
footer = '</body></html>'

return header + body + footer

@app.route('/monster/<name>')
def get_avatar(name):
image = cache.get(name)
if image is None:
print("Cache miss", flush=True)
r = requests.get('http://dnmonster:8080/monster/' + name + '?size=200')
image = r.content
cache.set(name, image)

return Response(image, mimetype='image/png')

if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0')

Dockerfile:

1
2
3
4
5
6
7
8
9
10
11
12
FROM python:3.6

RUN groupadd -r uwsgi && useradd -r -g uwsgi uwsgi
RUN pip install Flask==1.0.2 uWSGI==2.0.17.1 requests==2.5.1 redis==2.10.6
WORKDIR /app
COPY app /app
COPY cmd.sh /

EXPOSE 9090 9191
USER uwsgi

CMD ["/cmd.sh"]

docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
avatar:
build: .
ports:
- "5000:5000"
environment:
ENV: DEV
volumes:
- ./app:/app
links:
- dnmonster
- redis

dnmonster:
image: amouat/dnmonster

redis:
image: redis

cmd.sh 文件保持之前的版本即可。
然后就可以先使用 docker-compose stop 命令停止之前版本的容器,再使用 docker-compose builddocker-compose up -d 命令重新构建并运行新版本的容器。

1
2
3
4
5
6
7
8
9
10
11
$ docker-compose build
dnmonster uses an image, skipping
redis uses an image, skipping
Building avatar
...
Successfully built d1aa92c39f97
Successfully tagged avatar_avatar:latest
$ docker-compose up -d
Creating avatar_redis_1 ... done
Creating avatar_dnmonster_1 ... done
Creating avatar_avatar_1 ... done

参考资料

Using Docker: Developing and Deploying Software with Containers