纯Go分布式部署:从零搭建无容器的微服务架构
最近在学 Go正好手上的项目要从单体拆成多节点分布式部署。一开始也想着是不是需要用 Docker 把环境打包一下毕竟这类文章十个里有九个都在教容器化。但这台实验机环境比较特殊装容器运行时各种条件受限而且我本来就想把部署链路里每个细节都吃透于是干脆走了另一条路纯 Go 方案不依赖 Docker直接把编译好的二进制文件分发到多台服务器上运行。这一路走下来发现很多以前觉得必须靠容器才能解决的问题其实用 Go 原生能力加一些基础中间件就能解决。甚至因为少了一层抽象很多概念反而看得更清楚。这篇记录会把整个过程拆开讲从服务拆分、通信协议、服务注册与发现到 systemd 部署、优雅退出、问题排查尽量讲得细一点。适合正在学 Go 微服务、又不想一上来就上 K8s 或容器的同学参考。1. 整体思路不用容器分布式部署靠什么分布式部署的本质不是“用不用容器”而是多个独立进程如何协作完成同一个业务。容器解决的是环境一致性和资源隔离问题但部署模式背后还有四个躲不开的事服务之间怎么通信服务地址怎么互相发现共享状态放哪里实例挂了怎么处理只要这四个问题有答案不用容器也能跑出一个可用的小型分布式系统。Go 在这条路上有个天然优势静态编译。CGO_ENABLED0 go build出来的二进制几乎不依赖运行环境扔到 Linux 服务器上直接就能跑。这一点把“环境一致性”问题解决了大半所以纯 Go 方案并不可怕。我在这次实践中采用的技术栈是语言Go1.22通信内部 RPC 用 gRPC网关层对外用 HTTP/JSON服务注册与发现etcd分布式锁与选主etcd 的 lease campaign部署systemd 托管二进制日志标准库log/slog输出 JSON 到 stdout由 journald 统一管理这套组合里唯一需要额外安装的中间件是 etcd其余都是 Go 生态自带或标准库能力。我觉得这才是“纯 Go 方案”比较合理的定义业务代码和部署形态都以 Go 为主不引入额外的容器层。1.1 和容器化方案比纯 Go 的取舍很多人会把分布式和容器绑定在一起但实际上两者的关系是“可以搭配不是必须”。容器方案最大的好处是环境一致性、弹性调度标准化尤其是实例数量多、环境差异大的场景收益非常明显。但对于中小规模、环境相对可控或者学习阶段的分布式系统直接上容器反而增加了概念负担。纯 Go 二进制的优势在于部署动作简化为“拷贝文件 启动进程”故障排查链路短没有容器网络、镜像仓库这些中间环节对机器性能几乎无额外损耗单文件便于版本管理回滚也方便缺点也很明显没有容器的隔离能力依赖冲突需要自己管理没有编排系统多节点扩缩容得靠脚本或手动。但这些缺点在学习阶段其实可以变成优点——逼着你把服务注册、发现、健康检查这些机制亲手实现一遍理解反而更深。1.2 系统模块怎么拆分我原项目是一个简单的“任务提交 异步处理 结果查询”服务。单体结构下就是一个进程内部用 goroutine 处理任务队列。拆成分布式后我把进程分成了三类角色API 服务api-server对外提供 HTTP 接口接收请求调用任务服务Task Workertask-worker从队列拉取任务并执行执行完写回状态存储层Redis 存队列和临时状态MySQL 存最终结果这三类角色都是同一个代码仓库里的不同 main 入口编译后生成不同二进制。共享代码放在internal/目录里避免复制粘贴。这样拆的好处是可以用最少的机器演示“不同节点跑不同角色”也能验证“同一个角色多个副本”的负载均衡。后面要讨论的服务发现、注册、健康检查都是围绕这几种角色展开的。2. 核心组件设计与通信方案动手写代码之前先把几个核心组件的选型逻辑说清楚。因为我看过太多把 gRPC、etcd、Redis 全部塞进项目的所谓教学代码最后根本分不清哪个是必须的。2.1 内部通信为什么选 gRPC 而不是 HTTP开始时我图省事内部服务之间也用 HTTP/JSON 通信反正 Go 标准库直接http.Post就行。但很快发现几个问题内部接口缺少严格的参数约束字段名手写容易错超时和取消传递不方便一个请求挂在多个服务上时很难统一控制返回错误信息带在 HTTP body 里解析起来很麻烦后来把内部接口换成了 gRPC。它相当于给内部接口定义了一套强类型约定.proto文件就是接口文档生成的代码保证客户端和服务端不会“暗通款曲”。同时 gRPC 天然支持context超时传递、流式通信这些对分布式调试都很有用。以我的经验小项目里对外 API 保持 HTTP/JSON 没问题但服务之间的调用最好尽早统一到 gRPC 上越晚切换成本越高。如果你对 protobuf 还不熟可以先跳过沿用 HTTP 也能完成分布式通信只是要把接口定义文档做得仔细一点。2.2 服务注册与发现为什么必须引入注册中心分布式部署之后服务实例的 IP:Port 不是固定的而且同一个服务可能部署多个副本。客户端不能写死某台机器地址否则写死的那台一挂请求就全失败了。这时候需要一个“电话本”也就是注册中心。我在调研时比较了 etcd、Consul、Nacos 这几个常见组件。最终选了 etcd原因是本身是 Go 写的和 Go 生态亲和性很好文档简单部署方式也简单支持 lease 和 watch做服务注册和动态发现非常方便小规模集群下性能足够而且可以顺便做分布式锁服务注册和发现模型是这样的每个服务启动时在 etcd 里写一个 key路径类似/services/api-server/10.0.0.1:8080key 的值可以存放扩展元数据比如版本号、权重key 绑定一个 lease租约客户端必须定时续租客户端关闭或故障时lease 过期key 自动消失消费方 watch 这个前缀拿到所有在线实例列表并感知变化这套机制配合 Go 的 etcd client v3代码量其实不大但把流程跑通后我对动态扩缩容的理解立刻具象了。2.3 共享状态不要让业务进程各自维护本地状态分布式部署有一个基本纪律业务需要共享的数据不能放在进程本地内存里。比如任务队列如果不放到 Redis只放在某个 worker 的内存里那这个 worker 一重启所有排队任务全没了别的 worker 也不知道这个队列存在。我的方案是短期状态放 Redis任务队列、处理中任务、幂等标记长期数据放 MySQL用户提交记录、任务最终结果进程内只留缓存和不可变配置这样每个服务实例都是“无状态”的可以随时杀掉、重启、扩容。无状态是纯 Go 方案部署最简单的状态也是分布式部署最重要的基础。2.4 分布式锁与选主用 etcd 实现因为业务里有一个定时任务每天凌晨扫一次过期任务并归档。工作节点有多个如果每个节点都同时执行扫描会重复处理。这时就需要选主同一时刻只有一个节点执行定时任务其他节点等待。etcd 提供了一种简单方式通过clientv3/concurrency包里的Session和Campaign实现 leader 选举。具体代码后面会给。这套机制同样基于 lease谁抢到了 key谁就是 leaderlease 过期后重新竞选。3. 实操从代码到多节点部署理论说完开始动手。这部分我会放一些可运行的代码片段覆盖服务注册、服务发现、负载均衡、gRPC 调用、二进制编译这几个核心环节。代码以演示为主生产环境还需要加密和更完善的错误处理。3.1 项目目录结构我的项目结构是这样的distributed-go/ ├── cmd/ │ ├── api-server/ │ │ └── main.go │ └── task-worker/ │ └── main.go ├── internal/ │ ├── registry/ │ │ ├── register.go │ │ ├── discovery.go │ │ └── balancer.go │ ├── proto/ │ │ ├── task.proto │ │ └── task.pb.go │ └── config/ │ └── config.go ├── go.mod └── Makefileregistry包负责 etcd 注册发现逻辑proto放 gRPC 定义config负责解析配置。这里大家不用照搬核心是理解每个模块的职责边界。3.2 服务注册代码先看注册逻辑。服务启动时调用RegisterService传入服务名、实例地址和 etcd 连接信息package registry import ( context time clientv3 go.etcd.io/etcd/client/v3 go.etcd.io/etcd/client/v3/concurrency ) type Registrar struct { client *clientv3.Client } func NewRegistrar(endpoints []string) (*Registrar, error) { cli, err : clientv3.New(clientv3.Config{ Endpoints: endpoints, DialTimeout: 5 * time.Second, }) if err ! nil { return nil, err } return Registrar{client: cli}, nil } func (r *Registrar) Register(ctx context.Context, service, addr string, ttl int64) (func(), error) { lease, err : r.client.Grant(ctx, ttl) if err ! nil { return nil, err } key : /services/ service / addr _, err r.client.Put(ctx, key, addr, clientv3.WithLease(lease.ID)) if err ! nil { return nil, err } keepAliveCtx, cancel : context.WithCancel(ctx) ch, err : r.client.KeepAlive(keepAliveCtx, lease.ID) if err ! nil { cancel() return nil, err } go func() { for { select { case -keepAliveCtx.Done(): return case _, ok : -ch: if !ok { return } } } }() deregister : func() { cancel() ctx2, cancel2 : context.WithTimeout(context.Background(), 3*time.Second) defer cancel2() _, _ r.client.Delete(ctx2, key) _ r.client.Revoke(context.Background(), lease.ID) } return deregister, nil } // Campaign 用于 leader 选举 func (r *Registrar) Campaign(ctx context.Context, name string, ttl int64) (-chan bool, func(), error) { session, err : concurrency.NewSession(r.client, concurrency.WithTTL(int(ttl))) if err ! nil { return nil, nil, err } election : concurrency.NewElection(session, /election/name) if err : election.Campaign(ctx, i-am-leader); err ! nil { session.Close() return nil, nil, err } // 返回一个 channel关闭表示丢失 leader 身份 lost : make(chan bool) go func() { for { select { case -session.Done(): close(lost) return } } }() release : func() { session.Close() } return lost, release, nil }这段代码里最关键的是KeepAlive那个 goroutine。如果客户端和 etcd 之间网络抖动KeepAlive 通道会因为租约被 close 而退出此时注册的 key 会在 TTL 过后被 etcd 删除。生产环境要在退出后触发重注册或者直接标记节点不健康。Campaign函数里的session.Done()会在租约失效时触发用来通知当前节点失去 leader 资格。我在定时任务里这样用lostCh, release, _ : registry.Campaign(ctx, daily-archive, 10) defer release() select { case -lostCh: // 不是 leader 了停止任务 return case -ctx.Done(): return }3.3 服务发现与简单负载均衡服务发现我分了两层一层是给普通 HTTP 客户端用的另一层是给 gRPC 用的 resolver。先看基础实现package registry type Instance struct { Service string Address string Version string } type Discovery struct { client *clientv3.Client prefix string mu sync.RWMutex cache map[string][]Instance } func (d *Discovery) Watch(ctx context.Context, service string) (-chan []Instance, error) { key : d.prefix service / getResp, err : d.client.Get(ctx, key, clientv3.WithPrefix()) if err ! nil { return nil, err } instances : parseInstances(getResp.Kvs) d.updateCache(service, instances) ch : make(chan []Instance, 16) go func() { rch : d.client.Watch(ctx, key, clientv3.WithPrefix()) for wresp : range rch { if wresp.Err() ! nil { close(ch) return } // 这里简单处理watch 到事件后重新拉一次 resp, err : d.client.Get(ctx, key, clientv3.WithPrefix()) if err nil { ch - parseInstances(resp.Kvs) } } }() return ch, nil }很多新手第一次接触 watch以为每个事件都要精确处理“新增了谁删除了谁”。其实用 etcd 的 watch 配合“每次事件后重新拉取全量实例”实现简单并且不容易受增量事件丢失影响。实例数量达到上千个的时候再考虑纯增量更新也不迟。负载均衡我用了一个简单的轮询器type RoundRobinPicker struct { mu sync.Mutex instances []Instance next int } func (p *RoundRobinPicker) Pick() (Instance, bool) { p.mu.Lock() defer p.mu.Unlock() if len(p.instances) 0 { return Instance{}, false } inst : p.instances[p.next%len(p.instances)] p.next return inst, true }gRPC 客户端接入时需要实现resolver.Builder和resolver.Resolver将 etcd 的 watch 结果通知给 gRPC。这部分代码比较机械可以从官方示例改造。如果不想一开始就碰 gRPC resolver可以在应用层用上面的轮询器先顶一阵每次调 gRPC 前先Pick()选出地址然后grpc.DialContext建立连接。当然这不是高效的连接复用方式但学习阶段更容易理解。3.4 主服务与 Worker 的简单示例API 服务主要做这些事注册自己到 etcd启动 gRPC server对外只暴露 HTTP 健康检查接口。Worker 则主动去 etcd 选主、看任务队列。给一段 API 服务 main 的骨架func main() { cfg : config.Load() reg, err : registry.NewRegistrar(cfg.EtcdEndpoints) if err ! nil { log.Fatal(connect etcd failed, error, err) } ctx : context.Background() addr : cfg.ListenAddr deregister, err : reg.Register(ctx, api-server, addr, 10) if err ! nil { log.Fatal(register service failed, error, err) } defer deregister() // 启动 gRPC server lis, _ : net.Listen(tcp, cfg.GrpcAddr) s : grpc.NewServer(grpc.KeepaliveParams(keepalive.ServerParameters{Time: 2 * time.Minute})) proto.RegisterTaskServiceServer(s, taskServer{}) go s.Serve(lis) // 启动 HTTP 健康检查 http.HandleFunc(/healthz, func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(ok)) }) go http.ListenAndServe(cfg.HttpAddr, nil) // 等待退出信号 quit : make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) -quit // 优雅退出 s.GracefulStop() }Register里的 TTL 我设的是 10 秒。这个值要结合心跳频率和网络状况调太小容易因为瞬时抖动被误删太大则故障感知变慢。一般建议 TTL 至少是心跳间隔的三倍比如心跳 3 秒TTL 10 秒比较稳。3.5 编译静态二进制Go 代码写好之后最让人舒服的就是编译阶段。我的 Makefile 里有一段build: CGO_ENABLED0 GOOSlinux GOARCHamd64 go build -trimpath -ldflags-s -w -o build/api-server ./cmd/api-server CGO_ENABLED0 GOOSlinux GOARCHamd64 go build -trimpath -ldflags-s -w -o build/task-worker ./cmd/task-workerCGO_ENABLED0是关键它强制走纯静态编译。这样生成的二进制不依赖系统 glibc 版本在绝大多数 Linux 发行版上都能直接跑。-trimpath会把编译器的本机路径从二进制里去轻轻避免泄露构建环境。-ldflags-s -w去掉符号表能明显减小体积。编译完检查一下file build/api-server输出类似ELF 64-bit LSB executable, x86-64, statically linked看到statically linked就放心了。拿到服务器上chmod x就能执行。4. 服务器部署与平滑升级代码能在本地跑通之后真正的考验才开始。我准备了三台 Linux 服务器一台跑 etcd另外两台分别跑 api-server 和 task-worker 的多副本。部署过程我用到了 systemd这是目前 Linux 上最常用的进程托管方式。4.1 systemd 服务单元文件每类服务写一个.service文件放在/etc/systemd/system/下。以 api-server 为例[Unit] DescriptionAPI Server Afternetwork-online.target Wantsnetwork-online.target [Service] Usergoapp Groupgoapp WorkingDirectory/opt/distributed-go ExecStart/opt/distributed-go/bin/api-server --config/etc/distributed-go/api-server.yaml Restartalways RestartSec5 LimitNOFILE65535 EnvironmentGODEBUGgctrace0 [Install] WantedBymulti-user.target有几个细节我要重点说明User和Group我单独建了goapp用户避免用 root 跑业务进程WorkingDirectory要设置成约定目录否则相对路径容易出错RestartalwaysRestartSec5可以让服务崩溃后自动拉起LimitNOFILE调大文件描述符上限防止高并发下报too many open files配置好之后的操作systemctl daemon-reload systemctl enable api-server systemctl start api-server systemctl status api-server journalctl -u api-server -f如果启动失败先看systemctl status再看 journal 日志。journald 会把 stdout 和 stderr 都收进来所以我的 Go 代码里所有日志都打向 stdout方便统一查看。task-worker 的 service 文件类似只是 ExecStart 换成了 worker 二进制。两个副本部署在不同机器上时service 文件内容几乎一样唯一要注意的是配置文件里的实例地址不能写死同一个 IP。4.2 etcd 的部署方式学习阶段跑单点 etcd 就够了。下载 etcd 解压后用 systemd 托管文件如下[Unit] Descriptionetcd key-value store Afternetwork.target [Service] ExecStart/opt/etcd/etcd --name infra0 \ --data-dir /var/lib/etcd \ --listen-client-urls http://0.0.0.0:2379 \ --advertise-client-urls http://本机IP:2379 \ --listen-peer-urls http://0.0.0.0:2380 \ --initial-advertise-peer-urls http://本机IP:2380 \ --initial-cluster infra0http://本机IP:2380 \ --initial-cluster-state new \ --log-level info Restartalways RestartSec10 [Install] WantedBymulti-user.target单点 etcd 的问题是如果这台机器挂了整个服务发现链路就断了。生产环境至少要起三节点 etcd 集群这里我用--initial-cluster-state new属于初始化集群的方式后面加节点时注意不能重复用这个参数。部署好后可用这个命令验证etcdctl endpoint health --cluster能看到healthy输出说明 etcd 服务可用。启动业务服务后查看注册 keyetcdctl get --prefix /services/如果你看到类似/services/api-server/10.0.0.2:8080 /services/api-server/10.0.0.3:8080 /services/task-worker/10.0.0.4:8090就说明服务注册成功了。4.3 优雅退出从 kill 到无损升级分布式部署里进程被重启是家常便饭。如果直接kill -9杀掉进程正在处理的请求会断etcd 里的注册信息也不会及时清理消费方可能还会往这个“幽灵节点”发送请求。Go 的标准做法是监听信号做优雅退出。在之前的代码里我用到了signal.Notify。api-server里收到 SIGTERM 后应该按顺序做这几件事通知注册中心下线自己本次实现里就是调用deregister停止接收新请求gRPC 里调用GracefulStopHTTP 里调用http.Server.Shutdown(ctx)等正在处理的请求完成最多等 N 秒才退出进程systemd 在停止服务时默认会发 SIGTERM 给主进程。如果进程没有在超时时间内退出systemd 会再发 SIGKILL。所以业务里留给优雅退出的时间窗口要小于 systemd 的TimeoutStopSec配置默认一般是 90 秒比较充裕。4.4 日志和监控日志这块我强烈推荐从项目早期就用log/slog。它能把日志输出成 JSON配合 journald 或日志收集系统非常合适。例子logger : slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) slog.Info(server started, service, api-server, addr, addr)日志里要打上实例 ID、请求 ID、耗时这些字段排查问题时能省很多时间。监控方面在 API server 里加一个/metrics接口用 Prometheus client 暴露 Go runtime 指标再用 Prometheus 抓取。纯 Go 方案并不排斥外部监控组件只是部署本身不依赖它们。5. 常见问题与排查技巧实录这次实操踩了不少坑很多问题属于“代码看着没问题但分布式环境下一跑就露馅”。我整理成速查表方便大家对照。现象可能原因排查方法服务启动后 etcd 里没有 keyclient 连接失败注册方法没被调用TTL 太短etcdctl endpoint health看日志确认 Grant 成功检查 TTL服务在运行但调用方找不到节点watch 前缀不对缓存未更新打印 etcd Get 结果确认WithPrefix参数gRPC 调用报Unavailableresolver 没配置实例已在 etcd 删除但客户端还缓存检查 gRPC resolver重启客户端观察 watch 日志systemd 启动失败ExecStart路径不对配置权限不对端口被占用systemctl statusjournalctl -uss -lntp定时任务重复执行选主逻辑没生效多节点共享状态检查session.Done()是否误触发确认 etcd 选举 key 前缀隔离瞬时负载高时大量 503连接数被打满CPU 瓶颈ss -s查看连接状态ulimit 调高留 pprof 出口时钟不同步导致租约发紫节点间时钟偏差大配置 chrony 或 ntpd做时钟校准5.1 etcd 连接超时最常见的问题没有之一。新手经常把127.0.0.1:2379写进配置文件但服务跑到另一台机器上自然连不上。排查思路是先手动 telnet 一下telnet etcd_ip 2379或者用etcdctl endpoint health --cluster。如果 etcd 和业务服务之间有防火墙要放通 2379 端口。我在演示环境里为了省事曾经把 etcd 端口绑定到0.0.0.0然后忘了配置安全组被扫描器塞了一堆垃圾数据。生产环境至少要做 IP 白名单或者用 etcd 的认证功能。5.2 KeepAlive 导致 CPU 高这是一个容易忽略的问题。etcd 的KeepAlive如果实现不对比如每个实例每秒钟发一个空的 keepalive 请求实例很多时会无谓占用 etcd 的 CPU。我的建议是心跳间隔别太小TTL 设 10 到 15 秒之间KeepAlive 请求间隔可以设置成 TTL 的三分之一。clientv3的 KeepAlive 是并发发送的不是每个 key 一条 goroutine 就一定好要结合业务规模调整。5.3 服务发现结果里有“僵尸节点”服务进程被kill -9后可能来不及调用 revoke注册 key 只能靠 lease 过期清除。如果 TTL 设置成 60 秒那么这 60 秒内调用方依然能发现它就会导致部分请求失败。解决方案TTL 尽量短一些比如 10 秒启动时先 sleep 5 秒等旧实例彻底下线再开始接受流量调用方做重试遇到连接失败后主动从本地缓存里剔除一次最后一条“客户端剔除”很重要。很多负载均衡库只依赖注册中心推送变化但网络波动可能导致推送延迟客户端做一层主动失败剔除能显著提高成功率。5.4 gRPC 的 keepalive 参数gRPC 连接在长连接场景下容易被中间网络设备杀断。如果不设置 keepalive可能服务器端根本不知道连接已经断了客户端请求时才知道Unavailable。设置方式是在grpc.NewServer时加上 KeepaliveParams在客户端用grpc.WithKeepaliveParams。参数上我一般设Time: 30 秒Timeout: 10 秒PermitWithoutStream: true即使没有活跃请求也发送 ping这样能大幅减少“连接假死”的问题。5.5 系统时钟不同步这个问题在本地测试根本不会出现但一到多台物理机或云主机就比较明显。etcd 对时间比较敏感时钟跳变可能导致 lease 判断异常、选举超时。解决方式就是统一 NTPsystemctl enable chronyd systemctl start chronyd chronyc tracking看到Leap status : Normal说明时间同步正常。写在最后的一点体会这套纯 Go 方案部署在几台 Linux 服务器上跑了几天虽然没有容器那么“花哨”但稳定性反而超出我预期。最重要的是在排查问题的过程中我把服务注册、发现、租约、负载均衡这些概念彻底搞明白了。以前用容器时总觉得这些是“平台的事”跟我无关现在自己手动搭了一遍才发现分布式系统的核心从来不是工具而是思路进程要无状态状态要外置故障要可感知通信要可超时。如果之后要扩展到生产环境我大概率会再引入集中式配置中心和服务编排工具但不会急着上整套容器平台。纯 Go 二进制配合 systemd再加 etcd 做注册协调已经能覆盖非常多中小规模场景。对刚学分布式的人而言这条路比直接扑进容器编排的汪洋大海要友好得多。希望这篇记录能帮到正好卡在这些概念上的同学。