如果应用程序逻辑有误,会造成操作系统崩溃…
这句话其实不对。如果一个应用程序都能让一个操作系统崩溃了,那这一定是这个系统在设计上或者实现上的BUG!再次重申,我不知道谭浩强的C语言教材现在是怎么讲的,但是至少在15年前,很多老师都会说访问空指针会造成操作系统崩溃,这在32位虚拟内存的系统中是错误的。
虽然一个应用程序不能让一个正常的现代操作系统崩溃,但是它却可以对操作系统的运行环境造成巨大的人为破坏,比如触发一个操作系统潜在的漏洞…即便是基于虚拟地址空间的操作系统,也是不安全的。更退一步,抛开安全漏洞,从资源利用的角度看,如何限制一个进程或者一组进程可以使用的资源也是一个亟待解决的问题。换句话说,需求就是隔离。
于是人们就想出了沙盒这个概念,即Sandbox。将一个应用程序或者一组应用程序隔离在一个受限的环境中,使其无法逃逸。
概念很OK,实现起来就五花八门了,各说各的理。何谓受限,如何确保…
很早之前就玩过Java Applet,这种依托本地JVM运行远程Java字节码的环境就是一个沙盒,通过一个特殊的类加载器从一个URL加载字节码并运行。后面自从不做Java了以后我就再也没有关注过沙盒这个概念,直到前些时间偶然touch了一下Docker。不禁感叹,时间过了10年,技术变化的太多。
和大概六七个朋友聊Docker,大家水平绝对是第一梯队,都比我强太多,结果从他们那里获得的结论和我预期的结论相去甚远。Docker不是红了好几年吗?然而在他们眼里,无一例外,都在唱衰,他们都是用一种鄙视甚至哀其不幸的眼光来看待Docker这个现象级的玩具,是的,至少4个人说了相同的话,Docker从上到下就是个培训班课后作业或者玩具之类的话,当然了,这些人中大多数互相并不认识…
我比较懵的是,我不知道该站哪队了,其实我是想本着学习的态度向他们讨教些干货的,可没想到上来就是如此形而上的东西,令我悚然。我记得上周末午夜正在看恐怖悬疑电影放松一下,结果收到来自不同人的三封邮件(其中一封不是讨论形而上学的,而是讨论那个macvlan虚拟网卡和宿主网卡之间通信的),看了以后,便关上了电视,打开了一个SecureCRT终端…我想实地考察一下Docker,firejail这些在他们眼里为什么是如此不堪。
我是并不懂什么容器,沙盒这些的,我只是在工作中碰到了一个关于容器内网卡无法释放的BUG,需要定位,所以才稍微窥了一眼Docker,后来又顺藤摸瓜了解了firejail,而已…最后突然发现,好一片广阔的天地,感谢这些人的介绍,我又了解了seccomp以及gVisor这些。
现在,表一下我的观点,形而上的观点。
Docker或者说类似的容器到底好不好,我觉得这里面牵扯到两个问题:
内核态实现还是用户态实现的问题;
内核态实现的话,它能不能做好的问题。
同样的问题在网络协议栈领域也存在,于是就诞生了各类一路高歌猛进的用户态协议栈,伴随着的就是各种对内核协议栈的唱衰。
诚然,内核作为一个通用的基础设施,很多人都倾向于别什么东西都往内核里塞,当然,我也一直都这么想的。那么只要是依托内核机制的一切东西,看起来总是有那么一点点别扭,总是想把它拽出来看看能不能在用户态实现,这就跟性能优化领域中大多数人看见数值参数就想调大一点是一样的。所以说,对于大多数持此想法的人而言,即便是seccomp也同样是不堪,不行,毕竟seccomp也有一部分代码在内核态支撑着。
我的看法稍微不同,我更倾向于解决问题而不是设计方案,所以并不是很在意方式。我并不认为Namespace,Cgroup这种内核机制和gVisor沙盒之类说的是一回事。不过从分类上讲,比较让人疑惑和费解的时,一旦承认我上面说的,即它们不是一回事,你就很难解释为什么基于Namespace和Cgroup的Docker叫做容器,而基于同样机制的firejail却叫做Sandbox(其manual上就是这么说的)。不过我还是选择忽略这种措辞上的不同,不再咬文嚼字。
如果说Namespace隔离地不够,有泄漏,那是BUG,我的第一想法是如何让它隔离地更彻底,而不是彻底放弃它。如果说Cgroup不够彻底,那就想办法让它彻底,管它用户态还是内核态呢,管它有没有污染内核框架呢。这是容器的范畴。
要说限制进程的行为影响到同一内核上的其它进程,我觉得seccomp就非常不错。你想想,应用程序如果从不进行IO,那么在运行期间,操作系统的存在就是一个累赘,比如我就一个CPU密集型的计算任务,根本就不需要操作系统,当然,站在操作系统的角度,为了公平性,还是需要进行强制调度的,除此之外,它便不需要为应用程序提供任何服务。这时应用程序和内核之间的唯一主动交互手段系统调用是不需要的(所需的内存可以在进程实际启动之前从库里早已准备好的内存池里申请),为了让这种不需要系统调用得到一种保证,用seccomp限制它不是很好吗?而这个是沙盒的范畴。
从概念上讲,沙盒真的就不该依托共享的内核来构建,然后再把共享的内核用某种机制比如Namespace,Cgroup隔离成至少看起来不那么共享的区域,而这种复杂的策略注定在内核态是做不好的。但在我看来,内核的问题仍然不过是一个bugfix的问题而不是一个refactor问题。
是的,沙盒是要在用户态做,然而,容器必须是内核支撑,换句话说,两者并不是一回事,容器里装的是沙盒而不是一个或者一组进程,没人会把罐头直接扔进集装箱的,高档西装在扔进集装箱前也要在外面包裹几层箱子…即便是gVisor也有介绍如何将其装进Docker。
如果你非要抬杠说不依靠内核容器就能做好一切,那么就一个问题,如果我把沙盒内的一个进程的一段核心代码污染了,比如污染成了:
while(1);
怎么办?怎么限制其CPU利用率?不要依靠任何内核的隔离机制。
UNIX/Linux内核本身就是大内核,因此它本身就是揉在一起的一大坨东西,不管是静态代码还是运行时逻辑,它不像理想中的微内核那样仅仅通过消息传递来沟通,而是依赖了很多共享的东西。
举一个最简单的例子,你启动一个容器:
root@debian:/home/zhaoya# firejail --net=enp0s17 --ip=192.168.44.55/24
Reading profile /etc/firejail/server.profile
Reading profile /etc/firejail/disable-common.inc
Reading profile /etc/firejail/disable-programs.inc
Reading profile /etc/firejail/disable-passwdmgr.inc
** Note: you can use --noprofile to disable server.profile **
Parent pid 40456, child pid 40457
The new log directory is /proc/40457/root/var/log
Interface MAC IP Mask Status
lo 127.0.0.1 255.0.0.0 UP
eth0-40456 72:63:2f:a3:60:b3 192.168.44.55 255.255.255.0 UP
Default gateway 192.168.44.2
Child process initialized
root@debian:~#
在容器内部的top显示中,你会发现:
root@debian:~# top
top - 19:40:41 up 1 day, 11:09, 0 users, load average: 0.39, 0.10, 0.03
Tasks: 3 total, 1 running, 2 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.0 us, 0.0 sy, 0.0 ni,100.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu2 : 0.7 us, 1.0 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu3 : 0.3 us, 0.0 sy, 0.0 ni, 99.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 2033804 total, 1368180 free, 267104 used, 398520 buff/cache
KiB Swap: 1046524 total, 1046524 free, 0 used. 1614320 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
1 root 20 0 18264 2068 1816 S 0.0 0.1 0:00.02 firejail
3 root 20 0 21132 4868 3156 S 0.0 0.2 0:00.08 bash
8 root 20 0 44800 3620 3112 R 0.0 0.2 0:00.03 top
很费解吧,一共就三个进程,却有一个CPU的利用率达到100%,这如何解释?容器内的观察者无法观察到容器外的进程行为,无法分析是谁吃掉了CPU…
确实,这就是一个问题,然而,能解决吗?能啊。用调度组和Cgroup隔离一下,然后我们改变一下统计数据的解读方式,按照Cgroup内部来统计百分比,而不是全局统计,这就解决了问题。
对于沙盒而言,最典型最简单的操作系统级沙盒就是32位保护模式下的进程本身了吧,一个进程崩溃不至于造成整个操作系统崩溃。而在没有操作系统沙盒的时代,比如16位实模式Dos,真的就是一个进程崩溃整个操作系统就连带着崩溃。
32位虚拟内存隔离的代价,就是IPC代替了直接访问内存,消除这种代码的方式就是线程,所以说,线程就是隔离和效率之间一个权衡的产物,沙盒依然是进程。
对于Linux而言,它的风格是一贯的。沙盒是进程而不是线程,这点非常明确,然而Linux默认调度的却是线程而不是先调度进程再调度线程,在内核里,它只认task_struct这个schedule entry!也就是说,进程沙盒之间的CPU资源本来就是共享的而不是隔离的,然而内存却是隔离的。虽然我们可以把一个进程的多个线程放入同一个调度组,但是一般情况下没人去那么做,并且,调度组这个概念本身也是后来才引入的。
我的观点是,服务放进沙盒,沙盒在用户态做,然后将沙盒放入一个内核支撑的容器,配置好容器的规格,然后发布。不然,如果你要把所有的东西整成一大坨,那么就考虑类似JVM或者别的VM那样的大家伙吧….
不过,可以期待,肯定也有人看不惯JVM。总之,什么都是错。
咬文嚼字。
我觉得Docker和集装箱的隐喻为人们带来了一个新词,即容器,否则,就都喊沙盒了。这是Docker火爆了之后带来的礼物…