使用 Docker 创建简单的 Web 应用
一、Flask 小程序
首先创建一个简单的 Flask 小程序,用来返回一个比较原始的 HTML 页面。该项目的文件结构如下:
avatar
├── Dockerfile
├── app
└── avatar.py
其中的 Dockerfile 用于创建运行该项目的容器,app 目录下的 avatar.py 为程序的源文件。
avatar.py 的源代码如下:
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 内容如下:
# 初始镜像为 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
命令利用刚刚创建的镜像加载容器并运行项目。
$ 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>
命令查看后台运行的容器的输出:
$ 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
选项,将本地主机上的项目目录映射到容器中,并覆盖容器中原目录下的内容。
这样当本地主机上的项目源码被修改后,更新的内容会直接同步至容器中的对应文件,就不需要重新构建容器了。
$ 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 stop
和 docker rm
用于停止并删除当前的容器。
docker run
命令中的 -v "$(pwd)"/app:/app
选项用来将本地主机上的项目目录("$(pwd)"/app
)关联给容器中的 /app
目录。
所以当使用 sed -i
命令将源码中的 Avatar
替换为 Avatar_Modified
之后,不需要重新构建,容器返回的 HTML 文档中的 <title> 标签已经变成新值。
二、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 做如下修改:
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 镜像并运行容器:
$ 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 改为如下版本:
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
),再添加文件内容如下:
#!/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 内容如下:
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 可参考以下命令:
# 从 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
命令运行时参考的配置文件:
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
命令构建容器并执行项目:
$ 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 时,返回一个独一无二的“怪兽”头像。
$ docker pull amouat/dnmonster
...
$ docker run -d -p 8080:8080 amouat/dnmonster
...
此时打开浏览器访问 http://ip_address:8080/monster/some_string?size=200 ,结果如图所示:
整合 dnmonster 镜像
前面的 flask 应用只包含一个最基本的功能,即访问它的主页时返回一个简单的 HTML 页面,页面中包含一个获取用户输入的表单,和一个 src 属性值为空字符串的 <img>
标签(“空白”图片)。
结合 dnmonster 镜像的使用,可以将表单中获取到的输入整合到图片标签 <img> 的 src 属性中(/monster/<user_input>
)
将该图片的 URL 路径 (/monster/<user_input>
)绑定给另一个函数(get_avatar
),该函数访问 dnmonster 容器中的 RESTful API,所以网页中最终显示的是从 dnmonster 容器获取到的头像图片,并随用户输入而更新。
app/avatar.py
文件的具体代码如下:
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 模块
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"]
输入以下命令运行项目:
$ 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.py
中 get_avatar
函数才可以通过 http://dnmonster:8080/
类似的 URL 访问 dnmonster 的 API)。
效果如下:
avatar
输入不同的字符串并提交,将得到不一样的头像图片。
使用 Docker Compose
上面的例子虽然可以正常运行,但运行项目时手动输入 docker run
命令过于繁琐。可以通过修改 docker-compose.yml
配置文件,借助 Compose 的“自动化”简化操作步骤。
avatar:
build: .
ports:
- "5000:5000"
environment:
ENV: DEV
volumes:
- ./app:/app
links:
- dnmonster
dnmonster:
image: amouat/dnmonster
其中 links
项定义了 avatar 容器到 dnmonster 容器的关联
dnmonster
及后面的内容则定义了 dnmonster 容器的配置信息,通过 image
项指定用于生成该容器的镜像文件。
停止并删除之前的容器,重新构建运行项目:
$ 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 类型的数据库,适合此处的应用场景。
最终的项目文件结构如下:
avatar
├── app
│ └── avatar.py
├── cmd.sh
├── docker-compose.yml
└── Dockerfile
app/avatar.py
:
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
:
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
:
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 build
和 docker-compose up -d
命令重新构建并运行新版本的容器。
$ 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