1. 从一次配置泄露事故说起:为什么ACL不是可选项
去年帮一个朋友排查线上问题,他们的配置中心里数据库密码被明文拉取到了一个测试环境的日志里。排查下来原因很简单:Zookeeper集群没有开ACL,任何能连上2181端口的客户端都能读取/config下的所有节点。这件事之后,我把手上所有用SpringCloud Zookeeper做配置中心的项目都过了一遍ACL配置。
SpringCloud Zookeeper Config这套东西,用起来确实舒服——spring-cloud-starter-zookeeper-config一引,bootstrap.yml里写个connect-string,配置就自动从Zookeeper的/config路径下加载进来了。但舒服的背后有个容易被忽略的事实:Zookeeper默认是完全没有访问控制的。它不像MySQL有账号密码,不像Redis好歹有个requirepass,Zookeeper的默认行为就是"谁连上谁说了算"。
这就带来一个很现实的问题:在微服务架构里,配置中心往往存放着数据库连接串、第三方API密钥、加密盐值这类高敏感信息。如果Zookeeper集群暴露在内网但缺乏ACL,那么任何一个能访问到该网段的进程——包括被入侵的容器、误配置的测试服务、甚至是开发同学本地跑的一个demo——都能把生产配置完整读走。
ACL在Zookeeper里解决的就是这个问题:它给每个znode挂上一组权限规则,规定"谁"能对"这个节点"做"什么操作"。而SpringCloud Zookeeper Config要做的,是把这套ACL机制和Spring的配置加载流程对接起来,让配置的读取和写入都经过认证。
这篇文章会从Zookeeper ACL的底层模型讲起,然后一步步落到SpringCloud Zookeeper Config的实际配置上,包括认证信息的注入、节点的权限规划、以及我在实际项目中踩过的几个坑。不管你是刚开始用Zookeeper做配置中心,还是已经在生产环境跑了一段时间想补上安全这块,应该都能找到有用的东西。
2. Zookeeper ACL的权限模型:Scheme、ID与Permission的三元组
2.1 ACL的本质是一个三元组
Zookeeper的ACL模型比很多人想象的要简单,它的核心就是一个三元组:scheme:ID:permission。理解了这个三元组,后面所有的配置都是它的组合。
Scheme是认证方式,决定了"用什么方式证明你是谁"。Zookeeper内置了几种scheme:
world:最开放的,只有一个ID叫anyone,表示所有人。world:anyone:r就是"任何人可读"。auth:不使用任何ID,表示"所有已通过认证的用户"。这个scheme比较特殊,它不指定具体ID,而是把权限授予当前会话中所有已认证的身份。digest:用用户名和密码做认证,ID格式是username:base64(sha1(username:password))。这是最常用的方式。ip:按IP地址做认证,ID就是IP或者IP段,比如192.168.1.0/24。sasl:用SASL做认证,适合已经有一套Kerberos体系的环境。
ID是具体身份标识,格式取决于scheme。比如digest scheme下,ID就是user:hash的形式。
Permission是权限位,Zookeeper定义了五种:
| 权限 | 简写 | 含义 |
|---|---|---|
| CREATE | c | 可以在该节点下创建子节点 |
| READ | r | 可以读取该节点的数据和子节点列表 |
| WRITE | w | 可以修改该节点的数据 |
| DELETE | d | 可以删除该节点的子节点 |
| ADMIN | a | 可以设置该节点的ACL |
这里有个容易搞混的点:DELETE权限管的是"删除子节点",不是删除自己。一个节点自己的删除权限,是由它父节点的DELETE权限决定的。这个设计逻辑是:你对一个节点的子节点有删除权,才能删掉它下面的东西。
2.2 digest认证的密码是怎么算出来的
digest scheme的ID格式是username:Base64(SHA1(username:password))。注意这里有个细节:哈希的输入是username:password这个整体字符串,不是只哈希password。
举个例子,用户名admin,密码secret123,那么:
SHA1("admin:secret123") = 一串20字节的摘要 Base64(那串摘要) = 最终的hash部分最终ID就是admin:xxxxxxxxxxxxxxxxxxxxxxxxxxx=。
这个计算过程在实际操作中经常需要手动做,因为Zookeeper的命令行工具zkCli.sh在添加ACL时,如果你用addauth digest admin:secret123,它会自动帮你算hash。但如果你要在配置文件里直接写ACL,就得自己算好。
用命令行算的话:
echo -n admin:secret123 | openssl dgst -binary -sha1 | openssl base64或者用Python:
import hashlib, base64 def digest_id(username, password): raw = f"{username}:{password}".encode() sha1 = hashlib.sha1(raw).digest() return f"{username}:{base64.b64encode(sha1).decode()}"注意:
echo -n的-n不能省,否则会把换行符也算进哈希输入,算出来的hash和Zookeeper内部算的对不上。
2.3 ACL的继承规则:子节点不会自动继承
这是Zookeeper ACL设计里最容易被误解的一点:ACL不会从父节点继承到子节点。你在/config上设了ACL,不代表/config/application会自动有同样的ACL。
每个znode的ACL是独立设置的。当你创建一个子节点时,如果没有显式指定ACL,Zookeeper会用默认ACL,也就是world:anyone:cdrwa——完全开放。
这意味着一个很危险的场景:你在/config根节点上精心设置了digest认证,但SpringCloud Zookeeper Config在启动时如果发现/config/application不存在,它会自动创建这个节点,而创建时用的是默认ACL。结果就是根节点有保护,子节点全裸奔。
正确的做法是:要么预先手动创建好所有需要的节点并设置ACL,要么在客户端创建节点时显式传入ACL。SpringCloud Zookeeper Config在这一点上提供了配置项,后面会详细讲。
3. SpringCloud Zookeeper Config的ACL接入点:认证信息往哪塞
3.1 CuratorFramework的ACL支持
SpringCloud Zookeeper底层用的是Apache Curator来操作Zookeeper。Curator对ACL的支持主要通过两个地方:
一是CuratorFrameworkFactory构建时传入authorization信息,这相当于在连接建立后自动执行addauth。二是创建节点时通过CreateBuilder的withACL()方法指定ACL列表。
SpringCloud Zookeeper的自动配置类ZookeeperAutoConfiguration在构建CuratorFramework时,会读取配置属性来决定是否注入认证信息。具体来说,它看的是ZookeeperProperties里的authInfo字段。
3.2 在bootstrap.yml里配置认证信息
最直接的方式是在bootstrap.yml(注意是bootstrap,不是application,因为配置中心的连接信息要在应用上下文启动早期就可用)里加:
spring: cloud: zookeeper: connect-string: 10.0.0.1:2181,10.0.0.2:2181,10.0.0.3:2181 auth-info: admin:secret123这里auth-info的格式是username:password的明文形式。SpringCloud Zookeeper在构建Curator客户端时,会把它转成digest认证信息注入进去。
但这里有个问题:明文密码写在配置文件里。如果配置文件本身被提交到了代码仓库,或者被不具备权限的人看到,那ACL就形同虚设了。所以生产环境一般不会这么干。
3.3 用环境变量或启动参数注入
更稳妥的做法是把认证信息从配置文件里抽出来,通过环境变量传入:
spring: cloud: zookeeper: connect-string: ${ZK_CONNECT:10.0.0.1:2181} auth-info: ${ZK_AUTH:}然后在部署时通过环境变量ZK_AUTH=admin:secret123注入。这样配置文件里看不到密码,代码仓库里也不会泄露。
如果是用Kubernetes部署,可以把密码放在Secret里,然后通过envFrom或者valueFrom注入到容器环境变量。如果是用传统的虚拟机部署,可以在启动脚本里从密钥管理服务拉取后export。
提示:
auth-info这个属性在SpringCloud Zookeeper的不同版本里名字可能有差异。早期版本用的是spring.cloud.zookeeper.auth-info,后来有些版本改成了spring.cloud.zookeeper.auth.info或者需要通过ZookeeperProperties的自定义扩展来实现。建议先确认自己用的版本,再看对应的配置项名称。如果发现配置不生效,可以打开org.apache.curator的DEBUG日志,看连接建立后有没有执行addAuth操作。
3.4 认证信息注入的时机问题
这里有一个实际踩过的坑:SpringCloud Zookeeper Config在应用启动时,会先连接Zookeeper去拉取配置,然后用拉到的配置来构建应用上下文。但认证信息的注入必须发生在第一次读取操作之前。
如果auth-info配置在bootstrap.yml里,那没问题,因为bootstrap上下文比application上下文先启动。但如果有人把auth-info写在了application.yml里,那就会出问题——因为application.yml本身可能就是从Zookeeper拉下来的,而拉取的时候还没认证,直接就被拒了。
所以记住一个原则:所有跟Zookeeper连接和认证相关的配置,必须放在bootstrap.yml或者通过启动参数/环境变量传入,不能放在application.yml里。
4. 节点权限的规划:从/config根节点到每个应用路径
4.1 典型的节点结构
SpringCloud Zookeeper Config默认使用的根路径是/config。在这个根下面,它按应用名和profile来组织节点。典型的路径结构是这样的:
/config /application # 所有应用共享的配置 /default # 默认profile /dev # dev profile /my-service # 特定应用的配置 /default /prod每个叶子节点(比如/config/my-service/prod)里存放的是具体的配置数据,通常是JSON或者properties格式的字符串。
4.2 权限规划的实际方案
在实际项目中,我一般会把权限分成三类角色:
配置管理员:对所有节点有全部权限(cdrwa)。一般是运维或者配置管理平台的服务账号。
应用服务账号:对/config/application和/config/{自己的应用名}有读权限(r),对/config/{自己的应用名}有写权限(w)——有些场景下应用需要动态更新自己的配置。但一般不给CREATE和DELETE权限,防止应用误创建或误删节点。
只读监控账号:对所有节点只有读权限(r),用于监控系统拉取配置做审计。
对应的ACL设置大概是:
# 配置管理员 digest:config-admin:HASH:cdrwa # 应用服务账号(以my-service为例) digest:my-service:HASH:rw # 监控账号 digest:monitor:HASH:r4.3 用脚本批量设置ACL
手动用zkCli.sh一个个节点设ACL太累了,而且容易漏。我一般会写一个脚本,遍历/config下的所有节点,统一设置ACL。
用Python的kazoo库写一个:
from kazoo.client import KazooClient from kazoo.security import make_digest_acl zk = KazooClient(hosts='10.0.0.1:2181') zk.start() zk.add_auth('digest', 'admin:secret123') def set_acl_recursive(path, acls): try: zk.set_acls(path, acls) except Exception as e: print(f"set acl failed on {path}: {e}") for child in zk.get_children(path): set_acl_recursive(f"{path}/{child}", acls) acls = [ make_digest_acl('config-admin', 'admin-pass', all=True), make_digest_acl('my-service', 'svc-pass', read=True, write=True), make_digest_acl('monitor', 'mon-pass', read=True), ] set_acl_recursive('/config', acls) zk.stop()这个脚本的逻辑是递归遍历,对每个节点设置相同的ACL。实际使用时可以根据路径做差异化处理,比如/config/application只给读权限,/config/my-service给读写权限。
注意:
set_acls操作需要当前会话对目标节点有ADMIN权限。如果你已经设了ACL但忘了给自己留ADMIN,那就锁死了。所以第一次设置ACL时,一定要确保管理员账号有a权限。
4.4 创建节点时的ACL指定
前面提到,SpringCloud Zookeeper Config在节点不存在时会自动创建。如果不想让它用默认的开放ACL创建,有两个办法:
一是在应用启动前,用脚本预先创建好所有需要的节点并设好ACL。这样应用启动时发现节点已存在,就不会触发创建逻辑。
二是通过Curator的CreateBuilder自定义ACL。SpringCloud Zookeeper Config本身没有直接暴露这个配置项,但可以通过自定义CuratorFramework的Bean来干预。具体做法是定义一个CuratorFramework的@Bean,在构建时设置默认的ACL:
@Bean public CuratorFramework curatorFramework(ZookeeperProperties properties) { CuratorFrameworkFactory.Builder builder = CuratorFrameworkFactory.builder() .connectString(properties.getConnectString()) .retryPolicy(new RetryNTimes(3, 1000)); if (StringUtils.hasText(properties.getAuthInfo())) { builder.authorization("digest", properties.getAuthInfo().getBytes()); } CuratorFramework client = builder.build(); client.start(); return client; }但这种方式下,创建节点时的ACL还是需要额外处理。更彻底的做法是自定义一个ZookeeperConfigProperties或者覆盖ConfigDataLoader,在创建节点时传入ACL列表。这部分涉及SpringCloud Zookeeper的内部实现,改动量较大,一般项目里用预创建节点的方式就够了。
5. 踩坑实录:ACL配置中那些让人抓狂的细节
5.1 auth-info格式不对导致认证静默失败
最开始配的时候,我在bootstrap.yml里写的是:
spring: cloud: zookeeper: auth-info: admin:secret123结果应用启动后读配置一直报NoAuth异常。查了半天发现,SpringCloud Zookeeper在解析auth-info时,期望的格式其实是scheme:username:password,也就是要带上scheme前缀。正确的写法是:
spring: cloud: zookeeper: auth-info: digest:admin:secret123但这里又有个版本差异:有些版本的ZookeeperProperties里authInfo字段的解析逻辑是直接按username:password处理的,scheme硬编码为digest。所以到底要不要带digest:前缀,取决于你用的版本。我的建议是:先看源码里ZookeeperProperties.getAuthInfo()被怎么用的,或者直接试两种格式,看哪种能通。
5.2 节点已存在但ACL不对,应用读不到配置
有一次在测试环境,应用突然读不到配置了,报KeeperErrorCode = NoAuth。排查发现是有人手动用zkCli.sh改了/config/my-service的ACL,把应用的账号权限去掉了。
这里的问题是:Zookeeper的ACL修改是立即生效的,而且没有"回滚"机制。一旦某个账号的权限被移除,正在运行的会话如果之前已经通过了认证,可能还能继续操作(取决于会话状态),但新建立的连接就会被拒。
所以修改生产环境ACL时,一定要走变更流程,先确认新ACL里包含了所有需要的账号和权限,再执行修改。修改后立即用各个角色的账号验证一遍读写权限。
5.3 SpringCloud Zookeeper Config的watcher在ACL变更后失效
SpringCloud Zookeeper Config依赖Curator的watcher来监听配置变化。当配置节点数据变化时,watcher触发,应用重新拉取配置。但如果ACL变更导致当前会话失去读权限,watcher可能会收到NoAuth异常,而且不会自动恢复。
这意味着:如果你在应用运行期间修改了ACL,把应用的读权限去掉了,应用不仅读不到新配置,连配置变更的通知都收不到了。更麻烦的是,这种失败是静默的——应用不会崩溃,只是配置永远停留在旧版本。
所以ACL变更后,稳妥的做法是滚动重启所有相关应用,让它们用新的认证信息重新建立连接和watcher。
5.4 容器化部署时的IP scheme陷阱
有些团队会用ipscheme来做ACL,比如只允许特定网段的机器访问。这在虚拟机部署时没问题,但在容器化环境里就是个坑:容器的IP是动态分配的,每次重启可能都不一样。用IP做ACL,要么频繁改ACL,要么就得把整个容器网段都放开,失去了精细控制的意义。
容器环境下还是建议用digest scheme,每个应用一个独立的账号密码,通过Secret注入。这样即使容器IP变了,认证信息不变,ACL也不用动。
6. 验证与排查:怎么确认ACL真的生效了
6.1 用zkCli.sh验证
最直接的验证方式是用zkCli.sh连上去,分别用不同账号认证,然后尝试读写:
# 连接 zkCli.sh -server 10.0.0.1:2181 # 不认证直接读,应该被拒 get /config/my-service/prod # 用应用账号认证 addauth digest my-service:svc-pass get /config/my-service/prod # 应该成功 set /config/my-service/prod "test" # 如果有写权限,应该成功 # 尝试访问其他应用的配置,应该被拒 get /config/other-service/prod如果get返回null或者抛NoAuth,说明ACL生效了。如果返回了数据,说明ACL没配对。
6.2 查看节点的ACL列表
用getAcl命令可以查看某个节点当前的ACL:
getAcl /config/my-service输出会列出所有的scheme:ID:permission条目。检查一下是否包含了你期望的账号和权限。
6.3 应用侧的日志排查
如果应用启动时报NoAuth,打开Curator的DEBUG日志:
logging: level: org.apache.curator: DEBUG org.apache.zookeeper: DEBUG日志里会显示连接建立后是否执行了addAuth,以及具体的认证结果。如果看到Authentication failed或者NoAuth,说明认证信息不对。
另一个排查点是:确认auth-info配置真的被加载了。可以在启动时打印一下ZookeeperProperties的authInfo字段值(注意脱敏),看是不是空。
6.4 一个实用的ACL自检脚本
我写了一个简单的自检脚本,在应用启动前跑一遍,确认当前账号对所需路径有正确的权限:
from kazoo.client import KazooClient from kazoo.exceptions import NoAuthError def check_access(hosts, auth, paths): zk = KazooClient(hosts=hosts) zk.start() zk.add_auth('digest', auth) for path in paths: try: zk.get(path) print(f"READ OK: {path}") except NoAuthError: print(f"READ DENIED: {path}") except Exception as e: print(f"ERROR on {path}: {e}") zk.stop() check_access( '10.0.0.1:2181', 'my-service:svc-pass', ['/config/application/prod', '/config/my-service/prod'] )这个脚本可以集成到CI/CD流程里,部署前自动检查权限配置是否正确,避免上线后才发现读不到配置。
7. 生产环境的ACL管理策略:从手动到自动化
7.1 账号体系的设计
在生产环境里,我建议按"应用+环境"的维度来划分账号。比如:
svc-myapp-prod:myapp在生产环境的账号svc-myapp-dev:myapp在开发环境的账号admin-config:配置管理员的账号monitor-readonly:监控系统的只读账号
每个账号的密码不同,通过密钥管理服务统一管理。应用部署时从密钥服务拉取对应的密码,注入环境变量。
这样做的好处是:权限边界清晰,一个应用的账号泄露不会影响其他应用;审计时能追溯到具体是哪个应用的操作。
7.2 ACL变更的流程化
ACL变更不能随手改,要有流程:
- 在配置管理平台提交ACL变更申请,说明变更原因和影响范围。
- 自动检查新ACL是否包含了所有必要的账号和权限(可以用上面的自检脚本)。
- 在预发环境验证通过后,再应用到生产。
- 变更后滚动重启相关应用,确保watcher重新建立。
- 记录变更日志,便于回溯。
7.3 定期审计
Zookeeper本身不提供ACL变更的审计日志,但可以通过定期dump所有节点的ACL,和基线做对比,发现异常变更。
用zkCli.sh的getAcl命令配合脚本,可以定期导出ACL快照:
#!/bin/bash ZK_SERVER="10.0.0.1:2181" OUTPUT="acl_snapshot_$(date +%Y%m%d).txt" echo "getAcl /config" | zkCli.sh -server $ZK_SERVER >> $OUTPUT # 递归遍历所有子节点...把快照存起来,和上一次的做diff,如果有非预期的变更就告警。
8. 一些个人体会
ACL这个东西,配起来不复杂,但细节特别多。我踩过的坑里,大部分不是因为不懂原理,而是因为版本差异、配置项名称不一致、以及"以为配了其实没生效"。
几个我觉得最值得记住的点:
第一,认证信息必须放在bootstrap阶段能读到的地方,不能放在application.yml里。这个坑我见过至少三个团队踩过。
第二,ACL不会继承,每个节点都要单独设。自动创建的节点默认是开放的,要么预创建,要么自定义创建逻辑。
第三,改ACL后要重启应用,因为watcher可能已经失效了,而且这种失效是静默的,不重启你根本不知道。
第四,容器环境别用IP scheme,用digest,每个应用独立账号。
最后,如果你现在还没配ACL,建议至少先把/config根节点和所有应用节点的ACL加上,哪怕先用一个统一的账号。有总比没有强,后面再慢慢细化到按应用分账号。安全这件事,从来都是先有防线,再谈精细。