Python项目容器化实践(一) - Docker Compose
/ / / 阅读数:13743前言
作为开发者应该对新技术保持敏锐度,愿意尝试和接受新事物。我从 2013 年开始关注 LXC 和 Docker ,当时我还在做很多运维方面的工作,那会 Docker 刚发布不久,问题很多还不能在生产环境使用;在 2016 年我的书中也提供了 Docker 镜像,读者可以方便的使用这个包含全部代码和相关依赖的环境;而现在 Docker 容器已经被各大互联网公司广泛应用,而且由于 Docker 和 Etcd 等项目还算捧红了 Golang~
借着我个人博客这个小项目,我准备写几篇文章分享一些 Python 项目容器化方面的实践。我的文章里面就不介绍 Docker 已经你为什么应该用它了,网上可能很容易的搜到答案,可以通过延伸阅读链接 1 和 2 获得更多信息,我们直入今天的主题: Docker Compose
什么是 Docker Compose?
很多同学都知道可以用 Docker 创建一个容器,然后用docker run -it ubuntu:19.04 bash
之类的方式进入容器,就像是在用一个完整的操作系统那样使用它。
事实上除了各种版本的操作系统镜像,官方还维护了很多包含常用软件的镜像,如 Python、MySQL、Redis、Elasticsearch、Nginx 等等,举个例子,我只是想在容器里面使用目前最新的 Python3.7,不是用ubuntu:19.04
这种操作系统镜像,再进入容器安装 Python,可以直接用 Python 对应版本的容器:
❯ docker run -it python:3.7 bash root@2825696d5639:/# python Python 3.7.4 (default, Sep 12 2019, 15:40:15) [GCC 8.3.0] on linux Type "help", "copyright", "credits" or "license" for more information. |
官方维护的相关软件镜像列表可以看延伸阅读链接 3 这个项目目录的内容。
在实际的使用中,我们往往需要各种环境,安装各种软件和 Python 依赖,比如想要搭建一个 Web 开发环境,要有数据库、Nginx、Python、Web 框架、缓存等等,当然还是可以基于ubuntu:19.04
这种操作系统镜像,在 Dockerfile 里面写复杂的逻辑,挨个安装相关的软件和 Python 依赖。不过这样做有一些缺点:
- 不利于分享和复用。一般这种方式产生的镜像比较大,且由于里面堆了很多别人用不到的东西,不具备复用的价值
- 无法共享数据。应用只能连接到自己内部,如果共享镜像里的数据要配置复杂的端口转发,且容易出错
把全部东西堆到一个容器里面是典型的虚拟机的使用方式,不是 Docker 的正确打开方式
正确的做法是让一个容器做一件事:数据库、Nginx、Python 应用、缓存等等都是独立的容器,分别启动它们,这些容器组成了一个集群,需要某种方法把它们关联起来。
这个关联有一个非常专用、形象的称呼「编排」,我最早了解这个词是通过「Ansbile Playbooks」,而 Docker Compose 大家可以猜到就是负责实现对 Docker 容器集群编排的。
Docker Compose 的官方文档一开头就是对它的定位:
Compose is a tool for defining and running multi-container Docker applications.
可以说,Dockerfile 可以让用户管理一个单独的应用容器,而 Compose 则允许用户在一个模板 (YAML 格式) 中定义一组相关联的应用容器。好了,我们开始体验一下吧。
安装
在Mac下安装Docker自带了
docker-compose` 命令们可以直接使用,否者需要根据官方文档安装它。
实现 lyanna 的 Dockerfile
首先实现这个博客应用的 Dockerfile,看一下最后的全部内容:
FROM python:3.7-alpine AS build RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.ustc.edu.cn/g' /etc/apk/repositories \ && apk update \ && apk add git gcc musl-dev libffi-dev openssl-dev make WORKDIR /install COPY requirements.txt /requirements.txt RUN pip install -i https://mirrors.ustc.edu.cn/pypi/web/simple -r /requirements.txt \ && mkdir -p /install/lib/python3.7/site-packages \ && cp -rp /usr/local/lib/python3.7/site-packages /install/lib/python3.7 FROM python:3.7-alpine COPY --from=build /install/lib /usr/local/lib COPY --from=build /install/src /usr/local/src WORKDIR /app COPY . /app |
这里要解释的很多,我按行来说:
- 第 1 行。
python:3.7-alpine
是一个在 Alpine 系统下 Python3.7 镜像,Alpine
的优势是「系统的体积小」(系统镜像约 5 MB,而 Ubuntu 系列镜像接近 200 MB,可见一斑)、「运行时资源消耗低」和「提供包管理工具apk
,包管理机制完善」等。我是很推荐使用 Alpine 替代 Ubuntu 之类系统做为基础镜像环境的。 - 第 2-4 行。是为了修改 Alpine 使用国内源,可以明显加快软件下载。
- 第 5 行。切换工作目录到
/install
- 第 6 行。把本地的
requirements.txt
拷贝进容器 - 第 7-9 行。使用国内源安装项目依赖,并且把下载的包拷贝到
/install/lib/python3.7
。我都是用&&
把同类操作放在一层,可以显著减少容器大小。 - 第 11 行。如果你之前接触过 Dockerfile 可能会疑惑这个文件里面有 2 句 FROM,这么做是 Docker 的
多阶段构建
,如果不使用分段构建,会带来「镜像层次多,镜像体积较大,部署时间长」等问题,现在镜像体积会明显减少。 - 第 12-13 行。把从 build 容器下载的内容直接复制进来。注意
requirements.txt
里面的那些 Github 源的包 (如 arq、aiomemcache 等) 会安装到/install/src
目录,所以这个目录也需要拷贝 - 第 14 行。切换工作目录到
/app
- 第 15 行。把应用代码拷贝进
/app
有些同学可能还见过这样的代码:
EXPOSE 5000
CMD python app.py
之前说过,Dockerfile 是用来管理单个应用容器的,单个应用是需要这样启动应用,然后暴露对应端口,但我们很快就要用 Compose 来管理,所以是不需要的。
接着我们构建一下这个容器,再对比一下几个容易的大小:
❯ docker build -t lyanna-app . ❯ docker images |egrep "lyanna-app|3.7" lyanna-app latest f8908aadc20e 8 minutes ago 207MB python 3.7 02d2bb146b3b 9 days ago 918MB python 3.7-alpine 39fb80313465 3 weeks ago 98.7MB |
可以看到在 alpine 系统构建的 Python3.7 (python:3.7-alpine) 只是 Debian 版本 (python:3.7) 的十分之一,而我们在安装那么多系统包 (git、gcc、make 和 openssl-dev 等) 和依赖 (requirements.txt) 之后才 207MB。
让 lyanna 使用 Compose
之前就有对 lyanna 感兴趣的同学由于对这一套环境不熟悉而放弃了体验 lyanna,这次我借机构建一套包含 lyanna 所需全部环境的容器集群。这个容器集群包含:
- db。存放博客、用户、表态、评论等数据。
- redis。存放博客文章内容以及作为消息代理。
- memcached。缓存。
- web。lyanna 应用。
Compose 是通过docker-compose.yml
这个模板文件来控制编排的:
version: '3' services: db: image: mysql restart: always environment: MYSQL_DATABASE: 'test' MYSQL_USER: 'root' MYSQL_PASSWORD: '' MYSQL_ROOT_PASSWORD: '' MYSQL_ALLOW_EMPTY_PASSWORD: 'true' ports: - '3306:3306' volumes: - my-datavolume:/var/lib/mysql networks: - app-network redis: image: redis:alpine networks: - app-network memcached: image: memcached:1.5-alpine networks: - app-network web: networks: - app-network build: . ports: - '8000:8000' expose: - '8000' volumes: - .:/app - ./local_settings.py.tmpl:/app/local_settings.py depends_on: - db - redis - memcached environment: PYTHONPATH: $PYTHONPATH:/usr/local/src/aiomcache:/usr/local/src/tortoise:/usr/local/src/arq:/usr/local/src command: sh -c 'sleep 5 && ./setup.sh && python app.py' volumes: my-datavolume: networks: app-network: driver: bridge |
这样用户可以一个命令就启动这个环境 (加 - d 可以用 daemon 的方式启动):
❯ docker-compose up Starting lyanna_memcached_1 ... done Starting lyanna_db_1 ... done Starting lyanna_redis_1 ... done Starting lyanna_web_1 ... done Attaching to lyanna_redis_1, lyanna_memcached_1, lyanna_db_1, lyanna_web_1 ... |
然后访问http://localhost:8000/
就能看到博客效果了。由于把 lyanna 代码目录挂载到了容器中,且python app.py
启动了 DEBUG 模式,所以在本机的代码修改可以直接在容器里面生效,这样可以用来做本地后端开发。
具体的键及其意义以及可选值需要通过官方文档了解,我只介绍这个例子中出现的这些键:
- version。compose 文件格式的版本,理论上根据你安装的 Docker 的版本选择最高版本即可,1,2,3 是大版本,还是 minor 版本,最新的是 3.7
- services。服务的意思,每个容器是一个服务,所以这里有 db、redis、memcached 和 web 四个服务。
- image。键的值就是对应的镜像名字,如 mysql、redis:alpine、memcached:1.5-alpine。可以注意到,我会尽量选 Alpine 版本的对应镜像。
- networks。这里创建一个名称为 app-network,网络驱动模式为 bridge 的自定义网络,这些容器都使用推塔进行连接,我一般喜欢显式的用网络,而不是用默认的,所以在每个容易里面都会加
networks: - app-network
这项。 - volumes。容器的正常打开方式在进程中运行一些程序,不应该写数据。所以数据库之类需要保存动态数据的应用,应该保存于卷 (volume) 中,在上面的模板中,db 的 /var/lib/mysql (MySQL 存放数据的目录) 就不是用的宿主机的对应目录。而在服务中,
volumes
相当于将某个目录挂载到容器里面的某个目录。 - environment。设置环境变量,在 db 里面设置了 MySQL 相关的变量,在 web 里面设置了 PYTHONPATH。
- ports。宿主端口:容器端口 (HOST:CONTAINER) 格式,暴露端口信息。和 expose 相比,端口不被连接的服务访问,还会映射到宿主机。
- build。web 用了 build,而其他三个服务用的是现成的 image,web 是基于本地构建的 lyanna 来用。
- depends_on。顾名思义,web 的启动前需要先启动另外三个服务,它们是有依赖关系的。
- command。web 启动前需要先初始化,所以执行了 setup.sh 里面的逻辑 (创建表结构,添加默认的后台登录用户) 才能启动 Sanic 应用。
在这个模板中,有一个地方我需要重点说明:
web:
volumes:
- ./local_settings.py.tmpl:/app/local_settings.py # 👈
lyanna 支持使用local_settings.py
覆盖默认的 config.py 里面的设置,我为什么要在 Docker 里面使用本地设置呢?先看一下这个默认设置:
❯ cat local_settings.py.tmpl DEBUG = True REDIS_URL = 'redis://redis:6379' MEMCACHED_HOST = 'memcached' DB_URL = 'mysql://root:@db:3306/test?charset=utf8' |
这里面特意设置了 db、redis 和 memcached 的地址,注意它们的主机名,不是原来的localhost
,而是用了前面docker-compose.yml
里面定义的服务的名字。这是因为在桥接模式 (默认) 下,每个容器都有自己的 IP,容器之间通讯需要使用服务名字,因为此时 localhost 指的是容器自己的本地地址,而访问不到其他服务了。这个地方我觉得太重要了,当时花了很多时间去解决多容器通讯的问题,但是无论是官方文档还是技术文章鲜少提到这个地方。
Compose 只针对开发和测试环境
Compose 非常适合构建开发和测试环境,但如果你想在生产中使用你的容器,应该选择 Kubernetes 来编排容器,原因之后会聊!~
记录一下两个问题最后解决方法: 一个是需要安装 g++,在 Dockerfile 第四行 gcc 后面插入 g++; 一个是需要安装 ujson 指定版本,在 requirements.txt 第一行插入 ujson==1.35;