从53个漏洞到5个:我们用Distroless把容器安全"减"出来了

上篇文章讲了新来的 Atlassian 前Principal Engineer如何用"忽略CVE + 季度复查"的方式处理57个高危漏洞。评论区最高赞的问题是:既然CVE可以这么处理,为什么不从源头上减少漏洞数量?

这个问题问到点子上了。我们团队花了六周时间做了一件事:把容器基础镜像从标准Debian/Alpine切到Google Distroless,CVE数量从53个直接降到10个以内。更关键的是,我们扔掉了容器里65%的软件包,但应用功能一点没少

这不是简单的镜像替换,而是一次对"容器到底该包含什么"的重新思考。

一、我们的容器里到底装了什么垃圾

先看一组数字。我们的Python服务用的是python:3.12-slim镜像,听名字就知道是"精简版"。跑一次安全扫描,结果如下:

  • 镜像大小:149MB
  • 软件包数量:89个
  • 已知CVE:53个

我把扫描报告拿给那位Principal Engineer看。他扫了一眼问:"你们生产环境用过apt吗?"

"没有。"我很确定,我们所有依赖都在Docker构建阶段装好了,运行时根本不需要包管理器。

"用过bash脚本吗?"

"也没有。"我们的部署流程全是Python代码,从来不写shell脚本。

他指着报告说:"那为什么镜像里要带apt和bash?这53个CVE里,至少30个来自你们根本不会用的工具。"

这句话让我意识到一个被忽略的事实:我们的应用只是个标准的FastAPI服务,运行时真正需要的只有Python解释器和几个依赖包。但python:3.12-slim给我们打包了一整套Debian环境------shell、包管理器、网络工具、系统库,应有尽有。这些东西占了容器65%的软件包数量,生产环境使用率是0%

Node.js服务的情况类似。我们用的是node:20-alpine(140MB),看起来比Debian轻量,但Alpine有个致命问题:它用的是musl libc而不是glibc。这导致某些Python的C扩展wheel包在Alpine上根本装不了,我们之前就踩过这个坑,最后还是退回Debian系。

Nginx服务也是Alpine(40MB),相对最干净,但同样逃不掉Alpine历史上那些臭名昭著的漏洞,比如CVE-2019-5021。

二、53个CVE意味着什么

数字本身没什么感觉,但把它转换成时间成本就不一样了。

53个CVE意味着每个月至少一次的安全审查循环。流程是这样的:扫描工具报出新漏洞 → 评估严重程度和影响范围 → 决定是否修复 → 更新基础镜像版本 → 重新构建所有受影响的容器 → 在测试环境验证 → 推送到生产环境。

一次完整流程下来6到8个小时。一年累计就是60-70小时,相当于一个半工作周。这还不包括那些半夜突然爆出来的高危漏洞,需要周末紧急响应的情况。

更麻烦的是业务层面的影响。我们的产品要在Snowflake Marketplace上架,每次安全审查,对方都会追问同一个问题:"这些shell工具和包管理器是必需的吗?如果不是,为什么要放在生产容器里?"

我们当然答不上来。因为它们确实不是必需的,只是基础镜像默认带的。这种答案在安全审查中毫无说服力。

上周那篇文章里,Principal Engineer的做法是"忽略这些CVE,但季度复查"。那是在现有架构下的风险管理策略 。而现在我们要做的是从架构层面消灭这些本不该存在的CVE

三、Distroless:一个听起来很极端的方案

Google搞了个什么东西

Principal Engineer给我们看了一个镜像:gcr.io/distroless/python3-debian12

"这个有什么特别的?"我问。

"它没有shell。"

"什么意思?"

"字面意思。没有/bin/sh,没有/bin/bash,没有任何shell环境。"

我第一反应是:这怎么调试?出问题了不需要docker exec进去看看吗?

他反问:"生产环境你真的会exec进容器调试吗?还是说你们有完善的日志和监控系统?"

这话让我沉默了。确实,过去一年我们几乎没有在生产环境里exec过容器。真出问题了,看日志、查指标、追踪trace,这些工具比进容器里瞎翻有效得多。我们一直以为需要shell,实际上只是习惯了它的存在

Distroless的逻辑极其简单:容器里只放应用运行必需的东西,其他一律不要。没有shell,没有包管理器,没有调试工具。只有Python解释器、必要的动态库、你的应用代码和依赖。就这些。

