《Java 消息中间件·可靠消息与消费治理》13.2 SASL/ACL 与传输加密

配额管的是速率,认证和授权才管得住身份。本篇在本机单节点上起一个 SCRAM-SHA-256 加 ACL 的 broker,贴出未授权读取的 TopicAuthorizationException、错误密码的 SaslAuthenticationException、以及 topic 放行但 group 被拒的原始异常;并用同一份生产者对照 PLAINTEXT、SSL、SASL 的握手与稳态延迟。

没有身份,就没有租户

13.1 的配额绑定在 client.id 或 user 上。client.id 是客户端自己填的,改一个字符串就能绕过;user 来自 SASL 认证,客户端改不了。所以配额要真正生效,前提是先有认证。

认证(authentication)回答「你是谁」,授权(authorization)回答「你能做什么」,加密(encryption)回答「别人能不能在链路上看见」。三件事经常被混成一句「上 SSL 就安全了」,其实它们互相独立:

目标手段不做会怎样
证明身份SASL(SCRAM / PLAIN / GSSAPI / OAUTHBEARER)任何人可以冒充任何 client-id
限制权限ACL(StandardAuthorizer)认证通过的用户能读写所有主题
防窃听/篡改TLS(SSL / SASL_SSL)密码与消息明文过网

本节的实验把前两件做实:SCRAM 认证 + ACL 授权。加密只做代价测量,因为 TLS 的配置量大、和业务可靠性关系较弱,讲清成本比堆配置更有用。

SASL 机制怎么选

Kafka 支持四种常用机制,差别在「密码放在哪、走几次往返」:

机制凭据存放明文过网往返适用
PLAINbroker 配置里的用户表是(必须配 TLS)1内网、已有统一密码源
SCRAM-SHA-256集群元数据(加盐迭代)否(挑战应答)2大多数自建集群
SCRAM-SHA-512同上,摘要更强否2合规要求更强摘要
GSSAPIKerberos KDC否多已有 Kerberos 的企业
OAUTHBEARER外部 token 服务否多云上统一身份

PLAIN 的密码在 broker 的 listener.name.<listener>.plain.sasl.jaas.config 里以明文列出,改一个用户要改配置并滚动重启;SCRAM 的用户存在集群元数据里,用 kafka-configs.sh 动态增删,不需要重启。这也是本节选 SCRAM 的原因:它把凭据变成了可运维的资源。

SCRAM 的代价是多一次往返,以及每个用户一条 SCRAM-SHA-256=iterations=4096 的元数据记录。4096 是默认迭代次数,可以在创建用户时用 iterations= 调整;调高会增加每次登录的 CPU,不会增加每条消息的开销。

ACL 的粒度

ACL 是一条五元组:principal + host + operation + resource + pattern。资源类型与常用操作:

资源常用操作说明
topicRead / Write / Describe / DeleteRead 隐含 Describe
groupRead消费者提交位移、加入组都需要
clusterAlter / Describe平台管理员才给
transactional-idWrite / Describe事务生产者需要

pattern 只有两种:LITERAL(精确匹配)和 PREFIXED(前缀匹配)。前缀 ACL 是平台治理的关键:给租户一条 tenantA. 的 Read,新建主题不用逐条授权。代价是前缀 ACL 很容易给宽,Read 加在空前缀上等于全开。

另外两个开关决定了 ACL 的默认行为:

  • authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer:启用授权器。
  • allow.everyone.if.no.acl.found=false:没有匹配 ACL 时拒绝。默认是 true,也就是「没配 ACL 就放行」,很多人以为配了 ACL 就安全了,其实默认是全开。

还有 super.users,它绕过所有 ACL 检查。超级用户必须只留给平台账号,包括你自己用来 bootstrap 的那个 principal。

本机实测:单节点 SCRAM + ACL

环境:apache/kafka:4.3.0 单节点 KRaft,四个 listener:内部 PLAINTEXT(9092)、controller(9093)、SASL_PLAINTEXT(19094)、SSL(19095)。SASL 与 SSL 端口直接映射到宿主同名端口,这样容器内外都能用同一份 advertised 地址。

