大规模场景下的多服务部署和管理是一件很难的事情。幸运的是,Docker Stack 为解决该问题而生,Docker Stack 通过提供期望状态、滚动升级、简单易用、扩缩容、健康检查等特性简化了应用的管理,这些功能都封装在一个完美的声明式模型当中。在笔记本上测试和部署简单应用很容易。但这只能算业余选手。在真实的生产环境进行多服务的应用部署和管理,这才是专业选手的水平。
幸运的是,Stack 正为此而生!Stack 能够在单个声明文件中定义复杂的多服务应用。Stack 还提供了简单的方式来部署应用并管理其完整的生命周期:初始化部署 -> 健康检查 -> 扩容 -> 更新 -> 回滚,以及其他功能!步骤很简单。在 Compose 文件中定义应用,然后通过 docker stack deploy 命令完成部署和管理。Compose 文件中包含了构成应用所需的完整服务栈。此外还包括了卷、网络、安全以及应用所需的其他基础架构。然后基于该文件使用 docker stack deploy 命令来部署应用。Stack 是基于 Docker Swarm 之上来完成应用的部署。因此诸如安全等高级特性,其实都是来自 Swarm。简而言之,Docker 适用于开发和测试。Docker Stack 则适用于大规模场景和生产环境。如果了解 Docker Compose,就会发现 Docker Stack 非常简单。事实上在许多方面,Stack 一直是期望的 Compose——完全集成到 Docker 中,并能够管理应用的整个生命周期。从体系结构上来讲,Stack 位于 Docker 应用层级的最顶端。Stack 基于服务进行构建,而服务又基于容器,如下图所示。
我们使用示例应用 AtSea Shop。该示例托管在 Github 的 dockersamples/atsea-sample-shop-app 库中,基于 Apache 2.0 许可证开源。应用架构如下图所示。
2.1 拉取源码
git clone https://github.com/dockersamples/atsea-sample-shop-app.git
该应用的代码由若干目录和源码文件组成。接下来,重点关注的文件是 docker-stack.yml。该文件通常被称为 Stack 文件,在该文件中定义了应用及其依赖。
查看 docker-stack.yml
version: "3.2"
services:
reverse_proxy:
image: dockersamples/atseasampleshopapp_reverse_proxy
ports:
- "80:80"
- "443:443"
secrets:
- source: revprox_cert
target: revprox_cert
- source: revprox_key
target: revprox_key
networks:
- front-tier
database:
image: dockersamples/atsea_db
environment:
POSTGRES_USER: gordonuser
POSTGRES_DB_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_DB: atsea
networks:
- back-tier
secrets:
- postgres_password
deploy:
placement:
constraints:
- 'node.role == worker'
appserver:
image: dockersamples/atsea_app
networks:
- front-tier
- back-tier
- payment
deploy:
replicas: 2
update_config:
parallelism: 2
failure_action: rollback
placement:
constraints:
- 'node.role == worker'
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
secrets:
- postgres_password
visualizer:
image: dockersamples/visualizer:stable
ports:
- "8001:8080"
stop_grace_period: 1m30s
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
deploy:
update_config:
failure_action: rollback
placement:
constraints:
- 'node.role == manager'
payment_gateway:
image: dockersamples/atseasampleshopapp_payment_gateway
secrets:
- source: staging_token
target: payment_token
networks:
- payment
deploy:
update_config:
failure_action: rollback
placement:
constraints:
- 'node.role == worker'
- 'node.labels.pcidss == yes'
networks:
front-tier:
back-tier:
payment:
driver: overlay
driver_opts:
encrypted: 'yes'
secrets:
postgres_password:
external: true
staging_token:
external: true
revprox_key:
external: true
revprox_cert:
external: true
在该文件整体结构中,定义了 4 种顶级关键字。
如果展开顶级的关键字,可以看到类似上图中的结构。Stack 文件由 5 个服务构成,分别为“reverse_proxy”“database”“appserver”“visualizer”“payment_gateway”。Stack 文件中包含 3 个网络,分别为“front-tier”“back-tier”“payment”。最后,Stack 文件中有 4 个密钥,分别为“postgres_password”“staging_token”“revprox_key”“revprox_cert”。
2.2 分析文件
2.2.1 networks
networks:
front-tier:
back-tier:
payment:
driver: overlay
driver_opts:
encrypted: 'yes'
该文件中定义了 3 个网络:front-tier、back-tier 以及 payment。默认情况下,这些网络都会采用 overlay 驱动,新建对应的覆盖类型的网络。但是 payment 网络比较特殊,需要数据层加密。
默认情况下,覆盖网络的所有控制层都是加密的。如果需要加密数据层,有两种选择。
数据层加密会导致额外开销,而影响额外开销大小的因素有很多,比如流量的类型和流量的多少。但是,通常额外开销会在 10% 的范围之内。
正如前面提到的,全部的 3 个网络均会先于密钥和服务被创建。
2.2.2 secrets
secrets:
postgres_password:
external: true
staging_token:
external: true
revprox_key:
external: true
revprox_cert:
external: true
注意,4 个密钥都被定义为 external。这意味着在 Stack 部署之前,这些密钥必须存在。
当然在应用部署时按需创建密钥也是可以的,只需要将 file:
2.2.3 services:reverse_proxy 服务
reverse_proxy:
image: dockersamples/atseasampleshopapp_reverse_proxy
ports:
- "80:80"
- "443:443"
secrets:
- source: revprox_cert
target: revprox_cert
- source: revprox_key
target: revprox_key
networks:
- front-tier
正如读者所见,reverse_proxy 服务定义了镜像、端口、密钥以及网络。
image 关键字是服务对象中唯一的必填项。顾名思义,该关键字定义了将要用于构建服务副本的 Docker 镜像。
Docker 是可选项,除非指定其他值,否则镜像会从 Docker Hub 拉取。可以通过在镜像前添加对应第三方镜像仓库服务 API 的 DNS 名称的方式,来指定某个镜像从第三方服务拉取。例如 Google 的容器服务的 DNS 名称为 gcr.io。
Docker Stack 和 Docker Compose 的一个区别是,Stack 不支持构建。这意味着在部署 Stack 之前,所有镜像必须提前构建完成。
ports 关键字定义了两个映射。
默认情况下,所有端口映射都采用 Ingress 模式。这意味着 Swarm 集群中每个节点的对应端口都会映射并且是可访问的,即使是那些没有运行副本的节点。
另一种方式是 Host 模式,端口只映射到了运行副本的 Swarm 节点上。但是,Host 模式需要使用完整格式的配置。例如,在 Host 模式下将端口映射到 80 端口的语法如下所示。
ports:
- target: 80
published: 80
mode: host
推荐使用完整语法格式,这样可以提高易读性,并且更灵活(完整语法格式支持 Ingress 模式和 Host 模式)。但是,完整格式要求 Compose 文件格式的版本至少是 3.2。
secret 关键字中定义了两个密钥:revprox_cert 以及 revprox_key。这两个密钥必须在顶级关键字 secrets 下定义,并且必须在系统上已经存在。
密钥以普通文件的形式被挂载到服务副本当中。文件的名称就是 stack 文件中定义的 target 属性的值,其在 Linux 下的路径为 /run/secrets,在 Windows 下的路径为 C:\ProgramData\Docker\secrets。Linux 将 /run/secrets 作为内存文件系统挂载,但是 Windows 并不会这样。
本服务密钥中定义的内容会在每个服务副本中被挂载,具体路径为 /run/secrets/revprox_cert 和 /run/secrets/revprox_key。若将其中之一挂载为 /run/secrets/uber_secret,需要在 stack 文件中定义如下内容。
secrets:
- source: revprox_cert
target: uber_secret
networks 关键字确保服务所有副本都会连接到 front-tier 网络。网络相关定义必须位于顶级关键字 networks 之下,如果定义的网络不存在,Docker 会以 Overlay 网络方式新建一个网络。
2.2.4 services:database 服务
database:
image: dockersamples/atsea_db
environment:
POSTGRES_USER: gordonuser
POSTGRES_DB_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_DB: atsea
networks:
- back-tier
secrets:
- postgres_password
deploy:
placement:
constraints:
- 'node.role == worker'
数据库服务也在 Stack 文件中定义了,包括镜像、网络以及密钥。除上述内容之外,数据库服务还引入了环境变量和部署约束。environment 关键字允许在服务副本中注入环境变量。在该服务中,使用了 3 个环境变量来定义数据库用户、数据库密码的位置(挂载到每个服务副本中的密钥)以及数据库服务的名称。将三者作为密钥传递会更安全,因为这样可以避免将数据库名称和数据库用户以明文变量的方式记录在文件当中。该服务还在 deploy 关键字下定义了部署约束。这样保证了当前服务只会运行在 Swarm 集群的 worker 节点之上。
部署约束是一种拓扑感知定时任务,是一种很好的优化调度选择的方式。Swarm 目前允许通过如下几种方式进行调度。
注意: == 和 != 操作符均支持
2.2.4 services:appserver 服务
appserver:
image: dockersamples/atsea_app
networks:
- front-tier
- back-tier
- payment
deploy:
replicas: 2
update_config:
parallelism: 2
failure_action: rollback
placement:
constraints:
- 'node.role == worker'
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
window: 120s
secrets:
- postgres_password
appserver 服务使用了一个镜像,连接到 3 个网络,并且挂载了一个密钥。此外 appserver 服务还在 deploy 关键字下引入了一些额外的特性。
首先,services.appserver.deploy.replicas = 2 设置期望服务的副本数量为 2。缺省情况下,默认值为 1。如果服务正在运行,并且需要修改副本数,则需要显示声明该值。这意味着需要更新 stack 文件中的 services.appserver.deploy.replicas,设置一个新值,然后重新部署当前 stack。
services.appserver.deploy.update_config 定义了 Docker 在服务滚动升级的时候具体如何操作。对于当前服务,Docker 每次会更新两个副本(parallelism),并且在升级失败后自动回滚。
回滚会基于之前的服务定义启动新的副本。failure_action 的默认操作是 pause,会在服务升级失败后阻止其他副本的升级。failure_action 还支持 continue。
services.appserver.deploy.restart-policy 定义了 Swarm 针对容器异常退出的重启策略。当前服务的重启策略是,如果某个副本以非 0 返回值退出(condition: onfailure),会立即重启当前副本。重启最多重试 3 次,每次都会等待至多 120s 来检测是否启动成功。每次重启的间隔是 5s。
2.2.5 services:visualizer 服务
visualizer:
image: dockersamples/visualizer:stable
ports:
- "8001:8080"
stop_grace_period: 1m30s
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
deploy:
update_config:
failure_action: rollback
placement:
constraints:
- 'node.role == manager'
当 Docker 停止某个容器的时候,会给容器内部 PID 为 1 的进程发送 SIGTERM 信号。容器内 PID 为 1 的进程会有 10s 的优雅停止时间来执行一些清理操作。如果进程没有处理该信号,则 10s 后就会被 SIGKILL 信号强制结束。stop_grace_period 属性可以调整默认为 10s 的优雅停止时长。
volumes 关键字用于挂载提前创建的卷或者主机目录到某个服务副本当中。在本例中,会挂载 Docker 主机的 /var/run/docker.sock 目录到每个服务副本的 /var/run/docker.sock 路径。这意味着在服务副本中任何对 /var/run/docker.sock 的读写操作都会实际指向 Docker 主机的对应目录中。
/var/run/docker.sock 恰巧是 Docker 提供的 IPC 套接字,Docker daemon 通过该套接字对其他进程暴露其 API 终端。这意味着如果给某个容器访问该文件的权限,就是允许该容器接收全部的 API 终端,即等价于给予了容器查询和管理 Docker daemon 的能力。在大部分场景下这是决不允许的。但是,这是一个实验室环境中的示例应用。
该服务需要 Docker 套接字访问权限的原因是需要以图形化方式展示当前 Swarm 中服务。为了实现这个目标,当前服务需要能访问管理节点的 Docker daemon。为了确保能访问管理节点 Docker daemon,当前服务通过部署约束的方式,强制服务副本只能部署在管理节点之上,同时将 Docker 套接字绑定挂载到每个服务副本中。绑定挂载如下图所示。
2.2.6 services:payment_gateway 服务
payment_gateway:
image: dockersamples/atseasampleshopapp_payment_gateway
secrets:
- source: staging_token
target: payment_token
networks:
- payment
deploy:
update_config:
failure_action: rollback
placement:
constraints:
- 'node.role == worker'
- 'node.labels.pcidss == yes'
除了部署约束 node.label 之外,其余配置项在前面都已经出现过了。通过 docker node update
命令可以自定义节点标签,并添加到 Swarm 集群的指定节点。
因此,node.label 配置只适用于 Swarm 集群中指定的节点上(不能用于单独的容器或者不属于 Swarm 集群的容器之上)。
在本例中,payment_gateway 服务被要求只能运行在符合 PCI DSS(支付卡行业标准,译者注)标准的节点之上。为了使其生效,可以将某个自定义节点标签应用到 Swarm 集群中符合要求的节点之上。示例中在搭建应用部署实验环境的时候完成了该操作。
因为当前服务定义了两个部署约束,所以服务副本只会部署在两个约束条件均满足的节点之上,即具备 pcidss=yes 节点标签的 worker 节点。
关于 Stack 文件的分析到这里就结束了,目前对于应用需求应该有了较好的理解。前文中提到,Stack 文件是应用文档化的重要部分之一。已经了解该应用包含 5 个服务、3 个网络以及 4 个密钥。此外还知道了每个服务都会连接到哪个网络、有哪些端口需要发布、应用会使用到哪些镜像以及哪些服务需要在特定的节点上发布。
接下来的讲解中会完成基于 Linux 的三节点 Swarm 集群搭建,同时能满足上面应用的全部前置依赖。完成之后,实验环境如下图所示。
3.1 创建新的键值对
应用定义了 4 个密钥,这些都需要在应用部署前创建
postgress_password
staging_token
revprox_cert
revprox_key
密钥中有 3 个是需要加密 key 的。在本步骤中会创建加密 key,下一步会将加密 key 放到 Docker 密钥文件当中。
#创建加密 key
openssl req -newkey rsa:4096 -nodes -sha256 -keyout domain.key -x509 -days 365 -out domain.crt
#创建 revprox_cert、revprox_key 以及 postgress_password 密钥
docker secret create revprox_cert domain.crt
docker secret create revprox_key domain.key
docker secret create postgres_password domain.key
#创建 stage_token 密钥
echo staging | docker secret create staging_token -
#列出所有密钥
docker secret ls
3.1 部署项目
进如到 docker-stack.yml 所在目录
Stack 通过docker stack deploy
命令完成部署。基础格式下,该命令允许传入两个参数。
应用的 GitHub 仓库中包含一个名为 docker-stack.yml 的 Stack 文件。这里会使用该文件。这里为 Stack 起名 seastack。
在 Swarm 管理节点的 atsea-sample-shop-app 目录下运行下面的命令。
docker stack deploy -c docker-stack.yml seastack
可以运行 docker network ls
以及 docker service ls
命令来查看应用的网络和服务情况。
下面是命令输出中几个需要注意的地方。
网络是先于服务创建的。这是因为服务依赖于网络,所以网络需要在服务启动前创建。
Docker 将 Stack 名称附加到由他创建的任何资源名称前作为前缀。在本例中,Stack 名为 seastack,所以所有资源名称的格式都如:seastack_
另一个需要注意的点是出现了新的名为 seastack_default 的网络。该网络并未在Stack文件中定义,那为什么会创建呢?每个服务都需要连接到网络,但是 visualizer 服务并没有指定具体的网络。因此,Docker 创建了名为 seastack_default 的网络,并将 visualizer 连接到该网络。
可以通过两个命令来确认当前 Stack 的状态。docker stack ls
列出了系统中全部 Stack,包括每个 Stack 下面包含多少服务。docker stack ps
针对某个指定 Stack 展示了更详细的信息,例如期望状态以及当前状态。下面一起来了解下这两条命令。
可以看到everse_proxy有两次失败。启动失败原因是其依赖的某个服务仍然在启动中(一种启动时服务间依赖导致的竞争条件)。
Stack 是一组相关联的服务和基础设施,需要进行统一的部署和管理。虽然这句话里充斥着术语,但仍提醒我们 Stack 是由普通的 Docker 资源构建而来:网络、卷、密钥、服务等。
这意味着可以通过普通的 Docker 命令对其进行查看和重新配置,例如 docker network、docker volume、docker secret、docker service 等。
在此前提之下,通过 docker service 命令来管理 Stack 中某个服务是可行的。一个简单的例子是通过 docker service scale
命令来扩充 appserver 服务的副本数。但是,这并不是推荐的方式!
推荐方式是通过声明式方式修改,即将 Stack 文件作为配置的唯一声明。这样,所有 Stack 相关的改动都需要体现在 Stack 文件中,然后更新重新部署应用所需的 Stack 文件。
下面是一个简单例子,阐述了为什么通过命令修改的方式不好(通过 CLI 进行变更)。
假设读者已经部署了一个 Stack,采用的 Stack 文件是 GitHub 复制的仓库中的 docker-stack.yml。这意味着目前 appserver 服务有两个副本。如果通过 docker service scale
命令将副本修改为 4 个,当前运行的集群会有 4 个副本,但是 Stack 文件中仍然是两个。
但是,假设通过修改 Stack 文件对 Stack 做了某些改动,然后通过 docker stack deploy
命令进行滚动部署。这会导致 appserver 服务副本数被回滚到两个,因为 Stack 文件就是这么定义的。因此,推荐对 Stack 所有的变更都通过修改 Stack 文件来进行,并且将该文件放到一个合适的版本控制系统当中。
一起来回顾对Stack进行两个声明式修改的过程。目标是进行如下改动。
增加 appserver 副本数,数量为 2~10。将 visualizer 服务的优雅停止时间增加到 2min。修改 docker-stack.yml 文件,更新两个值:services.appserver.deploy.replicas=10
和services.visualizer.stop_grace_period=2m
。
appserver:
image: dockersamples/atsea_app
networks:
- front-tier
- back-tier
- payment
deploy:
replicas: 10
visualizer:
image: dockersamples/visualizer:stable
ports:
- "8001:8080"
stop_grace_period: 2m
保存文件并重新部署应用。
docker stack deploy -c docker-stack.yml seastack
以上重新部署应用的方式,只会更新存在变更的部分
docker stack ps seastack
注意关于 visualizer 服务有两行内容。其中一行表示某个副本在 一分 前停止,另一行表示新副本已经运行了 一分。这是因为刚才对 visualizer 服务作了修改,所以 Swarm 集群终止了正在运行的副本,并且启动了新的副本,新副本中更新了 stop_grace_period 的值。
还需要注意的是,appserver 服务目前拥有 10 个副本,但不同副本的“CURRENT STATE”一列状态并不相同:有些处于 running 状态,而有些仍在 starting 状态。(这里已经起来了)
经过足够的时间,集群的状态会完成收敛,期望状态和当前状态就会保持一致。在那时,集群中实际部署和观察到的状态,就会跟 Stack 文件中定义的内容完全一致。
所有应用 /Stack 都应采用该方式进行更新。所有的变更都应该通过 Stack 文件进行声明,然后通过 docker stack deploy
进行部署。
正确的删除某个 Stack 方式是通过 docker stack rm
命令。一定要谨慎!删除 Stack 不会进行二次确认。
docker stack rm seastack
注意:网络和服务已经删除,但是密钥并没有。这是因为密钥是在 Stack 部署前就创建并存在了。在 Stack 最上层结构中定义的卷同样不会被 docker stack rm
命令删除。这是因为卷的设计初衷是保存持久化数据,其生命周期独立于容器、服务以及 Stack 之外。