Mesos架构和工作流程简介

1. 什么是Mesos

Mesos是Apache软件基金会维护的一个开源软件,它负责管理一批服务器集群,并将所有服务器集群的CPU、GPU、内存、存储、端口和其它相关计算资源进行了抽象统一,让用户进行动态配置和使用,提高整个系统的资源利用率,并以集群分布式的方式保证系统运行的高可用和弹性扩展。

2. Mesos架构和工作流程

官网中有个Mesos的架构图如下,

主要的模块有,

  • Mesos Master:管理所有机器资源信息,一般会部署多台来保证master的高可用,以resource offers的方式定时告知scheduler可用资源。
  • ZooKeeper:实现当前工作Mesos Master的选举,并实现数据一致性。
  • Mesos Agent:集群中每个机器都会部署一台Mesos Agent,定时汇报当前机器可用资源给Master,并从Master获取分配的任务信息,调用Executor来运行。
  • Scheduler:定时得到Master发送过来的resource offers,将任务列表中各个任务的资源需求进行一一匹配,一旦资源需求获得满足,则告诉Master使用匹配到的资源启动任务。
  • Executor:被Agent调用,根据指定的任务和资源信息,执行任务。

其中Scheduler和Executor是开发者根据自己需要进行开发,业界里各种Mesos Framework也就是实现这两部分,例如图中的Hadoop Mesos/MPI Mesos,还有Mesosphere公司的marathon,HudSpot公司的Singularity,国内数人云的Swan等。

Mesos提供两层调度,一层是Agent调用Executor执行Master分配的任务,另外一层是任务的资源匹配和调度放到Scheduler,由Scheduler推送任务到Master。由于Scheduler和Executor的可动态调整,这样也使得任务调度策略和执行变得可动态配置。

整个Mesos系统的主要工作流程如下,

  1. Mesos Agent定期上报各个机器的资源(CPU、Memory、磁盘、端口号)
  2. Mesos Master收集所有Agent可用的资源,定期推送resource offer给Scheduler,offer中描述了可用的资源信息。
  3. Scheduler启动时,会找到Mesos Master并注册,定期获取Master推送给的resource offer,以此了解可用的资源。
  4. 用户向Scheduler申请任务需要的资源,执行相关任务,例如申请2CPU 4G Mem来运行程序Demo。
  5. Scheduler在获取到Master推送的offer后,当offer中的资源满足用户申请的任务需求,就向Master申请执行。
  6. Mesos master根据Scheduler申请,在相应的资源上调用Agent执行任务。

整个流程图见如下,

需要注意的是,Mesos的四大组件(Master/Slave/Scheduler/Executor)之间的通信,是通过libprocess实现actor model模型的进程间消息异步通信,每个进程是一个actor。

见上图,在Mesos的master节点中,每个Framework以及Slave都是一个远程的actor。而slave节点上,每个executor是一个actor,只不过内置的executor是在同一个进程中的,而其他自定义的executor是独立的进程,executor和slave之间通过进程间通信方式(网络端口)交互。

Actor模型通信带来的好处是省去了对消息队列的依赖,但同时由于消息都是异步的,需要actor处理消息的丢失以及超时逻辑,Mesos无法保证消息的可靠投递,提供的投递策略是 at-most-once(至多一次,不会重试)。

3. 如何开发一个Mesos Framework

Mesos提供支持多种语言的Framework开发,包括Java/Python/Scala等,下面以Java语言为例介绍Mesos Framework的开发,其它语言类似。

一个Mesos Framework主要实现两个模块,

  1. Scheduler
  2. Executor

其中Scheduler会是单独可运行的项目程序,比如一个Java程序或者一个Java Web后端服务;Executor则会是Jar包项目,打出Jar包后由Mesos Agent引用并启动。

在两个项目中各自引入依赖包,

org.apache.mesos mesos

在包org.apache.mesos中提供如下主要抽象接口,

  • Scheduler:这是Framework要实现的Scheduler接口,用于Mesos Master回调。
  • Executor:这是Framework要实现的Executor接口,用于Agent回调。
  • SchedulerDriver:这是Scheduler和Mesos进行通信的抽象接口。
  • ExecutorDriver:这是Executor和Mesos进行通信的抽象接口。

