这几年容器技术越来越火,Docker已经成为了容器的代名词,几乎所有的互联网公司都在用Docker做应用部署和运维。但是很多人虽然天天在用Docker,但是对Linux容器的底层原理了解不多,只知道怎么用命令,不知道容器到底是怎么实现的,遇到问题也不知道怎么排查。我这几年用Docker做了不少项目,也深入研究了一下Linux容器的底层原理,踩了不少坑,也积累了一些经验。今天就来聊聊Linux容器的底层原理,以及我总结的一些最佳实践。

一、容器到底是什么:不是虚拟机,是进程隔离

很多人刚接触Docker的时候,会把容器当成轻量级的虚拟机,觉得容器就是一个小的虚拟机,里面跑着一个完整的操作系统。其实这是不对的,容器和虚拟机有本质的区别。

虚拟机是通过Hypervisor(虚拟化层)模拟出完整的硬件,然后在上面安装完整的操作系统,每个虚拟机都有自己独立的内核、内存、CPU、磁盘、网络,是完全隔离的,隔离性很好,但是开销大,启动慢,资源占用多。

而容器不是虚拟机,容器本质上就是宿主机上的一个普通进程,只不过这个进程被Linux内核的Namespace和Cgroups技术做了隔离和限制,让它看起来像是一个独立的系统。容器和宿主机共享同一个内核,共享宿主机的硬件资源,没有自己独立的内核,所以容器的开销很小,启动很快,资源占用少,但是隔离性不如虚拟机。

举个例子,你在宿主机上启动一个Ubuntu的容器,这个容器里看起来是一个完整的Ubuntu系统,有自己的文件系统、进程树、网络、用户,但是实际上,这个容器里的进程,就是宿主机上的一个普通进程,用ps命令在宿主机上能看到,它和宿主机共享同一个内核,只是被Namespace隔离了,看不到宿主机的其他进程、文件、网络,被Cgroups限制了CPU、内存、IO的使用。

所以,容器的本质就是:被Namespace隔离了视图、被Cgroups限制了资源的宿主机进程。理解了这一点,就理解了容器的本质,很多容器的问题也就迎刃而解了。

二、容器的核心技术:Namespace和Cgroups

容器的核心技术就是Linux内核的Namespace和Cgroups,这两个技术分别实现了容器的隔离和限制,理解了这两个技术,就理解了容器的底层原理。

1. Namespace:隔离视图

Namespace(命名空间)是Linux内核提供的一种机制,它可以让一个进程看到的系统视图和其他进程不一样,也就是说,不同Namespace里的进程,看到的进程树、文件系统、网络、用户等都是不一样的,互相看不到,从而实现了隔离。

Linux内核提供了多种Namespace,分别隔离不同的系统资源:

  • PID Namespace:隔离进程ID,不同PID Namespace里的进程可以有相同的PID,每个Namespace里的进程都看不到其他Namespace的进程,每个Namespace都有自己的PID 1(init进程)。
  • Mount Namespace:隔离文件系统挂载点,不同Mount Namespace里的进程看到的文件系统不一样,可以有自己的根文件系统,挂载和卸载不影响其他Namespace。
  • Network Namespace:隔离网络,不同Network Namespace里有自己独立的网络栈、网卡、IP地址、路由表、端口,容器之间网络隔离,每个容器可以有自己的IP。
  • UTS Namespace:隔离主机名和域名,每个容器可以有自己的主机名,和宿主机以及其他容器不一样。
  • IPC Namespace:隔离进程间通信,不同IPC Namespace里的进程不能直接通信,比如共享内存、信号量、消息队列等。
  • User Namespace:隔离用户和用户组,不同User Namespace里的用户ID和组ID可以不一样,容器里的root用户在宿主机上可能只是一个普通用户,提升了安全性。

Docker在启动容器的时候,会为容器创建这些Namespace,然后把容器的进程放到这些Namespace里,这样容器里的进程就只能看到自己Namespace里的资源,看不到宿主机和其他容器的资源,从而实现了隔离。

