etcd 服务发现与配置中心实战:三节点集群搭建、watch 动态上下线、分布式锁与快照恢复

为什么个人站长也需要"服务发现"

看到"服务发现"这四个字,很多站长第一反应是:那是微服务、是大公司才玩的东西,我一个跑着 WordPress 的小站凑什么热闹。我以前也这么想,直到有一天我的架构变成了这样——一台 Nginx 反代,后面两台应用服务器,一台 Redis,一台 MySQL,还有一台专门跑定时任务。每次加机器、改内网 IP,我都要登录五六台机器改配置、重启服务,改错一处就是半小时的排障。

这时候"配置散落在各台机器上"就成了最大的运维债。而 etcd 这类工具解决的正是这个问题:把"谁在哪、谁健康、配置是什么"集中存到一个高可用的小型数据库里,所有机器都去那里读,改一处、全局生效。这篇文章用最通俗的方式,讲清 etcd 是什么、怎么搭一个三节点集群、以及一个草根站长能拿它做的三件实事。

etcd 是什么:一个"给机器用的配置数据库"

etcd 是 CoreOS 团队开发的分布式键值存储(key-value store),用 Go 编写。它的几个关键特性决定了一切:

  • 强一致:基于 Raft 共识算法,集群里任何时刻读到的数据都是一致的
  • 高可用:去中心化,没有单点,只要多数节点存活集群就能工作
  • watch 机制:客户端可以订阅某个 key,值一变立刻收到通知——这是"服务发现"能实时生效的根本
  • 简单:提供 HTTP/gRPC API 和一行命令的客户端,不依赖关系型数据库

它最出名的身份是 Kubernetes 的"大脑"——整个 K8s 集群的状态(Pod、Service、ConfigMap)都存在 etcd 里。但即使你不碰 K8s,etcd 也完全可以作为一个独立的配置中心 / 服务注册中心来用。

和几个容易混淆的东西的区别:

  • vs Redis:Redis 是为了快,可以丢数据(取决于持久化策略),主从异步复制。etcd 是为了准,为了不丢数据,写入要多数节点确认,所以更慢但更可靠。配置和"谁在哪"这种数据不能用 Redis 存。
  • vs ZooKeeper:ZooKeeper 是老前辈,Java 写的,运维更重。etcd 更轻、API 更现代,新项目我选 etcd。
  • vs Consul:Consul 功能更全(自带健康检查、DNS 接口、多数据中心),但也更重。etcd 更专注、更简单。个人站长场景,etcd 足够;需要开箱即用的健康检查,Consul 更省事。

搭建三节点 etcd 集群

etcd 集群需要奇数个节点(1、3、5),因为 Raft 靠多数派投票。3 节点可以容忍 1 台挂掉,5 节点容忍 2 台。个人场景,3 台是性价比最高的选择。我这里用三台内网机器举例:

# 三台机器的内网 IP
node1  192.168.1.11
node2  192.168.1.12
node3  192.168.1.13

先下载二进制。etcd 是单文件,不需要编译,直接从 GitHub Releases 下载对应版本(务必用 3.4 以上的稳定版):

cd /tmp
ETCD_VER=v3.5.15
curl -L -o etcd.tar.gz \
  https://github.com/etcd-io/etcd/releases/download/${ETCD_VER}/etcd-${ETCD_VER}-linux-amd64.tar.gz
tar xzvf etcd.tar.gz
cp etcd-${ETCD_VER}-linux-amd64/etcd* /usr/local/bin/
etcd --version

三台机器都要做这一步。然后给每台写 systemd 服务。以 node1 为例,关键是把 node1 自己的 --name、--initial-advertise-peer-urls 和 --initial-cluster 三处区分清楚,这是新手最容易错的地方:

# /etc/systemd/system/etcd.service (node1)
[Unit]
Description=etcd key-value store
Documentation=https://etcd.io
After=network.target

[Service]
Type=notify
ExecStart=/usr/local/bin/etcd \
  --name node1 \
  --data-dir /var/lib/etcd \
  --listen-client-urls http://0.0.0.0:2379 \
  --advertise-client-urls http://192.168.1.11:2379 \
  --listen-peer-urls http://0.0.0.0:2380 \
  --initial-advertise-peer-urls http://192.168.1.11:2380 \
  --initial-cluster-token my-etcd-cluster \
  --initial-cluster node1=http://192.168.1.11:2380,node2=http://192.168.1.12:2380,node3=http://192.168.1.13:2380 \
  --initial-cluster-state new
Restart=on-failure
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

node2、node3 只需把 --name、--listen-* 的 IP、--initial-advertise-peer-urls 换成自己,--initial-cluster 三台完全一致。注意 --data-dir 所在的盘要留够空间,etcd 的默认配额是 2GB,写满了整个集群会拒绝写入并报警。

