容器(Container)技术
什么是容器
容器是一种对进程进行隔离的运行环境。
很多人容易将容器与虚拟化技术相混淆,甚至认为容器就是轻量级虚拟机,其实两者有着许多的区别,最大的区别就是虚拟机有独立的内核(Kernel),而容器没有。一个运行着的容器可以看成是一个特殊的进程,启动一个容器就跟启动一个进程一样,所以容器的启动速度非常快,额外开销非常小。
本质上,容器其实就是一种沙盒技术,它把应用进程隔离在一个有边界的集装箱内,使其能看到有限的视图,使用有限的资源,并且集装箱内包含进程运行所需要的依赖,因此它可以被整箱搬走,到处运行。
容器的底层原理
由于 Windows 的容器技术局限性相对较多,实际产品环境的容器大部分都运行在 Linux 平台,所以本文提到的 Cgroups、Namespace、rootfs 等概念均以 Linux 平台实现为准。
对 Linux 容器来说,Namespace 是用来做隔离的主要方法,Cgroups 是用来约束资源使用的主要手段,而 rootfs 则确保了容器运行的一致性。
1
Namespace 隔离
当我们通过一些命令比如 docker run -it 启动并进入一个容器之后,会发现不论是进程、网络、文件系统还是用户,好像都被隔离了,比如, top 看不到宿主机上的进程(容器入口程序就变成了1号进程), ip 只能看到容器内部的 ip 地址, ls 显示的文件也和宿主机不一样, id 获取到的当前用户也变了,看起来真的像进入了一个虚拟机。这一切其实都是容器的核心功能之一,通过 Namespace 来实现进程的隔离。
我们以上述容器内的进程 PID 变成 1 为例进行分析,其核心的方法就是通过 PID Namespace 。具体实现上,我们只需要在调用 API clone 时,在其中指定 CLONE_NEWPID 参数,这样新创建的进程,就会看到一个全新的进程空间。而此时这个新的进程,也就变成了这个新的 Namespace 里面 PID=1 的进程。
pid_t child_pid = clone(childFunc, stack, CLONE_NEWPID | SIGCHLD, nullptr);
PID Namespace 是嵌套的,父 Namespace 能够看到子 Namespace 所有进程,所以在宿主机(默认的 Namespace)能看到启动的容器里面运行进程的真实 PID。而且跟默认 Namespace 的 init 1号进程类似,PID 1 进程的终止将立即终止其 PID Namespace 中的所有后代进程。这也是为什么通常我们在容器里面的入口进程退出,则会导致整个容器的退出。
在 Linux 下可以根据隔离的属性不同分为8种不同的 Namespace:
类型 | 描述 |
Mount (mnt) Namespace | 隔离文件系统挂载点 |
Process ID (pid) Namespace | 隔离进程 PID |
Network (net) Namespace | 隔离网络协议栈 |
Inter-process Communication (ipc) Namespace | 隔离进程间通信 |
UNIX Time-Sharing (UTS) Namespace | 隔离主机(host)和域名(domain) |
User ID (user) Namespace | 隔离用户,从内核 3.8 版本开始支持 |
Control group (cgroup) Namespace | 2016年3月发布的 Linux 4.6 版本开始支持 |
Time Namespace | 2020年3月发布的 Linux 5.6 版本开始支持 |
这里需要特别注意的是,不同的 Namespace 是独立的,比如说,两个进程/容器可以属于同一个 Net Namespace,但是不属于同一个 PID Namespace。一个常见的应用场景比如 K8S 的 Pod,我们知道一个 Pod 里面可以有多个容器,每个容器有各自的 PID Namespace,但是它们共享一个 Net Namespace,所以我们可以在 initcontainer 里面以 root 权限去配置一些 iptables 规则,然后在 Pod 的其他容器里面的网络访问请求就也受这些 iptables 规则的限制。
Linux 下也有很多工具比如 unshare 、 nsenter 可以进行一些 Namespace 的操作,这里不做深入展开,有兴趣的可以自己尝试尝试。
2
Cgroups (Control groups) 资源限制
通过 Namespace 技术,容器实现了在一定程度上的隔离。但这还不够,虽然 Namespace 限制了进程能“看到”的某些内核资源,但它并不能提供真实的物理资源上的隔离。以宿主机的视角来看的话,其实容器就是特殊的进程,而进程之间自然存在着竞争关系,它们之间有可能会抢夺或耗尽宿主机的资源。所以我们还需要有一种额外的手段来限制容器能使用的资源,幸运的是,从2008年1月正式发布的 Linux 内核 v2.6.24 开始,Linux 内核添加了 Cgroups 功能,该功能的作用就是限制进程组使用的资源上限,包括 CPU、内存、磁盘、网络带宽等,同时还可以对进程进行优先级设置、审计、挂起和恢复等操作。
我们以如何限制进程的 CPU 使用为例进行说明,首先我们启动一个死循环的脚本,正常情况下它会占满一个 CPU。
[root@tec-l-1201146 ~]
[1] 1896147
[root@tec-l-1201146 ~]
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1896147 root 20 0 224244 3108 1044 R 93.3 0.0 1:12.81 bash
Cgroups 给我们暴露出来的操作接口是文件系统,在 /sys/fs/cgoup 下面有很多诸如 cpu、memory 这样的子目录。我们进入 /sys/fs/cgroup/cpu 目录下创建一个名为 mstr_test 的目录,这里 mstr_test 就是一个 Control group,内核会在 mstr_test 目录下,自动生成对应的资源限制文件。
[root@tec-l-1201146 ~]
[root@tec-l-1201146 cpu]
[root@tec-l-1201146 cpu]
[root@tec-l-1201146 mstr_test]
cgroup.clone_children cpuacct.usage_percpu_sys cpu.cfs_quota_us
cgroup.procs cpuacct.usage_percpu_user cpu.idle
cpuacct.stat cpuacct.usage_sys cpu.shares
cpuacct.usage cpuacct.usage_user cpu.stat
cpuacct.usage_all cpu.cfs_burst_us notify_on_release
cpuacct.usage_percpu cpu.cfs_period_us tasks
我们去配置 mstr_test group 的 cpu 使用限制,并将我们刚启动的死循环进程 ID 添加到这个 group,
[root@tec-l-1201146 mstr_test]
[root@tec-l-1201146 mstr_test]
再来重新观察 1896147 这个进程占用的 CPU,就发现这个进程的 CPU 使用率真的被限制住了。
[root@tec-l-1201146 mstr_test]
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1896147 root 20 0 224244 3108 1044 R 19.6 0.0 4:07.06 bash
3
rootfs 保证一致性
现在我们再来剖析一下——为什么容器可以一次构建,到处运行?
道理其实很简单,因为容器启动的时候,挂载了一个 rootfs,它包含了一个操作系统所包含的文件、配置和目录,以及程序运行本身需要的一些自定义的库。前面我们讲到过 Mnt Namespace,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mnt Namespace 的存在,在容器中修改文件,宿主机也不受影响。
在 Linux 系统中,有一个叫 chroot 的命令,可以改变进程的根目录到指定的位置。而 Mnt Namespace 正是基于 chroot 的基础上发展出来的,它也是 Linux 操作系统里的一个 Namespace(这大概也是为什么 Mnt Namespace 使用了看上去更为通用的 Flag CLONE_NEWNS)。
在容器镜像(image)的设计中,还用到了一种叫做 Union File System(UnionFS)的能力。UnionFS 最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下,例如将目录 A 和目录 B 挂载到目录 C 下面,这样目录 C 下就包含目录 A 和目录 B 的所有文件。这就为镜像的层(layer)设计提供了可能,也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs,通过引入层(layer)的概念,实现了 rootfs 的复用。
实际我们在构建镜像的时候,可以观察到 Dockerfile 里面的每一个指令,例如 COPY 、 RUN 或 ADD ,都会生成一个新的层,如果我们只修改了 Dockerfile 里面的部分指令,其实只会 build 修改的 layer,对于其他层则依然会复用。理解了镜像的这种分层设计原理,我们就很容易解释很多不那么直观的现象,比如,在一个 Dockerfile 里面定义两个 RUN 指令,其中第一个添加一个文件 A,第二个删除文件 A, 虽然最终在容器运行的时候确实看不到文件 A, 但实际镜像文件的大小依然包含了 A 的大小。问题的关键就在于第一个 RUN 已经生成了一个层, 第二个层只是遮住了 A 文件,就跟标记删除一样,所以如果我们想要减少镜像文件的大小,就应该在第一个 RUN 中用完以后就将 A 文件删除。
容器技术的规范及相关常见组件与产品
前面大概介绍了容器技术的一些底层原理,理解了这些底层原理有助于我们对容器技术有一个更深入的认识,而在实际产品开发过程中,几乎很少有场景是需要我们直接跟 Linux 内核提供的这些具体的 API 来交互,大部分时候,我们可以直接使用一些工具来负责帮我们进行镜像的构建,容器的运行等。
当我们讨论容器时,通常情况下,我们总是会听到 OCI、CRI、Docker、Podman、containerd、ri-o、runc、K8S 等常见名词。让我们来尽可能的梳理一下它们之间的关系,可以让大家对这些常见的产品与术语有一个更直观的认识。
• OCI(Open Container Initiative) 定义了容器镜像的格式,以及容器运行时(Container Runtime)应该如何运行容器镜像,OCI 使得不同厂商和项目生产的容器产品和工具能够更好地一起工作。
• CRI(Container Runtime Interface) 是 K8S 项目定义的一个插件接口,使得 K8S 可以不依赖于任何特定的容器运行时,而是通过统一的接口与之通信。
• runc, crun, runsc 都是符合 OCI 标准的极简 Container Runtime, 它们直接跟 OS 交互实现容器启动与运行。
• containerd, cri-o 都是符合 CRI 标准的 Container Runtime,通常会直接调用 runc 来实现容器运行功能,还添加了一些其他功能如镜像管理等。其中 containerd 是从 Docker 项目中剥离出来的独立开源项目,所以其他 client 比如 ctr,也可以直接使用。而 cri-o,从名字就能看出来,它是为了 K8S CRI 设计的,当然 containerd 也符合 CRI,所以 containerd 也可以在 K8S 环境下使用。在 K8S 1.20 以前,K8S 还可以通过一个叫 dockershim 的模块来实现对 Docker 的支持,但是考虑到维护的成本问题,从1.20以后 K8S 已经宣布不再支持该功能。
• Docker 是用 Go 编程语言编写的用于开发、发布和运行应用程序的平台,docker 则是 client 命令行工具,dockerd 是 server 端的守护进程,docker 跟 dockerd 可以部署在一起也可以分开。因为 Docker 整体的生态比较完善,很多人容易把容器跟 Docker 划等号,但实际上,从上面的关系图,我们就可以轻松发现 Docker 并不是容器开发及部署的唯一选择。
• Podman 则是无守护进程的容器引擎,用于开发、管理和运行 OCI 兼容的容器和容器镜像。Podman 在设计上是 docker 的替代品,可以让用户在没有守护进程的情况下直接与容器和镜像交互,事实上很多的 docker 命令都可以使用 podman 命令来替换。
• K8S 是一个开源的容器编排平台,可以自动化在部署、管理和扩展容器化应用过程中涉及的许多手动操作。
容器化部署环境下的安全防护实践
安全挑战
通过上面的介绍,我们不难发现容器只是一个松散的隔离环境,使用 Namespace 隔离存在的最大问题, 就是隔离得不够彻底。因为在 Linux 内核中, Namespace 是一个新加的功能,Linux 内核并不是从诞生的那一刻起就考虑了 Namespace,这就导致了并不是所有内核资源跟对象都支持Namespace(Linux 的内核还在不断的完善支持各种 Namespace,如 syslog namespace 等),所以就会导致还有很多内核资源跟对象是宿主机上所有容器共享的,比如时间,虽然现在最新的内核(5.6以后)已经支持了 Time Namespace,但很多容器运行时还没有支持,这就会导致,只要你在一个容器里面修改了时间,那么宿主机以及这个宿主机上的所有容器内的进程看到的时间就都变了。
网络实现了容器之间、容器与外部之间的通信,以及应用之间的交互,但在虚拟化的容器网络环境中,其网络安全风险较传统网络更复杂、严峻,如果容器之间未进行有效隔离和控制,则一旦攻击者控制某台容器,可以以此为跳板,攻击同主机或不同主机上的其他容器,也就是所谓的“东西向攻击”,也有可能形成拒绝服务攻击。
安全实践
1
镜像安全
• 安全扫描
定期地使用 Prisma、AWS ECR 等扫描工具对镜像进行静态扫描,检测出镜像中可能有 CVE(Common Vulnerabilities and Exposures) 的库,采用升级或其他方法来避免有 CVE 的库被打包进入镜像文件,从而减少攻击面。同时我们应该使用可信的容器镜像来源,避免使用未知或不受信任的镜像。
• 最小镜像
使用最小化的镜像,只包含运行应用程序所必需的依赖和库,以减少受攻击面,比如我们可以使用 UBI-Minimal 甚至 UBI-Micro 作为基础镜像。
2
隔离增强
• gVisor
gVisor 是 Google 开发的沙箱环境,前面我们一直强调容器的脆弱性很大程度上是因为容器之间共享了宿主机的内核,gVisor 在宿主内核和容器应用程序之间提供一个额外的隔离层,帮我们屏蔽或者减少对内核的访问。具体是通过一个名为 "runsc"(run secure container)的用户空间内核来实现隔离,这个用户空间内核拦截和处理容器中的系统调用,从而限制了对宿主内核的直接访问。当然由于 gVisor 只是实现了部分内核功能,所以实际部署时可能会遇到一些兼容性的问题,比如普通的 runc 能支持的 nftables,在 gVisor 现在还不支持。
• Kata
Kata Containers 是由 OpenStack Foundation 创建的项目,它使用虚拟机管理程序(如 QEMU)与硬件辅助虚拟化技术(如 Intel VT-x 或 AMD-V)来创建隔离的环境。这种方法可以提供类似于传统虚拟机的安全性,但是设计上更接近于容器的轻量级和快速启动特性。
在 K8S 环境下,启动这两种运行时,对应用程序来说都是透明的,在运行时安装完了以后,需要在 K8S 集群配置这个新的运行时(通常是通过编辑 containerd 的配置文件),然后在 Pod 的 yaml 定义文件里面指定 runtimeClassName。
在选择 Kata Containers 或 gVisor 时,需要考虑安全性、性能和兼容性的权衡。对于需要高度隔离和安全性的场景,Kata Containers 可能是更好的选择。而对于需要较低开销的场景,gVisor 可能更合适。实际的性能测试结果会根据具体的工作负载和配置而有所不同,因此在决定之前,最好进行针对性的测试。
3
安全上下文(Security Context)
K8S 提供了安全上下文的配置,可以很方便我们使用,安全上下文主要可以提供下面一些安全加强
运行容器的用户和组(避免使用 root 用户)。
是否允许容器提升其权限(通过 allowPrivilegeEscalation 属性)。
文件系统的只读属性。
使用 Linux 安全模块,如 SELinux、AppArmor、seccomp 等。
控制容器可以访问的能力(通过Linux的capabilities 来赋予 root 用户的部分权限)。
我们可以通过 PSP(Pod Security Policy) 规则来强制限制在 cluster 里面创建 Pod 一定要符合 PSP 指定的安全上下文配置,否则 Pod 创建会失败。
4
网络控制
K8S 的网络策略(NetworkPolicy)可以通过定义一系列的规则来控制 Pod 之间的通信。这些规则指定哪些 Pod 可以相互通信以及它们可以使用哪些端口。通过网络策略,我们可以:
限制 Pod 之间的流量,以防止潜在的恶意行为。
创建网络隔离区域,类似于传统的 DMZ。
实施基于标签的白名单或黑名单访问控制。
5
资源控制
为容器和 Pod 设置资源限制可以防止单个应用程序占用过多资源,从而影响其他应用程序的运行。在 K8S 中,你可以设置:
CPU 和内存请求(requests):容器启动时所需的最小资源量。
CPU 和内存限制(limits):容器可以使用的最大资源量。通过这些限制,你可以保护系统免受资源耗尽攻击(如 DoS 攻击)。
6
其他安全保障
• 使用 K8S 的角色基于访问控制(RBAC)来限制对 K8S API 的访问。
• 为不同的用户和服务帐户(Service Account)分配最小的必要权限。
• 使用秘密管理工具(如 K8S Secrets 等)来安全地存储和管理敏感信息。
• 使用 istio 服务网格来提供双向 TLS,强身份认证,授权和访问控制以及审计和监控等功能。
随着云计算的发展,以容器和微服务为代表的云原生技术,受到人们的广泛关注,与之相对应的,容器化部署面临的安全挑战也越来越多。如何保证容器安全,将是一个值得不断探索与研究的话题,而理解了容器技术的原理能帮助我们更好的理解容器技术本身的一些脆弱性,从而结合具体业务场景采取一些有针对性的安全防护方法。
参考文献:
https://gvisor.dev/docs/
https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/CHANGELOG-1.20.md#deprecation
https://kubernetes.io/docs/tasks/configure-pod-container/security-context/
https://en.wikipedia.org/wiki/Linux_namespaces
https://www.docker.com/blog/containerd-vs-docker/
https://docs.docker.com/guides/docker-overview/
https://docs.podman.io/en/latest/
https://vineetcic.medium.com/the-differences-between-docker-containerd-cri-o-and-runc-a93ae4c9fdac