在生产环境中,标准的 API 网关插件集合往往无法满足所有定制化的业务需求。例如,我们需要一个与内部配置中心联动的动态金丝雀发布策略,或者一个基于特定业务逻辑的复杂认证流程。直接修改网关源码风险高、维护成本大,而使用 Lua 编写复杂插件又面临着生态、性能和团队技术栈的挑战。Apache APISIX 提供的 Go Plugin Runner 机制,允许我们用 Go 语言来编写高性能的外部插件,这是一个非常务实的选择。
但问题随之而来:如何管理这些 Go 插件的生命周期?一个独立的 Go 程序,需要被编译、打包、分发,并与 APISIX 实例一同部署。任何手动的构建和部署流程都是不可靠且低效的。在真实项目中,我们需要一个完全自动化的 CI/CD 流程来确保插件的质量与交付速度。
本文将复盘一个完整的实践过程:从零开始开发一个基于 Redis 的动态金丝雀发布 Go 插件,并利用 GitHub Actions 构建一个从代码提交到插件部署的全自动化流水线。
架构设计:APISIX 与 Go Plugin Runner 的协同
在我们深入代码之前,必须先理解 APISIX 与 Go Plugin Runner 的交互模型。它们并非一个单一进程,而是通过 Unix Domain Socket 进行通信的两个独立进程。
graph TD subgraph "Client Side" Client[HTTP Client] end subgraph "Gateway Server" Client -- HTTP/S Request --> APISIX[Apache APISIX Core] APISIX -- 1. gRPC over UDS --> GoRunner[Go Plugin Runner] GoRunner -- 2. Process Request with Custom Plugin --> GoRunner GoRunner -- 3. gRPC Response --> APISIX APISIX -- HTTP/S Response --> Upstream[Upstream Service] Upstream -- Response --> APISIX APISIX -- Final Response --> Client end style GoRunner fill:#f9f,stroke:#333,stroke-width:2px
- APISIX 核心 (Nginx + Lua): 接收外部请求,当路由规则匹配到配置了 Go 插件时,它不会在内部执行 Lua 逻辑。
- 序列化与通信: APISIX 会将请求的上下文(Headers, Body, Args 等)通过 gRPC 序列化,并通过 Unix Domain Socket 发送给 Go Plugin Runner 进程。
- Go Plugin Runner: 这是一个独立的 Go 长进程,它加载了我们编写的所有自定义插件。收到请求后,它会根据 APISIX 传递的插件名称和配置,执行相应的 Go 代码。
- 执行与返回: Go 插件代码执行业务逻辑(例如修改请求头、重定向、执行认证等),并将结果(或者错误)返回给 APISIX。
- 后续处理: APISIX 接收到 Go 插件的处理结果后,继续执行后续的 Nginx 阶段和 Lua 插件,最终将请求转发给上游服务。
这种架构的优势在于隔离性。Go 插件的崩溃不会直接导致 APISIX 核心进程宕机,并且我们可以独立地更新和重启 Go Plugin Runner,这为我们的自动化部署提供了基础。
插件实现:一个动态金丝雀发布插件
我们的目标是实现一个名为 dynamic-canary
的插件。它的逻辑如下:
- 从插件配置中获取稳定版(stable)和金丝雀版(canary)的上游服务名称。
- 从 Redis 中读取一个键,例如
canary_release_percentage:service-a
,获取金丝雀版本应分配的流量百分比(0-100)。 - 如果请求头中包含
x-user-group: internal
,则强制将流量路由到金丝雀版本,方便内部测试。 - 对于普通流量,根据从 Redis 获取的百分比,通过随机数决定将流量路由到稳定版还是金丝雀版。
1. 项目初始化与依赖
# 创建项目目录
mkdir apisix-go-plugin-dynamic-canary
cd apisix-go-plugin-dynamic-canary
# 初始化 Go Module
go mod init github.com/your-org/apisix-go-plugin-dynamic-canary
# 添加 APISIX Go Plugin SDK 和 Redis 客户端
go get github.com/apache/apisix-go-plugin-runner
go get github.com/go-redis/redis/v8
2. 插件核心代码 (main.go
)
这是整个插件的核心,代码必须是生产级的,包含错误处理和必要的日志。
// main.go
package main
import (
"context"
"fmt"
"log"
"math/rand"
"strconv"
"time"
"github.com/apache/apisix-go-plugin-runner/pkg/plugin"
"github.com/apache/apisix-go-plugin-runner/pkg/runner"
"github.com/go-redis/redis/v8"
)
// 定义插件的配置结构体,与 APISIX 路由配置中的 JSON 对应
type DynamicCanaryConf struct {
StableUpstream string `json:"stable_upstream"`
CanaryUpstream string `json:"canary_upstream"`
RedisHost string `json:"redis_host"`
RedisPort int `json:"redis_port"`
RedisKey string `json:"redis_key"`
InternalHeader string `json:"internal_header_key"` // e.g., "x-user-group"
InternalValue string `json:"internal_header_value"` // e.g., "internal"
}
// DynamicCanaryPlugin 实现了 APISIX 的插件接口
type DynamicCanaryPlugin struct {
// 每个插件实例可以在这里持有自己的状态
redisClient *redis.Client
}
// 确保我们的插件实现了 plugin.Plugin 接口
var _ plugin.Plugin = &DynamicCanaryPlugin{}
// Name 返回插件的名称,这个名称将在 APISIX 路由配置中使用
func (p *DynamicCanaryPlugin) Name() string {
return "dynamic-canary"
}
// ParseConf 解析用户在 APISIX 路由中为该插件配置的 JSON
// 这个方法在路由配置更新时被调用
func (p *DynamicCanaryPlugin) ParseConf(in []byte) (interface{}, error) {
conf := DynamicCanaryConf{}
if err := plugin.JSON.Unmarshal(in, &conf); err != nil {
return nil, fmt.Errorf("failed to parse configuration: %w", err)
}
// 基础配置校验
if conf.StableUpstream == "" || conf.CanaryUpstream == "" || conf.RedisKey == "" {
return nil, fmt.Errorf("stable_upstream, canary_upstream, and redis_key are required")
}
if conf.RedisHost == "" {
conf.RedisHost = "127.0.0.1"
}
if conf.RedisPort == 0 {
conf.RedisPort = 6379
}
if conf.InternalHeader == "" {
conf.InternalHeader = "x-user-group"
}
if conf.InternalValue == "" {
conf.InternalValue = "internal"
}
// 初始化 Redis 客户端。在真实项目中,这里应该使用连接池和更健壮的配置。
// 注意:这里的初始化是每个路由规则更新时都会执行,对于 Redis 连接这种重资源,
// 更好的做法是在插件级别共享一个客户端。这里为简化示例,每次都创建。
p.redisClient = redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", conf.RedisHost, conf.RedisPort),
})
return conf, nil
}
// Filter 是插件的核心逻辑,在请求的 `rewrite` 阶段执行
func (p *DynamicCanaryPlugin) Filter(conf interface{}, r plugin.Request) {
// 将配置转换为我们定义的结构体
canaryConf := conf.(DynamicCanaryConf)
// 检查内部测试用户头
userGroup := r.Header().Get(canaryConf.InternalHeader)
if userGroup == canaryConf.InternalValue {
// 强制路由到金丝雀版本
err := r.SetVar("upstream", canaryConf.CanaryUpstream)
if err != nil {
log.Printf("failed to set upstream for internal user: %v", err)
r.Stop() // 出现严重错误,中断请求
}
log.Printf("Request routed to canary [internal user]: %s", canaryConf.CanaryUpstream)
return
}
// 从 Redis 获取金丝雀流量百分比
percentageStr, err := p.redisClient.Get(context.Background(), canaryConf.RedisKey).Result()
if err == redis.Nil {
// Key 不存在,所有流量都走稳定版
log.Printf("Redis key '%s' not found, routing to stable", canaryConf.RedisKey)
err = r.SetVar("upstream", canaryConf.StableUpstream)
if err != nil {
log.Printf("failed to set stable upstream: %v", err)
}
return
} else if err != nil {
// Redis 查询失败,出于安全考虑,回退到稳定版
log.Printf("Error querying Redis key '%s': %v. Routing to stable as fallback.", canaryConf.RedisKey, err)
err = r.SetVar("upstream", canaryConf.StableUpstream)
if err != nil {
log.Printf("failed to set stable upstream on redis error: %v", err)
}
return
}
percentage, err := strconv.Atoi(percentageStr)
if err != nil {
// 值格式错误,回退到稳定版
log.Printf("Invalid percentage value in Redis '%s': %s. Routing to stable.", canaryConf.RedisKey, percentageStr)
err = r.SetVar("upstream", canaryConf.StableUpstream)
if err != nil {
log.Printf("failed to set stable upstream on parse error: %v", err)
}
return
}
// 根据百分比进行随机路由
if rand.Intn(100) < percentage {
// 路由到金丝雀版
err = r.SetVar("upstream", canaryConf.CanaryUpstream)
if err != nil {
log.Printf("failed to set canary upstream: %v", err)
}
log.Printf("Request routed to canary [percentage: %d%%]: %s", percentage, canaryConf.CanaryUpstream)
} else {
// 路由到稳定版
err = r.SetVar("upstream", canaryConf.StableUpstream)
if err != nil {
log.Printf("failed to set stable upstream by percentage: %v", err)
}
log.Printf("Request routed to stable [percentage: %d%%]: %s", percentage, canaryConf.StableUpstream)
}
}
// 入口函数
func main() {
// 初始化随机数种子
rand.Seed(time.Now().UnixNano())
// 配置 Go Plugin Runner
cfg := runner.RunnerConf{
// APISIX 和 runner 之间的通信 socket 文件路径
SocketPath: "/tmp/runner.sock",
// 日志级别和输出路径
LogLevel: "info",
LogOutput: "stderr",
}
// 注册我们的插件
if err := plugin.RegisterPlugin(&DynamicCanaryPlugin{}); err != nil {
log.Fatalf("failed to register plugin: %v", err)
}
// 启动 runner
runner.Run(cfg)
}
这个插件通过 r.SetVar("upstream", "...")
动态修改了当前请求的目标上游。这是 APISIX 插件系统非常强大的一个特性。
本地环境配置与测试
在提交到 CI/CD 之前,本地验证是必不可少的步骤。我们使用 docker-compose
来编排 APISIX、Go Plugin Runner 和 Redis。
docker-compose.yml
:
version: '3'
services:
apisix:
image: apache/apisix:3.5.0-debian
restart: always
volumes:
- ./apisix_conf/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- /tmp/runner.sock:/tmp/runner.sock # 共享 Unix Socket
ports:
- "9080:9080"
- "9180:9180"
depends_on:
- go-plugin-runner
- redis
go-plugin-runner:
build:
context: .
dockerfile: Dockerfile.runner
restart: always
volumes:
- /tmp/runner.sock:/tmp/runner.sock # 共享 Unix Socket
depends_on:
- redis
redis:
image: redis:6.2-alpine
restart: always
ports:
- "6379:6379"
# 两个模拟的上游服务
upstream_stable:
image: kennethreitz/httpbin
restart: always
upstream_canary:
image: kennethreitz/httpbin
restart: always
Dockerfile.runner
:
FROM golang:1.20-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 编译 Go 插件,静态链接,移除调试信息,确保在 Alpine 环境下可运行
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s -w' -o runner .
FROM alpine:latest
WORKDIR /app
# 从 builder 阶段拷贝编译好的二进制文件
COPY /app/runner .
# 暴露 Unix Socket 路径
VOLUME /tmp
# 容器启动命令
CMD ["./runner"]
apisix_conf/config.yaml
:
apisix:
node_listen: 9080
enable_admin: true
admin_key:
- name: "admin"
key: "edd1c9f034335f136f87ad84b625c8f1"
role: admin
plugins:
- ... # 默认插件
- ext-plugin # 必须启用 ext-plugin
ext-plugin:
cmd:
# 这里的配置会被 Go Plugin Runner 自己的配置覆盖,但必须存在
- path: /usr/local/bin/go-runner
# 这里的配置告诉 APISIX 去哪里找 Go Plugin Runner
runner_socket: /tmp/runner.sock
启动服务:
docker-compose up -d --build
配置 APISIX 路由和上游:
# 1. 添加上游服务
curl -i "http://127.0.0.1:9180/apisix/admin/upstreams/stable-service" -X PUT -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '
{
"nodes": {
"upstream_stable:80": 1
},
"type": "roundrobin"
}'
curl -i "http://127.0.0.1:9180/apisix/admin/upstreams/canary-service" -X PUT -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '
{
"nodes": {
"upstream_canary:80": 1
},
"type": "roundrobin"
}'
# 2. 添加使用 dynamic-canary 插件的路由
curl -i "http://127.0.0.1:9180/apisix/admin/routes/1" -X PUT -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -d '
{
"uri": "/anything/*",
"plugins": {
"dynamic-canary": {
"stable_upstream": "stable-service",
"canary_upstream": "canary-service",
"redis_host": "redis",
"redis_port": 6379,
"redis_key": "canary_percentage"
}
},
"upstream_id": "stable-service"
}'
# 注意:我们仍然需要配置一个默认的 upstream_id,即使插件会覆盖它。
测试路由:
# 设置 Redis 百分比为 30%
docker-compose exec redis redis-cli SET canary_percentage 30
# 循环发送请求,观察日志输出
# 在 go-plugin-runner 的日志中,你会看到路由决策
for i in {1..10}; do curl http://127.0.0.1:9080/anything/test; done
# 使用内部头测试
curl -H "x-user-group: internal" http://127.0.0.1:9080/anything/test
# 此时 go-plugin-runner 日志应该显示强制路由到 canary
本地验证通过,我们现在可以放心地构建自动化流水线了。
GitHub Actions CI/CD 流水线
我们的目标是:当代码推送到 main
分支时,自动触发一个流程,该流程完成编译、测试、构建 Docker 镜像,并将其推送到 GitHub Container Registry (GHCR),最后在目标服务器上更新服务。
.github/workflows/deploy-plugin.yml
:
name: Deploy APISIX Go Plugin Runner
on:
push:
branches:
- main
workflow_dispatch: # 允许手动触发
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # 授权向 GHCR 推送镜像
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
cache: true # 启用 Go 模块缓存
- name: Run vet and tests
run: |
go vet ./...
# 在真实项目中,这里应该有完整的单元测试
# go test -v ./...
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} # GITHUB_TOKEN 是由 GitHub Actions 自动提供的
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v4
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
dockerfile: Dockerfile.runner
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# 使用 buildx 并为 amd64 平台构建
platforms: linux/amd64
deploy:
needs: build-and-push # 依赖于上一个 job
runs-on: ubuntu-latest
steps:
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
# 这里的脚本在你的目标服务器上执行
cd /path/to/your/apisix/deployment # 进入部署目录
# 登录到 GHCR,需要提前在服务器上设置好 DOCKER_USER 和 DOCKER_PAT
echo ${{ secrets.GHCR_PAT }} | docker login ghcr.io -u ${{ github.repository_owner }} --password-stdin
# 拉取最新的镜像
docker-compose pull go-plugin-runner
# 重启服务以应用更新。这里的 `up -d` 会重新创建已更改的服务。
docker-compose up -d --no-deps go-plugin-runner
# 清理旧的、未被使用的镜像,防止磁盘空间被占满
docker image prune -f
关键点解释:
- Secrets:
SERVER_HOST
,SERVER_USER
,SSH_PRIVATE_KEY
和GHCR_PAT
必须在 GitHub 仓库的Settings -> Secrets and variables -> Actions
中配置。GHCR_PAT
是一个具有write:packages
权限的 Personal Access Token。 -
appleboy/ssh-action
: 这是一个非常实用的 Action,它允许我们在 Runner 中通过 SSH 连接到远程服务器并执行脚本。 - 部署脚本: 脚本的核心是
docker-compose pull
和docker-compose up -d
。这个组合会拉取新的镜像并只重启go-plugin-runner
服务,APISIX 核心服务不会中断。这是一个简单但有效的更新策略。 - 镜像清理:
docker image prune -f
是一个很好的实践,可以避免服务器磁盘被不断生成的旧镜像占满。
局限性与未来迭代方向
这个方案虽然实现了自动化,但在一个更严苛的生产环境中,仍然存在一些可以改进的地方。
- 部署中断:
docker-compose up -d
会销毁旧容器并创建新容器。在这个短暂的切换窗口内(通常是亚秒级),APISIX 对 Go Plugin Runner 的 gRPC 调用可能会失败。APISIX 自身有重试机制,但对于高并发场景,更优的方案是使用滚动更新。如果部署在 Kubernetes 中,可以利用其原生的滚动更新能力实现零停机部署。 - 配置管理: 插件中的 Redis 地址等配置是硬编码或通过 APISIX 配置传递的。更好的方式是通过环境变量注入到
go-plugin-runner
容器中,这样配置与代码可以分离,更符合十二要素应用原则。 - 可观测性: 当前插件只使用了
log.Printf
。一个生产级的插件应该集成结构化日志库(如zerolog
),并暴露 Prometheus 指标(例如,路由到 canary/stable 的请求计数、Redis 访问延迟等),以便进行监控和告警。 - GitOps 集成: 对于 Kubernetes 环境,可以更进一步。CI 流程只负责构建和推送镜像。部署则交给 ArgoCD 或 Flux 这类 GitOps 工具。GitHub Actions 在构建成功后,只需更新一个 K8s manifest Git 仓库中的镜像标签,GitOps 工具会监听到变化并自动将更新应用到集群中,这使得整个流程的声明性更强,也更易于审计和回滚。