0%

后台服务的快速搭建框架:一套简单 Go 技术栈

本文简单梳理一下一套快速搭建后台服务的框架,包含rpc、go、k8s、docker、微服务等技术,以及数据存储、服务组织、入口路由、镜像构建、镜像编排和配置下发等工作要点。对于个人开发者与小型项目而言,应该够用了。

总览

在本文中,我们将一起从服务骨架到发布流水线,了解一套基于 Go 的后台技术栈。整条链路的所有组件要么开源,要么可以直接通过 docker 命令拉起,因此任何人都能照着搭出一套能用的后台基础设施。本文只讨论架构骨架,不涉及任何业务。

整条链路为:

客户端
Nginx 入口
Go 服务
(tRPC-Go + Gin)
MySQL / Redis
Go 源码
Docker 镜像
K8s + Helm
Nacos 配置中心

上面两条线是本文的全部内容,其中第一条线是运行时链路,第二条线是交付链路。各组件的技术选型与开源情况如下:

选型 开源 / 部署方式
语言 Go 1.22+ 开源
服务框架 tRPC-Go + Gin 开源(trpc-group/trpc-go,Apache 2.0)
数据存储 MySQL 8 / Redis 7 docker run mysql:8 / docker run redis:7
入口网关 Nginx / ingress-nginx 开源
容器化 Docker Docker 社区版,镜像仓库用 docker.io 或自建 Harbor
编排平台 Kubernetes + Helm minikube / k3s / kind 单机拉起集群,Helm 开源
配置中心 Nacos docker run nacos/nacos-server

服务骨架:Go + tRPC-Go + Gin 中间件

我们先从整条链路的中心,即服务本身说起。Go + tRPC-Go + Gin 中间件是相对流行的微服务框架,其中:

  • Go 是编程语言(说了等于没说),其优势在于:编译出的是单一静态二进制,运行镜像只需要二进制加配置文件;每个 goroutine 初始栈仅几 KB,其高并发性能适合后台服务的网络 I/O 密集特性;吞吐性能强,并且在事实上,我们后续要说的 Docker、Kubernetes、etcd、Prometheus 全是 Go 实现的。
  • tRPC-Go 是服务容器,负责进程生命周期、优雅退出(收到退出信号后排空存量连接再关闭)、统一配置加载和结构化日志;
  • Gin 是 HTTP 引擎,负责路由匹配、参数绑定和灵活的中间件链,一般来说,中间件链只放横切能力,也就是 Recovery、Logger、Auth 这类与业务无关的通用处理,业务逻辑不允许进中间件。

两者的组合方式很简单,我们将 Gin 引擎作为 HTTP Service Handler 挂载进 tRPC-Go 服务容器。服务的代码组织采用四层结构,依赖方向为从外向内,禁止反向依赖,给出一个代码结构示例:

1
2
3
4
5
6
7
8
9
10
cmd/                          # 入口层:main.go 只引导启动,不含业务逻辑
└── demosvr/ # 服务目录,一个服务一个入口
internal/ # 内部代码,禁止被外部包引用
├── app/demosvr/ # 应用层:服务注册、router、logic
│ ├── demosvr.go # Register() 组装依赖、注册路由和中间件
│ ├── router/ # 路由层:参数解析校验、调用 logic
│ └── logic/ # 业务逻辑层:接口定义与实现分离
├── entity/ # 实体层:DTO / Model / 错误码 / 配置结构
├── infra/ # 基础设施层:数据库访问、外部服务客户端
└── pkg/ # 公共包层:跨服务共享的中间件、缓存、工具

分层里最重要的一条约定是接口与实现分离,譬如,在 logic 目录下 demo.go 定义接口,demo_impl.go 提供实现,router 层只依赖接口。这样每个服务都能独立测试,替换实现不影响上层。

