docker-compose.yml文件的编写

Table of Contents

去年的时候写过一篇用Docker打包Tornado项目的文章,本来是打算很快补上后续文章的,不过那个时候学其他东西就忘记了.前一段时间家里的网络失常,笔记本的系统崩坏以及服务器的各种问题,为了以后可以快速恢复环境,因此要了解 docker 的更多用法,这文章就是给自己备忘的.

由于我本人也没有经常用 Docker,所以难免会有些遗漏,我自己也尽量避开这些点,给出相关连接.本文主要是讲述如何编写 docker-compose.yml 文件,从而使用 docker-composedocker stack deploy 命令部署应用.

Docker Compose 和 Docker Swarm

官方文档介绍:

Docker compose (docker-compose cli) 是一个用来定义以及运行多容器的 Docker 应用.该工具需要你编写 docker-compose.yml 文件,然后一条 docker-compose up 命令就可以启动了.

Docker swarm (docker swarm and docker stack cli) 是一个管理集群服务的工具,在 Docker 的世界里面,一个集群叫做 swarm,关于集群的知识就不多说了,该工具同样需要编写 docker-compose.yml,然后几条简单的命令就可以管理集群.部署到 swarm 需要先 docker swarm init 启动 swarm,然后再 docker stack deploy -c /path/to/docker-compose.yml 进行部署.

除了都要编写 docker-compose.yml 文件外,它们都适合部署多服务的应用,差别在于前者只适合用于单机,后者是集群.

现在清楚 Docker Compose, Docker SwarmDocker Stack Deploy 三者的概念以及关系后,是时候讲一下 docker-compose.yml 文件的编写了.

docker-compose.yml

不论你是用 docker-compose 还是 docker swarm,你都要编写 docker-compose.yml 文件,值得注意的是 docker 的更新是很快的,不同版本的 docker-compose.yml 的能用指令是不一样的.这里是其中一个版本的reference,你可以在这里找到其他版本的参考文档,具体就不说了.

docker-compose.yml 有很多个指令,全部讲完又不太现实,所以我就抛砖引玉,刚好 Nginxdocker 官方参考只是讲了如何使用 Dockerfile 和命令行的使用,那么正好我就用它做为例子,补充一个 docker-compose.yml 的例子.

首先你要准备一个静态站点的目录以及它的 Nginx 配置文件和一个最新的 Nginx 镜像,其中静态站点目录的内容类似(请结合自己的实际情况来操作,这里为了演示就一切从简)如下:

site/
  |- index.html
  |- css/
  |- style/
  |- js/

我相信大部份刚接触 docker 的人在过完 docker 的入门教程后都会想到在 Dockerfile 文件里面使用 COPY 命令来把静态目录复制到容器里面,然后 build 出新的镜像.不是不行,但是如果你的站点频繁更新,那么就需要频繁 build 镜像,因为你的站点是和你的镜像耦合在一起了.

很明显,这样会十分繁琐,也不符合 docker 用于打包环境的初衷.我们部署需要满足一个要求:可以实时更新静态文件,不用每次更新一次文件就要重新 build 一次服务或者镜像并且重新运行容器.

由于 docker-compose.yml 是使用 YAML 做为语言的,所以编写配置文件之前请确保自己 了解 YAML 是什么以及它的基本语法,不了解的话花几分钟了解一下.

根据 Nginx 的官方参考可以了解到 volumes 指令可以满足这一个要求,所以 docker-compose.yml 的配置如下:

version: "3.2"

services:
  blog:
    image: nginx:latest
    volumes:
      - ./site:/site
      - ./conf.d:/etc/nginx/conf.d
    ports:
      - "80:80"

这里要求目录 conf.ddocker-compose.ymlsite 是目录同级,其中 conf.d 目录是 Nginx 的配置目录,内容如下:

conf.d/
  |- site.conf

site.conf 的配置如下,

