奇迹之流WonderfloW

Nothing Replaces Hard Work!

Tutum-agent原理浅析

| Comments

tutum-agent是tutum提供的一个开源代理引擎。当你把tutum-agent安装在你本地机器上以后,你就可以把本地的机器节点添加到云端,让tutum来帮你统一进行管理。你随时随地都可以登陆tutum网站获得如下功能。

  1. 启停Docker容器。
  2. 动态伸缩容器实例数量。
  3. 监控容器状态、容器CPU\Memory\Disk\Bandwidth使用量
  4. 查看容器日志、操作记录
  5. 与tutum其他服务结合的衍生功能(部署应用、绑定服务等)

可见,安装了tutum-agent以后相当于把本地的机器加入到了tutum的数据中心进行统一管理。本文将分析tutum-agent的工作原理。

安装

tutum-agent通过shell进行安装,安装脚本可以在执行官方的安装指令时下载curl -Ls https://get.tutum.co/,也可以在github源码中找到。

这个shell脚本很简单,根据不同linux发行版安装tutum-agent,如果是ubuntu则是执行apt-get install -yq tutum-agent,然后生成配置文件/etc/tutum/agent/tutum-agent.conf,内容如下。

1
2
3
4
5
CertCommonName:"<hashID>.node.tutum.io"
DockerHost:"tcp://0.0.0.0:2375"
TutumHost:"https://dashboard.tutum.co/"
TutumToken:"<tokenID>"
TutumUUID:"<UUID>"

这些配置文件的作用在随后的代码解析中讲解。生成完配置文件后启动该服务。

1
service tutum-agent restart

至此shell脚本执行完了,但是安装过程并没有结束,在安装tutum-agent时,会因为冲突卸载原先已经安装好的Docker。

启动后的第一件事就是在TutumHost上注册并验证用户的TutumToken和TutumUUID,获得相应的证书文件,然后从TutumHost上下载Docker的二进制文件,目前Tutum官方提供的是1.5版本的Docker。随后启动Docker daemon,之前下载的证书也主要用在这里。

启动完Docker以后,就开始下载Ngrok客户端。Ngrok是一个网络代理,它可以让你在防火墙或者NAT映射的私有网络内的服务,让公网可以直接访问。Ngrok的实现方式是,在公网架设一个Ngrok 服务器,私有网络的Ngrok客户端与公网的Ngrok Server保持tcp长连接,用户通过访问Ngrok Server进行代理,从而访问到Ngrok客户端所在机器的服务。使用Ngrok客户端,只需要配置你想要提供服务的应用程序对外服务的端口即可。把这个端neng l口告诉Ngrok客户端,就相当于把本地私有网络下的服务提供给外部。

至此,所有的服务也就安装完毕了。总结一下,我们实际上安装了tutum-agent、Docker、Ngrok三个应用程序。通过Ngrok把本地的Docker作为服务让公网的其他用户可以使用,tutum-agent在随后会处理包括日志、监控、Docker指令处理等相关内容。

连接

安装完Ngrok以后,实际上就相当于建立了穿越NAT网络的隧道,使得公网所在的程序可以直接控制私有网络的Docker服务。Tutum-agent在启动Docker时使用了用户相关的证书,保证了Docker只接受有证书认证的请求,一定程度上保证了本地服务的安全。

获得了本地Docker服务的控制能力,容器相关的功能的实现自然迎刃而解。Ngrok还会为用户提供命令操作日志以便于审计,所以你可以方便的查看容器的操作记录。

建立了连接后我们还需要做什么?

通过Ngrok,这一切都变得很简单,但是别忘了,Docker daemon的运行状态需要时刻监控。tutum-agent会对Docker daemon的运行状态周期性的检查,一旦daemon意外推出,可以保证快速检测到并重启。

性能

ngrok是一个非常强大的tunnel工具,使用它以后本身带来的代理转发性能损失非常低。

但是实际上通过ngrok搭建起来的服务,都是应用用户先去访问ngrok服务端,然后再有服务端转发给ngrok客户端,最后处理用户请求并返回。所以,真正让人感知到性能损失的可能是你的ngrok服务端搭建的位置较远,而ngrok客户端与应用用户本身网络较近,这样就容易导致较高的应用访问延迟。

延伸

看完了上述内容,可能你会想到,如何构建一个类似Tutum的“Bring your own node”服务,并把它应用到Docker以为的其他项目上。

第一步,搭建自己的ngrok服务,使得用户可以通过ngrok客户端连接并在任意网络访问本地的服务,可参考博客“搭建自己的ngrok服务”

第二步,区分用户。当你的ngrok对外提供服务的时候,会有许多客户端来连接,不同的客户端可能是不同的用户连接的,也有可能是同一个用户的不同应用或不同主机节点。所以你需要在服务端编写自己的判断逻辑,方法很简单。ngrok客户端与服务端建立连接时会生成子域名(或者自定义域名),这个域名一旦建立了连接,就是唯一的,别的用户无法占用,通过这个方法就可以进行最简单的区分。当然,根据你对外提供的服务,你可能还需要通过生成证书来保证服务的安全性。

第三步,通过API进行操控。当ngrok客户端连接成功后,实际上服务端已经可以连接用户私有网络下服务的端口了,通过端口自然可以访问到服务的API,就基本上可以让用户全局操控自己的服务了。

第四步,日志、监控、报警与可视化。简单的日志收集分为两部分,一部分自然是ngrok的操作日志,另一部分则是应用相关的日志,需要通过应用API收取。简单来说,监控也分为两部分,一部分是用户主机资源、服务可用性相关的数据监控,另一部分是业务相关的监控。

基本上,有了这些,一个最基本的服务就完成了。

Docker背后的容器管理——libcontainer深度解析

| Comments

原文首发于InfoQ http://www.infoq.com/cn/articles/docker-container-management-libcontainer-depth-analysis

libcontainer 是Docker中用于容器管理的包,它基于Go语言实现,通过管理namespacescgroupscapabilities以及文件系统来进行容器控制。你可以使用libcontainer创建容器,并对容器进行生命周期管理。

容器是一个可管理的执行环境,与主机系统共享内核,可与系统中的其他容器进行隔离。

在2013年Docker刚发布的时候,它是一款基于LXC的开源容器管理引擎。把LXC复杂的容器创建与使用方式简化为Docker自己的一套命令体系。随着Docker的不断发展,它开始有了更为远大的目标,那就是反向定义容器的实现标准,将底层实现都抽象化到libcontainer的接口。这就意味着,底层容器的实现方式变成了一种可变的方案,无论是使用namespace、cgroups技术抑或是使用systemd等其他方案,只要实现了libcontainer定义的一组接口,Docker都可以运行。这也为Docker实现全面的跨平台带来了可能。

1. libcontainer 特性

目前版本的libcontainer,功能实现上涵盖了包括namespaces使用、cgroups管理、Rootfs的配置启动、默认的Linux capability权限集、以及进程运行的环境变量配置。内核版本最低要求为2.6,最好是3.8,这与内核对namespace的支持有关。

目前除user namespace不完全支持以外,其他五个namespace都是默认开启的,通过clone系统调用进行创建。

1.1 建立文件系统

文件系统方面,容器运行需要rootfs。所有容器中要执行的指令,都需要包含在rootfs(在Docker中指令包含在其上叠加的镜像层也可以执行)所有挂载在容器销毁时都会被卸载,因为mount namespace会在容器销毁时一同消失。为了容器可以正常执行命令,以下文件系统必须在容器运行时挂载到rootfs中。

路径 类型 参数 权限及数据
/proc proc MS_NOEXEC,MS_NOSUID,MS_NODEV
/dev tmpfs MS_NOEXEC,MS_STRICTATIME mode=755
/dev/shm shm MS_NOEXEC,MS_NOSUID,MS_NODEV mode=1777,size=65536k
/dev/mqueue mqueue MS_NOEXEC,MS_NOSUID,MS_NODEV
/dev/pts devpts MS_NOEXEC,MS_NOSUID newinstance,ptmxmode=0666,mode=620,gid5
/sys sysfs MS_NOEXEC,MS_NOSUID,MS_NODEV,MS_RDONLY

当容器的文件系统刚挂载完毕时,/dev文件系统会被一系列设备节点所填充,所以rootfs不应该管理/dev文件系统下的设备节点,libcontainer会负责处理并正确启动这些设备。设备及其权限模式如下。

路径 模式 权限
/dev/null 0666 rwm
/dev/zero 0666 rwm
/dev/full 0666 rwm
/dev/tty 0666 rwm
/dev/random 0666 rwm
/dev/urandom 0666 rwm
/dev/fuse 0666 rwm

容器支持伪终端TTY,当用户使用时,就会建立/dev/console设备。其他终端支持设备,如/dev/ptmx则是宿主机的/dev/ptmx 链接。容器中指向宿主机 /dev/null的IO也会被重定向到容器内的 /dev/null设备。当/proc挂载完成后,/dev/中与IO相关的链接也会建立,如下表。

源地址 目的地址
/proc/1/fd /dev/fd
/proc/1/fd/0 /dev/stdin
/proc/1/fd/1 /dev/stdout
/proc/1/fd/2 /dev/stderr

pivot_root 则用于改变进程的根目录,这样可以有效的将进程控制在我们建立的rootfs中。如果rootfs是基于ramfs的(不支持pivot_root),那么会在mount时使用MS_MOVE标志位加上chroot来顶替。

当文件系统创建完毕后,umask权限被重新设置回0022

1.2 资源管理

《Docker背后的内核知识:cgroups资源隔离》一文中已经提到,Docker使用cgroups进行资源管理与限制,包括设备、内存、CPU、输入输出等。

目前除网络外所有内核支持的子系统都被加入到libcontainer的管理中,所以libcontainer使用cgroups原生支持的统计信息作为资源管理的监控展示。

容器中运行的第一个进程init,必须在初始化开始前放置到指定的cgroup目录中,这样就能防止初始化完成后运行的其他用户指令逃逸出cgroups的控制。父子进程的同步则通过管道来完成,在随后的运行时初始化中会进行展开描述。

1.3 可配置的容器安全

容器安全一直是被广泛探讨的话题,使用namespace对进程进行隔离是容器安全的基础,遗憾的是,usernamespace由于设计上的复杂性,还没有被libcontainer完全支持。

libcontainer目前可通过配置capabilitiesSELinuxapparmor 以及seccomp进行一定的安全防范,目前除seccomp以外都有一份默认的配置项提供给用户作为参考。

在本系列的后续文章中,我们将对容器安全进行更深入的探讨,敬请期待。

1.4 运行时与初始化进程

在容器创建过程中,父进程需要与容器的init进程进行同步通信,通信的方式则通过向容器中传入管道来实现。当init启动时,他会等待管道内传入EOF信息,这就给父进程完成初始化,建立uid/gid映射,并把新进程放进新建的cgroup一定的时间。

在libcontainer中运行的应用(进程),应该是事先静态编译完成的。libcontainer在容器中并不提供任何类似Unix init这样的守护进程,用户提供的参数也是通过exec系统调用提供给用户进程。通常情况下容器中也没有长进程存在。

如果容器打开了伪终端,就会通过dup2把console作为容器的输入输出(STDIN, STDOUT, STDERR)对象。

除此之外,以下4个文件也会在容器运行时自动生成。 * /etc/hosts * /etc/resolv.conf * /etc/hostname * /etc/localtime

1.5 在运行着的容器中执行新进程

用户也可以在运行着的容器中执行一条新的指令,就是我们熟悉的docker exec功能。同样,执行指令的二进制文件需要包含在容器的rootfs之内。

通过这种方式运行起来的进程会随容器的状态变化,如容器被暂停,进程也随之暂停,恢复也随之恢复。当容器进程不存在时,进程就会被销毁,重启也不会恢复。

1.6 容器热迁移(Checkpoint & Restore)

目前libcontainer已经集成了CRIU作为容器检查点保存与恢复(通常也称为热迁移)的解决方案,应该在不久之后就会被Docker使用。也就是说,通过libcontainer你已经可以把一个正在运行的进程状态保存到磁盘上,然后在本地或其他机器中重新恢复当前的运行状态。这个功能主要带来如下几个好处。

  • 服务器需要维护(如系统升级、重启等)时,通过热迁移技术把容器转移到别的服务器继续运行,应用服务信息不会丢失。
  • 对于初始化时间极长的应用程序来说,容器热迁移可以加快启动时间,当应用启动完成后就保存它的检查点状态,下次要重启时直接通过检查点启动即可。
  • 在高性能计算的场景中,容器热迁移可以保证运行了许多天的计算结果不会丢失,只要周期性的进行检查点快照保存就可以了。

要使用这个功能,需要保证机器上已经安装了1.5.2或更高版本的criu工具。不同Linux发行版都有criu的安装包,你也可以在CRIU官网上找到从源码安装的方法。我们将会在nsinit的使用中介绍容器热迁移的使用方法。

CRIU(Checkpoint/Restore In Userspace)由OpenVZ项目于2005年发起,因为其涉及的内核系统繁多、代码多达数万行,其复杂性与向后兼容性都阻碍着它进入内核主线,几经周折之后决定在用户空间实现,并在2012年被Linus加并入内核主线,其后得以快速发展。

你可以在CRIU官网查看其原理,简单描述起来可以分为两部分,一是检查点的保存,其中分为3步。

  1. 收集进程与其子进程构成的树,并冻结所有进程。
  2. 收集任务(包括进程和线程)使用的所有资源,并保存。
  3. 清理我们收集资源的相关寄生代码,并与进程分离。

第二部分自然是恢复,分为4步。

  1. 读取快照文件并解析出共享的资源,对多个进程共享的资源优先恢复,其他资源则随后需要时恢复。
  2. 使用fork恢复整个进程树,注意此时并不恢复线程,在第4步恢复。
  3. 恢复所有基础任务(包括进程和线程)资源,除了内存映射、计时器、证书和线程。这一步主要打开文件、准备namespace、创建socket连接等。
  4. 恢复进程运行的上下文环境,恢复剩下的其他资源,继续运行进程。

至此,libcontainer的基本特性已经预览完毕,下面我们将从使用开始,一步步深入libcontainer的原理。

2. nsinit与libcontainer的使用

俗话说,了解一个工具最好的入门方式就是去使用它,nsinit就是一个为了方便不通过Docker就可以直接使用libcontainer而开发的命令行工具。它可以用于启动一个容器或者在已有的容器中执行命令。使用nsinit需要有 rootfs 以及相应的配置文件。

2.1 nsinit的构建

使用nsinit需要rootfs,最简单最常用的是使用Docker busybox,相关配置文件则可以参考sample_configs目录,主要配置的参数及其作用将在配置参数一节中介绍。拷贝一份命名为container.json文件到你rootfs所在目录中,这份文件就包含了你对容器做的特定配置,包括运行环境、网络以及不同的权限。这份配置对容器中的所有进程都会产生效果。

具体的构建步骤在官方的README文档中已经给出,在此为了节省篇幅不再赘述。

最终编译完成后生成nsinit二进制文件,将这个指令加入到系统的环境变量,在busybox目录下执行如下命令,即可使用,需要root权限。

1
nsinit exec --tty --config container.json /bin/bash

执行完成后会生成一个以容器ID命名的文件夹,上述命令没有指定容器ID,默认名为”nsinit”,在“nsinit”文件夹下会生成一个state.json文件,表示容器的状态,其中的内容与配置参数中的内容类似,展示容器的状态。

2.2 nsinit的使用

目前nsinit定义了9个指令,使用nsinit -h就可以看到,对于每个单独的指令使用--help就能获得更详细的使用参数,如nsinit config --help

nsinit这个命令行工具是通过cli.go实现的,cli.go封装了命令行工具需要做的一些细节,包括参数解析、命令执行函数构建等等,这就使得nsinit本身的代码非常简洁明了。具体的命令功能如下。

  • config:使用内置的默认参数加上执行命令时用户添加的部分参数,生成一份容器可用的标准配置文件。
  • exec:启动容器并执行命令。除了一些共有的参数外,还有如下一些独有的参数。
    • –tty,-t:为容器分配一个终端显示输出内容。
    • –config:使用配置文件,后跟文件路径。
    • –id:指定容器ID,默认为nsinit
    • –user,-u:指定用户,默认为“root”.
    • –cwd:指定当前工作目录。
    • –env:为进程设置环境变量。
  • init:这是一个内置的参数,用户并不能直接使用。这个命令是在容器内部执行,为容器进行namespace初始化,并在完成初始化后执行用户指令。所以在代码中,运行nsinit exec后,传入到容器中运行的实际上是nsinit init,把用户指令作为配置项传入。
  • oom:展示容器的内存超限通知。
  • pause/unpause:暂停/恢复容器中的进程。
  • stats:显示容器中的统计信息,主要包括cgroup和网络。
  • state:展示容器状态,就是读取state.json文件。
  • checkpoint:保存容器的检查点快照并结束容器进程。需要填--image-path参数,后面是检查点保存的快照文件路径。完整的命令示例如下。 nsinit checkpoint --image-path=/tmp/criu

  • restore:从容器检查点快照恢复容器进程的运行。参数同上。

总结起来,nsinit与Docker execdriver进行的工作基本相同,所以在Docker的源码中并不会涉及到nsinit包的调用,但是nsinit为libcontainer自身的调试和使用带来了极大的便利。

3. 配置参数解析

  • no_pivot_root :这个参数表示用rootfs作为文件系统挂载点,不单独设置pivot_root
  • parent_death_signal: 这个参数表示当容器父进程销毁时发送给容器进程的信号。
  • pivot_dir:在容器root目录中指定一个目录作为容器文件系统挂载点目录。
  • rootfs:容器根目录位置。
  • readonlyfs:设定容器根目录为只读。
  • mounts:设定额外的挂载,填充的信息包括原路径,容器内目的路径,文件系统类型,挂载标识位,挂载的数据大小和权限,最后设定共享挂载还是非共享挂载(独立于mount_label的设定起作用)。
  • devices:设定在容器启动时要创建的设备,填充的信息包括设备类型、容器内设备路径、设备块号(major,minor)、cgroup文件权限、用户编号、用户组编号。
  • mount_label:设定共享挂载还是非共享挂载。
  • hostname:设定主机名。
  • namespaces:设定要加入的namespace,每个不同种类的namespace都可以指定,默认与父进程在同一个namespace中。
  • capabilities:设定在容器内的进程拥有的capabilities权限,所有没加入此配置项的capabilities会被移除,即容器内进程失去该权限。
  • networks:初始化容器的网络配置,包括类型(loopback、veth)、名称、网桥、物理地址、IPV4地址及网关、IPV6地址及网关、Mtu大小、传输缓冲长度txqueuelen、Hairpin Mode设置以及宿主机设备名称。
  • routes:配置路由表。
  • cgroups:配置cgroups资源限制参数,使用的参数不多,主要包括允许的设备列表、内存、交换区用量、CPU用量、块设备访问优先级、应用启停等。
  • apparmor_profile:配置用于SELinux的apparmor文件。
  • process_label:同样用于selinux的配置。
  • rlimits:最大文件打开数量,默认与父进程相同。
  • additional_groups:设定gid,添加同一用户下的其他组。
  • uid_mappings:用于User namespace的uid映射。
  • gid_mappings:用户User namespace的gid映射。
  • readonly_paths:在容器内设定只读部分的文件路径。
  • MaskPaths:配置不使用的设备,通过绑定/dev/null进行路径掩盖。

4. libcontainer实现原理

在Docker中,对容器管理的模块为execdriver,目前Docker支持的容器管理方式有两种,一种就是最初支持的LXC方式,另一种称为native,即使用libcontainer进行容器管理。在孙宏亮的《Docker源码分析系列》中,Docker Deamon启动过程中就会对execdriver进行初始化,会根据驱动的名称选择使用的容器管理方式。

虽然在execdriver中只有LXC和native两种选择,但是native(即libcontainer)通过接口的方式定义了一系列容器管理的操作,包括处理容器的创建(Factory)、容器生命周期管理(Container)、进程生命周期管理(Process)等一系列接口,相信如果Docker的热潮一直像如今这般汹涌,那么不久的将来,Docker必将实现其全平台通用的宏伟蓝图。本节也将从libcontainer的这些抽象对象开始讲解,与你一同解开Docker容器管理之谜。在介绍抽象对象的具体实现过程中会与Docker execdriver联系起来,让你充分了解整个过程。

4.1 Factory 对象

Factory对象为容器创建和初始化工作提供了一组抽象接口,目前已经具体实现的是Linux系统上的Factory对象。Factory抽象对象包含如下四个方法,我们将主要描述这四个方法的工作过程,涉及到具体实现方法则以LinuxFactory为例进行讲解。

  1. Create():通过一个id和一份配置参数创建容器,返回一个运行的进程。容器的id由字母、数字和下划线构成,长度范围为1~1024。容器ID为每个容器独有,不能冲突。创建的最终返回一个Container类,包含这个id、状态目录(在root目录下创建的以id命名的文件夹,存state.json容器状态文件)、容器配置参数、初始化路径和参数,以及管理cgroup的方式(包含直接通过文件操作管理和systemd管理两个选择,默认选cgroup文件系统管理)。
  2. Load():当创建的id已经存在时,即已经Create过,存在id文件目录,就会从id目录下直接读取state.json来载入容器。其中的参数在配置参数部分有详细解释。
  3. Type():返回容器管理的类型,目前可能返回的有libcontainer和lxc,为未来支持更多容器接口做准备。
  4. StartInitialization():容器内初始化函数。
  5. 这部分代码是在容器内部执行的,当容器创建时,如果New不加任何参数,默认在容器进程中运行的第一条命令就是nsinit init。在execdriver的初始化中,会向reexec注册初始化器,命名为native,然后在创建libcontainer以后把native作为执行参数传递到容器中执行,这个初始化器创建的libcontainer就是没有参数的。
  6. 传入的参数是一个管道文件描述符,为了保证在初始化过程中,父子进程间状态同步和配置信息传递而建立。
  7. 不管是纯粹新建的容器还是已经创建的容器执行新的命令,都是从这个入口做初始化。
  8. 第一步,通过管道获取配置信息。
  9. 第二步,从配置信息中获取环境变量并设置为容器内环境变量。
  10. 若是已经存在的容器执行新命令,则只需要配置cgroup、namespace的Capabilities以及AppArmor等信息,最后执行命令。
  11. 若是纯粹新建的容器,则还需要初始化网络、路由、namespace、主机名、配置只读路径等等,最后执行命令。

至此,容器就已经创建和初始化完毕了。

4.2 Container 对象

