前言

作为开发者应该对新技术保持敏锐度,愿意尝试和接受新事物。我从 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 依赖。不过这样做有一些缺点:

  1. 不利于分享和复用。一般这种方式产生的镜像比较大,且由于里面堆了很多别人用不到的东西,不具备复用的价值
  2. 无法共享数据。应用只能连接到自己内部,如果共享镜像里的数据要配置复杂的端口转发,且容易出错

把全部东西堆到一个容器里面是典型的虚拟机的使用方式,不是 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. 第 1 行。python:3.7-alpine 是一个在 Alpine 系统下 Python3.7 镜像,Alpine 的优势是「系统的体积小」(系统镜像约 5 MB,而 Ubuntu 系列镜像接近 200 MB,可见一斑)、「运行时资源消耗低」和「提供包管理工具 apk,包管理机制完善」等。我是很推荐使用 Alpine 替代 Ubuntu 之类系统做为基础镜像环境的。
  2. 第 2-4 行。是为了修改 Alpine 使用国内源,可以明显加快软件下载。
  3. 第 5 行。切换工作目录到 /install
  4. 第 6 行。把本地的 requirements.txt 拷贝进容器
  5. 第 7-9 行。使用国内源安装项目依赖,并且把下载的包拷贝到 /install/lib/python3.7。我都是用 && 把同类操作放在一层,可以显著减少容器大小。
  6. 第 11 行。如果你之前接触过 Dockerfile 可能会疑惑这个文件里面有 2 句 FROM,这么做是 Docker 的 多阶段构建 ,如果不使用分段构建,会带来「镜像层次多,镜像体积较大,部署时间长」等问题,现在镜像体积会明显减少。
  7. 第 12-13 行。把从 build 容器下载的内容直接复制进来。注意 requirements.txt 里面的那些 Github 源的包 (如 arq、aiomemcache 等) 会安装到 /install/src 目录,所以这个目录也需要拷贝
  8. 第 14 行。切换工作目录到 /app
  9. 第 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 所需全部环境的容器集群。这个容器集群包含:

  1. db。存放博客、用户、表态、评论等数据。
  2. redis。存放博客文章内容以及作为消息代理。
  3. memcached。缓存。
  4. 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 模式,所以在本机的代码修改可以直接在容器里面生效,这样可以用来做本地后端开发。

具体的键及其意义以及可选值需要通过官方文档了解,我只介绍这个例子中出现的这些键:

  1. version。compose 文件格式的版本,理论上根据你安装的 Docker 的版本选择最高版本即可,1,2,3 是大版本,还是 minor 版本,最新的是 3.7
  2. services。服务的意思,每个容器是一个服务,所以这里有 db、redis、memcached 和 web 四个服务。
  3. image。键的值就是对应的镜像名字,如 mysql、redis:alpine、memcached:1.5-alpine。可以注意到,我会尽量选 Alpine 版本的对应镜像。
  4. networks。这里创建一个名称为 app-network,网络驱动模式为 bridge 的自定义网络,这些容器都使用推塔进行连接,我一般喜欢显式的用网络,而不是用默认的,所以在每个容易里面都会加 networks: - app-network 这项。
  5. volumes。容器的正常打开方式在进程中运行一些程序,不应该写数据。所以数据库之类需要保存动态数据的应用,应该保存于卷 (volume) 中,在上面的模板中,db 的 /var/lib/mysql (MySQL 存放数据的目录) 就不是用的宿主机的对应目录。而在服务中,volumes 相当于将某个目录挂载到容器里面的某个目录。
  6. environment。设置环境变量,在 db 里面设置了 MySQL 相关的变量,在 web 里面设置了 PYTHONPATH。
  7. ports。宿主端口:容器端口 (HOST:CONTAINER) 格式,暴露端口信息。和 expose 相比,端口不被连接的服务访问,还会映射到宿主机。
  8. build。web 用了 build,而其他三个服务用的是现成的 image,web 是基于本地构建的 lyanna 来用。
  9. depends_on。顾名思义,web 的启动前需要先启动另外三个服务,它们是有依赖关系的。
  10. 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 来编排容器,原因之后会聊!~

延伸阅读

  1. http://www.ruanyifeng.com/blog/2018/02/docker-tutorial.html
  2. https://yeasy.gitbooks.io/docker_practice/
  3. https://github.com/docker-library/official-images/tree/master/library
  4. https://docs.docker.com/compose/