你可以用ls /proc/<pid>/ns/命令查看一个进程属于哪些Namespace,也可以用nsenter命令进入一个进程的Namespace,在容器外调试容器里的进程,这是排查容器问题的一个很有用的技巧。

2. Cgroups:限制资源

Cgroups(Control Groups,控制组)是Linux内核提供的另一种机制,它可以限制和统计一组进程的资源使用,比如CPU、内存、磁盘IO、网络带宽等,还可以设置优先级,挂起和恢复进程。

Cgroups的作用就是给容器设置资源限制,比如一个容器最多用多少CPU、多少内存,防止一个容器占用太多资源,影响宿主机和其他容器。如果没有Cgroups,容器里的进程可以无限占用宿主机的CPU和内存,一个容器出问题可能会把整个宿主机搞挂,这显然是不行的。

Cgroups是通过文件系统来管理的,一般挂载在/sys/fs/cgroup目录下,下面有各个子系统的目录,比如cpu、memory、blkio等,每个子系统下面可以创建控制组,每个控制组有对应的配置文件,设置资源限制,然后把进程加到控制组里,就生效了。

Docker在启动容器的时候,会为容器创建对应的Cgroups控制组,设置好CPU、内存、IO等资源限制,然后把容器的进程加到这些控制组里,这样容器的资源使用就被限制了。

你可以用docker stats命令查看容器的资源使用情况,也可以直接看/sys/fs/cgroup下面的文件,查看更详细的资源使用和限制信息,排查容器资源问题的时候很有用。

三、容器镜像:分层文件系统和Copy-on-Write

理解了Namespace和Cgroups,就理解了容器的运行时隔离和限制,接下来看看容器镜像是怎么回事。

容器镜像就是一个只读的模板,包含了容器运行需要的文件系统、程序、依赖、配置等,启动容器的时候,就是以镜像为基础,创建一个可写的层,然后在里面运行进程。

Docker镜像是用分层文件系统(UnionFS)实现的,一个镜像由很多层(Layer)组成,每一层都是只读的,下面的层是基础,上面的层叠加在下面的层上,最终形成一个完整的文件系统。

比如一个Ubuntu的镜像,最底层是bootfs(引导文件系统,包含bootloader和内核,容器启动后会卸载,所以你看不到),然后是rootfs(根文件系统,就是Ubuntu的基础文件系统,/bin、/etc、/lib等目录),然后上面可能还有你安装的软件、修改的配置等,每一次修改都会创建一个新的层,叠加在上面。

分层的好处是:

  • 复用层:不同的镜像可以共享相同的层,比如多个镜像都基于Ubuntu,那它们就共享Ubuntu的那几层,不需要重复存储,节省存储空间,下载镜像的时候也只需要下载没有的层,速度更快。
  • Copy-on-Write(写时复制):所有的层都是只读的,启动容器的时候,会在最上面创建一个可写的层,容器运行时对文件系统的修改,都写在这个可写层里,不会修改下面的只读层。当你修改一个文件的时候,会先把这个文件从下面的只读层复制到可写层,然后在可写层修改,这就是写时复制。这样多个容器可以共享同一个镜像的只读层,每个容器只有自己的可写层,大大节省了存储空间。

镜像的分层和写时复制,是Docker镜像的核心原理,理解了这个,就能理解为什么镜像构建要尽量减少层数,为什么修改文件会增加镜像大小,为什么容器删除后数据会丢失(因为可写层被删了)。

构建镜像的时候,有一些最佳实践:

  • 尽量减少层数,每一条RUN、COPY、ADD指令都会创建一层,所以尽量把多个命令合并到一条RUN里,用&&连接,减少层数。
  • 把变化少的层放在下面,变化多的层放在上面,这样构建缓存更有效,大部分层可以复用,不用每次都重新构建。
  • 不要在镜像里放不需要的文件,构建完之后清理缓存和临时文件,减小镜像体积。
  • 用.dockerignore文件排除不需要的文件,减小构建上下文的大小,加快构建速度。
  • 尽量用官方的基础镜像,安全、稳定、体积小,不要用乱七八糟的第三方镜像。

