Docker镜像优化终极指南

当我第一次使用Docker将一个Node.js应用程序部署到生产环境时,我感到震惊,最终的镜像大小竟然是惊人的1.2 GB。仅仅是为了几KB的JavaScript代码。这不仅仅是存储空间的浪费。过大的镜像减慢了部署速度,增加了成本,并扩大了我们的攻击面。经过几天在真实项目中的尝试和错误,我发现了一些技术,帮助我将Docker镜像缩小了高达95%

"每兆字节都很重要,当你每天部署数千个容器时。"

在这篇文章中,我会带你经历这段旅程,从臃肿的起点到精简、适合生产的容器。

1、膨胀镜像的真实影响

镜像大小直接影响您的应用程序在生产中的表现。一个1GB镜像与一个50MB镜像之间的差异会影响启动时间、基础设施成本等各个方面。

  • 更快的部署: 更小的镜像在CI/CD管道中移动得更快。当您每天部署数十个服务时,每减少几分钟的部署时间都会迅速累积。
  • 降低成本: 精简的镜像消耗更少的存储。团队在修剪镜像膨胀后看到了高达60%的容器注册表成本节约。
  • 更好的安全态势: 较少的包意味着较少的攻击向量。在一个案例中,超过三分之二的标记漏洞与不必要的依赖有关。

Docker镜像大小直接影响应用程序的启动时间,这影响到自动扩展、恢复时间和用户体验。

2、我们的示例应用

让我们处理一个典型的Express应用程序,它代表了您可能在生产环境中部署的Node.js微服务:

它有基本的依赖项,如用于路由的express、用于时间格式化的moment、用于MongoDB交互的mongoose,以及一些中间件,如用于处理跨源请求和改进安全性的corshelmet。在现实世界的微服务中,您可能还会有日志记录、指标或身份验证中间件,但为了简单起见,我们在这里只保留这些基本功能。

首先初始化一个新的Node.js项目:

mkdir docker-shrink  
cd docker-shrink  
npm init -y

安装依赖项:

npm install express moment mongoose cors helmet  
npm install --save-dev nodemon jest supertest

现在创建一个index.js文件:

const express = require("express");  
const moment = require("moment");  
const cors = require("cors");  
const helmet = require("helmet");  

// 初始化express  
const app = express();  
const PORT = process.env.PORT || 3000;  

// 中间件  
app.use(cors());  
app.use(helmet());  
app.use(express.json());  

// 路由  
app.get("/", (req, res) => {  
  res.json({  
    message: "Hello from a slim Docker container!",  
    timestamp: moment().format("MMMM Do YYYY, h:mm:ss a"),  
    uptime: process.uptime(),  
  });  
});  

app.get("/health", (req, res) => {  
  res.json({ status: "UP", memory: process.memoryUsage() });  
});  

// 错误处理中间件  
app.use((err, req, res, next) => {  
  console.error(err.stack);  
  res.status(500).json({ error: "Something went wrong!" });  
});  

// 启动服务器  
if (process.env.NODE_ENV !== "test") {  
  app.listen(PORT, () => {  
    console.log(`Server running on port ${PORT}`);  
  });  
}  

module.exports = app; // 用于测试

现在让我们开始容器化这个应用程序。

3、标准基线方法

像大多数开发人员一样,我最初使用了这样的Dockerfile,它是从教程或Stack Overflow帖子复制过来的,没有多想就使用了node:latest作为基础镜像。

FROM node:latest  

WORKDIR /app  

COPY package*.json ./  
RUN npm install  

COPY . .  

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

构建这个镜像:

docker build -t node-app:phase1 .  
docker images | grep node-app

1.22 GB,这个镜像非常庞大。我的应用程序代码可能只有几个兆字节,但该镜像包含了整个Linux发行版、Node.js运行时以及许多我不需要的依赖项。

测试我们的基线镜像

让我们确保我们的应用程序在继续优化之前正常工作:

docker run -d -p 3000:3000 node-app:phase1  
curl http://localhost:3000

太好了!我们的应用程序工作正常。现在让我们开始优化。

4、选择合适的基镜像

我的第一个优化是切换到更轻量的基础镜像,这带来了几个好处:

  • 它消除了不需要的软件包和实用程序,这些在运行时并不需要。
  • 它减少了容器的攻击面。
  • 它显著提高了构建和部署期间的构建和拉取速度。

以下是更新后的Dockerfile:

FROM node:slim  

WORKDIR /app  

COPY package*.json ./  
RUN npm install  

COPY . .  

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

让我们测量一下:

docker build -t node-app:phase2 . -f Dockerfile.phase2  
docker images | grep node-app

图像减少了345 MB,即71%的减少,只需一个词的改变!选择基础镜像是影响容器大小的最有力决定。

