在任何一个成熟的微服务体系中,单纯实现业务逻辑并打包部署只是万里长征的第一步。真正的挑战来自于服务间通信的安全性、部署过程的可靠性以及流量管理的灵活性。我们团队最近就面临这样一个典型的场景:数十个Spring Boot微服务运行在虚拟机上,服务间通过原始的HTTP调用,安全全靠网络ACL,每次上线都伴随着祈祷,回滚则是一场手忙脚乱的脚本执行大会。
我们的目标很明确:实现服务间通信的零信任安全(mTLS),并引入自动化的金丝雀发布流程来降低上线风险。技术选型上,我们已经在使用GitLab作为代码仓库和CI/CD工具,服务发现则依赖于Consul。因此,将Consul强大的服务网格能力——Consul Connect——集成到现有的GitLab CI/CD流程中,成为了最自然的选择。这篇日志记录了我们从零开始,将一个标准的Spring Boot应用接入Consul Connect,并通过GitLab CI流水线实现自动化sidecar注入和金丝雀发布的完整过程,其中不乏我们踩过的坑和做出的权衡。
起点:两个标准的Spring Boot微服务
故事的起点是两个微服务:api-gateway
和 product-service
。api-gateway
负责对外暴露接口,内部调用product-service
获取数据。在改造前,它们的关系简单直白。
product-service
提供一个简单的REST接口:
// product-service/src/main/java/com/example/product/ProductController.java
package com.example.product;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.HashMap;
@RestController
public class ProductController {
private static final Logger logger = LoggerFactory.getLogger(ProductController.class);
@Value("${server.port}")
private int serverPort;
// 通过环境变量获取应用版本,用于金丝雀发布验证
@Value("${APP_VERSION:v1.0.0}")
private String appVersion;
@GetMapping("/products/{id}")
public Map<String, Object> getProduct(@PathVariable String id) {
logger.info("Fetching product {} on port {} - version {}", id, serverPort, appVersion);
Map<String, Object> product = new HashMap<>();
product.put("id", id);
product.put("name", "Awesome Gadget");
product.put("version", appVersion);
product.put("port", serverPort);
return product;
}
}
api-gateway
使用RestTemplate
调用product-service
:
// api-gateway/src/main/java/com/example/gateway/GatewayController.java
package com.example.gateway;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
@RestController
public class GatewayController {
private static final Logger logger = LoggerFactory.getLogger(GatewayController.class);
@Autowired
private RestTemplate restTemplate;
// "http://product-service/..." 是通过Spring Cloud Consul + Ribbon/LoadBalancer实现的客户端负载均衡
private final String productServiceUrl = "http://product-service/products/";
@GetMapping("/gateway/products/{id}")
public Object getProductFromService(@PathVariable String id) {
logger.info("Forwarding request for product id {}", id);
try {
return restTemplate.getForObject(productServiceUrl + id, Object.class);
} catch (Exception e) {
logger.error("Error calling product-service", e);
return Map.of("error", "product-service is unavailable", "message", e.getMessage());
}
}
}
两个服务都集成了Spring Cloud Consul,通过application.yml
配置自动向Consul注册。
product-service
的application.yml
:
spring:
application:
name: product-service
cloud:
consul:
host: consul-server # 假设Consul Server地址可通过DNS解析
port: 8500
discovery:
instance-id: ${spring.application.name}:${vcap.application.instance_id:${spring.cloud.client.ip-address}}:${server.port}
health-check-path: /actuator/health
health-check-interval: 15s
tags: version=${APP_VERSION:v1.0.0} # 将版本信息注册为tag
server:
port: 8081
这是我们的基线。服务可以注册、发现并互相调用,但所有流量都是明文HTTP,没有任何访问控制。
第一步:手动集成Consul Connect Sidecar
在自动化之前,必须先手动走通流程。Consul Connect通过在每个服务实例旁部署一个轻量级Sidecar代理来实现服务网格。服务间的所有流量都通过各自的Sidecar代理进行,代理之间建立mTLS连接,并根据Consul中的“Intention”(意图)来决定是否放行流量。
首先,我们需要为product-service
定义一个服务注册文件 product-service.json
,声明它使用Connect:
{
"service": {
"name": "product-service",
"id": "product-service-1",
"port": 8081,
"tags": ["v1.0.0"],
"connect": {
"sidecar_service": {}
},
"check": {
"http": "http://localhost:8081/actuator/health",
"interval": "10s"
}
}
}
然后,为api-gateway
定义类似的服务文件,并声明它需要调用product-service
。这被称为“Upstream”:
{
"service": {
"name": "api-gateway",
"id": "api-gateway-1",
"port": 8080,
"connect": {
"sidecar_service": {
"proxy": {
"upstreams": [
{
"destination_name": "product-service",
"local_bind_port": 9091 // sidecar会在本地9091端口监听,并将流量转发到product-service
}
]
}
}
},
"check": {
"http": "http://localhost:8080/actuator/health",
"interval": "10s"
}
}
}
有了这些定义,我们就可以启动服务和它们的Sidecar了。
向Consul注册服务:
consul services register product-service.json consul services register api-gateway.json
启动
product-service
的应用和它的Sidecar:# 启动Spring Boot应用 java -jar product-service.jar --server.port=8081 # 在另一个终端启动Sidecar consul connect proxy -sidecar-for product-service-1
启动
api-gateway
的应用和Sidecar:# 修改api-gateway的RestTemplate配置,让它调用本地的Sidecar代理 # 原来是 "http://product-service/...", 现在改为 "http://localhost:9091/..." # 这是一个关键变更,服务不再直接发现和调用下游,而是固定调用本地端口 java -jar api-gateway.jar --product.service.url=http://localhost:9091 # 启动Sidecar consul connect proxy -sidecar-for api-gateway-1
此时,如果你尝试从api-gateway
调用product-service
,请求会失败。这是因为Consul Connect的默认策略是“deny all”。我们需要创建一个Intention来允许api-gateway
调用product-service
:
consul intention create -allow api-gateway product-service
创建Intention后,调用立刻就能成功。至此,我们手动验证了mTLS和基于Intention的访问控制是有效的。但这个过程太繁琐了,而且要求修改应用代码来改变调用地址,这在真实项目中是不可接受的。Spring Cloud Consul已经帮我们做了服务发现,我们不想丢掉这个能力。
这里的坑在于,我们必须让Spring Boot应用透明地使用Sidecar。解决方案是,在api-gateway
的application.yml
中,通过consul.discovery.tags
将upstream信息注入,Spring Cloud Consul会自动解析并配置Ribbon/LoadBalancer将product-service
的地址解析为localhost:9091
。这需要对Spring Cloud Consul有较深的理解。但为了简化CI流程,我们决定采用一种更通用的方法:容器化。
第二步:容器化与Sidecar注入的挑战
将应用和Sidecar打包到同一个容器镜像是实现自动化的关键。一个标准的Docker容器默认只运行一个主进程(CMD/ENTRYPOINT)。我们需要同时运行Java应用和consul connect proxy
进程。
一个常见的错误是使用&
在启动脚本中后台运行一个进程,但这会导致容器主进程退出后,其他进程成为孤儿,容器随之停止。在生产环境中,我们需要一个进程管理器。supervisord
是一个轻量级的选择。
我们的Dockerfile
结构如下:
FROM openjdk:11-jre-slim
# 安装Consul和supervisor
ARG CONSUL_VERSION=1.13.1
ADD https://releases.hashicorp.com/consul/${CONSUL_VERSION}/consul_${CONSUL_VERSION}_linux_amd64.zip /tmp/
RUN apt-get update && apt-get install -y unzip supervisor && \
unzip /tmp/consul.zip -d /usr/local/bin/ && \
rm /tmp/consul.zip && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# 拷贝应用和配置文件
WORKDIR /app
COPY target/product-service-*.jar app.jar
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
EXPOSE 8081
# entrypoint.sh会生成Consul服务定义文件,然后启动supervisord
ENTRYPOINT ["/app/entrypoint.sh"]
supervisord.conf
负责管理两个进程:
[supervisord]
nodaemon=true
[program:app]
command=java -jar /app/app.jar
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:sidecar]
# 注意这里的-sidecar-for参数,它的值需要动态生成
command=consul connect proxy -sidecar-for %(ENV_CONSUL_SERVICE_ID)s -log-level=info
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
entrypoint.sh
脚本是粘合剂。它在容器启动时,根据环境变量动态生成Consul的服务注册JSON文件,然后注册服务,最后启动supervisord
。
#!/bin/sh
set -e
# 从环境变量中读取配置
# CI流水线会传入这些变量
CONSUL_SERVICE_ID="${SERVICE_NAME}-${CI_PIPELINE_ID}-${CI_JOB_ID}"
export CONSUL_SERVICE_ID
# 动态生成服务定义文件
# 注意:在真实项目中,这个模板会更复杂,包含对upstreams的定义
cat > /app/service-definition.json <<EOF
{
"service": {
"name": "${SERVICE_NAME}",
"id": "${CONSUL_SERVICE_ID}",
"port": ${SERVICE_PORT},
"tags": ["${APP_VERSION}"],
"connect": {
"sidecar_service": {}
},
"check": {
"http": "http://localhost:${SERVICE_PORT}/actuator/health",
"interval": "10s",
"deregister_critical_service_after": "1m"
}
}
}
EOF
echo "Generated service definition:"
cat /app/service-definition.json
# 向Consul Agent注册服务
# 容器需要能访问到节点上的Consul Agent
consul services register /app/service-definition.json
# 启动supervisord来管理应用和sidecar
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
这个Dockerfile
和辅助脚本构成了一个自包含的、可接入Consul Connect的微服务单元。CI/CD流水线只需要构建这个镜像,并在运行时提供正确的环境变量即可。
第三步:构建GitLab CI/CD流水线
现在,我们将整个流程串接到.gitlab-ci.yml
中。流水线分为几个阶段:build
, package
, deploy_canary
, promote_production
, cleanup_canary
。
variables:
SERVICE_NAME: "product-service"
SERVICE_PORT: 8081
DOCKER_IMAGE: $CI_REGISTRY_IMAGE/$SERVICE_NAME
# Consul地址应该配置在GitLab CI/CD的变量中
# CONSUL_HTTP_ADDR: http://consul-agent:8500
stages:
- build
- package
- deploy_canary
- promote_production
- cleanup_canary
maven_build:
stage: build
image: maven:3.8-openjdk-11
script:
- mvn package -DskipTests
artifacts:
paths:
- target/*.jar
docker_package:
stage: package
image: docker:20.10
services:
- docker:20.10-dind
script:
- export APP_VERSION="v1.1.0-${CI_COMMIT_SHORT_SHA}" # 金丝雀版本
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build --build-arg APP_VERSION=${APP_VERSION} -t $DOCKER_IMAGE:$APP_VERSION .
- docker push $DOCKER_IMAGE:$APP_VERSION
dependencies:
- maven_build
deploy_canary_job:
stage: deploy_canary
image: curlimages/curl:7.78.0 # 使用一个轻量级镜像执行API调用
script:
- |
# 1. 部署金丝雀实例(这里用docker run模拟,实际环境中可能是调用Ansible/Terraform/Kubernetes API)
# ssh user@deploy-host "docker run -d --name ${SERVICE_NAME}-canary -e SERVICE_NAME=${SERVICE_NAME} ..."
echo "Simulating deployment of canary version ${CI_COMMIT_SHORT_SHA}"
# 2. 配置Consul Service Splitter,将10%流量切到金丝雀版本
# 我们通过tag来区分版本。v1.0.0是稳定版,新版本是v1.1.0-*
cat <<EOF > splitter.json
{
"Kind": "service-splitter",
"Name": "product-service",
"Splits": [
{
"Weight": 90,
"ServiceSubset": "v1.0.0"
},
{
"Weight": 10,
"ServiceSubset": "${CI_COMMIT_TAG:-$CI_COMMIT_SHORT_SHA}"
}
]
}
EOF
echo "Applying service splitter configuration to Consul..."
curl -X PUT --data @splitter.json "${CONSUL_HTTP_ADDR}/v1/config"
echo "10% of traffic for 'product-service' is now directed to the canary version."
dependencies:
- docker_package
promote_to_production:
stage: promote_production
image: curlimages/curl:7.78.0
script:
- |
echo "Promoting canary to 100% production traffic..."
cat <<EOF > splitter.json
{
"Kind": "service-splitter",
"Name": "product-service",
"Splits": [
{
"Weight": 0,
"ServiceSubset": "v1.0.0"
},
{
"Weight": 100,
"ServiceSubset": "${CI_COMMIT_TAG:-$CI_COMMIT_SHORT_SHA}"
}
]
}
EOF
curl -X PUT --data @splitter.json "${CONSUL_HTTP_ADDR}/v1/config"
echo "All traffic is now on the new version."
when: manual # 这是一个手动触发的步骤,等待QA或监控确认金丝雀版本稳定
cleanup_old_version:
stage: cleanup_canary
script:
- echo "Cleaning up old version instances..."
# 模拟下线旧版本实例
# ssh user@deploy-host "docker stop/rm old_containers"
when: manual
这里有几个关键点:
- 动态版本: 我们使用
CI_COMMIT_SHORT_SHA
为每次构建生成一个唯一的版本号,并将其作为Docker镜像的tag和Consul服务的tag。 - Service Splitter: 金丝雀发布的核心是Consul的
service-splitter
配置。它是一个L7流量切分器。我们在deploy_canary
阶段通过Consul的HTTP API写入一个配置,将10%的流量引导到带有新版本tag的服务实例上。 - Service Subset: Splitter通过
ServiceSubset
来识别不同版本的服务实例,而ServiceSubset
的名字默认就是服务的tag。这就是为什么在application.yml
和Dockerfile
中注入版本tag至关重要的原因。 - 手动晋升:
promote_production
阶段被设置为when: manual
。这意味着流水线在部署完金丝雀后会暂停,等待工程师在GitLab界面上点击“play”按钮。这给了我们观察金丝雀实例(通过监控、日志等)的时间。确认无误后,再手动触发全量。
为了让流量切分生效,我们还需要定义service-resolver
,告诉Consul如何根据ServiceSubset
查找服务实例:
// 需要预先配置到Consul中
{
"Kind": "service-resolver",
"Name": "product-service",
"Subsets": {
"v1.0.0": {
"Filter": "Service.Tags contains \"v1.0.0\""
},
// 使用通配符来匹配所有金丝雀版本
"*": {
"Filter": "Service.Tags not contains \"v1.0.0\""
}
}
}
部署流程的Mermaid图如下:
sequenceDiagram participant GitLab CI participant DeployHost participant Consul participant APIGateway participant ProductSvc_v1 participant ProductSvc_v2_Canary GitLab CI->>DeployHost: Deploy product-service:v2 (Canary) DeployHost->>Consul: Register product-service-v2 GitLab CI->>Consul: Apply ServiceSplitter (90% -> v1, 10% -> v2) loop Traffic APIGateway->>Consul: Resolve 'product-service' alt 90% of requests Consul-->>APIGateway: Return address of ProductSvc_v1 APIGateway->>ProductSvc_v1: Request else 10% of requests Consul-->>APIGateway: Return address of ProductSvc_v2_Canary APIGateway->>ProductSvc_v2_Canary: Request end end Note over GitLab CI: Manual Promotion Triggered GitLab CI->>Consul: Apply ServiceSplitter (0% -> v1, 100% -> v2) loop All Traffic APIGateway->>Consul: Resolve 'product-service' Consul-->>APIGateway: Return address of ProductSvc_v2_Canary APIGateway->>ProductSvc_v2_Canary: Request end GitLab CI->>DeployHost: Decommission product-service:v1
局限性与未来迭代路径
这套流程打通了从代码提交到安全、可控的线上部署的自动化闭环,解决了我们最初的痛点。然而,它并非银弹,依然存在一些局限和可以改进的地方。
首先,在非容器编排环境(如纯虚拟机)中使用supervisord
管理Sidecar和应用进程,虽然可行,但增加了容器镜像的复杂性和潜在的故障点。一个更稳健的方案是迁移到Kubernetes或Nomad,利用其原生的Sidecar注入和生命周期管理能力,这会使我们的Dockerfile
和部署脚本大大简化。
其次,直接在CI脚本中通过curl
调用Consul API来管理配置,功能上可以满足,但缺乏版本控制和审计。配置(如service-splitter
, service-resolver
, intention
)本身也应该被视为代码。下一步的演进方向是采用Configuration-as-Code
的模式,将所有Consul配置存储在专门的Git仓库中,通过Terraform或专门的GitOps工具(如ArgoCD配合一个Consul同步器)来管理这些配置的生命周期。
最后,我们的金丝雀晋升决策目前是手动的。一个更高级的自动化流水线应该能集成可观测性数据。例如,在金丝雀发布阶段,流水线可以自动查询Prometheus,分析金丝雀实例的错误率(SLI)。如果SLI在预设的错误预算(Error Budget)内,则自动执行晋升;反之,则自动回滚并告警。这需要一个更强大的部署工具(如Spinnaker或Argo Rollouts)和成熟的监控体系来支持。