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 会:
- 计算出镜像所需的所有依赖的精确集合
- 只包含运行时真正需要的文件(不包含编译工具、文档等)
- 将结果打包为兼容 Docker 的 tar 归档
- 通过
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 的对比
| 特性 | Dockerfile | Nix 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 镜像提供了一条从根本上解决容器安全性和体积问题的路径:
- 精确依赖分析:只包含真正运行时需要的文件
- 哈希锁定:每次构建都产生确定性结果
- 自动优化:无需手动优化 Dockerfile 层排序
- 安全基线:最小化攻击面,减少漏洞暴露
对于已经采用 Nix 作为核心技术栈的团队,用 Nix 构建容器镜像是水到渠成的选择。它可以显著减小镜像体积(通常减少 70% 至 90%),并从根本上消除"构建漂移"问题。虽然初期学习成本高于 Dockerfile,但长期收益——更小的镜像、更少的漏洞和完全可复现的构建——使其成为值得的投资。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。