在两个项目中需实现上述的Scheduler和Executor抽象接口,各个接口方法的实现需求请参见Apache Mesos项目源代码中对其的接口描述,可以通过如下git命令下载代码, git clone git@github.com:apache/mesos.git

在Apache Mesos项目代码中有对Mesos Framework开发提供的样例实现,在其文件目录中,

  • mesos\src\examples\java\TestFramework.java
  • mesos\src\examples\java\TestExecutor.java

前一个实现了org.apache.mesos.Scheduler,是一个可单独运行的Java程序;后一个实现了org.apache.mesos.Executor,是一个Jar包。

注:在包org.apache.mesos中还有两个抽象接口:SchedulerDriver和ExecutorDriver,Apache Mesos提供两个Driver的默认具体实现MesosSchedulerDriver和MesosExecutorDriver,所以Mesos Framework可以直接使用,用于和Mesos Master通信,不用自己实现这两个Driver。

4. 参考资料

Json Web Token的介绍和最佳实践

1. 什么是JSON Web Token

JSON Web Token是一个开放的、行业规范(RFC7519)的方法,用于通信双方进行可靠的数据传输。它规定了一种紧凑的数据格式,将要传输的数据以JSON对象方式的组织,然后进行编码和签名,转化成为Token。

JSON Web Token包含三部分信息数据,

Header.Payload.Signature

其中,

  • Header:数据头部,声明Token类型和加密算法(比如HMAC SHA256或者RSA)
  • Payload:数据的信息体,包含了要传输的信息体
  • Signature:数据的数字签名,用于对数据进行校验来源可靠性和不被篡改

在三部分中,header/payload都是公开信息,JSON对象数据格式,使用Base64方式进行编码,这意味着header/payload可以被任何人进行解码和阅读,所以不要在header/payload放入敏感信息。

一个Token样例为, eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

其中,

Header Base64编解码 {“alg”:”HS256″,”typ”:”JWT”}
Payload Base64编解码 {“sub”:”1234567890″,”name”:”John Doe”,”admin”:true}
Signature HS256签名算法 HS256(eyJhb……RydWV9, secret)

注:签名算法中的eyJhb……RydWV9为Base64编码后的Header.Payload

2. 应用场景

JWT一般用在如下两个场景,

  • 认证:这是最常用JWT应用场景,用于用户登录后,后端服务器颁发一个带有时效性的JWT token给前端应用,然后前端应用在后续的请求中使用该Token进行访问后端资源。
  • 信息交换:由于JWT对数据进行了签名,信息不能被篡改,可以可靠地在通信双方进行数据信息交换。

下图简单描绘了JWT在认证场景的使用流程,

使用方法

JWT的使用方法包括:签发、解码、校验,

  • 签发:用于对信息进行签名,然后颁发给使用方,供后续进行认证和信息交换
  • 解码:对JWT的信息体解码,获取信息,一般用在不进行校验的场合,比如在浏览器端
  • 校验:对JWT的校验,确认信息的来源可靠性和不被篡改、时效不过期

目前已经有如下语言的支持,

  • Java
  • Node.js
  • JavaScript
  • Microsoft .NET、.NET RT(Windows 8.1 and Windows Phone 8.1)
  • Python
  • Perl
  • Ruby
  • Go
  • Lua
  • Scala
  • Object-C
  • Swift
  • PHP

等等。

下面主要介绍JavaScript/Node.Js/Java三种语言对Jwt的签发、校验、解码的代码实现,这三种语言分别代表着web客户端/web服务器端/后端服务。

1) JavaScript

下面使用jwt-decode类库在web客户端(浏览器端)对token进行解码操作。

点击这里下载jwt-token.js脚本。

在HTML页面中引入上面的JavaScript脚本,下面为简单的解码演示,

<script type="text/javascript" src="jwt-token.js"></script>
<script>

    let jwtToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ";
    let payload = jwtToken.split('.')[1];
    let payloadInfo = JSON.parse(jwt.base64urldecode(payload));

    for (let key in payloadInfo) {
        console.log(key + ": " + payloadInfo[key]);
    }

</script>
2) Node.js

