Skip to content

Spark 3.5 升 4.1.3 实录:Iceberg 1.11 的 class 61、JDK 17 底座、CALL 鉴权前置到 parser

湖仓平台的引擎升级有个反常识的地方:mvn compile 全绿的那一刻,风险一个都还没暴露。 把「我的数据空间」(查询与引擎能力页)的引擎从 Spark 3.5.9 / Iceberg 1.9.2 / JDK 11 推到 Spark 4.1.3(Scala 2.13.17)/ Iceberg 1.11.0 / Temurin JDK 17,编译层真正需要改的只有一处;拦住上线的三道关全在运行期,而最难的一道根本不是版本问题,是鉴权时机

一、先定顺序:这三件事不能一起做

组件这轮怎么处理依据
JDK 11 → 17✅ 先升它是 Iceberg 1.11+ 与 Spark 4 的共同前置,且能独立验证、独立回滚
Iceberg 1.9.2 → 1.11.0✅ 再升只换 jar,联动面最小;但上界不由「最新版」决定,由 JDK 决定(见第二节)
Spark 3.5.9 → 4.1.3✅ 最后升牵动 Scala 2.13 全链重编 + SQL 网关引擎 jar + 引擎侧鉴权扩展适配
Flink 1.20.5 → 2.x⏸️ 按住不动外部依赖没跟上:Iceberg 对 Flink 2.x 的 runtime 只覆盖到 2.1 且刚落地一个版本,而实时链路全靠它写 Iceberg

顺序本身就是设计。三者的联动面互不相交,一起升会让故障无法定位——按「JDK → Iceberg → Spark」递进,每一步都能单独验证、单独回滚。

至于为什么现在升:判据是「越晚做越贵吗」,不是「现在能多干什么」。Scala 2.12 → 2.13 是一次性全链重编,平台自己的代码改完就完事;一旦有客户定制 jar 或用户提交的 Spark/Flink JAR 作业在跑,就变成要协调外部代码迁移。Spark 4 默认开启的 ANSI 模式同理——算术溢出与非法类型转换由静默返回 null 改成报错,这是正确性提升,但对已有报表就是 breaking change。这两条都是典型的越晚越贵,而 Spark 4 的新能力(VARIANT、String collation、SQL UDF)当前一个都不是刚需。升级理由是成本窗口,不是功能饥渴。

Spark 4 + Iceberg 1.11 升级依赖链:JDK 是共同前置,三道关全在运行期

二、编译全绿,运行期还有三道关

关一:Iceberg 1.11.0 的字节码是 Java 17

升完 Iceberg 后端能起、Trino 与 OLAP 路径都通,唯独 Spark 路径全挂,SQL 网关只报一句「engine application has been terminated」。真话在 engine driver 的 pod 日志里:

UnsupportedClassVersionError: org/apache/iceberg/spark/ExtendedParser
has been compiled by a more recent version of the Java Runtime (class file version 61.0),
this version of the Java Runtime only recognizes class file versions up to 55.0

逐版本验字节码,边界很干脆:

iceberg-spark-runtimeclass 文件版本可跑的 JRE
1.9.2 / 1.10.0 / 1.10.255Java 11 ✅
1.11.061Java 17 起

结论一句话:Iceberg 的可用上界不是「最新版」,而是「你的 JDK 允许的最新版」iceberg-flink-runtime 同步跳版,所以 Flink 镜像的底座也一起换。

关二:Iceberg 1.10 起多了一个 KMS 硬依赖

后端服务启动即挂,栈很清楚:

NoClassDefFoundError: software/amazon/awssdk/services/kms/model/EncryptionAlgorithmSpec
  at org.apache.iceberg.aws.AwsProperties.<clinit>
  at ... S3FileIO.initialize → CatalogUtil.loadFileIO → HiveCatalog.initialize

数一下 AwsProperties 里的 kms 引用:1.9.2 是 0 处,1.10.2 是 26 处——1.10 引入了对 AWS SDK v2 kms 模块的硬依赖。