Container对象主要包含了容器配置、控制、状态显示等功能,是对不同平台容器功能的抽象。目前已经具体实现的是Linux平台下的Container对象。每一个Container进程内部都是线程安全的。因为Container有可能被外部的进程销毁,所以每个方法都会对容器是否存在进行检测。

  1. ID():显示Container的ID,在Factor对象中已经说过,ID很重要,具有唯一性。
  2. Status():返回容器内进程是运行状态还是停止状态。通过执行“SIG=0”的KILL命令对进程是否存在进行检测。
  3. State():返回容器的状态,包括容器ID、配置信息、初始进程ID、进程启动时间、cgroup文件路径、namespace路径。通过调用Status()判断进程是否存在。
  4. Config():返回容器的配置信息,可在“配置参数解析”部分查看有哪些方面的配置信息。
  5. Processes():返回cgroup文件cgroup.procs中的值,在Docker背后的内核知识:cgroups资源限制部分的讲解中我们已经提过,cgroup.procs文件会罗列所有在该cgroup中的线程组ID(即若有线程创建了子线程,则子线程的PID不包含在内)。由于容器不断在运行,所以返回的结果并不能保证完全存活,除非容器处于“PAUSED”状态。
  6. Stats():返回容器的统计信息,包括容器的cgroups中的统计以及网卡设备的统计信息。Cgroups中主要统计了cpu、memory和blkio这三个子系统的统计内容,具体了解可以通过阅读“cgroups资源限制”部分对于这三个子系统统计内容的介绍来了解。网卡设备的统计则通过读取系统中,网络网卡文件的统计信息文件/sys/class/net/<EthInterface>/statistics来实现。
  7. Set():设置容器cgroup各子系统的文件路径。因为cgroups的配置是进程运行时也会生效的,所以我们可以通过这个方法在容器运行时改变cgroups文件从而改变资源分配。
  8. Start():构建ParentProcess对象,用于处理启动容器进程的所有初始化工作,并作为父进程与新创建的子进程(容器)进行初始化通信。传入的Process对象可以帮助我们追踪进程的生命周期,Process对象将在后文详细介绍。
  9. 启动的过程首先会调用Status()方法的具体实现得知进程是否存活。
  10. 创建一个管道(详见Docker初始化通信——管道)为后期父子进程通信做准备。
  11. 配置子进程cmd命令模板,配置参数的值就是从factory.Create()传入进来的,包括命令执行的工作目录、命令参数、输入输出、根目录、子进程管道以及KILL信号的值。
  12. 根据容器进程是否存在确定是在已有容器中执行命令还是创建新的容器执行命令。若存在,则把配置的命令构建成一个exec.Cmd对象、cgroup路径、父子进程管道及配置保留到ParentProcess对象中;若不存在,则创建容器进程及相应namespace,目前对user namespace有了一定的支持,若配置时加入user namespace,会针对配置项进行映射,默认映射到宿主机的root用户,最后同样构建出相应的配置内容保留到ParentProcess对象中。通过在cmd.Env写入环境变量_libcontainer_INITTYPE来告诉容器进程采用的哪种方式启动。
  13. 执行ParentProcess中构建的exec.Cmd内容,即执行ParentProcess.start(),具体的执行过程在Process部分介绍。
  14. 最后如果是新建的容器进程,还会执行状态更新函数,把state.json的内容刷新。
  15. Destroy():首先使用cgroup的freezer子系统暂停所有运行的进程,然后给所有进程发送SIGKIL信号(如果没有使用pid namespace就不对进程处理)。最后把cgroup及其子系统卸载,删除cgroup文件夹。
  16. Pause():使用cgroup的freezer子系统暂停所有运行的进程。
  17. Resume():使用cgroup的freezer子系统恢复所有运行的进程。
  18. NotifyOOM():为容器内存使用超界提供只读的通道,通过向cgroup.event_control写入eventfd(用作线程间通信的消息队列)和cgroup.oom_control(用于决定内存使用超限后的处理方式)来实现。
  19. Checkpoint():保存容器进程检查点快照,为容器热迁移做准备。通过使用CRIU的SWRK模式来实现,这种模式是CRIU另外两种模式CLI和RPC的结合体,允许用户需要的时候像使用命令行工具一样运行CRIU,并接受用户远程调用的请求,即传入的热迁移检查点保存请求,传入文件形式以Google的protobuf协议保存。
  20. Restore():恢复检查点快照并运行,完成容器热迁移。同样通过CRIU的SWRK模式实现,恢复的时候可以传入配置文件设置恢复挂载点、网络等配置信息。

至此,Container对象中的所有函数及相关功能都已经介绍完毕,包含了容器生命周期的全部过程。

TIPs: Docker初始化通信——管道

libcontainer创建容器进程时需要做初始化工作,此时就涉及到使用了namespace隔离后的两个进程间的通信。我们把负责创建容器的进程称为父进程,容器进程称为子进程。父进程clone出子进程以后,依旧是共享内存的。但是如何让子进程知道内存中写入了新数据依旧是一个问题,一般有四种方法。

  • 发送信号通知(signal)
  • 对内存轮询访问(poll memory)
  • sockets通信(sockets)
  • 文件和文件描述符(files and file-descriptors)

对于Signal而言,本身包含的信息有限,需要额外记录,namespace带来的上下文变化使其不易理解,并不是最佳选择。显然通过轮询内存的方式来沟通是一个非常低效的做法。另外,因为Docker会加入network namespace,实际上初始时网络栈也是完全隔离的,所以socket方式并不可行。

Docker最终选择的方式就是打开的可读可写文件描述符——管道。

Linux中,通过pipe(int fd[2])系统调用就可以创建管道,参数是一个包含两个整型的数组。调用完成后,在fd[1]端写入的数据,就可以从fd[0]端读取。

1
2
3
4
5
6
7
8
// 需要加入头文件: 
#include <unistd.h>
// 全局变量:
int fd[2];
// 在父进程中进行初始化:
pipe(fd);
// 关闭管道文件描述符
close(checkpoint[1]);

调用pipe函数后,创建的子进程会内嵌这个打开的文件描述符,对fd[1]写入数据后可以在fd[0]端读取。通过管道,父子进程之间就可以通信。通信完毕的奥秘就在于EOF信号的传递。大家都知道,当打开的文件描述符都关闭时,才能读到EOF信号,所以libcontainer中父进程先关闭自己这一端的管道,然后等待子进程关闭另一端的管道文件描述符,传来EOF表示子进程已经完成了初始化的过程。

4.3 Process 对象

Process 主要分为两类,一类在源码中就叫Process,用于容器内进程的配置和IO的管理;另一类在源码中叫ParentProcess,负责处理容器启动工作,与Container对象直接进行接触,启动完成后作为Process的一部分,执行等待、发信号、获得pid等管理工作。

ParentProcess对象,主要包含以下六个函数,而根据”需要新建容器”和“在已经存在的容器中执行”的不同方式,具体的实现也有所不同。

  • 已有容器中执行命令
  • pid(): 启动容器进程后通过管道从容器进程中获得,因为容器已经存在,与Docker Deamon在不同的pid namespace中,从进程所在的namespace获得的进程号才有意义。
  • start(): 初始化容器中的执行进程。在已有容器中执行命令一般由docker exec调用,在execdriver包中,执行exec时会引入nsenter包,从而调用其中的C语言代码,执行nsexec()函数,该函数会读取配置文件,使用setns()加入到相应的namespace,然后通过clone()在该namespace中生成一个子进程,并把子进程通过管道传递出去,使用setns()以后并没有进入pid namespace,所以还需要通过加上clone()系统调用。
  • 开始执行进程,首先会运行C代码,通过管道获得进程pid,最后等待C代码执行完毕。
  • 通过获得的pid把cmd中的Process替换成新生成的子进程。
  • 把子进程加入cgroup中。
  • 通过管道传配置文件给子进程。
  • 等待初始化完成或出错返回,结束。

  • 新建容器执行命令

  • pid():启动容器进程后通过exec.Cmd自带的pid()函数即可获得。
  • start():初始化及执行容器命令。
  • 开始运行进程。
  • 把进程pid加入到cgroup中管理。
  • 初始化容器网络。(本部分内容丰富,将从本系列的后续文章中深入讲解)
  • 通过管道发送配置文件给子进程。
  • 等待初始化完成或出错返回,结束。

  • 实现方式类似的一些函数

  • terminate() :发送SIGKILL信号结束进程。
  • startTime() :获取进程的启动时间。
  • signal():发送信号给进程。
  • wait():等待程序执行结束,返回结束的程序状态。

Process对象,主要描述了容器内进程的配置以及IO。包括参数Args,环境变量Env,用户User(由于uid、gid映射),工作目录Cwd,标准输入输出及错误输入,控制终端路径consolePath,容器权限Capabilities以及上述提到的ParentProcess对象ops(拥有上面的一些操作函数,可以直接管理进程)。

5. 总结

本文主要介绍了Docker容器管理的方式libcontainer,从libcontainer的使用到源码实现方式。我们深入到容器进程内部,感受到了libcontainer较为全面的设计。总体而言,libcontainer本身主要分为三大块工作内容,一是容器的创建及初始化,二是容器生命周期管理,三则是进程管理,调用方为Docker的execdriver。容器的监控主要通过cgroups的状态统计信息,未来会加入进程追踪等更丰富的功能。另一方面,libcontainer在安全支持方面也为用户尽可能多的提供了支持和选择。遗憾的是,容器安全的配置需要用户对系统安全本身有足够高的理解,user namespace也尚未支持,可见libcontainer依旧有很多工作要完善。但是Docker社区的火热也自然带动了大家对libcontainer的关注,相信在不久的将来,libcontainer就会变得更安全、更易用。

Docker背后的内核知识——cgroups资源限制

| Comments

原文首发自InfoQ,《Docker背后的内核知识——cgroups资源限制》

摘要

当我们谈论Docker时,我们常常会聊到Docker的实现方式。很多开发者都会知道,Docker的本质实际上是宿主机上的一个进程,通过namespace实现了资源隔离,通过cgroup实现了资源限制,通过UnionFS实现了Copy on Write的文件操作。但是当我们再深入一步的提出,namespace和cgroup实现细节时,知道的人可能就所剩无几了。浙江大学SEL/VLIS实验室孙健波同学在docker基础研究工作中着重对内核的cgroup技术做了细致的分析和梳理,希望能对读者深入理解Docker有所帮助

正文

上一篇中,我们了解了Docker背后使用的资源隔离技术namespace,通过系统调用构建一个相对隔离的shell环境,也可以称之为一个简单的“容器”。本文我们则要开始讲解另一个强大的内核工具——cgroups。他不仅可以限制被namespace隔离起来的资源,还可以为资源设置权重、计算使用量、操控进程启停等等。在介绍完基本概念后,我们将详细讲解Docker中使用到的cgroups内容。希望通过本文,让读者对Docker有更深入的了解。

1. cgroups是什么