server {
    listen 80;
    listen [::]:80;
    root /site;
    index index.html;
}

关于站点结构和 Nginx 的配置请自己到 Nginx 的使用文档进行阅读,不在本文的范围内.

到这里为止就可以开始选择你想要的方式(docker-compose 或者 docker swarm)进行部署了.

接下来针对 docker-compose.yml 的几个指令进行说明,

  • version,声明 docker-compose.yml 文件的版本,请一定要正确选择自己当前使用的版本,不同版本能够使用的命令是不一样的.关于版本的区别请看这里.
  • services,定义多个服务,上面例子中的 blog 就是一个服务,如果项目复杂了还可以多定义一些服务,并且服务与服务之间是可以相互访问的,并且不要求服务本身就要与别的服务通信.更多具体用法请阅读Networking in Compose.
    • image,指定该服务所使用的镜像,如果现成镜像不满足自己的需求,可以自己使用 build 命令指定 Dockerfile 来建立镜像.
    • volumes,挂载文件,比如上述例子中,把宿主机的 site 挂载到容器的 /site 位置,你可以通过 docker cp 命令来把容器中的 /site 复制到宿主机上进行验证.当然,这个命令有更加复杂的写法,用于更复杂的情况,具体自行读文档.
    • ports,有两种设置方法,1. 端口映射,格式为 Host:Container; 2. 只指定容器端口,主机端口随机,如果这么分配的话就要通过 docker ps 命令来找主机端口了.

追加例子: MYSQL + Tornado

写于 2019/3/17

因为最近有好几个朋友问我一些关于 Docker 的一些使用问题,每次和这个说完另外一个就问起,所以我决定追加一篇例子来做为日后回答.

主要是关于如何组合两个或者两个以上的服务,也算是为了自己之前的偷懒而负责了,这里我只负责演示两个服务,最后会留下一个思考(都会再说).

项目内容

这个这是一个很简单的 API 服务,使用 Python 做为后端语言,实际上什么后端语言都无所为,根据你自己的实际情况来就好.

换句话说,这里的 Python 代码没有必要看懂,要求看懂的我会特意说明,该项目主要使用了 MYSQL 数据库, Tornado 框架以及一个加密算法库.

项目结构

server/
  |- sql/
  |    |- init.sql
  |- data/
  |- src/
  |    |- app.py
  |    |- requirements.txt
  |    |- Dockerfile
  |- docker-compose-yml

其中, src 就是后端程序的整个源代码,和我刚刚说的一样你可以用任何语言开发的后端程序,同样,源代码也可以直接类似的放这里面.

app.py 是程序代码的本身, requirements.txt 是该程序所需要的一些依赖记录,部署的时候会先安装好依赖再运行程序的.

其中 Dockerfile 是用来构建镜像的,不过值得一提的是我并不打算把源代码也 build 到镜像里面,我只会建立一个已经安装好依赖的环境镜像.

最后启动的时候挂载好代码再执行(注意,有些人不了解直译型和编译型语言的工作方式的区别,我这里简单提一下, Python 主要是直译运行,也就是说一个程序读取代码然后直接执行,

Java 这种主要是编译的,也就是把源代码翻译成另外一种语言,所以每次 Java 程序员都是写完代码需要 build 以下然后把生成的东西打包好发布,而 Python 是直接写完再整理一下文件目录就可以发布了).

这里我要展示一下 app.py 的源代码,

#! /usr/bin/python3
# -*- coding: utf-8 -*-

import os
import tornado.web
import tornado.ioloop
import tornado.httpserver
import tornado.options
from tornado.options import options, define
import pymysql

MYSQL_HOST = os.environ.get('MYSQL_HOST')

define("port", default=8000, type=int, help="run server on the given port.")

database = pymysql.connect(
    host=MYSQL_HOST,
    database='MUSICDB', port=3306,
    user='saltborn', password='saltborn', charset='utf-8')