只有一类用法会中招:自己声明 iceberg-aws + 逐个挑 awssdk 模块的服务(缺谁就炸);引擎镜像装的是 shaded 的 iceberg-aws-bundle,自带完整 SDK,完全无感。同一次升级,在两种打包方式下的表现完全不同——这就是为什么升级验证不能只看一条路径。

关三:Spark 4 上少给 --add-opens,会挂住而不是报错

Spark 3.3 起就把自己需要的 JVM 模块化参数放在了 org.apache.spark.launcher.JavaModuleOptions.defaultModuleOptions()。手工拼 classpath 跑 Spark 的脚本习惯硬编码两三个 --add-opens,在 3.5 上侥幸能过,到 Spark 4 直接卡死到超时被杀,全程零错误输出(实测需要 22 项,含 jdk.internal.refsun.security.action--add-modules=jdk.incubator.vector)。

正确姿势是问 Spark 要,别硬编码。 这条的价值不在参数本身,在故障形态:同一个缺失,3.5 是「能跑」、4.x 是「无声挂住」,排查时极容易被引到别处。

三、自建 JDK 17 底座的两条硬约束

镜像底座要换 JDK,有两个地方一试就知道:

  • 不能把宿主的 JDK 挂进容器。宿主 glibc 2.39、容器 2.31/2.35,新 glibc 编出来的二进制跑不了旧 glibc:/lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found用官方 tarball(Temurin 17.0.20,顺带规避早期 Java 17 的 C2 SIGSEGV 已知问题),整目录替换 JAVA_HOME,下游镜像零改动。
  • ADD xx.tar.gz 不能留在最终镜像的层里。ADD 解包自成一层,后面 RUN rm -rf 只写删除标记、字节仍在下层——实测镜像从 654MB 涨到 1.71GB,白背一份解包后的 JDK。改多阶段(前一阶段 ADD,最终镜像只 COPY --from)后回落到 1.18GB。

还有一条容易被忽略的:自建底座必须与被替换的官方底座保持同样的默认用户语义。官方 Flink 镜像的 Config.User(即 root,由 entrypoint 用 gosu 降权),底座里擅自补一行 USER flink,下游 Dockerfile 里的 rm -rf 立刻 Permission denied。连写 USER root 都不对——那会把「空」固化成 "root",不写才是继承。

四、真正的门槛:Spark 4 在 analysis 阶段就执行 CALL

湖仓的维护动作(合并小文件 rewrite_data_files、过期快照 expire_snapshots、清孤儿文件 remove_orphan_files)在 Spark 上都是 CALL 语句。多租户平台必须在它执行之前判断:这个人有没有权限动这张表。

平台的引擎侧鉴权扩展原先把这道闸注入成 Catalyst 的 check rule——在 Spark 3.5 上完全正确。到了 Spark 4,Call 实现了 ExecutableDuringAnalysis,由 analyzer 规则 InvokeProcedures 在 analysis 阶段直接执行。实测 Resolution batch(79 条规则)内的次序:

[22/79] ResolveProcedures      [23/79] BindProcedures
[59/79] ProcedureArgumentCoercion
[74/79] InvokeProcedures                     ← 这里执行 procedure(真改数据)
[78/79] 扩展能注入到的最早位置                  ← 换注入点也救不了

扩展点全都太晚:injectResolutionRule 固定落在 batch 末尾,永远在 InvokeProcedures 之后;post-hoc 与 optimizer 规则更晚;而 InvokeProcedures 自己没有开关

两条看似可行的出路,一条不通、一条通:

  • 一律拒掉 CALL:合并小文件与清孤儿文件只能走 Spark CALL,拒掉等于湖仓失去核心运维能力。功能可降级,但不能把能力降到零。
  • 把鉴权前置到 parser 层:parser 是唯一早于 analysis 的注入点。

Spark 4 的 analysis 规则次序:procedure 在第 74 条就执行,鉴权只能拦在 parser