下面使用Node Auth0类库在nodejs的web服务器端进行token的签发和校验 安装命令,从npm中安装jsonwebtoken npm install jsonwebtoken

签发

let jwt = require('jsonwebtoken');
let token = jwt.sign({name: 'John'}, 'secret', { algorithm: 'HS256', expiresIn: '1d', issuer: 'pphh'});

校验

let jwt = require('jsonwebtoken');
let info = jwt.verify(token, 'secret');
console.log(info.name) // John

解码

let info = jwt.decode(token, {complete: true});
console.log(info.header);
console.log(info.payload);
3) Java

下面使用Java Auth0类库来进行token的签发和校验。

在Maven项目中添加依赖项,

com.auth0 java-jwt 3.2.0

签发

try {
    Algorithm algorithm = Algorithm.HMAC256("secret");
    String token = JWT.create()
                      .withIssuer("auth0")
                      .sign(algorithm);
} catch (UnsupportedEncodingException exception){
    //UTF-8 encoding not supported
} catch (JWTCreationException exception){
    //Invalid Signing configuration / Couldn't convert Claims.
}

校验

String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE";
try {
    Algorithm algorithm = Algorithm.HMAC256("secret");
    JWTVerifier verifier = JWT.require(algorithm)
                              .withIssuer("auth0")
                              .build(); //Reusable verifier instance
    DecodedJWT jwt = verifier.verify(token);
} catch (UnsupportedEncodingException exception){
    //UTF-8 encoding not supported
} catch (JWTVerificationException exception){
    //Invalid signature/claims
}

解码

String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXUyJ9.eyJpc3MiOiJhdXRoMCJ9.AbIJTDMFc7yUa5MhvcP03nJPyCPzZtQcGEp-zWfOkEE";
try {
    DecodedJWT jwt = JWT.decode(token);
} catch (JWTDecodeException exception){
    //Invalid token
}

更多语言和JWT类库使用,请参考官方网站和样例。

最佳实践

  1. 在JWT信息体Payload中不要包括敏感信息,若一定要,可以尝试使用JSON Web Encryption 。
  2. 一定要添加Token过期时间expiration time (exp claim) ,用于时效性检查。
  3. 不建议以无签名算法的方式(签名算法为none)来颁发JWT token,这个有安全风险,会导致攻击者绕过某些JWT类库的校验,具体可以查看该资料
  4. 建议使用HTTPS来传输JWT token信息,在最大程度保证信息在传输过程中的安全性,防止被中间人截取。
  5. 确保签名密钥只能让颁发者和使用者知道,尽可能使用非对称加密算法来颁发Token(比如RSA算法)。
  6. 如果担心重放攻击(replay attacks),可以尝试设置jti声明(JWT ID Claim),该声明将使得每个JWT token拥有唯一的ID,使其可以只使用一次,可以用来防止重放攻击。
  7. 在浏览器端,token可以存放到sessionStorage/localStorage,但注意XSS(跨站点脚本)攻击;还有一种方法是token放到了Cookie中,建议为其加上HttpOnly和Secure标记,HttpOnly保证JavaScript无法获取该Cookie,而Secure标记将保证该Cookie信息只能通过Https传输,但注意Cookie的方式可能会引起CSRF(跨站请求伪造)攻击。

演示项目

admin demo项目代码中有一一使用上述的JWT token颁发、校验、解码方法,

  1. 在前端项目admin-front中,通过jwt-decode解析JWT token来获取登录用户账号。
  2. 在前端项目admin-front中,在配置文件webpack.config.js中有提供/test/auth接口,通过jsonwebtoken颁发JWT token。
  3. 在后端项目admin-server中,在UserService中,通过com.auth0.jwt.JWT来颁发JWT token。
  4. 在后端项目admin-server中,在JwtFilter中,通过com.auth0.jwt.JWT来校验用户登录token。

具体可以获取项目代码查看。

admin demo代码仓库地址:https://gitee.com/pphh/simple-admin,可以通过如下git clone命令获取仓库代码,

git clone git@gitee.com:pphh/simple-admin.git

参考资料

在Linux中使用namespace和cgroup实现进程的资源隔离和限制

