Redis 6.2 多租户共享集群里,如何用 ACL 做到"命令最小化 + key 隔离"而不踩坑?
该问题的正文、回答与评论。
【背景】 我们一个业务方在 Redis 6.2 集群(4 主 4 从,集群模式)里同时跑了 3 个微服务:订单(order)、库存(stock)、风控(risk),共用一个集群省钱。最近做安全加固,目标是给每个服务一个独立 ACL 账号,做到「命令最小化 + key 前缀隔离」,防止任何一个服务被拖库后横向扫到另外两个的数据。
【现象 / 卡点】
- 我先用
ACL SETUSER order +@string +@transaction ~order:*给订单服务开权限,结果EVAL(Lua 脚本里 SET/GET)直接被拒——业务侧大量报错,因为我漏了+@scripting但没意识到 EVAL 还会受 key 权限约束。 - 给 stock 服务加了
~stock:*之后,它调用KEYS *仍然能列出 order 的 key(KEYS命令本身没被限制),审计日志里能看到越权读取的痕迹。 - 集群模式下
ACL SETUSER的 key 前缀是按 slot 分片后生效的,我本地单实例验证通过,上线到集群后某些 key 因为 hash tag 落在别的 slot,访问行为不一致,排查了 2 小时才定位。
【已尝试】
- 查了 Redis 官方 ACL 文档,确认 key 权限语法是
~pattern和&channel(6.2 支持 pub/sub channel)。 - 试过用
ACL DRYRUN模拟,但 DRYRUN 只针对单条命令,覆盖不了 Lua 脚本里的多命令组合。 - 想加
+@admin给 ops 账号,但发现+@admin会顺带放开CONFIG、DEBUG,等于把加固全拆了。
【期望得到的答案形态】 希望有人给一份可直接套用的 ACL 规则模板,覆盖:
- 每个业务服务的
SETUSER完整语句(含 @string / @scripting / ~prefix 的正确组合,以及 EVAL 场景下的权限细节); - 如何在不加
+@admin的前提下,让 ops 账号能执行CONFIG GET但不暴露CONFIG SET/DEBUG; - 集群模式下 hash tag / slot 对 key 权限的影响,以及推荐的 key 命名规范;
- 一套可落地的审计方案(
acl log或日志采集),如何快速发现越权尝试。 能给出 redis.conf 片段或acl setuser命令清单就更好。
结论先行:你踩的三个坑(EVAL 被拒、KEYS * 越权、集群 slot 行为不一致)本质上是同一件事——把 ACL 当成了"开关",而它其实是"白名单 + 模式匹配"的双层过滤器。下面给一份可直接套用的模板 + 踩坑解释 + 审计方案。
1) 三个业务服务的 SETUSER 完整语句
关键认知:EVAL 受两层权限约束——+eval(命令级)+ ~pattern(脚本里声明的 keys 必须全部命中)。你漏的不是 +@scripting,而是脚本里用到的 key 没在 pattern 白名单里。
ACL SETUSER order on >OrderS3cureP@ss! -@all \
+ping +select +read \
+get +mget +set +setex +psetex +hget +hgetall +hmget +hset +hsetnx +hincrby +hincrbyfloat +hdel +hscan +hlen +hexists \
+sadd +srem +smembers +sismember +scard +spop \
+zadd +zincrby +zrange +zrevrange +zrangebyscore +zrevrangebyscore +zscore +zcard +zrem +zscan \
+lpush +rpush +lpop +rpop +llen +lrange +lrem \
+expire +pexpire +ttl +pttl +persist +touch \
+eval +evalsha +fcall +fcallsha \
+scan +randomkey \
-keys -flushall -flushdb -shutdown -debug -config \
~order:* ~shared:risk:*
ACL SETUSER stock on >StockS3cureP@ss! -@all \
+ping +select +read \
+get +mget +set +setex +psetex +hget +hgetall +hmget +hset +hsetnx +hincrby +hincrbyfloat +hdel +hscan +hlen +hexists \
+sadd +srem +smembers +sismember +scard \
+zadd +zincrby +zrange +zscore +zcard +zrem +zscan \
+expire +pexpire +ttl +pttl +persist \
+eval +evalsha +fcall +fcallsha \
+scan \
-keys -flushall -flushdb -shutdown -debug -config \
~stock:*
ACL SETUSER risk on >RiskS3cureP@ss! -@all \
+ping +select +read \
+get +mget +set +setex +psetex +hget +hgetall +hmget +hset +hsetnx +hincrby +hincrbyfloat +hdel +hscan +hlen +hexists \
+sadd +srem +smembers +sismember +scard +spop \
+zadd +zincrby +zrange +zrevrange +zscore +zcard +zrem +zscan \
+expire +pexpire +ttl +pttl +persist \
+eval +evalsha +fcall +fcallsha \
+scan \
-keys -flushall -flushdb -shutdown -debug -config \
~risk:* ~order:* ~stock:*
要点解释:
-@all放在最前面:这是"白名单模式"的起手式,先全禁,再逐个放开。比+@string这种粗粒度组更安全,因为@string里混着SET和GETRANGE这类你未必都需要的命令。+eval +evalsha +fcall +fcallsha:Lua 脚本必须显式放开。fcall/fcallsha是 7.0 的 Function 调用,6.2 没有但加上不碍事(会被忽略)。-keys:这是你第 2 个坑的直接解法。KEYS *是 O(N) 全量扫描命令,不受~pattern约束(它是个"列举"命令,不是"访问"命令),一旦放开等于把整个集群的 key 空间暴露给业务服务。让业务侧改用SCAN 0 MATCH order:* COUNT 100(SCAN 是游标式,可分批),或者直接在服务端封装一层scanPrefix(client, "order:")工具函数,业务代码里永远不直接写KEYS。~shared:risk:*:如果 order 服务需要读风控决策结果(共享只读数据),用独立前缀shared:承载,不要直接放开~risk:*(那会让 order 能写 risk 的数据)。- risk 服务放开
~order:* ~stock:*:风控需要读订单和库存数据做决策,这是合理的只读依赖;如果风控还需要写订单状态(比如标记冻结),那就在 order 的 key 命名里单独开一个order:risk:*子前缀,只给 risk 放开写权限。
2) EVAL 场景的权限细节(你第 1 个坑的根因)
Redis 6.2 里 EVAL script numkeys key1 ... arg1 ... 的权限检查分两步:
- 命令级:用户必须有
+eval(你漏了这个); - key 级:
numkeys声明的所有 key 必须全部命中用户的~pattern白名单,否则直接NOPERM。
典型翻车:脚本里 redis.call('GET', KEYS[1]) 访问 order:123,但脚本里还隐式访问了 lock:order:123(分布式锁),而 lock:* 不在 pattern 里 → 整个脚本被拒。
落地建议:
- 所有 Lua 脚本用到的 key 前缀,必须在 ACL 的
~pattern列表里显式列出; - 在 CI 里加一条校验:解析脚本源码,提取所有
redis.call('XXX', KEY, ...)的 key 前缀,跟 ACL 配置 diff,不匹配就 fail; - 如果脚本里用了
redis.call('KEYS', 'order:*')这种危险写法,直接 ban——Lua 脚本里调KEYS同样不受 pattern 约束。
3) ops 账号:不加 +@admin 的前提下给 CONFIG GET
+@admin 包含 CONFIG、DEBUG、SHUTDOWN 等高危命令,不能给。正确做法是按命令粒度放开:
ACL SETUSER ops on >OpsS3cureP@ss! -@all \
+ping +info +client +config +acl +slowlog +latency +memory +dbsize +role +cluster \
+get +mget +scan +exists +type +ttl \
+acl +aclsetuser +aclgetuser +acllist +aclwhoami +aclgetkey +acllog \
-config_set -debug -flushall -flushdb -shutdown -save -bgsave -latency_do -latency_reset \
~*
注意:Redis 6.2 的 CONFIG 是一个命令,CONFIG GET 和 CONFIG SET 是同一个命令的不同子命令,ACL 无法按子命令拆分。所以:
- 如果 ops 只需要
CONFIG GET(看配置),不要给+config,改用INFO all(大部分配置项在 INFO 里能查到)+ 直接读redis.conf文件(通过 K8s ConfigMap / 文件挂载); - 如果必须
CONFIG GET,那就给+config,但在应用层做二次拦截:ops 工具里封装configGet()函数,只允许白名单参数(maxmemory、timeout等),configSet()函数直接拒绝; DEBUG、SHUTDOWN、FLUSHALL、FLUSHDB、SAVE、BGSAVE全部显式-,即使给了+@admin也要覆盖掉。
4) 集群模式下 hash tag / slot 对 key 权限的影响(你第 3 个坑)
关键认知:ACL 的 ~pattern 匹配发生在每个节点本地,跟 slot 无关。你本地单实例验证通过、集群上线后行为不一致,大概率不是"slot 分片"问题,而是以下两种之一:
- 客户端没走 cluster 模式:用的是
redis-py的RedisCluster还是Redis?如果是后者,客户端不知道 key 落在哪个 node,会发到错误的 node,那个 node 上查不到 key → 返回MOVED重定向,客户端没处理 → 看起来像"权限不一致",其实是路由问题; - hash tag 导致 key 落在同一 slot:如果你用了
{order}:123这种 hash tag(花括号里的内容决定 slot),order:123和{order}:123是不同的 slot,ACL pattern~order:*匹配的是完整 key 字符串,不是 slot。所以{order}:123这个 key 不会被~order:*命中(因为完整 key 是{order}:123,不是order:123)。
推荐 key 命名规范:
- 禁用 hash tag(花括号),除非你有明确的"多 key 同 slot"需求(比如事务里同时操作多个 key);
- 统一前缀:
{service}:{entity}:{id},比如order:detail:123、order:lock:123、stock:sku:456; - 共享数据用独立前缀:
shared:risk:decision:123; - 不要在 key 里用
*、?等 glob 字符(会跟 pattern 匹配冲突); - 给每个服务写一份
key-naming.md,CI 里加 lint:扫描代码里所有redis.get/set/...的 key 字面量,校验前缀是否在白名单里。
5) 可落地的审计方案
实时审计(Redis 6.2 自带):
# 查看最近 128 条 ACL 拒绝记录
ACL LOG
# 清空日志
ACL LOG RESET
ACL LOG 会记录每次 NOPERM 拒绝,包含:时间戳、用户、命令、key。
落盘 + 告警:
- Redis 6.2 的
ACL LOG不落盘,需要外部采集。推荐方案:- sidecar 代理(如
redis-proxy或Envoy):在代理层记录所有请求,正则匹配NOPERM错误,打到 ELK / Loki; - 客户端埋点:在业务 SDK 里 wrap 一层,捕获
redis.exceptions.NoPermissionError,上报到监控系统(Prometheus counter:redis_acl_denied_total{service="order",key_pattern="stock:*"}); - 定时任务:cron 每 5 分钟跑一次
ACL LOG,解析后写入对象存储,超过阈值(比如 10 次/分钟)触发告警。
- sidecar 代理(如
越权尝试检测规则:
- 同一服务在 1 分钟内对非白名单前缀的 key 发起 ≥ 3 次访问 → 告警(可能是业务 bug,也可能是攻击);
- 同一 IP / 连接在 1 分钟内对多个不同前缀的 key 发起访问 → 高优先级告警(横向探测特征);
KEYS、SCAN、FLUSHALL等命令被调用 → 无论是否成功,都记录到审计日志(因为这些命令本身就有 O(N) 性能风险)。
6) 一份可直接套用的 redis.conf 片段(参考)
# 禁用高危命令(全局兜底,即使 ACL 配错也有保护)
rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command SHUTDOWN ""
rename-command DEBUG ""
rename-command CONFIG ""
rename-command SCRIPT ""
# 注意:rename-command 是全局的,会影响所有用户(包括 ops),
# 如果 ops 需要 CONFIG GET,就不要 rename CONFIG,改用 ACL 控制
# 日志
loglevel notice
logfile /var/log/redis/redis.log
rename-command 是最后一道防线:即使 ACL 配置出错(比如误给了 +@all),被 rename 掉的命令也调不了。但注意 rename-command 是全局生效的,不能按用户区分,所以只用来 ban 那些任何业务都不该用的命令(KEYS、FLUSHALL、SHUTDOWN、DEBUG),CONFIG 这种 ops 可能需要的就留给 ACL 管。
一句话总结:ACL 加固的核心不是"加多少 +",而是"先 -@all 全禁,再按需放开",配合 rename-command 兜底 + 客户端埋点审计 + key 命名规范,三层防线缺一不可。