回到组合方式,把 Gin 引擎挂进 tRPC-Go 容器的完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {                               // 程序入口
s := trpc.NewServer() // 创建 tRPC-Go 服务容器

engine := gin.New() // 创建 Gin HTTP 引擎
engine.Use(middleware.Recovery(), middleware.Logger(), middleware.Auth()) // 挂载中间件链:恢复、日志、鉴权

group := engine.Group("/demo/v1") // 路由分组,统一服务前缀
router.NewDemoRouter(demoSvc).RegisterRoutes(group) // 注册路由,注入业务逻辑依赖

s.AddService("demo.demo.service", server.New( // 把 Gin 引擎作为 HTTP 服务挂载进 tRPC-Go 容器
server.WithHandler(engine), // 指定 HTTP 处理引擎
))
s.Serve() // 启动服务,阻塞等待退出信号
}

tRPC-Go 由腾讯开源,仓库 github.com/trpc-group/trpc-go,官方文档在 trpc.group。

数据层:MySQL & Redis

数据层是整条链路里最不能出错的环节,分工原则也很简单,即MySQL 存事实,Redis 存热数据。事实指业务实体的最终状态,比如账户余额、订单状态,这类数据必须满足 ACID,靠 MySQL 的事务保证;热数据指被高频读取、可以随时重建的内容,比如缓存、会话、计数,放 Redis 用 TTL 管理生命周期。

MySQL 侧有三个机制是标配:连接池、事务、悲观锁。连接池的初始化和参数决定并发上限;事务保证多步写操作的原子性;悲观锁(SELECT FOR UPDATE)用于转账、转让这类并发写场景,在事务内锁住目标行,防止竞态:

1
2
3
4
5
6
7
8
9
10
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})  // 连接 MySQL,dsn 含地址、账号、密码
sqlDB, _ := db.DB() // 拿到底层 sql.DB 对象,用于配置连接池
sqlDB.SetMaxOpenConns(100) // 最大连接数,决定并发上限
sqlDB.SetMaxIdleConns(10) // 空闲连接数,减少建连开销

err := db.Transaction(func(tx *gorm.DB) error { // 开启事务,闭包内全部成功才提交
tx.Set("gorm:query_option", "FOR UPDATE").First(&account, id) // SELECT FOR UPDATE 锁住目标行,防并发竞态
// 校验、扣减、写流水,全部在同一事务内
return nil // 返回 nil 表示提交事务
})

Redis 侧有两个机制:TTL 精确控制fail-open 降级。一个典型的例子是对象存储的预签名 URL:签名一次 24 小时有效,把它缓存进 Redis 时 TTL 设成 23 小时,留 1 小时缓冲,避免客户端拿到一个即将过期的地址。这里有一个必须遵守的原则:缓存是性能优化,不是核心链路。Redis 挂了要能退化为每次实时签名或直接查库,功能不中断,只是多一次调用开销,绝不能让缓存故障变成服务故障。

数据层用一条 docker 命令就能拉起:

1
2
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=xxx mysql:8   # 拉起 MySQL 8:-d 后台运行,-p 映射端口,-e 设置 root 密码
docker run -d -p 6379:6379 redis:7 # 拉起 Redis 7

同时,业务上线后,如果面临多 Pod 部署,那么数据的竞态问题需要额外考虑,必须谨慎对待。

入口层:Nginx

服务跑起来之后,对外只需要暴露一个域名和一个端口,多个服务按路径前缀分发到各自容器,这一部分工作由Nginx完成。

最小的 Nginx 配置只需要 server 和 location 两层,location 的前缀规则与服务路由前缀一一对应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
http {                                      # 全局 http 配置块
upstream demosvr2 { server demosvr2:8080; } # 上游组 demosvr2,指向服务容器名加端口
upstream demosvr { server demosvr:18082; } # 上游组 demosvr

server { # 虚拟主机配置块
listen 80; # 监听 80 端口

location /demo/ { # 匹配 /demo/ 前缀的请求
proxy_pass http://demosvr; # 转发给 demosvr 上游组
}
location / { # 其余所有请求
proxy_pass http://demosvr2; # 转发给 demosvr2 上游组
}
}
}