前置之后有三个设计点值得单独说,它们决定了这道闸能不能一套源码同时服务两个大版本:

  • 用动态代理包住 parser,而不是去实现 parser 接口。两版的方法集不同(Spark 4 多了抽象方法,还有方法的返回类型从可变 Seq 变成不可变 Seq),直接实现无法一套源码兼容;java.lang.reflect.Proxy 按运行时接口转发则天然兼容,将来 Spark 再加方法也不用改。
  • 在 parser 阶段就拿到真正的 procedure 实例。Iceberg 的过程注册表提供了按名字取实例的入口,且两版签名一致——于是 parser 阶段也能拿到过程实例,它的类名正是权限语义表的 key、它声明的参数顺序正是位置实参的落位依据。语义表与参数定位逻辑与原来那层完全同源,不必另建一份「SQL 过程名 → 权限语义」映射;两份映射迟早漂移,而鉴权口径漂移就是安全漏洞。
  • 两版的 CALL 语法产物按类名反射分派。3.5 的 CALL 由 Iceberg 的 parser 扩展产出,4.x 是 Spark 原生语法产出,连命名实参的表达式类型都不同;解包后下游逻辑零分支。

两层闸形成纵深防御:3.5 上两层都拦,4.x 上 parser 那层是唯一有效的那层。顺带把边界口径也校正了一处:网关侧的 SQL 解析抽不出表名时,交出空表集、由引擎侧鉴权兜底——网关的解析只是尽力而为,SQL 语义的权威是引擎。

越权访问被引擎侧鉴权直接拒掉

五、SQL 网关侧的三件事

平台用 Kyuubi 做统一 SQL 接入,升级时有三件事各能省半天:

  • Scala 2.13 的引擎 jar 不用自己编。Maven Central 只发 _2.12,一度以为要拿源码用 2.13 profile 自行构建;实际上官方 binary tarball 的 externals/engines/spark/ 下两个 Scala 版本都带
  • engine pod 会复用旧镜像。改了引擎镜像并重启网关 server 后,存量 engine pod 不会跟着换——它是独立 pod、按空闲超时回收。判据是直接看 engine pod 的 .spec.containers[0].image;不显式回收掉,你验的一直是旧引擎。
  • 改 ConfigMap 得 apply,不是 restart。网关从 ConfigMap 读配置,只重启读到的还是旧内容;apply 之后挂载同步还有秒级延迟,要回查 pod 内文件确认再重启。

六、验收标准:跑到进程起来,且每条引擎路径都发过一条 SQL

这次三道关里,第一关靠读字节码版本、第二关靠真启动、第三关靠跑到超时——mvn compile 全绿的时候,后两关都还埋着。

层次结果
引擎侧鉴权扩展回归 @ Spark 3.5.9 / Scala 2.1246/46(含 parser 闸 8 项)
同一套源码回归 @ Spark 4.1.3 / Scala 2.1343/43
真集群 CALL 鉴权 e2e6/6,越权与参数越界均拒在 procedure 执行之前
平台冒烟(查询 / schema 演进 / 文件入湖)8/8 · 17/17 · 30/30,跨引擎读写核验全过

顺带一条纪律:编译期依赖版本必须与运行期一致。升级前编译环境比运行环境低两个 Iceberg 版本,一直靠「签名恰好一致」撑着——这不是兼容,是运气。

七、四句话总结

  • 引擎升级的顺序本身就是设计:JDK 是 Iceberg 与 Spark 的共同前置,递进比一把梭风险可控得多,每步都能单独回滚;
  • Iceberg 的可用上界由 JDK 决定,不由发布页决定;字节码版本、shaded 与非 shaded 的依赖差异,都是编译期看不见的;
  • Spark 4 把 procedure 收成原生 API 并在 analysis 阶段执行,凡是靠 analysis 之后的规则做鉴权的实现,在 4.x 上都会被绕过——parser 是唯一有效的注入点;
  • 升级的验收线不是「构建通过」,而是「每条引擎路径都真发过一条 SQL,且安全边界逐项回归过」。

我的数据空间」是一套可私有化部署的数据平台(湖仓 + 调度 + 数据治理 + 智能诊断),引擎侧逐表逐列鉴权、湖仓维护作业、多引擎查询开箱即用。看安全与权限核心能力总览,交流合作 QQ:1559851993。