cgroups(Control Groups)最初叫Process Container,由Google工程师(Paul Menage和Rohit Seth)于2006年提出,后来因为Container有多重含义容易引起误解,就在2007年更名为Control Groups,并被整合进Linux内核。顾名思义就是把进程放到一个组里面统一加以控制。官方的定义如下{![引自:https://www.kernel.org/doc/Documentation/cgroups/cgroups.txt]}。

cgroups是Linux内核提供的一种机制,这种机制可以根据特定的行为,把一系列系统任务及其子任务整合(或分隔)到按资源划分等级的不同组内,从而为系统资源管理提供一个统一的框架。

通俗的来说,cgroups可以限制、记录、隔离进程组所使用的物理资源(包括:CPU、memory、IO等),为容器实现虚拟化提供了基本保证,是构建Docker等一系列虚拟化管理工具的基石。

对开发者来说,cgroups有如下四个有趣的特点: * cgroups的API以一个伪文件系统的方式实现,即用户可以通过文件操作实现cgroups的组织管理。 * cgroups的组织管理操作单元可以细粒度到线程级别,用户态代码也可以针对系统分配的资源创建和销毁cgroups,从而实现资源再分配和管理。 * 所有资源管理的功能都以“subsystem(子系统)”的方式实现,接口统一。 * 子进程创建之初与其父进程处于同一个cgroups的控制组。

本质上来说,cgroups是内核附加在程序上的一系列钩子(hooks),通过程序运行时对资源的调度触发相应的钩子以达到资源追踪和限制的目的。

2. cgroups的作用

实现cgroups的主要目的是为不同用户层面的资源管理,提供一个统一化的接口。从单个进程的资源控制到操作系统层面的虚拟化。Cgroups提供了以下四大功能{![参照自:http://en.wikipedia.org/wiki/Cgroups]}。

  • 资源限制(Resource Limitation):cgroups可以对进程组使用的资源总额进行限制。如设定应用运行时使用内存的上限,一旦超过这个配额就发出OOM(Out of Memory)。
  • 优先级分配(Prioritization):通过分配的CPU时间片数量及硬盘IO带宽大小,实际上就相当于控制了进程运行的优先级。
  • 资源统计(Accounting): cgroups可以统计系统的资源使用量,如CPU使用时长、内存用量等等,这个功能非常适用于计费。
  • 进程控制(Control):cgroups可以对进程组执行挂起、恢复等操作。

过去有一段时间,内核开发者甚至把namespace也作为一个cgroups的subsystem加入进来,也就是说cgroups曾经甚至还包含了资源隔离的能力。但是资源隔离会给cgroups带来许多问题,如PID在循环出现的时候cgroup却出现了命名冲突、cgroup创建后进入新的namespace导致脱离了控制等等{![详见:https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=a77aea92010acf54ad785047234418d5d68772e2]},所以在2011年就被移除了。

3. 术语表

  • task(任务):cgroups的术语中,task就表示系统的一个进程。
  • cgroup(控制组):cgroups 中的资源控制都以cgroup为单位实现。cgroup表示按某种资源控制标准划分而成的任务组,包含一个或多个子系统。一个任务可以加入某个cgroup,也可以从某个cgroup迁移到另外一个cgroup。
  • subsystem(子系统):cgroups中的subsystem就是一个资源调度控制器(Resource Controller)。比如CPU子系统可以控制CPU时间分配,内存子系统可以限制cgroup内存使用量。
  • hierarchy(层级树):hierarchy由一系列cgroup以一个树状结构排列而成,每个hierarchy通过绑定对应的subsystem进行资源调度。hierarchy中的cgroup节点可以包含零或多个子节点,子节点继承父节点的属性。整个系统可以有多个hierarchy。

4. 组织结构与基本规则

大家在namespace技术的讲解中已经了解到,传统的Unix进程管理,实际上是先启动init进程作为根节点,再由init节点创建子进程作为子节点,而每个子节点由可以创建新的子节点,如此往复,形成一个树状结构。而cgroups也是类似的树状结构,子节点都从父节点继承属性。

它们最大的不同在于,系统中cgroup构成的hierarchy可以允许存在多个。如果进程模型是由init作为根节点构成的一棵树的话,那么cgroups的模型则是由多个hierarchy构成的森林。这样做的目的也很好理解,如果只有一个hierarchy,那么所有的task都要受到绑定其上的subsystem的限制,会给那些不需要这些限制的task造成麻烦。

了解了cgroups的组织结构,我们再来了解cgroup、task、subsystem以及hierarchy四者间的相互关系及其基本规则{![参照自:https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/6/html/Resource_Management_Guide/sec-Relationships_Between_Subsystems_Hierarchies_Control_Groups_and_Tasks.html]}。

  • 规则1: 同一个hierarchy可以附加一个或多个subsystem。如下图1,cpu和memory的subsystem附加到了一个hierarchy。 pic1 图1 同一个hierarchy可以附加一个或多个subsystem

  • 规则2: 一个subsystem可以附加到多个hierarchy,当且仅当这些hierarchy只有这唯一一个subsystem。如下图2,小圈中的数字表示subsystem附加的时间顺序,CPU subsystem附加到hierarchy A的同时不能再附加到hierarchy B,因为hierarchy B已经附加了memory subsystem。如果hierarchy B与hierarchy A状态相同,没有附加过memory subsystem,那么CPU subsystem同时附加到两个hierarchy是可以的。 pic2 图2 一个已经附加在某个hierarchy上的subsystem不能附加到其他含有别的subsystem的hierarchy上

  • 规则3: 系统每次新建一个hierarchy时,该系统上的所有task默认构成了这个新建的hierarchy的初始化cgroup,这个cgroup也称为root cgroup。对于你创建的每个hierarchy,task只能存在于其中一个cgroup中,即一个task不能存在于同一个hierarchy的不同cgroup中,但是一个task可以存在在不同hierarchy中的多个cgroup中。如果操作时把一个task添加到同一个hierarchy中的另一个cgroup中,则会从第一个cgroup中移除。在下图3中可以看到,httpd进程已经加入到hierarchy A中的/cg1而不能加入同一个hierarchy中的/cg2,但是可以加入hierarchy B中的/cg3。实际上不允许加入同一个hierarchy中的其他cgroup野生为了防止出现矛盾,如CPU subsystem为/cg1分配了30%,而为/cg2分配了50%,此时如果httpd在这两个cgroup中,就会出现矛盾。 pic3 图3 一个task不能属于同一个hierarchy的不同cgroup

  • 规则4: 进程(task)在fork自身时创建的子任务(child task)默认与原task在同一个cgroup中,但是child task允许被移动到不同的cgroup中。即fork完成后,父子进程间是完全独立的。如下图4中,小圈中的数字表示task 出现的时间顺序,当httpd刚fork出另一个httpd时,在同一个hierarchy中的同一个cgroup中。但是随后如果PID为4840的httpd需要移动到其他cgroup也是可以的,因为父子任务间已经独立。总结起来就是:初始化时子任务与父任务在同一个cgroup,但是这种关系随后可以改变。 pic4 图4 刚fork出的子进程在初始状态与其父进程处于同一个cgroup

5. subsystem简介

subsystem实际上就是cgroups的资源控制系统,每种subsystem独立地控制一种资源,目前Docker使用如下八种subsystem,还有一种net_cls subsystem在内核中已经广泛实现,但是Docker尚未使用。他们的用途分别如下。

  • blkio: 这个subsystem可以为块设备设定输入/输出限制,比如物理驱动设备(包括磁盘、固态硬盘、USB等)。
  • cpu: 这个subsystem使用调度程序控制task对CPU的使用。
  • cpuacct: 这个subsystem自动生成cgroup中task对CPU资源使用情况的报告。
  • cpuset: 这个subsystem可以为cgroup中的task分配独立的CPU(此处针对多处理器系统)和内存。
  • devices 这个subsystem可以开启或关闭cgroup中task对设备的访问。
  • freezer 这个subsystem可以挂起或恢复cgroup中的task。
  • memory 这个subsystem可以设定cgroup中task对内存使用量的限定,并且自动生成这些task对内存资源使用情况的报告。
  • perf_event 这个subsystem使用后使得cgroup中的task可以进行统一的性能测试。{![perf: Linux CPU性能探测器,详见https://perf.wiki.kernel.org/index.php/Main_Page]}
  • *net_cls 这个subsystem Docker没有直接使用,它通过使用等级识别符(classid)标记网络数据包,从而允许 Linux 流量控制程序(TC:Traffic Controller)识别从具体cgroup中生成的数据包。

6. cgroups实现方式及工作原理简介

(1)cgroups实现结构讲解

cgroups的实现本质上是给系统进程挂上钩子(hooks),当task运行的过程中涉及到某个资源时就会触发钩子上所附带的subsystem进行检测,最终根据资源类别的不同使用对应的技术进行资源限制和优先级分配。那么这些钩子又是怎样附加到进程上的呢?下面我们将对照结构体的图表一步步分析,请放心,描述代码的内容并不多。

cgroup_struct 图5 cgroups相关结构体一览

Linux中管理task进程的数据结构为task_struct(包含所有进程管理的信息),其中与cgroup相关的字段主要有两个,一个是css_set *cgroups,表示指向css_set(包含进程相关的cgroups信息)的指针,一个task只对应一个css_set结构,但是一个css_set可以被多个task使用。另一个字段是list_head cg_list,是一个链表的头指针,这个链表包含了所有的链到同一个css_set的task进程(在图中使用的回环箭头,均表示可以通过该字段找到所有同类结构,获得信息)。

每个css_set结构中都包含了一个指向cgroup_subsys_state(包含进程与一个特定子系统相关的信息)的指针数组。cgroup_subsys_state则指向了cgroup结构(包含一个cgroup的所有信息),通过这种方式间接的把一个进程和cgroup联系了起来,如下图6。

cgroup_task 图6 从task结构开始找到cgroup结构

另一方面,cgroup结构体中有一个list_head css_sets字段,它是一个头指针,指向由cg_cgroup_link(包含cgroup与task之间多对多关系的信息,后文还会再解释)形成的链表。由此获得的每一个cg_cgroup_link都包含了一个指向css_set *cg字段,指向了每一个task的css_setcss_set结构中则包含tasks头指针,指向所有链到此css_set的task进程构成的链表。至此,我们就明白如何查看在同一个cgroup中的task有哪些了,如下图7。

cgroup_cglink 图7 cglink多对多双向查询

细心的读者可能已经发现,css_set中也有指向所有cg_cgroup_link构成链表的头指针,通过这种方式也能定位到所有的cgroup,这种方式与图1中所示的方式得到的结果是相同的。

那么为什么要使用cg_cgroup_link结构体呢?因为task与cgroup之间是多对多的关系。熟悉数据库的读者很容易理解,在数据库中,如果两张表是多对多的关系,那么如果不加入第三张关系表,就必须为一个字段的不同添加许多行记录,导致大量冗余。通过从主表和副表各拿一个主键新建一张关系表,可以提高数据查询的灵活性和效率。

而一个task可能处于不同的cgroup,只要这些cgroup在不同的hierarchy中,并且每个hierarchy挂载的子系统不同;另一方面,一个cgroup中可以有多个task,这是显而易见的,但是这些task因为可能还存在在别的cgroup中,所以它们对应的css_set也不尽相同,所以一个cgroup也可以对应多个·css_set

在系统运行之初,内核的主函数就会对root cgroupscss_set进行初始化,每次task进行fork/exit时,都会附加(attach)/分离(detach)对应的css_set

综上所述,添加cg_cgroup_link主要是出于性能方面的考虑,一是节省了task_struct结构体占用的内存,二是提升了进程fork()/exit()的速度。

cgroup_hashtable 图8 css_set与hashtable关系

当task从一个cgroup中移动到另一个时,它会得到一个新的css_set指针。如果所要加入的cgroup与现有的cgroup子系统相同,那么就重复使用现有的css_set,否则就分配一个新css_set。所有的css_set通过一个哈希表进行存放和查询,如上图8中所示,hlist_node hlist就指向了css_set_table这个hash表。

同时,为了让cgroups便于用户理解和使用,也为了用精简的内核代码为cgroup提供熟悉的权限和命名空间管理,内核开发者们按照Linux 虚拟文件系统转换器(VFS:Virtual Filesystem Switch)的接口实现了一套名为cgroup的文件系统,非常巧妙地用来表示cgroups的hierarchy概念,把各个subsystem的实现都封装到文件系统的各项操作中。有兴趣的读者可以在网上搜索并阅读VFS的相关内容,在此就不赘述了。

定义子系统的结构体是cgroup_subsys,在图9中可以看到,cgroup_subsys中定义了一组函数的接口,让各个子系统自己去实现,类似的思想还被用在了cgroup_subsys_state中,cgroup_subsys_state并没有定义控制信息,只是定义了各个子系统都需要用到的公共信息,由各个子系统各自按需去定义自己的控制信息结构体,最终在自定义的结构体中把cgroup_subsys_state包含进去,然后内核通过container_of(这个宏可以通过一个结构体的成员找到结构体自身)等宏定义来获取对应的结构体。

cgroup_subsys 图9 cgroup子系统结构体

(2)基于cgroups实现结构的用户层体现

了解了cgroups实现的代码结构以后,再来看用户层在使用cgroups时的限制,会更加清晰。

在实际的使用过程中,你需要通过挂载(mount)cgroup文件系统新建一个层级结构,挂载时指定要绑定的子系统,缺省情况下默认绑定系统所有子系统。把cgroup文件系统挂载(mount)上以后,你就可以像操作文件一样对cgroups的hierarchy层级进行浏览和操作管理(包括权限管理、子文件管理等等)。除了cgroup文件系统以外,内核没有为cgroups的访问和操作添加任何系统调用。

如果新建的层级结构要绑定的子系统与目前已经存在的层级结构完全相同,那么新的挂载会重用原来已经存在的那一套(指向相同的css_set)。否则如果要绑定的子系统已经被别的层级绑定,就会返回挂载失败的错误。如果一切顺利,挂载完成后层级就被激活并与相应子系统关联起来,可以开始使用了。

目前无法将一个新的子系统绑定到激活的层级上,或者从一个激活的层级中解除某个子系统的绑定。

当一个顶层的cgroup文件系统被卸载(umount)时,如果其中创建后代cgroup目录,那么就算上层的cgroup被卸载了,层级也是激活状态,其后代cgoup中的配置依旧有效。只有递归式的卸载层级中的所有cgoup,那个层级才会被真正删除。

层级激活后,/proc目录下的每个task PID文件夹下都会新添加一个名为cgroup的文件,列出task所在的层级,对其进行控制的子系统及对应cgroup文件系统的路径。

一个cgroup创建完成,不管绑定了何种子系统,其目录下都会生成以下几个文件,用来描述cgroup的相应信息。同样,把相应信息写入这些配置文件就可以生效,内容如下。

  • tasks:这个文件中罗列了所有在该cgroup中task的PID。该文件并不保证task的PID有序,把一个task的PID写到这个文件中就意味着把这个task加入这个cgroup中。
  • cgroup.procs:这个文件罗列所有在该cgroup中的线程组ID。该文件并不保证线程组ID有序和无重复。写一个线程组ID到这个文件就意味着把这个组中所有的线程加到这个cgroup中。
  • notify_on_release:填0或1,表示是否在cgroup中最后一个task退出时通知运行release agent,默认情况下是0,表示不运行。
  • release_agent:指定release agent执行脚本的文件路径(该文件在最顶层cgroup目录中存在),在这个脚本通常用于自动化umount无用的cgroup。

除了上述几个通用的文件以外,绑定特定子系统的目录下也会有其他的文件进行子系统的参数配置。

在创建的hierarchy中创建文件夹,就类似于fork中一个后代cgroup,后代cgroup中默认继承原有cgroup中的配置属性,但是你可以根据需求对配置参数进行调整。这样就把一个大的cgroup系统分割成一个个嵌套的、可动态变化的“软分区”。

7. cgroups的使用方法简介

(1)安装cgroups工具库

本节主要针对Ubuntu14.04版本系统进行介绍,其他Linux发行版命令略有不同,原理是一样的。不安装cgroups工具库也可以使用cgroups,安装它只是为了更方便的在用户态对cgroups进行管理,同时也方便初学者理解和使用,本节对cgroups的操作和使用都基于这个工具库。

1
apt-get install cgroup-bin

安装的过程会自动创建/cgroup目录,如果没有自动创建也不用担心,使用 mkdir /cgroup 手动创建即可。在这个目录下你就可以挂载各类子系统。安装完成后,你就可以使用lssubsys(罗列所有的subsystem挂载情况)等命令。

说明:也许你在其他文章中看到的cgroups工具库教程,会在/etc目录下生成一些初始化脚本和配置文件,默认的cgroup配置文件为/etc/cgconfig.conf,但是因为存在使LXC无法运行的bug,所以在新版本中把这个配置移除了,详见:https://bugs.launchpad.net/ubuntu/+source/libcgroup/+bug/1096771%E3%80%82

(2)查询cgroup及子系统挂载状态

在挂载子系统之前,可能你要先检查下目前子系统的挂载状态,如果子系统已经挂载,根据第4节中讲的规则2,你就无法把子系统挂载到新的hierarchy,此时就需要先删除相应hierarchy或卸载对应子系统后再挂载。

  • 查看所有的cgroup:lscgroup
  • 查看所有支持的子系统:lssubsys -a
  • 查看所有子系统挂载的位置: lssubsys –m
  • 查看单个子系统(如memory)挂载位置:lssubsys –m memory

(3)创建hierarchy层级并挂载子系统

在组织结构与规则一节中我们提到了hierarchy层级和subsystem子系统的关系,我们知道使用cgroup的最佳方式是:为想要管理的每个或每组资源创建单独的cgroup层级结构。而创建hierarchy并不神秘,实际上就是做一个标记,通过挂载一个tmpfs{![基于内存的临时文件系统,详见:http://en.wikipedia.org/wiki/Tmpfs]}文件系统,并给一个好的名字就可以了,系统默认挂载的cgroup就会进行如下操作。

1
mount -t tmpfs cgroups /sys/fs/cgroup

其中-t即指定挂载的文件系统类型,其后的cgroups是会出现在mount展示的结果中用于标识,可以选择一个有用的名字命名,最后的目录则表示文件的挂载点位置。

挂载完成tmpfs后就可以通过mkdir命令创建相应的文件夹。

1
mkdir /sys/fs/cgroup/cg1

再把子系统挂载到相应层级上,挂载子系统也使用mount命令,语法如下。

1
mount -t cgroup -o subsystems name /cgroup/name

其​​​中​​​ subsystems 是​​​使​​​用​​​,(逗号)​​​分​​​开​​​的​​​子​​​系​​​统​​​列​​​表,name 是​​​层​​​级​​​名​​​称​​​。具体我们以挂载cpu和memory的子系统为例,命令如下。

1
mount –t cgroup –o cpu,memory cpu_and_mem /sys/fs/cgroup/cg1

mount命令开始,-t后面跟的是挂载的文件系统类型,即cgroup文件系统。-o后面跟要挂载的子系统种类如cpumemory,用逗号隔开,其后的cpu_and_mem不被cgroup代码的解释,但会出现在/proc/mounts里,可以使用任何有用的标识字符串。最后的参数则表示挂载点的目录位置。

说明:如果挂载时提示mount: agent already mounted or /cgroup busy,则表示子系统已经挂载,需要先卸载原先的挂载点,通过第二条中描述的命令可以定位挂载点。

(4)卸载cgroup

目前cgroup文件系统虽然支持重新挂载,但是官方不建议使用,重新挂载虽然可以改变绑定的子系统和release agent,但是它要求对应的hierarchy是空的并且release_agent会被传统的fsnotify(内核默认的文件系统通知)代替,这就导致重新挂载很难生效,未来重新挂载的功能可能会移除。你可以通过卸载,再挂载的方式处理这样的需求。

卸载cgroup非常简单,你可以通过cgdelete命令,也可以通过rmdir,以刚挂载的cg1为例,命令如下。

1
rmdir /sys/fs/cgroup/cg1

rmdir执行成功的必要条件是cg1下层没有创建其它cgroup,cg1中没有添加任何task,并且它也没有被别的cgroup所引用。

1
cgdelete cpu,memory:/

使用cgdelete命令可以递归的删除cgroup及其命令下的后代cgroup,并且如果cgroup中有task,那么task会自动移到上一层没有被删除的cgroup中,如果所有的cgroup都被删除了,那task就不被cgroups控制。但是一旦再次创建一个新的cgroup,所有进程都会被放进新的cgroup中。

(5)设置cgroups参数

设置cgroups参数非常简单,直接对之前创建的cgroup对应文件夹下的文件写入即可,举例如下。

  • 设置task允许使用的cpu为0和1.
1
echo 0-1 > /sys/fs/cgroup/cg1/cpuset.cpus

使用cgset命令也可以进行参数设置,对应上述允许使用0和1cpu的命令为:

1
cgset -r cpuset.cpus=0-1 cpu,memory:/

(6)添加task到cgroup

  • 通过文件操作进行添加
1
echo [PID] > /path/to/cgroup/tasks

上述命令就是把进程ID打印到tasks中,如果tasks文件中已经有进程,需要使用">>"向后添加。

  • 通过cgclassify将进程添加到cgroup
1
cgclassify -g subsystems:path_to_cgroup pidlist

这个命令中,subsystems指的就是子系统(如果使用man命令查看,可能也会使用controllers表示)​​​,如果mount了多个,就是用","隔开的子系统名字作为名称,类似cgset命令。

  • 通过cgexec直接在cgroup中启动并执行进程
1
cgexec -g subsystems:path_to_cgroup command arguments

commandarguments就表示要在cgroup中执行的命令和参数。cgexec常用于执行临时的任务。

(7)权限管理

与文件的权限管理类似,通过chown就可以对cgroup文件系统进行权限管理。

1
chown uid:gid /path/to/cgroup

uidgid分别表示所属的用户和用户组。

8. subsystem配置参数用法

(1)blkio - BLOCK IO资源控制

  • 限额类 限额类是主要有两种策略,一种是基于完全公平队列调度(CFQ:Completely Fair Queuing )的按权重分配各个cgroup所能占用总体资源的百分比,好处是当资源空闲时可以充分利用,但只能用于最底层节点cgroup的配置;另一种则是设定资源使用上限,这种限额在各个层次的cgroup都可以配置,但这种限制较为生硬,并且容器之间依然会出现资源的竞争。
  • 按比例分配块设备IO资源
    1. blkio.weight:填写100-1000的一个整数值,作为相对权重比率,作为通用的设备分配比。
    2. blkio.weight_device: 针对特定设备的权重比,写入格式为device_types:node_numbers weight,空格前的参数段指定设备,weight参数与blkio.weight相同并覆盖原有的通用分配比。{![查看一个设备的device_types:node_numbers可以使用:ls -l /dev/DEV,看到的用逗号分隔的两个数字就是。有的文章也称之为major_number:minor_number。]}
    3. 控制IO读写速度上限
      1. blkio.throttle.read_bps_device:按每秒读取块设备的数据量设定上限,格式device_types:node_numbers bytes_per_second
      2. blkio.throttle.write_bps_device:按每秒写入块设备的数据量设定上限,格式device_types:node_numbers bytes_per_second
      3. blkio.throttle.read_iops_device:按每秒读操作次数设定上限,格式device_types:node_numbers operations_per_second
      4. blkio.throttle.write_iops_device:按每秒写操作次数设定上限,格式device_types:node_numbers operations_per_second
  • 针对特定操作(read, write, sync, 或async)设定读写速度上限

    1. . blkio.throttle.io_serviced:针对特定操作按每秒操作次数设定上限,格式device_types:node_numbers operation operations_per_second
    2. . blkio.throttle.io_service_bytes:针对特定操作按每秒数据量设定上限,格式device_types:node_numbers operation bytes_per_second
  • 统计与监控 以下内容都是只读的状态报告,通过这些统计项更好地统计、监控进程的 io 情况。

    1. blkio.reset_stats:重置统计信息,写入一个int值即可。
    2. blkio.time:统计cgroup对设备的访问时间,按格式device_types:node_numbers milliseconds读取信息即可,以下类似。
    3. blkio.io_serviced:统计cgroup对特定设备的IO操作(包括read、write、sync及async)次数,格式device_types:node_numbers operation number
    4. blkio.sectors:统计cgroup对设备扇区访问次数,格式 device_types:node_numbers sector_count
    5. blkio.io_service_bytes:统计cgroup对特定设备IO操作(包括read、write、sync及async)的数据量,格式device_types:node_numbers operation bytes
    6. blkio.io_queued:统计cgroup的队列中对IO操作(包括read、write、sync及async)的请求次数,格式number operation
    7. blkio.io_service_time:统计cgroup对特定设备的IO操作(包括read、write、sync及async)时间(单位为ns),格式device_types:node_numbers operation time
    8. blkio.io_merged:统计cgroup 将 BIOS 请求合并到IO操作(包括read、write、sync及async)请求的次数,格式number operation
    9. blkio.io_wait_time:统计cgroup在各设​​​备​​​中各类型​​​IO操作(包括read、write、sync及async)在队列中的等待时间​(单位ns),格式device_types:node_numbers operation time
    10. . blkio.*_recursive:各类型的统计都有一个递归版本,Docker中使用的都是这个版本。获取的数据与非递归版本是一样的,但是包括cgroup所有层级的监控数据。

(2) cpu - CPU资源控制

CPU资源的控制也有两种策略,一种是完全公平调度 (CFS:Completely Fair Scheduler)策略,提供了限额和按比例分配两种方式进行资源控制;另一种是实时调度(Real-Time Scheduler)策略,针对实时进程按周期分配固定的运行时间。配置时间都以微秒(µs)为单位,文件名中用us表示。

  • CFS调度策略下的配置
  • 设定CPU使用周期使用时间上限
    1. cpu.cfs_period_us:设定周期时间,必须与cfs_quota_us配合使用。
    2. cpu.cfs_quota_us :设定周期内最多可使用的时间。这里的配置指task对单个cpu的使用上限,若cfs_quota_uscfs_period_us的两倍,就表示在两个核上完全使用。数值范围为1000 - 1000,000(微秒)。
    3. cpu.stat:统计信息,包含nr_periods(表示经历了几个cfs_period_us周期)、nr_throttled(表示task被限制的次数)及throttled_time(表示task被限制的总时长)。
  • 按权重比例设定CPU的分配

    1. cpu.shares:设定一个整数(必须大于等于2)表示相对权重,最后除以权重总和算出相对比例,按比例分配CPU时间。(如cgroup A设置100,cgroup B设置300,那么cgroup A中的task运行25%的CPU时间。对于一个4核CPU的系统来说,cgroup A 中的task可以100%占有某一个CPU,这个比例是相对整体的一个值。)
  • RT调度策略下的配置 实时调度策略与公平调度策略中的按周期分配时间的方法类似,也是在周期内分配一个固定的运行时间。

  • cpu.rt_period_us :设定周期时间。
  • cpu.rt_runtime_us:设定周期中的运行时间。

(3) cpuacct - CPU资源报告

这个子系统的配置是cpu子系统的补充,提供CPU资源用量的统计,时间单位都是纳秒。 1. cpuacct.usage:统计cgroup中所有task的cpu使用时长 2. cpuacct.stat:统计cgroup中所有task的用户态和内核态分别使用cpu的时长 3. cpuacct.usage_percpu:统计cgroup中所有task使用每个cpu的时长

(4)cpuset - CPU绑定

为task分配独立CPU资源的子系统,参数较多,这里只选讲两个必须配置的参数,同时Docker中目前也只用到这两个。 1. cpuset.cpus:在这个文件中填写cgroup可使用的CPU编号,如0-2,16代表 0、1、2和16这4个CPU。 2. cpuset.mems:与CPU类似,表示cgroup可使用的memory node,格式同上

(5) device - 限制task对device的使用

  • 设备黑/白名单过滤
  • devices.allow:允许名单,语法type device_types:node_numbers access typetype有三种类型:b(块设备)、c(字符设备)、a(全部设备);access也有三种方式:r(读)、w(写)、m(创建)。
  • devices.deny:禁止名单,语法格式同上。
  • 统计报告
  • devices.list:报​​​告​​​为​​​这​​​个​​​ cgroup 中​​​的​task设​​​定​​​访​​​问​​​控​​​制​​​的​​​设​​​备

(6) freezer - 暂停/恢复cgroup中的task

只有一个属性,表示进程的状态,把task放到freezer所在的cgroup,再把state改为FROZEN,就可以暂停进程。不允许在cgroup处于FROZEN状态时加入进程。 * freezer.state ,包括如下三种状态: - FROZEN 停止 - FREEZING 正在停止,这个是只读状态,不能写入这个值。 - THAWED 恢复

(7) memory - 内存资源管理

  • 限额类
  • memory.limit_in_bytes:强制限制最大内存使用量,单位有kmg三种,填-1则代表无限制。
  • memory.soft_limit_in_bytes:软限制,只有比强制限制设置的值小时才有意义。填写格式同上。当整体内存紧张的情况下,task获取的内存就被限制在软限制额度之内,以保证不会有太多进程因内存挨饿。可以看到,加入了内存的资源限制并不代表没有资源竞争。
  • memory.memsw.limit_in_bytes:设定最大内存与swap区内存之和的用量限制。填写格式同上。

  • 报警与自动控制

  • memory.oom_control:改参数填0或1, 0表示开启,当cgroup中的进程使用资源超过界限时立即杀死进程,1表示不启用。默认情况下,包含memory子系统的cgroup都启用。当oom_control不启用时,实际使用内存超过界限时进程会被暂停直到有空闲的内存资源。

  • 统计与监控类

  • memory.usage_in_bytes:报​​​告​​​该​​​ cgroup中​​​进​​​程​​​使​​​用​​​的​​​当​​​前​​​总​​​内​​​存​​​用​​​量(以字节为单位)
  • memory.max_usage_in_bytes:报​​​告​​​该​​​ cgroup 中​​​进​​​程​​​使​​​用​​​的​​​最​​​大​​​内​​​存​​​用​​​量
  • memory.failcnt:报​​​告​​​内​​​存​​​达​​​到​​​在​​​ memory.limit_in_bytes设​​​定​​​的​​​限​​​制​​​值​​​的​​​次​​​数​​​
  • memory.stat:包含大量的内存统计数据。
  • cache:页​​​缓​​​存​​​,包​​​括​​​ tmpfs(shmem),单位为字节。
  • rss:匿​​​名​​​和​​​ swap 缓​​​存​​​,不​​​包​​​括​​​ tmpfs(shmem),单位为字节。
  • mapped_file:memory-mapped 映​​​射​​​的​​​文​​​件​​​大​​​小​​​,包​​​括​​​ tmpfs(shmem),单​​​位​​​为​​​字​​​节​​​
  • pgpgin:存​​​入​​​内​​​存​​​中​​​的​​​页​​​数​​​
  • pgpgout:从​​​内​​​存​​​中​​​读​​​出​​​的​​​页​​​数
  • swap:swap 用​​​量​​​,单​​​位​​​为​​​字​​​节​​​
  • active_anon:在​​​活​​​跃​​​的​​​最​​​近​​​最​​​少​​​使​​​用​​​(least-recently-used,LRU)列​​​表​​​中​​​的​​​匿​​​名​​​和​​​ swap 缓​​​存​​​,包​​​括​​​ tmpfs(shmem),单​​​位​​​为​​​字​​​节​​​
  • inactive_anon:不​​​活​​​跃​​​的​​​ LRU 列​​​表​​​中​​​的​​​匿​​​名​​​和​​​ swap 缓​​​存​​​,包​​​括​​​ tmpfs(shmem),单​​​位​​​为​​​字​​​节
  • active_file:活​​​跃​​​ LRU 列​​​表​​​中​​​的​​​ file-backed 内​​​存​​​,以​​​字​​​节​​​为​​​单​​​位
  • inactive_file:不​​​活​​​跃​​​ LRU 列​​​表​​​中​​​的​​​ file-backed 内​​​存​​​,以​​​字​​​节​​​为​​​单​​​位
  • unevictable:无​​​法​​​再​​​生​​​的​​​内​​​存​​​,以​​​字​​​节​​​为​​​单​​​位​​​
  • hierarchical_memory_limit:包​​​含​​​ memory cgroup 的​​​层​​​级​​​的​​​内​​​存​​​限​​​制​​​,单​​​位​​​为​​​字​​​节​​​
  • hierarchical_memsw_limit:包​​​含​​​ memory cgroup 的​​​层​​​级​​​的​​​内​​​存​​​加​​​ swap 限​​​制​​​,单​​​位​​​为​​​字​​​节​​​

8. 总结

本文由浅入深的讲解了cgroups的方方面面,从cgroups是什么,到cgroups该怎么用,最后对大量的cgroup子系统配置参数进行了梳理。可以看到,内核对cgroups的支持已经较为完善,但是依旧有许多工作需要完善。如网络方面目前是通过TC(Traffic Controller)来控制,未来需要统一整合;资源限制并没有解决资源竞争,在各自限制之内的进程依旧存在资源竞争,优先级调度方面依旧有很大的改进空间。希望通过本文帮助大家了解cgroups,让更多人参与到社区的贡献中。

9. 作者简介

孙健波,浙江大学SEL实验室硕士研究生,目前在云平台团队从事科研和开发工作。浙大团队对PaaS、Docker、大数据和主流开源云计算技术有深入的研究和二次开发经验,团队现将部分技术文章贡献出来,希望能对读者有所帮助。

参考资料

https://sysadmincasts.com/episodes/14-introduction-to-linux-control-groups-cgroups https://access.redhat.com/documentation/en-US/Red_Hat_Enterprise_Linux/6/html/Resource_Management_Guide/index.html http://www.cnblogs.com/lisperl/archive/2013/01/14/2860353.html https://www.kernel.org/doc/Documentation/cgroups

Docker背后的内核知识——Namespace资源隔离

| Comments

孙健波

Docker这么火,喜欢技术的朋友可能也会想,如果要自己实现一个资源隔离的容器,应该从哪些方面下手呢?也许你第一反应可能就是chroot命令,这条命令给用户最直观的感觉就是使用后根目录/的挂载点切换了,即文件系统被隔离了。然后,为了在分布式的环境下进行通信和定位,容器必然需要一个独立的IP、端口、路由等等,自然就想到了网络的隔离。同时,你的容器还需要一个独立的主机名以便在网络中标识自己。想到网络,顺其自然就想到通信,也就想到了进程间通信的隔离。可能你也想到了权限的问题,对用户和用户组的隔离就实现了用户权限的隔离。最后,运行在容器中的应用需要有自己的PID,自然也需要与宿主机中的PID进行隔离。

由此,我们基本上完成了一个容器所需要做的六项隔离,Linux内核中就提供了这六种namespace隔离的系统调用,如下表所示。

Namespace 系统调用参数 隔离内容
UTS CLONE_NEWUTS 主机名与域名
IPC CLONE_NEWIPC 信号量、消息队列和共享内存
PID CLONE_NEWPID 进程编号
Network CLONE_NEWNET 网络设备、网络栈、端口等等
Mount CLONE_NEWNS 挂载点(文件系统)
User CLONE_NEWUSER 用户和用户组

表 namespace六项隔离

实际上,Linux内核实现namespace的主要目的就是为了实现轻量级虚拟化(容器)服务。在同一个namespace下的进程可以感知彼此的变化,而对外界的进程一无所知。这样就可以让容器中的进程产生错觉,仿佛自己置身于一个独立的系统环境中,以此达到独立和隔离的目的。

需要说明的是,本文所讨论的namespace实现针对的均是Linux内核3.8及其以后的版本。接下来,我们将首先介绍使用namespace的API,然后针对这六种namespace进行逐一讲解,并通过程序让你亲身感受一下这些隔离效果{![参考自http://lwn.net/Articles/531114/]}。

1. 调用namespace的API

namespace的API包括clone()setns()以及unshare(),还有/proc下的部分文件。为了确定隔离的到底是哪种namespace,在使用这些API时,通常需要指定以下六个常数的一个或多个,通过|(位或)操作来实现。你可能已经在上面的表格中注意到,这六个参数分别是CLONE_NEWIPCCLONE_NEWNSCLONE_NEWNETCLONE_NEWPIDCLONE_NEWUSERCLONE_NEWUTS

(1)通过clone()创建新进程的同时创建namespace

使用clone()来创建一个独立namespace的进程是最常见做法,它的调用方式如下。

1
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

clone()实际上是传统UNIX系统调用fork()的一种更通用的实现方式,它可以通过flags来控制使用多少功能。一共有二十多种CLONE_*flag(标志位)参数用来控制clone进程的方方面面(如是否与父进程共享虚拟内存等等),下面外面逐一讲解clone函数传入的参数。

  • 参数child_func传入子进程运行的程序主函数。
  • 参数child_stack传入子进程使用的栈空间
  • 参数flags表示使用哪些CLONE_*标志位
  • 参数args则可用于传入用户参数

在后续的内容中将会有使用clone()的实际程序可供大家参考。

(2)查看/proc/[pid]/ns文件

从3.8版本的内核开始,用户就可以在/proc/[pid]/ns文件下看到指向不同namespace号的文件,效果如下所示,形如[4026531839]者即为namespace号。

1
2
3
4
5
6
7
8
$ ls -l /proc/$$/ns         <<-- $$ 表示应用的PID
total 0
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 ipc -> ipc:[4026531839]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 mnt -> mnt:[4026531840]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 net -> net:[4026531956]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 pid -> pid:[4026531836]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 user->user:[4026531837]
lrwxrwxrwx. 1 mtk mtk 0 Jan  8 04:12 uts -> uts:[4026531838]

如果两个进程指向的namespace编号相同,就说明他们在同一个namespace下,否则则在不同namespace里面。/proc/[pid]/ns的另外一个作用是,一旦文件被打开,只要打开的文件描述符(fd)存在,那么就算PID所属的所有进程都已经结束,创建的namespace就会一直存在。那如何打开文件描述符呢?把/proc/[pid]/ns目录挂载起来就可以达到这个效果,命令如下。

1
2
# touch ~/uts
# mount --bind /proc/27514/ns/uts ~/uts

如果你看到的内容与本文所描述的不符,那么说明你使用的内核在3.8版本以前。该目录下存在的只有ipcnetuts,并且以硬链接存在。

(3)通过setns()加入一个已经存在的namespace

上文刚提到,在进程都结束的情况下,也可以通过挂载的形式把namespace保留下来,保留namespace的目的自然是为以后有进程加入做准备。通过setns()系统调用,你的进程从原先的namespace加入我们准备好的新namespace,使用方法如下。

1
int setns(int fd, int nstype);
  • 参数fd表示我们要加入的namespace的文件描述符。上文已经提到,它是一个指向/proc/[pid]/ns目录的文件描述符,可以通过直接打开该目录下的链接或者打开一个挂载了该目录下链接的文件得到。
  • 参数nstype让调用者可以去检查fd指向的namespace类型是否符合我们实际的要求。如果填0表示不检查。

为了把我们创建的namespace利用起来,我们需要引入execve()系列函数,这个函数可以执行用户命令,最常用的就是调用/bin/bash并接受参数,运行起一个shell,用法如下。

1
2
3
fd = open(argv[1], O_RDONLY);   /* 获取namespace文件描述符 */
setns(fd, 0);                   /* 加入新的namespace */
execvp(argv[2], &argv[2]);      /* 执行程序 */

假设编译后的程序名称为setns

1
# ./setns ~/uts /bin/bash   # ~/uts 是绑定的/proc/27514/ns/uts

至此,你就可以在新的命名空间中执行shell命令了,在下文中会多次使用这种方式来演示隔离的效果。

(4)通过unshare()在原先进程上进行namespace隔离

最后要提的系统调用是unshare(),它跟clone()很像,不同的是,unshare()运行在原先的进程上,不需要启动一个新进程,使用方法如下。

1
int unshare(int flags);

调用unshare()的主要作用就是不启动一个新进程就可以起到隔离的效果,相当于跳出原先的namespace进行操作。这样,你就可以在原进程进行一些需要隔离的操作。Linux中自带的unshare命令,就是通过unshare()系统调用实现的,有兴趣的读者可以在网上搜索一下这个命令的作用。

(5)延伸阅读:fork()系统调用

系统调用函数fork()并不属于namespace的API,所以这部分内容属于延伸阅读,如果读者已经对fork()有足够的了解,那大可跳过。

当程序调用fork()函数时,系统会创建新的进程,为其分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的进程中,只有少量数值与原来的进程值不同,相当于克隆了一个自己。那么程序的后续代码逻辑要如何区分自己是新进程还是父进程呢?

fork()的神奇之处在于它仅仅被调用一次,却能够返回两次(父进程与子进程各返回一次),通过返回值的不同就可以进行区分父进程与子进程。它可能有三种不同的返回值:

  • 在父进程中,fork返回新创建子进程的进程ID
  • 在子进程中,fork返回0
  • 如果出现错误,fork返回一个负值

下面给出一段实例代码,命名为fork_example.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <unistd.h>
#include <stdio.h>
int main (){
  pid_t fpid; //fpid表示fork函数返回的值
  int count=0;
  fpid=fork();
  if (fpid < 0)printf("error in fork!");
  else if (fpid == 0) {
    printf("I am child. Process id is %d/n",getpid());
  }
  else {
    printf("i am parent. Process id is %d/n",getpid());
  }
  return 0;
}

编译并执行,结果如下。

1
2
3
root@local:~# gcc -Wall fork_example.c && ./a.out
I am parent. Process id is 28365
I am child. Process id is 28366

使用fork()后,父进程有义务监控子进程的运行状态,并在子进程退出后自己才能正常退出,否则子进程就会成为“孤儿”进程。

下面我们将分别对六种namespace进行详细解析。

2. UTS(UNIX Time-sharing System)namespace

UTS namespace提供了主机名和域名的隔离,这样每个容器就可以拥有了独立的主机名和域名,在网络上可以被视作一个独立的节点而非宿主机上的一个进程。

下面我们通过代码来感受一下UTS隔离的效果,首先需要一个程序的骨架,如下所示。打开编辑器创建uts.c文件,输入如下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static char child_stack[STACK_SIZE];
char* const child_args[] = {
  "/bin/bash",
  NULL
};

int child_main(void* args) {
  printf("在子进程中!\n");
  execv(child_args[0], child_args);
  return 1;
}

int main() {
  printf("程序开始: \n");
  int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL);
  waitpid(child_pid, NULL, 0);
  printf("已退出\n");
  return 0;
}

编译并运行上述代码,执行如下命令,效果如下。

1
2
3
4
5
6
7
root@local:~# gcc -Wall uts.c -o uts.o && ./uts.o
程序开始:
在子进程中!
root@local:~# exit
exit
已退出
root@local:~#

下面,我们将修改代码,加入UTS隔离。运行代码需要root权限,为了防止普通用户任意修改系统主机名导致set-user-ID相关的应用运行出错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//[...]
int child_main(void* arg) {
  printf("在子进程中!\n");
  sethostname("Changed Namespace", 12);
  execv(child_args[0], child_args);
  return 1;
}

int main() {
//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
    CLONE_NEWUTS | SIGCHLD, NULL);
//[...]
}

再次运行可以看到hostname已经变化。

1
2
3
4
5
6
7
root@local:~# gcc -Wall namespace.c -o main.o && ./main.o
程序开始:
在子进程中!
root@NewNamespace:~# exit
exit
已退出
root@local:~#  <- 回到原来的hostname

也许有读者试着不加CLONE_NEWUTS参数运行上述代码,发现主机名也变了,输入exit以后主机名也会变回来,似乎没什么区别。实际上不加CLONE_NEWUTS参数进行隔离而使用sethostname已经把宿主机的主机名改掉了。你看到exit退出后还原只是因为bash只在刚登录的时候读取一次UTS,当你重新登陆或者使用uname命令进行查看时,就会发现产生了变化。

Docker中,每个镜像基本都以自己所提供的服务命名了自己的hostname而没有对宿主机产生任何影响,用的就是这个原理。

3. IPC(Interprocess Communication)namespace

容器中进程间通信采用的方法包括常见的信号量、消息队列和共享内存。然而与虚拟机不同的是,容器内部进程间通信对宿主机来说,实际上是具有相同PID namespace中的进程间通信,因此需要一个唯一的标识符来进行区别。申请IPC资源就申请了这样一个全局唯一的32位ID,所以IPC namespace中实际上包含了系统IPC标识符以及实现POSIX消息队列的文件系统。在同一个IPC namespace下的进程彼此可见,而与其他的IPC namespace下的进程则互相不可见。

IPC namespace在代码上的变化与UTS namespace相似,只是标识位有所变化,需要加上CLONE_NEWIPC参数。主要改动如下,其他部位不变,程序名称改为ipc.c。{测试方法参考自:http://crosbymichael.com/creating-containers-part-1.html}

1
2
3
4
//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
//[...]

我们首先在shell中使用ipcmk -Q命令创建一个message queue。

1
2
root@local:~# ipcmk -Q
Message queue id: 32769

通过ipcs -q可以查看到已经开启的message queue,序号为32769

1
2
3
4
root@local:~# ipcs -q
------ Message Queues --------
key        msqid   owner   perms   used-bytes   messages
0x4cf5e29f 32769   root    644     0            0

然后我们可以编译运行加入了IPC namespace隔离的ipc.c,在新建的子进程中调用的shell中执行ipcs -q查看message queue。

1
2
3
4
5
6
7
8
9
root@local:~# gcc -Wall ipc.c -o ipc.o && ./ipc.o
程序开始:
在子进程中!
root@NewNamespace:~# ipcs -q
------ Message Queues --------
key   msqid   owner   perms   used-bytes   messages
root@NewNamespace:~# exit
exit
已退出

上面的结果显示中可以发现,已经找不到原先声明的message queue,实现了IPC的隔离。

目前使用IPC namespace机制的系统不多,其中比较有名的有PostgreSQL。Docker本身通过socket或tcp进行通信。

4. PID namespace

PID namespace隔离非常实用,它对进程PID重新标号,即两个不同namespace下的进程可以有同一个PID。每个PID namespace都有自己的计数程序。内核为所有的PID namespace维护了一个树状结构,最顶层的是系统初始时创建的,我们称之为root namespace。他创建的新PID namespace就称之为child namespace(树的子节点),而原先的PID namespace就是新创建的PID namespace的parent namespace(树的父节点)。通过这种方式,不同的PID namespaces会形成一个等级体系。所属的父节点可以看到子节点中的进程,并可以通过信号量等方式对子节点中的进程产生影响。反过来,子节点不能看到父节点PID namespace中的任何内容。由此产生如下结论{![部分内容引自:http://blog.dotcloud.com/under-the-hood-linux-kernels-on-dotcloud-part]}。

  • 每个PID namespace中的第一个进程“PID 1“,都会像传统Linux中的init进程一样拥有特权,起特殊作用。
  • 一个namespace中的进程,不可能通过killptrace影响父节点或者兄弟节点中的进程,因为其他节点的PID在这个namespace中没有任何意义。
  • 如果你在新的PID namespace中重新挂载/proc文件系统,会发现其下只显示同属一个PID namespace中的其他进程。
  • 在root namespace中可以看到所有的进程,并且递归包含所有子节点中的进程。

到这里,可能你已经联想到一种在外部监控Docker中运行程序的方法了,就是监控Docker Daemon所在的PID namespace下的所有进程即其子进程,再进行删选即可。

下面我们通过运行代码来感受一下PID namespace的隔离效果。修改上文的代码,加入PID namespace的标识位,并把程序命名为pid.c

1
2
3
4
5
//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS 
           | SIGCHLD, NULL);
//[...]

编译运行可以看到如下结果。

1
2
3
4
5
6
7
8
root@local:~# gcc -Wall pid.c -o pid.o && ./pid.o
程序开始:
在子进程中!
root@NewNamespace:~# echo $$
1                      <<--注意此处看到shell的PID变成了1
root@NewNamespace:~# exit
exit
已退出

打印$$可以看到shell的PID,退出后如果再次执行可以看到效果如下。

1
2
root@local:~# echo $$
17542

已经回到了正常状态。可能有的读者在子进程的shell中执行了ps aux/top之类的命令,发现还是可以看到所有父进程的PID,那是因为我们还没有对文件系统进行隔离,ps/top之类的命令调用的是真实系统下的/proc文件内容,看到的自然是所有的进程。

此外,与其他的namespace不同的是,为了实现一个稳定安全的容器,PID namespace还需要进行一些额外的工作才能确保其中的进程运行顺利。

(1)PID namespace中的init进程

当我们新建一个PID namespace时,默认启动的进程PID为1。我们知道,在传统的UNIX系统中,PID为1的进程是init,地位非常特殊。他作为所有进程的父进程,维护一张进程表,不断检查进程的状态,一旦有某个子进程因为程序错误成为了“孤儿”进程,init就会负责回收资源并结束这个子进程。所以在你要实现的容器中,启动的第一个进程也需要实现类似init的功能,维护所有后续启动进程的运行状态。

看到这里,可能读者已经明白了内核设计的良苦用心。PID namespace维护这样一个树状结构,非常有利于系统的资源监控与回收。Docker启动时,第一个进程也是这样,实现了进程监控和资源回收,它就是dockerinit

(2)信号量与init进程

PID namespace中的init进程如此特殊,自然内核也为他赋予了特权——信号量屏蔽。如果init中没有写处理某个信号量的代码逻辑,那么与init在同一个PID namespace下的进程(即使有超级权限)发送给它的该信号量都会被屏蔽。这个功能的主要作用是防止init进程被误杀。

那么其父节点PID namespace中的进程发送同样的信号量会被忽略吗?父节点中的进程发送的信号量,如果不是SIGKILL(销毁进程)SIGSTOP(暂停进程)也会被忽略。但如果发送SIGKILLSIGSTOP,子节点的init会强制执行(无法通过代码捕捉进行特殊处理),也就是说父节点中的进程有权终止子节点中的进程。

一旦init进程被销毁,同一PID namespace中的其他进程也会随之接收到SIGKILL信号量而被销毁。理论上,该PID namespace自然也就不复存在了。但是如果/proc/[pid]/ns/pid处于被挂载或者打开状态,namespace就会被保留下来。然而,保留下来的namespace无法通过setns()或者fork()创建进程,所以实际上并没有什么作用。

我们常说,Docker一旦启动就有进程在运行,不存在不包含任何进程的Docker,也就是这个道理。

(3)挂载proc文件系统

前文中已经提到,如果你在新的PID namespace中使用ps命令查看,看到的还是所有的进程,因为与PID直接相关的/proc文件系统(procfs)没有挂载到与原/proc不同的位置。所以如果你只想看到PID namespace本身应该看到的进程,需要重新挂载/proc,命令如下。

1
2
3
4
5
root@NewNamespace:~# mount -t proc proc /proc
root@NewNamespace:~# ps a
  PID TTY      STAT   TIME COMMAND
    1 pts/1    S      0:00 /bin/bash
   12 pts/1    R+     0:00 ps a

可以看到实际的PID namespace就只有两个进程在运行。

注意:因为此时我们没有进行mount namespace的隔离,所以这一步操作实际上已经影响了 root namespace的文件系统,当你退出新建的PID namespace以后再执行ps a就会发现出错,再次执行mount -t proc proc /proc可以修复错误。

(4)unshare()setns()

在开篇我们就讲到了unshare()setns()这两个API,而这两个API在PID namespace中使用时,也有一些特别之处需要注意。

unshare()允许用户在原有进程中建立namespace进行隔离。但是创建了PID namespace后,原先unshare()调用者进程并不进入新的PID namespace,接下来创建的子进程才会进入新的namespace,这个子进程也就随之成为新namespace中的init进程。

类似的,调用setns()创建新PID namespace时,调用者进程也不进入新的PID namespace,而是随后创建的子进程进入。

为什么创建其他namespace时unshare()setns()会直接进入新的namespace而唯独PID namespace不是如此呢?因为调用getpid()函数得到的PID是根据调用者所在的PID namespace而决定返回哪个PID,进入新的PID namespace会导致PID产生变化。而对用户态的程序和库函数来说,他们都认为进程的PID是一个常量,PID的变化会引起这些进程奔溃。

换句话说,一旦程序进程创建以后,那么它的PID namespace的关系就确定下来了,进程不会变更他们对应的PID namespace

5. Mount namespaces

Mount namespace通过隔离文件系统挂载点对隔离文件系统提供支持,它是历史上第一个Linux namespace,所以它的标识位比较特殊,就是CLONE_NEWNS。隔离后,不同mount namespace中的文件结构发生变化也互不影响。你可以通过/proc/[pid]/mounts查看到所有挂载在当前namespace中的文件系统,还可以通过/proc/[pid]/mountstats看到mount namespace中文件设备的统计信息,包括挂载文件的名字、文件系统类型、挂载位置等等。

进程在创建mount namespace时,会把当前的文件结构复制给新的namespace。新namespace中的所有mount操作都只影响自身的文件系统,而对外界不会产生任何影响。这样做非常严格地实现了隔离,但是某些情况可能并不适用。比如父节点namespace中的进程挂载了一张CD-ROM,这时子节点namespace拷贝的目录结构就无法自动挂载上这张CD-ROM,因为这种操作会影响到父节点的文件系统。

2006 年引入的挂载传播(mount propagation)解决了这个问题,挂载传播定义了挂载对象(mount object)之间的关系,系统用这些关系决定任何挂载对象中的挂载事件如何传播到其他挂载对象{![参考自:http://www.ibm.com/developerworks/library/l-mount-namespaces/]}。所谓传播事件,是指由一个挂载对象的状态变化导致的其它挂载对象的挂载与解除挂载动作的事件。

  • 共享关系(share relationship)。如果两个挂载对象具有共享关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象,反之亦然。
  • 从属关系(slave relationship)。如果两个挂载对象形成从属关系,那么一个挂载对象中的挂载事件会传播到另一个挂载对象,但是反过来不行;在这种关系中,从属对象是事件的接收者。

一个挂载状态可能为如下的其中一种:

  • 共享挂载(shared)
  • 从属挂载(slave)
  • 共享/从属挂载(shared and slave)
  • 私有挂载(private)
  • 不可绑定挂载(unbindable)

传播事件的挂载对象称为共享挂载(shared mount);接收传播事件的挂载对象称为从属挂载(slave mount)。既不传播也不接收传播事件的挂载对象称为私有挂载(private mount)。另一种特殊的挂载对象称为不可绑定的挂载(unbindable mount),它们与私有挂载相似,但是不允许执行绑定挂载,即创建mount namespace时这块文件对象不可被复制。

mount各类挂载状态示意图 图1 mount各类挂载状态示意图

共享挂载的应用场景非常明显,就是为了文件数据的共享所必须存在的一种挂载方式;从属挂载更大的意义在于某些“只读”场景;私有挂载其实就是纯粹的隔离,作为一个独立的个体而存在;不可绑定挂载则有助于防止没有必要的文件拷贝,如某个用户数据目录,当根目录被递归式的复制时,用户目录无论从隐私还是实际用途考虑都需要有一个不可被复制的选项。

默认情况下,所有挂载都是私有的。设置为共享挂载的命令如下。

1
mount --make-shared <mount-object>

从共享挂载克隆的挂载对象也是共享的挂载;它们相互传播挂载事件。

设置为从属挂载的命令如下。

1
mount --make-slave <shared-mount-object>

从从属挂载克隆的挂载对象也是从属的挂载,它也从属于原来的从属挂载的主挂载对象。

将一个从属挂载对象设置为共享/从属挂载,可以执行如下命令或者将其移动到一个共享挂载对象下。

1
mount --make-shared <slave-mount-object>

如果你想把修改过的挂载对象重新标记为私有的,可以执行如下命令。

1
mount --make-private <mount-object>

通过执行以下命令,可以将挂载对象标记为不可绑定的。

1
mount --make-unbindable <mount-object>

这些设置都可以递归式地应用到所有子目录中,如果读者感兴趣可以搜索到相关的命令。

在代码中实现mount namespace隔离与其他namespace类似,加上CLONE_NEWNS标识位即可。让我们再次修改代码,并且另存为mount.c进行编译运行。

1
2
3
4
5
//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
           CLONE_NEWNS | CLONE_NEWPID | CLONE_NEWIPC 
           | CLONE_NEWUTS | SIGCHLD, NULL);
//[...]

执行的效果就如同PID namespace一节中“挂载proc文件系统”的执行结果,区别就是退出mount namespace以后,root namespace的文件系统不会被破坏,此处就不再演示了。

6. Network namespace

通过上节,我们了解了PID namespace,当我们兴致勃勃地在新建的namespace中启动一个“Apache”进程时,却出现了“80端口已被占用”的错误,原来主机上已经运行了一个“Apache”进程。怎么办?这就需要用到network namespace技术进行网络隔离啦。

Network namespace主要提供了关于网络资源的隔离,包括网络设备、IPv4和IPv6协议栈、IP路由表、防火墙、/proc/net目录、/sys/class/net目录、端口(socket)等等。一个物理的网络设备最多存在在一个network namespace中,你可以通过创建veth pair(虚拟网络设备对:有两端,类似管道,如果数据从一端传入另一端也能接收到,反之亦然)在不同的network namespace间创建通道,以此达到通信的目的。

一般情况下,物理网络设备都分配在最初的root namespace(表示系统默认的namespace,在PID namespace中已经提及)中。但是如果你有多块物理网卡,也可以把其中一块或多块分配给新创建的network namespace。需要注意的是,当新创建的network namespace被释放时(所有内部的进程都终止并且namespace文件没有被挂载或打开),在这个namespace中的物理网卡会返回到root namespace而非创建该进程的父进程所在的network namespace。

当我们说到network namespace时,其实我们指的未必是真正的网络隔离,而是把网络独立出来,给外部用户一种透明的感觉,仿佛跟另外一个网络实体在进行通信。为了达到这个目的,容器的经典做法就是创建一个veth pair,一端放置在新的namespace中,通常命名为eth0,一端放在原先的namespace中连接物理网络设备,再通过网桥把别的设备连接进来或者进行路由转发,以此网络实现通信的目的。

也许有读者会好奇,在建立起veth pair之前,新旧namespace该如何通信呢?答案是pipe(管道)。我们以Docker Daemon在启动容器dockerinit的过程为例。Docker Daemon在宿主机上负责创建这个veth pair,通过netlink调用,把一端绑定到docker0网桥上,一端连进新建的network namespace进程中。建立的过程中,Docker Daemondockerinit就通过pipe进行通信,当Docker Daemon完成veth-pair的创建之前,dockerinit在管道的另一端循环等待,直到管道另一端传来Docker Daemon关于veth设备的信息,并关闭管道。dockerinit才结束等待的过程,并把它的“eth0”启动起来。整个效果类似下图所示。

图2 Docker网络示意图 图2 Docker网络示意图

跟其他namespace类似,对network namespace的使用其实就是在创建的时候添加CLONE_NEWNET标识位。也可以通过命令行工具ip创建network namespace。在代码中建立和测试network namespace较为复杂,所以下文主要通过ip命令直观的感受整个network namespace网络建立和配置的过程。

首先我们可以创建一个命名为test_ns的network namespace。

1
# ip netns add test_ns

ip命令工具创建一个network namespace时,会默认创建一个回环设备(loopback interface:lo),并在/var/run/netns目录下绑定一个挂载点,这就保证了就算network namespace中没有进程在运行也不会被释放,也给系统管理员对新创建的network namespace进行配置提供了充足的时间。

通过ip netns exec命令可以在新创建的network namespace下运行网络管理命令。

1
2
3
# ip netns exec test_ns ip link list
3: lo: <LOOPBACK> mtu 16436 qdisc noop state DOWN
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

上面的命令为我们展示了新建的namespace下可见的网络链接,可以看到状态是DOWN,需要再通过命令去启动。可以看到,此时执行ping命令是无效的。

1
2
# ip netns exec test_ns ping 127.0.0.1
connect: Network is unreachable

启动命令如下,可以看到启动后再测试就可以ping通。

1
2
3
4
5
# ip netns exec test_ns ip link set dev lo up
# ip netns exec test_ns ping 127.0.0.1
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_req=1 ttl=64 time=0.050 ms
...

这样只是启动了本地的回环,要实现与外部namespace进行通信还需要再建一个网络设备对,命令如下。

1
2
3
4
# ip link add veth0 type veth peer name veth1
# ip link set veth1 netns test_ns
# ip netns exec test_ns ifconfig veth1 10.1.1.1/24 up
# ifconfig veth0 10.1.1.2/24 up
  • 第一条命令创建了一个网络设备对,所有发送到veth0的包veth1也能接收到,反之亦然。
  • 第二条命令则是把veth1这一端分配到test_ns这个network namespace。
  • 第三、第四条命令分别给test_ns内部和外部的网络设备配置IP,veth1的IP为10.1.1.1veth0的IP为10.1.1.2

此时两边就可以互相连通了,效果如下。

1
2
3
4
5
6
7
8
# ping 10.1.1.1
PING 10.1.1.1 (10.1.1.1) 56(84) bytes of data.
64 bytes from 10.1.1.1: icmp_req=1 ttl=64 time=0.095 ms
...
# ip netns exec test_ns ping 10.1.1.2
PING 10.1.1.2 (10.1.1.2) 56(84) bytes of data.
64 bytes from 10.1.1.2: icmp_req=1 ttl=64 time=0.049 ms
...

读者有兴趣可以通过下面的命令查看,新的test_ns有着自己独立的路由和iptables。

1
2
ip netns exec test_ns route
ip netns exec test_ns iptables -L

路由表中只有一条通向10.1.1.2的规则,此时如果要连接外网肯定是不可能的,你可以通过建立网桥或者NAT映射来决定这个问题。如果你对此非常感兴趣,可以阅读Docker网络相关文章进行更深入的讲解。

做完这些实验,你还可以通过下面的命令删除这个network namespace。

1
# ip netns delete netns1

这条命令会移除之前的挂载,但是如果namespace本身还有进程运行,namespace还会存在下去,直到进程运行结束。

通过network namespace我们可以了解到,实际上内核创建了network namespace以后,真的是得到了一个被隔离的网络。但是我们实际上需要的不是这种完全的隔离,而是一个对用户来说透明独立的网络实体,我们需要与这个实体通信。所以Docker的网络在起步阶段给人一种非常难用的感觉,因为一切都要自己去实现、去配置。你需要一个网桥或者NAT连接广域网,你需要配置路由规则与宿主机中其他容器进行必要的隔离,你甚至还需要配置防火墙以保证安全等等。所幸这一切已经有了较为成熟的方案,我们会在Docker网络部分进行详细的讲解。

7. User namespaces

User namespace主要隔离了安全相关的标识符(identifiers)和属性(attributes),包括用户ID、用户组ID、root目录、key(指密钥)以及特殊权限。说得通俗一点,一个普通用户的进程通过clone()创建的新进程在新user namespace中可以拥有不同的用户和用户组。这意味着一个进程在容器外属于一个没有特权的普通用户,但是他创建的容器进程却属于拥有所有权限的超级用户,这个技术为容器提供了极大的自由。

User namespace是目前的六个namespace中最后一个支持的,并且直到Linux内核3.8版本的时候还未完全实现(还有部分文件系统不支持)。因为user namespace实际上并不算完全成熟,很多发行版担心安全问题,在编译内核的时候并未开启USER_NS实际上目前Docker也还不支持user namespace,但是预留了相应接口,相信在不久后就会支持这一特性。所以在进行接下来的代码实验时,请确保你系统的Linux内核版本高于3.8并且内核编译时开启了USER_NS(如果你不会选择,可以使用Ubuntu14.04)。

Linux中,特权用户的user ID就是0,演示的最终我们将看到user ID非0的进程启动user namespace后user ID可以变为0。使用user namespace的方法跟别的namespace相同,即调用clone()unshare()时加入CLONE_NEWUSER标识位。老样子,修改代码并另存为userns.c,为了看到用户权限(Capabilities),可能你还需要安装一下libcap-dev包。

首先包含以下头文件以调用Capabilities包。

1
#include <sys/capability.h>

其次在子进程函数中加入geteuid()getegid()得到namespace内部的user ID,其次通过cap_get_proc()得到当前进程的用户拥有的权限,并通过cap_to_text()输出。

1
2
3
4
5
6
7
8
9
10
int child_main(void* args) {
        printf("在子进程中!\n");
        cap_t caps;
        printf("eUID = %ld;  eGID = %ld;  ",
                        (long) geteuid(), (long) getegid());
        caps = cap_get_proc();
        printf("capabilities: %s\n", cap_to_text(caps, NULL));
        execv(child_args[0], child_args);
        return 1;
}

在主函数的clone()调用中加入我们熟悉的标识符。

1
2
3
4
//[...]
int child_pid = clone(child_main, child_stack+STACK_SIZE,
            CLONE_NEWUSER | SIGCHLD, NULL);
//[...]

至此,第一部分的代码修改就结束了。在编译之前我们先查看一下当前用户的uidguid,请注意此时我们是普通用户。

1
2
3
4
$ id -u
1000
$ id -g
1000

然后我们开始编译运行,并进行新建的user namespace,你会发现shell提示符前的用户名已经变为nobody

1
2
3
4
5
sun@ubuntu$ gcc userns.c -Wall -lcap -o userns.o && ./userns.o
程序开始:
在子进程中!
eUID = 65534;  eGID = 65534;  capabilities: = cap_chown,cap_dac_override,[...]37+ep  <<--此处省略部分输出,已拥有全部权限
nobody@ubuntu$ 

通过验证我们可以得到以下信息。

  • user namespace被创建后,第一个进程被赋予了该namespace中的全部权限,这样这个init进程就可以完成所有必要的初始化工作,而不会因权限不足而出现错误。
  • 我们看到namespace内部看到的UID和GID已经与外部不同了,默认显示为65534,表示尚未与外部namespace用户映射。我们需要对user namespace内部的这个初始user和其外部namespace某个用户建立映射,这样可以保证当涉及到一些对外部namespace的操作时,系统可以检验其权限(比如发送一个信号量或操作某个文件)。同样用户组也要建立映射。
  • 还有一点虽然不能从输出中看出来,但是值得注意。用户在新namespace中有全部权限,但是他在创建他的父namespace中不含任何权限。就算调用和创建他的进程有全部权限也是如此。所以哪怕是root用户调用了clone()在user namespace中创建出的新用户在外部也没有任何权限。
  • 最后,user namespace的创建其实是一个层层嵌套的树状结构。最上层的根节点就是root namespace,新创建的每个user namespace都有一个父节点user namespace以及零个或多个子节点user namespace,这一点与PID namespace非常相似。

接下来我们就要进行用户绑定操作,通过在/proc/[pid]/uid_map/proc/[pid]/gid_map两个文件中写入对应的绑定信息可以实现这一点,格式如下。

1
ID-inside-ns   ID-outside-ns   length

写这两个文件需要注意以下几点。

  • 这两个文件只允许由拥有该user namespace中CAP_SETUID权限的进程写入一次,不允许修改。
  • 写入的进程必须是该user namespace的父namespace或者子namespace。
  • 第一个字段ID-inside-ns表示新建的user namespace中对应的user/group ID,第二个字段ID-outside-ns表示namespace外部映射的user/group ID。最后一个字段表示映射范围,通常填1,表示只映射一个,如果填大于1的值,则按顺序建立一一映射。

明白了上述原理,我们再次修改代码,添加设置uid和guid的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
//[...]
void set_uid_map(pid_t pid, int inside_id, int outside_id, int length) {
    char path[256];
    sprintf(path, "/proc/%d/uid_map", getpid());
    FILE* uid_map = fopen(path, "w");
    fprintf(uid_map, "%d %d %d", inside_id, outside_id, length);
    fclose(uid_map);
}
void set_gid_map(pid_t pid, int inside_id, int outside_id, int length) {
    char path[256];
    sprintf(path, "/proc/%d/gid_map", getpid());
    FILE* gid_map = fopen(path, "w");
    fprintf(gid_map, "%d %d %d", inside_id, outside_id, length);
    fclose(gid_map);
}
int child_main(void* args) {
  cap_t caps;
  printf("在子进程中!\n");
  set_uid_map(getpid(), 0, 1000, 1);
  set_gid_map(getpid(), 0, 1000, 1);
  printf("eUID = %ld;  eGID = %ld;  ",
      (long) geteuid(), (long) getegid());
  caps = cap_get_proc();
  printf("capabilities: %s\n", cap_to_text(caps, NULL));
  execv(child_args[0], child_args);
  return 1;
}
//[...]

编译后即可看到user已经变成了root

1
2
3
4
5
$ gcc userns.c -Wall -lcap -o usernc.o && ./usernc.o
程序开始:
在子进程中!
eUID = 0;  eGID = 0;  capabilities: = [...],37+ep
root@ubuntu:~#

至此,你就已经完成了绑定的工作,可以看到演示全程都是在普通用户下执行的。最终实现了在user namespace中成为了root而对应到外面的是一个uid为1000的普通用户。

如果你要把user namespace与其他namespace混合使用,那么依旧需要root权限。解决方案可以是先以普通用户身份创建user namespace,然后在新建的namespace中作为root再clone()进程加入其他类型的namespace隔离。

讲完了user namespace,我们再来谈谈Docker。虽然Docker目前尚未使用user namespace,但是他用到了我们在user namespace中提及的Capabilities机制。从内核2.2版本开始,Linux把原来和超级用户相关的高级权限划分成为不同的单元,称为Capability。这样管理员就可以独立对特定的Capability进行使能或禁止。Docker虽然没有使用user namespace,但是他可以禁用容器中不需要的Capability,一次在一定程度上加强容器安全性。

当然,说到安全,namespace的六项隔离看似全面,实际上依旧没有完全隔离Linux的资源,比如SELinuxCgroups以及/sys/proc/sys/dev/sd*等目录下的资源。关于安全的更多讨论和讲解,我们会在后文中接着探讨。

8. 总结

本文从namespace使用的API开始,结合Docker逐步对六个namespace进行讲解。相信把讲解过程中所有的代码整合起来,你也能实现一个属于自己的“shell”容器了。虽然namespace技术使用起来非常简单,但是要真正把容器做到安全易用却并非易事。PID namespace中,我们要实现一个完善的init进程来维护好所有进程;network namespace中,我们还有复杂的路由表和iptables规则没有配置;user namespace中还有很多权限上的问题需要考虑等等。其中有些方面Docker已经做的很好,有些方面也才刚刚开始。希望通过本文,能为大家更好的理解Docker背后运行的原理提供帮助。

Dive Into Etcd

| Comments

etcd:从应用场景到实现原理的全方位解读

随着CoreOS和Kubernetes等项目在开源社区日益火热,它们项目中都用到的etcd组件作为一个高可用、强一致性的服务发现存储仓库,渐渐为开发人员所关注。在云计算时代,如何让服务快速透明地接入到计算集群中,如何让共享配置信息快速被集群中的所有机器发现,更为重要的是,如何构建这样一套高可用、安全、易于部署以及响应快速的服务集群,已经成为了迫切需要解决的问题。etcd为解决这类问题带来了福音,本章将从etcd的应用场景开始,深入解读etcd的实现方式,以供开发者们更为充分地享用etcd所带来的便利。

1 etcd经典应用场景

etcd是什么?很多人对这个问题的第一反应可能是,它是一个键值存储仓库,却没有重视官方定义的后半句,用于配置共享和服务发现。

A highly-available key value store for shared configuration and service discovery.

实际上,etcd作为一个受到Zookeeper与doozer启发而催生的项目,除了拥有与之类似的功能外,更具有以下4个特点{![引自Docker官方文档]}。

  • 简单:基于HTTP+JSON的API让你用curl命令就可以轻松使用。
  • 安全:可选SSL客户认证机制。
  • 快速:每个实例每秒支持一千次写操作。
  • 可信:使用Raft算法充分实现了分布式。

随着云计算的不断发展,分布式系统中涉及的问题越来越受到人们重视。受阿里中间件团队对ZooKeeper典型应用场景一览一文的启发{![部分案例引自此文。]},我根据自己的理解也总结了一些etcd的经典使用场景。值得注意的是,分布式系统中的数据分为控制数据和应用数据。使用etcd的场景处理的数据默认为控制数据,对于应用数据,只推荐处理数据量很小,但是更新访问频繁的情况。

1.1 场景一:服务发现

服务发现(Service Discovery)要解决的是分布式系统中最常见的问题之一,即在同一个分布式集群中的进程或服务如何才能找到对方并建立连接。从本质上说,服务发现就是想要了解集群中是否有进程在监听udp或tcp端口,并且通过名字就可以进行查找和连接。要解决服务发现的问题,需要有下面三大支柱,缺一不可。

  • 一个强一致性、高可用的服务存储目录。基于Raft算法的etcd天生就是这样一个强一致性高可用的服务存储目录。
  • 一种注册服务和监控服务健康状态的机制。用户可以在etcd中注册服务,并且对注册的服务设置key TTL,定时保持服务的心跳以达到监控健康状态的效果。
  • 一种查找和连接服务的机制。通过在etcd指定的主题下注册的服务也能在对应的主题下查找到。为了确保连接,我们可以在每个服务机器上都部署一个proxy模式的etcd,这样就可以确保能访问etcd集群的服务都能互相连接。

图1所示为服务发现示意图。 enter image description here

图1 服务发现示意图

下面我们来看一下服务发现对应的具体应用场景。

  • 微服务协同工作架构中,服务动态添加。随着Docker容器的流行,多种微服务共同协作,构成一个功能相对强大的架构的案例越来越多。透明化的动态添加这些服务的需求也日益强烈。通过服务发现机制,在etcd中注册某个服务名字的目录,在该目录下存储可用的服务节点的IP。在使用服务的过程中,只要从服务目录下查找可用的服务节点进行使用即可。 微服务协同工作如图2所示。 enter image description here

图2 微服务协同工作

  • PaaS平台中应用多实例与实例故障重启透明化。PaaS平台中的应用一般都有多个实例,通过域名,不仅可以透明地对多个实例进行访问,而且还可以实现负载均衡。但是应用的某个实例随时都有可能故障重启,这时就需要动态地配置域名解析(路由)中的信息。通过etcd的服务发现功能就可以轻松解决这个动态配置的问题,如图33所示。

enter image description here

图3 云平台多实例透明化

1.2 场景二:消息发布与订阅

在分布式系统中,最为适用的组件间通信方式是消息发布与订阅机制。具体而言,即构建一个配置共享中心,数据提供者在这个配置中心发布消息,而消息使用者则订阅他们关心的主题,一旦相关主题有消息发布,就会实时通知订阅者。通过这种方式可以实现分布式系统配置的集中式管理与实时动态更新。

  • 应用中用到的一些配置信息存放在etcd上进行集中管理。这类场景的使用方式通常是这样的:应用在启动的时候主动从etcd获取一次配置信息,同时,在etcd节点上注册一个Watcher并等待,以后每次配置有更新的时候,etcd都会实时通知订阅者,以此达到获取最新配置信息的目的。
  • 分布式搜索服务中,索引的元信息和服务器集群机器的节点状态信息存放在etcd中,供各个客户端订阅使用。使用etcd的key TTL功能可以确保机器状态是实时更新的。
  • 分布式日志收集系统。这个系统的核心工作是收集分布在不同机器上的日志。收集器通常按照应用(或主题)来分配收集任务单元,因此可以在etcd上创建一个以应用(或主题)命名的目录P,并将这个应用(或主题)相关的所有机器ip,以子目录的形式存储在目录P下,然后设置一个递归的etcd Watcher,递归式地监控应用(或主题)目录下所有信息的变动。这样就实现了在机器IP(消息)发生变动时,能够实时通知收集器调整任务分配。
  • 系统中信息需要动态自动获取与人工干预修改信息请求内容的情况。通常的解决方案是对外暴露接口,例如JMX接口,来获取一些运行时的信息或提交修改的请求。而引入etcd之后,只需要将这些信息存放到指定的etcd目录中,即可通过HTTP接口直接被外部访问。

enter image description here

图4 消息发布与订阅

1.3 场景三:负载均衡

场景一中也提到了负载均衡,本文提及的负载均衡均指软负载均衡。在分布式系统中,为了保证服务的高可用以及数据的一致性,通常都会把数据和服务部署多份,以此达到对等服务,即使其中的某一个服务失效了,也不影响使用。这样的实现虽然会导致一定程度上数据写入性能的下降,但是却能实现数据访问时的负载均衡。因为每个对等服务节点上都存有完整的数据,所以用户的访问流量就可以分流到不同的机器上。

  • etcd本身分布式架构存储的信息访问支持负载均衡。etcd集群化以后,每个etcd的核心节点都可以处理用户的请求。所以,把数据量小但是访问频繁的消息数据直接存储到etcd中也是个不错的选择,如业务系统中常用的二级代码表。二级代码表的工作过程一般是这样,在表中存储代码,在etcd中存储代码所代表的具体含义,业务系统调用查表的过程,就需要查找表中代码的含义。所以如果把二级代码表中的小量数据存储到etcd中,不仅方便修改,也易于大量访问。
  • 利用etcd维护一个负载均衡节点表。etcd可以监控一个集群中多个节点的状态,当有一个请求发过来后,可以轮询式地把请求转发给存活着的多个节点。类似KafkaMQ,通过Zookeeper来维护生产者和消费者的负载均衡。同样也可以用etcd来做Zookeeper的工作。

fuz

图5 负载均衡

1.4 场景四:分布式通知与协调

这里讨论的分布式通知与协调,与消息发布和订阅有些相似。两者都使用了etcd中的Watcher机制,通过注册与异步通知机制,实现分布式环境下不同系统之间的通知与协调,从而对数据变更进行实时处理。实现方式通常为:不同系统都在etcd上对同一个目录进行注册,同时设置Watcher监控该目录的变化(如果对子目录的变化也有需要,可以设置成递归模式),当某个系统更新了etcd的目录,那么设置了Watcher的系统就会收到通知,并作出相应处理。

  • 通过etcd进行低耦合的心跳检测。检测系统和被检测系统通过etcd上某个目录关联而非直接关联起来,这样可以大大减少系统的耦合性。
  • 通过etcd完成系统调度。某系统有控制台和推送系统两部分组成,控制台的职责是控制推送系统进行相应的推送工作。管理人员在控制台做的一些操作,实际上只需要修改etcd上某些目录节点的状态,而etcd就会自动把这些变化通知给注册了Watcher的推送系统客户端,推送系统再做出相应的推送任务。
  • 通过etcd完成工作汇报。大部分类似的任务分发系统,子任务启动后,到etcd来注册一个临时工作目录,并且定时将自己的进度进行汇报(将进度写入到这个临时目录),这样任务管理者就能够实时知道任务进度。

enter image description here

图6 分布式协同工作

1.5 场景五:分布式锁

因为etcd使用Raft算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。锁服务有两种使用方式,一是保持独占,二是控制时序。

  • 保持独占,即所有试图获取锁的用户最终只有一个可以得到。etcd为此提供了一套实现分布式锁原子操作CAS(CompareAndSwap)的API。通过设置prevExist值,可以保证在多个节点同时创建某个目录时,只有一个成功,而该用户即可认为是获得了锁。
  • 控制时序,即所有试图获取锁的用户都会进入等待队列,获得锁的顺序是全局唯一的,同时决定了队列执行顺序。etcd为此也提供了一套API(自动创建有序键),对一个目录建值时指定为POST动作,这样etcd会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用API按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,而这些键中存储的值可以是代表客户端的编号。

enter image description here

图7 分布式锁

1.6 场景六:分布式队列

分布式队列的常规用法与场景五中所描述的分布式锁的控制时序用法类似,即创建一个先进先出的队列,保证顺序。

另一种比较有意思的实现是在保证队列达到某个条件时再统一按顺序执行。这种方法的实现可以在/queue这个目录中另外建立一个/queue/condition节点。

  • condition可以表示队列大小。比如一个大的任务需要很多小任务就绪的情况下才能执行,每次有一个小任务就绪,就给这个condition数字加1,直到达到大任务规定的数字,再开始执行队列里的一系列小任务,最终执行大任务。
  • condition可以表示某个任务在不在队列。这个任务可以是所有排序任务的首个执行程序,也可以是拓扑结构中没有依赖的点。通常,必须执行这些任务后才能执行队列中的其他任务。
  • condition还可以表示其它的一类开始执行任务的通知。可以由控制程序指定,当condition出现变化时,开始执行队列任务。

enter image description here 图8 分布式队列

1.7 场景七:集群监控与Leader竞选

通过etcd来进行监控实现起来非常简单并且实时性强,用到了以下两点特性。

  1. 前面几个场景已经提到Watcher机制,当某个节点消失或有变动时,Watcher会第一时间发现并告知用户。
  2. 节点可以设置TTL key,比如每隔30s向etcd发送一次心跳使代表该节点仍然存活,否则说明节点消失。

这样就可以第一时间检测到各节点的健康状态,以完成集群的监控要求。

另外,使用分布式锁,可以完成Leader竞选。对于一些长时间CPU计算或者使用IO操作,只需要由竞选出的Leader计算或处理一次,再把结果复制给其他Follower即可,从而避免重复劳动,节省计算资源。

Leader应用的经典场景是在搜索系统中建立全量索引。如果每个机器分别进行索引的建立,不但耗时,而且不能保证索引的一致性。通过在etcd的CAS机制竞选Leader,由Leader进行索引计算,再将计算结果分发到其它节点。

enter image description here 图9 Leader竞选

1.8 场景八:为什么用etcd而不用Zookeeper?

阅读了“ZooKeeper典型应用场景一览”一文的读者可能会发现,etcd实现的这些功能,Zookeeper都能实现。那么为什么要用etcd而非直接使用Zookeeper呢?4

相较之下,Zookeeper有如下缺点。

  1. 复杂。Zookeeper的部署维护复杂,管理员需要掌握一系列的知识和技能;而Paxos强一致性算法也是素来以复杂难懂而闻名于世;另外,Zookeeper的使用也比较复杂,需要安装客户端,官方只提供了java和C两种语言的接口。
  2. Java编写。这里不是对Java有偏见,而是Java本身就偏向于重型应用,它会引入大量的依赖。而运维人员则普遍希望机器集群尽可能简单,维护起来也不易出错。
  3. 发展缓慢。Apache基金会项目特有的“Apache Way”在开源界饱受争议,其中一大原因就是由于基金会庞大的结构以及松散的管理导致项目发展缓慢。

而etcd作为一个后起之秀,其优点也很明显。

  1. 简单。使用Go语言编写部署简单;使用HTTP作为接口使用简单;使用Raft算法保证强一致性让用户易于理解
  2. 数据持久化。etcd默认数据一更新就进行持久化。
  3. 安全。etcd支持SSL客户端安全认证。

最后,etcd作为一个年轻的项目,正在高速迭代和开发中,这既是一个优点,也是一个缺点。优点在于它的未来具有无限的可能性,缺点是版本的迭代导致其使用的可靠性无法保证,无法得到大项目长时间使用的检验。然而,目前CoreOS、Kubernetes和Cloudfoundry等知名项目均在生产环境中使用了etcd,所以总的来说,etcd值得你去尝试。

1:https://github.com/coreos/etcd

2:http://jm-blog.aliapp.com/?p=1232

3:http://progrium.com/blog/2014/07/29/understanding-modern-service-discovery-with-docker/

4:http://devo.ps/blog/zookeeper-vs-doozer-vs-etcd

2 深度解析etcd

上一节中,我们概括了许多etcd的经典场景,这一节,我们将从etcd的架构开始,深入到源码中解析etcd。

2.1 etcd架构

enter image description here 图10 etcd架构图

从etcd的架构图中我们可以看到,etcd主要分为四个部分。

  • HTTP Server: 用于处理用户发送的API请求以及其它etcd节点的同步与心跳信息请求。
  • Store:用于处理etcd支持的各类功能的事务,包括数据索引、节点状态变更、监控与反馈、事件处理与执行等等,是etcd对用户提供的大多数API功能的具体实现。
  • Raft:Raft强一致性算法的具体实现,是etcd的核心。
  • WAL:Write Ahead Log(预写式日志),是etcd的数据存储方式。除了在内存中存有所有数据的状态以及节点的索引以外,etcd还通过WAL进行持久化存储。WAL中,所有的数据提交前都会事先记录日志。Snapshot是为了防止数据过多而进行的状态快照;Entry则表示存储的具体日志内容。

通常,一个用户的请求发送过来,会经由HTTP Server转发给Store进行具体的事务处理,如果涉及到节点的修改,则交给Raft模块进行状态的变更、日志的记录,然后再同步给别的etcd节点以确认数据提交,最后进行数据的提交,再次同步。

2.2 etcd2.0.0区别于0.4.6的重要变更列表

  • 获得了IANA认证的端口,2379用于客户端通信,2380用于节点通信,与原先的(4001 peers/7001 clients)共用。
  • 每个节点可监听多个广播地址。监听的地址由原来的一个扩展到多个,用户可以根据需求实现更加复杂的集群环境,如一个是公网IP,一个是虚拟机(容器)之类的私有IP。
  • etcd任意节点均可代理访问Leader节点的请求,所以如果你可以访问任何一个etcd节点,那么就可以对整个集群进行读写操作,而不需要刻意关注网络的拓扑结构。
  • etcd集群和集群中的节点都有了自己独特的ID。这样就防止出现配置混淆,不是本集群的其他etcd节点发来的请求将被屏蔽。
  • etcd配置信息在集群启动时就完全固定,这样有助于用户确认集群大小,正确地配置和启动集群。
  • 运行时节点变更 (Runtime Reconfiguration)。用户不需要重启 etcd 服务即可实现对 etcd 集群结构进行变更,可以动态操作。
  • 重新设计和实现了Raft算法,使得运行速度更快,更容易理解,包含更多测试代码。
  • Raft日志现在是严格的只能向后追加、预写式日志系统,并且在每条记录中都加入了CRC校验码。
  • 启动时使用的_etcd/* 关键字不再暴露给用户
  • 废弃使用集群自动调整功能的standby模式,standby模式会使得用户维护集群更困难。
  • 新增Proxy模式,不加入到etcd一致性集群中,纯粹进行代理转发。
  • ETCD_NAME(-name)参数目前是可选的,不再用于唯一标识一个节点。
  • 摒弃通过配置文件配置 etcd 属性的方式,你可以通过设置环境变量的方式代替。
  • 通过自发现方式启动集群时要求用户提供集群大小,这样有助于确定集群实际启动的节点数量。

2.3 etcd概念词汇表

  • Raft:etcd所采用的保证分布式系统强一致性的算法。
  • Node:一个Raft状态机实例。
  • Member: 一个etcd实例。它管理着一个Node,并且可以为客户端请求提供服务。
  • Cluster:由多个Member构成可以协同工作的etcd集群。
  • Peer:对同一个etcd集群中另外一个Member的称呼。
  • Client: 向etcd集群发送HTTP请求的客户端。
  • WAL:预写式日志,etcd用于持久化存储的日志格式。
  • snapshot:etcd防止WAL文件过多而设置的快照,存储etcd数据状态。
  • Proxy:etcd的一种模式,为etcd集群提供反向代理服务。
  • Leader:Raft算法中通过竞选而产生的处理所有数据提交的节点。
  • Follower:竞选失败的节点作为Raft中的从属节点,为算法提供强一致性保证。
  • Candidate:当Follower超过一定时间接收不到Leader的心跳时转变为Candidate开始Leader竞选。
  • Term:某个节点成为Leader到下一次竞选开始的时间周期,称为一个Term。
  • Index:数据项编号。Raft中通过Term和Index来定位数据。

2.4 集群化应用实践

etcd作为一个高可用键值存储系统,天生就是为集群化而设计的。由于Raft算法在做决策时需要多数节点的投票,所以etcd一般部署集群推荐奇数个节点,推荐的数量为3、5或者7个节点构成一个集群。

2.4.1 集群启动

etcd有三种集群化启动的配置方案,分别为静态配置启动、etcd自身服务发现、通过DNS进行服务发现。

根据启动环境,你可以选择不同的配置方式。值得一提的是,这也是新版etcd区别于旧版的一大特性,它摒弃了使用配置文件进行参数配置的做法,转而使用命令行参数或者环境变量来配置参数。

(1). 静态配置

这种方式比较适用于离线环境。在启动整个集群之前,你如果已经预先清楚所要配置的集群大小,以及集群上各节点的地址和端口信息,那么启动时,你就可以通过配置initial-cluster参数进行etcd集群的启动。

在每个etcd机器启动时,配置环境变量或者添加启动参数的方式如下。

ETCD_INITIAL_CLUSTER="infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380"
ETCD_INITIAL_CLUSTER_STATE=new

参数方法:

-initial-cluster 
infra0=http://10.0.1.10:2380,http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
 -initial-cluster-state new

值得注意的是-initial-cluster参数中配置的url地址必须与各个节点启动时设置的initial-advertise-peer-urls参数相同。(initial-advertise-peer-urls参数表示节点监听其他节点同步信号的地址)

如果你所在的网络环境配置了多个etcd集群,为了避免意外发生,最好使用-initial-cluster-token参数为每个集群单独配置一个token认证。这样就可以确保每个集群和集群的成员都拥有独特的ID。

综上所述,如果你要配置包含3个etcd节点的集群,那么你在三个机器上的启动命令分别如下所示。

$ etcd -name infra0 -initial-advertise-peer-urls http://10.0.1.10:2380 \
  -listen-peer-urls http://10.0.1.10:2380 \
  -initial-cluster-token etcd-cluster-1 \
  -initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  -initial-cluster-state new

$ etcd -name infra1 -initial-advertise-peer-urls http://10.0.1.11:2380 \
  -listen-peer-urls http://10.0.1.11:2380 \
  -initial-cluster-token etcd-cluster-1 \
  -initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  -initial-cluster-state new

$ etcd -name infra2 -initial-advertise-peer-urls http://10.0.1.12:2380 \
  -listen-peer-urls http://10.0.1.12:2380 \
  -initial-cluster-token etcd-cluster-1 \
  -initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  -initial-cluster-state new

在初始化完成后,etcd还提供动态增、删、改etcd集群节点的功能,这个需要用到etcdctl命令进行操作。

(2). etcd自发现模式

通过自发现的方式启动etcd集群需要事先准备一个etcd集群。如果你已经有一个etcd集群,首先你可以执行如下命令设定集群的大小,假设为3.

$ curl -X PUT https://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83/_config/size -d value=3

然后你要把这个url地址https://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83作为 -discovery参数来启动etcd。节点会自动使用https://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83目录进行etcd的注册和发现服务。

所以最终你在某个机器上启动etcd的命令如下。

$ etcd -name infra0 -initial-advertise-peer-urls http://10.0.1.10:2380 \
  -listen-peer-urls http://10.0.1.10:2380 \
  -discovery https://myetcd.local/v2/keys/discovery/6c007a14875d53d9bf0ef5a6fc0257c817f0fb83

如果你在本地没有可用的etcd集群,etcd官网提供了一个可以用公网访问的etcd存储地址。 你可以通过如下命令得到etcd服务的目录,并把它作为-discovery参数使用。

$ curl https://discovery.etcd.io/new?size=3
https://discovery.etcd.io/3e86b59982e49066c5d813af1c2e2579cbf573de

同样的,当你完成了集群的初始化后,这些信息就失去了作用。当你需要增加节点时,需要使用etcdctl来进行操作。

为了安全,在每次启动新的etcd集群时,请务必使用新的discovery token进行注册。 另外,如果你初始化时启动的节点超过了指定的数量,多余的节点会自动转化为proxy模式的etcd。

(3). DNS自发现模式

etcd还支持使用DNS SRV记录进行启动。关于DNS SRV记录如何进行服务发现,可以参阅RFC2782,所以,你要在DNS服务器上进行相应的配置。

  • 开启DNS服务器上SRV记录查询,并添加相应的域名记录,使得查询到的结果类似如下。

    $ dig +noall +answer SRV etcd-server.tcp.example.com etcd-server.tcp.example.com. 300 IN SRV 0 0 2380 infra0.example.com. etcd-server.tcp.example.com. 300 IN SRV 0 0 2380 infra1.example.com. etcd-server.tcp.example.com. 300 IN SRV 0 0 2380 infra2.example.com.

  • 分别为各个域名配置相关的A记录指向etcd核心节点对应的机器IP,使得查询结果类似如下。

    $ dig +noall +answer infra0.example.com infra1.example.com infra2.example.com infra0.example.com. 300 IN A 10.0.1.10 infra1.example.com. 300 IN A 10.0.1.11 infra2.example.com. 300 IN A 10.0.1.12

做好了上述两步DNS的配置,就可以使用DNS启动etcd集群了。配置DNS解析的url参数为-discovery-srv,其中某一个节点地启动命令如下。

$ etcd -name infra0 \
-discovery-srv example.com \
-initial-advertise-peer-urls http://infra0.example.com:2380 \
-initial-cluster-token etcd-cluster-1 \
-initial-cluster-state new \
-advertise-client-urls http://infra0.example.com:2379 \
-listen-client-urls http://infra0.example.com:2379 \
-listen-peer-urls http://infra0.example.com:2380

当然,你也可以直接把节点的域名改成IP来启动。

2.4.2 关键部分源码解析

etcd的启动是从主目录下的main.go开始的,然后进入etcdmain/etcd.go,载入配置参数。如果被配置为proxy模式,则进入startProxy函数,否则进入startEtcd,开启etcd服务模块和http请求处理模块。

在启动http监听时,为了与集群其他etcd机器(peers)保持连接,均采用了transport.NewTimeoutListener启动方式,在超过指定时间没有获得响应时就会出现超时错误。而在监听client请求时,采用的是transport.NewKeepAliveListener,有助于连接的稳定。

etcdmain/etcd.go中的setupCluster函数可以看到,对于不同的etcd参数,启动集群的方法略有不同,但是最终需要的就是一个IP与端口构成的字符串。

在静态配置的启动方式中,集群的所有信息都已经给出,所以直接解析用逗号隔开的集群url信息就好了。

DNS发现的方式与静态配置启动类似,会预先发送一个tcp的SRV请求,先查看etcd-server-ssl._tcp.example.com下是否有集群的域名信息,如果没有找到,则去查看etcd-server._tcp.example.com。根据找到的域名,解析出对应的IP和端口,即集群的url信息。

较为复杂是etcd式的自发现启动。首先用自身单个的url构成一个集群,然后在启动的过程中根据参数进入discovery/discovery.go源码的JoinCluster函数。在启动时使用的etcd的token地址中,包含了集群大小(size)信息,所以集群的启动本质上是一个不断监测与等待的过程。启动的第一步就是在这个借用的etcd的token目录下注册自身的信息,然后再监测token目录下所有节点的数量,如果数量没有达到指定值,则循环等待。否则结束等待,进入后续启动过程。

配置etcd过程中通常要用到两种url地址容易混淆,一种用于etcd集群同步信息并保持连接,通常称为peer-urls;另外一种用于接收用户端发来的HTTP请求,通常称为client-urls。

  • peer-urls:通常监听的端口为2380(老版本使用的端口为7001),包括所有已经在集群中正常工作的所有节点的地址。
  • client-urls:通常监听的端口为2379(老版本使用的端口为4001),为适应复杂的网络环境,新版etcd监听客户端请求的url从原来的1个变为现在可配置的多个。这样etcd就可以配合多块网卡同时监听不同网络下的请求。

2.4.3 运行时节点变更

etcd集群启动完毕后,可以在运行的过程中对集群进行重构,包括核心节点的增加、删除、迁移、替换等。运行时重构使得etcd集群无须重启即可改变集群的配置,这也是新版etcd区别于旧版包含的新特性。

只有当集群中多数节点正常的情况下,你才可以进行运行时的配置管理。因为配置更改的信息也会被etcd当成一个信息存储和同步,如果集群多数节点损坏,集群就失去了写入数据的能力。所以在配置etcd集群数量时,强烈推荐至少配置3个核心节点,配置数目越多,可用性越强。

(1). 节点迁移、替换

当你节点所在的机器出现硬件故障,或者节点出现如数据目录损坏等问题,导致节点永久性的不可恢复时,就需要对节点进行迁移或者替换。当一个节点失效以后,必须尽快修复,因为etcd集群正常运行的必要条件是集群中多数节点都正常工作。

迁移一个节点需要进行四步操作:

  • 暂停正在运行着的节点程序进程
  • 把数据目录从现有机器拷贝到新机器
  • 使用api更新etcd中对应节点指向机器的url记录更新为新机器的ip
  • 使用同样的配置项和数据目录,在新的机器上启动etcd。
(2). 节点增加

增加节点可以让etcd的高可用性更强。举例来说,如果你有3个节点,那么最多允许1个节点失效;当你有5个节点时,就可以允许有2个节点失效。同时,增加节点还可以让etcd集群具有更好的读性能。因为etcd的节点都是实时同步的,每个节点上都存储了所有的信息,所以增加节点可以从整体上提升读的吞吐量。

增加一个节点需要进行两步操作:

  • 在集群中添加这个节点的url记录,同时获得集群的信息。
  • 使用获得的集群信息启动新etcd节点。
(3). 节点移除

有时你不得不在提高etcd的写性能和增加集群高可用性上进行权衡。Leader节点在提交一个写记录时,会把这个消息同步到每个节点上,当得到多数节点的同意反馈后,才会真正写入数据。所以节点越多,写入性能越差。在节点过多时,你可能需要移除其中的一个或多个。 移除节点非常简单,只需要一步操作,就是把集群中这个节点的记录删除,则对应机器上的该节点就会自动停止。

(4). 强制性重启集群

当集群超过半数的节点都失效时,就需要通过手动的方式,强制性让某个节点以自己为Leader,利用原有数据启动一个新集群。

此时你需要进行两步操作。

  • 备份原有数据到新机器。
  • 使用-force-new-cluster和备份的数据重新启动节点

注意:强制性重启是一个迫不得已的选择,它会破坏一致性协议保证的安全性(如果操作时集群中尚有其它节点在正常工作,就会出错),所以在操作前请务必要保存好数据。

2.5 Proxy模式

Proxy模式也是新版etcd的一个重要变更,etcd作为一个反向代理把客户的请求转发给可用的etcd集群。这样,你就可以在每一台机器都部署一个Proxy模式的etcd作为本地服务,如果这些etcd Proxy都能正常运行,那么你的服务发现必然是稳定可靠的。

enter image description here

图11 Proxy模式示意图

所以Proxy并不是直接加入到符合强一致性的etcd集群中,也同样的,Proxy并没有增加集群的可靠性,当然也没有降低集群的写入性能。

那么,为什么要有Proxy模式而不是直接增加etcd核心节点呢?实际上,etcd每增加一个核心节点(peer),都会给Leader节点增加一定程度的负担(包括网络、CPU和磁盘负载),因为每次信息的变化都需要进行同步备份。增加etcd的核心节点固然可以让整个集群具有更高的可靠性,但是当其数量达到一定程度以后,增强可靠性带来的好处就变得不那么明显,反倒是降低了集群写入同步的性能。因此,增加一个轻量级的Proxy模式etcd节点是对直接增加etcd核心节点的一个有效代替。

熟悉0.4.6这个旧版本etcd的用户会发现,Proxy模式实际上取代了原先的Standby模式。Standby模式具备转发代理的功能。此外,在核心节点因为故障导致数量不足时,还会从Standby模式转为核心节点。而当故障节点恢复时,若etcd的核心节点数量已经达到预设值,则前述节点会再次转为Standby模式。

但是在新版etcd中,只在最初启动etcd集群的过程中,若核心节点的数量已经满足要求,自动启用Proxy模式,反之则并未实现。主要原因如下。

  • etcd是用来保证高可用的组件,因此它所需要的系统资源(包括内存、硬盘和CPU等)都应该得到充分保障。任由集群的自动变换随意地改变核心节点,无法让机器保证性能。所以etcd官方鼓励大家在大型集群中为运行etcd准备专有机器集群。
  • 因为etcd集群是支持高可用的,部分机器故障并不会导致功能失效。所以在机器发生故障时,管理员有充分的时间对机器进行检查和修复。
  • 自动转换使得etcd集群变得更为复杂,尤其是在如今etcd支持多种网络环境的监听和交互的情况下。在不同网络间进行转换,更容易发生错误,导致集群不稳定。

基于上述原因,目前Proxy模式有转发代理功能,而不会进行角色转换。

关键部分源码解析

从代码中可以看到,Proxy模式的本质就是起一个http代理服务器,把客户发到这个服务器的请求转发给别的etcd节点。

etcd目前支持读写皆可和只读两种模式。默认情况下是读写皆可,就是把读、写两种请求都进行转发。而只读模式只转发读的请求,对所有其他请求返回501错误。

值得注意的是,在etcd集群化启动时,除了因为设置proxy参数作为Proxy模式启动之外,如果节点注册自身信息的时候监测到集群的实际节点数量已经符合要求,那么也会退化为Proxy模式。

2.6 数据存储

etcd的存储分为内存存储和持久化(硬盘)存储两部分,内存中的存储除了顺序化地记录下所有用户对节点数据变更的记录外,还会对用户数据进行索引、建堆等方便查询的操作。而持久化则使用预写式日志(WAL:Write Ahead Log)进行记录存储。

在WAL的体系中,所有的数据在提交之前都会进行日志记录。在etcd的持久化存储目录中,有两个子目录。一个是WAL,存储着所有事务的变化记录;另一个则是snapshot,用于存储某一个时刻etcd所有目录的数据。通过WAL和snapshot相结合的方式,etcd可以有效地进行数据存储和节点故障恢复等操作。

也许你会有这样的疑问,既然已经在WAL实时存储了所有的变更,为什么还需要snapshot呢?原因是这样的,随着使用量的增加,WAL存储的数据会急剧增加,为了防止磁盘空间不足,etcd默认每10000条记录做一次snapshot,经过snapshot以后的WAL文件就可以删除。通过API可以查询的历史etcd操作默认为1000条。

首次启动时,etcd会把启动的配置信息存储到data-dir参数指定的数据目录中。配置信息包括本地节点ID、集群ID和初始时集群信息。用户需要避免etcd从一个过期的数据目录中重新启动,因为使用过期的数据目录启动的节点会与集群中的其他节点产生不一致(如:之前已经记录并同意Leader节点存储某个信息,重启后又向Leader节点申请这个信息)。所以,为了最大化集群的安全性,一旦有任何数据损坏或丢失的可能性,你就应该把这个节点从集群中移除,然后加入一个不带数据目录的新节点。

(1)预写式日志(WAL)

WAL最大的作用是记录了整个数据变化的全部历程。在etcd中,所有数据的修改在提交前,都要先写入到WAL中。使用WAL进行数据的存储使得etcd拥有两个重要功能。

  • 故障快速恢复: 当你的数据遭到破坏时,就可以通过执行所有WAL中记录的修改操作,快速从最原始的数据恢复到数据损坏前的状态。
  • 数据回滚(undo)/重做(redo):因为所有的修改操作都被记录在WAL中,在需要回滚或重做时,只需要反向或正向执行日志中的操作即可。
WAL与snapshot在etcd中的命名规则

在etcd的数据目录中,WAL文件以$seq-$index.wal的格式存储。最初始的WAL文件是0000000000000000-0000000000000000.wal,表示是所有WAL文件中的第0个,初始的Raft状态编号为0。运行一段时间后可能需要进行日志切分,把新的条目放到一个新的WAL文件中。

假设,当集群运行到Raft状态为20,需要进行WAL文件的切分时,则下一份WAL文件就会变为0000000000000001-0000000000000021.wal。如果在10次操作后又进行了一次日志切分,那么后一次的WAL文件名会变为0000000000000002-0000000000000031.wal。可以看到-符号前面的数字是每次切分后自增1,而-符号后面的数字则是根据实际存储的Raft起始状态来定。

snapshot的存储命名则比较容易理解,以$term-$index.wal格式进行命名存储。term和index就表示存储snapshot时数据所在的Raft节点状态,当前的任期编号以及数据项位置信息。

(2) 关键部分源码解析

从代码逻辑中可以看到,WAL有两种模式,读(read)模式和数据添加(append)模式,两者是互斥的。一个新创建的WAL文件处于append模式,并且不会进入到read模式。一个本来存在的WAL文件被打开的时候必然是read模式,只有在所有记录都被读完的时候,才能进入append模式,进入append模式后也不会再进入read模式。这样做有助于保证数据的完整与准确。

集群在进入到etcdserver/server.goNewServer函数准备启动一个etcd节点时,会检测是否存在以前的遗留WAL数据。

etcd从v0.4.6升级到v2.0.0,它数据格式存储的格式也变化了。检测的第一步是查看snapshot文件夹下是否有符合规范的文件,若检测到snapshot格式是v0.4的,则调用函数升级到v0.5。从snapshot中获得集群的配置信息,包括token、其他节点的信息等等,然后载入WAL目录的内容,从小到大进行排序。根据snapshot中得到的term和index,找到WAL紧接着snapshot下一条的记录,然后向后更新,直到所有WAL包的entry都已经遍历完毕,Entry记录到ents变量中存储在内存里。此时WAL就进入append模式,为数据项添加进行准备。

当WAL文件中数据项内容过大达到设定值(默认为10000)时,会进行WAL的切分,同时进行snapshot操作。这个过程可以在etcdserver/server.gosnapshot函数中看到。所以,实际上数据目录中有用的snapshot和WAL文件各只有一个,默认情况下etcd会各保留5个历史文件。

2.7 Raft

新版Etcd中,Raft包就是对Raft一致性算法的具体实现。关于Raft算法的讲解,网上已经有很多文章,有兴趣的读者可以去阅读一下Raft算法论文,非常精彩。本文不再对Raft算法进行详细描述,而是结合etcd,针对算法中一些关键内容以问答的形式进行讲解。Raft算法的相关术语参见概念词汇表一节。

(1) Raft常见问答一览

  • Raft中一个Term(任期)是什么意思? 在Raft算法中,从时间上讲,一个任期即从某一次竞选开始到下一次竞选开始。从功能上讲,如果Follower接收不到Leader节点的心跳信息,就会结束当前任期,变为Candidate发起竞选,有助于Leader节点故障时集群的恢复。 发起竞选投票时,任期值小的节点不会竞选成功。如果集群不出现故障,那么一个任期将无限延续下去。而投票出现冲突则有可能直接进入下一任再次竞选。

enter image description here 图12 Term示意图

  • Raft状态机是怎样切换的? Raft刚开始运行时,节点默认进入Follower状态,等待Leader发来心跳信息。若等待超时,则状态由Follower切换到Candidate进入下一轮Term发起竞选,等到收到集群多数节点的投票时,该节点转变为Leader。Leader节点有可能出现网络等故障,导致别的节点发起投票成为新Term的Leader,此时原先的老Leader节点会切换为Follower。Candidate在等待其它节点投票的过程中如果发现别的节点已经竞选成功成为Leader了,也会切换为Follower节点。

enter image description here

图13 Raft状态机

  • 如何保证最短时间内竞选出Leader,防止竞选冲突? 在Raft状态机一图中可以看到,在Candidate状态下, 有一个times out,这里的times out时间是个随机值,也就是说,每个机器成为Candidate以后,超时发起新一轮竞选的时间是各不相同的,这就会出现一个时间差。在时间差内,如果Candidate1收到的竞选信息比自己发起的竞选信息Term值大(即对方为新一轮Term),并且新一轮想要成为Leader的Candidate2包含了所有提交的数据,那么Candidate1就会投票给Candidate2。这样就保证了只有很小的概率会出现竞选冲突。

  • 如何防止别的Candidate在遗漏部分数据的情况下发起投票成为Leader? Raft竞选的机制中,使用随机值决定超时时间,第一个超时的节点就会提升Term编号发起新一轮投票,一般情况下别的节点收到竞选通知就会投票。但是,如果发起竞选的节点在上一个Term中保存的已提交数据不完整,节点就会拒绝投票给它。通过这种机制就可以防止遗漏数据的节点成为Leader。

  • Raft某个节点宕机后会如何? 通常情况下,如果是Follower节点宕机,且剩余可用节点数量超过总节点数的一半,集群可以几乎不受影响地正常工作。如果是Leader节点宕机,那么Follower节点会因为收不到心跳而超时,发起竞选获得投票,成为新一轮Term的Leader,继续为集群提供服务。需要注意的是;etcd目前没有任何机制会自动去变化整个集群的总节点数量,即如果没有人为地调用API,etcd宕机后的节点仍然被计算在总节点数中,任何请求被确认需要获得的投票数都是这个总数的一半以上。

enter image description here 图14 节点宕机

  • 为什么Raft算法在确定可用节点数量时不需要考虑拜占庭将军问题? 拜占庭将军问题中提出,允许n个节点宕机还能提供正常服务的分布式架构,需要的总节点数量为3n+1,而Raft只需要2n+1就可以了。其主要原因在于,拜占庭将军问题中存在数据欺骗的现象,而etcd中假设所有的节点都是诚实的。etcd在竞选前需要告诉别的节点自身的Term编号以及前一轮Term最终结束时的index值,这些数据都是准确的,其他节点可以根据这些值决定是否投票。另外,etcd严格限制Leader到Follower这样的数据流向保证数据一致不会出错。

  • 用户从集群中哪个节点读写数据? Raft为了保证数据的强一致性,所有的数据流向都是一个方向,从Leader流向Follower,即所有Follower的数据必须与Leader保持一致,如果不一致则会被覆盖。也就是说,所有用户更新数据的请求都最先由Leader获得并保存下来,然后通知其他节点将其保存,等到大多数节点反馈时再把数据提交。一个已提交的数据项才是Raft真正稳定存储下来的数据项,不再被修改,最后再把提交的数据同步给其他Follower。因为每个节点都有Raft已提交数据准确的备份(最坏的情况也只是已提交数据还未完全同步),所以任何一个节点都可以处理读请求。

  • etcd实现的Raft算法性能如何? 单实例节点支持每秒1000次数据写入。随着节点数目的增加,数据同步会因为网络延迟越来越慢;而读性能则会随之提升,因为每个节点都能处理用户的读请求。

(2) 关键部分源码解析

在etcd代码中,Node作为Raft状态机的具体实现,是整个算法的关键,也是了解算法的入口。

在etcd中,对Raft算法的调用如下,你可以在etcdserver/raft.go中的startNode找到:

storage := raft.NewMemoryStorage()
n := raft.StartNode(0x01, []int64{0x02, 0x03}, 3, 1, storage)

通过这段代码可以了解到,Raft在运行过程记录数据和状态都是保存在内存中,而代码中raft.StartNode启动的Node就是Raft状态机Node。启动了一个Node节点后,Raft会做如下事项。

首先,你需要把从集群的其他机器上收到的信息推送到Node节点,你可以在etcdserver/server.go中的Process函数看到。

func (s *EtcdServer) Process(ctx context.Context, m raftpb.Message) error {
  if m.Type == raftpb.MsgApp {
    s.stats.RecvAppendReq(types.ID(m.From).String(), m.Size())
  }
  return s.node.Step(ctx, m)
}

检测发来请求的机器是否是集群中的节点,自身节点是否是Follower,把发来请求的机器作为Leader,具体对Node节点信息的推送和处理则通过node.Step()函数实现。

其次,你需要把日志项存储起来,在你的应用中执行提交的日志项,然后把完成信号发送给集群中的其它节点,再通过node.Ready()监听等待下一次任务执行。有一点非常重要,你必须确保在你发送完成消息给其他节点之前,你的日志项内容已经确切稳定地存储下来了。

最后,你需要保持一个心跳信号Tick()。Raft有两个很重要的地方用到超时机制:心跳保持和Leader竞选。需要用户在其Raft的Node节点上周期性地调用Tick()函数,以便为超时机制服务。

综上所述,整个Raft节点的状态机循环类似如下所示:

for {
    select {
    case <-s.Ticker:
        n.Tick()
    case rd := <-s.Node.Ready():
        saveToStorage(rd.State, rd.Entries)
        send(rd.Messages)
        process(rd.CommittedEntries)
        s.Node.Advance()
    case <-s.done:
        return
    }
}

而这个状态机真实存在的代码位置为etcdserver/server.go中的run函数。

对状态机进行状态变更(如用户数据更新等)时将调用n.Propose(ctx, data)函数,在存储数据时,会先进行序列化操作。获得大多数其他节点的确认后,数据会被提交,保存为已提交状态。

之前提到etcd集群的启动如果使用自发现方式,需要借助别的etcd集群或者DNS,而启动完毕后这些外力就不需要了。etcd会把自身集群的信息作为状态存储起来。所以要变更自身集群节点数量实际上也需要像用户数据变更那样添加数据条目到Raft状态机中。上述功能由n.ProposeConfChange(ctx, cc)实现。当集群配置信息变更的请求同样得到大多数节点的确认反馈后,再进行配置变更的正式操作,代码如下。

var cc raftpb.ConfChange
cc.Unmarshal(data)
n.ApplyConfChange(cc)

注意:为了避免不同etcd集群消息混乱,ID需要确保唯一性,不能重复使用旧的token数据作为ID。

2.8 Store

顾名思义,Store这个模块就像一个商店一样把etcd已经准备好的各项底层支持加工起来,为用户提供五花八门的API支持,处理用户的各项请求。要理解Store,就要从etcd的API入手。打开etcd的API列表,我们可以看到如下API,均系对etcd存储的键值进行的操作,亦即Store提供的内容。API中提到的目录(Directory)和键(Key),上文中也可能称为etcd节点(Node)。

  • 为etcd存储的键赋值

      curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="Hello world"
      {
          "action": "set",
          "node": {
              "createdIndex": 2,
              "key": "/message",
              "modifiedIndex": 2,
              "value": "Hello world"
          }
      }
    

反馈的内容含义如下:

  • action: 刚刚进行的动作名称。
  • node.key: 请求的HTTP路径。etcd使用一个类似文件系统的方式来反映键值存储的内容。
  • node.value: 刚刚请求的键所存储的内容。
  • node.createdIndex: etcd节点每次发生变化时,该值会自动增加。除了用户请求外,etcd内部运行(如启动、集群信息变化等)也可能会因为对节点有变动而引起该值的变化。
  • node.modifiedIndex: 类似node.createdIndex,能引起modifiedIndex变化的操作包括set, delete, update, create, compareAndSwap and compareAndDelete。

  • 查询etcd某个键存储的值

      curl http://127.0.0.1:2379/v2/keys/message
    
  • 修改键值:与创建新值几乎相同,但是反馈时会有一个prevNode值反应了修改前存储的内容。

      curl http://127.0.0.1:2379/v2/keys/message -XPUT -d value="Hello etcd"
    
  • 删除一个值

      curl http://127.0.0.1:2379/v2/keys/message -XDELETE
    
  • 对一个键进行定时删除:etcd中对键进行定时删除,设定一个ttl值,当这个值到期时键就会被删除。反馈的内容会给出expiration项告知超时时间,ttl项告知设定的时长。

      curl http://127.0.0.1:2379/v2/keys/foo -XPUT -d value=bar -d ttl=5
    
  • 取消定时删除任务

      curl http://127.0.0.1:2379/v2/keys/foo -XPUT -d value=bar -d ttl= -d prevExist=true
    
  • 对键值修改进行监控:etcd提供的这个API让用户可以监控一个值或者递归式地监控一个目录及其子目录的值,当目录或值发生变化时,etcd会主动通知。

      curl http://127.0.0.1:2379/v2/keys/foo?wait=true
    
  • 对过去的键值操作进行查询:类似上面提到的监控,在其基础上指定过去某次修改的索引编号,就可以查询历史操作。默认可查询的历史记录为1000条。

      curl 'http://127.0.0.1:2379/v2/keys/foo?wait=true&waitIndex=7'
    
  • 自动在目录下创建有序键。在对创建的目录使用POST参数,会自动在该目录下创建一个以createdIndex值为键的值,这样就相当于根据创建时间的先后进行了严格排序。该API对分布式队列这类场景非常有用。

      curl http://127.0.0.1:2379/v2/keys/queue -XPOST -d value=Job1
      {
          "action": "create",
          "node": {
              "createdIndex": 6,
              "key": "/queue/6",
              "modifiedIndex": 6,
              "value": "Job1"
          }
      }
    
  • 按顺序列出所有创建的有序键。

      curl -s 'http://127.0.0.1:2379/v2/keys/queue?recursive=true&sorted=true'
    
  • 创建定时删除的目录:就跟定时删除某个键类似。如果目录因为超时被删除了,其下的所有内容也自动超时删除。

      curl http://127.0.0.1:2379/v2/keys/dir -XPUT -d ttl=30 -d dir=true
    
  • 刷新超时时间。

      curl http://127.0.0.1:2379/v2/keys/dir -XPUT -d ttl=30 -d dir=true -d prevExist=true
    
  • 自动化CAS(Compare-and-Swap)操作:etcd强一致性最直观的表现就是这个API,通过设定条件,阻止节点二次创建或修改。即用户的指令被执行当且仅当CAS的条件成立。条件有以下几个。

    • prevValue 先前节点的值,如果值与提供的值相同才允许操作。
    • prevIndex 先前节点的编号,编号与提供的校验编号相同才允许操作。
    • prevExist 先前节点是否存在。如果存在则不允许操作。这个常常被用于分布式锁的唯一获取。

假设先进行了如下操作:设定了foo的值。 curl http://127.0.0.1:2379/v2/keys/foo -XPUT -d value=one然后再进行操作:curl http://127.0.0.1:2379/v2/keys/foo?prevExist=false -XPUT -d value=three就会返回创建失败的错误。

  • 条件删除(Compare-and-Delete):与CAS类似,条件成立后才能删除。

  • 创建目录

      curl http://127.0.0.1:2379/v2/keys/dir -XPUT -d dir=true
    
  • 列出目录下所有的节点信息,最后以/结尾。还可以通过recursive参数递归列出所有子目录信息。

      curl http://127.0.0.1:2379/v2/keys/
    
  • 删除目录:默认情况下只允许删除空目录,如果要删除有内容的目录需要加上recursive=true参数。

      curl 'http://127.0.0.1:2379/v2/keys/foo_dir?dir=true' -XDELETE
    
  • 创建一个隐藏节点:命名时名字以下划线_开头默认就是隐藏键。

      curl http://127.0.0.1:2379/v2/keys/_message -XPUT -d value="Hello hidden world"
    

相信看完这么多API,相信读者已经对Store的工作内容有了基本的了解。它对etcd下存储的数据进行加工,创建出如文件系统般的树状结构供用户快速查询。它有一个Watcher用于节点变更的实时反馈,还需要维护一个WatcherHub对所有Watcher订阅者进行通知的推送。同时,它还维护了一个由定时键构成的小顶堆,快速返回下一个要超时的键。最后,所有这些API的请求都以事件的形式存储在事件队列中等待处理。

3 总结

通过从应用场景到源码分析的一系列回顾,我们了解到etcd并不是一个简单的分布式键值存储系统。它解决了分布式场景中最为常见的一致性问题,为服务发现提供了一个稳定高可用的消息注册仓库,为以微服务协同工作的架构提供了无限的可能。相信在不久的将来,通过etcd构建起来的大型系统会越来越多。

4 参考文献

我的二零一四

| Comments

说起来2014就这么过去了,站在年月的末尾回望这一年传说中的本命,真是有点伤感。娄老师在QQ空间里说:“QQ上都是老朋友了,所以可以放心的把自己细致的像日记一样的计划列出来让大家看,哪怕完不成也不怕被笑,反倒可以起个监督的效果”。我觉得这个想法也同样适用于我,那么就对着年初的计划讲讲这一年吧。

概览

  1. 博客,每月至少1篇(没完成,六月份竟然没写,有兴趣自行前往: http://wonderflow.info%EF%BC%89

  2. 书籍阅读四十本(大约完成34本)

  3. 编程语言:ruby(半吊子)、python(这一年压根没碰)、shell(算是熟练了不少)。

  4. 技术:Hadoop(了解)、nginx、haproxy(会用)、linux底层(熟悉了一些)、网络(基本没有进步,依旧不好意思说自己本科学的是网络)、安全、设计模式(这两项压根没碰)

  5. 实验室:项目上线->ccng源码->自动化部署(CFv170)->高可用&性能测试->Openstack.Heat->项目上线->{多端口设计、etcd、yarn}

  6. 意外收获:运动习惯,跑步、游泳、篮球。

推荐阅读的书籍

  • 《暗时间》:有关思维、心理学、互联网知识的书籍

  • 《在难搞的日子笑出声来》:“屌丝男士”、“大鹏嘚不嘚”创作者大鹏的故事,我写过专门的推荐文章。

  • 《不要因为走的太远而忘了为什么出发》:陈虻的经典语录以及生平故事,有点人物传记的意思。其实是因为柴静的《看见》火起来的书,但真的挺好看的。

  • 《Mactalk人生元编程》:池老板教你用mac的同时讲了很多故事,也讲了很多道理,读来轻松也有用。

  • 《大数据时代》:描绘大数据时代的一本概述。很多人不是都不知道大数据到底说的是什么么,这本书就可以看看。

  • 《活着》:这是一本极为好看、震撼的书。

  • 《尘埃落定》:阿来写的藏族人民中,最后一代土司的历史。同时还有人性、亲情,最终是对人的尊重。

  • 《三体》:宏伟瑰丽的科幻小说,硬科幻,非常好看。

  • 《周鸿祎自述——我的互联网方法论》:周老板讲得360战略,讲互联网公司的方法论,讲互联网的免费战略,通俗易懂,值得一读。

  • 《经济学原理》:曼昆写的经济学原理,正如书背面的评论所述,原来学经济学也可以很有趣。

  • 《追风筝的人》:一本关于亲情,友情,以及自我救赎的故事,以阿富汗人的灾难为背景,感人至深。

  • more(完整书单)

有人说:“读书是为数不多的能自己给自己找到快乐的方式之一,是一件私人的事情。”我深表赞同,所以大家无聊的时候可以看看书。以前无聊的时候特别喜欢看没有营养的YY小说,这一年很少看了,因为找不到好看的O(∩_∩)O~。

这一节,我要特别鸣谢一下我的室友狄天,这个一年读一百本书(据他说这个数字是往少了算的)的家伙实在是太容易激起男人的好胜欲望,让人忍不住为了尊严多读几本,不至于差的太远。

实验室&&工作

那天丁老板在docker meetup上说的第一句话就是:“我们实验室不是搞着玩,我们是正规军。”所以我们是一个微妙的实验室,有点像公司,但更多的还是实验室。

很幸运今年十月份,实验室从三墩搬回了玉泉,让我们免去了每天两个小时在路上的时间。那天我兴致勃勃的在班车上跟李老师说到这个事情,他就说:“哈哈,估计也就让你们可以多睡两个小时。”当时我不以为然,后来发现果不其然……

不过晚上确实多了很多时间呆在实验室,看书也好,干活也好。比起以前一回来食堂就没菜了,呆在宿舍就不想学习的状态,真是好了太多。

实验室研究的方向也一直不断的在变化,从专注于cloudfoundry,到随着业务和开源热度,开始跟openstack、docker、hadoop相结合,逐渐看到一个更广阔的世界。

宏亮师兄就常常跟我们说:“要走出去多跟人交流,不要被一个小圈子给局限住,从一个跟高维度去思考自己做的事情跟别人做的事情的价值、区别在哪里,非常有必要。”师兄还说:“李老师一开始就跟我们说,不要局限于cloudfoundry,它不是全盘被业界接受的,必然有它不完美的地方。当时我们又不以为然,后来发现还是自己太浅薄。”哈哈,充分说明我们师兄弟们都以有李老师这个导师感到自豪啊。

不过话又说回来,平时自己思考的确实太少,碰到事情就是做一个最初的好坏判定就应承下来,没有丝毫自己的思考和理解。就像编译器里面的条件判定,凡是值不为零的都为真,值是零的时候才是假。但是放在为人处世上,一个值是一还是十,都是截然不同的,生活不是简单的真假判定,需要我们更细腻的处理。

记得去年七月初刚来实验室的时候,磊哥就去百度实习了,持续了将近三个月,回来的时候就忙毕设了,一直觉得很遗憾,没有机会向磊哥学习了。谁知磊哥后来竟然留校了,真是有缘,就这样在这一年里接受磊哥潜移默化的指导。

我想这一年里,在技术学习方面,最要感谢的就是磊哥与亮哥两位学长了。

饮食习惯&&生活节奏

说到吃饭,今年的饮食习惯真是发生了翻天覆地的变化。我不像俊哥,每天都要为了第二天吃什么而忧愁半天。所以我可以每天吃一碗葱油拌面加一包豆浆,就这样一直吃半年。

自从有一天从小明那知道了一食堂还有萝卜丝饼这个东西,勾起了我儿时的回忆。然后我又吃了半年萝卜丝饼加白粥的组合。现在想起来都觉得百吃不厌,回味无穷啊。

身在鱼米之乡,家里经常吃鱼,但是每次看到怡膳堂千奇百怪的小鱼,就觉得没有胃口,所以从来没有吃过。但是有一天,在思玫的推荐下进行了第一次尝试,味道真心不错,从此以后我就开始顿顿吃鱼,又这样过了快半年。由此可见,我们不但不能以貌取人,更应该勇于尝试。

晚上就在磊哥的鼓动下天天吃小乐惠地道的川菜,吃起来同样是回味无穷。

这一年的生活节奏基本上也随着实验室(项目组)的搬迁一分为二。从每天早晨被闹钟吵醒匆匆忙忙赶班车,到每天睡到自然醒。从每天晚上回来吃留食,把留食所有的菜都尝了一遍直到吃腻,宁愿去怡膳堂吃剩菜剩饭,到上文所述的可以对怡膳堂的食物挑三拣四,晚上去个小乐惠还能有剩菜。生活质量和幸福指数明显有质的提升啊!

那这一章应该感谢谁呢?想来想去觉得搬迁这个事情实属多方巧合所致,哈哈,还是感谢李老师吧!

运动

我想,2014年最让我高兴而意外的一件事情,就是养成了运动的习惯。

是的,我爱上了跑步,不管那一天是星光璀璨、月光闪亮,还是迷雾丛生、树影憧憧,只要是我能肆意奔跑的夜晚,我都喜欢。跑着跑着就仿佛进入了诗意的世界,其实更多的是觉得自己在一点点变强,在生活中也充满了活力。那时意识到跑步时自己与内心的对话,彷如宗教似的自省,在这个过程中慢慢认识到自己生活中的界限和不足,更清醒地认识到了自我,是和日常生活的一种有效隔离。

我还爱上了游泳,虽然泳姿很差,还被戏称为黄龙水怪,但是这些都丝毫阻挡不了我在水中自由舒展的意志。游泳的时候,因为大脑缺氧,其实能思考的时候不多。但是那种摆脱重力束缚,整个人变得轻盈的感觉,特别令人感到畅快。

还有一项运动就是打篮球,其实我球技很差,至今不会运球。但是和亲爱的兄弟们一起聊聊天,出出汗,这种感觉真是妙处难于人说啊。

说起来,跑步跟读书一样,其实是非常私人的一件事情,毕竟大家步伐不一,跑得旅程也不尽相同,让我有时候都会恍惚觉得,人生其实就是一场马拉松,好多人停停走走、分分合合,能遇到一起,走过一程就是缘分,过了这一站,又会有不同的风景。但是更多的时候,你只能独自面对。

但是我感到我很幸运,当我想要跑步的时候,我竟然能在周围找到两个跑完全程马拉松的选手一起,敏献和凌峰。尤其是高凌峰,竟然已经这样高强度运动了四五年,所以我并不孤单。

我非常感激他们,以及陪我一起打球游泳的小伙伴们,生命因运动而精彩。

尾声·大闲话

其实自从开启了运动模式以后,有很长一阵子没有好好写日志了。运动虽好,但是所耗的时间却也是实实在在的。在这2014与2015新旧交替之际,就该说几句温暖的话来辞旧迎新。比如今年见证了两个特别要好朋友,认识了快一年多了,兜兜转转,终于走到了一起。那天我走在路上,无意中聊天。我先起了个头:

“晚上有什么活动啊?” “她刚从家里回来,准备去火车站接她。” “哈哈,你小子可以啊,怎么不索性直接坐火车到人家家里去接啊?” “我倒是想啊,人家不让啊!”

哈哈,这个故事不知大家觉得够不够温暖?

一年就这么过去了,其实有欢喜就有悲伤,不过大多数都是别人的故事。以前只有在电视剧里才能看到的桥段,仿佛离自己还很远的时候,就在某些天突如其来,接踵而至,其实会有种哭笑不得的感觉。

好在我自己的生活一直是波澜不惊。然后我想起前些天在朋友圈无意中看到的句子:“只要活着,总会有好事发生。”可见平淡也不一定是坏事。转念一想,能不嫌我啰嗦,耐住寂寞,看到这里的朋友,也一定能理解平淡中的快乐,我祝愿你们在新的一年,在平平淡淡中获得属于自己的幸福。

就在我们走走停停、忙忙碌碌的过程中,研究生生活已经过去了一大半。如果这个时候给我一次,让我告诉两年前的自己,到底要不要选择读研?那我可以非常坚定的告诉我自己,当然要读啊。如果非要问个为什么?其实我也说不清楚。

我只知道,我遇到好多事,他们让我想要走遍大山大海,阅尽红尘万卷;也遇到许多人,她们来来往往,让我对醒来的每个明天都充满了期待,也同样把温暖渗透到我的每个明天里。

Cf-release结构解析

| Comments

1. 制作时的cf-release结构解析

此处指的release统一为CloudFoundry官方给出的cf-release,不做修改。

1.1. 通过载入cf-release文件夹下config/final.yml文件,获得需要下载release文件的远程服务器网址,默认使用的提供商是s3,地址是:blob.cfblob.com

link

1.2. 通过config/blobs.yml,可以得到所有blobs的object_id,通过服务器地址+object_id拼接的字符串即可下载到相对应的blob内容。

1.3. 默认存储的位置为cf-release/.blobs,存储的文件名为sha1值,下载完成后会在cf-release/blobs文件夹下创建以package真实名字命名的软链接到.blobs里面各个具体的包。

未来的某一天——普适计算展望小作业

| Comments

今天突然翻邮箱,发现一年前修读“普适计算”课程的时候还写过这么一篇小东西,看看还蛮有趣的,就发出来跟大家分享。以下是正文:

那天下午,小赵在普适计算的课上,迷迷糊糊的打着盹就掉进了梦乡。在梦里,他仿佛又置身在不久前,某次见导师的忐忑情景中。

“潘老师来电,赵先生,请问您是否接听!”“潘老师来电,请问……”一个陌生的声音在身边响起,可是办公室里一个人也没有。再仔细一听,声音竟然是从办公室某个角落的音响里传来的。就在那个声音越来越微弱快要停下的时候,小赵忙喊道:“接听!”

“喂,小赵啊,都已经到了啊,稍微等一下,我也很快就到了。”耳边又传来导师那熟悉的声音。小赵想起来,今天是来给导师介绍他们公司最新研制出的一款产品的,所以就约着见个面。

墙上的石英钟显示着时间已经进入到了下午一点,背景的纹理竟然若隐若现的显示着2038 这样的数字表示年份,原来恍惚间已经过去了二十五年。小赵手腕上的一块类似手表的设备,呼吸灯在其外侧一闪一闪,仿若心脏的跳动。

而刚刚那一幕其实是包含这近二十年来智慧的集体产物,每个人根据手腕上戴着的发射器,就可以使用大量的公共设施,比如通讯,在所有通讯运营商网络覆盖的范围下,任何一个如音响这样的可输入输出的设备都被装上了一块可以接入网络的芯片。同时在云端可以智能识别你的语音输入,并作出反馈。

而语音识别这个技术,因为二十年前一个伟大的心理学家的参与,获得了巨大的突破。他根据人类的行为心理学,结合计算机对大量统计信息的数据挖掘,设计出一套神奇的算法。目前,哪怕你在使用方言讲话,也能有 95%的识别率与正确响应率。并且这项突破顺带解决了困扰大家已久的智能反馈功能,行为心理学的这套算法设计的最初本身就是根据预想输入者想要得到的反馈,综合根据统计学上的大量反馈做出的大概率猜测,所以反馈的信息也能轻而易举的通过这个算法获得,从而使智能机器人领域也得到了飞速发展。

“来,小赵,怎么站着啊,快坐快坐。”正在小赵思索的时候,潘老师到了。

“刚刚电子管家告诉我你已经在了,那时候我就快到了。”

“嗯,电子管家确实很方便!”小赵应道。

记得十几年前,在开车的时候接电话特别不方便,现在有了电子管家,还是之前提到的技术。通过车上的输入输出设备,电子管家智能的帮你监控各方面的信息,包括家庭、办公室以及路面交通情况。就像一个真实的管家无时无刻都在你身边跟你汇报情况,帮你执行命令一样。

“今天天气有点热啊!”刚赶回来的潘老师头上略有点冒汗。当这个热字刚说完的时候,旁边的空调打开了,一股清凉的微风扑面而来。

在这个时代,物联网的精神已经被彻底渗透到方方面面,电子管家就是负责识别人们在交谈中所发布的一系列命令,并且去执行的人。当一切都接入网络,并且有了一套超强的语音识别匹配算法以后,原来很多不可思议事情都变得那么理所当然。

“潘老师,这次我来给您介绍的产品是这样的。”说着,小赵掏出了三个玻璃珠子类似的玩意,往地上随手一抛。三个珠子在三个不同的方位向中央发射出了三道光芒,瞬间立体的影像就铺了开来。小赵站在中间,手触碰到了影像的一份文件上,向右做出一个摊手的姿势,文件就在中间放大打开了。

“这三个珠子是集量子计算机、人工智能与普适计算之大成的作品。是我们‘和平鸽’最新研发出来的产品。”小赵自豪的说。

“和平鸽”公司是十几年前,苹果和谷歌两大公司合并后的产物,他们立要打造改变世界格局的产品,之前的电子管家物联网系统就是这个公司开发的。公司因为这项技术,成为了二十一世纪以来最大的集硬件与互联网融合之大成的物联网公司。

“通过三点互相之间传感器的感应,定位空间。同时根据附带在珠子上的录像设备,识别人的动作,在量子计算机的超强计算能力之下,把人的动作模拟到正式的三维世界中,并且根据模拟后的结果,反馈到现实中的 3D 影像中,备受人们的喜爱。这个神奇的产品也因为其充满了科幻色彩而得到了一个科幻小说的名字——三体。”小赵接着说道。

“潘老师,您可以走进来点看的更清楚”。正在潘老师走进影像所构筑的立体世界的一瞬间,影像因为自检,出现了一瞬间的波动。“三体的神奇之处就在于,他不像普通的影像会因为人的介入而出现阴影。它有三个发光体,它也是交互式的,当人物体进入时,它会把物体加入到自己的计算模型中进行计算,并且把计算结果体现在影像的输出中,使得影像全方位三百六十度无死角。并且这里面有通过人的行为习惯而进行识别的极为严格的权限系统,没有权限的用户并不会因为进入三体影像中而进行误操作。”

“而三体技术最为值得夸耀的还是其学习能力,一个用户使用三体系统时间越长,三体系统习得的用户使用习惯越多,就越不会出现因为肢体的轻微变化而产生的干扰。三体系统定义了一套纠错系统,比如用户跺脚,大声骂三体系统等等行为,三体系统都会认识到自己之前的操作是错误的,并且回到上一刻。而久而久之,肥胖臃肿的人再也不会因为自己笨手笨脚而产生错误,懒惰的人只要动动手指就能被领会其心意。”

小赵越说越兴奋,他随手拿起影像中一叠照片,从左往右,像铺开扑克牌一样慢慢展开一张张照片。然后将手指往影像中的某一张照片轻轻一点,一段视频开启了。录像中,小姑娘纯真的笑脸正和一个虚拟的影像形成的阿姨笑谈着。

“潘老师,我想向您申请一下您公司这边电子管家的访客权限”。

“好的,给他权限。”就当潘老师话音刚落的时候,音响里就传来了录像中小姑娘清脆的笑声。

“如您所见,三体系统可以和电子管家完美衔接,并且模拟出一个管家的影像。电子管家本身的智能可以进入到三体系统中,并且选择您所喜爱的影像。画面中,这是我女儿在家里用三体系统听电子管家讲笑话呢。”

“不仅如此,三体系统可以轻而易举的实现变化的场景,不管是要向人展示您的研究成果,还是要开一个舞会营造氛围,电子管家都可以轻而易举的帮您实现。”

“当然,您完全不必担心耗电的问题,虽然电池技术的发展一直很缓慢,但是在不久之前,我们终于实现了无缝充电。也许您没有看到,在三体构筑的虚拟世界的边缘,每个珠子都与您办公室里的照明设备形成了一条闭合的通道。这些灯都是亮着的,当您授予了三体系统一定的权限后,它就能指挥环境中的灯光,并且接收这些灯光提供的光能以及辅助它们构筑影像。”

潘教授虽然惊讶,但却并没有变得愕然,这么多年科技日新月异的发展,他早已经见怪不怪。他耐心的听着小赵的描述,显得很感兴趣。

这时,三体系统突然发出警报,“警报,警报,检测到有黑客攻击。”

影像中,一个全身黑衣蒙面的蝙蝠侠形象的黑客,拿着尖锐的刺刀迎面扑来。小赵忍不住大喊道:“啊!!”

课堂上,后排某同学突然尖叫,把大家吓了一跳。老师看到小赵脸上流着的口水,默默地继续讲课……

Touching Moments

| Comments

今天是闰九月十八,闰九月这种日子据说要一个世纪才会出现一次。我想,出生在这样日子里的孩子,如果像我一样固执的过阴历生日,那么一辈子就只有一岁啦。这就是传说中的永远年轻了吧。

在这样特殊的日子,似乎就该写点文字来纪念一下。但是转念一想,要是没有平日里的那些稀松平常,又怎么会衬托出今天的这种奇妙特殊呢。所以,今天就写点往日记录下的感悟吧。

喝茶

原来我有很长一段时间,在公司喝好的茶,在宿舍喝差的,是无形中给自己努力工作带来了一丝激励,让我对每一天去公司上班有所期待。 想不到这个道理,直到喝完了所有的茶叶,我才明白。所以,自从搬回实验室以后,我又开始了这样的安排。

驾驶员

有的路别人说是错的给你纠正过来你不以为意,直到自己开错了才发现是真的错了。

司机不仅要会开车,还得认路,其实最重要的还是你和副驾驶上的那份责任。

郁结

有时候不知道是别人的郁闷还是自己的郁闷,仿佛美好的事情就该这样,直到有人撕碎了摆在你面前,你不知道该替那人难过还是替自己难过。

气场

昨天仇主席带我参加他的生日宴会,大家都特别投缘。黄梦蝶说:“气场如此相投的人,怎么能不早点认识呢!” 她说的太对了,人都是有气场的,气场不同的人很难相处,气场相同的人相见恨晚。

对未来慷慨

加缪在《鼠疫》里说过一句话,对未来的真正慷慨,是把一切献给现在。

期待

不知道你还记不记得我那天第一次跟你见面,对你抱怨说,似乎每天起床都没有什么期待。 其实自从那天认识你以后,我每天都有了期待。

直到后来你一直对我不理不睬,期待就淡了。所以我时常想起那句话,“有些人,让你对明天充满期待,却从没有出现在你的明天里。”

期待2

后来,我又找到了每天的第二份期待。

每天充分锻炼的又一个好处就是对第二天的早饭充满了期待,起床就有了动力!

青花笔

包装很好的一支青花笔,平时写字似乎也不多,一直舍不得用。直到放的时间长了,都积上了灰尘。某天突然有很重要的东西要写,觉得该用这支青花笔了,写上第一笔画的时候才发现,不知道是因为时间久了还是本身质量就差,写出来的字还不如平时用的笔。

术、法、道

做人、做事都会有“术——法——道”三个层次。怎么区分,怎么对待,怎么理解?先在心里想想这三个问题,再去做。

感谢“看不见的手”

读《经济学原理》也能读出感动来:

> > 亚当·斯密称它为“看不见的手”——引导千百万为自己工作的人促成有利于许多人的结果的神秘力量。在看似混乱的千百万未经协调的私人交易中产生了自发的市场命令。自由的人自由交易,结果是物品和劳务之丰富超出了人们的想象。没有独裁者,没有官员,没有超级计算机提前做出计划。的确,有时一个经济中的计划越多,它就越受短缺、混乱和失败的困扰。 自由的社会秩序,和它所带来的财富与进步一样,都是一种上天的极大恩赐。在这个感恩节以及生活中的每一天,我们都要心存感激。 > >

十次相处理论

狄天说,如果你在聚会或者party上,遇到一个姑娘,第一次见面感觉非常好,其实是有酒精的迷幻作用在里面的。

你需要相处十次,来确定你是否是真的喜欢。

大道理

经常听到很多大道理,有时候不是你不知道这些道理,而是不知道这些场合是否适应这些道理。

是啊,大道理是有场合的,这个世界太复杂,不存在一成不变的道理。

光辉岁月

每次听到beyond《光辉岁月》,浮现到眼前的就是初三的时候,班主任倪飞放学前都喜欢拖堂,给我们补课、总结、批评、励志。但是学校为了防止老师这么做,总喜欢在放学的时候广播音乐,把《光辉岁月》之类的歌放的很响很响,所以倪飞不得不用嗓门跟喇叭比拼谁喊的更响。

现在回想起来才知道,倪飞那时候配合着那种背景音乐,让我们感受到了多么美妙的演讲!

关于跑步的共鸣

关于跑步,那天我真的因为这番话受到了巨大的共鸣和鼓舞:

> > 夕阳西下,红晕满空, 跑着跑着像是进入了诗意的世界, 其实更多时候在觉得自己在一点点变强, 在生活中也更自信了, 那时意识到跑步更多的是有一种宗教似的自省作用, 不是说我跑完我就像超人一样如何如何而是在过程中慢慢认识到自己生活中的界限和不足,更清醒地认识到自我部分, 和日常生活的一种有效隔离。 > >

老妈的吊坠

记忆里,小时候最喜欢捏妈妈脖子上的肉,捏到最狠的那些年,把老妈的结婚时脖子上的金项链都捏断了。

那一天,我们一家三口去万达逛街,说是逛街,其实是帮我买衣服。买完衣服到万达玉石柜台说可以抽奖,老爸一抽就是个一等奖,全场玉石一折,限购两件。

多么拙劣的骗局啊。

但是我转念一想老妈都快二十多年了,脖子上从来没带过东西。

“老妈,快挑一个,多好的机会啊。儿子送你一个吊坠。”

老妈高高兴兴的挑选了一个。

我由衷的感谢这个十一万达的抽奖活动。

老爸的老花镜

今天和小兄弟们喝完酒回来,发现老爸正在拿着本“装修笔记”计算着什么。想来自己从来没有为新房子装修操过心,都是老爸在忙。偶然间竟抬头竟发现老爸也戴上了眼镜。

我问老爸怎么突然戴起了眼镜,他说有点老花看不清了,前段时间就在街上配了副眼镜。

这一刻,我觉得老爸超帅,拍了张照片发到朋友圈。

同学说,你爸看着好年轻。

其实我心里却在想,不经意间,记忆里永远30岁爱赌爱玩的老爸,也在这些年戒赌守店为儿子的操心中开始变老了。

眼泪

看电影《亲爱的》的时候,发现周围有个女士感动的掉下了眼泪。然后感叹没心没肺过日子的自己好像多年没掉过眼泪了啊,真是怀念小时候爱哭的自己呢。

就在回家的这些日子里,发现自己有了点小钱,可以时不时孝敬下爸妈的时候,发现岁月总是这样残酷。

当我们才一长大,爸爸妈妈就在我们一声声老爸老妈中被我们叫老了。

莫名的掉出了好多眼泪。

结尾

我一直以为自己的生活枯燥乏味,有时候甚至有些无聊。直到我多次听到别人说我是个热爱生活的人,这真是莫大的赞美。我一定会继续热爱下去的,加油!

十月的尾巴

| Comments

本来今天这篇日志准备用“社交减肥”这四个字做题目的,但是转念一想这样实在太高调了。一定会招来各种像我这样常年寻找减肥良方而不得的人满怀希冀的点进来,结果发现这狗屁不通的内容而对我嗤之以鼻。然后发现今天不仅是周五,还是十月的尾巴,然后就愉快的定下来这个标题。但是今天的主题,确确实实就是社交减肥。

社交减肥这个新鲜词汇起初我是从champion那里听来的。那天他笑嘻嘻的告诉我,可以用这种方法减肥,通过社交的力量来监督自己减肥。然后我研究了一下社交减肥的起源,原来是2011年美国的科学家用小鼠做的一组对照试验。跟一大群同类生活在一起的小鼠饭量增加的同时,腹部脂肪还是减少了一半。然后研究人员就提出参加更多社交活动可以达到减肥的效果。

然后我仔细思考了一下,我以前的社交活动也不少啊。隔三差五的就约塔斯、狄国奖、仇大叔、欸喽他们出去左吃一顿烧烤,又吃一顿自助的。想想这个方法真是太蠢了,社交怎么能少了吃饭呢?社交怎么能干劈情操呢?社交又怎么能减肥呢?

再来看看三年后的今天,研究人员又来提出了社交减肥新的内涵。他们说通过社交媒体,大家相互交流、督促和鼓励,从而更好地完成减肥计划。仔细想想研究人员这次看上去好像很有道理,然后看看我的朋友圈。

laicb时不时晚上回来就大喊一声:“DiTian,吃泡面!”然后捧回一碗香喷喷的泡面加鸡蛋。这边狄主席的回复就是:“吃什么泡面啊!都吃的撑死了!”每天晚上回来各种南粉北面,鸡腿肉干,香蕉橘子,还偏偏实现不了增肥的目标。然后一边嚼着肉干一边跟我抱怨生活:“哎,活着都不知道要干什么。”再过一会,俊就回来了。一起加入到生与死的讨论,“我只是觉得每天吃饭的时候比较烦,总觉得都吃腻了,人要是能不吃饭多好!”到了实验室,还有今天肉松饼干、明天巧克力,零食从来不会断的太后,以及每次我们一起吃饭吃到很撑的时候都喜欢忧愁的说一句:“我感觉今天只吃了三分饱~”的星宇。

我勒个去,偏偏这帮人还都是瘦子!实际上我目前的圈子里好像就我一个胖子。是啊,多么黑暗的现实,谁跟你互相鼓励、互相监督、社交减肥啊!

由此可见,社交减肥注定是要失败的。但是社交减肥确实给了我们减肥的启示。那就是拒绝社交减肥。这个词的正确读法应该是,拒绝社交->减肥!