基于Docker Swarm、Portainer和Jenkins的Spring Cloud服务自动构建和部署
本文探讨基于Docker Swarm、Portainer和Jenkins的Spring Cloud微服务自动构建和部署。相对本文讨论的方案,业界更主流的是基于k8s,显而易见k8s的功能更强大,但也更复杂,也需要投入更多开发和运维成本。对于小公司,集群规模不会很大,Docker Swarm加上Portainer可以满足大部分需求,建议可以根据需求选择适合的方案。
然而本文的重点是自动构建和部署,就目前的信息,Docker Swarm没法做到像k8s那样的平滑自动部署,即部署过程中如果控制不好可能会有downtime。原因是Docker Swarm没有提供生命周期管理,没有提供像k8s的PreStop
这种stop服务之前执行脚本的能力。即没有办法在发送SIGTERM
信号之前先下线服务并等待状态同步。而我们都知道Spring Cloud的服务需要注册到服务注册中心(如Eureka),服务的状态从注册中心同步到客户端需要一定时间。虽然不能做到像k8s那样优雅的平滑部署,但是我们还是可以通过配置的调优做到尽量少的downtime。
根据经验,可以尝试放弃服Spring Cloud,而直接利用Service Swarm网络来作负载均衡。前端和服务之间的调用可以直接使用主机地址加上服务绑定的端口号或Service Swarm服务名来调用服务。这样服务的状态直接由Service Swarm网络来管理,从而实现完全的平滑部署。
1 环境
本文不是介绍如何搭建Docker Swarm和Portainer和Jenkins,如有需要可以到网上搜相关的资料自行安装部署。本文的基础是假设所有需要的平台和组件已经准备好。
主要组件和版本:
- Docker: 20.10.6
- Portainer: 2.5.0
- Jenkins: 2.401.2
- Spring Boot: 2.3.12.RELEASE
- Spring Cloud: Hoxton.SR12
2 Spring Boot配置
2.1 优雅关闭
Spring Boot默认并没有开启优雅关闭
,需要手动配置。优雅关闭的意思是在服务关闭前,等待正在处理的请求,而不是直接关闭服务。Docker容器停止的时候会发送SIGTERM信号,Spring Boot默认会处理这个信号,并优雅关闭。
启用很简单,只需要在Spring Boot的配置文件中添加以下配置即可:
这里我们设置了1分钟的超时时间,如果超过这个时间即使请求还没有处理完,也会强制关闭。
#graceful shutdown
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=1m
2.2 健康探针
利用Spring的Actuator组件来开启健康检查api,可以通过HTTP请求的方式,检测服务是否正常。
- 引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
- 配置
info、service-registry、shutdown则不是必须的,按需添加。
#actuator configuration
management.endpoint.shutdown.enabled=true
management.endpoints.web.exposure.include=health,info,service-registry,shutdown
management.endpoints.jmx.exposure.include=health,info
3 Spring Cloud配置
3.1 Eureka配置
Eureka是Spring Cloud框架里面流行的服务注册中心,它提供了服务注册和发现功能。Eureak的分布式执行的是AP模式,为了保证分区容错性中间必然加了多个缓存,服务的上线和下线需要一定的时间才能同步到客户端。在不修改Eureka默认配置的情况下,一个服务从下线到真正可以从客户端移除,最糟糕可能需要210秒,即使服务主动标记离线,也需要最长60秒。
默认情况下各个节点的间隔时间:
- 实例心跳时间:30秒
- 实例租约到期时间:90秒 (如果90秒没有收到心跳,则服务会被移除)
- 服务端ReadWriteCache清理间隔:60秒 (每60秒检查清除一次租约到期的服务)
- 服务端ReadOnlyCache刷新间隔:30秒
- 客户端更新间隔:30秒
最糟糕异常下线情况:实例租约到期时间 + 服务端ReadWriteCache清理间隔 + 服务端ReadOnlyCache刷新间隔 + 客户端更新间隔
= 90秒 + 60秒 + 30秒 + 30秒 = 210秒
主动下线的情况:服务端ReadOnlyCache刷新间隔 + 客户端更新间隔
= 30秒 + 30秒 = 60秒
了解这些信息后,我们就可以通过适当调整配置来减少服务更新和同步间隔时间导致的down time。
推荐配置
- 客户端配置
注册表获取时间调整为5秒。
YMAL配置:
eureka:
client:
registry-fetch-interval-seconds: 5
Properties配置:
eureka.client.registry-fetch-interval-seconds: 5
- Eureka Server配置
服务端ReadWriteCache清理间隔设置为3秒(每3秒检查清除一次租约到期的服务),服务端ReadOnlyCache刷新间隔设置为1秒。
YMAL配置:
eureka:
server:
eviction-interval-timer-in-ms: 3000
response-cache-update-interval-ms: 1000
Properties配置:
eureka.server.eviction-interval-timer-in-ms=3000
eureka.server.response-cache-update-interval-ms=1000
主动下线的情况:服务端ReadOnlyCache刷新间隔 + 客户端更新间隔
= 1秒 + 5秒 = 6秒
3.2 Gateway配置
我们使用Spring Cloud Gateway作为网关,它提供了路由功能,可以配置路由规则,将请求转发到不同的服务。为了减低服务重启对前端请求的影响,这里使用Spring Cloud Gateway的配置文件方式配置路由重试规则。让服务请求得到Bad Gateway错误时,自动重试。
spring:
lifecycle:
timeout-per-shutdown-phase: 1m
application:
name: mes-cloud-gateway
cloud:
inetutils:
preferredNetworks:
- 172.16
gateway:
default-filters:
- DedupeResponseHeader=Vary Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST
- name: Retry
args:
retries: 5
statuses: BAD_GATEWAY
methods: GET,PUT,DELETE
backoff:
firstBackoff: 100ms
maxBackoff: 1000ms
factor: 2
basedOnPreviousValue: true
配置解释:如果是GET,PUT,DELETE的请求(只对幂等操作重试,POST重试可能会导致数据重复提交等问题)遇到BAD_GATEWAY错误就会触发重试。通过逐步退避策略减少重试对服务的影响,第一次重试等待100ms,第二次等待200ms,依次内推最多等待1000ms,重试5次如果还不成功,则不再重试。
我们的服务是和docker一起配置,所以默认所有的更新都是正常下线,调整之后服务的更新的状态同步时间最长需要6秒。为了满足高可用,建议服务至少起两个以上的实例。再配合Gateway的重试规则,可以把服务更新对请求的影响降到最低。
4 Docker Compose脚本
我们使用Docker Compose脚本来管理服务,在Portainer里面对应的东西叫Stack,我们可以选择把服务某个业务的的多个服务放在同一个Stask进行统一管理。
- 脚本
version: '3.8'
services:
my-service:
image: registry:5000/my-image:latest
working_dir: /home/app
volumes:
- /data/my-app/log:/home/app/log
- /etc/localtime:/etc/localtime
command: java -Xms128m -Xmx256m -Djava.security.egd=file:/dev/./urandom -Duser.timezone=Asia/Shanghai -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./ -jar my-app.jar
deploy:
replicas: 2
endpoint_mode: dnsrr
restart_policy:
condition: on-failure
update_config:
parallelism: 1
delay: 30s
order: start-first
placement:
constraints: [node.labels.app == true]
environment:
- spring.profiles.active=test
- eureka.client.serviceUrl.defaultZone=http://admin:admin-pass@eureka-master:12000/eureka/,http://admin:admin-pass@eureka-slave:12001/eureka/
- eureka.instance.instanceId=$${spring.cloud.client.ip-address}:$${server.port}
- eureka.instance.hostname=$${spring.cloud.client.ip-address}
- eureka.instance.lease-renewal-interval-in-seconds=5
- eureka.instance.lease-expiration-duration-in-seconds=15
networks:
mes-cloud-net:
stop_signal: SIGTERM
stop_grace_period: 30s
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
以上脚本我们创建一个叫my-service的服务,这个服务有两个副本。下面是一些重要的配置:
- 镜像
指定my容器的镜像为"registry:5000/my-image:latest"
image: registry:5000/my-image:latest
- 挂载日志
把主机的目录/data/my-app/log挂载到容器的/home/app/log目录下,方便查看服务日志。
volumes:
- /data/my-app/log:/home/app/log
- 部署配置
设置服务的部署配置,指定副本数是2,当服务失败的情况下,会自动重启服务。重启服务时,会先启动一个新容器,当容器启动成功后,再把旧容器停止。容器重启的并行度是1,即逐个重启容器,重启服务的间隔是30秒。
deploy:
replicas: 2
endpoint_mode: dnsrr
restart_policy:
condition: on-failure
update_config:
parallelism: 1
delay: 30s
order: start-first
placement:
constraints: [node.labels.app == true]
- 健康探针
设置健康探针,当容器启动成功后,会检查容器是否健康,如果健康则认为容器启动成功。
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/actuator/health"]
interval: 10s
timeout: 5s
retries: 5
- 容器停止策略
当容器停止时,会发送SIGTERM信号给容器,然后等待容器停止,如果容器30秒没有停止,则发送SIGKILL信号给容器。
stop_signal: SIGTERM
stop_grace_period: 30s
5 Jenkins配置
Jenkins是一个持续集成工具,可以自动化构建、测试和部署软件。我们的服务并不是很复杂,所以这里没有使用Jenkins的Pipeline,而是使用Jenkins的传统UI。
5.1 Jenkins自动构建
我们可以设置Jenkins的构建触发器,当有新的代码提交到指定的分支时,就会触发构建。在这里我们是用Maven管理的服务,所以构建命令可以用shell命令执行mvn clean Package
。
5.2 Jenkins自动部署
自动部署脚本我们用python编写,原因是Groovy脚本每次修改都需要审批,而且需要先执行报错才能取审批,太过繁琐。
- 自动部署过程:
- 获取Portainer的Token。
- 打包Dcoker镜像。
- 推送镜像到regitry。
- 重启Docker服务。
获取Portainer的Token
利用Portainer的认证api获取授权Token于后续的api调用。
portainer_auth = {"Username":"portainer-user", "Password":"portainer-pw"}
def get_portainer_token():
response = requests.post(
f"{portainer_url}/api/auth",
headers={"Content-Type": "application/json"},
data=json.dumps(portainer_auth),
verify=False # -k from curl translates to verify=False in requests
)
if response.status_code == 200:
return response.json().get("jwt")
else:
print_message(f"Auth failed:{response.status_code}" )
return None
Docker镜像编译和推送
我们利用Portainer提供的镜像编译功能,通过调用api完成镜像的编译和推送。
这里是一个Dockerfile示例
FROM registry:5000/openjdk:11.0.13-jdk-oraclelinux8
LABEL version=20240730063001
RUN mkdir -p /home/app/
RUN curl https://my-web-server/statics/jar/my-app.jar -k -o /home/app/my-app.jar
编译镜像
def build_docker_image(token):
version = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
payload = {
"content": f"FROM {parent_image}\nLABEL version={version}\nRUN mkdir -p /home/app/\nRUN curl {file_server_url}/{jar_file} -k -o /home/app/{jar_file}"
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-PortainerAgent-Target": build_docker_target_host
}
response = requests.post(
f"{portainer_url}/api/endpoints/{endpoint}/docker/build?t={image_name}",
headers=headers,
data=json.dumps(payload),
verify=False
)
print_message(f"Building image: {payload}")
if response.status_code >= 200 and response.status_code < 300:
print_message(f"Built image: {response.status_code}")
else:
print_message(f"Build image failed: {response.status_code}")
exit(1)
推送镜像到registry
def push_docker_image(token):
payload = {"imageName": image_name}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-PortainerAgent-Target": build_docker_target_host
}
response = requests.post(
f"{portainer_url}/api/endpoints/{endpoint}/docker/images/{image_name}/push",
headers=headers,
data=json.dumps(payload),
verify=False
)
print_message(f"Pushing image: {payload}")
if response.status_code >= 200 and response.status_code < 300:
print_message(f"Pushed image: {response.status_code}")
else:
print_message(f"Push image failed: {response.status_code}")
exit(1)
重启Docker服务
没有查到官方提供直接重启Service的api,这里的重启思路是通过研究网页网络请求得到。其中如何设置更新时拉取最新镜像并强制更新根本无法通过网络请求看出来如何设置,我们是通过查看github上的源码得到。
主要步骤:
- 获取服务信息
调用apidocker/services?filters={{\"name\":[\"{docker_service_name}\"]}}
,通过服务名称获取服务信息。 - 设置更新时拉取最新镜像并强制更新
提取服务信息中的Spec
就是我们目标Service的配置,后面的更新Service api需要这份完整的配置。
- 获取Service的id和Version,后面调用更新Service api需要用到
- 获取ForceUpdate,强制更新需要设置配置中的ForceUpdate+1
- 设置拉取最新镜像,先获取当前镜像名称,如果镜像带有哈希字符串,说明已经有更新的镜像。这里我们需要并替换掉sha256部分。
- 更新服务
调用apidocker/services/{serviceId}/update?version={version}
,传递第2步的配置更Service。
更新服务的python脚本:
def restart_service(token):
response = requests.get(
f"{portainer_url}/api/endpoints/{endpoint}/docker/services?filters={{\"name\":[\"{docker_service_name}\"]}}",
headers={"Authorization": f"Bearer {token}"},
verify=False # --location and -g options are not applicable in requests, so we remove them
)
serviceList = json.loads(response.text)
if len(serviceList) == 0:
print_message("Service not found")
exit(1)
serviceInfo = serviceList[0]
spec=serviceInfo["Spec"]
serviceId=serviceInfo["ID"]
image=serviceInfo["Spec"]["TaskTemplate"]["ContainerSpec"]["Image"]
forceUpdate=serviceInfo["Spec"]["TaskTemplate"]["ForceUpdate"]
updatedImage = re.sub(r"@sha256:.+", "", image)
spec["TaskTemplate"]["ContainerSpec"]["Image"] = updatedImage
spec["TaskTemplate"]["ForceUpdate"] = forceUpdate+1
version = serviceInfo["Version"]["Index"]
print_message(f"updating service: {serviceId} ===>\n{spec}")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-PortainerAgent-Target": build_docker_target_host
}
response = requests.post(
f"{portainer_url}/api/endpoints/{endpoint}/docker/services/{serviceId}/update?version={version}",
headers=headers,
data=json.dumps(spec),
verify=False
)
if response.status_code >= 200 and response.status_code < 300:
print_message(f"updated service: {response.status_code}")
else:
print_message(f"update service failed: {response.status_code}")
exit(1)
- 完整的脚本
python -c '
import requests
import json
import datetime
import re
docker_service_name = "app_my-service"
build_docker_target_host = "cluster4"
image_name = "registry:5000/my-app:latest"
jar_file = "my-app.jar"
file_server_url = "https://my-web-server/statics/jar"
parent_image = "registry:5000/openjdk:11.0.13-jdk-oraclelinux8"
portainer_url = "http://my-web-server:9900"
endpoint = 2
portainer_auth = {"Username":"portainer-user", "Password":"portainer-pw"}
def print_message(message=""):
current_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"{current_time}: {message}")
def get_portainer_token():
response = requests.post(
f"{portainer_url}/api/auth",
headers={"Content-Type": "application/json"},
data=json.dumps(portainer_auth),
verify=False # -k from curl translates to verify=False in requests
)
if response.status_code == 200:
return response.json().get("jwt")
else:
print_message(f"Auth failed:{response.status_code}" )
return None
# Function to build Docker image
def build_docker_image(token):
version = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
payload = {
"content": f"FROM {parent_image}\nLABEL version={version}\nRUN mkdir -p /home/app/\nRUN curl {file_server_url}/{jar_file} -k -o /home/app/{jar_file}"
}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-PortainerAgent-Target": build_docker_target_host
}
response = requests.post(
f"{portainer_url}/api/endpoints/{endpoint}/docker/build?t={image_name}",
headers=headers,
data=json.dumps(payload),
verify=False
)
print_message(f"Building image: {payload}")
if response.status_code >= 200 and response.status_code < 300:
print_message(f"Built image: {response.status_code}")
else:
print_message(f"Build image failed: {response.status_code}")
exit(1)
# Function to push Docker image
def push_docker_image(token):
payload = {"imageName": image_name}
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-PortainerAgent-Target": build_docker_target_host
}
response = requests.post(
f"{portainer_url}/api/endpoints/{endpoint}/docker/images/{image_name}/push",
headers=headers,
data=json.dumps(payload),
verify=False
)
print_message(f"Pushing image: {payload}")
if response.status_code >= 200 and response.status_code < 300:
print_message(f"Pushed image: {response.status_code}")
else:
print_message(f"Push image failed: {response.status_code}")
exit(1)
# Function to restart service
def restart_service(token):
response = requests.get(
f"{portainer_url}/api/endpoints/{endpoint}/docker/services?filters={{\"name\":[\"{docker_service_name}\"]}}",
headers={"Authorization": f"Bearer {token}"},
verify=False # --location and -g options are not applicable in requests, so we remove them
)
serviceList = json.loads(response.text)
if len(serviceList) == 0:
print_message("Service not found")
exit(1)
serviceInfo = serviceList[0]
spec=serviceInfo["Spec"]
serviceId=serviceInfo["ID"]
image=serviceInfo["Spec"]["TaskTemplate"]["ContainerSpec"]["Image"]
forceUpdate=serviceInfo["Spec"]["TaskTemplate"]["ForceUpdate"]
updatedImage = re.sub(r"@sha256:.+", "", image)
spec["TaskTemplate"]["ContainerSpec"]["Image"] = updatedImage
spec["TaskTemplate"]["ForceUpdate"] = forceUpdate+1
version = serviceInfo["Version"]["Index"]
print_message(f"updating service: {serviceId} ===>\n{spec}")
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"X-PortainerAgent-Target": build_docker_target_host
}
response = requests.post(
f"{portainer_url}/api/endpoints/{endpoint}/docker/services/{serviceId}/update?version={version}",
headers=headers,
data=json.dumps(spec),
verify=False
)
if response.status_code >= 200 and response.status_code < 300:
print_message(f"updated service: {response.status_code}")
else:
print_message(f"update service failed: {response.status_code}")
exit(1)
# Main logic
def main():
token = get_portainer_token()
if token:
build_docker_image(token)
push_docker_image(token)
restart_service(token)
else:
print_message("Failed to get token")
exit(1)
# Run the main function
if __name__ == "__main__":
main()
'