我用 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,看一眼 layer:
dockerhistorymyapp: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_ENV=production" "PATH=/bin:/nix/store/.../bin" ]; }; maxLayers = 100; }; in { images.x86_64-linux = runtimeImage; }; }编出来 80MB。看着缩水 93%,但请注意这里有个关键点:npmDepsHash必须准确。
踩坑记录(4 条)
坑 1:npmDepsHash 怎么都对不上
第一次编,Nix 提示:
error: hash mismatch in fixed-output derivation specified: sha256-xxxxxx= got: sha256-yyyyyy=原因是npmDepsHash是把package-lock.json+node_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.conf<<EOF 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 秒,且大部分步骤是下载预编译产物。
坑 4:Docker 镜像怎么 push 到 Kubernetes
Nix 生成的镜像是一个 tarball(dockerTools.buildLayeredImage输出result),用docker load加载:
nix build.#images.x86_64-linuxdockerload<result# Loaded image: myapp:latest# 或者直接 skopeo:skopeo 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)上同时编同一个 commit:
nix 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 --omit=dev就够了,不必上 Nix。但当你需要"signature build"——同一个 commit 永远编出同一个字节序列——Nix 几乎是唯一靠谱的解。
写在最后
Nix 不是银弹,但它解决了 Dockerfile 三个根本问题:基础镜像漂移、构建副作用、版本不可复现。换完之后我们 CI 出错率从每月 3-4 次降到了 0,因为"我这跑得好好的"这种问题在 hash 一致的前提下根本不存在。
完全迁移到 Nix 用了 2 周(包括踩坑),ROI 大概 3 个月回本(节省的 disk + 节省的"为什么我 build 出来不一样"调试时间)。
有问题评论区交流,看到就回。