命名空间

262 阅读11分钟

在本章中,我们讨论了 Linux 容器的一个重要方面,称为 Linux 命名空间(Namespaces)。命名空间允许内核通过限制不同命名空间中进程对内核资源(如挂载点、网络子系统等)的可见性来提供隔离。

如今,容器已成为事实上的云软件部署机制。容器提供了快速的启动时间,并且比虚拟机具有更少的开销。这些特性背后有一些非常具体的原因。

正如在第1章中介绍的那样,基于虚拟机的虚拟化模拟硬件并提供操作系统作为抽象层。这意味着操作系统的大部分代码和设备驱动程序在部署时都会被加载。相比之下,容器本身虚拟化操作系统。这意味着在内核中存在一些数据结构,促进这种分离。大多数时候,我们并不清楚背后发生了什么。

Linux 容器由三个 Linux 内核原语组成:

  • Linux 命名空间
  • 控制组(Cgroups,详见第4章)
  • 分层文件系统(详见第5章)

命名空间是一种数据结构,它在 Linux 内核中提供逻辑隔离。命名空间控制内核内的可见性。所有控制都在进程级别定义。这意味着命名空间控制一个进程在内核中可以看到哪些资源。可以将 Linux 内核视为守护操作系统内存、特权 CPU 指令、磁盘和其他仅限内核访问的资源的守卫。在用户空间运行的应用程序应仅通过捕获来访问这些资源,在这种情况下,内核接管控制权并代表基于用户空间的应用程序执行这些指令。例如,一个想要访问磁盘上文件的应用程序必须通过系统调用(内部捕获到内核)将此调用委托给 Linux 内核,然后内核代表应用程序执行此请求。

由于在单个 Linux 内核上可能会并行运行许多基于用户空间的应用程序,因此我们需要一种方法在这些基于用户空间的应用程序之间提供隔离。隔离意味着应该对单个应用程序进行某种形式的沙盒化,以便应用程序中的某些资源限制在该沙盒中。例如,我们希望拥有一个文件系统沙盒,这意味着在该沙盒内,我们可以拥有自己的文件视图。这样,多个这样的沙盒可以在同一个 Linux 内核上运行而互不干扰。

这种沙盒化通过使用命名空间来实现。

命名空间类型

本节解释了 Linux 内核中存在的不同命名空间,并讨论了它们在内核中的实现方式。

UTS

此命名空间允许进程看到与主机全局命名空间中的主机名不同的单独主机名。

PID

PID 命名空间中的进程有一个不同的进程树。它们有一个 PID 为 1 的 init 进程。然而,在数据结构层面,这些进程属于一个全局进程树,该树仅在主机级别可见。像 ps 这样的工具或在命名空间内直接使用 /proc 文件系统会列出命名空间内进程树的进程及其相关资源。

挂载

挂载是最重要的命名空间之一。它控制进程应该能够看到哪些挂载点。如果进程在命名空间内,它只能看到该命名空间内的挂载。

为了更好地解释挂载传播在容器中的工作原理,我们可以稍作讨论。内核中的挂载由一个称为 vfsmount 的数据结构表示。所有挂载形成一个树状结构,子挂载结构包含对父挂载结构的引用。

所有代码均取自 Linux Kernel 4.15.18:

struct vfsmount {
      struct list_head mnt_hash;
      struct vfsmount *mnt_parent;    /* fs we are mounted on */
      struct dentry *mnt_mountpoint;  /* dentry of mountpoint */
      struct dentry *mnt_root;      /* root of the mounted tree*/
      struct super_block *mnt_sb;     /* pointer to superblock */
      struct list_head mnt_mounts;  /* list of children,
                                       anchored here */
      struct list_head mnt_child;     /* and going through their mnt_child */
      atomic_t mnt_count;
      int mnt_flags;
      char *mnt_devname;              /* Name of device e.g.
                                         /dev/dsk/hda1 */
      struct list_head mnt_list;
};

每当调用挂载操作时,都会创建一个 vfsmount 结构,并填充挂载点的 dentry 和挂载树的 dentry。dentry 是一个将 inode 映射到文件名的数据结构。

除了挂载之外,还有绑定挂载,它允许将目录(而不是设备)挂载到挂载点。绑定挂载的过程会创建一个指向目录 dentry 的 vfsmount 结构。

容器的工作基于绑定挂载的概念。因此,当为容器创建卷时,实际上是在主机内的目录与容器文件系统内的挂载点之间的绑定挂载。由于挂载发生在挂载命名空间内,vfsmount 结构被限定在挂载命名空间内。这意味着,通过创建目录的绑定挂载,我们可以在包含容器的命名空间内暴露一个卷。

网络

网络命名空间为容器提供了一组单独的网络子系统。这意味着网络命名空间内的进程将看到不同的网络接口、路由和 iptables。这将容器网络与主机网络分离开来。我们将在第6章中深入研究这一点,并通过示例展示在同一主机上不同命名空间中的两个容器之间的数据包流动,以及在同一主机内不同命名空间中的容器之间的网络通信。