这几年,Docker容器和其它资源虚拟化平台(比如Mesos)研究非常多,各种应用技术层出不穷,但是要想使用好容器,其底层实现技术原理必须要掌握清楚。其中,无论是LXC、Mesos还是Docker容器,都依赖于Linux内核底层技术namespace和cgroup,本文以简要方式演示如何使用namespace和cgroup来实现进程的资源隔离和限制。

1. 什么是Namespace和Cgroups

在Linux中,敲击man帮助手册命令,

– man namespaces – man cgroups

可以获取到namespace和cgoups的相关文档定义,

A namespace wraps a global system resource in an abstraction that makes it appear to the processes within the namespace that they have their own isolated instance of the global resource. Changes to the global resource are visible to other processes that are members of the namespace, but are invisible to other processes. One use of namespaces is to implement containers…… Control cgroups, usually referred to as cgroups, are a Linux kernel feature which provides for grouping of tasks and resource tracking and limitations for those groups……

从上述定义,可以知道namespace/cgroup是可以控制指定进程对系统资源的支配调度,系统资源包括网络、进程间通信、进程空间、文件系统及其用户权限等。换句话说,namespace/cgroup对系统资源做了一层抽象,让系统资源对进程部分可见,在同一个namespace中的进程,可看到系统资源是一样的。

Linux提供Namespace/Cgroup功能,主要是为了实现Linux容器技术。

2. 启动一个进程

在进入演示步骤前,我们先了解下Linux如何启动一个进程。

在Linux中有三种方法来启动一个子进程,分别为

1) clone:从父进程中创建子进程,和fork不一样之处在于,可以更加灵活的指定子进程的运行上下文,也具有更为强大的进程调度功能。man namespaces 2) fork:该方法创造的子进程是几乎是父进程的完整副本(进程ID\内存锁等除外)。子进程和父进程各自独立执行,先后顺序不定。 3) vfork:该方法创建的子进程和父进程共享物理空间,包括堆栈,而且和fork不一样之处是,其会阻塞父进程的执行。换句话说,vfork保证子进程在父进程之前执行完毕。

演示2-1

创建一个代码文件clone.c

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define STACK_SIZE (1024*1024)

static char child_stack[STACK_SIZE];

int child_main(void * args) {
   printf("in child process, pid=%d\n", getpid());
   printf("quit child process...\n");
   return EXIT_SUCCESS;
}

