<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>绿色记忆 &#187; PaaS</title>
	<atom:link href="https://blog.gmem.cc/category/work/cloud/paas/feed" rel="self" type="application/rss+xml" />
	<link>https://blog.gmem.cc</link>
	<description></description>
	<lastBuildDate>Mon, 13 Apr 2026 08:03:10 +0000</lastBuildDate>
	<language>en-US</language>
		<sy:updatePeriod>hourly</sy:updatePeriod>
		<sy:updateFrequency>1</sy:updateFrequency>
	<generator>http://wordpress.org/?v=3.9.14</generator>
	<item>
		<title>K8S集群跨云迁移</title>
		<link>https://blog.gmem.cc/k8s-migration</link>
		<comments>https://blog.gmem.cc/k8s-migration#comments</comments>
		<pubDate>Tue, 27 Dec 2022 11:37:50 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=39115</guid>
		<description><![CDATA[<p>要将K8S集群从一个云服务商迁移到另外一个，需要解决以下问题： 各种K8S资源的迁移 工作负载所挂载的数据卷的迁移 工作负载所使用的镜像的迁移 K8S资源、数据卷的迁移，可以考虑基于Velero项目。镜像仓库的迁移较为简单，可供选择的开源工具包括阿里云的image-syncer、腾讯云的image-transfer。 Velero 简介 Velero是一个专注于K8S集群备份与恢复的开源项目，通过备份源集群，并在目标集群进行恢复，即可完成集群的跨云迁移。 Velero由一组运行在被备份/恢复的K8S集群中的服务，外加一个CLI客户端组成。服务端包含若干控制器，这些控制器监听备份、恢复相关的自定义资源（CRD）并进行处理。CLI主要是提供了创建、修改、删除自定义资源的快捷方式，让用户不必编写复杂的YAML。 主要新特性 在Kubernetes故障检测和自愈一文中，我们对Velero做过调研，这三年多时间里，Velero新增加的特性包括： 多读写的持久卷不会被重复备份 云服务商插件从Velero代码库独立出 基于Restic的持久卷备份总是增量的，即使Pod被调度走 克隆命名空间（将备份恢复到另外一个命名空间）时自动克隆相关的持久卷 支持处理基于CSI驱动创建的持久卷，目前支持的厂商包括AWS、Azure、GCP 支持报告备份、恢复的进度 支持备份资源的所有API版本 支持自动基于Restic进行卷备份（--default-volumes-to-restic） 选项restoreStatus，用于定制那些资源的状态会被恢复 --existing-resource-policy用于修改默认的恢复策略。默认策略时当资源存在时不覆盖（除了ServiceAccount），该选项设置为update，则会更新已存在的资源 从1.10开始，支持Kopia，作为Restic的备选。Kopia在备份时消耗的时间较少、在备份数据量很大或者文件数量很多时，Kopia常常有更好的性能 <a class="read-more" href="https://blog.gmem.cc/k8s-migration">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/k8s-migration">K8S集群跨云迁移</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><p>要将K8S集群从一个云服务商迁移到另外一个，需要解决以下问题：</p>
<ol style="list-style-type: undefined;">
<li>各种K8S资源的迁移</li>
<li>工作负载所挂载的数据卷的迁移</li>
<li>工作负载所使用的镜像的迁移</li>
</ol>
<p>K8S资源、数据卷的迁移，可以考虑基于<a href="https://velero.io/">Velero项目</a>。镜像仓库的迁移较为简单，可供选择的开源工具包括阿里云的<a href="https://github.com/AliyunContainerService/image-syncer">image-syncer</a>、腾讯云的<a href="https://github.com/tkestack/image-transfer">image-transfer</a>。</p>
<div class="blog_h1"><span class="graybg">Velero</span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>Velero是一个专注于K8S集群备份与恢复的开源项目，通过备份源集群，并在目标集群进行恢复，即可完成集群的跨云迁移。</p>
<p>Velero由一组运行在被备份/恢复的K8S集群中的服务，外加一个CLI客户端组成。服务端包含若干控制器，这些控制器监听备份、恢复相关的自定义资源（CRD）并进行处理。CLI主要是提供了创建、修改、删除自定义资源的快捷方式，让用户不必编写复杂的YAML。</p>
<div class="blog_h3"><span class="graybg">主要新特性</span></div>
<p>在<a href="/problem-detection-and-auto-repairing-in-k8s#velero">Kubernetes故障检测和自愈</a>一文中，我们对Velero做过调研，这三年多时间里，Velero新增加的特性包括：</p>
<ol style="list-style-type: undefined;">
<li>多读写的持久卷不会被重复备份</li>
<li>云服务商插件从Velero代码库独立出</li>
<li>基于Restic的持久卷备份总是增量的，即使Pod被调度走</li>
<li>克隆命名空间（将备份恢复到另外一个命名空间）时自动克隆相关的持久卷</li>
<li>支持处理基于CSI驱动创建的持久卷，目前支持的厂商包括AWS、Azure、GCP</li>
<li>支持报告备份、恢复的进度</li>
<li>支持备份资源的所有API版本</li>
<li>支持自动基于Restic进行卷备份（--default-volumes-to-restic）</li>
<li>选项restoreStatus，用于定制那些资源的状态会被恢复</li>
<li>--existing-resource-policy用于修改默认的恢复策略。默认策略时当资源存在时不覆盖（除了ServiceAccount），该选项设置为update，则会更新已存在的资源</li>
<li>从1.10开始，支持Kopia，作为Restic的备选。Kopia在备份时消耗的时间较少、在备份数据量很大或者文件数量很多时，Kopia常常有更好的性能</li>
</ol>
<div class="blog_h3"><span class="graybg">备份流程</span></div>
<p>备份分为两种方式：按需备份、周期性备份。它们都是收集（支持过滤）K8S资源并打包上传到存储后端（例如云服务商的对象存储）。</p>
<p>备份的典型流程如下：</p>
<ol>
<li>用户通过命令<pre class="crayon-plain-tag">velero backup create</pre>创建<pre class="crayon-plain-tag">Backup</pre>资源</li>
<li>控制器<pre class="crayon-plain-tag">BackupController</pre>监听到新创建的Backup资源，并进行校验</li>
<li>校验成功后BackupController开始执行备份操作。默认情况下会为所有持久卷创建快照，使用命令行选项<pre class="crayon-plain-tag">--snapshot-volumes=false</pre>改变此默认行为</li>
<li>BackupController调用对象存储，上传备份文件</li>
</ol>
<p>备份资源的时候，Velero使用<span style="background-color: #c0c0c0;">首选API版本（preferred version）获取和保存资源</span>。举例来说，源API Server中teleport组具有v1alpha1、v1两个版本，其中v1是首选版本，那么Velero备份的时候将使用v1格式存储资源。在恢复的时候，目标集群必须支持teleport/v1版本（但不必是首选版本），这意味着，<span style="background-color: #c0c0c0;">恢复到和源的版本不同的K8S集群可能会出错，因为API版本会有新增或删除</span>。</p>
<p>备份的时候，可以通过--ttl来指定备份的存留时间。当存留时间到达后，备份中的K8S资源、备份文件、快照、关联的Restore，均被删除。如果删除失败Velero会给Backup对象添加velero.io/gc-failure=REASON标签。</p>
<p>需要注意：<span style="background-color: #c0c0c0;">Velero基于快照的卷备份，在跨云迁移这一场景下，是没有意义的，不可能把云A生成的快照拿到云T上恢复</span>。</p>
<div class="blog_h3"><span class="graybg">恢复流程</span></div>
<p>恢复是将先前生成的备份（包括K8S资源 + 数据卷），同步到目标K8S集群的过程。<span style="background-color: #c0c0c0;">目标集群可以是源集群自身</span>，可以<span style="background-color: #c0c0c0;">对备份中的资源进行过滤</span>，仅恢复一部分数据。</p>
<p>恢复产生的K8S资源，具有velero.io/restore-name=RESTORE_NAME标签。RESTORE_NAME即Restore资源的名字，默认形式为BACKUP_NAME-TIMESTAMP，TIMESTAMP的默认格式为YYYYMMDDhhmmss。</p>
<p>恢复的典型流程如下：</p>
<ol>
<li>用户通过命令<pre class="crayon-plain-tag">velero restore create</pre>创建<pre class="crayon-plain-tag">Restore</pre>资源</li>
<li>控制器<pre class="crayon-plain-tag">RestoreController</pre>监听到新创建的Restore资源，并进行校验</li>
<li>校验成功后，RestoreController从对象存储中获取备份的信息，对备份的资源进行一些预处理，例如API版本检查，以保证它们能够在新集群中运行</li>
<li>开始逐个资源的进行恢复</li>
</ol>
<p><span style="background-color: #c0c0c0;">默认情况下，Velero不会对目标集群进行任何修改、删除操作</span>，如果某个资源已经存在，它会简单的跳过。设置 --existing-resource-policy=update则Velero会尝试修改已经存在的资源，使其和备份中的同名资源内容匹配。</p>
<div class="blog_h3"><span class="graybg">关于对象存储</span></div>
<p>保存备份信息的<span style="background-color: #c0c0c0;">对象存储，是Velero的单一数据源</span>（source of truth），也就是说：</p>
<ol>
<li>如果对象存储中具有备份信息，但是K8S API资源中没有对应的Backup对象，那么会自动创建这些对象</li>
<li>如果K8S中有Backup对象，但是在对象存储中没有对应信息，那么此Backup会被删除</li>
</ol>
<p>这一特性也让跨云迁移场景获益 —— 源、目标集群不需要任何直接的关联，云服务商的对象存储服务将作为唯一的媒介。</p>
<p>定义对象存储位置的CRD是<pre class="crayon-plain-tag">BackupStorageLocation</pre>，它可以指向一个桶，或者桶中的某个前缀，<span style="background-color: #c0c0c0;">Velero备份的元数据数据存放在其中</span>。<span style="background-color: #c0c0c0;">通过文件系统方式（Restic/Kopia）备份的卷，也存放在桶中</span>。通过快照方式备份的卷的数据，其存储机制是云服务商私有的，不会存放到桶中。</p>
<p>每个Backup可以使用一个BackupStorageLocation。</p>
<div class="blog_h3"><span class="graybg">关于快照</span></div>
<p>存储卷快照相关的信息存储存储在<pre class="crayon-plain-tag">VolumeSnapshotLocation</pre>中，由于快照实现技术完全取决于云服务商，因此该CRD的包含的字段取决于对应插件。</p>
<p>每个Backup，对于任何一个Volume Provider，可以使用一个VolumeSnapshotLocation。</p>
<div class="blog_h3"><span class="graybg">关于提供者</span></div>
<p>Velero提供了一种插件机制，将存储提供者从Velero核心中独立出来。</p>
<div class="blog_h3"><span class="graybg">钩子机制</span></div>
<p>Velero提供了若干钩子来扩展标准的备份/恢复流程。</p>
<p>备份钩子在备份流程中执行。例如你可以利用备份钩子，在创建快照前，通知数据库刷空内存缓冲。</p>
<p>恢复钩子在恢复流程中执行。例如你可以在应用启动前，通过钩子进行某种初始化操作。</p>
<div class="blog_h2"><span class="graybg">安装</span></div>
<div class="blog_h3"><span class="graybg">安装CLI</span></div>
<p>目前最新稳定版本是<a href="https://github.com/vmware-tanzu/velero/releases/tag/v1.9.5">1.9.5</a>，点击此连接，选择匹配操作系统的压缩包，下载解压，存放velero到$PATH即可。执行下面的命令配置自动提示：</p>
<pre class="crayon-plain-tag">echo 'source &lt;(velero completion bash)' &gt;&gt;~/.bashrc </pre>
<p>下面的命令实例，展示了如何进行客户端设置：</p>
<pre class="crayon-plain-tag"># 启用客户端特性
velero client config set features=EnableCSI
# 关闭彩色字符输出
velero client config set colorized=false</pre>
<div class="blog_h3"><span class="graybg">安装服务端</span></div>
<p>使用CLI安装：</p>
<pre class="crayon-plain-tag">velero install 
    # 指定命名空间
    --namespace=teleport-system
    # 支持基于文件系统的卷备份（FSB）
    --use-node-agent
    # 默认启用基于文件系统的卷备份，来备份所有Pod卷。缺省行为是必须指定注解才备份
    # backup.velero.io/backup-volumes=YOUR_VOLUME_NAME_1,YOUR_VOLUME_NAME_2,...
    # 如果准备使用FSB作为唯一的卷备份机制，可以开启
    # 启用该命令后，如果还想在某次备份时，使用快照备份，使用命令 backup create --snapshot-volumes
    # 也可以在备份时使用backup create --default-volumes-to-fs-backup，而不是在安装阶段全局性设置
    --default-volumes-to-fs-backup
    # 特性开关，这些开关也会被传递给node-agent
    --features=EnableCSI,EnableAPIGroupVersions
    # 设置资源请求/限制。默认值对于1000-资源、100GB-卷的场景足够。如果使用FSB，则可能需要增加资源请求/限制
    --velero-pod-cpu-request
    --velero-pod-mem-request
    --velero-pod-cpu-limit
    --velero-pod-mem-limit
    --node-agent-pod-cpu-request
    --node-agent-pod-mem-request
    --node-agent-pod-cpu-limit
    --node-agent-pod-mem-limit
    # 在安装阶段，你可以指定一个BackupLocation和SnapshotLocation
    --provider aws
    --bucket backups 
    --secret-file ./aws-iam-creds 
    --backup-location-config region=us-east-2 
    --snapshot-location-config region=us-east-2
    # 如果在安装阶段，不配置BackupLocation，可以不指定--bucket和--provider，同时：
    --no-default-backup-location
    # 仅仅生成YAML
    --dry-run -o yaml</pre>
<p>在安装完毕后，你可以设置默认的 BackupLocation、SnapshotLocation：</p>
<pre class="crayon-plain-tag">velero backup-location create backups-primary \
    --provider aws \
    --bucket velero-backups \
    --config region=us-east-1 \
    --default

# SnapshotLocation需要为每一个volume snapshot provider设置
velero server --default-volume-snapshot-locations="PROVIDER-NAME:LOCATION-NAME,PROVIDER2-NAME:LOCATION2-NAME"</pre>
<p>在安装完毕后，可以添加额外的volume snapshot provider：</p>
<pre class="crayon-plain-tag">velero plugin add registry/image:version
# 为此volume snapshot provider配置SnapshotLocation
velero snapshot-location create NAME -provider PROVIDER-NAME [--config PROVIDER-CONFIG]</pre>
<div class="blog_h2"><span class="graybg">测试跨云迁移</span></div>
<p>我们分别在阿里云、腾讯云上创建一个K8S集群，分别作为源、目标集群，并尝试利用Velero将工作负载从源迁移到目标。</p>
<div class="blog_h3"><span class="graybg">创建集群</span></div>
<p>到云服务商的控制台创建，这里不赘述具体步骤。</p>
<div class="blog_h3"><span class="graybg">无状态工作负载迁移</span></div>
<p>目前K8S集群的主流用法是运行无状态的工作负载。数据库等有状态的基础服务，大多数用户选择使用云平台提供的PaaS产品。这一现状实质上让K8S的迁移变得简单，因为基本不需要考虑数据卷的问题。</p>
<p>这里以一个Nginx的Deployment + Service为例，测试Velero的备份、恢复特性。首先在源集群创建相应K8S资源：</p>
<pre class="crayon-plain-tag">apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - image: nginx:1.7.9
        name: nginx
        ports:
        - containerPort: 80

---

apiVersion: v1
kind: Service
metadata:
  labels:
    app: nginx
  name: nginx
spec:
  ports:
  - port: 80
    targetPort: 80
  selector:
    app: nginx
  type: LoadBalancer</pre>
<p>&nbsp;</p>
<p>&nbsp;</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/k8s-migration">K8S集群跨云迁移</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/k8s-migration/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>编写Kubernetes风格的APIServer</title>
		<link>https://blog.gmem.cc/kubernetes-style-apiserver</link>
		<comments>https://blog.gmem.cc/kubernetes-style-apiserver#comments</comments>
		<pubDate>Fri, 20 Aug 2021 07:33:34 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=38317</guid>
		<description><![CDATA[<p>背景 前段时间接到一个需求做一个工具，工具将在K8S中运行。需求很适合用控制器模式实现，很自然的就基于kubebuilder进行开发了。但是和K8S环境提供方沟通时发现，他们不允许工作负载调用控制平面的接口，这该怎么办呢。 最快速的解决方案是，自己运行一套kube-apiserver + etcd。但是这对我们来说太重了，kube-apiserver很多我们不需要的特性占用了过多资源，因此这里想寻找一个更轻量的方案。 apiserver库 kubernetes/apiserver同步自kubernertes主代码树的taging/src/k8s.io/apiserver目录，它提供了创建K8S风格的API Server所需要的库。包括kube-apiserver、kube-aggregator、service-catalog在内的很多项目都依赖此库。 apiserver库的目的主要是用来构建API Aggregation中的Extension API Server。它提供的特性包括： 将authn/authz委托给主kube-apiserver 支持kuebctl兼容的API发现 支持admisson control链 支持版本化的API类型 K8S提供了一个样例kubernetes/sample-apiserver，但是这个例子依赖于主kube-apiserver。即使不使用authn/authz或API聚合，也是如此。你需要通过--kubeconfig来指向一个主kube-apiserver，样例中的SharedInformer依赖于会连接到主kube-apiserver来访问K8S资源。 sample-apiserver分析 显然我们是不能对主kube-apiserver有任何依赖的，这里分析一下sample-apiserver的代码，看看如何进行改动。 入口点 [crayon-69de136b9d3d7977318462/] <a class="read-more" href="https://blog.gmem.cc/kubernetes-style-apiserver">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/kubernetes-style-apiserver">编写Kubernetes风格的APIServer</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">背景</span></div>
<p>前段时间接到一个需求做一个工具，工具将在K8S中运行。需求很适合用控制器模式实现，很自然的就基于kubebuilder进行开发了。但是和K8S环境提供方沟通时发现，他们不允许工作负载调用控制平面的接口，这该怎么办呢。</p>
<p>最快速的解决方案是，自己运行一套kube-apiserver + etcd。但是这对我们来说太重了，kube-apiserver很多我们不需要的特性占用了过多资源，因此这里想寻找一个更轻量的方案。</p>
<div class="blog_h1"><span class="graybg">apiserver库</span></div>
<p><a href="https://github.com/kubernetes/apiserver">kubernetes/apiserver</a>同步自kubernertes主代码树的taging/src/k8s.io/apiserver目录，它提供了创建K8S风格的API Server所需要的库。包括kube-apiserver、kube-aggregator、service-catalog在内的很多项目都依赖此库。</p>
<p>apiserver库的目的主要是用来构建API Aggregation中的Extension API Server。它提供的特性包括：</p>
<ol>
<li>将authn/authz委托给主kube-apiserver</li>
<li>支持kuebctl兼容的API发现</li>
<li>支持admisson control链</li>
<li>支持版本化的API类型</li>
</ol>
<p>K8S提供了一个样例<a href="https://github.com/kubernetes/sample-apiserver">kubernetes/sample-apiserver</a>，但是这个例子依赖于主kube-apiserver。即使不使用authn/authz或API聚合，也是如此。你需要通过--kubeconfig来指向一个主kube-apiserver，样例中的SharedInformer依赖于会连接到主kube-apiserver来访问K8S资源。</p>
<div class="blog_h2"><span class="graybg">sample-apiserver分析</span></div>
<p>显然我们是不能对主kube-apiserver有任何依赖的，这里分析一下sample-apiserver的代码，看看如何进行改动。</p>
<div class="blog_h3"><span class="graybg">入口点</span></div>
<pre class="crayon-plain-tag">func main() {
	logs.InitLogs()
	defer logs.FlushLogs()

	stopCh := genericapiserver.SetupSignalHandler()
	// 初始化服务器选项
	options := server.NewWardleServerOptions(os.Stdout, os.Stderr)
	// 启动服务器
	cmd := server.NewCommandStartWardleServer(options, stopCh)
	cmd.Flags().AddGoFlagSet(flag.CommandLine)
	if err := cmd.Execute(); err != nil {
		klog.Fatal(err)
	}
}</pre>
<div class="blog_h3"><span class="graybg">服务器选项 </span></div>
<pre class="crayon-plain-tag">type WardleServerOptions struct {
	RecommendedOptions *genericoptions.RecommendedOptions

	SharedInformerFactory informers.SharedInformerFactory
	StdOut                io.Writer
	StdErr                io.Writer
}

func NewWardleServerOptions(out, errOut io.Writer) *WardleServerOptions {
	o := &amp;WardleServerOptions{
		RecommendedOptions: genericoptions.NewRecommendedOptions(
			// 数据默认存放在Etcd的/registry/wardle.example.com目录下
			defaultEtcdPathPrefix,
			// 指定wardle.example.com/v1alpha1使用遗留编解码器
			apiserver.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion),
			// API Server的进程信息
			genericoptions.NewProcessInfo("wardle-apiserver", "wardle"),
		),

		StdOut: out,
		StdErr: errOut,
	}
	// wardle.example.com/v1alpha1中的所有对象存储到Etcd
	o.RecommendedOptions.Etcd.StorageConfig.EncodeVersioner = 
		runtime.NewMultiGroupVersioner(v1alpha1.SchemeGroupVersion, 
			schema.GroupKind{Group: v1alpha1.GroupName})
	return o
}</pre>
<p>可以看到，选项的核心是genericoptions.RecommendedOptions，顾名思义，它用于提供运行apiserver所需的“推荐”选项：</p>
<pre class="crayon-plain-tag">type RecommendedOptions struct {
	// Etcd相关的配置
	Etcd           *EtcdOptions
	// HTTPS相关选项，包括监听地址、证书等配置。还负责创建并设置Lookback专用的rest.Config
	SecureServing  *SecureServingOptionsWithLoopback
	// authn选项
	Authentication *DelegatingAuthenticationOptions
	// authz选项
	Authorization  *DelegatingAuthorizationOptions
	// 审计选项
	Audit          *AuditOptions
	// 用于启用剖析、竞态条件剖析
	Features       *FeatureOptions
	// 核心API选项，指定主kube-apiserver配置文件位置
	CoreAPI        *CoreAPIOptions

	// 特性开关
	FeatureGate featuregate.FeatureGate
	// 所有以上选项的ApplyTo被调用后，调用下面的函数。返回的PluginInitializer会传递给Admission.ApplyTo
	ExtraAdmissionInitializers func(c *server.RecommendedConfig) ([]admission.PluginInitializer, error)
	Admission                  *AdmissionOptions
	// 提供服务器信息
	ProcessInfo *ProcessInfo
	// Webhook选项
	Webhook     *WebhookOptions
	// 控制服务器的出站流量
	EgressSelector *EgressSelectorOptions
}</pre>
<p>推荐的选项取值，可以由函数genericoptions.NewRecommendedOptions()提供。RecommendedOptions支持通过命令行参数获取选项取值：</p>
<pre class="crayon-plain-tag">func (o *RecommendedOptions) AddFlags(fs *pflag.FlagSet) {}</pre>
<div class="blog_h3"><span class="graybg">准备服务器 </span></div>
<p>服务器实现为cobra.Command命令，首先会将RecommendedOptions绑定到命令行参数。</p>
<pre class="crayon-plain-tag">func NewCommandStartWardleServer(defaults *WardleServerOptions, stopCh &lt;-chan struct{}) *cobra.Command {
	o := *defaults
	cmd := &amp;cobra.Command{
		Short: "Launch a wardle API server",
		Long:  "Launch a wardle API server",
		RunE: func(c *cobra.Command, args []string) error {
			// ...
		},
	}

	flags := cmd.Flags()
	// 将选项添加为命令行标记
	o.RecommendedOptions.AddFlags(flags)
	utilfeature.DefaultMutableFeatureGate.AddFlag(flags)

	return cmd
}</pre>
<p>然后，调用cmd.Execute()，进而调用上面的RunE方法：</p>
<pre class="crayon-plain-tag">if err := o.Complete(); err != nil {
	return err
}
if err := o.Validate(args); err != nil {
	return err
}
if err := o.RunWardleServer(stopCh); err != nil {
	return err
}
return nil</pre>
<p>Complete方法，就是注册了一个Admission控制器，<a href="#WardleServerOptions-Complete">参考下文</a>。</p>
<p>Validate方法调用RecommendedOptions进行选项（合并了用户提供的命令行标记）合法性校验：</p>
<pre class="crayon-plain-tag">func (o WardleServerOptions) Validate(args []string) error {
	errors := []error{}
	// 校验结果是错误的切片
	errors = append(errors, o.RecommendedOptions.Validate()...)
	// 合并为单个错误
	return utilerrors.NewAggregate(errors)
}</pre>
<div class="blog_h3"><span class="graybg"><a id="start-server"></a>启动服务器</span></div>
<p>RunWardleServer方法启动API Server。它包含了将服务器选项（Option）转换为服务器配置（Config），从服务器配置实例化APIServer，并运行APIServer的整个流程：</p>
<pre class="crayon-plain-tag">func (o WardleServerOptions) RunWardleServer(stopCh &lt;-chan struct{}) error {
	// 选项转换为配置
	config, err := o.Config()
	if err != nil {
		return err
	}
	// 配置转换为CompletedConfig，实例化APIServer
	server, err := config.Complete().New()
	if err != nil {
		return err
	}

	// 注册一个在API Server启动之后运行的钩子
	server.GenericAPIServer.AddPostStartHookOrDie("start-sample-server-informers", func(context genericapiserver.PostStartHookContext) error {
		config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
		o.SharedInformerFactory.Start(context.StopCh)
		return nil
	})

	//                             准备运行、  运行 APIServer
	return server.GenericAPIServer.PrepareRun().Run(stopCh)
}</pre>
<div class="blog_h3"><span class="graybg">服务器配置</span></div>
<p>API Server不能直接使用选项，必须将选项转换为apiserver.Config：</p>
<pre class="crayon-plain-tag">func (o *WardleServerOptions) Config() (*apiserver.Config, error) {
	// 这里又对选项进行了若干修改

	// 检查证书是否可以读取，如果不可以则尝试生成自签名证书
	if err := o.RecommendedOptions.SecureServing.MaybeDefaultWithSelfSignedCerts("localhost", nil, []net.IP{net.ParseIP("127.0.0.1")}); err != nil {
		return nil, fmt.Errorf("error creating self-signed certificates: %v", err)
	}
	// 根据特性开关，决定是否支持分页
	o.RecommendedOptions.Etcd.StorageConfig.Paging = utilfeature.DefaultFeatureGate.Enabled(features.APIListChunking)
	// 
	o.RecommendedOptions.ExtraAdmissionInitializers = func(c *genericapiserver.RecommendedConfig) ([]admission.PluginInitializer, error) {
		// ...
	}

	// 创建推荐配置
	serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs)
	// 暴露OpenAPI端点
	serverConfig.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(
		// 自动生成的
		sampleopenapi.GetOpenAPIDefinitions, openapi.NewDefinitionNamer(apiserver.Scheme))
	serverConfig.OpenAPIConfig.Info.Title = "Wardle"
	serverConfig.OpenAPIConfig.Info.Version = "0.1"

	// 将RecommendedOptions应用到RecommendedConfig
	if err := o.RecommendedOptions.ApplyTo(serverConfig); err != nil {
		return nil, err
	}

	// 选项包含GenericConfig和你自定义的选项两部分
	config := &amp;apiserver.Config{
		GenericConfig: serverConfig,
		ExtraConfig:   apiserver.ExtraConfig{},
	}
	return config, nil
}</pre>
<p>从上面的代码可以看到RecommendedConfig是配置的核心：</p>
<pre class="crayon-plain-tag">type RecommendedConfig struct {
	// 用于配置GenericAPIServer的结构
	Config

	// SharedInformerFactory用于提供K8S资源的shared informers
	// 该字段由RecommendedOptions.CoreAPI.ApplyTo调用设置，informer默认使用in-cluster的ClientConfig
	SharedInformerFactory informers.SharedInformerFactory

	// 由RecommendedOptions.CoreAPI.ApplyTo设置，informer使用
	ClientConfig *restclient.Config
}

type Config struct {
	SecureServing *SecureServingInfo
	Authentication AuthenticationInfo
	Authorization AuthorizationInfo
	// 特权的本机使用的ClientConfig，PostStartHooks用到它
	LoopbackClientConfig *restclient.Config
	// ...
}</pre>
<p>通过调用ApplyTo方法，将RecommendedOptions中的选项传递给了RecommendedConfig：</p>
<pre class="crayon-plain-tag">func (o *RecommendedOptions) ApplyTo(config *server.RecommendedConfig) error {
	// 调用config.AddHealthChecks添加Etcd的健康检查
	// 设置config.RESTOptionsGetter
	if err := o.Etcd.ApplyTo(&amp;config.Config); err != nil {
		return err
	}
	// 创建config.Listener
	// 初始化config.Cert、config.CipherSuites、config.SNICerts等
	if err := o.SecureServing.ApplyTo(&amp;config.Config.SecureServing, &amp;config.Config.LoopbackClientConfig); err != nil {
		return err
	}
	// 初始化身份验证配置，获取相应的K8S客户端接口
	if err := o.Authentication.ApplyTo(&amp;config.Config.Authentication, config.SecureServing, config.OpenAPIConfig); err != nil {
		return err
	}
	// 初始化访问控制配置，获取相应的K8S客户端接口
	if err := o.Authorization.ApplyTo(&amp;config.Config.Authorization); err != nil {
		return err
	}
	if err := o.Audit.ApplyTo(&amp;config.Config, config.ClientConfig, config.SharedInformerFactory, o.ProcessInfo, o.Webhook); err != nil {
		return err
	}
	if err := o.Features.ApplyTo(&amp;config.Config); err != nil {
		return err
	}
	// 从配置文件加载kubeconfig或者使用incluster配置，提供config.ClientConfig和config.SharedInformerFactory
	if err := o.CoreAPI.ApplyTo(config); err != nil {
		return err
	}
	// 调用Admission初始化器
	if initializers, err := o.ExtraAdmissionInitializers(config); err != nil {
		return err
	// 逐个初始化Admission控制器
	} else if err := o.Admission.ApplyTo(&amp;config.Config, config.SharedInformerFactory, config.ClientConfig, o.FeatureGate, initializers...); err != nil {
		return err
	}
	if err := o.EgressSelector.ApplyTo(&amp;config.Config); err != nil {
		return err
	}
	if feature.DefaultFeatureGate.Enabled(features.APIPriorityAndFairness) {
		config.FlowControl = utilflowcontrol.New(
			config.SharedInformerFactory,
			kubernetes.NewForConfigOrDie(config.ClientConfig).FlowcontrolV1alpha1(),
			config.MaxRequestsInFlight+config.MaxMutatingRequestsInFlight,
			config.RequestTimeout/4,
		)
	}
	return nil
}</pre>
<p>可以看到，在生成服务器配置的阶段，对主kube-apiserver有强依赖，这些依赖导致了sample-apiserver无法脱离K8S独立运行。</p>
<p>RecommendedConfig还需要通过Conplete方法，变成CompletedConfig：</p>
<pre class="crayon-plain-tag">// server, err := config.Complete().New()

func (cfg *Config) Complete() CompletedConfig {
	c := completedConfig{
		cfg.GenericConfig.Complete(),
		&amp;cfg.ExtraConfig,
	}

	c.GenericConfig.Version = &amp;version.Info{
		Major: "1",
		Minor: "0",
	}

	return CompletedConfig{&amp;c}
}

// Complete补全缺失的、必须的配置信息，这些信息能够从已有配置导出
func (c *RecommendedConfig) Complete() CompletedConfig {
	return c.Config.Complete(c.SharedInformerFactory)
}</pre>
<div class="blog_h3"><span class="graybg">实例化APIServer</span></div>
<p>WardleServer，是从CompletedConfig实例化的：</p>
<pre class="crayon-plain-tag">func (c completedConfig) New() (*WardleServer, error) {
	// 创建GenericAPIServer
	//                                         名字用于在记录日志时进行区分
	//                                                           DelegationTarget用于进行APIServer的组合（composition）
	genericServer, err := c.GenericConfig.New("sample-apiserver", genericapiserver.NewEmptyDelegate())
	if err != nil {
		return nil, err
	}

	s := &amp;WardleServer{
		GenericAPIServer: genericServer,
	}</pre>
<p>上面的<pre class="crayon-plain-tag">New()</pre>创建了核心的GenericAPIServer：</p>
<pre class="crayon-plain-tag">func (c completedConfig) New(name string, delegationTarget DelegationTarget) (*GenericAPIServer, error) {
	// 断言
	if c.Serializer == nil {
		return nil, fmt.Errorf("Genericapiserver.New() called with config.Serializer == nil")
	}
	if c.LoopbackClientConfig == nil {
		return nil, fmt.Errorf("Genericapiserver.New() called with config.LoopbackClientConfig == nil")
	}
	if c.EquivalentResourceRegistry == nil {
		return nil, fmt.Errorf("Genericapiserver.New() called with config.EquivalentResourceRegistry == nil")
	}

	handlerChainBuilder := func(handler http.Handler) http.Handler {
		return c.BuildHandlerChainFunc(handler, c.Config)
	}
	// 构建请求处理器
	apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler())

	// 创建GenericAPIServer，很多字段直接来自completedConfig
	s := &amp;GenericAPIServer{
		discoveryAddresses:         c.DiscoveryAddresses,
		LoopbackClientConfig:       c.LoopbackClientConfig,
		legacyAPIGroupPrefixes:     c.LegacyAPIGroupPrefixes,
		admissionControl:           c.AdmissionControl,
		Serializer:                 c.Serializer,
		AuditBackend:               c.AuditBackend,
		Authorizer:                 c.Authorization.Authorizer,
		delegationTarget:           delegationTarget,
		EquivalentResourceRegistry: c.EquivalentResourceRegistry,
		HandlerChainWaitGroup:      c.HandlerChainWaitGroup,

		minRequestTimeout:     time.Duration(c.MinRequestTimeout) * time.Second,
		ShutdownTimeout:       c.RequestTimeout,
		ShutdownDelayDuration: c.ShutdownDelayDuration,
		SecureServingInfo:     c.SecureServing,
		ExternalAddress:       c.ExternalAddress,

		Handler: apiServerHandler,

		listedPathProvider: apiServerHandler,

		openAPIConfig:           c.OpenAPIConfig,
		skipOpenAPIInstallation: c.SkipOpenAPIInstallation,

		postStartHooks:         map[string]postStartHookEntry{},
		preShutdownHooks:       map[string]preShutdownHookEntry{},
		disabledPostStartHooks: c.DisabledPostStartHooks,

		healthzChecks:    c.HealthzChecks,
		livezChecks:      c.LivezChecks,
		readyzChecks:     c.ReadyzChecks,
		readinessStopCh:  make(chan struct{}),
		livezGracePeriod: c.LivezGracePeriod,

		DiscoveryGroupManager: discovery.NewRootAPIsHandler(c.DiscoveryAddresses, c.Serializer),

		maxRequestBodyBytes: c.MaxRequestBodyBytes,
		livezClock:          clock.RealClock{},
	}

	// ...

	// 添加delegationTarget的生命周期钩子
	for k, v := range delegationTarget.PostStartHooks() {
		s.postStartHooks[k] = v
	}
	for k, v := range delegationTarget.PreShutdownHooks() {
		s.preShutdownHooks[k] = v
	}

	// 添加预配置的钩子
	for name, preconfiguredPostStartHook := range c.PostStartHooks {
		if err := s.AddPostStartHook(name, preconfiguredPostStartHook.hook); err != nil {
			return nil, err
		}
	}

	// 如果配置包含了SharedInformerFactory，而启动该SharedInformerFactory的钩子没有注册
	// 则注册一个PostStart钩子来启动它
	genericApiServerHookName := "generic-apiserver-start-informers"
	if c.SharedInformerFactory != nil {
		if !s.isPostStartHookRegistered(genericApiServerHookName) {
			err := s.AddPostStartHook(genericApiServerHookName, func(context PostStartHookContext) error {
				c.SharedInformerFactory.Start(context.StopCh)
				return nil
			})
			if err != nil {
				return nil, err
			}
			// TODO: Once we get rid of /healthz consider changing this to post-start-hook.
			err = s.addReadyzChecks(healthz.NewInformerSyncHealthz(c.SharedInformerFactory))
			if err != nil {
				return nil, err
			}
		}
	}

	const priorityAndFairnessConfigConsumerHookName = "priority-and-fairness-config-consumer"
	if s.isPostStartHookRegistered(priorityAndFairnessConfigConsumerHookName) {
	} else if c.FlowControl != nil {
		err := s.AddPostStartHook(priorityAndFairnessConfigConsumerHookName, func(context PostStartHookContext) error {
			go c.FlowControl.Run(context.StopCh)
			return nil
		})
		if err != nil {
			return nil, err
		}
		// TODO(yue9944882): plumb pre-shutdown-hook for request-management system?
	} else {
		klog.V(3).Infof("Not requested to run hook %s", priorityAndFairnessConfigConsumerHookName)
	}

	// 添加delegationTarget的健康检查
	for _, delegateCheck := range delegationTarget.HealthzChecks() {
		skip := false
		for _, existingCheck := range c.HealthzChecks {
			if existingCheck.Name() == delegateCheck.Name() {
				skip = true
				break
			}
		}
		if skip {
			continue
		}
		s.AddHealthChecks(delegateCheck)
	}

	s.listedPathProvider = routes.ListedPathProviders{s.listedPathProvider, delegationTarget}

	// 安装Profiling、Metrics、URL / 下显示的路径列表（listedPathProvider）
	installAPI(s, c.Config)

	// use the UnprotectedHandler from the delegation target to ensure that we don't attempt to double authenticator, authorize,
	// or some other part of the filter chain in delegation cases.
	if delegationTarget.UnprotectedHandler() == nil &amp;&amp; c.EnableIndex {
		s.Handler.NonGoRestfulMux.NotFoundHandler(routes.IndexLister{
			StatusCode:   http.StatusNotFound,
			PathProvider: s.listedPathProvider,
		})
	}

	return s, nil
}</pre>
<p>GenericAPIServer.Handler就是HTTP请求的处理器，我们在下文的<a title="记录一次KeyDB缓慢的定位过程" href="#request-processing">请求处理过程</a>一节分析。</p>
<div class="blog_h3"><span class="graybg"><a id="InstallAPIGroup"></a>安装APIGroup</span></div>
<p>实例化GenericAPIServer之后，是安装APIGroup：</p>
<pre class="crayon-plain-tag">// 创建APIGroupInfo，关于一组API的各种信息，包括已经注册的API（Scheme），如何进行编解码（Codec）
	// 如何解析查询参数（ParameterCodec）
	apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(wardle.GroupName, Scheme, metav1.ParameterCodec, Codecs)

	// 从资源到rest.Storage的映射
	v1alpha1storage := map[stcongring]rest.Storage{}
	v1alpha1storage["flunders"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))
	v1alpha1storage["fischers"] = wardleregistry.RESTInPeace(fischerstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))
	// 两个版本分别对应一个映射
	apiGroupInfo.VersionedResourcesStorageMap["v1alpha1"] = v1alpha1storage

	v1beta1storage := map[string]rest.Storage{}
	v1beta1storage["flunders"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))
	apiGroupInfo.VersionedResourcesStorageMap["v1beta1"] = v1beta1storage
	if err := s.GenericAPIServer.InstallAPIGroup(&amp;apiGroupInfo); err != nil {
		return nil, err
	}

	return s, nil
}</pre>
<p>上面的flunderstorage.NewREST等方法返回的registry.REST，内嵌的genericregistry.Store不仅仅实现了rest.Storage：</p>
<pre class="crayon-plain-tag">type Storage interface {
	// 当请求的数据存放到该方法创建的对象之后，可以调用Create/Update进行持久化
	// 必须返回一个适用于 Codec.DecodeInto([]byte, runtime.Object) 的指针类型
	New() runtime.Object
}</pre>
<p>还实现了rest.StandardStorage：</p>
<pre class="crayon-plain-tag">type StandardStorage interface {
	Getter
	Lister
	CreaterUpdater
	GracefulDeleter
	CollectionDeleter
	Watcher
}</pre>
<p>实现了这些接口，意味着registry.REST能够支持API对象的增删改查和Watch。更多细节我们在下面的<a href="#request-processing">请求处理过程</a>一节中探讨。</p>
<div class="blog_h3"><span class="graybg">启动APIServer</span></div>
<p>如<a href="#start-server">启动服务器</a>一节中的代码所示， 在将选项转换为配置、完成配置，并从配置实例化APIServer之后，会执行由两个步骤组成的启动逻辑。</p>
<p>首先是PrepareRun，这里执行一些需要在API安装（在实例化时）之后进行的操作：</p>
<pre class="crayon-plain-tag">func (s *GenericAPIServer) PrepareRun() preparedGenericAPIServer {
	s.delegationTarget.PrepareRun()
	// 安装OpenAPI的Handler
	if s.openAPIConfig != nil &amp;&amp; !s.skipOpenAPIInstallation {
		s.OpenAPIVersionedService, s.StaticOpenAPISpec = routes.OpenAPI{
			Config: s.openAPIConfig,
		}.Install(s.Handler.GoRestfulContainer, s.Handler.NonGoRestfulMux)
	}
	// 安装健康检查的Handler
	s.installHealthz()
	s.installLivez()
	err := s.addReadyzShutdownCheck(s.readinessStopCh)
	if err != nil {
		klog.Errorf("Failed to install readyz shutdown check %s", err)
	}
	s.installReadyz()

	// 为审计后端注册关闭前钩子
	if s.AuditBackend != nil {
		err := s.AddPreShutdownHook("audit-backend", func() error {
			s.AuditBackend.Shutdown()
			return nil
		})
		if err != nil {
			klog.Errorf("Failed to add pre-shutdown hook for audit-backend %s", err)
		}
	}

	return preparedGenericAPIServer{s}
}</pre>
<p>然后是Run，启动APIServer：</p>
<pre class="crayon-plain-tag">func (s preparedGenericAPIServer) Run(stopCh &lt;-chan struct{}) error {
	delayedStopCh := make(chan struct{})
	go func() {
		defer close(delayedStopCh)
		// 收到关闭信号
		&lt;-stopCh
		// 一旦关闭流程被触发，/readyz就需要立刻返回错误
		close(s.readinessStopCh)
		// 关闭服务器前休眠ShutdownDelayDuration，这让LB有个时间窗口来检测/readyz状态，不再发送请求给此服务器
		time.Sleep(s.ShutdownDelayDuration)
	}()

	// 运行服务器
	err := s.NonBlockingRun(delayedStopCh)
	if err != nil {
		return err
	}

	// 收到关闭信号
	&lt;-stopCh

	// 运行关闭前钩子
	err = s.RunPreShutdownHooks()
	if err != nil {
		return err
	}

	// 等待延迟关闭信号
	&lt;-delayedStopCh

	// 等待现有请求完毕，然后关闭
	s.HandlerChainWaitGroup.Wait()

	return nil
}</pre>
<p> NonBlockingRun是启动APIServer的核心代码。它会启动一个HTTPS服务器：</p>
<pre class="crayon-plain-tag">func (s preparedGenericAPIServer) NonBlockingRun(stopCh &lt;-chan struct{}) error {
	// 这个通道用于保证HTTP Server优雅关闭，不会导致丢失审计事件
	auditStopCh := make(chan struct{})

	// 首先启动审计后端，这时任何请求都进不来
	if s.AuditBackend != nil {
		if err := s.AuditBackend.Run(auditStopCh); err != nil {
			return fmt.Errorf("failed to run the audit backend: %v", err)
		}
	}

	// 下面的通道用于出错时清理listener
	internalStopCh := make(chan struct{})
	var stoppedCh &lt;-chan struct{}
	if s.SecureServingInfo != nil &amp;&amp; s.Handler != nil {
		var err error
		// 启动HTTPS服务器，仅当证书错误或内部listen调用出错时会失败
		// server loop在一个Goroutine中运行
		// 可以看到，这里的s.Handler是可以被我们访问到的，因此建立HTTP（非HTTPS）服务器应该很方便
		stoppedCh, err = s.SecureServingInfo.Serve(s.Handler, s.ShutdownTimeout, internalStopCh)
		if err != nil {
			close(internalStopCh)
			close(auditStopCh)
			return err
		}
	}

	// 清理
	go func() {
		&lt;-stopCh
		close(internalStopCh)
		if stoppedCh != nil {
			&lt;-stoppedCh
		}
		s.HandlerChainWaitGroup.Wait()
		close(auditStopCh)
	}()

	// 启动后钩子
	s.RunPostStartHooks(stopCh)

	if _, err := systemd.SdNotify(true, "READY=1\n"); err != nil {
		klog.Errorf("Unable to send systemd daemon successful start message: %v\n", err)
	}

	return nil
}</pre>
<div class="blog_h3"><span class="graybg">结构注册到Scheme</span></div>
<p>apis/wardle包，以及它的子包，定义了wardle.example.com组的API。</p>
<p>wardle包的register.go中定义了组，以及从GV获得GVK、GVR的函数：</p>
<pre class="crayon-plain-tag">const GroupName = "wardle.example.com"
//                                                             没有正式（formal）类型则使用此常量
var SchemeGroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal}

func Kind(kind string) schema.GroupKind {
	return SchemeGroupVersion.WithKind(kind).GroupKind()
}

func Resource(resource string) schema.GroupResource {
	return SchemeGroupVersion.WithResource(resource).GroupResource()
}</pre>
<p>API包通常都要提供AddToScheme变量，用于将API注册到指定的scheme：</p>
<pre class="crayon-plain-tag">var (
	SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
	AddToScheme   = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
	// 注册API的核心，添加GV中的若干API类型
	scheme.AddKnownTypes(SchemeGroupVersion,
		&amp;Flunder{}, // 必须提供指针类型，Go反射得到类型在编码时作为Kind
		&amp;FlunderList{},
		&amp;Fischer{},
		&amp;FischerList{},
	)
	return nil
}

// 所谓SchemeBuilder其实就是切片
type SchemeBuilder []func(*Scheme) error
// NewSchemeBuilder方法支持多个回调函数，对这些函数调用SchemeBuilder.Register
func NewSchemeBuilder(funcs ...func(*Scheme) error) SchemeBuilder {
	var sb SchemeBuilder
	sb.Register(funcs...)
	return sb
}
// 所谓Register就是添加到切片中
func (sb *SchemeBuilder) Register(funcs ...func(*Scheme) error) {
	for _, f := range funcs {
		*sb = append(*sb, f)
	}
}

// 所谓AddToScheme就是遍历回调，Go方法可以作为变量传递
func (sb *SchemeBuilder) AddToScheme(s *Scheme) error {
	for _, f := range *sb {
		if err := f(s); err != nil {
			return err
		}
	}
	return nil
}</pre>
<p>wardle包的types.go则定义了API类型对应的Go结构体。</p>
<p>子包v1alpha1、v1beta1定义了API的两个版本。它们包含和wardle包类似的GroupName、SchemeGroupVersion、SchemeBuilder、AddToScheme…等变量/函数，以及对应的API类型结构体。还包括自动生成的、与APIVersionInternal版本API进行转换的函数。</p>
<p>回到wardle包，Install方法支持将APIVersionInternal、v1alpha1、v1beta1这些版本都注册到scheme：</p>
<pre class="crayon-plain-tag">func Install(scheme *runtime.Scheme) {
	utilruntime.Must(wardle.AddToScheme(scheme))
	utilruntime.Must(v1beta1.AddToScheme(scheme))
	utilruntime.Must(v1alpha1.AddToScheme(scheme))
	utilruntime.Must(scheme.SetVersionPriority(v1beta1.SchemeGroupVersion, v1alpha1.SchemeGroupVersion))
}</pre>
<p>而在apiserver包中，在init时会调用上面的Install函数：</p>
<pre class="crayon-plain-tag">var (
	// API注册表
	Scheme = runtime.NewScheme()
	// 编解码器工厂
	Codecs = serializer.NewCodecFactory(Scheme)
)

func init() {
	// 安装sample-apiserver提供的API
	install.Install(Scheme)

	// 将k8s.io/apimachinery/pkg/apis/meta/v1"注册到v1组，为什么？
	// we need to add the options to empty v1
	// TODO fix the server code to avoid this
	metav1.AddToGroupVersion(Scheme, schema.GroupVersion{Version: "v1"})

	// TODO: keep the generic API server from wanting this
	unversioned := schema.GroupVersion{Group: "", Version: "v1"}
	Scheme.AddUnversionedTypes(unversioned,
		&amp;metav1.Status{},
		&amp;metav1.APIVersions{},
		&amp;metav1.APIGroupList{},
		&amp;metav1.APIGroup{},
		&amp;metav1.APIResourceList{},
	)
}</pre>
<p>Codecs是API资源编解码器的工厂，RecommendedOptions需要此工厂：</p>
<pre class="crayon-plain-tag">// ... 
		RecommendedOptions: genericoptions.NewRecommendedOptions(
			defaultEtcdPathPrefix,
			// 得到v1alpha1版本的LegacyCodec，遗留编解码器（runtime.Codec）
			// LegacyCodec编码到指定的API版本，解码（从任何支持的源）到内部形式
			// 此编码器总是编码为JSON
			// 
			// LegacyCodec方法已经废弃，客户端/服务器应该根据MIME类型协商serializer
			// 并调用CodecForVersions
			apiserver.Codecs.LegacyCodec(v1alpha1.SchemeGroupVersion),
			genericoptions.NewProcessInfo("wardle-apiserver", "wardle"),
		),

func NewRecommendedOptions(prefix string, codec runtime.Codec, processInfo *ProcessInfo) *RecommendedOptions {
	return &amp;RecommendedOptions{
		//  不过这里的runtime.Codec用于将AP对象转换为JSON并存放到Etcd，而不是发给客户端
		Etcd:           NewEtcdOptions(storagebackend.NewDefaultConfig(prefix, codec)),
	// ...</pre>
<p>APIGroupInfo也需要此工厂：</p>
<pre class="crayon-plain-tag">apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(wardle.GroupName, Scheme, metav1.ParameterCodec, Codecs)

func NewDefaultAPIGroupInfo(group string, scheme *runtime.Scheme, parameterCodec runtime.ParameterCodec, codecs serializer.CodecFactory) APIGroupInfo {
	return APIGroupInfo{
		// ...
		Scheme:                 scheme,
		ParameterCodec:         parameterCodec,
		NegotiatedSerializer:   codecs,
	}
}</pre>
<p>在APIServer实例化期间，Scheme会传递给APIGroupInfo，从而实现资源 - Go类型之间的映射。</p>
<div class="blog_h3"><span class="graybg">Admission控制器</span></div>
<p>sample-server也示例了如何集成自己的Admission控制器到API Server中。</p>
<p>在选项中，需要注册一个Admission初始化器</p>
<pre class="crayon-plain-tag">o.RecommendedOptions.ExtraAdmissionInitializers = func(c *genericapiserver.RecommendedConfig) ([]admission.PluginInitializer, error) {
		client, err := clientset.NewForConfig(c.LoopbackClientConfig)
		if err != nil {
			return nil, err
		}
		informerFactory := informers.NewSharedInformerFactory(client, c.LoopbackClientConfig.Timeout)
		o.SharedInformerFactory = informerFactory
		//                                   初始化函数
		return []admission.PluginInitializer{wardleinitializer.New(informerFactory)}, nil
	}</pre>
<p>初始化器是一个函数，会在选项转为配置的时候执行：</p>
<pre class="crayon-plain-tag">// 调用上面的函数
	if initializers, err := o.ExtraAdmissionInitializers(config); err != nil {
		return err
	// 初始化Admission控制器
	} else if err := o.Admission.ApplyTo(&amp;config.Config, config.SharedInformerFactory, config.ClientConfig, o.FeatureGate, initializers...); err != nil {
		return err
	}</pre>
<p>上面的ApplyTo方法，会组建一个Admission控制器的初始化函数链，并逐个调用，以初始化所有Admission控制器。</p>
<p>此外，在PostStart钩子中，会启动Admission控制器所依赖的SharedInformerFactory：</p>
<pre class="crayon-plain-tag">server.GenericAPIServer.AddPostStartHookOrDie("start-sample-server-informers", func(context genericapiserver.PostStartHookContext) error {
		// 主kube-apiserver的InformerFactory，貌似没什么用
		config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
		// 次级kube-apiserver的InformerFactory，Admission控制器需要使用
		o.SharedInformerFactory.Start(context.StopCh)
		return nil
	})</pre>
<p>我们看一下sample-apiserver的Admission相关代码。</p>
<p>位于pkg/admission/wardleinitializer包中的是Admission初始化器，它能够为任何WantsInternalWardleInformerFactory类型的Admission控制器注入InformerFactory：</p>
<pre class="crayon-plain-tag">type pluginInitializer struct {
	informers informers.SharedInformerFactory
}

var _ admission.PluginInitializer = pluginInitializer{}

// 该函数在ExtraAdmissionInitializers函数中调用
func New(informers informers.SharedInformerFactory) pluginInitializer {
	return pluginInitializer{
		informers: informers,
	}
}

// 该函数在o.Admission.ApplyTo中调用
func (i pluginInitializer) Initialize(plugin admission.Interface) {
	if wants, ok := plugin.(WantsInternalWardleInformerFactory); ok {
		wants.SetInternalWardleInformerFactory(i.informers)
	}
}</pre>
<p>位于pkg/admission/plugin/banflunder包中的是为了的Admission控制器BanFlunder。函数：</p>
<pre class="crayon-plain-tag">func Register(plugins *admission.Plugins) {
	plugins.Register("BanFlunder", func(config io.Reader) (admission.Interface, error) {
		return New()
	})
}</pre>
<p><a id="WardleServerOptions-Complete"></a>会在程序运行的很早期调用，以注册Admission控制器到API Server：</p>
<pre class="crayon-plain-tag">func (o *WardleServerOptions) Complete() error {
	// 注册插件
	banflunder.Register(o.RecommendedOptions.Admission.Plugins)

	// 配置顺序
	o.RecommendedOptions.Admission.RecommendedPluginOrder = append(o.RecommendedOptions.Admission.RecommendedPluginOrder, "BanFlunder")

	return nil
}</pre>
<p>Admission控制器的核心是Admit函数，它可以修改或否决一个API Server请求：</p>
<pre class="crayon-plain-tag">func (d *DisallowFlunder) Admit(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
	// 仅仅对特定类型的API感兴趣
	if a.GetKind().GroupKind() != wardle.Kind("Flunder") {
		return nil
	}

	if !d.WaitForReady() {
		return admission.NewForbidden(a, fmt.Errorf("not yet ready to handle request"))
	}

	// 用于获取元数据
	metaAccessor, err := meta.Accessor(a.GetObject())
	if err != nil {
		return err
	}
	flunderName := metaAccessor.GetName()

	fischers, err := d.lister.List(labels.Everything())
	if err != nil {
		return err
	}

	for _, fischer := range fischers {
		for _, disallowedFlunder := range fischer.DisallowedFlunders {
			if flunderName == disallowedFlunder {
				return errors.NewForbidden(
					a.GetResource().GroupResource(),
					a.GetName(),
					// 拒绝请求
					fmt.Errorf("this name may not be used, please change the resource name"),
				)
			}
		}
	}
	return nil
}</pre>
<div class="blog_h3"><span class="graybg"><a id="request-processing"></a>请求处理过程</span></div>
<p>通过上文的分析，我们知道资源的增删改查是由registry.REST（空壳，实际是genericregistry.Store）负责的，那么HTTP请求是如何传递给它的呢？</p>
<p>回顾一下向APIGroupInfo中添加资源的代码：</p>
<pre class="crayon-plain-tag">//              资源类型          
v1alpha1storage["flunders"] = wardleregistry.RESTInPeace(
	// 创建registry.REST
	flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter)
)


func NewREST(scheme *runtime.Scheme, optsGetter generic.RESTOptionsGetter) (*registry.REST, error) {
	// 策略，参考下文
	strategy := NewStrategy(scheme)

	store := &amp;genericregistry.Store{
		// 实例化资源的函数
		NewFunc:                  func() runtime.Object {
			// 每次增删改查，都牵涉到结构的创建，因此在此打断点可以拦截所有请求
			return &amp;wardle.Flunder{}
		},
		// 实例化资源列表的函数
		NewListFunc:              func() runtime.Object { return &amp;wardle.FlunderList{} },
		// 判断对象是否可以被该存储处理
		PredicateFunc: func(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
			return storage.SelectionPredicate{
				Label: label,
				Field: field,
				GetAttrs: func(obj runtime.Object) (labels.Set, fields.Set, error) {
					apiserver, ok := obj.(*wardle.Flunder)
					if !ok {
						return nil, nil, fmt.Errorf("given object is not a Flunder")
					}
					return apiserver.ObjectMeta.Labels, SelectableFields(apiserver), nil
				},
			}
		},
		// 资源的复数名称，当上下文中缺少必要的请求信息时使用
		DefaultQualifiedResource: wardle.Resource("flunders"),
		// 增删改的策略
		CreateStrategy: strategy,
		UpdateStrategy: strategy,
		DeleteStrategy: strategy,
	}
	options := &amp;generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: GetAttrs}
	// 填充默认字段
	if err := store.CompleteWithOptions(options); err != nil {
		return nil, err
	}
	return &amp;registry.REST{store}, nil
}</pre>
<p>为了创建genericregistry.Store，需要两个信息：</p>
<ol>
<li>Scheme，它提供的信息是Go类型和GVK之间的映射关系。其中Kind是根据Go结构的类型名反射得到的</li>
<li>generic.RESTOptionsGetter</li>
</ol>
<p>RESTOptionsGetter用于获得RESTOptions：</p>
<pre class="crayon-plain-tag">type RESTOptionsGetter interface {
	GetRESTOptions(resource schema.GroupResource) (RESTOptions, error)
}</pre>
<p>RESTOptions包含了关于存储的信息，尽管这个职责和名字好像没什么关系：</p>
<pre class="crayon-plain-tag">type RESTOptions struct {
	// 创建一个存储后端所需的配置信息
	StorageConfig *storagebackend.Config
	// 这是一个函数，能够提供storage.Interface和factory.DestroyFunc
	Decorator     StorageDecorator

	EnableGarbageCollection bool
	DeleteCollectionWorkers int
	ResourcePrefix          string
	CountMetricPollPeriod   time.Duration
}

// 
type Config struct {
	// 存储后端的类型，默认etcd3
	Type string
	// 传递给storage.Interface的所有方法的前缀，对应etcd存储前缀
	Prefix string
	// 连接到Etcd服务器的相关信息
	Transport TransportConfig
	// 提示APIServer是否应该支持分页
	Paging bool
	// 负责（反）串行化
	Codec runtime.Codec
	// 在持久化到Etcd之前，该对象输出目标将被转换为的GVK
	Transformer value.Transformer

	CompactionInterval time.Duration
	CountMetricPollPeriod time.Duration
	LeaseManagerConfig etcd3.LeaseManagerConfig
}


// 利用入参函数，构造storage.Interface
type StorageDecorator func(
	config *storagebackend.Config,
	resourcePrefix string,
	keyFunc func(obj runtime.Object) (string, error),
	newFunc func() runtime.Object,
	newListFunc func() runtime.Object,
	getAttrsFunc storage.AttrFunc,
	trigger storage.IndexerFuncs,
	indexers *cache.Indexers) (storage.Interface, factory.DestroyFunc, error)
// CRUD
type Interface interface {
	Versioner() Versioner
	Create(ctx context.Context, key string, obj, out runtime.Object, ttl uint64) error
	Delete(ctx context.Context, key string, out runtime.Object, preconditions *Preconditions, validateDeletion ValidateObjectFunc) error
	Watch(ctx context.Context, key string, resourceVersion string, p SelectionPredicate) (watch.Interface, error)
	WatchList(ctx context.Context, key string, resourceVersion string, p SelectionPredicate) (watch.Interface, error)
	Get(ctx context.Context, key string, resourceVersion string, objPtr runtime.Object, ignoreNotFound bool) error
	GetToList(ctx context.Context, key string, resourceVersion string, p SelectionPredicate, listObj runtime.Object) error
	List(ctx context.Context, key string, resourceVersion string, p SelectionPredicate, listObj runtime.Object) error
	GuaranteedUpdate(
		ctx context.Context, key string, ptrToType runtime.Object, ignoreNotFound bool,
		precondtions *Preconditions, tryUpdate UpdateFunc, suggestion ...runtime.Object) error
	Count(key string) (int64, error)
}
// 这个函数用于一次性销毁任何Create()创建的、当前Storage使用的对象
type DestroyFunc func()</pre>
<p>在这里我们可以注意到storagebackend.Config和Etcd是有耦合的，因此想支持其它存储后端，需要在更早的节点介入。</p>
<p>genericregistry.Store还包含了三个Strategy字段：</p>
<pre class="crayon-plain-tag">type Store struct {
	// ...
	CreateStrategy rest.RESTCreateStrategy
	UpdateStrategy rest.RESTUpdateStrategy
	DeleteStrategy rest.RESTDeleteStrategy
	// ...
}

type RESTCreateStrategy interface {
	runtime.ObjectTyper
	// 用于生成名称
	names.NameGenerator
	// 对象是否必须在命名空间中
	NamespaceScoped() bool
	// 在Validate、Canonicalize之前调用，进行对象的normalize
	// 例如删除不需要持久化的字段、对顺序不敏感的列表字段进行重新排序
	// 不得移除这样的字段：它不能通过校验
	PrepareForCreate(ctx context.Context, obj runtime.Object)
	// 校验，在对象的默认字段被填充后调用
	Validate(ctx context.Context, obj runtime.Object) field.ErrorList
	// 在校验之后，持久化之前调用。正规化对象，通常实现为类型检查，或空函数
	Canonicalize(obj runtime.Object)
}

type RESTUpdateStrategy interface {
	runtime.ObjectTyper
	NamespaceScoped() bool
	// 对象是否可以被PUT请求创建
	AllowCreateOnUpdate() bool
	// 准备更新
	PrepareForUpdate(ctx context.Context, obj, old runtime.Object)
	// 校验
	ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList
	// 正规化
	Canonicalize(obj runtime.Object)
	// 当对象上没有指定版本时，是否允许无条件的更新，也就是不管最新的资源版本（禁用乐观并发控制）
	AllowUnconditionalUpdate() bool
}

type RESTDeleteStrategy interface {
	runtime.ObjectTyper
}

type ObjectTyper interface {
	// 得到对象可能的GVK信息
	ObjectKinds(Object) ([]schema.GroupVersionKind, bool, error)
	// Scheme是否支持指定的GVK
	Recognizes(gvk schema.GroupVersionKind) bool
}</pre>
<p>可以看到，策略能够影响增删改的行为，它能够生成对象名称，校验对象合法性，甚至修改对象。 我们看一下sample-apiserver提供的策略实现：</p>
<pre class="crayon-plain-tag">func NewStrategy(typer runtime.ObjectTyper) flunderStrategy {
	// 简单命名策略：返回请求的basename外加5位字母数字的随即后缀
	return flunderStrategy{typer, names.SimpleNameGenerator}
}

type flunderStrategy struct {
	runtime.ObjectTyper
	names.NameGenerator
}

func (flunderStrategy) NamespaceScoped() bool {
	return true
}

func (flunderStrategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
}

func (flunderStrategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
}

func (flunderStrategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
	flunder := obj.(*wardle.Flunder)
	return validation.ValidateFlunder(flunder)
}

func (flunderStrategy) AllowCreateOnUpdate() bool {
	return false
}

func (flunderStrategy) AllowUnconditionalUpdate() bool {
	return false
}

func (flunderStrategy) Canonicalize(obj runtime.Object) {
}

func (flunderStrategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
	return field.ErrorList{}
}



package validation

// ValidateFlunder validates a Flunder.
func ValidateFlunder(f *wardle.Flunder) field.ErrorList {
	allErrs := field.ErrorList{}

	allErrs = append(allErrs, ValidateFlunderSpec(&amp;f.Spec, field.NewPath("spec"))...)

	return allErrs
}

// ValidateFlunderSpec validates a FlunderSpec.
func ValidateFlunderSpec(s *wardle.FlunderSpec, fldPath *field.Path) field.ErrorList {
	allErrs := field.ErrorList{}

	if len(s.FlunderReference) != 0 &amp;&amp; len(s.FischerReference) != 0 {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("fischerReference"), s.FischerReference, "cannot be set with flunderReference at the same time"))
	} else if len(s.FlunderReference) != 0 &amp;&amp; s.ReferenceType != wardle.FlunderReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("flunderReference"), s.FlunderReference, "cannot be set if referenceType is not Flunder"))
	} else if len(s.FischerReference) != 0 &amp;&amp; s.ReferenceType != wardle.FischerReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("fischerReference"), s.FischerReference, "cannot be set if referenceType is not Fischer"))
	} else if len(s.FischerReference) == 0 &amp;&amp; s.ReferenceType == wardle.FischerReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("fischerReference"), s.FischerReference, "cannot be empty if referenceType is Fischer"))
	} else if len(s.FlunderReference) == 0 &amp;&amp; s.ReferenceType == wardle.FlunderReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("flunderReference"), s.FlunderReference, "cannot be empty if referenceType is Flunder"))
	}

	if len(s.ReferenceType) != 0 &amp;&amp; s.ReferenceType != wardle.FischerReferenceType &amp;&amp; s.ReferenceType != wardle.FlunderReferenceType {
		allErrs = append(allErrs, field.Invalid(fldPath.Child("referenceType"), s.ReferenceType, "must be Flunder or Fischer"))
	}

	return allErrs
}</pre>
<p>分析到这里，我们可以猜测到Create请求被genericregistry.Store处理的过程：</p>
<ol>
<li>读取请求体，调用NewFunc反串行化为runtime.Obejct</li>
<li>调用PredicateFunc判断是否能够处理该对象</li>
<li>调用CreateStrategy，校验、正规化对象</li>
<li>调用RESTOptions，存储到Etcd</li>
</ol>
<p>那么，请求是如何传递过来的，上述处理的细节又是怎样的？上文中我们已经定位到关键代码路径，通过断点很容易跟踪到完整处理流程。</p>
<p>在上文分析的GenericAPIServer实例过程中，它的Handler字段是这样创建的：</p>
<pre class="crayon-plain-tag">apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler())

// 处理器链的构建器，注意出入参类型一样。这是因为处理器链是一层层包裹的，而不是链表那样的结构
type HandlerChainBuilderFn func(apiHandler http.Handler) http.Handler

func NewAPIServerHandler(name string, s runtime.NegotiatedSerializer, 
		handlerChainBuilder HandlerChainBuilderFn, notFoundHandler http.Handler) *APIServerHandler {

	// 配置go-restful，go-restful是一个构建REST风格WebService的框架
	nonGoRestfulMux := mux.NewPathRecorderMux(name)
	if notFoundHandler != nil {
		nonGoRestfulMux.NotFoundHandler(notFoundHandler)
	}

	// 容器，是一组WebService的集合
	gorestfulContainer := restful.NewContainer()
	// 容器包含一个用户HTTP请求多路复用的ServeMux
	gorestfulContainer.ServeMux = http.NewServeMux()
	// 路由器
	gorestfulContainer.Router(restful.CurlyRouter{}) // e.g. for proxy/{kind}/{name}/{*}
	// panic处理器
	gorestfulContainer.RecoverHandler(func(panicReason interface{}, httpWriter http.ResponseWriter) {
		logStackOnRecover(s, panicReason, httpWriter)
	})
	// 错误处理器
	gorestfulContainer.ServiceErrorHandler(func(serviceErr restful.ServiceError, request *restful.Request, response *restful.Response) {
		serviceErrorHandler(s, serviceErr, request, response)
	})

	director := director{
		name:               name,
		goRestfulContainer: gorestfulContainer,
		nonGoRestfulMux:    nonGoRestfulMux,
	}

	return &amp;APIServerHandler{
		// 构建处理器链，                 注意传入的director
		FullHandlerChain:   handlerChainBuilder(director),
		GoRestfulContainer: gorestfulContainer,
		NonGoRestfulMux:    nonGoRestfulMux,
		Director:           director,
	}
}


type APIServerHandler struct {
	// 处理器链，接口是http包中标准的：
	// type Handler interface {
	// 	ServeHTTP(ResponseWriter, *Request)
	// }
	// 它组织一系列的过滤器，并在请求通过过滤器链后，调用Director
	FullHandlerChain http.Handler
	// 所有注册的API由此容器处理
	// InstallAPIs使用该字段，其他server不应该直接访问
	GoRestfulContainer *restful.Container
	// 链中最后一个处理器。这个类型包装一个mux对象，并且记录下注册了哪些URL路径
	NonGoRestfulMux *mux.PathRecorderMux

	// Director用于处理fall through和proxy
	Director http.Handler
}</pre>
<p> 这个Handler实现了http.Handler，简单的把请求委托给FullHandlerChain处理：</p>
<pre class="crayon-plain-tag">func (a *APIServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	a.FullHandlerChain.ServeHTTP(w, r)
}</pre>
<p>上面这个函数，就是所有HTTP请求处理的入口点。</p>
<p>FullHandlerChain是handlerChainBuilder()调用得到的：</p>
<pre class="crayon-plain-tag">var c completedConfig
handlerChainBuilder := func(handler http.Handler) http.Handler {
	return c.BuildHandlerChainFunc(handler, c.Config)
}
apiServerHandler := NewAPIServerHandler(name, c.Serializer, handlerChainBuilder, delegationTarget.UnprotectedHandler())</pre>
<p>completedConfig.BuildHandlerChainFunc则来自于它内嵌的Config：</p>
<pre class="crayon-plain-tag">serverConfig := genericapiserver.NewRecommendedConfig(apiserver.Codecs)
func NewRecommendedConfig(codecs serializer.CodecFactory) *RecommendedConfig {
	return &amp;RecommendedConfig{
		Config: *NewConfig(codecs),
	}
}
func NewConfig(codecs serializer.CodecFactory) *Config {
	defaultHealthChecks := []healthz.HealthChecker{healthz.PingHealthz, healthz.LogHealthz}
	return &amp;Config{
		Serializer:                  codecs,
		BuildHandlerChainFunc:       DefaultBuildHandlerChain,
	// ...
}

func DefaultBuildHandlerChain(apiHandler http.Handler, c *Config) http.Handler {
	// 最内层：传入的apiHandler，也就是上文中的director
	// 访问控制
	handler := genericapifilters.WithAuthorization(apiHandler, c.Authorization.Authorizer, c.Serializer)
	// 访问速率控制
	if c.FlowControl != nil {
		handler = genericfilters.WithPriorityAndFairness(handler, c.LongRunningFunc, c.FlowControl)
	} else {
		handler = genericfilters.WithMaxInFlightLimit(handler, c.MaxRequestsInFlight, c.MaxMutatingRequestsInFlight, c.LongRunningFunc)
	}
	// 身份扮演
	handler = genericapifilters.WithImpersonation(handler, c.Authorization.Authorizer, c.Serializer)
	// 审计
	handler = genericapifilters.WithAudit(handler, c.AuditBackend, c.AuditPolicyChecker, c.LongRunningFunc)
	// 处理身份认证失败
	failedHandler := genericapifilters.Unauthorized(c.Serializer, c.Authentication.SupportsBasicAuth)
	failedHandler = genericapifilters.WithFailedAuthenticationAudit(failedHandler, c.AuditBackend, c.AuditPolicyChecker)
	// 身份验证
	handler = genericapifilters.WithAuthentication(handler, c.Authentication.Authenticator, failedHandler, c.Authentication.APIAudiences)
	// 处理CORS请求
	handler = genericfilters.WithCORS(handler, c.CorsAllowedOriginList, nil, nil, nil, "true")
	// 超时处理
	handler = genericfilters.WithTimeoutForNonLongRunningRequests(handler, c.LongRunningFunc, c.RequestTimeout)
	// 所有长时间运行请求被添加到等待组，用于优雅关机
	handler = genericfilters.WithWaitGroup(handler, c.LongRunningFunc, c.HandlerChainWaitGroup)
	// 将RequestInfo附加到Context对象
	handler = genericapifilters.WithRequestInfo(handler, c.RequestInfoResolver)
	// HTTP Goway处理
	if c.SecureServing != nil &amp;&amp; !c.SecureServing.DisableHTTP2 &amp;&amp; c.GoawayChance &gt; 0 {
		handler = genericfilters.WithProbabilisticGoaway(handler, c.GoawayChance)
	}
	// 设置Cache-Control头为"no-cache, private"，因为所有server被authn/authz保护
	handler = genericapifilters.WithCacheControl(handler)
	// 崩溃恢复
	handler = genericfilters.WithPanicRecovery(handler)
	return handler
}</pre>
<p>我们可以清楚的看到默认的处理器链包含的大量过滤器，以及处理器是一层层包裹而非链表结构。</p>
<p>最后，我们来从头跟踪一下请求处理过程，首先看看处理器链中的过滤器们：</p>
<pre class="crayon-plain-tag">func (a *APIServerHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	a.FullHandlerChain.ServeHTTP(w, r)
}
// FullHandlerChain是这个类型
type HandlerFunc func(ResponseWriter, *Request)
// 巧妙的设计，实现了
// type Handler interface {
//	ServeHTTP(ResponseWriter, *Request)
// }
// 接口，但是这个实现，直接委托给类型对应的函数
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
	f(w, r)
}

// 进入处理器链的最外层
func withPanicRecovery(handler http.Handler, crashHandler func(http.ResponseWriter, *http.Request, interface{})) http.Handler {
	handler = httplog.WithLogging(handler, httplog.DefaultStacktracePred)
	//                      处理函数
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		defer runtime.HandleCrash(func(err interface{}) {
			crashHandler(w, req, err)
		})

		// 转发给内层处理器，内层处理器通过闭包捕获
		handler.ServeHTTP(w, req)
	})
}

// 省略若干中间的处理器...

// 这个处理器值得注意，它将请求信息存放到context中，内层处理器可以直接使用这些信息
// 信息包括：
type RequestInfo struct {
	// 是否针对资源/子资源的请求
	IsResourceRequest bool
	// URL的路径部分
	Path string
	// 小写的HTTP动词
	Verb string
	// API前缀
	APIPrefix  string
	// API组
	APIGroup   string
	// API版本
	APIVersion string
	// 命名空间
	Namespace  string
	// 资源类型名，通常是小写的复数形式，而不是Kind
	Resource string
	// 请求的子资源，子资源是scoped to父资源的另外一个资源，可以具有不同的Kind
	// 例如 /pods对应资源"pods"，对应Kind为"Pod"
	//      /pods/foo/status对应资源"pods"，对应子资源 "status"，对应Kind为"Pod"
	// 然而 /pods/foo/binding对应资源"pods"，对应子资源 "binding", 而对应Kind为"Binding"
	Subresource string
	// 对于某些资源，名字是空的。如果请求指示一个名字（不在请求体中）则填写在该字段
	// Parts are the path parts for the request, always starting with /{resource}/{name}
	Parts []string
}
func WithRequestInfo(handler http.Handler, resolver request.RequestInfoResolver) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		ctx := req.Context()
		info, err := resolver.NewRequestInfo(req)
		if err != nil {
			responsewriters.InternalError(w, req, fmt.Errorf("failed to create RequestInfo: %v", err))
			return
		}

		req = req.WithContext(request.WithRequestInfo(ctx, info))

		handler.ServeHTTP(w, req)
	})
}

// 省略若干中间的处理器...</pre>
<p>遍历所有过滤器后，来到处理器链的最后一环，也就是director。从名字上可以看到，它在整体上负责请求的分发：</p>
<pre class="crayon-plain-tag">func (d director) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	path := req.URL.Path

	// 遍历已经注册的所有WebService，看看有没有负责当前URL路径的
	for _, ws := range d.goRestfulContainer.RegisteredWebServices() {
		switch {
		case ws.RootPath() == "/apis":
			// 如果URL路径是 /apis或/apis/需要特殊处理。通常情况下，应该交由nonGoRestfulMux
			// 但是在启用descovery的情况下，需要直接由goRestfulContainer处理
			if path == "/apis" || path == "/apis/" {
				klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath())
				d.goRestfulContainer.Dispatch(w, req)
				return
			}
		// 如果前缀匹配
		case strings.HasPrefix(path, ws.RootPath()):
			if len(path) == len(ws.RootPath()) || path[len(ws.RootPath())] == '/' {
				klog.V(5).Infof("%v: %v %q satisfied by gorestful with webservice %v", d.name, req.Method, path, ws.RootPath())
				// don't use servemux here because gorestful servemuxes get messed up when removing webservices
				// TODO fix gorestful, remove TPRs, or stop using gorestful
				d.goRestfulContainer.Dispatch(w, req)
				return
			}
		}
	}

	// 无法找到匹配，跳过 gorestful 容器
	d.nonGoRestfulMux.ServeHTTP(w, req)
}</pre>
<p> 对于非API资源请求，由PathRecorderMux处理：</p>
<pre class="crayon-plain-tag">func (m *PathRecorderMux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	m.mux.Load().(*pathHandler).ServeHTTP(w, r)
}
func (h *pathHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	// pathToHandler 记录了所有精确匹配路径的处理器
	//   0 = /healthz/etcd -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   1 = /livez/etcd -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   2 = /metrics -&gt; k8s.io/apiserver/pkg/server/routes.MetricsWithReset.Install.func1
	//   3 = /readyz/shutdown -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   4 = /readyz/ping -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   5 = /debug/pprof/profile -&gt; net/http/pprof.Profile
	//   6 = /healthz/log -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   7 = /livez/log -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   8 = /healthz/ping -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   9 = / -&gt; 
	//   10 = /healthz -&gt; k8s.io/apiserver/pkg/endpoints/metrics.InstrumentHandlerFunc.func1
	//   11 = /debug/flags -&gt; k8s.io/apiserver/pkg/server/routes.DebugFlags.Index-fm
	//   12 = /livez/ping -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   13 = /readyz/log -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   14 = /debug/pprof -&gt; k8s.io/apiserver/pkg/server/routes.redirectTo.func1
	//   15 = /debug/pprof/trace -&gt; net/http/pprof.Trace
	//   16 = /debug/flags/v -&gt; k8s.io/apiserver/pkg/server/routes.StringFlagPutHandler.func1
	//   17 = /readyz/poststarthook/start-sample-server-informers -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   18 = /livez/poststarthook/start-sample-server-informers -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   19 = /openapi/v2 -&gt; github.com/NYTimes/gziphandler.NewGzipLevelAndMinSize.func1.1
	//   20 = /healthz/poststarthook/start-sample-server-informers -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   21 = /readyz/etcd -&gt; k8s.io/apiserver/pkg/server/healthz.adaptCheckToHandler.func1
	//   22 = /index.html -&gt; 
	//   23 = /debug/pprof/symbol -&gt; net/http/pprof.Symbol
	//   24 = /livez -&gt; k8s.io/apiserver/pkg/endpoints/metrics.InstrumentHandlerFunc.func1
	//   25 = /readyz -&gt; k8s.io/apiserver/pkg/endpoints/metrics.InstrumentHandlerFunc.func1
	if exactHandler, ok := h.pathToHandler[r.URL.Path]; ok {
		klog.V(5).Infof("%v: %q satisfied by exact match", h.muxName, r.URL.Path)
		exactHandler.ServeHTTP(w, r)
		return
	}

	// 前缀匹配路径的处理器，默认有/debug/flags/ 和 /debug/pprof/
	for _, prefixHandler := range h.prefixHandlers {
		if strings.HasPrefix(r.URL.Path, prefixHandler.prefix) {
			klog.V(5).Infof("%v: %q satisfied by prefix %v", h.muxName, r.URL.Path, prefixHandler.prefix)
			prefixHandler.handler.ServeHTTP(w, r)
			return
		}
	}

	// 找不到处理器，404
	klog.V(5).Infof("%v: %q satisfied by NotFoundHandler", h.muxName, r.URL.Path)
	h.notFoundHandler.ServeHTTP(w, r)
}</pre>
<p>对于API资源请求，例如路径 /apis/wardle.example.com/v1beta1，则由go-restful处理。go-restful框架内部处理细节我们就忽略了，我们主要深入探讨上文中提到的<a href="#InstallAPIGroup">安装APIGroup</a>，每种资源的go-restful Handler都是由它注册的：</p>
<pre class="crayon-plain-tag">func (s *GenericAPIServer) InstallAPIGroup(apiGroupInfo *APIGroupInfo) error {
	return s.InstallAPIGroups(apiGroupInfo)
}
func (s *GenericAPIServer) InstallAPIGroups(apiGroupInfos ...*APIGroupInfo) error {
	// 遍历API组
	for _, apiGroupInfo := range apiGroupInfos {
		//          安装API资源        常量 /apis
		if err := s.installAPIResources(APIGroupPrefix, apiGroupInfo, openAPIModels); err != nil {
			return fmt.Errorf("unable to install api resources: %v", err)
		}

		// setup discovery
		// Install the version handler.
		// Add a handler at /apis/&lt;groupName&gt; to enumerate all versions supported by this group.
		apiVersionsForDiscovery := []metav1.GroupVersionForDiscovery{}
		for _, groupVersion := range apiGroupInfo.PrioritizedVersions {
			// Check the config to make sure that we elide versions that don't have any resources
			if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 {
				continue
			}
			apiVersionsForDiscovery = append(apiVersionsForDiscovery, metav1.GroupVersionForDiscovery{
				GroupVersion: groupVersion.String(),
				Version:      groupVersion.Version,
			})
		}
		preferredVersionForDiscovery := metav1.GroupVersionForDiscovery{
			GroupVersion: apiGroupInfo.PrioritizedVersions[0].String(),
			Version:      apiGroupInfo.PrioritizedVersions[0].Version,
		}
		apiGroup := metav1.APIGroup{
			Name:             apiGroupInfo.PrioritizedVersions[0].Group,
			Versions:         apiVersionsForDiscovery,
			PreferredVersion: preferredVersionForDiscovery,
		}

		s.DiscoveryGroupManager.AddGroup(apiGroup)
		s.Handler.GoRestfulContainer.Add(discovery.NewAPIGroupHandler(s.Serializer, apiGroup).WebService())
	}
	return nil
}

// 安装API资源
func (s *GenericAPIServer) installAPIResources(apiPrefix string, apiGroupInfo *APIGroupInfo, openAPIModels openapiproto.Models) error {
	// 遍历版本
	for _, groupVersion := range apiGroupInfo.PrioritizedVersions {
		// 跳过没有资源的组
		if len(apiGroupInfo.VersionedResourcesStorageMap[groupVersion.Version]) == 0 {
			klog.Warningf("Skipping API %v because it has no resources.", groupVersion)
			continue
		}

		apiGroupVersion := s.getAPIGroupVersion(apiGroupInfo, groupVersion, apiPrefix)
		if apiGroupInfo.OptionsExternalVersion != nil {
			apiGroupVersion.OptionsExternalVersion = apiGroupInfo.OptionsExternalVersion
		}
		apiGroupVersion.OpenAPIModels = openAPIModels
		apiGroupVersion.MaxRequestBodyBytes = s.maxRequestBodyBytes
		// 安装为go-restful的Handler
		if err := apiGroupVersion.InstallREST(s.Handler.GoRestfulContainer); err != nil {
			return fmt.Errorf("unable to setup API %v: %v", apiGroupInfo, err)
		}
	}

	return nil
}

// 注册一系列REST Handler（ storage, watch, proxy, redirect）到restful容器
func (g *APIGroupVersion) InstallREST(container *restful.Container) error {
	// 例如/apis/wardle.example.com/v1beta1
	prefix := path.Join(g.Root, g.GroupVersion.Group, g.GroupVersion.Version)
	installer := &amp;APIInstaller{
		group:             g,
		prefix:            prefix,
		minRequestTimeout: g.MinRequestTimeout,
	}
	// 执行API资源处理器的安装
	apiResources, ws, registrationErrors := installer.Install()
	// 执行资源发现处理器的安装
	versionDiscoveryHandler := discovery.NewAPIVersionHandler(g.Serializer, g.GroupVersion, staticLister{apiResources})
	versionDiscoveryHandler.AddToWebService(ws)
	// 添加WebService到容器
	container.Add(ws)
	return utilerrors.NewAggregate(registrationErrors)
}
func (a *APIInstaller) Install() ([]metav1.APIResource, *restful.WebService, []error) {
	var apiResources []metav1.APIResource
	var errors []error
	// 创建一个针对特定GV的WebService
	ws := a.newWebService()

	// Register the paths in a deterministic (sorted) order to get a deterministic swagger spec.
	paths := make([]string, len(a.group.Storage))
	var i int = 0
	for path := range a.group.Storage {
		paths[i] = path
		i++
	}
	sort.Strings(paths)
	for _, path := range paths {
		// 遍历资源，注册处理器，例如flunders
		apiResource, err := a.registerResourceHandlers(path, a.group.Storage[path], ws)
		if err != nil {
			errors = append(errors, fmt.Errorf("error in registering resource: %s, %v", path, err))
		}
		if apiResource != nil {
			apiResources = append(apiResources, *apiResource)
		}
	}
	return apiResources, ws, errors
}
func (a *APIInstaller) newWebService() *restful.WebService {
	ws := new(restful.WebService)
	// 此WebService的路径模板
	ws.Path(a.prefix)
	// a.prefix contains "prefix/group/version"
	ws.Doc("API at " + a.prefix)
	// 向后兼容的考虑，支持没有MIME类型
	ws.Consumes("*/*")
	// 根据API组使用的编解码器来确定响应支持的MIME类型
	//   0 = {string} "application/json"
	//   1 = {string} "application/yaml"
	//   2 = {string} "application/vnd.kubernetes.protobuf"
	mediaTypes, streamMediaTypes := negotiation.MediaTypesForSerializer(a.group.Serializer)
	ws.Produces(append(mediaTypes, streamMediaTypes...)...)
	// 例如 wardle.example.com/v1beta1
	ws.ApiVersion(a.group.GroupVersion.String())
	return ws
}
// 注册资源处理器，过于冗长，仅仅贴片段
func (a *APIInstaller) registerResourceHandlers(path string, storage rest.Storage, ws *restful.WebService) (*metav1.APIResource, error) {
	// ...
	// creater就是Handler的核心逻辑所在，它来自rest.Storage，对接Etcd
	creater, isCreater := storage.(rest.Creater)
	// ...
	actions = appendIf(actions, action{"POST", resourcePath, resourceParams, namer, false}, isCreater)
	switch action.Verb {
		case "POST": 
			handler = restfulCreateResource(creater, reqScope, admit)

			route := ws.POST(action.Path).To(handler).
				Doc(doc).
				Param(ws.QueryParameter("pretty", "If 'true', then the output is pretty printed.")).
				Operation("create"+namespaced+kind+strings.Title(subresource)+operationSuffix).
				Produces(append(storageMeta.ProducesMIMETypes(action.Verb), mediaTypes...)...).
				Returns(http.StatusOK, "OK", producedObject).
				// TODO: in some cases, the API may return a v1.Status instead of the versioned object
				// but currently go-restful can't handle multiple different objects being returned.
				Returns(http.StatusCreated, "Created", producedObject).
				Returns(http.StatusAccepted, "Accepted", producedObject).
				Reads(defaultVersionedObject).
				Writes(producedObject)
			if err := AddObjectParams(ws, route, versionedCreateOptions); err != nil {
				return nil, err
			}
			addParams(route, action.Params)
			routes = append(routes, route)
	// ...
}
func restfulCreateResource(r rest.Creater, scope handlers.RequestScope, admit admission.Interface) restful.RouteFunction {
	return func(req *restful.Request, res *restful.Response) {
		handlers.CreateResource(r, &amp;scope, admit)(res.ResponseWriter, req.Request)
	}
}
func CreateResource(r rest.Creater, scope *RequestScope, admission admission.Interface) http.HandlerFunc {
	return createHandler(&amp;namedCreaterAdapter{r}, scope, admission, false)
}
// 创建资源处理器核心
func createHandler(r rest.NamedCreater, scope *RequestScope, admit admission.Interface, includeName bool) http.HandlerFunc {
	return func(w http.ResponseWriter, req *http.Request) {
		// For performance tracking purposes.
		trace := utiltrace.New("Create", utiltrace.Field{Key: "url", Value: req.URL.Path}, utiltrace.Field{Key: "user-agent", Value: &amp;lazyTruncatedUserAgent{req}}, utiltrace.Field{Key: "client", Value: &amp;lazyClientIP{req}})
		defer trace.LogIfLong(500 * time.Millisecond)
		// 处理Dryrun
		if isDryRun(req.URL) &amp;&amp; !utilfeature.DefaultFeatureGate.Enabled(features.DryRun) {
			scope.err(errors.NewBadRequest("the dryRun feature is disabled"), w, req)
			return
		}

		// TODO: we either want to remove timeout or document it (if we document, move timeout out of this function and declare it in api_installer)
		timeout := parseTimeout(req.URL.Query().Get("timeout"))
		// 命名空间和资源名字处理
		namespace, name, err := scope.Namer.Name(req)
		if err != nil {
			if includeName {
				// name was required, return
				scope.err(err, w, req)
				return
			}

			// otherwise attempt to look up the namespace
			namespace, err = scope.Namer.Namespace(req)
			if err != nil {
				scope.err(err, w, req)
				return
			}
		}

		ctx, cancel := context.WithTimeout(req.Context(), timeout)
		defer cancel()
		ctx = request.WithNamespace(ctx, namespace)
		// 协商输出MIME类型
		outputMediaType, _, err := negotiation.NegotiateOutputMediaType(req, scope.Serializer, scope)
		if err != nil {
			scope.err(err, w, req)
			return
		}

		gv := scope.Kind.GroupVersion()
		// 协商输入如何反串行化
		s, err := negotiation.NegotiateInputSerializer(req, false, scope.Serializer)
		if err != nil {
			scope.err(err, w, req)
			return
		}
		// 从串行化器得到能将请求解码为特定版本的解码器
		decoder := scope.Serializer.DecoderToVersion(s.Serializer, scope.HubGroupVersion)

		// 读取请求体
		body, err := limitedReadBody(req, scope.MaxRequestBodyBytes)
		if err != nil {
			scope.err(err, w, req)
			return
		}

		options := &amp;metav1.CreateOptions{}
		values := req.URL.Query()
		if err := metainternalversionscheme.ParameterCodec.DecodeParameters(values, scope.MetaGroupVersion, options); err != nil {
			err = errors.NewBadRequest(err.Error())
			scope.err(err, w, req)
			return
		}
		if errs := validation.ValidateCreateOptions(options); len(errs) &gt; 0 {
			err := errors.NewInvalid(schema.GroupKind{Group: metav1.GroupName, Kind: "CreateOptions"}, "", errs)
			scope.err(err, w, req)
			return
		}
		options.TypeMeta.SetGroupVersionKind(metav1.SchemeGroupVersion.WithKind("CreateOptions"))

		defaultGVK := scope.Kind
		// 实例化资源的Go结构
		original := r.New()
		trace.Step("About to convert to expected version")
		// 将请求体解码到Go结构中
		obj, gvk, err := decoder.Decode(body, &amp;defaultGVK, original)
		if err != nil {
			err = transformDecodeError(scope.Typer, err, original, gvk, body)
			scope.err(err, w, req)
			return
		}
		if gvk.GroupVersion() != gv {
			err = errors.NewBadRequest(fmt.Sprintf("the API version in the data (%s) does not match the expected API version (%v)", gvk.GroupVersion().String(), gv.String()))
			scope.err(err, w, req)
			return
		}
		trace.Step("Conversion done")

		// 审计和Admission控制
		ae := request.AuditEventFrom(ctx)
		admit = admission.WithAudit(admit, ae)
		audit.LogRequestObject(ae, obj, scope.Resource, scope.Subresource, scope.Serializer)

		userInfo, _ := request.UserFrom(ctx)

		// On create, get name from new object if unset
		if len(name) == 0 {
			_, name, _ = scope.Namer.ObjectName(obj)
		}

		trace.Step("About to store object in database")
		admissionAttributes := admission.NewAttributesRecord(obj, nil, scope.Kind, namespace, name, scope.Resource, scope.Subresource, admission.Create, options, dryrun.IsDryRun(options.DryRun), userInfo)
		// 构建入库函数
		requestFunc := func() (runtime.Object, error) {
			// 调用rest.Storage进行入库
			return r.Create(
				ctx,
				name,
				obj,
				rest.AdmissionToValidateObjectFunc(admit, admissionAttributes, scope),
				options,
			)
		}
		// finishRequest能够异步执行回调，并且处理响应返回的错误
		result, err := finishRequest(timeout, func() (runtime.Object, error) {
			if scope.FieldManager != nil {
				liveObj, err := scope.Creater.New(scope.Kind)
				if err != nil {
					return nil, fmt.Errorf("failed to create new object (Create for %v): %v", scope.Kind, err)
				}
				obj = scope.FieldManager.UpdateNoErrors(liveObj, obj, managerOrUserAgent(options.FieldManager, req.UserAgent()))
			}
			if mutatingAdmission, ok := admit.(admission.MutationInterface); ok &amp;&amp; mutatingAdmission.Handles(admission.Create) {
				if err := mutatingAdmission.Admit(ctx, admissionAttributes, scope); err != nil {
					return nil, err
				}
			}
			result, err := requestFunc()
			// If the object wasn't committed to storage because it's serialized size was too large,
			// it is safe to remove managedFields (which can be large) and try again.
			if isTooLargeError(err) {
				if accessor, accessorErr := meta.Accessor(obj); accessorErr == nil {
					accessor.SetManagedFields(nil)
					result, err = requestFunc()
				}
			}
			return result, err
		})
		if err != nil {
			scope.err(err, w, req)
			return
		}
		trace.Step("Object stored in database")

		code := http.StatusCreated
		status, ok := result.(*metav1.Status)
		if ok &amp;&amp; err == nil &amp;&amp; status.Code == 0 {
			status.Code = int32(code)
		}

		transformResponseObject(ctx, scope, trace, req, w, code, outputMediaType, result)
	}
}</pre>
<p>回顾一下请求处理的整体逻辑：</p>
<ol style="list-style-type: undefined;">
<li>GenericAPIServer.Handler就是http.Handler，可以注册给任何HTTP服务器。因此我们想绕开HTTPS的限制应该很容易</li>
<li>GenericAPIServer.Handler是一个层层包裹的处理器链，外层是一系列过滤器，最里面是director</li>
<li>director负责整体的请求分发：
<ol>
<li>对于非API资源请求，分发给nonGoRestfulMux。我们可以利用这个扩展点，扩展任意形式的HTTP接口</li>
<li>对于API资源请求，分发给gorestfulContainer</li>
</ol>
</li>
<li>在GenericAPIServer.InstallAPIGroup中，所有支持的API资源的所有版本，都注册为go-restful的一个WebService</li>
<li>这些WebService的逻辑包括（依赖于rest.Storage）：
<ol>
<li>将请求解码为资源对应的Go结构</li>
<li>将Go结构编码为JSON</li>
<li>将JSON存储到Etcd</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">sample-apiserver小结</span></div>
<p>通过对sample-apiserver的代码分析，我们理解了构建自己的API Server的各种关键要素。</p>
<p>APIServer的核心类型是<pre class="crayon-plain-tag">GenericAPIServer</pre>，它是由<pre class="crayon-plain-tag">genericapiserver.CompletedConfig</pre>的<pre class="crayon-plain-tag">New()</pre>方法生成的。后者则是<pre class="crayon-plain-tag">genericapiserver.RecommendedConfig</pre>的<pre class="crayon-plain-tag">Complete()</pre>方法生成的。而RecommendedConfig又是从<pre class="crayon-plain-tag">genericoptions.RecommendedOptions</pre>得到的。sample-apiserver对Config、Option、Server等对象都做了一层包装，我们不关注这些wrapper。</p>
<p>RecommendedOptions对应了用户提供的各类选项（外加所谓推荐选项，降低使用时的复杂度），例如Etcd地址、Etcd存储前缀、APIServer的基本信息等等。调用RecommendedOptions的<pre class="crayon-plain-tag">ApplyTo</pre>方法，会根据选项，推导出APIServer所需的，完整的配置信息。在这个方法中，甚至会进行自签名证书等重操作，而不是简单的将信息从Option复制给Config。RecommendedOptions会依次调用它的各个字段的ApplyTo方法，从而推导出RecommendedConfig的各个字段。</p>
<p>RecommendedConfig的Complete方法，再一次进行配置信息的推导，主要牵涉到OpenAPI相关的配置。</p>
<p>CompletedConfig的New方法实例化GenericAPIServer，这一步最关键的逻辑是安装API组。API组定义了如何实现GroupVersion中API的增删改查，它将GroupVersion的每种资源映射到registry.REST，后者具有处理REST风格请求的能力，并（默认）存储到Etcd。</p>
<p>GenericAPIServer提供了一些钩子来处理Admission控制器的注册、初始化。以及另外一些钩子来对API Server的生命周期事件做出响应。</p>
<div class="blog_h2"><span class="graybg">sample-apiserver改造</span></div>
<div class="blog_h3"><span class="graybg">解除对kube-apiserver的依赖</span></div>
<p>想实现sample-apiserver的独立运行，RecommendedOptions有三个字段必须处理：Authentication、Authorization、CoreAPI，它们都隐含了对主kube-apiserver的依赖。</p>
<p>Authentication依赖主kube-apiserver，是因为它需要访问TokenReviewInterface，访问kube-system中的ConfigMap。Authorization依赖主kube-apiserver，是因为它需要访问SubjectAccessReviewInterface。CoreAPI则是直接为Config提供了两个字段：ClientConfig、SharedInformerFactory。</p>
<p>将这些字段置空，可以解除对主kube-apiserver的依赖。这样启动sample-apiserver时就不需要提供这三个命令行选项：</p>
<p style="padding-left: 30px;">--kubeconfig=/home/alex/.kube/config<br />--authentication-kubeconfig=/home/alex/.kube/config<br />--authorization-kubeconfig=/home/alex/.kube/config</p>
<p>但是，置空CoreAPI会导致报错：admission depends on a Kubernetes core API shared informer, it cannot be nil。这提示我们不能在不依赖主kube-apiserver的情况下使用Admission控制器这一特性，需要将Admission也置空：</p>
<pre class="crayon-plain-tag">o.RecommendedOptions.Authentication = nil
o.RecommendedOptions.Authorization = nil
o.RecommendedOptions.CoreAPI = nil
o.RecommendedOptions.Admission = nil</pre>
<p>清空上述四个字段后，sample-server还会在PostStart钩子中崩溃：</p>
<pre class="crayon-plain-tag">// panic，这个SharedInformerFactory是CoreAPI选项提供的
config.GenericConfig.SharedInformerFactory.Start(context.StopCh)
// 仅仅Admission控制器使用该InformerFactory
o.SharedInformerFactory.Start(context.StopCh)</pre>
<p>由于注释中给出的原因，这个PostStart钩子已经没有意义，删除即可正常启动服务器。</p>
<div class="blog_h3"><span class="graybg">使用HTTP而非HTTPS</span></div>
<p>GenericAPIServer的<pre class="crayon-plain-tag">Run</pre>方法的默认实现，是调用<pre class="crayon-plain-tag">s.SecureServingInfo.Serve</pre>，因而强制使用HTTPS：</p>
<pre class="crayon-plain-tag">stoppedCh, err = s.SecureServingInfo.Serve(s.Handler, s.ShutdownTimeout, internalStopCh)</pre>
<p>不过，很明显的，我们只需要将s.Handler传递给自己的http.Server即可使用HTTP。</p>
<div class="blog_h3"><span class="graybg">添加任意HTTP接口</span></div>
<p>我们的迁移工具还提供一些非Kubernetes风格的HTTP接口，那么如何集成到APIServer中呢？</p>
<p>在启动服务器之前，可以直接访问<pre class="crayon-plain-tag">GenericAPIServer.Handler.NonGoRestfulMux</pre>，NonGoRestfulMux实现了：</p>
<pre class="crayon-plain-tag">type mux interface {
	Handle(pattern string, handler http.Handler)
}</pre>
<p>调用Handle即可为任何路径注册处理器。</p>
<div class="blog_h1"><span class="graybg">apiserver-builder-alpha</span></div>
<p>通过对sample-apiserver代码的分析，我们了解到构建自己的API Server有大量繁琐的工作需要做。幸运的是，K8S提供了<a href="https://github.com/kubernetes-sigs/apiserver-builder-alpha">apiserver-builder-alpha</a>简化这一过程。</p>
<p>apiserver-builder-alpha是一系列工具和库的集合，它能够：</p>
<ol>
<li>为新的API资源创建Go类型、控制器（基于controller-runtime）、测试用例、文档</li>
<li>构建、（独立、在Minikube或者在K8S中）运行扩展的控制平面组件（APIServer）</li>
<li>让在控制器中watch/update资源更简单</li>
<li>让创建新的资源/子资源更简单</li>
<li>提供大部分合理的默认值</li>
</ol>
<div class="blog_h2"><span class="graybg">安装</span></div>
<p>下载<a href="https://github.com/kubernetes-sigs/apiserver-builder-alpha/releases">压缩包</a>，解压并存放到目录，然后设置环境变量：</p>
<pre class="crayon-plain-tag">export PATH=$HOME/.local/kubernetes/apiserver-builder/bin/:$PATH</pre>
<div class="blog_h2"><span class="graybg">起步</span></div>
<p>你需要在$GOPATH下创建一个项目，创建一个boilerplate.go.txt文件。然后执行：</p>
<pre class="crayon-plain-tag">apiserver-boot init repo --domain cloud.gmem.cc</pre>
<p>该命令会生成如下目录结构：</p>
<pre class="crayon-plain-tag">.
├── bin
├── boilerplate.go.txt
├── BUILD.bazel
├── cmd
│   ├── apiserver
│   │   └── main.go
│   └── manager
│       └── main.go
├── go.mod
├── go.sum
├── pkg
│   ├── apis
│   │   └── doc.go
│   ├── controller
│   │   └── doc.go
│   ├── doc.go
│   ├── openapi
│   │   └── doc.go
│   └── webhook
│       └── webhook.go
├── PROJECT
└── WORKSPAC</pre>
<p> cmd/apiserver/main.go，是APIServer的入口点：</p>
<pre class="crayon-plain-tag">import "sigs.k8s.io/apiserver-builder-alpha/pkg/cmd/server"

func main() {
	version := "v0"

	err := server.StartApiServerWithOptions(&amp;server.StartOptions{
		EtcdPath:         "/registry/cloud.gmem.cc",
		//                无法运行，这个函数不存在
		Apis:             apis.GetAllApiBuilders(),
		Openapidefs:      openapi.GetOpenAPIDefinitions,
		Title:            "Api",
		Version:          version,

		// TweakConfigFuncs []func(apiServer *apiserver.Config) error
		// FlagConfigFuncs []func(*cobra.Command) error
	})
	if err != nil {
		panic(err)
	}
}</pre>
<p>可以看到apiserver-builder-alpha进行了一些封装。</p>
<p>执行下面的命令，添加一个新的API资源：</p>
<pre class="crayon-plain-tag">apiserver-boot create group version resource --group tcm --version v1 --kind Flunder</pre>
<p>最后，执行命令可以在本地启动APIServer：</p>
<pre class="crayon-plain-tag">apiserver-boot run local</pre>
<div class="blog_h2"><span class="graybg">问题 </span></div>
<p>本文提及的工具项目，最初架构是基于CRD，使用kubebuilder进行代码生成。kubebuilder的目录结构和apiserver-builder并不兼容。</p>
<p>此外apiserver-builder项目仍然处于Alpha阶段，并且经过测试，发现生成代码无法运行。为了避免不必要的麻烦，我们不打算使用它。</p>
<div class="blog_h1"><span class="graybg">编写APIServer</span></div>
<p>由于apiserver-builder不成熟，而且我们已经基于kubebuilder完成了大部分开发工作。因此打算基于分析sample-apiserver获得的经验，手工编写一个独立运行、使用HTTP协议的APIServer。</p>
<p>kubebuilder并不会生成zz_generated.openapi.go文件，因为该文件对于CRD没有意义。但是这个文件对于独立API Server是必须的。</p>
<p>我们需要为资源类型所在包添加注解：</p>
<pre class="crayon-plain-tag">// +k8s:openapi-gen=true

package v1</pre>
<p>并调用<a href="https://blog.gmem.cc/openapi#openapi-gen">openapi-gen</a>生成此文件：</p>
<pre class="crayon-plain-tag">openapi-gen  \
	--input-dirs "k8s.io/apimachinery/pkg/apis/meta/v1,k8s.io/apimachinery/pkg/runtime,k8s.io/apimachinery/pkg/version" \
	--input-dirs cloud.gmem.cc/teleport/api/v1    -p cloud.gmem.cc/teleport/api/v1 -O zz_generated.openapi</pre>
<p>下面是完整的quick&amp;dirty的代码：</p>
<pre class="crayon-plain-tag">package main

import (
	v1 "cloud.gmem.cc/teleport/api/v1"
	"context"
	"fmt"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/fields"
	"k8s.io/apimachinery/pkg/labels"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/runtime/serializer"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/apimachinery/pkg/util/validation/field"
	"k8s.io/apiserver/pkg/endpoints/openapi"
	"k8s.io/apiserver/pkg/features"
	"k8s.io/apiserver/pkg/registry/generic"
	genericregistry "k8s.io/apiserver/pkg/registry/generic/registry"
	"k8s.io/apiserver/pkg/registry/rest"
	genericapiserver "k8s.io/apiserver/pkg/server"
	genericoptions "k8s.io/apiserver/pkg/server/options"
	"k8s.io/apiserver/pkg/storage"
	"k8s.io/apiserver/pkg/storage/names"
	"k8s.io/apiserver/pkg/util/feature"
	utilfeature "k8s.io/apiserver/pkg/util/feature"
	"net"
	"net/http"
	"os"
	"reflect"
	ctrl "sigs.k8s.io/controller-runtime"
)

var (
	setupLog = ctrl.Log.WithName("setup")
)

func main() {

	s := runtime.NewScheme()
	utilruntime.Must(v1.AddToScheme(s))
	gv := v1.GroupVersion
	utilruntime.Must(s.SetVersionPriority(gv))
	metav1.AddToGroupVersion(s, schema.GroupVersion{Version: "v1"})
	unversioned := schema.GroupVersion{Group: "", Version: "v1"}
	s.AddUnversionedTypes(unversioned,
		&amp;metav1.Status{},
		&amp;metav1.APIVersions{},
		&amp;metav1.APIGroupList{},
		&amp;metav1.APIGroup{},
		&amp;metav1.APIResourceList{},
	)

	//  必须注册一个__internal版本，否则报错
	//  failed to prepare current and previous objects: no kind "Flunder" is registered for the internal version of group "tcm.cloud.gmem.cc" in scheme 
	gvi := gv
	gvi.Version = runtime.APIVersionInternal
	s.AddKnownTypes(gvi, &amp;v1.Flunder{}, &amp;v1.FlunderList{})

	codecFactory := serializer.NewCodecFactory(s)
	codec := codecFactory.LegacyCodec(gv)
	options := genericoptions.NewRecommendedOptions(
		"/teleport/cloud.gmem.cc",
		codec,
	)
	options.Etcd.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(gv, schema.GroupKind{Group: gv.Group})
	ips := []net.IP{net.ParseIP("127.0.0.1")}
	if err := options.SecureServing.MaybeDefaultWithSelfSignedCerts("localhost", nil, ips); err != nil {
		setupLog.Error(err, "error creating self-signed certificates")
		os.Exit(1)
	}
	options.Etcd.StorageConfig.Paging = utilfeature.DefaultFeatureGate.Enabled(features.APIListChunking)
	options.Etcd.StorageConfig.Transport.ServerList = []string{"http://etcd.gmem.cc:2379"}

	options.Authentication = nil
	options.Authorization = nil
	options.CoreAPI = nil
	options.Admission = nil
	options.SecureServing.BindPort = 6443

	config := genericapiserver.NewRecommendedConfig(codecFactory)
	config.OpenAPIConfig = genericapiserver.DefaultOpenAPIConfig(v1.GetOpenAPIDefinitions,
		openapi.NewDefinitionNamer(s))
	config.OpenAPIConfig.Info.Title = "Teleport"
	config.OpenAPIConfig.Info.Version = "1.0"

	feature.DefaultMutableFeatureGate.SetFromMap(map[string]bool{
		string(features.APIPriorityAndFairness): false,
	})

	if err := options.ApplyTo(config); err != nil {
		panic(err)
	}
	completedConfig := config.Complete()
	server, err := completedConfig.New("teleport-apiserver", genericapiserver.NewEmptyDelegate())
	if err != nil {
		panic(err)
	}

	apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(gv.Group, s, metav1.ParameterCodec, codecFactory)
	v1storage := map[string]rest.Storage{}
	resource := v1.ResourceFlunders
	v1storage[resource] = createStore(
		s,
		gv.WithResource(resource).GroupResource(),
		func() runtime.Object { return &amp;v1.Flunder{} },
		func() runtime.Object { return &amp;v1.FlunderList{} },
		completedConfig.RESTOptionsGetter,
	)
	apiGroupInfo.VersionedResourcesStorageMap[gv.Version] = v1storage
	if err := server.InstallAPIGroups(&amp;apiGroupInfo); err != nil {
		panic(err)
	}
	server.AddPostStartHookOrDie("teleport-post-start", func(context genericapiserver.PostStartHookContext) error {
		return nil
	})
	preparedServer := server.PrepareRun()
	http.ListenAndServe(":6080", preparedServer.Handler)
}

func createStore(scheme *runtime.Scheme, gr schema.GroupResource, newFunc, newListFunc func() runtime.Object,
	optsGetter generic.RESTOptionsGetter) rest.Storage {
	attrs := func(obj runtime.Object) (labels.Set, fields.Set, error) {
		typ := reflect.TypeOf(newFunc())
		if reflect.TypeOf(obj) != typ {
			return nil, nil, fmt.Errorf("given object is not a %s", typ.Name())
		}
		oma := obj.(metav1.ObjectMetaAccessor)
		meta := oma.GetObjectMeta()
		return meta.GetLabels(), fields.Set{
			"metadata.name":      meta.GetName(),
			"metadata.namespace": meta.GetNamespace(),
		}, nil
	}
	s := strategy{
		scheme,
		names.SimpleNameGenerator,
	}
	store := &amp;genericregistry.Store{
		NewFunc:     newFunc,
		NewListFunc: newListFunc,
		PredicateFunc: func(label labels.Selector, field fields.Selector) storage.SelectionPredicate {
			return storage.SelectionPredicate{
				Label:    label,
				Field:    field,
				GetAttrs: attrs,
			}
		},
		DefaultQualifiedResource: gr,

		CreateStrategy: s,
		UpdateStrategy: s,
		DeleteStrategy: s,

		TableConvertor: rest.NewDefaultTableConvertor(gr),
	}
	options := &amp;generic.StoreOptions{RESTOptions: optsGetter, AttrFunc: attrs}
	if err := store.CompleteWithOptions(options); err != nil {
		panic(err)
	}
	return store
}

type strategy struct {
	runtime.ObjectTyper
	names.NameGenerator
}

func (strategy) NamespaceScoped() bool {
	return true
}

func (strategy) PrepareForCreate(ctx context.Context, obj runtime.Object) {
}

func (strategy) PrepareForUpdate(ctx context.Context, obj, old runtime.Object) {
}

func (strategy) Validate(ctx context.Context, obj runtime.Object) field.ErrorList {
	return field.ErrorList{}
}

func (strategy) AllowCreateOnUpdate() bool {
	return false
}

func (strategy) AllowUnconditionalUpdate() bool {
	return false
}

func (strategy) Canonicalize(obj runtime.Object) {
}

func (strategy) ValidateUpdate(ctx context.Context, obj, old runtime.Object) field.ErrorList {
	return field.ErrorList{}
}</pre>
<div class="blog_h1"><span class="graybg">定制存储后端 </span></div>
<p>在安装APIGroup的时候，我们需要为每API组的每个版本的每种资源，指定存储后端：</p>
<pre class="crayon-plain-tag">// 每个组
apiGroupInfo := genericapiserver.NewDefaultAPIGroupInfo(wardle.GroupName, Scheme, metav1.ParameterCodec, Codecs)
// 每个版本
v1alpha1storage := map[stcongring]rest.Storage{}
// 每种资源提供一个rest.Storage
v1alpha1storage["flunders"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))

// 安装APIGroup
s.GenericAPIServer.InstallAPIGroup(&amp;apiGroupInfo)</pre>
<p>默认情况下，使用的是genericregistry.Store，它对接到Etcd。要实现自己的存储后端，实现相关接口即可。</p>
<p>注意：关于存储后端，有很多细节需要处理。</p>
<div class="blog_h2"><span class="graybg">基于文件的存储</span></div>
<p>下面贴一个在文件系统中，以YAML形式存储API资源的例子：</p>
<pre class="crayon-plain-tag">package file

import (
	"bytes"
	"context"
	"fmt"
	"io/ioutil"
	"k8s.io/apimachinery/pkg/util/uuid"
	"os"
	"path/filepath"
	"reflect"
	"strings"
	"sync"

	apierrors "k8s.io/apimachinery/pkg/api/errors"
	"k8s.io/apimachinery/pkg/api/meta"
	metainternalversion "k8s.io/apimachinery/pkg/apis/meta/internalversion"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/conversion"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
	"k8s.io/apimachinery/pkg/watch"
	genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
	"k8s.io/apiserver/pkg/registry/rest"
)

var _ rest.StandardStorage = &amp;store{}
var _ rest.Scoper = &amp;store{}
var _ rest.Storage = &amp;store{}

// NewStore instantiates a new file storage
func NewStore(groupResource schema.GroupResource, codec runtime.Codec, rootpath string, isNamespaced bool,
	newFunc func() runtime.Object, newListFunc func() runtime.Object, tc rest.TableConvertor) rest.Storage {
	objRoot := filepath.Join(rootpath, groupResource.Group, groupResource.Resource)
	if err := ensureDir(objRoot); err != nil {
		panic(fmt.Sprintf("unable to write data dir: %s", err))
	}
	rest := &amp;store{
		defaultQualifiedResource: groupResource,
		TableConvertor:           tc,
		codec:                    codec,
		objRootPath:              objRoot,
		isNamespaced:             isNamespaced,
		newFunc:                  newFunc,
		newListFunc:              newListFunc,
		watchers:                 make(map[int]*yamlWatch, 10),
	}
	return rest
}

type store struct {
	rest.TableConvertor
	codec        runtime.Codec
	objRootPath  string
	isNamespaced bool

	muWatchers sync.RWMutex
	watchers   map[int]*yamlWatch

	newFunc                  func() runtime.Object
	newListFunc              func() runtime.Object
	defaultQualifiedResource schema.GroupResource
}

func (f *store) notifyWatchers(ev watch.Event) {
	f.muWatchers.RLock()
	for _, w := range f.watchers {
		w.ch &lt;- ev
	}
	f.muWatchers.RUnlock()
}

func (f *store) New() runtime.Object {
	return f.newFunc()
}

func (f *store) NewList() runtime.Object {
	return f.newListFunc()
}

func (f *store) NamespaceScoped() bool {
	return f.isNamespaced
}

func (f *store) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
	return read(f.codec, f.objectFileName(ctx, name), f.newFunc)
}

func (f *store) List(ctx context.Context, options *metainternalversion.ListOptions) (runtime.Object, error) {
	newListObj := f.NewList()
	v, err := getListPrt(newListObj)
	if err != nil {
		return nil, err
	}

	dirname := f.objectDirName(ctx)
	if err := visitDir(dirname, f.newFunc, f.codec, func(path string, obj runtime.Object) {
		appendItem(v, obj)
	}); err != nil {
		return nil, fmt.Errorf("failed walking filepath %v", dirname)
	}
	return newListObj, nil
}

func (f *store) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc,
	options *metav1.CreateOptions) (runtime.Object, error) {
	if createValidation != nil {
		if err := createValidation(ctx, obj); err != nil {
			return nil, err
		}
	}
	if f.isNamespaced {
		ns, ok := genericapirequest.NamespaceFrom(ctx)
		if !ok {
			return nil, apierrors.NewBadRequest("namespace required")
		}
		if err := ensureDir(filepath.Join(f.objRootPath, ns)); err != nil {
			return nil, err
		}
	}

	accessor, err := meta.Accessor(obj)
	if err != nil {
		return nil, err
	}
	if accessor.GetUID() == "" {
		accessor.SetUID(uuid.NewUUID())
	}

	name := accessor.GetName()
	filename := f.objectFileName(ctx, name)
	qualifiedResource := f.qualifiedResourceFromContext(ctx)
	if exists(filename) {
		return nil, apierrors.NewAlreadyExists(qualifiedResource, name)
	}

	if err := write(f.codec, filename, obj); err != nil {
		return nil, apierrors.NewInternalError(err)
	}

	f.notifyWatchers(watch.Event{
		Type:   watch.Added,
		Object: obj,
	})

	return obj, nil
}

func (f *store) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo,
	createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc,
	forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
	isCreate := false
	oldObj, err := f.Get(ctx, name, nil)
	if err != nil {
		if !forceAllowCreate {
			return nil, false, err
		}
		isCreate = true
	}

	if f.isNamespaced {
		// ensures namespace dir
		ns, ok := genericapirequest.NamespaceFrom(ctx)
		if !ok {
			return nil, false, apierrors.NewBadRequest("namespace required")
		}
		if err := ensureDir(filepath.Join(f.objRootPath, ns)); err != nil {
			return nil, false, err
		}
	}

	updatedObj, err := objInfo.UpdatedObject(ctx, oldObj)
	if err != nil {
		return nil, false, err
	}
	filename := f.objectFileName(ctx, name)

	if isCreate {
		if createValidation != nil {
			if err := createValidation(ctx, updatedObj); err != nil {
				return nil, false, err
			}
		}
		if err := write(f.codec, filename, updatedObj); err != nil {
			return nil, false, err
		}
		f.notifyWatchers(watch.Event{
			Type:   watch.Added,
			Object: updatedObj,
		})
		return updatedObj, true, nil
	}

	if updateValidation != nil {
		if err := updateValidation(ctx, updatedObj, oldObj); err != nil {
			return nil, false, err
		}
	}
	if err := write(f.codec, filename, updatedObj); err != nil {
		return nil, false, err
	}
	f.notifyWatchers(watch.Event{
		Type:   watch.Modified,
		Object: updatedObj,
	})
	return updatedObj, false, nil
}

func (f *store) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc,
	options *metav1.DeleteOptions) (runtime.Object, bool, error) {
	filename := f.objectFileName(ctx, name)
	qualifiedResource := f.qualifiedResourceFromContext(ctx)
	if !exists(filename) {
		return nil, false, apierrors.NewNotFound(qualifiedResource, name)
	}

	oldObj, err := f.Get(ctx, name, nil)
	if err != nil {
		return nil, false, err
	}
	if deleteValidation != nil {
		if err := deleteValidation(ctx, oldObj); err != nil {
			return nil, false, err
		}
	}

	if err := os.Remove(filename); err != nil {
		return nil, false, err
	}
	f.notifyWatchers(watch.Event{
		Type:   watch.Deleted,
		Object: oldObj,
	})
	return oldObj, true, nil
}

func (f *store) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc,
	options *metav1.DeleteOptions, listOptions *metainternalversion.ListOptions) (runtime.Object, error) {
	newListObj := f.NewList()
	v, err := getListPrt(newListObj)
	if err != nil {
		return nil, err
	}
	dirname := f.objectDirName(ctx)
	if err := visitDir(dirname, f.newFunc, f.codec, func(path string, obj runtime.Object) {
		_ = os.Remove(path)
		appendItem(v, obj)
	}); err != nil {
		return nil, fmt.Errorf("failed walking filepath %v", dirname)
	}
	return newListObj, nil
}

func (f *store) objectFileName(ctx context.Context, name string) string {
	if f.isNamespaced {
		// FIXME: return error if namespace is not found
		ns, _ := genericapirequest.NamespaceFrom(ctx)
		return filepath.Join(f.objRootPath, ns, name+".yaml")
	}
	return filepath.Join(f.objRootPath, name+".yaml")
}

func (f *store) objectDirName(ctx context.Context) string {
	if f.isNamespaced {
		// FIXME: return error if namespace is not found
		ns, _ := genericapirequest.NamespaceFrom(ctx)
		return filepath.Join(f.objRootPath, ns)
	}
	return filepath.Join(f.objRootPath)
}

func write(encoder runtime.Encoder, filepath string, obj runtime.Object) error {
	buf := new(bytes.Buffer)
	if err := encoder.Encode(obj, buf); err != nil {
		return err
	}
	return ioutil.WriteFile(filepath, buf.Bytes(), 0600)
}

func read(decoder runtime.Decoder, path string, newFunc func() runtime.Object) (runtime.Object, error) {
	content, err := ioutil.ReadFile(filepath.Clean(path))
	if err != nil {
		return nil, err
	}
	newObj := newFunc()
	decodedObj, _, err := decoder.Decode(content, nil, newObj)
	if err != nil {
		return nil, err
	}
	return decodedObj, nil
}

func exists(filepath string) bool {
	_, err := os.Stat(filepath)
	return err == nil
}

func ensureDir(dirname string) error {
	if !exists(dirname) {
		return os.MkdirAll(dirname, 0700)
	}
	return nil
}

func visitDir(dirname string, newFunc func() runtime.Object, codec runtime.Decoder,
	visitFunc func(string, runtime.Object)) error {
	return filepath.Walk(dirname, func(path string, info os.FileInfo, err error) error {
		if err != nil {
			return err
		}
		if info.IsDir() {
			return nil
		}
		if !strings.HasSuffix(info.Name(), ".yaml") {
			return nil
		}
		newObj, err := read(codec, path, newFunc)
		if err != nil {
			return err
		}
		visitFunc(path, newObj)
		return nil
	})
}

func appendItem(v reflect.Value, obj runtime.Object) {
	v.Set(reflect.Append(v, reflect.ValueOf(obj).Elem()))
}

func getListPrt(listObj runtime.Object) (reflect.Value, error) {
	listPtr, err := meta.GetItemsPtr(listObj)
	if err != nil {
		return reflect.Value{}, err
	}
	v, err := conversion.EnforcePtr(listPtr)
	if err != nil || v.Kind() != reflect.Slice {
		return reflect.Value{}, fmt.Errorf("need ptr to slice: %v", err)
	}
	return v, nil
}

func (f *store) Watch(ctx context.Context, options *metainternalversion.ListOptions) (watch.Interface, error) {
	yw := &amp;yamlWatch{
		id: len(f.watchers),
		f:  f,
		ch: make(chan watch.Event, 10),
	}
	// On initial watch, send all the existing objects
	list, err := f.List(ctx, options)
	if err != nil {
		return nil, err
	}

	danger := reflect.ValueOf(list).Elem()
	items := danger.FieldByName("Items")

	for i := 0; i &lt; items.Len(); i++ {
		obj := items.Index(i).Addr().Interface().(runtime.Object)
		yw.ch &lt;- watch.Event{
			Type:   watch.Added,
			Object: obj,
		}
	}

	f.muWatchers.Lock()
	f.watchers[yw.id] = yw
	f.muWatchers.Unlock()

	return yw, nil
}

type yamlWatch struct {
	f  *store
	id int
	ch chan watch.Event
}

func (w *yamlWatch) Stop() {
	w.f.muWatchers.Lock()
	delete(w.f.watchers, w.id)
	w.f.muWatchers.Unlock()
}

func (w *yamlWatch) ResultChan() &lt;-chan watch.Event {
	return w.ch
}

func (f *store) ConvertToTable(ctx context.Context, object runtime.Object,
	tableOptions runtime.Object) (*metav1.Table, error) {
	return f.TableConvertor.ConvertToTable(ctx, object, tableOptions)
}
func (f *store) qualifiedResourceFromContext(ctx context.Context) schema.GroupResource {
	if info, ok := genericapirequest.RequestInfoFrom(ctx); ok {
		return schema.GroupResource{Group: info.APIGroup, Resource: info.Resource}
	}
	// some implementations access storage directly and thus the context has no RequestInfo
	return f.defaultQualifiedResource
}</pre>
<p>调用NewStore即可创建一个rest.Storage。前面我们提到过存储后端有很多细节需要处理，对于上面这个样例，它没有：</p>
<ol>
<li>发现正在删除中的资源，并在CRUD时作出适当响应</li>
<li>进行资源合法性校验。genericregistry.Store的做法是，调用strategy进行校验</li>
<li>自动填充某些元数据字段，包括creationTimestamp、selfLink等</li>
</ol>
<div class="blog_h1"><span class="graybg">处理子资源</span></div>
<p>假如K8S中某种资源具有状态子资源。那么当客户端更新状态子资源时，发出的HTTP请求格式为：</p>
<p style="padding-left: 30px;">PUT /apis/cloud.gmem.cc/v1/namespaces/default/flunders/sample/status</p>
<p>它会匹配路由：</p>
<p style="padding-left: 30px;">PUT /apis/cloud.gmem.cc/v1/namespaces/{namespace}/flunders/{name}/status</p>
<p>这个路由是专门为status子资源准备的，和主资源路由不同：</p>
<p style="padding-left: 30px;">PUT /apis/cloud.gmem.cc/v1/namespaces/{namespace}/flunders/{name}</p>
<p>那么，主资源、子资源的处理方式有什么不同？如何影响这种资源处理逻辑呢？</p>
<div class="blog_h2"><span class="graybg">注册子资源</span></div>
<p>InstallAPIGroups时，你只需要简单的为带有 / 的资源名字符串添加一个rest.Storage，就支持子资源了：</p>
<pre class="crayon-plain-tag">v1beta1storage["flunders/status"] = wardleregistry.RESTInPeace(flunderstorage.NewREST(Scheme, c.GenericConfig.RESTOptionsGetter))</pre>
<p>你甚至可以直接使用父资源的rest.Storage。但是这样的结果是，客户端请求可以仅更新status，也可以更新整个flunder，一般情况下这是不符合预期的。</p>
<p>上面的代码，还会导致在APIServer中注册类似本章开始处的go-restful路由。</p>
<p>抽取路径变量namespace、name的代码是：</p>
<pre class="crayon-plain-tag">pathParams := pathProcessor.ExtractParameters(route, webService, httpRequest.URL.Path)</pre>
<p>这两个变量识别了当前操控的是什么资源。请求进而会转发给rest.Storage的Update方法：</p>
<pre class="crayon-plain-tag">func (e *Store) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {}</pre>
<p>name参数传递的是资源的名字。当前是否应当（仅）更新子资源，rest.Storage无从知晓。</p>
<div class="blog_h2"><span class="graybg">子资源处理器</span></div>
<p>更新状态子资源的时候，我们通常仅仅允许更新Status字段。要达成这个目的，我们需要为子资源注册独立的rest.Storage。</p>
<pre class="crayon-plain-tag">package store

import (
	"context"
	"k8s.io/apimachinery/pkg/runtime"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	"k8s.io/apiserver/pkg/registry/generic/registry"
	"k8s.io/apiserver/pkg/registry/rest"
	"sigs.k8s.io/apiserver-runtime/pkg/builder/resource/util"
)

// CopyStatusFunc copies status from obj to old
type CopyStatusFunc func(src, dst runtime.Object)

// StatusStore decorates a parent storage and only updates
// status subresource when updating
func StatusStore(parentStore rest.StandardStorage, copyStatusFunc CopyStatusFunc) rest.Storage {
	switch pstor := parentStore.(type) {
	case *registry.Store:
		pstor.UpdateStrategy = &amp;statusStrategy{
			RESTUpdateStrategy: pstor.UpdateStrategy,
			copyStatusFunc:     copyStatusFunc,
		}
	}
	return &amp;statusStore{
		StandardStorage: parentStore,
	}
}

var _ rest.Getter = &amp;statusStore{}
var _ rest.Updater = &amp;statusStore{}

type statusStore struct {
	rest.StandardStorage
}

var _ rest.RESTUpdateStrategy = &amp;statusStrategy{}

// statusStrategy defines a default Strategy for the status subresource.
type statusStrategy struct {
	rest.RESTUpdateStrategy
	copyStatusFunc CopyStatusFunc
}

// PrepareForUpdate calls the PrepareForUpdate function on obj if supported, otherwise does nothing.
func (s *statusStrategy) PrepareForUpdate(ctx context.Context, new, old runtime.Object) {
	s.copyStatusFunc(new, old)
	if err := util.DeepCopy(old, new); err != nil {
		utilruntime.HandleError(err)
	}
}</pre>
<p>genericregistry.Store的更新会在一个原子操作的回调函数中进行。在回调中，它会调用Strategy的PrepareForUpdate方法。上面的statusStore的原理就是覆盖此方法，仅仅改变状态子资源。</p>
<div class="blog_h1"><span class="graybg"><a id="multipleversions"></a>多版本化</span></div>
<p>当你的API需要引入破坏性变更时，就要考虑支持多版本化。</p>
<div class="blog_h2"><span class="graybg">API文件布局</span></div>
<p>下面是一个典型的多版本API文件目录的布局：</p>
<pre class="crayon-plain-tag">api
├── doc.go
├── fullvpcmigration_types.go
├── 
├── v1
│   ├── conversion.go
│   ├── doc.go
│   ├── fullvpcmigration_types.go
│   ├── register.go
│   ├── zz_generated.conversion.go
│   ├── zz_generated.deepcopy.go
│   └── zz_generated.openapi.go
├── v2
│   ├── doc.go
│   ├── fullvpcmigration_types.go
│   ├── register.go
│   ├── zz_generated.conversion.go
│   ├── zz_generated.deepcopy.go
│   └── zz_generated.openapi.go
└── zz_generated.deepcopy.go</pre>
<p> API组的根目录（上面的示例项目只有一个组，因此直接将api目录作为组的根目录）下，应该存放__internal版本的资源结构定义，建议将其内容和最新版本保持一致。</p>
<div class="blog_h3"><span class="graybg">doc.go</span></div>
<p>这个文件应当提供包级别的注释，例如：</p>
<pre class="crayon-plain-tag">// +k8s:openapi-gen=true
// +groupName=gmem.cc
// +kubebuilder:object:generate=true

package api</pre>
<div class="blog_h3"><span class="graybg">register.go</span></div>
<p>这个文件用于Scheme的注册。对于__internal版本：</p>
<pre class="crayon-plain-tag">package api

import (
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
)

const (
	GroupName = "gmem.cc"
)

var (
	// GroupVersion is group version used to register these objects
	GroupVersion = schema.GroupVersion{Group: GroupName, Version: runtime.APIVersionInternal}

	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
	// no &amp;scheme.Builder{} here, otherwise vk __internal/WatchEvent will double registered to k8s.io/apimachinery/pkg/apis/meta/v1.WatchEvent &amp;
	// k8s.io/apimachinery/pkg/apis/meta/v1.InternalEvent, which is illegal
	SchemeBuilder = runtime.NewSchemeBuilder()

	// AddToScheme adds the types in this group-version to the given scheme.
	AddToScheme = SchemeBuilder.AddToScheme
)

// Kind takes an unqualified kind and returns a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
	return GroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
	return GroupVersion.WithResource(resource).GroupResource()
}</pre>
<p>对于普通的版本：</p>
<pre class="crayon-plain-tag">package v2

import (
	"cloud.tencent.com/teleport/api"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/runtime/schema"
)

var (
	// GroupVersion is group version used to register these objects
	GroupVersion = schema.GroupVersion{Group: api.GroupName, Version: "v2"}

	// SchemeBuilder is used to add go types to the GroupVersionKind scheme
	SchemeBuilder = runtime.NewSchemeBuilder(func(scheme *runtime.Scheme) error {
		metav1.AddToGroupVersion(scheme, GroupVersion)
		return nil
	})
	localSchemeBuilder = &amp;SchemeBuilder

	// AddToScheme adds the types in this group-version to the given scheme.
	AddToScheme = SchemeBuilder.AddToScheme
)

// Kind takes an unqualified kind and returns a Group qualified GroupKind
func Kind(kind string) schema.GroupKind {
	return GroupVersion.WithKind(kind).GroupKind()
}

// Resource takes an unqualified resource and returns a Group qualified GroupResource
func Resource(resource string) schema.GroupResource {
	return GroupVersion.WithResource(resource).GroupResource()
}</pre>
<p>可以看到，普通版本需要将metav1包中的某些结构注册到自己的GroupVersion。</p>
<div class="blog_h3"><span class="graybg">zz_generated.openapi.go</span></div>
<p>这是每个普通版本都需要生成的OpenAPI定义。这些OpenAPI定义必须注册到API Server，否则将会导致kubectl apply等命令报404错误：</p>
<pre class="crayon-plain-tag">$(OPENAPI_GEN)  \
	--input-dirs "k8s.io/apimachinery/pkg/apis/meta/v1,k8s.io/apimachinery/pkg/runtime,k8s.io/apimachinery/pkg/version" \
	--input-dirs cloud.tencent.com/teleport/api/v1 -o ./  -p api/v1 -O zz_generated.openapi

$(OPENAPI_GEN)  \
	--input-dirs cloud.tencent.com/teleport/api/v2 -o ./  -p api/v2 -O zz_generated.openapi</pre>
<div class="blog_h3"><span class="graybg">zz_generated.conversion.go</span></div>
<p>这是每个普通版本都需要生成的From/To __internal版本的类型转换函数。这些转换函数会通过上面的localSchemeBuilder注册到当前GroupVersion：</p>
<pre class="crayon-plain-tag">$(CONVERSION_GEN) -h hack/boilerplate.go.txt --input-dirs cloud.tencent.com/teleport/api/v1 -O zz_generated.conversion
$(CONVERSION_GEN) -h hack/boilerplate.go.txt --input-dirs cloud.tencent.com/teleport/api/v2 -O zz_generated.conversion</pre>
<p>升级API版本的原因，自然是因为结构出现了变动。结构的变动，就意味着新旧版本有特殊的<span style="background-color: #c0c0c0;">类型转换逻辑。这种逻辑显然不可能自动生成，你手工添加的转换代码应该存放在conversion.go中</span>。</p>
<div class="blog_h3"><span class="graybg">zz_generated.deepcopy.go</span></div>
<p>这个文件是__internal版本、普通版本中的资源对应Go结构都需要生成的深拷贝函数。</p>
<div class="blog_h2"><span class="graybg">关于__internal版本</span></div>
<p>如前文所述，每个API资源（的版本），都需要一个rest.Storage，这个Storage会直接负责该API资源版本的GET/CREATE/UPDATE/DELETE/WATCH等操作。</p>
<p>作为默认的，针对Etcd存储后端的rest.Storage的实现genericregistry.Store，它在内部有一个Cacher。此Cacher利用缓存来处理WATCH/LIST请求，避免对Etcd过频的访问。在此Cacher内部，会使用资源的内部版本。</p>
<p>所谓内部版本，就是注册到__internal这个特殊版本号的资源。<pre class="crayon-plain-tag">__internal</pre>这个字面值由常量<pre class="crayon-plain-tag">runtime.APIVersionInternal</pre>提供。我们通常将<span style="background-color: #c0c0c0;">组的根目录下的资源结构体，注册为__internal版本</span>。</p>
<p>有了这种内部版本机制，Cacher就不需要在内存中，存储资源的不同版本。</p>
<p>除此之外，rest.Storage或者它的Strategy所需要的一系列资源生命周期回调函数，接受的参数，都是__internal版本。这意味着：</p>
<ol>
<li>我们不需要为每个版本，编写重复的回调函数</li>
<li>在多版本化的时候，需要将<span style="background-color: #c0c0c0;">这些回调函数的入参都改为__internal版本</span></li>
</ol>
<div class="blog_h2"><span class="graybg">生成和定制转换函数</span></div>
<p>之所以Cacher、生命周期回调函数，以及下文会提到的，kubectl和存储能够自由的选择自己需要的版本，是因为不同版本的API资源之间可以进行转换。</p>
<p>当你复制一份v1资源的代码为v2时，这时可以使用完全自动生成的转换函数。一旦你添加或修改了一个字段，你就需要定制转换函数了。</p>
<p>假设我们将FullVPCMigrationSpec.TeamName字段改为Team，则需要：</p>
<pre class="crayon-plain-tag">// zz_generated.conversion.go中报错的地方，就是你需要实现的函数

func Convert_v1_FullVPCMigrationSpec_To_api_FullVPCMigrationSpec(in *FullVPCMigrationSpec, out *api.FullVPCMigrationSpec, s conversion.Scope) error {
    // 这里编写因为字段变化还需要手工处理的部分
	out.Team = in.TeamName
    // 然后调用自动生成的函数，这个函数和你实现的函数，名字的差异就是auto前缀
	return autoConvert_v1_FullVPCMigrationSpec_To_api_FullVPCMigrationSpec(in, out, s)
}

func Convert_api_FullVPCMigrationSpec_To_v1_FullVPCMigrationSpec(in *api.FullVPCMigrationSpec, out *FullVPCMigrationSpec, s conversion.Scope) error {
	out.TeamName = in.Team
	return autoConvert_api_FullVPCMigrationSpec_To_v1_FullVPCMigrationSpec(in, out, s)
}</pre>
<p>上面两个，是自动生成的转换代码中，缺失的函数，会导致编译错误。你需要自己实现它们。</p>
<p>带有auto前缀的版本，是自动生成的、完成了绝大部分逻辑的转换函数，你需要进行必要的手工处理，然后调用这个auto函数即可。</p>
<p>需要注意，<span style="background-color: #c0c0c0;">转换函数都是在特定版本和__internal版本之间进行的</span>。也就是如果v1需要转换到v2，则需要先转换为__internal，然后在由__internal转换为v2。这种设计也很好理解，不这样做随着版本的增多，转换函数的数量会爆炸式增长。</p>
<p><span style="background-color: #c0c0c0;">类型转换代码必须注册到Scheme</span>，不管是在API Server、kubectl或controller-runtime这样的客户端，都依赖于Scheme。</p>
<div class="blog_h2"><span class="graybg">多版本如何存储</span></div>
<p>不管是存储（串行化），还是读取（反串行化），都依赖于Codec。所谓Codec就是Serializer：</p>
<pre class="crayon-plain-tag">package runtime

type Serializer interface {
	Encoder
	Decoder
}

// Codec is a Serializer that deals with the details of versioning objects. It offers the same
// interface as Serializer, so this is a marker to consumers that care about the version of the objects
// they receive.
type Codec Serializer</pre>
<p>Codec由CodecFactory提供，后者持有Scheme：</p>
<pre class="crayon-plain-tag">serializer.NewCodecFactory(scheme)</pre>
<p>我们已经知道，Scheme包含这些信息：</p>
<ol>
<li>各种Group、Version、Kind，映射到了什么Go结构</li>
<li>Go结构上具有json标签，这些信息决定了结构的串行化格式是怎样的</li>
<li>同一个Group、Kind的不同Version，如何进行相互转换 </li>
</ol>
<p>因此，Codec有能力进行JSON或其它格式的串行化操作，并且在不同版本的Go结构之间进行转换。</p>
<p>对于 genericregistry.Store 来说，存储就是将API资源的Go结构转换为JSON或者ProtoBuf，保存到Etcd，它显然需要Codec的支持。</p>
<p>当启用多版本支持后，你需要将所有版本（prioritizedVersions）作为参数传递给CodecFactory并创建Codec：</p>
<pre class="crayon-plain-tag">prioritizedVersions ：= []schema.GroupVersion{
    {
        Group: "gmem.cc",
        Version: "v2",
    },
    {
        Group: "gmem.cc",
        Version: "v1",
    },
}
codec := codecFactory.LegacyCodec(prioritizedVersions...)

genericOptions.Etcd.StorageConfig.EncodeVersioner = runtime.NewMultiGroupVersioner(schema.GroupVersion{
    Group: "gmem.cc",
    Version: "v2",
} )</pre>
<p>并且，<span style="background-color: #c0c0c0;">prioritizedVersions决定了存储一个资源的时候，优先选择的格式</span>。例如fullvpcmigrations有v1,v2版本，因此在存储的时候会使用v2。而jointeamrequests只有v1版本，因此存储的时候只能使用v1。</p>
<p>注意：如果存在一个既有的v1版本的fullvpcmigration，<span style="background-color: #c0c0c0;">在上述配置应用后，第一次对它进行修改，会导致存储格式修改为v2</span>。</p>
<div class="blog_h2"><span class="graybg">多版本的OpenAPI</span></div>
<p>你需要为每个版本生成OpenAPI定义。OpenAPI的定义只是一个map，将所有版本的内容合并即可。</p>
<div class="blog_h2"><span class="graybg">APIServer暴露哪个版本</span></div>
<p>APIServer会暴露所有注册的资源版本。但是，它有一个版本优先级的概念：</p>
<pre class="crayon-plain-tag">apiGroupInfo.PrioritizedVersions = prioritizedVersions</pre>
<p>这个决定了kubectl的时候，优先显示为哪个版本。优选版本也会显示在api-resources子命令的输出：</p>
<pre class="crayon-plain-tag"># kubectl -s http://127.0.0.1:6080 api-resources  
NAME                    SHORTNAMES   APIVERSION                 NAMESPACED   KIND
fullvpcmigrations                    gmem.cc/v2   true         FullVPCMigration
jointeamrequests                     gmem.cc/v1   true         JoinTeamRequest</pre>
<p>kuebctl get命令，默认展示优选版本，但是你也可以强制要求显示为指定版本：</p>
<pre class="crayon-plain-tag">kubectl -s http://127.0.0.1:6080 -n default get fullvpcmigration.v1.gmem.cc
# GET http://127.0.0.1:6080/apis/gmem.cc/v1/namespaces/default/fullvpcmigrations?limit=500

kubectl -s http://127.0.0.1:6080 -n default get fullvpcmigration.v2.gmem.cc
# GET http://127.0.0.1:6080/apis/gmem.cc/v2/namespaces/default/fullvpcmigrations?limit=500</pre>
<p>不管怎样，<span style="background-color: #c0c0c0;">存储为任何版本的fullvpcmigrations都会被查询到</span>。你可以认为在<span style="background-color: #c0c0c0;">客户端视角，选择版本仅仅是选择资源的一种“视图”</span>。</p>
<div class="blog_h2"><span class="graybg">控制器中的版本选择</span></div>
<p>控制器所<span style="background-color: #c0c0c0;">监听的资源版本，必须已经在控制器管理器的Scheme中注册</span>。</p>
<p>你在Reconcile代码中，可以<span style="background-color: #c0c0c0;">用任何已经注册的版本来作为Get操作的容器</span>，类型转换会自动进行。  </p>
<p>建议仅在读取、存储资源状态的时候，用普通版本，其余时候，都用__internal版本。这样你的控制器逻辑，在版本升级后，需要的变更会很少。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/kubernetes-style-apiserver">编写Kubernetes风格的APIServer</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/kubernetes-style-apiserver/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>记录一次KeyDB缓慢的定位过程</title>
		<link>https://blog.gmem.cc/debugging-slow-keydb</link>
		<comments>https://blog.gmem.cc/debugging-slow-keydb#comments</comments>
		<pubDate>Thu, 28 Jan 2021 07:04:58 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[C++]]></category>
		<category><![CDATA[PaaS]]></category>
		<category><![CDATA[DNS]]></category>
		<category><![CDATA[Redis]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=35755</guid>
		<description><![CDATA[<p>环境说明 运行环境 这个问题出现在一套搭建在虚拟机上的Kubernetes 1.18集群上。集群有三个节点： [crayon-69de136b9eedd182423520/] KeyDB配置 KeyDB通过StatefulSet管理，一共有三个实例：  [crayon-69de136b9eee6213857090/] 这三个实例： 由于反亲和设置，会在每个节点上各运行一个实例 启用Active - Active（--active-replica）模式的多主（--multi-master）复制 ：每个实例都是另外两个的Slave，每个实例都支持读写 故障描述 触发条件 出现一个节点宕机的情况，就可能出现此故障。经过一段时间以后，会出现GET/PUT或者任何其它请求处理缓慢的情况。 故障特征 此故障有两个明显的特征： 故障出现前需要等待的时间，随机性很强，有时甚至测试了数小时都没有发现请求缓慢的情况。常常发生的情况是，宕机后剩下的两个实例，一个很快出现缓慢问题，另外一个却还能运行较长时间 请求处理延缓的时长不定，有时候没有明显延缓，有时候长达10+秒。而且一次缓慢请求后，可以跟着10多次正常速度处理的请求。这个特征提示故障和某种周期性的、长时间占用的锁有关。在锁被释放的间隙，请求可以被快速处理 故障分析 <a class="read-more" href="https://blog.gmem.cc/debugging-slow-keydb">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/debugging-slow-keydb">记录一次KeyDB缓慢的定位过程</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">环境说明</span></div>
<div class="blog_h2"><span class="graybg">运行环境</span></div>
<p>这个问题出现在一套搭建在虚拟机上的Kubernetes 1.18集群上。集群有三个节点：</p>
<pre class="crayon-plain-tag"># kubectl get node -o wide
NAME              STATUS   VERSION   INTERNAL-IP      OS-IMAGE                KERNEL-VERSION              CONTAINER-RUNTIME
192.168.104.51    Ready    v1.18.3   192.168.104.51   CentOS Linux 7 (Core)   3.10.0-862.3.2.el7.x86_64   docker://19.3.9
192.168.104.72    Ready    v1.18.3   192.168.104.72   CentOS Linux 7 (Core)   3.10.0-862.3.2.el7.x86_64   docker://19.3.9
192.168.104.108   Ready    v1.18.3   192.168.104.108  CentOS Linux 7 (Core)   3.10.0-862.3.2.el7.x86_64   docker://19.3.9</pre>
<div class="blog_h2"><span class="graybg">KeyDB配置</span></div>
<p><a href="/keydb-study-note">KeyDB</a>通过StatefulSet管理，一共有三个实例： </p>
<pre class="crayon-plain-tag"># kubectl -n default get pod -o wide -l app.kubernetes.io/name=keydb
NAME             READY   STATUS    RESTARTS     IP             NODE            
keydb-0   1/1     Running   0            172.29.2.63     192.168.104.108 
keydb-1   1/1     Running   0            172.29.1.69     192.168.104.72  
keydb-2   1/1     Running   0            172.29.1.121    192.168.104.51</pre>
<p>这三个实例：</p>
<ol>
<li>由于反亲和设置，会在每个节点上各运行一个实例</li>
<li>启用Active - Active（--active-replica）模式的多主（--multi-master）复制 ：每个实例都是另外两个的Slave，每个实例都支持读写</li>
</ol>
<div class="blog_h1"><span class="graybg">故障描述</span></div>
<div class="blog_h2"><span class="graybg">触发条件</span></div>
<p>出现一个节点宕机的情况，就可能出现此故障。经过一段时间以后，会出现<span style="background-color: #c0c0c0;">GET/PUT或者任何其它请求处理缓慢的情况</span>。</p>
<div class="blog_h2"><span class="graybg">故障特征</span></div>
<p>此故障有两个明显的特征：</p>
<ol>
<li>故障出现前需要等待的时间，随机性很强，有时甚至测试了数小时都没有发现请求缓慢的情况。常常发生的情况是，宕机后剩下的两个实例，一个很快出现缓慢问题，另外一个却还能运行较长时间</li>
<li>请求处理延缓的时长不定，有时候没有明显延缓，有时候长达10+秒。而且一次缓慢请求后，可以跟着10多次正常速度处理的请求。这个特征提示故障和某种<span style="background-color: #c0c0c0;">周期性的、长时间占用的锁</span>有关。在锁被释放的间隙，请求可以被快速处理</li>
</ol>
<div class="blog_h1"><span class="graybg">故障分析</span></div>
<div class="blog_h2"><span class="graybg">触发故障</span></div>
<p>我们将节点192.168.104.108强制关闭，这样实例keydb-0无法访问，另外两个节点无法和它进行Replication。</p>
<p>分别登录另外两个节点，监控GET/SET操作的性能：</p>
<pre class="crayon-plain-tag">kubectl -n default exec -it keydb-1 -- bash -c  \
  'while true; do key=keydb-1-$(date +%s); keydb-cli set $key $key-val; keydb-cli get $key; done'

kubectl -n default exec -it keydb-2 -- bash -c \
 'while true; do key=keydb-2-$(date +%s); keydb-cli set $key $key-val; keydb-cli get $key; done'</pre>
<p>监控Replication相关信息：</p>
<pre class="crayon-plain-tag">watch -- kubectl -n default exec -i keydb-1 -- keydb-cli info replication

watch -- kubectl -n default exec -i keydb-2 -- keydb-cli info replication</pre>
<p>监控KeyDB日志： </p>
<pre class="crayon-plain-tag">kubectl -n default logs  keydb-1 -f

kubectl -n default logs  keydb-2 -f</pre>
<p>经过一段时间，keydb-1请求处理随机延缓的情况出现：</p>
<pre class="crayon-plain-tag">127.0.0.1:6379&gt; set hello world
OK
(1.24s)
127.0.0.1:6379&gt; set hello world
OK
(8.96s)
127.0.0.1:6379&gt; get hello
"world"
(5.99s)
127.0.0.1:6379&gt; get hello
"world"
(9.44s) </pre>
<p>此时keydb-2仍然正常运行，请求处理速度正常</p>
<div class="blog_h2"><span class="graybg">缓慢查询</span></div>
<p>获取keydb-1的慢查询，没有发现有价值的信息。而且延缓的时间没有计算在内：</p>
<pre class="crayon-plain-tag">127.0.0.1:6379&gt; slowlog get 10                                         
1) 1) (integer) 7                                                      
   2) (integer) 1611833042                                             
   3) (integer) 14431               # 最慢的查询才耗时14ms                                            
   4) 1) "set"                                                         
      2) "keydb-1-1611833042"                                          
      3) "keydb-1-1611833042-val"                                      
   5) "127.0.0.1:38488"                                                
   6) ""                                                               
2) 1) (integer) 6                                                      
   2) (integer) 1611831322                                             
   3) (integer) 14486                                                  
   4) 1) "get"                                                         
      2) "keydb-1-1611831312"                                          
   5) "127.0.0.1:51680"                                                
   6) ""  </pre>
<div class="blog_h2"><span class="graybg">日志分析</span></div>
<p>部署KeyDB已经设置<pre class="crayon-plain-tag">--loglevel debug</pre>，以获得尽可能详尽的日志。</p>
<p>由于正在运行不间断执行SET/GET操作的脚本，因此日志量很大而刷屏，但是每隔一段时间就会出现卡顿。下面是keydb-1的日志片段：</p>
<pre class="crayon-plain-tag">7:11:S 28 Jan 2021 08:57:51.233 - Client closed connection
7:11:S 28 Jan 2021 08:57:51.251 - Accepted 127.0.0.1:44224
7:11:S 28 Jan 2021 08:57:51.252 - Client closed connection
7:12:S 28 Jan 2021 08:57:51.276 - Accepted 127.0.0.1:44226
7:11:S 28 Jan 2021 08:57:51.277 - Client closed connection
# 这一行日志之后，卡顿了10s。没有任何日志输出
7:11:S 28 Jan 2021 08:57:51.279 * Connecting to MASTER keydb-0.keydb:6379
7:11:S 28 Jan 2021 08:58:01.290 * Unable to connect to MASTER: Resource temporarily unavailable
7:11:S 28 Jan 2021 08:58:01.290 - Accepted 127.0.0.1:44228
7:11:S 28 Jan 2021 08:58:01.290 - Accepted 127.0.0.1:44264</pre>
<p>从日志信息上可以看到，卡顿前keydb-1正在尝试连接到已经宕机的keydb-0，这个连接尝试被阻塞10秒后报<pre class="crayon-plain-tag">EAGAIN</pre>错误。</p>
<p>阻塞期间SET/GET请求得不到处理，猜测原因包括：</p>
<ol>
<li>连接keydb-0的时候，占用了某种全局的锁，SET/GET请求也需要持有该锁</li>
<li>连接keydb-0、处理SET/GET请求，由同一线程负责</li>
</ol>
<p>第2种猜测应该不大可能，因为KeyDB宣称的优势之一就是，支持多线程处理请求。并且我们设置了参数<pre class="crayon-plain-tag">--server-threads 2</pre>，也就是有两个线程用于处理请求。</p>
<p>EAGAIN这个报错也没有参考价值，因为目前不卡顿的实例keydb-2输出的日志是一样的，只是没有任何卡顿：</p>
<pre class="crayon-plain-tag">7:11:S 28 Jan 2021 08:19:22.624 * Connecting to MASTER keydb-0.keydb:6379
# 仅仅耗时5ms即检测到连接失败
7:11:S 28 Jan 2021 08:19:22.629 * Unable to connect to MASTER: Resource temporarily unavailable</pre>
<div class="blog_h2"><span class="graybg">源码分析</span></div>
<div class="blog_h3"><span class="graybg">复制定时任务</span></div>
<p>我们使用的KeyDB版本是5.3.3，尝试用关键字“Connecting to MASTER”搜索，发现只有一个匹配，位于<pre class="crayon-plain-tag">replicationCron</pre>函数中。从函数名称上就可以看到，它是和复制（Replication）有关的定时任务。</p>
<p>KeyDB启动时会调用<pre class="crayon-plain-tag">initServer</pre>进行初始化，后者会在事件循环中每1ms调度一次<pre class="crayon-plain-tag">serverCron</pre>。serverCron负责后台任务的总体调度，它的一个职责就是，每1s调度一次replicationCron函数。</p>
<p>下面看一下replicationCron的源码：</p>
<pre class="crayon-plain-tag">/* Replication cron function, called 1 time per second. */
void replicationCron(void) {
    static long long replication_cron_loops = 0;
    serverAssert(GlobalLocksAcquired());
    listIter liMaster;
    listNode *lnMaster;
    listRewind(g_pserver-&gt;masters, &amp;liMaster);
    // 遍历当前实例的每一个Master
    while ((lnMaster = listNext(&amp;liMaster)))
    {
        redisMaster *mi = (redisMaster*)listNodeValue(lnMaster);
        std::unique_lock&lt;decltype(mi-&gt;master-&gt;lock)&gt; ulock;
        // 获得              Master的 客户端的 锁
        if (mi-&gt;master != nullptr)
            ulock = decltype(ulock)(mi-&gt;master-&gt;lock);

        /* Non blocking connection timeout? */
        // 如果当前复制状态为：正在连接到Master
        // 或者复制状态处于握手阶段（包含多个状态）且超时了
        if (mi-&gt;masterhost &amp;&amp;
            (mi-&gt;repl_state == REPL_STATE_CONNECTING ||
            slaveIsInHandshakeState(mi)) &amp;&amp;
            (time(NULL)-mi-&gt;repl_transfer_lastio) &gt; g_pserver-&gt;repl_timeout)
        {
            // 那么取消握手 —— 取消进行中的非阻塞连接尝试，或者取消进行中的RDB传输
            serverLog(LL_WARNING,"Timeout connecting to the MASTER...");
            cancelReplicationHandshake(mi);
        }

        /* Bulk transfer I/O timeout? */
        // 如果当前正在接收来自Master的RDB文件且超时了
        if (mi-&gt;masterhost &amp;&amp; mi-&gt;repl_state == REPL_STATE_TRANSFER &amp;&amp;
            (time(NULL)-mi-&gt;repl_transfer_lastio) &gt; g_pserver-&gt;repl_timeout)
        {
            serverLog(LL_WARNING,"Timeout receiving bulk data from MASTER... If the problem persists try to set the 'repl-timeout' parameter in keydb.conf to a larger value.");
            // 那么取消握手
            cancelReplicationHandshake(mi);
        }

        /* Timed out master when we are an already connected replica? */
        // 如果当前复制状态为：已连接。而且超时之前没有活动（正常情况下有心跳维持）
        if (mi-&gt;masterhost &amp;&amp; mi-&gt;master &amp;&amp; mi-&gt;repl_state == REPL_STATE_CONNECTED &amp;&amp;
            (time(NULL)-mi-&gt;master-&gt;lastinteraction) &gt; g_pserver-&gt;repl_timeout)
        {
            // 那么释放掉客户端资源
            serverLog(LL_WARNING,"MASTER timeout: no data nor PING received...");
            if (FCorrectThread(mi-&gt;master))
                freeClient(mi-&gt;master);
            else
                freeClientAsync(mi-&gt;master);
        }

        /* Check if we should connect to a MASTER */
        // 上面几个分支都不会匹配我们的场景，因为keydb-0已经宕机，因此
        // 状态必然是REPL_STATE_CONNECT
        if (mi-&gt;repl_state == REPL_STATE_CONNECT) {
            // 这一行就是卡顿前的日志
            serverLog(LL_NOTICE,"Connecting to MASTER %s:%d",
                mi-&gt;masterhost, mi-&gt;masterport);
            // 发起连接
            if (connectWithMaster(mi) == C_OK) {
                serverLog(LL_NOTICE,"MASTER &lt;-&gt; REPLICA sync started");
            }
        }

        // 每秒钟发送心跳给Master
        if (mi-&gt;masterhost &amp;&amp; mi-&gt;master &amp;&amp;
            !(mi-&gt;master-&gt;flags &amp; CLIENT_PRE_PSYNC))
            replicationSendAck(mi);
    }

    // 后面处理和本实例的Slave有关的逻辑，例如发送心跳。和我们的场景无关，略...
}</pre>
<p>很明显，卡顿是因为调用<pre class="crayon-plain-tag">connectWithMaster</pre>导致的。从代码注释也可以看到，KeyDB期望这个连接操作是非阻塞的，但是不知道为何，在我们的场景中严重的阻塞了。</p>
<p>进一步查看connectWithMaster的代码：</p>
<pre class="crayon-plain-tag">int connectWithMaster(redisMaster *mi) {
    int fd;

    fd = anetTcpNonBlockBestEffortBindConnect(NULL,
        mi-&gt;masterhost,mi-&gt;masterport,NET_FIRST_BIND_ADDR);
    if (fd == -1) {
        int sev = g_pserver-&gt;enable_multimaster ? LL_NOTICE : LL_WARNING;
        // 这一行是卡顿10s后的日志，因此阻塞发生在anetTcpNonBlockBestEffortBindConnect函数中
        serverLog(sev,"Unable to connect to MASTER: %s", strerror(errno));
        return C_ERR;
    }
    // ...
}

int anetTcpNonBlockBestEffortBindConnect(char *err, char *addr, int port,
                                         char *source_addr)
{
    return anetTcpGenericConnect(err,addr,port,source_addr,
            // 非阻塞 + BestEffort绑定
            ANET_CONNECT_NONBLOCK|ANET_CONNECT_BE_BINDING);
}


static int anetTcpGenericConnect(char *err, char *addr, int port,
                                 char *source_addr, int flags)
{
    int s = ANET_ERR, rv;
    char portstr[6];  /* strlen("65535") + 1; */
    struct addrinfo hints, *servinfo, *bservinfo, *p, *b;

    snprintf(portstr,sizeof(portstr),"%d",port);
    memset(&amp;hints,0,sizeof(hints));
    // 不指定地址族，这会触发getaddrinfo同时进行A/AAAA查询
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    // 根据Master的主机名查找得到IP地址信息（addrinfo）列表
    if ((rv = getaddrinfo(addr,portstr,&amp;hints,&amp;servinfo)) != 0) {
        anetSetError(err, "%s", gai_strerror(rv));
        return ANET_ERR;
    }
    // 遍历Master的IP地址列表
    for (p = servinfo; p != NULL; p = p-&gt;ai_next) {
        // 创建套接字，如果socket/connect调用失败，则尝试下一个
        if ((s = socket(p-&gt;ai_family,p-&gt;ai_socktype,p-&gt;ai_protocol)) == -1)
            continue;
        // 设置套接字选项SO_REUSEADDR
        if (anetSetReuseAddr(err,s) == ANET_ERR) 
            goto error;
        // 设置套接字选项 SO_REUSEPORT
        if (flags &amp; ANET_CONNECT_REUSEPORT &amp;&amp; anetSetReusePort(err, s) != ANET_OK)
            goto error;
        // 调用fcntl设置 O_NONBLOCK
        if (flags &amp; ANET_CONNECT_NONBLOCK &amp;&amp; anetNonBlock(err,s) != ANET_OK)
            goto error;
        if (source_addr) {
            int bound = 0;
            /* Using getaddrinfo saves us from self-determining IPv4 vs IPv6 */
            // 解析源地址
            if ((rv = getaddrinfo(source_addr, NULL, &amp;hints, &amp;bservinfo)) != 0)
            {
                anetSetError(err, "%s", gai_strerror(rv));
                goto error;
            }
            for (b = bservinfo; b != NULL; b = b-&gt;ai_next) {
                // 绑定到第一个源地址
                if (bind(s,b-&gt;ai_addr,b-&gt;ai_addrlen) != -1) {
                    bound = 1;
                    break;
                }
            }
            freeaddrinfo(bservinfo);
            if (!bound) {
                // 绑定源地址失败，跳转到Best Effort绑定
                anetSetError(err, "bind: %s", strerror(errno));
                goto error;
            }
        }
        // 发起连接
        if (connect(s,p-&gt;ai_addr,p-&gt;ai_addrlen) == -1) {
            // 我们的场景下套接字是非阻塞的，因此这里会立即返回EINPROGRESS，属于预期行为
            if (errno == EINPROGRESS &amp;&amp; flags &amp; ANET_CONNECT_NONBLOCK)
                goto end;
            // 其它错误均认为失败，尝试连接下一个Master地址
            close(s);
            s = ANET_ERR;
            continue;
        }

        goto end;
    }
    if (p == NULL)
        anetSetError(err, "creating socket: %s", strerror(errno));

error:
    if (s != ANET_ERR) {
        close(s);
        s = ANET_ERR;
    }

end:
    freeaddrinfo(servinfo);

    // 上面指定源地址，绑定失败时跳转到此处。尝试不指定源地址来连接
    if (s == ANET_ERR &amp;&amp; source_addr &amp;&amp; (flags &amp; ANET_CONNECT_BE_BINDING)) {
        return anetTcpGenericConnect(err,addr,port,NULL,flags);
    } else {
        return s;
    }
}</pre>
<p>尽管可以确定connectWithMaster调用的anetTcpGenericConnect就是发生阻塞的地方，但是从代码上看不出什么问题，就是简单的socket、bind，外加一个非阻塞的connect操作。</p>
<div class="blog_h3"><span class="graybg">请求处理逻辑</span></div>
<p>从现象上我们已经看到了，复制定时器卡顿的时候，请求处理也无法进行。通过代码分析，也明确了卡顿期间，复制定时器持有Master的客户端的锁。</p>
<p>那么，关于请求处理（线程？）会和复制定时器产生锁争用的猜测是否正确呢？</p>
<div class="blog_h2"><span class="graybg">单步跟踪</span></div>
<p>为了精确定位阻塞的代码，我们使用GDB进行单步跟踪：</p>
<pre class="crayon-plain-tag">#              需要特权模式，否则无法加载符号表
docker run -it --rm --name gdb --privileged --net=host --pid=host --entrypoint gdb docker.gmem.cc/debug

(gdb) attach 449
(gdb) break replication.cpp:3084
# 连续执行s，以step into anet.c
(gdb) s
# 连续执行n命令
(gdb) n
# 卡顿后，查看变量
# 解析的地址
(gdb) p addr
$2 = 0x7f2f31411281 "keydb-0.keydb"
# getaddrinfo的返回值
(gdb) p rv
$3 = -3</pre>
<p>进入anetTcpGenericConnect后，逐行执行，多次测试，均在<pre class="crayon-plain-tag">anet.c</pre>的291行出现卡顿：</p>
<pre class="crayon-plain-tag">if ((rv = getaddrinfo(addr,portstr,&amp;hints,&amp;servinfo)) != 0) {
    anetSetError(err, "%s", gai_strerror(rv));
    return ANET_ERR;
}</pre>
<p>也就是说，调用getaddrinfo函数耗时可能长达数秒。这是来自glibc的标准函数，用于将主机名解析为IP地址。</p>
<p>调试过程中发现此函数的返回值是-3，我们的场景中，需要解析的地址是keydb-0.keydb，卡顿时函数的返回值是-3，<pre class="crayon-plain-tag">man getaddrinfo</pre>可以了解到此返回值的意义：</p>
<p style="padding-left: 30px;">EAI_AGAIN  The name server returned a temporary failure indication. Try again later.</p>
<p>乍看起来，好像是<a href="/tcp-ip-study-note#dns">DNS</a>服务器，也就是K8S的<a href="/coredns-study-note">CoreDNS</a>存在问题。但无法解释此时keydb-2.keydb没有受到影响？</p>
<div class="blog_h2"><span class="graybg">检查CoreDNS</span></div>
<p>为了确认CoreDNS是否存在问题，我们分别在宿主机上、两个实例的网络命名空间中进行验证：</p>
<pre class="crayon-plain-tag"># nslookup keydb-0.keydb.default.svc.cluster.local 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb.default.svc.cluster.local: NXDOMAIN

# nslookup keydb-0.keydb.svc.cluster.local 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb.svc.cluster.local: NXDOMAIN

# nslookup keydb-0.keydb.cluster.local 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb.cluster.local: NXDOMAIN

# nslookup keydb-0.keydb 10.96.0.10
Server:		10.96.0.10
Address:	10.96.0.10#53

** server can't find keydb-0.keydb: SERVFAIL</pre>
<p>反复测试循环测试，没有任何解析缓慢的现象。此外，查看CoreDNS的日志，我们也发现了来自keydb-1.keydb和keydb-2.keydb的查询请求，请求都是通过UDP协议发送的，处理耗时都是亚毫秒级别。</p>
<p>也就是说，从KeyDB实例所在宿主机/命名空间到CoreDNS的网络链路、CoreDNS服务器自身，都没有问题。</p>
<p>这就让人头疼了……难道问题出在getaddrinfo函数内部？或者在单步跟踪时判断错误，问题和DNS无关？为了确认，我们在CoreDNS上动了点手脚，强制将keydb-0.keydb解析到一个不存在的IP地址：</p>
<pre class="crayon-plain-tag">.:53 {
    # ...
    hosts {
        192.168.144.51  keydb-1.keydb
    }
    # ...
}</pre>
<p>结果很快，卡顿的问题就消失了。所以，我们更加怀疑问题出在getaddrinfo函数上了。</p>
<div class="blog_h2"><span class="graybg">调试getaddrinfo</span></div>
<p>查看文件/etc/lsb-release，可以看到KeyDB镜像是基于Ubuntu 18.04.4 LTS构建的，使用的libc6版本是2.27-3ubuntu1。</p>
<p>在launchpad.net找到了它的<a href="http://launchpadlibrarian.net/365856914/libc6-dbg_2.27-3ubuntu1_amd64.deb">调试文件</a>和<a href="http://launchpadlibrarian.net/365856911/glibc-source_2.27-3ubuntu1_all.deb">源码</a>。下载deb包，解压后复制到GDB容器，然后设置一下调试文件目录，就可以step into到glibc的代码进行跟踪了：</p>
<pre class="crayon-plain-tag">ar x libc6-dbg_2.27-3ubuntu1_amd64.deb
tar -xf data.tar.xz 

# 拷贝到我们正在运行GDB的容器
docker cp usr gdb:/root

# 修改调试文件搜索目录
(gdb) set debug-file-directory /root/usr/lib/debug
# 打断点，下面是缓慢的执行路径
(gdb) b anet.c:291
(gdb) b getaddrinfo.c:342
(gdb) b getaddrinfo.c:786  
# (gdb) print fct4
# $2 = (nss_gethostbyname4_r) 0x7f32f97e9a70 &lt;_nss_dns_gethostbyname4_r&gt;
(gdb) b dns-host.c:317
(gdb) b res_query.c:336
(gdb) b res_query.c:495                       # invoke __res_context_querydomain
(gdb) b res_query.c:601                       # invoke __res_context_query    
(gdb) b res_query.c:216                       # invoke __res_context_send
(gdb) b res_send.c:1066 if buflen==45         # send_dg</pre>
<p>通过调试，我们发现getaddrinfo会依次对4个名字进行DNS查询：</p>
<p style="padding-left: 30px;">keydb-0.keydb.default.svc.cluster.local. <br />keydb-0.keydb.svc.cluster.local. <br />keydb-0.keydb.cluster.local.<br />keydb-0.keydb.</p>
<p>CoreDNS的日志显示，所有请求都快速的处理完毕：</p>
<pre class="crayon-plain-tag">4242 "A IN keydb-0.keydb.default.svc.cluster.local. udp 68 false 512" NXDOMAIN qr,aa,rd 161 0.000215337s
38046 "AAAA IN keydb-0.keydb.default.svc.cluster.local. udp 68 false 512" NXDOMAIN qr,aa,rd 161 0.000203934s

23194 "A IN keydb-0.keydb.svc.cluster.local. udp 63 false 512" NXDOMAIN qr,aa,rd 156 0.000301011s
23722 "AAAA IN keydb-0.keydb.svc.cluster.local. udp 63 false 512" NXDOMAIN qr,aa,rd 156 0.000125386s

36552 "A IN keydb-0.keydb.cluster.local. udp 59 false 512" NXDOMAIN qr,aa,rd 152 0.000281247s
217 "AAAA IN keydb-0.keydb.cluster.local. udp 59 false 512" NXDOMAIN qr,aa,rd 152 0.000150689s

6776 "A IN keydb-0.keydb. udp 45 false 512" NOERROR - 0 0.000196686s
6776 "A IN keydb-0.keydb. udp 45 false 512" NOERROR - 0 0.000157011s </pre>
<p>最后一个名字，也就是传递给getaddrinfo的原始请求keydb-0.keydb.的处理过程有以下值得注意的点：</p>
<ol>
<li>从GDB角度来看，<span style="background-color: #c0c0c0;">卡顿就是在解析该名字时出现</span></li>
<li>从CoreDNS日志上看，没有AAAA请求。由于KeyDB<span style="background-color: #c0c0c0;">指定了AF_UNSPEC，getaddrinfo会同时发送并等待A/AAAA应答</span>。可能<span style="background-color: #c0c0c0;">因为某种原因，该名字的AAAA解析过程没有完成，导致getaddrinfo一直等待到超时</span>。作为对比，没有卡顿的keydb-2的A/AAAA查询处理过程都是正常的</li>
<li>其它名字是一次A请求，一次AAAA请求。该名字却是两次A请求，而且，<span style="background-color: #c0c0c0;">第一次A请求日志出现了数秒后，第二次日志才出现</span>。有可能第二次是getaddrinfo没有收到应答而进行的重试</li>
<li>前三个名字分别的错误码是NXDOMAIN，该名字的错误码却是<a href="/tcp-ip-study-note#dns-rtnmsg">NOERROR</a>。通过nslookup/dig查询，错误码却是SERVFAIL，难道是CoreDNS日志有BUG？尽管如此，是否不同的错误码影响了getaddrinfo的行为</li>
</ol>
<div class="blog_h2"><span class="graybg">抓包分析</span></div>
<p>glibc的代码是优化过（<a href="https://stackoverflow.com/questions/30089652/glibc-optimizations-required">也必须优化</a>）的，GDB跟踪起来相当耗时，因此我们打算换一个角度来定位问题。基于上一节的分析，我们相信实例keydb-1.keydb在发送DNS请求的时候存在超时或丢包的情况，可以抓包来证实：</p>
<pre class="crayon-plain-tag"># 进入keydb-1.keydb的网络命名空间
nsenter -t 449 --net
# 抓包
tcpdump -i any -vv -nn udp port 53</pre>
<p>抓包的结果如下： </p>
<p style="padding-left: 30px;"><span style="background-color: #c0c0c0;">对 keydb-0.keydb.default.svc.cluster.local.  的A请求</span><br /><em> 172.29.1.69.42083 &gt; 10.96.0.10.53: [bad udp cksum 0xb829 -&gt; 0x95aa!] 22719+ A? keydb-0.keydb.default.svc.cluster.local. (68)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.42083: [bad udp cksum 0xb886 -&gt; 0x3f57!] 22719 NXDomain*- q: A? keydb-0.keydb.default.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (161)</em><br /><span style="background-color: #c0c0c0;">对keydb-0.keydb.default.svc.cluster.local.  的AAAA请求，注意，仍然使用之前的UDP套接字</span><br /> 172.29.1.69.42083 &gt; 10.96.0.10.53: [bad udp cksum 0xb829 -&gt; 0x4d76!] 41176+ AAAA? keydb-0.keydb.default.svc.cluster.local. (68)<br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.42083: [bad udp cksum 0xb886 -&gt; 0xf722!] 41176 NXDomain*- q: AAAA? keydb-0.keydb.default.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (161)</em><br /><span style="background-color: #c0c0c0;">对 keydb-0.keydb.svc.cluster.local. 的A请求，注意，这里使用了新的UDP套接字</span><br /> <em>172.29.1.69.45508 &gt; 10.96.0.10.53: [bad udp cksum 0xb824 -&gt; 0x3b5e!] 45156+ A? keydb-0.keydb.svc.cluster.local. (63)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.45508: [bad udp cksum 0xb881 -&gt; 0x21ce!] 45156 NXDomain*- q: A? keydb-0.keydb.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (156)</em><br /><span style="background-color: #c0c0c0;">对keydb-0.keydb.svc.cluster.local. 的AAAA请求</span><br /> 172.29.1.69.45508 &gt; 10.96.0.10.53: [bad udp cksum 0xb824 -&gt; 0x8a4e!] 18036+ AAAA? keydb-0.keydb.svc.cluster.local. (63)<br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.45508: [bad udp cksum 0xb881 -&gt; 0x70be!] 18036 NXDomain*- q: AAAA? keydb-0.keydb.svc.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (156)</em><br /><span style="background-color: #c0c0c0;">对 keydb-0.keydb.cluster.local. 的A请求</span><br /><em>172.29.1.69.48243 &gt; 10.96.0.10.53: [bad udp cksum 0xb820 -&gt; 0x5054!] 2718+ A? keydb-0.keydb.cluster.local. (59)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain</span><br /> <em>10.96.0.10.53 &gt; 172.29.1.69.48243: [bad udp cksum 0xb87d -&gt; 0x36c4!] 2718 NXDomain*- q: A? keydb-0.keydb.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (152)</em><br /><span style="background-color: #c0c0c0;">对 keydb-0.keydb.cluster.local. 的AAAA请求</span><br /> <em>172.29.1.69.48243 &gt; 10.96.0.10.53: [bad udp cksum 0xb820 -&gt; 0x5147!] 61098+ AAAA? keydb-0.keydb.cluster.local. (59)</em><br /><span style="background-color: #c0c0c0;">CoreDNS应答NXDomain，这里开始，我们保留时间戳那一行日志</span><br /><em>14:42:15.028168 IP (tos 0x0, ttl 63, id 44636, offset 0, flags [DF], proto UDP (17), length 180)</em><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.48243: [bad udp cksum 0xb87d -&gt; 0x37b7!] 61098 NXDomain*- q: AAAA? keydb-0.keydb.cluster.local. 0/1/0 ns: cluster.local. SOA ns.dns.cluster.local. hostmaster.cluster.local. 1612073759 7200 1800 86400 30 (152)</em><br /><span style="background-color: #c0c0c0;">对keydb-0.keydb的A请求，这里还没有出现卡顿</span><br /><em>14:42:15.028328 IP (tos 0x0, ttl 64, id 30583, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 172.29.1.69.47652 &gt; 10.96.0.10.53: [bad udp cksum 0xb812 -&gt; 0x181e!] 26682+ A? keydb-0.keydb. (45)</em><br /><span style="background-color: #c0c0c0;">很快接收到CoreDNS的ServFail应答，抓包和我们nslookup/dig的错误码一致，CoreDNS日志显示的应该不正常</span><br /><span style="background-color: #c0c0c0;">猜测“有可能第二次是getaddrinfo没有收到应答而进行的重试”被排除，至少说没收到应答不是网络层面的原因</span><br /><em>14:42:15.028651 IP (tos 0x0, ttl 63, id 44637, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.47652: [bad udp cksum 0xb812 -&gt; 0x981b!] 26682 ServFail- q: A? keydb-0.keydb. 0/0/0 (45)</em><br /><span style="background-color: #c0c0c0;">再一次对keydb-0.keydb.的A请求，<strong>注意时间戳，刚好5秒之后</strong>，这是默认DNS请求超时。<strong>还是使用之前的套接字</strong></span><br /><em>14:42:20.029271 IP (tos 0x0, ttl 64, id 33006, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 172.29.1.69.47652 &gt; 10.96.0.10.53: [bad udp cksum 0xb812 -&gt; 0x181e!] 26682+ A? keydb-0.keydb. (45)</em><br /><span style="background-color: #c0c0c0;">很快接收到CoreDNS的ServFail应答</span><br /><em>14:42:20.029812 IP (tos 0x0, ttl 63, id 46397, offset 0, flags [DF], proto UDP (17), length 73)</em><br /><em> 10.96.0.10.53 &gt; 172.29.1.69.47652: [bad udp cksum 0xb812 -&gt; 0x981b!] 26682 ServFail- q: A? keydb-0.keydb. 0/0/0 (45)</em></p>
<p>通过上述分析我们可以相信，keydb-1.keydb容器到CoreDNS之间的DNS通信是没有问题的。但是，getaddrinfo似乎没有收到keydb-0.keydb的第一次应答，并且在超时（5s）之后进行重试</p>
<div class="blog_h2"><span class="graybg">Conntrack竞态条件</span></div>
<p>tcpdump和应用程序之间，还有个netfilter框架。回想起之前阅读过的文章：<a href="/dns-problems-on-k8s">Kubernetes上和DNS相关的问题</a>，conntrack相关的竞态条件可能导致DNS查询5秒超时。遗憾的是，这里的故障和此竞态条件无关：</p>
<ol>
<li>通过<pre class="crayon-plain-tag">conntrack -S</pre>看到的<pre class="crayon-plain-tag">insert_failed</pre>是0</li>
<li>故障一旦出现，就每次都会超时5s，没有竞态条件的随机性</li>
<li>如果是conntrack竞态条件导致，无法解释为什么前面3个名字解析正常，也无法解释为什么CoreDNS中配置一个静态解析故障就消失</li>
</ol>
<div class="blog_h1"><span class="graybg">深入理解</span></div>
<div class="blog_h2"><span class="graybg">getaddrinfo</span></div>
<p>在IPv4中，我们使用<pre class="crayon-plain-tag">gethostbyname</pre>实现主机名到地址的解析。<pre class="crayon-plain-tag">getaddrinfo</pre>也用于地址解析，而且它是协议无关的，既可用于IPv4也可用于IPv6。它的原型如下：</p>
<pre class="crayon-plain-tag">int getaddrinfo(const char* hostname,  // 主机名，可以使用IP地址或者DNS名称
                const char* service,   // 服务名，可以使用端口号或者/etc/services中的服务名
                const struct addrinfo* hints, // 可以NULL，或者一个addrinfo，提示调用者想得到的信息类型
                struct addrinfo** res);  // 解析得到的addrinfo，地址的链表</pre>
<p>此函数返回的是套接字地址信息的链表，地址信息存储在下面的addrinfo结构中。参数<pre class="crayon-plain-tag">hints</pre>会影响getaddrinfo的行为，提示信息同样存放在addrinfo结构中：</p>
<pre class="crayon-plain-tag">struct addrinfo
{
  // 额外的提示标记
  int ai_flags;	
  // 提示需要查询哪些地址族，默认AF_UNSPEC，这意味着同时查询IPv4和IPv6地址
  // 也就是同时发起A/AAAA查询
  int ai_family;
  // 提示偏好的套接字类型，例如SOCK_STREAM|SOCK_DGRAM，默认可以返回任何套接字类型
  int ai_socktype;
  // 提示返回的套接字地址的协议类型
  int ai_protocol;

  // 套接字地址
  socklen_t ai_addrlen;
  struct sockaddr *ai_addr;
  // ...
  // 指向链表的下一条目
  struct addrinfo *ai_next;
};</pre>
<p>很多软件调用getaddrinfo的时候，都会指定AF_UNSPEC（或者不提供hints，效果一样），例如KeyDB。但是，很多运行环境根本没有IPv6支持，这就凭白的给DNS服务器增加了负担。这也是在K8S中查看CoreDNS日志，总是会发现很多AAAA记录的原因。</p>
<div class="blog_h3"><span class="graybg">解析流程概览</span></div>
<p>KeyDB 5.3.3使用的glibc版本是2.27。函数getaddrinfo过于冗长，这里就不贴出来了，大概梳理一下：</p>
<ol>
<li>如果可能，它会通过/var/run/nscd/socket访问DNS缓存服务，我们没有这个服务</li>
<li>初始化NSS的hosts数据库，如果没有在文件中配置，则默认使用<pre class="crayon-plain-tag">hosts: dns [!UNAVAIL=return] files</pre>，我们的环境下配置是<pre class="crayon-plain-tag">hosts: files dns</pre></li>
<li>通过<a href="/linux-faq#nss">NSS</a>进行名字查询，实际上是调用<pre class="crayon-plain-tag">gethostbyname4_r</pre>函数：
<ol>
<li>查找files源，调用<pre class="crayon-plain-tag">_nss_files_gethostbyname4_r</pre>函数，也就是打开/etc/hosts查找。K8S容器中，/etc/hosts中仅仅存在当前Pod的条目，因此files源不会匹配</li>
<li>查找dns源，调用<pre class="crayon-plain-tag">_nss_dns_gethostbyname4_r</pre>函数：
<ol>
<li>读取/etc/resolv.conf构建<pre class="crayon-plain-tag">resolv_context</pre>。我们的环境下，配置文件内容为：<br />
<pre class="crayon-plain-tag">nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5</pre>
</li>
<li>
<p>调用<pre class="crayon-plain-tag">__res_context_search</pre>， 执行DNS查找逻辑。它会<span style="background-color: #c0c0c0;">将上面的search domain作为域名后缀，产生多个名字，逐个尝试。每个名字查询失败时都会重试，重试时尽可能选择不同的DNS服务器。可能同时发起A/AAA查询</span></p>
</li>
</ol>
</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">DNS搜索逻辑</span></div>
<p>__res_context_search()首先会计算一下，待查找名字中的dot的数量。如果<span style="background-color: #c0c0c0;">名字以dot结尾，或者dot数量大于等于ndots</span>，则直接调用<pre class="crayon-plain-tag">__res_context_querydomain</pre>向DNS服务器发请求，该函数会同时发起A/AAAA查询。</p>
<p>否则，它会根据/etc/resolv.conf中的search domain列表，给待查找的名字加后缀，然后多次向DNS服务器发请求。我们的环境下，待查找名字为keydb-0.keydb，getaddrinfo函数会依次尝试：</p>
<p style="padding-left: 30px;">keydb-0.keydb.default.svc.cluster.local. <br />keydb-0.keydb.svc.cluster.local. <br />keydb-0.keydb.cluster.local.<br />keydb-0.keydb.</p>
<p>需要注意：</p>
<ol>
<li>向DNS服务器发请求，仍然是由__res_context_querydomain()负责</li>
<li>一旦查找成功，就立即返回不再尝试其它search domain</li>
<li><span style="background-color: #c0c0c0;">不加修饰的原始名字，会放在最后尝试</span></li>
</ol>
<p>在K8S中，*.cluster.local一般都由CoreDNS自身负责，处理速度会很快。至于keydb-0.keydb.的处理速度，如果为CoreDNS配置了上游DNS，则处理速度依赖于外部环境。</p>
<p>__res_context_querydomain仅仅是在domain参数不为空的时候，将name和domain连接起来，然后调用<pre class="crayon-plain-tag">__res_context_query</pre>函数。</p>
<div class="blog_h3"><span class="graybg">DNS查询过程</span></div>
<p>__res_context_query负责和DNS服务器的交互，完成<span style="background-color: #c0c0c0;">单个名字的DNS查</span>询。它会调用<pre class="crayon-plain-tag">__res_context_mkquery</pre><span style="background-color: #c0c0c0;">构建一个查询请求（对应DNS报文），然后发送</span>，然后等待应答。这是一个阻塞的过程，KeyDB在期望非阻塞的代码路径下调用getaddrinfo且没有任何缓存机制，同时还加了锁，我觉得是不妥的。这导致DNS缓慢/不可用会极大的影响KeyDB的服务质量。</p>
<p>发送DNS请求的代码在<pre class="crayon-plain-tag">__res_context_send</pre>中，<span style="background-color: #c0c0c0;">重试逻辑</span>发生在该函数中，我们的环境下重试次数为2，这解释了两次keydb-0.keydb. A查询：</p>
<pre class="crayon-plain-tag">//                  重试次数，statp-&gt;retry为2
for (try = 0; try &lt; statp-&gt;retry; try++) {
    // 如果有多个DNS服务器，重试时会轮询它们
    for (unsigned ns_shift = 0; ns_shift &lt; statp-&gt;nscount; ns_shift++)
    {
    unsigned int ns = ns_shift + ns_offset;
    if (ns &gt;= statp-&gt;nscount)
        ns -= statp-&gt;nscount;

    same_ns:
    if (__glibc_unlikely (v_circuit)) {
        // ...
    } else {
        // 使用UDP方式发送请求
        n = send_dg(statp, buf, buflen, buf2, buflen2,
                &amp;ans, &amp;anssiz, &amp;terrno,
                ns, &amp;v_circuit, &amp;gotsomewhere, ansp,
                ansp2, nansp2, resplen2, ansp2_malloced);
        if (n &lt; 0)
            return (-1);
        if (n == 0 &amp;&amp; (buf2 == NULL || *resplen2 == 0))
            // 如果有多个DNS服务器的时候，会尝试下一个
            goto next_ns;
        // ...
    }
    return (resplen);
next_ns: ;
   } /*foreach ns*/
} /*foreach retry*/</pre>
<p>通常情况下，都是通过UDP协议进行DNS查询的，因此会调用<pre class="crayon-plain-tag">send_dg</pre>函数。在我们的场景中，两次尝试均5秒超时（尽管抓包显示应答报文很快就收到），__res_context_send设置错误码ETIMEDOUT，返回-1：</p>
<pre class="crayon-plain-tag">__res_iclose(statp, false);
if (!v_circuit) {
    if (!gotsomewhere)
        __set_errno (ECONNREFUSED);	/* no nameservers found */
    else
        __set_errno (ETIMEDOUT);	/* no answer obtained */
} else
    __set_errno (terrno);
return (-1);</pre>
<p>而它的调用者__res_context_query则在返回值是-1的时候，设置错误码TRY_AGAIN，这就是我们从KeyDB日志上看到报错The name server returned a temporary failure indication的原因：</p>
<pre class="crayon-plain-tag">if (n &lt; 0) {
    RES_SET_H_ERRNO(statp, TRY_AGAIN);
    return (n);
}</pre>
<div class="blog_h3"><span class="graybg">缓慢之源</span></div>
<p>缓慢的根源是send_dg函数，它阻塞了5秒。该函数的原型如下：</p>
<pre class="crayon-plain-tag">// 如果没有错误，返回第一个应答的字节数
// 对于可恢复错误，返回0；对于不可恢复错误，返回负数
static int send_dg(
    // 各种选项、DNS服务器列表、指向DNS服务器的套接字（文件描述符）
    res_state statp,
    // 查询请求1的缓冲区 和 长度
	const u_char *buf, int buflen, 
    // 查询请求2的缓冲区 和 长度
    const u_char *buf2, int buflen2,
    // 收到的第1个应答   和 最大长度
	u_char **ansp,      int *anssizp,
    // 出现错误时，将errno设置到此字段
	int *terrno, 
    // 使用的DNS服务器的序号
    int ns, 
    // 如果由于UDP数据报的限制而导致截断，则v_circuit设置为1，提示调用者使用TCP方式重试
    int *v_circuit, 
    // 提示访问DNS服务器时，是拒绝服务还是超时。如果是超时则设置为1
    int *gotsomewhere,
    // 提示遇到超长应答的时候，是否重新分配缓冲区
    u_char **anscp,
    // 收到的第2个应答 和 最大长度
	u_char **ansp2, int *anssizp2, 
    // 第2个应答的实际长度 是否为第2个应答重新分配了缓冲区
    int *resplen2, int *ansp2_malloced);</pre>
<p>该函数会向指定序号的DNS服务器发送DNS查询。它同时支持IPv4/IPv6查询，你可以传递两个查询请求，分别放在buf和buf2参数中。<span style="background-color: #c0c0c0;">如果提供了两个查询请求，默认使用并行方式发送查询</span>。设置选项<pre class="crayon-plain-tag">RES_SINGLKUP</pre>可以强制串行发送；设置选项<pre class="crayon-plain-tag">RES_SNGLKUPREOP</pre>可以<span style="background-color: #c0c0c0;">强制串行发送，同时总是关闭并重新打开套接字</span>，这样可以和某些行为异常的DNS服务器一起工作。</p>
<p>由于请求可以并行发送，因此应答到达的顺序是不确定的。<span style="background-color: #c0c0c0;">先收到的</span>应答会存放在ansp中，入参最大长度anssizp。入参anscp用于提示，应答过长的时候的处理方式：</p>
<ol>
<li>如果anscp不为空：则自动分配新的缓冲区，并且ansp、anscp都被修改为指向该缓冲区</li>
<li>如果anscp为空：则过长的部分被截断，DNS包头的TC字段被设置为1</li>
</ol>
<p>glibc的2.27-3ubuntu1版本中send_dg的完整实现如下：</p>
<pre class="crayon-plain-tag">static int
send_dg(res_state statp,
	const u_char *buf, int buflen, const u_char *buf2, int buflen2,
	u_char **ansp, int *anssizp,
	int *terrno, int ns, int *v_circuit, int *gotsomewhere, u_char **anscp,
	u_char **ansp2, int *anssizp2, int *resplen2, int *ansp2_malloced)
{
	const HEADER *hp = (HEADER *) buf;
	const HEADER *hp2 = (HEADER *) buf2;
	struct timespec now, timeout, finish;
	struct pollfd pfd[1];
	int ptimeout;
	struct sockaddr_in6 from;
	int resplen = 0;
	int n;

	/*
	 * Compute time for the total operation.
	 */
	int seconds = (statp-&gt;retrans &lt;&lt; ns); // 0. 计算超时
	if (ns &gt; 0)
		seconds /= statp-&gt;nscount;
	if (seconds &lt;= 0)
		seconds = 1;
	bool single_request_reopen = (statp-&gt;options &amp; RES_SNGLKUPREOP) != 0; // 0. 确定是否并行请求
	bool single_request = (((statp-&gt;options &amp; RES_SNGLKUP) != 0)
			       | single_request_reopen);
	int save_gotsomewhere = *gotsomewhere;

	int retval;
 retry_reopen: // tx1. 如果套接字没有创建，则创建， SOCK_DGRAM | SOCK_NONBLOCK | SOCK_CLOEXEC，非阻塞
	retval = reopen (statp, terrno, ns); // tx1. 然后调用一下connect操作，不发数据
	if (retval &lt;= 0)
	  {
	    if (resplen2 != NULL)
	      *resplen2 = 0;
	    return retval;
	  }
 retry:
	evNowTime(&amp;now);
	evConsTime(&amp;timeout, seconds, 0);
	evAddTime(&amp;finish, &amp;now, &amp;timeout);
	int need_recompute = 0;
	int nwritten = 0;
	int recvresp1 = 0;  // 用于标记请求1的应答是否接收到
	/* Skip the second response if there is no second query.
	   To do that we mark the second response as received.  */
	int recvresp2 = buf2 == NULL; // 用于标记请求2的应答是否接收到，如果buf2为空则立即标记为1
	pfd[0].fd = EXT(statp).nssocks[ns];
	pfd[0].events = POLLOUT; // tx2. 准备监听可写事件
 wait:
	if (need_recompute) {
	recompute_resend:
		evNowTime(&amp;now);
		if (evCmpTime(finish, now) &lt;= 0) {
		poll_err_out:
			return close_and_return_error (statp, resplen2);
		}
		evSubTime(&amp;timeout, &amp;finish, &amp;now);
		need_recompute = 0;
	}
	/* Convert struct timespec in milliseconds.  */
	ptimeout = timeout.tv_sec * 1000 + timeout.tv_nsec / 1000000;

	n = 0;
	if (nwritten == 0)
	  n = __poll (pfd, 1, 0); // tx2. 等待套接字可写
	if (__glibc_unlikely (n == 0))       {
		n = __poll (pfd, 1, ptimeout); // rx1. 等待套接字可读，5秒超时
		need_recompute = 1;
	}
	if (n == 0) {
		if (resplen &gt; 1 &amp;&amp; (recvresp1 || (buf2 != NULL &amp;&amp; recvresp2)))
		  { // 处理某些DNS服务器不支持处理并行请求的场景
		    /* There are quite a few broken name servers out
		       there which don't handle two outstanding
		       requests from the same source.  There are also
		       broken firewall settings.  If we time out after
		       having received one answer switch to the mode
		       where we send the second request only once we
		       have received the first answer.  */
		    if (!single_request)
		      {
			statp-&gt;options |= RES_SNGLKUP; // 这里永久改变为串行发送请求。statp是线程本地变量，
			single_request = true;         // KeyDB复制定时任务总是在同一线程中运行
			*gotsomewhere = save_gotsomewhere;
			goto retry;
		      }
		    else if (!single_request_reopen)
		      {
			statp-&gt;options |= RES_SNGLKUPREOP;
			single_request_reopen = true;
			*gotsomewhere = save_gotsomewhere;
			__res_iclose (statp, false);
			goto retry_reopen;
		      }

		    *resplen2 = 1;
		    return resplen;
		  }

		*gotsomewhere = 1;
		if (resplen2 != NULL)
		  *resplen2 = 0;
		return 0;
	}
	if (n &lt; 0) {
		if (errno == EINTR)
			goto recompute_resend;

		goto poll_err_out;
	}
	__set_errno (0);
	if (pfd[0].revents &amp; POLLOUT) { // tx3. 监听到可写事件
#ifndef __ASSUME_SENDMMSG
		static int have_sendmmsg;
#else
# define have_sendmmsg 1
#endif
		if (have_sendmmsg &gt;= 0 &amp;&amp; nwritten == 0 &amp;&amp; buf2 != NULL // 查询请求2不为空
		    &amp;&amp; !single_request) // 且允许并行发送
		  {
		    struct iovec iov[2];
		    struct mmsghdr reqs[2];
		    reqs[0].msg_hdr.msg_name = NULL;
		    reqs[0].msg_hdr.msg_namelen = 0;
		    reqs[0].msg_hdr.msg_iov = &amp;iov[0];
		    reqs[0].msg_hdr.msg_iovlen = 1;
		    iov[0].iov_base = (void *) buf;
		    iov[0].iov_len = buflen;
		    reqs[0].msg_hdr.msg_control = NULL;
		    reqs[0].msg_hdr.msg_controllen = 0;

		    reqs[1].msg_hdr.msg_name = NULL;
		    reqs[1].msg_hdr.msg_namelen = 0;
		    reqs[1].msg_hdr.msg_iov = &amp;iov[1];
		    reqs[1].msg_hdr.msg_iovlen = 1;
		    iov[1].iov_base = (void *) buf2;
		    iov[1].iov_len = buflen2;
		    reqs[1].msg_hdr.msg_control = NULL;
		    reqs[1].msg_hdr.msg_controllen = 0;
            // 发送消息，注意这里同时发送2个查询请求，返回值是实际发送的数量
		    int ndg = __sendmmsg (pfd[0].fd, reqs, 2, MSG_NOSIGNAL);
		    if (__glibc_likely (ndg == 2))
		      {
			if (reqs[0].msg_len != buflen
			    || reqs[1].msg_len != buflen2)
			  goto fail_sendmmsg;

			pfd[0].events = POLLIN;
			nwritten += 2;
		      }
		    else if (ndg == 1 &amp;&amp; reqs[0].msg_len == buflen)
		      goto just_one;
		    else if (ndg &lt; 0 &amp;&amp; (errno == EINTR || errno == EAGAIN))
		      goto recompute_resend;
		    else
		      {
#ifndef __ASSUME_SENDMMSG
			if (__glibc_unlikely (have_sendmmsg == 0))
			  {
			    if (ndg &lt; 0 &amp;&amp; errno == ENOSYS)
			      {
				have_sendmmsg = -1;
				goto try_send;
			      }
			    have_sendmmsg = 1;
			  }
#endif

		      fail_sendmmsg:
			return close_and_return_error (statp, resplen2);
		      }
		  }
		else
		  { // 不支持并行发送
		    ssize_t sr;
#ifndef __ASSUME_SENDMMSG
		  try_send:
#endif
		    if (nwritten != 0)
		      sr = send (pfd[0].fd, buf2, buflen2, MSG_NOSIGNAL);
		    else
		      sr = send (pfd[0].fd, buf, buflen, MSG_NOSIGNAL); // tx4. 发送查询请求1

		    if (sr != (nwritten != 0 ? buflen2 : buflen)) { // 发送长度和缓冲区长度不匹配
		      if (errno == EINTR || errno == EAGAIN) // 如果原因是EINTR或EAGAIN，则尝试重发
			goto recompute_resend;
		      return close_and_return_error (statp, resplen2);
		    }
		  just_one:
		    if (nwritten != 0 || buf2 == NULL || single_request)
		      pfd[0].events = POLLIN;  // 串行模式下，后续只需监听可读时间
		    else
		      pfd[0].events = POLLIN | POLLOUT; // 并行发送，如果实际仅发送1个消息，跳转到这里。后续需要继续写入发送失败的那个消息
		    ++nwritten;
		  }
		goto wait; // tx4. 发送完毕，回到上面的wait分支等待应答
	} else if (pfd[0].revents &amp; POLLIN) { // rx2. 监听到套接字可读
		int *thisanssizp; // 本次读数据到哪个缓冲
		u_char **thisansp;
		int *thisresplenp;

		if ((recvresp1 | recvresp2) == 0 || buf2 == NULL) {
			/* We have not received any responses
			   yet or we only have one response to
			   receive.  */
			thisanssizp = anssizp;
			thisansp = anscp ?: ansp;
			assert (anscp != NULL || ansp2 == NULL);
			thisresplenp = &amp;resplen;
		} else {
			thisanssizp = anssizp2;
			thisansp = ansp2;
			thisresplenp = resplen2;
		}

		if (*thisanssizp &lt; MAXPACKET
		    /* If the current buffer is not the the static
		       user-supplied buffer then we can reallocate
		       it.  */
		    &amp;&amp; (thisansp != NULL &amp;&amp; thisansp != ansp)
#ifdef FIONREAD
		    /* Is the size too small?  */
		    &amp;&amp; (ioctl (pfd[0].fd, FIONREAD, thisresplenp) &lt; 0
			|| *thisanssizp &lt; *thisresplenp)
#endif
                    ) {
			/* Always allocate MAXPACKET, callers expect
			   this specific size.  */
			u_char *newp = malloc (MAXPACKET);
			if (newp != NULL) {
				*thisanssizp = MAXPACKET;
				*thisansp = newp;
				if (thisansp == ansp2)
				  *ansp2_malloced = 1;
			}
		}
		/* We could end up with truncation if anscp was NULL
		   (not allowed to change caller's buffer) and the
		   response buffer size is too small.  This isn't a
		   reliable way to detect truncation because the ioctl
		   may be an inaccurate report of the UDP message size.
		   Therefore we use this only to issue debug output.
		   To do truncation accurately with UDP we need
		   MSG_TRUNC which is only available on Linux.  We
		   can abstract out the Linux-specific feature in the
		   future to detect truncation.  */
		HEADER *anhp = (HEADER *) *thisansp;
		socklen_t fromlen = sizeof(struct sockaddr_in6);
		assert (sizeof(from) &lt;= fromlen);
		*thisresplenp = recvfrom(pfd[0].fd, (char*)*thisansp, // rx3. 读取应答
					 *thisanssizp, 0,
					(struct sockaddr *)&amp;from, &amp;fromlen);
		if (__glibc_unlikely (*thisresplenp &lt;= 0))       {
			if (errno == EINTR || errno == EAGAIN) {
				need_recompute = 1;
				goto wait;  // 如果EINTR|EAGAIN则重新等待
			}
			return close_and_return_error (statp, resplen2);
		}
		*gotsomewhere = 1;
		if (__glibc_unlikely (*thisresplenp &lt; HFIXEDSZ))       { // 消息比报文头长度还小，错误
			/*
			 * Undersized message.
			 */
			*terrno = EMSGSIZE;
			return close_and_return_error (statp, resplen2);
		}
		if ((recvresp1 || hp-&gt;id != anhp-&gt;id)
		    &amp;&amp; (recvresp2 || hp2-&gt;id != anhp-&gt;id)) { // 查询标识符不匹配，可能服务器缓慢，返回之前查询的应答
			/*
			 * response from old query, ignore it.
			 * XXX - potential security hazard could
			 *	 be detected here.
			 */
			goto wait;
		}
		if (!(statp-&gt;options &amp; RES_INSECURE1) &amp;&amp; // 安全性检查type1
		    !res_ourserver_p(statp, &amp;from)) {
			/*
			 * response from wrong server? ignore it.
			 * XXX - potential security hazard could
			 *	 be detected here.
			 */
			goto wait;
		}
		if (!(statp-&gt;options &amp; RES_INSECURE2) // 安全性检查type2
		    &amp;&amp; (recvresp1 || !res_queriesmatch(buf, buf + buflen,
						       *thisansp,
						       *thisansp
						       + *thisanssizp))
		    &amp;&amp; (recvresp2 || !res_queriesmatch(buf2, buf2 + buflen2,
						       *thisansp,
						       *thisansp
						       + *thisanssizp))) {
			/*
			 * response contains wrong query? ignore it.
			 * XXX - potential security hazard could
			 *	 be detected here.
			 */
			goto wait;
		}
		if (anhp-&gt;rcode == SERVFAIL ||
		    anhp-&gt;rcode == NOTIMP ||
		    anhp-&gt;rcode == REFUSED) {  //  rx4. 处理服务器不愿意处理请求的情况
		next_ns:
			if (recvresp1 || (buf2 != NULL &amp;&amp; recvresp2)) {
			  *resplen2 = 0;
			  return resplen;
			}
			if (buf2 != NULL)
			  {
			    /* No data from the first reply.  */
			    resplen = 0;
			    /* We are waiting for a possible second reply.  */
			    if (hp-&gt;id == anhp-&gt;id)
			      recvresp1 = 1;
			    else
			      recvresp2 = 1;

			    goto wait;  // 事件类型仍然是POLLIN，会导致超时
			  }

			/* don't retry if called from dig */
			if (!statp-&gt;pfcode)
			  return close_and_return_error (statp, resplen2);
			__res_iclose(statp, false);
		}
		if (anhp-&gt;rcode == NOERROR &amp;&amp; anhp-&gt;ancount == 0 // rx.4 处理nodata的情况，名字请求，请求的记录类型不存在
		    &amp;&amp; anhp-&gt;aa == 0 &amp;&amp; anhp-&gt;ra == 0 &amp;&amp; anhp-&gt;arcount == 0) {
			goto next_ns;
		}
		if (!(statp-&gt;options &amp; RES_IGNTC) &amp;&amp; anhp-&gt;tc) { // rx.4 处理应答截断的情况
			/*
			 * To get the rest of answer,
			 * use TCP with same server.
			 */
			*v_circuit = 1; // 提示使用TCP重发请求
			__res_iclose(statp, false);
			// XXX if we have received one reply we could
			// XXX use it and not repeat it over TCP...
			if (resplen2 != NULL)
			  *resplen2 = 0;
			return (1);
		}
		/* Mark which reply we received.  */
		if (recvresp1 == 0 &amp;&amp; hp-&gt;id == anhp-&gt;id)
			recvresp1 = 1;
		else
			recvresp2 = 1;
		/* Repeat waiting if we have a second answer to arrive.  */
		if ((recvresp1 &amp; recvresp2) == 0) { // 如果只有一个查询请求，recvresp2一开始就标记为1，因此不会走到这个分支
			if (single_request) { // 如果是串行模式，这里开始处理第2个请求
				pfd[0].events = POLLOUT;
				if (single_request_reopen) {  // 如果需要关闭并重新打开套接字
					__res_iclose (statp, false);
					retval = reopen (statp, terrno, ns);
					if (retval &lt;= 0)
					  {
					    if (resplen2 != NULL)
					      *resplen2 = 0;
					    return retval;
					  }
					pfd[0].fd = EXT(statp).nssocks[ns];
				}
			}
			goto wait;  // 事件类型已经改为POLLOUT，因此不会发生超时
		}
		/* All is well.  We have received both responses (if
		   two responses were requested).  */
		return (resplen); // rx.5 DNS查询完毕
	} else if (pfd[0].revents &amp; (POLLERR | POLLHUP | POLLNVAL)) // poll出现错误
	  /* Something went wrong.  We can stop trying.  */
	  return close_and_return_error (statp, resplen2);
	else {
		/* poll should not have returned &gt; 0 in this case.  */
		abort ();
	}
}</pre>
<p>注释中tx.标注了DNS查询请求发送的基本过程，rx.则标注了DNS查询应答接收的基本过程。调试查询keydb-0.keydb时该函数的行为，发现以下事实：</p>
<ol>
<li>查询时串行发送的，而不是并行。因此正常流程应该是发送A查询，接收A应答，发送AAAA查询，接收AAAA应答</li>
<li>仅执行了1225行，没有执行1223行。也就是说仅仅发送了A查询，没有发送AAA查询</li>
<li>走到了1241行的分支，也就是说，<span style="background-color: #c0c0c0;">A请求的应答报文是接收到的</span>：<br />
<pre class="crayon-plain-tag">// (gdb) i r eax
// eax            0x2d     45   A应答长度45
		*thisresplenp = recvfrom(pfd[0].fd, (char*)*thisansp,
					 *thisanssizp, 0,
					(struct sockaddr *)&amp;from, &amp;fromlen);</pre></p>
<p>由于接收到的应答是servfail，因此走到这个分支：</p>
<pre class="crayon-plain-tag">if (anhp-&gt;rcode == SERVFAIL ||
		    anhp-&gt;rcode == NOTIMP ||
		    anhp-&gt;rcode == REFUSED) {
		next_ns:
			if (recvresp1 || (buf2 != NULL &amp;&amp; recvresp2)) {
			  *resplen2 = 0;
			  return resplen;
			}
			if (buf2 != NULL)
			  {
			    /* No data from the first reply.  */
			    resplen = 0;
			    /* We are waiting for a possible second reply.  */
			    if (hp-&gt;id == anhp-&gt;id)
			      recvresp1 = 1;  // 接收到第一个应答
			    else
			      recvresp2 = 1;
                // 由于同时需要进行A和AAAA查询，这里仅仅接收到A应答（串行发送）
			    goto wait; // 因此需要跳转到这里，等待套接字可写，以发送AAAA请求
			  }</pre>
</li>
<li>
<p>CoreDNS应答A查询SERVFAIL，重新跳转到wait标签：
<pre class="crayon-plain-tag">if (need_recompute) { // 等待A应答的时候，设置了超时 need_recompute，因此再次wait执行这个分支
	recompute_resend:
		evNowTime(&amp;now);
		if (evCmpTime(finish, now) &lt;= 0) {
		poll_err_out: // 如果超时了，直接关闭套接字并返回错误
			return close_and_return_error (statp, resplen2);
		}
		evSubTime(&amp;timeout, &amp;finish, &amp;now);
		need_recompute = 0;
	}
	/* Convert struct timespec in milliseconds.  */
	ptimeout = timeout.tv_sec * 1000 + timeout.tv_nsec / 1000000;

	n = 0;
	if (nwritten == 0)
	  n = __poll (pfd, 1, 0);  // 发送A请求的时候在这里pull，等待套接字可写。timeout 0表示立即返回
	if (__glibc_unlikely (n == 0))       {
		n = __poll (pfd, 1, ptimeout);  // 接收A应答的时候在这里poll，等待套接字可读
		need_recompute = 1; // 发送AAAA请求时，在这里等待套接字可写
	}</pre>
</li>
<li>
<p> 这时，由于nwritten已经被设置为1，因此走带有timeout的poll分支。然后在1110行出现5秒超时，并因为poll返回值是0而导致send_dg函数退出。在一次A请求处理过程中，有两次在1110行poll：
<ol>
<li>第一次是尝试A请求的应答，poll前的pollfd是{fd = 87, events = 1, revents = 4}，之后是{fd = 87, events = 1, revents = 1}</li>
<li>第二次就是因为这个跳转，poll前的pollfd是{fd = 87, events = 1, revents = 1}，超时之后是{fd = 87, events = 1, revents = 0}</li>
</ol>
</li>
</ol>
<p>poll函数原型：<pre class="crayon-plain-tag">int poll(struct pollfd *fds, nfds_t nfds, int timeout);</pre>，它等待文件描述符集合中的某个可用（可执行I/O）。文件描述符集合由参数fds指定，它是pollfd结构的数组：</p>
<pre class="crayon-plain-tag">struct pollfd {
    // 打开文件的描述符
    int   fd;         
    // 输入参数，应用程序感兴趣的事件类型。如果置零则revents中仅能返回POLLHUP,POLLERR,POLLNVAL事件
    short events;
    // 输出参数，内核填充实际发生的事件
    short revents;    
};</pre>
<p>如果文件描述符集中没有任何一个发生了events中指定的事件，则该函数会阻塞，直到超时或者被信号处理器中断。</p>
<p><span style="background-color: #c0c0c0;">事件类型1表示POLLIN，即有数据可读；事件类型4表示POLLOUT</span>，即文件描述符可写。正常情况下该函数返回就绪的（revents非零）文件描述符数量，超时返回0，出现错误则返回-1</p>
<p>第2次在1110行的poll行为难以理解：</p>
<ol>
<li>A的应答已经接收到，而由于进行的是串行发送A/AAAA，此时尚未发送AAAA请求，因此可以<span style="background-color: #c0c0c0;">预期后续不会有可读事件</span></li>
<li>poll时events设置为POLLIN（肯定会导致超时），难道不是应该设置为POLLOUT，尝试发送AAAA请求或重试A请求么？</li>
</ol>
<p>为了进行对照，我们由调试了没有发生缓慢问题的keydb-2.keydb。它在第2次执行1110行的poll时没有超时，pollfd的状态是{fd = 88, events = 1, revents = 1}。连续两次poll到可读事件，这提示进行了并行A/AAAA查询。检查变量single_request_reopen、single_request果然都是false，从CoreDNS日志上也可以看到A/AAAA</p>
<p>可能的情况是，keydb-1.keydb最初是并行发送A/AAAA查询的，后来由于某种原因，改为串行发送，从而导致出现5秒超时相关的缓慢现象。根源应该还是在glibc中，因为KeyDB调用getaddrinfo的方式是固定的。</p>
<p>回顾一下send_dg的代码，可以发现<pre class="crayon-plain-tag">statp-&gt;options</pre>决定了是否进行串行发送，statp是<pre class="crayon-plain-tag">resolv_context</pre>的一个字段，后者则是一个线程本地变量。如果某次并行发送请求后，可以接收到第一个应答，而在继续等待第二个应答时出现超时（1113行），则send_dg函数会修改statp-&gt;options，改为串行发送，<span style="background-color: #c0c0c0;">这个修改具有全局性影响</span>，以后KeyDB的复制定时任务（总是由同一线程执行）调用getaddrinfo，都会使用串行方式发送请求。</p>
<p>改变为串行方式后，由于CoreDNS应答keydb-0.keydb.以SERVFAIL，导致跳转到wait标签（1363行），进而执行了一次必然超时的poll调用。CoreDNS应答其它（加了search domain后缀的）域名以NXDOMAIN，则不会导致超时的poll调用，<span style="background-color: #c0c0c0;">因为会在1396行修改事件类型为POLLOUT</span>。</p>
<div class="blog_h1"><span class="graybg">解决方案</span></div>
<p>触发本文中的glibc缺陷，需要满足以下条件：</p>
<ol>
<li>出现某个KeyDB节点宕机的情况，并且没有修复。这会导致复制定时任务反复执行DNS查询，从而可能触发缺陷</li>
<li>某个DNS查询的应答UDP包丢失，导致当前线程串行发送DNS请求。由于UDP本身的不可靠性，随着程序不断运行，最终会发生</li>
<li>DNS服务器返回SERVFAIL、NOTIMP或者REFUSED应答</li>
</ol>
<p>第1、2个条件都是随机性的，我们没法干预，只有从第3个条件入手。作为最快速的解决方案，只需要配置KeyDB，使用全限定域名来指定replicaof即可。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/debugging-slow-keydb">记录一次KeyDB缓慢的定位过程</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/debugging-slow-keydb/feed</wfw:commentRss>
		<slash:comments>1</slash:comments>
		</item>
		<item>
		<title>IPVS模式下ClusterIP泄露宿主机端口的问题</title>
		<link>https://blog.gmem.cc/nodeport-leak-under-ipvs-mode</link>
		<comments>https://blog.gmem.cc/nodeport-leak-under-ipvs-mode#comments</comments>
		<pubDate>Tue, 05 Jan 2021 10:50:51 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[C]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Network]]></category>
		<category><![CDATA[PaaS]]></category>
		<category><![CDATA[IPVS]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=35061</guid>
		<description><![CDATA[<p>问题 在一个启用了IPVS模式kube-proxy的K8S集群中，运行着一个Docker Registry服务。我们尝试通过docker manifest命令（带上--insecure参数）来推送manifest时，出现TLS timeout错误。 这个Registry通过ClusterIP类型的Service暴露访问端点，且仅仅配置了HTTP/80端口。docker manifest命令的--insecure参数的含义是，在Registry不支持HTTPS的情况下，允许使用不安全的HTTP协议通信。从报错上来看，很明显docker manifest认为Registry支持HTTPS协议。 在宿主机上尝试[crayon-69de136ba0062155687554-i/]，居然可以连通。检查后发现节点上使用443端口的，只有Ingress Controller的NodePort类型的Service，它在0.0.0.0上监听。删除此NodePort服务后，RegistryClusterIP:443就不通了，docker manifest命令恢复正常。 定义 如果kube-proxy启用了IPVS模式，并且宿主机在0.0.0.0:NonServicePort上监听，那么可以在宿主机上、或者Pod内，通过任意ClusterIP:NonServicePort访问到宿主机的NonServicePort。 这一行为显然不符合预期，我们期望仅仅在Service对象中声明的端口，才可能通过Cluster连通。如果ClusterIP上的未知端口，内核应该丢弃报文或者返回适当的ICMP。 如果kube-proxy使用iptables模式，不会出现这种异常行为。 原因 启用IPVS的情况下，所有ClusterIP都会绑定在kube-ipvs0这个虚拟的网络接口上。例如对于kube-dns服务的ClusterIP 10.96.0.10（ServicePort为TCP 53 / TCP 9153）： [crayon-69de136ba0068821561575/] <a class="read-more" href="https://blog.gmem.cc/nodeport-leak-under-ipvs-mode">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/nodeport-leak-under-ipvs-mode">IPVS模式下ClusterIP泄露宿主机端口的问题</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">问题</span></div>
<p>在一个启用了IPVS模式kube-proxy的K8S集群中，运行着一个Docker Registry服务。我们尝试通过docker manifest命令（带上--insecure参数）来推送manifest时，出现TLS timeout错误。</p>
<p>这个Registry通过ClusterIP类型的Service暴露访问端点，且仅仅配置了HTTP/80端口。docker manifest命令的--insecure参数的含义是，在Registry不支持HTTPS的情况下，允许使用不安全的HTTP协议通信。从报错上来看，很明显docker manifest认为Registry支持HTTPS协议。</p>
<p>在宿主机上尝试<pre class="crayon-plain-tag">telnet RegistryClusterIP 443</pre>，居然可以连通。检查后发现节点上使用443端口的，只有Ingress Controller的NodePort类型的Service，它在0.0.0.0上监听。删除此NodePort服务后，RegistryClusterIP:443就不通了，docker manifest命令恢复正常。</p>
<div class="blog_h1"><span class="graybg">定义</span></div>
<p>如果kube-proxy启用了IPVS模式，并且宿主机在0.0.0.0:NonServicePort上监听，那么可以在宿主机上、或者Pod内，通过任意ClusterIP:NonServicePort访问到宿主机的NonServicePort。</p>
<p>这一行为显然不符合预期，我们期望仅仅在Service对象中声明的端口，才可能通过Cluster连通。如果ClusterIP上的未知端口，内核应该丢弃报文或者返回适当的ICMP。</p>
<p>如果kube-proxy使用iptables模式，不会出现这种异常行为。</p>
<div class="blog_h1"><span class="graybg">原因</span></div>
<p>启用IPVS的情况下，所有ClusterIP都会绑定在kube-ipvs0这个虚拟的网络接口上。例如对于kube-dns服务的ClusterIP 10.96.0.10（ServicePort为TCP 53 / TCP 9153）：</p>
<pre class="crayon-plain-tag">5: kube-ipvs0: &lt;BROADCAST,NOARP&gt; mtu 1500 qdisc noop state DOWN group default 
    link/ether fa:d9:9e:37:12:68 brd ff:ff:ff:ff:ff:ff
    inet 10.96.0.10/32 brd 10.96.0.10 scope global kube-ipvs0
       valid_lft forever preferred_lft forever</pre>
<p>这种绑定是必须的，因为IPVS的工作原理是，<span style="background-color: #c0c0c0;">在netfilter挂载点LOCAL_IN上注册钩子ip_vs_in，拦截目的地是VIP（ClusterIP）的封包</span>。而要使得封包进入到LOCAL_IN，它的目的地址必须是本机地址。</p>
<p>每当为网络接口添加一个IP地址，内核都会<span style="background-color: #c0c0c0;">自动</span>在local路由表中增加一条规则，对于上面的10.96.0.10，会增加：</p>
<pre class="crayon-plain-tag"># 对于目的地址是10.96.0.10的封包，从kube-ipvs0发出，如果没有指定源IP，使用10.96.0.10
local 10.96.0.10 dev kube-ipvs0 proto kernel scope host src 10.96.0.10</pre>
<p>上述自动添加路由的一个副作用是，<span style="background-color: #c0c0c0;">对于任意一个端口Port，如果不存在匹配ClusterIP:Port的IPVS规则，同时宿主机上某个应用在0.0.0.0:Port上监听，封包就会交由此应用处理</span>。</p>
<p>在宿主机上执行<pre class="crayon-plain-tag">telnet 10.96.0.10 22</pre>，会发生以下事件序列：</p>
<ol>
<li>出站选路，根据local表路由规则，从kube-ipvs0接口发出封包</li>
<li>由于kube-ipvs0是dummy的，封包<span style="background-color: #c0c0c0;">立刻从kube-ipvs0的出站队列移动到入站队列</span></li>
<li>目的地址是本地地址，因此进入LOCAL_IN挂载点</li>
<li>由于22不是ServicePort，封包被转发给本地进程处理，即监听了22的那个进程</li>
</ol>
<p>如果删除内核自动在local表中添加的路由：</p>
<pre class="crayon-plain-tag">ip route del table local local 10.96.0.10 dev kube-ipvs0 proto kernel scope host src 10.96.0.10</pre>
<p>则会出现以下现象：</p>
<ol>
<li>无法访问10.96.0.10:22。这是我们期望的，因为10.96.0.10这个服务没有暴露22端口，此端口理当不通</li>
<li>无法ping 10.96.0.10。这不是我们期望的，但是一般情况下不会有什么问题。iptables模式下ClusterIP就是无法ping的，IPVS模式下可以在本机ping仅仅是绑定ClusterIP到kube-ipvs0的一个副作用。通常应用程序不应该对ClusterIP做ICMP检测，来判断服务是否可用，因为这依赖了kube-proxy的特定工作模式</li>
<li>在宿主机上，可以访问10.96.0.10:53。这是我们期望的，宿主机上可以访问ClusterIP</li>
<li>在某个容器的网络命名空间下，无法访问10.96.0.10:53。这不是我们期望的，相当于Pod无法访问ClusterIP了</li>
</ol>
<p>以上4条，惟独3难以理解。<span style="background-color: #c0c0c0;">为什么路由没了，宿主机仍然能访问ClusterIP:ServicePort</span>？这个我们还没有从源码级别深究，但是很明显和IPVS有关。IPVS在LOCAL_OUT上挂有钩子，它可能在此钩子中检测到来自本机（主网络命名空间）的、访问ClusterIP+ServicePort（即IPVS虚拟服务）的封包，并进行了某种“魔法”处理，从而避开了没有路由的问题。</p>
<p>下面我们进一步验证上述“魔法”处理的可能性。使用<pre class="crayon-plain-tag">tcpdump -i any host 10.96.0.10</pre>来捕获流量，从容器命名空间访问ClusterIP:ServicePort时，可以看到：</p>
<pre class="crayon-plain-tag">#                  容器IP
11:32:00.448470 IP 172.27.0.24.56378 &gt; 10.96.0.10.53: Flags [S], seq 2946888109, win 28200, options...</pre>
<p>但是从宿主机访问ClusterIP:ServicePort时，则捕获不到任何流量。但是，通过iptables logging，我们可以确定，内核的确<span style="background-color: #c0c0c0;">以ClusterIP为源地址和目的地址</span>，发起了封包：</p>
<pre class="crayon-plain-tag">iptables -t mangle -I OUTPUT 1 -p tcp --dport 53 -j LOG --log-prefix 'out-d53: '

# dmesg -w
#                                      源地址          目的地址
# [3374381.426541] out-d53: IN= OUT=lo SRC=10.96.0.100 DST=10.96.0.100 LEN=52 TOS=0x10 PREC=0x00 TTL=64 ID=18885 DF PROTO=TCP SPT=42442 DPT=53 WINDOW=86 RES=0x00 ACK URGP=0 </pre>
<p>回顾一下数据报出站、入站的处理过程：</p>
<ol>
<li>出站，依次经过 <strong><span style="background-color: #99cc00;">netfilter/iptables</span></strong> ⇨ <strong><span style="background-color: #cc99ff;">tcpdump</span></strong> ⇨ 网络接口 ⇨网线</li>
<li>入站，依次经过 网线 ⇨ 网络接口 ⇨ tcpdump ⇨ netfilter/iptables</li>
</ol>
<p>只有当IPVS在宿主机请求10.96.0.10的封包出站时，在netfilter中对匹配IPVS虚拟服务的封包进行如下处理，才能解释<span style="background-color: #99cc00;"><strong>iptables</strong></span>中能看到10.96.0.10，而紧随其后的<strong><span style="background-color: #cc99ff;">tcpdump</span></strong>中却又看不到的现象：</p>
<ol>
<li>修改目的地址为Service的Endpoint地址，这就是NAT模式的IPVS（即kube-proxy使用NAT模式）应有的行为</li>
<li>修改了源地址为当前宿主机的地址，不这样做，回程报文就无法路由回来</li>
</ol>
<p>另外注意一下，如果从宿主机访问ClusterIP:NonServicePort，则tcpdump能捕获到源或目的地址为ClusterIP的流量。这是因为IPVS发现它不匹配任何虚拟服务，会直接返回NF_ACCEPT，然后封包就按照常规流程处理了。</p>
<div class="blog_h1"><span class="graybg">后果</span></div>
<div class="blog_h2"><span class="graybg">安全问题</span></div>
<p>如果宿主机上有一个在0.0.0.0上监听的、存在安全漏洞的服务，则可能被恶意的工作负载利用。</p>
<div class="blog_h2"><span class="graybg">行为异常</span></div>
<p>少部分的应用程序，例如docker manifest，其行为取决于端口探测的结果，会无法正常工作。</p>
<div class="blog_h1"><span class="graybg">解决</span></div>
<p>可能的解决方案有：</p>
<ol>
<li>在iptables中匹配哪些针对ClusterIP:NonServicePort的流量，Drop或Reject掉</li>
<li>使用基于fwmark的IPVS虚拟服务，这需要在iptables中对针对ClusterIP:ServicePort的流量打fwmark，而且每个ClusterIP都需要占用独立的fwmark，难以管理</li>
</ol>
<p>对于解决方案1，可以使用如下iptables规则： </p>
<pre class="crayon-plain-tag">#                 如果目的地址是ClusterIP    但是目的端口不是ServicePort           则拒绝
iptables -A INPUT -d  10.96.0.0/12 -m set ! --match-set KUBE-CLUSTER-IP dst,dst -j REJECT</pre>
<p>这个规则能够为容器解决宿主机端口泄露的问题，但是会导致宿主机上无法访问ClusterIP。</p>
<p>引起此问题的原因是，在宿主机访问ClusterIP时，会同时使用ClusterIP作为源地址/目的地址。这样，来自Endpoint的回程报文，unNATed后的目的地址，就会匹配到上面的iptables规则，从而导致封包被Reject掉。</p>
<p>要解决此问题，我们可以修改内核自动添加的路由，提示使用其它地址作为源地址：</p>
<pre class="crayon-plain-tag"># 这条路由给出src提示，当访问10.96.0.10时，选取192.168.104.82（节点IP）作为源地址
ip route replace table local local 10.96.0.10 dev kube-ipvs0 proto kernel scope host src 192.168.104.82</pre>
<div class="blog_h1"><span class="graybg">深入</span></div>
<p>上文我们提到了一个“魔法”处理的猜想，这里我们对IPVS的实现细节进行深入学习，证实此猜想。</p>
<p>本节牵涉到的内核源码均来自linux-3.10.y分支。</p>
<div class="blog_h2"><span class="graybg">Netfilter</span></div>
<p>这是从2.4.x引入内核的一个框架，用于实现防火墙、NAT、封包修改、记录封包日志、用户空间封包排队之类的功能。</p>
<p>netfilter运行在内核中，允许内核模块在Linux网络栈的<span style="background-color: #c0c0c0;">不同位置注册钩子（回调函数），当每个封包穿过网络栈时，这些钩子函数会被调用</span>。</p>
<p><a href="/iptables">iptables</a>是经典的，基于netfilter的用户空间工具。它的继任者是nftables，它更加灵活、可扩容、性能好。</p>
<div class="blog_h3"><span class="graybg">钩子挂载点</span></div>
<p>netfilter提供了5套钩子（的挂载点）：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 180px; text-align: center;">挂载点</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>NF_IP_PER_ROUTING</td>
<td>
<p>当封包进入网络栈时调用。封包的目的地可能是本机，或者需要转发</p>
<p>ip_rcv / ipv6_rcv是内核接受并处理IP数据报的入口，此函数会调用这类钩子：</p>
<pre class="crayon-plain-tag">int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)
{
	// ...
	return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL,
		       ip_rcv_finish);
}</pre>
</td>
</tr>
<tr>
<td>NF_IP_LOCAL_IN</td>
<td>
<p>当路由判断封包应该由本机处理时（目的地址是本机地址）调用
<p>ip_local_deliver / ip6_input负责将IP数据报向上层传递，此函数会调用这类钩子</p>
</td>
</tr>
<tr>
<td>NF_IP_FORWARD</td>
<td>
<p>当路由判断封包应该被转发给其它机器（或者网络命名空间）时调用</p>
<p>ip_forward / ip6_forward负责封包转发，此函数会调用这类钩子</p>
</td>
</tr>
<tr>
<td>NF_IP_POST_ROUTING</td>
<td>
<p>在封包即将离开网络栈（进入网线）时调用，不管是转发的、还是本机发出的，都需要经过此挂载点</p>
<p>ip_output / ip6_finish_output2会调用这类钩子</p>
</td>
</tr>
<tr>
<td>NF_IP_LOCAL_OUT</td>
<td>
<p>当封包由本机产生，需要往外发送时调用</p>
<p>__ip_local_out / __ip6_local_out会调用这类钩子</p>
</td>
</tr>
</tbody>
</table>
<p>这些挂载点，和iptables的各链是对应的。</p>
<div class="blog_h3"><span class="graybg">注册钩子</span></div>
<p>要在内核中使用netfilter的钩子，你需要调用函数：</p>
<pre class="crayon-plain-tag">// 注册钩子
int nf_register_hook(struct nf_hook_ops *reg){}
// 反注册钩子
void nf_unregister_hook(struct nf_hook_ops *reg){}</pre>
<p>入参nf_hook_ops是一个结构：</p>
<pre class="crayon-plain-tag">struct nf_hook_ops {
	// 钩子的函数指针，依据内核的版本不同此函数的签名有所差异
	nf_hookfn		*hook;
	struct net_device	*dev;
	void			*priv;
	// 钩子针对的协议族，PF_INET表示IPv4
	u_int8_t		pf;
	// 钩子类型代码，参考上面的表格
	unsigned int		hooknum;
	// 每种类型的钩子，都可以有多个，此数字决定执行优先级
	int			priority;
};


// 钩子函数的签名
typedef unsigned int nf_hookfn(unsigned int hooknum,
			       struct sk_buff *skb, // 正被处理的数据报
			       const struct net_device *in, // 输入设备
			       const struct net_device *out, // 是出设备
			       int (*okfn)(struct sk_buff *)); // 如果通过钩子检查，则调用此函数，通常用不到</pre>
<div class="blog_h3"><span class="graybg">钩子返回值</span></div>
<pre class="crayon-plain-tag">/* Responses from hook functions. */
// 丢弃该报文，不再继续传输或处理
#define NF_DROP 0
// 继续正常传输报文，如果后面由低优先级的钩子，仍然会调用它们
#define NF_ACCEPT 1
// 告知netfilter，报文被别人偷走处理了，不需要再对它做任何处理
// 下文的分析中，我们有个例子。一个netfilter钩子在内部触发了对netfilter钩子的调用
// 外层钩子返回的就是NF_STOLEN，相当于将封包的控制器转交给内层钩子了
#define NF_STOLEN 2
// 对该数据报进行排队，通常用于将数据报提交给用户空间进程处理
#define NF_QUEUE 3
// 再次调用该钩子函数
#define NF_REPEAT 4
// 继续正常传输报文，不会调用此挂载点的后续钩子
#define NF_STOP 5
#define NF_MAX_VERDICT NF_STOP </pre>
<div class="blog_h3"><span class="graybg">钩子优先级</span></div>
<p>优先级通常以下面的枚举为基准+/-：</p>
<pre class="crayon-plain-tag">enum nf_ip_hook_priorities {
	// 数值越小，优先级越高，越先执行
	NF_IP_PRI_FIRST = INT_MIN,
	NF_IP_PRI_CONNTRACK_DEFRAG = -400,
	// 可以看到iptables各表注册的钩子的优先级
	NF_IP_PRI_RAW = -300,
	NF_IP_PRI_SELINUX_FIRST = -225,
	NF_IP_PRI_CONNTRACK = -200,
	NF_IP_PRI_MANGLE = -150,
	NF_IP_PRI_NAT_DST = -100,
	NF_IP_PRI_FILTER = 0,
	NF_IP_PRI_SECURITY = 50,
	NF_IP_PRI_NAT_SRC = 100,
	NF_IP_PRI_SELINUX_LAST = 225,
	NF_IP_PRI_CONNTRACK_HELPER = 300,
	NF_IP_PRI_CONNTRACK_CONFIRM = INT_MAX,
	NF_IP_PRI_LAST = INT_MAX,
};</pre>
<div class="blog_h2"><span class="graybg"><a id="ipvs"></a>IPVS</span></div>
<div class="blog_h3"><span class="graybg">钩子列表</span></div>
<p> ip_vs模块初始化时，会通过ip_vs_init函数，调用nf_register_hook，注册以下netfilter钩子：</p>
<pre class="crayon-plain-tag">static struct nf_hook_ops ip_vs_ops[] __read_mostly = {
	// 注册到LOCAL_IN，这两个钩子处理外部客户端的报文
	// 转而调用ip_vs_out，用于NAT模式下，处理LVS回复外部客户端的报文，例如修改IP地址
	{
		.hook		= ip_vs_reply4,
		.owner		= THIS_MODULE,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_IN,
		.priority	= NF_IP_PRI_NAT_SRC - 2,
	},
	// 转而调用ip_vs_in，用于处理外部客户端进入IPVS的请求报文
	// 如果没有对应请求报文的连接，则使用调度函数创建连接结构，这其中牵涉选择RS负载均衡算法
	{
		.hook		= ip_vs_remote_request4,
		.owner		= THIS_MODULE,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_IN,
		.priority	= NF_IP_PRI_NAT_SRC - 1,
	},

	// 注册到LOCAL_OUT，这两个钩子处理LVS本机的报文
	// 转而调用ip_vs_out，用于NAT模式下，处理LVS回复客户端的报文
	{
		.hook		= ip_vs_local_reply4,
		.owner		= THIS_MODULE,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_OUT,
		.priority	= NF_IP_PRI_NAT_DST + 1,
	},
	// 转而调用ip_vs_in，调度并转发（给RS）本机的请求
	{
		.hook		= ip_vs_local_request4,
		.owner		= THIS_MODULE,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_LOCAL_OUT,
		.priority	= NF_IP_PRI_NAT_DST + 2,
	},

	// 这两个函数注册到FORWARD
	// 转而调用ip_vs_in_icmp，用于处理外部客户端发到IPVS的ICMP报文，并转发到RS
	{
		.hook		= ip_vs_forward_icmp,
		.owner		= THIS_MODULE,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_FORWARD,
		.priority	= 99,
	},
	// 转而调用ip_vs_out，用于NAT模式下，修改RS给的应答报文的源地址为IPVS虚拟地址
	{
		.hook		= ip_vs_reply4,
		.owner		= THIS_MODULE,
		.pf		= NFPROTO_IPV4,
		.hooknum	= NF_INET_FORWARD,
		.priority	= 100,
	}
};</pre>
<div class="blog_h3"><span class="graybg">ip_vs_in</span></div>
<p>从上面的钩子我们可以看到：</p>
<ol>
<li>针对外部发起的、本机发起的，对IPVS的请求（目的是VIP的SYN），钩子的位置是不一样的：
<ol>
<li>对于外部的请求，在LOCAL_IN中处理，钩子函数为ip_vs_remote_request4</li>
<li>对于本机的请求，在LOCAL_OUT中处理，钩子函数为ip_vs_local_request4</li>
</ol>
</li>
<li> 尽管钩子的位置不同，但是函数ip_vs_remote_request4、ip_vs_local_request4都是调用ip_vs_in。实际上，这两个函数的逻辑完全一样：<br />
<pre class="crayon-plain-tag">/*
 *	AF_INET handler in NF_INET_LOCAL_IN chain
 *	Schedule and forward packets from remote clients
 */
static unsigned int
ip_vs_remote_request4(unsigned int hooknum, struct sk_buff *skb,
		      const struct net_device *in,
		      const struct net_device *out,
		      int (*okfn)(struct sk_buff *))
{
	return ip_vs_in(hooknum, skb, AF_INET);
}

/*
 *	AF_INET handler in NF_INET_LOCAL_OUT chain
 *	Schedule and forward packets from local clients
 */
static unsigned int
ip_vs_local_request4(unsigned int hooknum, struct sk_buff *skb,
		     const struct net_device *in, const struct net_device *out,
		     int (*okfn)(struct sk_buff *))
{
	return ip_vs_in(hooknum, skb, AF_INET);
}</pre>
</li>
</ol>
<p>回顾一下上文我们关于“魔法”处理的疑惑。对于从宿主机发起对10.96.0.10:53的请求，我们通过iptables logging证实了使用的源IP地址是10.96.0.10：</p>
<ol>
<li>这个请求为什么tcpdump捕获不到？</li>
<li>为什么删除路由不影响宿主机对ClusterIP的请求（却又导致容器无法请求ClusterIP）？</li>
</ol>
<p>这两个问题的答案，很可能就隐藏在ip_vs_in函数中，因为它是处理进入IPVS的数据报的统一入口。如果该函数同时修改了原始封包的源/目的地址，就解释了问题1；如果该函数在内部进行了选路操作，则解释了问题2。</p>
<p>下面分析一下ip_vs_in的代码：</p>
<pre class="crayon-plain-tag">static unsigned int
ip_vs_in(unsigned int hooknum, struct sk_buff *skb, int af)
{
	// 网络命名空间
	struct net *net;
	// IPVS的IP头，其中存有3层头len、协议、标记、源/目的地址
	struct ip_vs_iphdr iph;
	// 持有协议（TCP/UDP/SCTP/AH/ESP）信息，更重要的是带着很多函数指针。这些指针负责针对特定协议的IPVS逻辑
	struct ip_vs_protocol *pp;
	// 每个命名空间一个此对象，包含统计计数器、超时表
	struct ip_vs_proto_data *pd;
	// 当前封包所属的IPVS连接对象，此对象最重要的是packet_xmit函数指针。它负责将封包发走
	struct ip_vs_conn *cp;
	int ret, pkts;
	// 描述当前网络命名空间的IPVS状态
	struct netns_ipvs *ipvs;

	// 如果封包已经被标记为IPVS请求/应答，不做处理，继续netfilter常规流程
	// 后续ip_vs_nat_xmit会让封包“重入”netfilter，那时封包已经打上IPVS标记
	// 这里的判断确保重入的封包走netfilter常规流程，而不是进入死循环
	if (skb-&gt;ipvs_property)
		return NF_ACCEPT;


	// 如果封包目的地不是本机且当前不在LOCAL_OUT
	// 或者封包的dst_entry不存在，不做处理，继续netfilter常规流程
	if (unlikely((skb-&gt;pkt_type != PACKET_HOST &amp;&amp;
		      hooknum != NF_INET_LOCAL_OUT) ||
		     !skb_dst(skb))) {
		ip_vs_fill_iph_skb(af, skb, &amp;iph);
		IP_VS_DBG_BUF(12, "packet type=%d proto=%d daddr=%s"
			      " ignored in hook %u\n",
			      skb-&gt;pkt_type, iph.protocol,
			      IP_VS_DBG_ADDR(af, &amp;iph.daddr), hooknum);
		return NF_ACCEPT;
	}
	// 如果当前IPVS主机是backup，或者当前命名空间没有启用IPVS，不做处理，继续netfilter常规流程
	net = skb_net(skb);
	ipvs = net_ipvs(net);
	if (unlikely(sysctl_backup_only(ipvs) || !ipvs-&gt;enable))
		return NF_ACCEPT;

	// 使用封包的IP头填充IPVS的IP头
	ip_vs_fill_iph_skb(af, skb, &amp;iph);

	// 如果是RAW套接字，不做处理，继续netfilter常规流程
	if (unlikely(skb-&gt;sk != NULL &amp;&amp; hooknum == NF_INET_LOCAL_OUT &amp;&amp;
		     af == AF_INET)) {
		struct sock *sk = skb-&gt;sk;
		struct inet_sock *inet = inet_sk(skb-&gt;sk);

		if (inet &amp;&amp; sk-&gt;sk_family == PF_INET &amp;&amp; inet-&gt;nodefrag)
			return NF_ACCEPT;
	}

	// 处理ICMP报文，和我们的场景无关
	if (unlikely(iph.protocol == IPPROTO_ICMP)) {
		int related;
		int verdict = ip_vs_in_icmp(skb, &amp;related, hooknum);
		if (related)
			return verdict;
	}

	// 如果协议不受IPVS支持，不做处理，继续netfilter常规流程
	pd = ip_vs_proto_data_get(net, iph.protocol);
	if (unlikely(!pd))
		return NF_ACCEPT;
	// 协议被支持，得到pp
	pp = pd-&gt;pp;
	// 尝试获取封包所属的IPVS连接对象
	cp = pp-&gt;conn_in_get(af, skb, &amp;iph, 0);
	// 如果封包属于既有IPVS连接，且此连接的RS（dest）已经设置，且RS的权重为0
	// 认为是无效连接，设为过期
	if (unlikely(sysctl_expire_nodest_conn(ipvs)) &amp;&amp; cp &amp;&amp; cp-&gt;dest &amp;&amp;
	    unlikely(!atomic_read(&amp;cp-&gt;dest-&gt;weight)) &amp;&amp; !iph.fragoffs &amp;&amp;
	    is_new_conn(skb, &amp;iph)) {
		ip_vs_conn_expire_now(cp);
		__ip_vs_conn_put(cp);
		cp = NULL;
	}

	// 调度一个新的IPVS连接，这里牵涉到RS的LB算法
	if (unlikely(!cp) &amp;&amp; !iph.fragoffs) {
		int v;
		if (!pp-&gt;conn_schedule(af, skb, pd, &amp;v, &amp;cp, &amp;iph))
			// 如果返回0，通常v是NF_DROP，这以为这调度失败，封包丢弃
			return v;
	}

	if (unlikely(!cp)) {
		IP_VS_DBG_PKT(12, af, pp, skb, 0,
			      "ip_vs_in: packet continues traversal as normal");
		if (iph.fragoffs) {
			IP_VS_DBG_RL("Unhandled frag, load nf_defrag_ipv6\n");
			IP_VS_DBG_PKT(7, af, pp, skb, 0, "unhandled fragment");
		}
		return NF_ACCEPT;
	}

	// 入站封包 —— 在我们的场景中，这是本地客户端入了IPVS系统的封包
	// 从网络栈的角度来说，我们正在处理的是出站封包...
	IP_VS_DBG_PKT(11, af, pp, skb, 0, "Incoming packet");

	// IPVS连接的RS不可用
	if (cp-&gt;dest &amp;&amp; !(cp-&gt;dest-&gt;flags &amp; IP_VS_DEST_F_AVAILABLE)) {
		// 立即将连接设为过期
		if (sysctl_expire_nodest_conn(ipvs)) {
			ip_vs_conn_expire_now(cp);
		}
		// 丢弃封包
		__ip_vs_conn_put(cp);
		return NF_DROP;
	}
	// 更新计数器
	ip_vs_in_stats(cp, skb);
	// 更新IPVS连接状态机，做的事情包括
	//   根据数据包 tcp 标记字段来更新当前状态机
	//   更新连接对应的统计数据，包括：活跃连接和非活跃连接
	//   根据连接状态，设置超时时间
	ip_vs_set_state(cp, IP_VS_DIR_INPUT, skb, pd);

	if (cp-&gt;packet_xmit)
		// 调用packet_xmit将封包发走，实际上是重入netfilter的LOCAL_OUT，封包控制权转移走，后续不该再操控skb
		ret = cp-&gt;packet_xmit(skb, cp, pp, &amp;iph);
	else {
		IP_VS_DBG_RL("warning: packet_xmit is null");
		ret = NF_ACCEPT;
	}

	if (cp-&gt;flags &amp; IP_VS_CONN_F_ONE_PACKET)
		pkts = sysctl_sync_threshold(ipvs);
	else
		pkts = atomic_add_return(1, &amp;cp-&gt;in_pkts);

	if (ipvs-&gt;sync_state &amp; IP_VS_STATE_MASTER)
		ip_vs_sync_conn(net, cp, pkts);

	// 放回连接对象，重置连接定时器
	ip_vs_conn_put(cp);
	return ret;
}</pre>
<p>上面这段代码中，“魔法”处理最可能发生在：</p>
<ol>
<li>conn_schedule：在这里需要进行IPVS连接的调度</li>
<li>packet_xmit：在这里发送经过IPVS处理的封包</li>
</ol>
<p>二者都是函数指针，在TCP协议下，conn_schedule指向tcp_conn_schedule。在NAT模式下，packet_xmit指向ip_vs_nat_xmit。packet_xmit指针是在conn_schedule过程中初始化的。</p>
<div class="blog_h3"><span class="graybg">tcp_conn_schedule</span></div>
<p>我们看一下TCP协议下IPVS连接的调度过程。</p>
<pre class="crayon-plain-tag">static int
tcp_conn_schedule(int af, struct sk_buff *skb, struct ip_vs_proto_data *pd,
		  int *verdict, struct ip_vs_conn **cpp,
		  struct ip_vs_iphdr *iph)
{
	// 网络命名空间
	struct net *net;
	// IPVS虚拟服务对象
	struct ip_vs_service *svc;
	struct tcphdr _tcph, *th;

	// 解析L4头，如果失败，提示ip_vs_in丢弃封包
	th = skb_header_pointer(skb, iph-&gt;len, sizeof(_tcph), &amp;_tcph);
	if (th == NULL) {
		*verdict = NF_DROP;
		return 0;
	}
	net = skb_net(skb);
	rcu_read_lock();
	if (th-&gt;syn &amp;&amp;
	    // 根据封包特征，去查找匹配的虚拟服务
	    (svc = ip_vs_service_find(net, af, skb-&gt;mark, iph-&gt;protocol,
				      &amp;iph-&gt;daddr, th-&gt;dest))) {
		int ignored;
		// 如果当前网络命名空间“过载”了，丢弃封包
		if (ip_vs_todrop(net_ipvs(net))) {
			rcu_read_unlock();
			*verdict = NF_DROP;
			return 0;
		}

		// 选择一个RS，建立IPVS连接
		// 如果找不到RS，或者发生致命错误，则ignore为0或-1，这种情况下
		// IPVS连接没有成功创建，提示ip_vs_in丢弃封包，可能附带回复ICMP
		*cpp = ip_vs_schedule(svc, skb, pd, &amp;ignored, iph);
		if (!*cpp &amp;&amp; ignored &lt;= 0) {
			if (!ignored)
				// ignored=0，找不到RS
				*verdict = ip_vs_leave(svc, skb, pd, iph);
			else
				*verdict = NF_DROP;
			rcu_read_unlock();
			return 0;
		}
	}
	rcu_read_unlock();
	// 如果调度成功，IPVS连接对象不为空，返回1
	/* NF_ACCEPT */
	return 1;
}</pre>
<p>到这里我们还没有看到IPVS对封包地址进行更改，需要进一步阅读ip_vs_schedule。 </p>
<div class="blog_h3"><span class="graybg">ip_vs_schedule</span></div>
<p>这是IPVS调度的核心函数，它支持TCP/UDP，它为虚拟服务选择一个RS，创建IPVS连接对象。</p>
<pre class="crayon-plain-tag">struct ip_vs_conn *
ip_vs_schedule(struct ip_vs_service *svc, struct sk_buff *skb,
	       struct ip_vs_proto_data *pd, int *ignored,
	       struct ip_vs_iphdr *iph)
{
	struct ip_vs_protocol *pp = pd-&gt;pp;
	// IPVS连接对象（connection entry）
	struct ip_vs_conn *cp = NULL;
	struct ip_vs_scheduler *sched;
	struct ip_vs_dest *dest;
	__be16 _ports[2], *pptr;
	unsigned int flags;

	// ...

	*ignored = 0;

	/*
	 *    Non-persistent service
	 */
	// 调度工作委托给虚拟服务的scheduler
	sched = rcu_dereference(svc-&gt;scheduler);
	// 调度器就是选择一个RS（ip_vs_dest）
	dest = sched-&gt;schedule(svc, skb);
	if (dest == NULL) {
		IP_VS_DBG(1, "Schedule: no dest found.\n");
		return NULL;
	}

	flags = (svc-&gt;flags &amp; IP_VS_SVC_F_ONEPACKET
		 &amp;&amp; iph-&gt;protocol == IPPROTO_UDP) ?
		IP_VS_CONN_F_ONE_PACKET : 0;

	// 初始化IPVS连接对象 ip_vs_conn
	{
		struct ip_vs_conn_param p;

		ip_vs_conn_fill_param(svc-&gt;net, svc-&gt;af, iph-&gt;protocol,
				      &amp;iph-&gt;saddr, pptr[0], &amp;iph-&gt;daddr,
				      pptr[1], &amp;p);
		// 操控ip_vs_conn的逻辑包括：
		//   初始化定时器
		//   设置网络命名空间
		//   设置地址、fwmark、端口
		//   根据IP版本、IPVS模式（NAT/DR/TUN）为连接设置一个packet_xmit
		cp = ip_vs_conn_new(&amp;p, &amp;dest-&gt;addr,
				    dest-&gt;port ? dest-&gt;port : pptr[1],
				    flags, dest, skb-&gt;mark);
		if (!cp) {
			*ignored = -1;
			return NULL;
		}
	}

	// ...
	return cp;
}</pre>
<p>到这里我们可以看到， conn_schedule仍然没有对封包做任何修改。看来关键在packet_xmit函数中。</p>
<div class="blog_h3"><span class="graybg">ip_vs_nat_xmit</span></div>
<pre class="crayon-plain-tag">int ip_vs_nat_xmit(struct sk_buff *skb, struct ip_vs_conn *cp,
	       struct ip_vs_protocol *pp, struct ip_vs_iphdr *ipvsh)
{
	// 路由表项
	struct rtable *rt;		/* Route to the other host */
	// 是否本机    是否输入路由
	int local, rc, was_input;

	EnterFunction(10);

	rcu_read_lock();
	// 是否尚未设置客户端端口
	if (unlikely(cp-&gt;flags &amp; IP_VS_CONN_F_NO_CPORT)) {
		__be16 _pt, *p;

		p = skb_header_pointer(skb, ipvsh-&gt;len, sizeof(_pt), &amp;_pt);
		if (p == NULL)
			goto tx_error;
		// 设置IPVS连接对象的cport
		// caddr cport 客户端地址
		// vaddr vport 虚拟服务地址
		// daddr dport RS地址
		ip_vs_conn_fill_cport(cp, *p);
		IP_VS_DBG(10, "filled cport=%d\n", ntohs(*p));
	}

	was_input = rt_is_input_route(skb_rtable(skb));
	// 出口路由查找，依据是封包、RS的地址、以及若干标识位
	// 返回值提示路由目的地是否是本机
	local = __ip_vs_get_out_rt(skb, cp-&gt;dest, cp-&gt;daddr.ip,
				   IP_VS_RT_MODE_LOCAL |
				   IP_VS_RT_MODE_NON_LOCAL |
				   IP_VS_RT_MODE_RDR, NULL);
	if (local &lt; 0)
		goto tx_error;
	rt = skb_rtable(skb);

	// 如果目的地是本机，RS地址是环回地址，是输入
	if (local &amp;&amp; ipv4_is_loopback(cp-&gt;daddr.ip) &amp;&amp; was_input) {
		IP_VS_DBG_RL_PKT(1, AF_INET, pp, skb, 0, "ip_vs_nat_xmit(): "
				 "stopping DNAT to loopback address");
		goto tx_error;
	}

	// 封包将被修改，执行copy-on-write
	if (!skb_make_writable(skb, sizeof(struct iphdr)))
		goto tx_error;

	if (skb_cow(skb, rt-&gt;dst.dev-&gt;hard_header_len))
		goto tx_error;

	// 修改封包，dnat_handler指向tcp_dnat_handler
	if (pp-&gt;dnat_handler &amp;&amp; !pp-&gt;dnat_handler(skb, pp, cp, ipvsh))
		goto tx_error;
	// 更改目的地址
	ip_hdr(skb)-&gt;daddr = cp-&gt;daddr.ip;
	// 为出站封包生成chksum
	ip_send_check(ip_hdr(skb));

	IP_VS_DBG_PKT(10, AF_INET, pp, skb, 0, "After DNAT");

	skb-&gt;local_df = 1;

	// 发送封包：
	//   如果发送出去了，返回 NF_STOLEN
	//   如果没有发送（local=1，目的地是本机），返回NF_ACCEPT
	rc = ip_vs_nat_send_or_cont(NFPROTO_IPV4, skb, cp, local);
	rcu_read_unlock();

	LeaveFunction(10);
	return rc;

  tx_error:
	kfree_skb(skb);
	rcu_read_unlock();
	LeaveFunction(10);
	return NF_STOLEN;
}

static inline int ip_vs_nat_send_or_cont(int pf, struct sk_buff *skb,  struct ip_vs_conn *cp, int local)
{
	// 注意这个NF_STOLEN的含义，参考上文
	int ret = NF_STOLEN;
	// 给封包设置IPVS标记，NF_HOOK会导致当前封包重入netfilter，此标记会让重入后的封包立即NF_ACCEPT、
	// 重入让修改后的封包有机会被ipables处理
	skb-&gt;ipvs_property = 1;
	if (likely(!(cp-&gt;flags &amp; IP_VS_CONN_F_NFCT)))
		ip_vs_notrack(skb);
	else
		ip_vs_update_conntrack(skb, cp, 1);
	// 如果目的地不是本机
	if (!local) {
		skb_forward_csum(skb);
		// 调用LOCAL_OUT挂载点
		NF_HOOK(pf, NF_INET_LOCAL_OUT, skb, NULL, skb_dst(skb)-&gt;dev, dst_output);
	} else
		ret = NF_ACCEPT;
	return ret;
}</pre>
<p>在ip_vs_nat_xmit中，我们可以了解到，对于宿主机发起的针对ClusterIP:ServicePort的请求</p>
<ol>
<li>封包的目的地址被修改为Endpoint（通常是Pod，IPVS中的RS）的地址</li>
<li>修改后的封包，重新被塞入netfilter（内层），注意当前就正在netfilter（外层）中
<ol>
<li>外层钩子的返回值是NF_STOLEN：封包处理权转移给内层钩子，停止后续netfilter流程</li>
<li>内层钩子的返回值是NF_ACCEPT：不做IPVS相关处理，继续后续netfilter流程。IPVS前、后的LOCAL_OUT、POSTROUTING钩子都会正常执行。也就是说，对于修改后的封包，内核会进行完整、常规的netfilter处理，就像没有IPVS存在一样</li>
</ol>
</li>
</ol>
<p>到这里，我们确定了，IPVS会在LOCAL_OUT中进行DNAT。但是只有同时进行SNAT，才能解释上文的中的疑惑。</p>
<div class="blog_h2"><span class="graybg">SNAT</span></div>
<p>花费了不少时间在IPVS上探究后，我们意识到走错了方向。我们忘记了SANT是kube-proxy会去做的事情。查看一下iptables规则就一目了然了：</p>
<pre class="crayon-plain-tag"># iptables -t nat -L -n -v

Chain OUTPUT (policy ACCEPT 2 packets, 150 bytes)
 pkts bytes target     prot opt in     out     source        destination         
# 所有出站流量都要经过自定义的 KUBE-SERVICES 链
  21M 3825M KUBE-SERVICES  all  --  *  *   0.0.0.0/0         0.0.0.0/0            /* kubernetes service portals */

Chain KUBE-SERVICES (2 references)
 pkts bytes target     prot opt in     out     source        destination         
# 如果目的IP:PORT属于K8S服务，则调用KUBE-MARK-MASQ链
    0     0 KUBE-MARK-MASQ  all  --  * *  !172.27.0.0/16     0.0.0.0/0   match-set KUBE-CLUSTER-IP dst,dst

# 给封包打上标记 0x4000
Chain KUBE-MARK-MASQ (5 references)
 pkts bytes target     prot opt in     out     source         destination         
   98  5880 MARK       all  --  *       *  0.0.0.0/0          0.0.0.0/0            MARK or 0x4000

 
Chain POSTROUTING (policy ACCEPT 2 packets, 150 bytes)
 pkts bytes target     prot opt in     out     source         destination         
  44M 5256M KUBE-POSTROUTING  all  --  *  *       0.0.0.0/0   0.0.0.0/0            /* kubernetes postrouting rules */

Chain KUBE-POSTROUTING (1 references)
 pkts bytes target     prot opt in     out     source         destination         
# 仅仅处理 0x4000标记的封包
 1781  166K RETURN     all  --  *      *  0.0.0.0/0           0.0.0.0/0            mark match ! 0x4000/0x4000
# 执行SNAT     
   97  5820 MARK       all  --  *      *  0.0.0.0/0           0.0.0.0/0   MARK xor 0x4000
   97  5820 MASQUERADE  all  --  *     *  0.0.0.0/0           0.0.0.0/0   /* kubernetes service traffic requiring SNAT */</pre>
<p>由于ip_vs_local_request4挂钩在LOCAL_OUT，优先级为NF_IP_PRI_NAT_DST+2 ，因此它是发生在上面nat表OUTPUT链中MARK之后的。也就是说在IPVS处理之前，kube-proxy已经给原始的封包打上标记。</p>
<p>重入的、DNAT后的封包进入LOCAL_OUT，随后进入POSTROUTING。由于标记的缘故，封包被kube-proxy的规则SNAT。</p>
<p>经过POSTROUTING的封包，经过tcpdump，但是由于源、目的IP地址，以及目的端口都改变了，因而我们看到tcpdump没有任何输出。</p>
<div class="blog_h1"><span class="graybg">总结</span></div>
<p>这里做一下小结。</p>
<p>为什么IPVS模式下，能够ping通ClusterIP？ 这是因为IPVS模式下，ClusterIP被配置为宿主机上一张虚拟网卡kube-ipvs0的IP地址。</p>
<p>为什么IPVS模式下，宿主机端口被ClusterIP泄漏？每当添加一个ClusterIP给网络接口后，内核自动在local表中增加一条路由，此路由保证了针对ClusterIP的访问，在没有IPVS干涉的情况下，路由到本机处理。这样，在0.0.0.0上监听的进程，就接收到报文并处理。</p>
<p>为什么删除内核添加的路由后：</p>
<ol>
<li>宿主机上访问ClusterIP:NonServicePort不通了？因为没有路由了</li>
<li>没有路由了，为什么宿主机上访问ClusterIP:ServicePort仍然畅通？如上文分析，IPVS在ip_vs_nat_xmit中仍然会进行选路操作</li>
<li>那为什么从容器网络命名空间访问ClusterIP:ServicePort不通呢？IPVS处理本地、远程客户端的代码路径不一样。容器网络命名空间是远程客户端，需要首先进入PER_ROUTING，然后选路，路由目的地是本机，才会进入LOCAL_IN，IPVS才有介入的时机。由于路由被删掉了，选路那一步就会出问题</li>
</ol>
<p>为什么通过--match-set KUBE-CLUSTER-IP匹配目的地址，如果封包目的端口是NonServicePort则Reject：</p>
<ol>
<li>这种方案对容器命名空间有效？容器请求的源地址不会是ClusterIP，因此回程报文的目的地址不会因为匹配规则而Reject</li>
<li>这种方案导致宿主机无法访问ClusterIP？宿主机发起请求时用的是ClusterIP，请求端口是随机的。这种请求的回程报文必然匹配规则导致Reject</li>
</ol>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/nodeport-leak-under-ipvs-mode">IPVS模式下ClusterIP泄露宿主机端口的问题</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/nodeport-leak-under-ipvs-mode/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>内核缺陷触发的NodePort服务63秒延迟问题</title>
		<link>https://blog.gmem.cc/nodeport-63s-delay-due-to-kernel-issue</link>
		<comments>https://blog.gmem.cc/nodeport-63s-delay-due-to-kernel-issue#comments</comments>
		<pubDate>Fri, 14 Aug 2020 09:05:27 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=34113</guid>
		<description><![CDATA[<p>现象 我们有一个新创建的TKE 1.3.0集群，使用基于Galaxy + Flannel（VXLAN模式）的容器网络，集群由三个二层互通的Master节点[crayon-69de136ba09d6096364369-i/]、[crayon-69de136ba09db113585789-i/]、[crayon-69de136ba09dd208772529-i/]组成。在访问宿主机端口为[crayon-69de136ba09df443116408-i/]的NodePort类型的Service时，出现了很有趣的现象： 在节点[crayon-69de136ba09e1222726747-i/]、[crayon-69de136ba09e3365985227-i/]节点上[crayon-69de136ba09e5295844945-i/]，有50%几率卡住 在节点[crayon-69de136ba09e7460868418-i/]上[crayon-69de136ba09e9402438301-i/]，100%几率卡住 从集群内部，访问非本节点的30153端口，畅通 从集群外部，访问任意节点的30153端口，畅通 三个节点本身并无差异，卡住几率不同，可能和服务的端点（Endpoint，即Pod）的分布情况有关。 NodePort服务的定义如下： [crayon-69de136ba09eb930987451/] 该服务的端点有两个： [crayon-69de136ba09ed505868754/] 可以看到，端点在10.0.0.11、10.0.0.13上分别有一个。假设容器网络存在问题，只能访问本机的Pod，则能解释前面的卡住现象 —— 10.0.0.12上没有端点，因此一直卡住。10.0.0.11、10.0.0.13分别占有50%端点，因此50%几率卡住。 但是，我们在任意节点直接访问Pod，发现都是畅通的： [crayon-69de136ba09f0785816287/] 这说明故障和容器网络没有直接关系。  分析 内层封包 <a class="read-more" href="https://blog.gmem.cc/nodeport-63s-delay-due-to-kernel-issue">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/nodeport-63s-delay-due-to-kernel-issue">内核缺陷触发的NodePort服务63秒延迟问题</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">现象</span></div>
<p>我们有一个新创建的TKE 1.3.0集群，使用基于Galaxy + Flannel（VXLAN模式）的容器网络，集群由三个二层互通的Master节点<pre class="crayon-plain-tag">10.0.0.11</pre>、<pre class="crayon-plain-tag">10.0.0.12</pre>、<pre class="crayon-plain-tag">10.0.0.13</pre>组成。在访问宿主机端口为<pre class="crayon-plain-tag">30153</pre>的NodePort类型的Service时，出现了很有趣的现象：</p>
<ol>
<li>在节点<pre class="crayon-plain-tag">10.0.0.11</pre>、<pre class="crayon-plain-tag">10.0.0.13</pre>节点上<pre class="crayon-plain-tag">curl http://localhost:30153</pre>，有50%几率卡住</li>
<li>在节点<pre class="crayon-plain-tag">10.0.0.12</pre>上<pre class="crayon-plain-tag">curl http://localhost:30153</pre>，100%几率卡住</li>
<li>从集群内部，访问非本节点的30153端口，畅通</li>
<li>从集群外部，访问任意节点的30153端口，畅通</li>
</ol>
<p>三个节点本身并无差异，卡住几率不同，可能和服务的端点（Endpoint，即Pod）的分布情况有关。</p>
<p>NodePort服务的定义如下：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: Service
metadata:
  name: kube-dns-nodeport
  namespace: kube-system
spec:
  externalTrafficPolicy: Cluster
  ports:
  - name: metrics
    nodePort: 30153
    port: 9153
    protocol: TCP
    targetPort: 9153
  selector:
    k8s-app: kube-dns
  sessionAffinity: None
  type: NodePort</pre>
<p>该服务的端点有两个：</p>
<pre class="crayon-plain-tag">kubectl -n kube-system get pod -l k8s-app=kube-dns -o wide
NAME                      READY   STATUS    RESTARTS   AGE    IP           NODE        NOMINATED NODE   READINESS GATES
coredns-bbc9b5888-r72zd   1/1     Running   0          140m   172.29.0.2   10.0.0.11   &lt;none&gt;           &lt;none&gt;
coredns-bbc9b5888-v6wx6   1/1     Running   0          10m    172.29.2.3   10.0.0.13   &lt;none&gt;           &lt;none&gt;</pre>
<p>可以看到，端点在10.0.0.11、10.0.0.13上分别有一个。假设容器网络存在问题，只能访问本机的Pod，则能解释前面的卡住现象 —— 10.0.0.12上没有端点，因此一直卡住。10.0.0.11、10.0.0.13分别占有50%端点，因此50%几率卡住。</p>
<p>但是，我们在任意节点直接访问Pod，发现都是畅通的：</p>
<pre class="crayon-plain-tag">curl http://172.29.0.2:9153
404 page not found

curl http://172.29.2.3:9153
404 page not found</pre>
<p>这说明故障和容器网络没有直接关系。 </p>
<div class="blog_h1"><span class="graybg">分析</span></div>
<div class="blog_h2"><span class="graybg">内层封包</span></div>
<p>我们在10.0.0.11向localhost:30153发起请求，并且抓取卡住时的封包：</p>
<pre class="crayon-plain-tag"># 经过iptables时，DNAT为POD_IP:9153，SNAT为宿主机eth0地址
curl http://127.0.0.1:30153

tcpdump -ttttt -nn -vvv -i any 'tcp port 9153'

# 请求端
# SYN 0
 00:00:00.000000 IP (tos 0x0, ttl 64, id 42199, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0xd480), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19658463 ecr 0,nop,wscale 9], length 0
# SYN 1
 00:00:01.000549 IP (tos 0x0, ttl 64, id 42200, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0xd097), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19659464 ecr 0,nop,wscale 9], length 0
# SYN 2
 00:00:03.005510 IP (tos 0x0, ttl 64, id 42201, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0xc8c2), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19661469 ecr 0,nop,wscale 9], length 0
# SYN 3
 00:00:07.008579 IP (tos 0x0, ttl 64, id 42202, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0xb91f), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19665472 ecr 0,nop,wscale 9], length 0
# SYN 4
 00:00:15.024516 IP (tos 0x0, ttl 64, id 42203, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0x99cf), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19673488 ecr 0,nop,wscale 9], length 0
# SYN 5
 00:00:31.072562 IP (tos 0x0, ttl 64, id 42204, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0x5b1f), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19689536 ecr 0,nop,wscale 9], length 0
# SYN 6   63秒
 00:01:03.136526 IP (tos 0x0, ttl 64, id 42205, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5a6c (incorrect -&gt; 0xddde), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19721600 ecr 0,nop,wscale 9], length 0
# SYN+ACK 通讯建立
 00:01:03.137188 IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.2.3.9153 &gt; 172.29.0.0.40233: Flags [S.], cksum 0xbdbe (correct), seq 4208932479, ack 1769165321, win 27960, options [mss 1410,sackOK,TS val 19735883 ecr 19721600,nop,wscale 9], length 0


# 服务端

# 这个报文在63秒后才收到
# SYN 6
 00:00:00.000000 IP (tos 0x0, ttl 64, id 42205, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xddde (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19721600 ecr 0,nop,wscale 9], length 0
 00:00:00.000025 IP (tos 0x0, ttl 63, id 42205, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xddde (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19721600 ecr 0,nop,wscale 9], length 0
 00:00:00.000065 IP (tos 0x0, ttl 64, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.2.3.9153 &gt; 172.29.0.0.40233: Flags [S.], cksum 0x5a6c (incorrect -&gt; 0xbdbe), seq 4208932479, ack 1769165321, win 27960, options [mss 1410,sackOK,TS val 19735883 ecr 19721600,nop,wscale 9], length 0</pre>
<p>可以注意到：</p>
<ol>
<li>当Service负载均衡到本机的Pod时畅通，负载均衡到其它节点的Pod时卡住。这就是50%卡住的原因</li>
<li>并非彻底卡死，在63秒后，SYN成功</li>
</ol>
<div class="blog_h2"><span class="graybg">iptables规则</span></div>
<p>从上面的抓包结果分析，我们初步判断故障和iptables没有关系。iptables导致的问题可能是无限卡死直到超时（静默的丢弃了报文）、ICMP错误、TCP RST等，通常不会出现过了一段时间自动恢复的情况。</p>
<p>然后，这个故障很特别，它的确是由iptables规则所触发的。我们是后来查找资料才发现的这一事实，这里先列出相关的规则。其中PREROUTING阶段的规则如下：</p>
<pre class="crayon-plain-tag"># iptables -L -n -v -t nat

Chain PREROUTING (policy ACCEPT 1 packets, 60 bytes)
 pkts bytes target                prot opt in  out  source     destination         
# 所有封包都要这经过这个链
# kubernetes service portals
46185 2817K KUBE-SERVICES         all  --  *   *    0.0.0.0/0  0.0.0.0/0            

Chain KUBE-SERVICES (2 references)
 pkts bytes target                prot opt in  out  source      destination         
# 这些会匹配ClusterIP，和本场景无关
# kube-system/kube-dns:metrics cluster IP 
# 0  0 KUBE-SVC-JD5MR3NA4I4DYORP  tcp  --  *  *     0.0.0.0/0  172.29.255.10  tcp dpt:9153
#kube-system/kube-dns-nodeport:metrics cluster IP                   
# 0  0 KUBE-SVC-CZA6AQQ7F4S64XIF  tcp  --  *  *     0.0.0.0/0  172.29.255.56  tcp dpt:9153
# default/kubernetes:https cluster IP                             
# 0  0 KUBE-SVC-NPX46M4PTMTKRN6Y  tcp  --  *  *     0.0.0.0/0  172.29.255.1   tcp dpt:443
# kube-system/kube-dns:dns cluster IP                           
# 0  0 KUBE-SVC-TCOU7JCQXEZGVUNU  udp  --  *  *     0.0.0.0/0  172.29.255.10  udp dpt:53
#kube-system/kube-dns:dns-tcp cluster IP                         
# 0  0 KUBE-SVC-ERIFXISQEP7F7OF4  tcp  --  *  *     0.0.0.0/0  172.29.255.10  tcp dpt:53
# 不是访问ClusterIP的、目的地址是本机绑定地址的封包，都要经过这个链
# kubernetes service nodeports; NOTE: this must be the last rule in this chain 
  678 40680 KUBE-NODEPORTS        all  --  *  *     0.0.0.0/0  0.0.0.0/0  ADDRTYPE match dst-type LOCAL


Chain KUBE-NODEPORTS (1 references)
 pkts bytes target                prot opt in out   source     destination         
# 匹配本场景（目标端口30153），会给封包打标记，因此不会终止规则链遍历
# kube-system/kube-dns-nodeport:metrics 
    0     0 KUBE-MARK-MASQ        tcp  --  *  *     0.0.0.0/0  0.0.0.0/0  tcp dpt:30153
# 匹配本场景（目标端口30153），跳转到NodePort的目标服务的专属规则链
# kube-system/kube-dns-nodeport:metrics 
    0     0 KUBE-SVC-CZAXXX       tcp  --  *  *     0.0.0.0/0  0.0.0.0/0  tcp dpt:30153


Chain KUBE-MARK-MASQ (8 references)
 pkts bytes target                prot opt in out   source     destination         
# 封包会被打上 0x4000标记
    0     0 MARK                  all  --  *  *     0.0.0.0/0  0.0.0.0/0  MARK or 0x4000


# 这个是NodePort的目标服务的专属规则链，随机转发给某个服务端点
Chain KUBE-SVC-CZAXXX (2 references)
 pkts bytes target                prot opt in out   source     destination    
# kube-system/kube-dns-nodeport:metrics     
    0     0 KUBE-SEP-DZXXXX       all  --  *  *     0.0.0.0/0  0.0.0.0/0  statistic mode random probability 0.50000000000
# kube-system/kube-dns-nodeport:metrics 
    0     0 KUBE-SEP-COSXXX       all  --  *  *     0.0.0.0/0  0.0.0.0/0           


# 这是NodePort服务的某个端点的专属规则链
Chain KUBE-SEP-DZXXXX (1 references)
 pkts bytes target                prot opt in out   source     destination         
# kube-system/kube-dns-nodeport:metrics
    0     0 KUBE-MARK-MASQ        all  --  *  *     172.29.2.3 0.0.0.0/0            
# 匹配本场景，进行DNAT，将目的地址从本机地址转为服务端点地址，如果端点不在本机，报文会从flannel.1接口发出
# kube-system/kube-dns-nodeport:metrics 
    0     0 DNAT                  tcp  --  *  *     0.0.0.0/0  0.0.0.0/0  tcp to:172.29.2.3:9153</pre>
<p>我们可以看到，如果服务端点不在本机，发往localhost:30153的封包，会被先打上0x4000标记，然后DNAT到服务端点的IP:PORT（例如172.29.2.3:9153），这会保证封包从flannel.1发出。</p>
<p>POSTROUTING阶段的规则如下：</p>
<pre class="crayon-plain-tag">Chain POSTROUTING (policy ACCEPT 2 packets, 120 bytes)
pkts bytes target   prot opt in  out  source     destination         
# kubernetes postrouting rules
83159 5015K KUBE-POSTROUTING all  --  *   *    0.0.0.0/0  0.0.0.0/0            

Chain KUBE-POSTROUTING (1 references)
pkts bytes target prot opt in out source destination   
# kubernetes service traffic requiring SNAT      
0   0 MASQUERADE  all  --  *  *  0.0.0.0/0 0.0.0.0/0  mark match 0x4000/0x4000 random-fully</pre>
<p>可以看到，这里做了SNAT，任何具有0x4000标记的封包，都被SNAT，确保使用flannel.1的地址作为源IP。</p>
<div class="blog_h2"><span class="graybg">63秒现象</span></div>
<p>经过反复测试， 发现卡住时，总是会消耗63秒左右，然后接收到响应。</p>
<p>63秒这个数字，和TCP默认的SYN重试机制有关。SYN如果没有收到ACK，发送端会自动重发SYN，每次重试的延迟时间指数增长，依次为1, 2, 4, 8, 16, 32，这会引发合计63秒的总延迟。</p>
<p>令人费解的是，为什么63秒之后，不是超时，而是连接成功？</p>
<div class="blog_h2"><span class="graybg">外层封包</span></div>
<p>从上文抓取的TCP封包看，服务端的Pod网卡没有收到前面6次SYN，这些封包应该在链路的某个位置被丢弃了。</p>
<p>在VXLAN模式下，上面抓的TCP封包，会封装在UDP报文中，并通过节点物理网卡的8472端口发出。我们从外层报文的角度分析一下</p>
<pre class="crayon-plain-tag"># tcpdump -ttttt -n -v -i eth0 'udp port 8472'
# 畅通时，没有输出，因为访问本机的Pod时不走VXLAN

# 卡住时，请求端封包
 00:00:00.000000 IP (tos 0x0, ttl 64, id 43516, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42199, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xd480 (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19658463 ecr 0,nop,wscale 9], length 0
 00:00:01.000542 IP (tos 0x0, ttl 64, id 44011, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42200, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xd097 (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19659464 ecr 0,nop,wscale 9], length 0
 00:00:03.005505 IP (tos 0x0, ttl 64, id 45443, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42201, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xc8c2 (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19661469 ecr 0,nop,wscale 9], length 0
 00:00:07.008579 IP (tos 0x0, ttl 64, id 46574, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42202, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xb91f (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19665472 ecr 0,nop,wscale 9], length 0
 00:00:15.024518 IP (tos 0x0, ttl 64, id 50068, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42203, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x99cf (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19673488 ecr 0,nop,wscale 9], length 0
 00:00:31.072564 IP (tos 0x0, ttl 64, id 65085, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42204, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5b1f (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19689536 ecr 0,nop,wscale 9], length 0
 00:01:03.136538 IP (tos 0x0, ttl 64, id 19809, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.50024 &gt; 10.0.0.13.8472: [no cksum] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42205, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xddde (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19721600 ecr 0,nop,wscale 9], length 0
 00:01:03.137105 IP (tos 0x0, ttl 64, id 63229, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.13.50017 &gt; 10.0.0.11.8472: [no cksum] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.2.3.9153 &gt; 172.29.0.0.40233: Flags [S.], cksum 0xbdbe (correct), seq 4208932479, ack 1769165321, win 27960, options [mss 1410,sackOK,TS val 19735883 ecr 19721600,nop,wscale 9], length 0


# 卡住时，服务端封包
# SYN 0
 00:00:00.000000 IP (tos 0x0, ttl 64, id 43516, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42199, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xd480 (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19658463 ecr 0,nop,wscale 9], length 0
# SYN 1
 00:00:01.000543 IP (tos 0x0, ttl 64, id 44011, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42200, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xd097 (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19659464 ecr 0,nop,wscale 9], length 0
# SYN 2
 00:00:03.005514 IP (tos 0x0, ttl 64, id 45443, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42201, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xc8c2 (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19661469 ecr 0,nop,wscale 9], length 0
# SYN 3
 00:00:07.008577 IP (tos 0x0, ttl 64, id 46574, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42202, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xb91f (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19665472 ecr 0,nop,wscale 9], length 0
# SYN 4
 00:00:15.024575 IP (tos 0x0, ttl 64, id 50068, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42203, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x99cf (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19673488 ecr 0,nop,wscale 9], length 0
# SYN 5
 00:00:31.072593 IP (tos 0x0, ttl 64, id 65085, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.60142 &gt; 10.0.0.13.8472: [bad udp cksum 0xffff -&gt; 0x4b80!] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42204, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0x5b1f (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19689536 ecr 0,nop,wscale 9], length 0
# SYN 6 63秒，可以看到这次没有UDP封包没有chksum了，服务端也收到SYN了
 00:01:03.136659 IP (tos 0x0, ttl 64, id 19809, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.11.50024 &gt; 10.0.0.13.8472: [no cksum] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 64, id 42205, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.0.0.40233 &gt; 172.29.2.3.9153: Flags [S], cksum 0xddde (correct), seq 1769165320, win 43690, options [mss 65495,sackOK,TS val 19721600 ecr 0,nop,wscale 9], length 0
 00:01:03.136830 IP (tos 0x0, ttl 64, id 63229, offset 0, flags [none], proto UDP (17), length 110)
    10.0.0.13.50017 &gt; 10.0.0.11.8472: [no cksum] OTV, flags [I] (0x08), overlay 0, instance 1
IP (tos 0x0, ttl 63, id 0, offset 0, flags [DF], proto TCP (6), length 60)
    172.29.2.3.9153 &gt; 172.29.0.0.40233: Flags [S.], cksum 0xbdbe (correct), seq 4208932479, ack 1769165321, win 27960, options [mss 1410,sackOK,TS val 19735883 ecr 19721600,nop,wscale 9], length 0</pre>
<p>可以看到，请求端/服务端的UDP报文相互呼应， 至少可以说，请求端的全部报文都送到了服务端。</p>
<p>但是，前面5次重试的UDP报文都被标注了bad udp cksum，最后一次UDP报文没有chksum，连接成功建立。有理由怀疑，故障和chksum有关系。</p>
<p>通过查阅<a href="https://tools.ietf.org/html/rfc7348">VXLAN的RFC</a>，在VXLAN Frame Format一章中，关于UDP封包的Checksum，具有如下说明：</p>
<p style="padding-left: 30px;">UDP Checksum应该以零传递。接收端接收到零Checksum的UDP包后，它必须接受，用于解包（decapsulation）。但是，如果发送端的确提供了非零Checksum，那么它必须是正确的、基于整个封包进行计算的 —— 包括IP头、UDP头、VXLAN头，以及最里层的MAC帧。接收端可以对非零Checksum进行校验，或者不去校验。但是，如果进行了校验，且校验结果不正确，则必须丢弃UDP封包</p>
<p>RFC说的很明确，如果Checksum是错误的，并且进行了校验，则封包会被丢弃。带入我们的场景中，可以推测，服务端内核丢弃了那些bad udp cksum的封包，因而服务端的Pod网卡一直没有收到SYN。</p>
<p>那么，Checksum为什么会错了呢？根源应该在内核。</p>
<div class="blog_h2"><span class="graybg">内核缺陷</span></div>
<p>现代操作系统都支持某些形式的Network Offloading，将某些工作委托给网卡完成，从而减轻CPU的负担。从内核代码的演变情况来看，这种Offloading的种类越来越丰富。</p>
<p>Checksum就可以Offload给网卡来完成，这样，IP、TCP和UDP的Checksum，会在报文即将从网络接口发送出去的时候进行计算。Offloading需要内核的TCP/IP栈、设备驱动、硬件正确的配合才能完成。</p>
<p>通过查阅资料，我们了解到，内核中存在一个和VXLAN处理有关的缺陷，该缺陷会导致Checksum Offloading不能正确完成。这个缺陷仅仅在很边缘的场景下才会表现出来。</p>
<p>在VXLAN的UDP头被NAT过（见下文的二次SNAT问题）的前提下，如果：</p>
<ol>
<li>VXLAN设备禁用（这是RFC的建议）了UDP Checksum</li>
<li>VXLAN设备启用了Tx Checksum Offloading</li>
</ol>
<p>就会导致生成错误的UDP Checksum。</p>
<div class="blog_h2"><span class="graybg">二次SNAT</span></div>
<p>前面提到内核缺陷必须在VXLAN的UDP封包被NAT时，才会触发。那么，在源、目标地址都是宿主机网段的情况下，为什么还对UDP封包进行NAT呢？</p>
<p>在上文的iptables分析中我们看到，访问localhost:30153的封包，会被：</p>
<ol>
<li>DNAT到服务端Pod的地址，这保证封包能够通过flannel.1发出</li>
<li>打上0x4000标记，这个标记会在随后的POSTROUTING阶段，用于进行SNAT。使用flannel.1的地址作为源地址</li>
</ol>
<p>被DNAT+SNAT后的内层TCP报文，进入flannel.1接口，进而在内核的VXLAN驱动中处理，封装为UDP报文。需要注意，iptables打标记，我们期望是针对内层报文的。然而，内层封包被VXLAN处理后包裹了外层UDP，重新进入网络栈，内核自动将0x4000标记关联到外层UDP报文上，这导致了额外的一次SNAT：</p>
<pre class="crayon-plain-tag">iptables -t nat -I  KUBE-POSTROUTING 1 -j LOG --log-prefix "0x4000-marked: " -m mark --mark 0x4000/0x4000

dmesg -wH

# 第一次NAT，针对内层报文，我们期望将127.0.0.1 SNAT为 flannel.1的地址
[  +3.851027] 0x4000-marked: IN= OUT=flannel.1 SRC=127.0.0.1 DST=172.29.2.3 LEN=60 TOS=0x00 PREC=0x00 TTL=64 ID=44704 DF PROTO=TCP SPT=43326 DPT=9153 WINDOW=43690 RES=0x00 SYN URGP=0 MARK=0x4000 
# 第二次NAT，针对外层报文，我们并没有期望这次SNAT，因为源地址本来就是eth0的地址了
[  +0.000019] 0x4000-marked: IN= OUT=eth0 SRC=10.0.0.12 DST=10.0.0.11 LEN=110 TOS=0x00 PREC=0x00 TTL=64 ID=9697 PROTO=UDP SPT=60211 DPT=8472 LEN=90 MARK=0x4000</pre>
<p>在<a href="https://github.com/kubernetes/kubernetes/commit/d86d1defa1e619b60031d173ed401b00a2d8957f">Kubernetes 1.16.0</a>之前的版本，Kube Proxy做SNAT（<pre class="crayon-plain-tag">-j MASQUERADE</pre>）时，没有使用<pre class="crayon-plain-tag">--random-fully</pre>参数。这意味着第二次SNAT不会有任何效果，因为内核会在Masquerading时尝试保持源端口不变，与此同时，源端口已经是期望的地址了。</p>
<p>但是，使用了--random-fully参数后，情况变得不同。该参数会强制的进行随机的源端口映射。这就触发了上文提到的内核缺陷。</p>
<div class="blog_h2"><span class="graybg">random-fully</span></div>
<p>这是SNAT目标的一个参数，它会使用伪随机数生成器，自动产生一个端口，来替换NAT前的端口。根据文档，它需要内核3.14+才能支持。</p>
<p>然而，我们用的是CentOS 7，内核版本是 3.10.0-1127.13.1.el7.x86_64，照理说应该不支持这个特性。</p>
<p>在宿主机上，用iptables-save导出规则，也是看不到--random-fully的。但是，从Kube Proxy容器里面导出规则，却能看见：</p>
<pre class="crayon-plain-tag"># iptables-save | egrep '\-A\sKUBE-POSTROUTING'
-A KUBE-POSTROUTING  -m mark --mark 0x4000/0x4000 -j MASQUERADE

# kubectl -n kube-system exec kube-proxy-7qtzm -- iptables-save | egrep '\-A\sKUBE-POSTROUTING'
-A KUBE-POSTROUTING  -m mark --mark 0x4000/0x4000 -j MASQUERADE --random-fully</pre>
<p>原因可能是两个iptables的版本不同。有一点可以明确，--random-fully在我们的环境下的确产生了影响，因为禁用该参数后，问题就消失了。</p>
<div class="blog_h1"><span class="graybg">解决</span></div>
<div class="blog_h2"><span class="graybg">临时方案</span></div>
<div class="blog_h3"><span class="graybg">关闭Offloading</span></div>
<p>既然故障的根源是内核中，和Offloading有关的缺陷，因此，禁用Offloading是最直接的手段：</p>
<pre class="crayon-plain-tag">ethtool --offload flannel.1 rx off tx off</pre>
<p>这个命令执行的时机很重要，如果主机重启，Flannel创建网卡后，才能执行该命令，否则会提示找不到设备。</p>
<div class="blog_h3"><span class="graybg">防止二次SNAT</span></div>
<p>有两种方式防止对VXLAN的UDP封包进行SNAT。第一种是禁用--random-fully参数。这种做法印证了上文关于此参数的猜测。</p>
<pre class="crayon-plain-tag">iptables -t nat -R KUBE-POSTROUTING  1  -m mark --mark 0x4000/0x4000 -j MASQUERADE </pre>
<p>第二种，将发往8472端口的UDP封包，做一个重置标记的操作。Kubernetes社区就是这种做法。</p>
<pre class="crayon-plain-tag">iptables -A OUTPUT -p udp -m udp --dport 8472 -j MARK --set-mark 0x0  </pre>
<div class="blog_h2"><span class="graybg">永久方案</span></div>
<div class="blog_h3"><span class="graybg">升级K8S版本</span></div>
<p>查看Kubernetes v1.18.5的Changelog，可以发现PR <a href="https://github.com/kubernetes/kubernetes/pull/92035">92035</a>修复了这个故障。这个PR会在不需要0x4000标记时，将其清除。</p>
<p>在Kubelet初始化期间，会在NAT表创建KUBE-MARK-MASQ、KUBE-MARK-DROP、KUBE-POSTROUTING等链，并添加一些规则。该PR对这部分的逻辑进行了修改：</p>
<pre class="crayon-plain-tag">func (kl *Kubelet) syncNetworkUtil() {
	// ...
	if _, err := kl.iptClient.EnsureRule(utiliptables.Append, utiliptables.TableNAT,
	// 这里将原先有缺陷的--set-xmark 0x4000/0x4000 改为 --xor-mark 
      KubeMarkMasqChain, "-j", "MARK", "--or-mark", masqueradeMark); err != nil {
		klog.Errorf("Failed to ensure marking rule for %v: %v", KubeMarkMasqChain, err)
		return
	}
	// ...
	// 这里是关键的修改，在KUBE-POSTROUTING中添加以下规则：
	// 如果封包没有0x4000标记位，则不做处理
	// iptables -t NAT -A KUBE-POSTROUTING -m mark ! --mark=0x4000/0x4000 -j RETRUN
	if _, err := kl.iptClient.EnsureRule(utiliptables.Append, utiliptables.TableNAT, KubePostroutingChain,
		"-m", "mark", "!", "--mark", fmt.Sprintf("%s/%s", masqueradeMark, masqueradeMark),
		"-j", "RETURN"); err != nil {
		klog.Errorf("Failed to ensure filtering rule for %v: %v", KubePostroutingChain, err)
		return
	}
	// 否则，清除0x4000标记位，防止封包重新遍历网络栈时，被再次SNAT
	// 注意，在这里可以明确知道0x4000被设置，因此可以安全的用XOR将该位取消掉，不需要关心其它位
	// iptables -t NAT -A  KUBE-POSTROUTING  -j MARK --xor-mark=0x4000
	if _, err := kl.iptClient.EnsureRule(utiliptables.Append, utiliptables.TableNAT, KubePostroutingChain,
		"-j", "MARK", "--xor-mark", masqueradeMark); err != nil {
		klog.Errorf("Failed to ensure unmarking rule for %v: %v", KubePostroutingChain, err)
		return
	}
	// ...
}</pre>
<p>此外，该PR还对Kube Proxy的iptables/ipvs相关模块进行了类似修改，这里就不张贴代码了。</p>
<div class="blog_h3"><span class="graybg">升级内核</span></div>
<p>已知内核版本5.6.13, 5.4.41, 4.19.123, 4.14.181修复了上文提到的内核缺陷，但是CentOS 7何时修复未知，可能需要自行Patch。</p>
<div class="blog_h1"><span class="graybg">深入</span></div>
<div class="blog_h2"><span class="graybg">Checksum</span></div>
<p>所谓Checksum是一个固定长度的字段，网络协议使用该字段来纠正某些传输错误。</p>
<p>Checksum通常是基于某些报文字段来计算摘要信息，算法决定了Checksum的可靠性和计算成本。IP协议仅仅使用报文头，而大部分L4协议，同时使用报文头、报文体。</p>
<p>在IPv4（IPV6没有IP Checksum）中，IP Checksum是16bit字段，信息来自IP头所有字段。在任一跳发现Checksum错误，都会导致静默的丢弃，而不产生ICMP报文 —— L4协议需要考虑这种静默丢弃的可能并进行相应处理，例如TCP在ACK没有及时收到时会进行重传。</p>
<p>IP数据报在经过每一跳时，都需要更新Checksum，至少TTL的变化需要重新计算Checksum。除了TTL，IP头还可能因为以下原因变化：</p>
<ol>
<li>NAT导致的地址变化</li>
<li>IP选项处理</li>
<li>IP分片</li>
</ol>
<p>计算IP Checksum时，报文被分隔为16bit的小段，将这些小段相加并取反（ones-complemented），就得到最后的Checksum。在Linux中，可能分隔为32bit甚至64bit的小段，以提升计算速度，但是取反操作前需要一个额外的折叠（csum_fold）操作。</p>
<p>由于IP Checksum仅仅牵涉到报文头，成本很低，Linux总是在CPU中进行计算，不会Offload给硬件。</p>
<p>L4协议的Checksum牵涉完整报文，包括L4报文头、L4报文体、以及所谓的伪头（pseudoheader）。伪头其实就是IP头中的源地址、目的地址、以及之后的32bit。</p>
<p>IP层在NAT等场景下，需要对IP头进行变更，这会导致L4协议计算的Checksum失效。如果没有更新失效的Checksum，则在IP报文传输的每一跳都不会发现错误，因为中间路由仅仅会校验IP Checksum。结果就是，只有目的地内核才能在L4发现这一情况。我们可以了解到Checksum算法具有可逆性，因此NAT这样导致很少字段变化的情况下，更新Checksum不需要从头计算。</p>
<div class="blog_h2"><span class="graybg">Offloading</span></div>
<p>前面提到过，L4的Checksum计算涉及完整报文，成本较高。因此Linux支持将L4的Checksum委托给硬件完成，这就是Checksum Offloading。</p>
<p>设备能否支持Checksum Offloading，是通过<pre class="crayon-plain-tag">net_device-&gt;features</pre>标记传递给内核的：</p>
<ol>
<li>NETIF_F_HW_CSUM 驱动能够为任何协议组合、协议层计算IP Checksum</li>
<li>NETIF_F_IP_CSUM 驱动支持L4（仅限于TCP/UDP over IPv4）的Checksum计算</li>
<li>NETIF_F_IPV6_CSUM 驱动支持L4（仅限于TCP/UDP over IPv6）的Checksum计算</li>
<li>NETIF_F_NO_CSUM 表示设备明确知道不需要计算Checksum，通常用于loopback设备</li>
<li>NETIF_F_RXCSUM  驱动进行接收封包的Checksum Offloading，仅仅用于禁用设备的RX Checksum</li>
</ol>
<p><pre class="crayon-plain-tag">skb-&gt;ip_summed</pre>字段存放了Checksum的状态，其含义在接收封包、发送封包期间有所不同。</p>
<p>在接收封包期间：</p>
<ol>
<li>CHECKSUM_NONE 提示设备没有对封包进行Checksum校验，可能由于缺少相关特性</li>
<li>CHECKSUM_UNNECESSARY 提示内核不再需要对Checksum进行校验</li>
<li>CHECKSUM_COMPLETE 提示设备已经提供了完整的L4 Checksum，L4代码只需要加上伪头即可进行校验</li>
</ol>
<p>在发送封包期间：</p>
<ol>
<li>CHECKSUM_NONE 提示内核已经完全处理好Checksum了，设备不需要做任何事情</li>
<li>CHECKSUM_UNNECESSARY 意义和CHECKSUM_NONE相同</li>
<li>CHECKSUM_PARTIAL  提示内核已经完成伪头部分的Checksum，驱动必须计算从<pre class="crayon-plain-tag">skb-&gt;csum_start</pre>到封包结尾部分的Checksum，并且将其存放在<pre class="crayon-plain-tag">skb-&gt;csum_start + skb-&gt;csum_offset</pre>这个位置</li>
<li>CHECKSUM_COMPLETE  不使用</li>
</ol>
<p>可以看到，在发送封包时，如果skb-&gt;ip_summed的值为CHECKSUM_PARTIAL，则意味着内核要求驱动Checksum Offloading。</p>
<div class="blog_h2"><span class="graybg">内核缺陷</span></div>
<p>基于上面的认识，我们可以看一下本文牵涉到的内核缺陷到底是什么了：</p>
<pre class="crayon-plain-tag">// linux-3.10.y
static bool
udp_manip_pkt(struct sk_buff *skb,  // 当前操控的套接字缓冲
	      const struct nf_nat_l3proto *l3proto,  // 持有NAT操作相关的若干函数指针
	      unsigned int iphdroff, unsigned int hdroff,  // IP头、L4头的偏移量
	      const struct nf_conntrack_tuple *tuple,  // 连接跟踪相关的信息，新旧IP端口
	      enum nf_nat_manip_type maniptype)  // 是SNAT还是DNAT
{
	struct udphdr *hdr;
	__be16 *portptr, newport;

	if (!skb_make_writable(skb, hdroff + sizeof(*hdr)))
		return false;

	// 获得UDP头
	hdr = (struct udphdr *)(skb-&gt;data + hdroff);

	if (maniptype == NF_NAT_MANIP_SRC) {
		// NAT后的源端口
		newport = tuple-&gt;src.u.udp.port;
		// NAT前的源端口
		portptr = &amp;hdr-&gt;source;
	} else {
		/* Get rid of dst port */
		newport = tuple-&gt;dst.u.udp.port;
		portptr = &amp;hdr-&gt;dest;
	}
	// 如果Checksum不为零， 或者 开启了Offloading，则更新Checksum
	if (hdr-&gt;check || skb-&gt;ip_summed == CHECKSUM_PARTIAL) {

		//       这里调用的是 nf_nat_ipv4_csum_update
		l3proto-&gt;csum_update(skb, iphdroff, &amp;hdr-&gt;check,
				     tuple, maniptype);
		inet_proto_csum_replace2(&amp;hdr-&gt;check, skb, *portptr, newport,
					 0);
		if (!hdr-&gt;check)
			hdr-&gt;check = CSUM_MANGLED_0;
	}
	*portptr = newport;
	return true;
}

static void nf_nat_ipv4_csum_update(struct sk_buff *skb,
				    unsigned int iphdroff, __sum16 *check,
				    const struct nf_conntrack_tuple *t,
				    enum nf_nat_manip_type maniptype)
{
	struct iphdr *iph = (struct iphdr *)(skb-&gt;data + iphdroff);
	__be32 oldip, newip;

	if (maniptype == NF_NAT_MANIP_SRC) {
		oldip = iph-&gt;saddr;
		newip = t-&gt;src.u3.ip;
	} else {
		oldip = iph-&gt;daddr;
		newip = t-&gt;dst.u3.ip;
	}
	// 这里传入了无效的Checksum
	inet_proto_csum_replace4(check, skb, oldip, newip, 1);
}

void inet_proto_csum_replace4(__sum16 *sum, struct sk_buff *skb,
			      __be32 from, __be32 to, int pseudohdr)
{
	__be32 diff[] = { ~from, to };
	if (skb-&gt;ip_summed != CHECKSUM_PARTIAL) {
		*sum = csum_fold(csum_partial(diff, sizeof(diff),
				~csum_unfold(*sum)));
		if (skb-&gt;ip_summed == CHECKSUM_COMPLETE &amp;&amp; pseudohdr)
			skb-&gt;csum = ~csum_partial(diff, sizeof(diff),
						~skb-&gt;csum);
	} else if (pseudohdr)
		// 走这个分支，可以看到，更新Checksum依赖于先前的Checksum是正确值
		*sum = ~csum_fold(csum_partial(diff, sizeof(diff), csum_unfold(*sum)));
}</pre>
<p>当VXLAN端点的UDP被NAT的情况下，上述代码会执行。如果 VXLAN设备禁用了UDP Checksum，它会将udphdr-&gt;check置零。如果同时VXLAN设备还启用了Tx Checksum Offloading，skb-&gt;ip_summed的值就会是CHECKSUM_PARTIAL。这就是我们环境下的配置。</p>
<p>UDP Checksum被禁用情况下，udphdr-&gt;check是个零值，显然没有包含旧的伪头的Checksum信息，因为通过伪头计算的Checksum，至少协议类型部分（UDP 0x11）是非零。</p>
<p>因此，判断是否需要更新Checksum，应当只VXLAN接口是否禁用了UDP Checksum，<a href="https://github.com/torvalds/linux/commit/ea64d8d6c675c0bb712689b13810301de9d8f77a">禁用了就不应该更新</a>。</p>
<div class="blog_h1"><span class="graybg">参考</span></div>
<ol>
<li><a href="https://github.com/projectcalico/calico/issues/3145">TCP offloading on VXLAN.calico adaptor causing 63 second delays in VXLAN communications node-&gt;nodeport or node-&gt;clusterip:port</a></li>
<li><a href="https://github.com/torvalds/linux/commit/ea64d8d6c675c0bb712689b13810301de9d8f77a">netfilter: nat: never update the UDP checksum when it's 0 </a></li>
<li><a href="https://github.com/kubernetes/kubernetes/pull/92035">kubelet, kube-proxy: unmark packets before masquerading them</a></li>
</ol>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/nodeport-63s-delay-due-to-kernel-issue">内核缺陷触发的NodePort服务63秒延迟问题</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/nodeport-63s-delay-due-to-kernel-issue/feed</wfw:commentRss>
		<slash:comments>3</slash:comments>
		</item>
		<item>
		<title>Galaxy学习笔记</title>
		<link>https://blog.gmem.cc/galaxy-study-note</link>
		<comments>https://blog.gmem.cc/galaxy-study-note#comments</comments>
		<pubDate>Sat, 01 Aug 2020 11:28:21 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[CNI]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=36521</guid>
		<description><![CDATA[<p>简介 Galaxy是TKEStack的一个网络组件，支持为TKE集群提供Overlay/Underlay容器网络。Galaxy的一个特性是能够提供浮动IP（弹性IP） —— 即使Pod因为节点宕机而漂移到其它节点，其IP地址也能够保持不变。 网络类型 Galaxy提供四类网络，支持为每个工作负载单独配置网络模式。 Overlay网络 默认模式，基于Flannel的VXLAN或者Host Gateway（路由）方案。同节点容器通信不走网桥，报文直接利用主机路由转发，跨节点容器通信利用VXLAN协议封装或者直接路由，节点间路由记录在Etcd中。优点：简单可靠，性能不错，支持网络策略。 tke-installer默认安装的TKEStack会自动配置Galaxy为Overlay模式，CNI配置： [crayon-69de136ba1b11260789123/] 在该模式下： Flannel在每个Kubelet节点上分配一个子网，并将其保存在etcd和本地路径[crayon-69de136ba1b17734025623-i/] Kubelet调用Galaxy的CNI插件galaxy-sdn，该插件会通过UDS调用本机的Galaxy进程 Galaxy进程则又调用Flannel CNI，解析/run/flannel/subnet.env中的子网信息 Flannel CNI会调用： Bridge CNI或Veth CNI来为POD配置网络 调用host-lo <a class="read-more" href="https://blog.gmem.cc/galaxy-study-note">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/galaxy-study-note">Galaxy学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">简介</span></div>
<p>Galaxy是TKEStack的一个网络组件，支持为TKE集群提供Overlay/Underlay容器网络。Galaxy的一个特性是能够提供浮动IP（弹性IP） —— 即使Pod因为节点宕机而漂移到其它节点，其IP地址也能够保持不变。</p>
<div class="blog_h2"><span class="graybg">网络类型</span></div>
<p>Galaxy提供四类网络，支持为每个工作负载单独配置网络模式。</p>
<div class="blog_h3"><span class="graybg">Overlay网络</span></div>
<p>默认模式，基于Flannel的VXLAN或者Host Gateway（路由）方案。<span style="background-color: #c0c0c0;">同节点容器通信不走网桥，报文直接利用主机路由转发</span>，<span style="background-color: #c0c0c0;">跨节点容器通信利用VXLAN协议封装或者直接路由</span>，<span style="background-color: #c0c0c0;">节点间路由记录在Etcd</span>中。优点：简单可靠，性能不错，支持网络策略。</p>
<p>tke-installer默认安装的TKEStack会自动配置Galaxy为Overlay模式，CNI配置：</p>
<pre class="crayon-plain-tag">{
  "type": "galaxy-sdn",
  "capabilities": {"portMappings": true},
  "cniVersion": "0.2.0"
} </pre>
<p>在该模式下：</p>
<ol>
<li>Flannel在每个Kubelet节点上分配一个子网，并将其保存在etcd和本地路径<pre class="crayon-plain-tag">/run/flannel/subnet.env</pre></li>
<li>Kubelet调用Galaxy的CNI插件galaxy-sdn，该插件会通过UDS调用本机的Galaxy进程</li>
<li>Galaxy进程则又调用Flannel CNI，解析/run/flannel/subnet.env中的子网信息</li>
<li>Flannel CNI会调用：
<ol>
<li>Bridge CNI或Veth CNI来为POD配置网络</li>
<li>调用host-lo</li>
</ol>
</li>
</ol>
<p>架构图：</p>
<p><a href="https://cdn.gmem.cc/wp-content/uploads/2020/07/galaxy-overlay.png"><img class="wp-image-33845 aligncenter" src="https://cdn.gmem.cc/wp-content/uploads/2020/07/galaxy-overlay.png" alt="galaxy-overlay" width="1013" height="455" /></a></p>
<div class="blog_h3"><span class="graybg">Underlay网络</span></div>
<p>该模式下，容器IP由宿主机网络提供，容器与宿主机可以直接路由，性能更好。<span style="background-color: #c0c0c0;">支持基于Linux Bridge/MacVlan/IPVlan和SRIOV的容器-宿主机二层联通</span>，可以根据业务场景和硬件环境，具体选择使用哪种网桥。</p>
<p>要使用Galaxy Underlay网络，需要启用Galaxy-ipam组件，该组件为Pod分配IP，浮动IP的能力也由它提供。</p>
<div class="blog_h3"><span class="graybg">NAT</span></div>
<p>利用K8S的hostPort配置，将容器端口映射到宿主机端口。如果不指定hostPort，Galaxy进行随机映射。</p>
<div class="blog_h3"><span class="graybg">Host</span></div>
<p>利用K8S的HostNetwork模式，直接使用物理网络。</p>
<div class="blog_h2"><span class="graybg">架构</span></div>
<p>Galaxy有三类组件构成。</p>
<div class="blog_h3"><span class="graybg">Galaxy</span></div>
<p>以DaemonSet方式运行在每个节点上，通过调用各种CNI插件来配置容器网络。</p>
<div class="blog_h3"><span class="graybg">CNI插件</span></div>
<p>Galaxy将实际的创建CNI的工作委托给其它CNI插件，也就是说它扮演一个装饰器的角色。Galaxy支持任何标准CNI插件，它也内置了若干CNI插件。</p>
<div class="blog_h3"><span class="graybg">Galaxy IPAM</span></div>
<p>是一个K8S Scheduler插件。kube-scheduler通过HTTP调用Galaxy-ipam，实现浮动IP的配置和管理。</p>
<p>因为仅仅在重新调度Pod的时候才会面临IP地址变化的问题，因此这个组件实现为Scheduler插件就很自然了。</p>
<div class="blog_h2"><span class="graybg">支持的CNI列表</span></div>
<p>Galaxy支持任何标准的CNI插件，你可以将其用作一个CNI框架，就像<a href="https://github.com/intel/multus-cni">multus-cni</a>那样。</p>
<p>Galaxy还包含了一系列内置的CNI插件：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 20%; text-align: center;">插件</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>SDN CNI</td>
<td>使用Overlay网络时，此插件是CNI的入口。该插件会通过UDS调用Galaxy守护进程，转发Kubelet提供的所有CNI参数</td>
</tr>
<tr>
<td>Veth CNI</td>
<td>用于Overlay网络中，创建一个VETH对来连接容器和宿主机命名空间。该插件从ipam插件得到Pod的IP</td>
</tr>
<tr>
<td>underlay-veth CNI</td>
<td>用于Underlay网络，创建VETH对来连接容器和宿主机命名空间，该插件不会创建网桥，而是使用宿主机路由规则来进行封包转发</td>
</tr>
<tr>
<td>Vlan CNI</td>
<td>
<p>用于Underlay网络，创建一个VETH对，连接容器和宿主机上的bridge/macvlan/ipvlan设备</p>
<p>此插件支持为Pod配置VLAN，它能够从CNI参数，例如ipinfos=[{"ip":"192.168.0.68/26","vlan":2,"gateway":"192.168.0.65"}] ，或者ipam CNI插件的结果中获得IP地址</p>
</td>
</tr>
<tr>
<td>SRIOV CNI</td>
<td>用于Underlay网络，利用以太网服务器适配器（Ethernet Server Adapter）的SR-IOV，能够创建VF设备并将其放入容器的网络命名空间</td>
</tr>
<tr>
<td>TKE route ENI CNI</td>
<td>用于腾讯云</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">基础</span></div>
<div class="blog_h2"><span class="graybg">构建Galaxy</span></div>
<div class="blog_h3"><span class="graybg">二进制文件</span></div>
<pre class="crayon-plain-tag">go get -d tkestack.io/galaxy
cd $GOPATH/src/tkestack.io/galaxy

# 构建所有二进制文件
make
# 构建指定二进制文件
make BINS="galxy galxy-ipam"
make BINS="galxy-ipam"</pre>
<div class="blog_h3"><span class="graybg">镜像</span></div>
<pre class="crayon-plain-tag"># 构建所有镜像
make image

# 构建指定镜像
make image BINS="galxy-ipam"

# 为指定体系结构构建
make image.multiarch PLATFORMS="linux_arm64" </pre>
<div class="blog_h2"><span class="graybg">使用Galaxy</span></div>
<p>清单文件可以<a href="https://raw.githubusercontent.com/tkestack/galaxy/master/yaml/galaxy.yaml">到GitHub下载</a>，默认内容如下：</p>
<pre class="crayon-plain-tag">---
apiVersion: rbac.authorization.k8s.io/v1
# kubernetes versions before 1.8.0 should use rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  name: galaxy
rules:
- apiGroups: [""]
  resources:
  - pods
  - namespaces
  - nodes
  - pods/binding
  verbs: ["list", "watch", "get", "patch", "create", "update"]
- apiGroups: ["apps", "extensions"]
  resources:
  - statefulsets
  - deployments
  verbs: ["list", "watch"]
- apiGroups: [""]
  resources:
  - configmaps
  - endpoints
  - events
  verbs: ["get", "list", "watch", "update", "create", "patch"]
- apiGroups: ["galaxy.k8s.io"]
  resources:
  - pools
  - floatingips
  verbs: ["get", "list", "watch", "update", "create", "patch", "delete"]
- apiGroups: ["apiextensions.k8s.io"]
  resources:
  - customresourcedefinitions
  verbs:
  - "*"
- apiGroups: ["networking.k8s.io"]
  resources:
  - networkpolicies
  verbs: ["get", "list", "watch"]
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: galaxy
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
# kubernetes versions before 1.8.0 should use rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: galaxy
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: galaxy
subjects:
  - kind: ServiceAccount
    name: galaxy
    namespace: kube-system
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
  labels:
    app: galaxy
  name: galaxy
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: galaxy
  template:
    metadata:
      labels:
        app: galaxy
    spec:
      priorityClassName: system-node-critical
      serviceAccountName: galaxy
      hostNetwork: true
      hostPID: true
      containers:
      - image: tkestack/galaxy:v1.0.7
        command: ["/bin/sh"]
# 入口点脚本：
#   拷贝来自ConfigMap的Galaxy配置到宿主机
#   cp -p /etc/galaxy/cni/00-galaxy.conf /etc/cni/net.d/; 
#   拷贝CNI插件二进制文件到宿主机
#   cp -p /opt/cni/galaxy/bin/galaxy-sdn /opt/cni/galaxy/bin/loopback /opt/cni/bin/; 
#   启动Galaxy守护进程                        腾讯云中运行要这个参数
#   /usr/bin/galaxy --logtostderr=true --v=3 --route-eni
      # qcloud galaxy should run with --route-eni
        args: ["-c", "cp -p /etc/galaxy/cni/00-galaxy.conf /etc/cni/net.d/; cp -p /opt/cni/galaxy/bin/galaxy-sdn /opt/cni/galaxy/bin/loopback /opt/cni/bin/; /usr/bin/galaxy --logtostderr=true --v=3 --route-eni"]
      # private-cloud should run without --route-eni
      # args: ["-c", "cp -p /etc/galaxy/cni/00-galaxy.conf /etc/cni/net.d/; cp -p /opt/cni/galaxy/bin/galaxy-sdn /opt/cni/galaxy/bin/loopback /opt/cni/bin/; /usr/bin/galaxy --logtostderr=true --v=3"]
        imagePullPolicy: Always
        env:
          - name: MY_NODE_NAME
            valueFrom:
              fieldRef:
                fieldPath: spec.nodeName
          - name: DOCKER_HOST
            value: unix:///host/run/docker.sock
        name: galaxy
        resources:
          requests:
            cpu: 100m
            memory: 200Mi
        securityContext:
          privileged: true
        volumeMounts:
        - name: galaxy-run
          mountPath: /var/run/galaxy/
        - name: flannel-run
          mountPath: /run/flannel
        - name: galaxy-etc
          mountPath: /etc/galaxy
        - name: cni-config
          mountPath: /etc/cni/net.d/
        - name: cni-bin
          mountPath: /opt/cni/bin
        - name: cni-etc
          mountPath: /etc/galaxy/cni
        - name: cni-state
          mountPath: /var/lib/cni
        - name: docker-sock
          mountPath: /host/run/
        - name: tz-config
          mountPath: /etc/localtime
      terminationGracePeriodSeconds: 30
      tolerations:
      - operator: Exists
      volumes:
      - name: galaxy-run
        hostPath:
          path: /var/run/galaxy
      - name: flannel-run
        hostPath:
          path: /run/flannel
      - configMap:
          defaultMode: 420
          name: galaxy-etc
        name: galaxy-etc
      - name: cni-config
        hostPath:
          path: /etc/cni/net.d/
      - name: cni-bin
        hostPath:
          path: /opt/cni/bin
      - name: cni-state
        hostPath:
          path: /var/lib/cni
      - configMap:
          defaultMode: 420
          name: cni-etc
        name: cni-etc
      - name: docker-sock
        # in case of docker restart, /run/docker.sock may change, we have to mount the /run directory
        hostPath:
          path: /run/
      - name: tz-config
        hostPath:
          path: /etc/localtime
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: galaxy-etc
  namespace: kube-system
data:
  # Galaxy配置文件
  # update network card name in "galaxy-k8s-vlan" and "galaxy-k8s-sriov" if necessary
  # update vf_num in "galaxy-k8s-sriov" according to demand
  # update ENIIPNetwork to tke-route-eni if running on qcloud
  galaxy.json: |
    {
      "NetworkConf":[
        {"name":"tke-route-eni","type":"tke-route-eni","eni":"eth1","routeTable":1},
        {"name":"galaxy-flannel","type":"galaxy-flannel", "delegate":{"type":"galaxy-veth"},"subnetFile":"/run/flannel/subnet.env"},
        {"name":"galaxy-k8s-vlan","type":"galaxy-k8s-vlan", "device":"eth1", "default_bridge_name": "br0"},
        {"name":"galaxy-k8s-sriov","type": "galaxy-k8s-sriov", "device": "eth1", "vf_num": 10}
      ],
      "DefaultNetworks": ["galaxy-flannel"],
      "ENIIPNetwork": "galaxy-k8s-vlan"
    }
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: cni-etc
  namespace: kube-system
data:
  # CNI网络配置
  00-galaxy.conf: |
    {
      "name": "galaxy-sdn",
      "type": "galaxy-sdn",
      "capabilities": {"portMappings": true},
      "cniVersion": "0.2.0"
    }</pre>
<p>根据实际运行环境、使用的网络模式，需要进行调整。</p>
<div class="blog_h2"><span class="graybg">配置Kubelet</span></div>
<p>需要保证命令行标记：<pre class="crayon-plain-tag">--network-plugin=cni --cni-bin-dir=/opt/cni/bin/</pre>，并重启Kubelet。</p>
<div class="blog_h2"><span class="graybg">Overlay网络</span></div>
<div class="blog_h3"><span class="graybg">安装Flannel</span></div>
<p>这种网络模式依赖于Flannel，参考<a href="/flannel#install">Flannel学习笔记</a>。</p>
<div class="blog_h2"><span class="graybg">配置Galaxy</span></div>
<div class="blog_h3"><span class="graybg">全局配置</span></div>
<p>你需要修改上述Galaxy清单中的galaxy-etc，来配置默认网络（DefaultNetworks）：</p>
<pre class="crayon-plain-tag">{
  // 所有支持的网络模式的配置
  "NetworkConf":[
    {
      "name":"tke-route-eni", // 如果name为空，Galaxy假设它和type相同
      "type":"tke-route-eni",
      "eni":"eth1",
      "routeTable":1
    },
    {
      "name":"galaxy-flannel", // 这个插件就是flannel的CNI，只是被改了名字
      "type":"galaxy-flannel",
      "delegate":{
        "type":"galaxy-veth"
      },
      "subnetFile":"/run/flannel/subnet.env"
    },
    {
      "name":"galaxy-k8s-vlan",
      "type":"galaxy-k8s-vlan",
      "device":"eth1",
      "default_bridge_name":"br0"
    },
    {
      "name":"galaxy-k8s-sriov",
      "type":"galaxy-k8s-sriov",
      "device":"eth1",
      "vf_num":10
    },
    {
      "name":"galaxy-underlay-veth",
      "type":"galaxy-underlay-veth",
      "device":"eth1"
    }
  ],
  // Pod默认使用这个网络，注意可以加入多个网络
  "DefaultNetworks":[
    "galaxy-flannel" // 使用基于Flannel的Overlay网络
  ],
  // 如果Pod需要腾讯云弹性网卡（ENI）而且没有配置k8s.v1.cni.cncf.io/networks注解，则
  // 默认使用此网络。这个配置可以避免需要为所有需要Underlay网络的Pod添加注解
  "ENIIPNetwork":"galaxy-k8s-vlan"
}</pre>
<div class="blog_h3"><span class="graybg">Pod配置</span></div>
<p>可以为Pod指定注解： </p>
<pre class="crayon-plain-tag">k8s.v1.cni.cncf.io/networks: galaxy-flannel,galaxy-k8s-sriov</pre>
<p>来提示Galaxy应该为它配置哪些网络。 </p>
<div class="blog_h2"><span class="graybg">和其它CNI插件共存</span></div>
<p>Galaxy可以和其它CNI插件同时存在。需要注意的一点是，--network-conf-dir目录下其它插件的配置文件，其文件名的字典排序不应该比00-galaxy.conf更小，<span style="background-color: #c0c0c0;">否则Kubelet会在调用Galaxy CNI插件之前，调用其它CNI插件</span>，这可能不符合预期。</p>
<div class="blog_h2"><span class="graybg">Galaxy命令行标记</span></div>
<pre class="crayon-plain-tag">--alsologtostderr                   # 记录日志到文件，同时记录到标准错误
--bridge-nf-call-iptables           # 是否配置 bridge-nf-call-iptables，启用/禁用对网桥转发的封包被iptables规则过滤，默认true
--cni-paths stringSlice             # 除了从kubelet接收的，额外的CNI路径，默认/opt/cni/galaxy/bin
--flannel-allocated-ip-dir string   # Flannel CNI插件在何处存放分配的IP地址，默认/var/lib/cni/networks
--flannel-gc-interval duration      # 执行Flannel网络垃圾回收的间隔，默认10s
--gc-dirs string                    # 清理哪些目录，这些目录中的文件名包含容器ID
                                    # 默认 /var/lib/cni/flannel,/var/lib/cni/galaxy,/var/lib/cni/galaxy/port
--hostname-override string          # 覆盖kubelet hostname，如果指定该参数，则Galaxy使用它从API Server得到节点对象
--ip-forward                        # 是否启用IP转发
--json-config-path string           # Galaxy配置文件路径，默认/etc/galaxy/galaxy.json
--kubeconfig string                 # Kubelet配置文件
--log-backtrace-at traceLocation    # 如果日志在file:N打印，打印栈追踪。默认default :0
--log-dir string                    # 日志输出目录
--log-flush-frequency duration      # 日志刷出间隔，默认5s
--logtostderr                       # 输出到标准错误而非文件
--master string                     # API Server的地址和端口
--network-conf-dir string           # 额外的CNI网络配置文件。默认/etc/cni/net.d/
--network-policy                    # 启用网络策略支持
--route-eni                         # 是否启用腾讯云route-eni
--stderrthreshold severity          # 以上级别的日志打印到标准错误。默认2
-v, --v Level                       # 日志冗长级别 </pre>
<div class="blog_h1"><span class="graybg">Underlay网络</span></div>
<div class="blog_h2"><span class="graybg">工作原理</span></div>
<p>Underlay网络必须配合Galaxy-ipam使用，Galaxy-ipam负责为Pod分配或释放IP地址：</p>
<ol>
<li>你需要规划容器网络中使用的Underlay IP范围（注意和物理网路上其它节点的IP冲突），并且配置到ConfigMap floatingip-config中</li>
<li>调度Pod时，kube-scheduler会在filter/priority/bind方法中调用Galaxy-ipam</li>
<li>Galaxy-ipam检查Pod是否配置了Reserved IP，如果是，则Galaxy-ipam仅将此IP所在的可用子网的节点标记为有效节点，否则所有节点都将被标记为有效节点。在Pod绑定IP期间，Galaxy-ipam分配一个IP并将其写入到Pod annotations中</li>
<li>Galaxy从Pod的注解中获得IP，并将其作为参数传递给CNI，通过CNI配置Pod IP</li>
</ol>
<p>Galaxy的浮动IP能力由Galaxy-ipam提供，后者是一个<a href="https://kubernetes.io/docs/concepts/extend-kubernetes/extend-cluster/#scheduler-extensions">Kubernetes Scheudler Extender</a>，K8S的调度器会调用Galaxy-ipam，影响其filtering/binding的过程。浮动IP必须配合Underlay网络使用。</p>
<p>整体工作流图如下：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/07/galaxy-ipam.png"><img class="wp-image-33853 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/07/galaxy-ipam.png" alt="galaxy-ipam" width="1007" height="485" /></a></p>
<div class="blog_h2"><span class="graybg">安装Galaxy-ipam</span></div>
<p>清单文件可以到GitHub下载，默认内容如下：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: Service
metadata:
  name: galaxy-ipam
  namespace: kube-system
  labels:
    app: galaxy-ipam
spec:
  type: NodePort
  ports:
  - name: scheduler-port
    port: 9040
    targetPort: 9040
    nodePort: 32760
    protocol: TCP
  - name: api-port
    port: 9041
    targetPort: 9041
    nodePort: 32761
    protocol: TCP
  selector:
    app: galaxy-ipam
---
apiVersion: rbac.authorization.k8s.io/v1
# kubernetes versions before 1.8.0 should use rbac.authorization.k8s.io/v1beta1
kind: ClusterRole
metadata:
  name: galaxy-ipam
rules:
- apiGroups: [""]
  resources:
  - pods
  - namespaces
  - nodes
  - pods/binding
  verbs: ["list", "watch", "get", "patch", "create"]
- apiGroups: ["apps", "extensions"]
  resources:
  - statefulsets
  - deployments
  verbs: ["list", "watch"]
- apiGroups: [""]
  resources:
  - configmaps
  - endpoints
  - events
  verbs: ["get", "list", "watch", "update", "create", "patch"]
- apiGroups: ["galaxy.k8s.io"]
  resources:
  - pools
  - floatingips
  verbs: ["get", "list", "watch", "update", "create", "patch", "delete"]
- apiGroups: ["apiextensions.k8s.io"]
  resources:
  - customresourcedefinitions
  verbs:
  - "*"
- apiGroups: ["apps.tkestack.io"]
  resources:
  - tapps
  verbs: ["list", "watch"]
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: galaxy-ipam
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
# kubernetes versions before 1.8.0 should use rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: galaxy-ipam
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: galaxy-ipam
subjects:
  - kind: ServiceAccount
    name: galaxy-ipam
    namespace: kube-system
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: galaxy-ipam
  name: galaxy-ipam
  namespace: kube-system
spec:
  replicas: 1
  selector:
    matchLabels:
      app: galaxy-ipam
  template:
    metadata:
      labels:
        app: galaxy-ipam
    spec:
      priorityClassName: system-cluster-critical
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
              - key: app
                operator: In
                values:
                - galaxy-ipam
            topologyKey: "kubernetes.io/hostname"
      serviceAccountName: galaxy-ipam
      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet
      containers:
      - image: tkestack/galaxy-ipam:v1.0.7
        args:
          - --logtostderr=true
          - --profiling
          - --v=3
          - --config=/etc/galaxy/galaxy-ipam.json
          # 这个服务暴露一个端口，供Scheduler调用
          - --port=9040
          - --api-port=9041
          - --leader-elect
        command:
          - /usr/bin/galaxy-ipam
        ports:
          - containerPort: 9040
          - containerPort: 9041
        imagePullPolicy: Always
        name: galaxy-ipam
        resources:
          requests:
            cpu: 100m
            memory: 200Mi
        volumeMounts:
        - name: kube-config
          mountPath: /etc/kubernetes/
        - name: galaxy-ipam-log
          mountPath: /data/galaxy-ipam/logs
        - name: galaxy-ipam-etc
          mountPath: /etc/galaxy
        - name: tz-config
          mountPath: /etc/localtime
      terminationGracePeriodSeconds: 30
      tolerations:
        - effect: NoSchedule
          key: node-role.kubernetes.io/master
          operator: Exists
      volumes:
      - name: kube-config
        hostPath:
          path: /etc/kubernetes/
      - name: galaxy-ipam-log
        emptyDir: {}
      - configMap:
          defaultMode: 420
          name: galaxy-ipam-etc
        name: galaxy-ipam-etc
      - name: tz-config
        hostPath:
          path: /etc/localtime
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: galaxy-ipam-etc
  namespace: kube-system
data:
  # 配置文件
  galaxy-ipam.json: |
    {
      "schedule_plugin": {
        # 如果不是使用腾讯云的弹性网卡（ENI），去掉这一行
        "cloudProviderGrpcAddr": "127.0.0.2:80"
      }
    }</pre>
<div class="blog_h2"><span class="graybg">配置kube-scheduler</span></div>
<p>你需要为K8S调度器配置调度策略，此策略现在可以放在ConfigMap中：</p>
<pre class="crayon-plain-tag">cat &lt;&lt;EOF | kubectl create -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: scheduler-policy
  namespace: kube-system
data:
  policy.cfg: |
    {
      "kind": "Policy",
      "apiVersion": "v1",
      "extenders": [
        {
          // 通过HTTP调用扩展
          "urlPrefix": "http://127.0.0.1:9040/v1",
          "httpTimeout": 10000000000,
          "filterVerb": "filter",
          "BindVerb": "bind",
          "weight": 1,
          "enableHttps": false,
          "managedResources": [
            {
              "name": "tke.cloud.tencent.com/eni-ip",
              "ignoredByScheduler": false
            }
          ]
        }
      ]
    }
EOF</pre>
<p>然后为调度器添加命令行标记：<pre class="crayon-plain-tag">--policy-configmap=scheduler-policy</pre>即可。</p>
<div class="blog_h2"><span class="graybg">配置浮动IP</span></div>
<div class="blog_h3"><span class="graybg">floatingip-config</span></div>
<p>运行在裸金属环境下时，可以使用如下配置：</p>
<pre class="crayon-plain-tag">kind: ConfigMap
apiVersion: v1
metadata:
 name: floatingip-config
 namespace: kube-system
data:
 floatingips: |
    [
        { 
            # 节点的CIDR，这个网络范围内的节点可以运行具有浮动IP的Pod
            "nodeSubnets": ["10.0.0.0/16"],
            # 可以分配给Pod的IP地址范围
            "ips": ["10.0.70.2~10.0.70.241"],
            # Pod IP子网信息
            "subnet":"10.0.70.0/24",
            # Pod IP网关信息
            "gateway":"10.0.70.1",
            # Pod IP所属VLAN，如果Pod IP和Node IP不在同一VLAN，需要设置该字段，同时
            # 确保节点所连接的交换机时一个trunk port
            "vlan": 1024
        }
    ]</pre>
<p>一个nodeSubnet可以对应多个Pod Subnet，例如：</p>
<pre class="crayon-plain-tag">// 如果Pod运行在10.49.28.0/26，那么它可以具有10.0.80.2~10.0.80.4或者10.0.81.2~10.0.81.4的IP地址
// 如果Pod运行在10.49.29.0/24，则只能具有10.0.80.0/24的IP地址
[{
	"nodeSubnets": ["10.49.28.0/26", "10.49.29.0/24"],
	"ips": ["10.0.80.2~10.0.80.4"],
	"subnet": "10.0.80.0/24",
	"gateway": "10.0.80.1"
}, {
	"nodeSubnets": ["10.49.28.0/26"],
	"ips": ["10.0.81.2~10.0.81.4"],
	"subnet": "10.0.81.0/24",
	"gateway": "10.0.81.1",
	"vlan": 3
}]</pre>
<p>只要可分配的IP地址范围不重叠，多个nodeSubnet可以共享同一个Pod Subnet：</p>
<pre class="crayon-plain-tag">[{
	"routableSubnet": "10.180.1.2/32",
	"ips": ["10.180.154.2~10.180.154.3"],
	"subnet": "10.180.154.0/24",
	"gateway": "10.180.154.1",
	"vlan": 3
}, {
	"routableSubnet": "10.180.1.3/32",
	"ips": ["10.180.154.7~10.180.154.8"],
	"subnet": "10.180.154.0/24",
	"gateway": "10.180.154.1",
	"vlan": 3
}]</pre>
<div class="blog_h3"><span class="graybg">保留IP</span></div>
<p>要保留一个IP不被分配，可以使用下面的FloatingIP资源： </p>
<pre class="crayon-plain-tag">apiVersion: galaxy.k8s.io/v1alpha1
kind: FloatingIP
metadata:
  # 名字是需要保留的IP
  name: 10.0.0.1
  labels:
    # 从1.0.8下面这个标签不再需要
    ipType: internalIP
    # 这个标签必须保留
    reserved: this-is-not-for-pods
spec:
  key: pool__reserved-for-node_
  policy: 2</pre>
<div class="blog_h3"><span class="graybg">Deployment配置</span></div>
<p>目前浮动IP仅仅支持Deployment、StatefulSet产生的工作负载。</p>
<pre class="crayon-plain-tag">apiVersion: apps/v1
kind: Deployment
...
spec:
  ...
  template:
    metadata:
      annotations:
        # 如果默认网络不是galaxy-k8s-vlan则必须添加下面的注解
        k8s.v1.cni.cncf.io/networks: galaxy-k8s-vlan
        # IP释放策略：
        #    为空/不指定：Pod一旦停止，就释放IP
        #    immutable：仅仅在删除、缩容Deployment / StatefulSet的情况下才释放IP
        #    never：即使Deployment / StatefulSet被删除，也不会释放IP。后续的同名Deployment
        #           /StatefulSet会重用已分配的IP
        k8s.v1.cni.galaxy.io/release-policy: immutable
      creationTimestamp: null
      labels:
        k8s-app: nnn
        qcloud-app: nnn
    spec:
      containers:
      - image: nginx
        imagePullPolicy: Always
        name: nnn
        resources:
          limits:
            cpu: 500m
            memory: 1Gi
            # 扩展的资源限制，在某个容器中配置即可
            tke.cloud.tencent.com/eni-ip: "1"
          requests:
            cpu: 250m
            memory: 256Mi
            tke.cloud.tencent.com/eni-ip: "1"</pre>
<p>生成的Pod中，通过注解<pre class="crayon-plain-tag">k8s.v1.cni.galaxy.io/args</pre>指明了它的IP地址等信息。CNI插件也是基于这些信息为容器网络命名空间设置IP地址、路由的：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: Pod
metadata:
  name: nnn-7df5984746-58hjm
  annotations:
    k8s.v1.cni.cncf.io/networks: galaxy-k8s-vlan
    k8s.v1.cni.galaxy.io/args: '{"common":{"ipinfos":[{"ip":"192.168.64.202/24",
        "vlan":0,"gateway":"192.168.64.1","routable_subnet":"172.21.64.0/20"}]}}'
    k8s.v1.cni.galaxy.io/release-policy: immutable
...
spec:
...
status:
...
  hostIP: 172.21.64.15
  phase: Running
  podIP: 192.168.64.202
  podIPs:
  - ip: 192.168.64.202</pre>
<div class="blog_h2"><span class="graybg">Galaxy-ipam相关CRD</span></div>
<div class="blog_h3"><span class="graybg">Pool</span></div>
<p>为Deployment设置注解<pre class="crayon-plain-tag">tke.cloud.tencent.com/eni-ip-pool</pre>，可以让多个Deployment共享一个IP池。</p>
<p>使用IP池的情况下，默认的IP释放策略为never，除非通过注解<pre class="crayon-plain-tag">k8s.v1.cni.galaxy.io/release-policy</pre>指定其它策略。</p>
<p>默认情况下，IP池的大小随着Deployment/StatefulSet的副本数量的增加而增大，你可以设置固定尺寸的IP池：</p>
<pre class="crayon-plain-tag">apiVersion: galaxy.k8s.io/v1alpha1
kind: Pool
metadata:
  name: example-pool
size: 4</pre>
<p>通过HTTP接口创建IP池的时候，可以设置preAllocateIP=true来为池预先分配IP，通过kubectl创建CR时无法实现预分配。 </p>
<div class="blog_h3"><span class="graybg">FloatingIP</span></div>
<p>该CR保存了浮动IP，及其绑定的工作负载信息：</p>
<pre class="crayon-plain-tag">apiVersion: galaxy.k8s.io/v1alpha1
kind: FloatingIP
metadata:
  creationTimestamp: "2020-03-04T08:28:15Z"
  generation: 1
  labels:
    ipType: internalIP
  # IP地址
  name: 192.168.64.202
  resourceVersion: "2744910"
  selfLink: /apis/galaxy.k8s.io/v1alpha1/floatingips/192.168.64.202
  uid: b5d55f27-4548-44c7-b8ad-570814b55026
spec:
  attribute: '{"NodeName":"172.21.64.15"}'
  # 绑定的工作负载
  key: dp_default_nnn_nnn-7df5984746-58hjm
  policy: 1
  subnet: 172.21.64.0/20
  updateTime: "2020-03-04T08:28:15Z" </pre>
<div class="blog_h2"><span class="graybg">Galaxy-ipam HTTP接口</span></div>
<p>Galaxy-ipam提供了基于<a href="https://github.com/tkestack/galaxy/blob/master/doc/swagger.json">Swagger 2.0</a>的API，为galaxy-ipam提供命令行选项<pre class="crayon-plain-tag">--swagger</pre>，则它能够在下面的URL展示此API：</p>
<p style="padding-left: 30px;">http://${galaxy-ipam-ip}:9041/apidocs.json/v1</p>
<div class="blog_h3"><span class="graybg">查询分配的IP</span></div>
<pre class="crayon-plain-tag">// 查询分配给default命名空间中的StatefulSet sts的IP地址
// curl 'http://192.168.30.7:9041/v1/ip?appName=sts&amp;appType=statefulsets&amp;namespace=default'
{
 "last": true,
 "totalElements": 2,
 "totalPages": 1,
 "first": true,
 "numberOfElements": 2,
 "size": 10,
 "number": 0,
 "content": [
  {
   "ip": "10.0.0.112",
   "namespace": "default",
   "appName": "sts",
   "podName": "sts-0",
   "policy": 2,
   "appType": "statefulset",
   "updateTime": "2020-05-29T11:11:44.633383558Z",
   "status": "Deleted",
   "releasable": true
  },
  {
   "ip": "10.0.0.174",
   "namespace": "default",
   "appName": "sts",
   "podName": "sts-1",
   "policy": 2,
   "appType": "statefulset",
   "updateTime": "2020-05-29T11:11:45.132450117Z",
   "status": "Deleted",
   "releasable": true
  }
 ]
}</pre>
<div class="blog_h3"><span class="graybg">释放IP地址</span></div>
<pre class="crayon-plain-tag">// curl -X POST -H "Content-type: application/json" -d '
//   { "ips": [
//     { "ip":"10.0.0.112", "appName":"sts", "appType":"statefulset",  "podName":"sts-0","namespace":"default"},
//     {"ip":"10.0.0.174", "appName":"sts", "appType":"statefulset", "podName":"sts-1", "namespace":"default"}
//   ]}
// '
// http://192.168.30.7:9041/v1/ip
{
 "code": 200,
 "message": ""
}</pre>
<div class="blog_h2"><span class="graybg">常见问题</span></div>
<div class="blog_h3"><span class="graybg">关于滚动更新</span></div>
<p>Deployment的默认更新策略是StrategyType=RollingUpdate，25% max unavailable, 25% max surge。这意味着这在滚动更新过程中，可能存在超过副本数限制25%的Pod。这可能导致死锁状态：</p>
<ol>
<li>假设Deployment副本数为3，那么有一个副本不可用，就超过25%的限制。Deployment的控制器只能先创建新Pod，然后等它就绪后再删除一个旧Pod</li>
<li>如果浮动IP的释放策略被设置为immutable/never，这就意味着新的Pod需要从一个旧Pod那复用IP，然而旧Pod还尚未删除</li>
</ol>
<p>这会导致新Pod卡斯在调度阶段。</p>
<div class="blog_h1"><span class="graybg">端口映射</span></div>
<p>使用K8S的NodePort服务，可以将一组Pod通过宿主机端口暴露到集群外部。但是如果希望访问StatefulSet的每个特定Pod的端口，使用K8S NodePort服务无法实现。</p>
<p>使用K8S的HostPort，则会存在两个Pod调度到同一节点，进而产生端口冲突的问题：</p>
<pre class="crayon-plain-tag">spec:
      containers:
      - image: ...
        ports:
          - containerPort: 9040
            hostPort: 9040</pre>
<p>Galaxy提供了一个功能，可以进行随机的端口映射： </p>
<pre class="crayon-plain-tag">apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: hello
  name: hello
spec:
  replicas: 1
  selector:
    matchLabels:
      app: hello
  template:
    metadata:
      labels:
        app: hello
      annotations:
        # 为Pod设置此注解即可
        tkestack.io/portmapping: ""
    spec:
      containers:
      - image: ...
        ports:
          - containerPort: 9040</pre>
<p>这样Galaxy会利用iptables，随机的映射宿主机的端口到Pod的每个容器端口，并且将此随机端口回填到tkestack.io/portmapping注解中。</p>
<div class="blog_h1"><span class="graybg">源码解读</span></div>
<div class="blog_h2"><span class="graybg">CNI</span></div>
<div class="blog_h3"><span class="graybg">galaxy-sdn</span></div>
<p>在TKEStack的缺省配置（基于Flannel的VXLAN模式的Overlay网络）下，每个节点只有一个CNI配置：</p>
<pre class="crayon-plain-tag">{
  "type": "galaxy-sdn",
  "capabilities": {"portMappings": true},
  "cniVersion": "0.2.0"
}</pre>
<p>我们先看看这个CNI插件做的什么事情，又是如何和Flannel交互的。Galaxy所有自带的CNI插件都位于github.com/tkestack/galaxy/cni目录下，其中galaxy-sdn插件入口点如下：</p>
<pre class="crayon-plain-tag">package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"net"
	"net/http"
	"os"
	"strings"

	"github.com/containernetworking/cni/pkg/skel"
	// 使用0.20版本的CNI
	t020 "github.com/containernetworking/cni/pkg/types/020"
	"github.com/containernetworking/cni/pkg/version"
	galaxyapi "tkestack.io/galaxy/pkg/api/galaxy"
	"tkestack.io/galaxy/pkg/api/galaxy/private"
)

type cniPlugin struct {
	socketPath string
}

func NewCNIPlugin(socketPath string) *cniPlugin {
	return &amp;cniPlugin{socketPath: socketPath}
}

// Create and fill a CNIRequest with this plugin's environment and stdin which
// contain the CNI variables and configuration
func newCNIRequest(args *skel.CmdArgs) *galaxyapi.CNIRequest {
	envMap := make(map[string]string)
	for _, item := range os.Environ() {
		idx := strings.Index(item, "=")
		if idx &gt; 0 {
			envMap[strings.TrimSpace(item[:idx])] = item[idx+1:]
		}
	}

	return &amp;galaxyapi.CNIRequest{
		Env:    envMap,
		Config: args.StdinData,
	}
}

// 调用Galaxy守护进程
func (p *cniPlugin) doCNI(url string, req *galaxyapi.CNIRequest) ([]byte, error) {
	data, err := json.Marshal(req)
	if err != nil {
		return nil, fmt.Errorf("failed to marshal CNI request %v: %v", req, err)
	}
	// 通过UDS连接
	client := &amp;http.Client{
		Transport: &amp;http.Transport{
			Dial: func(proto, addr string) (net.Conn, error) {
				return net.Dial("unix", p.socketPath)
			},
		},
	}

	resp, err := client.Post(url, "application/json", bytes.NewReader(data))
	if err != nil {
		return nil, fmt.Errorf("failed to send CNI request: %v", err)
	}
	defer resp.Body.Close() // nolint: errcheck

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return nil, fmt.Errorf("failed to read CNI result: %v", err)
	}

	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("galaxy returns: %s", string(body))
	}

	return body, nil
}

// 就是将CNI请求转发给Galaxy守护进程处理
func (p *cniPlugin) CmdAdd(args *skel.CmdArgs) (*t020.Result, error) {
	// 由于通过UDS通信，URL的hostname部分随便写了一个dummy，就是简单的调用/cni这个Endpoint
	body, err := p.doCNI("http://dummy/cni", newCNIRequest(args))
	if err != nil {
		return nil, err
	}

	result := &amp;t020.Result{}
	if err := json.Unmarshal(body, result); err != nil {
		return nil, fmt.Errorf("failed to unmarshal response '%s': %v", string(body), err)
	}

	return result, nil
}

// Send the ADD command environment and config to the CNI server, printing
// the IPAM result to stdout when called as a CNI plugin
func (p *cniPlugin) skelCmdAdd(args *skel.CmdArgs) error {
	result, err := p.CmdAdd(args)
	if err != nil {
		return err
	}
	return result.Print()
}

// Send the DEL command environment and config to the CNI server
func (p *cniPlugin) CmdDel(args *skel.CmdArgs) error {
	_, err := p.doCNI("http://dummy/cni", newCNIRequest(args))
	return err
}

// 使用skel框架开发
func main() {
	// 传入UDS套接字的路径
	p := NewCNIPlugin(private.GalaxySocketPath)
	skel.PluginMain(p.skelCmdAdd, p.CmdDel, version.Legacy)
}</pre>
<p>可以看到galaxy-sdn就是个空壳子，所有工作都是委托给Galaxy守护进程处理的。 </p>
<div class="blog_h3"><span class="graybg">galaxy-k8s-vlan</span></div>
<p>整体流程：</p>
<pre class="crayon-plain-tag">func init() {
	// this ensures that main runs only on main thread (thread group leader).
	// since namespace ops (unshare, setns) are done for a single thread, we
	// must ensure that the goroutine does not jump from OS thread to thread
	runtime.LockOSThread()
	_, pANet, _ = net.ParseCIDR("10.0.0.0/8")
	_, pBNet, _ = net.ParseCIDR("172.16.0.0/12")
	_, pCNet, _ = net.ParseCIDR("192.168.0.0/16")
}

func main() {
	d = &amp;vlan.VlanDriver{}
	skel.PluginMain(cmdAdd, cmdDel, version.Legacy)
}

func cmdAdd(args *skel.CmdArgs) error {
	// 加载CNI配置文件
	conf, err := d.LoadConf(args.StdinData)
	if err != nil {
		return err
	}
	// 首先尝试从args获得IPInfo，如果失败则调用第三方IPAM插件分配IP
	vlanIds, results, err := ipam.Allocate(conf.IPAM.Type, args)
	if err != nil {
		return err
	}
	// 判断是否创建默认网桥
	if d.DisableDefaultBridge == nil {
		defaultTrue := true
		d.DisableDefaultBridge = &amp;defaultTrue
		for i := range vlanIds {
			if vlanIds[i] == 0 {
				*d.DisableDefaultBridge = false
			}
		}
	}
	// 初始化VLAN驱动，主要是在必要的情况下对宿主机上的网桥进行配置，或者进行针对MACVlan/IPVlan的配置
	if err := d.Init(); err != nil {
		return fmt.Errorf("failed to setup bridge %v", err)
	}
	result020s, err := resultConvert(results)
	if err != nil {
		return err
	}
	// 配置宿主机网络
	if err := setupNetwork(result020s, vlanIds, args); err != nil {
		return err
	}
	result020s[0].DNS = conf.DNS
	return result020s[0].Print()
}

func cmdDel(args *skel.CmdArgs) error {
	// 删除所有VETH对
	if err := utils.DeleteAllVeth(args.Netns); err != nil {
		return err
	}
	conf, err := d.LoadConf(args.StdinData)
	if err != nil {
		return err
	}
	// 是否IP地址
	return ipam.Release(conf.IPAM.Type, args)
}</pre>
<p>VlanDriver是此CNI的核心。 </p>
<pre class="crayon-plain-tag">type VlanDriver struct {
	//FIXME add a file lock cause we are running multiple processes?
	*NetConf
	// 不管是物理接口，还是VLAN子接口，都位于宿主机命名空间
	// 所有VLAN接口的物理接口（父接口）的设备索引，例如eth1
	vlanParentIndex int
	// 如果启用VLAN，则是当前所属VLAN的子接口的索引。否则是物理接口的索引
	DeviceIndex int
}</pre>
<p>ADD/DEL命令都需要调用下面的方法加载CNI配置：</p>
<pre class="crayon-plain-tag">const (
	VlanPrefix        = "vlan"
	BridgePrefix      = "docker"
	DefaultBridge     = "docker"
	// IPVLAN有两种模式L2/L3
	DefaultIPVlanMode = "l3"
)

func (d *VlanDriver) LoadConf(bytes []byte) (*NetConf, error) {
	conf := &amp;NetConf{}
	if err := json.Unmarshal(bytes, conf); err != nil {
		return nil, fmt.Errorf("failed to load netconf: %v", err)
	}
	if conf.DefaultBridgeName == "" {
		conf.DefaultBridgeName = DefaultBridge
	}
	if conf.BridgeNamePrefix == "" {
		conf.BridgeNamePrefix = BridgePrefix
	}
	if conf.VlanNamePrefix == "" {
		conf.VlanNamePrefix = VlanPrefix
	}
	if conf.IpVlanMode == "" {
		conf.IpVlanMode = DefaultIPVlanMode
	}

	d.NetConf = conf
	return conf, nil
}</pre>
<p>CNI配置定义如下：</p>
<pre class="crayon-plain-tag">type NetConf struct {
	// CNI标准网络配置
	types.NetConf
	// 持有IDC IP地址的设备名，例如eth1或eth1.12（VLAN子接口）
	Device string `json:"device"`
	// 上述Device为空的时候，候选设备列表。取第一个存在的设备
	Devices []string `json:"devices"`
	// 交换机实现方式 macvlan, bridge（默认）, pure（避免创建不必要的网桥）
	Switch string `json:"switch"`
	// IPVLAN模式，可选l2, l3（默认）, l3s
	IpVlanMode string `json:"ipvlan_mode"`

	// 不去创建默认网桥
	DisableDefaultBridge *bool `json:"disable_default_bridge"`
	// 默认网桥的名字
	DefaultBridgeName string `json:"default_bridge_name"`
	// 网桥名字前缀
	BridgeNamePrefix string `json:"bridge_name_prefix"`
	// VLAN接口名字前缀
	VlanNamePrefix string `json:"vlan_name_prefix"`
	// 是否启用免费ARP
	GratuitousArpRequest bool `json:"gratuitous_arp_request"`
	// 设置MTU
	MTU int `json:"mtu"`
}</pre>
<p>ADD命令，加载完CNI配置后，首先是调用IPAM进行IP地址分配：</p>
<pre class="crayon-plain-tag">func Allocate(ipamType string, args *skel.CmdArgs) ([]uint16, []types.Result, error) {
	var (
		vlanId uint16
		err    error
	)
	// 解析key1=val1;key2=val2格式的CNI参数。这些参数来自Pod的k8s.v1.cni.galaxy.io/args注解，
	// 如果使用浮动IP，则Galaxy IPAM会设置该注解，将IP地址填写在ipinfos字段
	kvMap, err := cniutil.ParseCNIArgs(args.Args)
	if err != nil {
		return nil, nil, err
	}
	var results []types.Result
	var vlanIDs []uint16
	// 读取ipinfos字段
	if ipInfoStr := kvMap[constant.IPInfosKey]; ipInfoStr != "" {
		// 解析IP信息
		var ipInfos []constant.IPInfo
		if err := json.Unmarshal([]byte(ipInfoStr), &amp;ipInfos); err != nil {
			return nil, nil, fmt.Errorf("failed to unmarshal ipInfo from args %q: %v", args.Args, err)
		}
		if len(ipInfos) == 0 {
			return nil, nil, fmt.Errorf("empty ipInfos")
		}
		for j := range ipInfos {
			results = append(results, cniutil.IPInfoToResult(&amp;ipInfos[j]))
			vlanIDs = append(vlanIDs, ipInfos[j].Vlan)
		}
		// 直接“分配”Pod注解里写的IP地址
		return vlanIDs, results, nil
	}
	// 如果Pod注解里面没有IP地址信息，则需要调用谋者IPAM插件，因此断言ipamType不为空：
	if ipamType == "" {
		return nil, nil, fmt.Errorf("neither ipInfo from cni args nor ipam type from netconf")
	}
	// 调用IPAM插件
	generalResult, err := ipam.ExecAdd(ipamType, args.StdinData)
	if err != nil {
		return nil, nil, err
	}
	result, err := t020.GetResult(generalResult)
	if err != nil {
		return nil, nil, err
	}
	if result.IP4 == nil {
		return nil, nil, fmt.Errorf("IPAM plugin returned missing IPv4 config")
	}
	return append(vlanIDs, vlanId), append(results, generalResult), err
}</pre>
<p>如果使用Galaxy IPAM提供的浮动IP功能，则上述代码会自动获取已经分配的IP信息并返回。</p>
<p>分配/获取IP地址之后，就执行VlanDriver的初始化：</p>
<pre class="crayon-plain-tag">func (d *VlanDriver) Init() error {
	var (
		device netlink.Link
		err    error
	)
	// 这个驱动需要指定宿主机上的父接口
	if d.Device != "" {
		device, err = netlink.LinkByName(d.Device)
	} else {
		if len(d.Devices) == 0 {
			return fmt.Errorf("a device is needed to use vlan plugin")
		}
		for _, devName := range d.Devices {
			device, err = netlink.LinkByName(devName)
			if err == nil {
				break
			}
		}
	}
	if err != nil {
		return fmt.Errorf("Error getting device %s: %v", d.Device, err)
	}
	// 默认MTU从父接口上查找
	if d.MTU == 0 {
		d.MTU = device.Attrs().MTU
	}
	d.DeviceIndex = device.Attrs().Index
	d.vlanParentIndex = device.Attrs().Index
	// 如果指定的设备是VLAN子接口，则使用其父接口的索引
	if device.Type() == "vlan" {
		//A vlan device
		d.vlanParentIndex = device.Attrs().ParentIndex
		//glog.Infof("root device %s is a vlan device, parent index %d", d.Device, d.vlanParentIndex)
	}
	if d.IPVlanMode() {
		switch d.GetIPVlanMode() {
		case netlink.IPVLAN_MODE_L3S, netlink.IPVLAN_MODE_L3:
			// 允许绑定到非本地地址，什么作用
			return utils.EnableNonlocalBind()
		default:
			return nil
		}
	} else if d.MacVlanMode() {
		return nil
	} else if d.PureMode() {
		if err := d.initPureModeArgs(); err != nil {
			return err
		}
		return utils.EnableNonlocalBind()
	}
	if d.DisableDefaultBridge != nil &amp;&amp; *d.DisableDefaultBridge {
		return nil
	}
	// 需要创建默认网桥
	// 获得父接口的IP地址
	v4Addr, err := netlink.AddrList(device, netlink.FAMILY_V4)
	if err != nil {
		return fmt.Errorf("Errror getting ipv4 address %v", err)
	}
	filteredAddr := network.FilterLoopbackAddr(v4Addr)
	if len(filteredAddr) == 0 {
		// 父接口没有IP地址，那么地址应当转给网桥了。检查确保网桥设备存在
		bri, err := netlink.LinkByName(d.DefaultBridgeName)
		if err != nil {
			return fmt.Errorf("Error getting bri device %s: %v", d.DefaultBridgeName, err)
		}
		if bri.Attrs().Index != device.Attrs().MasterIndex {
			return fmt.Errorf("No available address found on device %s", d.Device)
		}
	} else {
		// 否则，意味着网桥应该还不存在，初始化之
		if err := d.initVlanBridgeDevice(device, filteredAddr); err != nil {
			return err
		}
	}
	return nil
}

// 不使用MacVlan/IPVlan，则需要依赖网桥
func (d *VlanDriver) initVlanBridgeDevice(device netlink.Link, filteredAddr []netlink.Addr) error {
	// 创建网桥
	bri, err := getOrCreateBridge(d.DefaultBridgeName, device.Attrs().HardwareAddr)
	if err != nil {
		return err
	}
	// 启动网桥
	if err := netlink.LinkSetUp(bri); err != nil {
		return fmt.Errorf("failed to set up bridge device %s: %v", d.DefaultBridgeName, err)
	}
	// 获取父接口路由列表
	rs, err := netlink.RouteList(device, nl.FAMILY_V4)
	if err != nil {
		return fmt.Errorf("failed to list route of device %s", device.Attrs().Name)
	}
	defer func() {
		if err != nil {
			// 如果出现错误，则路由需要加回去
			for i := range rs {
				_ = netlink.RouteAdd(&amp;rs[i])
			}
		}
	}()
	// 将父接口的IP地址、路由，转移到网桥上
	err = d.moveAddrAndRoute(device, bri, filteredAddr, rs)
	if err != nil {
		return err
	}
	return nil
}</pre>
<p>可以看到，如果使用MacVlan或者IPVlan模式，则不会去管理宿主机上的网桥（这也是MacVlan/IPVlan的价值之一，不需要网桥）。否则，会创建网桥并转移走父接口的IP地址、路由。</p>
<p>最后，下面的函数负责配置好网络：  </p>
<pre class="crayon-plain-tag">func setupNetwork(result020s []*t020.Result, vlanIds []uint16, args *skel.CmdArgs) error {
	if d.MacVlanMode() {
		// 处理MACVlan模式
		if err := setupMacvlan(result020s[0], vlanIds[0], args); err != nil {
			return err
		}
	} else if d.IPVlanMode() {
		// 处理IPVlan模式
		if err := setupIPVlan(result020s[0], vlanIds[0], args); err != nil {
			return err
		}
	} else {
		// 处理网桥模式
		ifName := args.IfName
		if err := setupVlanDevice(result020s, vlanIds, args); err != nil {
			return err
		}
		args.IfName = ifName
	}
	// 发送一个免费ARP，让交换机知道浮动IP漂移到当前节点上了
	if d.PureMode() {
		_ = utils.SendGratuitousARP(d.Device, result020s[0].IP4.IP.IP.String(), "", d.GratuitousArpRequest)
	}
	return nil
}</pre>
<p>如果使用MACVlan，则配置过程如下：</p>
<pre class="crayon-plain-tag">func setupMacvlan(result *t020.Result, vlanId uint16, args *skel.CmdArgs) error {
	if err := d.MaybeCreateVlanDevice(vlanId); err != nil {
		return err
	}
	// 通过MACVlan连接宿主机和容器
	if err := utils.MacVlanConnectsHostWithContainer(result, args, d.DeviceIndex, d.MTU); err != nil {
		return err
	}
	// 在命名空间内进行宣告
	_ = utils.SendGratuitousARP(args.IfName, result.IP4.IP.IP.String(), args.Netns, d.GratuitousArpRequest)
	return nil
}

func MacVlanConnectsHostWithContainer(result *t020.Result, args *skel.CmdArgs, parent int, mtu int) error {
	var err error
	// 创建MACVlan设备
	macVlan := &amp;netlink.Macvlan{
		Mode: netlink.MACVLAN_MODE_BRIDGE,
		LinkAttrs: netlink.LinkAttrs{
			Name:        HostMacVlanName(args.ContainerID),
			MTU:         mtu,
			ParentIndex: parent, // 父设备可能是物理接口或者VLAN子接口
		}}
	// 添加接口
	if err := netlink.LinkAdd(macVlan); err != nil {
		return err
	}
	// 出错时必须接口被删除
	defer func() {
		if err != nil {
			netlink.LinkDel(macVlan)
		}
	}()
	// 配置沙盒，细节参考下文
	if err = configSboxDevice(result, args, macVlan); err != nil {
		return err
	}
	return nil
}</pre>
<p>如果使用IPVlan，则配置过程如下： </p>
<pre class="crayon-plain-tag">func setupIPVlan(result *t020.Result, vlanId uint16, args *skel.CmdArgs) error {
	// 如有必要，创建VLAN设备
	if err := d.MaybeCreateVlanDevice(vlanId); err != nil {
		return err
	}
	// 连接宿主机和容器网络                                         IPVLAN的父设备可能是
	//                                                            VLAN子接口（位于宿主机命名空间）
	if err := utils.IPVlanConnectsHostWithContainer(result, args, d.DeviceIndex, d.GetIPVlanMode(), d.MTU); err != nil {
		return err
	}

	// l3模式下，所有连接在一起的容器之间的相互通信，都要通过宿主机上的IPVlan父接口进行代理
	// 在我们的环境中（部署在OpenStack的虚拟机上），出现Pod无法ping二层网关的情况，需要
	//     arping -c 2 -U -I eth0 &lt;pod-ip&gt;
	// 才可以解决，等价于下面的代码
	// Gratuitous ARP不能保证ARP缓存永久有效，底层交换机（OpenStack虚拟的）需要进行适当的配置，
	// 将Pod IP和VM的MAC地址关联
	if d.IpVlanMode == "l3" || d.IpVlanMode == "l3s" {
		_ = utils.SendGratuitousARP(d.Device, result.IP4.IP.IP.String(), "", d.GratuitousArpRequest)
		return nil
	}
	// 在网络命名空间中进行宣告
	_ = utils.SendGratuitousARP(args.IfName, result.IP4.IP.IP.String(), args.Netns, d.GratuitousArpRequest)
	return nil
}


// 创建VLAN设备的细节
func (d *VlanDriver) MaybeCreateVlanDevice(vlanId uint16) error {
	// 如果不启用VLAN支持，则不做任何事情
	if vlanId == 0 {
		return nil
	}
	_, err := d.getOrCreateVlanDevice(vlanId)
	return err
}
func (d *VlanDriver) getOrCreateVlanDevice(vlanId uint16) (netlink.Link, error) {
	// 根据父接口和VLAN ID查找已存在的VLAN设备
	link, err := d.getVlanIfExist(vlanId)
	if err != nil || link != nil {
		if link != nil {
			// 设置VLAN设备索引
			d.DeviceIndex = link.Attrs().Index
		}
		return link, err
	}
	vlanIfName := fmt.Sprintf("%s%d", d.VlanNamePrefix, vlanId)
	// 获取VLAN设备，如果获取不到则通过后面的回调函数创建
	vlan, err := getOrCreateDevice(vlanIfName, func(name string) error {
		// 创建设备                                                 名字 vlan100 父接口索引
		vlanIf := &amp;netlink.Vlan{LinkAttrs: netlink.LinkAttrs{Name: vlanIfName, ParentIndex: d.vlanParentIndex},
			// VLAN号
			VlanId: (int)(vlanId)}
		// 添加设备
		if err := netlink.LinkAdd(vlanIf); err != nil {
			return fmt.Errorf("Failed to add vlan device %s: %v", vlanIfName, err)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	// 设置为UP状态
	if err := netlink.LinkSetUp(vlan); err != nil {
		return nil, fmt.Errorf("Failed to set up vlan device %s: %v", vlanIfName, err)
	}
	// 更新VLAN子接口索引号
	d.DeviceIndex = vlan.Attrs().Index
	return vlan, nil
}
func (d *VlanDriver) getVlanIfExist(vlanId uint16) (netlink.Link, error) {
	links, err := netlink.LinkList()
	if err != nil {
		return nil, err
	}
	for _, link := range links {
		// 遍历网络接口列表，查找VLAN类型的、VLAN ID匹配的、父接口匹配的
		// 隐含意味着对于每个VLAN，只需要一个子接口，所有连接到此VLAN的容器使用它
		if link.Type() == "vlan" {
			if vlan, ok := link.(*netlink.Vlan); !ok {
				return nil, fmt.Errorf("vlan device type case error: %T", link)
			} else {
				if vlan.VlanId == int(vlanId) &amp;&amp; vlan.ParentIndex == d.vlanParentIndex {
					return link, nil
				}
			}
		}
	}
	return nil, nil
}


// 通过IPVLAN两宿主机和容器连接起来的细节
// 配置网络沙盒
func configSboxDevice(result *t020.Result, args *skel.CmdArgs, sbox netlink.Link) error {
	// 配置MAC地址之前，应该将接口DOWN掉
	if err := netlink.LinkSetDown(sbox); err != nil {
		return fmt.Errorf("could not set link down for container interface %q: %v", sbox.Attrs().Name, err)
	}
	if sbox.Type() != "ipvlan" {
		// IPVlan不需要设置MAC地址，因为必须和父接口一致
		if err := netlink.LinkSetHardwareAddr(sbox, GenerateMACFromIP(result.IP4.IP.IP)); err != nil {
			return fmt.Errorf("could not set mac address for container interface %q: %v", sbox.Attrs().Name, err)
		}
	}
	// 获得容器网络命名空间
	netns, err := ns.GetNS(args.Netns)
	if err != nil {
		return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
	}
	// 总是要关闭命名空间
	defer netns.Close() // nolint: errcheck
	// 移动到网络命名空间中
	if err = netlink.LinkSetNsFd(sbox, int(netns.Fd())); err != nil {
		return fmt.Errorf("failed to move sbox device %q to netns: %v", sbox.Attrs().Name, err)
	}
	// 在网络命名空间中配置
	return netns.Do(func(_ ns.NetNS) error {
		// 改名，IPVLAN接口直接变为容器的eth0，就像VETH对的一端那样
		if err := netlink.LinkSetName(sbox, args.IfName); err != nil {
			return fmt.Errorf("failed to rename sbox device %q to %q: %v", sbox.Attrs().Name, args.IfName, err)
		}
		// 如果容器、宿主机上有多个网络设备，则需要禁用rp_filter（反向路径过滤）
		// 因为从宿主机来的ARP请求（广播报文），可能使用不确定的源IP，如果启用反向路径过滤，封包可能被丢弃
		if err := DisableRpFilter(args.IfName); err != nil {
			return fmt.Errorf("failed disable rp_filter to dev %s: %v", args.IfName, err)
		}
		// 禁用所有接口的rp_filter
		if err := DisableRpFilter("all"); err != nil {
			return fmt.Errorf("failed disable rp_filter to all: %v", err)
		}
		// 配置容器网络接口：将IP地址、路由添加到容器网络接口
		return cniutil.ConfigureIface(args.IfName, result)
	})
}
func ConfigureIface(ifName string, res *t020.Result) error {
	// 网络接口应该已经存在
	link, err := netlink.LinkByName(ifName)
	if err != nil {
		return fmt.Errorf("failed to lookup %q: %v", ifName, err)
	}
	// 设置为UP状态
	if err := netlink.LinkSetUp(link); err != nil {
		return fmt.Errorf("failed to set %q UP: %v", ifName, err)
	}

	// TODO(eyakubovich): IPv6
	addr := &amp;netlink.Addr{IPNet: &amp;res.IP4.IP, Label: ""}
	// 添加IP地址
	if err = netlink.AddrAdd(link, addr); err != nil {
		return fmt.Errorf("failed to add IP addr to %q: %v", ifName, err)
	}

	// 遍历并应用IPV4路由
	for _, r := range res.IP4.Routes {
		gw := r.GW
		if gw == nil {
			gw = res.IP4.Gateway
		}
		if err = ip.AddRoute(&amp;r.Dst, gw, link); err != nil {
			// we skip over duplicate routes as we assume the first one wins
			if !os.IsExist(err) {
				return fmt.Errorf("failed to add route '%v via %v dev %v': %v", r.Dst, gw, ifName, err)
			}
		}
	}
	return nil
}</pre>
<p>DEL命令比较简单：</p>
<ol>
<li>删除容器网络命名空间中所有VETH对</li>
<li>释放IP，就是调用IPAM插件，如果没有插件则什么都不做。Galaxy IPAM不会被调用，它总是自己负责IP地址的回收</li>
</ol>
<div class="blog_h3"><span class="graybg">galaxy-veth-host</span></div>
<p><a href="https://github.com/lyft/cni-ipvlan-vpc-k8s/blob/master/plugin/unnumbered-ptp/unnumbered-ptp.go">该插件</a>用于解决使用IPVlan L2模式下Pod访问不了宿主机IP、Service IP的问题。使用该插件，需要配置Galaxy：</p>
<pre class="crayon-plain-tag">{
  "NetworkConf":[
    {"name":"tke-route-eni","type":"tke-route-eni","eni":"eth1","routeTable":1},
    {"name":"galaxy-flannel","type":"galaxy-flannel", "delegate":{"type":"galaxy-veth"},"subnetFile":"/run/flannel/subnet.env"},
    {"name":"galaxy-k8s-vlan","type":"galaxy-k8s-vlan", "device":"eth0", "switch":"ipvlan", "ipvlan_mode":"l2", "mtu": 1500},
    {"name":"galaxy-k8s-sriov","type": "galaxy-k8s-sriov", "device": "eth0", "vf_num": 10},
    // 新增
    {
        "name":"galaxy-veth-host","type": "galaxy-veth-host", 
        // 这里配置K8S的服务CIDR         指明宿主机网卡
        "serviceCidr": "11.1.252.0/22", "hostInterface": "eth0", 
        // 容器中此插件创建的网卡名称     是否进行SNAT
        "containerInterface": "veth0", "ipMasq": true
    }
  ],
  "DefaultNetworks": ["galaxy-flannel"]
}</pre>
<p>Pod注解需要使用两个网络：</p>
<pre class="crayon-plain-tag">annotations:
  k8s.v1.cni.cncf.io/networks: "galaxy-k8s-vlan,galaxy-veth-host"</pre>
<p>代码解读： </p>
<pre class="crayon-plain-tag">package main

import (
	"encoding/json"
	"fmt"
	"math"
	"math/rand"
	"net"
	"os"
	"runtime"
	"sort"
	"strconv"
	"time"

	"github.com/containernetworking/cni/pkg/skel"
	"github.com/containernetworking/cni/pkg/types"
	"github.com/containernetworking/cni/pkg/types/current"
	"github.com/containernetworking/cni/pkg/version"
	"github.com/containernetworking/plugins/pkg/ip"
	"github.com/containernetworking/plugins/pkg/ns"
	"github.com/containernetworking/plugins/pkg/utils"
	"github.com/containernetworking/plugins/pkg/utils/sysctl"
	"github.com/coreos/go-iptables/iptables"
	"github.com/j-keck/arping"
	"github.com/vishvananda/netlink"
)

// constants for full jitter backoff in milliseconds, and for nodeport marks
const (
	maxSleep             = 10000 // 10.00s
	baseSleep            = 20    //  0.02
	RPFilterTemplate     = "net.ipv4.conf.%s.rp_filter"
	podRulePriority      = 1024
	nodePortRulePriority = 512
)

func init() {
	// this ensures that main runs only on main thread (thread group leader).
	// since namespace ops (unshare, setns) are done for a single thread, we
	// must ensure that the goroutine does not jump from OS thread to thread
	runtime.LockOSThread()
}

// PluginConf is whatever you expect your configuration json to be. This is whatever
// is passed in on stdin. Your plugin may wish to expose its functionality via
// runtime args, see CONVENTIONS.md in the CNI spec.
type PluginConf struct {
	types.NetConf

	// This is the previous result, when called in the context of a chained
	// plugin. Because this plugin supports multiple versions, we'll have to
	// parse this in two passes. If your plugin is not chained, this can be
	// removed (though you may wish to error if a non-chainable plugin is
	// chained.
	// If you need to modify the result before returning it, you will need
	// to actually convert it to a concrete versioned struct.
	RawPrevResult *map[string]interface{} `json:"prevResult"`
	PrevResult    *current.Result         `json:"-"`

	IPMasq             bool   `json:"ipMasq"`
	HostInterface      string `json:"hostInterface"`
	ServiceCidr        string `json:"serviceCidr"`
	ContainerInterface string `json:"containerInterface"`
	MTU                int    `json:"mtu"`
	TableStart         int    `json:"routeTableStart"`
	NodePortMark       int    `json:"nodePortMark"`
	NodePorts          string `json:"nodePorts"`
}

// parseConfig parses the supplied configuration (and prevResult) from stdin.
func parseConfig(stdin []byte) (*PluginConf, error) {
	conf := PluginConf{}

	if err := json.Unmarshal(stdin, &amp;conf); err != nil {
		return nil, fmt.Errorf("failed to parse network configuration: %v", err)
	}

	// Parse previous result.
	if conf.RawPrevResult != nil {
		resultBytes, err := json.Marshal(conf.RawPrevResult)
		if err != nil {
			return nil, fmt.Errorf("could not serialize prevResult: %v", err)
		}
		res, err := version.NewResult(conf.CNIVersion, resultBytes)
		if err != nil {
			return nil, fmt.Errorf("could not parse prevResult: %v", err)
		}
		conf.RawPrevResult = nil
		conf.PrevResult, err = current.NewResultFromResult(res)
		if err != nil {
			return nil, fmt.Errorf("could not convert result to current version: %v", err)
		}
	}
	// End previous result parsing

	if conf.HostInterface == "" {
		return nil, fmt.Errorf("hostInterface must be specified")
	}

	if conf.ContainerInterface == "" {
		return nil, fmt.Errorf("containerInterface must be specified")
	}

	if conf.NodePorts == "" {
		conf.NodePorts = "30000:32767"
	}

	if conf.NodePortMark == 0 {
		conf.NodePortMark = 0x2000
	}

	// start using tables by default at 256
	if conf.TableStart == 0 {
		conf.TableStart = 256
	}

	return &amp;conf, nil
}

func cmdAdd(args *skel.CmdArgs) error {
	conf, err := parseConfig(args.StdinData)
	if err != nil {
		return err
	}

	// 必须作为插件链调用，并且有前置插件
	if conf.PrevResult == nil {
		return fmt.Errorf("must be called as chained plugin")
	}

	// 从前序插件的结果中得到容器IP列表
	containerIPs := make([]net.IP, 0, len(conf.PrevResult.IPs))
	if conf.CNIVersion != "0.3.0" &amp;&amp; conf.CNIVersion != "0.3.1" {
		for _, ip := range conf.PrevResult.IPs {
			containerIPs = append(containerIPs, ip.Address.IP)
		}
	} else {
		for _, ip := range conf.PrevResult.IPs {
			if ip.Interface == nil {
				continue
			}
			intIdx := *ip.Interface
			if intIdx &gt;= 0 &amp;&amp; intIdx &lt; len(conf.PrevResult.Interfaces) &amp;&amp; conf.PrevResult.Interfaces[intIdx].Name != args.IfName {
				continue
			}
			containerIPs = append(containerIPs, ip.Address.IP)
		}
	}
	if len(containerIPs) == 0 {
		return fmt.Errorf("got no container IPs")
	}
	// 得到宿主机网络接口
	iface, err := netlink.LinkByName(conf.HostInterface)
	if err != nil {
		return fmt.Errorf("failed to lookup %q: %v", conf.HostInterface, err)
	}
	// 得到宿主机网络接口的地址列表
	hostAddrs, err := netlink.AddrList(iface, netlink.FAMILY_ALL)
	if err != nil || len(hostAddrs) == 0 {
		return fmt.Errorf("failed to get host IP addresses for %q: %v", iface, err)
	}

	netns, err := ns.GetNS(args.Netns)
	if err != nil {
		return fmt.Errorf("failed to open netns %q: %v", args.Netns, err)
	}
	defer netns.Close()

	containerIPV4 := false
	containerIPV6 := false
	for _, ipc := range containerIPs {
		if ipc.To4() != nil {
			containerIPV4 = true
		} else {
			containerIPV6 = true
		}
	}
	// 创建VETH对，并且在容器端进行配置：
	// 1. 如果启用了MASQ，则对出口是args.IfName的流量进行SNAT
	// 2. 为宿主机所有IP添加路由，走veth
	// 3. 为K8S服务IP添加旅游，则veth，网关设置为第一个宿主机IP
	hostInterface, _, err := setupContainerVeth(netns, conf.ServiceCidr, conf.ContainerInterface, conf.MTU,
		hostAddrs, conf.IPMasq, containerIPV4, containerIPV6, args.IfName, conf.PrevResult)
	if err != nil {
		return err
	}
	// 配置容器的宿主端：
	if err = setupHostVeth(hostInterface.Name, hostAddrs, conf.IPMasq, conf.TableStart, conf.PrevResult); err != nil {
		return err
	}

	if conf.IPMasq {
		// 在宿主端启用IP转发
		err := enableForwarding(containerIPV4, containerIPV6)
		if err != nil {
			return err
		}

		chain := utils.FormatChainName(conf.Name, args.ContainerID)
		comment := utils.FormatComment(conf.Name, args.ContainerID)
		for _, ipc := range containerIPs {
			addrBits := 128
			if ipc.To4() != nil {
				addrBits = 32
			}
			// 对来自容器IP的流量进行SNAT
			if err = ip.SetupIPMasq(&amp;net.IPNet{IP: ipc, Mask: net.CIDRMask(addrBits, addrBits)}, chain, comment); err != nil {
				return err
			}
		}
	}

	// 配置NodePort相关的iptables规则
	if err = setupNodePortRule(conf.HostInterface, conf.NodePorts, conf.NodePortMark); err != nil {
		return err
	}

	// Pass through the result for the next plugin
	return types.PrintResult(conf.PrevResult, conf.CNIVersion)
}

func setupContainerVeth(netns ns.NetNS, serviceCidr string, ifName string, mtu int,
	hostAddrs []netlink.Addr, masq, containerIPV4, containerIPV6 bool, k8sIfName string,
	pr *current.Result) (*current.Interface, *current.Interface, error) {
	hostInterface := &amp;current.Interface{}
	containerInterface := &amp;current.Interface{}
	// 在容器网络命名空间（netns）中执行
	err := netns.Do(func(hostNS ns.NetNS) error {
		// 创建VETH对，一端自动放入hostNS（这个变量netns.Do函数自动提供，为初始网络命名空间）
		hostVeth, contVeth0, err := ip.SetupVeth(ifName, mtu, hostNS)
		if err != nil {
			return err
		}
		hostInterface.Name = hostVeth.Name
		hostInterface.Mac = hostVeth.HardwareAddr.String()
		containerInterface.Name = contVeth0.Name
		// ip.SetupVeth函数不会获取VETH对的peer（第二个接口）的MAC地址，因此这里需要执行查询
		containerNetlinkIface, _ := netlink.LinkByName(contVeth0.Name)
		containerInterface.Mac = containerNetlinkIface.Attrs().HardwareAddr.String()
		containerInterface.Sandbox = netns.Path()

		// 本次CNI调用产生的两个网络接口，纳入到Result
		pr.Interfaces = append(pr.Interfaces, hostInterface, containerInterface)

		// 后面只是用到index属性，用上面Link对象不行么？
		contVeth, err := net.InterfaceByName(ifName)
		if err != nil {
			return fmt.Errorf("failed to look up %q: %v", ifName, err)
		}

		if masq {
			// 支持IPv4/IPv6的IP转发
			err := enableForwarding(containerIPV4, containerIPV6)
			if err != nil {
				return err
			}

			// 如果出口网卡是k8sIfName（容器主接口，通常是eth0，kubelet调用galaxy-sdn时提供）
			// ，则进行源地址转换
			err = setupSNAT(k8sIfName, "kube-proxy SNAT")
			if err != nil {
				return fmt.Errorf("failed to enable SNAT on %q: %v", k8sIfName, err)
			}
		}

		// 为宿主机的每个网络接口的地址添加路由条目
		for _, ipc := range hostAddrs {
			addrBits := 128
			if ipc.IP.To4() != nil {
				addrBits = 32
			}
			// 这些地址的出口网卡都设置为
			err := netlink.RouteAdd(&amp;netlink.Route{
				LinkIndex: contVeth.Index, // 新添加的VETH
				Scope:     netlink.SCOPE_LINK,
				Dst: &amp;net.IPNet{
					IP:   ipc.IP,
					Mask: net.CIDRMask(addrBits, addrBits),
				},
			})

			if err != nil {
				return fmt.Errorf("failed to add host route dst %v: %v", ipc.IP, err)
			}
		}

		_, serviceNet, err := net.ParseCIDR(serviceCidr)
		if err != nil {
			return fmt.Errorf("failed to parse service cidr :%v", err)
		}

		// 将K8S服务网段的路由设置为：出口网卡新添加的VETH，网关为宿主机第一个IP
		err = netlink.RouteAdd(&amp;netlink.Route{
			LinkIndex: contVeth.Index,
			Scope:     netlink.SCOPE_UNIVERSE,
			Dst:       serviceNet,
			// 封包发给宿主机第一个IP/网络接口处理。VETH的宿主端没有连接到什么网络接口
			// 这些网络接口可以作为下一跳，因为和VETH宿主端属于同一网络命名空间
			Gw: hostAddrs[0].IP,
		})
		if err != nil {
			return fmt.Errorf("failed to add service cidr route %v: %v", hostAddrs[0].IP, err)
		}

		// 为所有IPv4地址发送免费ARP
		for _, ipc := range pr.IPs {
			if ipc.Version == "4" {
				_ = arping.GratuitousArpOverIface(ipc.Address.IP, *contVeth)
			}
		}

		return nil
	})
	if err != nil {
		return nil, nil, err
	}
	return hostInterface, containerInterface, nil
}

func enableForwarding(ipv4 bool, ipv6 bool) error {
	if ipv4 {
		err := ip.EnableIP4Forward()
		if err != nil {
			return fmt.Errorf("Could not enable IPv6 forwarding: %v", err)
		}
	}
	if ipv6 {
		err := ip.EnableIP6Forward()
		if err != nil {
			return fmt.Errorf("Could not enable IPv6 forwarding: %v", err)
		}
	}
	return nil
}

func setupSNAT(ifName string, comment string) error {
	ipt, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
	if err != nil {
		return fmt.Errorf("failed to locate iptables: %v", err)
	}
	rulespec := []string{"-o", ifName, "-j", "MASQUERADE"}
	//if ipt.HasRandomFully() {
	//	rulespec = append(rulespec, "--random-fully")
	//}
	rulespec = append(rulespec, "-m", "comment", "--comment", comment)
	return ipt.AppendUnique("nat", "POSTROUTING", rulespec...)
}

func setupHostVeth(vethName string, hostAddrs []netlink.Addr, masq bool, tableStart int, result *current.Result) error {
	// no IPs to route
	if len(result.IPs) == 0 {
		return nil
	}

	// lookup by name as interface ids might have changed
	veth, err := net.InterfaceByName(vethName)
	if err != nil {
		return fmt.Errorf("failed to lookup %q: %v", vethName, err)
	}

	// 对于所有容器IP，出口设置为VETH
	for _, ipc := range result.IPs {
		addrBits := 128
		if ipc.Address.IP.To4() != nil {
			addrBits = 32
		}

		err := netlink.RouteAdd(&amp;netlink.Route{
			LinkIndex: veth.Index,
			Scope:     netlink.SCOPE_LINK,
			Dst: &amp;net.IPNet{
				IP:   ipc.Address.IP,
				Mask: net.CIDRMask(addrBits, addrBits),
			},
		})

		if err != nil {
			return fmt.Errorf("failed to add host route dst %v: %v", ipc.Address.IP, err)
		}
	}

	// 为来自Pod的，目的地址是VPC的配置策略路由
	err = addPolicyRules(veth, result.IPs[0], result.Routes, tableStart)
	if err != nil {
		return fmt.Errorf("failed to add policy rules: %v", err)
	}

	// 为所有宿主机端的IP发送免费ARP
	for _, ipc := range hostAddrs {
		if ipc.IP.To4() != nil {
			_ = arping.GratuitousArpOverIface(ipc.IP, *veth)
		}
	}

	return nil
}

func addPolicyRules(veth *net.Interface, ipc *current.IPConfig, routes []*types.Route, tableStart int) error {
	table := -1

	// 对路由，来自先前插件调用的结果，进行排序
	sort.Slice(routes, func(i, j int) bool {
		return routes[i].Dst.String() &lt; routes[j].Dst.String()
	})

	// 尝试最多10次，向空表（table slot）写入路由
	for i := 0; i &lt; 10 &amp;&amp; table == -1; i++ {
		var err error
		// 寻找空白路由表
		table, err = findFreeTable(tableStart + rand.Intn(1000))
		if err != nil {
			return err
		}

		// 将所有路由添加到路由表
		for _, route := range routes {
			err := netlink.RouteAdd(&amp;netlink.Route{
				LinkIndex: veth.Index,     // 出口设置为宿主机VETH
				Dst:       &amp;route.Dst,     // 目标是先前CNI调用发现的路由，这些路由可能来自云提供商（VPC）
				Gw:        ipc.Address.IP, // 将Result的第一个IP作为网关
				Table:     table,          // 写在这个表中
			})
			if err != nil {
				table = -1
				break
			}
		}

		if table == -1 {
			// failed to add routes so sleep and try again on a different table
			wait := time.Duration(rand.Intn(int(math.Min(maxSleep,
				baseSleep*math.Pow(2, float64(i)))))) * time.Millisecond
			fmt.Fprintf(os.Stderr, "route table collision, retrying in %v\n", wait)
			time.Sleep(wait)
		}
	}

	// ensure we have a route table selected
	if table == -1 {
		return fmt.Errorf("failed to add routes to a free table")
	}

	// 创建路由策略
	rule := netlink.NewRule()
	// 如果流量来自VETH宿主端，也就是来自Pod
	rule.IifName = veth.Name
	rule.Table = table
	rule.Priority = podRulePriority

	err := netlink.RuleAdd(rule)
	if err != nil {
		return fmt.Errorf("failed to add policy rule %v: %v", rule, err)
	}

	return nil
}

func findFreeTable(start int) (int, error) {
	allocatedTableIDs := make(map[int]bool)
	// 遍历所有IPv4/IPv6的路由规则
	for _, family := range []int{netlink.FAMILY_V4, netlink.FAMILY_V6} {
		rules, err := netlink.RuleList(family)
		if err != nil {
			return -1, err
		}
		for _, rule := range rules {
			// 收集所有已经占用的table slot
			allocatedTableIDs[rule.Table] = true
		}
	}
	// 寻找第一个空白的slot
	for i := start; i &lt; math.MaxUint32; i++ {
		if !allocatedTableIDs[i] {
			return i, nil
		}
	}
	return -1, fmt.Errorf("failed to find free route table")
}

func setupNodePortRule(ifName string, nodePorts string, nodePortMark int) error {
	ipt, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
	if err != nil {
		return fmt.Errorf("failed to locate iptables: %v", err)
	}

	// 确保NodePort流量被正确的标记
	if err := ipt.AppendUnique("mangle", "PREROUTING", "-i", ifName, "-p", "tcp",
		"--dport", nodePorts, "-j", "CONNMARK", "--set-mark", strconv.Itoa(nodePortMark),
		"-m", "comment", "--comment", "NodePort Mark"); err != nil {
		return err
	}
	if err := ipt.AppendUnique("mangle", "PREROUTING", "-i", ifName, "-p", "udp",
		"--dport", nodePorts, "-j", "CONNMARK", "--set-mark", strconv.Itoa(nodePortMark),
		"-m", "comment", "--comment", "NodePort Mark"); err != nil {
		return err
	}
	if err := ipt.AppendUnique("mangle", "PREROUTING", "-i", "veth+", "-j", "CONNMARK",
		"--restore-mark", "-m", "comment", "--comment", "NodePort Mark"); err != nil {
		return err
	}

	// 在宿主机网络接口上启用非严格的RP filter
	_, err = sysctl.Sysctl(fmt.Sprintf(RPFilterTemplate, ifName), "2")
	if err != nil {
		return fmt.Errorf("failed to set RP filter to loose for interface %q: %v", ifName, err)
	}

	// 对于标记为NodePort的流量，添加策略路由
	rule := netlink.NewRule()
	rule.Mark = nodePortMark
	rule.Table = 254 // main table
	rule.Priority = nodePortRulePriority

	exists := false
	rules, err := netlink.RuleList(netlink.FAMILY_V4)
	if err != nil {
		return fmt.Errorf("Unable to retrive IP rules %v", err)
	}

	for _, r := range rules {
		if r.Table == rule.Table &amp;&amp; r.Mark == rule.Mark &amp;&amp; r.Priority == rule.Priority {
			exists = true
			break
		}
	}
	if !exists {
		err := netlink.RuleAdd(rule)
		if err != nil {
			return fmt.Errorf("failed to add policy rule %v: %v", rule, err)
		}
	}

	return nil
}

// cmdDel is called for DELETE requests
func cmdDel(args *skel.CmdArgs) error {
	conf, err := parseConfig(args.StdinData)
	if err != nil {
		return err
	}

	if args.Netns == "" {
		return nil
	}

	// 网络命名空间存在，进行清理
	var ipnets []netlink.Addr
	vethPeerIndex := -1
	_ = ns.WithNetNSPath(args.Netns, func(_ ns.NetNS) error {
		var err error

		if conf.IPMasq {
			iface, err := netlink.LinkByName(args.IfName)
			if err != nil {
				if err.Error() == "Link not found" {
					return ip.ErrLinkNotFound
				}
				return fmt.Errorf("failed to lookup %q: %v", args.IfName, err)
			}
			// 从args.IfName（通常是eth0）获取IP地址列表
			ipnets, err = netlink.AddrList(iface, netlink.FAMILY_ALL)
			if err != nil || len(ipnets) == 0 {
				return fmt.Errorf("failed to get IP addresses for %q: %v", args.IfName, err)
			}
		}

		vethIface, err := netlink.LinkByName(conf.ContainerInterface)
		if err != nil &amp;&amp; err != ip.ErrLinkNotFound {
			return err
		}
		vethPeerIndex, _ = netlink.VethPeerIndex(&amp;netlink.Veth{LinkAttrs: *vethIface.Attrs()})
		return nil
	})

	if conf.IPMasq {
		chain := utils.FormatChainName(conf.Name, args.ContainerID)
		comment := utils.FormatComment(conf.Name, args.ContainerID)
		for _, ipn := range ipnets {
			addrBits := 128
			if ipn.IP.To4() != nil {
				addrBits = 32
			}

			_ = ip.TeardownIPMasq(&amp;net.IPNet{IP: ipn.IP, Mask: net.CIDRMask(addrBits, addrBits)}, chain, comment)
		}

		if vethPeerIndex != -1 {
			link, err := netlink.LinkByIndex(vethPeerIndex)
			if err != nil {
				return nil
			}

			rule := netlink.NewRule()
			rule.IifName = link.Attrs().Name
			// ignore errors as we might be called multiple times
			_ = netlink.RuleDel(rule)
			_ = netlink.LinkDel(link)
		}
	}

	return nil
}

func main() {
	rand.Seed(time.Now().UnixNano())
	skel.PluginMain(cmdAdd, cmdDel, version.All)
}&amp;nbsp;</pre>
<div class="blog_h2"><span class="graybg">Galaxy守护进程</span></div>
<div class="blog_h3"><span class="graybg">初始化</span></div>
<p>这个守护进程由DaemonSet galaxy提供，在所有K8S节点上运行，入口点如下：</p>
<pre class="crayon-plain-tag">package main

import (
	"math/rand"
	"time"

	"github.com/spf13/pflag"
	"k8s.io/component-base/cli/flag"
	"k8s.io/component-base/logs"
	glog "k8s.io/klog"
	"tkestack.io/galaxy/pkg/galaxy"
	"tkestack.io/galaxy/pkg/signal"
	"tkestack.io/galaxy/pkg/utils/ldflags"
)

func main() {
	// 随机化
	rand.Seed(time.Now().UTC().UnixNano())
	// 创建Galaxy结构
	galaxy := galaxy.NewGalaxy()
	galaxy.AddFlags(pflag.CommandLine)
	flag.InitFlags()
	logs.InitLogs()
	defer logs.FlushLogs()

	ldflags.PrintAndExitIfRequested()
	// 启动Galaxy
	if err := galaxy.Start(); err != nil {
		glog.Fatalf("Error start galaxy: %v", err)
	}
	// 等待信号，并停止Galaxy
	signal.BlockSignalHandler(func() {
		if err := galaxy.Stop(); err != nil {
			glog.Errorf("Error stop galaxy: %v", err)
		}
	})
}</pre>
<p>Galaxy结构的规格如下：</p>
<pre class="crayon-plain-tag">type Galaxy struct {
	// 对应Galaxy主配置文件
	JsonConf
	// 对应命令行选项
	*options.ServerRunOptions
	quitChan  chan struct{}
	dockerCli *docker.DockerInterface
	netConf   map[string]map[string]interface{}
	// 负责处理端口映射
	pmhandler *portmapping.PortMappingHandler
	client    kubernetes.Interface
	pm        *policy.PolicyManager
}

type JsonConf struct {
	NetworkConf     []map[string]interface{}
	DefaultNetworks []string
	ENIIPNetwork string
}</pre>
<p>初始化过程：</p>
<pre class="crayon-plain-tag">func NewGalaxy() *Galaxy {
	g := &amp;Galaxy{
		ServerRunOptions: options.NewServerRunOptions(),
		quitChan:         make(chan struct{}),
		netConf:          map[string]map[string]interface{}{},
	}
	return g
}

func (g *Galaxy) Init() error {
	if g.JsonConfigPath == "" {
		return fmt.Errorf("json config is required")
	}
	data, err := ioutil.ReadFile(g.JsonConfigPath)
	if err != nil {
		return fmt.Errorf("read json config: %v", err)
	}
	// 解析主配置文件
	if err := json.Unmarshal(data, &amp;g.JsonConf); err != nil {
		return fmt.Errorf("bad config %s: %v", string(data), err)
	}
	glog.Infof("Json Config: %s", string(data))
	// 配置合法性的简单校验
	if err := g.checkNetworkConf(); err != nil {
		return err
	}
	// 需要访问本机Docker
	dockerClient, err := docker.NewDockerInterface()
	if err != nil {
		return err
	}
	g.dockerCli = dockerClient
	g.pmhandler = portmapping.New("")
	return nil
}</pre>
<p>启动过程：</p>
<pre class="crayon-plain-tag">func (g *Galaxy) Start() error {
	// 初始化Galaxy配置
	if err := g.Init(); err != nil {
		return err
	}
	// 创建K8S客户端
	g.initk8sClient()
	// 启动Flannel垃圾回收器
	gc.NewFlannelGC(g.dockerCli, g.quitChan, g.cleanIPtables).Run()
	// 启用或禁用bridge-nf-call-iptables，此参数可以控制网桥转发的封包被不被iptables过滤
	kernel.BridgeNFCallIptables(g.quitChan, g.BridgeNFCallIptables)
	// 配置iptables转发
	kernel.IPForward(g.quitChan, g.IPForward)
	// 启动时，遍历节点上已有的Pod，对于需要进行端口映射的，执行端口映射
	if err := g.setupIPtables(); err != nil {
		return err
	}
	if g.NetworkPolicy {
		g.pm = policy.New(g.client, g.quitChan)
		go wait.Until(g.pm.Run, 3*time.Minute, g.quitChan)
	}
	if g.RouteENI {
		// 使用腾讯云ENI需要关闭反向路径过滤
		kernel.DisableRPFilter(g.quitChan)
		eni.SetupENIs(g.quitChan)
	}
	// 在UDS上监听、启动HTTP服务器、注册路由
	return g.StartServer()
}</pre>
<div class="blog_h3"><span class="graybg">CNI请求处理</span></div>
<p>上述代码结尾的g.StartServer()会调用下面的方法注册路由：</p>
<pre class="crayon-plain-tag">func (g *Galaxy) installHandlers() {
	ws := new(restful.WebService)
	ws.Route(ws.GET("/cni").To(g.cni))
	ws.Route(ws.POST("/cni").To(g.cni))
	restful.Add(ws)
}

// 处理CNI插件转发来的CNI请求
func (g *Galaxy) cni(r *restful.Request, w *restful.Response) {
	data, err := ioutil.ReadAll(r.Request.Body)
	if err != nil {
		glog.Warningf("bad request %v", err)
		http.Error(w, fmt.Sprintf("err read body %v", err), http.StatusBadRequest)
		return
	}
	defer r.Request.Body.Close() // nolint: errcheck
	// 将请求转换为CNIRequest，然后转换为PodRequest
	req, err := galaxyapi.CniRequestToPodRequest(data)
	if err != nil {
		glog.Warningf("bad request %v", err)
		http.Error(w, fmt.Sprintf("%v", err), http.StatusBadRequest)
		return
	}
	req.Path = strings.TrimRight(fmt.Sprintf("%s:%s", req.Path, strings.Join(g.CNIPaths, ":")), ":")
	// 处理CNI请求
	result, err := g.requestFunc(req)
	if err != nil {
		http.Error(w, fmt.Sprintf("%v", err), http.StatusInternalServerError)
	} else {
		// Empty response JSON means success with no body
		w.Header().Set("Content-Type", "application/json")
		if _, err := w.Write(result); err != nil {
			glog.Warningf("Error writing %s HTTP response: %v", req.Command, err)
		}
	}
}</pre>
<p>当CNI插件galaxy-sdn调用Galaxy守护进程的/cni端点时，Galaxy会将请求从：</p>
<pre class="crayon-plain-tag">// Request sent to the Galaxy by the Galaxy SDN CNI plugin
type CNIRequest struct {
    // CNI environment variables, like CNI_COMMAND and CNI_NETNS
    Env map[string]string `json:"env,omitempty"`
    // CNI configuration passed via stdin to the CNI plugin
    Config []byte `json:"config,omitempty"`
}</pre>
<p>转换为PodRequest：</p>
<pre class="crayon-plain-tag">type PodRequest struct {
    // 需要执行的CNI命令
    Command string
    // 来自环境变量CNI_ARGS
    PodNamespace string
    // 来自环境变量CNI_ARGS
    PodName string
    // kubernetes pod ports
    Ports []k8s.Port
    // 存放操作结果的通道
    Result chan *PodResult
    // 通过环境变量传来的CNI_IFNAME、CNI_COMMAND、CNI_ARGS...
    *skel.CmdArgs
    // Galaxy需要委托其它CNI插件完成工作，这是传递给那些插件的参数
    //                 插件类型    参数名  参数值
    ExtendedCNIArgs map[string]map[string]json.RawMessage
}

// 请求处理结果
type PodResult struct {
    Response []byte
    Err error
}</pre>
<p>然后调用requestFunc方法处理转换后的CNI请求：</p>
<pre class="crayon-plain-tag">func (g *Galaxy) requestFunc(req *galaxyapi.PodRequest) (data []byte, err error) {
	start := time.Now()
	glog.Infof("%v, %s+", req, start.Format(time.StampMicro))
	// 处理ADD命令
	if req.Command == cniutil.COMMAND_ADD {
		defer func() {
			glog.Infof("%v, data %s, err %v, %s-", req, string(data), err, start.Format(time.StampMicro))
		}()
		var pod *corev1.Pod
		// 查找Pod
		pod, err = g.getPod(req.PodName, req.PodNamespace)
		if err != nil {
			return
		}
		result, err1 := g.cmdAdd(req, pod)
		if err1 != nil {
			err = err1
			return
		} else {
			// 转换结果格式
			result020, err2 := convertResult(result)
			if err2 != nil {
				err = err2
			} else {
				data, err = json.Marshal(result)
				if err != nil {
					return
				}
				// 如果处理成功，则执行端口映射，回顾一下启动的时候会对所有本节点现存的Pod进行端口映射
				err = g.setupPortMapping(req, req.ContainerID, result020, pod)
				if err != nil {
					g.cleanupPortMapping(req)
					return
				}
				pod.Status.PodIP = result020.IP4.IP.IP.String()
				// 处理网络策略
				if g.pm != nil {
					if err := g.pm.SyncPodChains(pod); err != nil {
						glog.Warning(err)
					}
					g.pm.SyncPodIPInIPSet(pod, true)
				}
			}
		}
	} else if req.Command == cniutil.COMMAND_DEL {
		defer glog.Infof("%v err %v, %s-", req, err, start.Format(time.StampMicro))
		err = cniutil.CmdDel(req.CmdArgs, -1)
		if err == nil {
			err = g.cleanupPortMapping(req)
		}
	} else {
		err = fmt.Errorf("unknown command %s", req.Command)
	}
	return
}</pre>
<p>requestFunc方法就是查询出Pod，将其作为参数的一部分，再调用 cmdAdd方法。需要注意，由于CNI版本很低，仅仅支持ADD/DEL两个命令。</p>
<p>ADD命令的处理逻辑如下：</p>
<pre class="crayon-plain-tag">func (g *Galaxy) cmdAdd(req *galaxyapi.PodRequest, pod *corev1.Pod) (types.Result, error) {
	// 通过解析Pod的注解、spec特殊字段，来构造出Pod的网络需求
	networkInfos, err := g.resolveNetworks(req, pod)
	if err != nil {
		return nil, err
	}
	return cniutil.CmdAdd(req.CmdArgs, networkInfos)
}

func CmdAdd(cmdArgs *skel.CmdArgs, networkInfos []*NetworkInfo) (types.Result, error) {
	// 每个NetworkInfo代表Pod需要加入的一个网络
	if len(networkInfos) == 0 {
		return nil, fmt.Errorf("No network info returned")
	}
	// 将网络需求的JSON格式保存在/var/lib/cni/galaxy/$ContainerID
	if err := saveNetworkInfo(cmdArgs.ContainerID, networkInfos); err != nil {
		return nil, fmt.Errorf("Error save network info %v for %s: %v", networkInfos, cmdArgs.ContainerID, err)
	}
	var (
		err    error
		// 前一个网络的结果
		result types.Result
	)
	// 处理并满足每一个网络需求
	for idx, networkInfo := range networkInfos {
		// 将来自k8s.v1.cni.galaxy.io/args注解的CNI参数添加到参数数组
		cmdArgs.Args = strings.TrimRight(fmt.Sprintf("%s;%s", cmdArgs.Args, BuildCNIArgs(networkInfo.Args)), ";")
		if result != nil {
			networkInfo.Conf["prevResult"] = result
		}
		// 委托给具体的CNI插件的ADD命令
		result, err = DelegateAdd(networkInfo.Conf, cmdArgs, networkInfo.IfName)
		if err != nil {
			// 如果失败，删除所有已经创建的CNI
			glog.Errorf("fail to add network %s: %v, begin to rollback and delete it", networkInfo.Args, err)
			// 从idx开始，倒过来依次为每种网络需求调用DEL命令
			delErr := CmdDel(cmdArgs, idx)
			glog.Warningf("fail to delete cni in rollback %v", delErr)
			return nil, fmt.Errorf("fail to establish network %s:%v", networkInfo.Args, err)
		}
	}
	if err != nil {
		return nil, err
	}
	return result, nil
}</pre>
<p>处理每一个网络需求时，会委托给相应的CNI插件：</p>
<pre class="crayon-plain-tag">// 每个插件的NetConf是在前面resolveNetworks时，从confdir=/etc/cni/net.d/读取出来的
func DelegateAdd(netconf map[string]interface{}, args *skel.CmdArgs, ifName string) (types.Result, error) {
	netconfBytes, err := json.Marshal(netconf)
	if err != nil {
		return nil, fmt.Errorf("error serializing delegate netconf: %v", err)
	}
	typ, err := getNetworkType(netconf)
	if err != nil {
		return nil, err
	}
	pluginPath, err := invoke.FindInPath(typ, strings.Split(args.Path, ":"))
	if err != nil {
		return nil, err
	}
	// 通过命令行调用
	glog.Infof("delegate add %s args %s conf %s", args.ContainerID, args.Args, string(netconfBytes))
	return invoke.ExecPluginWithResult(pluginPath, netconfBytes, &amp;invoke.Args{
		Command:       "ADD",
		ContainerID:   args.ContainerID,
		NetNS:         args.Netns,
		PluginArgsStr: args.Args,
		IfName:        ifName,
		Path:          args.Path,
	})
}</pre>
<div class="blog_h3"><span class="graybg">小结</span></div>
<p>这里做个小结：</p>
<ol>
<li>工作负载的网络需求（需要加入到哪些CNI网络），可以通过注解、Spec配置</li>
<li>Galaxy通过读取Pod注解、Spec，构造出网络列表</li>
<li>Galaxy会遍历CNI网络列表，依次通过命令行调用对应的CNI插件的ADD命令</li>
<li>如果遍历过程中出错，逆序的调用已经ADD的CNI插件的DEL命令</li>
</ol>
<p>第3、4其实就是新版本的CNI中NetworkList提供的能力。</p>
<div class="blog_h2"><span class="graybg">Galaxy IPAM</span></div>
<p>和Galaxy守护进程一样，Galaxy IPAM也是一个HTTP服务器。只是前者仅仅供本机的CNI插件galaxy-sdn调用，因此使用UDS，而Galaxy IPAM是供Scheduler调用，因此使用TCP。</p>
<div class="blog_h3"><span class="graybg">初始化</span></div>
<p>入口点如下：</p>
<pre class="crayon-plain-tag">func main() {
	// initialize rand seed
	rand.Seed(time.Now().UTC().UnixNano())

	s := server.NewServer()
	// add command line args
	s.AddFlags(pflag.CommandLine)

	flag.InitFlags()
	logs.InitLogs()
	defer logs.FlushLogs()

	// if checking version, print it and exit
	ldflags.PrintAndExitIfRequested()

	if err := s.Start(); err != nil {
		fmt.Fprintf(os.Stderr, "%v\n", err) // nolint: errcheck
		os.Exit(1)
	}
}</pre>
<p>启动Galaxy IPAM服务器： </p>
<pre class="crayon-plain-tag">func (s *Server) Start() error {
	if err := s.init(); err != nil {
		return fmt.Errorf("init server: %v", err)
	}
	// 启用InformerFactory监控K8S资源变化
	s.StartInformers(s.stopChan)
	// 多实例部署的Leader选举
	if s.LeaderElection.LeaderElect &amp;&amp; s.leaderElectionConfig != nil {
		leaderelection.RunOrDie(context.Background(), *s.leaderElectionConfig)
		return nil
	}
	return s.Run()
}

// Galaxy IPAM初始化
func (s *Server) init() error {
	// 读取配置文件
	if options.JsonConfigPath == "" {
		return fmt.Errorf("json config is required")
	}
	data, err := ioutil.ReadFile(options.JsonConfigPath)
	if err != nil {
		return fmt.Errorf("read json config: %v", err)
	}
	if err := json.Unmarshal(data, &amp;s.JsonConf); err != nil {
		return fmt.Errorf("bad config %s: %v", string(data), err)
	}
	// 初始化K8S客户端、IPAMContext
	s.initk8sClient()
	// 此浮动IP插件，实现了PodWatcher接口，能够监听Pod的生命周期事件
	s.plugin, err = schedulerplugin.NewFloatingIPPlugin(s.SchedulePluginConf, s.IPAMContext)
	if err != nil {
		return err
	}
	// 当有Pod事件后，调用浮动IP插件
	s.PodInformer.Informer().AddEventHandler(eventhandler.NewPodEventHandler(s.plugin))
	return nil
}

func (s *Server) Run() error {
	// 初始化浮动IP插件
	if err := s.plugin.Init(); err != nil {
		return err
	}
	// 运行浮动IP插件
	s.plugin.Run(s.stopChan)
	// 注册API路由
	go s.startAPIServer()
	// 注册供Scheduler调度的路由 /v1/filter、/v1/priority、/v1/bind
	s.startServer()
	return nil
}</pre>
<div class="blog_h3"><span class="graybg">FloatingIPPlugin</span></div>
<p>在上面的初始化流程中我们看到，在运行Galaxy IPAM服务器时，会初始化并运行s.plugin。类型为FloatingIPPlugin。</p>
<p>它是一个Pod事件监听器，实现接口：</p>
<pre class="crayon-plain-tag">type PodWatcher interface {
	AddPod(pod *corev1.Pod) error
	UpdatePod(oldPod, newPod *corev1.Pod) error
	DeletePod(pod *corev1.Pod) error
}</pre>
<p>当Pod事件发生后，上述方法会被调用。</p>
<p>它的初始化逻辑如下：</p>
<pre class="crayon-plain-tag">//                                  这个上下文包含各种客户端、Lister、Informer
func NewFloatingIPPlugin(conf Conf, ctx *context.IPAMContext) (*FloatingIPPlugin, error) {
	conf.validate()
	glog.Infof("floating ip config: %v", conf)
	plugin := &amp;FloatingIPPlugin{
		nodeSubnet:  make(map[string]*net.IPNet),
		IPAMContext: ctx,
		conf:        &amp;conf,
		unreleased:  make(chan *releaseEvent, 50000),
		// 这是一个哈希，每个key都可以被请求加锁
		dpLockPool:  keymutex.NewHashed(500000),
		podLockPool: keymutex.NewHashed(500000),
		crdKey:      NewCrdKey(ctx.ExtensionLister),
		crdCache:    crd.NewCrdCache(ctx.DynamicClient, ctx.ExtensionLister, 0),
	}
	// 初始化IPAM
	plugin.ipam = floatingip.NewCrdIPAM(ctx.GalaxyClient, plugin.FIPInformer)
	if conf.CloudProviderGRPCAddr != "" {
		plugin.cloudProvider = cloudprovider.NewGRPCCloudProvider(conf.CloudProviderGRPCAddr)
	}
	return plugin, nil
}

func (p *FloatingIPPlugin) Init() error {
	if len(p.conf.FloatingIPs) &gt; 0 {
		// 初始化IP池
		if err := p.ipam.ConfigurePool(p.conf.FloatingIPs); err != nil {
			return err
		}
	} else {
		// 配置文件中没有浮动IP，从ConfigMap中拉取
		glog.Infof("empty floatingips from config, fetching from configmap")
		if err := wait.PollInfinite(time.Second, func() (done bool, err error) {
			updated, err := p.updateConfigMap()
			if err != nil {
				glog.Warning(err)
			}
			return updated, nil
		}); err != nil {
			return fmt.Errorf("failed to get floatingip config from configmap: %v", err)
		}
	}
	glog.Infof("plugin init done")
	return nil
}</pre>
<p>它的run方法的逻辑，主要包括三部分：</p>
<pre class="crayon-plain-tag">func (p *FloatingIPPlugin) Run(stop chan struct{}) {
	if len(p.conf.FloatingIPs) == 0 {
		go wait.Until(func() {
// 1. 定期刷新ConfigMap floatingip-config，获取最新的浮动IP配置，并调用ipam.ConfigurePool配置IP池
			if _, err := p.updateConfigMap(); err != nil {
				glog.Warning(err)
			}
		}, time.Minute, stop)
	}
	go wait.Until(func() {
// 2. 定期同步Pod状态，必要的情况下进行IP释放
		if err := p.resyncPod(); err != nil {
			glog.Warningf("resync pod: %v", err)
		}
		p.syncPodIPsIntoDB()
	}, time.Duration(p.conf.ResyncInterval)*time.Minute, stop)
	for i := 0; i &lt; 5; i++ {
// 3. 从通道中读取IP释放事件，解除IP和Pod的绑定关系
		go p.loop(stop)
	}
}

func (p *FloatingIPPlugin) updateConfigMap() (bool, error) {
	// 拉取ConfigMap floatingip-config
	cm, err := p.Client.CoreV1().ConfigMaps(p.conf.ConfigMapNamespace).Get(p.conf.ConfigMapName, v1.GetOptions{})
	if err != nil {
		return false, fmt.Errorf("failed to get floatingip configmap %s_%s: %v", p.conf.ConfigMapName,
			p.conf.ConfigMapNamespace, err)
	}
	val, ok := cm.Data[p.conf.FloatingIPKey]
	if !ok {
		return false, fmt.Errorf("configmap %s_%s doesn't have a key floatingips", p.conf.ConfigMapName,
			p.conf.ConfigMapNamespace)
	}
	var updated bool
	// 调用IPAM，更新IP池
	if updated, err = p.ensureIPAMConf(&amp;p.lastIPConf, val); err != nil {
		return false, err
	}
	defer func() {
		if !updated {
			return
		}
		// 浮动IP配置更新，则节点允许的子网信息被清空
		p.nodeSubnetLock.Lock()
		defer p.nodeSubnetLock.Unlock()
		p.nodeSubnet = map[string]*net.IPNet{}
	}()
	return true, nil
}

// 释放以下Pod的IP
// 1. 所有者TAPP不存在的、已被删除的Pod
// 2. 所有者StatefulSet/Deployment存在、已被删除的Pod，但是没有配置IP为不变的
// 3. 所有者Deployment不需要这么多IP的、已被删除的的Pod
// 4. 所有者StatefulSet的副本数超过小于被删除Pod索引的
// 5. 未被删除，但是被驱逐的Pod
func (p *FloatingIPPlugin) resyncPod() error {
	glog.V(4).Infof("resync pods+")
	defer glog.V(4).Infof("resync pods-")
	resyncMeta := &amp;resyncMeta{}
	if err := p.fetchChecklist(resyncMeta); err != nil {
		return err
	}
	p.resyncAllocatedIPs(resyncMeta)
	return nil
}

// 拉取并处理待释放事件
func (p *FloatingIPPlugin) loop(stop chan struct{}) {
	for {
		select {
		case &lt;-stop:
			return
		case event := &lt;-p.unreleased:
			go func(event *releaseEvent) {
				// 解除Pod和IP地址的绑定关系
				if err := p.unbind(event.pod); err != nil {
					event.retryTimes++
					if event.retryTimes &gt; 3 {
						// leave it to resync to protect chan from explosion
						glog.Errorf("abort unbind for pod %s, retried %d times: %v", util.PodName(event.pod),
							event.retryTimes, err)
					} else {
						glog.Warningf("unbind pod %s failed for %d times: %v", util.PodName(event.pod),
							event.retryTimes, err)
						// backoff time if required
						time.Sleep(100 * time.Millisecond * time.Duration(event.retryTimes))
						p.unreleased &lt;- event
					}
				}
			}(event)
		}
	}
}</pre>
<p>当监听到Pod事件时，可能需要和IPAM同步Pod IP，或者产生一个待释放事件：</p>
<pre class="crayon-plain-tag">// AddPod does nothing
func (p *FloatingIPPlugin) AddPod(pod *corev1.Pod) error {
	return nil
}

// Pod更新时，和IPAM同步Pod IP
func (p *FloatingIPPlugin) UpdatePod(oldPod, newPod *corev1.Pod) error {
	if !p.hasResourceName(&amp;newPod.Spec) {
		return nil
	}
	// 先前的pod.Status.Phase不是终点状态（Failed/Succeeded），这一次是
	// 意味着Pod运行完毕，需要释放IP。通常是Job类Pod
	if !finished(oldPod) &amp;&amp; finished(newPod) {
		// Deployments will leave evicted pods
		// If it's a evicted one, release its ip
		glog.Infof("release ip from %s_%s, phase %s", newPod.Name, newPod.Namespace, string(newPod.Status.Phase))
		p.unreleased &lt;- &amp;releaseEvent{pod: newPod}
		return nil
	}
	// 同步Pod IP
	if err := p.syncPodIP(newPod); err != nil {
		glog.Warningf("failed to sync pod ip: %v", err)
	}
	return nil
}

func (p *FloatingIPPlugin) DeletePod(pod *corev1.Pod) error {
	if !p.hasResourceName(&amp;pod.Spec) {
		return nil
	}
	glog.Infof("handle pod delete event: %s_%s", pod.Name, pod.Namespace)
	// Pod删除后，产生一个待释放IP事件
	p.unreleased &lt;- &amp;releaseEvent{pod: pod}
	return nil
}</pre>
<p>和IPAM同步Pod IP的逻辑：</p>
<pre class="crayon-plain-tag">func (p *FloatingIPPlugin) syncPodIP(pod *corev1.Pod) error {
	// 只有到达Running状态的，才同步
	if pod.Status.Phase != corev1.PodRunning {
		return nil
	}
	// 同步干的事情是，如果Pod就有ipinfos注解，并且IP地址没有在池中分配，那么在池中将IP分配给Pod
	if pod.Annotations == nil {
		return nil
	}
	// 这个有点意思，lockpod会立即调用，并且返回一个unlock函数，此函数会在syncPodIP退出时执行。代码简洁
	// 防止有其它线程再操作此Pod
	defer p.lockPod(pod.Name, pod.Namespace)()
	keyObj, err := util.FormatKey(pod)
	if err != nil {
		glog.V(5).Infof("sync pod %s/%s ip formatKey with error %v", pod.Namespace, pod.Name, err)
		return nil
	}
	cniArgs, err := constant.UnmarshalCniArgs(pod.Annotations[constant.ExtendedCNIArgsAnnotation])
	if err != nil {
		return err
	}
	ipInfos := cniArgs.Common.IPInfos
	for i := range ipInfos {
		if ipInfos[i].IP == nil || ipInfos[i].IP.IP == nil {
			continue
		}
		// 遍历所有注解中的IP地址，调用IPAM的AllocateSpecificIP方法分配IP
		if err := p.syncIP(keyObj.KeyInDB, ipInfos[i].IP.IP, pod); err != nil {
			glog.Warningf("sync pod %s ip %s: %v", keyObj.KeyInDB, ipInfos[i].IP.IP.String(), err)
		}
	}
	return nil
}</pre>
<p>小结一下FloatingIPPlugin的职责。它主要负责监控ConfigMap、Pod的变化。当ConfigMap变化后更新IPAM的配置，当Pod发生变化时，更新IPAM池中的IP分配信息。</p>
<p>此外FloatingIPPlugin还提供了调度的核心算法，包括Filter、Prioritize、Bind这几个方法，我们在后面在探讨。</p>
<div class="blog_h3"><span class="graybg">IPAM</span></div>
<p>FloatingIPPlugin.ipam字段，代表了IP地址分配管理的核心，它的接口：</p>
<pre class="crayon-plain-tag">type IPAM interface {
	// 初始化IP池
	ConfigurePool([]*FloatingIPPool) error
	// 释放映射中键匹配的IP，返回已分配、未分配IP地址的映射
	ReleaseIPs(map[string]string) (map[string]string, map[string]string, error)
	// 为Pod分配指定的IP
	AllocateSpecificIP(string, net.IP, Attr) error
	// 在子网中分配IP
	AllocateInSubnet(string, *net.IPNet, Attr) (net.IP, error)
	// 在给定的多个IP范围中，都分配一个IP
	AllocateInSubnetsAndIPRange(string, *net.IPNet, [][]nets.IPRange, Attr) ([]net.IP, error)
	// 在指定的子网中，以指定的key分配IP
	AllocateInSubnetWithKey(oldK, newK, subnet string, attr Attr) error
	// 保留IP
	ReserveIP(oldK, newK string, attr Attr) (bool, error)
	// 释放IP
	Release(string, net.IP) error
	// 根据Key返回的一个匹配
	First(string) (*FloatingIPInfo, error)
	// 将IP地址转换为浮动IP对象
	ByIP(net.IP) (FloatingIP, error)
	// 根据前缀查询
	ByPrefix(string) ([]*FloatingIPInfo, error)
	// 根据关键字查询
	ByKeyword(string) ([]FloatingIP, error)
	// 返回节点的子网信息
	NodeSubnet(net.IP) *net.IPNet
} </pre>
<div class="blog_h3"><span class="graybg">调度器扩展</span></div>
<p>Galaxy IPAM对接K8S调度器，使用的是Scheduler Extender Webhook，也就是它提供若干HTTP接口供调度器使用。</p>
<p>这些接口实现为Galaxy IPAM Server的方法，在startServer()的时候注册：</p>
<pre class="crayon-plain-tag">// 筛选匹配的节点
func (s *Server) filter(request *restful.Request, response *restful.Response) {
	// K8S标准API，ExtenderArgs包含了当前正被调度的Pod，以及可用的（已经被筛选过的）节点列表
	args := new(schedulerapi.ExtenderArgs)
	if err := request.ReadEntity(&amp;args); err != nil {
		glog.Error(err)
		_ = response.WriteError(http.StatusInternalServerError, err)
		return
	}
	glog.V(5).Infof("POST filter %v", *args)
	start := time.Now()
	glog.V(3).Infof("filtering %s_%s, start at %d+", args.Pod.Name, args.Pod.Namespace, start.UnixNano())
	// 调用FloatingIPPlugin
	filteredNodes, failedNodesMap, err := s.plugin.Filter(&amp;args.Pod, args.Nodes.Items)
	glog.V(3).Infof("filtering %s_%s, start at %d-", args.Pod.Name, args.Pod.Namespace, start.UnixNano())
	args.Nodes.Items = filteredNodes
	errStr := ""
	if err != nil {
		errStr = err.Error()
	}
	_ = response.WriteEntity(schedulerapi.ExtenderFilterResult{
		// 可以调度的节点
		Nodes:       args.Nodes,
		// 不可调度的节点，以及不可调度的原因
		FailedNodes: failedNodesMap,
		// 错误信息
		Error:       errStr,
	})
}

// 节点优先级判定（评分）
func (s *Server) priority(request *restful.Request, response *restful.Response) {
	args := new(schedulerapi.ExtenderArgs)
	if err := request.ReadEntity(&amp;args); err != nil {
		glog.Error(err)
		_ = response.WriteError(http.StatusInternalServerError, err)
		return
	}
	glog.V(5).Infof("POST priority %v", *args)
	// 调用FloatingIPPlugin
	hostPriorityList, err := s.plugin.Prioritize(&amp;args.Pod, args.Nodes.Items)
	if err != nil {
		glog.Warningf("prioritize err: %v", err)
	}
	_ = response.WriteEntity(*hostPriorityList)
}


// 绑定Pod到节点
func (s *Server) bind(request *restful.Request, response *restful.Response) {
	args := new(schedulerapi.ExtenderBindingArgs)
	if err := request.ReadEntity(&amp;args); err != nil {
		glog.Error(err)
		_ = response.WriteError(http.StatusInternalServerError, err)
		return
	}
	glog.V(5).Infof("POST bind %v", *args)
	start := time.Now()
	glog.V(3).Infof("binding %s_%s to %s, start at %d+", args.PodName, args.PodNamespace, args.Node, start.UnixNano())
	// 调用FloatingIPPlugin
	err := s.plugin.Bind(args)
	glog.V(3).Infof("binding %s_%s to %s, start at %d-", args.PodName, args.PodNamespace, args.Node, start.UnixNano())
	var result schedulerapi.ExtenderBindingResult
	if err != nil {
		glog.Warningf("bind err: %v", err)
		result.Error = err.Error()
	}
	_ = response.WriteEntity(result)
}</pre>
<p>这些方法仅仅是负责HTTP相关处理，核心是上文我们提到的FloatingIPPlugin的几个方法：</p>
<pre class="crayon-plain-tag">// 没有可用IP的节点被标记为failedNodes
// 如果Pod不需要浮动IP，则failedNodes为空
func (p *FloatingIPPlugin) Filter(pod *corev1.Pod, nodes []corev1.Node) (
    []corev1.Node, schedulerapi.FailedNodesMap, error) {
	start := time.Now()
	failedNodesMap := schedulerapi.FailedNodesMap{}
	// 没有指定自定义资源tke.cloud.tencent.com/eni-ip的request，认为不需要浮动IP
	if !p.hasResourceName(&amp;pod.Spec) {
		return nodes, failedNodesMap, nil
	}
	filteredNodes := []corev1.Node{}
	// 开始过滤，期间锁定针对此Pod的操作
	defer p.lockPod(pod.Name, pod.Namespace)()
	// 读取注解k8s.v1.cni.galaxy.io/args，获取Pod要加入的子网信息
	subnetSet, err := p.getSubnet(pod)
	if err != nil {
		return filteredNodes, failedNodesMap, err
	}
	// 遍历所有节点
	for i := range nodes {
		nodeName := nodes[i].Name
		// 得到节点所属的子网
		subnet, err := p.getNodeSubnet(&amp;nodes[i])
		if err != nil {
			failedNodesMap[nodes[i].Name] = err.Error()
			continue
		}
		// 如果节点可以分配Pod要加入的子网，则保留节点，否则，丢弃节点
		if subnetSet.Has(subnet.String()) {
			filteredNodes = append(filteredNodes, nodes[i])
		} else {
			failedNodesMap[nodeName] = "FloatingIPPlugin:NoFIPLeft"
		}
	}
	if glog.V(5) {
		nodeNames := make([]string, len(filteredNodes))
		for i := range filteredNodes {
			nodeNames[i] = filteredNodes[i].Name
		}
		glog.V(5).Infof("filtered nodes %v failed nodes %v for %s_%s", nodeNames, failedNodesMap,
			pod.Namespace, pod.Name)
	}
	metrics.ScheduleLatency.WithLabelValues("filter").Observe(time.Since(start).Seconds())
	return filteredNodes, failedNodesMap, nil
}

// 打分目前是空实现
func (p *FloatingIPPlugin) Prioritize(pod *corev1.Pod, nodes []corev1.Node) (*schedulerapi.HostPriorityList, error) {
	list := &amp;schedulerapi.HostPriorityList{}
	if !p.hasResourceName(&amp;pod.Spec) {
		return list, nil
	}
	//TODO
	return list, nil
}

// 将新的浮动IP绑定给Pod，或者重用以有的IP
func (p *FloatingIPPlugin) Bind(args *schedulerapi.ExtenderBindingArgs) error {
	start := time.Now()
	pod, err := p.PodLister.Pods(args.PodNamespace).Get(args.PodName)
	if err != nil {
		return fmt.Errorf("failed to find pod %s: %w", util.Join(args.PodName, args.PodNamespace), err)
	}
	if !p.hasResourceName(&amp;pod.Spec) {
		return fmt.Errorf("pod which doesn't want floatingip have been sent to plugin")
	}
	defer p.lockPod(pod.Name, pod.Namespace)()
	// 为Pod生成键，键由要加入的池、Pod命名空间、Pod名字、所属StatefulSet/Deployment的名字构成
	keyObj, err := util.FormatKey(pod)
	if err != nil {
		return err
	}
	// 分配IP给Pod
	cniArgs, err := p.allocateIP(keyObj.KeyInDB, args.Node, pod)
	if err != nil {
		return err
	}
	data, err := json.Marshal(cniArgs)
	if err != nil {
		return fmt.Errorf("marshal cni args %v: %v", *cniArgs, err)
	}
	// 添加k8s.v1.cni.galaxy.io/args注解
	bindAnnotation := map[string]string{constant.ExtendedCNIArgsAnnotation: string(data)}
	var err1 error
	if err := wait.PollImmediate(time.Millisecond*500, 3*time.Second, func() (bool, error) {
		// 执行绑定操作，为Pod添加binding子资源
		if err := p.Client.CoreV1().Pods(args.PodNamespace).Bind(&amp;corev1.Binding{
			ObjectMeta: v1.ObjectMeta{Namespace: args.PodNamespace, Name: args.PodName, UID: args.PodUID,
				Annotations: bindAnnotation},
			Target: corev1.ObjectReference{
				Kind: "Node",
				Name: args.Node,
			},
		}); err != nil {
			err1 = err
			if apierrors.IsNotFound(err) {
				// Pod已经不存在了，终止轮询
				return false, err
			}
			return false, nil
		}
		glog.Infof("bind pod %s to %s with %s", keyObj.KeyInDB, args.Node, string(data))
		return true, nil
	}); err != nil {
		if apierrors.IsNotFound(err1) {
			// 绑定过程中发现Pod消失，说明有人将其删除了。已经分配的IP需要回收
			glog.Infof("binding returns not found for pod %s, putting it into unreleased chan", keyObj.KeyInDB)
			// attach ip annotation
			p.unreleased &lt;- &amp;releaseEvent{pod: pod}
		}
		// If fails to update, depending on resync to update
		return fmt.Errorf("update pod %s: %w", keyObj.KeyInDB, err1)
	}
	metrics.ScheduleLatency.WithLabelValues("bind").Observe(time.Since(start).Seconds())
	return nil
}</pre>
<p>我们再来看一下在Bind阶段，分配IP的细节：</p>
<pre class="crayon-plain-tag">func (p *FloatingIPPlugin) allocateIP(key string, nodeName string, pod *corev1.Pod) (*constant.CniArgs, error) {
	cniArgs, err := getPodCniArgs(pod)
	if err != nil {
		return nil, err
	}
	ipranges := cniArgs.RequestIPRange</pre>
<p>getPodCniArgs读取k8s.v1.cni.galaxy.io/args注解为结构： </p>
<pre class="crayon-plain-tag">type CniArgs struct {
	// 用户可以指定从什么地址范围分配IP
	RequestIPRange [][]nets.IPRange `json:"request_ip_range,omitempty"`
	// 这些参数最终会传递给底层CNI插件，作为key1=val1;key2=val2格式的参数
	Common CommonCniArgs `json:"common"`
}</pre>
<p>allocateIP会使用key去查询已经分配给key的、每个iprange中的IP信息：</p>
<pre class="crayon-plain-tag">ipInfos, err := p.ipam.ByKeyAndIPRanges(key, ipranges)
	if err != nil {
		return nil, fmt.Errorf("failed to query floating ip by key %s: %v", key, err)
	}
	if len(ipranges) == 0 &amp;&amp; len(ipInfos) &gt; 0 {
		// reuse only one if requesting only one ip
		ipInfos = ipInfos[:1]
	}
	// 那些尚未分配IP地址的ipranges
	var unallocatedIPRange [][]nets.IPRange // those does not have allocated ips
	reservedIPs := sets.NewString()
	for i := range ipInfos {
		if ipInfos[i] == nil {
			// 未分配
			unallocatedIPRange = append(unallocatedIPRange, ipranges[i])
		} else {
			// 已经分配给当前key，需要保留
			reservedIPs.Insert(ipInfos[i].IP.String())
		}
	}
	policy := parseReleasePolicy(&amp;pod.ObjectMeta)
	attr := floatingip.Attr{Policy: policy, NodeName: nodeName, Uid: string(pod.UID)}</pre>
<p>检查从IPAM获取的ipinfos，如果PodUid和当前Pod.GetUID()，说明可能先前版本的Pod尚未删除，等待删除（bind函数返回错误）：</p>
<pre class="crayon-plain-tag">for _, ipInfo := range ipInfos {
		// check if uid missmatch, if we delete a statfulset/tapp and creates a same name statfulset/tapp immediately,
		// galaxy-ipam may receive bind event for new pod early than deleting event for old pod
		if ipInfo != nil &amp;&amp; ipInfo.PodUid != "" &amp;&amp; ipInfo.PodUid != string(pod.GetUID()) {
			return nil, fmt.Errorf("waiting for delete event of %s before reuse this ip", key)
		}
	}</pre>
<p>如果有需要新分配的IP，调用IPAM进行分配：</p>
<pre class="crayon-plain-tag">if len(unallocatedIPRange) &gt; 0 || len(ipInfos) == 0 {
		// 节点所属的子网
		subnet, err := p.queryNodeSubnet(nodeName)
		if err != nil {
			return nil, err
		}
		// 执行分配
		if _, err := p.ipam.AllocateInSubnetsAndIPRange(key, subnet, unallocatedIPRange, attr); err != nil {
			return nil, err
		}
		// 更新IP信息
		ipInfos, err = p.ipam.ByKeyAndIPRanges(key, ipranges)
		if err != nil {
			return nil, fmt.Errorf("failed to query floating ip by key %s: %v", key, err)
		}
	}</pre>
<p>检查已分配IP重用的情况，并更新IPAM属性： </p>
<pre class="crayon-plain-tag">for _, ipInfo := range ipInfos {
		//...
		if reservedIPs.Has(ipInfo.IP.String()) {
			glog.Infof("%s reused %s, updating attr to %v", key, ipInfo.IPInfo.IP.String(), attr)
			if err := p.ipam.UpdateAttr(key, ipInfo.IPInfo.IP.IP, attr); err != nil {
				return nil, fmt.Errorf("failed to update floating ip release policy: %v", err)
			}
		}
	}</pre>
<p>回填Pod注解：</p>
<pre class="crayon-plain-tag">// 新分配的IP地址
	var allocatedIPs []string
	// 所有IP地址
	var ret []constant.IPInfo
	for _, ipInfo := range ipInfos {
		if !reservedIPs.Has(ipInfo.IP.String()) {
			allocatedIPs = append(allocatedIPs, ipInfo.IP.String())
		}
		ret = append(ret, ipInfo.IPInfo)
	}
	glog.Infof("%s reused ips %v, allocated ips %v, attr %v", key, reservedIPs.List(), allocatedIPs, attr)
	// 回填
	cniArgs.Common.IPInfos = ret
	return &amp;cniArgs, nil</pre>
<div class="blog_h3"><span class="graybg">小结</span></div>
<p>当一个Pod创建时，如果它的Spec上具有自定义的资源请求tke.cloud.tencent.com/eni-ip，则调度器认为，需要为它分配浮动IP。</p>
<p>由于在floatingip-config中，可以配置多组浮动IP子网。Pod应该通过注解：</p>
<ol>
<li>k8s.v1.cni.galaxy.io/args 来声明自己需要加入什么子网、需要请求什么范围的IP地址</li>
<li>tke.cloud.tencent.com/eni-ip-pool 来声明需要使用哪个IP池</li>
</ol>
<p>Pod创建后，首先进入调度阶段。作为K8S调度器扩展的Galaxy IPAM会按需进行IP分配，并将结果写入到Pod的注解中。</p>
<p>当Kubelet在本地启动Pod时， 会调用CNI插件galaxy-sdn，galaxy-sdn则会调用Galaxy守护进程。后者则<span style="background-color: #c0c0c0;">通过命令行调用galaxy-k8s-vlan等底层CNI插件，并且将Pod注解k8s.v1.cni.galaxy.io/args中的键值转换为key1=val1;key2=val2CNI参数传递</span>。</p>
<p>galaxy-k8s-vlan插件会调用ipam.Allocate，获取CNI参数中的IP地址信息，并转换为v0.20格式的Result，然后调用setupNetwork将IP地址设置到容器的网络接口上。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/galaxy-study-note">Galaxy学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/galaxy-study-note/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Istio中的透明代理问题</title>
		<link>https://blog.gmem.cc/istio-tproxy</link>
		<comments>https://blog.gmem.cc/istio-tproxy#comments</comments>
		<pubDate>Wed, 22 Jul 2020 02:07:07 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[ServiceMesh]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=31995</guid>
		<description><![CDATA[<p>为何需要透明代理 Istio的Sidecar作为一个网络代理，它拦截入站、出站的网络流量。拦截入站流量后，会使用127.0.0.1作为源地址，将流量转发给本地服务进程。本地服务进程看不到真实源IP地址。 很多应用场景下，真实源IP地址是必须的，可能原因包括： IP地址作为标识的一部分。以ZooKeeper为例，它通过成员的IP地址来验证集群成员身份 IP地址用于网络策略，或者用于审计目的 本文将设置这样的场景：一个启用了Istio Sidecar的Nginx Pod，需要被当前命名空间的另外一个Pod访问。我们将尝试解决Nginx不能看到真实的客户端IP地址的问题。 Envoy的现状 &#160; 目前Envoy已经能够很好的支持IP Transparency了。 它提供了多种机制把真实源地址提供给上游服务。 http.original_src 真实源地址可以通过[crayon-69de136ba3132094207691-i/]这样的请求头获取，很多应用都能识别这种请求头。 Envoy还提供了[crayon-69de136ba3138309607784-i/]，此过滤器能够从请求头读取真实源地址，并修改底层TCP连接的源地址。此过滤器还能处理单一下游连接携带来自多个源的HTTP请求的情况。此过滤器的缺点包括： 下游连接必须正确设置了x-forwarded-for头 由于连接池方面的限制，会导致些许性能影响 配置较为复杂，可能需要路由的配合，即使在Sidecar场景（Envoy和上游在同一网络命名空间）下，也需要配置好iptables规则 listener.proxy_protocol HAProxy代理协议提供了交换连接元数据的机制，这些元数据就包括真实源IP。Envoy通过监听器过滤器[crayon-69de136ba313c349403480-i/]支持代理协议。此过滤器的缺点包括： 上游主机需要支持代理协议 <a class="read-more" href="https://blog.gmem.cc/istio-tproxy">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/istio-tproxy">Istio中的透明代理问题</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">为何需要透明代理</span></div>
<p>Istio的Sidecar作为一个网络代理，它拦截入站、出站的网络流量。拦截入站流量后，会使用127.0.0.1作为源地址，将流量转发给本地服务进程。本地服务进程看不到真实源IP地址。</p>
<p>很多应用场景下，真实源IP地址是必须的，可能原因包括：</p>
<ol>
<li>IP地址作为标识的一部分。以ZooKeeper为例，它通过成员的IP地址来验证集群成员身份</li>
<li>IP地址用于网络策略，或者用于审计目的</li>
</ol>
<p>本文将设置这样的场景：一个启用了Istio Sidecar的Nginx Pod，需要被当前命名空间的另外一个Pod访问。我们将尝试解决Nginx不能看到真实的客户端IP地址的问题。</p>
<div class="blog_h1"><span class="graybg">Envoy的现状</span></div>
<p>&nbsp;</p>
<p>目前Envoy已经能够<a href="https://www.envoyproxy.io/docs/envoy/latest/intro/arch_overview/other_features/ip_transparency">很好的支持IP Transparency</a>了。 它提供了多种机制把真实源地址提供给上游服务。</p>
<div class="blog_h2"><span class="graybg">http.original_src</span></div>
<p>真实源地址可以通过<pre class="crayon-plain-tag">x-forwarded-for</pre>这样的请求头获取，很多应用都能识别这种请求头。</p>
<p>Envoy还提供了<pre class="crayon-plain-tag">envoy.filters.http.original_src</pre>，此过滤器能够从请求头读取真实源地址，并修改底层TCP连接的源地址。此过滤器还能处理单一下游连接携带来自多个源的HTTP请求的情况。此过滤器的缺点包括：</p>
<ol>
<li>下游连接必须正确设置了x-forwarded-for头</li>
<li>由于连接池方面的限制，会导致些许性能影响</li>
<li>配置较为复杂，可能需要路由的配合，即使在Sidecar场景（Envoy和上游在同一网络命名空间）下，也需要配置好iptables规则</li>
</ol>
<div class="blog_h2"><span class="graybg">listener.proxy_protocol</span></div>
<p>HAProxy代理协议提供了交换连接元数据的机制，这些元数据就包括真实源IP。Envoy通过监听器过滤器<pre class="crayon-plain-tag">envoy.filters.listener.proxy_protocol</pre>支持代理协议。此过滤器的缺点包括：</p>
<ol>
<li>上游主机需要支持代理协议</li>
<li>仅仅支持TCP</li>
</ol>
<p>该监听器过滤器可以和envoy.filters.listener.original_src联用。</p>
<div class="blog_h2"><span class="graybg">listener.original_src</span></div>
<p>在受控部署环境下，通过监听器过滤器<pre class="crayon-plain-tag">envoy.filters.listener.original_src</pre>可以把下游连接源地址复制为上游连接的源地址。</p>
<p>这需要使用透明代理，让Envoy直接以下游地址向上游服务发起连接。对于上游服务，没有任何要求。此过滤器的缺点包括：</p>
<ol>
<li>Envoy要能够获得真实的下游地址</li>
<li>由于路由方面的限制，可能无法实现</li>
<li>由于连接池方面的限制，会导致些许性能影响</li>
</ol>
<p>这个过滤器是让Istio能够解决透明代理问题的途径，回答一下对它的缺点的规避：</p>
<ol>
<li>Envoy获取真实下游IP地址，也就是入站连接的真实源地址：这可以通过TPROXY拦截模式让Envoy看到真实下游地址</li>
<li>路由方面的限制：不存在，因为Envoy和上游服务（入站连接需要访问的服务）在一个网络命名空间中，可以软件控制路由</li>
</ol>
<div class="blog_h1"><span class="graybg">Istio的现状</span></div>
<p>在两年前就有了关于此问题的Issue：<a href="https://github.com/istio/istio/issues/5679">https://github.com/istio/istio/issues/5679</a>。到目前为止，Istio官方没有提供支持透明代理的方案。</p>
<div class="blog_h2"><span class="graybg">关于拦截模式</span></div>
<p>Istio支持两种拦截模式：</p>
<ol>
<li>REDIRECT：使用iptables的REDIRECT目标来拦截入站请求，转给Envoy</li>
<li>TPROXY：使用iptables的TPROXY目标来拦截入站请求，转给Envoy</li>
</ol>
<p>你可以全局的设置默认拦截模式，也可以通过注解<pre class="crayon-plain-tag">sidecar.istio.io/interceptionMode: TPROXY</pre>给某个工作负载单独设置。</p>
<p>需要注意的是TPROXY模式<span style="background-color: #c0c0c0;">解决的仅仅是Envoy看到的入站连接源IP地址的问题</span>，被代理本地服务看到的地址仍然是127.0.0.1。</p>
<p>下面对比一下两种拦截模式下生成的iptables规则的差异：</p>
<div class="blog_h3"><span class="graybg">TPROXY</span></div>
<p>mangle表的内容如下：</p>
<pre class="crayon-plain-tag"># iptables -t mangle -L -n
Chain PREROUTING (policy ACCEPT)
target     prot opt source               destination         
ISTIO_INBOUND  tcp  --  0.0.0.0/0            0.0.0.0/0           

Chain INPUT (policy ACCEPT)
target     prot opt source               destination         

Chain FORWARD (policy ACCEPT)
target     prot opt source               destination         

Chain OUTPUT (policy ACCEPT)
target     prot opt source               destination         

Chain POSTROUTING (policy ACCEPT)
target     prot opt source               destination         

Chain ISTIO_DIVERT (1 references)
target     prot opt source               destination         
MARK       all  --  0.0.0.0/0            0.0.0.0/0            MARK set 0x539
ACCEPT     all  --  0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_INBOUND (1 references)
target     prot opt source               destination        
# 不拦截特殊端口 
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:22
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:15090
RETURN     tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:15020
# 如果SRC_IP:SRC_PORT:DST_IP:DST_PORT已经建立拦截，则打标记，接受封包
ISTIO_DIVERT  tcp  --  0.0.0.0/0            0.0.0.0/0            socket
# 否则，如果目的地不是127.0.0.1，则重定向给Envoy
ISTIO_TPROXY  tcp  --  0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_TPROXY (1 references)
target     prot opt source               destination         
TPROXY     tcp  --  0.0.0.0/0           !127.0.0.1            TPROXY redirect 0.0.0.0:15001 mark 0x539/0xffffffff</pre>
<p>可以看到，拦截的逻辑比较简单，仅仅改了 PREROUTING （关注进入的封包）链，增加以下逻辑：</p>
<ol>
<li>对于一些特殊端口，不做拦截</li>
<li>对于已经建立了连接的封包，直接打标记1337并允许通过</li>
<li>对于目的地址不是127.0.0.1的封包，进行透明代理，发送给Envoy的15001监听器，给封包打标记1337</li>
</ol>
<p>istio-init在启动工作负载之前会设置策略路由：</p>
<pre class="crayon-plain-tag">ip -f inet rule add fwmark 1337 lookup 133
ip -f inet route add local default dev lo table 133</pre>
<p>这保证了目的地不是127.0.0.1的封包都会被15001处理，也就是<span style="background-color: #c0c0c0;">所有外部请求都需要经过Envoy处理</span>，而<span style="background-color: #c0c0c0;">Envoy向本地被代理服务转发时，会使用目的地址127.0.0.1</span>，不会被拦截。</p>
<p>nat表的内容如下：</p>
<pre class="crayon-plain-tag"># iptables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 1271 packets, 76260 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain INPUT (policy ACCEPT 1271 packets, 76260 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 38 packets, 3183 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    7   420 ISTIO_OUTPUT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain POSTROUTING (policy ACCEPT 38 packets, 3183 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain ISTIO_IN_REDIRECT (2 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15006

Chain ISTIO_OUTPUT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      lo      127.0.0.6            0.0.0.0/0 
# 下面根据UID进行匹配的规则，应该有问题。因为TPROXY模式下，UID固定为0，因此下面3条规则应该去掉
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner UID match 1337
    2   120 RETURN             all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner UID match 1337
    0     0 RETURN             all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner UID match 1337
# 根据用户不同决定行为，如果GID为1337，意味着是Envoy进程发起的封包，否则是其它进程发起的
# 对于将从lo发出的封包，如果用户是Envoy，目的地址非127.0.0.1的，则重定向到入站虚拟监听器15006
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner GID match 1337
# 对于将从lo发出的封包，如果用户不是Envoy，则允许通过。这保证了本机上的服务可以访问自己
    0     0 RETURN             all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner GID match 1337
# 对于将从非lo发出的封包，如果用户是Envoy，允许通过。这保证了Envoy可以访问外部
    5   300 RETURN             all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner GID match 1337
# 到这里，所有目的地址是127.0.0.1的都被允许
    0     0 RETURN             all  --  *      *       0.0.0.0/0            127.0.0.1           
# 重定向给出站虚拟监听器15001，可能情况：
# 对于将从非lo发出的封包，如果用户不是Envoy，目的地址不是本机，则重定向到出站虚拟监听器15001
#     这保证了服务的对外访问，需要经过Envoy代理
    0     0 ISTIO_REDIRECT     all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_REDIRECT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15001</pre>
<p>基于UID匹配的3条规则，我觉得没有意义。原因是TPROXY模式下，运行Envoy的用户是0，而非1337，这个可以从istio-sidecar-injector这个Configmap中看出来：</p>
<pre class="crayon-plain-tag">allowPrivilegeEscalation: {{ .Values.global.proxy.privileged }}
capabilities:
  {{ if or (eq (annotation .ObjectMeta `sidecar.istio.io/interceptionMode` .ProxyConfig.InterceptionMode) `TPROXY`) (eq (annotation .ObjectMeta `sidecar.istio.io/capNetBindService` .Values.global.proxy.capNetBindService) `true`) -}}
  add:
  # 如果是TPROXY模式，增加NET_ADMIN权限
  {{ if eq (annotation .ObjectMeta `sidecar.istio.io/interceptionMode` .ProxyConfig.InterceptionMode) `TPROXY` -}}
  - NET_ADMIN
  {{- end }}
  {{ if eq (annotation .ObjectMeta `sidecar.istio.io/capNetBindService` .Values.global.proxy.capNetBindService) `true` -}}
  - NET_BIND_SERVICE
  {{- end }}
  {{- end }}
  drop:
  - ALL
privileged: {{ .Values.global.proxy.privileged }}
readOnlyRootFilesystem: {{ not .Values.global.proxy.enableCoreDump }}
# 总是使用GID 1337运行Envoy
runAsGroup: 1337
fsGroup: 1337
{{ if or (eq (annotation .ObjectMeta `sidecar.istio.io/interceptionMode` .ProxyConfig.InterceptionMode) `TPROXY`) (eq (annotation .ObjectMeta `sidecar.istio.io/capNetBindService` .Values.global.proxy.capNetBindService) `true`) -}}
# 如果是TPROXY模式，则使用UID 0运行
runAsNonRoot: false
runAsUser: 0
{{- else -}}
# 否则，使用UID 1337运行
runAsNonRoot: true
runAsUser: 1337
{{- end }}</pre>
<p>对nat表的更改发生在 OUTPUT 链（关注发出的封包）。核心逻辑：</p>
<ol>
<li>Envoy通过lo发出的，目的地址不是127.0.0.1的封包，重定向给入站监听器。根据观察，Envoy代理外部请求后，都是从lo发给127.0.0.1的，因此不会匹配此规则</li>
<li>允许本机的服务访问自身</li>
<li>服务对外发出的访问，必须经过Envoy</li>
</ol>
<p>我们仔细分析一下重定向到的15001、15006是什么东西。这些端口是istio-iptables设置的，我们看一下它的帮助：</p>
<pre class="crayon-plain-tag">Script responsible for setting up port forwarding for Istio sidecar.

Usage:
  istio-iptables [flags]

Flags:
  -p, --envoy-port string             Specify the envoy port to which redirect all TCP traffic 
                                          (default $ENVOY_PORT = 15001)
  -z, --inbound-capture-port string   Port to which all inbound TCP traffic to the pod/VM should be redirected to 
                                          (default $INBOUND_CAPTURE_PORT = 15006)</pre>
<p>看样子15006是需要将所有入站流量重定向到的端口，而在TPROXY中将入站流量都重定向到15001，这两端口如何分工？</p>
<p>这里Dump一下它们的配置。15001的：</p>
<pre class="crayon-plain-tag">// istioctl proxy-config listener nginx-84c66c7fb9-95wrd  --port 15001 -o json
[
    {
        "name": "virtualOutbound",
        "address": {
            "socketAddress": {
                "address": "0.0.0.0",
                "portValue": 15001
            }
        },
        "filterChains": [
            {
                "filters": [
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "@type": "type.googleapis.com/envoy.config.filter.network.tcp_proxy.v2.TcpProxy",
                            "cluster": "PassthroughCluster",
                        }
                    }
                ]
            }
        ],
        // 使用原始的（被透明代理之前的）连接的目标地址来判断，由哪个监听器（Envoy进程内）来处理连接
        // 如果找不到这样的监听器，则当前监听器来处理，也就是Passthrough
        "useOriginalDst": true,
        // 可以作为TPROXY的目标，和useOriginalDst联用
        "transparent": true,
        // 期望的、相对于Envoy的流量方向
        "trafficDirection": "OUTBOUND"
    }
]

// istioctl proxy-config cluster nginx-84c66c7fb9-95wrd  --fqdn=PassthroughCluster -o json
{
    "name": "PassthroughCluster",
    "type": "ORIGINAL_DST",
    "connectTimeout": "1s",
    "lbPolicy": "CLUSTER_PROVIDED"
}</pre>
<p>可以看到，这个监听器非常简单，仅仅是做穿透处理。从它的名字virtualOutbound和字段trafficDirection上来看，它是用来处理从Pod向外发起的流量的。但是iptables却把入站流量发给它，似乎有些矛盾？</p>
<p>再看看15006的配置：</p>
<pre class="crayon-plain-tag">// istioctl proxy-config listener nginx-84c66c7fb9-95wrd  --port 15001 -o json
[
    {
        "name": "virtualInbound",
        "address": {
            "socketAddress": {
                "address": "0.0.0.0",
                "portValue": 15006
            }
        },
        "filterChains": [
            // 兜底的过滤器链
            {
                "filterChainMatch": {
                    "prefixRanges": [
                        {
                            "addressPrefix": "0.0.0.0",
                            "prefixLen": 0
                        }
                    ]
                },
                "filters": [
                    {
                        "name": "envoy.tcp_proxy",
                        "typedConfig": {
                            "statPrefix": "InboundPassthroughClusterIpv4",
                            "cluster": "InboundPassthroughClusterIpv4"
                        }
                    }
                ]
            },
            // 匹配请求本地Nginx进程的流量
            {
                "filterChainMatch": {
                    "destinationPort": 80,
                    "prefixRanges": [
                        {
                            "addressPrefix": "172.27.155.72",
                            "prefixLen": 32
                        }
                    ]
                },
                "filters": [
                    {
                        "name": "envoy.http_connection_manager",
                        "typedConfig": {
                            "statPrefix": "inbound_172.27.155.72_80",
                            "routeConfig": {
                                "name": "inbound|80|http|nginx.default.svc.k8s.gmem.cc",
                                "virtualHosts": [
                                    {
                                        "name": "inbound|http|80",
                                        "domains": [
                                            "*"
                                        ],
                                        "routes": [
                                            {
                                                "name": "default",
                                                "route": {
                                                    "cluster": "inbound|80|http|nginx.default.svc.k8s.gmem.cc"
                                                }
                                            }
                                        ]
                                    }
                                ]
                            }
                        }
                    }
                ],
            }
        ],
        "listenerFilters": [
            {
                "name": "envoy.listener.original_dst"
            },
            {
                "name": "envoy.listener.tls_inspector"
            }
        ],
        "transparent": true,
        "trafficDirection": "INBOUND"
    }
]


// istioctl proxy-config cluster nginx-84c66c7fb9-95wrd  --fqdn=InboundPassthroughClusterIpv4 -o json
{
    "name": "InboundPassthroughClusterIpv4",
    "type": "ORIGINAL_DST",
    "connectTimeout": "1s",
    "lbPolicy": "CLUSTER_PROVIDED",
    "upstreamBindConfig": {
        // 绑定新创建上游连接时使用的源地址
        "sourceAddress": {
            "address": "127.0.0.6",
            "portValue": 0
        }
    }
}


// istioctl proxy-config cluster nginx-84c66c7fb9-95wrd  --fqdn=nginx.default.svc.k8s.gmem.cc  --direction inbound -o json
[
    {
        "name": "inbound|80|http|nginx.default.svc.k8s.gmem.cc",
        "type": "STATIC",
        "loadAssignment": {
            "clusterName": "inbound|80|http|nginx.default.svc.k8s.gmem.cc",
            "endpoints": [
                {
                    "lbEndpoints": [
                        {
                            "endpoint": {
                                "address": {
                                    "socketAddress": {
                                        "address": "127.0.0.1",
                                        "portValue": 80
                                    }
                                }
                            }
                        }
                    ]
                }
            ]
        }
    }
]</pre>
<p>可以看到，这个监听器叫virtualInbound，从它的名字和配置trafficDirection上来看，它是用来处理从外面发给Pod的流量的，它明确的定义了处理连接的集群，127.0.0.1:80，即本地Nginx服务。 </p>
<div class="blog_h3"><span class="graybg">REDIRECT</span></div>
<p>此模式下，mangle表没有变动，Istio只修改了nat表。入站、出站流量的处理都在此完成：</p>
<pre class="crayon-plain-tag">Chain PREROUTING (policy ACCEPT 23 packets, 1380 bytes)
 pkts bytes target     prot opt in     out     source               destination         
   23  1380 ISTIO_INBOUND  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain INPUT (policy ACCEPT 23 packets, 1380 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 21 packets, 1675 bytes)
 pkts bytes target     prot opt in     out     source               destination         
    5   300 ISTIO_OUTPUT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain POSTROUTING (policy ACCEPT 21 packets, 1675 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain ISTIO_INBOUND (1 references)
 pkts bytes target     prot opt in     out     source               destination         
# 特殊端口不处理
    0     0 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:22
    1    60 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15090
   22  1320 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15020
# 其它的一律转发给15006
    0     0 ISTIO_IN_REDIRECT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_IN_REDIRECT (3 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15006

Chain ISTIO_OUTPUT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     all  --  *      lo      127.0.0.6            0.0.0.0/0           
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner UID match 1337
    0     0 RETURN     all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner UID match 1337
    5   300 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner UID match 1337
    0     0 ISTIO_IN_REDIRECT  all  --  *      lo      0.0.0.0/0           !127.0.0.1            owner GID match 1337
    0     0 RETURN     all  --  *      lo      0.0.0.0/0            0.0.0.0/0            ! owner GID match 1337
    0     0 RETURN     all  --  *      *       0.0.0.0/0            0.0.0.0/0            owner GID match 1337
    0     0 RETURN     all  --  *      *       0.0.0.0/0            127.0.0.1           
    0     0 ISTIO_REDIRECT  all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_REDIRECT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 REDIRECT   tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            redir ports 15001</pre>
<p>可以看到，REDIRECT模式下，处理进入封包的逻辑是完全一样的。 </p>
<p>REDIRECT模式下，将入站流量重定向给15006，这很好理解，因为15006是 virtualInbound监听器嘛。</p>
<div class="blog_h3"><span class="graybg">有何区别</span></div>
<p>从Nginx的日志上看，不管是REDIRECT还是TPROXY模式，看到的IP都不是真实IP，没有区别。</p>
<p>Envoy访问日志也没有任何区别，至少可以说，在REDIRECT模式下，Envoy也是可以看到真实源IP的：</p>
<pre class="crayon-plain-tag"># 开始时间                请求方法 原始地址  协议 响应码 响应标记
[2020-04-22T12:52:23.278Z] "GET  /    HTTP/1.1" 200   - 
  # 元数据mixer状态  # 上游传输失败原因   接受字节数   发送字节数  耗时 上游访问耗时
  "-"                "-"                 0            612         0 0 
  # x-forwarded-for头     User Agent
  "-"                   "curl/7.67.0" 
  # 请求ID                                AUTHORITY  上游主机
  "d05b5196-c413-9003-be2a-6b2841efe4e1" "nginx" "127.0.0.1:80" 
  # 上游集群
  inbound|80|http|nginx.default.svc.k8s.gmem.cc 
  # 访问上游使用的本地地址    下游访问本机使用目的地址   下游远程地址
  127.0.0.1:33024          172.27.155.70:80        172.27.155.74:45326 
  # 请求的服务名称                               路由名称
  outbound_.80_._.nginx.default.svc.k8s.gmem.cc default</pre>
<p>TPROXY模式下，Envoy也没有使用真实源IP来请求上游集群。</p>
<p>感觉这TPROXY很鸡肋，从<a href="https://github.com/istio/istio/issues/5679">https://github.com/istio/istio/issues/5679</a>上看到的，它的价值是： </p>
<blockquote>
<p>Contrary to REDIRECT, TPROXY doesn't perform NAT, and therefore preserves both source and destination IP addresses and ports of inbound connections. One benefit is that the source.ip attributes reported by Mixer for inbound connections will always be correct, unlike when using REDIRECT.</p>
</blockquote>
<p>也就是说，TPROXY模式下允许Mixer获得真实源IP地址。</p>
<div class="blog_h2"><span class="graybg">EnvoyFilter</span></div>
<p>目前Istio支持一种自定义资源EnvoyFilter，使用它，你可以对生成的Envoy配置进行深度定制。比如添加监听器过滤器：</p>
<pre class="crayon-plain-tag">apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: nginx-original-src
  namespace: default
spec:
  workloadSelector:
    labels:
      app: nginx
  configPatches:
  - applyTo: LISTENER
    match:
      context: SIDECAR_INBOUND
      listener:
        portNumber: 80
    patch:
      operation: MERGE
      value:
        listenerFilters:
        - name: envoy.listener.original_src</pre>
<p>像上面这个过滤器，它为入站监听器添加了envoy.listener.original_src这个监听器过滤器。生成的配置如下：</p>
<pre class="crayon-plain-tag">// istioctl proxy-config listener nginx-84c66c7fb9-7mfwz   --port 80 --type http -o json

...
        "deprecatedV1": {
            "bindToPort": false
        },
        "listenerFilters": [
            {
                "name": "envoy.listener.tls_inspector"
            },
            {
                "name": "envoy.listener.original_src",
            }
        ],
        "listenerFiltersTimeout": "0.100s",
        "continueOnListenerFiltersTimeout": true,
        "trafficDirection": "INBOUND"
    }
]</pre>
<div class="blog_h1"><span class="graybg">如何实现透明代理</span></div>
<div class="blog_h2"><span class="graybg">原理</span></div>
<div class="blog_h3"><span class="graybg">典型场景</span></div>
<p><a href="https://cdn.gmem.cc/wp-content/uploads/2020/04/tproxy-classic.png"><img class="size-full wp-image-32083 aligncenter" src="https://cdn.gmem.cc/wp-content/uploads/2020/04/tproxy-classic.png" alt="tproxy-classic" width="830" height="345" /></a></p>
<p>关键点：</p>
<ol>
<li>路由器发现目的地址、源地址是REAL_SERVER:80的，且不是来自透明代理的封包，都会路由给透明代理。而不是路由给服务器、客户端</li>
<li>透明代理能够在非本机IP地址上监听，例如REAL_SERVER:80</li>
<li>透明代理能够以非本机IP地址发起TCP连接，例如以客户端的IP地址</li>
</ol>
<p>第一条，可能需要硬件支持。</p>
<p>后面两条，可以由透明代理在软件上支持，相关套接字选项：</p>
<ol>
<li>IP_FREEBIND：允许绑定非本地的，或者尚不存在的IP地址</li>
<li>IP_TRANSPARENT：在套接字上启用透明代理。该选项运行应用程序绑定非本地地址，并使用这个外部地址来扮演客户端、服务器角色。需要CAP_NET_ADMIN权限才能启用</li>
</ol>
<p>此外，根据实际需要，“透明度”可以变化：</p>
<ol>
<li>如果仅仅想让客户端觉得透明，那么代理可以直接使用自己的IP地址请求服务器。这样服务器看不到客户端真实IP</li>
<li>如果服务器仅仅需要知道客户端真实IP，不关心真实端口，那么代理可以用客户端地址+任意端口发起请求</li>
<li>如果需要绝对透明，则代理必须以客户端地址+客户端端口发起请求</li>
</ol>
<div class="blog_h3"><span class="graybg">Sidecar场景</span></div>
<p>在Envoy Sidecar部署场景下，情况变的简单，透明代理和服务器位于同一台主机内部，这意味着：</p>
<ol>
<li>不需要路由器/网关的配合</li>
<li>代理请求的目的地址可以从真实服务器地址换为127.0.0.1</li>
</ol>
<p>可以实现透明代理的通信模型如下：</p>
<p><a href="https://cdn.gmem.cc/wp-content/uploads/2020/04/tproxy-envoy-expected.png"><img class="size-full wp-image-32085 aligncenter" src="https://cdn.gmem.cc/wp-content/uploads/2020/04/tproxy-envoy-expected.png" alt="tproxy-envoy-expected" width="465" height="760" /></a></p>
<div class="blog_h2"><span class="graybg">Istio的问题</span></div>
<p>在Istio的TPROXY拦截模式下，实际的通信模型如下：</p>
<p><a href="https://cdn.gmem.cc/wp-content/uploads/2020/04/tproxy-envoy-actual.png"><img class="size-full wp-image-32089 aligncenter" src="https://cdn.gmem.cc/wp-content/uploads/2020/04/tproxy-envoy-actual.png" alt="tproxy-envoy-actual" width="463" height="756" /></a></p>
<p>&nbsp;</p>
<p>差别似乎仅仅是Envoy用127.0.0.1作为源地址，而非客户端真实IP，向服务器发送请求。</p>
<p>使用EnvoyFilter，为virtualOutbound所引用的，80监听器配置一个EnvoyFilter，配置envoy.listener.original_src，可以让Envoy访问服务器时使用真实客户端IP，解决我们的问题吗？</p>
<p>我们参考3.2节配置好EnvoyFilter，然后从外部访问Pod的Nginx服务，很遗憾，并不能正常工作，curl给出的错误是：</p>
<pre class="crayon-plain-tag">upstream connect error or disconnect/reset before headers. reset reason: connection failure</pre>
<p>从Envoy访问日志上看：</p>
<pre class="crayon-plain-tag">[2020-04-23T09:18:42.434Z] "GET / HTTP/1.1" 503 
# 日志格式取决于配置/版本。通过
#   kubectl exec nginx-tproxy-774fb7958c-t2lnk -c istio-proxy -- curl 0:15000/config_dump | grep .log_format
# 响应标记：
#   LR   本地重置
#   UH   没有健康的上游主机，和503一起发送
#   UF   连接到上游主机时失败，和503一起发送
#   UO   针对上游的访问溢出（断路器触发），和503一起发送
#   NR   没有匹配的路由，和404一起发送
#   URX  请求被拒绝，原因是超过上游的最大重试次数，或者TCP最大连接尝试次数

#  上游连接失败     收  发   耗时
   UF "-" "-"      0   91   999   - "-" "curl/7.67.0" "747cdfcb-5d1e-9ac0-8858-33aa1b1eaa4d" 
"nginx" "127.0.0.1:80" inbound|80|http|nginx.default.svc.k8s.gmem.cc 
# 访问上游使用的本地地址    下游访问本机使用目的地址   下游远程地址
-                       172.27.155.94:80         172.27.155.90:56356 outbound_.80_._.nginx.default.svc.k8s.gmem.cc default</pre>
<p>存在如下异常：</p>
<ol>
<li>访问上游时使用的源地址为空了</li>
<li>响应标记UF，耗时999，提示连接不到上游服务器 </li>
</ol>
<p>为什么连接不到上游服务器？我们尝试通过iptables日志诊断一下。在Nginx的例子里，数据报的特点是，源或目的端口为80，因此增加以下规则：</p>
<pre class="crayon-plain-tag"># 删除基于UID匹配的规则，因为TPROXY模式下Envoy的运行用户是0而非1337
iptables -t nat -D ISTIO_OUTPUT 2
iptables -t nat -D ISTIO_OUTPUT 2
iptables -t nat -D ISTIO_OUTPUT 2

# 增加入站流量TPROXY规则日志
iptables -t mangle -I ISTIO_INBOUND 5 -p tcp --dport 80 -j LOG --log-prefix "b-tproxy: " --log-tcp-sequence --log-uid
iptables -t mangle -A ISTIO_INBOUND -p tcp --dport 80 -j LOG --log-prefix "a-tproxy: " --log-tcp-sequence --log-uid

# 在nat表的OUTPUT链，需要增加源、目标端口是80的，分别对应服务向Envoy发出、Envoy向服务发出的封包
iptables -t nat -I ISTIO_OUTPUT 6 -p tcp --dport 80 -j LOG --log-prefix 't-redir-*-*-*-*: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 6 -p tcp --sport 80 -j LOG --log-prefix 'f-redir-*-*-*-*: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 5 -p tcp --dport 80 -j LOG --log-prefix 't-rturn-*-*-*-1: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 5 -p tcp --sport 80 -j LOG --log-prefix 'f-rturn-*-*-*-1: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 4 -p tcp --dport 80 -j LOG --log-prefix 't-rturn-*-*-*-*-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 4 -p tcp --sport 80 -j LOG --log-prefix 'f-rturn-*-*-*-*-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 3 -p tcp --dport 80 -j LOG --log-prefix 't-rturn-*-l-*-*-!1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 3 -p tcp --sport 80 -j LOG --log-prefix 'f-rturn-*-l-*-*-!1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 2 -p tcp --dport 80 -j LOG --log-prefix 't-inred-*-l-*-!1-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 2 -p tcp --sport 80 -j LOG --log-prefix 'f-inred-*-l-*-!1-1337: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 1 -p tcp --dport 80 -j LOG --log-prefix 't-inred-*-l-6-*: ' --log-tcp-sequence --log-uid
iptables -t nat -I ISTIO_OUTPUT 1 -p tcp --sport 80 -j LOG --log-prefix 'f-inred-*-l-6-*: ' --log-tcp-sequence --log-uid</pre>
<p>拦截到的日志：</p>
<pre class="crayon-plain-tag"># [30714.928765] 客户端往POD的连接，首次SYN，TPROXY之前
b-tproxy: IN=eth0 OUT=  SRC=172.27.155.90 DST=172.27.155.108     ID=35338   SPT=57252 DPT=80 SEQ=1901693983    SYN  
# 没有出现a-tproxy，说明SYN被TPROXY拦截，发往15001，也就是Envoy

# Envoy往Nginx的连接，出站，首次SYN，注意看到SRC是172.27.155.90:44297，和客户端172.27.155.90:57252的IP一致，端口用了新的
# 没有启用EnvoyFilter时是这样：
# inred-*-l-6-*: IN= OUT=lo SRC=127.0.0.1 DST=127.0.0.1 ...
# 可以看到EnvoyFilter达到我们的目的：传递真实源IP

t-inred-*-l-6-*: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
t-inred-*-l-*-!1-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
t-rturn-*-l-*-*-!1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 
# 由于GID是1337，因此下面的规则匹配，ACCEPT，封包发出去了
t-rturn-*-*-*-*-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  UID=0 GID=1337 

# Envoy往Nginx的连接，入站，由于目的地址是127.0.0.1，因此不TPROXY
b-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  
a-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=33217   SPT=44297 DPT=80 SEQ=147818454    SYN  



# [30715.971504] 一秒过了，客户端往POD的连接，二次SYN
b-tproxy: IN=eth0 OUT=  SRC=172.27.155.90 DST=172.27.155.108     ID=60309   SPT=57258 DPT=80 SEQ=829877388    SYN  
# Envoy往Nginx的连接，出站，二次SYN
t-inred-*-l-6-*: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
t-inred-*-l-*-!1-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
t-rturn-*-l-*-*-!1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
t-rturn-*-*-*-*-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  UID=0 GID=1337 
b-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  
a-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=51130   SPT=51761 DPT=80 SEQ=3083321507    SYN  

# [30717.046657] 一秒过了，客户端往POD的连接，三次SYN
b-tproxy: IN=eth0 OUT=  SRC=172.27.155.90 DST=172.27.155.108     ID=8963   SPT=57268 DPT=80 SEQ=3705219877    SYN  
t-inred-*-l-6-*: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
t-inred-*-l-*-!1-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
t-rturn-*-l-*-*-!1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
t-rturn-*-*-*-*-1337: IN= OUT=lo SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  UID=0 GID=1337 
b-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN  
a-tproxy: IN=lo OUT=  SRC=172.27.155.90 DST=127.0.0.1     ID=12974   SPT=48739 DPT=80 SEQ=2548447881    SYN</pre>
<p>可以看到：</p>
<ol>
<li>客户端向Pod发请求，被TPROXY给Envoy 15001</li>
<li>Envoy 15001是透明套接字，因此它虽然客户端请求的DPT=80，它也接收并处理了</li>
<li>Envoy执行代理，通过lo向127.0.0.1:80发送请求，注意这里<span style="background-color: #c0c0c0;">它使用的源地址是客户端地址，这意味着我们的EnvoyFilter起作用了</span></li>
<li>Envoy代理的请求，通过lo入站，由于目的地址是127.0.0.1，因此不被TPROXY，通过PREROUTING - mangle链</li>
</ol>
<p>此外，在OUTPUT链中nat表里，好像根据SPT=80无法匹配，所以看不到任何f-开头的日志。此链对于nat表来说，应该是用于做DNAT，Istio生成的规则遵循了这一点，REDIRECT可以看作是一种DNAT。Istio的规则有基于源IP进行匹配的，我基于源端口为何不行，目前不清楚。</p>
<p>换个位置来诊断吧，目前我们已经明确，Envoy接收到请求后，会冒充客户端源IP向localhost:80发请求，此请求已经通过PREROUTING-mangle。它有没有被Nginx接收到？</p>
<p>我们可以在INPUT-mangle上做日志，如果能监控到发往127.0.0.1:80的封包，就可以认定Nginx接收到了，因为整个Iptables中没有设置INPUT链的任何拦截规则。</p>
<pre class="crayon-plain-tag">iptables -t mangle -I INPUT 1 -p tcp -d 127.0.0.1/32 --dport 80 -j LOG --log-prefix='input-mangle-d80: '
iptables -t nat -I INPUT 1 -p tcp -d 127.0.0.1/32  --dport 80 -j LOG --log-prefix='input-nat-d80: '
iptables -t filter -I INPUT 1 -p tcp -d 127.0.0.1/32  --dport 80 -j LOG --log-prefix='input-filter-d80: '</pre>
<p>日志如下：</p>
<pre class="crayon-plain-tag">[3612374.269256] input-mangle-d80: IN=lo OUT= SRC=172.27.252.159 DST=127.0.0.1 SPT=40283 DPT=80 SYN
[3612374.269276] input-filter-d80: IN=lo OUT= SRC=172.27.252.159 DST=127.0.0.1 SPT=40283 DPT=80 SYN</pre>
<p>nat表仍然没有日志，<span style="background-color: #c0c0c0;">看样子是在DNAT时，不能使用源端口匹配，SNAT时，不能使用目的端口匹配</span>。</p>
<p>不过从日志上，从lo端口进入的、Envoy仿冒客户端身份发往127.0.0.1:80的封包，的确是通过iptables了。</p>
<p>那么，应该是Nginx没有给出应答。我们需要监控一下源是Nginx，目的是客户端真实IP地址的出站封包的流向： </p>
<pre class="crayon-plain-tag">iptables -t mangle -R POSTROUTING 1 -p tcp -d 172.27.252.159/32 -s 127.0.0.1/32 \
         --sport 80  -j LOG --log-prefix='pr-mangle-to-clientip: '</pre>
<p>日志如下：</p>
<pre class="crayon-plain-tag">pr-mangle-to-clientip: IN= OUT=eth0 SRC=127.0.0.1 DST=172.27.252.159 PROTO=TCP SPT=80 DPT=54969 ACK SYN URGP=0 
pr-mangle-to-clientip: IN= OUT=eth0 SRC=127.0.0.1 DST=172.27.252.159 PROTO=TCP SPT=80 DPT=50979 ACK SYN URGP=0 
pr-mangle-to-clientip: IN= OUT=eth0 SRC=127.0.0.1 DST=172.27.252.159 PROTO=TCP SPT=80 DPT=54969 ACK SYN URGP=0</pre>
<p>相似的日志会连续出现很多条。我们可以看到Nginx收到首次握手SYN后，尝试ACK+SYN，但是一致没有收到第三次握手信息…… 原因很明显，<span style="background-color: #c0c0c0;">出口网卡是eth0，封包发走了，没有返回给Envoy代理</span>。</p>
<p>到这里，问题就算定位完毕了。 </p>
<div class="blog_h2"><span class="graybg">解决方案</span></div>
<p>我们需要保证，对于Envoy以客户端IP发起的，给Nginx的请求，它的响应能够原路返回。响应的封包具有以下特点：</p>
<ol>
<li>源地址（请求封包的目的地址）是 127.0.0.1，因为Envoy总是向127.0.0.1发请求</li>
<li>目的地址（请求封包的源地址）不是本机地址，因为Envoy发请求时，FREEBIND源地址为客户端IP</li>
</ol>
<p>我们需要将这种封包，从lo网卡，而非eth0路由出去。 可以使用下面的iptables规则：</p>
<pre class="crayon-plain-tag">iptables -t mangle -I OUTPUT 1 -s 127.0.0.1/32 ! -d 127.0.0.1/32 \
    -j MARK --set-xmark 0x539/0xffffffff</pre>
<p>再次访问服务，Nginx可以看到真实客户端IP地址了：</p>
<pre class="crayon-plain-tag"># TPORXY mode without envoyfilter
127.0.0.1 - - [24/Apr/2020:02:58:53 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.67.0" "-"
# TPROXY mode + envoyfilter, iptable rule applied
172.27.252.159 - - [24/Apr/2020:05:52:04 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/7.67.0" "-"</pre>
<p>到此为止，问题解决。初步测试，没有发现负面效果，<a href="https://github.com/istio/istio/issues/5679#issuecomment-618824519">已经将此方案提交社区讨论</a>。</p>
<div class="blog_h3"><span class="graybg">提交社区</span></div>
<p>此方案已经通过PR <a href="https://github.com/istio/istio/pull/23275">https://github.com/istio/istio/pull/23275</a> 合并到上游Istio仓库的master分支（1.7dev），并将<a href="https://github.com/istio/istio/pull/23446">自动Cherry Pick到1.6版本</a>。</p>
<p>1.5版本的逻辑稍有不同，仅仅在我Fork的Istio中实现：<a href="https://github.com/gmemcc/istio/tree/release-1.5.1-patch">https://github.com/gmemcc/istio/tree/release-1.5.1-patch</a>，不准备提交到上游Istio仓库。</p>
<div class="blog_h3"><span class="graybg">1.6版本TPROXY问题</span></div>
<p>在此版本中验证时，发现TPROXY模式损坏，无限循环自我请求。我已经提起Issue：<a href="https://github.com/istio/istio/issues/23369">23369</a>。</p>
<p>解决无限循环的方法是把TPROXY目标从15001改为15006。我一直就怀疑为什么要把入站流量重定向给出站监听器15001，现在想想，最初只有一个“虚拟监听器”15001，最近版本的Istio才拆分为virtualInbound（15006）、virtualOutbound（15001）两个，在这个变更过程中，TPROXY相关代码没有跟着改动。</p>
<div class="blog_h1"><span class="graybg">问题23369</span></div>
<p>解决透明代理源IP的PR 23275并没有达到预期效果，问题原因参考ISSUE 23369。</p>
<p>即使按照上节的方法，将TPROXY目标从15001改为15006，也仅仅能解决无限自我请求的问题。新得到的错误信息是：upstream connect error or disconnect/reset before headers. reset reason: local reset</p>
<div class="blog_h2"><span class="graybg">抓包分析</span></div>
<p>我们从10.0.0.1发起针对启用了Sidecar的、IP地址为172.27.0.10的请求。可以在Nginx Pod的网络命名空间中看到如下连接信息：</p>
<pre class="crayon-plain-tag">netstat -nt
Active Internet connections (w/o servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      1 10.0.0.1:50829          172.27.0.10:80          SYN_SENT</pre>
<p>源地址为10.0.0.1:50829套接字，应该是Envoy发起上游请求时创建的，因为我们配置了监听器过滤器original_src。</p>
<p>但是，这个套接字的状态一直是SYN_SENT，这提示它没有收到答复。结合抓包结果：</p>
<pre class="crayon-plain-tag"># 从客户端发起的原始包，源端口39062
10.0.0.1.39062 &gt; 172.27.0.10.80: Flags [S]
# Envoy给的ACK
172.27.0.10.80 &gt; 10.0.0.1.39062: Flags [S.]
# 客户端发起HTTP请求
10.0.0.1.39062 &gt; 172.27.0.10.80: Flags [.]
10.0.0.1.39062 &gt; 172.27.0.10.80: Flags [P.] GET / HTTP/1.1
# Envoy给的ACK
172.27.0.10.80 &gt; 10.0.0.1.39062
# Envoy向上游发起请求，注意这里它不是发给127.0.0.1，而是Pod IP
# 尽管目的地址是172.27.0.10.80，这个包仍然是从lo发出去的
# 当从本机访问时，不论使用哪个目的IP时，默认都会从lo出去
10.0.0.1.50829 &gt; 172.27.0.10.80: Flags [S]
# Nginx给出ACK，但是这个ACK没有收到，所以SYN+ACK反复了几次
# 实际上这些封包都从eth0发出去了
172.27.0.10.80 &gt; 10.0.0.1.50829: Flags [S.]
10.0.0.1.50829 &gt; 172.27.0.10.80: Flags [S]
172.27.0.10.80 &gt; 10.0.0.1.50829: Flags [S.]
172.27.0.10.80 &gt; 10.0.0.1.50829: Flags [S.]
10.0.0.1.50829 &gt; 172.27.0.10.80: Flags [S]
172.27.0.10.80 &gt; 10.0.0.1.50829: Flags [S.]
172.27.0.10.80 &gt; 10.0.0.1.50829: Flags [S.]
10.0.0.1.50829 &gt; 172.27.0.10.80: Flags [S]
172.27.0.10.80 &gt; 10.0.0.1.50829: Flags [S.]
# Envoy没有收到上游应答，认为服务不可用
172.27.0.10.80 &gt; 10.0.0.1.39062  HTTP/1.1 503 Service Unavailable
# 终止连接
10.0.0.1.39062 &gt; 172.27.0.10.80: Flags [.]
10.0.0.1.39062 &gt; 172.27.0.10.80: Flags [F.]</pre>
<p>可以看到，新版本的Istio，向上游发请求时，使用的目的地址是原始Dest地址，而不是127.0.0.1，因此， PR 23275也就失效了。</p>
<p>在当前的场景下，Envoy以客户端真实IP、通过lo向Nginx进程发起TCP连接，这个是OK的。但是回程报文从容器eth0发走了。回程报文到达宿主机后，被丢弃。</p>
<div class="blog_h2"><span class="graybg">解决方案</span></div>
<p>我们需要识别，哪些请求是Envoy代表客户端转发的，并把这些请求的响应封包发回给Envoy，而不是通过eth0发送出去。</p>
<p>早前版本可以根据目的地址识别，现在直接来自客户端的、Envoy代表客户端转发的请求（以及响应），连接5元组完全一样，这意味着无法从IP地址上进行区分了。</p>
<p>幸运的是，iptables支持的CONNMARK目标可以在连接级别上打标记，这意味着往返报文可以共享信息。此外，original_src支持为封包设置标记，我们可以利用这一特性识别Envoy代表客户端发出的封包。结合这两点，我们可以得到23369的解决方案。</p>
<p>首先，我们需要为监听器过滤器original_src增加一个参数：</p>
<pre class="crayon-plain-tag">{
    "name": "envoy.listener.original_src",
    "typedConfig": {
        "@type": "type.googleapis.com/envoy.extensions.filters.listener.original_src.v3.OriginalSrc",
        "mark": 1337
    }
},</pre>
<p>这样，Envoy请求上游（Nginx）时，发出的封包具有标记 1337。</p>
<p>然后，我们增加如下iptables规则：</p>
<pre class="crayon-plain-tag"># Envoy发出的封包，被Nginx处理之前，获取封包标记，保存为连接标记
iptables  -t mangle -I PREROUTING -m mark     --mark 1337  -j CONNMARK --save-mark
# Nginx处理请求...
# Nginx返回的响应封包，被打上从连接标记上取得的1337标记
iptables  -t mangle -I OUTPUT     -m connmark --mark 1337 -j CONNMARK --restore-mark</pre>
<p>结合现有的策略路由，Nginx的回程封包就会从lo发出，并被Envoy接收到了。</p>
<p>到这一步，会出现先前的无限自我请求问题，这是由于规则：</p>
<pre class="crayon-plain-tag">Chain ISTIO_TPROXY (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    8   480 TPROXY     tcp  --  *      *       0.0.0.0/0           !127.0.0.1            TPROXY redirect 0.0.0.0:15006 mark 0x539/0xffffffff</pre>
<p>该规则要求，只要目的地址不是127.0.0.1的请求，都会重定向到15006。在前面我们已经发现，TPROXY模式下访问上游Nginx不像先前版本那样使用127.0.0.1作为目的地址，因此这个规则必须要处理。</p>
<p>我的做法是，在它的前面做个判断，如果具有标记1337（意味着这是Envoy和上游Nginx之间的通信），就不走ISTIO_TPROXY：</p>
<pre class="crayon-plain-tag">iptables -t mangle -I ISTIO_INBOUND 5 -p tcp -m mark --mark 0x539   -j RETURN</pre>
<p>修改后mangle表的整体内容如下：</p>
<pre class="crayon-plain-tag"># iptables -t mangle -L -n -v
Chain PREROUTING (policy ACCEPT 6280 packets, 680K bytes)
 pkts bytes target     prot opt in     out     source               destination         
1163K   97M CONNMARK   all  --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x539 CONNMARK save
1440K  115M ISTIO_INBOUND  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain INPUT (policy ACCEPT 7459 packets, 817K bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain OUTPUT (policy ACCEPT 6126 packets, 781K bytes)
 pkts bytes target     prot opt in     out     source               destination         
1107K   93M CONNMARK   all  --  *      *       0.0.0.0/0            0.0.0.0/0            connmark match  0x539 CONNMARK restore
    0     0 MARK       tcp  --  *      *       127.0.0.1           !127.0.0.1            MARK set 0x539

Chain POSTROUTING (policy ACCEPT 6126 packets, 781K bytes)
 pkts bytes target     prot opt in     out     source               destination         

Chain ISTIO_DIVERT (1 references)
 pkts bytes target     prot opt in     out     source               destination         
1308K  107M MARK       all  --  *      *       0.0.0.0/0            0.0.0.0/0            MARK set 0x539
1308K  107M ACCEPT     all  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_INBOUND (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    0     0 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:22
    0     0 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15090
14058 1047K RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15021
 4713  814K RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            tcp dpt:15020
   39  7165 RETURN     tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            mark match 0x539
1308K  107M ISTIO_DIVERT  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0            ctstate RELATED,ESTABLISHED
 113K 6778K ISTIO_TPROXY  tcp  --  *      *       0.0.0.0/0            0.0.0.0/0           

Chain ISTIO_TPROXY (1 references)
 pkts bytes target     prot opt in     out     source               destination         
    8   480 TPROXY     tcp  --  *      *       0.0.0.0/0           !127.0.0.1            TPROXY redirect 0.0.0.0:15006 mark 0x539/0xffffffff</pre>
<p>从Nginx Pod外部访问、从Nginx Pod内部访问localhost以及Pod IP，一切行为正常，解决方案有效。 </p>
<p>此方案将通过<a href="https://github.com/istio/istio/pull/28363">PR 28363</a>提交社区讨论。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/istio-tproxy">Istio中的透明代理问题</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/istio-tproxy/feed</wfw:commentRss>
		<slash:comments>10</slash:comments>
		</item>
		<item>
		<title>Cilium学习笔记</title>
		<link>https://blog.gmem.cc/cilium</link>
		<comments>https://blog.gmem.cc/cilium#comments</comments>
		<pubDate>Mon, 22 Jun 2020 10:56:30 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[CNI]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=37551</guid>
		<description><![CDATA[<p>简介 Cilium Cilium是在Docker/K8S之类的容器管理平台下，透明的为应用程序服务提供安全网络连接的开源软件。Cilium的底层技术是eBPF，eBPF完全在内核中运行，因此改变Cilium的安全策略时不需要程序代码、容器配置的任何变更。 Hubble Hubble是一个完全分布式的网络和安全可观察性平台。它构建在Cilium + eBPF之上，它以完全透明的方式，实现了服务、网络基础设施的通信/行为的深度可观察性。 由于可观察性依赖于eBPF，因此是可动态编程的、成本最小化的、可深度定制的。 Hubble可以回答以下问题： 服务依赖和通信关系图： 两个服务是否通信，通信频度如何，服务之间的依赖关系是怎样的？ 进行了哪些HTTP调用？ 服务消费了哪些Kafka主题，发布了哪些Kafka主题 网络监控和报警： 网络通信是否失败，为何失败？是DNS导致的失败？还是L4/L7的原因 最近5分钟有哪些服务存在DNS解析问题 哪些服务出现连接超时、中断的问题 无应答SYN请求的频率是多高 应用程序监控： 特定服务/或者整个集群的5xx/4xx HTTP响应的频率是多高 HTTP请求延迟的95th/99th位数是多少 <a class="read-more" href="https://blog.gmem.cc/cilium">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/cilium">Cilium学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">简介</span></div>
<div class="blog_h2"><span class="graybg">Cilium</span></div>
<p>Cilium是在Docker/K8S之类的容器管理平台下，透明的为应用程序服务提供安全网络连接的开源软件。Cilium的底层技术是eBPF，eBPF完全在内核中运行，因此改变Cilium的安全策略时不需要程序代码、容器配置的任何变更。</p>
<div class="blog_h2"><span class="graybg">Hubble</span></div>
<p>Hubble是一个完全分布式的网络和安全可观察性平台。它构建在Cilium + eBPF之上，它以完全透明的方式，实现了服务、网络基础设施的通信/行为的深度可观察性。</p>
<p>由于可观察性依赖于eBPF，因此是可动态编程的、成本最小化的、可深度定制的。</p>
<p>Hubble可以回答以下问题：</p>
<ol>
<li>服务依赖和通信关系图：
<ol>
<li>两个服务是否通信，通信频度如何，服务之间的依赖关系是怎样的？</li>
<li>进行了哪些HTTP调用？</li>
<li>服务消费了哪些Kafka主题，发布了哪些Kafka主题</li>
</ol>
</li>
<li>网络监控和报警：
<ol>
<li>网络通信是否失败，为何失败？是DNS导致的失败？还是L4/L7的原因</li>
<li>最近5分钟有哪些服务存在DNS解析问题</li>
<li>哪些服务出现连接超时、中断的问题</li>
<li>无应答SYN请求的频率是多高</li>
</ol>
</li>
<li>应用程序监控：
<ol>
<li>特定服务/或者整个集群的5xx/4xx HTTP响应的频率是多高</li>
<li>HTTP请求延迟的95th/99th位数是多少</li>
<li>哪两个服务之间的延迟最高</li>
</ol>
</li>
<li>安全可观察性：
<ol>
<li>哪些服务因为网络策略而出现连接被阻止</li>
<li>哪些服务被从集群外部访问</li>
<li>哪些服务尝试解析了特定的域名</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">优势</span></div>
<p>在监控（系统和应用）领域，从来没有一个技术能像eBPF一样做到如此的高性能、细粒度、透明化，以及动态性。</p>
<p>现代数据中心中运行的应用程序，通常基于微服务架构设计，应用程序被拆分为大量独立的小服务，这些服务基于轻量级的协议（例如HTTP）进行通信。这些微服务通常容器化部署，可以动态按需创建、销毁、扩缩容。</p>
<p>这种容器化的微服务架构，在连接安全性方面引入了挑战。传统的Linux网络安全机制，例如iptables，基于IP地址、TCP/UDP端口进行过滤。在容器化架构下IP地址会很快变化，这会导致ACL规则、LB表需要不断的、加速（随着业务规模扩大）更新。由于IP地址不稳定，给实现精准可观察性也带来了挑战。</p>
<p>依赖于eBPF，Cilium能够基于服务/Pod/容器的标识（而非IP地址），实现安全策略的更新。能够在L7/L4/L3进行过滤。</p>
<div class="blog_h2"><span class="graybg">能力</span></div>
<div class="blog_h3"><span class="graybg">透明的保护API</span></div>
<p>能够在L7进行过滤，支持REST/HTTP、gRPC、Kafka等协议。从而实现：</p>
<ol>
<li>允许对/public/.*的GET请求，禁止其它任何请求</li>
<li>允许service1在Kafka的主题topic1上发布消息，service2在topic1上消费消息，禁止其它Kafka消息</li>
<li>要求所有HTTP请求具有头X-Token: [0-9]+</li>
</ol>
<div class="blog_h3"><span class="graybg">基于身份标识的安全访问</span></div>
<p>经典的容器防火墙，基于IP地址/端口来进行封包过滤，每当有新的容器启动，都要求所有服务器更新防火墙规则。</p>
<p>Cilium支持为一组应用程序分配身份标帜，共享同一安全策略。身份标识将关联到容器发出的所有网络封包，在接收封包的节点，可以校验身份信息。</p>
<div class="blog_h3"><span class="graybg">外部服务安全访问</span></div>
<p>上一段提到了，Cilium支持基于身份标识的内部服务之间的安全访问机制。对于外部服务，Cilium支持经典的基于CIDR的ingress/egress安全策略。</p>
<div class="blog_h3"><span class="graybg">简单的容器网络</span></div>
<p>Cilium支持一个简单的、扁平的L3网络，能够跨越多个集群，连接所有容器。通过使用host scope的IP分配器，IP分配被保持简单，每个主机可以独立进行分配分配，不需要相互协作。</p>
<p>支持以下多节点网络模型：</p>
<ol>
<li>Overlay：目前内置支持VxLAN和Geneve，所有Linux支持的封装格式都可以启用</li>
<li>Native Routing：也叫Direct Routing，使用Linux宿主机的路由表，底层网络必须具有路由容器IP的能力。支持原生的IP6网络，能够和云网络路由器协作</li>
</ol>
<div class="blog_h3"><span class="graybg">负载均衡</span></div>
<p>Cilium实现了分布式的负载均衡，可以完全代替kube-proxy。LB基于eBPF实现，使用高效的、可无限扩容的哈希表来存储信息。</p>
<p>对于南北向负载均衡，Cilium作了最大化性能的优化。支持XDP、DSR（Direct Server Return，LB仅仅修改转发封包的目标MAC地址）。</p>
<p>对于东西向负载均衡，Cilium在内核套接字层（TCP连接时）执行高效的service-to-backend转换（通过eBPF直接修改封包），避免了更低层次（IP）中per-packet的NAT（依赖conntrack，在高并发或大量连接的情况下有若干问题）操作成本。</p>
<div class="blog_h3"><span class="graybg">带宽管理</span></div>
<p>Cilium利用eBPF实现高效的基于EDT（Earliest Departure Time）的egress限速，能够很大程度上避免HTB/TBF等经典qdisc的缺点，包括传输尾延迟，多队列NIC下的锁问题。</p>
<div class="blog_h3"><span class="graybg">监控和诊断</span></div>
<p>对于任何分布式系统，可观察性对于监控和故障诊断都非常重要。Cilium提供了更好的诊断工具：</p>
<ol>
<li>携带元数据的事件监控：当封包被丢弃时，不但报告源地址，而能提供 完整的发送者/接收者元数据</li>
<li>策略决策跟踪：支持跟踪并发现是什么策略导致封包丢弃或请求拒绝</li>
<li>支持通过Prometheus暴露指标</li>
<li>Hubble：一个专门为Cilium设计的可观察性平台，能够提供服务依赖图、监控和报警</li>
</ol>
<div class="blog_h1"><span class="graybg">架构</span></div>
<div class="blog_h2"><span class="graybg">整体架构</span></div>
<p>整体上的组件架构如下：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/cilium-arch.png"><img class="size-large wp-image-37593 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/cilium-arch-1024x912.png" alt="cilium-arch" width="710" height="632" /></a></p>
<div class="blog_h2"><span class="graybg">Cilium组件</span></div>
<div class="blog_h3"><span class="graybg">Agent</span></div>
<p>cilium-agent在集群的每个节点上运行，它通过K8S或API接收描述网络、服务负载均衡、网络策略、可观察性的配置信息。</p>
<p>cilium-agent监听来自容器编排系统的事件，从而知晓哪些容器被启动/停止，它管理所有eBPF程序，后者控制所有网络访问。</p>
<p>Cilium利用eBPF实现datapath的过滤、修改、监控、重定向，需要<span style="background-color: #c0c0c0;">Linux 4.8+才能运行，推荐使用4.9.17+内核</span>（因为4.8已经EOL）。Cilium会自动探测内核版本，识别可用特性。</p>
<div class="blog_h3"><span class="graybg">CLI</span></div>
<p>cilium和cilium-agent一起安装，它和cilium-agent的REST API交互，从而探测本地agent的状态。CLI也提供了直接访问eBPF map的工具。</p>
<div class="blog_h3"><span class="graybg">Operator</span></div>
<p>负责集群范围的工作，它不在任何封包转发/网络策略决策的关键路径上，即使operator暂时不可用，集群仍然能正常运作。</p>
<p>根据配置，operator持续不可用一段时间后，可能出现问题：</p>
<ol>
<li>如果需要operator来分配IP地址，则会出现IPAM延迟，因此导致新的工作负载的调度延迟</li>
<li>由于没有operator来更新kvstore的心跳，会导致agent认为kvstore不健康，并重启</li>
</ol>
<div class="blog_h3"><span class="graybg">CNI插件</span></div>
<p>cilium-cni和当前节点上的Cilium API交互，触发必要的datapath配置，以提供容器网络、LB、网络策略。</p>
<div class="blog_h2"><span class="graybg">Hubble组件</span></div>
<div class="blog_h3"><span class="graybg">Server</span></div>
<p>在所有节点上运行，从cilium中抓去eBPF的可观察性数据。它被嵌入在cilium-agent中，以实现高性能和低overhead。它提供了一个gRPC服务，用于抓取flow和Prometheus指标。</p>
<div class="blog_h3"><span class="graybg">Relay</span></div>
<p>hubble-relay是一个独立组件，能够连接到所有Server，通过Server的gRPC API，获取全集群的可观察性数据。这些数据又通过一个API来暴露出去。</p>
<div class="blog_h3"><span class="graybg">CLI</span></div>
<p>hubble是一个命令行工具，能够连接到gRPC API、hubble-relay、本地server，来获取flow events。</p>
<div class="blog_h3"><span class="graybg">GUI</span></div>
<p>hubble-ui能够利用hubble-relay的可观测性数据，提供图形化的服务依赖、连接图。</p>
<div class="blog_h2"><span class="graybg">数据存储</span></div>
<p>Cilium需要一个数据存储，用来在Agent之间传播状态。</p>
<div class="blog_h3"><span class="graybg">K8S CRD</span></div>
<p>默认数据存储。</p>
<div class="blog_h3"><span class="graybg">KV Store</span></div>
<p>外部键值存储，可以提供更好的性能。支持etcd和consul。</p>
<div class="blog_h1"><span class="graybg">概念</span></div>
<div class="blog_h2"><span class="graybg">术语</span></div>
<div class="blog_h3"><span class="graybg">Label</span></div>
<p>标签是定位大的资源集合的一种通用、灵活的方法。每当需要定位、选择、描述某些实体时，Cilium使用标签：</p>
<ol>
<li>Endpoint：从容器运行时、编排系统或者其它资源得到标签</li>
<li>Network Policy：根据标签来选择可以相互通信的一组Endpoint，网络策略自身也基于标签来识别</li>
</ol>
<p>标签就是键值对，值部分可以省略。键具有唯一性，一个实体上不会有两个相同键的标签。键通常仅仅包含字符<code class="docutils literal notranslate"><span class="pre">[a-z0-9-.]</span></code>。</p>
<p>标签从源提取，为了防止潜在的键冲突，Cilium为所有导入的键添加前缀。例如</p>
<ol>
<li><pre class="crayon-plain-tag">k8s:role=frontend</pre>：具有role=frontend的K8S Pod，对应的Cilium端点具有此标签</li>
<li><pre class="crayon-plain-tag">container:user=alex</pre>：通过docker run -l user=alex运行的容器，对应的Cilium端点具有此标签</li>
</ol>
<p>不同前缀含义如下：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">container</pre>：从本地容器运行时得到的标签<br /><pre class="crayon-plain-tag">k8s</pre>：从Kubernetes得到的标签<br /><pre class="crayon-plain-tag">mesos</pre>：从Mesos得到的标签<br /><pre class="crayon-plain-tag">reserved</pre>：专用于特殊的保留标签<br /><pre class="crayon-plain-tag">unspec</pre>：未指定来源的标签</p>
<p>当通过标签来匹配资源时，使用前缀可以限定资源来源。如果不指定前缀，默认为<pre class="crayon-plain-tag">any:</pre>，标识匹配任何来源的资源。</p>
<div class="blog_h3"><span class="graybg">Endpoint</span></div>
<p>通过为容器分配IP，Cilium让它在网络上可见。多个应容器可能具有相同IP，典型的例子是Pod中的容器。任何享有同一IP的容器，在Cilium的术语里面，叫做端点。</p>
<p>Cilium的默认行为是，同时分配IPv4/IPv6地址给每个端点，你可以使用--enable-ipv4=false这样的选项来禁用某个IP版本。</p>
<p>在内部，Cilium为每个端点，在节点范围内，分配为唯一性的ID。</p>
<p>端点会从关联的容器自动提取端点元数据（Endpoint Metadata）。这些元数据用来安全、策略、负载均衡、路由上识别端点。端点元数据可能来自K8S的Pod标签、Mesos的标签、Docker的容器标签。</p>
<div class="blog_h3"><span class="graybg">Identity</span></div>
<p>任何端点都被分配身份标识（Identity），<span style="background-color: #c0c0c0;">身份标识通过端点的标签确定</span>，并且具有集群范围内的标识符（数字ID）。<span style="background-color: #c0c0c0;">端点被分配的身份标识，和它的安全相关标签匹配</span>，也就是说，具有相同安全相关标签的所有端点，共享同一身份标识。</p>
<p>仅仅安全相关标签用于确定端点的身份标识。<span style="background-color: #c0c0c0;">安全相关标签具有特定前缀</span>，默认情况下安全相关标签以<pre class="crayon-plain-tag">id.</pre>开头。启动cilium-agent时可以<span style="background-color: #c0c0c0;">指定自定义的前缀</span>。</p>
<p>特殊身份标识用于那些不被Cilium管理的端点，这些特殊标识以<pre class="crayon-plain-tag">reserved:</pre>作为前缀：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 60px; text-align: center;">数字ID</td>
<td style="width: 200px; text-align: center;">身份标识</td>
<td style="text-align: center;">描述</td>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td>reserved:unknown</td>
<td>无法提取身份标识的任何端点</td>
</tr>
<tr>
<td>1</td>
<td>reserved:host</td>
<td>localhost，任何来自/发往本机IP的流量，都牵涉到该端点</td>
</tr>
<tr>
<td>2</td>
<td>reserved:world</td>
<td>任何集群外的端点</td>
</tr>
<tr>
<td>3</td>
<td>reserved:unmanaged</td>
<td>不被Cilium管理的网络端点，例如在Cilium安装前就存在的Pod</td>
</tr>
<tr>
<td>4</td>
<td>reserved:health</td>
<td>由cilium-agent发起健康检查流量而产生的端点</td>
</tr>
<tr>
<td>5</td>
<td>reserved:init</td>
<td>身份标识尚未提取的端点</td>
</tr>
<tr>
<td>6</td>
<td>reserved:remote-node</td>
<td>集群中所有其它节点的集合</td>
</tr>
</tbody>
</table>
<p>Cilium能够识别一些知名标签，包括k8s-app=kube-dns，并自动分配安全标识。这个特性的目的是，让Cilium顺利的在启用Policy的情况下自举并获得网络连接性。</p>
<p>Cilium利用分布式的KV存储，为身份标识产生数字ID。cilium-agent为会使用身份标识去查询，如果KV存储已经没有对应的数字ID，就会新创建一个。</p>
<div class="blog_h3"><span class="graybg">Node</span></div>
<p>集群的单个成员，每个节点都必须运行cilium-agent。</p>
<div class="blog_h2"><span class="graybg">网络安全</span></div>
<p>Cilium提供多个层次的安全特性，这些特性可以单独或者联合使用。</p>
<div class="blog_h3"><span class="graybg">基于身份标识</span></div>
<p>容器编排系统中倾向于产生大量的Pod，这些Pod具有独立IP。传统的基于IP的网络策略，在容器场景下需要大量的、频繁变动的规则。</p>
<p>Cilium则完全将网络地址和安全策略分开。作为代替，它总是基于Pod的身份标识（通过它的标签提取）应用安全策略。它能够允许任何具有role=frontend标签的Pod访问role=backend的Pod，不管Pod的数量多少。</p>
<div class="blog_h3"><span class="graybg">安全策略</span></div>
<p>如果运行从A到B发起通信，则自动意味着允许B到A的报文传输，但是不意味着B能够发起到A的通信。</p>
<p>安全策略可以在ingress/egress端应用。</p>
<p>如果不提供任何策略，默认行为是允许任何通信。一旦提供一个安全策略规则，则所有不在白名单中的流量都被丢弃。</p>
<div class="blog_h3"><span class="graybg">代理注入</span></div>
<p>Cilium能够透明的为任何网络连接注入L4代理，这是L7网络策略的基础。目前支持的代理实现是<a href="https://docs.cilium.io/en/v1.10/concepts/security/proxy/envoy/">Envoy</a>。</p>
<p>你可以用Go语言编写少量的、用于解析新协议的代码。这种Go代码能够完全利用Cilium提供的高性能的转发到/自Envoy代理的能力、丰富的L7感知策略定义语言、访问日志、基于kTLS的加密流量可观察性。总而言之，作为开发者你只需要使用Go语言编写协议解析代码，其它的事情Cilium+Envoy+eBPF会做好。</p>
<div class="blog_h2"><span class="graybg">网络数据路径概要</span></div>
<div class="blog_h3"><span class="graybg">L1-2</span></div>
<ol>
<li>当网卡接收到封包后，它可以通过PCI桥，将其存放到内存（ring buffer）中</li>
<li>内核中一般化的轮询机制NAPI Poll（epoll、驱动程序都会使用该机制），会拉取到ring buffer中的数据，开始处理</li>
</ol>
<p>&nbsp;</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l12.png"><img class="aligncenter wp-image-37985" src="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l12.png" alt="datapath-l12" width="788" height="444" /></a></p>
<div class="blog_h3"><span class="graybg">L2</span></div>
<p>几乎所有驱动程序都会实现的<pre class="crayon-plain-tag">drvr_poll</pre>，它会调用第一个BPF程序，即XDP。</p>
<p>如果此程序返回<pre class="crayon-plain-tag">pass</pre>，内核会：</p>
<ol>
<li>调用clean_rx，在此Linux分配skb</li>
<li>如果启用GRO（Generic receive offload），则调用gro_rx，在此封包会被聚合，以一点延迟来换取吞吐量的提升。如果tcpdump时发现不可理解的巨大封包，可能是因为启用了GRO，你看到的是内核给的fake封包</li>
<li>调用receive_skb，开始L2接收处理</li>
</ol>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l2.png"><img class="wp-image-37983 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l2.png" alt="datapath-l2" width="952" height="430" /></a></p>
<div class="blog_h3"><span class="graybg">L2-3</span></div>
<p>当调用receive_skb后：</p>
<ol>
<li>如果驱动没有实现XDP支持，则在此调用XDP BPF程序，这里的效率比较低</li>
<li>轮询所有的 socket tap，将包放到正确的（如果存在） tap 设备的缓冲区</li>
<li>调用tc BPF程序。这是Cilium最依赖的挂钩点，实现了修改封包（例如打标记）、重新路由、丢弃封包等操作。这里的BPF程序可能会影响qdisc统计信息，从而影响流量塑形。如果tc BPF程序返回OK，则进入netfilter</li>
<li>netfilter 也会对入向的包进行处理，它是<span style="background-color: #c0c0c0;">网络栈的下半部分</span>，iptables规则越多，对网络栈下半部分造成的瓶颈也就越大</li>
<li>取决于L3协议的类型（几乎都是IP），调用相应L3接收函数并进入网络栈第三层</li>
</ol>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l23.png"><img class="wp-image-37981 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l23.png" alt="datapath-l23" width="967" height="435" /></a></p>
<div class="blog_h3"><span class="graybg">L3-4</span></div>
<p>当调用ip_rcv后：</p>
<ol>
<li>首先是netfilter钩子pre_routing，这里会从L4视角处理封包，会执行netfilter中的任何四层规则</li>
<li>netfilter处理完毕后，回调ip_rcv_finish</li>
<li>ip_rcv_finish会立即调用ip_routing对封包进行路由判断：是否位于lookback上，是否能够路由出去。如果Cilium没有使用隧道模式，则会使用到这里的路由功能</li>
<li>如果路由目的地是本机，则会调用ip_local_deliver。进而调用xfrm4_policy</li>
<li>xfrm4_policy负责完成包的封装、解封装、加解密。IPSec就是在此完成</li>
<li>根据L4协议的不同，调用相应的L4接收函数</li>
</ol>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l34.png"><img class="wp-image-37979 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l34.png" alt="datapath-l34" width="964" height="353" /></a></p>
<div class="blog_h3"><span class="graybg">L4</span></div>
<p>这里以UDP为例，L4入口函数为udp_rcv：</p>
<ol>
<li>该函数会对封包的合法性进行验证，检查UDP的checksum</li>
<li>封包再次送到xfrm4_policy进行处理。这是因为某些transform policy能够指定L4协议，而此时L4协议才明确</li>
<li>根据端口，查找对应的套接字，然后将skb存放到一个链表s.rcv_q中</li>
<li>最后，调用sk_data_ready，标记套接字有数据待收取</li>
</ol>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l4.png"><img class="wp-image-37977 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/datapath-l4.png" alt="datapath-l4" width="788" height="428" /></a></p>
<div class="blog_h3"><span class="graybg">L4-userspace</span></div>
<ol>
<li>上节提到了，套接字（的等待队列）会被标记为有数据待收取。用户空间程序，通过epoll在等待队列上监听，而因获得通知</li>
<li>用户空间调用udp_recv_msg函数，后者会调用cgroup BPF程序。这种程序用来实现透明的客户端egressing负载均衡</li>
<li>最后是sock_ops BPF程序。用于socket level的细粒度流量塑形。对于某些功能来说这很重要，例如客户端限速</li>
</ol>
<p> <a href="https://blog.gmem.cc/wp-content/uploads/2020/06/l4-us.png"><img class="size-full wp-image-37989 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/l4-us.png" alt="l4-us" width="664" height="654" /></a></p>
<div class="blog_h2"><span class="graybg">BPF挂钩点和对象</span></div>
<p>Linux内核在网络栈中支持一系列的BPF挂钩点，用于挂接BPF程序。Cilium利用这些挂钩点来实现高层次的网络功能。</p>
<div class="blog_h3"><span class="graybg">挂钩点</span></div>
<p>Cilium用到的钩子包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 150px; text-align: center;">钩子</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>XDP</td>
<td>
<p>网络路径上最早的、可以软件介入的点，在驱动接收到封包之后，具有最好的封包处理性能</p>
<p>能够快速过滤恶意/非预期的流量，例如DDoS</p>
</td>
</tr>
<tr>
<td>tc ingress/egress</td>
<td>
<p>在封包已经开始最初的处理之后的挂钩点，此时内核L3处理尚未开始，但是已经能够访问大部分的封包元数据</p>
<p>适合进行本节点相关的处理，例如应用L3/L4端点策略，重定向流量到特定端点</p>
</td>
</tr>
<tr>
<td>socket operations</td>
<td>socket operation hook挂钩到特定的cgroup，并且当TCP事件发生时执行。Cilium挂钩到根cgroup，依此实现TCP状态转换的监控，特别是ESTABLISHED状态转换。当一个TCP套接字进入ESTABLISHED状态，并且它具有一个节点本地的对端（可能是一个本地的proxy），则自动执行socket send/recv钩子来进行加速</td>
</tr>
<tr>
<td>socket send/recv</td>
<td>
<p>每当TCP套接字执行send操作时触发，钩子可以探查消息，然后或者丢弃、或者将消息发送到TCP层，或者重定向给另外一个套接字</p>
<p>Cilium使用这种钩子来加速数据路径的重定向</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">网络对象</span></div>
<p>利用上面这些挂钩点，以及虚拟接口（cilium_host, cilium_net）、一个可选的Overlay接口（cilium_vxlan）、内核的crypto支持、以及用户空间代理Envoy，Cilium创建以下类型的网络对象：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 150px; text-align: center;">网络对象</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>Prefilter</td>
<td>
<p>这类对象运行XDP程序，提供一系列的预过滤规则，获得最大性能的封包过滤</p>
<p>通过Cillium Agent提供的CIDR map，被用<span style="background-color: #c0c0c0;">于快速查找，判定一个封包是否应该被丢弃</span>。例如，假设目的地址不是有效的端点，则应该快速丢弃</p>
</td>
</tr>
<tr>
<td>Endpoint Policy</td>
<td>
<p>这类对象实现Cilium端点策略，它使用一个Map来查询当前封包关联的身份标识，当端点数量很大时，性能不会变差</p>
<p>根据策略，在这一层可能<span style="background-color: #c0c0c0;">丢弃封包、转发给本地端点、转发给Service对象、转发给L7策略对象</span></p>
<p>在Cilium中，这是<span style="background-color: #c0c0c0;">映射封包到身份标识、以及<span style="background-color: #c0c0c0;">应用</span>L3/L4策略</span>的主要对象</p>
</td>
</tr>
<tr>
<td>Service</td>
<td>
<p>这类对象根据每个封包的目的地址来进行Map查找，寻找对应的Service，如果找到了，则封包被转发给Service的某个L3/L4端点</p>
<p>可以和Endpoint Policy对象集成；也可以实现独立的LB</p>
</td>
</tr>
<tr>
<td>L3 Encryption</td>
<td>
<p>在ingress端，L3 Encryption对象标记封包为待解密，随后封包被传递给内核的xfrm（transform）层进行解密，随后解密后的封包传回，并交给网络栈中的其它对象进行后续处理</p>
<p>在egress端，首先根据目的地址进行Map查找，判断是否需要加密，如果是，目标节点上哪些key可用。同时在两端可用的、最近的key被用来加密。封包随后被标记为待解密，传递给内核的xfrm层。加密后的封包，传递给下一层处理，可能是传递给Linux网络栈进行路由，使用overlay的情况下可能直接发起一个尾调用</p>
</td>
</tr>
<tr>
<td>Socket Layer Enforcement</td>
<td>
<p>使用两类钩子：socket operations、socket send/recv，来<span style="background-color: #c0c0c0;">监、控所有Cilium管理的端点（包括L7代理）的TCP连接</span></p>
<p><span style="background-color: #c0c0c0;">socket operations钩子</span>否则<span style="background-color: #c0c0c0;">识别候选的、可加速的套接字</span>。这些<span style="background-color: #c0c0c0;">可加速套接字包括所有本地端点之间的连接、任何发往Cilium代理的连接</span>。可加速套接字的所有封包都会被socket send/recv钩子处理 —— 通过BPF sockmap进行快速重定向</p>
</td>
</tr>
<tr>
<td>L7 Policy</td>
<td>该对象将代理流量重定向给Cilium的用户空间代理，也就是Envoy。Envoy随后要么转发流量，要么根据配置的L7策略生成适当的reject消息</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">BPF Maps</span></div>
<p>Cilium使用了大量的BPF Maps，这些Map创建的时候都进行了容量限制。超过限制后，无法插入数据，因此限制了数据路径的扩容能力。下表显示了默认容量：</p>
<table class="docutils full-width" border="1">
<tbody valign="top">
<tr class="row-even">
<td style="width: 150px; text-align: center;"><strong>Map类别</strong></td>
<td style="text-align: center;"><strong>作用域</strong></td>
<td style="width: 80px; text-align: center;"><strong>默认限制</strong></td>
<td style="text-align: center;"><strong>扩容影响</strong></td>
</tr>
<tr>
<td>Connection Tracking</td>
<td>node<br />endpoint</td>
<td>1M TCP<br />256k UDP</td>
<td>Max 1M concurrent TCP connections, max 256k expected UDP answers</td>
</tr>
<tr class="row-odd">
<td>NAT</td>
<td>node</td>
<td>512k</td>
<td>Max 512k NAT entries</td>
</tr>
<tr class="row-even">
<td>Neighbor Table</td>
<td>node</td>
<td>512k</td>
<td>Max 512k neighbor entries</td>
</tr>
<tr class="row-odd">
<td>Endpoints</td>
<td>node</td>
<td>64k</td>
<td>Max 64k local endpoints + host IPs per node</td>
</tr>
<tr class="row-even">
<td>IP cache</td>
<td>node</td>
<td>512k</td>
<td>Max 256k endpoints (IPv4+IPv6), max 512k endpoints (IPv4 or IPv6) across all clusters</td>
</tr>
<tr class="row-odd">
<td>Load Balancer</td>
<td>node</td>
<td>64k</td>
<td>Max 64k cumulative backends across all services across all clusters</td>
</tr>
<tr class="row-even">
<td>Policy</td>
<td>endpoint</td>
<td>16k</td>
<td>Max 16k allowed identity + port + protocol pairs for specific endpoint</td>
</tr>
<tr class="row-odd">
<td>Proxy Map</td>
<td>node</td>
<td>512k</td>
<td>Max 512k concurrent redirected TCP connections to proxy</td>
</tr>
<tr class="row-even">
<td>Tunnel</td>
<td>node</td>
<td>64k</td>
<td>Max 32k nodes (IPv4+IPv6) or 64k nodes (IPv4 or IPv6) across all clusters</td>
</tr>
<tr class="row-odd">
<td>IPv4 Fragmentation</td>
<td>node</td>
<td>8k</td>
<td>Max 8k fragmented datagrams in flight simultaneously on the node</td>
</tr>
<tr class="row-even">
<td>Session Affinity</td>
<td>node</td>
<td>64k</td>
<td>Max 64k affinities from different clients</td>
</tr>
<tr class="row-odd">
<td>IP Masq</td>
<td>node</td>
<td>16k</td>
<td>Max 16k IPv4 cidrs used by BPF-based ip-masq-agent</td>
</tr>
<tr class="row-even">
<td>Service Source Ranges</td>
<td>node</td>
<td>64k</td>
<td>Max 64k cumulative LB source ranges across all services</td>
</tr>
<tr class="row-odd">
<td>Egress Policy</td>
<td>endpoint</td>
<td>16k</td>
<td>Max 16k endpoints across all destination CIDRs across all clusters</td>
</tr>
</tbody>
</table>
<p>部分BPF Map的容量上限可以通过cilium-agent的命令行选项覆盖：</p>
<p style="padding-left: 30px;">--bpf-ct-global-tcp-max<br />--bpf-ct-global-any-max<br />--bpf-nat-global-max<br />--bpf-neigh-global-max<br />--bpf-policy-map-max<br />--bpf-fragments-map-max<br />--bpf-lb-map-max</p>
<p>如果指定了--bpf-ct-global-tcp-max或/和--bpf-ct-global-any-max，则NAT表（<pre class="crayon-plain-tag">--bpf-nat-global-max</pre>）的大小不能超过前面两个表合计大小的2/3。</p>
<p>使用<pre class="crayon-plain-tag">--bpf-map-dynamic-size-ratio=0.0025</pre>，则cilium-agent在启动时能够动态根据总计内存来调整Map的容量。该选项取值0.0025则0.25%的系统内存用于BPF Map。 该标记会影响消耗大部分内存的Map，包括：</p>
<p style="padding-left: 30px;">cilium_ct_{4,6}_global<br />cilium_ct_{4,6}_any<br />cilium_nodeport_neigh{4,6}<br />cilium_snat_v{4,6}_external<br />cilium_lb{4,6}_reverse_sk</p>
<p>Cilium使用自己的，基于BPF Map实现的连接跟踪表，--bpf-map-dynamic-size-ratio影响容量，但是不会小于131072。</p>
<div class="blog_h2"><span class="graybg">封包生命周期</span></div>
<div class="blog_h3"><span class="graybg">从端点到端点</span></div>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/cilium_bpf_endpoint.png"><img class="alignnone wp-image-37657 " src="https://blog.gmem.cc/wp-content/uploads/2020/06/cilium_bpf_endpoint.png" alt="cilium_bpf_endpoint" width="1070" height="1124" /></a></p>
<p>上图包含两个部分：</p>
<ol>
<li>上半部分：本地端点到端点数据流图，显示了Cilium如何配合L7代理进行封包重定向的细节</li>
<li>下半部分：启用了Socket Layer Enforcement后的数据流图。这种情况下，TCP连接的握手阶段，需要遍历Endpoint Policy，直到ESTABLISHED，之后仅仅需要L7 Policy</li>
</ol>
<p>如果启用了L7规则，则流量会被转发给用户空间代理，代理处理完后，转发给目的端点的代理，后者再转发给目的端点的Pod。转发都是由bpf_redir负责，直接修改封包。</p>
<div class="blog_h3"><span class="graybg">端点到Egress</span></div>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/cilium_bpf_egres.png"><img class="alignnone wp-image-37661 " src="https://blog.gmem.cc/wp-content/uploads/2020/06/cilium_bpf_egres.png" alt="cilium_bpf_egres" width="1079" height="724" /></a></p>
<p>跨节点的封包流，可能牵涉到overlay，默认情况下overlay接口的名字是cilium_vxlan。</p>
<p>如果需要L3 Encryption，则Endpoint端的tc钩子会将其流量传递给L3 Encryption处理。需要注意tc BPF程序的<span id="crayon-60d3fea497f3d798084325" class="crayon-syntax crayon-syntax-inline  crayon-theme-gmem-github crayon-theme-gmem-github-inline crayon-font-consolas" style="font-size: 15px !important; line-height: 20px !important;"><span class="crayon-pre crayon-code" style="font-size: 15px !important; line-height: 20px !important; -moz-tab-size: 4; -o-tab-size: 4; -webkit-tab-size: 4; tab-size: 4;"><span class="crayon-v">da</span></span></span>模式，能够直接对封包进行修改、转发，而不需要外部的tc action模块。</p>
<p>和端点到端点流量类似，当启用Socket Layer Enforcement时，并且使用L7代理，则对于TCP流量可以避免运行端点和L7代理之间的Endpoint Policy。</p>
<div class="blog_h3"><span class="graybg">Ingress到端点</span></div>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2021/06/cilium_bpf_ingress.svg"><img class="alignnone" src="https://blog.gmem.cc/wp-content/uploads/2021/06/cilium_bpf_ingress.svg" alt="" width="1068" height="507" /></a></p>
<p>和端点到端点流量类似，当启用Socket Layer Enforcement时，并且使用L7代理，则对于TCP流量可以避免运行端点和L7代理之间的Endpoint Policy。</p>
<p>这种封包流可以被Prefilter快速处理，决定是否需要丢弃封包、是否需要进行负载均衡处理。</p>
<div class="blog_h2"><span class="graybg">iptables</span></div>
<p>依赖于实际使用的Linux内核版本，Cilium能够利用eBPF datapath全部或部分特性。如果Linux内核版本较低，某些功能可能基于iptables实现。</p>
<p>下图显示了Cilium和kube-proxy安装的iptables规则以及相互关系：<a href="https://blog.gmem.cc/wp-content/uploads/2021/06/kubernetes_iptables.svg"><img class="alignnone" src="https://blog.gmem.cc/wp-content/uploads/2021/06/kubernetes_iptables.svg" alt="" width="1078" height="962" /></a></p>
<div class="blog_h1"><span class="graybg">安装</span></div>
<div class="blog_h2"><span class="graybg">前提条件</span></div>
<div class="blog_h3"><span class="graybg">内核</span></div>
<p>要求内核版本4.9.17或者更高。</p>
<div class="blog_h3"><span class="graybg">K8S</span></div>
<p>必须启用CNI作为网络插件。</p>
<div class="blog_h2"><span class="graybg">下载命令行工具</span></div>
<pre class="crayon-plain-tag">curl -L --remote-name-all https://github.com/cilium/cilium-cli/releases/latest/download/cilium-linux-amd64.tar.gz{,.sha256sum}
sha256sum --check cilium-linux-amd64.tar.gz.sha256sum
sudo tar xzvfC cilium-linux-amd64.tar.gz /usr/local/bin
rm cilium-linux-amd64.tar.gz{,.sha256sum}</pre>
<div class="blog_h2"><span class="graybg">安装到K8S</span></div>
<div class="blog_h3"><span class="graybg">通过命令行工具</span></div>
<p>参考下面的命令进行安装：</p>
<pre class="crayon-plain-tag"># 安装到当前Kubernetes context
cilium install

cilium install
  --agent-image string              # Cilium Agent镜像
  --operator-image                  # Cilium Operator镜像
  --cluster-id int                  # 多集群模式下唯一ID
  --cluster-name string             # 多集群模式下此集群的名字
  --config strings                  # 添加Cilium配置条目，对应ConfigMap中的一个键值
  --context string                  # 使用的K8S Context
  --datapath-mode                   # 使用的datapath模式
  --disable-check strings           # 禁用指定的校验
  --encryption string               # 所有工作负载流量的加密：disabled（默认） | ipsec | wireguard
  --inherit-ca string               # 从另外一个集群继承/导入CA
  --ipam string                     # IPAM模式
  --kube-proxy-replacement string   # kube-proxy replacement工作模式：disabled（默认） | probe | strict
  -n, --namespace                   # Cilium安装到什么命名空间，默认kube-system
  --native-routing-cidr string      # 直接路由的CIDR，和PodCIDR一致
  --node-encryption                 # 加密所有节点到节点流量
  --restart-unmanaged-pods          # 重启所有没有被Cilium管理的Pod，默认true，保证所有Pod获得Cilium提供的容器网络
  --wait                            # 等待安装完毕，默认true


cilium install  \
  --agent-image=docker.gmem.cc/cilium/cilium:v1.10.1 \
  --operator-image=docker.gmem.cc/cilium/operator-generic:v1.10.1</pre>
<p>如果安装失败，可以通过命令<pre class="crayon-plain-tag">cilium status</pre> 查看整体部署状态，查看日志。</p>
<p>安装完毕后，使用下面的命令来检查状态、进行连通性测试：</p>
<pre class="crayon-plain-tag">cilium status --wait

cilium connectivity test</pre>
<div class="blog_h3"><span class="graybg">通过Helm</span></div>
<pre class="crayon-plain-tag">helm repo add cilium https://helm.cilium.io/



helm install cilium cilium/cilium --version 1.10.1 \
  --namespace kube-system

  # 是否启用调试日志
  --set debug.enabled=true

  # 集群ID，整数，范围1-255，网格中所有集群都必须有唯一ID
  --set cluster.id=1
  # 集群的名字，仅对于集群网格需要
  --set cluster.name=gmem

  # 容器网络路径选项，veth或者ipvlan
  --set datapathMode=veth

  # 禁用隧道，使用直接路由
  --set tunnel=disabled
  # 如果所有节点位于L2网络中，下面的选项用于自动在工作节点之间同步PodCIDR的路由
  # 如果不指定该选项，节点之间的路由不会同步，会出现节点A无法访问B上Pod IP的问题
  --set autoDirectNodeRoutes=true
  # 指定可以直接进行路由（访问时不需要进行IP遮掩）的CIDR，对应K8S配置的cluster-cidr（PodCIDR）
  # 禁用隧道后必须手工设置，否则报错
  --set nativeRoutingCIDR=172.27.0.0/16

  # 当ConfigMap改变时，滚动更新cilium-agent
  --set rollOutCiliumPods=false
  # 为cilium-config这个ConfigMap配置额外的键值
  --set extraConfig={}

  # 传递cilium-agent的额外命令行选项
  --set extraArgs=[]

  # 传递cilium-agent的额外环境变量
  --set extraEnv={}
  
  # 是否在Cilium中启用内置BGP支持
  --set bgp.enabled=false
  # 是否分配、宣告LoadBalancer服务的IP地址
  --set bgp.announce.loadbalancerIP=false

  # 强制cilium-agent在init容器中等待eBPF文件系统已挂载
  --set bpf.waitForMount=false
  # 是否预先分配eBPF Map的键值，会增加内存消耗并降低延迟
  --set bpf.preallocateMaps=false
  # TCP连接跟踪表的最大条目数量
  --set bpf.ctTcpMax=524288
  # 非TCP连接跟踪表的最大条目数量
  --set bpf.ctAnyMax=262144
  # 负载均衡表中最大服务条目
  --set bpf.lbMapMax=65536
  # NAT表最大条目数量
  --set bpf.natMax=524288
  # neighbor表最大条目数量
  --set bpf.neighMax=524288
  # 端点策略映射最大条目数量
  --set bpf.policyMapMax=16384
  # 配置所有BPF Map的自动sizing，根据可用内存
  --set bpf.mapDynamicSizeRatio=0.0025
  # 监控通知（monitor notifications）的聚合级别 none, low, medium, maximum
  --set bpf.monitorAggregation=medium
  # 活动连接的监控通知的间隔
  --set bpf.monitorInterval=5s
  # 哪些TCP flag第一次出现在某个连接中，会触发通知
  --set bpf.monitorFlags=all
  # 允许从外部访问集群的ClusterIP
  --set bpf.lbExternalClusterIP=false
  # 即用基于eBPF的IP遮掩支持
  --set bpf.masquerade=true
  # 直接路由模式，是通过宿主机网络栈进行（true），还是（如果内核支持）使用更直接的、高效的eBPF（false）
  # 后者的副作用是，跳过宿主机的netfilter
  --set bpf.hostRouting=true
  # 是否启用基于eBPF的TPROXY，以便在实现L7策略时减少对iptables的依赖
  --set bpf.tproxy=true
  # NodePort反向NAT处理时，是否跳过FIB查找
  --set bpf.lbBypassFIBLookup=true

  # 每当cilium-agnet重启时，清空BPF状态
  --set cleanBpfState=false
  # 每当cilium-agnet重启时，清空所有状态
  --set cleanState=false

  # 和其它CNI插件组成链，可选值none generic-veth portmap
  --set cni.chainingMode=none
  # 让Cilium管理/etc/cni/net.d目录，将其它CNI插件的配置改为*.cilium_bak
  --set cni.exclusive=true
  # 如果你希望通过外部机制将CNI配置写入，则设置为true
  --set cni.customConf=false
  --set cni.confPath: /etc/cni/net.d
  --set cni.binPath: /opt/cni/bin

  # 配置容器运行时集成  containerd crio docker none auto
  --set containerRuntime.integration=none

  # 支持对自定义BPF程序的尾调用
  --set customCalls.enabled=false

  # IPAM模式
  --set ipam.mode=cluster-pool
  # IPv4 CIDR
  --set ipam.operator.clusterPoolIPv4PodCIDR=0.0.0.0/8
  --set ipam.operator.clusterPoolIPv4MaskSize=24
  # IPv6 CIDR
  --set ipam.operator.clusterPoolIPv6PodCIDR=fd00::/104
  --set ipam.operator.clusterPoolIPv6MaskSize=120

  # 配置基于eBPF的ip-masq-agent
  --set ipMasqAgent.enabled=false

  # IP协议版本支持
  --set ipv4.enabled=true
  --set ipv6.enabled=false

  # 如果启用，这重定向、SNAT离开集群的流量
  --set egressGateway.enabled=false

  # 启用监控sidecar
  --set monitor.enabled=false

  # 配置Service负载均衡
  # 是否启用独立的、不连接到kube-apiserver的L4负载均衡器
  --set loadBalancer.standalone=false
  # 负载均衡算法 random或者maglev
  --set loadBalancer.algorithm=random
  # 对于远程后端，LB操作模式 snat, dsr, hybrid
  --set loadBalancer.mode=snat
  # 是否基于XDP来加速服务处理
  --set loadBalancer.acceleration=disabled
  # 是否利用IP选项/IPIP封装，来将Service的IP/端口信息传递到远程后端
  --set loadBalancer.dsrDispatch=opt

  # 是否对从端点离开节点的流量进行IP遮掩
  --set enableIPv4Masquerade=true
  --set enableIPv6Masquerade=true

  # 支持L7网络策略
  --set l7Proxy=true

  # 镜像
  --set image.repository=docker.gmem.cc/cilium/cilium
  --set image.useDigest=false
  --set operator.image.repository=docker.gmem.cc/cilium/operator
  --set operator.image.useDigest=false


# 重启所有被有被cilium管理的Pod
kubectl get pods --all-namespaces -o custom-columns=NAMESPACE:.metadata.namespace,NAME:.metadata.name,HOSTNETWORK:.spec.hostNetwork --no-headers=true | grep '&lt;none&gt;' | awk '{print "-n "$1" "$2}' | xargs -L 1 -r kubectl delete pod</pre>
<div class="blog_h2"><span class="graybg">高级安装</span></div>
<div class="blog_h3"><span class="graybg">使用外部Etcd</span></div>
<p>使用独立的外部Etcd（而非K8S自带的）可以提供更好的性能适用于更大的部署环境。</p>
<p>选用外部Etcd的时机可能是：</p>
<ol>
<li>超过250节点，5000个Pod。或者，在通过Kubernetes evnets进行状态传播时，出现了很高的overhead</li>
<li>你不希望利用CRD来存储Cilium状态</li>
</ol>
<pre class="crayon-plain-tag">helm install cilium cilium/cilium --version 1.10.1 \
  --namespace kube-system \
  --set etcd.enabled=true \
  --set "etcd.endpoints[0]=http://etcd-endpoint1:2379" \
  --set "etcd.endpoints[1]=http://etcd-endpoint2:2379" \
  --set "etcd.endpoints[2]=http://etcd-endpoint3:2379" \
  # 不使用CRD来存储状态
  --set identityAllocationMode=kvstore


# 使用SSL
kubectl create secret generic -n kube-system cilium-etcd-secrets \
    --from-file=etcd-client-ca.crt=ca.crt \
    --from-file=etcd-client.key=client.key \
    --from-file=etcd-client.crt=client.crt

helm install cilium cilium/cilium --version 1.10.1 \
  --namespace kube-system \
  --set etcd.enabled=true \
  --set etcd.ssl=true \
  --set "etcd.endpoints[0]=https://etcd-endpoint1:2379" \
  --set "etcd.endpoints[1]=https://etcd-endpoint2:2379" \
  --set "etcd.endpoints[2]=https://etcd-endpoint3:2379"</pre>
<div class="blog_h3"><span class="graybg">CNI Chaining</span></div>
<p>CNI Chaining允许联用Cilium和其它CNI插件。联用时某些Cilium高级特性不可用，包括：</p>
<ol>
<li><a href="https://github.com/cilium/cilium/issues/12454">L7策略</a></li>
<li><a href="https://github.com/cilium/cilium/issues/15596">IPsec透明加密</a></li>
</ol>
<p>你需要创建一个CNI配置，使用plugin list：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: ConfigMap
metadata:
  name: cni-configuration
  namespace: kube-system
data:
  cni-config: |-
    {
      "name": "generic-veth",
      "cniVersion": "0.3.1",
      "plugins": [
        {
          "type": "calico",
          "log_level": "info",
          "datastore_type": "kubernetes",
          "mtu": 1440,
          "ipam": {
              "type": "calico-ipam"
          },
          "policy": {
              "type": "k8s"
          },
          "kubernetes": {
              "kubeconfig": "/etc/cni/net.d/calico-kubeconfig"
          }
        },
        {
          "type": "portmap",
          "snat": true,
          "capabilities": {"portMappings": true}
        },
        {
          "type": "cilium-cni"
        }
      ]
    }</pre><br />
<pre class="crayon-plain-tag">helm install cilium cilium/cilium --version 1.10.1 \
  --namespace=kube-system \
  --set cni.chainingMode=generic-veth \
  --set cni.customConf=true \
  --set cni.configMap=cni-configuration \
  --set tunnel=disabled \
  --set enableIPv4Masquerade=false \
  --set enableIdentityMark=false</pre>
<div class="blog_h1"><span class="graybg">K8S集成</span></div>
<p>Cilium能够为K8S带来：</p>
<ol>
<li>基于CNI的容器网络支持</li>
<li>基于身份标识实现的NetworkPolicy，用于隔离L3/L4连接性</li>
<li>CRD形式的NetworkPolicy扩展，支持：
<ol>
<li>L7策略，目前支持HTTP、Kafka等协议</li>
<li>Egress策略支持CIDR</li>
</ol>
</li>
<li>ClusterIP实现，提供分布式的负载均衡。完全兼容kube-proxy模型</li>
</ol>
<p>支持的K8S版本为1.16+，内核版本4.9+。</p>
<p>K8S能够自动分配per-node的CIDR，通过kube-controller-manager的命令行选项<pre class="crayon-plain-tag">--allocate-node-cidrs</pre>启用此特性。Cilium会自动使用分配的CIDR。</p>
<div class="blog_h2"><span class="graybg">ConfigMap</span></div>
<p>Cilium使用名为cilium-config的ConfigMap：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: ConfigMap
metadata:
  name: cilium-config
  namespace: kube-system
data:
  # The kvstore configuration is used to enable use of a kvstore for state
  # storage.
  kvstore: etcd
  kvstore-opt: '{"etcd.config": "/var/lib/etcd-config/etcd.config"}'

  # Etcd配置
  etcd-config: |-
    ---
    endpoints:
      - https://node-1:31079
      - https://node-2:31079
    trusted-ca-file: '/var/lib/etcd-secrets/etcd-client-ca.crt'
    key-file: '/var/lib/etcd-secrets/etcd-client.key'
    cert-file: '/var/lib/etcd-secrets/etcd-client.crt'

  # 是否让Cilium运行在调试模式下
  debug: "false"
  # 是否启用IPv4地址支持
  enable-ipv4: "true"
  # 是否启用IPv6地址支持
  enable-ipv6: "true"
  # 在启动cilium-agent时，从文件系统中移除所有eBPF状态。这会导致进行中的连接中断、负载均衡决策丢失
  # 所有的eBPF状态将从源（例如K8S或kvstore）重新构造
  # 该选项用于缓和严重的eBPF maps有关的问题，并且在打开、重启cilium-agent后，立即关闭
  clean-cilium-bpf-state: "false"
  # 清除所有Cilium状态，包括钉在文件系统中的eBPF状态、CNI配置文件、端点状态
  # 当前被Cilium管理的Pod可能继续正常工作，但是可能在没有警告的情况下不再工作
  clean-cilium-state: "false"
  # 该选项启用在cilium monitor中的追踪事件的聚合
  monitor-aggregation: none, low, medium, maximum
  # 启用Map条目的预分配，这样可以降低per-packet的延迟，代价是提前的为Map中条目分配内存
  # 如果此选项改变，则cilium-agnet下次重启会导致具有活动连接的端点临时性中断
  preallocate-bpf-maps: "true"</pre>
<p>修改此ConfigMap后，你需要重新启动cilium-agent才能生效。需要注意，K8S的ConfigMap变更可能需要2分钟才能传播到所有节点。</p>
<div class="blog_h2"><span class="graybg">NetworkPolicy</span></div>
<p>K8S标准的NetworkPolicy，可以用来指定L3/L4 ingress策略，以及受限的egress策略。详细参考<a href="/kubernetes-study-note#network-policy">Kubernetes学习笔记</a>。</p>
<div class="blog_h2"><span class="graybg">CiliumNetworkPolicy</span></div>
<p>功能类似于标准的NetworkPolicy，但是提供丰富的多的特性，能够配置L3/L4/L7策略。</p>
<div class="blog_h2"><span class="graybg">L3策略</span></div>
<p>L3策略用于提供端点之间基本的连接性。支持通过以下方式来指定：</p>
<ol>
<li>基于标签：当通信双方端点都被Cilium管理（因而被提取了标签）时，使用此方式。此方式的优点是IP地址之类的易变信息不会编码在策略中</li>
<li>基于服务：自动提取、维护编排系统的服务的后端IP列表（对于K8S就是Service的Endpoint的IP地址列表）。即使端点不会Cilium管理，这种方式也可以避免硬编码IP到策略中</li>
<li>基于实体：实体用于描述那些被归类的、不需要知道其IP地址的端点。例如具有reserved:身份标识的那些端点</li>
<li>基于IP/CIDR：当外部服务不是一个端点时使用</li>
<li>基于DNS：先进行DNS查找，然后转换为IP</li>
</ol>
<div class="blog_h3"><span class="graybg">基于标签</span></div>
<pre class="crayon-plain-tag">## ingress示例

# 允许frontend访问backend
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l3-rule"
spec:
  endpointSelector:
    matchLabels:
      role: backend
  ingress:
  - fromEndpoints:
    - matchLabels:
        role: frontend

# 允许所有端点访问victim
kind: CiliumNetworkPolicy
metadata:
  name: "allow-all-to-victim"
spec:
  endpointSelector:
    matchLabels:
      role: victim
  ingress:
  - fromEndpoints:
    - {}



## egress示例

# 允许frontend访问backend
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l3-egress-rule"
spec:
  endpointSelector:
    matchLabels:
      role: frontend
  egress:
  - toEndpoints:
    - matchLabels:
        role: backend

# 允许frontend访问所有端点
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "allow-all-from-frontend"
spec:
  endpointSelector:
    matchLabels:
      role: frontend
  egress:
  - toEndpoints:
    - {}

# 禁止restricted访问任何端点
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "deny-all-egress"
spec:
  endpointSelector:
    matchLabels:
      role: restricted
  egress:
  - {}</pre>
<p>在设计策略时，通常遵循关注点分离原则。CiliumNetworkPolicy支持设置任何连接性发生所需要的“前提条件”。字段fromRequires用于为任何fromEndpoints指定前提条件。类似的还有toRequires。</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "requires-rule"
specs:
  # 对于生产环境下的Pod，允许访问它的端点，必须也在生产环境中（前提条件）
  - description: "For endpoints with env=prod, only allow if source also has label env=prod"
    endpointSelector:
      matchLabels:
        env: prod
    ingress:
    - fromRequires:
      - matchLabels:
          env: prod</pre>
<p>上面这个 fromRequires规则本身不会允许任何流量，它必须和fromEndpoints规则进行“与”才能允许特定流量：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l3-rule"
specs:
  # 配合上面的规则，效果就是，生产环境下的前端组件，可以访问生产环境下的端点
  - description: "For endpoints with env=prod, allow if source also has label role=frontend"
    endpointSelector:
      matchLabels:
        env: prod
    ingress:
    - fromEndpoints:
      - matchLabels:
          role: frontend</pre>
<div class="blog_h3"><span class="graybg">基于服务</span></div>
<p>运行在集群中的服务，可以在Egress规则的白名单中列出：</p>
<pre class="crayon-plain-tag"># 使用服务名
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "service-rule"
spec:
  # 策略控制的目标端点，总是通过标签选择
  endpointSelector:
    matchLabels:
      id: app2
  egress:
  # 允许访问特定服务
  - toServices:
    - k8sService:
        serviceName: myservice
        namespace: default


# 使用服务选择器
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "service-labels-rule"
spec:
  endpointSelector:
    matchLabels:
      id: app2
  egress:
  - toServices:
    - k8sServiceSelector:
        selector:
          matchLabels:
            head: none</pre>
<p>fromEntities用于描述哪些实体可以访问选择的端点； toEntities则用于描述选择的端点能够访问哪些实体。</p>
<div class="blog_h3"><span class="graybg">基于身份标识</span></div>
<p>支持的实体参考前文描述的具有reserved:前缀的特殊身份标识。</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "dev-to-host"
spec:
  endpointSelector:
    matchLabels:
      env: dev
  # 允许开发环境端点访问其本机上的实体
  egress:
    - toEntities:
      - host


apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "to-dev-from-nodes-in-cluster"
spec:
  endpointSelector:
    matchLabels:
      env: dev
  # 允许本机、集群远程机器访问开发环境端点
  # 注意，K8S默认允许从宿主机访问任何本地端点，cilium-agnet选项 --allow-localhost=policy可以禁用这默认行为
  ingress:
    - fromEntities:
      - host
      - remote-node


apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "from-world-to-role-public"
spec:
  endpointSelector:
    matchLabels:
      role: public
  # 允许集群外部访问role:public的端点
  ingress:
    - fromEntities:
      - world</pre>
<div class="blog_h3"><span class="graybg">基于CIDR</span></div>
<p>不被Cilium管理的实体，没有标签，不属于端点。这些实体通常是运行在特定子网中的外部服务、VM、裸金属机器。这类实体在策略中，可以用CIDR规则来描述。</p>
<p>CIDR规则不能用在通信两端都是以下之一的场景：</p>
<ol>
<li>被Cilium管理的端点</li>
<li>使用属于集群节点的IP的实体，包括使用host networking的Pod</li>
</ol>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "cidr-rule"
spec:
  endpointSelector:
    matchLabels:
      app: myService
  # 允许访问外部CIDR
  egress:
  - toCIDR:
    - 20.1.1.1/32
  - toCIDRSet:
    - cidr: 10.0.0.0/8
      except:
      - 10.96.0.0/12</pre>
<div class="blog_h3"><span class="graybg">基于DNS</span></div>
<p>使用DNS名称来指定不被Cilium管理的实体也是支持的，由matchName/matchPattern规则给出的DNS信息，会被cilium-agent收集为IP地址。</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "to-fqdn"
spec:
  endpointSelector:
    matchLabels:
      app: test-app
  egress:
    # 通过DNS Proxy拦截DNS请求，这样，当应用程序发起对my-remote-service.com的DNS查询时
    # Cilium能够学习到域名对应的IP地址
    - toEndpoints:
      - matchLabels:
          "k8s:io.kubernetes.pod.namespace": kube-system
          "k8s:k8s-app": kube-dns
      toPorts:
        - ports:
           - port: "53"
             protocol: ANY
          # DNS代理允许查询的域名
          rules:
            dns:
              - matchPattern: "*"
    - toFQDNs:
        # 将精确匹配此名称的IP地址插入到网路策略中
        - matchName: "my-remote-service.com"
        # 将匹配此模式的所有名称对应的IP地址插入到网络策略中
        # * 匹配所有域名，导致所有缓存的DNS IPs插入到规则
        # *.gmem.cc 匹配子域名，不匹配gmem.cc
        - matchPattern: "*"</pre>
<p>很多情况下，应用程序打开的长连接，生存期大于DNS的TTL，如果没有发生后续、针对此长连接域名的查询，则DNS缓存会过期。这种情况下，已经建立的长连接会继续运行。DNS缓存的TTL可以通过<pre class="crayon-plain-tag">--tofqdns-min-ttl</pre>配置。</p>
<p>相反的，对于短连接场景，可能由于反复的DNS查询（服务backed by大量主机）导致FQDN映射的IP地址很快增加，到达默认 <pre class="crayon-plain-tag">--tofqdns-max-ip-per-hostname=50</pre>的限制，并导致最旧的IP被剔除。这种情况下，已经建立的短连接也不会受到影响，直到它断开。</p>
<div class="blog_h3"><span class="graybg">关于DNS Proxy</span></div>
<p>DNS代理能够拦截DNS请求，记录IP和域名的对应关系。为了实现拦截，必须配置一个管理DNS请求的策略规则。</p>
<p><span style="background-color: #c0c0c0;">某些常用的容器镜像（例如alpine/musl）将DNS的Refused应答（当DNS代理拒绝某个查询时）看作更一般性的错误</span>，并且停止遍历/etc/resolv.conf的search list。例如，当Pod访问gmem.cc时，它会首先查询gmem.cc.svc.cluster.local.而得到DNS Proxy的Refused应答，停止遍历，不再查询gmem.cc.并且最终导致Pod认为DNS查询失败。</p>
<p>要解决此问题，可以配置<pre class="crayon-plain-tag">--tofqdns-dns-reject-response-code</pre>，默认值是refused，可以改为nameError，这样DNS代理会返回NXDomain应答。</p>
<div class="blog_h2"><span class="graybg">L4策略</span></div>
<p>主要是在L3的基础上，进行端口限制。</p>
<div class="blog_h3"><span class="graybg">限制端口</span></div>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l4-rule"
spec:
  endpointSelector:
    matchLabels:
      app: myService
  # 允许myService访问80端口
  egress:
    - toPorts:
      - ports:
        - port: "80"
          protocol: TCP</pre>
<div class="blog_h3"><span class="graybg">标签依赖的端口限制</span></div>
<p>下面的例子，允许针对特定标签（所关联的端点）的端口的访问：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l4-rule"
spec:
  endpointSelector:
    matchLabels:
      role: backend
  # 允许frontend服务访问backend服务的80端口
  ingress:
  - fromEndpoints:
    - matchLabels:
        role: frontend
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP</pre>
<div class="blog_h3"><span class="graybg">CIDR依赖的端口限制</span></div>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "cidr-l4-rule"
spec:
  endpointSelector:
    matchLabels:
      role: crawler
  # 允许爬虫访问192.0.2.0/24的80端口
  egress:
  - toCIDR:
    - 192.0.2.0/24
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP</pre>
<div class="blog_h2"><span class="graybg">L7策略</span></div>
<p>目前Cilium支持的L7协议很有限，仅仅HTTP和Kafka（beta）。</p>
<div class="blog_h3"><span class="graybg">HTTP</span></div>
<p>策略可以根据URL路径、HTTP方法、主机名、HTTP头来设置。</p>
<pre class="crayon-plain-tag"># 限定URL路径
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "rule1"
spec:
  description: "Allow HTTP GET /public from env=prod to app=service"
  endpointSelector:
    matchLabels:
      app: service
  # 允许生产环境访问service的/public
  ingress:
  - fromEndpoints:
    - matchLabels:
        env: prod
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP
      rules:
        http:
        - method: "GET"
          path: "/public"


# 限定URL和请求头
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l7-rule"
spec:
  endpointSelector:
    matchLabels:
      app: myService
  ingress:
  - toPorts:
    - ports:
      - port: '80'
        protocol: TCP
      rules:
        http:
        - method: GET
          path: "/path1$"
        - method: PUT
          path: "/path2$"
          headers:
          - 'X-My-Header: true'</pre>
<div class="blog_h3"><span class="graybg">Deny策略</span></div>
<p>用于明确的拒绝特定的流量，<span style="background-color: #c0c0c0;">优先级比Allow策略（CiliumNetworkPolicy/CiliumClusterwideNetworkPolicy/NetworkPolicy）高</span>，上文提及的所有策略都是Allow策略。</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "external-lockdown"
spec:
  endpointSelector: {}
  # 明确禁止外部访问
  ingressDeny:
  - fromEntities:
    - "world"
  ingress:
  - fromEntities:
    - "all"</pre>
<div class="blog_h2"><span class="graybg">CiliumClusterwideNetworkPolicy</span></div>
<p>类似于上面的CiliumNetworkPolicy，区别是：</p>
<ol>
<li>不限定到某个命名空间，集群范围的</li>
<li>支持使用节点选择器</li>
</ol>
<div class="blog_h3"><span class="graybg">主机策略</span></div>
<p>使用节点选择器，可以将策略应用到特定的一个/一组节点。主机策略仅仅应用到宿主机的初始命名空间，包括使用hostnetwork的Pod。</p>
<p>要支持主机策略，需要使用Helm值： <pre class="crayon-plain-tag">--set devices='{interface}'</pre>、<pre class="crayon-plain-tag">--set hostFirewall=true</pre>。</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumClusterwideNetworkPolicy
metadata:
  name: "lock-down-ingress-worker-node"
spec:
  # 允许据表标签type=ingress-worker的宿主机的所有指定端口的入站流量
  description: "Allow a minimum set of required ports on ingress of worker nodes"
  nodeSelector:
    matchLabels:
      type: ingress-worker
  ingress:
  - fromEntities:
    - remote-node
    - health
  - toPorts:
    - ports:
      - port: "6443"
        protocol: TCP
      - port: "22"
        protocol: TCP
      - port: "2379"
        protocol: TCP
      - port: "4240"
        protocol: TCP
      - port: "8472"
        protocol: UDP
      - port: "REMOVE_ME_AFTER_DOUBLE_CHECKING_PORTS"
        protocol: TCP</pre>
<div class="blog_h2"><span class="graybg">CiliumEndpoint</span></div>
<p>管理K8S中的Pod的过程中，Cilium会自动创建CiliumEndpoint对象，和对应Pod具有相同的namespace+name。</p>
<p>CiliumEndpoint和<pre class="crayon-plain-tag">cilium endpoint get</pre>命令得到的<pre class="crayon-plain-tag">.status</pre>字段有相同的信息：</p>
<pre class="crayon-plain-tag">kubectl get ciliumendpoints.cilium.io  nginx-0 -o jsonpath="{.status}" | jq
{
  // 通信加密设置
  "encryption": {},
  "external-identifiers": {
    "container-id": "eac9972f57187a7afe7bb3edf97c4e70eff8edff26b6923dda8f398d7e622ec9",
    "k8s-namespace": "default",
    "k8s-pod-name": "nginx-0",
    "pod-name": "default/nginx"
  },
  // 端点ID，每个端点都有唯一的ID
  "id": 2318,
  "identity": {
    // 身份标识
    "id": 34796,
    // 具有相同标签的Pod，共享同一身份标识
    "labels": [
      "k8s:app=nginx",
      "k8s:io.cilium.k8s.policy.cluster=default",
      "k8s:io.cilium.k8s.policy.serviceaccount=default",
      "k8s:io.kubernetes.pod.namespace=default"
    ]
  },
  "networking": {
    "addressing": [
      {
        "ipv4": "172.27.2.23"
      }
    ],
    "node": "10.0.3.1"
  },
  "state": "ready"
}

kubectl -n kube-system exec -it cilium-skvr6 -- cilium endpoint get 2318</pre>
<p>每个cilium-agent会创建一个名为<pre class="crayon-plain-tag">cilium-health-&lt;node-name&gt;</pre>的CiliumEndpoint，表示inter-agent健康检查端点。</p>
<div class="blog_h1"><span class="graybg">Istio集成</span></div>
<p>Cilium和Istio都使用Envoy作为七层代理。</p>
<p>集成Cilium和Istio，可以为启用了mTLS的Istio流量提供L7网络策略。如果不进行集成，则可以在Istio Sidecar之外应用应用L7策略，且不能识别mTLS流量。</p>
<div class="blog_h2"><span class="graybg">cilium-istioctl</span></div>
<p>Cilium增强的Istio版本，可以通过cilium-istioctl安装，当前版本1.8.2：</p>
<pre class="crayon-plain-tag">curl -L https://github.com/cilium/istio/releases/download/1.8.2/cilium-istioctl-1.8.2-linux-amd64.tar.gz | tar xz</pre>
<p>运行下面的命令安装Istio：</p>
<pre class="crayon-plain-tag"># 使用默认的Istio配置安装
cilium-istioctl install -y</pre>
<p>启用Istio自动的Envoy Sidecar注入：</p>
<pre class="crayon-plain-tag">kubectl label namespace default istio-injection=enabled</pre>
<p>&nbsp;</p>
<div class="blog_h1"><span class="graybg">网络策略</span></div>
<div class="blog_h2"><span class="graybg">准备</span></div>
<p>我们在default命名空间下，创建以下Pod，用于测试Cilium的功能：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: Pod
metadata:
  labels:
    app: nginx
  name: nginx-0
spec:
  containers:
  - args:
    - -g
    - daemon off;
    command:
    - nginx-debug
    image: docker.gmem.cc/library/nginx:1.19.3
    imagePullPolicy: Always
    name: nginx
    ports:
    - containerPort: 80
      protocol: TCP

---

apiVersion: v1
kind: Pod
metadata:
  labels:
    app: nginx
  name: nginx-1
spec:
  containers:
  - args:
    - -g
    - daemon off;
    command:
    - nginx-debug
    image: docker.gmem.cc/library/nginx:1.19.3
    imagePullPolicy: Always
    name: nginx
    ports:
    - containerPort: 80
      protocol: TCP

---

apiVersion: v1
kind: Pod
metadata:
  labels:
    app: alpine
  name: alpine
spec:
  containers:
  - args:
    - -c
    - sleep 365d
    command:
    - /bin/sh
    image: docker.gmem.cc/alpine:3.11
    imagePullPolicy: Always
    name: apline

---

apiVersion: v1
kind: Pod
metadata:
  name: ubuntu
  labels:
    app: ubuntu
spec:
  containers:
  - args:
    - -c
    - sleep 365d
    command:
    - /bin/sh
    image: docker.gmem.cc/ubuntu:16.04
    imagePullPolicy: Always
    name: ubuntu</pre>
<p>当Pod就绪后，查看端点状态：</p>
<pre class="crayon-plain-tag"># 在端点所在节点的cilium-agent中执行cilium endpoint list
# kubectl -n kube-system exec -it cilium-skvr6 -- cilium endpoint list | grep -E 'ubuntu|alpine|nginx'

ENDPOINT   POLICY (ingress)   POLICY (egress)   IDENTITY   LABELS (source:key[=value]) IPv6   IPv4           STATUS   
           ENFORCEMENT        ENFORCEMENT                                                                                                              
888        Disabled           Disabled          5371       k8s:app=alpine                     172.27.2.118   ready   
931        Disabled           Disabled          34796      k8s:app=nginx                      172.27.2.97    ready   
1781       Disabled           Disabled          34796      k8s:app=nginx                      172.27.2.148   ready   
2363       Disabled           Disabled          42034      k8s:app=ubuntu                     172.27.2.162   ready</pre>
<p>可以看到两个Nginx的Pod具有相同的身份标识，这是因为它们的标签一样。由于没有应用任何策略，因此ingress/egress policy为Disabled。</p>
<p>在为两个Nginx端点创建一个服务：</p>
<pre class="crayon-plain-tag">kubectl create service clusterip nginx --tcp=80:80</pre>
<p>确认客户端可以访问服务：</p>
<pre class="crayon-plain-tag">kubectl exec alpine -- curl -s -o /dev/null -I -w "%{http_code}\n" nginx
# 200
kubectl exec ubuntu -- curl -s -o /dev/null -I -w "%{http_code}\n" nginx
# 200</pre>
<div class="blog_h2"><span class="graybg">身份感知和HTTP感知</span></div>
<div class="blog_h3"><span class="graybg">身份感知（L3/L4策略）</span></div>
<p>下面我们增加一个策略，允许app=alpine访问app=nginx：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "nginx-ingress"
spec:
  endpointSelector:
    matchLabels:
      app: nginx
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: alpine
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP</pre>
<p>应用上述策略后，通过cilium endpoint list可以看到，两个Nginx的ingress policy为Enabled。</p>
<p>现在，在alpine中还能够访问nginx：</p>
<pre class="crayon-plain-tag">kubectl exec alpine -- curl -s -o /dev/null -I -w "%{http_code}\n" nginx
# 200</pre>
<p>在ubuntu中不能访问：</p>
<pre class="crayon-plain-tag">kubectl exec ubuntu -- curl -s -o /dev/null -I -w "%{http_code}\n" nginx
# 000
# 超时
# command terminated with exit code 7</pre>
<p>这说明策略生效，并且是白名单 —— 如果对某个identity应用了（accept）ingress policy，则只有明确声明的fromEndpoints才具有访问权限。</p>
<div class="blog_h3"><span class="graybg">HTTP感知（L7策略）</span></div>
<p>现在alpine能够访问nginx，假设我们向限制它仅仅能访问/welcome这个URL路径，就需要用到Cilium的L7策略：</p>
<pre class="crayon-plain-tag"># kubectl edit cnp nginx-ingress
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "nginx-ingress"
spec:
  endpointSelector:
    matchLabels:
      app: nginx
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: alpine
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP
      # 在L4策略的基础上，添加以下内容
      rules:
        http:
        - method: "GET"
          # 支持正则式，例如/welcome/.*
          path: "/welcome"</pre>
<p>现在，alpine访问index.html时会得到403（禁止访问）错误，而GET /welcome则正常访问：</p>
<pre class="crayon-plain-tag">kubectl exec alpine -- curl -s -o /dev/null -I -w "%{http_code}\n" nginx
# 403

kubectl exec alpine -- curl -s -o /dev/null -I -w "%{http_code}\n" -X GET http://nginx/welcome
# 200</pre>
<p>你可以通过下面的命令对流量进行监控：</p>
<pre class="crayon-plain-tag">kubectl -n kube-system exec -it cilium-skvr6 -- cilium monitor -v --type l7

&lt;- Request http from 0 ([k8s:app=alpine k8s:io.cilium.k8s.policy.cluster=default k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default]) 
                to 931 ([k8s:io.cilium.k8s.policy.cluster=default k8s:app=nginx k8s:io.kubernetes.pod.namespace=default k8s:io.cilium.k8s.policy.serviceaccount=default]), 
                identity 5371-&gt;34796, verdict Denied HEAD http://nginx/welcome =&gt; 403

&lt;- Request http from 0 ([k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.kubernetes.pod.namespace=default k8s:app=alpine k8s:io.cilium.k8s.policy.cluster=default]) 
                to 931 ([k8s:io.cilium.k8s.policy.cluster=default k8s:app=nginx k8s:io.kubernetes.pod.namespace=default k8s:io.cilium.k8s.policy.serviceaccount=default]), 
                identity 5371-&gt;34796, verdict Forwarded GET http://nginx/welcome =&gt; 0
&lt;- Response http to 0 ([k8s:io.kubernetes.pod.namespace=default k8s:app=alpine k8s:io.cilium.k8s.policy.cluster=default k8s:io.cilium.k8s.policy.serviceaccount=default]) 
                from 931 ([k8s:io.cilium.k8s.policy.serviceaccount=default k8s:io.cilium.k8s.policy.cluster=default k8s:app=nginx k8s:io.kubernetes.pod.namespace=default]), 
                identity 5371-&gt;34796, verdict Forwarded GET http://nginx/welcome =&gt; 200</pre>
<div class="blog_h2"><span class="graybg">使用DNS规则</span></div>
<div class="blog_h3"><span class="graybg">锁死外部访问</span></div>
<p>假设我们想仅允许nginx访问docker.gmem.cc，可以使用下面的策略：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: nginx-egress
spec:
  endpointSelector:
    matchLabels:
      app: nginx
  egress:
  # 两个规则
  # 第一个规则：允许访问域名docker.gmem.cc
  - toFQDNs:
    # 支持使用通配符，例如 *.gmem.cc
    - matchName: "docker.gmem.cc"  
  # 第二个规则：允许访问kube-dns
  - toEndpoints:
    - matchLabels:
        "k8s:io.kubernetes.pod.namespace": kube-system
        "k8s:k8s-app": kube-dns
    toPorts:
    - ports:
      - port: "53"
        protocol: ANY
      # 这个规则提示Cilium检查匹配pattern的DNS查询，并将结果缓存
      rules:
        dns:
        - matchPattern: "*"</pre>
<p>第二个规则的作用在于，允许nginx访问kube-dns服务，进行域名查询。同时，让Cilium的DNS Proxy能够记录nginx执行的所有DNS查询，并且记录域名和IP地址的对应关系。</p>
<p>Cilium缓存的DNS查询结果中的IP地址，才是真正放到BPF Map中的、允许访问的白名单。</p>
<p>应用上述策略后，nginx将无法访问任何集群内部服务，除了kube-dns，除非你配置额外的策略。测试一下效果：</p>
<pre class="crayon-plain-tag">kubectl exec nginx-0 -- curl -s -o /dev/null -I -w "%{http_code}\n" --insecure https://docker.gmem.cc         
# 200
kubectl exec nginx-0 -- curl -s -o /dev/null -I -w "%{http_code}\n" --insecure https://blog.gmem.cc
# 000
# command terminated with exit code 7
kubectl exec nginx-0 -- curl -s -o /dev/null -I -w "%{http_code}\n" --insecure http://nginx
# 000
# command terminated with exit code 7</pre>
<div class="blog_h3"><span class="graybg">联用toPorts </span></div>
<p>可以联合使用toFQDNs和toPorts，以限制访问外部服务使用的端口、通信协议：</p>
<pre class="crayon-plain-tag"># ...
  egress:
  - toFQDNs:
    - matchPattern: "*.gmem.cc" 
    toPorts:
    - ports:
      - port: "443"
        protocol: TCP</pre>
<div class="blog_h2"><span class="graybg">拦截和探查TLS </span></div>
<p>Cilium支持透明的探查TLS加密连接的内容。 基于这个能力，即使是HTTPS流量，Cilium也能做到API感知并应用L7策略。这种能力完全基于软件实现，并且是策略驱动的，仅仅探测策略选中的网络连接。</p>
<p>我们需要以下步骤，以实现TLS拦截/探查：</p>
<ol>
<li>创建一个内部使用的CA，并基于此CA创建办法证书，以实现TLS拦截。端点访问外部TLS服务时，请求被Cilium拦截，并使用此内部CA颁发的证书为端点提供TLS服务</li>
<li>使用Cilium网络策略的DNS规则，选择需要拦截的流量</li>
<li>进行TLS探查，例如：
<ol>
<li>利用cilium monitor来探查HTTP请求的详细内容</li>
<li>使用L7策略过滤/修改HTTP请求</li>
<li>通过Hubble进行观察</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">创建CA和证书</span></div>
<pre class="crayon-plain-tag"># 自签名CA证书
openssl genrsa -des3 -out ca.key 2048
openssl req -x509 -new -nodes -key ca.key -sha256 -days 1825 -out ca.crt

# 生成被探查目标服务，这里是docker.gmem.cc的证书，注意填写正确的Common Name
openssl genrsa -out gmem.cc.key 2048
openssl req -new -key gmem.cc.key -out gmem.cc.csr

# 签名证书
openssl x509 -req -days 360 -in gmem.cc.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out gmem.cc.crt -sha256

# 将证书和密钥写入为secret备用
kubectl create secret tls gmem-tls-data -n kube-system --cert=gmem.cc.crt --key=gmem.cc.key</pre>
<div class="blog_h3"><span class="graybg">将CA加入受信根证书列表</span></div>
<p>上面的自签名CA，需要加到源端点（客户端Pod）的受信任根证书列表：</p>
<pre class="crayon-plain-tag">kubectl cp ca.crt default/ubuntu:/usr/local/share/ca-certificates/ca.crt
kubectl exec ubuntu -- update-ca-certificates</pre>
<p>目标服务的CA证书，则需要写入secret备用。最简单办法是，将系统所有受信任证书的列表，一起写入：</p>
<pre class="crayon-plain-tag">kubectl cp default/ubuntu:/etc/ssl/certs/ca-certificates.crt ca-certificates.crt
kubectl -n kube-system create secret generic tls-orig-data --from-file=ca.crt=./ca-certificates.crt</pre>
<div class="blog_h3"><span class="graybg">创建DNS/TLS感知Egress策略 </span></div>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "l7-visibility-tls"
spec:
  endpointSelector:
    matchLabels:
      app: ubuntu
  egress:
  - toFQDNs:
    - matchName: "docker.gmem.cc"
    toPorts:
    - ports:
      - port: "443"
        protocol: "TCP"
      # 第一个TLS连接，也就是Cilium扮演服务端的连接，使用的证书（和密钥）
      terminatingTLS:
        secret:
          namespace: "kube-system"
          name: "gmem-tls-data"
      # 第二个TLS连接，也就是Cilium扮演客户端的连接，使用的受信任证书列表
      originatingTLS:
        secret:
          namespace: "kube-system"
          name: "tls-orig-data"
      # 启用L7策略
      rules:
        http:
        # 允许所有HTTP流量
        - {}
  - toPorts:
    - ports:
      - port: "53"
        protocol: ANY
      rules:
        dns:
          - matchPattern: "*"</pre>
<p>应用上述策略后，尝试从ubuntu访问docker.gmem.cc，然后通过cilium monitor -v --type l7探查发生的流量。</p>
<div class="blog_h2"><span class="graybg">gRPC安全策略</span></div>
<p>gRPC是基于HTTP2协议的，Cilium不支持gRPC的原语，但是gRPC服务/方法是映射到特定URL路径的POST方法的：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "rule1"
spec:
  endpointSelector:
    matchLabels:
      app: nginx
  ingress:
  - fromEndpoints:
    - matchLabels:
        app: alpine
    toPorts:
    - ports:
      - port: "80"
        protocol: TCP
      rules:
        http:
        - method: "POST"
          #      gRPC服务          gRPC方法
          path: "/gmem.UserManager/GetName"</pre>
<div class="blog_h2"><span class="graybg">Kafka安全策略 </span></div>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "rule2"
spec:
  endpointSelector:
    matchLabels:
      app: kafka
  ingress:
  - fromEndpoints:
    - matchLabels:
        # 允许alpine访问kafka
        app: alpine
    toPorts:
    - ports:
      - port: "9092"
        protocol: TCP
      rules:
        kafka:
        # 允许消费msgs主题
        - role: "consume"
          topic: "msgs"</pre>
<div class="blog_h2"><span class="graybg">Cassanadra安全策略</span></div>
<p>目前Cilium提供了对Apache Cassanadra的Beta支持。</p>
<p>Apache Cassanadra是一种NoSQL数据库，专注于提供高性能的（特别是写）事务能力，同时不以牺牲可用性和可扩容性为代价。Cassanadra以集群方式运行，客户端通过Cassanadra协议与集群通信。</p>
<p>Cilium能理解Cassanadra协议，从而控制客户端可以访问哪些表，可以对表进行哪些操作。</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "secure-empire-cassandra"
specs:
    endpointSelector:
      matchLabels:
        app: cass-server
    ingress:
    - fromEndpoints:
      - matchLabels:
          app: alpine
      toPorts:
      - ports:
        - port: "9042"
          protocol: TCP
        rules:
          # Cassanadra协议
          l7proto: cassandra
          l7:
            # 允许的操作
          - query_action: "select"
            # 操作针对的表，正则式。指定表需要&lt;keyspace&gt;.&lt;table&gt;形式
            query_table: "system\\..*"
          - query_action: "select"
            query_table: "system_schema\\..*"
          - query_action: "insert"
            query_table: "attendance.daily_records"</pre>
<div class="blog_h2"><span class="graybg">本地重定向策略</span></div>
<p>所谓本地重定向，是指Pod发向IP地址/Service的流量，被重定向到本机Pod的情况。本地重定向策略管理这种流量 —— 它可以将<span style="background-color: #c0c0c0;">匹配策略的流量重定向到本机</span>。</p>
<p>该特性需要4.19+内核。使用选项 <pre class="crayon-plain-tag">--set localRedirectPolicy=true</pre> 开启该特性。</p>
<p>本地重定向策略对应自定义资源<pre class="crayon-plain-tag">CiliumLocalRedirectPolicy</pre>。以下配置字段：</p>
<ol>
<li><pre class="crayon-plain-tag">ServiceMatcher</pre>：用于被重定向的ClusterIP类型的服务</li>
<li><pre class="crayon-plain-tag">AddressMatcher</pre>：用于目的地是IP地址，不属于任何服务的情况</li>
</ol>
<p>当启用本地重定向策略后，非backend Pod访问frontend时，Cilium BPF数据路径会将frontend地址转换为一个本地backend Pod地址。如果流量从backend Pod发往frontend地址，则不会进行进行转换（导致的结果是访问frontend的原始端点），否则就导致循环。Cilium通过调用sk_lookup_助手函数实现这一逻辑。</p>
<div class="blog_h3"><span class="graybg">根据地址匹配</span></div>
<p>下面这个例子，将发往169.254.169.254:8080的TCP流量，重定向到本机的app=proxy端点的80端口：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumLocalRedirectPolicy
metadata:
  name: "lrp-addr"
spec:
  redirectFrontend:
    addressMatcher:
      ip: "169.254.169.254"
      toPorts:
        - port: "8080"
          protocol: TCP
  redirectBackend:
    localEndpointSelector:
      matchLabels:
        app: proxy
    toPorts:
      - port: "80"
        protocol: TCP</pre>
<div class="blog_h3"><span class="graybg">根据服务匹配</span></div>
<p>下面的例子，如果访问default/my-service，则重定向到本机的app=proxy端点的80端口：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumLocalRedirectPolicy
metadata:
  name: "lrp-svc"
spec:
  redirectFrontend:
    serviceMatcher:
      serviceName: my-service
      namespace: default
  redirectBackend:
    localEndpointSelector:
      matchLabels:
        app: proxy
    toPorts:
      - port: "80"
        protocol: TCP</pre>
<div class="blog_h3"><span class="graybg">限制条件</span></div>
<ol>
<li>策略应用之前，匹配策略的已经存在的连接，不受策略影响</li>
<li>此策略不支持更新，只能删除重建</li>
</ol>
<div class="blog_h3"><span class="graybg">应用场景</span></div>
<p>本地重定向策略的一个应用场景是节点本地DNS缓存。 </p>
<p>节点本地DNS缓存在一个静态的IP地址上监听，配合本地重定向策略，可以拦截来自应用程序Pod的、发往kubed-dns ClusterIP的流量。</p>
<p>策略定义示例：</p>
<pre class="crayon-plain-tag">apiVersion: "cilium.io/v2"
kind: CiliumLocalRedirectPolicy
metadata:
  name: "node-local-dns"
  namespace: kube-system
spec:
  # 如果访问kube-dns
  redirectFrontend:
    serviceMatcher:
      serviceName: kube-dns
      namespace: kube-system
  redirectBackend:
    # 那么重定向到Node local DNS
    localEndpointSelector:
      matchLabels:
        k8s-app: node-local-dns
    # TCP和UDP都支持
    toPorts:
      - port: "53"
        name: dns
        protocol: UDP
      - port: "53"
        name: dns-tcp
        protocol: TCP</pre>
<div class="blog_h1"><span class="graybg">网络连接</span></div>
<div class="blog_h2"><span class="graybg">路由</span></div>
<div class="blog_h3"><span class="graybg">Encapsulation</span></div>
<p>如果不提供任何配置，Cilium自动运行在overlay（encapsulation）模式下，这种模式对底层网络的要求最小。overlay模式下所有节点组成基于UDP封装的网格。支持的封装协议包括：</p>
<ol>
<li>VxLAN：默认封装模式，占用8472/UDP端口</li>
<li>Geneve：占用6081/UDP端口</li>
</ol>
<p>所有Cilium节点之间的流量都被封装。</p>
<p>overlay模式的优点：</p>
<ol>
<li>简单：集群节点所在的网络，不需要对PodCIDR有任何感知。只要底层网络支持IP/UDP，即可构建出overlay网络</li>
<li>地址空间：由于不依赖底层网络，因而可以使用很大的IP地址范围，支持很大规模的Pod数量</li>
<li>自动配置：在编排系统中，每个节点可以被分配一个IP前缀，并独立进行IPAM</li>
<li>身份标识上下文：利用封装协议，可以为网络封包附带元数据。Cilium利用这种能力，来传输源节点的安全标识信息，让目标节点不必查询封包所属的实体</li>
</ol>
<p>overlay模式的缺点：</p>
<ol>
<li>MTU overhead：由于额外的封装头，导致有效MTU比native-routing小。对于VxLAN每个封包的有效MTU减少50字节。这会导致单个特定网络连接的最大吞吐率减小。使用Jumbo frames则实际影响大大减小</li>
</ol>
<div class="blog_h3"><span class="graybg">Native-Routing</span></div>
<p>配置<pre class="crayon-plain-tag">tunnel: disabled</pre>可以启用此datapath，这种模式下，目的地不是本机的封包，被委托给Linux的路由子系统处理。这要求连接节点的网络能够正确处理路由：</p>
<ol>
<li>要么所有节点直接位于L2网络中，可以配置<pre class="crayon-plain-tag">auto-direct-node-routes: true</pre></li>
<li>要么连接它们的路由器能够处理路由：
<ol>
<li>在云环境下，VPC需要和Cilium进行集成，以获得路由信息。目前主流云厂商已经支持</li>
<li>在支持BGP的路由器的配合下，基于BGP协议分发路由。可以通过kube-router来运行BGP守护程序</li>
</ol>
</li>
</ol>
<p>配置<pre class="crayon-plain-tag">native-routing-cidr: x.x.x.x/y</pre>指定可以进行native-routing的CIDR。</p>
<div class="blog_h2"><span class="graybg">IPAM</span></div>
<p>IPAM负责分配和管理网络端点（容器或其它）的IP地址。Cilium支持多种IPAM模式。</p>
<div class="blog_h3"><span class="graybg">kubernetes</span></div>
<p>使用Kubernetes自带的host-scope IPAM。地址分配委托给每个节点进行，per-node的Pod CIDR存放在v1.Node中。</p>
<div class="blog_h3"><span class="graybg">cluster-pool</span></div>
<p>这是默认的IPAM mode，它分配per-node的Pod CIDR，并在每个节点上使用host-scope的分配器来分配IP地址。</p>
<p>此模式和kubernetes类似，区别在于后者在v1.Node资源中存储per-node的Pod CIDR，而Cilium在<pre class="crayon-plain-tag">v2.CiliumNode</pre>中存储此信息。</p>
<p>此模式下，cilium-agent在启动时会等待v2.CiliumNode中的<pre class="crayon-plain-tag">Spec.IPAM.PodCIDRs</pre>字段可用。</p>
<p>通过Helm安装时，使用下面的值来启用此模式：</p>
<pre class="crayon-plain-tag">helm install ...
  --set ipam.mode=cluster-pool

  --set ipam.operator.clusterPoolIPv4PodCIDR=&lt;IPv4CIDR&gt;
  # 调整每个节点的CIDR规模
  --set ipam.operator.clusterPoolIPv4MaskSize=&lt;IPv4MaskSize&gt;

  --set ipam.operator.clusterPoolIPv6PodCIDR=&lt;IPv6CIDR&gt;
  --set ipam.operator.clusterPoolIPv6MaskSize=&lt;IPv6MaskSize&gt;</pre>
<p>在运行时，使用下面的命令查询IP分配错误：</p>
<pre class="crayon-plain-tag">kubectl get ciliumnodes -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.operator.error}{"\n"}{end}'</pre>
<p>使用下面的命令查看IP分配情况：</p>
<pre class="crayon-plain-tag">cilium status --all-addresses</pre>
<div class="blog_h3"><span class="graybg">crd</span></div>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/crd_arch.png"><img class="size-full wp-image-37601 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/crd_arch.png" alt="crd_arch" width="787" height="250" /></a></p>
<p>此模式下，cilium-agent会监听当前节点同名的v2.CiliumNode资源，每当CiliumNode被更新，cilium-agent会利用列在<pre class="crayon-plain-tag">spec.ipam.available</pre>的IP地址来更新本节点的IP池。如果<span style="background-color: #c0c0c0;">已经分配的IP地址从spec.ipam.available中移除</span>，仍然可以正常使用，但是释放后不能重新分配。</p>
<p>当IP被分配出去之后，会记录到<pre class="crayon-plain-tag">status.ipam.inuse</pre>字段。</p>
<p>你需要开发一个Operator，将IP地址分配给特定节点，此模式提供了很大的灵活性。</p>
<div class="blog_h2"><span class="graybg">IP遮掩</span></div>
<p>对于IPv4，容器访问外部流量时Cilium会自动进行SNAT，替换源地址为节点的IP地址。对于IPv6，IP遮掩仅在iptables模式下被支持。</p>
<p>使用选项<pre class="crayon-plain-tag">enable-ipv4-masquerade: false</pre>和<pre class="crayon-plain-tag">enable-ipv6-masquerade: false</pre>可以改变上述默认行为。</p>
<p>如果Pod IP在节点网络中可以路由，可以配置<pre class="crayon-plain-tag">native-routing-cidr</pre>，如果<span style="background-color: #c0c0c0;">目的地址</span>在此CIDR中，则不进行IP遮掩。</p>
<p>Cilium支持多种IP遮掩的实现模式。</p>
<div class="blog_h3"><span class="graybg">ebpf</span></div>
<p>最高效的实现，要求内核版本4.19+，默认启用。对应Helm值<pre class="crayon-plain-tag">bpf.masquerade=true</pre>。当前版本此特性依赖<a href="https://docs.cilium.io/en/v1.10/gettingstarted/kubeproxy-free/#kubeproxy-free">BPF NodePort</a>特性。</p>
<p>基于eBPF的IP遮掩，只能发生在挂钩了eBPF masquerading程序的节点出口设备上。哪些出口设备进行挂钩，可以通过Helm值<pre class="crayon-plain-tag">devices</pre>指定，如果不指定则基于<a href="https://docs.cilium.io/en/v1.10/gettingstarted/kubeproxy-free/#nodeport-devices">BPF NodePort device detection metchanism</a>自动选择。</p>
<p>使用cilium status命令可以检查哪些设备挂钩了：</p>
<pre class="crayon-plain-tag">kubectl exec -it -n kube-system cilium-xxxxx -- cilium status | grep Masquerading
#                                       已挂钩设备     不遮掩的CIDR
# Masquerading:   BPF (ip-masq-agent)   [eth0, eth1]  10.0.0.0/16</pre>
<p>该模式支持TCP/UDP/ICMP这三类IPv4的L4协议，其中ICMP仅仅支持Echo请求/应答。</p>
<p>除了配置native-routing-cidr，你还可以配置Helm值<pre class="crayon-plain-tag">ipMasqAgent.enabled=true</pre>，更细粒度的控制，访问哪些目的IP时不需要进行遮掩。这个能力是依靠Cilium开发的eBPF版本的<a href="https://github.com/kubernetes-sigs/ip-masq-agent">ip-masq-agent</a>来实现的。</p>
<div class="blog_h3"><span class="graybg">iptables</span></div>
<p>遗留模式，支持在所有版本的内核上运行。</p>
<div class="blog_h2"><span class="graybg">IP分片处理</span></div>
<p>默认情况下，Cilium配置eBPF数据路径，进行IP分片跟踪，以允许不支持分段的协议能透明的通过网络传输大报文。</p>
<p>IP分片跟踪在eBPF中通过LRU Map实现，要求4.10+内核。该特性通过以下选项启用：</p>
<ol>
<li><pre class="crayon-plain-tag">enable-ipv4-fragment-tracking</pre>：启用或禁用IPv4分片跟踪，默认启用</li>
<li><pre class="crayon-plain-tag">bpf-fragments-map-max</pre>：控制使用IP分配的活动并发连接的数量</li>
</ol>
<p>UDP这样的协议，它没有TCP那种分段和重组的能力，大报文只能依赖于IP层的分片机制。由于IP分片缺乏重传机制，因此大UDP报文一旦丢失一个片段，就需要整个报文的重传。</p>
<div class="blog_h2"><span class="graybg">BGP</span></div>
<div class="blog_h3"><span class="graybg">集成BIRD</span></div>
<p>BIRD是一个开源软件，支持BGP协议。利用BIRD可以将Cilium管理的端点暴露到集群外部。</p>
<p>通过下面的命令安装bird2：</p>
<pre class="crayon-plain-tag"># Ubuntu
sudo apt install bird2
# CentOS
yum install -y bird2

sudo systemctl enable bird
sudo systemctl restart bird</pre>
<p>节点配置文件示例：</p>
<pre class="crayon-plain-tag"># 其它节点只是ID不同
router id 10.0.3.1;

debug protocols all;

# 如果使用直接路由模式
filter cnionly {
    if net ~ 172.27.0.0/16 &amp;&amp; ifname != "cilium_host" then accept;
    else reject;
}

protocol kernel {
    learn;
    scan time 10;
    ipv4 {
        import none;            # 如果使用隧道模式
        import filter cnionly;  # 如果使用直接路由模式
        export none;
    };
}

protocol device {
    scan time 5;
}

# 直接添加到BIRD的路由表
protocol static {
    ipv4;
    # 宣告Pod CIDR
    route 172.27.0.0/16 via "cilium_host";  # 如果使用隧道模式
    # 宣告ClusterIP CIDR。不能和kube-proxy replacement联用，因为后者不允许集群外访问ClusterIP
    route 10.96.0.0/24  via "eth0";
    # 宣告LoadBalancer CIRD
    route 10.0.10.0/24 via "eth0";
}

# 连接到上游路由器，并宣告上面的静态路由
protocol bgp k8s {
    local as 65000;
    neighbor 10.0.0.1 as 65000;
    direct;
    ipv4 {
        export all;
    };
}
# 查看路由     birdc show route
# 查看BGP状态  birdc show protocols all k8s</pre>
<p>上游路由（反射器）配置示例：</p>
<pre class="crayon-plain-tag">log syslog all;

router id 10.0.0.1;

debug protocols all;

protocol kernel {
    scan time 10;
    ipv4 {
        import none;
        export all;
    };
}

protocol device {
    scan time 5;
}

protocol bgp k8s {
    local as 65000;
    neighbor range 10.0.3.0/24 as 65000;
    direct;
    rr client;
    ipv4 {
        import all;
        export all;
    };
}</pre>
<p>可以启用双向转发检测（Bidirectional Forwarding Detection，BFD），以加入路径故障检测（path failure detection）。BFD由一系列几乎独立的BFD会话组成，每个会话在<span style="background-color: #c0c0c0;">双方都启用了</span>BFD的路由器之间进行双向单播路径的监控。监控方式是周期性的、双向发送控制封包。</p>
<p>BFD不会进行邻居发现，BFD会话是按需（例如被BGP协议请求）创建的。</p>
<pre class="crayon-plain-tag">protocol bfd {
    interface "eth*" {
        min rx interval 100 ms;
        min tx interval 100 ms;
        idle tx interval 300 ms;
        multiplier 10;
    };
    # 不需要按需创建，直接初始化和这些邻居的BFD会话
    neighbor 10.0.3.2;
    neighbor 10.0.3.3;
}

protocol bgp k8s {
    # BGP支持使用BFD来发现邻居是否存活
    bfd on;
}</pre>
<p>下面的命令查看BFD会话状态：</p>
<pre class="crayon-plain-tag">birdc show bfd sessions

bfd1:
IP address                Interface  State      Since         Interval  Timeout
10.0.3.2                  virbr0     Up         11:56:01.055    0.100    1.000
10.0.3.1                  virbr0     Up         11:56:00.094    0.100    1.000
10.0.3.3                  virbr0     Up         11:56:00.389    0.100    1.000</pre>
<p>为了某些特殊目的，例如L4负载均衡，你需要在多个节点上配置Pod CIDR的静态路由，并且在Bird中配置<a href="/network-faq#ecmp">ECMP（Equal-cost multi-path）路由</a>。</p>
<pre class="crayon-plain-tag">protocol kernel {
    merge paths yes limit 3;
}</pre>
<div class="blog_h3"><span class="graybg">宣告LoadBalancerIP</span></div>
<p>Cilium可以原生支持，将LoadBalancer服务分配IP地址、并通过BGP协议将地址宣告出去。是否宣告LoadBalancer服务的IP，取决于服务的externalTrafficPolicy设置。</p>
<p>使用下面的Helm值启用该特性：<pre class="crayon-plain-tag">--set bgp.enabled=true --set bgp.announce.loadbalancerIP=true</pre>。该特性依赖于MetalLB。</p>
<p>添加bgp-config这个ConfigMap，参考：</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: ConfigMap
metadata:
  name: bgp-config
  namespace: kube-system
data:
  config.yaml: |
    peers:
      - peer-address: 10.0.0.1
        peer-asn: 65000
        my-asn: 65000
    address-pools:
      - name: default
        protocol: bgp
        addresses:
          - 10.0.10.0/24 </pre>
<div class="blog_h2"><span class="graybg">IPVLAN模式</span></div>
<div class="blog_h3"><span class="graybg">VETH vs IPVLAN<br /></span></div>
<p>容器通常都使用虚拟设备，例如veth对，作为连接初始命名空间的桥梁。通过在宿主机端veth挂钩tc ingress钩子，Cilium能够监控容器的任何流量。</p>
<p>veth对处理流量时，需要两次通过网络栈，相比起ipvlan有性能上的劣势。对于两个在同一节点上的容器veth端点，一个封包需要4次通过网络栈。</p>
<p>Cilium CNI也支持L3/L3S的ipvlan，这种模式下，宿主机物理设备作为ipvlan master，而容器端的ipvlan虚拟设备是slave。使用ipvlan时<span style="background-color: #c0c0c0;">将封包从其它网络命名空间推入ipvlan slave设备时消耗更少的资源，因而可能改善网络延迟</span>。使用ipvlan时Cilium在容器命名空间中挂钩BPF程序到ipvlan slave设备的egress钩子，以便应用L3/L4策略（因为初始命名空间下所有容器共享单个设备）。同时挂钩到ipvlan master的tc ingress钩子，可以对节点的所有入站流量应用网络策略。</p>
<p>为了支持老版本的不支持ipvlan hairpin模式的内核，Cilium在ipvlan slave设备（位于容器网络命名空间）的tc gress上挂钩了BPF程序。</p>
<p>当前版本的ipvlan支持有以下限制：</p>
<ol>
<li>NAT64不被支持</li>
<li>基于Envoy的L7 Policy不被支持</li>
<li>容器到host-local的通信不被支持</li>
<li>Service不支持LB到本地端点</li>
</ol>
<div class="blog_h3"><span class="graybg">启用IPVLAN</span></div>
<p>Cilium默认使用veth提供容器网络连接。你可以选用Beta支持的IPVLAN，目前尚未提供的特性包括：</p>
<ol>
<li>IPVLAN L2模式</li>
<li>L7策略支持</li>
<li>FQDN策略支持</li>
<li>NAT64</li>
<li>IPVLAN+隧道</li>
<li>基于eBPF的IP遮掩</li>
</ol>
<p>这些特性将在未来版本提供。</p>
<p>由于使用IPVLAN L3模式，需要4.12+的内核。如果使用L3S模式（流量经过宿主机网络栈因而被netfilter处理），这需要修复<a href="https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net.git/commit/?id=d5256083f62e2720f75bb3c5a928a0afe47d6bc3">d5256083f62e</a>（4.19.20）的稳定版内核。</p>
<p>参考下面的方式进行安装：</p>
<pre class="crayon-plain-tag">helm install cilium cilium/cilium --version 1.10.1 \
  --namespace kube-system \
  # 启用IPVLAN
  --set datapathMode=ipvlan \
  # 选择IPVLAN主设备，要求所有节点主设备名字相同
  --set ipvlan.masterDevice=eth0 \
  # IPVLAN数据路径目前仅支持直接路由，因此必须禁用tunnel
  --set tunnel=disabled  \
  # 要让IPVLAN跨节点工作，每个主机都必须安装正确的路由
  # 路由要么手工设置，要么由Cilium自动安装。对于后者，设置：
  --set autoDirectNodeRoutes="true" \
  # 下面的选项，用于控制是否安装iptables规则，这些规则主要用于和kube-proxy交互
  # 如果设置为false，则不安装，并且IPVLAN工作在L3模式
  # 默认值为true，IPVLAN工作在L3S模式，初始命名空间的netfilter会对容器封包进行过滤
  --set installIptablesRules="true"  \
  # 对所有离开IPVLAN master设备的流量进行IP遮掩
  --set masquerade="true"</pre>
<p>IPVLAN L3模式中宿主机的netfilters钩子被绕过，因此无法进行IP遮掩，必须使用L3S模式（会降低性能）。</p>
<div class="blog_h2"><span class="graybg">透明加密（IPsec）</span></div>
<p>Cilium支持使用IPsec/WireGuard透明的加密：</p>
<ol>
<li>Cilium管理的宿主机之间</li>
<li>Cilium管理的端点之间</li>
</ol>
<p>的流量。</p>
<p>为了确定某个连接是否可以被加密，Cilium需要明确封包目的地址是否是受管理的端点。在明确之前，流量可能不被加密。</p>
<p>同一主机内部的流量不会被加密。</p>
<p>如果在其它CNI插件之上链接Cilium，则目前无法支持透明加密特性。</p>
<div class="blog_h3"><span class="graybg">生成和导入PSK</span></div>
<pre class="crayon-plain-tag">kubectl create -n kube-system secret generic cilium-ipsec-keys \
    --from-literal=keys="3 rfc4106(gcm(aes)) $(echo $(dd if=/dev/urandom count=20 bs=1 2&gt; /dev/null | xxd -p -c 64)) 128"</pre>
<div class="blog_h3"><span class="graybg">启用加密</span></div>
<pre class="crayon-plain-tag">helm install cilium cilium/cilium --version 1.10.1 \
  --namespace kube-system \
  # 启用Pod之间流量的加密
  --set encryption.enabled=true \
  # 启用节点流量加密（Beta）
  --set encryption.nodeEncryption=false \
  # 算法，默认ipsec
  --set encryption.type=ipsec    \
  # 如果启用直接路由（不使用隧道），则不指定下面选项的时候，会查询路由表，选择默认路由对应的网络接口
  --set encryption.ipsec.interface=ethX</pre>
<div class="blog_h2"><span class="graybg">宿主机可达服务</span></div>
<p>通过本节的配置，可以让服务从初始命名空间无需NAT的访问。</p>
<p>此特性要求4.19.57, 5.1.16, 5.2.0等版本以上的内核。如果仅要支持TCP（不支持UDP）则需要4.17.0。</p>
<pre class="crayon-plain-tag">helm install cilium cilium/cilium --version 1.10.1 \
  --namespace kube-system \
  # 启用此特性
  --set hostServices.enabled=true \
  # 仅仅支持TCP
  --set hostServices.protocols=tcp</pre>
<p>此特性的工作原理：在connect系统调用（TCP, connected UDP），或者 sendmsg/recvmsg系统调用（UDP）时，Cilium会检查目的地址，如果它是一个Service IP，则<span style="background-color: #c0c0c0;">直接将目的地址更换为一个后端的地址</span>。这样，套接字实际上会直接连接真实后端，<span style="background-color: #c0c0c0;">不会在更低层次的数据路径上发生NAT</span>，也就是对数据路径的更低层次透明。</p>
<p>宿主机可达服务，允许从宿主机/Pod中，以多种IP:NODE_PORT访问到NodePort服务。这些IP包括：环回地址、服务ClusterIP、节点本地接口（除了docker*）地址。</p>
<div class="blog_h2"><span class="graybg">kube-proxy replacement<br /></span></div>
<p>Cilium能够完全代替kube-proxy。此特性依赖“宿主机可达服务”，因此对内核有着相同的要求。Cilium还利用5.3/5.8添加的额外特性，进行了更进一步的优化。</p>
<div class="blog_h3"><span class="graybg">移除kube-proxy</span></div>
<p>基于kubeadm安装K8S时，可以用下面的命令跳过kube-proxy：</p>
<pre class="crayon-plain-tag">kubeadm init --skip-phases=addon/kube-proxy</pre>
<p>需要注意：如果节点有多网卡，确保kubelet的<pre class="crayon-plain-tag">--node-ip</pre>设置正确，否则Cilium可能无法正常工作。</p>
<p>如果集群已经安装了kube-proxy，可以使用下面的命令移除：</p>
<pre class="crayon-plain-tag">kubectl -n kube-system delete ds kube-proxy
# 删除cm，可以防止升级K8S（1.19+）时候重新安装kube-proxy
kubectl -n kube-system delete cm kube-proxy</pre>
<div class="blog_h3"><span class="graybg">启用kube-proxy replacement<br /></span></div>
<pre class="crayon-plain-tag">helm install cilium cilium/cilium --version 1.10.1 \
    --namespace kube-system \
    # 代替kube-proxy，取值：
    #   strict，如果内核不支持，则导致cilium-agent退出
    #   probe，探测内核特性，自动禁用不支持的特性子集。该取值假设kube-proxy不被删除，作为可能的fallback
    --set kubeProxyReplacement=strict \
    # 替换为API Server的地址和端口
    --set k8sServiceHost=10.0.3.1 \
    --set k8sServicePort=6443</pre>
<p>使用如上命令安装的Cilium，可以作为ClisterIP、NodePort、LoadBalancer，以及具有externalIP的服务的控制器。在此之上，eBPF kube-proxy replacement<span style="background-color: #c0c0c0;">还能够支持容器的hostPort，从而不再需要portmap</span>。</p>
<p>kube-proxy replacement同时支持直接路由和隧道模式。</p>
<p>使用下面的命令可以验证kube-proxy replacement已经正常安装：</p>
<pre class="crayon-plain-tag"># kubectl exec -it -n kube-system cilium-ch5qk --  cilium status --verbose 
# ...
KubeProxyReplacement:   Strict   [eth0 10.0.3.2 (Direct Routing)]
# ...
KubeProxyReplacement Details:
  Status:                Strict
  Socket LB Protocols:   TCP, UDP
  Devices:               eth0 10.0.3.2 (Direct Routing)
  Mode:                  SNAT
  Backend Selection:     Random
  Session Affinity:      Enabled
  XDP Acceleration:      Disabled
  Services:
  - ClusterIP:      Enabled
  - NodePort:       Enabled (Range: 30000-32767) 
  - LoadBalancer:   Enabled 
  - externalIPs:    Enabled 
  - HostPort:       Enabled</pre>
<div class="blog_h3"><span class="graybg">磁悬浮一致性哈希</span></div>
<p>kube-proxy replacement支持<a href="https://storage.googleapis.com/pub-tools-public-publication-data/pdf/44824.pdf">磁悬浮（Maglev）一致性哈希算法</a>的变体，作为负载均衡算法。一致性哈希是一类算法，它将后端（RS）计算哈希值后，分布在一个环上。进行负载均衡时，对5元组计算哈希，然后看落在环上哪两个RS之间，取哈希值较小的RS作为LB目标。磁悬浮算法通过将每个RS在环上映射多次，减少当RS数量增加/减少时，必须映射到其它RS的五元组的数量。自然，减少一个RS之后，原先映射到其上的5元组必然需要重新映射，磁悬浮的目标是尽量减少除此之外的重新映射</p>
<p>该算法增强了故障时的弹性。新增节点后，在不需要和其它节点同步的前提下，能够对任意指定5元组能够保持相同的、一致性的后端选择；移除节点后，除了那些后端对应被移除节点的5元组，不超过1% difference in reassignments</p>
<p>通过<pre class="crayon-plain-tag">--set loadBalancer.algorithm=maglev</pre>启用</p>
<p>需要注意，<span style="background-color: #c0c0c0;">该LB算法仅用于外部（南北向）流量</span>。对于集群内部（东西向）流量，套接字直接分配到服务的后端，也就是说在TCP connect的时候，目的地址被修改为后端，不会使用该算法</p>
<p>Cilium XDP加速支持磁悬浮一致性哈希算法。</p>
<p>该算法有两个专用的配置项：</p>
<ol>
<li><pre class="crayon-plain-tag">maglev.tableSize</pre>：每单个服务的Maglev查找表的大小。理想值M，应当大大大于期望后端数N的质数。最好大于100*N，以确保当后端发生变化时最多1% difference in reassignments。支持的取值包括251 509 1021 2039 4093 8191 16381 32749 65521 131071。取值16381用于大概160个后端的服务</li>
<li><pre class="crayon-plain-tag">maglev.hashSeed</pre>：用于避免受限于Cilium内置的固定的seed，seed是base64编码的16byte随机数。所有节点必须具有相同的seed</li>
</ol>
<div class="blog_h3"><span class="graybg">保留客户端源IP</span></div>
<p>Cilium基于eBPF的kube-proxy replacement实现了保留客户端源IP的能力。</p>
<p>Service的<pre class="crayon-plain-tag">externalTrafficPolicy</pre>选项决定Cilium的行为：</p>
<ol>
<li>Local：集群内的服务可以相互访问，也可以从没有该服务后端的节点上访问。集群内的端点，不需要SNAT就能实现访问服务时的负载均衡</li>
<li>Cluster：默认值。有多种途径保留客户端源IP。如果仅TCP服务需要暴露到集群外部，可以让kube-proxy replacement运行在DSR/Hybrid模式</li>
</ol>
<div class="blog_h3"><span class="graybg">直接服务器返回（DSR）</span></div>
<p>默认情况下，Cilium的eBPF NodePort实现，在SNAT模式下运作。也就是说，当来自外部的、访问集群服务的流量到达时，如果<span style="background-color: #c0c0c0;">入群节点判断出服务</span>（LoadBalancer/NodePort/其它具有ExternalIP的服务）<span style="background-color: #c0c0c0;">的后端位于其它节点</span>，<span style="background-color: #c0c0c0;">它就需要将请求重定向到远程节点。这个重定向时需要SNAT，将外部流量的源地址换成入群节点的地址</span></p>
<p>这个<span style="background-color: #c0c0c0;">SNAT的代价是，访问链路多了一跳，同时丢失了源IP信息</span>。为了进行reverse SNAT，返回报文还必须经过入群节点，然后传回给外部客户端</p>
<p>设置<pre class="crayon-plain-tag">loadBalancer.mode=dsr</pre>，可以让Cilium的eBPF NodePort实现切换到DSR模式。这种模式下，后端直接应答外部客户端，不经过入群节点。<span style="background-color: #c0c0c0;">这一特性必须和Direct Routing一起使用</span>，也就是不能使用隧道。</p>
<p>DSR模式的另外一个优势是，源IP地址被保留，因此，运行<span style="background-color: #c0c0c0;">在服务后端节点上的Cilium策略，可以正确的根据依据源IP进行过滤</span>。</p>
<p>由于一个后端可能被多个Service引用，<span style="background-color: #c0c0c0;">后端（所在节点的cilium-agent）需要知道生成（直接回复给原始客户端的）应答报文时，使用什么Service IP/Port（作为源地址）</span>。Cilium的解决办法是，<span style="background-color: #c0c0c0;">使用IPv4选项</span>或IPv6 Destination选项扩展，<span style="background-color: #c0c0c0;">将Service IP/Port信息编码到IP头中</span>，代价是MTU变小。对于TCP服务，<span style="background-color: #c0c0c0;">仅仅SYN封包需要编码Service IP/Port信息，因此MTU变小不会有影响</span>。</p>
<p>需要注意，在某些公有云环境下，DSR模式可能无法工作。原因可能是：</p>
<ol>
<li>底层Fabric<span style="background-color: #c0c0c0;">可能丢弃掉Cilium的IP选项</span></li>
<li>某些云实现了源/目的地址检查，你需要禁用此特性DSR才能正常工作</li>
</ol>
<p>为了避免UDP的MTU变小问题，可以设置<pre class="crayon-plain-tag">loadBalancer.mode=hybrid</pre>，这样对于UDP协议，会工作在SNAT模式，对于TCP则工作在DSR模式</p>
<div class="blog_h3"><span class="graybg">NodePort XDP加速</span></div>
<p>对于LoadBalancer/NodePort/其它具有ExternalIP的服务，如果外部流量入群节点上没有服务后端，则入群节点需要将请求转发给其它节点。Cilium 1.8+支持基于XDP进行加速这一转发行为。XDP工作在驱动层，大部分支持10G+bps的驱动都支持native XDP。云上环境中大多数具有SR-IOV变体的驱动也支持native XDP。在裸金属环境下，XDP加速可以和MetalLB这样的LoadBalancer控制器联用</p>
<p>要启用XDP加速，需要设置<pre class="crayon-plain-tag">loadBalancer.acceleration=native</pre>，默认值<pre class="crayon-plain-tag">disabled</pre>。对于大规模环境，可以考虑调优Map的容量：<pre class="crayon-plain-tag">config.bpfMapDynamicSizeRatio</pre></p>
<p>XDP加速可以和loadBalancer.mode：DSR/SNAT/hybrid一起使用。</p>
<div class="blog_h3"><span class="graybg">NodePort设备/端口/绑定设置</span></div>
<p>启用Cilium的eBPF kube-proxy replacement时，默认情况下，LoadBalancer/NodePort/其它具有ExternalIP的服务，可以通过这样的网络接口访问：</p>
<ol>
<li>具有默认路由的接口</li>
<li>被分配的K8S节点的InternalIP / ExternalIP的接口</li>
</ol>
<p>要改变设备，可以配置devices选项，例如<pre class="crayon-plain-tag">devices='{eth0,eth1,eth2}'</pre>。需要注意每个节点的名字必须一致，如果不一致，可以考虑用通配符<pre class="crayon-plain-tag">devices=eth+</pre></p>
<p>如果使用多个网络接口，仅其中单个可用于Cilium节点之间的直接路由。Cilium会选择具有InternalIP / ExternalIP的接口，InternalIP优先。你也可以手工指定直接路由设备<pre class="crayon-plain-tag">nodePort.directRoutingDevice=eth1</pre>，如果该选项中的设备，不在<pre class="crayon-plain-tag">devices</pre>中，Cilium会自动加入</p>
<p>直接路由设备也用于NodePort XDP加速，也就是说该设备的驱动应该支持native XDP</p>
<p>如果kube-apiserver使用了非默认的NodePort范围，则相同的配置必须传递给Cilium，例如<pre class="crayon-plain-tag">nodePort.range="10000\,32767"</pre></p>
<p>如果NodePort返回和内核临时端口范围（net.ipv4.ip_local_port_range）重叠，则Cilium会将NodePort范围附加到保留端口范围（net.ipv4.ip_local_reserved_ports）。这可以避免NodePort服务劫持宿主机本地应用程序发起的（源端口在和NodePort冲突的）连接。要禁用这种端口范围保护的行为，设置<pre class="crayon-plain-tag">nodePort.autoProtectPortRanges=false</pre></p>
<p>默认情况下，NodePort实现禁止应用程序对NodePort服务端口的bind系统调用，应用程序会接收到bind: Operation not permitted 错误。对于5.7+内核，在Pod内部bind不会报此错误。如果需要完全允许（包括老版本内核、5.7+在初始命名空间）bind，可以设置<pre class="crayon-plain-tag">nodePort.bindProtection=false</pre></p>
<div class="blog_h3"><span class="graybg">容器HostPort支持</span></div>
<p>尽管不是kube-proxy的一部分，Cilium的eBPF kube-proxy replacement也原生实现了hostPort，因此不需要使用CNI chaining：<pre class="crayon-plain-tag">cni.chainingMode=portmap</pre></p>
<p>如果启用了eBPF kube-proxy replacement，hostPort就自动支持，不需要额外配置。其它情况下，可以使用<pre class="crayon-plain-tag">hostPort.enabled=true</pre>启用此特性</p>
<p>如果指定hostPort时没有额外指定hostIP，则Pod的端口将通过宿主机用于暴露NodePort服务的那些IP地址，对外暴露出去。包括K8S的InternalIP/ExternalIP、环回地址。如果指定了hostIP则仅仅从该IP暴露，hostIP指定为0.0.0.0效果等于未指定。</p>
<div class="blog_h3"><span class="graybg">kube-proxy混合模式</span></div>
<p>除了完全代替kube-proxy，Cilium的eBPF kube-proxy replacement还可以与自共存，成为混合模式。混合模式的目的是解决某些内核版本不足以实现完全的kube-proxy replacement的问题。</p>
<p>kubeProxyReplacement取值：</p>
<ol>
<li>strict：严格完全替代或者失败</li>
<li>probe：混合模式。自动探测内核，并尽量替代</li>
<li>partial：混合模式。手工指定需要启用哪些eBPF kube-proxy replacement组件。取该值时必须设置<pre class="crayon-plain-tag">enableHealthCheckNodeport=false</pre>，以确保cilium-agent不会启动NodePort健康检查服务器。可以手工开启的特性如下，默认全部false：
<ol>
<li><pre class="crayon-plain-tag">hostServices.enabled</pre></li>
<li><pre class="crayon-plain-tag">nodePort.enabled</pre></li>
<li><pre class="crayon-plain-tag">externalIPs.enabled</pre></li>
<li><pre class="crayon-plain-tag">hostPort.enabled</pre></li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">会话绑定</span></div>
<p>Cilium的eBPF kube-proxy replacement支持K8S服务的会话绑定设置。对于<pre class="crayon-plain-tag">sessionAffinity: ClientIP</pre>，它会确保同一个Pod/宿主机总是被LB到同一个服务后端。会话绑定的默认超时为3h，可通过K8S的<pre class="crayon-plain-tag">sessionAffinityConfig</pre>改变。</p>
<p>会话绑定的依据，取决于请求的来源：</p>
<ol>
<li>对于集群外部发送给服务的请求，源IP地址用于会话绑定</li>
<li>对于集群内部发起的请求，则客户端网络命名空间的cookie用于会话绑定。这个特性5.7+内核支持，用于在socket layer实现会话绑定（此时源IP尚不可用，封包结构还没被内核创建）</li>
</ol>
<p>如果启用了eBPF kube-proxy replacement，则会话绑定默认启用。要启用，设置<pre class="crayon-plain-tag">config.sessionAffinity=false</pre></p>
<p>如果用户内核版本比较老，不支持网络命名空间cookie。则可以使用fallback的in-cluster模式，该模式使用一个固定的cookie，导致同一主机上，所有端点会绑定到某个服务的同一个后端。</p>
<div class="blog_h3"><span class="graybg">健康检查服务器</span></div>
<p>eBPF kube-proxy replacement包含一个health check server。要启用，需要设置<pre class="crayon-plain-tag">kubeProxyReplacementHealthzBindAddr</pre>。例如<pre class="crayon-plain-tag">kubeProxyReplacementHealthzBindAddr='0.0.0.0:10256'</pre>。/healthz端点用于访问健康状态。</p>
<div class="blog_h3"><span class="graybg">LoadBalancer源地址范围检查</span></div>
<p>如果LoadBalancer服务指定了spec.loadBalancerSourceRanges。则eBPF kube-proxy replacement会限制外部流量对服务的访问。仅仅允许spec.loadBalancerSourceRanges指定的CIDR白名单。从集群内部访问时，忽略此字段。</p>
<p>此特性默认启用，要禁用，设置<pre class="crayon-plain-tag">config.svcSourceRangeCheck=false</pre>。</p>
<div class="blog_h3"><span class="graybg">service-proxy-name</span></div>
<p>和kube-proxy类似，eBPF kube-proxy replacement遵从服务的<pre class="crayon-plain-tag">service.kubernetes.io/service-proxy-name</pre>注解。此注解声明什么服务代理（kube-proxy / replacement...）应该管理此服务。</p>
<p>eBPF kube-proxy replacement的服务代理名通过<pre class="crayon-plain-tag">k8s.serviceProxyName</pre>设置。默认值为空，意味着仅仅没有设置service.kubernetes.io/service-proxy-name的服务可以被replacement管理。</p>
<div class="blog_h3"><span class="graybg">限制条件</span></div>
<p>使用Cilium的eBPF kube-proxy replacement时，有很多限制条件需要注意：</p>
<ol>
<li>不能和透明加密一起使用</li>
<li>依赖宿主机可达服务这一特性。该特性需要依赖于eBPF cgroup hooks来实现服务转换。而eBPF中的getpeername需要5.8+内核才能支持。这意味着replacement无法和libceph一起工作</li>
<li>XDP加速仅支持单个设备的hairpin LB场景。如果具有多个网卡，并且cilium自动检测并选择多个网卡，则必须通过devices选项指定一个</li>
<li>DSR NodePort模式目前不能很好的在启用了TCP Fast Open（TFO）的环境下使用，建议切换到SNAT模式</li>
<li>不支持SCTP协议</li>
<li>不支持Pod配置的hostPort和NodePort范围冲突。这种情况下hostPort被忽略，并且cilium-agent会打印警告日志</li>
<li><span style="background-color: #c0c0c0;">不允许从集群外部访问ClusterIP</span></li>
<li>不支持ping ClusterIP，不像IPVS</li>
</ol>
<div class="blog_h2"><span class="graybg">带宽管理器</span></div>
<p>利用Cilium的带宽管理器，可以有效的在EDT（Earliest Departure Time）、eBPF的帮助下，管理每个Pod的带宽占用。</p>
<p>Cilium的带宽管理器，不依赖于CNI chaining，而是在Cilium内部实现的，它不使用<a href="https://kubernetes.io/docs/concepts/extend-kubernetes/compute-storage-net/network-plugins/#support-traffic-shaping">bandwidth CNI</a>这个插件。出于可扩容性考虑（特别是对于多队列的网卡），不建议使用bandwith CNI插件，因为它基于qdisc TBF而非EDT。</p>
<p>Cilium带宽管理器支持Pod注解<pre class="crayon-plain-tag">kubernetes.io/egress-bandwidth</pre>，它在“原生宿主网络设备”上控制egress流量带宽。不管是直接路由还是隧道，都可以进行流量限制。</p>
<p>Pod注解<pre class="crayon-plain-tag">kubernetes.io/ingress-bandwidth</pre>不被支持，也不推荐使用。</p>
<p>带宽限制天然应该发生在egress以降低/整平在网线上的带宽使用。如果在ingress段进行带宽限制，会额外的、通过ifb设备，在节点的关键fast-path增加一层缓冲队列，这种情况下，流量需要被重定向到ifb设备的egress端以实现塑形。这本质上没有意义，因为流量已经占用了网线山的带宽，节点也已经消耗了资源处理它，唯一的作用就是引入ifb让上层应用遭受带宽限制的痛苦。</p>
<p>带宽管理器需要Linux 5.1+内核。</p>
<p>带宽管理器默认启用，不需要在安装时指定特殊的选项。如果想禁用，设置<pre class="crayon-plain-tag">bandwidthManager=false</pre>。</p>
<p>所谓“原生宿主网络设备”是指具有默认路由的网络接口，或者分配了InternalIP/ExternalIP的接口，分配InternalIP的接口优先。如果要手工指定设置，设置<pre class="crayon-plain-tag">devices</pre>选项。</p>
<p>对Pod进行带宽限制的例子：</p>
<pre class="crayon-plain-tag">apiVersion: apps/v1
kind: Deployment
metadata:
  name: netperf
spec:
  selector:
    matchLabels:
      run: netperf
  replicas: 1
  template:
    metadata:
      labels:
        run: netperf
      annotations:
        kubernetes.io/egress-bandwidth: "10M"
    spec:
      nodeName: foobar
      containers:
      - name: netperf
        image: cilium/netperf
        ports:
        - containerPort: 12865</pre>
<div class="blog_h3"><span class="graybg">限制条件 </span></div>
<p>目前带宽管理器不能和L7策略联用。如果L7策略选择了Pod，则Pod上设置的注解被忽略，不进行带宽限制。</p>
<div class="blog_h2"><span class="graybg">联用Kata Containers</span></div>
<p>Cilium可以和Kata联用，后者提供计算层安全性。根据你使用的容器运行时，配置Cilium：</p>
<ol>
<li>如果使用CRI-O：<pre class="crayon-plain-tag">--set containerRuntime.integration=crio</pre></li>
<li>如果使用CRI-containerd：<pre class="crayon-plain-tag">--set containerRuntime.integration=containerd</pre></li>
</ol>
<p>Kata containers不支持宿主机可达服务特性，因而也不支持kube-proxy replacement的strict模式。</p>
<div class="blog_h2"><span class="graybg">Egress网关</span></div>
<p>出口网关允许将Pod的出口流量重定向到特定的网关节点，功能类似于Istio的出口网关。参考下面的选项启用该特性：</p>
<pre class="crayon-plain-tag">helm upgrade cilium cilium/cilium
   --namespace kube-system \
   --reuse-values \
   --set egressGateway.enabled=true \
   --set bpf.masquerade=true \
   --set kubeProxyReplacement=strict</pre>
<p>你需要配置<pre class="crayon-plain-tag">CiliumEgressNATPolicy</pre>才能让Egress网关对特定端点生效：</p>
<pre class="crayon-plain-tag">apiVersion: cilium.io/v2alpha1
kind: CiliumEgressNATPolicy
metadata:
  name: egress-sample
spec:
  egress:
  - podSelector:
      matchLabels:
        # 如果端点是运行在default命名空间的app=alpine
        app: alpine
        io.kubernetes.pod.namespace: default
    # 也可以用命名空间选择器，匹配多个命名空间（中的所有Pod）
    # namespaceSelector:
    #  matchLabels:
    #    ns: default
  # 并且尝试访问下面的CIDR（集群外部服务）
  destinationCIDRs:
  - 192.168.33.13/32
  # 那么将流量转发给Egress网关，该网关（节点）配置了IP地址192.168.33.100
  # 出集群封包将SNAT为192.168.33.100
  egressSourceIP: "192.168.33.100"</pre>
<p>作为Egress网关的节点，需要在网络接口上配置额外的IP（对应上面的 egressSourceIP）。</p>
<div class="blog_h1"><span class="graybg">集群网格</span></div>
<p>Cluster Mesh将网络数据路径延伸到多个集群，支持以下特性：</p>
<ol>
<li>实现<span style="background-color: #c0c0c0;">所有集群的Pod之间相互连通</span>，不管使用直接路由还是隧道模式。不需要额外的网关节点或代理</li>
<li>支持全局服务，可以在所有集群访问</li>
<li>支持全局性的安全策略</li>
<li>支持跨集群边界通信的透明加密</li>
</ol>
<div class="blog_h2"><span class="graybg">应用场景</span></div>
<div class="blog_h3"><span class="graybg">高可用</span></div>
<p>两个（位于不同Region或AZ的）集群组成高可用，当一个集群的后端服务（不是整个AZ不可用）出现故障时，可以failover到另外一个集群的对等物。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/usecase_ha.png"><img class="size-large wp-image-37965 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/usecase_ha-1024x342.png" alt="usecase_ha" width="710" height="237" /></a></p>
<div class="blog_h3"><span class="graybg">共享服务</span></div>
<p>最初的K8S用法是，倾向于创建巨大的、多租户的集群。而现在，更场景的用法是为每个租户创建独立的集群，甚至为不同类型的服务（例如安全级别不同）创建独立的集群。尽管如此，仍然有一些服务具有共享特征，不适合在每个集群中都部署一份。这类服务包括：日志、监控、DNS、密钥管理，等等。</p>
<p>使用集群网格，可以将共享服务独立部署在一个集群中，租户集群可以访问其中的全局服务。</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/06/usecase_shared_services.png"><img class="size-large wp-image-37969 aligncenter" src="https://blog.gmem.cc/wp-content/uploads/2020/06/usecase_shared_services-1024x444.png" alt="usecase_shared_services" width="710" height="307" /></a></p>
<div class="blog_h3"><span class="graybg">联合Istio Multicluster<br /></span></div>
<p>Cilium Clustermesh和Istio Multicluster可以相互补充。典型的用法是，<span style="background-color: #c0c0c0;">Cilium提供跨集群的Pod IP可路由性，而这是Istio Multiplecluster所需要的前置条件</span>。</p>
<div class="blog_h2"><span class="graybg">前提条件</span></div>
<ol>
<li>所有集群的Pod CIDR不冲突</li>
<li>所有节点的IP地址不冲突</li>
<li>所有集群的节点，都具有IP层的相互连接性。可能需要创建对等/VPN隧道</li>
<li>集群之间的网络必须允许跨集群通信，到底需要哪些端口本章后续会详述</li>
</ol>
<div class="blog_h2"><span class="graybg">启用网格</span></div>
<div class="blog_h3"><span class="graybg">指定集群标识</span></div>
<p>每个集群都需要唯一的名字和ID：</p>
<pre class="crayon-plain-tag">helm upgrade cilium cilium/cilium \
   --namespace kube-system \
   --reuse-values \
   --set cluster.name=k8s --set cluster.id=27</pre>
<p>注意，如果改变正在运行的集群的ID/名字，其中所有工作负载都需要重新启动。因为ID用于生成安全标识（security identity），安全标识需要重新创建才能创建跨集群的通信。</p>
<pre class="crayon-plain-tag">helm --kube-context tke install cilium cilium/cilium --version 1.10.1                        \
  --namespace kube-system                                                                    \
  --set debug.enabled=true                                                                   \
  --set cluster.id=28                                                                        \
  --set rollOutCiliumPods=true                                                               \
  --set cluster.name=tke                                                                     \
  --set image.repository=docker.gmem.cc/cilium/cilium                                        \
  --set preflight.image.repository=docker.gmem.cc/cilium/cilium                              \
  --set image.useDigest=false                                                                \
  --set operator.image.repository=docker.gmem.cc/cilium/operator                             \
  --set operator.image.useDigest=false                                                       \
  --set certgen.image.repository=docker.gmem.cc/cilium/certgen                               \
  --set hubble.relay.image.repository=docker.gmem.cc/cilium/hubble-relay                     \
  --set hubble.relay.image.useDigest=false                                                   \
  --set hubble.ui.backend.image.repository=docker.gmem.cc/cilium/hubble-ui-backend           \
  --set hubble.ui.backend.image.tag=v0.7.9                                                   \
  --set hubble.ui.frontend.image.repository=docker.gmem.cc/cilium/hubble-ui                  \
  --set hubble.ui.frontend.image.tag=v0.7.9                                                  \
  --set hubble.ui.proxy.image.repository=docker.gmem.cc/envoyproxy/envoy                     \
  --set hubble.ui.proxy.image.tag=v1.18.2                                                    \
  --set etcd.image.repository=docker.gmem.cc/cilium/cilium-etcd-operator                     \
  --set etcd.image.tag=v2.0.7                                                                \
  --set nodeinit.image.repository=docker.gmem.cc/cilium/startup-script                       \
  --set nodeinit.image.tag=62bfbe88c17778aad7bef9fa57ff9e2d4a9ba0d8                          \
  --set clustermesh.apiserver.image.repository=docker.gmem.cc/cilium/clustermesh-apiserver   \
  --set clustermesh.apiserver.image.useDigest=false                                          \
  --set clustermesh.apiserver.etcd.image.repository=docker.gmem.cc/coreos/etcd               \
  --set tunnel=disabled                                                                      \
  --set autoDirectNodeRoutes=true                                                            \
  --set nativeRoutingCIDR=172.28.0.0/16                                                      \
  --set bpf.hostRouting=true                                                                 \
  --set ipam.mode=cluster-pool                                                               \
  --set ipam.operator.clusterPoolIPv4PodCIDR=172.28.0.0/16                                   \
  --set ipam.operator.clusterPoolIPv4MaskSize=24                                             \
  --set fragmentTracking=true                                                                \
  --set bpf.masquerade=true                                                                  \
  --set hostServices.enabled=true                                                            \
  --set kubeProxyReplacement=strict                                                          \
  --set k8sServiceHost=10.2.0.61                                                             \
  --set k8sServicePort=6443                                                                  \
  --set loadBalancer.algorithm=maglev                                                        \
  --set loadBalancer.mode=hybrid                                                             \
  --set bandwidthManager=true</pre>
<div class="blog_h3"><span class="graybg">创建cilium-ca</span></div>
<p>集群网格会基于此CA创建其API Server的数字证书：</p>
<pre class="crayon-plain-tag">kubectl -n kube-system create secret generic --from-file=ca.key=ca.key --from-file=ca.crt=ca.crt cilium-ca </pre>
<div class="blog_h3"><span class="graybg">启用网格</span></div>
<p>需要在组成网格的两个集群中都执行cilium clustermesh enable命令：</p>
<pre class="crayon-plain-tag">cilium clustermesh enable --context k8s --service-type LoadBalancer \
    --apiserver-image docker.gmem.cc/cilium/clustermesh-apiserver:v1.10.1
cilium clustermesh enable --context tke --service-type LoadBalancer \
    --apiserver-image docker.gmem.cc/cilium/clustermesh-apiserver:v1.10.1</pre>
<p>上述命令会：</p>
<ol>
<li>部署clustermesh-apiserver到集群</li>
<li>生成所有必须的数字证书、保存为Secret</li>
<li>自动检测最佳的service类型，以暴露集群网格的控制平面给其它集群。某些时候，service类型不能自动检测，你可手工通过<pre class="crayon-plain-tag">--service-type</pre>指定</li>
</ol>
<p>通过下面的命令等待集群网格组件就绪：<pre class="crayon-plain-tag">cilium clustermesh status --wait</pre>，如果服务类型选择LoadBalancer，该命令也会等待LoadBalancer IP就绪。</p>
<div class="blog_h3"><span class="graybg">连接集群</span></div>
<p>最后一步是连接集群，只需要在网格的一端进行连接即可。对向连接会自动创建：</p>
<pre class="crayon-plain-tag">cilium clustermesh connect --context k8s --destination-context tke</pre>
<p>通过下面的命令等待连接成功：<pre class="crayon-plain-tag">cilium clustermesh status --wait</pre></p>
<div class="blog_h3"><span class="graybg">测试跨集群连接性</span></div>
<pre class="crayon-plain-tag">cilium connectivity test --context k8s --multi-cluster tke</pre>
<p>注意：<span style="background-color: #c0c0c0;">两个集群的Pod网络会被打通，你可以从一个集群直接访问另外一个集群的Pod</span>。<span style="background-color: #c0c0c0;">默认情况下，Cilium不允许从集群外部访问PodCIDR，可以ping但是访问端口会RST</span>。</p>
<div class="blog_h3"><span class="graybg">查看网格状态</span></div>
<pre class="crayon-plain-tag">cilium clustermesh status --context k8s
cilium clustermesh status --context tke</pre>
<div class="blog_h3"><span class="graybg">限制条件</span></div>
<p>目前最多支持相互连接在一起的集群数量为255，未来此限制会放开，当：</p>
<ol>
<li>运行在直接路由模式时</li>
<li>运行在隧道模式，且启用加密时</li>
</ol>
<div class="blog_h2"><span class="graybg">服务发现</span></div>
<p>Cilium的集群网格，支持跨集群的服务发现和负载均衡。</p>
<div class="blog_h3"><span class="graybg">全局服务</span></div>
<p>跨集群的负载均衡，依赖于全局服务。所谓全局服务：</p>
<ol>
<li>在所有集群中具有相同的namespace和name</li>
<li>设置了注解<pre class="crayon-plain-tag">io.cilium/global-service: "true"</pre>，注意，<span style="background-color: #c0c0c0;">所有集群的服务都要添加此注解</span></li>
</ol>
<p>Cilium会自动跨越多个集群进行负载均衡。A集群中的Pod可能访问到B集群的后端。</p>
<pre class="crayon-plain-tag">apiVersion: v1
kind: Service
metadata:
  name: nginx
  annotations:
    io.cilium/global-service: "true"
    # 下面这个注解是隐含的
    io.cilium/shared-service: "true"
spec:
  type: ClusterIP
  ports:
  - port: 80
  selector:
    app: nginx</pre>
<div class="blog_h3"><span class="graybg">远程服务</span></div>
<p>如果设置<pre class="crayon-plain-tag">io.cilium/shared-service: "false"</pre>，则该服务的端点，仅由远程集群提供。</p>
<div class="blog_h2"><span class="graybg">网络策略</span></div>
<p>CiliumNetworkPolicy、NetworkPolicy自然就能跨集群生效，这是因为Cilium解耦了网络安全和网络连接性。但是这些对象不会自动复制到各集群，你需要手工处理。</p>
<p>下面的网络策略，允许特定端点跨集群的访问服务：</p>
<pre class="crayon-plain-tag"># 允许k8s中的alpine访问tke中的nginx
apiVersion: "cilium.io/v2"
kind: CiliumNetworkPolicy
metadata:
  name: "allow-cross-cluster"
spec:
  endpointSelector:
    matchLabels:
      app: alpine
      io.cilium.k8s.policy.cluster: k8s
  egress:
  - toEndpoints:
    - matchLabels:
        app: nginx
        io.cilium.k8s.policy.cluster: tke</pre>
<div class="blog_h3"><span class="graybg">限制条件</span></div>
<p>L7策略仅仅在以下条件下可以跨集群生效：</p>
<ol>
<li>启用直接路由模式，也就是禁用隧道</li>
<li>节点安装了路由，允许路由所有集群的Pod IP</li>
</ol>
<p>第2点，如果节点L2互联，可以通过设置--auto-direct-node-routes=true满足。</p>
<div class="blog_h2"><span class="graybg">集群外工作负载</span></div>
<p>你可以将外部工作负载（例如VM）加入到K8S集群，并且应用安全策略。</p>
<div class="blog_h3"><span class="graybg"> 前提条件</span></div>
<ol>
<li>必须配置基于K8S进行身份标识（identity）分配，即<pre class="crayon-plain-tag">identityAllocationMode=crd</pre>（默认值）</li>
<li>外部工作负载必须基于4.17+的内核，这样它才能访问K8S服务</li>
<li>外部工作负载必须和集群节点IP层互联。如果在同一VPC中运行虚拟机和K8S，通常可以满足。否则，可能需要在K8S集群网络和外部工作负载网络之间进行对等/VPN连接</li>
<li>外部工作负载必须具有唯一的IP地址，和集群内节点不冲突</li>
<li>目前此特性仅在VXLAN隧道模式下测试过</li>
</ol>
<div class="blog_h3"><span class="graybg">启用集群网格</span></div>
<pre class="crayon-plain-tag">cilium install --config tunnel=vxlan ...
cilium clustermesh enable</pre>
<div class="blog_h3"><span class="graybg">测试工作负载</span></div>
<p>必须创建<pre class="crayon-plain-tag">CiliumExternalWorkload</pre>来通知集群，外部工作负载的存在。该自定义资源：</p>
<ol>
<li>为外部工作负载指定命名空间和身份标识标签</li>
<li>名字必须和外部工作负载的主机名（hostname命令输出）一致</li>
<li>为外部工作负载分配一个很小的CIDR</li>
</ol>
<p>可以通过命令创建CiliumExternalWorkload：</p>
<pre class="crayon-plain-tag">#                  vm是子命令external-workload的别名
#                            工作负载名字
#                                      加入的命名空间
#                                              分配的CIDR
cilium clustermesh vm create zircon -n default --ipv4-alloc-cidr 10.0.0.1/32</pre>
<p>下面的命令可以查看现有外部工作负载的状态：</p>
<pre class="crayon-plain-tag">cilium clustermesh vm status</pre>
<p>此时，可以看到 zircon 的<pre class="crayon-plain-tag">IP</pre>状态为<pre class="crayon-plain-tag">N/A</pre>，这提示工作负责尚未加入集群。</p>
<p>下面的命令会生成一个安装脚本：</p>
<pre class="crayon-plain-tag">cilium clustermesh vm install install-external-workload.sh</pre>
<p>该脚本从集群中抽取了TLS证书、其它访问信息 ，可用于在外部工作负责中安装Cilium并连接到你的K8S集群。脚本中嵌入了clustermesh-apiserver服务的IP地址，如果你没有使用LoadBalancer类型而是使用NodePort，则IP是第一个K8S节点的地址。</p>
<p>拷贝install-external-workload.sh到外部工作负载节点，然后执行，该脚本会：</p>
<ol>
<li>创建并运行一个名为cilium的容器</li>
<li>拷贝cilium CLI到文件系统</li>
<li>等待节点连接到集群，集群服务可用。然后修改/etc/resolv.conf，将kube-dns地址设置到其中</li>
</ol>
<p>注意，如果外部工作负载有多个网络接口，在运行脚本之前你需要设置环境变量<pre class="crayon-plain-tag">HOST_IP</pre>。</p>
<p>在外部工作负载执行命令<pre class="crayon-plain-tag">cilium status</pre>检查连接性。</p>
<div class="blog_h1"><span class="graybg">运维</span></div>
<div class="blog_h2"><span class="graybg">状态解读</span></div>
<p>每个cilium-agnet节点的各种状态信息，可以通过cilium status命令获得：</p>
<pre class="crayon-plain-tag"># kubectl -n kube-system exec cilium-m8wf2 -- cilium status
# 是否启用外部的KV存储
KVStore:                Ok   Disabled
# K8S状态
Kubernetes:             Ok   1.20 (v1.20.5) [linux/amd64]
Kubernetes APIs:        ["cilium/v2::CiliumClusterwideNetworkPolicy", "cilium/v2::CiliumEndpoint", "cilium/v2::CiliumNetworkPolicy", "cilium/v2::CiliumNode", "core/v1::Namespace", "core/v1::Node", "core/v1::Pods", "core/v1::Service", "discovery/v1beta1::EndpointSlice", "networking.k8s.io/v1::NetworkPolicy"]
# kube-proxy replacement工作模式
KubeProxyReplacement:   Strict   [eth0 10.0.3.1 (Direct Routing)]
# cilium-agnet状态
Cilium:                 Ok   1.10.1 (v1.10.1-e6f34c3)
NodeMonitor:            Listening for events on 8 CPUs with 64x4096 of shared memory
Cilium health daemon:   Ok   
# 本节点IP池信息           已分配/总计                分配的节点CIDR
IPAM:                   IPv4: 9/254 allocated from 172.27.2.0/24, 
# 集群网格状态
ClusterMesh:            0/0 clusters ready, 0 global-services
# 带宽管理器             这里提示基于qdisc EDT实现，在eth0上进行带宽管理
BandwidthManager:       EDT with BPF   [eth0]
# 直接路由需要通过宿主机的网络栈
Host Routing:           Legacy
# 基于BPF实现IP遮掩                     不进行遮掩的CIDR
Masquerading:           BPF   [eth0]   172.27.0.0/16 [IPv4: Enabled, IPv6: Disabled]
Controller Status:      55/55 healthy
Proxy Status:           OK, ip 172.27.2.122, 0 redirects active on ports 10000-20000
Hubble:                 Ok   Current/Max Flows: 4095/4095 (100.00%), Flows/s: 67.08   Metrics: Disabled
# 流量加密已禁用
Encryption:             Disabled
# 集群节点/端点状态。如果存在不健康的对象，这里可以看到
Cluster health:         3/3 reachable   (2021-07-02T07:09:23Z)</pre>
<div class="blog_h1"><span class="graybg">开发</span></div>
<div class="blog_h2"><span class="graybg">构建</span></div>
<p>迁出项目后，在项目根目录下执行命令进行开发环境检查：<pre class="crayon-plain-tag">make dev-doctor</pre> 。</p>
<p>为了运行单元测试，需要docker；为了在虚拟机中运行Cilium，需要Vagrant和VirtualBox。建议在虚拟机中进行开发、构建、运行。</p>
<div class="blog_h3"><span class="graybg">Vagrant配置</span></div>
<p>通过下面的命令启动包含Cilium依赖的Vagrant虚拟机：</p>
<pre class="crayon-plain-tag"># 基于base系统cilium/ubuntu
contrib/vagrant/start.sh [vm_name]</pre>
<p>可选的vm_name用于添加新的虚拟机到现有集群中：</p>
<pre class="crayon-plain-tag"># 在节点上构建并安装K8S，主节点k8s1
#     创建一个从节点k8s2
#                使用net-next内核
K8S=1 NWORKERS=1 NETNEXT=1 ./contrib/vagrant/start.sh k8s2+

# 其它环境变量

# 执行vagrant reload而非vagrant up，用于恢复挂起的虚拟机
RELOAD=1
# 不在虚拟机中构建Cilium，用于快速重启（不去完全重新构建Cilium）
NO_PROVISION=1
# 启用Cilium的IPv4支持
IPV4=1
# 选择容器运行时：docker, containerd, crio
RUNTIME=docker
# 设置代理
VM_SET_PROXY=http://10.0.0.1:8088 
# 重新安装Cilium、K8S等，如果安装过程被打断有用
INSTALL=1
# 在虚拟机中构建Cilium前执行make clean
MAKECLEAN=1
# 不在虚拟机中进行构建，假设开发者先前已经在虚拟机中执行过make build
NO_BUILD=1
# 定义额外的挂载点
# USER_MOUNTS=foo 将宿主机的~/foo挂载为虚拟机的/home/vagrant/foo
# USER_MOUNTS=foo,/tmp/bar=/tmp/bar 额外挂载宿主机的/tmp/bar为虚拟机的/tmp/bar
USER_MOUNTS=
# 设置虚拟机内存,单位MB
VM_MEMORY=4096
# 设置虚拟机CPU数量
VM_CPUS=2</pre>
<p>Vagrantfile会在项目根目录寻找文件 <pre class="crayon-plain-tag">.devvmrc</pre>，如果文件存在且可执行，则VM启动时会执行它。你可以用该文件定制VM。</p>
<p>宿主机上的Cilium代码树不需要手工同步到虚拟机中，该目录默认已经通过VirtualBox NFS共享给虚拟机。</p>
<p>你也可以不使用start.sh脚本，手工启动虚拟机并构建Cilium：</p>
<pre class="crayon-plain-tag">vagrant init cilium/ubuntu
vagrant up
vagrant ssh [...]

go get github.com/cilium/cilium
cd go/src/github.com/cilium/cilium/
# 修改代码后，构建Cilium
make
# 重新安装Cilium
make install

mkdir -p /etc/sysconfig/
cp contrib/systemd/cilium.service /etc/systemd/system/
cp contrib/systemd/cilium-docker.service /etc/systemd/system/
cp contrib/systemd/cilium-consul.service /etc/systemd/system/
cp contrib/systemd/cilium  /etc/sysconfig/cilium
usermod -a -G cilium vagrant
systemctl enable cilium-docker
systemctl restart cilium-docker
systemctl enable cilium-consul
systemctl restart cilium-consul
systemctl enable cilium
# 重新启动新安装的Cilium
systemctl restart cilium

# 冒烟测试，确保Cilium正确启动，和Envoy的集成正常工作
tests/envoy-smoke-test.sh</pre>
<div class="blog_h3"><span class="graybg">构建开发者镜像</span></div>
<p>使用下面的命令，依据本地修改，构建cilium-agnet的镜像：</p>
<pre class="crayon-plain-tag">ARCH=amd64 DOCKER_REGISTRY=docker.gmem.cc DOCKER_DEV_ACCOUNT=cilium DOCKER_IMAGE_TAG=1.10.1 make dev-docker-image</pre>
<p>使用下面的命令，依据本地修改，构建cilium-operator的镜像：</p>
<pre class="crayon-plain-tag">make docker-operator-generic-image
# 类似，针对特定云平台的Operator镜像
make docker-operator-aws-image
make docker-operator-azure-image</pre>
<div class="blog_h2"><span class="graybg">调试</span></div>
<div class="blog_h3"><span class="graybg"> 数据路径代码</span></div>
<p>无法单步跟踪，主要依靠<pre class="crayon-plain-tag">cilium monitor</pre>。当cilium-agent或者某个特定的端点在debug模式下运行的话，Cilium会发送调试信息。</p>
<p>要让cilium-agent运行在debug模式，使用<pre class="crayon-plain-tag">--debug</pre>选项或在在运行时执行<pre class="crayon-plain-tag">cilium config debug=true</pre>。</p>
<p>要让特定端点进入debug模式，执行命令<pre class="crayon-plain-tag">cilium endpoint config ID debug=true</pre>。</p>
<p>使用<pre class="crayon-plain-tag">cilium monitor -v -v</pre>可以显示更多调试信息。</p>
<p>开发eBPF时，常遇到的问题是代码无法载入内核，此时，通过<pre class="crayon-plain-tag">cilium endpoint list</pre>会看到<pre class="crayon-plain-tag">not-ready</pre>状态的端点。你可以利用命令<pre class="crayon-plain-tag">cilium endpoint get</pre>来获取端点的eBPF校验日志。</p>
<p>目录<pre class="crayon-plain-tag">/var/run/cilium/state</pre>下的文件说明Cilium如何建立和管理BPF数据路径。.h文件包含了用于BPF程序编译的头文件配置，以数字为名的目录对应特定端点的状态，包括头文件和BPF二进制文件。</p>
<div class="blog_h3"><span class="graybg">查看eBPF Map</span></div>
<p>eBPF Map状态存放在/sys/fs/bpf/下，工具bpf-map可以用于查看其中的内容。</p>
<div class="blog_h1"><span class="graybg">源码分析(cilium-agent)</span></div>
<div class="blog_h2"><span class="graybg">环境准备</span></div>
<p>K8S中cilium-agent启动时的命令行为：</p>
<pre class="crayon-plain-tag">/usr/bin/cilium-agent --config-dir=/tmp/cilium/config-map</pre>
<p>/tmp/cilium/config-map是一个目录， 每个文件对应ConfigMap cilium-config中的一项。我们在本地调试cilium-agent时，可以将配置项写在YAML中：</p>
<pre class="crayon-plain-tag">auto-direct-node-routes: "true"
bpf-lb-map-max: "65536"
bpf-map-dynamic-size-ratio: "0.0025"
bpf-policy-map-max: "16384"
cilium-endpoint-gc-interval: 5m0s
cluster-id: "27"
cluster-name: k8s
cluster-pool-ipv4-cidr: 172.27.0.0/16
cluster-pool-ipv4-mask-size: "24"
custom-cni-conf: "false"
debug: "true"
disable-cnp-status-updates: "true"
enable-auto-protect-node-port-range: "true"
enable-bandwidth-manager: "true"
enable-bpf-clock-probe: "true"
enable-bpf-masquerade: "true"
enable-endpoint-health-checking: "true"
enable-health-check-nodeport: "true"
enable-health-checking: "true"
enable-host-legacy-routing: "false"
enable-host-reachable-services: "true"
enable-hubble: "true"
enable-ipv4: "true"
enable-ipv4-fragment-tracking: "true"
enable-ipv4-masquerade: "true"
enable-ipv6: "false"
enable-ipv6-masquerade: "true"
enable-l7-proxy: "true"
enable-local-redirect-policy: "false"
enable-policy: default
enable-remote-node-identity: "true"
enable-session-affinity: "true"
enable-well-known-identities: "false"
enable-xt-socket-fallback: "true"
hubble-disable-tls: "false"
hubble-listen-address: :4244
hubble-socket-path: /var/run/cilium/hubble.sock
hubble-tls-cert-file: /var/lib/cilium/tls/hubble/server.crt
hubble-tls-client-ca-files: /var/lib/cilium/tls/hubble/client-ca.crt
hubble-tls-key-file: /var/lib/cilium/tls/hubble/server.key
identity-allocation-mode: crd
install-iptables-rules: "true"
install-no-conntrack-iptables-rules: "false"
ipam: cluster-pool
kube-proxy-replacement: strict
kube-proxy-replacement-healthz-bind-address: ""
monitor-aggregation: medium
monitor-aggregation-flags: all
monitor-aggregation-interval: 5s
native-routing-cidr: 172.27.0.0/16
node-port-bind-protection: "true"
operator-api-serve-addr: 127.0.0.1:9234
preallocate-bpf-maps: "false"
sidecar-istio-proxy-image: cilium/istio_proxy
tunnel: disabled
wait-bpf-mount: "false"</pre>
<p>然后使用<pre class="crayon-plain-tag">--config=ciliumd.yaml</pre>启动cilium-agent。</p>
<div class="blog_h2"><span class="graybg">核心数据结构</span></div>
<div class="blog_h3"><span class="graybg">Endpoint</span></div>
<p>端点表示一个容器或者类似的，能够在L3独立寻址（具有独立IP地址）的网络实体。端点由端点管理器管理。</p>
<p>Cilium中的端点，仅仅<span style="background-color: #c0c0c0;">在当前节点的视角下</span>考虑。</p>
<pre class="crayon-plain-tag">type Endpoint struct {
	owner regeneration.Owner

	// 节点范围内唯一的ID
	ID uint16

	// 端点创建时间
	createdAt time.Time

	// 保护端点状态写入操作
	mutex lock.RWMutex

	// 端点对应容器的名字
	containerName string

	// 端点对应容器的ID
	containerID string

	// 如果端点由Docker管理，该字段填写libnetwork网络ID
	dockerNetworkID string

	// 如果端点由Docker管理，该字段填写libnetwork端点ID
	dockerEndpointID string

	// IPVLAN数据路径下，对应的用于尾调用的BPF Map标识符
	datapathMapID int

	// 宿主机端的，连接到端点的网络接口名。通常是veth
	ifName string

	// 宿主机段网络接口索引
	ifIndex int

	// 端点标签配置
	// FIXME: 该字段应该命名为Label
	OpLabels labels.OpLabels

	// 身份标识版本号，该版本号在端点的身份标识标签变化后会递增
	identityRevision int

	// 端点的出口速率
	bps uint64

	// 端点的MAC地址
	mac mac.MAC

	// 端点的IPv6地址
	IPv6 addressing.CiliumIPv6

	// 端点的IPv4地址
	IPv4 addressing.CiliumIPv4

	// 宿主节点的MAC地址，对于每个端点不一样
	nodeMAC mac.MAC

	// 根据端点标签计算的安全标识
	SecurityIdentity *identity.Identity `json:"SecLabel"`

	// 提示端点是否被Istio注入了Cilium兼容的sidecar proxy：
	// 1. 如果是，该sidecar用于应用L7策略规则
	// 2. 如果否，则节点级别的Cilium Envoy用于应用L7策略规则
	// 
	// 目前仅针对HTTP L7规则，Kafka只能在节点级别Enovy中应用
	hasSidecarProxy bool

	// 数据路径的策略相关的Map，包含对所有策略相关的BPF的引用
	policyMap *policymap.PolicyMap

	// 跟踪policyMap压力的指标
	policyMapPressureGauge *metrics.GaugeWithThreshold

	// 端点的数据路径配置
	Options *option.IntOptions

	// 最后N次端点的状态转换信息
	status *EndpointStatus

	// 当前端点特定的DNS代理规则的集合。cilium-agent重启时能够恢复
	DNSRules restore.DNSRules

	// 为此端点拦截的，依然有效的DNS响应缓存
	DNSHistory *fqdn.DNSCache

	// 已经过期或者从DNSHistory中驱除的DNS IPs，在确认没有连接时用这些IP后会自动删除
	DNSZombies *fqdn.DNSZombieMappings

	// dnsHistoryTrigger is the trigger to write down the ep_config.h to make
	// sure that restores when DNS policy is in there are correct
	dnsHistoryTrigger *trigger.Trigger

	// 端点状态
	state State

	// 最后一次编译和安装的BPF头文件的哈希
	bpfHeaderfileHash string

	// 端点对应的K8S Pod名
	K8sPodName string

	// 端点对应的K8S Namespace
	K8sNamespace string

	// 端点对应的Pod
	pod *slim_corev1.Pod

	// 关联到Pod 的容器端口。基于端口名应用K8S网络策略时需要
	k8sPorts policy.NamedPortMap

	// 限制重复的警告日志
	logLimiter logging.Limiter

	// 跟踪k8sPorts被设置至少一次的情况
	hasK8sMetadata bool

	// 端点当前使用的策略的版本
	policyRevision uint64

	// policyRevisionSignals contains a map of PolicyRevision signals that
	// should be triggered once the policyRevision reaches the wanted wantedRev.
	policyRevisionSignals map[*policySignal]bool

	// 应用到代理的策略修订版
	proxyPolicyRevision uint64

	// 写proxyStatistics用的锁
	proxyStatisticsMutex lock.RWMutex

	proxy EndpointProxy

	// 代理重定向的统计信息，键是 policy.ProxyIDs
	proxyStatistics map[string]*models.ProxyStatistics

	// 端点已经更新的、下一次regenerate时使用的策略修订版
	nextPolicyRevision uint64

	// 当端点选项变化后，是否强制重新计算端点的策略
	forcePolicyCompute bool

	// buildMutex synchronizes builds of individual endpoints and locks out
	// deletion during builds
	buildMutex lock.Mutex

	// logger is a logrus object with fields set to report an endpoints information.
	// This must only be accessed with atomic.LoadPointer/StorePointer.
	// 'mutex' must be Lock()ed to synchronize stores. No lock needs to be held
	// when loading this pointer.
	logger unsafe.Pointer

	// policyLogger is a logrus object with fields set to report an endpoints information.
	// This must only be accessed with atomic LoadPointer/StorePointer.
	// 'mutex' must be Lock()ed to synchronize stores. No lock needs to be held
	// when loading this pointer.
	policyLogger unsafe.Pointer

	// controllers is the list of async controllers syncing the endpoint to
	// other resources
	controllers *controller.Manager

	// realizedRedirects maps the ID of each proxy redirect that has been
	// successfully added into a proxy for this endpoint, to the redirect's
	// proxy port number.
	// You must hold Endpoint.mutex to read or write it.
	realizedRedirects map[string]uint16

	// ctCleaned indicates whether the conntrack table has already been
	// cleaned when this endpoint was first created
	ctCleaned bool

	hasBPFProgram chan struct{}

	// selectorPolicy represents a reference to the shared SelectorPolicy
	// for all endpoints that have the same Identity.
	selectorPolicy policy.SelectorPolicy

	desiredPolicy *policy.EndpointPolicy

	realizedPolicy *policy.EndpointPolicy

	visibilityPolicy *policy.VisibilityPolicy

	eventQueue *eventqueue.EventQueue

	// skippedRegenerationLevel is the DatapathRegenerationLevel of the regeneration event that
	// was skipped due to another regeneration event already being queued, as indicated by
	// state. A lower-level current regeneration is bumped to this level to cover for the
	// skipped regeneration levels.
	skippedRegenerationLevel regeneration.DatapathRegenerationLevel

	// DatapathConfiguration is the endpoint's datapath configuration as
	// passed in via the plugin that created the endpoint, e.g. the CNI
	// plugin which performed the plumbing will enable certain datapath
	// features according to the mode selected.
	DatapathConfiguration models.EndpointDatapathConfiguration

	aliveCtx        context.Context
	aliveCancel     context.CancelFunc
	regenFailedChan chan struct{}

	allocator cache.IdentityAllocator

	isHost bool

	noTrackPort uint16
}</pre>
<p>&nbsp;</p>
<div class="blog_h2"><span class="graybg">入口点</span></div>
<p>程序入口点位于daemon/cmd/daemon_main.go文件的RootCmd中： </p>
<pre class="crayon-plain-tag">var (
	log = logging.DefaultLogger.WithField(logfields.LogSubsys, daemonSubsys)

	bootstrapTimestamp = time.Now()

	// RootCmd represents the base command when called without any subcommands
	RootCmd = &amp;cobra.Command{
		Use:   "cilium-agent",
		Short: "Run the cilium agent",
		Run: func(cmd *cobra.Command, args []string) {
			cmdRefDir := viper.GetString(option.CMDRef)
			if cmdRefDir != "" {
				genMarkdown(cmd, cmdRefDir)
				os.Exit(0)
			}

			// gops监听套接字，gops能够获取Go进程的网络连接、调用栈等诊断信息
			addr := fmt.Sprintf("127.0.0.1:%d", viper.GetInt(option.GopsPort))
			addrField := logrus.Fields{"address": addr}
			if err := gops.Listen(gops.Options{
				Addr:                   addr,
				ReuseSocketAddrAndPort: true,
			}); err != nil {
				log.WithError(err).WithFields(addrField).Fatal("Cannot start gops server")
			}
			log.WithFields(addrField).Info("Started gops server")

			bootstrapStats.earlyInit.Start()
			// 环境初始化
			initEnv(cmd)
			bootstrapStats.earlyInit.End(true)
			// 运行cilium-agent
			runDaemon()
		},
	}

	bootstrapStats = bootstrapStatistics{}
)</pre>
<div class="blog_h2"><span class="graybg">环境初始化</span></div>
<p>该部分的整体逻辑包括：</p>
<ol>
<li>初始化配置option.Config
<ol>
<li>初始化Map尺寸（sizeof***Element）相关配置</li>
<li>利用viper读取命令行选项、配置文件中的的选项</li>
<li>配置K8S API Server客户端参数</li>
<li>各种调试选项：flow/envoy/datapath/policy</li>
<li>其它选项处理</li>
</ol>
</li>
<li>打印Logo和版本信息</li>
<li>确然当前用户具有root权限</li>
<li>检查PATH下cilium-envoy文件的版本</li>
<li>如果identity-allocation-mode=crd，断言启用了K8S集成</li>
<li>如果必要，启用PProf端口</li>
<li>如果必要，打开自动BPF Map分配开关</li>
<li>检查目录：
<ol>
<li>LibDir: "/var/lib/cilium"</li>
<li>RunDir: "/var/run/cilium"</li>
<li>BpfDir: "/var/lib/cilium/bpf"</li>
<li>StateDir: "/var/run/cilium/state"</li>
</ol>
</li>
<li>检查数据路径最小要求
<ol>
<li>Linux版本最低4.8.0</li>
<li>需要启用策略路由支持，检查内核配置CONFIG_IP_MULTIPLE_TABLES</li>
<li>如果Cilium启用了IPv6，断言路径/proc/net/if_inet6存在</li>
<li>断言clang、llc存在且版本满足需求。需要在运行时编译BPF源码</li>
<li>如果BpfDir不存在，提示make install-bpf拷贝BPF源码</li>
<li>启动probes.NewProbeManager()探测系统的BPF特性，检查内核参数。断言包linux-tools-generic已经安装</li>
</ol>
</li>
<li>检查或挂载BPF文件系统</li>
<li>检查或挂载Cgroups2文件系统</li>
</ol>
<div class="blog_h3"><span class="graybg">检查用户权限</span></div>
<p>直接要求当前用户为root：</p>
<pre class="crayon-plain-tag">func RequireRootPrivilege(cmd string) {
	if os.Getuid() != 0 {
		fmt.Fprintf(os.Stderr, "Please run %q command(s) with root privileges.\n", cmd)
		os.Exit(1)
	}
}</pre>
<div class="blog_h3"><span class="graybg">检查数据路径最小要求</span></div>
<p>解析内核版本major.minor.patch的代码：</p>
<pre class="crayon-plain-tag">func parseKernelVersion(ver string) (semver.Version, error) {
	verStrs := strings.Split(ver, ".")
	switch {
	case len(verStrs) &lt; 2:
		return semver.Version{}, fmt.Errorf("unable to get kernel version from %q", ver)
	case len(verStrs) &lt; 3:
		verStrs = append(verStrs, "0")
	}
	// We are assuming the kernel version will be something as:
	// 4.9.17-040917-generic

	// If verStrs is []string{ "4", "9", "17-040917-generic" }
	// then we need to retrieve patch number.
	patch := regexp.MustCompilePOSIX(`^[0-9]+`).FindString(verStrs[2])
	if patch == "" {
		verStrs[2] = "0"
	} else {
		verStrs[2] = patch
	}
	return versioncheck.Version(strings.Join(verStrs[:3], "."))
}

// GetKernelVersion returns the version of the Linux kernel running on this host.
func GetKernelVersion() (semver.Version, error) {
	var unameBuf unix.Utsname
	if err := unix.Uname(&amp;unameBuf); err != nil {
		return semver.Version{}, err
	}
	return parseKernelVersion(string(unameBuf.Release[:]))
}</pre>
<p>比较版本时使用库<pre class="crayon-plain-tag">github.com/blang/semver/v4</pre>：</p>
<pre class="crayon-plain-tag">type Range func(Version) bool;  // 这是一个函数，调用后直接判断版本是否符合要求
func MustCompile(constraint string) semver.Range {
	verCheck, err := Compile(constraint)
	if err != nil {
		panic(fmt.Errorf("cannot compile go-version constraint '%s' %s", constraint, err))
	}
	return verCheck
}

minKernelVer = "4.8.0"
isMinKernelVer = versioncheck.MustCompile("&gt;=" + minKernelVer)
if !isMinKernelVer(kernelVersion) {

}</pre>
<p>检查是否支持策略路由：</p>
<pre class="crayon-plain-tag">_, err = netlink.RuleList(netlink.FAMILY_V4)
//                该errno表示地址族不支持？  https://man7.org/linux/man-pages/man3/errno.3.html
if errors.Is(err, unix.EAFNOSUPPORT) {
	log.WithError(err).Error("Policy routing:NOT OK. " +
		"Please enable kernel configuration item CONFIG_IP_MULTIPLE_TABLES")
}</pre>
<p>检查是否支持IPv6：</p>
<pre class="crayon-plain-tag">if option.Config.EnableIPv6 {
	if _, err := os.Stat("/proc/net/if_inet6"); os.IsNotExist(err) {
		log.Fatalf("kernel: ipv6 is enabled in agent but ipv6 is either disabled or not compiled in the kernel")
	}
}</pre>
<p>查找clang二进制文件：</p>
<pre class="crayon-plain-tag">if filePath, err := exec.LookPath("clang"); err != nil {}</pre>
<p>调用bpftool进行特性探测：</p>
<pre class="crayon-plain-tag">probeManager := probes.NewProbeManager()
func NewProbeManager() *ProbeManager {
	newProbeManager := func() {
		probeManager = &amp;ProbeManager{}
		// 调用bpftool -j feature probe探测内核配置
		probeManager.features = probeManager.Probe()
	}
	// Do只会调用一次，这保证了全局变量probeManager不会被重复初始化
	once.Do(newProbeManager)
	return probeManager
}

//  判断Cilium必须的、可选的内核配置是否满足
if err := probeManager.SystemConfigProbes(); err != nil {
	errMsg := "BPF system config check: NOT OK."
	// TODO(brb) warn after GH#14314 has been resolved
	if !errors.Is(err, probes.ErrKernelConfigNotFound) {
		log.WithError(err).Warn(errMsg)
	}
}
func (p *ProbeManager) SystemConfigProbes() error {
	if !p.KernelConfigAvailable() {
		return ErrKernelConfigNotFound
	}
	requiredParams := p.GetRequiredConfig()
	for param, kernelOption := range requiredParams {
		if !kernelOption.Enabled {
			// err
		}
	}
	optionalParams := p.GetOptionalConfig()
	for param, kernelOption := range optionalParams {
		if !kernelOption.Enabled {
			// warn
		}
	}
	return nil
}
// 必须内核配置列表，大部分取决于cilium-agent的选项
func (p *ProbeManager) GetRequiredConfig() map[KernelParam]kernelOption {
	config := p.features.SystemConfig
	coreInfraDescription := "Essential eBPF infrastructure"
	kernelParams := make(map[KernelParam]kernelOption)

	kernelParams["CONFIG_BPF"] = kernelOption{
		Enabled:     config.ConfigBpf.Enabled(),
		Description: coreInfraDescription,
		CanBeModule: false,
	}
	kernelParams["CONFIG_BPF_SYSCALL"] = kernelOption{
		Enabled:     config.ConfigBpfSyscall.Enabled(),
		Description: coreInfraDescription,
		CanBeModule: false,
	}
	kernelParams["CONFIG_NET_SCH_INGRESS"] = kernelOption{
		Enabled:     config.ConfigNetSchIngress.Enabled() || config.ConfigNetSchIngress.Module(),
		Description: coreInfraDescription,
		CanBeModule: true,
	}
	kernelParams["CONFIG_NET_CLS_BPF"] = kernelOption{
		Enabled:     config.ConfigNetClsBpf.Enabled() || config.ConfigNetClsBpf.Module(),
		Description: coreInfraDescription,
		CanBeModule: true,
	}
	kernelParams["CONFIG_NET_CLS_ACT"] = kernelOption{
		Enabled:     config.ConfigNetClsAct.Enabled(),
		Description: coreInfraDescription,
		CanBeModule: false,
	}
	kernelParams["CONFIG_BPF_JIT"] = kernelOption{
		Enabled:     config.ConfigBpfJit.Enabled(),
		Description: coreInfraDescription,
		CanBeModule: false,
	}
	kernelParams["CONFIG_HAVE_EBPF_JIT"] = kernelOption{
		Enabled:     config.ConfigHaveEbpfJit.Enabled(),
		Description: coreInfraDescription,
		CanBeModule: false,
	}

	return kernelParams
}
// 可选内核配置列表
func (p *ProbeManager) GetOptionalConfig() map[KernelParam]kernelOption {
	config := p.features.SystemConfig
	kernelParams := make(map[KernelParam]kernelOption)

	kernelParams["CONFIG_CGROUP_BPF"] = kernelOption{
		Enabled:     config.ConfigCgroupBpf.Enabled(),
		Description: "Host Reachable Services and Sockmap optimization",
		CanBeModule: false,
	}
	kernelParams["CONFIG_LWTUNNEL_BPF"] = kernelOption{
		Enabled:     config.ConfigLwtunnelBpf.Enabled(),
		Description: "Lightweight Tunnel hook for IP-in-IP encapsulation",
		CanBeModule: false,
	}
	kernelParams["CONFIG_BPF_EVENTS"] = kernelOption{
		Enabled:     config.ConfigBpfEvents.Enabled(),
		Description: "Visibility and congestion management with datapath",
		CanBeModule: false,
	}

	return kernelParams
}

// 创建一个头文件 /var/run/cilium/state/globals/bpf_features.h 包含描述内核特性的宏
if err := probeManager.CreateHeadersFile(); err != nil {
	log.WithError(err).Fatal("BPF check: NOT OK.")
}
func (p *ProbeManager) CreateHeadersFile() error {
	// ...
	return p.writeHeaders(featuresFile);
}</pre>
<div class="blog_h3"><span class="graybg">挂载文件系统 </span></div>
<p>Cilium可能不在初始命名空间下运行，并且初始命名空间下的/sys/fs/bpf已经被挂载到命名空间的特定位置（option.Config.BPFRoot）。下面的调用检查BPF文件系统是否已经挂载，如果没有则挂载之：</p>
<pre class="crayon-plain-tag">func checkOrMountFS(bpfRoot string, printWarning bool) error {
	// 如果必要，进行挂载
	if bpfRoot == "" {
		checkOrMountDefaultLocations(printWarning)
	} else {
		checkOrMountCustomLocation(bpfRoot, printWarning)
	}
	// 确保没有重复挂载
	multipleMounts, err := hasMultipleMounts()
	if multipleMounts {
		return fmt.Errorf("multiple mount points detected at %s", mapRoot)
	}
	return nil
}

func checkOrMountDefaultLocations(printWarning bool) error {
	// 首先检查 /sys/fs/bpf 是否挂载了BPFFS
	mounted, bpffsInstance, err := mountinfo.IsMountFS(mountinfo.FilesystemTypeBPFFS, mapRoot)

	// 不是挂载点，则这里进行挂载
	if !mounted {
		mountFS(printWarning)
		return nil
	}
	// 挂载了，但是不是BPFFS。这意味着Cilium在容器中运行且宿主机/sys/fs/bpf没有挂载 （要避免这种情况！）
	// 这种情况下使用备用挂载点  /run/cilium/bpffs。此备用挂载点能够被Cilium使用
	// 但是会在Pod重启的时候导致umount，进而导致BPF Map（例如ct表）不可用，后果是
	// 所有到本地容器的连接被丢弃
	//
	//
	if !bpffsInstance {
		setMapRoot(defaults.DefaultMapRootFallback)

		cMounted, cBpffsInstance, err := mountinfo.IsMountFS(mountinfo.FilesystemTypeBPFFS, mapRoot)
		if !cMounted {
			if err := mountFS(printWarning); err != nil {
				return err
			}
		} else if !cBpffsInstance {
			log.Fatalf("%s is mounted but has a different filesystem than BPFFS", defaults.DefaultMapRootFallback)
		}
	}
	log.Infof("Detected mounted BPF filesystem at %s", mapRoot)
	return nil
}
func IsMountFS(mntType int64, path string) (bool, bool, error) {
	var st, pst unix.Stat_t
	// 类似于stat，但当path是符号连接的时候，查看的是链接自身（而非目标）的信息
	err := unix.Lstat(path, &amp;st)
	if err != nil {
		if errors.Is(err, unix.ENOENT) {
			// non-existent path can't be a mount point
			return false, false, nil
		}
		return false, false, &amp;os.PathError{Op: "lstat", Path: path, Err: err}
	}

	parent := filepath.Dir(path)
	err = unix.Lstat(parent, &amp;pst)
	if err != nil {
		return false, false, &amp;os.PathError{Op: "lstat", Path: parent, Err: err}
	}
	if st.Dev == pst.Dev {
		// 如果路径和父目录的设备一样，意味着它不是挂载点
		return false, false, nil
	}

	// 否则，获取文件系统信息
	fst := unix.Statfs_t{}
	err = unix.Statfs(path, &amp;fst)
	if err != nil {
		return true, false, &amp;os.PathError{Op: "statfs", Path: path, Err: err}
	}
	//           文件系统类型
	return true, fst.Type == mntType, nil

}
func mountFS(printWarning bool) error {
	// ...
	if err := unix.Mount(mapRoot, mapRoot, "bpf", 0, ""); err != nil {
		return fmt.Errorf("failed to mount %s: %s", mapRoot, err)
	}
	return nil
}

func hasMultipleMounts() (bool, error) {
	num := 0
	mountInfos, err := mountinfo.GetMountInfo()
	for _, mountInfo := range mountInfos {
		// 什么时候两个条目具有相同挂载点
		if mountInfo.Root == "/" &amp;&amp; mountInfo.MountPoint == mapRoot {
			num++
		}
	}
	return num &gt; 1, nil
}
// 读取/proc/self/mountinfo获取所有挂载信息
func GetMountInfo() ([]*MountInfo, error) {
	fMounts, err := os.Open(mountInfoFilepath)
	defer fMounts.Close()
	return parseMountInfoFile(fMounts)
}</pre>
<p>除了bpf，还需要检查/挂载cgroup2。和bpf不一样的是，存在多个cgroupv2的root mount是无害的，因此不会作重复挂载检查。</p>
<pre class="crayon-plain-tag">func CheckOrMountCgrpFS(mapRoot string) {
	cgrpMountOnce.Do(func() {
		if mapRoot == "" {
			mapRoot = cgroupRoot
		}
		cgrpCheckOrMountLocation(mapRoot)
	})
}
func cgrpCheckOrMountLocation(cgroupRoot string) error {
	setCgroupRoot(cgroupRoot)
	mounted, cgroupInstance, err := mountinfo.IsMountFS(mountinfo.FilesystemTypeCgroup2, cgroupRoot)
	if !mounted {
		return mountCgroup()
	} else if !cgroupInstance {
		return fmt.Errorf("Mount in the custom directory %s has a different filesystem than cgroup2", cgroupRoot)
	}
	return nil
}
func mountCgroup() error {
	unix.Mount("none", cgroupRoot, "cgroup2", 0, "") //...
}</pre>
<div class="blog_h3"><span class="graybg">其它选项处理</span></div>
<p>initEnv阶段会进行大量的选项校验、处理。这里列出一些比较重要的。</p>
<p>至少启用IPv4/IPv6之一：</p>
<pre class="crayon-plain-tag">if !option.Config.EnableIPv4 &amp;&amp; !option.Config.EnableIPv6 {
		log.Fatal("Either IPv4 or IPv6 addressing must be enabled")
	}</pre>
<p>和数据路径模式相关的判断逻辑：</p>
<pre class="crayon-plain-tag">switch option.Config.DatapathMode {
	case datapathOption.DatapathModeVeth:
		// 使用VETH数据路径时，不能配置IPVLAN master设备名，默认使用隧道模式，隧道默认使用VXLAN技术
		if name := viper.GetString(option.IpvlanMasterDevice); name != "undefined" {
			log.WithField(logfields.IpvlanMasterDevice, name).
				Fatal("ipvlan master device cannot be set in the 'veth' datapath mode")
		}
		if option.Config.Tunnel == "" {
			option.Config.Tunnel = option.TunnelVXLAN
		}
	case datapathOption.DatapathModeIpvlan:
		// 使用IPVLAN数据路径时，必须禁用隧道，不支持IPSec
		if option.Config.Tunnel != "" &amp;&amp; option.Config.Tunnel != option.TunnelDisabled {
			log.WithField(logfields.Tunnel, option.Config.Tunnel).
				Fatal("tunnel cannot be set in the 'ipvlan' datapath mode")
		}
		if len(option.Config.Devices) != 0 {
			log.WithField(logfields.Devices, option.Config.Devices).
				Fatal("device cannot be set in the 'ipvlan' datapath mode")
		}
		if option.Config.EnableIPSec {
			log.Fatal("Currently ipsec cannot be used in the 'ipvlan' datapath mode.")
		}

		option.Config.Tunnel = option.TunnelDisabled
		// 尽管IPVLAN模式不允许指定--device，但是后续逻辑都是通过option.Config.Devices保存设备名的，因此这里
		// 读取--ipvlan-master-device并存放在option.Config.Devices
		iface := viper.GetString(option.IpvlanMasterDevice)
		if iface == "undefined" {
			// 必须指定Master设备名
			log.WithField(logfields.IpvlanMasterDevice, option.Config.Devices[0]).
				Fatal("ipvlan master device must be specified in the 'ipvlan' datapath mode")
		}
		option.Config.Devices = []string{iface}
		link, err := netlink.LinkByName(option.Config.Devices[0])
		if err != nil {
			log.WithError(err).WithField(logfields.IpvlanMasterDevice, option.Config.Devices[0]).
				Fatal("Cannot find device interface")
		}
		option.Config.Ipvlan.MasterDeviceIndex = link.Attrs().Index
		option.Config.Ipvlan.OperationMode = connector.OperationModeL3
		if option.Config.InstallIptRules {
			option.Config.Ipvlan.OperationMode = connector.OperationModeL3S
		} else {
			log.WithFields(logrus.Fields{
				logfields.URL: "https://github.com/cilium/cilium/issues/12879",
			}).Warn("IPtables rule configuration has been disabled. This may affect policy and forwarding, see the URL for more details.")
		}
	case datapathOption.DatapathModeLBOnly:
		// 仅LB模式
		log.Info("Running in LB-only mode")
		option.Config.LoadBalancerPMTUDiscovery =
			option.Config.NodePortAcceleration != option.NodePortAccelerationDisabled
		option.Config.KubeProxyReplacement = option.KubeProxyReplacementPartial
		option.Config.EnableHostReachableServices = true
		option.Config.EnableHostPort = false
		option.Config.EnableNodePort = true
		option.Config.EnableExternalIPs = true
		option.Config.Tunnel = option.TunnelDisabled
		option.Config.EnableHealthChecking = false
		option.Config.EnableIPv4Masquerade = false
		option.Config.EnableIPv6Masquerade = false
		option.Config.InstallIptRules = false
		option.Config.EnableL7Proxy = false
	default:
		log.WithField(logfields.DatapathMode, option.Config.DatapathMode).Fatal("Invalid datapath mode")
	}</pre>
<p> 要支持L7策略，必须允许安装iptables规则：</p>
<pre class="crayon-plain-tag">if option.Config.EnableL7Proxy &amp;&amp; !option.Config.InstallIptRules {
		log.Fatal("L7 proxy requires iptables rules (--install-iptables-rules=\"true\")")
	}</pre>
<p>IPSec + 隧道组合，需要4.19+内核：</p>
<pre class="crayon-plain-tag">if option.Config.EnableIPSec &amp;&amp; option.Config.Tunnel != option.TunnelDisabled {
		if err := ipsec.ProbeXfrmStateOutputMask(); err != nil {
			log.WithError(err).Fatal("IPSec with tunneling requires support for xfrm state output masks (Linux 4.19 or later).")
		}
	}</pre>
<p>如果要求Cilium安装iptables规则install-no-conntrack-iptables-rules，让所有Pod流量跳过netfilter的conntrack，则必须使用直接路由模式。因为隧道模式下，外层封包已经自动跳过conntrack：</p>
<pre class="crayon-plain-tag">if option.Config.InstallNoConntrackIptRules {
		if option.Config.Tunnel != option.TunnelDisabled {
			log.Fatalf("%s requires the agent to run in direct routing mode.", option.InstallNoConntrackIptRules)
		}

		// 此外，跳过conntrack必须和IPv4一起使用，原因是用于匹配PodCIDR的是native routing CIDR
		// 此CIDR目前仅支持IPv4
		if !option.Config.EnableIPv4 {
			log.Fatalf("%s requires IPv4 support.", option.InstallNoConntrackIptRules)
		}
	}</pre>
<p>使用隧道时，不能使用直接路由相关的选项。</p>
<p>auto-direct-node-routes：（L2模式下）将直接路由通过给其它节点</p>
<pre class="crayon-plain-tag">if option.Config.Tunnel != option.TunnelDisabled &amp;&amp; option.Config.EnableAutoDirectRouting {
		log.Fatalf("%s cannot be used with tunneling. Packets must be routed through the tunnel device.", option.EnableAutoDirectRoutingName)
	}</pre>
<div class="blog_h2"><span class="graybg">运行cilium-agent</span></div>
<div class="blog_h3"><span class="graybg">整体逻辑</span></div>
<p>runDaemon</p>
<p style="padding-left: 30px;">enableIPForwarding  启用IPv4/IPv6转发</p>
<p style="padding-left: 30px;">iptablesManager.Init  初始化iptables管理器，检查相关内核模块是否可用</p>
<p style="padding-left: 30px;">k8s.Init  初始化各种K8S客户端对象</p>
<p style="padding-left: 30px;">NewDaemon 创建新的cilium-agent守护进程实例</p>
<p style="padding-left: 60px;">WithDefaultEndpointManager 创建端点管理器，负责从底层数据源同步端点信息</p>
<div class="blog_h3"><span class="graybg"> 设置宿主机设备</span></div>
<pre class="crayon-plain-tag">func runDaemon() {
	datapathConfig := linuxdatapath.DatapathConfiguration{
		HostDevice: option.Config.HostDevice,
}</pre>
<p>使用veth数据路径时，默认使用的宿主机设备是cilium_host，它是一个veth，对端也在初始命名空间中，是cilium_net。 </p>
<p>直接路由模式下，从路由上看，发往PodCIDR的流量都是从cilium_host出去：</p>
<pre class="crayon-plain-tag">172.27.2.0      172.27.2.122    255.255.255.0   UG    0      0        0 cilium_host
172.27.2.122    0.0.0.0         255.255.255.255 UH    0      0        0 cilium_host</pre>
<p>但是却找不到什么规则将其发送给Pod的veth（位于初始命名空间的一端）也就是lxc***网卡。</p>
<p>实际上，转发操作是通过挂钩在cilium_host中的BPF程序进行redirect实现的。类似的难以用传统Linux网络拓扑思维难以理解的地方会有很多。</p>
<div class="blog_h3"><span class="graybg">启用IP转发</span></div>
<pre class="crayon-plain-tag">if err := enableIPForwarding(); err != nil {
		log.WithError(err).Fatal("Error when enabling sysctl parameters")
	}

// ...
func enableIPForwarding() error {
	if err := sysctl.Enable("net.ipv4.ip_forward"); err != nil {
		return err
	}
	if err := sysctl.Enable("net.ipv4.conf.all.forwarding"); err != nil {
		return err
	}
	if option.Config.EnableIPv6 {
		if err := sysctl.Enable("net.ipv6.conf.all.forwarding"); err != nil {
			return err
		}
	}
	return nil
}</pre>
<div class="blog_h3"><span class="graybg">初始化iptables管理器</span></div>
<pre class="crayon-plain-tag">iptablesManager := &amp;iptables.IptablesManager{}
	iptablesManager.Init()

// ...

unc (m *IptablesManager) Init() {
	modulesManager := &amp;modules.ModulesManager{}
	haveIp6tables := true
	// 内核模块管理器，读取/proc/modules中的模块信息
	modulesManager.Init()
	// 确保模块加载
	modulesManager.FindOrLoadModules(
		"ip_tables", "iptable_nat", "iptable_mangle", "iptable_raw",
		"iptable_filter");
	modulesManager.FindOrLoadModules(
		"ip6_tables", "ip6table_mangle", "ip6table_raw", "ip6table_filter")

	if err := modulesManager.FindOrLoadModules("xt_socket"); err != nil {
		if option.Config.Tunnel == option.TunnelDisabled {
			// xt_socket执行一个local socket match(根据封包进行套接字查找，匹配应该本地处理的封包)，并且
			// 设置skb mark，这样封包将被Cilium的策略路由导向本地网络栈，不会被ip_forward()处理
			//
			// 如果xt_socket模块不存在，那么可以禁用ip_early_demux，避免在ip_forward()中明确的drop
			// 在隧道模式下可以不需要ip_early_demux，因为我们可以在BPF逻辑中设置skb mark，这个BPF也是
			// 早于策略路由阶段的，可以保证封包被导向本地网络栈，不会ip_forward()转发
			// 
			// 如果对于任何场景，我们都能保证在封包到达策略路由之前，被设置好“to proxy” 这个skb mark
			// 则可以不需要xt_socket模块。目前对于endpoint routing mode，无法满足这一点
			log.WithError(err).Warning("xt_socket kernel module could not be loaded")

			if option.Config.EnableXTSocketFallback {
				v4disabled := true
				v6disabled := true
				if option.Config.EnableIPv4 {
					v4disabled = sysctl.Disable("net.ipv4.ip_early_demux") == nil
				}
				if option.Config.EnableIPv6 {
					v6disabled = sysctl.Disable("net.ipv6.ip_early_demux") == nil
				}
				if v4disabled &amp;&amp; v6disabled {
					m.ipEarlyDemuxDisabled = true
					log.Warning("Disabled ip_early_demux to allow proxy redirection with 
                                               original source/destination address without xt_socket support 
                                               also in non-tunneled datapath modes.")
				} else {
					log.WithError(err).Warning("Could not disable ip_early_demux, traffic
                                           redirected due to an HTTP policy or visibility may be dropped unexpectedly")
				}
			}
		}
	} else {
		m.haveSocketMatch = true
	}
	m.haveBPFSocketAssign = option.Config.EnableBPFTProxy

	v, err := ip4tables.getVersion()
	if err == nil {
		switch {
		case isWaitSecondsMinVersion(v):
			m.waitArgs = []string{waitString, fmt.Sprintf("%d", option.Config.IPTablesLockTimeout/time.Second)}
		case isWaitMinVersion(v):
			m.waitArgs = []string{waitString}
		}
	}
}</pre>
<p>封包到达内核之后，内核需要知道如何路由它。为了避免反复查找路由表，Linux出现过很多路由缓存机制。从3.6开始，Linux废弃了全局路由缓存，由各子系统（例如TCP协议栈）负责维护路由缓存。</p>
<p>在套接字级别，有一个dst字段作为路由缓存。当一个TCP连接最初建立时，此字段为空，随后dst被填充，该套接字的生命周期内，后续到来的skb都不再需要查找路由。</p>
<p>ip_early_demux是一个内核特性。它向（网络栈的）下优化某些类型的本地套接字（目前是已建立的TCP套接字）输入封包的处理，它的工作原理是：</p>
<ol>
<li>内核接收到封包后，需要查找skb对应的路由，然后查找skb对应的socket</li>
<li>这里存在一种浪费，对应已建立的连接，属于同一socket的skb的路由是一致的</li>
<li>如果能将路由信息缓存到套接字上，那么就可以避免为每个skb查找路由</li>
</ol>
<p>问题在于，对于主要（60%+流量）作为路由器使用的Linux系统，它会引入不必要的吞吐量下降，可以禁用。</p>
<p>回到Cilium的场景，使用HTTP策略时，需要将流量重定向给Envoy，同时保持源、目的IP地址不变。这个重定向是依赖于xt_socket模块的。如果xt_socket不可用则需要禁用ip_early_demux，否则会导致原本应该发给Envoy的流量被转发走。</p>
<div class="blog_h3"><span class="graybg">初始化K8S</span></div>
<pre class="crayon-plain-tag">func Init(conf k8sconfig.Configuration) error {
	// 创建K8S核心API客户端
	k8sRestClient, closeAllDefaultClientConns, err := createDefaultClient()
	if err != nil {
		return fmt.Errorf("unable to create k8s client: %s", err)
	}
	// 创建Cilium客户端
	closeAllCiliumClientConns, err := createDefaultCiliumClient()
	if err != nil {
		return fmt.Errorf("unable to create cilium k8s client: %s", err)
	}
	// 创建API Extensions客户端
	if err := createAPIExtensionsClient(); err != nil {
		return fmt.Errorf("unable to create k8s apiextensions client: %s", err)
	}

	// 心跳函数
	heartBeat := func(ctx context.Context) error {
		// Kubernetes默认的心跳是获取所在节点的信息
		// [0] https://github.com/kubernetes/kubernetes/blob/v1.17.3/pkg/kubelet/kubelet_node_status.go#L423
		// 这对于Cilium来说太重，因此这里的心跳采用检查/healthz端点的方式
		res := k8sRestClient.Get().Resource("healthz").Do(ctx)
		return res.Error()
	}
	
	// 对K8S进行心跳检测
	if option.Config.K8sHeartbeatTimeout != 0 {
		controller.NewManager().UpdateController("k8s-heartbeat",
			controller.ControllerParams{
				DoFunc: func(context.Context) error {
					runHeartbeat(
						heartBeat, // 心跳函数
						option.Config.K8sHeartbeatTimeout, // 超时
						// 后面的是超时后的回调，这里是关闭客户端连接
						closeAllDefaultClientConns,
						closeAllCiliumClientConns,
					)
					return nil
				},
				// 心跳运行间隔
				RunInterval: option.Config.K8sHeartbeatTimeout,
			},
		)
	}
	// 获取K8S版本进而推导具有哪些特性
	if err := k8sversion.Update(Client(), conf); err != nil {
		return err
	}
	if !k8sversion.Capabilities().MinimalVersionMet {
		return fmt.Errorf("k8s version (%v) is not meeting the minimal requirement (%v)",
			k8sversion.Version(), k8sversion.MinimalVersionConstraint)
	}

	return nil
}

// 创建Cilium客户端
import 	clientset "github.com/cilium/cilium/pkg/k8s/client/clientset/versioned"
func createDefaultCiliumClient() (func(), error) {
	restConfig, err := CreateConfig()
	closeAllConns := setDialer(restConfig)
	createdCiliumK8sClient, err := clientset.NewForConfig(restConfig)
	k8sCiliumCLI.Interface = createdCiliumK8sClient
	return closeAllConns, nil
}
// 为 rest.Config设置Dial，一个负责拨号（创建TCP连接）的函数
func setDialer(config *rest.Config) func() {
	if option.Config.K8sHeartbeatTimeout == 0 {
		return func() {}
	}
	ctx := (&amp;net.Dialer{
		Timeout:   option.Config.K8sHeartbeatTimeout,
		KeepAlive: option.Config.K8sHeartbeatTimeout,
	}).DialContext
	dialer := connrotation.NewDialer(ctx)
	// 拨号器的DialContext用于创建连接，CloseAll用于关闭它创建的所有连接
	// 这个关闭函数返回，并在心跳失败时调用以关闭连接
	config.Dial = dialer.DialContext
	return dialer.CloseAll
}</pre>
<p>&nbsp;</p>
<div class="blog_h1"><span class="graybg">常见问题</span></div>
<div class="blog_h2"><span class="graybg">零散问题</span></div>
<div class="blog_h3"><span class="graybg">native routing cidr must be configured with option --native-routing-cidr in combination with --masquerade --tunnel=disabled</span></div>
<p>禁用隧道，使用直接路由时，必须指定native-routing-cidr选项（对应Helm值nativeRoutingCIDR）。该选项必须设置为PodCIDR，也就是Kubernetes的--cluster-cidr。该选项提示Cilium直接路由的目的地址范围。</p>
<div class="blog_h3"><span class="graybg">Failed to compile XDP program" error="Failed to load prog with ip cilium</span></div>
<p>可能提示不支持XDP，禁用<pre class="crayon-plain-tag">--set loadBalancer.acceleration=disabled</pre>。</p>
<div class="blog_h2"><span class="graybg">cilium status提示端点不通</span></div>
<p>异常时输出示例：</p>
<pre class="crayon-plain-tag">kubectl -n kube-system exec cilium-4bsb6 -- cilium status
# ...
# Cluster health:           0/3 reachable   (2021-07-02T04:06:04Z)
#   Name                    IP              Node          Endpoints
#   k8s/k8s-3 (localhost)   10.0.3.3        reachable     unreachable
#   k8s/k8s-1               10.0.3.1        reachable     unreachable
#   k8s/k8s-2               10.0.3.2        unreachable   unreachable</pre>
<p>正常时输出示例：</p>
<pre class="crayon-plain-tag"># Cluster health:         3/3 reachable   (2021-07-02T04:09:19Z)</pre>
<p>任何导致网络不通的故障，都会出现类似报错。</p>
<div class="blog_h3"><span class="graybg">禁用隧道但未同步路由</span></div>
<p>配置<pre class="crayon-plain-tag">--set tunnel=disabled</pre>以使用直接路由模式后，容器网络封包不再使用VXLAN包装，而是直接在集群节点之间路由。</p>
<p>这需要集群节点之间有正确的路由规则：</p>
<ol>
<li>如果节点是L2直连的，应当设置<pre class="crayon-plain-tag">--set autoDirectNodeRoutes=true</pre>。这样，路由规则会直接安装到节点路由表中，例如：<br />
<pre class="crayon-plain-tag">route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
# 下面的两个PodCIDR的节点CIDR，通过以太网路由给其它节点
172.27.0.0      10.0.3.2        255.255.255.0   UG    0      0        0 eth0
172.27.1.0      10.0.3.3        255.255.255.0   UG    0      0        0 eth0
# 下面是本节点的CIDR，交给cilium_host虚拟设备处理
172.27.2.0      172.27.2.122    255.255.255.0   UG    0      0        0 cilium_host
172.27.2.122    0.0.0.0         255.255.255.255 UH    0      0        0 cilium_host</pre>
</li>
<li>如果节点是L3互联的，则路由器需要和Cilium进行某种形式的集成。例如通过BGP协议，或者使用crd这个IPAM，静态规定每个节点的CIDR，然后更新路由器的路由规则</li>
</ol>
<div class="blog_h3"><span class="graybg">IaaS层配置异常</span></div>
<p>如果节点是虚拟机，则要考虑IaaS层的网络设置。例如OpenStack可能需要为Port配置Allowed Address Pairs，将PodCIDR加入其中。</p>
<div class="blog_h2"><span class="graybg">DSR模式下无法从集群外部访问LB服务</span></div>
<p>LoadBalancer服务的IP地址为：10.0.11.20，服务端点IP地址为：172.28.1.76。流量入口节点为m1，服务端点所在节点为m3。</p>
<p>在m1上通过tcpdump抓包：</p>
<pre class="crayon-plain-tag"># tcpdump -i any -nnn -vvv 'dst 172.28.1.76'
15:21:02.310338 IP (tos 0x10, ttl 64, id 58773, offset 0, flags [DF], proto TCP (6), length 68, options (unknown 154))
    10.2.0.1.36568 &gt; 172.28.1.76.2379: Flags [S], cksum 0xb799 (incorrect -&gt; 0x65e9), seq 2591998745, win 64240, options [mss 1460,sackOK,TS val 899286115 ecr 0,nop,wscale 9], length 0</pre>
<p>可以看到，在入口节点m1，已经尝试将请求直接转发给位于m3的172.28.1.76.2379。携带了IP选项154。但是，在m3上抓包，没有任何相关信息。</p>
<p>禁用DSR后，两个节点抓包可以发现通信正常：</p>
<pre class="crayon-plain-tag"># m1
15:36:13.301471 IP (tos 0x10, ttl 64, id 47272, offset 0, flags [DF], proto TCP (6), length 57)
    10.2.0.61.38732 &gt; 172.28.1.76.2379: Flags [P.], cksum 0xb7d2 (incorrect -&gt; 0x5309), seq 1086727409:1086727414, ack 3915217720, win 126, options [nop,nop,TS val 900197106 ecr 488619248], length 5

# m3
15:36:13.301825 IP (tos 0x10, ttl 64, id 47272, offset 0, flags [DF], proto TCP (6), length 57)
    10.2.0.61.38732 &gt; 172.28.1.76.2379: Flags [P.], cksum 0xb7d2 (incorrect -&gt; 0x5309), seq 1086727409:1086727414, ack 3915217720, win 126, options [nop,nop,TS val 900197106 ecr 488619248], length 5</pre>
<p>差别在于源地址、IP选项不同。考虑是OpenStack源地址检查的原因。增加Allowed Address Pairs：10.0.0.0/8，问题解决。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/cilium">Cilium学习笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/cilium/feed</wfw:commentRss>
		<slash:comments>2</slash:comments>
		</item>
		<item>
		<title>从镜像中抽取文件</title>
		<link>https://blog.gmem.cc/extract-files-from-image</link>
		<comments>https://blog.gmem.cc/extract-files-from-image#comments</comments>
		<pubDate>Sat, 18 Apr 2020 09:09:23 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[Docker]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=32389</guid>
		<description><![CDATA[<p>动机 在某个应用场景中，我们需要在每个K8S节点上运行一个Agent，此Agent能够执行运维人员动态配置的Python脚本，来检查节点是否出现故障。 Python脚本能够调用的库，需要按需不断更新，受限于运行环境，我们不便搭建和维护PyPI私服。因此，我们考虑将库封装在Docker镜像中进行分发。这里会面临两个问题： 如何尽量高效的分发Python库镜像 如何在Agent中正确的安装Python库 我们的想法是，利用镜像分层这一特性，将Python库的更新存放在单独的层中，这样Agent就可以仅仅拉取需要的层。此外，Agent需要分析层对文件系统做了哪些变更，把这些变更在自己的文件系统中回放一遍。 镜像仓库接口 包括Harbor在内的镜像仓库软件，底层是Registry，它是OCI Distribution Specification的参考实现，描述了客户端和镜像仓库服务器的交互接口。 OCI Distribution Specification是Docker Registry HTTP API V2的标准化（两者大体相同）。V2相对于V1，主要改进包括：镜像定义的简化、安全方面的增强、带宽占用的减少。 接口概览 新特性 镜像校验：客户端可以先下载manifest，校验清单的签名，然后再下载镜像层。这可以确保来源可靠且未被篡改。在每下载一个层后，客户端都可以计算其摘要，和清单中的值进行比对，确保一致 可恢复Push/Pull：推送、拉取镜像都支持断点续传 层去重，两个相同的层仅仅会被上传或存储一份： 如果上传层时发现它已经存在，则不上传 <a class="read-more" href="https://blog.gmem.cc/extract-files-from-image">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/extract-files-from-image">从镜像中抽取文件</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">动机</span></div>
<p>在某个应用场景中，我们需要在每个K8S节点上运行一个Agent，此Agent能够执行运维人员动态配置的Python脚本，来检查节点是否出现故障。</p>
<p>Python脚本能够调用的库，需要按需不断更新，受限于运行环境，我们不便搭建和维护PyPI私服。因此，我们考虑将库封装在Docker镜像中进行分发。这里会面临两个问题：</p>
<ol>
<li>如何尽量高效的分发Python库镜像</li>
<li>如何在Agent中正确的安装Python库</li>
</ol>
<p>我们的想法是，利用镜像分层这一特性，将Python库的更新存放在单独的层中，这样Agent就可以<span style="background-color: #c0c0c0;">仅仅拉取需要的层</span>。此外，Agent需要分析层对文件系统做了哪些变更，<span style="background-color: #c0c0c0;">把这些变更在自己的文件系统中回放一遍</span>。</p>
<div class="blog_h1"><span class="graybg">镜像仓库接口</span></div>
<p>包括Harbor在内的镜像仓库软件，底层是<a href="https://github.com/docker/distribution">Registry</a>，它是<a href="https://github.com/opencontainers/distribution-spec/blob/master/spec.md">OCI Distribution Specification</a>的参考实现，描述了客户端和镜像仓库服务器的交互接口。</p>
<p>OCI Distribution Specification是Docker Registry HTTP API V2的标准化（两者大体相同）。V2相对于V1，主要改进包括：镜像定义的简化、安全方面的增强、带宽占用的减少。</p>
<div class="blog_h2"><span class="graybg">接口概览</span></div>
<div class="blog_h3"><span class="graybg">新特性</span></div>
<ol>
<li>镜像校验：客户端可以先下载manifest，校验清单的签名，然后再下载镜像层。这可以确保来源可靠且未被篡改。在每下载一个层后，客户端都可以计算其摘要，和清单中的值进行比对，确保一致</li>
<li>可恢复Push/Pull：推送、拉取镜像都支持断点续传</li>
<li>层去重，两个相同的层仅仅会被上传或存储一份：
<ol>
<li>如果上传层时发现它已经存在，则不上传</li>
<li>如果两个进程同时上传一个层，则仅仅保存先上传的那个</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">URL前缀</span></div>
<p>所有接口端点都以API版本和仓库名为前缀，例如仓库library/ubuntu的URI前缀是<pre class="crayon-plain-tag">/v2/library/ubuntu/</pre>，这种URI布局便于实现身份验证和访问控制。</p>
<p>仓库名需要满足以下限制：</p>
<ol>
<li>仓库名可以包含多个路径分量（/分隔开的片断），每个分量匹配正则式<pre class="crayon-plain-tag">[a-z0-9]+(?:[._-][a-z0-9]+)*</pre></li>
<li>分量的数量没有限制</li>
<li>仓库名中长度（包括/）不超过256字符</li>
</ol>
<div class="blog_h3"><span class="graybg">错误报文</span></div>
<p>如果处理请求出现错误，返回4xx响应，并且附带下面格式的响应体：</p>
<pre class="crayon-plain-tag">{
    // 1-N个错误
    "errors:" [{
            // 全大写唯一标识符
            "code": &lt;error identifier&gt;,
            // 可读文本
            "message": &lt;message describing condition&gt;,
            // 任意结构化消息
            "detail": &lt;unstructured&gt;
        },
        ...
    ]
}</pre>
<p>示例：<pre class="crayon-plain-tag">{"errors":[{"code":"UNAUTHORIZED","message":"authentication required","detail":null}]}</pre>  </p>
<div class="blog_h3"><span class="graybg">内容摘要</span></div>
<p>镜像仓库接口非常依赖于<a href="https://en.wikipedia.org/wiki/Content-addressable_storage">内容可寻址性</a>，这种技术利用防冲撞哈希（collision-resistant hash）算法获取信息内容的摘要，此摘要作为信息的唯一标识符，既可用于寻址，也可用于信息完整性校验。</p>
<p>摘要的形式为sha256:6c3c624b58dbbcd3c0dd82b4c53f04194d1247c6eebdaab7c610cf7d66709b3b，前面是算法，后面是HEX形式的哈希值。</p>
<div class="blog_h3"><span class="graybg">摘要头</span></div>
<p>为了支持内容校验，所有接口响应都可以提供<pre class="crayon-plain-tag">Docker-Content-Digest</pre>头，其中包含响应中实体的摘要信息，对于：</p>
<ol>
<li>Blob，实体就是整个响应体</li>
<li>Manifest，实体是清单体，不包括签名部分</li>
</ol>
<p>出于安全性的考虑，客户端应该基于摘要来验证响应实体。</p>
<div class="blog_h2"><span class="graybg">镜像拉取</span></div>
<p>镜像是<span style="background-color: #c0c0c0;">一个JSON清单 + 一系列独立的层文件</span>，要获取完整镜像，需要对镜像仓库发起多次HTTP调用。</p>
<div class="blog_h3"><span class="graybg">清单拉取</span></div>
<p>镜像拉取的第一步是获取清单，API格式：</p>
<pre class="crayon-plain-tag">// reference可以包含一个Tag或摘要
// GET /v2/&lt;name&gt;/manifests/&lt;reference&gt;

// 示例
// curl -H 'Authorization: Basic &lt;credentials&gt;' 
//      -H 'Accept: application/vnd.docker.distribution.manifst.v2+json'
//      https://docker.gmem.cc/v2/alpine/manifests/3.11
// 默认内容类型 application/vnd.docker.distribution.manifest.v1+prettyjws
{
   "schemaVersion": 2,
   "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
   "config": {
      ...
   },
   "layers": [
      {
         "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
         "size": 2802957,
         "digest": "sha256:c9b1b535fdd91a9855fb7f82348177e5f019329a58c53c47272962dd60f71fc9"
      }
   ]
}</pre>
<p>可以看到，镜像包含的所有层的信息，都可以从清单上找到。下一步就是基于层的标识符（摘要）发起层拉取请求。</p>
<div class="blog_h3"><span class="graybg">层拉取</span></div>
<p>拉取层或者其它Blob数据，使用的是同一接口。你需要在URL中传递Blob数据的摘要，包括算法前缀：</p>
<pre class="crayon-plain-tag">// name为仓库名，digest为层的摘要
// GET /v2/&lt;name&gt;/blobs/&lt;digest&gt;

// 示例
// https://docker.gmem.cc/v2/alpine/blobs/sha256:86235187a3abfce6fa63b526aaea608392a9f629fd827ad75867b906362f9dd0</pre>
<p><span style="background-color: #c0c0c0;">镜像仓库可以发送307（对于HTTP1.1以下版本则是302）响应，客户端应当正确处理响应，从其它地方下载层</span>。</p>
<p>镜像仓库会设置适当的头，以支持层的缓存。它还会设置Range头以支持断点续传。</p>
<div class="blog_h2"><span class="graybg">小结</span></div>
<p>了解镜像仓库的API后，我们可以确定，要下载镜像的某个层是很容易的。</p>
<p>对于本文开头的Python库镜像，我们可以检查并下载新增的层，剩下需要解决的问题就是，如何从层中读取对文件系统做的变更了。</p>
<div class="blog_h1"><span class="graybg">镜像格式</span></div>
<p>要从层中抽取文件，必须理解镜像、层的结构。 </p>
<p><a href="https://github.com/docker/distribution/blob/5cb406d511b7b9163bff9b6439072e4892e5ae3b/docs/spec/manifest-v2-1.md">Image Manifest Version 2, Schema 1</a>（已经弃用）、<a href="https://github.com/docker/distribution/blob/5cb406d511b7b9163bff9b6439072e4892e5ae3b/docs/spec/manifest-v2-2.md">Image Manifest Version 2, Schema 2</a> 是Docker关于镜像清单文件的规范。</p>
<p><a href="https://github.com/moby/moby/blob/master/image/spec/v1.2.md#docker-image-specification-v120">Docker Image Specification v1.2.0</a> 是Docker关于镜像的规范，包含层的格式说明。</p>
<p><a href="https://github.com/opencontainers/image-spec/blob/master/spec.md">OCI Image Format Specification</a> 是关于镜像格式的规范，包括Docker在内的主流容器引擎都支持支持此规范。</p>
<p>本章主要基于Docker相关的最新标准进行分析，OCI在很大程度上是兼容的。</p>
<div class="blog_h2"><span class="graybg">格式概览</span></div>
<p>对于Docker镜像，其清单格式大致如下：</p>
<pre class="crayon-plain-tag">{
    // 清单版本
    "schemaVersion": 2,
    // MIME类型
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    // 引用配置对象，配置存放在一个JSON blob中，容器运行时使用此配置来启动容器
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7023,
        // 需要使用blobs接口下载配置对象
        "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
    },
    // 层列表，以Base镜像开始（顺序和Schema1相反）
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 32654,
            // 需要使用blobs接口下载层
            "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f"
        }
    ]
}</pre>
<p>可以看到，清单给出了每个层的格式（mediaType），Schema 2支持的MIME类型包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="text-align: center;">MIME</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>application/vnd.docker.distribution.manifest.v1+json</td>
<td>遗留的Schema 1清单格式</td>
</tr>
<tr>
<td>application/vnd.docker.distribution.manifest.v2+json</td>
<td>Schema 2清单格式</td>
</tr>
<tr>
<td>application/vnd.docker.distribution.manifest.list.v2+json</td>
<td>清单列表（所谓fat manifest）</td>
</tr>
<tr>
<td>application/vnd.docker.container.image.v1+json</td>
<td>容器配置对象格式</td>
</tr>
<tr>
<td>application/vnd.docker.image.rootfs.diff.tar.gzip</td>
<td>层，tar.gz格式</td>
</tr>
<tr>
<td>application/vnd.docker.image.rootfs.foreign.diff.tar.gzip</td>
<td>永远不应该被Push的层</td>
</tr>
<tr>
<td>application/vnd.docker.plugin.v1+json</td>
<td>插件配置JSON</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">镜像文件系统变更集</span></div>
<p>从MIME类型的说明上可以看到，镜像的层是gzip压缩处理的tar包。 </p>
<p>尝试解压缩，粗略看起来就是一棵Linux目录层次树。那么，如何识别出哪些文件是新增、哪些是修改，哪些又是删除掉的呢？</p>
<p>在相关规范中，层也叫镜像文件系统变更集（Image Filesystem Changeset ），不管是OCI还是Docker，都遵循：</p>
<ol>
<li>新增、修改的目录或文件，按照它的原始路径存储在变更集中</li>
<li>删除的目录或文件，为文件的basename前缀以<pre class="crayon-plain-tag">.wh.</pre>，存储位置保持不变。例如在某个变更集中<pre class="crayon-plain-tag">./etc/hosts</pre>被删除，则会创建一个空白的<pre class="crayon-plain-tag">./etc/.wh.hosts</pre></li>
</ol>
<div class="blog_h2"><span class="graybg">小结</span></div>
<p>可以看到，要解开存放在镜像层中的Python库更新，也很简单：</p>
<ol>
<li>根据MIME类型，选取适当的工具解压缩</li>
<li>遍历得到的目录树，遇到<pre class="crayon-plain-tag">.wh.</pre>开头的节点，则从Agent文件系统中删除，对于其它节点，覆盖即可</li>
</ol>
<div class="blog_h1"><span class="graybg">相关库</span></div>
<p>尽管利用Go语言标准库就可以完成镜像层下载、解压缩、应用到Agent文件系统，我们还是在这里介绍一些可以简化操作的库。 </p>
<div class="blog_h2"><span class="graybg"> opencontainers/image-tools</span></div>
<p>该项目提供了创建、校验、解包OCI镜像的工具，鉴于Docker和OCI规范具有很多兼容的地方，这个项目中的代码参考。</p>
<div class="blog_h2"><span class="graybg">docker/docker</span></div>
<p>github.com/docker/docker提供了Docker客户端库，比较不方便的地方是，它必须连接到Docker守护程序才能工作。</p>
<div class="blog_h3"><span class="graybg">创建客户端</span></div>
<pre class="crayon-plain-tag">var dockerClient *client.Client

var clientOpts []client.Opt
clientOpts = append(clientOpts, client.FromEnv)
clientOpts = append(clientOpts, client.WithAPIVersionNegotiation())

dockerClient, err = client.NewClientWithOpts(clientOpts...)

// 获取镜像基本信息
dockerClient.ImageInspectWithRaw(ctx, "nginx:latest")

// 从Docker守护程序获取一个或多个镜像，返回Reader
readCloser, err := dockerClient.ImageSave(ctx, []string{id})</pre>
<div class="blog_h3"><span class="graybg">镜像接口</span></div>
<p>Docker客户端实现了接口：</p>
<pre class="crayon-plain-tag">type ImageAPIClient interface {
	ImageBuild(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error)
	BuildCachePrune(ctx context.Context, opts types.BuildCachePruneOptions) (*types.BuildCachePruneReport, error)
	BuildCancel(ctx context.Context, id string) error
	ImageCreate(ctx context.Context, parentReference string, options types.ImageCreateOptions) (io.ReadCloser, error)
	ImageHistory(ctx context.Context, image string) ([]image.HistoryResponseItem, error)
	ImageImport(ctx context.Context, source types.ImageImportSource, ref string, options types.ImageImportOptions) (io.ReadCloser, error)
	ImageInspectWithRaw(ctx context.Context, image string) (types.ImageInspect, []byte, error)
	ImageList(ctx context.Context, options types.ImageListOptions) ([]types.ImageSummary, error)
	ImageLoad(ctx context.Context, input io.Reader, quiet bool) (types.ImageLoadResponse, error)
	ImagePull(ctx context.Context, ref string, options types.ImagePullOptions) (io.ReadCloser, error)
	ImagePush(ctx context.Context, ref string, options types.ImagePushOptions) (io.ReadCloser, error)
	ImageRemove(ctx context.Context, image string, options types.ImageRemoveOptions) ([]types.ImageDeleteResponseItem, error)
	ImageSearch(ctx context.Context, term string, options types.ImageSearchOptions) ([]registry.SearchResult, error)
	ImageSave(ctx context.Context, images []string) (io.ReadCloser, error)
	ImageTag(ctx context.Context, image, ref string) error
	ImagesPrune(ctx context.Context, pruneFilter filters.Args) (types.ImagesPruneReport, error)
}</pre>
<p>可以看到，它没有提供针对层的操控函数，因此本文的场景下没有什么用处。</p>
<div class="blog_h2"><span class="graybg">google/go-containerregistry</span></div>
<p>这个项目提供了镜像仓库接口的Go语言封装。提供读写镜像、层、ImageIndex等功能。 </p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/extract-files-from-image">从镜像中抽取文件</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/extract-files-from-image/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>服务网格的现状和未来</title>
		<link>https://blog.gmem.cc/the-present-and-future-of-servicemesh</link>
		<comments>https://blog.gmem.cc/the-present-and-future-of-servicemesh#comments</comments>
		<pubDate>Mon, 13 Apr 2020 04:10:44 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[PaaS]]></category>
		<category><![CDATA[ServiceMesh]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=31771</guid>
		<description><![CDATA[<p>引言 服务网格（Service Mesh）是一种微服务治理基础设施，用于控制、监测微服务之间的东西向流量。它通常由控制平面、数据平面两部分组成。其中数据平面就是伴随着业务应用部署的网络代理，控制平面则是一组独立的组件，和数据平面交互，发送控制网络流量的规则，接收各类监测指标。业务应用的开发人员对服务网格的存在并无感知，这是服务网格最关键的优势。 服务网格的概念出现于2010年代早期，2017年前后开始风行。从ServiceMesher社区在2020年2月发起的终端用户调研结果来看，在生产环境下使用该技术的公司占比不到15%，大部分公司仍然在观望： 是什么原因阻碍了服务网格的大面积应用，它的未来会如何？本文将从主要从技术角度来细化分析。 现状和问题 社区发展情况 Kubernetes早已成为容器编排领域的事实标准，主流服务网格框架都选择构筑在Kubernetes之上。Kubernetes和服务网格是相辅相成的，尽管功能上有些许重叠。Kubernetes主要专注于应用的部署，服务网格更关注应用的运行时管理。 目前占据着领导地位的服务网格框架是Istio + Envoy的组合。早期的Linkerd运行在JVM之上，资源消耗较高，已经被Linkerd 2所取代。Linkerd 2的进展不尽如人意，支持的特性比Istio要少很多。国内的参与者，包括阿里的SOFAMesh、华为的ASM等，这些框架一部分没有开源，另一部分更新较为缓慢，SOFAMesh的代码库2020年尚未有新代码合并到主干。 从ServiceMesher社区在2020年2月发起的终端用户调研结果来看，Istio和Envoy是用户关注度最高的服务网格开源项目： 要打造自研服务网格产品，理性的方案是以一种成熟的社区技术为基础，这是因为服务网格牵涉到的技术面很广，从零开发的成本非常高。目前来看，Istio + Envoy是较好的选择，包括AWS App Mesh、F5 Aspen Mesh等商业方案都是在Istio / Envoy的基础上进行深入定制实现的。 <a class="read-more" href="https://blog.gmem.cc/the-present-and-future-of-servicemesh">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/the-present-and-future-of-servicemesh">服务网格的现状和未来</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></description>
				<content:encoded><![CDATA[<div class="wri_content_clear_both"><div class="blog_h1"><span class="graybg">引言</span></div>
<p>服务网格（Service Mesh）是一种微服务治理基础设施，用于控制、监测微服务之间的东西向流量。它通常由控制平面、数据平面两部分组成。其中数据平面就是伴随着业务应用部署的网络代理，控制平面则是一组独立的组件，和数据平面交互，发送控制网络流量的规则，接收各类监测指标。业务应用的开发人员对服务网格的存在并无感知，这是服务网格最关键的优势。</p>
<p>服务网格的概念出现于2010年代早期，2017年前后开始风行。从ServiceMesher社区在2020年2月发起的终端用户调研结果来看，在生产环境下使用该技术的公司占比不到15%，大部分公司仍然在观望：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/04/service-mesh-usage.png"><img class="aligncenter  wp-image-31779" src="https://blog.gmem.cc/wp-content/uploads/2020/04/service-mesh-usage.png" alt="service-mesh-usage" width="1024" height="311" /></a></p>
<p>是什么原因阻碍了服务网格的大面积应用，它的未来会如何？本文将从主要从技术角度来细化分析。</p>
<div class="blog_h1"><span class="graybg">现状和问题</span></div>
<div class="blog_h2"><span class="graybg">社区发展情况</span></div>
<p>Kubernetes早已成为容器编排领域的事实标准，主流服务网格框架都选择构筑在Kubernetes之上。Kubernetes和服务网格是相辅相成的，尽管功能上有些许重叠。Kubernetes主要专注于应用的部署，服务网格更关注应用的运行时管理。</p>
<p>目前占据着领导地位的服务网格框架是Istio + Envoy的组合。早期的Linkerd运行在JVM之上，资源消耗较高，已经被Linkerd 2所取代。Linkerd 2的进展不尽如人意，支持的特性比Istio要少很多。国内的参与者，包括阿里的SOFAMesh、华为的ASM等，这些框架一部分没有开源，另一部分更新较为缓慢，SOFAMesh的代码库2020年尚未有新代码合并到主干。</p>
<p>从ServiceMesher社区在2020年2月发起的终端用户调研结果来看，Istio和Envoy是用户关注度最高的服务网格开源项目：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/04/focused-service-mesh-tech.png"><img class="aligncenter  wp-image-31785" src="https://blog.gmem.cc/wp-content/uploads/2020/04/focused-service-mesh-tech.png" alt="focused-service-mesh-tech" width="1012" height="323" /></a></p>
<p>要打造自研服务网格产品，理性的方案是以一种成熟的社区技术为基础，这是因为服务网格牵涉到的技术面很广，从零开发的成本非常高。目前来看，Istio + Envoy是较好的选择，包括AWS App Mesh、F5 Aspen Mesh等商业方案都是在Istio / Envoy的基础上进行深入定制实现的。</p>
<div class="blog_h2"><span class="graybg">标准化问题</span></div>
<p>2019年5月，微软联合一系列厂商发布了一个服务网格规范（Service Mesh Interface，SMI），它是一个基于Kubernetes的服务网格接口标准，致力于实现不同服务网格框架的互操作性。</p>
<p>巨头们早已看到了服务网格的价值，并且纷纷出手，支持或参与开源项目，推出自己的商业产品。百花齐放的服务网格产品，接口不一致，必然会导致供应商锁定问题，一旦选择了某个厂商的服务网格产品，你将很难切换到另一家。SMI需要解决的就是供应商锁定问题，微软、Vmware、HashiCorp、F5等都加入了SMI阵营。遗憾的是，社区的领头羊，Istio阵营的Google、IBM等却对SMI不感兴趣，个中利益纷争耐人寻味。</p>
<p>标准化的分歧，的确会导致技术选型相关的风险，因为我们难以准确判断中长期的技术方向。为了规避这类风险，自研服务网格产品应该面向上层应用暴露技术中立的API，避免在API上和某种服务网格实现耦合。</p>
<div class="blog_h2"><span class="graybg">遗留技术</span></div>
<p>我已经在多个团队中目睹推行服务网格甚至是Kubernetes时遇到强大的阻力，原因是这些团队已经在使用“等位”技术，而且工作的不错。</p>
<p>这种阻力在以Java为单一技术栈的公司/团队中尤为明显。Java生态圈一直非常繁荣，自成体系。特别是Spring Cloud项目提供了服务发现、负载均衡、内容感知路由等特性，这些正和Kubernetes、Istio存在功能重叠，导致技术迁移的动力不足。</p>
<p>在决定引入服务网格技术之前，首先要确信自己的团队的确需要它。对于规模很小的公司/团队，答案往往是否。答案反转的一个重要的契机是，你提供的服务需要被别的团队所消费，且这个团队和你使用不一样的技术栈，例如使用Go而非Java。</p>
<p>另外需要注意一点，Spring Cloud和Kubernetes并非水火不容，它不但可以容器化部署在Kubernetes上，改用Kubernetes作为服务发现机制也非常简单。 事实上，Spring背后的VMware是云原生领域的重要参与者，Spring Cloud对Kubernetes的集成能力也在不断提高。服务网格属于PaaS层而非应用级的解决方案，它和Spring Cloud这类框架可以长期共同存在，平滑迁移。</p>
<p>服务网格的边缘代理，则又和API网关存在功能上的重叠。前些年在Kubernetes尚未普遍应用的时候，很多互联网开发团队就在自研API网关产品。我的建议是，现有的API网关产品可以继续使用，如果团队已经全面转向云原生，则不要新造轮子，可以基于Envoy构建API网关。</p>
<div class="blog_h2"><span class="graybg">性能问题</span></div>
<p>Istio的架构，在1.0版本之后已经经过多次重大调整，以平衡架构的优雅性和性能。1.1版本进一步微服务化，分离了 Pilot 的配置下发功能到新的 Galley 组件中，将 Mixer 组件中原本进程内插件改为进程外插件，这一版本加剧了性能问题。随后的版本又开始180度转弯，放弃架构之美，追求性能和实用。到1.5版本为止，Mixer已经废弃，遥测功能下沉到Envoy代理中，控制平面变成一个单体的istiod：</p>
<p><a href="https://blog.gmem.cc/wp-content/uploads/2020/04/istiod-archeture.jpg"><img class="aligncenter  wp-image-31797" src="https://blog.gmem.cc/wp-content/uploads/2020/04/istiod-archeture.jpg" alt="istiod-archeture" width="521" height="321" /></a></p>
<p>即使是在禁用Mixer，仅仅启用最基础的流量管理功能的情况下，Envoy引入的性能开销仍然需要关注。对于规模很大的命名空间，Envoy占用的内存会比较可观，甚至大于被代理的微服务本身。使用Istio提供的Sidecar CRD，可以对代理进行细粒度配置，很大程度上降低内存消耗。</p>
<p>在服务网格内部，每个请求都要经由一个客户端代理、一个服务端代理。在Istio 1.5版本的默认配置下的基准测试中，客户端代理的P99延迟大约为2.8ms，服务端代理的P99延迟大约为2.7ms，也就是一次微服务调用会可能会引入5.5ms的延迟。如果调用链比较复杂，引入的总延迟可能达到数十毫秒。大部分场景下，这样的延迟不是问题，但对于一些本身响应时间很短的HTTP服务，影响就难以忽略了。</p>
<p>Istio数据平面引入的延迟，主要有三个来源：</p>
<ol>
<li>代理本身的逻辑：L7代理逻辑较为复杂，执行这些逻辑需要CPU时间。如果扩展了自定义的过滤器，这些过滤器的性能很关键</li>
<li>流量重定向：Istio使用iptables重定向拦截Pod的全部流量给Enovy处理，这种重定向需要多次遍历TCP/IP栈，单次拦截可能引入0.3ms左右的延迟。基于Socket感知的BPF程序，例如Cilium CNI，可以消除这一延迟，但是对内核版本有要求</li>
<li>网络传输：不管是否引入代理，网络传输导致的延迟都存在。但是代理给我们带来了降低延迟的契机。QUIC可以代替TCP作为HTTP的底层协议，它具有更好的拥塞控制、多路复用、前向纠错、链接迁移特性，在网格的边缘这些特性具有显著的优势。Envoy已经逐步加入对QUIC的支持，当实现了QUIC Termination后，就能透明的将业务应用流量的底层传输协议更换为QUIC，提升性能</li>
</ol>
<div class="blog_h2"><span class="graybg">可扩展性</span></div>
<p>Envoy基于C++开发，扩展它需要编写自定义的过滤器。2019年12月进入W3C推荐标准的WebAssembly（Wasm）改变了这一状况。Wasm是一套可移植的字节码格式，你可以将任何主流语言编写的代码编译为Wasm字节码。Envoy现在已经能够支持Wasm Filter，这意味着扩展Envoy，和团队现有的系统进行集成，不会再遇到编程语言上的障碍，也不需要重新部署新版本的Enovy二进制文件。</p>
<p>尽管<a href="https://github.com/envoyproxy/envoy-wasm/">尚未合并到主干</a>，Istio 1.5版本附带的Envoy，已经包含了Alpha版本的Wasm Filter支持。</p>
<p>Istio的开发语言是Go，经典的扩展方式是Mixer插件，如今这种方式已经被废弃。 Mixer的两大功能，Check和Report，将分别由<a href="https://github.com/proxy-wasm/spec">Proxy-Wasm</a> plugins和Telemetry V2代替。Proxy-Wasm是一套ABI规范，规定了Envoy这样的L4/L7代理软件如何和它们的Wasm扩展进行交互。Istio 1.6将提供Proxy-Wasm plugins的统一配置API。 </p>
<div class="blog_h2"><span class="graybg">可观察性</span></div>
<p>服务网格的可观察性有三个方面的内容：日志收集、分布式追踪、指标收集。</p>
<p>日志收集仍然是基于EFK/ELK这样的集中化日志解决方案。Envoy代理可以提供完善的访问日志，收集这些日志后，可以在ElasticSearch中检索和分析。</p>
<p>指标收集，粒度可以细致到微服务级别，这个从设计上来说是刻意的，因为服务网格中的服务，就是指微服务。遗憾的是，很多业务应用并没有按照微服务的理念进行设计，单个服务提供了太多的职责。这样的服务出现了问题，在网格拓扑图上只能进行模糊的定位。另一方面，Envoy支持的协议仍然很少，HTTP、WebSocket、gRPC被支持的很好，Redis、MySQL、ZooKeeper、Dubbo、Kafka目前获得了一定的支持，其它的协议目前Enovy都不能理解，只能看作是原始的L4流量。无法理解协议，也就不能收集有价值的指标，并展现在网格拓扑图上。</p>
<p>应用层协议过于繁多，每种协议还可能有多个版本，要支持这么多协议的确是个苦差。但是在自研服务网格的产品时，我们只需要关注团队经常用到的协议，针对它们进行解析就足够了。</p>
<p>对于分布式追踪，Istio能够自动添加必要的请求头，以便在微服务之间传递Trace ID、Span ID、Parent ID，并且，Istio会将这些信息上报给分布式追踪系统。 但是，Istio能做的也仅仅是这些了，对于单个微服务来说，还得依靠开发人员把入站请求中的Trace ID传递给出站请求。</p>
<p>通用的、零入侵的自动传递调用链上下文的解决方案，在技术上是不可能实现的。因为各种编程语言具有不同的线程模型、运行时架构，无法单纯的从网络流量中分析出足够的信息。某些语言，可以几乎没有入侵的自动传递调用链上下文，例如Java，可以通过Java Instrumentation来穷举式的拦截各种流行的客户端库，自动传递调用链信息。另外一些语言，则只能编写代码传递。 </p>
<div class="blog_h1"><span class="graybg">未来之路</span></div>
<p>经过上文的分析，我们了解到服务网格技术仍然存在一定的不足。在标准化方面，云厂商巨头们竞争激烈，这为我们带来了技术选型的风险。由于功能类似的遗留技术的存在，导致服务网格技术推广起来遇到阻力。此外，在性能、可扩展性、可观察性等非功能因素方面，现有主流服务网格开源项目存在不尽如人意的地方。</p>
<p>不过，我们应当看到社区的繁荣发展，开源项目的不断进步，以及服务网格具有的，不可替代的技术优势。</p>
<p>以Istio为例，它的性能自发布以来已经有了长足的进步，代理的P99延迟从数十ms降为10ms，进一步降低到5ms级别，已经能满足绝大部分场景的需求。如果需要进一步降低延迟，可以考虑BPF和QUIC等技术。</p>
<p>零入侵是服务网格不可替代的技术优势，这种优势在大型企业中更加明显。大型企业会有很多开发团队，使用不同的技术栈，Spring Cloud这种入侵式、绑定到JVM语言的解决方案显然是不可取的。微服务甚至是无服务是可预见的趋势，相对笨重的JVM并不十分适合这种应用场景，将企业的技术栈锁死在JVM甚至是Spring Cloud不是明智的选择。</p>
<p>服务网格领域仍然有大量的事情等待人们去做，比如各种中间件协议的解析、提升边缘节点性能的QUIC Termination、智能化的灰度发布平台，等等。自研服务网格产品，对内提升服务治理水平，对外输出社区影响力，现在就是很好的契机。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/the-present-and-future-of-servicemesh">服务网格的现状和未来</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/the-present-and-future-of-servicemesh/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
	</channel>
</rss>
