Linux Namespace 是 Linux 提供的一种内核级别环境隔离的方法。用官方的话来说,Linux Namespace 将全局系统资源封装在一个抽象中,从而使 namespace 内的进程认为自己具有独立的资源实例。这项技术本来没有掀起多大的波澜,是容器技术的崛起让他重新引起了大家的注意。

Linux Namespace 有如下 6 个种类:

分类 系统调用参数 相关内核版本
Mount namespaces CLONE_NEWNS Linux 2.4.19
UTS namespaces CLONE_NEWUTS Linux 2.6.19
IPC namespaces CLONE_NEWIPC Linux 2.6.19
PID namespaces CLONE_NEWPID Linux 2.6.24
Network namespaces CLONE_NEWNET 始于Linux 2.6.24 完成于 Linux 2.6.29
User namespaces CLONE_NEWUSER 始于 Linux 2.6.23 完成于 Linux 3.8

namespace 的 API 由三个系统调用和一系列 /proc 文件组成,本文将会详细介绍这些系统调用和 /proc 文件。为了指定要操作的 namespace 类型,需要在系统调用的 flag 中通过常量 CLONE_NEW* 指定(包括 CLONE_NEWIPCCLONE_NEWNSCLONE_NEWNETCLONE_NEWPIDCLONE_NEWUSER 和 `CLONE_NEWUTS),可以指定多个常量,通过 |(位或)操作来实现。

简单描述一下三个系统调用的功能:

  • clone() : 实现线程的系统调用,用来创建一个新的进程,并可以通过设计上述系统调用参数达到隔离的目的。
  • unshare() : 使某进程脱离某个 namespace。
  • setns() : 把某进程加入到某个 namespace。

具体的实现原理请往下看。

1. clone()


clone() 的原型如下:

int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
  • child_func : 传入子进程运行的程序主函数。
  • child_stack : 传入子进程使用的栈空间。
  • flags : 表示使用哪些 CLONE_* 标志位。
  • args : 用于传入用户参数。

clone()fork() 类似,都相当于把当前进程复制了一份,但 clone() 可以更细粒度地控制与子进程共享的资源(其实就是通过 flags 来控制),包括虚拟内存、打开的文件描述符和信号量等等。一旦指定了标志位 CLONE_NEW*,相对应类型的 namespace 就会被创建,新创建的进程也会成为该 namespace 中的一员。

clone() 的原型并不是最底层的系统调用,而是封装过的,真正的系统调用内核实现函数为 do_fork(),形式如下:

long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)

其中 clone_flags 可以赋值为上面提到的标志。

下面来看一个例子:

/* demo_uts_namespaces.c

   Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later Demonstrate the operation of UTS namespaces.
*/
#define _GNU_SOURCE
#include <sys/wait.h>
#include <sys/utsname.h>
#include <sched.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* A simple error-handling function: print an error message based
on the value in 'errno' and terminate the calling process */ #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0) static int /* Start function for cloned child */
childFunc(void *arg)
{
struct utsname uts; /* 在新的 UTS namespace 中修改主机名 */ if (sethostname(arg, strlen(arg)) == -1)
errExit("sethostname"); /* 获取并显示主机名 */ if (uname(&uts) == -1)
errExit("uname");
printf("uts.nodename in child: %s\n", uts.nodename); /* Keep the namespace open for a while, by sleeping.
This allows some experimentation--for example, another
process might join the namespace. */ sleep(100); return 0; /* Terminates child */
} /* 定义一个给 clone 用的栈,栈大小1M */
#define STACK_SIZE (1024 * 1024) static char child_stack[STACK_SIZE]; int
main(int argc, char *argv[])
{
pid_t child_pid;
struct utsname uts; if (argc < 2) {
fprintf(stderr, "Usage: %s <child-hostname>\n", argv[0]);
exit(EXIT_FAILURE);
} /* 调用 clone 函数创建一个新的 UTS namespace,其中传出一个函数,还有一个栈空间(为什么传尾指针,因为栈是反着的);
新的进程将在用户定义的函数 childFunc() 中执行 */ child_pid = clone(childFunc,
child_stack + STACK_SIZE, /* 因为栈是反着的,
所以传尾指针 */
CLONE_NEWUTS | SIGCHLD, argv[1]);
if (child_pid == -1)
errExit("clone");
printf("PID of child created by clone() is %ld\n", (long) child_pid); /* Parent falls through to here */ sleep(1); /* 给子进程预留一定的时间来改变主机名 */ /* 显示当前 UTS namespace 中的主机名,和
子进程所在的 UTS namespace 中的主机名不同 */ if (uname(&uts) == -1)
errExit("uname");
printf("uts.nodename in parent: %s\n", uts.nodename); if (waitpid(child_pid, NULL, 0) == -1) /* 等待子进程结束 */
errExit("waitpid");
printf("child has terminated\n"); exit(EXIT_SUCCESS);
}

该程序通过标志位 CLONE_NEWUTS 调用 clone() 函数创建一个 UTS namespace。UTS namespace 隔离了两个系统标识符 — 主机名NIS 域名 —它们分别通过 sethostname()setdomainname() 这两个系统调用来设置,并通过系统调用 uname() 来获取。

下面将对程序中的一些关键部分进行解读(为了简单起见,我们将省略其中的错误检查)。

程序运行时后面需要跟上一个命令行参数,它将会创建一个在新的 UTS namespace 中执行的子进程,该子进程会在新的 UTS namespace 中将主机名改为命令行参数中提供的值。

主程序的第一个关键部分是通过系统调用 clone() 来创建子进程:

child_pid = clone(childFunc,
child_stack + STACK_SIZE, /* Points to start of
downwardly growing stack */
CLONE_NEWUTS | SIGCHLD, argv[1]); printf("PID of child created by clone() is %ld\n", (long) child_pid);

子进程将会在用户定义的函数 childFunc() 中开始执行,该函数将会接收 clone() 最后的参数(argv[1])作为自己的参数,并且标志位包含了 CLONE_NEWUTS,所以子进程会在新创建的 UTS namespace 中执行。

接下来主进程睡眠一段时间,让子进程能够有时间更改其 UTS namespace 中的主机名。然后调用 uname() 来检索当前 UTS namespace 中的主机名,并显示该主机名:

sleep(1);           /* Give child time to change its hostname */

uname(&uts);
printf("uts.nodename in parent: %s\n", uts.nodename);

与此同时,由 clone() 创建的子进程执行的函数 childFunc() 首先将主机名改为命令行参数中提供的值,然后检索并显示修改后的主机名:

sethostname(arg, strlen(arg);

uname(&uts);
printf("uts.nodename in child: %s\n", uts.nodename);

子进程退出之前也睡眠了一段时间,这样可以防止新的 UTS namespace 不会被关闭,让我们能够有机会进行后续的实验。

执行程序,观察父进程和子进程是否处于不同的 UTS namespace 中:

$ su                   # 需要特权才能创建 UTS namespace
Password:
# uname -n
antero
# ./demo_uts_namespaces bizarro
PID of child created by clone() is 27514
uts.nodename in child: bizarro
uts.nodename in parent: antero

除了 User namespace 之外,创建其他的 namespace 都需要特权,更确切地说,是需要相应的 Linux Capabilities,即 CAP_SYS_ADMIN。这样就可以避免设置了 SUID(Set User ID on execution)的程序因为主机名不同而做出一些愚蠢的行为。如果对 Linux Capabilities 不是很熟悉,可以参考我之前的文章:Linux Capabilities 入门教程:概念篇

2. proc 文件


每个进程都有一个 /proc/PID/ns 目录,其下面的文件依次表示每个 namespace, 例如 user 就表示 user namespace。从 3.8 版本的内核开始,该目录下的每个文件都是一个特殊的符号链接,链接指向 $namespace:[$namespace-inode-number],前半部份为 namespace 的名称,后半部份的数字表示这个 namespace 的句柄号。句柄号用来对进程所关联的 namespace 执行某些操作。

$ ls -l /proc/$$/ns         # $$ 表示当前所在的 shell 的 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 inode number 相同,就说明他们在同一个 namespace 下,否则就在不同的 namespace 下。这些符号链接指向的文件比较特殊,不能直接访问,事实上指向的文件存放在被称为 nsfs 的文件系统中,该文件系统用户不可见,可以使用系统调用 stat() 在返回的结构体的 st_ino 字段中获取 inode number。在 shell 终端中可以用命令(实际上就是调用了 stat())看到指向文件的 inode 信息:

$ stat -L /proc/$$/ns/net
File: /proc/3232/ns/net
Size: 0 Blocks: 0 IO Block: 4096 regular empty file
Device: 4h/4d Inode: 4026531956 Links: 1
Access: (0444/-r--r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2020-01-17 15:45:23.783304900 +0800
Modify: 2020-01-17 15:45:23.783304900 +0800
Change: 2020-01-17 15:45:23.783304900 +0800
Birth: -

除了上述用途之外,这些符号链接还有其他的用途,如果我们打开了其中一个文件,那么只要与该文件相关联的文件描述符处于打开状态,即使该 namespace 中的所有进程都终止了,该 namespace 依然不会被删除。通过 bind mount 将符号链接挂载到系统的其他位置,也可以获得相同的效果:

$ touch ~/uts
$ mount --bind /proc/27514/ns/uts ~/uts

3. setns()


加入一个已经存在的 namespace 可以通过系统调用 setns() 来完成。它的原型如下:

int setns(int fd, int nstype);

更确切的说法是:setns() 将调用的进程与特定类型 namespace 的一个实例分离,并将该进程与该类型 namespace 的另一个实例重新关联。

  • fd 表示要加入的 namespace 的文件描述符,可以通过打开其中一个符号链接来获取,也可以通过打开 bind mount 到其中一个链接的文件来获取。
  • nstype 让调用者可以去检查 fd 指向的 namespace 类型,值可以设置为前文提到的常量 CLONE_NEW*,填 0 表示不检查。如果调用者已经明确知道自己要加入了 namespace 类型,或者不关心 namespace 类型,就可以使用该参数来自动校验。

结合 setns()execve() 可以实现一个简单但非常有用的功能:将某个进程加入某个特定的 namespace,然后在该 namespace 中执行命令。直接来看例子:

/* ns_exec.c 

   Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later Join a namespace and execute a command in the namespace
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h> /* A simple error-handling function: print an error message based
on the value in 'errno' and terminate the calling process */ #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0) int
main(int argc, char *argv[])
{
int fd; if (argc < 3) {
fprintf(stderr, "%s /proc/PID/ns/FILE cmd [arg...]\n", argv[0]);
exit(EXIT_FAILURE);
} fd = open(argv[1], O_RDONLY); /* 获取想要加入的 namespace 的文件描述符 */
if (fd == -1)
errExit("open"); if (setns(fd, 0) == -1) /* 加入该 namespace */
errExit("setns"); execvp(argv[2], &argv[2]); /* 在加入的 namespace 中执行相应的命令 */
errExit("execvp");
}

该程序运行需要两个或两个以上的命令行参数,第一个参数表示特定的 namespace 符号链接的路径(或者 bind mount 到这些符号链接的文件路径);第二个参数表示要在该符号链接相对应的 namespace 中执行的程序名称,以及执行这个程序所需的命令行参数。关键步骤如下:

fd = open(argv[1], O_RDONLY);   /* 获取想要加入的 namespace 的文件描述符 */

setns(fd, 0);                   /* 加入该 namespace */

execvp(argv[2], &argv[2]);      /* 在加入的 namespace 中执行相应的命令 */

还记得我们之前已经通过 bind mount 将 demo_uts_namespaces 创建的 UTS namespace 挂载到 ~/uts 中了吗?可以将本例中的程序与之结合,让新进程可以在该 UTS namespace 中执行 shell:

    $ ./ns_exec ~/uts /bin/bash     # ~/uts 被 bind mount 到了 /proc/27514/ns/uts
My PID is: 28788

验证新的 shell 是否与 demo_uts_namespaces 创建的子进程处于同一个 UTS namespace:

$ hostname
bizarro
$ readlink /proc/27514/ns/uts
uts:[4026532338]
$ readlink /proc/$$/ns/uts # $$ 表示当前 shell 的 PID
uts:[4026532338]

在早期的内核版本中,不能使用 setns() 来加入 mount namespace、PID namespace 和 user namespace,从 3.8 版本的内核开始,setns() 支持加入所有的 namespace。

util-linux 包里提供了nsenter 命令,其提供了一种方式将新创建的进程运行在指定的 namespace 里面,它的实现很简单,就是通过命令行(-t 参数)指定要进入的 namespace 的符号链接,然后利用 setns() 将当前的进程放到指定的 namespace 里面,再调用 clone() 运行指定的执行文件。我们可以用 strace 来看看它的运行情况:

# strace nsenter -t 27242 -i -m -n -p -u /bin/bash
execve("/usr/bin/nsenter", ["nsenter", "-t", "27242", "-i", "-m", "-n", "-p", "-u", "/bin/bash"], [/* 21 vars */]) = 0
…………
…………
pen("/proc/27242/ns/ipc", O_RDONLY) = 3
open("/proc/27242/ns/uts", O_RDONLY) = 4
open("/proc/27242/ns/net", O_RDONLY) = 5
open("/proc/27242/ns/pid", O_RDONLY) = 6
open("/proc/27242/ns/mnt", O_RDONLY) = 7
setns(3, CLONE_NEWIPC) = 0
close(3) = 0
setns(4, CLONE_NEWUTS) = 0
close(4) = 0
setns(5, CLONE_NEWNET) = 0
close(5) = 0
setns(6, CLONE_NEWPID) = 0
close(6) = 0
setns(7, CLONE_NEWNS) = 0
close(7) = 0
clone(child_stack=0, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f4deb1faad0) = 4968

4. unshare()


最后一个要介绍的系统调用是 unshare(),它的原型如下:

int unshare(int flags);

unshare()clone() 类似,但它运行在原先的进程上,不需要创建一个新进程,即:先通过指定的 flags 参数 CLONE_NEW* 创建一个新的 namespace,然后将调用者加入该 namespace。最后实现的效果其实就是将调用者从当前的 namespace 分离,然后加入一个新的 namespace。

Linux 中自带的 unshare 命令,就是通过 unshare() 系统调用实现的,使用方法如下:

$ unshare [options] program [arguments]

options 指定要创建的 namespace 类型。

unshare 命令的主要实现如下:

/* 通过提供的命令行参数初始化 'flags' */

unshare(flags);

/* Now execute 'program' with 'arguments'; 'optind' is the index
of the next command-line argument after options */ execvp(argv[optind], &argv[optind]);

unshare 命令的完整实现如下:

/* unshare.c 

   Copyright 2013, Michael Kerrisk
Licensed under GNU General Public License v2 or later A simple implementation of the unshare(1) command: unshare
namespaces and execute a command.
*/ #define _GNU_SOURCE
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h> /* A simple error-handling function: print an error message based
on the value in 'errno' and terminate the calling process */ #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE); \
} while (0) static void
usage(char *pname)
{
fprintf(stderr, "Usage: %s [options] program [arg...]\n", pname);
fprintf(stderr, "Options can be:\n");
fprintf(stderr, " -i unshare IPC namespace\n");
fprintf(stderr, " -m unshare mount namespace\n");
fprintf(stderr, " -n unshare network namespace\n");
fprintf(stderr, " -p unshare PID namespace\n");
fprintf(stderr, " -u unshare UTS namespace\n");
fprintf(stderr, " -U unshare user namespace\n");
exit(EXIT_FAILURE);
} int
main(int argc, char *argv[])
{
int flags, opt; flags = 0; while ((opt = getopt(argc, argv, "imnpuU")) != -1) {
switch (opt) {
case 'i': flags |= CLONE_NEWIPC; break;
case 'm': flags |= CLONE_NEWNS; break;
case 'n': flags |= CLONE_NEWNET; break;
case 'p': flags |= CLONE_NEWPID; break;
case 'u': flags |= CLONE_NEWUTS; break;
case 'U': flags |= CLONE_NEWUSER; break;
default: usage(argv[0]);
}
} if (optind >= argc)
usage(argv[0]); if (unshare(flags) == -1)
errExit("unshare"); execvp(argv[optind], &argv[optind]);
errExit("execvp");
}

下面我们执行 unshare.c 程序在一个新的 mount namespace 中执行 shell:

$ echo $$                             # 显示当前 shell 的 PID
8490
$ cat /proc/8490/mounts | grep mq # 显示当前 namespace 中的某个挂载点
mqueue /dev/mqueue mqueue rw,seclabel,relatime 0 0
$ readlink /proc/8490/ns/mnt # 显示当前 namespace 的 ID
mnt:[4026531840]
$ ./unshare -m /bin/bash # 在新创建的 mount namespace 中执行新的 shell
$ readlink /proc/$$/ns/mnt # 显示新 namespace 的 ID
mnt:[4026532325]

对比两个 readlink 命令的输出,可以知道两个shell 处于不同的 mount namespace 中。改变新的 namespace 中的某个挂载点,然后观察两个 namespace 的挂载点是否有变化:

$ umount /dev/mqueue                  # 移除新 namespace 中的挂载点
$ cat /proc/$$/mounts | grep mq # 检查是否生效
$ cat /proc/8490/mounts | grep mq # 查看原来的 namespace 中的挂载点是否依然存在?
mqueue /dev/mqueue mqueue rw,seclabel,relatime 0 0

可以看出,新的 namespace 中的挂载点 /dev/mqueue 已经消失了,但在原来的 namespace 中依然存在。

5. 总结


本文仔细研究了 namespace API 的每个组成部分,并将它们结合起来一起使用。后续的文章将会继续深入研究每个单独的 namespace,尤其是 PID namespace 和 user namespace。

参考链接

微信公众号

扫一扫下面的二维码关注微信公众号,在公众号中回复◉加群◉即可加入我们的云原生交流群,和孙宏亮、张馆长、阳明等大佬一起探讨云原生技术

Linux Namespace 入门系列:Namespace API的更多相关文章

  1. Web API 入门系列 - RESTful API 设计指南

    参考:https://developer.github.com/v3/  https://github.com/bolasblack/http-api-guide HTTP 协议 目前使用HTTP1. ...

  2. Linux ns 6. Network Namespace 详解

    文章目录 1. 简介 1.1 Docker Network 桥接模式配置 2. 代码解析 2.1 copy_net_ns() 2.2 pernet_list 2.2.1 loopback_net_op ...

  3. linux入门系列18--Web服务之Apache服务1

    前面系列文章讲解了Linux下通过文件传输.文件共享.邮件系统来分享和获取资源,本文讲解网络资源获取和共享的另外一种形式,通过Apache服务程序来提供Web服务. 本文先讲解目前主流的Web服务程序 ...

  4. Hadoop MapReduce编程 API入门系列之压缩和计数器(三十)

    不多说,直接上代码. Hadoop MapReduce编程 API入门系列之小文件合并(二十九) 生成的结果,作为输入源. 代码 package zhouls.bigdata.myMapReduce. ...

  5. 爬虫入门系列(三):用 requests 构建知乎 API

    爬虫入门系列目录: 爬虫入门系列(一):快速理解HTTP协议 爬虫入门系列(二):优雅的HTTP库requests 爬虫入门系列(三):用 requests 构建知乎 API 在爬虫系列文章 优雅的H ...

  6. HBase编程 API入门系列之create(管理端而言)(8)

    大家,若是看过我前期的这篇博客的话,则 HBase编程 API入门系列之put(客户端而言)(1) 就知道,在这篇博文里,我是在HBase Shell里创建HBase表的. 这里,我带领大家,学习更高 ...

  7. HBase编程 API入门系列之delete(客户端而言)(3)

    心得,写在前面的话,也许,中间会要多次执行,连接超时,多试试就好了. 前面的基础,如下 HBase编程 API入门系列之put(客户端而言)(1) HBase编程 API入门系列之get(客户端而言) ...

  8. HBase编程 API入门系列之get(客户端而言)(2)

    心得,写在前面的话,也许,中间会要多次执行,连接超时,多试试就好了. 前面是基础,如下 HBase编程 API入门系列之put(客户端而言)(1) package zhouls.bigdata.Hba ...

  9. HBase编程 API入门系列之HTable pool(6)

    HTable是一个比较重的对此,比如加载配置文件,连接ZK,查询meta表等等,高并发的时候影响系统的性能,因此引入了“池”的概念. 引入“HBase里的连接池”的目的是: 为了更高的,提高程序的并发 ...

随机推荐

  1. 网络爬虫url跳转代码

    from bs4 import BeautifulSoup from urllib.request import urlopen import re import random base_url = ...

  2. 神侃:反向激励能救活多少APP?

    ​ 在很多宣扬互联网企业成功的宣传文案中,为了将其包装地更大高大上和有逼格,总是会将各种心理学术名词用上,以显示自己对市场.用户群体的观察入微.当然事实上所谓的心理学术名词,都是"马后炮&q ...

  3. Scheme实现数字电路仿真(3)——模块

    版权申明:本文为博主窗户(Colin Cai)原创,欢迎转帖.如要转贴,必须注明原文网址 http://www.cnblogs.com/Colin-Cai/p/12242650.html 作者:窗户 ...

  4. 用nodejs+express搭建前端测试服务端

    平时开发前端应用,如果没有现成的后端接口调试,又要保证前端进度,该怎么办呢,当然办法还是很多的,很多大牛都分享过很多经验,我也来说说我常用的方法. 请求本地数据文件 把本地数据放到程序指定目录,发起h ...

  5. js 打开新窗口方式

    之前的项目,有个功能是下载文件,这里只要在浏览器输入 url 就会下载那个文件了.当时我只是简单得使用 window.open ,但是却会被浏览器进行拦截,要手动开启才行,然后就搜索研究其他方法,就看 ...

  6. Visual Studio Code打开后是黑色的什么都没显示

    测试系统:win7 x64. 问题:打开Microsoft VS Code后是黑色的界面并且什么都没有显示. 截图:本来想放一张图片的,因为当时忘记截了,所以这里就忽略了. 解决办法: 需要安装以下三 ...

  7. 在shell脚本中调用sql语句

    查询员工信息 -S:静默登录 [oracle@localhost shells]$ cat shell1.sh #!/bin/bash #查询员工信息 sqlplus -S /nolog <&l ...

  8. 神奇的 SQL 之 ICP → 索引条件下推

    开心一刻 楼主:来,我们先排练一遍 小伙伴们:好 嘿.哈.嚯 楼主:非常好,就是这个节奏,我们开始吧 楼主:啊.啊.啊,疼 ! 你们是不是故意的 ? 回表与覆盖索引 正式讲 ICP 之前了,我们先将相 ...

  9. Cinemachine简介

      先贴一下官方的Cinemachine文档Cinemachine Documentation 简介 使用   我们第一次使用Cinemachine时大概是这样一个流程: 在需要被控制的Camera上 ...

  10. 计算机网络原理实验_使用网络协议分析仪Wireshark

    一.实验名称  使用网络协议分析仪Wireshark 二.实验目的: 1. 掌握安装和配置网络协议分析仪Wireshark的方法: 2. 熟悉使用Wireshark工具分析网络协议的基本方法,加深对协 ...