第一章:CentOS下Go项目Docker镜像体积暴增的根源剖析
在基于 CentOS 构建 Go 应用 Docker 镜像时,常出现最终镜像体积远超预期(如从 20MB 暴增至 800MB+)的现象。其核心矛盾并非 Go 二进制本身——静态编译后的可执行文件通常仅数 MB,而是构建环境与基础镜像的隐式耦合导致了大量冗余层。
构建阶段残留的开发依赖
CentOS 官方镜像(如 centos:7)默认包含完整的 GCC 工具链、glibc-devel、kernel-headers 等开发套件。若直接在该镜像中执行 go build,即使使用 -ldflags="-s -w",构建过程仍会拉入 CGO_ENABLED=1 下的动态链接依赖(如 libgcc_s.so.1, libstdc++.so.6),且 go mod download 缓存、/root/go 目录、临时 .o 文件均未清理,全部滞留在镜像层中。
基础镜像选择失当
对比可见差异:
| 镜像类型 | 典型大小 | 是否含 glibc | 是否含编译工具 |
|---|---|---|---|
centos:7 |
~200MB | 是 | 是 |
golang:1.21-alpine |
~350MB | 是(musl) | 是 |
gcr.io/distroless/static:nonroot |
~2MB | 否(纯静态) | 否 |
解决路径:多阶段构建与 CGO 显式禁用
必须分离构建环境与运行环境。示例 Dockerfile 关键段:
# 构建阶段:复用 golang 镜像完成编译,但不保留其整个文件系统
FROM golang:1.21-centos AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 强制静态链接,避免动态库污染
RUN CGO_ENABLED=0 GOOS=linux go build -a -ldflags '-s -w' -o myapp .
# 运行阶段:仅复制二进制到极简镜像
FROM centos:7
# 清理所有非必要包(仅保留运行必需的 glibc)
RUN yum clean all && \
yum install -y --setopt=tsflags=nodocs glibc-minimal-langpack && \
yum autoremove -y && \
rm -rf /var/cache/yum
COPY --from=builder /app/myapp /usr/local/bin/myapp
CMD ["/usr/local/bin/myapp"]
该方案将镜像体积从 780MB 降至约 35MB,关键在于切断构建工具链向运行时的传递,并通过 CGO_ENABLED=0 彻底规避 C 依赖嵌入。
第二章:Go静态链接与libc生态深度解析
2.1 glibc vs musl libc:ABI兼容性差异与net.LookupIP失效机理
根本差异:名称解析实现路径分裂
glibc 通过 getaddrinfo() 动态链接 libnss_* 插件(如 libnss_dns.so),依赖 /etc/nsswitch.conf 配置;musl 则静态内联精简版 DNS 解析器,忽略 NSS 配置,直接读取 /etc/resolv.conf 并发送 UDP 查询。
失效典型场景
- 容器中缺失
/etc/resolv.conf或权限受限 - 使用
nss_wrapper或自定义 NSS 模块时 musl 完全忽略 net.LookupIP在 musl 环境下跳过hosts文件查找(仅查 DNS)
关键行为对比表
| 行为 | glibc | musl |
|---|---|---|
/etc/hosts 查找 |
✅(默认启用) | ❌(仅 DNS) |
nsswitch.conf 支持 |
✅ | ❌ |
| DNS 超时策略 | 可配置 options timeout: |
固定 5s,不可调 |
// musl src/network/lookup.c 片段(简化)
static int dns_query(...) {
// 直接构造DNS报文,无getaddrinfo重入逻辑
int s = socket(AF_INET, SOCK_DGRAM, 0); // 无SOCK_CLOEXEC标志 → 泄漏风险
sendto(s, buf, len, 0, &ns->sa, ns->salen);
recvfrom(s, ans, sizeof(ans), 0, NULL, NULL);
close(s); // 无错误检查
}
该实现省略错误处理与文件描述符安全标记,导致在高并发 Go 程序中易触发 EBADF,进而使 net.LookupIP 返回空切片而非错误——Go runtime 依赖底层 getaddrinfo 语义,musl 的“静默失败”打破 ABI 合约。
graph TD
A[net.LookupIP] --> B{libc 实现}
B -->|glibc| C[调用 getaddrinfo → NSS 插件链]
B -->|musl| D[直连 DNS + 忽略 hosts/NSS]
C --> E[返回完整 error/IPs]
D --> F[无 /etc/resolv.conf → 返回 nil, nil]
2.2 CentOS默认glibc版本演进与Go交叉编译链适配实践
CentOS各版本内建glibc存在显著差异:
- CentOS 7.9 → glibc 2.17(2012年发布)
- CentOS 8.5 → glibc 2.28(2018年)
- CentOS Stream 9 → glibc 2.34(2021年)
Go静态链接默认禁用cgo,但启用CGO_ENABLED=1时将动态链接宿主机glibc——这导致在低版本系统运行高glibc编译的二进制时触发GLIBC_2.28 not found错误。
关键适配策略
# 在CentOS 7构建机上强制绑定最低glibc兼容性
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
CC=gcc-9 \
GODEBUG=asyncpreemptoff=1 \
go build -ldflags="-linkmode external -extldflags '-static-libgcc -Wl,--sysroot=/usr/lib/glibc-2.17'" \
-o myapp .
逻辑分析:
-linkmode external启用外部链接器;--sysroot指定glibc 2.17头文件与库路径,确保符号解析不越界;-static-libgcc避免GCC运行时版本冲突。需提前通过yum install gcc-toolset-9-gcc部署兼容工具链。
兼容性验证矩阵
| 构建环境 | 目标系统 | 运行结果 | 原因 |
|---|---|---|---|
| CentOS 7 + glibc 2.17 | CentOS 7 | ✅ | ABI完全匹配 |
| CentOS 8 + glibc 2.28 | CentOS 7 | ❌ | 引入memmove@GLIBC_2.28等新符号 |
graph TD
A[Go源码] --> B{CGO_ENABLED=0?}
B -->|是| C[纯静态二进制<br>零glibc依赖]
B -->|否| D[动态链接宿主机glibc]
D --> E[检查目标系统glibc版本]
E --> F[≥构建机版本?]
F -->|是| G[正常运行]
F -->|否| H[符号缺失崩溃]
2.3 CGO_ENABLED=0模式下DNS解析退化实测与抓包验证
当 Go 程序以 CGO_ENABLED=0 编译时,运行时弃用系统 libc 的 getaddrinfo(),转而使用纯 Go 实现的 DNS 解析器——其默认仅支持 UDP 查询且无并发 A/AAAA 请求合并。
抓包现象对比
- 正常 CGO 模式:单次解析触发 1 次 UDP 查询(含 EDNS0 扩展)
CGO_ENABLED=0模式:对同一域名分别发起 A 和 AAAA 查询,间隔约 100ms,无共享超时控制
DNS 查询行为差异(Go 1.22+)
| 特性 | CGO_ENABLED=1 | CGO_ENABLED=0 |
|---|---|---|
| 协议 | UDP/TCP 自动降级 | 仅 UDP(无 TCP fallback) |
| 并发 | A+AAAA 合并查询 | 串行独立查询 |
| 超时 | 共享 5s 总超时 | 各自 5s,总耗时可能达 10s |
# 启动监听(过滤 DNS 且按时间排序)
tcpdump -i lo 'port 53' -w dns.pcap & \
go run -ldflags="-extldflags '-static'" main.go
此命令强制静态链接并捕获本地 DNS 流量;
-ldflags="-extldflags '-static'"确保二进制不依赖动态 libc,复现纯 Go 解析路径。
// Go DNS 解析逻辑简化示意
func lookupIP(ctx context.Context, host string) ([]net.IP, error) {
// CGO_DISABLED=true 时走 internal/nettrace + purego/dns
r := &net.Resolver{PreferGo: true} // 显式启用纯 Go 解析器
return r.LookupIPAddr(ctx, host)
}
PreferGo: true强制触发dnsClient.exchange()路径,该实现对host.example.com会先发A? host.example.com,再发AAAA? host.example.com,两次 query ID 不同、无响应关联,导致中间设备无法优化。
2.4 Go build -ldflags ‘-extldflags “-static”‘ 的底层链接行为解析
Go 静态链接的本质,是让最终二进制不依赖系统动态链接器(ld-linux.so)及 libc 共享库。
链接器链式调用路径
go build -ldflags '-extldflags "-static"' main.go
# → 触发 go tool link → 调用系统 clang/gcc → 传递 -static 给其内置 ld
-extldflags "-static" 并非直接传给 Go linker(cmd/link),而是透传给外部 C 链接器(如 gcc),强制其执行完全静态链接——所有符号(含 malloc、getaddrinfo)均从 libc.a 解析,而非 libc.so。
关键约束与行为
- 仅对 CGO 启用时生效(纯 Go 程序默认已静态链接);
- 若依赖的 C 库无静态版本(如
libssl.a缺失),链接失败; - 生成二进制体积显著增大(嵌入完整
libc.a)。
| 链接模式 | 依赖 libc.so | 可移植性 | 启动速度 |
|---|---|---|---|
| 默认(动态) | ✅ | ❌(需目标机有对应 glibc) | ⚡较快(延迟绑定) |
-extldflags "-static" |
❌ | ✅(真正免依赖) | ⏱️ 略慢(全符号预解析) |
graph TD
A[go build] --> B[go tool link]
B --> C{CGO_ENABLED=1?}
C -->|Yes| D[调用 gcc -static]
C -->|No| E[纯 Go 静态链接]
D --> F[链接 libc.a + libgcc.a]
2.5 静态链接glibc二进制在CentOS容器中的符号依赖精简实操
静态链接 glibc 虽受官方 discouraged,但在极简容器场景中可显著削减动态依赖树。
核心限制与权衡
glibc不支持完全静态链接(--static会失败),需配合musl-gcc或glibc的有限静态模式;- CentOS 默认
glibc缺少libc_nonshared.a等静态构件,须手动补全或改用glibc-static包。
构建验证流程
# 安装静态支持并编译(仅含基础符号)
yum install -y glibc-static
gcc -static -o hello_static hello.c -Wl,--exclude-libs,ALL
-Wl,--exclude-libs,ALL告知链接器不从libgcc.a等非 glibc 静态库导出符号,避免污染;-static强制静态链接,但仅对具备.a形式的依赖生效。
依赖对比(ldd 输出)
| 二进制类型 | ldd 输出行数 |
关键依赖项 |
|---|---|---|
| 动态链接 | 5+ | libc.so.6, ld-linux… |
| 静态链接 | not a dynamic executable |
无运行时 .so 依赖 |
graph TD
A[源码hello.c] --> B[gcc -static]
B --> C{链接器解析}
C -->|命中libc.a| D[符号内联]
C -->|缺失libm.a| E[回退动态链接]
第三章:Alpine替代方案失效的系统级归因
3.1 Alpine中musl libc对getaddrinfo()实现差异的源码级对照分析
核心路径差异
glibc 走 sysdeps/posix/getaddrinfo.c + NSS 插件机制;musl 则在 src/network/getaddrinfo.c 中纯静态实现,无动态解析器加载。
关键行为分歧点
- musl 不支持
AI_ADDRCONFIG的 IPv6 接口检测(因跳过getifaddrs()调用) - 默认禁用
AF_UNSPEC下的 IPv6 回退(需显式设hints.ai_flags |= AI_V4MAPPED) - 解析超时硬编码为
5s(#define TIMEOUT 5),不可配置
源码片段对照(musl)
// src/network/getaddrinfo.c:212
if (hints->ai_flags & AI_ADDRCONFIG) {
// musl 中此分支为空 —— 直接忽略该 flag
}
逻辑分析:musl 认为
AI_ADDRCONFIG语义模糊且依赖内核接口稳定性,选择保守省略。参数hints->ai_flags在此上下文中被静默丢弃,导致跨平台 DNS 行为不一致。
| 特性 | glibc | musl |
|---|---|---|
AI_ADDRCONFIG 支持 |
✅(调用 getifaddrs) |
❌(无实现) |
AF_UNSPEC 默认策略 |
IPv6 优先 | IPv4 优先 |
graph TD
A[getaddrinfo call] --> B{hints.ai_family == AF_UNSPEC?}
B -->|musl| C[先查 /etc/hosts IPv4]
B -->|musl| D[再查 DNS A 记录]
C --> E[跳过 AAAA 除非 AI_V4MAPPED]
3.2 net.LookupIP在glibc/musl双环境下的syscall trace对比实验
为探究DNS解析底层行为差异,我们在Alpine(musl)与Ubuntu(glibc)容器中对 net.LookupIP("example.com") 执行 strace -e trace=socket,connect,sendto,recvfrom,getaddrinfo。
关键差异点
- glibc 调用
getaddrinfo()系统调用(实际为glibc封装的用户态函数,不进入内核),再触发socket/sendto/recvfrom; - musl 直接使用
socket+sendto/recvfrom实现精简DNS查询,无getaddrinfo系统调用痕迹。
syscall调用序列对比
| 环境 | 主要系统调用序列 |
|---|---|
| glibc | socket → sendto → recvfrom(经 getaddrinfo 封装) |
| musl | socket → sendto → recvfrom(纯用户态DNS协议栈) |
# Alpine (musl) 中典型片段
socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
sendto(3, "\276\210\1\0\0\1\0\0\0\0\0\0\7example\3com\0\0\1\0\1", 29, MSG_NOSIGNAL, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("1.1.1.1")}, 16)
recvfrom(3, "\276\210\201\200\0\1\0\1\0\0\0\0\7example\3com\0\0\1\0\1\300\12\0\1\0\1\0\0\0\17\0\4\220\1\2\264", 512, 0, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("1.1.1.1")}, [16]) = 46
此调用链表明:musl绕过传统NSS机制,直接构造DNS UDP报文;glibc则依赖
/etc/nsswitch.conf及libnss_dns.so动态加载路径,syscall trace更冗长。
3.3 Docker多阶段构建中libc混用导致的隐式动态依赖残留检测
在多阶段构建中,若构建阶段使用 glibc(如 ubuntu:22.04),而运行阶段使用 musl libc(如 alpine:3.19),二进制文件可能因链接时未显式指定 -static 而隐式携带 glibc 符号表,导致 ldd 检测失效、运行时 Symbol not found。
动态依赖残留的典型表现
- 运行时崩溃:
/lib/ld-musl-x86_64.so.1: cannot load needed library 'libc.so.6' file命令显示:dynamically linked (uses shared libs)
检测与验证方法
# 在目标镜像中检查二进制真实依赖(绕过ldd误判)
readelf -d ./app | grep 'NEEDED' | grep -E '(libc|libm|libpthread)'
此命令直接解析 ELF 动态段,避免
ldd因缺失对应 libc 而静默跳过;NEEDED条目中若出现libc.so.6,即表明存在 glibc 隐式依赖,与 musl 运行时不兼容。
| 工具 | 是否可靠识别 libc 混用 | 说明 |
|---|---|---|
ldd ./app |
❌ 否 | musl 环境下无法解析 glibc 符号 |
readelf -d |
✅ 是 | 直接读取 ELF 元数据,无运行时依赖 |
objdump -p |
✅ 是 | 输出 .dynamic 段,等价于 readelf |
graph TD
A[构建阶段:ubuntu:22.04] -->|gcc 默认动态链接 glibc| B[生成 ./app]
B --> C[COPY 到 alpine:3.19]
C --> D{readelf -d ./app<br/>含 libc.so.6?}
D -->|是| E[运行失败:符号缺失]
D -->|否| F[安全]
第四章:CentOS专属glibc静态精简术落地指南
4.1 使用musl-gcc交叉编译工具链构建轻量glibc兼容层
在资源受限的嵌入式或容器环境中,直接依赖完整glibc会引入冗余符号与动态链接开销。musl-gcc工具链提供了一条轻量替代路径:通过静态链接+符号重定向,模拟关键glibc ABI行为。
核心构建流程
- 下载并编译
musl源码(启用--enable-wrapper) - 配置
musl-gccwrapper 脚本,注入-Wl,--def指向兼容符号定义文件 - 使用
gcc -specs=...指定链接规范,覆盖默认glibc路径
兼容符号映射示例
// glibc_compat.def —— 控制导出符号集
EXPORTS
malloc @1
free @2
printf @3
// 仅暴露POSIX.1-2008子集,剔除__libc_start_main等内部符号
此
.def文件被ld --def解析,强制生成符合glibc ABI签名的符号表;@N序号确保调用约定稳定,避免PLT跳转偏移错位。
| 功能 | musl原生 | glibc兼容层 | 差异说明 |
|---|---|---|---|
dlopen() |
✅ | ⚠️(stub) | 返回NULL+设errno |
getaddrinfo() |
✅ | ✅(重定向) | 经由libanl.a桥接 |
graph TD
A[源码.c] --> B[musl-gcc -specs=glibc.specs]
B --> C[链接器读取glibc_compat.def]
C --> D[生成兼容SO/静态存根]
D --> E[运行时符号解析命中glibc ABI]
4.2 patchelf修改RPATH与剥离无用.so依赖的生产级脚本封装
在构建可移植Linux二进制分发包时,动态链接路径混乱和冗余.so依赖是常见痛点。patchelf是轻量、可靠的核心工具,但裸用易出错。
核心能力边界
- ✅ 修改
RPATH/RUNPATH(支持$ORIGIN相对路径) - ✅ 剥离未被符号引用的
.so(需配合readelf -d与nm -D验证) - ❌ 无法修复缺失符号或重写 ELF 结构体布局
生产级封装设计原则
- 幂等性:重复执行不改变已合规二进制
- 安全性:自动备份原文件(
.orig后缀) - 可追溯:记录
RPATH变更前/后值及移除的库列表
# 生产脚本核心片段(节选)
patchelf \
--set-rpath '$ORIGIN/lib:$ORIGIN/../lib' \
--print-rpath "$BIN" 2>/dev/null | \
grep -q '\$ORIGIN' || exit 1
逻辑说明:
--set-rpath强制注入安全相对路径;--print-rpath验证生效,grep -q实现断言式校验,失败即中止流水线,防止静默错误传播。
| 操作阶段 | 工具链组合 | 输出验证方式 |
|---|---|---|
| RPATH 重写 | patchelf --set-rpath |
readelf -d $BIN \| grep PATH |
| 无用 .so 清理 | patchelf --remove-needed + ldd 对比 |
diff <(ldd old) <(ldd new) |
graph TD
A[输入二进制] --> B{是否含绝对RPATH?}
B -->|是| C[备份并patchelf重写]
B -->|否| D[跳过RPATH处理]
C --> E[扫描所有DT_NEEDED项]
E --> F[调用nm -D过滤未解析符号]
F --> G[移除无引用的.so]
4.3 go mod vendor + go build -trimpath -buildmode=pie的最小化组合策略
在构建可复现、轻量且安全的 Go 二进制时,go mod vendor 与 -trimpath -buildmode=pie 形成关键协同。
为什么需要 vendor?
- 隔离依赖版本,规避网络/代理导致的构建失败
- 确保 CI/CD 环境与本地构建行为一致
构建命令组合
go mod vendor # 将所有依赖复制到 ./vendor/
go build -trimpath -buildmode=pie -o myapp . # 构建无路径信息、位置无关可执行文件
-trimpath:移除编译结果中的绝对路径,提升可复现性与安全性(避免泄露开发机路径)-buildmode=pie:生成位置无关可执行文件,启用 ASLR,增强运行时防护能力
关键参数对比表
| 参数 | 作用 | 是否影响体积 | 安全增益 |
|---|---|---|---|
-trimpath |
清除源码路径信息 | 否 | ✅(防路径泄漏) |
-buildmode=pie |
启用地址空间随机化 | 微增(+~2%) | ✅✅(缓解 ROP 攻击) |
graph TD
A[go mod vendor] --> B[依赖锁定至本地]
B --> C[go build -trimpath]
C --> D[go build -buildmode=pie]
D --> E[最小化、可复现、安全二进制]
4.4 基于CentOS Stream 8/9的glibc-minimal RPM定制与镜像分层优化
为精简容器镜像体积并提升构建可复现性,需剥离 glibc 中非运行时必需组件,仅保留 ld-linux-x86-64.so.2、libc.so.6 及基础 locale 数据。
构建定制化 RPM
# glibc-minimal.spec(关键片段)
%package minimal
Summary: Minimal glibc runtime for containerized workloads
Requires: %{name}-common = %{version}-%{release}
%files minimal
%{_libdir}/libc.so.6
%{_libdir}/ld-linux-x86-64.so.2
%{_datadir}/locale/en_US.utf8/
该 spec 显式限定文件白名单,避免 glibc-all-langpacks 等冗余依赖被拉入;Requires 确保基础元数据一致性。
分层优化策略
| 层级 | 内容 | 复用率 |
|---|---|---|
| base | glibc-minimal + ca-certificates |
高 |
| runtime | bash, coreutils |
中 |
| app | 用户二进制与配置 | 低 |
构建流程
graph TD
A[源码解压] --> B[patch: 移除nscd/pt_chown]
B --> C[rpm-build --define 'minimal_build 1']
C --> D[生成glibc-minimal-*.rpm]
第五章:从体积暴增到极致精简的技术范式跃迁
过去五年,前端应用包体积平均增长320%——某电商中台项目v1.0构建产物为4.2MB(gzip后),升级至微前端架构+全量UI组件库+冗余Polyfill后,v3.5版本主包飙升至18.7MB。用户在3G网络下首屏加载耗时从1.8s恶化至12.4s,跳出率上升37%。这并非孤例:2023年Webpack Bundle Analyzer统计显示,TOP 100企业级React应用中,63%的node_modules依赖存在未使用导出(unused exports),平均浪费空间达2.1MB。
构建时Tree Shaking失效的典型场景
// utils/index.js
export const formatCurrency = (val) => `$${val.toFixed(2)}`;
export const formatDate = (date) => date.toISOString().split('T')[0];
export const deepClone = (obj) => JSON.parse(JSON.stringify(obj)); // 实际项目中从未调用
// 组件中仅引入formatCurrency
import { formatCurrency } from './utils';
Webpack 5默认启用sideEffects: false,但若package.json未声明"sideEffects": ["*.css"]或"sideEffects": false,或使用动态require()、非ESM语法导入,Tree Shaking即失效。某金融系统因遗留CommonJS模块混用,导致Lodash 4.17.21中92%的函数被强制打包。
精简策略落地四步法
| 步骤 | 工具链动作 | 实测压缩效果(某CRM系统) |
|---|---|---|
| 依赖审计 | depcheck + npm ls --depth=0 |
发现17个未引用包(含废弃moment) |
| 模块替换 | date-fns替代moment(+ tree-shakable imports) |
减少1.4MB(gzip后) |
| 运行时分包 | Webpack SplitChunksPlugin配置chunks: 'all' + minSize: 20000 |
vendor chunk从8.3MB→2.1MB |
| 字节级优化 | terser-webpack-plugin启用compress.drop_console: true + mangle: { reserved: ['React'] } |
额外减少380KB |
动态导入与预加载协同机制
flowchart LR
A[用户进入订单页] --> B{是否已加载OrderForm组件?}
B -->|否| C[执行import\\(\"./OrderForm.vue\"\\).then\\(render\\)]
C --> D[触发preload:link rel=“modulepreload” href=“order-form.chunk.js”]
B -->|是| E[直接渲染缓存实例]
D --> F[浏览器并行预加载,降低后续操作延迟]
某SaaS平台将表单编辑器拆分为独立chunk,配合<link rel="modulepreload">,用户点击“新建订单”按钮后,组件加载耗时从2.3s降至380ms。更关键的是,通过IntersectionObserver监听滚动位置,在用户滑动至表单区域前500px时触发预加载,使首屏外模块的冷启动延迟归零。
CSS-in-JS的体积陷阱与解法
Styled-components v5默认注入全部CSS规则,某管理后台生成127KB未压缩样式代码。切换至@emotion/styled + babel-plugin-emotion的hoist模式后,仅保留运行时实际渲染的样式规则,CSS体积下降64%。同时禁用@emotion/react的css prop自动注入,改用显式css模板字面量导入,避免Babel插件全局扫描。
WASM加速的边界实践
对报表引擎中的数值聚合计算模块,使用Rust编写WASM模块(wasm-pack build --target web),替代原JavaScript版d3-array排序逻辑。在10万条数据聚合场景下,执行时间从840ms降至92ms,且WASM二进制文件经wabt工具优化后仅21KB(gzip后)。但需注意:Chrome 110+才支持WASM Exception Handling,旧版IE完全不可用,故采用渐进增强策略——检测WebAssembly.compile可用性后动态加载。