这个项目是Google在2017年开源的,已经运行了5年以上。不是什么实验性的玩具,Google自己的Cloud Run和GKE都在用。

为什么不选其他方案

我们其实评估了好几个选项:

Chainguard镜像声称"零CVE",补丁速度全行业最快。听起来很诱人,但它是个新项目,社区还不够成熟。而且是商业公司运作,免费版随时可能改成付费。生产环境我们需要更稳定的选择。

自己从头构建镜像?理论上可以完全控制,但维护成本太高。我们是小团队,没精力追踪每个依赖的安全更新,更别说还要处理兼容性问题。

继续用Alpine做安全加固?这解决不了根本问题。只要shell和包管理器还在,攻击面就降不下来。而且Alpine的musl libc在Python生态里就是个已知的坑。

Google Distroless给了我们最实际的选择:成熟稳定(5年以上生产验证),完全开源免费(没有商业风险),基于Debian + glibc(Python生态兼容性完美),活跃的社区和详细的文档,CVE数量控制在5-15个的健康水平。

数字本身已经说明问题:从53个CVE降到10个以内,减少了70-80%

四、迁移的核心难点:Distroless不让你在里面装东西

传统的Dockerfile长这样:从基础镜像开始,装依赖,拷贝代码,设置入口点。一气呵成,简单明了。

但Distroless不让你这么干。因为它根本没有pip和npm,你没法在里面装依赖。这意味着传统的"一站式"构建流程彻底行不通了。

多阶段构建:把脏活和干净活分开

解决方案是把构建和运行彻底分开

第一阶段(Builder) 用完整的Debian或Alpine镜像,随便用pip、npm、apt,想怎么装怎么装。这个阶段的镜像可以很大,可以有很多漏洞,没关系------它只用来构建,不会进生产环境。

第二阶段(Runtime) 用Distroless作为基础,从Builder阶段把编译好的依赖和应用代码拷贝过来。最终推到生产的镜像干净得像手术室。

我们Python服务的改造是这样的:

Builder阶段用python:3.12-slim执行pip install -r requirements.txt --target=/app/packages,把所有依赖装到一个独立目录。注意这里用了--target参数,让pip把包装到指定位置,而不是系统的site-packages。

Runtime阶段用gcr.io/distroless/python3-debian12:nonroot,从Builder阶段把整个/app/packages目录拷过来,设置PYTHONPATH环境变量指向它。再把应用代码拷进去,设置入口点。完事。

最终效果:镜像从149MB降到85MB(减少43%),CVE从53+降到不超过10个

Node.js:更简单的迁移路径

Node.js的迁移比Python还要简单。因为npm ci --only=production装好的node_modules可以直接拷贝到Distroless里用,不需要额外的路径配置。

Builder阶段跑npm ci,Runtime阶段拷贝node_modules和应用代码,设置入口点为node server.js。我们的路由服务迁移后稳定在90MB左右。

Nginx:最复杂的自定义构建

Nginx是最麻烦的,因为Distroless没有官方的Nginx镜像。我们需要自己构建。

方案是用Debian作为Builder,安装nginx包,然后把nginx二进制文件、配置目录(/etc/nginx)、静态资源目录(/usr/share/nginx/html)全部拷贝到gcr.io/distroless/base-debian12:nonroot里。

这个过程需要仔细测试每个nginx模块的兼容性。我们花了不少时间验证核心功能,确保HTTP代理、SSL、静态文件服务这些关键特性都正常工作。

最后的镜像只有25MB ,比原来的Alpine版(40MB)还小了37.5%

非root用户:一个容易忽略的细节

Distroless的:nonroot变体默认用UID 65532运行,不需要root权限。这听起来是小细节,实际上能防住一大类容器逃逸攻击。

但这带来了文件权限的问题。如果你在拷贝文件时不指定owner,文件默认属于root,非root用户读不了。我们在测试环境踩过这个坑,后来在所有COPY指令上都加了--chown=nonroot:nonroot参数。

这个细节很容易被忽略,但在生产部署时会导致诡异的权限错误。务必在测试阶段就验证清楚。

##五、迁移带来的实质收益

安全指标的质变

指标 迁移前 迁移后 改善幅度
Python镜像CVE数量 53+ 5-15 -70~80%
Python镜像大小 149MB 85MB -43%
软件包数量 89个 20-30个 -65~75%
Nginx镜像大小 40MB 25MB -37.5%

