关于容器连接:Docker 网络
容器可以被认为是继物理机(第一波浪潮)和虚拟机(第二波浪潮)之后,服务提供的第三波浪潮。您不必使用完整的服务器(硬件或虚拟),而是拥有虚拟操作系统,它更加轻量级。您不必携带完整的环境,只需将应用程序及其配置从一台服务器移动到另一台服务器,它将在那里消耗其资源,而无需任何虚拟层。从开发到运维的项目交付也得到了简化——这是另一个福音。当然,与任何技术一样,您将面临新的和不同的挑战,但可能的风险和问题似乎并非不可克服,最终的回报似乎是巨大的。
Docker 是一个基于 Linux 容器的开源项目,其采用率正在快速增长。Docker 的第一个版本仅在几年前发布,因此该技术尚未被认为是成熟的,但它显示出巨大的潜力。更低的成本、更简单的部署和更快的启动时间无疑有所帮助。
在本文中,我将详细介绍如何设置一个基于多个独立容器的系统,每个容器提供一个独特的、独立的角色,并解释一些底层网络配置的方面。您不能在不了解连接是如何建立的、端口是如何使用的以及桥接和路由是如何设置的情况下考虑生产部署,因此我也将检查这些要点,同时部署一个简单的 Web 数据库查询应用程序。
基本容器网络让我们首先考虑 Docker 如何配置网络方面。当 Docker 服务守护程序启动时,它会在主机系统上配置一个虚拟桥接器 docker0
(图 1)。Docker 选择主机上未使用的子网,并为该桥接器分配一个空闲的 IP 地址。首次尝试是 172.17.42.1/16,但如果存在冲突,则可能会有所不同。此虚拟桥接器处理所有主机-容器通信。
当 Docker 启动容器时,默认情况下,它会在主机上创建一个虚拟接口,并使用唯一的名称,例如 veth220960a
,以及同一子网内的地址。这个新的接口将连接到容器本身的 eth0
接口。为了允许连接,添加了 iptables 规则,使用名为 DOCKER
的链。网络地址转换 (NAT) 用于将流量转发到外部主机,并且主机必须设置为转发 IP 数据包。