启动时踩到的三个坑

坑一:SASL listener 要求 KAFKA_OPTS。 镜像的 /etc/kafka/docker/configure 在 advertised listeners 里出现 SASL_* 时会 ensure KAFKA_OPTS,并检查其中是否包含 java.security.auth.login.config。我第一次启动容器直接 Exited (1),docker logs 只有一段「0 errors」的汇总块,看不到原因;对照脚本补上 JAAS 文件后才起来:

KAFKA_OPTS='-Djava.security.auth.login.config=/cfg/kafka_server_jaas.conf'

JAAS 文件本身可以是最小化的,因为每个 listener 的 sasl.jaas.config 会覆盖它:

KafkaServer {
  org.apache.kafka.common.security.scram.ScramLoginModule required;
};

坑二:listener 级的 JAAS 配置要写对属性名。 属性是 listener.name.sasl.scram-sha-256.sasl.jaas.config。用环境变量表达时,镜像的规则是 _ 变 .、__ 变 _,所以必须保留 SCRAM-SHA-256 里的连字符:

-e 'KAFKA_LISTENER_NAME_SASL_SCRAM-SHA-256_SASL_JAAS_CONFIG=org.apache.kafka.common.security.scram.ScramLoginModule required;'

启动后检查渲染结果:

24:listener.name.sasl.scram-sha-256.sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required;

如果我按习惯写成 SCRAM_SHA_256,属性会变成 listener.name.sasl.scram.sha.256.sasl.jaas.config,broker 不认识,SASL 握手直接失败。

坑三:advertised 里有 SSL:// 就强制要 keystore 凭据文件。 脚本会把 KAFKA_SSL_KEYSTORE_LOCATION 覆盖成 /etc/kafka/secrets/<FILENAME>,并要求 KAFKA_SSL_KEY_CREDENTIALS、KAFKA_SSL_KEYSTORE_CREDENTIALS 指向两个只含密码的文件。也就是说,自签证书不能藏在任意目录,必须挂到 /etc/kafka/secrets。

先建用户,再收紧授权

顺序很重要:先把 SCRAM 用户建好,再把 allow.everyone.if.no.acl.found 关掉,否则会把自己锁在门外。本实验用内部 PLAINTEXT listener 的 ANONYMOUS principal 做 bootstrap,并把它设为超级用户:

docker exec mq-rel13-auth sh -c 'unset JMX_PORT; \
  /opt/kafka/bin/kafka-configs.sh --bootstrap-server 127.0.0.1:9092 \
  --alter --entity-type users --entity-name libraryd \
  --add-config "SCRAM-SHA-256=[password=libraryd-secret]"'

本机实测输出:

Completed updating config for user libraryd.
SCRAM credential configs for user-principal 'libraryd' are SCRAM-SHA-256=iterations=4096

再建主题和三条 ACL:libraryd 对 library.loan.created 有 Write 和 Read,对消费组 libraryd-consumer 有 Read。

docker exec mq-rel13-auth sh -c 'unset JMX_PORT; cd /opt/kafka/bin; \
  ./kafka-acls.sh --bootstrap-server 127.0.0.1:9092 --add \
  --allow-principal User:libraryd --operation Write --topic library.loan.created'

授权用户:生产与消费都正常

客户端配置只有四行:

security.protocol=SASL_PLAINTEXT
sasl.mechanism=SCRAM-SHA-256
sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="libraryd" password="libraryd-secret";

生产三条、再用 libraryd-consumer 组读回:

loan.created L-1 B-101
loan.created L-2 B-102
loan.created L-3 B-103
Processed a total of 3 messages

Processed a total of 3 messages 之前的 TimeoutException 是 --timeout-ms 到点退出的正常行为,不是错误。

未授权主题:原始异常

换一个没有 ACL 的用户 intruder,读同一个主题:

WARN [Consumer clientId=console-consumer, groupId=intruder-consumer] The metadata response from the cluster reported a recoverable issue with correlation id 2 : {library.loan.created=TOPIC_AUTHORIZATION_FAILED}
ERROR [Consumer clientId=console-consumer, groupId=intruder-consumer] Topic authorization failed for topics [library.loan.created]
ERROR Error processing message, terminating consumer process:
org.apache.kafka.common.errors.TopicAuthorizationException: Not authorized to access topics: [library.loan.created]
Processed a total of 0 messages

注意两点:报错发生在拉元数据阶段,不是拉消息阶段,所以客户端连一条记录都没拿到;WARN 里是 TOPIC_AUTHORIZATION_FAILED 错误码,ERROR 里才是被包装成的 Java 异常。排查时按错误码检索比按异常类名检索更快。

密码错误:认证失败

把密码改错一个字符,异常换了一层:

ERROR [Consumer clientId=console-consumer, groupId=libraryd-consumer] Connection to node -1 (localhost/127.0.0.1:19094) failed authentication due to: Authentication failed during authentication due to invalid credentials with SASL mechanism SCRAM-SHA-256
org.apache.kafka.common.errors.SaslAuthenticationException: Authentication failed during authentication due to invalid credentials with SASL mechanism SCRAM-SHA-256

这条和上一条的区别是没有进入授权阶段:连接在 SASL 握手里就断了。监控上要分开计数,否则无法区分「密码配错」和「权限没给」。

前缀 ACL:一条规则放行一族主题

给 intruder 加一条前缀读权限,再读同一个主题:

./kafka-acls.sh --bootstrap-server 127.0.0.1:9092 --add \
  --allow-principal User:intruder --operation Read \
  --topic library. --resource-pattern-type prefixed
loan.created L-1 B-101
loan.created L-2 B-102
loan.created L-3 B-103
Processed a total of 3 messages

同一条命令把 --topic library. 换成 --group intruder- 就是组前缀授权。这里有个操作陷阱:非交互环境删除 ACL 必须加 --force,否则 CLI 会尝试读控制台确认,直接抛 NPE:

Error while executing ACL command: Cannot invoke "java.io.Console.readLine()" because the return value of "java.lang.System.console()" is null
java.lang.NullPointerException: Cannot invoke "java.io.Console.readLine()" because the return value of "java.lang.System.console()" is null
	at org.apache.kafka.tools.AclCommand.confirmAction(AclCommand.java:388)

这条不是 Kafka 的 bug,是 docker exec 没有 TTY 时的 CLI 行为。自动化脚本里所有 --remove 都要带 --force。加上 --force 后同一条命令删除成功,--list 里对应的组前缀 ACL 消失。

主题放行、消费组被拒

把组前缀 ACL 删掉后,用同一个用户读同一个主题,但换一个不匹配任何组 ACL 的 foreign-group:

org.apache.kafka.common.errors.GroupAuthorizationException: Not authorized to access group: foreign-group
Processed a total of 0 messages

这条最能说明 ACL 的粒度:topic 的读权限和 group 的读权限是两张独立的票。只给 topic 不给 group,消费者能拿到元数据、能拉消息,但提交位移和加入组会被拒;反过来只给 group 不给 topic,连消息都拿不到。生产上最常见的「消费者起不来」就是漏了 group 那条 ACL。

认证之后,配额才落到了实处

13.1 的配额可以按 user 配,但前提是 user 来自认证。开了 SCRAM 之后,libraryd 就是 broker 认可的 principal,可以这样把 13.1 和 13.2 接起来:

docker exec mq-rel13-auth sh -c 'unset JMX_PORT; \
  /opt/kafka/bin/kafka-configs.sh --bootstrap-server 127.0.0.1:9092 \
  --alter --entity-type users --entity-name libraryd \
  --add-config producer_byte_rate=8192,consumer_byte_rate=16384'

这条配置对「以 libraryd 认证的所有客户端」生效,客户端无论把 client.id 改成什么,都逃不出这个上限。这才是租户级别配额;按 client-id 配的那套,只适合应用内分模块。

顺序上也要注意:先有认证,再有关闭 allow.everyone.if.no.acl.found,最后才是配额。反过来的话,配额还没生效,集群已经对所有匿名客户端开放了。