四、容器网络:从桥接到自定义网络

容器网络是Docker里比较复杂的一部分,很多人对容器的网络原理不太清楚,遇到网络问题不知道怎么排查。简单聊聊Docker的网络原理。

Docker默认有几种网络模式:

  • bridge(桥接模式):默认的网络模式,Docker会创建一个docker0的网桥,容器启动的时候,会创建一对veth pair(虚拟网卡对),一端在容器里,叫eth0,另一端在宿主机上,连接到docker0网桥上。这样容器之间可以通过docker0网桥互相通信,容器访问外网的时候,通过docker0网桥,再经过宿主机的iptables NAT转换,用宿主机的IP访问外网。
  • host(主机模式):容器和宿主机共享同一个Network Namespace,容器没有自己的网络栈,直接用宿主机的网络,容器里的服务直接占用宿主机的端口。这种模式网络性能最好,没有隔离,但是端口会冲突,安全性差,适合对网络性能要求高的场景。
  • none(无网络模式):容器没有网络,只有lo回环接口,不能和外界通信,适合完全不需要网络的场景,或者自己手动配置网络的场景。
  • container(容器模式):和另一个容器共享同一个Network Namespace,两个容器共享网络栈,可以用localhost互相访问,适合需要紧密协作的容器,比如sidecar模式。
  • 自定义网络:Docker支持创建自定义的网络,比如自定义bridge网络、overlay网络(跨主机)、macvlan网络等,自定义bridge网络比默认的docker0更好,支持容器之间用容器名互相解析(DNS),更好的隔离性,更灵活的配置。

容器网络的原理,本质上还是Linux的网络Namespace、veth pair、网桥、iptables、路由等技术,理解了这些Linux网络基础,就能理解Docker的网络原理,遇到网络问题也能排查。

容器网络的最佳实践:

  • 生产环境尽量用自定义bridge网络,不要用默认的docker0,自定义网络有更好的隔离性和DNS解析。
  • 不要用--link,已经过时了,用自定义网络的DNS解析就行。
  • 容器之间通信尽量用容器名,不要用IP,因为容器重启IP可能会变。
  • 只映射需要的端口,不要把所有端口都映射到宿主机,减少攻击面。
  • 跨主机的容器通信,可以用overlay网络,或者用Kubernetes、Swarm等编排工具,不要自己搞复杂的端口映射。

五、容器存储:数据卷和绑定挂载

容器的文件系统是临时的,容器删除之后,可写层就没了,数据就丢了,所以需要持久化存储来保存重要的数据。

Docker提供了两种持久化存储的方式:

  • Volume(数据卷):由Docker管理的持久化存储,存在Docker的目录下(一般是/var/lib/docker/volumes),可以在容器之间共享和复用,生命周期独立于容器,容器删除了数据卷还在。这是Docker推荐的持久化方式。
  • Bind Mount(绑定挂载):把宿主机上的某个目录或文件挂载到容器里,直接映射宿主机的路径,容器里的修改会直接反映到宿主机上,宿主机的修改也会反映到容器里。这种方式更灵活,但是依赖宿主机的目录结构,可移植性差,而且有权限问题。

怎么选?一般来说,需要持久化的数据,比如数据库的数据,用Volume,由Docker管理,更安全,更规范。需要在宿主机和容器之间共享文件,比如配置文件、日志、代码开发的时候挂载源码,用Bind Mount,更灵活。

存储的最佳实践:

  • 数据库等有状态服务的数据,一定要用Volume持久化,不要存在容器的可写层里,不然容器删了数据就没了。
  • 不要在容器里存重要数据,容器是随时可以删除和重建的,数据要存在Volume或者外部存储里。
  • 开发环境可以用Bind Mount挂载代码,实现热更新,生产环境不要这样,把代码打包到镜像里。
  • 定期备份Volume里的数据,Volume虽然持久化,但是也可能出问题,重要数据一定要备份。
  • 注意权限问题,Bind Mount的时候,宿主机目录的权限要和容器里的用户匹配,不然容器里的进程可能没有权限读写。