图 1. Docker 使用桥接器将同一主机上的所有容器连接到本地网络。
连接容器的标准方法是如前所述的“桥接”模式。但是,对于特殊情况,还有更多方法可以做到这一点,这取决于 docker run
命令的 -net
选项。以下是所有可用模式的列表
-
-net=bridge
— 新容器使用桥接器连接到网络的其余部分。只有其导出的公共端口可以从外部访问。 -
-net=container:ANOTHER.ONE
— 新容器将使用先前定义的容器的网络堆栈。它将共享其 IP 地址和端口号。 -
-net=host
— 这是一个危险的选项。Docker 不会将容器的网络与主机的网络分开。新容器将完全访问主机的网络堆栈。这可能会导致问题和安全风险! -
-net=none
— Docker 将完全不配置容器网络。如果需要,您可以设置自己的 iptables 规则(如果您对此感兴趣,请参阅“资源”)。即使没有网络,容器也可以通过共享目录等方式与外界联系。
Docker 还配置每个容器,使其具有 DNS 解析信息。在容器内运行 findmnt
以生成类似于列表 1 的内容。默认情况下,Docker 使用主机的 /etc/resolv.conf 数据进行 DNS 解析。您可以使用 --dns
和 --dns-search
选项使用不同的域名服务器和搜索列表。
root@4de393bdbd36:/var/www/html# findmnt -o TARGET,SOURCE
TARGET SOURCE
/ /dev/mapper/docker-8:2-25824189-4de...822[/rootfs]
|-/proc proc
| |-/proc/sys proc[/sys]
| |-/proc/sysrq-trigger proc[/sysrq-trigger]
| |-/proc/irq proc[/irq]
| |-/proc/bus proc[/bus]
| `-/proc/kcore tmpfs[/null]
|-/dev tmpfs
| |-/dev/shm shm
| |-/dev/mqueue mqueue
| |-/dev/pts devpts
| `-/dev/console devpts[/2]
|-/sys sysfs
|-/etc/resolv.conf /dev/sda2[/var/lib/docker/containers/4de...822/resolv.conf]
|-/etc/hostname /dev/sda2[/var/lib/docker/containers/4de...822/hostname]
`-/etc/hosts /dev/sda2[/var/lib/docker/containers/4de...822/hosts]
现在您已经了解了 Docker 如何为单个容器设置网络,接下来让我们开发一个小系统,该系统将通过容器部署,然后完成如何将所有部件连接在一起的工作。
设计您的应用程序:世界数据库假设您需要一个应用程序,让您可以搜索名称中包含给定文本字符串的城市。(图 2 显示了一个示例运行。)在本例中,我使用了 GeoNames 的地理信息(请参阅“资源”)来创建适当的数据库。基本上,您使用国家/地区(由其 ISO 3166-1 两位字母代码标识,例如“UY”代表“乌拉圭”)和城市(具有名称、一对坐标以及它们所属的国家/地区)进行工作。用户将能够输入城市名称的一部分并获取所有匹配的城市(不是很复杂)。

图 2. 此示例应用程序查找名称中包含 DARWIN 的城市。
您应该如何设计您的迷你系统?Docker 旨在打包单个应用程序,因此为了利用容器,您将为每个需要的角色运行单独的容器。(这并不一定意味着只有一个进程可以在容器上运行。容器应该履行一个单一的、明确的角色,如果这意味着运行两个或多个程序,那也没关系。在这个非常简单的示例中,每个容器将只有一个进程,但这不一定是普遍情况。)
您将需要一个 Web 服务器(将在容器中运行)和一个数据库服务器(在单独的容器中)。Web 服务器将访问数据库服务器,最终用户将需要连接到 Web 服务器,因此您必须设置这些网络连接。
首先创建数据库容器,无需从头开始。您可以使用官方 MySQL Docker 镜像(请参阅“资源”)并节省一些时间。生成镜像的 Dockerfile 可以指定如何下载所需的地理数据。RUN
命令设置了一个 loaddata.sh 脚本来处理这个问题。(对于纯粹主义者:一个更长的 RUN
命令就足够了,但我在这里使用了三个,以使其更清晰。)请参阅列表 2,了解完整的 Dockerfile 文件;它应该驻留在一个空目录中。构建 worlddb
镜像本身可以从该目录中使用 sudo docker build -t worlddb .
命令完成。
FROM mysql:latest
MAINTAINER Federico Kereki fkereki@gmail.com
RUN apt-get update && \
apt-get -q -y install wget unzip && \
wget 'http://download.geonames.org/export/dump/countryInfo.txt' && \
grep -v '^#' countryInfo.txt >countries.txt && \
rm countryInfo.txt && \
wget 'http://download.geonames.org/export/dump/cities1000.zip' && \
unzip cities1000.zip && \
rm cities1000.zip
RUN echo "\
CREATE DATABASE IF NOT EXISTS world; \
USE world; \
DROP TABLE IF EXISTS countries; \
CREATE TABLE countries ( \
id CHAR(2), \
ignore1 CHAR(3), \
ignore2 CHAR(3), \
ignore3 CHAR(2), \
name VARCHAR(50), \
capital VARCHAR(50), \
PRIMARY KEY (id)); \
LOAD DATA LOCAL INFILE 'countries.txt' \
INTO TABLE countries \
FIELDS TERMINATED BY '\t'; \
DROP TABLE IF EXISTS cities; \
CREATE TABLE cities ( \
id NUMERIC(8), \
name VARCHAR(200), \
asciiname VARCHAR(200), \
alternatenames TEXT, \
latitude NUMERIC(10,5), \
longitude NUMERIC(10,5), \
ignore1 CHAR(1), \
ignore2 VARCHAR(10), \
country CHAR(2)); \
LOAD DATA LOCAL INFILE 'cities1000.txt' \
INTO TABLE cities \
FIELDS TERMINATED BY '\t'; \
" > mydbcommands.sql
RUN echo "#!/bin/bash \n \
mysql -h localhost -u root -p\$MYSQL_ROOT_PASSWORD <mydbcommands.sql \
" >loaddata.sh && \
chmod +x loaddata.sh
sudo docker images
命令验证镜像是否已创建。在您创建基于它的容器之后,您将能够使用 ./loaddata.sh
命令初始化数据库。
现在让我们处理系统的另一部分。您可以利用官方 PHP Docker 镜像,它也包括 Apache。您只需要添加 php5-mysql
扩展即可连接到数据库服务器。脚本应该位于一个新目录中,以及 search.php,这是这个“系统”的完整代码。构建此镜像(您将命名为“worldweb”)需要 sudo docker build -t worldweb .
命令(列表 3)。
FROM php:5.6-apache
MAINTAINER Federico Kereki fkereki@gmail.com
COPY search.php /var/www/html/
RUN apt-get update && \
apt-get -q -y install php5-mysql && \
docker-php-ext-install mysqli
搜索应用程序 search.php 很简单(列表 4)。它在顶部绘制了一个带有单个文本框的基本表单,外加一个“Go!”按钮来运行搜索。搜索结果显示在下面的表格中。过程也很简单——您访问数据库服务器以运行搜索并输出一个表格,其中每找到一个城市就有一行。
列表 4. 整个系统仅由一个 search.php 文件组成。
<html>
<head>
<h3>Cities Search</h3>
</head>
<body>
<form action="search.php">
Search for: <input type="text" name="searchFor"
↪value="<?php echo $_REQUEST["searchFor"]; ?>">
<input type="submit" value="Go!">
<br><br>
<?php
if ($_REQUEST["searchFor"]) {
try {
$conn = mysqli_connect("MYDB", "root", "ljdocker", "world");
$query = "SELECT countries.name, cities.name,
↪cities.latitude, cities.longitude ".
"FROM cities JOIN countries ON cities.country=countries.id ".
"WHERE cities.name LIKE ? ORDER BY 1,2";
$stmt = $conn->prepare($query);
$searchFor = "%".$_REQUEST["searchFor"]."%";
$stmt->bind_param("s", $searchFor);
$stmt->execute();
$result = $stmt->get_result();
echo "<table><tr><td>Country</td><td>City</td><td>Lat</td>
↪<td>Long</td></tr>";
foreach ($result->fetch_all(MYSQLI_NUM) as $row) {
echo "<tr>";
foreach($row as $data) {
echo "<td>".$data."</td>";
}
echo "</tr>";
}
echo "</table>";
} catch (Exception $e) {
echo "Exception " . $e->getMessage();
}
}
?>
</form>
</body>
</html>
两个镜像都已准备就绪,让我们让您的完整“系统”运行起来。
链接容器鉴于您为此示例构建的镜像,创建两个容器很简单,但您希望 Web 服务器能够访问数据库服务器。最简单的方法是将容器链接在一起。首先,启动并初始化数据库容器(列表 5)。
列表 5. 数据库容器必须首先启动,然后才能初始化。
# su -
# docker run -it -d -e MYSQL_ROOT_PASSWORD=ljdocker
↪--name MYDB worlddb
fbd930169f26fce189a9d6020861eb136643fdc9ee73a4e1f114e0bfd0fe6a5c
# docker exec -it MYDB bash
root@fbd930169f26:/# dir
bin cities1000.txt dev etc lib
↪loaddata.sh mnt opt root sbin
↪srv tmp var
boot countries.txt entrypoint.sh home lib64 media
↪mydbcommands.sql proc run selinux sys usr
root@fbd930169f26:/# ./loaddata.sh
Warning: Using a password on the command line interface
↪can be insecure.
root@fbd930169f26:/# exit
现在,启动 Web 容器,使用 docker run -it -d -p 80:80 --link MYDB:MYDB --name MYWEB worldweb
。此命令有几个有趣的选项
-
-p 80:80
— 这意味着容器的 80 端口(标准 HTTP 端口)将作为主机本身上的 80 端口发布。 -
--link MYDB:MYDB
— 这意味着 MYDB 容器(您之前启动的)将可以从 MYWEB 容器访问,别名也为 MYDB。(使用数据库容器名称作为别名是合乎逻辑的,但不是强制性的。)MYDB 容器在网络中不可见,仅对 MYWEB 可见。
在 MYWEB 容器中,/etc/hosts 包括每个链接容器的条目(列表 6)。现在您可以看到 search.php 如何连接到数据库。它通过链接容器时给定的名称来引用它(请参阅列表 4 中的 mysqli_connect
调用)。在本例中,MYDB 在 IP 172.17.0.2 上运行,MYWEB 在 172.17.0.3 上运行。
# su -
# docker exec -it MYWEB bash
root@fbff94177fc7:/var/www/html# cat /etc/hosts
172.17.0.3 fbff94177fc7
127.0.0.1 localhost
...
172.17.0.2 MYDB
root@fbff94177fc7:/var/www/html# export
declare -x MYDB_PORT="tcp://172.17.0.2:3306"
declare -x MYDB_PORT_3306_TCP="tcp://172.17.0.2:3306"
declare -x MYDB_PORT_3306_TCP_ADDR="172.17.0.2"
declare -x MYDB_PORT_3306_TCP_PORT="3306"
declare -x MYDB_PORT_3306_TCP_PROTO="tcp"
...
环境变量基本上为每个链接提供所有连接数据:它链接到哪个容器,使用哪个端口和协议,以及如何从目标容器访问每个导出的端口。在本例中,MySQL 容器仅导出标准 3306 端口,并使用 TCP 进行连接。关于这些变量,只有一个问题。如果您碰巧重启 MYDB 容器,Docker 不会更新它们(尽管它会更新 /etc/hosts 信息),因此如果您使用它们,则必须小心!
检查 iptables 配置,您会找到一个新的 DOCKER
链(列表 7)。主机上的 80 端口连接到 MYWEB 容器中的 80 端口 (http
),并且有一个端口 3306 (mysql
) 的连接将 MYWEB 链接到 MYDB。
# sudo iptables --list DOCKER
Chain DOCKER (1 references)
target prot opt source destination
ACCEPT tcp -- anywhere 172.17.0.3 tcp dpt:http
ACCEPT tcp -- 172.17.0.3 172.17.0.2 tcp dpt:mysql
ACCEPT tcp -- 172.17.0.2 172.17.0.3 tcp spt:mysql
如果您需要循环链接(容器 A 链接到容器 B,反之亦然),那么您使用标准 Docker 链接就倒霉了,因为您无法链接到未运行的容器!您可能需要研究 docker-dns(请参阅“资源”),它可以根据正在运行的容器动态创建 DNS 记录。(实际上,稍后当您在单独的主机中设置容器时,您将使用 DNS。)另一种可能性是创建一个第三个容器 C,A 和 B 都将链接到它,并通过它它们将互连。您还可以研究编排包和服务注册/发现包。Docker 在这些领域仍在发展,随时可能有新的解决方案可用。
您刚刚看到了如何将容器链接在一起,但这有一个问题。它仅适用于同一主机上的容器,而不适用于单独主机上的容器。人们正在努力解决此限制,但目前可以使用适当的解决方案。
编织远程容器如果您有容器在不同的服务器(本地和远程服务器)上运行,您可以设置所有内容,以便容器最终可以相互连接,但这将是大量的工作和复杂的配置。Weave(当前版本为 0.9.0,但发展迅速;请参阅“资源”以获取最新版本)允许您定义虚拟网络,以便容器可以透明地相互连接(可以选择使用加密以增加安全性),就好像它们都在同一服务器上一样。Weave 的行为就像一个巨大的交换机,所有容器都连接在同一个虚拟网络中。每个主机上都必须运行一个实例才能完成路由工作。
在本地,在其运行的服务器上,Weave 路由器建立一个网络桥接器,平淡地命名为 weave。它还添加了从每个容器以及从 Weave 路由器本身到桥接器的虚拟以太网连接。每当本地容器需要联系远程容器时,数据包都会被转发(可能使用“多跳”路由)到其他 Weave 路由器,直到它们被(远程)Weave 路由器传递到远程容器。本地流量不受影响;此转发仅适用于远程容器(图 3)。

图 3. Weave 添加了几个虚拟设备,以将部分流量最终重定向到其他服务器。
构建容器网络就是在每台服务器上启动 Weave,然后启动容器。(好吧,这里缺少一个步骤;我稍后会讲到。)首先,在每台服务器上使用 sudo weave launch
启动 Weave。如果您计划跨不受信任的网络连接容器,请添加密码(显然,所有 Weave 实例都相同),方法是添加 -password some.secret.password
选项。如果您的所有服务器都在安全网络中,则可以不用密码。请参阅侧边栏,了解所有可用的 weave
命令行选项的列表。
weave
命令行选项
-
weave attach
— 将先前启动的正在运行的 Docker 容器附加到 Weave 实例。 -
weave connect
— 将本地 Weave 实例连接到另一个实例,以将其添加到其网络中。 -
weave detach
— 从 Weave 实例分离 Docker 容器。 -
weave expose
— 将 Weave 网络与主机的网络集成。 -
weave hide
— 恢复之前的expose
命令。 -
weave launch
— 启动本地 Weave 路由器实例;您可以指定密码来加密通信。 -
weave launch-dns
— 启动本地 DNS 服务器以连接不同服务器上的 Weave 实例。 -
weave ps
— 列出附加到 Weave 实例的所有正在运行的 Docker 容器。 -
weave reset
— 停止正在运行的 Weave 实例并删除所有与其网络相关的内容。 -
weave run
— 启动 Docker 容器。 -
weave setup
— 下载 Weave 运行所需的一切。 -
weave start
— 启动已停止的 Weave 实例,使其重新加入 Weave 拓扑。 -
weave status
— 提供有关正在运行的 Weave 实例的数据,包括加密、对等方、路由等。 -
weave stop
— 停止正在运行的 Weave 实例,使其脱离 Weave 拓扑。 -
weave stop-dns
— 停止正在运行的 Weave DNS 服务。 -
weave version
— 列出正在运行的 Weave 组件的版本;今天(2015 年 4 月)将是 0.9.0。
当您连接两个 Weave 路由器时,它们会交换拓扑信息以“了解”网络的其余部分。收集的数据用于路由决策,以避免不必要的数据包广播。为了检测可能的变化并解决可能出现的任何网络问题,Weave 路由器会定期监控连接。要连接两个路由器,请在服务器上键入 weave connect the.ip.of.another.server
命令。(要删除 Weave 路由器,请执行 weave forget ip.of.the.dropped.host
。)每当您向现有网络添加新的 Weave 路由器时,您无需将其连接到每个先前的路由器。您只需为其提供同一网络中单个现有 Weave 实例的地址,从那时起,它将自行收集所有拓扑信息。其余路由器也会在过程中类似地更新其自身的信息。
让我们启动 Docker 容器,附加到 Weave 路由器。容器本身像以前一样运行;唯一的区别是它们是通过 Weave 启动的。本地网络连接像以前一样工作,但与远程容器的连接由 Weave 管理,Weave 封装(和加密)流量并将其发送到远程 Weave 实例。(这使用端口 6783,该端口必须在所有运行 Weave 的服务器上打开且可访问。)虽然我不会在此处深入探讨,但对于更复杂的应用程序,您可以拥有多个独立的子网,因此同一应用程序的容器将能够相互通信,但不能与其他应用程序的容器通信。
首先,确定您将使用的(未使用的)子网,并在其上为每个容器分配不同的 IP。然后,您可以 weave run
每个容器以通过 Docker 启动它,设置所有需要的网络连接。但是,在这里您会遇到一个障碍,这与我之前提到的缺失步骤有关。不同主机上的容器将如何相互连接?Docker 的 --link
选项仅在主机内有效,如果您尝试链接到其他主机上的容器,则它将不起作用。当然,您可以使用 IP,但这对于该设置的维护将是一项繁琐的工作。最佳解决方案是使用 DNS,而 Weave 已经包含一个适当的软件包 WeaveDNS。
WeaveDNS(它本身就是一个 Docker 容器)在 Weave 网络上运行。每个网络服务器上都必须运行一个 WeaveDNS 实例,使用 weave launch-dns
命令。您必须为 WeaveDNS 使用不同的、未使用的子网,并在其中为每个实例分配一个不同的 IP。然后,在启动 Docker 容器时,添加 --with-dns
选项,以便 DNS 信息可用。您应该在 .weave.local
域中为容器指定主机名,该主机名将自动输入到 WeaveDNS 注册表中。完整的网络将如图 4 所示。

图 4. 使用 Weave,本地和远程网络中的容器可以透明地相互连接;使用 Weave DNS 简化了访问。
现在,让您的迷你系统运行起来。我将稍微作弊一下,不用远程服务器,而是使用虚拟机作为本例。我的主机器(在 192.168.1.200)运行 OpenSUSE 13.2,而虚拟机(在 192.168.1.108)运行 Linux Mint 17,只是为了多样性。尽管发行版不同,但 Docker 容器的工作方式完全相同,这显示了它的真正可移植性(列表 8)。
列表 8. 在两台服务器上运行 Weave 网络。
> # At 192.168.1.200 (OpenSUSE 13.2 server)
> su -
$ weave launch
$ weave launch-dns 10.10.10.1/24
$ C=$(weave run --with-dns 10.22.9.1/24 -it -d -e
↪MYSQL_ROOT_PASSWORD=ljdocker -h MYDB.weave.local --name MYDB worlddb)
$ # You can now enter MYDB with "docker exec -it $C bash"
> # At 192.168.1.108 (Linux Mint virtual machine)
> su -
$ weave launch
$ weave launch-dns 10.10.10.2/24
$ weave connect 192.168.1.200
$ D=$(weave run --with-dns 10.22.9.2/24 -it -d -p 80:80 -h
↪MYWEB.weave.local --name MYWEB worldweb)
生成的配置如图 5 所示。有两台主机,分别在 192.168.1.200 和 192.168.1.108 上。尽管未显示,但两者都打开了端口 6783 以供 Weave 工作。在第一台主机中,您会找到 MYDB MySQL 容器(在 10.22.9.1/24 上,端口 3306 打开,但仅在该子网上)和一个 WeaveDNS 服务器,地址为 10.10.10.1/24。在第二台主机中,您会找到 MYWEB Apache+PHP 容器(在 10.22.9.2/24 上,端口 80 打开,导出到服务器)和一个 WeaveDNS 服务器,地址为 10.10.10.2/24。从外部,只有 MYWEB 容器的 80 端口可访问。

图 5. 最终的基于 Docker 容器的系统,在单独的系统上运行,由 Weave 连接。
由于 192.168.1.108 服务器上的 80 端口直接连接到 MYWEB 服务器上的 80 端口,因此您可以访问 http://192.168.1.108/search.php 并获得您之前看到的网页(图 2)。现在您拥有一个多主机 Weave 网络,具有 DNS 服务和远程 Docker 容器,就像它们驻留在同一主机上一样——成功!
结论现在您知道如何开发一个多容器系统(好吧,它不是很大,但仍然是),并且您已经了解了 Docker(和 Weave)网络的内部结构的一些细节。Docker 仍在成熟,并且肯定会出现更好的工具来简化更大、更复杂的应用程序的配置、分发和部署。当前容器网络解决方案的可用性表明您已经可以开始投资这些技术,尽管请务必关注新的发展,以进一步简化您的工作。
资源从 https://docker.net.cn 获取 Docker 本身。实际代码位于 https://github.com/docker/docker。
有关 Docker 网络配置的更详细文档,请参阅 https://docs.docker.net.cn/articles/networking。
docker-dns 站点位于 https://npmjs.net.cn/package/docker-dns,其源代码位于 https://github.com/bnfinet/docker-dns。
官方 MySQL Docker 镜像位于 https://registry.hub.docker.com/_/mysql。如果您愿意,还有 MariaDB 的官方仓库 (https://registry.hub.docker.com/_/mariadb)。让它工作应该不成问题。
Apache+PHP 官方 Docker 镜像位于 https://registry.hub.docker.com/_/php。
Weave 位于 http://weave.works,代码本身位于 GitHub 上,网址为 https://github.com/weaveworks/weave。有关其功能的更多详细信息,请访问 https://zettio.github.io/weave/features.html。
WeaveDNS 在 GitHub 上,网址为 https://github.com/weaveworks/weave/tree/master/weavedns。
有关 Linux Journal 中关于 Docker 的更多文章,请阅读以下内容
-
David Strauss 的“容器——不是虚拟机——是未来的云”:https://linuxjournal.cn/content/containers—not-virtual-machines—are-future-cloud。
-
Dirk Merkel 的“Docker:用于一致开发和部署的轻量级 Linux 容器”:https://linuxjournal.cn/content/docker-lightweight-linux-containers-consistent-development-and-deployment。
-
Rami Rosen 的“Linux 容器和未来的云”:https://linuxjournal.cn/content/linux-containers-and-future-cloud。
我在本文示例中使用的地理数据来自 GeoNames http://www.geonames.org。特别是,我使用了国家/地区表 (http://download.geonames.org/export/dump/countryInfo.txt) 和城市(人口超过 1,000 人)表 (http://download.geonames.org/export/dump/cities1000.zip),但有更大和更小的集合。