class Application(tornado.web.Application):
    def __init__(self):
        handlers = [
            (r"/music/(?P<id>\d+)/?",
             MusicSrcHandler,
             dict(database=database))
        ]
        settings = dict(debug=True)
        super().__init__(handlers, **settings)


class MusicSrcHandler(tornado.web.RequestHandler):

    def initialize(self, database):
        self.database = database

    def get(self, id):
        # return music src
        self.set_header("Content-Type", "application/json")
        with self.database.cursor() as cursor:
            sql = "SELECT `msg` FROM MUSIC WHERE `id` = %s"
            cursor.execute(sql, (id,))
            result = cursor.fetchone()
            # do something with result, I am not doing here
        self.write({"msg": id})


def main():
    tornado.options.parse_command_line()
    app = Application()
    app.listen(options.port)
    tornado.ioloop.IOLoop.current().start()

if __name__ == '__main__':
    main()

这里我们程序的 pymysql.connect 中的 host 参数表示 MYSQL 数据库的主机地址,我们这里 不需要写 MYSQL 数据库的地址,只需要写 MYSQL 服务名字就可以了.

我这里是通过环境变量 MYSQL_HOST 来获取服务名,因为日后服务名字可能会变,这样就可以防万变了,当然你也可以直接写死.这也是项目代码中唯二值得注意的地方了.

docker-compose.yml 配置

version: "3"

services:

mysql:
  container_name: mysql
  image: mysql
  restart: on-failure
    environment:
      - MYSQL_USER=saltborn
      - MYSQL_ROOT_PASSWORD=saltborn
      - MYSQL_DATABASE=MUSICDB
  ports:
    - "3306:3306"
  volumes:
    - "./data:/var/lib/mysql"
    - "./sql:/docker-entrypoint-initdb.d"
  healthcheck:
    test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u","$MYSQL_USER", "-p$MYSQL_ROOT_PASSWORD"]
    interval: 30s
    timeout: 10s
    retries: 5

music.api:
  container_name: music_api
  restart: on-failure
  # build: ./src
  image: saltborn/music
  environment:
    - MYSQL_HOST=mysql
  depends_on:
    - mysql
  # links:
  #   - mysql
  ports:
    - "8000:8000"
  volumes:
    - "./src:/app"
  command: sh -c "python3 /app/app.py"

这里如你所见定义了两个服务,分别是 mysqlmusic.api, 其中 music.api 需要连接 mysql 这个服务.

这里你会发现一些新选项,不用害怕我会尽量讲的简单点,

  • container_name: 运行时容器的名字;
  • restart: 在什么时候重启容器, on-failure 表示失败的时候重启;
  • build: 建立镜像的目录/文件,服务就是基于这个新建立成的镜像生成容器,个人一般都是手动建立好镜像然后通过 image 选项来指定镜像,否则每次启动这些服务都会很耗时;
  • environment: 设定环境变量,比如上面提到的 MYSQL_HOST 就是在这里设置的;
  • depends_on: 告诉 docker music.api 服务基于 mysql 服务,还有一个作用就是确保先让 mysql 服务在 music.api 服务之前启动,但是这里有一些小问题,下面再说;
  • links: 告诉 docker music.api 服务需要连接 mysql 服务,实际上该选项和 depends_on 比较相识,该选项可以不使用,直接用 depends_on 即可;
  • command: 服务的入口,相当于 DockerfileENTRYPOINT,也就是启动服务时候执行的命令;
  • healthcheck: 由于 music.api 服务是基于 mysql 服务的,所以我们需要检测 mysql 服务是否运行(防止 mysql 因为错误而无限重启);