本地或单机形态下,Nginx 以容器或系统包方式运行,而如果使用 Kubernetes,入口层的职责由 ingress-nginx 接管,nginx.conf 里的 location 前缀规则原样迁移成 Ingress 资源的 path 规则,一一对应。

构建层:Docker 两段式镜像

镜像的构建策略决定了交付物的大小和构建速度。这里采用两段式构建

  • 先维护一个 builder 基础镜像,预装 Go 工具链并预缓存依赖,每个服务镜像从它编译出二进制。
  • 再把二进制和配置文件装进一个精简运行镜像。
    构建依赖大而运行依赖小,分离之后运行镜像小、传输快、攻击面小。

给出一个示例dockerfile配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 编译阶段:产出可执行二进制
FROM demo-builder:latest AS builder # 基于预装 Go 工具链的 builder 基础镜像
WORKDIR /workspace # 设置工作目录
COPY go.mod go.sum ./ # 先只复制依赖清单,依赖不变时利用分层缓存
RUN --mount=type=cache,target=/go/pkg/mod go mod download # 预下载依赖,cache mount 让二次构建秒级完成
COPY . . # 复制全部源码
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -ldflags="-s -w" -o /app/demosvr ./cmd/demosvr # 编译二进制,-s -w 去掉符号表缩小体积

# 运行阶段:只装二进制和配置
FROM tencentos4-minimal:latest # 精简基础镜像,运行镜像约 50MB
WORKDIR /app # 设置工作目录
COPY --from=builder /app/demosvr . # 从编译阶段拷贝二进制
COPY configs/demosvr/trpc_go.yaml . # 拷贝框架配置文件
CMD ["./demosvr"] # 容器启动命令

我们可以将镜像构建和推送统一收敛到 build.sh 脚本,直接通过一条命令完成:

1
./docker/build.sh -s demosvr --push --tag v1.3.0   # 构建 demosvr 镜像并推送,镜像 tag 为 v1.3.0

镜像仓库用 docker.io 或自建 Harbor,两者都是公开可用的方案。

平台层:Kubernetes 与 Helm

Kubernetes 是容器编排平台,管理容器集群的调度、自愈、扩缩容和服务发现。它把一组容器打包成最小调度单元 Pod,围绕 Pod 提供四类核心能力:

  • 调度(决定 Pod 跑在哪台机器上)
  • 自愈(Pod 挂了自动重建)
  • 扩缩(按副本数增减 Pod)
  • 服务发现(Service 给一组 Pod 提供稳定的访问入口)。
    我们后面要讲的滚动更新也来自它,即按策略逐个替换 Pod,全程不中断服务。

Helm 是 Kubernetes 的包管理工具。K8s 里每个组件都对应众多 yaml 清单(Deployment、Service、Ingress、ConfigMap、Secret),直接维护的话,环境之间的差异无从处理。Helm 把一组清单打包成一个 Chart,清单里的差异部分用模板变量占位,由 values 文件在不同环境注入不同的值。

一个服务在集群里需要的最小资源集是六个,透明各自的职责如下表所示:

资源 职责
Deployment 无状态工作负载,管理副本和滚动更新
Service 集群内稳定的访问入口(ClusterIP)
Ingress 对外入口,与第三章的 Nginx 职责对应
ConfigMap 非敏感配置
Secret 敏感配置(密码、密钥)
HPA 按指标自动扩缩(可选)

Helm 的价值在于 values 分层,默认配置写在 values.yaml,各环境用独立文件覆盖,发布时局部参数用 --set 覆盖,三者的优先级从低到高,下面是一些常见的哦诶之文件:

