我用 Nix 重写 Docker 镜像构建,把镜像从 1.2GB 压到 80MB 且可复现

📅 2026/7/28 17:06:45
我用 Nix 重写 Docker 镜像构建,把镜像从 1.2GB 压到 80MB 且可复现
我用 Nix 重写 Docker 镜像构建把镜像从 1.2GB 压到 80MB 且可复现说实话我一开始是拒绝用 Nix 的。那天我们项目的 Node.js 镜像在生产环境又出问题了——同样是node:20-slim基础镜像同样是package.json没变但 CI 流水线今天编出来 1.2GB昨天编出来 1.18GB。我换了一台机器本地点 build又变成 1.31GB。包版本没动Dockerfile 没动。这一行行拍着脑袋写的RUN apt-get install npm install组合压根就不可复现。最后我赌气把整个构建换成了 Nix。结果镜像 80MB构建时间从 9 分钟压到 4 分 10 秒hash 准准地锁住每次构建的字节级产物。今天聊聊我踩过的坑。为什么默认 Dockerfile 不可复现Dockerfile 不可复现不是玄学是它在三层上都漂浮。第一层基础镜像漂移。node:20-slim每次 pull 拿到的 debian 层都不完全一样apt 源里包的版本每天都在动。apt-get update apt-get install -y libssl-dev这一行今天编出的libssl-dev是 3.0.11明天可能是 3.0.13。它们的依赖链不一致最终镜像 layer 就漂了。第二层包管理器漂移。npm install拿到的依赖版本受package-lock.json约束但npm自身的 patch 版本差异 平台差异glibc vs musl 缓存命中状态都会让node_modules树在字节级别不一致。第三层构建时间漂移。Docker 构建中的RUN步骤是有 side effect 的你装包时多安了个wget改 Dockerfile 删掉但 layer cache 没失效下一次 build 镜像里仍然有wget。这个问题叫 “layer cache poisoning”CI 上半年能炸一次。Nix 解决的就是这三点所有依赖显式声明、源文件哈希校验、构建无副作用。第一次试水把一个 Node.js 服务迁到 Nix我们项目是常见的 Node.js TypeScript 服务目录结构很标准. ├── package.json ├── package-lock.json ├── tsconfig.json ├── src/ └── Dockerfile原来的 Dockerfile 长这样简化版FROM node:20-slim RUN apt-get update apt-get install -y \ python3 python3-pip build-essential \ libssl-dev libffi-dev \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build CMD [node, dist/server.js]编出来 1.2GB看一眼 layerdockerhistorymyapp:latest# 1.2GB 总计# - node_modules 占了 540MB# - apt-get 装的 build-essential python3 占了 380MB# - 基础镜像 180MB# - 剩下的产物代码痛点python3和build-essential只是为了node-gyp编译原生模块用的运行时根本不需要但多阶段构建我们没做历史债。我用 Nix 重写后flake.nix是这样的{ description MyApp production image; inputs.nixpkgs.url github:NixOS/nixpkgs/nixos-24.05; outputs { self, nixpkgs }: let pkgs import nixpkgs { system x86_64-linux; }; nodejs pkgs.nodejs_20; # 关键用 mkDerivation 显式声明所有依赖 appEnv pkgs.mkDerivation { name myapp-env; src ./.; nativeBuildInputs [ pkgs.makeWrapper ]; buildInputs [ nodejs pkgs.python3 pkgs.libffi pkgs.openssl pkgs.pkg-config ]; # 关键明确说不需要 devDependencies 在运行时 npmBuildScript build; npmDepsHash sha256-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx; # 只把 dist node_modules/production 拷进 $out installPhase mkdir -p $out/lib/myapp cp -r dist $out/lib/myapp/ cp -r node_modules $out/lib/myapp/ # 排除 .map / .ts / 测试文件 find $out/lib/myapp -name *.map -delete find $out/lib/myapp -name *.ts -not -path */node_modules/* -delete find $out/lib/myapp -name test -type d -exec rm -rf {} 2/dev/null || true ; }; # 运行时只保留真正的运行时依赖 runtimeImage pkgs.dockerTools.buildLayeredImage { name myapp; tag latest; contents [ appEnv pkgs.cacert ]; config { Cmd [ /bin/myapp-server ]; Env [ NODE_ENVproduction PATH/bin:/nix/store/.../bin ]; }; maxLayers 100; }; in { images.x86_64-linux runtimeImage; }; }编出来 80MB。看着缩水 93%但请注意这里有个关键点npmDepsHash必须准确。踩坑记录4 条坑 1npmDepsHash 怎么都对不上第一次编Nix 提示error: hash mismatch in fixed-output derivation specified: sha256-xxxxxx got: sha256-yyyyyy原因是npmDepsHash是把package-lock.jsonnode_modules整棵树哈希算出来的。你改了 package.json 没改 hash 必然对不上。但另一个坑是lock 文件里有 install script 的必须给npmDepsHash而不是npmConfigHook。解决办法先随便填一个伪 hash让 Nix 报错并打印真实 hash复制过去# 错误信息里会有一行# got: sha256-abcdef1234567890# 把这个填到 npmDepsHash坑 2原生模块编译失败我们用了sharp图像处理它在 install 时会下载预编译的二进制而不是本地编译。这种包在 Nix 里特别麻烦因为 Nix 默认 sandbox 不开网络。解法用nodePackages.sharp而不是 npm 装。Nixpkgs 里有现成的sharp包装buildInputs [ pkgs.nodejs_20 pkgs.nodePackages.sharp # 用 Nix pkgs 的 sharp不要 npm install sharp ];然后从package.json里删掉sharp依赖运行时从node_modules/sharp改成引用 nix store 路径。坑 3构建时间反而变长了第一次编 12 分钟比原来 9 分钟还慢。原因是 Nix 默认从源代码编译一切连 openssl 都要自己编。解法用官方 binary cache# 用户级配置mkdir-p~/.config/nixcat~/.config/nix/nix.confEOF substituters https://cache.nixos.org https://my-company-cache.example.com trusted-public-keys cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY experimental-features nix-command flakes EOF打开 binary cache 后构建时间直接压到 4 分 10 秒且大部分步骤是下载预编译产物。坑 4Docker 镜像怎么 push 到 KubernetesNix 生成的镜像是一个 tarballdockerTools.buildLayeredImage输出result用docker load加载nix build.#images.x86_64-linuxdockerloadresult# Loaded image: myapp:latest# 或者直接 skopeoskopeo copy docker-archive:result docker://registry.example.com/myapp:v1.0.0CI 集成时改成nix build.#images.x86_64-linux --json | jq -r .[0].outputs.out | xargs -I {} skopeo copy docker-archive:{} docker://registry.example.com/myapp:$CI_COMMIT_SHA复现性验证三个机器编出来 hash 一致这是 Nix 最爽的地方。我让三个工程师在三个不同的机器macOS M2、Ubuntu 22.04、CentOS Stream 9上同时编同一个 commitnix build.#images.x86_64-linux --print-out-paths# macOS M2: /nix/store/xxx-myapp.drv - sha256:abc123# Ubuntu 22.04: /nix/store/xxx-myapp.drv - sha256:abc123# CentOS 9: /nix/store/xxx-myapp.drv - sha256:abc123三条命令输出完全相同的 hash。Dockerfile 时代这是不可能完成的每个机器的 glibc 版本、kernel headers、默认 locales 都不一样编出来的镜像在字节级别必然漂。谁适合用 Nix谁不适合最后聊点大实话。适合用 Nix 的场景安全敏感的供应链金融、密码学、加密通信复现性是硬指标的研究/科学计算monorepo 多语言构建同时有 Go、Node.js、Python、Rust团队被 “works on my machine” 折磨得痛不欲生不适合用 Nix 的场景团队没人懂 Nix学习曲线陡峭Flake 是另一门语言只是小项目、Dockerfile 够用对镜像大小没那么敏感、能接受 1GB 镜像如果你只是想在 Dockerfile 基础上优化distroless 多阶段构建 npm ci --omitdev就够了不必上 Nix。但当你需要signature build——同一个 commit 永远编出同一个字节序列——Nix 几乎是唯一靠谱的解。写在最后Nix 不是银弹但它解决了 Dockerfile 三个根本问题基础镜像漂移、构建副作用、版本不可复现。换完之后我们 CI 出错率从每月 3-4 次降到了 0因为我这跑得好好的这种问题在 hash 一致的前提下根本不存在。完全迁移到 Nix 用了 2 周包括踩坑ROI 大概 3 个月回本节省的 disk 节省的为什么我 build 出来不一样调试时间。有问题评论区交流看到就回。