六、容器运行和运维的最佳实践

最后总结一些容器运行和运维的最佳实践,都是我踩坑踩出来的经验:

1. 一个容器只跑一个进程:这是Docker的最佳实践,一个容器只跑一个主进程,不要在一个容器里跑多个服务,比如不要在一个容器里同时跑Nginx和PHP-FPM和MySQL,应该分成三个容器,用docker-compose或者Kubernetes编排。这样更符合容器的设计哲学,更容易扩展、维护、排错,也更安全。

2. 容器进程不要用root用户运行:默认情况下,容器里的进程是以root用户运行的,虽然有User Namespace隔离,但是还是有安全风险,万一容器被攻破,攻击者可能获得宿主机的root权限。所以应该在Dockerfile里创建普通用户,用USER指令切换到普通用户运行进程,最小权限原则,提升安全性。

3. 容器要设置资源限制:启动容器的时候,一定要设置--memory和--cpus等参数,限制容器的CPU和内存使用,防止一个容器占用太多资源,把宿主机搞挂,或者影响其他容器。不要让容器无限使用资源,这是生产环境的基本要求。

4. 容器要设置健康检查:在Dockerfile里用HEALTHCHECK指令设置健康检查,或者在编排工具里配置,这样Docker或者编排工具能知道容器是否健康,不健康的话可以自动重启,实现自愈。没有健康检查,容器进程挂了或者服务不正常了,都不知道,会影响业务。

5. 不要在容器里存日志,日志要输出到stdout/stderr:容器里的应用日志,不要写到文件里,要直接输出到标准输出(stdout)和标准错误(stderr),这样Docker可以统一收集和管理日志,用docker logs命令就能查看,也可以用日志驱动把日志转发到ELK等日志系统。写到文件里的话,容器删除了日志就没了,而且不方便统一收集。

6. 镜像要打标签,不要用latest:构建镜像的时候,要打明确的版本标签,比如v1.0.0、20231026,不要用latest标签,latest是可变的,不知道具体是哪个版本,出了问题不好回滚。生产环境要用明确的版本标签,可追溯,可回滚。

7. 定期清理不用的镜像和容器:Docker用久了,会积累很多不用的镜像、容器、数据卷,占用大量磁盘空间,要定期清理,用docker system prune命令可以清理不用的资源,保持系统干净。但是清理的时候要小心,不要删了有用的数据。

8. 不要用docker commit构建镜像:docker commit是把容器的当前状态保存成镜像,这样构建的镜像不透明,不知道里面改了什么,而且层数多,体积大,不可复现。应该用Dockerfile构建镜像,Dockerfile是文本文件,可以版本控制,可复现,可审查,这是正确的构建镜像的方式。

七、写在最后

Linux容器原理最佳实践:我总结了这些经验。

以上就是我对Linux容器底层原理的理解,以及总结的一些最佳实践,从容器的本质、Namespace和Cgroups、镜像分层、网络、存储,到运行和运维的最佳实践,都做了比较详细的介绍。容器技术看起来简单,就是几个命令的事情,但是深入理解底层原理之后,你会发现里面有很多学问,理解了原理,才能用好容器,遇到问题才能快速排查和解决。

这几年容器技术发展很快,Docker、Kubernetes、Service Mesh等技术层出不穷,但是万变不离其宗,底层的原理还是Linux的Namespace、Cgroups、网络、文件系统这些基础技术。把基础打牢,理解了底层原理,不管上层技术怎么变,都能快速掌握。

容器是个好东西,它改变了应用的部署和运维方式,大大提升了开发和运维的效率,但是也要用对、用好,遵循最佳实践,不然也会踩很多坑。希望我的这些经验能帮到大家,让大家少踩坑,用好容器。

最后用一句话结尾:"容器不是虚拟机,是被隔离和限制的进程。"理解了这句话,就理解了容器的本质。愿我们都能用好容器,让技术为我们服务,而不是被技术折腾。