测试我们的精简镜像

让我们确保我们的应用程序仍然可以在更精简的镜像中正常工作:

docker run -d -p 3000:3000 node-app:phase2  
curl http://localhost:3000

完美!我们的应用程序仍然可以正常工作,但镜像已经显著缩小。

5、使用Alpine基础镜像

接下来,我们进一步使用Alpine,这是一种以极小足迹和安全性著称的最小化Linux发行版。使用Alpine不仅会大幅削减镜像大小,还会极大地减少攻击面。

FROM node:alpine  

WORKDIR /app  

COPY package*.json ./  
RUN npm install  

COPY . .  

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

让我们构建并检查镜像大小:

docker build -t node-app:phase3 . -f Dockerfile.phase3  
docker images | grep node-app

我们现在达到了258MB,令人印象深刻的78%减少,从最初的1.22 GB开始。

测试我们的基于Alpine的镜像

让我们验证我们的基于Alpine的镜像是否正常工作:

docker run -d -p 3000:3000 node-app:phase3  
curl http://localhost:3000

Alpine之所以如此有效,是因为它设计得很简洁。它使用musl libc而不是glibc,BusyBox而不是GNU核心实用程序,并且提供了一个更小的软件包存储库。整个发行版仅重5 MB。

但我在一个客户项目上遇到了一个艰难的教训。我们迁移到了Alpine,在开发和测试阶段一切正常,但在生产环境中开始出现随机崩溃。深入挖掘后,我们发现问题是:一个本地依赖项在musl libc下的行为与glibc不同。

从那以后,我养成了一个最佳实践,即在使用Alpine进行构建时要彻底验证,特别是在处理依赖于编译本地模块的应用程序时。

6、多阶段构建和.dockerignore文件

我逐渐依赖的一种强大技术是多阶段构建,这种方法允许你将构建环境与运行时环境分开,同时保持所有开发工具的好处,从而减少镜像大小。通过多阶段构建,您可以使用功能齐全的Node镜像来编译、测试或捆绑您的应用程序,然后仅将必要的工件复制到干净、最小的运行时镜像中,通常基于Alpine。这种方法不仅剔除了不必要的文件和开发依赖项,还确保最终容器瘦小、安全且适合生产。

你可以将.dockerignore视为Docker构建的.gitignore。它强制执行哪些文件应包含在容器中,是最简单的清理和增强构建安全性的方法之一。

在构建之前,至关重要的是添加一个.dockerignore文件以排除不应复制到镜像中的文件和文件夹:

node_modules  
npm-debug.log  
tests  
coverage  
Dockerfile  
.dockerignore  
.env  
*.md

这是带有多阶段构建的Dockerfile:

# 构建阶段  
FROM node:alpine AS builder  

WORKDIR /app  

COPY package*.json ./  
RUN npm ci --only=production  

COPY . .  

# 运行时阶段  
FROM node:alpine  

WORKDIR /app  

