导读
本文会先从一个臃肿的部署架构视角出发,审视一个不好的部署架构是什么样的,它的缺点以及带来的隐患。之后我们从定制服务通用镜像到搭建服务部署架构,逐步讲解实现思路及过程。最后从架构的发展角度出发,完成服务滚动更新的实现。
本文将涉及 Shell 脚本指令以及 Docker 基础命令,限于篇幅不会对其使用方法详细说明。相关更多内容可参考官方文档或笔者的其他文章(^ ^)。
臃肿的部署架构
还记得第一次接触 Docker 是在毕业工作的半年之后,在那之前我一直是忙于业务的新人,直到我成为后端组长,开始管理项目,部署服务,才有机会了解 Docker 的大概的样子。当时的 Docker 在我眼中就是一个单纯部署服务的平台,一切的项目打包、服务部署、网关配置、数据库管理都是在一个容器内完成。在 DevOps 方面看起来就是十分方便。
公司的以 Docker 为基准的部署流程一直都运用地得心应手,直到迎来的首个微服务项目,我才发现这个一直稳定可用的 Docker 容器原来是那么臃肿。它能臃肿到什么程度呢?其镜像大小为2.44GB,里面塞进了 MySQL、Nginx、Redis 等等服务中间件;单个微服务的构建打包到启动运行接近2分钟,并且只能停服更新......
服务架构样貌如下:
部署大致流程如下:
由于其臃肿的部署架构,难以扩展、运维,整个项目开发一年多直到最后也无法完成交付。别说是客户不满意,就连负责开发、部署和运维的我也是痛苦不堪。在此之后我就决定开始深入了解 Docker,研究如何搭建更好的一套 Docker 工程部署流程。
为了更好地了解 Docker 的全貌及生态,在一个多月的时间里,从 Docker 基础应用到 Docker Compose 容器编排,再从 Docker Swarm 集群到 Kubernetes 容器管理,每一次进阶都会感慨 Docker 的应用能力之广,发展程度之深。于是在通过全面了解 Docker 之后,我开始了初次的服务部署的重构,前后大概步骤分别为:自定制服务通用镜像、重建服务部署架构、Jenkins自动化部署。
构建服务通用镜像
定制一个 Docker 镜像并不是说构建一个大而全的镜像,把所有用到的服务通通运行在一个容器,当然这可能是入门时最简单的用法了,不过为了后期扩展性和易维护性,不建议这样设计镜像。定制镜像时应遵循几点:
- 单一职责原则:每个 Docker 镜像只包含一个服务。
- 最小化镜像体积:选择小体积的基础镜像、合理使用镜像层,删除不必要的文件和缓存。
- 优化构建过程:使用多阶段构建、缓存构建结果、合并多个命令等方式来提高镜像的构建效率。
- 可配置性:使用环境变量、配置文件挂载等方式将服务的配置参数外部化。
基础镜像
选择一个基础镜像是定制镜像的第一步,基础镜像体积应尽量小,比如alpine:latest
镜像(体积只在3M左右)。当然镜像的选择还应该再细分,比如我们的镜像主要用途是提供 Java Web 应用运行的环境,那么就可以选择openjdk:8-jre-alpine
这个镜像,它只包含JRE(Java Runtime Environment)
。
系统时区
定制镜像中还要注意调整系统时区,对于Alpine Linux
系统设置时区操作如下:
shell
apk add tzdata
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
init进程
在 Linux 系统启动过程的最后阶段时,会执行第一个用户级的进程(PID 1 进程),也就是 init 进程。init 是所有其他进程的父进程,并负责启动和管理这些进程。init 进程会在系统运行期间一直存在,直到系统关机。可作为 init 进程的有sysvinit
,upstart
,还有现在 Linux 中熟知的systemd
,以及用于容器的tini
。
在继续讲解 init 进程之前,先回忆一下之前使用容器过程遇到一个现象。例如我们运行了一个centos
镜像,我们进到容器是用的这样的命令:
shell
docker run -ti centos:latest /bin/bash
为了在退出容器时依然能运行,容器内需要有一个后台进程,于是在容器内启动了一个后台运行的 Java Web 服务。接着,等到我们要更新服务容器时就要先停止这个容器,命令如:
shell
docker stop container_name
此时会发现容器并不是立即就停掉,而是要等待大约10s的时间。Docker 对于docker stop
的描述是这样:
The main process inside the container will receive SIGTERM, and after a grace period, SIGKILL. The first signal can be changed with the STOPSIGNAL instruction in the container's Dockerfile, or the --stop-signal option to docker run.
简单来说就是,容器的主进程接收到SIGTERM
信号,会给一段优雅停止的时间,如果超时 Docker 则发送 SIGKILL
信号来强制终止进程。
回到前面运行容器的命令,也就是我们用了bash
作为了容器的主进程,进到容器查看进程:
实际上bash
并不是用于启动和管理子进程的,自然也无法处理reaping
(停止)子线程。
此时,我们就需要考虑为容器指定一个可以用于管理子进程的主进程。在 Docker 运行容器的命令中,可以这样:
shell
docker run -ti --init centos:latest
通过--init
,容器中会使用一个很小的 init 进程作为主进程。在容器中查看进程:
在启动容器的过程中,通过 Docker 守护进程的系统路径找到docker-init
作为 init 进程。docker-init
二进制文件是随着 Docker 安装中的一部分,它是由tini
提供功能支持的。
也就是说我们可以通过docker --init
实现容器的优雅停。当然除了这种方式,也可以在Dockerfile
文件中预构建tini
。tini
,从名字上看就是init
反着写,它是一个小型的的 init 程序,主要是解决在容器中使用 init 进程时可能出现的一些问题,例如进程信号处理、僵尸进程的处理等。
在Alpine Linux
为基础镜像的 Dockerfile 中可以使用以下命令安装tini
:
shell
RUN apk add --no-cache tini
# Tini is now available at /sbin/tini
ENTRYPOINT ["/sbin/tini", "--"]
服务通用
在 Docker Swarm 和 Kubernetes 官方的文档中对容器服务的更新描述都是以镜像:版本标签
粒度作为更新标识的。
在 Docker Swarm 中实现滚动更新的主要流程如下。
- 部署 Redis 到 Swarm 中并配置10s更新延迟,注意现在的 Redis 为
redis:3.0.6
:
shell
docker service create \
--replicas 3 \
--name redis \
--update-delay 10s \
redis:3.0.6
- 更新 Redis 服务,Swarm 会根据更新策略管理节点服务更新,新的 Redis 为
redis:3.0.7
:
shell
docker service update --image redis:3.0.7 redis
再来看一下 Kubernetes 中实现滚动更新的主要流程。
- 创建 Deployment 资源描述文件
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx:1.14.2
ports:
- containerPort: 80
- 通过运行以下命令创建 Deployment :
shell
kubectl apply -f https://k8s.io/examples/controllers/nginx-deployment.yaml
- 更新 nginx 使用镜像从
nginx:1.14.2
到nginx:1.16.1
:
shell
kubectl set image deployment/nginx-deployment nginx=nginx:1.16.1
可以看到,无论是在 Docker Swarm 还是 Kubernetes 都是以镜像:版本标签
作为更新标识的。不过这也是理所当然,就像第三方提供的镜像,都是以镜像:版本标签
发布新版本的。
然而对于内部开发,是否有必要每发布一个版本就构建一个新镜像?在敏捷开发或后期运维中,快速的更新迭代和 bug 修复,整个项目的周期必然会产生大量的镜像,更有可能的是大量淘汰的旧版本镜像。这在存储和管理上都会提高成本。
于是我们可以试着设计一个基准镜像,镜像只包含应用程序必要的运行环境,而具体的新版本程序文件通过挂载到容器的方式运行,这样我们就降低了时间成本和存储成本,并且只需要管理程序文件版本即可,它与传统的版本管理方式基本无异。比如我们前面提到的openjdk:8-jre-alpine
镜像只提供 JRE,专门用于运行 Java 应用,而构建打包好的 Java Web 应用程序包则挂载到容器内运行,这样就不必于每发布新版本应用就重新构建新镜像,这就是服务通用镜像的设计理念和意义。
不过这个方案还可以继续探讨一下可行性和扩展性。在简单的单体容器化服务上显然是可用的,不过我们最终还是要向着 Docker Compose、Docker Swarm 和 Kubernetes 的发展和应用上考虑。也就是在 Docker Swarm 或 Kubernetes 中是否支持我们的不以镜像:版本标签
作为更新标识的方案,这里我们特指依然支持滚动更新。通过研究与实践这个方案是具有扩展性和可行性的。
在 Docker Swarm 使用--force
强制更新服务,可以不指定--image
:
shell
# docker service update [OPTIONS] SERVICE
docker service update --force <SERVICE>
在 Kubernetes 中更新 Deployment 资源,即重启:
shell
# kubectl rollout restart RESOURCE
kubectl rollout restart deployment/<deployment-name>
Dockerfile
通过前面对基础镜像、init 进程、服务通用的说明,结合定制镜像时应遵循的几点要求,一个 Dockerfile 的示例如下:
dockerfile
### 基础镜像
FROM openjdk:8-jre-alpine
### 镜像源
RUN echo -e https://mirrors.aliyun.com/alpine/v3.9/main/ > /etc/apk/repositories
RUN echo -e https://mirrors.aliyun.com/alpine/v3.9/community/ >> /etc/apk/repositories
### 系统时区
RUN apk update \
&& apk add --no-cache tzdata \
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& apk del tzdata
ENV TZ="Asia/Shanghai"
### TINI
RUN apk add --no-cache tini \
&& rm -rf /var/cache/apk/*
### 自定义ENTRYPOINT
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/entrypoint.sh"]
在上面的 Dockerfile 中引入了一个自定义的entrypoint.sh
可执行脚本,并将其作为容器的入口,具体一点,是通过tini
来处理脚本的执行。
entrypoint.sh
脚本很常见地用于在容器启动时执行一些初始化操作,例如设置环境变量、启动服务等,这样使可以让容器的启动变得更加灵活可扩展和自动化。在 Jenkins 或 MySQL 提供的官方镜像中都能找到entrypoint.sh
的身影。
另外,在 Dockerfile 中我们没有把要运行的应用程序文件预构建在里面,就像COPY app.jar /data/
。因此,在 Dockerfile 中也没有使用CMD
指令,而是在运行容器时指定实际的command
。
entrypoint.sh
在前面 Dockerfile ENTRYPOINT
中我们使用的entrypoint.sh
脚本如下:
shell
#!/bin/sh
if [ "$1" = 'java' ]; then
shift
exec java ${JAVA_OPT_EXT} "$@" ${JAVA_OPTS}
fi
exec "$@"
在这个脚本中,运行容器时command
参数会传递进来,根据第一个参数,如果是java
相关的命令,就执行java ${JAVA_OPT_EXT} "$@" ${JAVA_OPTS}
,否则执行其他命令。
比如我们运行容器时的命令是:
shell
docker run -d --name=demo-admin \
--publish published=8080,target=8080 \
--mount type=bind,src=/Users/wei/data/project/demo/demo-admin,dst=/data/project/demo/demo-admin \
-w /data/project/demo/demo-admin/jar/test/ \
-env JAVA_OPT_EXT="-server -Xmx512m -Xms512m -Xmn256m -Xss256k" \
-env JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom --spring.profiles.active=test" \
5d4c8f33377a \
java -jar demo-admin.jar
在entrypoint.sh
中就会执行:
shell
java -server -Xmx512m -Xms512m -Xmn256m -Xss256k \
-jar demo-admin.jar \
-Djava.security.egd=file:/dev/./urandom --spring.profiles.active=test
另外,如果我们想在容器运行过程中进到容器内查看,也可以执行sh
命令:
shell
docker exec -ti demo-admin sh
搭建服务部署架构
在搭建服务部署架构中主要构思数据目录结构、部署流程和运维方面的实施。在不同的 Docker 编排,Docker Compose、Docker Stack,和 Kubernetes 容器管理上部署架构的设计则不尽相同,尤其是对于 Kubernetes 本身就是独立完善的一套容器管理体系。本次搭建的部署架构会以 Docker Compose 编排下的服务为出发点,探究应有的架构样貌。
数据目录结构
在开始服务部署架构的正式搭建之前,先要规划工程部署的目录结构,主要用于挂载和存储容器服务数据。
- ~/data/project:是数据目录的基准目录,它作为所有项目的父目录。
- demo:假定我们有一个项目名是 demo,那么就以项目名作为这个项目的根目录。
- docker-copmose.yml:容器服务编排文件。
- demo-admin、demo-app:以容器服务名作为目录名称,也是作为用于其中服务容器的挂载点。
- jar:存放
jar
包的目录,即应用程序文件目录。 - dev、test、stage、product:根据环境划分
jar
包实际存储目录。 - demo-admin-*.jar:Java 应用程序,即实际运行的应用程序文件。
- log:存放服务日志的目录。
- resources:存放管理后台静态资源的目录。
shell
~/data/project
└─ demo # 项目名
├─ docker-copmose.yml # 项目docker-compose.yml
├─ demo-admin # 服务名
│ ├─ jar # jar包
│ │ ├─ dev # dev环境
│ │ ├─ product # product环境
│ │ ├─ stage # stage环境
│ │ └─ test # test环境
│ │ └─ demo-admin-*.jar # jar构件
│ ├─ log # 日志
│ └─ resources # 管理后台静态资源
└─ demo-app # 服务名
docker-compose.yml
接下来,我们需要编排容器服务,在这一步需要联系数据目录结构来配置。
- services、container_name:在容器编排中,为规范统一管理,服务名与容器名一致。
- image:各个服务容器均应用同一个服务通用镜像。
- volumes:在各个容器服务中以它的服务名称目录作为挂载点,这里使用
bind mount
方式挂载。 - working_dir:设置各个容器服务的工作目录为
jar
包的实际存储目录,这样在运行容器时会以此目录作为容器当前目录执行命令。 - environment、command:环境变量和命令会以参数传递到
entrypoint.sh
中。
yaml
version: "3.9"
services:
demo-admin:
image: cc6a14d508e9
container_name: demo-admin
ports:
- 8080:20008
volumes:
- "/data/project/demo/demo-admin:/data/project/demo/demo-admin"
working_dir: /data/project/demo/demo-admin/jar/test/
environment:
JAVA_OPT_EXT: -server -Xmx512m -Xms512m -Xmn256m -Xss256k
JAVA_OPTS: -Djava.security.egd=file:/dev/./urandom --spring.profiles.active=test
command: java -jar demo-admin-1.0.jar
demo-app:
image: cc6a14d508e9
container_name: demo-app
ports:
- 8090:20018
volumes:
- "/data/project/demo/demo-app:/data/project/demo/demo-app"
working_dir: /data/project/demo/demo-app/jar/test/
environment:
JAVA_OPT_EXT: -server -Xmx512m -Xms512m -Xmn256m -Xss256k
JAVA_OPTS: -Djava.security.egd=file:/dev/./urandom --spring.profiles.active=test
command: java -jar demo-app-1.0.jar
服务部署架构
经过前面的配置后,即完成了服务的部署,对比旧的部署架构,每个容器只运行一个服务,并且统一以宿主服务器的 Nginx 作为网关与反向代理。此外,其他的中间件服务如 MySQL、Redis 则另外单独部署。其整体的部署架构如下图。
服务部署流程
对比旧的部署流程,所有的部署操作都在宿主服务器进行,而不再进入容器操作,并且通过 Docker Compose 简化、快速地完成整个项目的管理和部署。服务部署大致流程如下。
滚动更新实现
在前面搭建服务部署架构中,我们是应用 Docker Compose 容器编排完成的。在滚动更新服务的支持上,Docker Compose 区别于 Docker Stack 和 Kubernetes 容器编排,并不原生地具备相关的实现方式。在 Docker 设计上,Docker Compose 也并不是用于集群多服务部署的,自然也不会考虑到服务的滚动更新。但是如果我们有需求,也可以自己实现滚动更新。
滚动发布是实现不停机更新服务的方案之一,其他的不停机更新方式还有蓝绿发布和灰度发布。蓝绿发布在资源要求上会使成本翻倍,因此很少用;灰度发布也叫金丝雀部署,先通过开放少量的流量探测,再逐步开放全部流量;滚动发布类似于金丝雀部署,但会立即开放全部流量,相较于金丝雀部署在系统稳定性上差些。本文将以滚动发布为不停机更新方案,为新的服务部署架构增强可靠性。
滚动更新状态流转
我们根据了解到的滚动发布实现原理,以demo-app
服务为例,观察它在整个部署架构中的状态流转。
- 更新服务前,Nginx 只对一个容器服务
demo-app
实现负载; - 运行新版本的服务容器
demo-app-node
,在新版本服务完成启动前不加入负载均衡; - 新版本容器服务
demo-app-node
完成启动后,将其加入负载,并暂时下线对demo-app
服务的负载,在此期间更新demo-app
服务; - 新版本服务
demo-app
完成更新后,恢复对demo-app
服务的负载,并移除对demo-app-node
服务的负载,在此期间删除demo-app-node
服务容器; - 状态流转回步骤1,完成闭环。
滚动更新实现过程
通过上面滚动更新的状态流转,我们从宏观层面了解了它的实现原理。然而具体的实现过程还需继续深入探究,这里面包括了新版程序文件的构建打包,Nginx 负载均衡配置,docker-compose.yml 文件编辑,我们依照前面的状态流转还原其中的实现过程。
1.配置 Nginx 负载均衡
在整体的部署架构中,我们通过 Nginx 实现各个服务的负载均衡,这是实现滚动更新的核心机制。以demo-app
服务为例,Nginx 配置如下:
nginx
upstream demo-app-server {
server 127.0.0.1:8090;
}
server {
listen 80;
server_name domain;
location /app {
proxy_pass http://demo-app-server;
break;
}
}
2.运行新版本的服务容器
运行新版本服务前,首先要先构建打包好新版本的程序文件,并根据实际环境目录存放jar
包。之后,编辑 docker-compose.yml 文件,添加新的容器服务demo-app-node
。为节省篇幅,省略其他相同配置,如下:
yaml
version: "3.9"
services:
demo-app:
image: cc6a14d508e9
container_name: demo-app
ports:
- 8090:20018
demo-admin-node: # 新的服务
image: cc6a14d508e9
container_name: demo-app-node # 容器名与服务名相同
ports:
- 8091:20018 # 开放端口递增
执行命令重编排:
shell
docker compose up -d
3.更改 Nginx 负载均衡
等待容器服务完成启动,此时demo-app-node
即为新版本的服务。将demo-app-node
加入负载,并暂时下线对demo-app
服务的负载。Nginx 配置如下:
nginx
upstream demo-app-server {
server 127.0.0.1:8090 down; # 下线旧服务的负载
server 127.0.0.1:8091; # 加入对新服务的负载
}
server {
listen 80;
server_name domain;
location /app {
proxy_pass http://demo-app-server;
break;
}
}
热重载 Nginx:
shell
nginx -s reload
4.更新旧版本的容器服务到新版本
先更新demo-app
容器服务到新版本,执行命令:
shell
docker compose restart demo-app
之后恢复对demo-app
服务的负载,并移除对demo-app-node
服务的负载,Nginx 配置如下:
nginx
upstream demo-app-server {
server 127.0.0.1:8090; # 恢复服务的负载
}
server {
listen 80;
server_name domain;
location /app {
proxy_pass http://demo-app-server;
break;
}
}
热重载 Nginx:
shell
nginx -s reload
编辑 docker-compose.yml 文件,删除demo-app-node
容器服务,如下:
yaml
version: "3.9"
services:
demo-app:
image: cc6a14d508e9
container_name: demo-app
ports:
- 8090:20018
执行命令重编排,并指定--remove-orphans
删除遗留的demo-app-node
容器:
shell
docker compose up -d --remove-orphans
脚本化滚动更新
经过前面对滚动更新具体实现过程的探究,应当对其原理有了更深的理解。然而在实际的实施中,总不能都是人为手动去控制其状态流转,编写脚本实现自动化滚动更新才是最终目标。
不过在编写脚本之前,我们先要清楚状态流转关键点是什么,特定变量值怎么获取,以及借助什么样的工具可以简化我们的文件处理。
1.简化脚本处理工具
在滚动更新的实现中,需编辑 YAML 文件,我们可以使用yq
来处理。yq
是一个轻量、便携的处理 YAML、JSON 和 XML 文件的命令行处理器。
执行以下命令安装yq
:
shell
# wget https://github.com/mikefarah/yq/releases/download/${VERSION}/${BINARY}.tar.gz -O - | tar xz && mv ${BINARY} /usr/bin/yq
wget https://github.com/mikefarah/yq/releases/download/v4.2.0/yq_linux_amd64.tar.gz -O - |\
tar xz && mv yq_linux_amd64 /usr/bin/yq
通过yq
,我们可以对 docker-compose.yml 复刻新服务demo-app-node
,并修改其容器名、端口映射,以及删除整个demo-app-node
:
shell
yq eval -i '.services.demo-app-node = .services.demo-app' docker-compose.yml
yq eval -i '.services.demo-app-node.container_name = "demo-app-node"' docker-compose.yml
yq eval -i '.services.demo-app-node.ports[0] = "8091:20018"' docker-compose.yml
yq eval -i 'del(.services.demo-app-node)' docker-compose.yml
2.修改 Nginx 配置
对于 Nginx 配置文件,可以使用sed
来处理文本。
将demo-app-node
加入负载,使用sed
在server 127.0.0.1:8090
之下新增一行 server 127.0.0.1:8091;
:
shell
sed -i "/server 127.0.0.1:8090/a\ server 127.0.0.1:8091;" demo.conf
暂时下线对demo-app
服务的负载,修改server 127.0.0.1:8090
为server 127.0.0.1:8090 down
:
shell
sed -i "s/server 127.0.0.1:8090/server 127.0.0.1:8090 down/" demo.conf
恢复对demo-app
服务的负载:
shell
sed -i "s/server 127.0.0.1:8090 down/server 127.0.0.1:8090/" demo.conf
移除对demo-app-node
服务的负载:
shell
sed -i "/server 127.0.0.1:8091;/d" demo.conf
3.脚本示例
在脚本中定义了两个入参变量,PROJECT_NAME
和SERVICE_NAME
,执行脚本时分别传递项目名称和服务名称即可实现服务的自动化滚动更新。
shell
#!/bin/bash
BASE_DIR="/data/project"
PROJECT_NAME="$1" # 项目名称
SERVICE_NAME="$2" # 服务名称
PROJECT_DIR="${BASE_DIR}/${PROJECT_NAME}" # 项目目录
SERVICE_DIR="${PROJECT_DIR}/${SERVICE_NAME}" # 服务目录
PROJECT_DOCKER_COMPOSE="${PROJECT_DIR}/docker-compose.yml" # docker-compose.yml
NGINX_CONF_D="/etc/nginx/conf.d" # Nginx 配置文件目录
NGINX_CONF="${NGINX_CONF_D}/${PROJECT_NAME}.conf" # Nginx 配置文件
### docker-compose.yml 增加备份服务
function compose_node_add() {
# 读取原服务开放端口,并赋予备份服务新端口
local port=$(yq eval '.services."'"${SERVICE_NAME}"'".ports[0]' ${PROJECT_DOCKER_COMPOSE})
local split_port=(${port//:/ })
published_port=${split_port[0]}
node_port=$((${published_port/:*/} + 1))
yq eval -i '.services.'"${SERVICE_NAME}"'-node = .services.'"${SERVICE_NAME}"'' ${PROJECT_DOCKER_COMPOSE}
yq eval -i '.services.'"${SERVICE_NAME}"'-node.container_name = "'"${SERVICE_NAME}"'-node"' ${PROJECT_DOCKER_COMPOSE}
yq eval -i '.services.'"${SERVICE_NAME}"'-node.ports[0] = "'"${node_port}"':20018"' ${PROJECT_DOCKER_COMPOSE}
}
### docker-compose.yml 删除备份服务
function compose_node_del() {
yq eval -i 'del(.services.'"${SERVICE_NAME}"'-node)' ${PROJECT_DOCKER_COMPOSE}
}
### 运行备份服务
function service_node_run() {
cd ${PROJECT_DIR} && docker compose up -d
service_healthcheck "${SERVICE_NAME}"-node
nginx_node_add
}
### 更新原服务
function service_update() {
cd ${PROJECT_DIR} && docker compose restart "${SERVICE_NAME}"
service_healthcheck "${SERVICE_NAME}"
nginx_service_restore
}
### 删除遗留备份服务
function service_restore() {
cd ${PROJECT_DIR} && docker compose up -d --remove-orphans
}
### 服务运行状态检查
function service_healthcheck() {
local service="$1"
local time=$(date +"%Y-%m-%dT%T")
local check_count=0
until [ $check_count -gt 24 ]
do
if [ -n "$(docker compose logs "$service" --since "$time" | grep -o 'Started .* in .* seconds')" ]
then
return 0
fi
check_count=$((check_count + 1))
sleep 5
done
echo "Error: 服务 ${SERVICE_NAME} 启动超时"
exit 1
}
### 备份服务加入负载,临时下线原服务负载
function nginx_node_add() {
sed -i "/server 127.0.0.1:${published_port}/a\ server 127.0.0.1:${node_port};" ${NGINX_CONF}
sed -i "s/server 127.0.0.1:${published_port}/server 127.0.0.1:${published_port} down/" ${NGINX_CONF}
nginx -s reload
}
### 删除备份服务负载,恢复原服务负载
function nginx_service_restore() {
sed -i "/server 127.0.0.1:${node_port};/d" ${NGINX_CONF}
sed -i "s/server 127.0.0.1:${published_port} down/server 127.0.0.1:${published_port}/" ${NGINX_CONF}
nginx -s reload
}
compose_node_add
service_node_run
service_update
compose_node_del
service_restore
exit 0
结语&展望
至此,我们的整个新的服务部署架构已搭建完毕。当然,其中还有诸多值得思考与改善的地方。比如我们构建的服务通用镜像还会继续增加特定业务需要的字体库以及其需要的依赖程序,这些都视实际生产情况决定;又或者我们实际的滚动更新脚本会更加健壮,在脚本执行中应当考虑服务启动失败时如何处理,以及配置文件的备份和恢复等等非预期情况; 此外,我们的部署架构增强了滚动更新的能力,但还未涉及版本回滚,这些都可以自行编写脚本实现。
目前我们的以 Docker Compose 编排为基准的部署架构不是最终目标,Docker Swarm 和 Kubernetes 依然值得继续深入研究。