1
2
3
4
5
6
7
8
9
10
11
12
helm/                         # Helm Chart 根目录
├── Chart.yaml # Chart 元信息:名称、版本、依赖
├── values.yaml # 默认配置
├── values-test.yaml # 测试环境覆盖
├── values-prod.yaml # 生产环境覆盖
└── templates/ # K8s 资源模板目录,values 渲染进模板
├── deployment.yaml # Deployment 模板:工作负载与滚动策略
├── service.yaml # Service 模板:集群内访问入口
├── ingress.yaml # Ingress 模板:对外入口路由
├── configmap.yaml # ConfigMap 模板:非敏感配置
├── secret.yaml # Secret 模板:敏感配置
└── hpa.yaml # HPA 模板:自动扩缩

滚动更新是这套栈里"更新不中断"的机制来源,配置在 Deployment 模板里:

1
2
3
4
5
6
7
8
9
10
11
12
13
strategy:                    # 更新策略
type: RollingUpdate # 滚动更新:逐个替换 Pod
rollingUpdate:
maxSurge: 1 # 更新时最多额外多起的 Pod 数
maxUnavailable: 0 # 允许同时不可用的 Pod 数,0 表示零停机

livenessProbe: # 存活探针:探测失败则重启容器
httpGet: { path: /health, port: 8080 } # 通过请求 /health 接口探测
initialDelaySeconds: 10 # 容器启动 10 秒后才开始探测

readinessProbe: # 就绪探针:探测失败则摘除流量
httpGet: { path: /health, port: 8080 } # 通过请求 /health 接口探测
initialDelaySeconds: 5 # 容器启动 5 秒后开始探测

新 Pod 先起来,readinessProbe 通过后再把流量切过去,旧 Pod 等存量请求排空后再销毁,这就是零停机更新的全部机制。环境隔离也是平台层的职责,各环境在配置来源、镜像 tag、副本数三个维度上分层:

环境 配置来源 镜像 tag 副本
local 本地 .env 本地编译 1
dev docker-compose test 1
test ConfigMap + Secret test 1-2
staging ConfigMap + Secret rc-x.x.x 2
prod ConfigMap + Secret v-x.x.x 2-5

配置层:三层配置体系与 Nacos

服务运行需要的参数由配置层提供与管理。这里采用三层结构,每层的变更成本不同:

内容 载体 变更成本
框架级 端口、日志、超时 镜像内 trpc_go.yaml 重新构建镜像
业务级 DB 地址密码、API Key .env / K8s Secret 更新 Secret 并重启
运行时动态 开关、阈值、模型参数 Nacos 热更新,不重启

Nacos 是阿里巴巴开源的动态服务发现与配置管理平台,名字取自 Naming 和 Configuration 两个词。它提供两大能力:服务注册与发现、配置管理。在这套架构里,服务发现已经由 Kubernetes 的 Service 和 Ingress 承担,所以这里我们只使用它的配置管理能力。当然,如果不需要热更新的配置项,也可以直接使用 Kubernetes 自带 ConfigMap

Nacos提供三个核心能力:命名空间隔离(dev、prod 各一套配置互不干扰)、配置热更新(应用监听变更立即生效)、可视化控制台。拉起它只需要一条命令:

1
docker run -d -p 8848:8848 -e MODE=standalone nacos/nacos-server   # 单机模式拉起 Nacos,控制台与 API 端口 8848

需要说明 tRPC-Go 配置插件生态的现状:官方文档的插件矩阵里,Config 数据源 tested 的是 etcd(trpc-ecosystem/go-config-etcd),Naming 是 Polaris;Nacos 在 tRPC 生态里主要覆盖 Java 端。Go 端接入 Nacos 有两条路径:一是用社区实现,二是基于 tRPC-Go 的配置 Provider 接口自研适配,该接口只要求加载与监听两个能力,实现成本很低。如果不想做任何适配,直接用官方 tested 的 etcd 也能获得同样的热更新能力,代价是少了可视化控制台。