IPC

此命名空间限定了 IPC 构造,如 POSIX 消息队列。在同一命名空间内的两个进程之间,IPC 是启用的,但如果两个不同命名空间中的进程试图通过 IPC 进行通信,则会受到限制。

控制组

此命名空间限制了进程只能看到其所属的控制组文件系统。如果没有此限制,进程可以通过 /proc/self/cgroup 层次结构窥视全局控制组。此命名空间有效地虚拟化了控制组本身。

除了这里提到的命名空间之外,写作本文时还有一个称为时间命名空间的命名空间。

时间

时间命名空间有两个主要用途:

  • 更改容器内的日期和时间
  • 为从检查点恢复的容器调整时钟

内核提供了对多个时钟的访问:CLOCK_REALTIMECLOCK_MONOTONICCLOCK_BOOTTIME。后两个时钟是单调的,但它们的起点不明确(目前的起点是系统启动时间,但 POSIX 规定的是“自过去某个不明确的时间点以来”),而且每个系统的起点不同。当容器从一个节点迁移到另一个节点时,所有时钟都会恢复到其一致状态。换句话说,它们必须从转储时的同一点继续运行。

Linux 命名空间的数据结构

现在你已经对命名空间有了基本的了解,接下来我们将详细研究 Linux 内核中的一些数据结构如何在 Linux 容器中实现这种分离。这些结构被称为 Linux 命名空间。

内核将每个进程表示为一个 task_struct 数据结构。以下是该结构的一些成员的详细信息:

/* task_struct 成员的预声明(按字母顺序排列):*/
struct audit_context;
struct backing_dev_info;
struct bio_list;
struct blk_plug;
struct capture_control;
struct cfs_rq;
struct fs_struct;
struct futex_pi_state;
struct io_context;
struct mempolicy;
struct nameidata;
struct nsproxy;
struct perf_event_context;
struct pid_namespace;
struct pipe_inode_info;
struct rcu_node;
struct reclaim_state;
struct robust_list_head;
struct root_domain;
struct rq;
struct sched_attr;
struct sched_param;
struct seq_file;
struct sighand_struct;
struct signal_struct;
struct task_delay_info;
struct task_group;

nsproxy 结构是一个持有不同命名空间的结构,这些命名空间是任务(进程)所属的:

struct nsproxy {
       atomic_t count;
       struct uts_namespace *uts_ns;
       struct ipc_namespace *ipc_ns;
       struct mnt_namespace *mnt_ns;
       struct pid_namespace *pid_ns_for_children;
       struct net           *net_ns;
       struct time_namespace *time_ns;
       struct time_namespace *time_ns_for_children;
       struct cgroup_namespace *cgroup_ns;
};
extern struct nsproxy init_nsproxy;

nsproxy 结构包含八个命名空间数据结构。缺失的一个是用户命名空间,它是 task_structcred 数据结构的一部分。

有三个系统调用可以将任务放入特定命名空间:cloneunsharesetnsclonesetns 调用会创建一个 nsproxy 对象,然后为任务添加所需的特定命名空间。

为了说明问题,本节的其余部分将特别关注网络命名空间。网络命名空间由一个 net 结构表示。该数据结构的一部分如下所示:

