我们的变更失败率(Change Failure Rate, CFR)一度高达23%。这意味着每四次生产部署,就有一次会触发P0或P1级别的告警。在Scrum的回顾会议上,气氛总是很沉重,话题从技术债转向了发布流程,最终往往演变成毫无结果的讨论。团队的速度和士气都受到了严重影响。问题不在于开发人员的能力,而在于我们从代码合并到生产流量完全接管之间的过程,充满了手动的、易错的步骤和祈祷式的发布。
在一次关键的Sprint规划会议上,我们决定将下一个迭代周期的核心工程目标定为:将CFR降至5%以下。我们达成共识,根本原因有两个:第一,缺乏自动化的、一致的基础设施部署与变更能力;第二,全量发布模式风险太高,一旦新版本有问题,影响范围就是100%的用户。
为了解决这两个问题,我们选择了一套技术栈:使用Ansible来标准化和自动化我们的Kubernetes环境与应用部署,并引入Linkerd服务网格来实现精细化的金丝雀发布。
第一阶段:用Ansible固化基础设施
在真实项目中,混乱往往始于环境的不一致。开发环境的Kubernetes版本、网络插件配置、甚至内核参数都与生产环境有细微差别。这些差别是滋生“在我机器上是好的”这类问题的温床。我们决定用Ansible来终结这种混乱。Ansible的幂等性是我们选择它的关键原因,无论执行多少次,它都能确保系统收敛到期望的状态。
我们的第一步是创建一个Ansible Playbook,用于从零开始构建一个生产就绪的Kubernetes集群(我们使用的是K3s,因为它更轻量)。这不仅仅是执行curl | sh
。
# playbook-k8s-bootstrap.yml
- hosts: k8s_cluster
become: yes
gather_facts: yes
vars:
k3s_version: "v1.25.9+k3s1"
# 定义控制平面节点的IP,第一个将是主节点
controller_node_ip: "{{ hostvars[groups['k8s_masters'][0]]['ansible_host'] }}"
roles:
- role: common/kernel_tuning
tags: ['system-prep']
- role: k8s/prerequisites
tags: ['system-prep']
- hosts: k8s_masters
become: yes
roles:
- role: k8s/control_plane
vars:
# 主节点初始化时需要一个token供worker节点加入
k3s_cluster_token: "{{ hostvars[groups['k8s_masters'][0]].k3s_token }}"
is_first_master: "{{ inventory_hostname == groups['k8s_masters'][0] }}"
tags: ['k8s-install']
- hosts: k8s_workers
become: yes
roles:
- role: k8s/worker_node
vars:
k3s_cluster_token: "{{ hostvars[groups['k8s_masters'][0]].k3s_token }}"
tags: ['k8s-install']
这个Playbook的核心不在于安装本身,而在于roles
的拆分。common/kernel_tuning
这个role尤其重要,它负责统一所有节点的系统配置。
# roles/common/kernel_tuning/tasks/main.yml
# 生产环境中,内核参数的统一至关重要,特别是网络和内存相关的。
# Ansible的sysctl模块保证了这些设置的幂等性。
- name: Apply kernel parameters for Kubernetes
sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
state: present
reload: yes
loop:
- { name: 'net.bridge.bridge-nf-call-iptables', value: '1' }
- { name: 'net.ipv4.ip_forward', value: '1' }
- { name: 'fs.file-max', value: '1000000' }
- { name: 'vm.max_map_count', value: '262144' }
notify: Restart containerd
- name: Ensure required kernel modules are loaded
modprobe:
name: "{{ item }}"
state: present
loop:
- br_netfilter
- overlay
通过这套Playbook,我们可以在几分钟内拉起一个配置完全一致的集群。下一步就是将Linkerd的安装也纳入Ansible的管理。一个常见的错误是直接在CI/CD脚本里调用linkerd install | kubectl apply -f -
。这种方式不可重复,也无法进行版本控制。我们将其转化为一个Ansible Role。
# roles/linkerd/install/tasks/main.yml
- name: Check if Linkerd is already installed
command: "linkerd check --pre"
register: linkerd_pre_check
changed_when: false
failed_when: false # 我们不希望命令失败导致playbook中断
- name: Download Linkerd CLI if not present
get_url:
url: "https://github.com/linkerd/linkerd2/releases/download/stable-{{ linkerd_version }}/linkerd2-cli-stable-{{ linkerd_version }}-linux-amd64"
dest: "/usr/local/bin/linkerd"
mode: '0755'
when: linkerd_pre_check.rc != 0
- name: Generate Linkerd control plane manifest
command: >
/usr/local/bin/linkerd install
--set proxy.resources.cpu.limit=200m
--set proxy.resources.memory.limit=250Mi
--set controllerLogLevel=info
register: linkerd_manifest
changed_when: false
when: linkerd_pre_check.rc != 0
- name: Apply Linkerd control plane manifest
# 使用k8s模块而不是shell模块来应用manifest,更安全,更原生
kubernetes.core.k8s:
state: present
src: "{{ linkerd_manifest.stdout }}"
when: linkerd_pre_check.rc != 0
- name: Wait for Linkerd control plane to be ready
command: "linkerd check"
register: linkerd_post_check
until: linkerd_post_check.rc == 0
retries: 10
delay: 30
changed_when: false
when: linkerd_pre_check.rc != 0
这个Role的设计考虑了幂等性:它首先检查Linkerd是否存在,只有在不存在时才执行安装。并且,我们不是简单地应用配置,而是显式地配置了代理的资源限制,这是生产环境中必须考虑的细节,以防sidecar耗尽节点资源。
第二阶段:构建自动化的金丝雀发布流程
基础设施标准化之后,我们开始改造发布流程。核心是利用Linkerd的TrafficSplit
CRD。它允许我们用声明式的方式,将流向一个Kubernetes Service的流量按百分比分配到后端的多个Deployment。
我们的目标是创建一个通用的Ansible Role,它可以为任何服务执行金丝g雀发布。这个Role需要接收以下参数:应用名、命名空间、稳定版镜像、金丝雀版镜像以及初始流量权重。
这是流程的可视化:
graph TD subgraph CI/CD Pipeline A[Developer Merges Code] --> B{Trigger Pipeline}; B --> C[Build & Push new_image:canary]; B --> D[Run Ansible Playbook: canary_deploy]; end subgraph "Ansible Host" D --> E{Ansible Role: `deploy_canary`}; E --> F[Generate Canary Deployment Manifest]; E --> G[Generate TrafficSplit Manifest]; E --> H[Apply Manifests to K8s API]; end subgraph "Kubernetes Cluster" H --> I[Creates `my-app-canary` Deployment]; H --> J[Creates/Updates `my-app-trafficsplit`]; J -- routes traffic --> K[Service: `my-app`]; K -- 90% --> L[Deployment: `my-app-stable`]; K -- 10% --> I; end
这个Ansible Role deploy_canary
的实现是整个方案的灵魂。
# roles/deploy_canary/tasks/main.yml
# 变量: app_name, namespace, stable_image, canary_image, initial_weight
- name: "Step 1: Get details of the stable deployment"
kubernetes.core.k8s_info:
api_version: apps/v1
kind: Deployment
name: "{{ app_name }}-stable"
namespace: "{{ namespace }}"
register: stable_deployment_info
- name: "Fail if stable deployment does not exist"
fail:
msg: "Stable deployment '{{ app_name }}-stable' not found in namespace '{{ namespace }}'."
when: stable_deployment_info.resources | length == 0
- name: "Step 2: Create canary deployment manifest from stable"
# 我们不从头写canary的yaml,而是基于stable的定义来修改。
# 这样可以确保除了镜像版本外,所有配置(资源、环境变量等)都一致。
set_fact:
canary_deployment_manifest: "{{ stable_deployment_info.resources[0] | combine({
'metadata': {
'name': app_name + '-canary',
'labels': stable_deployment_info.resources[0].metadata.labels | combine({'version': 'canary'})
},
'spec': {
'replicas': 1, # 金丝雀通常从一个副本开始
'selector': {
'matchLabels': stable_deployment_info.resources[0].spec.selector.matchLabels | combine({'version': 'canary'})
},
'template': {
'metadata': {
'labels': stable_deployment_info.resources[0].spec.template.metadata.labels | combine({'version': 'canary'})
},
'spec': stable_deployment_info.resources[0].spec.template.spec | combine({
'containers': [
stable_deployment_info.resources[0].spec.template.spec.containers[0] | combine({'image': canary_image})
]
})
}
}
}, recursive=True) }}"
- name: "Step 3: Deploy the canary version"
kubernetes.core.k8s:
state: present
definition: "{{ canary_deployment_manifest }}"
- name: "Step 4: Create or update the TrafficSplit"
kubernetes.core.k8s:
state: present
definition:
apiVersion: split.smi-spec.io/v1alpha2
kind: TrafficSplit
metadata:
name: "{{ app_name }}-trafficsplit"
namespace: "{{ namespace }}"
spec:
service: "{{ app_name }}" # 这是根服务,指向stable
backends:
- service: "{{ app_name }}-stable"
weight: "{{ 100 - initial_weight }}"
- service: "{{ app_name }}-canary"
weight: "{{ initial_weight }}"
# 还需要为canary和stable创建对应的Service
- name: "Ensure stable service exists"
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Service
metadata:
name: "{{ app_name }}-stable"
namespace: "{{ namespace }}"
spec:
selector:
app: "{{ app_name }}"
version: stable
ports:
- port: 80
targetPort: 8080
- name: "Ensure canary service exists"
kubernetes.core.k8s:
state: present
definition:
apiVersion: v1
kind: Service
metadata:
name: "{{ app_name }}-canary"
namespace: "{{ namespace }}"
spec:
selector:
app: "{{ app_name }}"
version: canary
ports:
- port: 80
targetPort: 8080
这段代码有几个关键设计:
- 基于现有配置生成:我们不为金丝雀版本硬编码Deployment YAML。而是通过
k8s_info
获取线上稳定版的配置,仅修改name
、image
和labels
。这避免了因配置漂移导致金丝雀测试失效。 - 原子化操作:使用
kubernetes.core.k8s
模块,Ansible可以直接与K8s API交互,比shell: kubectl apply
更可靠,支持check mode,也返回更结构化的结果。 - 服务分离:
TrafficSplit
需要将流量路由到不同的后端服务。因此,我们必须确保存在my-app-stable
和my-app-canary
两个Service,它们分别通过selector
指向不同版本的Pods。而用户流量入口,即根服务my-app
,则不需要selector
,因为它的流量完全由TrafficSplit
接管。
第三阶段:基于指标的自动晋升与回滚
手动调整流量权重仍然是风险点。我们必须实现自动化决策:如果金丝雀版本是健康的,就自动增加流量;如果出现问题,就立即自动回滚。
“健康”的定义不能是简单的Pod存活,而必须是业务指标。Linkerd的优势在于它能开箱即用地为每个服务提供黄金指标:请求成功率、RPS和延迟。这些指标会暴露给Prometheus。
我们编写了一个Ansible Playbook,它会编排整个发布过程,包括调用一个Python脚本来查询Prometheus。
# playbook-promote-canary.yml
- hosts: localhost
connection: local
gather_facts: no
vars:
app_name: "my-critical-service"
namespace: "production"
canary_image: "my-registry/my-app:v1.2.1"
prometheus_url: "http://prometheus.internal:9090"
tasks:
- name: "Phase 1: Start canary with 10% traffic"
include_role:
name: deploy_canary
vars:
initial_weight: 10
# 其他变量继承自playbook vars
- name: "Pause for 5 minutes to collect metrics"
pause:
minutes: 5
- name: "Phase 2: Check canary health via Prometheus"
script: "scripts/check_canary_health.py --url {{ prometheus_url }} --service {{ app_name }}-canary.{{ namespace }}.svc.cluster.local"
register: health_check_result
- name: "ROLLBACK: Canary is unhealthy"
when: health_check_result.stdout | from_json | json_query('is_healthy') == false
block:
- name: "Set canary weight to 0"
kubernetes.core.k8s:
state: present
definition: # ... TrafficSplit YAML with weight 0 for canary
- name: "Delete canary deployment"
kubernetes.core.k8s:
state: absent
kind: Deployment
name: "{{ app_name }}-canary"
namespace: "{{ namespace }}"
- name: "Fail the pipeline"
fail:
msg: "Canary deployment failed health checks. Rolled back."
- name: "PROMOTE: Canary is healthy, increase traffic to 50%"
when: health_check_result.stdout | from_json | json_query('is_healthy') == true
kubernetes.core.k8s:
state: present
definition: # ... TrafficSplit YAML with weight 50 for canary
# ... 此处可以添加更多阶段,比如等待后检查,然后提升至100% ...
- name: "Final Phase: Promote canary to stable"
# 这个阶段会将stable deployment的镜像更新为canary_image,
# 然后将TrafficSplit的流量100%指向stable,最后清理canary资源。
# ... 逻辑略 ...
这里的核心是check_canary_health.py
脚本。在真实项目中,这个脚本应该足够健壮。
# scripts/check_canary_health.py
import argparse
import json
import sys
import requests
# Linkerd 暴露的成功率指标
# 方向 'to' 表示进入该服务的请求
SUCCESS_RATE_QUERY = 'sum(rate(response_total{direction="to", dst_service="%s", classification="success"}[1m])) / sum(rate(response_total{direction="to", dst_service="%s"}[1m]))'
# P95 延迟指标
LATENCY_QUERY = 'histogram_quantile(0.95, sum(rate(response_latency_ms_bucket{direction="to", dst_service="%s"}[1m])) by (le))'
# 最小RPS阈值,避免在流量过低时做出误判
MIN_RPS_THRESHOLD = 5
def query_prometheus(prometheus_url, query):
try:
response = requests.get(f"{prometheus_url}/api/v1/query", params={'query': query})
response.raise_for_status()
result = response.json()['data']['result']
if not result:
return None
return float(result[0]['value'][1])
except (requests.exceptions.RequestException, KeyError, IndexError) as e:
print(f"Error querying Prometheus: {e}", file=sys.stderr)
return None
def main():
parser = argparse.ArgumentParser(description="Check canary service health from Prometheus.")
parser.add_argument("--url", required=True, help="Prometheus API URL")
parser.add_argument("--service", required=True, help="Full service name (e.g., myservice.myns.svc.cluster.local)")
parser.add_argument("--success-threshold", type=float, default=0.995, help="Minimum success rate")
parser.add_argument("--latency-threshold-ms", type=float, default=200, help="Maximum P95 latency in ms")
args = parser.parse_args()
# 查询成功率
success_rate = query_prometheus(args.url, SUCCESS_RATE_QUERY % (args.service, args.service))
# 查询P95延迟
p95_latency = query_prometheus(args.url, LATENCY_QUERY % (args.service))
is_healthy = True
reasons = []
if success_rate is None or p95_latency is None:
is_healthy = False
reasons.append("Could not retrieve metrics from Prometheus.")
else:
if success_rate < args.success_threshold:
is_healthy = False
reasons.append(f"Success rate {success_rate:.4f} is below threshold {args.success_threshold}")
if p95_latency > args.latency_threshold_ms:
is_healthy = False
reasons.append(f"P95 latency {p95_latency:.2f}ms is above threshold {args.latency_threshold_ms}ms")
# 返回JSON,方便Ansible解析
print(json.dumps({
"is_healthy": is_healthy,
"success_rate": success_rate,
"p95_latency": p95_latency,
"reasons": reasons
}))
if not is_healthy:
sys.exit(1)
if __name__ == "__main__":
main()
这个脚本的价值在于它将“健康”这个模糊的概念量化为两个具体的SLO(服务等级目标):成功率 > 99.5% 和 P95延迟 < 200ms。如果任何一个指标不达标,脚本会以非零状态码退出,触发Ansible的失败处理逻辑,实现自动回滚。
最终成果
经过两个Sprint的迭代,我们用这套Ansible+Linkerd的自动化发布系统替换了旧的手动流程。效果是立竿见影的。我们的变更失败率从23%降到了3%以下。失败的部署不再能进入生产环境影响用户,它们在金丝雀阶段就被自动化的健康检查捕获并回滚。开发团队的信心也回来了,他们可以更频繁地发布更小的变更,这反过来又进一步降低了单次变更的风险。部署频率(Deployment Frequency)这个DORA指标也因此提升了近3倍。
局限与下一步
当前这套系统虽然有效,但并非完美。
首先,它仍然是基于Ansible的命令式编排,尽管Role是声明式的。对于非常复杂的发布策略(比如基于用户Header的路由),我们可能需要一个更云原生的控制器。像Flagger或Argo Rollouts这样的工具,它们以Operator的形式运行在集群内部,直接监听Deployment资源的变化并与服务网格交互,这会是一种更优雅的实现方式。我们正在调研将其作为下一步的演进方向。
其次,我们的健康检查目前只依赖技术指标(成功率、延迟)。一个更成熟的系统应该能结合业务指标。例如,对于一个电商服务,金丝雀版本的“加入购物车”转化率是否与稳定版持平?这需要将业务监控数据也接入到我们的决策脚本中,是一个更复杂的挑战。
最后,随着服务数量的增多,维护所有这些Ansible配置和脚本本身也带来了管理成本。平台工程的理念提醒我们,应该将这些能力封装成一个对开发者更友好的内部平台或CLI工具,让他们能通过一个简单的命令(如 platform deploy my-app --version v1.2.1 --canary
)来触发整个流程,而不是直接与Ansible Playbook打交道。