配置的变更路径由此分成两类:

  • 改运行时参数使用 Nacos 热更新
  • 改框架级配置使用 ConfigMap 更新随后进行 kubectl rollout restart

拉起全流程:从代码到服务的闭环

了解每一个层次的技术选型之后,我们可以尝试将它们串成一条完整的链路,首次拉起一共七步,而每一步的产物恰好是下一步的输入:

步骤 动作 产物
1 本地写好 Go 代码 源码
2 build.sh 构建并推送镜像 registry 里的新 tag 镜像
3 Nacos 写好业务配置 配置中心里的配置
4 helm upgrade 挂载 Deployment 新 Pod
5 Pod 启动读 trpc_go.yaml,从 Nacos 拉配置 服务就绪
6 Ingress 路由生效 对外可访问
7 健康检查确认 服务上线

日常更新则只有两条路径,修改业务代码后提交新镜像,并滚动更新,而修改参数可以直接通过 Nacos 热更新。

需要额外注意的是,每次发布推新 tag 时,绝不可以覆盖旧 tag,原因在于 Kubernetes 的回滚机制依赖镜像引用,执行回滚时平台去仓库拉取旧 tag,如果它已经被新代码覆盖,拉到的还是新代码,回滚名存实亡。因此,我们可以配合代码的 git 仓库,每次修改业务代码后,push到仓库,使用该次 commit 号作为tag,更有利于管理。

说到git仓库,对于一个后台系统的代码开发,可以给每个微服务拉去一个基于主分支的业务分支,在业务分支上开发,测试与验收后合并入主分支,合并前,记得先合并主分支远端代码到业务分支并处理冲突。

从零到上线的完整命令序列的示例如下:

1
2
3
4
5
6
7
8
9
10
# 首次拉起
./docker/build.sh -s demosvr --push --tag v1.0.0 # 构建并推送 v1.0.0 镜像
helm upgrade --install demo ./helm -f helm/values.yaml -f helm/values-prod.yaml --namespace demo --create-namespace --set services.demosvr.image.tag=v1.0.0 # 首次安装:默认值加 prod 覆盖,指定镜像 tag

# 日常更新:新镜像 + 滚动
./docker/build.sh -s demosvr --push --tag v1.1.0 # 构建并推送 v1.1.0 镜像
helm upgrade demo ./helm --reuse-values --set services.demosvr.image.tag=v1.1.0 # 只更新镜像 tag,触发滚动更新

# 回滚:切回旧 tag
helm upgrade demo ./helm --reuse-values --set services.demosvr.image.tag=v1.0.0 # 镜像 tag 切回 v1.0.0,完成回滚

往这套骨架里加一个新服务,需要动的地方只有七处,全部是机械操作:

  1. cmd/<newsvr>/main.go:入口
  2. internal/app/<newsvr>/:应用层(Register + router + logic)
  3. configs/<newsvr>/:trpc_go.yaml 与 .env 模板
  4. docker/Dockerfile.<newsvr>:构建镜像
  5. docker/build.sh:注册服务名
  6. helm/values.yaml:添加服务配置
  7. docker/nginx.conf 或 Ingress:添加路由规则

构建发布流水线

到这一章,所有环节还是手工命令,为了方便与规范化,我们可以使用流水线将一系列动作压缩为一次触发,阶段划分如下:

提交代码
打 tag
构建
单元测试
推送镜像
新 tag
迁移 job
(有 schema 变更时)
helm upgrade
触发滚动
健康检查

流水线成立有两个前提,都是前文已经铺垫过的规则,一是tag 不可变,流水线强制每次发布使用新 tag,从机制上禁止覆盖;二是服务无状态,数据全部外置到 MySQL 和 Redis,Pod 只是无状态执行体,滚动更新才不会丢状态。