TLS 的代价:本机三组对照

同一份生产者代码,只换传输层,向同一主题同步发送 200 条 1024 字节消息。PLAINTEXT 基线取自本机另一个同镜像单节点(39192),SSL 与 SASL 走本节 broker 的 19095 / 19094。三轮都只跑一次;容器内 4 vCPU、/proc/loadavg 约 0.4/0.8/1.0,数字只用于看数量级,不作基准。

轮次传输首条耗时(含握手)稳态 200 条平均单条
第一轮PLAINTEXT230 ms470 ms1.88 ms
第一轮SSL381 ms704 ms3.06 ms
第一轮SASL/SCRAM340 ms799 ms3.48 ms
第二轮PLAINTEXT278 ms970 ms4.33 ms
第二轮SSL410 ms905 ms4.01 ms
第二轮SASL/SCRAM363 ms684 ms2.95 ms

第二轮里 SSL 的稳态比 PLAINTEXT 还快,说明稳态差异已经被调度噪声淹没。两轮里唯一稳定的信号是首条耗时:SSL 比 PLAINTEXT 多约 100–150 ms,SASL 多约 85–110 ms,这部分是握手(TLS 1.3 一次往返 + 证书校验,SCRAM 两次往返 + 4096 次迭代)。16 KB 那一轮的 SSL 首条只多了 70 ms,说明握手成本本身也受调度和批处理影响。

把消息放大到 16 KB、发 300 条,让对称加密的 CPU 占比上升:

传输首条耗时稳态 300 条平均单条
PLAINTEXT262 ms1033 ms2.93 ms
SSL332 ms1281 ms3.78 ms
SASL/SCRAM367 ms996 ms2.81 ms

这一轮 SSL 的稳态慢了约 24%,但只有一次运行,不能据此下「TLS 慢 24%」的结论。本机能确定的是握手成本,不能确定稳态 CPU 成本;后者要在目标硬件上用 perf 或 JFR 采样,本节不做。

工程上的结论反而简单:

  • 连接是长连接,握手成本被摊薄,TLS 的稳态开销通常远小于一次跨机房 RTT。
  • 真正贵的是频繁建连:消费者反复 rebalance、生产者频繁重建、健康检查短连接,都会把握手成本放大。
  • 需要为 TLS 预留的 CPU 余量,按吞吐而不是按连接数估;具体系数必须实测。

最小权限的落地顺序

  1. 先认证,后授权:没有 SASL user 的 ACL 只能约束 User:ANONYMOUS,等于没约束。
  2. 关掉 allow.everyone.if.no.acl.found,并确保 super.users 只有平台账号。
  3. 按「主题前缀 + 组前缀」给租户授权,不要逐主题点。
  4. 生产端至少要 Write + Describe,消费端要 Read(topic)+ Read(group)。
  5. 前缀 ACL 定期审计:kafka-acls.sh --list 的输出进版本库,变更走评审。
  6. 认证失败与授权失败分开监控:前者是配置/密码问题,后者是权限问题,处置完全不同。

本节判据

  1. PLAIN 和 SCRAM 的本质区别是什么? 答:PLAIN 的密码存在 broker 配置里、明文过网(必须配 TLS);SCRAM 的凭据存在集群元数据里、走挑战应答,可动态增删。
  2. 只配了 ACL 但没关 allow.everyone.if.no.acl.found,会发生什么? 答:没有匹配 ACL 的请求仍然放行,ACL 形同虚设。
  3. 未授权读主题和密码错误,异常有什么区别? 答:前者是 TopicAuthorizationException(已通过认证,卡在授权),后者是 SaslAuthenticationException(握手阶段就断了)。
  4. 为什么 topic 有读权限,消费者还是起不来? 答:消费组也需要 Read ACL;缺了会抛 GroupAuthorizationException。
  5. 本机 TLS 实验能得出什么结论? 答:只能确定握手多 100–150 ms;稳态差异在噪声内,不能宣称 TLS 的吞吐损失比例。

阅读导航:上一节 13.1 多租户与配额 | 下一节 13.3 跨集群复制

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收