数字背后是每个月安全审查工作量的大幅下降。原来需要评估53个漏洞,现在只需要看10个左右。很多时候甚至更少。

更重要的是攻击面的本质缩小。没有shell,攻击者就算拿到了容器的执行权限,也没法下载额外的工具、建立持久化后门、横向移动到其他容器。很多教科书级别的容器攻击手法------比如反弹shell、下载恶意二进制、利用系统工具提权------全部失效。

运维成本的实质降低

以前每个月至少处理一次基础镜像安全更新。整个流程:扫描 → 评估 → 更新 → 构建 → 测试 → 部署,走下来6-8小时。高危漏洞爆出来的时候还得周末紧急响应。

迁移到Distroless后,这个频率直接降低了70% 。不是说不需要更新了,而是基础镜像里的包少了,被扫出新漏洞的概率也大幅下降。

我们算了笔账:每年节省60-70个工时。这相当于多了一个半工作周,可以用来做更有价值的事情。

业务层面的附加价值

我们的产品要通过Snowflake Marketplace的安全审查。以前审查团队总会追问那些shell工具和包管理器的必要性,我们只能说"基础镜像自带的"。这个答案在安全审查中毫无说服力。

现在我们可以直接拿出Distroless的扫描报告:CVE个位数,没有shell,没有包管理器,非root用户运行。这种主动的安全姿态明显提升了审查通过的信心

另外镜像变小带来的副作用是部署变快了。从容器仓库拉取镜像的时间从20-30秒降到15秒,SPCS的冷启动也有改善。虽然不是质的飞跃,但在高频部署的场景下,这些秒数会累积成可观的时间节省。

五、迁移中的三个关键决策

第一个决策:调试方式必须彻底改变

最大的挑战不是技术实现,而是思维方式的转变

以前遇到问题,团队的第一反应是docker exec -it <container> /bin/bash,进去翻日志、看进程、测网络连接。这是所有运维工程师的肌肉记忆。Distroless环境下这条路彻底断了。

我们的应对策略是全面拥抱结构化日志和可观测性工具。所有应用日志改成JSON格式,统一包含这些字段:timestamp(时间戳)、request_id(请求ID)、service_name(服务名)、log_level(日志级别)、message(消息内容)、context(上下文信息)。

配合observability工具(我们用的是标准的metrics + traces + logs三件套),在出问题时通过日志查询和指标分析定位根因。实际使用下来,这种方式比传统的exec调试更高效。日志是持久化的,可以回溯历史;指标是聚合的,可以看到趋势和异常模式;trace可以追踪跨服务的调用链路。

唯一的例外是真的搞不定的紧急情况。这时候我们会临时部署:debug变体------它内置了busybox shell,可以进去救火。但过去半年这种情况只发生过一次。大部分时候你以为需要shell,实际上只是还没习惯用日志和指标思考问题

第二个决策:CI/CD必须精细优化

多阶段构建增加了复杂度,我们最担心的是会拖慢CI/CD流水线。实际测试发现,关键在于充分利用Docker的层缓存机制

我们的优化策略是把依赖安装和代码拷贝严格分成不同的层。依赖变化频率低(可能几周才更新一次requirements.txt),这一层可以被缓存很久。代码变化频率高(每天可能提交好几次),但这一层很小,构建很快。

在CI环境里开启BuildKit的inline cache功能,让不同的构建节点可以共享缓存层。这样即使是在全新的构建机器上,也能利用之前的缓存,而不是从头开始。

最终效果:全量构建时间从之前的12分钟增加到14分钟(+16.7%),这是可以接受的。但由于缓存命中率高,日常的增量构建反而从8分钟降到了6分钟(-25%)。

第三个决策:分阶段推进严控风险

我们没有一次性迁移所有服务,而是分了三个阶段,每个阶段都有明确的验证标准:

Phase 1(第1-2周):选一个低风险服务做POC

挑了一个Python后端服务,特点是:依赖简单(只有5个第三方包),流量不大(日均1万次请求),出问题影响面可控(只是内部数据查询服务)。

在这个阶段我们踩了所有该踩的坑:文件权限问题、环境变量配置、日志输出格式、健康检查路径。把所有问题都在低风险环境下解决掉

Phase 2(第3-4周):扩展到所有Python和Node.js服务

流程已经成熟,主要是重复劳动。但我们给每个服务设置了严格的验证标准:迁移完在测试环境跑满一周,期间要通过完整的回归测试,监控指标不能出现异常波动。