int main(){
   printf("start...\n");
   printf("in parent process, pid=%d\n", getpid());
   int child_pid = clone(child_main, child_stack + STACK_SIZE, SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit...\n");
   return EXIT_SUCCESS;
}

编译命令gcc clone.c -o clone.o

运行可执行文件clone.o,有如下console输出结果,

start… in parent process, pid=4673 in child process, pid=4674 quit child process… quit…

可以看到一个子进程(ID=4674)通过父进程启动,然后先后退出。

演示2-2

创建一个代码文件fork.c

#include 
#include 

#include 
#include 

int main(void) {
    int count = 1;
    int child;

    child = fork();

    if(child < 0) {
        perror("fork error : ");
    } else if(child == 0) {
        ++count;
        printf("in child process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
    } else {
        ++count;
        printf("in parent process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
    }

    printf("pid=%d quit now...\n", getpid());
    return EXIT_SUCCESS;
}

编译命令gcc fork.c -o fork.o

运行可执行文件fork.o,有如下console输出结果,

in parent process, pid=4253, count=2 (0x7ffd29cf1180) pid=4253 quit now... in child process, pid=4254, count=2 (0x7ffd29cf1180) pid=4254 quit now...

可以看到在fork之后,程序分成两个进程同时执行并且分两次退出,其中上面的输出结果告知父进程先退出。

演示2-3

创建一个代码文件vfork.c

#include 
#include 

#include 
#include 

int main(void) {
    int count = 1;
    int child;

    child = vfork();

    if(child < 0) {
        perror("fork error : ");
    } else if(child == 0) {
        ++count;
        printf("in child process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
        sleep(2);
        printf("pid=%d sleep 2 seconds, and now quit...\n", getpid());
        exit(0);
    } else {
        ++count;
        printf("in parent process, pid=%d, count=%d (%p)\n", getpid(), count, &count);
        printf("pid=%d quit now...\n", getpid());
        exit(0);
    }

    return EXIT_SUCCESS;
}

编译命令gcc vfork.c -o vfork.o

运行可执行文件vfork.o,有如下输出结果,

in child process, pid=4317, count=2 (0x7ffdbbed5830) pid=4317 sleep 2 seconds, and now quit... in parent process, pid=4316, count=3 (0x7ffdbbed5830) pid=4316 quit now...

可以看到在vfork之后,程序分成两个进程同时执行并且分两次退出,其中父进程被子进程阻塞,在等待子进程sleep两秒退出后,父进程才退出。

3. 通过clone启动一个进程

通过clone方法可以启动一个进程,其中调用方法可以指定flags参数来标记父子进程之间需要共享/隔离的资源,

flag标记位 功能 隔离的资源 Linux 内核版本支持
CLONE_NEWIPC 在新的 IPC namespace启动新进程 进程间通信,包括信号量、消息队列和共享内存 since 2.6.19
CLONE_NEWNET 在新的network namespace启动新进程 网络设备、网络栈、端口 since 2.6.24
CLONE_NEWNS 在新的mount namespace启动新进程 挂载点(文件系统) since 2.6.19
CLONE_NEWPID 在新的PID namespace启动新进程 进程编号(新的进程树) since 2.6.24
CLONE_NEWUSER 在新的user namespace启动新进程 用户和用户组 在2.6.23引入,在3.5和3.8有更新
CLONE_NEWUTS 在新的UTS namespace启动新进程 主机名和域名 since 2.6.19
CLONE_NEWCGROUP 在新的cgroup namespace启动新进程 CPU、内存、磁盘读写等 since 4.6

更详细信息可以通过man clone命令来查看。

下面将上述各标记位来一步步实现新启子进程的各项资源隔离。

4. 隔离进程的主机名和域名

演示4

创建一个代码文件demo_uts.c

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define STACK_SIZE (1024*1024)

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

int child_main(void * args) {
   printf("in child process\n");
   sethostname("NewHostName", 12);
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent process...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWUTS | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent process...\n");
   return 0;
}

编译命令gcc demo_uts.c -o demo_uts.o 请切换到root用户权限下(sudo su -),运行可执行文件demo_uts.o,有如下输出结果,

root@test:/home/test# ./demo_uts.o start parent process... in child process root@NewHostName:/home/test#

可以看到当前进入子进程后,当前的host名字已经更改为NewHostName,这是当前进程在网络中使用的新主机名。

演示完毕后,别忘了输入exit以退出当前子进程,然后父进程安全退出,整个程序演示完毕。

root@NewHostName:/home/test# exit exit quit parent process... root@test:/home/test#

注意,上述和后续演示需要切换到root用户权限下执行,这是因为子进程会更改网络等系统设置,需要相应的权限,这也是docker run需要root权限的原因。

5. 隔离进程间通信

在Linux中,敲击man帮助手册命令,

- ipcs:查看进程间通信设施 - ipcmk:创建进程间通信设施

在使用ipcs命令可以查看当前进程所能使用的通信设施,主要包括

- 消息队列 - 共享内存 - 信号量

如果想创建一个消息队列,可以通过命令ipcmk -Q test。运行Linux命令ipcs -q可以看到刚刚创建的test消息队列,

root@test:~# ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages 0x01bbd371 0 root 644 0 0

通过上述两个命令可以查看进程间的通信是否隔离。

演示5

创建一个代码文件demo_ipc.c

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define STACK_SIZE (1024*1024)

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

int child_main(void * args) {
   printf("in child process\n");
   sethostname("NewHostName", 12);
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent process...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent process...\n");
   return 0;
}

编译命令gcc demo_ipc.c -o demo_ipc.o 请切换到root用户权限下运行可执行文件demo_ipc.o,进入子进程,然后执行命令ipcs查看当前可见的进程间通信资源,

root@test:/home/test# ./demo_ipc.o start parent process... in child process root@NewHostName:/home/test# ipcs -q ------ Message Queues -------- key msqid owner perms used-bytes messages

可以看到当前进入子进程后,当前没有刚刚创建的test消息队列,这说明子进程已经看不到父进程的进程间通信资源。如果此时在子进程中创建进程间通信资源,然后到root进程空间查看,root进程也是看不到子进程的通信资源。 新启动的进程间通信资源已经被隔离。 演示完毕后,别忘了输入exit以退出当前子进程和父进程,结束整个演示。

6. 隔离进程空间

Linux中有个进程树的概念,在进程树中,有一个PID=1的进程,该进程是一个非常特殊的进程,它拥有特权,可以对所有后续启动的子进程资源监控和回收。 在Linux系统中可以通过pstree –g来查看整个进程树和进程ID。 在进程树中,所有父节点可以看到子节点的进程,并通过信号灯方式对子节点的进程产生影响(比如发送OOM信号,告知子进程退出)。子节点看不到父节点PID namespace中的任何内容。 下面的演示将为新启的进程实现独立的进程空间,拥有自己的进程树和pid为1的特权进程。

演示6

创建一个代码文件demo_pid.c

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define STACK_SIZE (1024*1024)

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

int child_main(void * args) {
   printf("in child process\n");
   sethostname("NewHostName", 12);
   execv(child_args[0], child_args);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent process...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent process...\n");
   return 0;
}

编译命令gcc demo_pid.c -o demo_pid.o 请切换到root用户权限下运行可执行文件demo_pid.o,进入子进程,

root@test:/home/test# ./demo_pid.o start parent process... in child process root@NewHostName:/home/test# mount -t proc proc /proc root@NewHostName:/home/test# pstree -g bash(1)───pstree(13)

可以看到在子进程中看到了一个全新的进程树,里面有个pid=1的进程,按照Linux的定义,这个进程将拥有管理当前进程空间的特权。对于子进程来说,它已经拥有了一个独立的进程空间。

注意,上述中执行了mount -t proc proc /proc命令,由于子进程和父进程的文件系统还没有隔离,该命令会重新加载子进程中的proc文件,从而执行pstree时能够读取到更新后的进程空间信息。该命令会对父进程的文件系统产生影响,若要恢复,同样在父进程中执行一次mount –t proc proc /proc命令

7. 隔离文件系统

Linux的文件系统挂载方式主要包括如下几种,

- 共享挂载(shared):父子进程空间的文件变化共享 - 从属挂载(slave): - 私有挂载(private):子进程空间的文件变化不影响 - 不可绑定挂载(unbindable):该文件不允许挂载

在Linux中可以通过命令mount –help查看支持的文件挂载方式,不同的挂载方式决定了在各个挂载点发生文件变化时,如何通知其他挂载点。 进程中是通过隔离文件系统挂载点来隔离文件系统。

演示7

获取演示6的程序代码,将flags添加CLONE_NEWNS,则新启动的进程拥有隔离的文件系统。

8. 隔离用户权限

在Linux中用户ID为0的用户是一个拥有特权的超级用户。在内核版本2.2之前,Linux只区分普通用户和超级用户(root)的进程,超级用户的进程不受任何权限检查。从内核版本2.2之后,Linux将超级用户的权限进行单元区分,各个权限单元就叫capabilities,关于capabilities的权限列表,可以通过命令man capabilities查看。 下面的演示将为新启动的进程运行在一个ID为0的超级用户中。

演示8

创建一个代码文件demo_user.c

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define STACK_SIZE (1024*1024)

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

void set_uid_map(pid_t pid, int inside_id, int outside_id, int length){
   char path[256];
   sprintf(path, "/proc/%d/uid_map", pid);
   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", pid);
   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) {
   printf("in child process\n");
   cap_t caps;
   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);
   printf("quit child process…\n");
   return 1;
}

int main(){
   printf("start parent ...\n");
   int child_pid = clone(child_main, child_stack + STACK_SIZE, CLONE_NEWUSER | SIGCHLD, NULL);
   waitpid(child_pid, NULL, 0);
   printf("quit parent ...\n");
   return 0;
}

编译命令gcc demo_user.c -lcap -o demo_user.o 若编译出现下面的报错信息,

fatal error: sys/capability.h: No such file or directory #include

可以尝试安装如下依赖包来解决。

sudo apt-get install libcap-dev

为了方便查看效果,请切换到普通用户权限下运行可执行文件demo_user.o,进入子进程,

test@test:/home/test# ./demo_user.o start parent process... in child process eUID = 0; eGID = 65534; capabilities: = cap_chown,…… root@test:/home/test# id uid=0(root)……

可以看到当前子进程运行在root用户下。

注意,在程序代码中,

set_uid_map(getpid(), 0, 1000, 1); set_gid_map(getpid(), 0, 1000, 1)

里面的1000是当前执行用户ID,该ID可能会随着系统用户发生变化,请使用Linux命令id查看用户ID,并相应进行替代。

9. Cgroup实现原理

在进行cgroup演示前,需要了解几个概念,

- Task (任务):一个进程或者线程 - Cgroup (控制组):cgroup技术对资源的控制以控制组为单位 - Subsystem(子系统):一个子系统就是一个资源调度器 - Hierachy (层级):控制组之间可以组织成层级的形式,子控制组继承父控制组的属性。

在Linux中可以通过如下命令查看相关信息,

- lscgroup:列出所有的cgroup - lssubsys:列出所有的子系统,其中lssubsys –M列出子系统挂载点

若提示命令不存在,可以执行apt install cgroup-tools进行安装

接下来我们将演示如何通过cgroup来控制一个进程的cpu使用率。

演示9

创建一个循环执行的bash脚本long_task.sh

#!/bin/bash
x=0
while [ True ];do
    x=$x+1
    #sleep(100)
done;

执行该脚本。 通过Linux top命令,可以看到该任务已经在执行中,

PID USER %CPU %MEM TIME+ COMMAND 5947 test 97.4 0.4 1:23.82 bash

进程ID为5947,CPU占用率为97.4%,接近100%。

下一步我们切换目录到cpu子系统,创建一个test目录

cd /sys/fs/cgroup/cpu/ mkdir ./test

这个时候查看test目录,可以看到该目录下已经有了很多文件,

ls ./test drwxr-xr-x 2 root root 0 10月 16 21:03 ./ dr-xr-xr-x 6 root root 0 10月 16 15:13 ../ -rw-r--r-- 1 root root 0 10月 16 21:03 cgroup.clone_children -rw-r--r-- 1 root root 0 10月 16 21:03 cgroup.procs -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.stat -rw-r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_all -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_percpu -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_percpu_sys -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_percpu_user -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_sys -r--r--r-- 1 root root 0 10月 16 21:03 cpuacct.usage_user -rw-r--r-- 1 root root 0 10月 16 21:03 cpu.cfs_period_us -rw-r--r-- 1 root root 0 10月 16 21:03 cpu.cfs_quota_us -rw-r--r-- 1 root root 0 10月 16 21:03 cpu.shares -r--r--r-- 1 root root 0 10月 16 21:03 cpu.stat -rw-r--r-- 1 root root 0 10月 16 21:03 notify_on_release -rw-r--r-- 1 root root 0 10月 16 21:03 tasks

我们执行如下命令,将进程5947的CPU使用率控制到50%,

cd /sys/fs/cgroup/cpu/test echo 5947 > tasks echo 50000 > cpu.cfs_quota_us

这个时候再通过Linux top命令查看任务执行情况,

PID USER %CPU %MEM TIME+ COMMAND 5947 test 50.1 0.4 1:23.82 bash

可以看到5947的CPU使用率已经从100%降到了50.1%,接近50%的CPU控制。

10. 结束语

到目前为止,我们使用Linux内核底层技术namespace和cgroup一步步地演示了进程的资源隔离和限制,通过这些演示可以很好地了解轻量级容器技术的实现原理。我们不仅要隔离进程的运行空间、文件系统、用户权限,让进程拥有独立的网络实体身份,而且要配置进程的CPU、内存、磁盘读写等。

目前轻量级虚拟化技术都在进程级别,可以看到,其进程的隔离性和安全性面临着挑战。

演示代码

本文演示资料运行在Ubuntu 16.10, Linux 内核版本为4.8.0-59-generic。 演示代码仓库地址:https://gitee.com/pphh/blog,可以通过如下git clone命令获取仓库代码,

git clone git@gitee.com:pphh/blog.git

上述代码样例在文件路径blog\171019_namespace_cgroup中。

参考资料