最近在学 Go,正好手上的项目要从单体拆成多节点分布式部署。一开始也想着是不是需要用 Docker 把环境打包一下,毕竟这类文章十个里有九个都在教容器化。但这台实验机环境比较特殊,装容器运行时各种条件受限,而且我本来就想把部署链路里每个细节都吃透,于是干脆走了另一条路:纯 Go 方案,不依赖 Docker,直接把编译好的二进制文件分发到多台服务器上运行。
这一路走下来,发现很多以前觉得必须靠容器才能解决的问题,其实用 Go 原生能力加一些基础中间件就能解决。甚至因为少了一层抽象,很多概念反而看得更清楚。这篇记录会把整个过程拆开讲,从服务拆分、通信协议、服务注册与发现,到 systemd 部署、优雅退出、问题排查,尽量讲得细一点。适合正在学 Go 微服务、又不想一上来就上 K8s 或容器的同学参考。
1. 整体思路:不用容器,分布式部署靠什么
分布式部署的本质不是“用不用容器”,而是多个独立进程如何协作完成同一个业务。容器解决的是环境一致性和资源隔离问题,但部署模式背后还有四个躲不开的事:
- 服务之间怎么通信
- 服务地址怎么互相发现
- 共享状态放哪里
- 实例挂了怎么处理
只要这四个问题有答案,不用容器也能跑出一个可用的小型分布式系统。Go 在这条路上有个天然优势:静态编译。CGO_ENABLED=0 go build出来的二进制几乎不依赖运行环境,扔到 Linux 服务器上直接就能跑。这一点把“环境一致性”问题解决了大半,所以纯 Go 方案并不可怕。
我在这次实践中采用的技术栈是:
- 语言:Go(1.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 Worker(task-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:8080 - key 的值可以存放扩展元数据,比如版本号、权重
- 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,谁就是 leader,lease 过期后重新竞选。
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_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o build/api-server ./cmd/api-server CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o build/task-worker ./cmd/task-workerCGO_ENABLED=0是关键,它强制走纯静态编译。这样生成的二进制不依赖系统 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] Description=API Server After=network-online.target Wants=network-online.target [Service] User=goapp Group=goapp WorkingDirectory=/opt/distributed-go ExecStart=/opt/distributed-go/bin/api-server --config=/etc/distributed-go/api-server.yaml Restart=always RestartSec=5 LimitNOFILE=65535 Environment=GODEBUG=gctrace=0 [Install] WantedBy=multi-user.target有几个细节我要重点说明:
User和Group我单独建了goapp用户,避免用 root 跑业务进程WorkingDirectory要设置成约定目录,否则相对路径容易出错Restart=always+RestartSec=5可以让服务崩溃后自动拉起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] Description=etcd key-value store After=network.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 infra0=http://<本机IP>:2380 \ --initial-cluster-state new \ --log-level info Restart=always RestartSec=10 [Install] WantedBy=multi-user.target单点 etcd 的问题是如果这台机器挂了,整个服务发现链路就断了。生产环境至少要起三节点 etcd 集群,这里我用--initial-cluster-state new属于初始化集群的方式,后面加节点时注意不能重复用这个参数。
部署好后可用这个命令验证:
etcdctl endpoint health --cluster能看到healthy输出,说明 etcd 服务可用。
启动业务服务后,查看注册 key:
etcdctl 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 里调用
GracefulStop;HTTP 里调用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 里没有 key | client 连接失败;注册方法没被调用;TTL 太短 | etcdctl endpoint health;看日志确认 Grant 成功;检查 TTL |
| 服务在运行,但调用方找不到节点 | watch 前缀不对;缓存未更新 | 打印 etcd Get 结果;确认WithPrefix参数 |
gRPC 调用报Unavailable | resolver 没配置;实例已在 etcd 删除但客户端还缓存 | 检查 gRPC resolver;重启客户端观察 watch 日志 |
| systemd 启动失败 | ExecStart路径不对;配置权限不对;端口被占用 | systemctl status;journalctl -u;ss -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 判断异常、选举超时。解决方式就是统一 NTP:
systemctl enable chronyd systemctl start chronyd chronyc tracking看到Leap status : Normal说明时间同步正常。
写在最后的一点体会
这套纯 Go 方案部署在几台 Linux 服务器上跑了几天,虽然没有容器那么“花哨”,但稳定性反而超出我预期。最重要的是,在排查问题的过程中,我把服务注册、发现、租约、负载均衡这些概念彻底搞明白了。以前用容器时,总觉得这些是“平台的事”,跟我无关;现在自己手动搭了一遍,才发现分布式系统的核心从来不是工具,而是思路:进程要无状态,状态要外置,故障要可感知,通信要可超时。
如果之后要扩展到生产环境,我大概率会再引入集中式配置中心和服务编排工具,但不会急着上整套容器平台。纯 Go 二进制配合 systemd,再加 etcd 做注册协调,已经能覆盖非常多中小规模场景。对刚学分布式的人而言,这条路比直接扑进容器编排的汪洋大海要友好得多。希望这篇记录能帮到正好卡在这些概念上的同学。