自从进入大容器时代以来,Docker、K8s已逐渐成为开发、测试和部署时不可或缺的工具。如果突然让我不使用Docker,那我可能什么都做不了。但也因为如此,与容器相关的攻击也越来越普遍,因此容器的安全性也变得越来越重要。
想要从零开始构建一个容器,第一步就是要编写 Dockerfile 将你的应用程序打包成 Docker 镜像。关于如何生成尽可能小的镜像已经有很多人写过了,所以今天想要与大家分享的是如何编写一个安全的 Dockerfile,有哪些需要注意的地方。
这篇文章主要关注Dockerfile的安全性,所以不会介绍基本语法。如果你还没有写过Dockerfile,可以先学习了解一些docker知识,然后再回来继续阅读,会更容易理解哦~
使用稳定版或长期支持版的基础镜像
很多人在写 Dockerfile 的时候并不会特别指定基础镜像的版本(就是懒啊,我懂 XD),比如说想要打包一个 Node API 服务器,就直接写 FROM node
或者 FROM node:latest
# Bad
FROM node
WORKDIR /app
COPY . .
RUN npm install
RUN npm run test
但是这样做可能会导致在某次构建镜像时意外地从 Node 14 升级到 Node 16,从而导致部分功能直接崩溃。而且最新版本的 Node 可能存在一些未知的错误,需要一些勇者来帮忙发现并解决问题,所以除非是自己的副业项目想要尝试最新的功能,否则直接在生产环境中使用最新版本的 Node 并不是一个好的做法
比较好的方式是先看看 Node 的 LTS(Long-Term Support) 版本是多少,像我在写文章的当下是 v14.17.5
,那就选择 node:14
或是 node:14.17
作为 base image
# Good
FROM node:14
WORKDIR /app
COPY . .
RUN npm install
RUN npm run test
这样做的好处有两个:一是可以将版本号固定在Node 14上,确保不会有大的变动;二是LTS版本会持续推出安全修复,所以如果有一天Node更新到 v14.17.10
,那些安全修复也会在部署API服务器时被加入。我们只需要等待更新即可。
在安装软件包时,需要指定版本
这一点和上面提到的不要使用最新的图像有些相似,无论你是使用 apt-get install
安装CLI工具,还是使用 npm install
安装库,或者使用curl/wget下载并编译东西,都要尽量确保每次下载到的东西是一样的
比如说,在使用 apt-get 安装 nginx 的时候,可以通过 apt-get install nginx=1.14.0
来下载指定版本(有点麻烦对吧,我也觉得XD)。而像 npm、pip 这类的语言包管理工具,则是根据官方推荐的方法来操作,比如 npm 就是使用 package-lock.json
来锁定包的版本,pip 则是先运行 pip freeze > requirements.txt
将包的版本冻结,等需要安装时再运行 pip install -r requirements.txt
将原本的包安装回来
虽然将套件版本锁定后可以避免很多麻烦,但也不能一直停留在那里不更新。所以记得偶尔去检查一下版本是否过旧,如果是的话,只需手动将版本号升级即可
只复制需要的东西
平时写 Dockerfile 的时候,有些人为了一时方便,会直接使用 COPY . /app
将整个项目文件夹复制到容器的 /app
文件夹内。这样做虽然不需要费太多心思,在容器内也可以直接访问所有文件,但这种做法可以说是相当糟糕的
首先,这样做会让图片变得非常庞大(连 node_modules
都包含在内,能不庞大吗XD)。而且一不小心就会将敏感数据如 .envrc
一同放入其中。如果有一天黑客获取了这个图片,其中的AWS凭证、数据库密码等极其机密的信息就会直接泄露出去。甚至有可能突然遭受数据库删除的风险
为了避免这种情况发生,在构建图像时应该只复制所需的内容进去,比如说你马上就要跑的时候,才把 npm install
和 package.json
放进去,而代码也只放进去真正会执行的部分就可以了
而且这样还有另外一个好处,就是如果你修改了 src
里面的代码,但没有安装新的 package(开发时大部分都是这样吧~),因为 Docker 会自动进行缓存,所以就不需要重新运行一次 npm install,会直接从第 10 行的 npm run test
开始运行,因此可以大幅缩减需要等待的时间
使用多阶段构建,丢弃不需要的文件
这跟上一点有点类似,简单来说就是不要在镜像里留下任何不需要的东西(没用的东西都给我滚),即便那是构建镜像过程中产生的东西也是一样
举个例子,原本 Go API 服务器的 Dockerfile 可能是这样的,因为在构建镜像时需要编译可执行文件,所以第 5 行的 COPY *.go
是必需的,没有它们就无法进行编译
但说真的一旦编译出执行文件之后,那些 Go 程序代码就用不到了,所以应该来个爽快的过河拆桥,用 multi-stage build 把编译完的执行文件保留下来就好,程序代码什么的就直接拜拜
经过多阶段构建后,下面这个 Dockerfile 中的镜像只包含已编译好的可执行文件,没有任何源代码和 Go 编译器,非常纯粹
那这样有什么好处呢?除了图片可以变小很多之外,即便图片被黑客拿到了,程序代码也不会外泄出去(有很多攻击都是拿到程序代码后从里面找到漏洞),因此只保留可执行文件可以提高安全性
除此之外,由于环境越复杂就越可能存在未发现的漏洞,将基础镜像从原本的 golang:1.17
替换为谷歌提供的 distroless 镜像正好能够大幅减少环境的复杂性(distroless 几乎没有安装任何东西,甚至连 shell 都没有),从而提高安全性
请不要将敏感资料硬编码在Dockerfile文件中
我觉得这已经是常识级别的安全知识了,因为直接把敏感资料写在Dockerfile里会让黑客轻易拿到(只要拿到镜像就可以了),所以绝对不要想不开把数据库或任何的账号密码写在里面,最多用来设置时区或是这种被公开也不会出问题的变量就好,否则哪天数据被偷走真的会哭出来
如果说 ENV 不能放敏感资料,那这些资料究竟要怎么被加进环境变量呢?
答案就是在代码中加上 docker run
或 --env
,或者将环境变量传递进去;如果使用docker-compose,那么将这些数据写入 docker-compose.yml
的环境变量中,这样容器启动时就能读取这些变量,即使镜像被盗也不用担心数据泄露
弱点扫描
在完成所有可行的任务之后,接下来就是使用工具进行弱点扫描。由于有很多用于弱点扫描的工具,这里介绍一下已经被 Docker 加入 CLI 的 Snyk。它可以将您的环境和安装的软件包发送到他们的数据库进行搜索,以查找潜在的危险
举个例子,我手头有几个多年前用 Node.js 写的 express server,Dockerfile 长这样(看到 9.2.0 就知道真的是多年前XD)。
先用 docker build . -t app
把图像建立起来,然后对其进行 docker scan app
扫描。由于我使用的是古老的 node:9.2.0
,所以仅仅对基础图像进行扫描就发现了1039个漏洞,其中有99个属于关键级别的,真是吓人
除了告诉你有多少漏洞之外,他还会把每个漏洞列出来(有兴趣可以去读一下那些漏洞的报告,其实都不长),并且告诉你这些漏洞分别在哪些版本修复了
如果你不想看那些漏洞的话,也可以直接滑到最底下看他给你的建议。比如说,他建议将基础镜像升级到 node:16.7.0
。如果不一定需要完整镜像的话,那么 node:16-bullseye-slim
也是一个不错的选择。因为安装的东西更少,所以漏洞自然也会更少
总结
今天介绍了一些在写 Dockerfile 时的注意事项,虽然很多都是小地方,但毕竟魔鬼藏在细节里,想要让你的 Docker image 更安全,那就连这些小细节都不能放过