更新镜像本身不会修改数据库,MySQL 和 Redis 是独立服务,数据原样保留。真正有风险的是 Schema 变更,如果新代码改了表结构,而迁移在应用启动时执行,滚动窗口内新旧代码并存,旧代码读写新结构就会出错。因此,我们可以采用 Expand-Contract模式,即先发布只做加法的迁移(加列带默认值、加索引),新旧代码都能读写;全量流量切到新版本并稳定一个周期后,再删旧列和旧索引。迁移本身要独立于应用启动,作为流水线里的单独一步,避免多副本同时启动时并发执行迁移。

滚动窗口内还有两个瞬态问题需要注意,长事务在 Pod 收到退出信号时会被中断回滚,这由 tRPC-Go 的优雅退出处理,它先排空存量连接再关闭进程;定时任务可能在新旧副本之间重叠执行,任务本身需要幂等或加锁兜底。

落地形态从脚本级开始。给 build.sh 加一个 deploy 参数,把"构建、推送、更新"这三步绑定为一条命令:

1
./docker/build.sh -s demosvr --push --deploy --tag v1.2.0 --env prod   # 构建、推送镜像并触发部署,目标环境 prod

再进一步是 CI 级,提交 tag 即触发整条流水线。一个最小的 GitHub Actions 定义如下,核心只有两步:

1
2
3
4
5
6
7
8
9
10
11
12
13
name: deploy                       # 工作流名称
on: # 触发条件
push: # 推送事件
tags: ["v*"] # 匹配 v 开头的 tag
jobs: # 任务列表
deploy: # 任务名
runs-on: ubuntu-latest # 运行环境
steps: # 步骤列表
- uses: actions/checkout@v4 # 检出代码
- name: build and push # 步骤名:构建并推送镜像
run: ./docker/build.sh -s demosvr --push --tag ${GITHUB_REF_NAME} # 用 git tag 名作为镜像 tag
- name: helm upgrade # 步骤名:更新部署
run: helm upgrade demo ./helm --reuse-values --set services.demosvr.image.tag=${GITHUB_REF_NAME} # 更新镜像 tag,触发滚动更新

GitLab CI、Jenkins 的写法同理,都是同一个阶段序列。同时,流水线末尾必须预留回滚入口,采用helm rollback 或切回旧 tag,一条命令即可完成回滚,不需要重新构建、重新推送镜像(因为旧镜像已经在仓库里了)。

如果希望连"打 tag"这一步都省掉,让平台监听镜像仓库、发现新 tag 自动滚动,可以引入 Flux 的 image automation 或 Argo CD Image Updater,这里我们就不详细叙述了。

参考文章

[1] tRPC-Go 官方文档[EB/OL]. https://trpc.group/docs/languages/go/.

[2] trpc-group/trpc-go[EB/OL]. https://github.com/trpc-group/trpc-go.

[3] Gin Web Framework[EB/OL]. https://gin-gonic.com/.

[4] GORM[EB/OL]. https://gorm.io/.

[5] MySQL Docker Official Image[EB/OL]. https://hub.docker.com/_/mysql.

[6] Redis Docker Official Image[EB/OL]. https://hub.docker.com/_/redis.

[7] Nginx[EB/OL]. https://nginx.org/.

[8] ingress-nginx[EB/OL]. https://github.com/kubernetes/ingress-nginx.

[9] Docker[EB/OL]. https://www.docker.com/.

[10] Kubernetes[EB/OL]. https://kubernetes.io/.

[11] Helm[EB/OL]. https://helm.sh/.

[12] Nacos[EB/OL]. https://nacos.io/.

[13] minikube[EB/OL]. https://minikube.sigs.k8s.io/.

[14] trpc-ecosystem/go-config-etcd[EB/OL]. https://github.com/trpc-ecosystem/go-config-etcd.

[15] Flux Image Automation[EB/OL]. https://fluxcd.io/flux/guides/image-update/.

[16] Argo CD Image Updater[EB/OL]. https://argocd-image-updater.readthedocs.io/.

🌙