关于如何确保 mysql 服务在 music.api 服务启动之前就绪,我在网上(官网)看到几种说法, wait-for-itwait-for 等脚本,也有人说 healthcheck,但是我看了文档并没觉得这选项能干嘛(也有可能根据它的检测结果来决定是否启动 music.api 服务?). 上面的几种解决方法我都没有试过,但是很明显我目前这个配置已经可以确保 music.api 服务能够正常启动,为什么?因为 restart: on-failure,这会在 music.api 服务发生错误的时候重启,这样 music.api 会一直重启直到 mysql 服务就绪为止,当然这不是什么解决方法,但是对于简单的项目而言已经足够用了. 我个人还是推荐尝试上面的提到的 wait-for-itwait-for.

关于 MYSQL Docker 的额外补充

也有一两个朋友不大了解如何使用 MYSQL 的镜像的,其实很简单(可能官方文档太多了大部份人都懒得看),这里我大概说一下,重点在于 mysql 服务的 volumes 选项那里,

你会发现主机上的 sql 挂载到容器的 /docker-entrypoint-initdb.d 目录上,这个容器的目录是 mysql 服务初次启动时候查找 sql 文件的位置,也就是说如果你想新建用户,数据库和表等等的东西都可以写个脚本挂载到这里.

比如例子中的 init.sql,

CREATE DATABASE IF NOT EXISTS MUSICDB CHARACTER SET utf8;

USE MUSICDB;

CREATE TABLE IF NOT EXISTS MUSIC(
       `ID` int(11) NOT NULL AUTO_INCREMENT,
       `NAME` varchar(50) NOT NULL,
       PRIMARY KEY (`ID`)) ENGINE=InnoDB;

CREATE USER IF NOT EXISTS 'saltborn'@'%' IDENTIFIED BY 'saltborn';
CREATE USER IF NOT EXISTS 'saltborn'@'localhost' IDENTIFIED BY 'saltborn';

GRANT SELECT, INSERT, UPDATE, DELETE ON MUSICDB.* TO 'saltborn'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON MUSICDB.* TO 'saltborn'@'localhost';

FLUSH PRIVILEGES;

/var/lib/mysql 则是容器中 MYSQL 保存数据的地方,我们应该挂载到这个位置来保存数据到主机上(除非你不在乎这些数据).

要注意的是/var/lib/mysql 已经有数据的时候, init.sql 就不会在下一次服务启动的时候被执行了.

还有就是 MYSQL_USER, MYSQL_ROOT_PASSWORD, MYSQL_DATABASE 等等这些变量,分别是说启动镜像的时候创建出用户,设定 root 密码和创建数据库(尽管我的 init.sql 的工作内容就是和这里的一样,但这几个变量还是要提供的).

额外思考

假设你已经根据上面这些思路自己动手操作过一遍了(选自己熟悉的后端语言写一个简单的程序),那么再想一下 后端程序 + MYSQL + 前端 这样的组合该如何用 docker-compose 打包呢?配置文件该如何写呢?(所有的答案都在这文件里面了).

这里我给点提示,前端访问后端提供的接口时候是跨域的,并且前端不像后端那样简单设置的 depends_on: music.api 就通过 music.api:8000/api 这种形式获取响应,不过可以通过 Nginx 做到反向代理来解决跨域问题,提示已经给了,剩下就要自己动手验证了.

结语

由于官方文档的有效例子太少,所以我就自己写了这么一个简单的实验过程,内容虽少但五脏俱全,包含了基本概念以及基础用法.还有就是官方文档的结构实在是太乱了,新手看到估计会很迷茫,所以每篇文章下面都出现大量的 thumbs down,不过官方文档的内容其实质量很好,针对这个问题我在文章用合适的地方放上官方的参考连接.

这不是 docker-compose.yml 的全部用法,如果你要实现负载均衡,拓展服务等,那么请去阅读 docker-compose.ymlreference 文档,本文边幅有限,再加上如果你看懂这篇文章的内容,那么就问题不大了.

Author: saltb0rn (asche34@outlook.com)

Date: 2019-02-15

Emacs 28.2 (Org mode 9.5.5)

Validate