COPY --from=builder /app/node_modules ./node_modules  
COPY --from=builder /app/package.json ./  
COPY --from=builder /app/*.js ./  

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

让我们构建并检查镜像大小:

docker build -t node-app:phase4 . -f Do4kerfile.phase4  
docker images | grep node-app

让我们验证应用程序是否仍能正常工作:

一切都按预期正常工作!

我们现在已经达到了178 MB,几乎**85%**的原始大小减少!多阶段构建将构建工具和中间文件排除在最终镜像之外。这种方法让您使用一个容器来构建您的应用程序,而使用不同的、更小的容器来运行它。

这种模式特别适用于TypeScript项目。在一个医疗保健项目中,TypeScript编译和测试步骤生成了超过300MB的工件,如果使用单阶段构建,这些工件将被包括在内。多阶段构建仅在最终镜像中保留编译后的JavaScript。

7、使用Distroless

Google的Distroless镜像通过剥离甚至操作系统包管理器和shell,将极简主义提升到了另一个层次。这些镜像仅包含运行您的应用程序所需的必要运行时依赖项。

它们的非凡之处在于:

  • 没有包管理器(例如apkapt)攻击者无法在妥协后安装恶意软件。
  • 没有shell,减少了攻击面(没有/bin/sh)。
  • 不可变且最小化,通常比基于Alpine的镜像还要小。
  • 非常适合生产环境,其中可重复性、大小和安全性是优先事项。

以下是使用Distroless镜像的Dockerfile示例:

FROM node:22-alpine AS builder  

WORKDIR /app  

COPY package*.json ./  
RUN npm install --only=production  

COPY . .  

# 第2步:使用distroless镜像  
FROM gcr.io/distroless/nodejs22  

WORKDIR /app  

COPY --from=builder /app /app  

CMD ["index.js"]

让我们构建并检查镜像大小:

docker build -t node-app:phase5 . -f Dockerfile.phase5  
docker images | grep node-app

让我们看看我们的应用程序是否仍然可以在Distroless镜像中正常工作:

如果我们尝试访问容器的shell,我们会发现没有:

docker exec -it <CONTAINER_ID> sh

这是Distroless镜像的安全特性,它们只包含您的应用程序及其运行时依赖项。没有包管理器,没有shell,没有其他任何东西。这不仅节省了空间,还极大地提高了安全性。

在采用Distroless之后,我们达到了143MB,相比原来的1.22GB镜像减少了88.2%。考虑到我们并没有在功能性和安全性上妥协,这是一个令人难以置信的成就。

8、静态二进制文件

对于最终的体积优化,我们将我们的Node.js应用程序编译成一个独立的二进制文件。通过使用工具如pkgnexe,我们将整个应用程序,包括Node.js运行时,打包成一个单一的可执行文件。这种方法不仅减少了攻击面和依赖复杂性,还使我们能够从头或Distroless基础镜像如scratch运行应用程序,实现通常低于20MB的极小镜像。这种方法消除了对Node.js运行时的需求,导致镜像显著缩小并且冷启动更快。

这里是Dockerfile:

FROM node:alpine AS builder  

WORKDIR /app  

COPY package*.json ./  
RUN npm ci --only=production  
RUN npm install -g pkg  

COPY . .  
RUN pkg --targets node16-alpine-x64 index.js -o app  

# 最小化运行时  
FROM alpine:latest  

WORKDIR /app  
COPY --from=builder /app/app .  

EXPOSE 3000  
CMD ["./app"]

让我们构建并检查镜像大小:

docker build --platform=linux/amd64 -t node-app:phase6 . -f Dockerfile.phase6  
docker run --platform=linux/amd64 -d -p 3000:3000 node-app:phase6

显著减少的57MB最终镜像,仅为原始大小的4.7%!这是一个巨大的改进,去掉了不必要的层、包和依赖项。

我传递了--platform=linux/amd64以确保二进制文件在x86_64架构上运行,避免了我在ARM-based Mac上的兼容性问题。

让我们测试编译后的应用程序是否正常工作:

docker run --platform=linux/amd64 -d -p 3000:3000 node-app:phase6  
curl http://localhost:3000
这种方法并不适合所有人。静态编译对较简单的应用程序效果很好,但对于复杂的依赖关系可能会变得有问题。我曾经有一个在开发环境中正常工作的编译应用程序,但在生产环境中由于动态模块加载模式而失败。

在使用各种技术容器化我们的Node.js应用程序后,从基本的基础镜像到Scratch、Distroless,甚至是静态二进制文件,我们达到了令人印象深刻的57MB最终镜像。

10、使用Slim(以前称为Dockerslim)

在寻找简化优化工作流程的方法时,我发现了Slim,这是一个强大的开源工具,可以自动化容器镜像的最小化。它检查您的镜像,识别运行时使用的部分,并删除其余部分。

这不仅大大减少了镜像大小,通常减少30倍或更多,而且通过消除不必要的包和减少攻击面,显著提高了安全性。更令人印象深刻的是,它可以在不重写您的Dockerfile或重构您的应用程序代码的情况下实现这一点。

10.1 它是如何工作的

Slim在沙盒环境中运行您的容器,跟踪系统调用,并构建一个新的优化镜像,仅包含运行时所需的必要组件。它还生成报告、安全见解和工件,如:

  • 精简镜像slim.<original-image-name>
  • Seccomp配置文件以强化您的容器
  • 报告目录,包含文件、包和网络使用情况的详细信息

10.2 让我们安装并使用它

我们将本地安装Slim并使用它来优化我们的Phase 1 Docker镜像(我们的原始未优化的Node.js应用)。

在macOS上,最简单的安装方式是通过Homebrew:

brew install docker-slim  
slim --version

现在,我们将优化命令运行在我们的node-app:phase1镜像上:

slim build node-app:phase1  
docker images | grep slim

Slim将我们1.22GB的镜像减少到仅仅123MB

10.3 测试Slim镜像

让我们验证我们的Slim优化容器是否正常工作:

docker run -d -p 3000:3000 node-app.slim  
curl http://localhost:3000

虽然Slim带来了令人印象深刻的结果,但我学会了谨慎使用。在一个项目中,它删除了仅在罕见错误处理路径中使用的文件,导致在特定条件下出现难以捉摸的生产故障。

Slim不是灵丹妙药。当您了解应用程序的运行时行为并相应地引导优化过程时,它效果最佳。

11、为什么不直接从Slim开始?

在看到Slim的惊人结果后,您可能会想知道为什么还要费心手动优化。当我向我的团队展示这种方法时,我也问过自己同样的问题。

自动化工具在优化方面非常出色,但它们无法替代理解基础知识。有以下几个令人信服的理由先学习手动优化:

  • 故障排除: 当Slim删除关键文件时,了解Docker优化原理可以帮助您快速诊断和修复问题,这是自动化工具有时无法处理的事情。
  • 控制和可预测性: 手动优化提供了对镜像内容的精确控制,确保仅保留必要的组件,从而实现更可预测的构建。
  • 安全验证: 安全团队需要全面了解容器的内容。手动优化的镜像更容易进行文档记录和验证,确保符合安全政策。
  • 可转移的知识: 容器优化的原则适用于各种语言和框架,因此手动优化是一种适用于任何项目的通用技能。
  • 定制和灵活性: 手动优化让您根据具体需求调整镜像,无论是调整构建流水线还是满足存储限制。
  • 理解构建过程: 手动优化提供了对Docker层如何交互以及如何构建Dockerfile以获得最大效率的更深层次理解。

12、优化的潜在问题

虽然优化带来了显著的改进,但也带来了一些需要解决的挑战:

  • Alpine兼容性问题: 本地依赖项在Alpine上的行为有时会有所不同,可能导致潜在的兼容性问题。某些库或工具可能无法按预期工作,需要额外的工作绕过或确保兼容性。
  • 调试困难: Distroless容器虽然最小化且高效,但调试起来更困难。由于容器中可用的工具较少,识别和解决问题变得更加复杂和耗时。
  • 构建复杂性: 管理多阶段构建增加了Dockerfile的复杂性。对于一些团队成员来说,理解多阶段构建的流程并在确保包含和排除正确的层方面成为一个挑战。
  • 团队采用: 鼓励整个团队采用和遵循优化最佳实践需要时间和教育。并非所有人都熟悉这些做法,获得全面认可需要投入培训和持续学习的努力。

关键的教训是优化不是一刀切的。大小、功能和安全性的平衡取决于您的具体应用程序。

13、其他优化技术

以下是一些对我有效的其他技术:

  1. 层优化

Docker镜像由层组成,Dockerfile中的每个指令都会创建一个新层。结合相关的命令可以减少层数,从而显著减小镜像大小。

糟糕的方法:

RUN apt-get update  
RUN apt-get install -y package1  
RUN apt-get install -y package2

正确的方法:

RUN apt-get update && \  
    apt-get install -y package1 package2 && \  
    rm -rf /var/lib/apt/lists/*  

2. 利用BuildKit缓存挂载

对于Node.js应用程序,npm缓存可以显著加快构建速度:

# syntax=docker/dockerfile:1.4  
FROM node:alpine  

WORKDIR /app  

COPY package*.json ./  
RUN --mount=type=cache,target=/root/.npm \  
    npm ci --only=production  

COPY . .  

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

此技术将npm缓存保留在最终镜像之外,同时加速构建。

3. 考虑替代工具

除了手动调整和使用Slim进行自动化外,还有一些强大的工具可以帮助您检查、分析并进一步优化您的容器镜像:

  • dive:一个CLI工具,用于直观探索Docker镜像层。它帮助您了解是什么导致了镜像大小,并识别不必要的文件或低效的层排序导致的膨胀。
  • Docker Scout:Docker的官方镜像分析和安全工具。它为您提供有关漏洞、过时依赖项和大小优化的见解,直接来自您的Docker镜像元数据和SBOM(软件物料清单)。
  • Buildpacks:一个CNCF项目,允许您从源代码构建容器镜像,而无需Dockerfile。Buildpacks强制实施最佳实践并生成针对您应用程序运行时量身定制的最小化、生产就绪的镜像。

这些工具是帮助您超越基本Dockerfile优化的强大助手,深入了解容器内部。

14、最终思考

从1.22GB到57MB的旅程不仅仅是为了节省磁盘空间,它反映了效率、安全性和工程卓越性的哲学。

实施这些技术的好处随着时间的推移而积累。更小的容器意味着:

  • 更快的部署
  • 更低的基础设施成本
  • 改善的安全态势
  • 更好的资源利用
  • 更快的水平扩展

记住,优化是一个光谱,不是一个二元目标。即使您不能实现每一种技术,每一步都有好处。即使从1GB减少到200MB也是一个巨大的胜利。

从适合您项目的那些技术开始,并随着您对它们感到舒适而逐步实施更高级的优化。通往容器效率的旅程是迭代的,但回报是值得努力的。

我已经在我的GitHub仓库中发布了本指南中使用的所有Dockerfile


原文链接:A Step-by-Step Guide to Docker Image Optimisation: Reduce Size by Over 95%

汇智网翻译整理,转载请标明出处