systemctl daemon-reload
systemctl enable --now etcd
systemctl status etcd

# 集群健康检查(在三台中任意一台执行)
etcdctl --endpoints=http://192.168.1.11:2379,http://192.168.1.12:2379,http://192.168.1.13:2379 endpoint health
etcdctl --endpoints=http://192.168.1.11:2379 endpoint status --write-out=table

看到三个 endpoint 都是 healthy: true,说明集群起来了。endpoint status 表格里会显示每个节点的 RAFT TERM、RAFT INDEX、DB SIZE,还有谁是 LEADER——投票选出的主节点。

实操一:当"配置中心"用,消灭散落的配置文件

最实用的第一个场景:把所有机器都要用的配置集中到 etcd,改一处全局生效。比如你有一堆自定义的 Nginx 上游、一堆应用都要连的 Redis 地址、一堆脚本都要用的 API 端点。

# 写入配置(etcdctl v3 需要设置 API 版本)
export ETCDCTL_API=3
ENDPOINTS="http://192.168.1.11:2379,http://192.168.1.12:2379,http://192.168.1.13:2379"

etcdctl --endpoints=$ENDPOINTS put /config/redis/host 192.168.1.20
etcdctl --endpoints=$ENDPOINTS put /config/redis/port 6379
etcdctl --endpoints=$ENDPOINTS put /config/api/endpoint https://api.example.com

# 读取
etcdctl --endpoints=$ENDPOINTS get /config/redis/host

# 按前缀读取全部(类似"列出目录下所有文件")
etcdctl --endpoints=$ENDPOINTS get --prefix /config/

服务端应用或 shell 脚本启动时,先 etcdctl get 拉取配置。这样你改一次 Redis 地址,所有节点重启服务时自动拿到新值,不用逐台改文件。如果配合 watch,连重启都可以省。

实操二:服务注册与健康发现(watch 的威力)

这是 etcd 相比静态配置文件的质变之处。思路是:每个服务实例启动时往 etcd 写一个带 TTL(租约)的 key,比如 /services/web/192.168.1.31,并持续续租;实例挂了不再续租,key 自动过期消失。Nginx 或反向代理那边 watch 这个前缀,动态生成 upstream 列表。

export ETCDCTL_API=3
ENDPOINTS="http://192.168.1.11:2379"

# 创建一个 10 秒租约,拿到 lease-id
LEASE=$(etcdctl --endpoints=$ENDPOINTS lease grant 10 | awk '{print $2}')
echo "lease=$LEASE"

# 把服务的 key 绑定到该租约(存活即续租,宕机即删除)
etcdctl --endpoints=$ENDPOINTS put /services/web/192.168.1.31:8080 alive --lease=$LEASE

# 要保活,就周期性续租(真实脚本里用 while 循环)
etcdctl --endpoints=$ENDPOINTS lease keep-alive $LEASE

# 另一端:列出所有活着的 web 实例
etcdctl --endpoints=$ENDPOINTS get --prefix /services/web/

一旦某台机器宕机、续租停止,10 秒后它的 key 就自动从 /services/web/ 里消失,消费方 watch 到删除事件,把该节点从上游摘掉。这就是"服务发现"的本质——机器自己在 etcd 里报到,而不是你手工维护一份服务器清单。

用 Python 消费 watch 事件也非常简单:

import etcd3

client = etcd3.client(host='192.168.1.11', port=2379)
# 先读一次全量
for value, meta in client.get_prefix('/services/web/'):
    print('alive:', meta.key.decode(), value.decode())

# 然后持续监听变化
events_iter, cancel = client.watch_prefix('/services/web/')
for event in events_iter:
    if isinstance(event, etcd3.events.PutEvent):
        print('UP  :', event.key.decode())
    elif isinstance(event, etcd3.events.DeleteEvent):
        print('DOWN:', event.key.decode())

实操三:分布式锁,避免定时任务重复执行

个人站长常见困境:cron 配在多台机器上,本意是"谁活着谁跑",结果三台都跑了,备份脚本把带宽占满,清理脚本互相打架。

etcd 的原子写 + 租约天生就是一把分布式锁。核心思路是:所有竞争者同时用同一个 key 写自己的唯一标识,只有一个人能写成功,谁成功谁执行,执行完释放。

export ETCDCTL_API=3
ENDPOINTS="http://192.168.1.11:2379"
MYSELF="node-$(hostname)-$$"