struct net {
       /* 第一个缓存行经常被弄脏。
        * 不要在这里放置大多数只读字段。
        */
       refcount_t           passive;       /* 用于决定何时
                                           * 释放网络命名空间。
                                           */
       refcount_t           count;        /* 用于决定何时
                                           * 关闭网络命名空间。
                                           */
       spinlock_t           rules_mod_lock;
       unsigned int         dev_unreg_count;
       unsigned int         dev_base_seq;   /* 受 rtnl_mutex 保护 */
       int                  ifindex;
       spinlock_t           nsid_lock;
       atomic_t             fnhe_genid;
       struct list_head    list;            /* 网络命名空间列表 */
       struct list_head    exit_list;       /* 链接到调用 pernet exit
                                             * 方法的死网(
                                             * 受 pernet_ops_rwsem 读锁定),
                                             * 或注销 pernet ops
                                             * (受 pernet_ops_rwsem 写锁定)。
                                             */
       struct llist_node   cleanup_list;   /* 死亡行列中的命名空间 */
#ifdef CONFIG_KEYS
       struct key_tag          *key_domain; /* 操作标签的密钥域 */
#endif
       struct user_namespace   *user_ns;    /* 所属用户命名空间 */
       struct ucounts           *ucounts;
       struct idr               netns_ids;
       struct ns_common    ns;
       struct list_head    dev_base_head;
       struct proc_dir_entry    *proc_net;
       struct proc_dir_entry    *proc_net_stat;
#ifdef CONFIG_SYSCTL
       struct ctl_table_set      sysctls;
#endif
       struct sock            *rtnl;        /* rtnetlink 套接字 */
       struct sock            *genl_sock;
       struct uevent_sock     *uevent_sock;  /* uevent 套接字 */
       struct hlist_head      *dev_name_head;
       struct hlist_head      *dev_index_head;
       struct raw_notifier_head     netdev_chain;

此数据结构的一个元素是此网络命名空间所属的用户命名空间。除此之外,主要的结构部分是 net_ns_ipv4,其中包括路由表、网络过滤规则等:

struct netns_ipv4 {
#ifdef CONFIG_SYSCTL
      struct ctl_table_header    *forw_hdr;
      struct ctl_table_header    *frags_hdr;
      struct ctl_table_header    *ipv4_hdr;
      struct ctl_table_header    *route_hdr;
      struct ctl_table_header    *xfrm4_hdr;
#endif
      struct ipv4_devconf        *devconf_all;
      struct ipv4_devconf        *devconf_dflt;
      struct ip_ra_chain __rcu *ra_chain;
      struct mutex         ra_mutex;
#ifdef CONFIG_IP_MULTIPLE_TABLES
      struct fib_rules_ops  *rules_ops;
      bool                       fib_has_custom_rules;
      unsigned int               fib_rules_require_fldissect;
      struct fib_table __rcu    *fib_main;
      struct fib_table __rcu    *fib_default;
#endif
      bool                  fib_has_custom_local_routes;
#ifdef CONFIG_IP_ROUTE_CLASSID
      Int                   fib_num_tclassid_users;
#endif
      struct hlist_head    *fib_table_hash;
      bool                  fib_offload_disabled;
      struct sock          *fibnl;
      struct sock  * __percpu    *icmp_sk;
      struct sock          *mc_autojoin_sk;
      struct inet_peer_base      *peers;
      struct sock  * __percpu    *tcp_sk;
      struct fqdir         *fqdir;
#ifdef CONFIG_NETFILTER
      struct xt_table      *iptable_filter;
      struct xt_table      *iptable_mangle;
      struct xt_table      *iptable_raw;
      struct xt_table      *arptable_filter;
#ifdef CONFIG_SECURITY
      struct xt_table      *iptable_security;
#endif
      struct xt_table      *nat_table;
#endif
      int sysctl_icmp_echo_ignore_all;
      int sysctl_icmp_echo_ignore_broadcasts;
      int sysctl_icmp_ignore_bogus_error_responses;
      int sysctl_icmp_ratelimit;
      int sysctl_icmp_ratemask;
      int sysctl_icmp_errors_use_inbound_ifaddr;
      struct local_ports ip_local_ports;
      int sysctl_tcp_ecn;
      int sysctl_tcp_ecn_fallback;
      int sysctl_ip_default_ttl;
      int sysctl_ip_no_pmtu_disc;
      int sysctl_ip_fwd_use_pmtu;
      int sysctl_ip_fwd_update_priority;
      int sysctl_ip_nonlocal_bind;
      int sysctl_ip_autobind_reuse;
      /* 我们是否应该尝试破坏路由 dev 更改时的输出数据包? */
      int sysctl_ip_dynaddr;

这就是 iptables 和路由规则如何都限定在网络命名空间中的。

这里还有一些相关的数据结构是 net_device(这是内核表示网卡/设备的方式)和 sock(表示套接字数据结构的内核结构)。这两个结构允许设备被限定在网络命名空间内,以及套接字被限定在命名空间内。这两个结构一次只能是一个命名空间的一部分。我们可以通过 iproute2 工具将设备移动到不同的命名空间。

以下是一些处理网络命名空间的用户空间命令:

  • ip netns add testns:添加一个网络命名空间
  • ip netns del testns:删除指定的命名空间
  • ip netns exec testns sh:在 testns 命名空间中执行一个 shell

将设备添加到命名空间

要将设备添加到命名空间,首先创建一个 veth 对设备(此设备可用于连接两个命名空间):

ip link add veth0 type veth peer name veth1

然后将 veth 对的其中一端添加到网络命名空间 testns 中:

ip link set veth1 netns testns

veth 对的另一端(veth0)位于主机命名空间中,因此任何发送到 veth0 的流量最终都会到达 testns 命名空间中的 veth1。

假设我们在 testns 命名空间中运行一个 HTTP 服务器,这意味着监听套接字的范围限定在 testns 命名空间内,正如之前在 sock 数据结构中解释的那样。因此,要传递到 testns 命名空间内的应用程序 IP 和端口的 TCP 数据包将传递到限定在该命名空间内的套接字。

这就是内核如何虚拟化操作系统及其各种子系统(如网络、IPC、挂载等)的方式。

总结

在本章中,你了解了 Linux 命名空间及其如何实现基于用户空间的应用程序之间的隔离。我们还探讨了 Linux 内核中不同的数据结构如何用于实现不同的命名空间。接下来,我们将研究 Linux 内核如何为不同的用户空间进程提供资源限制,以防止某个进程占用操作系统的资源。