Nix 构建 Docker 镜像

Nix 提供了比传统 Dockerfile 更优的容器镜像构建方案。本文详解 nixpkgs dockerTools、OCI 镜像构建、层缓存优化、多阶段构建以及与传统 Docker 工作流的对比。

1. 传统 Dockerfile 的痛点

Docker 容器化彻底改变了软件部署方式,但 Dockerfile 本身存在几个根本性问题:

  • 不稳定的构建:apt-get update && apt-get install 的结果取决于构建时远程仓库的状态,今天构建和明天构建可能产生不同的镜像
  • 层缓存陷阱:不当的指令排序导致缓存失效,Dockerfile 越写越长、越来越复杂
  • 图像膨胀:ubuntu:22.04 基础镜像已有 77MB,加上 Python 、Node 等运行时,最终镜像动辄数百 MB,而实际业务代码可能只有几十 MB
  • 重复依赖:10 个微服务都用了不同版本的 node:18-alpine,每个镜像独立重复存储依赖
  • 安全问题:apt-get install 引入的依赖包数量不可控,漏洞扫描发现大量并不需要的库

Nix 通过纯函数式构建和精确依赖追踪,从根本上解决了这些问题。

2. Nix 构建 Docker 镜像的原理

Nix 构建 Docker 镜像的核心思想是:不使用 Dockerfile,而是直接用 Nix 表达式描述镜像内容。Nix 会:

  1. 计算出镜像所需的所有依赖的精确集合
  2. 只包含运行时真正需要的文件(不包含编译工具、文档等)
  3. 将结果打包为兼容 Docker 的 tar 归档
  4. 通过 docker load 导入 Docker

由于 Nix 知道每个文件的精确依赖关系,最终镜像只包含运行应用所必需的文件——通常比传统 Docker 镜像小 50% 到 90%。

3. dockerTools 基础

3.1 构建最小镜像

# docker-image.nix
{ pkgs ? import <nixpkgs> {} }:

let
  # 创建一个最小化的 Docker 镜像
  myAppImage = pkgs.dockerTools.buildLayeredImage {
    name = "my-app";
    tag = "latest";
    
    # 镜像内容
    contents = [ 
      pkgs.hello       # GNU Hello 程序
    ];

    # 配置
    config = {
      Cmd = [ "/bin/hello" ];
      Env = [ "PATH=/bin" ];
    };
  };
in
  myAppImage
# 构建镜像
nix-build docker-image.nix

# 加载到 Docker
docker load < result

# 运行
docker run my-app:latest

3.2 构建 Node.js 应用镜像

# node-app-image.nix
{ pkgs ? import <nixpkgs> {} }:

let
  # 定义 Node.js 应用
  myNodeApp = pkgs.stdenv.mkDerivation {
    name = "my-node-app";
    src = ./.;  # 当前目录

    buildInputs = with pkgs; [ nodejs_20 yarn ];

    buildPhase = ''
      yarn install --frozen-lockfile
      yarn build
    '';

    installPhase = ''
      mkdir -p $out/app
      cp -r dist node_modules package.json $out/app/
    '';
  };
in
  pkgs.dockerTools.buildLayeredImage {
    name = "my-node-app";
    tag = "latest";
    
    contents = [
      myNodeApp
      pkgs.nodejs_20    # 包含 Node.js 运行时
      pkgs.bash         # 容器内可能用到 bash
      pkgs.coreutils    # 基本命令
    ];

    config = {
      WorkingDir = "/app";
      Cmd = [ "node" "/app/dist/index.js" ];
      Env = [
        "NODE_ENV=production"
        "PATH=${pkgs.nodejs_20}/bin:${pkgs.bash}/bin"
      ];
    };
  }

4. 镜像优化策略

4.1 分层优化

buildLayeredImage 自动将依赖分层。Nix 的 store path 哈希使得不相关的依赖可以分布在不同层中,提高缓存命中率:

pkgs.dockerTools.buildLayeredImage {
  name = "my-app";
  tag = "latest";
  
  # 分层策略
  contents = [ myApp ];
  
  # 最大层数(Docker 限制 127 层)
  maxLayers = 100;
  
  # 自定义额外命令
  extraCommands = ''
    mkdir -p app/data
    chmod 755 app/data
  '';
}

4.2 使用 nix 用户(非 root)

pkgs.dockerTools.buildLayeredImage {
  name = "my-app";
  
  contents = [ myApp pkgs.bash ];
  
  # 创建非 root 用户
  fakeRootCommands = ''
    ${pkgs.dockerTools.shadowSetup}
    groupadd -r appgroup
    useradd -r -g appgroup appuser
    chown -R appuser:appgroup /app
  '';

  config = {
    User = "appuser";
    Cmd = [ "/app/bin/my-app" ];
  };
}

4.3 health check

pkgs.dockerTools.buildLayeredImage {
  name = "my-app";
  
  contents = [ myApp pkgs.curl ];

  config = {
    Cmd = [ "/app/bin/my-app" ];
    Healthcheck = {
      Test = [ "CMD" "curl" "-f" "http://localhost:8080/health" ];
      Interval = 30000000000;  # 30 秒(纳秒)
      Timeout = 10000000000;   # 10 秒
      Retries = 3;
    };
  };
}

5. 多阶段构建

Nix 天然支持"多阶段"构建——编译产物和运行时依赖可以自由分离:

# multi-stage-image.nix
{ pkgs ? import <nixpkgs> {} }:

let
  # 阶段 1:编译环境(包含所有构建依赖)
  buildEnv = pkgs.buildEnv {
    name = "build-env";
    paths = [ 
      pkgs.rustc 
      pkgs.cargo 
      pkgs.openssl 
      pkgs.pkg-config 
    ];
  };

  # 阶段 2:编译应用
  rustApp = pkgs.rustPlatform.buildRustPackage {
    pname = "my-rust-app";
    version = "0.1.0";
    src = ./.;
    cargoSha256 = "sha256-xxx=";
    
    nativeBuildInputs = [ pkgs.pkg-config ];
    buildInputs = [ pkgs.openssl ];
  };

  # 阶段 3:只包含运行时的镜像
  runtimeImage = pkgs.dockerTools.buildLayeredImage {
    name = "my-rust-app";
    tag = "latest";
    
    # 只包含运行时依赖
    contents = [
      rustApp
      pkgs.bash
      pkgs.coreutils
      # 注意:没有 rustc, cargo, gcc 等编译工具!
    ];

    config = {
      Cmd = [ "${rustApp}/bin/my-rust-app" ];
    };
  };

in
  runtimeImage

与传统 Dockerfile 的多阶段不同,Nix 的版本是声明式的:你不需要在 Dockerfile 中写多个 FROM 和 COPY --from,Nix 会自动分析哪些文件是运行时必需的,只把它们放入最终镜像。

6. Flakes 集成

6.1 用 Flakes 构建 Docker 镜像

# flake.nix
{
  description = "My App with Docker Image";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        pkgs = nixpkgs.legacyPackages.${system};

        myApp = pkgs.buildGoModule {
          pname = "my-go-app";
          version = "0.1.0";
          src = ./.;
          vendorSha256 = "sha256-xxx=";
        };

        dockerImage = pkgs.dockerTools.buildLayeredImage {
          name = "my-go-app";
          tag = "latest";
          contents = [ myApp pkgs.bash pkgs.cacert ];
          config = {
            Cmd = [ "${myApp}/bin/my-go-app" ];
            Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ];
          };
        };
      in
      {
        packages = {
          default = myApp;
          docker = dockerImage;
        };

        apps.default = flake-utils.lib.mkApp {
          drv = myApp;
        };
      });
}
# 构建 Docker 镜像
nix build .#docker

# 加载到 Docker
docker load < result

# 推送(可选)
docker tag my-go-app:latest registry.example.com/my-go-app:latest
docker push registry.example.com/my-go-app:latest

6.2 CI/CD 中自动构建和推送

# .github/workflows/docker.yml
name: Build and Push Docker Image

on:
  push:
    tags: [ 'v*' ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Install Nix
        uses: cachix/install-nix-action@v24
        with:
          extra_nix_config: |
            experimental-features = nix-command flakes
      
      - name: Build Docker Image
        run: nix build .#docker
      
      - name: Load and Push
        run: |
          docker load < result
          docker tag my-go-app:latest ghcr.io/${{ github.repository }}:${{ github.ref_name }}
          echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}

7. Skopeo 推送

对于不想在构建机安装 Docker 的场景,可以使用 skopeo 直接推送:

{ pkgs ? import <nixpkgs> {} }:

let
  image = pkgs.dockerTools.buildLayeredImage {
    name = "my-app";
    tag = "latest";
    contents = [ myApp ];
  };
in
  pkgs.writeShellScriptBin "push-image" ''
    ${pkgs.skopeo}/bin/skopeo copy \
      docker-archive:${image} \
      docker://registry.example.com/my-app:latest \
      --dest-creds $USER:$PASS
  ''

8. 与传统 Docker 的对比

特性DockerfileNix dockerTools
构建可复现性弱(依赖远程仓库状态)强(哈希锁定所有内容)
镜像大小通常较大(包含不需要的文件)小(精确运行时依赖)
安全扫描漏洞多(包数量大)漏洞少(包数量精确控制)
缓存机制基于层签名基于 Nix store 路径
多阶段需要显式 COPY自动分离构建和运行时
学习曲线较低较高(需要 Nix 知识)
生态兼容性通用Nix 生态专用

9. 混合工作流

对于已经在使用 Docker 的团队,可以采用渐进式引入:

# 在 Nix 中定义基础镜像层
{ pkgs ? import <nixpkgs> {} }:

let
  # 构建轻量级基础层
  baseLayer = pkgs.dockerTools.buildLayeredImage {
    name = "my-base";
    tag = "latest";
    contents = with pkgs; [
      cacert
      tzdata
      bash
      coreutils
    ];
  };
in
  pkgs.writeShellScriptBin "export-base" ''
    echo "Building base layer..."
    docker load < ${baseLayer}
    echo "Export base layer Dockerfile:"
    echo "FROM ${baseLayer.imageName}:${baseLayer.imageTag}"
  ''
# Dockerfile(使用 Nix 构建的基础层)
FROM my-base:latest

COPY ./app /app
WORKDIR /app

RUN npm install --production

CMD ["node", "index.js"]

10. 总结

Nix 构建 Docker 镜像提供了一条从根本上解决容器安全性和体积问题的路径:

  1. 精确依赖分析:只包含真正运行时需要的文件
  2. 哈希锁定:每次构建都产生确定性结果
  3. 自动优化:无需手动优化 Dockerfile 层排序
  4. 安全基线:最小化攻击面,减少漏洞暴露

对于已经采用 Nix 作为核心技术栈的团队,用 Nix 构建容器镜像是水到渠成的选择。它可以显著减小镜像体积(通常减少 70% 至 90%),并从根本上消除"构建漂移"问题。虽然初期学习成本高于 Dockerfile,但长期收益——更小的镜像、更少的漏洞和完全可复现的构建——使其成为值得的投资。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「devops」更多文章

  1. 值班工程与告警疲劳治理
  2. 基础设施代码测试:Terratest、Kitchen 与 InSpec
  3. 发布列车与版本节奏治理