🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
# 第6章 安全 安全一直都是个困难重重的领域。优秀的安全专家会在追求完美和生产上线之间寻求恰当的平衡,并促进开发团队考虑安全问题。 Docker在安全方面一直备受关注,因其“实用为先,安全在后”的模式总是会招致非议。此外,它并不具备单一的强安全模式及响应,而是依赖于多个层次,其中一些层次可能尚不存在或无法用于应用程序中。官方的[安全文档](http://docs.docker.com/articles/security/) 也非常缺乏,因此用户需要更多的支持。 这反映出了Linux的安全现状,在市面上有大量选择。尽管这些选择不全是容器专用的,但是容器添加了更多工具,以至于比非容器化环境面临的安全问题更为复杂。 容器或一般性微服务构架的安全评估,离不开对威胁模型(threat model)的理解。不同情况下,这些模型的形式也不同,不过其中很多都具有共通之处。 如果打算运行不受信任的代码,可能是作为服务提供商,或运行一个PaaS平台,人们可能会故意上传恶意代码,这与小团队开发和托管一个应用程序的要求有所不同。大型企业的情况则不一样,其监管要求代表着某些形式的隔离是强制性的。 如果你是一家服务提供商,目前虚拟化是隔离恶意代码最成熟的技术。不是说它没有安全问题,而是问题的数量很少且攻击面更小。这并不意味着容器不能作为解决方案的一部分,特别是如果正在提供某种语言的运行时(如Ruby或Node.js),下面所讲的很多技术也是完全适用的。容器是隔离的延伸形式,可以作为一个层次化防护,进一步减少攻击面。 Google在[Borg论文](http://static.googleusercontent.com/media/research.google.com/en//pubs/archive/43438.pdf)中解释了他们如何为受信任的内部作业提供完全不同的架构:“我们使用Linux chroot牢笼作为同一机器上多个任务之间的主要安全隔离机制”,这种安全性的形式比容器更脆弱,与之相反:“Google应用引擎(GAE)和Google计算引擎(GCE)使用虚拟机和安全沙箱技术运行外部软件。我们在一个作为Borg任务运行的KVM进程中运行每个托管的虚拟机”。 那些关注围绕容器构建微服务架构的安全性的大型和小型公司会特别关心这一章。 安全是一个复杂的领域,需要评估运行中应用程序的内容。不存在什么灵丹妙药,深度防御才是关键,这就是本章将涵盖多种不同方法来加强应用程序安全性的原因。很多方法最终会变成用户使用的工具,但是理解它们保护或不保护的范围,以及能否使用更具体的方案取代通用方案仍然非常重要。 Linux容器不是一个单一实体,与FreeBSD的[牢笼](https://www.freebsd.org/cgi/man.cgi?format=html&query=jail%282%29)(jail)不同,后者采用单一系统调用来创建和配置容器。相反,Linux容器是一组工具,可以进一步加强进程隔离,并远远超过传统的Unix用户ID机制和权限。 容器从根本上是构建于命名空间、cgroup及权能(capability)之上的。 Linux容器的核心是一系列的“命名空间”,效仿于Plan 9操作系统的思想。一个进程命名空间隐藏了所有命名空间之外的进程,给用户分配了一组新的进程ID,包括新的init(`pid 1`)。一个网络命名空间隐藏了系统的网络接口,并以一个新的集合取而代之。这里的安全方面是,如果一个项目没有可以引用的名字,就不能与之进行交互,也就形成了隔离。 系统中不是所有东西都命名空间化了,还存在大量全局状态。例如,时钟就不具备命名空间,所以如果有容器设置了系统时间,将影响内核中运行的所有东西。这些程序绝大多数只能被以root运行的进程影响。 另外一个问题是,Linux内核接口非常庞大,有些错误会藏身其中。具有超过300个系统调用,以及数以千计的各类`ioctl`操作,只要有一个存在用户输入验证错误,就会导致内核漏洞。 不过,有很多方法可以用来降低这些风险,接下来我们会对此做一些介绍。 最基本的建议是及时使用安全补丁更新内核。我们并不是很清楚哪些修改会造成安全问题,因为可能很多错误已经造成漏洞,只是还没被人发现。这意味着用户需要经常性地重启容器宿主机,因此也将重启所有的容器。 显然,用户不会想要同时重启整个集群,这会造成所有服务下线,造成分布式系统法定节点缺失,因此需要考虑如何进行管理。CoreOS机器运行着`etcd`,当它们检测自己处于一个集群中时,会从`etcd`中获取一个[重启锁](https://coreos.com/docs/cluster-management/setup/update-strategies/),因此每次只会有一台机器重启。其他系统也需要一个类似的交错重启机制。 用户必须保持宿主机内核和宿主机操作系统被更新,并且保持对运行中的容器进行安全更新修补则是一个关键要求。 如果用户所运行的“胖”容器包含了整个宿主机操作系统,如RHEL或Ubuntu,要保持它们被更新就非常简单。只需要像对待虚拟机那样运行同样的工具软件即可。虽然这无法利用到基于容器的工作流,但至少是个很好理解的问题。 人们担心的情况是,Docker的使用是否会造成开发人员把内容不明的随机容器放置到生产环境中。显然,这不是用户希望发生的事。容器必须从头可重现地构建,如果组件中存在安全问题且无法在运行时更新的话,则必须重新构建。 最接近传统做法的方法是拿传统的发行版,使用类似Puppet这样的工具对其进行配置,然后将其作为一个基础Docker镜像。 微服务的路线是让容器只包含静态链接的二进制文件,如使用Go生产的,这样的构建过程就只是简单地利用更新后的依赖对应用程序进行重新构建。然后,升级问题就变成了构建时依赖管理问题。Java应用程序也与此类似。 在这些极端情况之间还存在着大量其他模型。重要的是要有一个模型,并且最理想的是有测试,可以测试构建产物。例如,在[`bash` shellshock bug](http://en.wikipedia.org/wiki/Shellshock_%28software_bug%29)被发现之后,用户希望能检查生产环境中的容器,并测试它们是否包含bash且存在漏洞。 Unix长期以来都用着一个设计糟糕的特权提升机制,文件可以被标记上`suid`或`guid`,在这种情况下,程序将以程序的属主(或组)身份运行,而非运行该程序的用户。通常这用于以root运行那些需要特殊权限的程序。如果程序编写得很好,那么它们会尽快丢弃这个root状态,在解析任何用户输入之前,并尽可能缩小使用范围。如果不是这么做,就存在被破坏的危险。 典型的可`suid`成root的二进制文件包括`su`、`sudo`、`mount`及`ping`。这些文件大多数在容器内是不需要的,因此可以对其进行删除、移除suid位,或使用`nosuid`选项挂载容器根目录以忽略它们。这是安全测试套件可以测试的东西。 需要注意的是,专门设计用于运行在容器内的发行版可以解决这类问题,但尚不多见。目前的发行版会假定这些基础命令都是必需的。有些轻量级容器基础系统使用了[Busybox](http://www.busybox.net/)核心小工具,这类工具没有安全地实现`suid`程序,未丢弃特权,因此千万不要在运行时启用`suid`。 使用下面的命令可以查找系统中所有的`suid`和`guid`文件: ``` find / -xdev -perm -4000 -a -type f -print find / -xdev -perm -2000 -a -type f -print ``` 容器的设计原则是保证容器内没有需要root权限的东西。尤其不要使用`docker run --privileged ...`,这将使用完全的root权限运行容器,并能执行宿主机可以操作的任何事情。 权能(见6.7节)是在需要时将root的权能子集赋予进程的一种方法。 用户命名空间(见6.12节)旨在提供一匹神奇的“不是root的root”独角兽,允许root的使用。6.12节中会详细讨论这一魔法。 遗憾的是,很多现存的容器都需要root权限,往往为了一些其实只需要修复即可的不好原因。其中一个例子是[Docker registry](https://github.com/docker/docker-registry/issues/915),它会在一个root拥有的目录中创建锁文件,除非用户禁用搜索功能,否则这个问题依然存在。 Linux对于root拥有的权能具有一些细粒度的权限,可以独立地分配给容器。capabilities(7)的[帮助页](http://linux.die.net/man/7/capabilities)中罗列了各个权能对应的操作。例如: `docker run --cap-add=NET_ADMIN ubuntu sh -c "ip link eth0 down"`将只使用`NET_ADMIN`权限来停止容器内的`eth0`接口,而这是完成此项操作的最低要求。可以以此运行那些需要suid的二进制文件,而不需要以root身份运行整个容器,但总的来说还是应该避免这么做,为了保持最大化的安全性,容器应在不带权能的情况下运行。 权能限制的是可以采取的操作类型,而seccomp过滤器则是完全移除了使用特定系统调用或特定参数调用的能力。 这一方案的困难之处在于确定应用程序需要使用的调用。用户可以使用跟踪的方式,不过必须覆盖100%的代码,而这很难做到。用户的代码可能会改变,使用的调用也可能改变。因此,对于一般用途的用例来说,最简单的策略是使用黑名单,过滤那些管理专用的,并且一般不为应用程序所用或完全过时的系统调用。大约25%的调用可以归入这些类别。 截至本书编写时,只有Docker lxc后端具有运行seccomp过滤器的钩子,默认的[libcontainer](https://github.com/docker/libcontainer/)后端则没有。在Docker仓库的[contrib目录](https://github.com/docker/docker/blob/487a417d9fd074d0e78876072c7d1ebfd398ea7a/contrib/mkseccomp.sample)中有一些过滤器示例。为应用程序设置自身的过滤器也是可能的。 Linux支持多个内核安全框架,其中最著名的是由NAS设计的与Red Hat Linux一起发行的SELinux。与Ubuntu一起发行的AppArmor与此类似。 SELinux是一个实现强制[访问控制](https://en.wikipedia.org/wiki/Mandatory_access_control)策略的框架。需要注意的是,它只是一个框架,必须定义实际的策略。但定义策略的人少之又少,其过程不仅复杂,且缺乏文档。因此,多数人使用的是供应商提供的策略。事实上,尽管存在一本解释SELinux的[填色书](http://opensource.com/business/13/11/selinux-policy-guide),Google搜索建议中最受欢迎的依然是“关闭”。 如果你不是在一个长期运作的组织里工作,如这些策略的发源地——美国国防部,定义确实管用的安全策略是件很困难的事。不过,原则上可以将其应用在隔离不同类型数据的访问上,对于PCI合规而言,是HR数据或个人信息。遗憾的是,支持这些用途的工具还相当缺乏。 不过,我们建议尽可能不要禁用供应商策略,并且要理解如何标记允许访问的项目。供应商策略好过没有策略。容器的策略相对较新,可能不会一直很好地工作。 Docker从1.3版本开始支持SELinux,不过默认是关闭的。`docker --selinux-enabled`将启用这个功能,而类似`--security-opt="label:user:USER"`的选项可以在运行容器时设置用户、角色、类型及标签。 内核cgroup功能是由Google创建的,用于在Borg调度器(Kubernetes的前身)中运行规模化的应用程序。 一个cgroup限制着分配给一组进程(通常是一个容器)的资源。cgroup控制器集合数量庞大而复杂,不过重要的几个与CPU时间、内存和存储被限制有关。 最简单的是内存和CPU访问限制。可通过`docker run -m 128m`来设置内存用量。通过`docker run --cpuset=0-3`来设置容器运行所在的CPU,而通过`docker run --cpu-shares=512`来分配CPU时间共享。 重要的一点是,要停掉占用了所有内存、IO带宽和CPU时间的应用程序,它将影响同一台宿主机上运行的其他应用程序。 根据容器设置的方式,可能还存在一些干扰。例如,除非彻底地分配了CPU,否则缓存将是共享的,而如果共享了IO设备,如网络或磁盘,则存在IO竞争。这种情况会带来多大的影响取决于负载以及超额申请的资源数量,不过通常这是一个吞吐量的问题,而非安全问题,不过[旁路攻击](https://en.wikipedia.org/wiki/Side-channel_attack)还是有可能发生。 Docker 1.6增加了`cgroup-parent`选项,它可以将容器附加到现存cgroup中。这意味着用户可以在Docker之外使用其他工具管理cgroup,然后选择要添加到容器中的cgroup。这使得用户可以使用所有的cgroup控制,而无需在意它们是否在Docker命令行中公开。 Docker 1.6引入了控制每个容器`ulimit`的能力。这是一个在每个进程基础上控制资源的古老的Unix功能。需要注意的是`ulimit`也可以用来配置最大的处理器数量。出于不同目的,在资源控制上`ulimit`可能比cgroup更为简单,系统管理员更熟悉。 此前,容器会继承Docker进程的`ulimit`,这个限制一般都设置得相当高。现在可以用以下命令来设置可创建进程数的默认`ulimit`:软限制为1024,硬限制为2048。 `docker -d --default-ulimit nproc=1024:2048`软限制是要强制执行的限制值,不过进程可以对其进行提高,最高至硬限制值。 然后,可以在每个容器级别上覆盖这些限制值,例如: `docker run -d --ulimit nproc=2048:4096 httpd`这将提高`httpd`容器的进程`ulimit`。 相比其他命名空间,用户命名空间加入Linux内核的时间要迟一些,也较为复杂。 其思想与其他命名空间形式类似,只是用于用户ID(uid)和组ID(gid)。特别是,处于用户命名空间内的容器中的root用户和uid 0,可以映射到宿主机不同的非特权用户上。 这意味着,容器的root用户在宿主机系统中只是一个普通用户,无法做任何特殊的事情。那么它如何能称为root?对于属于其容器的资源来说,它就是root,如容器的网卡,因此它可以重新配置容器的网卡,或绑定到80端口上。 这引入了更多复杂性,由于uid只是存储在文件系统中并分配权限给文件,因此对于不同的命名空间,它们的意义有所不同。这也意味着这项功能从引入到适合生产环境使用之间经历了长时间的延误。这样的延误意味着它错过了REHL 7.0的最后期限,无法得到来自Docker的直接支持,不过预计不会很久,在lxc驱动程序里也会得到部分支持。 用户命名空间另一个不太明显的优点是创建命名空间完全不需要root权限。这让Docker守护进程可以减少内部需要以root运行的代码量。 在拉取请求[\#12648](https://github.com/docker/docker/pull/12648)中引入了最基础的功能:容器内root用户不对应宿主机系统的root用户,但由于用户命名空间代码与libnetwork代码存在冲突,这一请求错过了Docker 1.7的最后期限,只能延后到Docker 1.8[\[1\]](part0012.xhtml#anchor61)。用户命名空间更复杂的功能就差得更远了。 Docker 1.3引入了Docker[镜像验证](http://blog.docker.com/2014/10/docker-1-3-signed-images-process-injection-security-options-mac-shared-directories/)的路线图的最初部分。这仅是个开始,其目标是追寻Linux包管理器的路线,打造一个完整的模型。在这个模型中用户有一组信任的密钥,其中可能包括了受信任的供应商以及用户所在组织的签名,未经签名的镜像则不允许运行。 当前的实现还只是一个开始,验证签名失败时它给出警告,但不会阻止未签名包的安装,因此它无法提供任何实际的安全性价值。不过,它只是路线图的一个开端,可以在签名镜像问题[\#2700](https://github.com/docker/docker/issues/2700)上查看其计划,并对其实现进行跟踪。 默认情况下,Docker守护进程只能通过本地Unix域套接字进行访问,这意味着可以在本地通过套接字的权限控制其访问,而远程访问是完全不可能的。 访问Docker守护进程将获取整台计算机完整的root权限,因此用户能以root身份运行Docker容器来执行宿主机上的任何命令,所以对访问的保护尤为重要。 如果用户使用`-H`选项强行将Docker绑定到一个TCP端口上以便进行远程控制(而不是通过ssh进行控制),那么用户需要使用iptables和SSL来控制其他访问。对多数用例而言,不推荐这么做。 要了解容器的运行情况,包括发现安全相关的问题,容器的监控非常重要。本书中有专门的一章讲述监控,读者可以从那里开始设计自己的监控策略。 如果容器需要访问提供硬件或虚拟设备访问的设备结点,可以通过`--device`选项传递所需的设备并设置权限。 例如,`docker run --device=/dev/snd:/dev/snd:r ...`将添加`/dev/snd`音频设备到容器中,使其在容器中只读。 由于设备节点允许`ioctl`访问,同时下层内核驱动程序中可能存在问题,它们成为了攻击面的一部分,尤其是特殊设备。因此,只提供需要的设备及最小的权限是最佳的策略。 在使用默认的`libcontainer`驱动程序时,Docker会很小心地以只读权限挂载必要的虚拟文件系统。如果用户使用的是lxc驱动程序,则这一步需要自己完成。如果容器内具有root权限,对类似`/sys`和`/proc/bus`这类文件系统的写权限可能造成宿主机受损。 不要在容器里运行ssh。要习惯从宿主机上管理它们。这不仅简化了容器,还消除了权限的复杂度的级别。习惯在需要时使用Docker提供的工具来查看容器运行情况。 简化的容器更易于管理,并且从宿主机进入容器也相当简单。实际上,Docker最终可以非常好地管理进程,而ssh是不必要的,还会增加复杂度。 服务需要使用密钥去访问其他服务,如访问AWS的密钥,或用于验证它们是否可以加入集群或访问资源。密钥管理很难,是个有待解决的问题,现在存在着或好或坏的方法,同时一些有用的工具正如雨后春笋般出现。 密钥应当只在必要时进行分发,如果它们被发现,最不可能的访问也会受到威胁。密钥应定期进行轮换,限制偶发缺口的持续时间。密钥不应被签入源代码中,因为更新密钥不应要求做一次新部署,而且它们最终将出现在公共的GitHub仓库中。 能够对密钥访问进行审计也是一个理想的目标,这样可以跟踪其使用。 出现的服务包括来自[SquareKeywhiz](https://corner.squareup.com/2015/04/keywhiz.html)和来自Hashicorp的[Vault](https://www.vaultproject.io/)。Kubernetes也有一份优秀的关于密钥管理的[设计文档](https://github.com/GoogleCloudPlatform/kubernetes/blob/master/docs/design/secrets.md),可作为密钥管理框架的基础。 如果运行在宿主机或虚拟机上的所有服务对相同的数据都具有相同级别的访问权限,可能就不太需要担心隔离的问题了。毕竟,这不会比单体应用程序更糟,单体应用程序的组件并没有真正的隔离。 虽然微服务可以让用户构建一个具备高级别的特权分离的更安全的架构,但对于不需要访问敏感数据的应用程序,这并不是首要目标。用户需要将安全方面的努力放在能带来最大收益的地方。 面向用户的服务面临着不可信的输入,显然是一个薄弱点,应与重要数据的所有访问隔离开。与PCI合规相关的端点不应与其他服务运行在同一台宿主机上,并且应隔离到自己的集群,以减少审计边界。 在第7章中,我们将细述Docker中镜像的构建。 - - - - - - [\[1\]](part0012.xhtml#ac61) 该请求最终合并入Docker 1.9。——译者注