# 抢锁:10 秒租约,设 30 秒超时,谁抢到谁执行
LEASE=$(etcdctl --endpoints=$ENDPOINTS lease grant 10 | awk '{print $2}')
if etcdctl --endpoints=$ENDPOINTS put /locks/daily-backup "$MYSELF" --lease=$LEASE; then
    # 抢到了,执行真正的备份
    /usr/local/bin/backup.sh
    # 释放锁
    etcdctl --endpoints=$ENDPOINTS del /locks/daily-backup
fi

要注意,上面这种写法是"乐观"的——严格来说 put 会覆盖旧值,应该配合事务(txn)判断版本号才能真正互斥。生产级实现建议直接用各语言的 etcd 客户端库,它们封装好了 lease + txn 的完整抢锁逻辑(比如 Python 的 etcd3、Go 的 concurrency 包)。简单场景下"租约 + 事务"已经够用,别用裸的 put 做锁。

日常运维:备份、压缩、监控

etcd 是集群的"大脑",一旦数据丢失,配置和注册信息全没了。所以定期快照备份是刚需:

export ETCDCTL_API=3
ENDPOINTS="http://192.168.1.11:2379"

# 拍快照(可以在集群运行中执行,一般不影响服务)
etcdctl --endpoints=$ENDPOINTS snapshot save /backup/etcd-$(date +%F-%H%M).db

# 校验快照完整性
etcdctl snapshot status /backup/etcd-2026-10-02-0300.db --write-out=table

恢复时,必须先停掉所有 etcd 服务,然后在每台机器上用同一份快照恢复:

systemctl stop etcd
etcdctl snapshot restore /backup/etcd-2026-10-02-0300.db \
  --name node1 \
  --initial-cluster node1=http://192.168.1.11:2380,node2=http://192.168.1.12:2380,node3=http://192.168.1.13:2380 \
  --initial-advertise-peer-urls http://192.168.1.11:2380 \
  --data-dir /var/lib/etcd-restored
# 把 data-dir 指过去后重启
systemctl start etcd

另外,etcd 会不断产生历史版本,长期运行 DB 会膨胀。要定期压缩(compaction)和整理碎片(defrag):

# 压缩到最近 1000 个版本之前
etcdctl --endpoints=$ENDPOINTS compact $(($(etcdctl --endpoints=$ENDPOINTS get --prefix / --keys-only --rev=0 | tail -1))) --physical

# 整理碎片(注意:会短暂阻塞该节点,逐个节点做)
etcdctl --endpoints=http://192.168.1.11:2379 defrag

踩坑记录:三个我真实遇到的 etcd 问题

坑一:节点重启后集群起不来。有一次我图省事,把 --initial-cluster-state new 一直留着没改成 existing。结果某台机器重启后,etcd 尝试以"全新集群"的身份加入,直接报错退出。正确做法是:首次搭建用 new,之后所有重启都必须改成 existing——大多数情况下把这一行去掉即可,因为默认就是 existing。只有第一次初始化才需要 new。

坑二:时钟不同步导致选主失败。Raft 对时间敏感,如果三台机器时钟偏差过大,会出现选主反复抖动、集群不可用。装好 etcd 后第一件事是确认 NTP/chrony 都在跑,timedatectl status 里 System clock synchronized: yes 是底线。

坑三:数据盘写满,整个集群变只读。etcd 默认配额 2GB,写到接近上限时会先收到 etcdserver: mvcc: database space exceeded 警告,然后拒绝新写入。这时候要么清理历史版本(compact + defrag),要么调大 --quota-backend-bytes,要么归档掉没用的 key。别等到写满才处理,监控 DB SIZE 到 70% 就该动手了。

⚠️ 安全:etcd 默认不设防

etcd 默认的客户端端口 2379 是没有任何认证的——任何人连上来都能读走你所有的配置,甚至把服务注册表全删了。这已经导致了多起 Kubernetes 集群被入侵的公开事件。

加固清单:

  • 绝不监听 0.0.0.0 暴露公网。只监听内网 IP,用防火墙限制只有应用机器能访问 2379。
  • 启用认证:--auth-token=simple(或 JWT),然后用 etcdctl user add 建用户、auth enable 开启。
  • 跨机器(尤其跨机房)的 peer 通信启用 TLS:--cert-file --key-file --peer-cert-file 等。
  • 定期升级版本,etcd 历史上出现过数据泄露和权限绕过漏洞。

小结

对个人站长来说,etcd 不是必须品,但当你的机器超过三五台、配置开始散乱、定时任务开始互相打架时,它会成为一把利器。它用一套简洁的 API 同时解决了三件事:集中配置、服务发现、分布式锁。搭建一个三节点集群大概一小时,之后你省下的每一次"逐台改配置、逐台重启",都是在为未来攒时间。先从小场景(集中配置)用起,尝到甜头后再上服务发现和分布式锁,是风险最低的路径。

Last modification:October 2nd, 2026 at 12:24 pm

Leave a Comment