Phase 3(第5-6周):最后处理Nginx

这个最复杂,因为需要自定义构建。我们花了不少时间测试各种nginx模块的兼容性,特别是SSL、反向代理、静态文件服务这些核心功能。

每次部署都采用金丝雀发布策略:先切5%流量到新镜像,观察关键指标(P99延迟、错误率、内存占用、CPU使用率)。设置自动回滚阈值:如果错误率超过1%或者P99延迟增加20%,自动切回旧版本。

在新版本稳定运行24小时后,逐步提升流量比例:5% → 25% → 50% → 100%。每个阶段都留出足够的观察时间。

这套机制让我们整个迁移过程实现了零生产事故。有一次Node.js服务在金丝雀阶段发现内存占用偶发性飙升(后来发现是我们在拷贝node_modules时漏了一个优化参数),自动回滚机制在3秒内就切回了旧版本,用户完全无感知。

六、三个月后的复盘:Distroless不是银弹

它解决了什么问题

迁移三个月后,我们做了一次全面复盘。最直观的改变是安全审查从噩梦变成了例行公事

以前每次看到扫描报告的53个CVE,就知道接下来要花一整天时间逐个评估、查文档、判断影响、决定优先级。现在打开报告,通常只有5-8个CVE,而且大部分是低危的,半小时就能处理完。

攻击面的缩小带来了更深层的安全信心。我们不再担心某个未知的系统工具漏洞被利用,不再担心攻击者在容器里建立持久化后门。因为那些工具根本就不存在。

从业务角度看,Snowflake Marketplace的安全审查变得顺利多了。审查团队看到我们的镜像构成和CVE数量,直接通过了容器安全这一项。这是以前从来没有过的体验

它没解决什么问题

但Distroless不是银弹。它解决的是基础镜像层面的安全问题,对应用层的逻辑漏洞、供应链攻击、配置错误完全无能为力

比如我们的一个Python服务依赖了一个有SQL注入漏洞的第三方库。Distroless帮不上忙,该有的漏洞还是有。我们还是要靠依赖扫描工具(比如Snyk或Dependabot)来发现和修复。

再比如有一次开发同事把数据库密码硬编码在环境变量里,这是典型的配置安全问题。Distroless也管不了,还是要靠代码审查和secret管理工具来防范。

Distroless只是给了你一个更干净的基础,让你可以把精力集中在应用层的安全问题上,而不是每个月疲于应付那些根本用不到的系统工具漏洞。

开发环境不一定适合。开发者需要调试工具的便利性,强行用Distroless会降低开发效率。我们的做法是开发环境继续用标准镜像,只有生产环境才用Distroless。

实验性项目不一定适合。Distroless需要完善的日志和监控配套,这对早期项目是负担。如果你的项目还在快速迭代,功能都没稳定,先别急着上Distroless。

复杂依赖场景不一定适合。如果你的应用依赖很多系统库、需要特殊的编译环境、或者依赖闭源的二进制工具,迁移成本可能超过收益。我们的场景恰好是标准的Python/Node.js技术栈,迁移相对容易。

最后

从53个CVE到10个以内,从149MB到85MB,从89个软件包到30个。这些数字背后是每年60-70小时的时间节省,是更顺利的安全审查,是更安心的生产环境。

真正的安全不是在容器里塞更多的防护工具,而是从一开始就不要放那些用不到的东西


阅读上一篇:Atlassian老兵空降第一周:手把手教你建立可持续的安全扫描体系

相关推荐
BingoGo6 小时前
PHP 中的命名艺术 实用指南
后端·php
骑着bug的coder6 小时前
第1讲:入门篇——把MySQL当成Excel来学
后端·mysql
SimonKing6 小时前
Spring Boot全局异常处理的背后的故事
java·后端·程序员
骑着bug的coder6 小时前
线上503了?聊聊Feign熔断降级这点事
后端
初级程序员Kyle6 小时前
开始改变第六天 MySQL(1)
后端
MeowRain6 小时前
JVM分代回收
后端
程序员蜗牛6 小时前
拒绝重复造轮子!SpringBoot 内置的20个高效官方工具类详解
后端
白衣鸽子6 小时前
ListUtils:Java列表操作的瑞士军刀
后端·开源·设计
bcbnb6 小时前
Charles vs Fiddler vs Wireshark,哪款抓包工具最适合开发者?
后端