<?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; Cloud</title>
	<atom:link href="https://blog.gmem.cc/category/work/cloud/feed" rel="self" type="application/rss+xml" />
	<link>https://blog.gmem.cc</link>
	<description></description>
	<lastBuildDate>Tue, 21 Apr 2026 10:40:56 +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>DevPod 远程开发环境搭建笔记</title>
		<link>https://blog.gmem.cc/devpod</link>
		<comments>https://blog.gmem.cc/devpod#comments</comments>
		<pubDate>Fri, 10 Apr 2026 07:22:22 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[Cloud]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=42107</guid>
		<description><![CDATA[<p>DevPod 是一个开源的开发环境管理工具，支持在 Docker、K8s、SSH 主机及多种云平台上创建可复现的开发环境。本文记录在 K8s 集群上使用 DevPod 搭建远程开发环境的完整实践，涵盖持久卷策略、自定义镜像、文件同步、IDE 集成以及 GPU 接入中遇到的典型问题与解决方案。 DevPod 简介 DevPod 由 Loft Labs 开发，核心理念是将开发环境的定义与基础设施解耦。开发者通过 [crayon-69e7a0e66dd2a934091126-i/] 描述环境需求（基础镜像、工具链、端口），DevPod 负责在指定的 Provider <a class="read-more" href="https://blog.gmem.cc/devpod">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/devpod">DevPod 远程开发环境搭建笔记</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>DevPod 是一个开源的开发环境管理工具，支持在 Docker、K8s、SSH 主机及多种云平台上创建可复现的开发环境。本文记录在 K8s 集群上使用 DevPod 搭建远程开发环境的完整实践，涵盖持久卷策略、自定义镜像、文件同步、IDE 集成以及 GPU 接入中遇到的典型问题与解决方案。</p>
<div class="blog_h1"><span class="graybg">DevPod 简介</span></div>
<p>DevPod 由 Loft Labs 开发，核心理念是将开发环境的定义与基础设施解耦。开发者通过 <pre class="crayon-plain-tag">devcontainer.json</pre> 描述环境需求（基础镜像、工具链、端口），DevPod 负责在指定的 <span style="background-color: #c0c0c0;">Provider</span> 上创建并管理对应的 Workspace。</p>
<p>三个核心概念：</p>
<ul>
<li>Provider：基础设施后端。内置支持 Docker、K8s、SSH，以及 AWS、GCP、Azure 等云平台。</li>
<li>Workspace：一个独立的开发环境实例，对应 Provider 上的一个容器或虚拟机。</li>
<li>devcontainer.json：遵循 Dev Container 规范的配置文件，定义镜像、生命周期钩子、端口转发等。</li>
</ul>
<p>与 GitHub Codespaces 和 Gitpod 相比，DevPod 的关键差异在于它是<span style="background-color: #c0c0c0;">客户端工具</span>——不依赖 SaaS 平台，可以对接任何你已有的基础设施。在自建 K8s 集群的场景下，这意味着完全掌控网络、存储和安全策略。</p>
<div class="blog_h1"><span class="graybg">K8s Provider 架构</span></div>
<p>选择 K8s 作为 Provider 时，DevPod 在目标集群中创建 Pod 来承载开发环境。整个配置由三个文件协同工作：</p>
<ol>
<li><pre class="crayon-plain-tag">devcontainer.json</pre>：声明基础镜像、工作目录、端口转发、生命周期命令。</li>
<li><pre class="crayon-plain-tag">pod-manifest.yaml</pre>：K8s Pod 模板，定义安全上下文、资源限制、卷挂载等 K8s 特有配置。</li>
<li>编排脚本（如 <pre class="crayon-plain-tag">devpod.sh</pre>）：封装 <pre class="crayon-plain-tag">devpod up</pre>、文件同步、环境初始化等流程，是胶水层。</li>
</ol>
<div class="blog_h2"><span class="graybg">Workspace 生命周期</span></div>
<p>典型的操作流程：</p>
<pre class="crayon-plain-tag"># 创建并启动 Workspace（在 K8s 中创建 Pod）
devpod up . --ide none --provider K8s

# 同步本地源码到远端
rsync -az --exclude='node_modules' ./project/ remote:/workspace/project/

# SSH 进入开发环境
devpod ssh my-workspace

# 停止（Pod 被删除，PVC 保留）
devpod stop my-workspace

# 彻底删除（Pod + PVC 全部清理）
devpod delete my-workspace</pre>
<p>关键行为：<pre class="crayon-plain-tag">devpod stop</pre> 删除 Pod 但保留 PVC（Persistent Volume Claim），下次 <pre class="crayon-plain-tag">devpod up</pre> 会重建 Pod 并挂回同一 PVC。这意味着工作区数据在 Pod 重建之间是持久的。</p>
<div class="blog_h2"><span class="graybg">多环境管理</span></div>
<p>通过编排脚本的参数区分环境，典型做法是为每个环境维护独立的 Pod Manifest：</p>
<pre class="crayon-plain-tag"># 编排脚本示例：按环境选择 Manifest 和磁盘大小
case "$ENV" in
  prod) MANIFEST="pod-manifest.yaml";      DISK="300Gi" ;;
  dev)  MANIFEST="pod-manifest-dev.yaml";   DISK="50Gi"  ;;
  test) MANIFEST="pod-manifest-test.yaml";  DISK="500Gi" ;;
esac

devpod up . --ide none \
  --provider K8s \
  --provider-option DISK_SIZE="$DISK" \
  --provider-option POD_MANIFEST="$MANIFEST"</pre>
<p>不同环境可以指定不同的节点选择器、资源配额和安全策略，而共享同一套 <pre class="crayon-plain-tag">devcontainer.json</pre> 和基础镜像。</p>
<div class="blog_h1"><span class="graybg">持久卷挂载策略</span></div>
<p>PVC 的挂载点选择直接决定了哪些数据能在 Pod 重建后存活。</p>
<div class="blog_h2"><span class="graybg">推荐：挂载到 $HOME</span></div>
<p>将 PVC 挂载到容器的 <pre class="crayon-plain-tag">$HOME</pre> 目录（如 <pre class="crayon-plain-tag">/root</pre>）是最省心的方案。好处是：</p>
<ul>
<li>IDE 的 Server 端（VS Code Server、Cursor Server）默认安装在 <pre class="crayon-plain-tag">~/.vscode-server</pre> 或 <pre class="crayon-plain-tag">~/.cursor-server</pre>，自动落在持久存储上。</li>
<li>工具链配置（<pre class="crayon-plain-tag">~/.nvm</pre>、<pre class="crayon-plain-tag">~/.local/bin</pre>）无需额外符号链接。</li>
<li>Shell 配置文件（<pre class="crayon-plain-tag">~/.bashrc</pre>）也是持久的，环境变量只需注入一次。</li>
</ul>
<p>如果挂载到其他路径（如 <pre class="crayon-plain-tag">/workspace</pre>），则需要为上述目录创建符号链接或在每次 Pod 启动时重新安装工具。</p>
<div class="blog_h2"><span class="graybg">目录布局示例</span></div>
<pre class="crayon-plain-tag">/root/                          # PVC 挂载点 = $HOME
├── .cursor-server/             # IDE Server + 扩展（持久）
│   ├── cli/                    # Server 二进制（可重建）
│   └── extensions/             # 已安装扩展（需保留）
├── .nvm/                       # Node.js 版本管理器（持久）
├── .local/bin/                 # kubectl 等工具（持久）
├── .bashrc                     # Shell 配置（持久）
├── Projects/
│   ├── my-project/             # 项目源码
│   └── shared-libs/            # 共享库
└── .config/                    # 各工具配置</pre>
<div class="blog_h1"><span class="graybg">常用命令</span></div>
<p>DevPod 通过命令行工具 <pre class="crayon-plain-tag">devpod</pre> 管理 Workspace 的完整生命周期。以下是日常开发中最常用的命令。</p>
<div class="blog_h2"><span class="graybg">Provider 管理</span></div>
<p>使用前需要先添加并配置 Provider：</p>
<pre class="crayon-plain-tag"># 添加 Kubernetes Provider
devpod provider add kubernetes

# 查看已配置的 Provider
devpod provider list

# 设置 Provider 选项（如命名空间、Pod Manifest 路径）
devpod provider set-options kubernetes \
  --option KUBERNETES_NAMESPACE=devpod \
  --option POD_MANIFEST=pod-manifest.yaml</pre>
<div class="blog_h2"><span class="graybg">Workspace 生命周期</span></div>
<pre class="crayon-plain-tag"># 创建并启动 Workspace
# --ide none 跳过 IDE 自动连接，适合脚本化流程
devpod up . --provider kubernetes --ide none

# 查看所有 Workspace 状态
devpod list

# SSH 进入 Workspace
devpod ssh my-workspace

# 停止 Workspace（删除 Pod，保留 PVC）
devpod stop my-workspace

# 彻底删除（Pod + PVC 全部清理）
devpod delete my-workspace</pre>
<p>关键行为：<pre class="crayon-plain-tag">stop</pre> 只删除 Pod，PVC 上的数据（IDE 扩展、工具链、源码）全部保留。下次 <pre class="crayon-plain-tag">up</pre> 会重建 Pod 并挂回同一 PVC，环境几乎瞬间恢复。</p>
<div class="blog_h2"><span class="graybg">常用 Provider 选项</span></div>
<p>Kubernetes Provider 支持通过 <pre class="crayon-plain-tag">--provider-option</pre> 传递额外参数：</p>
<pre class="crayon-plain-tag">devpod up . --provider kubernetes --ide none \
  --provider-option DISK_SIZE=100Gi \
  --provider-option POD_MANIFEST=pod-manifest-test.yaml \
  --provider-option KUBERNETES_NAMESPACE=devpod</pre>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 35%;">选项</td>
<td>说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>DISK_SIZE</td>
<td>PVC 容量，如 50Gi、300Gi。</td>
</tr>
<tr>
<td>POD_MANIFEST</td>
<td>自定义 Pod Manifest 文件路径。</td>
</tr>
<tr>
<td>KUBERNETES_NAMESPACE</td>
<td>Pod 创建的目标命名空间。</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">状态检查与调试</span></div>
<pre class="crayon-plain-tag"># 查看 Workspace 详细状态
devpod status my-workspace

# 直接查看底层 Pod 状态（需要 kubectl 访问同一集群）
kubectl get pod -n devpod -l app=devpod

# 查看 Pod 事件（排查启动失败）
kubectl describe pod my-workspace -n devpod</pre>
<div class="blog_h1"><span class="graybg">配置详解</span></div>
<p><pre class="crayon-plain-tag">devcontainer.json</pre> 是 Dev Container 规范的核心配置文件，定义了开发环境的镜像、生命周期钩子、端口转发、IDE 定制等一切参数。DevPod 完整支持该规范。文件通常位于 <pre class="crayon-plain-tag">.devcontainer/devcontainer.json</pre>。</p>
<p>以下是一个面向 Kubernetes 远程开发的完整示例：</p>
<pre class="crayon-plain-tag">{
  "name": "my-workspace",

  // 预装全部工具的自定义镜像，省去 onCreateCommand 等待
  "image": "registry.example.com/dev/ubuntu:22.04-tools",

  // 工具已烘焙进镜像，跳过首次创建命令
  "onCreateCommand": "true",

  // PVC 挂载到 $HOME（/root），IDE 配置和扩展天然持久化
  // workspaceMount 故意留空——DevPod v0.6.x 的 .devpodignore 存在已知 bug，
  // 大型单仓库会被全量上传。改用自定义 rsync 同步源码。
  "workspaceFolder": "/root",

  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.vscode-pylance",
        "ms-python.debugpy",
        "redhat.vscode-yaml",
        "ms-kubernetes-tools.vscode-kubernetes-tools"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "editor.formatOnSave": true,
        "terminal.integrated.defaultProfile.linux": "bash"
      }
    }
  },

  "forwardPorts": [8000, 8080, 5432, 6379],
  "portsAttributes": {
    "8000": { "label": "API Server" },
    "8080": { "label": "Web UI" },
    "5432": { "label": "PostgreSQL", "onAutoForward": "silent" },
    "6379": { "label": "Redis", "onAutoForward": "silent" }
  },
  "otherPortsAttributes": {
    "onAutoForward": "silent"
  }
}</pre>
<div class="blog_h2"><span class="graybg">镜像与构建</span></div>
<p>指定容器基础镜像有两种方式：直接引用镜像或通过 Dockerfile 构建。</p>
<p><pre class="crayon-plain-tag">image</pre> 字段接受任何 OCI 镜像地址（DockerHub、GHCR、私有仓库均可）。对于 Kubernetes 远程开发，推荐<span style="background-color: #c0c0c0;">预构建镜像</span>而非运行时构建——将所有开发工具烘焙进镜像可以将 Pod 启动时间从分钟级缩短到秒级。</p>
<p>如果需要在镜像基础上定制，可以使用 <pre class="crayon-plain-tag">build</pre> 字段：</p>
<pre class="crayon-plain-tag">{
  "build": {
    "dockerfile": "Dockerfile",
    "context": "..",
    "args": {
      "PYTHON_VERSION": "3.11"
    }
  }
}</pre>
<p><pre class="crayon-plain-tag">context</pre> 默认为 <pre class="crayon-plain-tag">"."</pre>（即 <pre class="crayon-plain-tag">devcontainer.json</pre> 所在目录）。设为 <pre class="crayon-plain-tag">".."</pre> 可以在 Dockerfile 中引用项目根目录的文件。</p>
<div class="blog_h2"><span class="graybg">workspaceFolder 与 workspaceMount</span></div>
<p><pre class="crayon-plain-tag">workspaceFolder</pre> 定义 IDE 连接后默认打开的目录。在 Kubernetes 场景下，建议将其设为 PVC 的挂载点（如 <pre class="crayon-plain-tag">/root</pre>），使工作区与持久存储完全对齐。</p>
<p><pre class="crayon-plain-tag">workspaceMount</pre> 控制本地源码如何挂载到容器。在本地 Docker 场景下它很有用，但在 Kubernetes 远程开发中通常<span style="background-color: #c0c0c0;">故意留空</span>。原因是 DevPod v0.6.x 存在一个已知问题（<a href="https://github.com/loft-sh/devpod/issues/1885">#1885</a>）：<pre class="crayon-plain-tag">.devpodignore</pre> 在流式上传本地仓库时被忽略，导致大型工作区（包括 venv、node_modules 等）被全量上传。更好的做法是使用自定义 rsync 脚本精确控制同步内容。</p>
<div class="blog_h2"><span class="graybg">生命周期钩子</span></div>
<p>Dev Container 规范定义了六个生命周期钩子，按以下顺序执行：</p>
<pre class="crayon-plain-tag">initializeCommand     # 在宿主机上执行（每次启动）
  ↓
onCreateCommand       # 容器首次创建后执行（仅一次）
  ↓
updateContentCommand  # 源码更新后执行（至少一次）
  ↓
postCreateCommand     # 分配给用户后执行（可访问用户密钥）
  ↓
postStartCommand      # 每次容器启动后执行
  ↓
postAttachCommand     # 每次 IDE 连接后执行</pre>
<p>每个钩子都接受三种格式：</p>
<ul>
<li>字符串：通过 <pre class="crayon-plain-tag">/bin/sh</pre> 执行。</li>
<li>数组：直接执行，不经过 shell（更安全）。</li>
<li>对象：多个命名命令<span style="background-color: #c0c0c0;">并行执行</span>，适合同时启动多个服务。</li>
</ul>
<pre class="crayon-plain-tag">{
  "postAttachCommand": {
    "api-server": "cd /root/api &amp;&amp; python -m uvicorn main:app --port 8000",
    "worker": "cd /root/worker &amp;&amp; python -m celery -A tasks worker"
  }
}</pre>
<p>实践建议：</p>
<ul>
<li>如果所有工具已烘焙进镜像，将 <pre class="crayon-plain-tag">onCreateCommand</pre> 设为 <pre class="crayon-plain-tag">"true"</pre> 跳过。</li>
<li><pre class="crayon-plain-tag">postStartCommand</pre> 适合放启动时的环境检查或服务预热。</li>
<li><pre class="crayon-plain-tag">waitFor</pre> 字段控制 IDE 在哪个阶段之后才开始连接，默认为 <pre class="crayon-plain-tag">"updateContentCommand"</pre>。</li>
</ul>
<div class="blog_h2"><span class="graybg">IDE 定制</span></div>
<p><pre class="crayon-plain-tag">customizations.vscode</pre> 下可以声明扩展和设置，IDE 连接后自动应用：</p>
<pre class="crayon-plain-tag">"customizations": {
  "vscode": {
    "extensions": [
      "ms-python.python",
      "ms-python.vscode-pylance",
      "ms-python.debugpy",
      "redhat.vscode-yaml",
      "ms-kubernetes-tools.vscode-kubernetes-tools"
    ],
    "settings": {
      "python.defaultInterpreterPath": "/usr/local/bin/python",
      "editor.formatOnSave": true,
      "terminal.integrated.defaultProfile.linux": "bash"
    }
  }
}</pre>
<p><pre class="crayon-plain-tag">extensions</pre> 中声明的扩展会在首次连接时自动安装到远端。结合 PVC 持久化，后续连接无需重复安装。<pre class="crayon-plain-tag">settings</pre> 中的配置优先级高于用户本地设置，确保团队成员使用一致的编辑器行为。</p>
<div class="blog_h2"><span class="graybg">端口转发</span></div>
<p><pre class="crayon-plain-tag">forwardPorts</pre> 声明的端口会在 IDE 连接后自动转发到本地。容器内的服务在这些端口上启动时，本地浏览器可以直接通过 <pre class="crayon-plain-tag">localhost:port</pre> 访问，无需任何手动设置。</p>
<p><pre class="crayon-plain-tag">portsAttributes</pre> 为每个端口配置显示名称和行为：</p>
<pre class="crayon-plain-tag">"forwardPorts": [8000, 8080, 5432, 6379],
"portsAttributes": {
  "8000": { "label": "API Server" },
  "8080": { "label": "Web UI", "onAutoForward": "openBrowser" },
  "5432": { "label": "PostgreSQL", "onAutoForward": "silent" },
  "6379": { "label": "Redis", "onAutoForward": "silent" }
},
"otherPortsAttributes": {
  "onAutoForward": "silent"
}</pre>
<p><pre class="crayon-plain-tag">onAutoForward</pre> 控制端口首次被检测到时的行为：<pre class="crayon-plain-tag">"notify"</pre>（默认，弹通知）、<pre class="crayon-plain-tag">"openBrowser"</pre>（自动打开浏览器）、<pre class="crayon-plain-tag">"silent"</pre>（静默转发，适合数据库等后台服务）、<pre class="crayon-plain-tag">"ignore"</pre>（完全忽略）。<pre class="crayon-plain-tag">otherPortsAttributes</pre> 为未显式配置的端口设置默认行为。</p>
<div class="blog_h2"><span class="graybg">环境变量</span></div>
<p>Dev Container 规范区分两层环境变量：</p>
<ul>
<li><pre class="crayon-plain-tag">containerEnv</pre>：设置在容器本身上，所有进程可见，容器生命周期内不变（修改需重建）。</li>
<li><pre class="crayon-plain-tag">remoteEnv</pre>：仅对 IDE 启动的进程（终端、任务、调试）可见，可以引用 <pre class="crayon-plain-tag">${containerEnv:VAR}</pre> 来扩展已有变量，修改后无需重建容器。</li>
</ul>
<pre class="crayon-plain-tag">{
  "containerEnv": {
    "PYTHONPATH": "/root/libs/common:/root/libs/shared"
  },
  "remoteEnv": {
    "PATH": "${containerEnv:PATH}:/root/.local/bin"
  }
}</pre>
<p>两个字段都支持 <pre class="crayon-plain-tag">${localEnv:VAR}</pre> 语法引用宿主机环境变量，例如 <pre class="crayon-plain-tag">${localEnv:HOME}</pre>。</p>
<div class="blog_h2"><span class="graybg">Features</span></div>
<p>Dev Container Features 是可复用的 Dockerfile 片段，以 OCI 制品形式分发。通过 <pre class="crayon-plain-tag">features</pre> 字段可以在不修改基础镜像的情况下安装额外工具：</p>
<pre class="crayon-plain-tag">{
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2": {},
    "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {
      "version": "latest"
    },
    "ghcr.io/devcontainers/features/node:1": {
      "version": "22"
    }
  }
}</pre>
<p>可用的 Features 列表参见 <a href="https://containers.dev/features">containers.dev/features</a>。对于 Kubernetes 远程开发，推荐将工具烘焙进基础镜像而非依赖 Features，以避免每次创建容器时的安装延迟。Features 更适合本地 Docker 场景下的快速原型搭建。</p>
<div class="blog_h2"><span class="graybg">容器行为控制</span></div>
<p>几个影响容器运行方式的字段：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 30%;">字段</td>
<td style="width: 20%;">默认值</td>
<td>说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>overrideCommand</td>
<td>true</td>
<td>覆盖容器默认命令为无限循环（保持容器存活）。使用自定义镜像时通常保持默认。</td>
</tr>
<tr>
<td>shutdownAction</td>
<td>stopContainer</td>
<td>IDE 关闭时的行为：stopContainer（停止容器）、none（保持运行）。K8s 场景建议 none。</td>
</tr>
<tr>
<td>init</td>
<td>false</td>
<td>使用 tini 作为 init 进程，处理僵尸进程回收。</td>
</tr>
<tr>
<td>privileged</td>
<td>false</td>
<td>特权模式。Docker 场景下通过此字段设置，K8s 场景在 Pod Manifest 中设置。</td>
</tr>
<tr>
<td>containerUser</td>
<td>root 或 Dockerfile USER</td>
<td>容器内所有操作使用的用户。</td>
</tr>
<tr>
<td>remoteUser</td>
<td>同 containerUser</td>
<td>IDE 终端和任务使用的用户，可以与 containerUser 不同。</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">预定义变量</span></div>
<p><pre class="crayon-plain-tag">devcontainer.json</pre> 的字符串值中可以使用以下预定义变量：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 40%;">变量</td>
<td>含义</td>
</tr>
</thead>
<tbody>
<tr>
<td>${localEnv:VAR_NAME}</td>
<td>宿主机环境变量，支持默认值：${localEnv:VAR:default}</td>
</tr>
<tr>
<td>${containerEnv:VAR_NAME}</td>
<td>容器环境变量（仅在 remoteEnv 中可用）</td>
</tr>
<tr>
<td>${localWorkspaceFolder}</td>
<td>宿主机上打开的工作区路径</td>
</tr>
<tr>
<td>${containerWorkspaceFolder}</td>
<td>容器内的工作区路径</td>
</tr>
<tr>
<td>${devcontainerId}</td>
<td>容器的唯一标识符，重建后保持稳定</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">镜像定制</span></div>
<p>Dev Container 的 <pre class="crayon-plain-tag">image</pre> 字段虽然可以填写任意公共镜像，但在 Kubernetes 远程开发场景下，<span style="background-color: #c0c0c0;">应当构建专用的基础镜像，将所有开发工具、语言运行时和系统库固化到镜像层中</span>。这样做的好处是：</p>
<ul>
<li>Pod 启动即可用，无需等待 <pre class="crayon-plain-tag">onCreateCommand</pre> 安装依赖。</li>
<li>环境一致性有保障——团队成员共享同一镜像，不会因安装顺序或网络问题导致环境差异。</li>
<li>Pod 重建后工具链自动恢复，不依赖外部包管理器的可用性。</li>
</ul>
<div class="blog_h2"><span class="graybg">Dockerfile 分层原则</span></div>
<p>合理的分层可以提高构建缓存命中率：变更频率低的工具放在底层，变更频率高的放在上层。每个 <pre class="crayon-plain-tag">RUN</pre> 指令末尾执行 <pre class="crayon-plain-tag">apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/*</pre> 减小层体积，安装时使用 <pre class="crayon-plain-tag">--no-install-recommends</pre> 避免拉入不必要的依赖。</p>
<p>以下示例构建了一个包含 Python 3.11、常用系统工具和 NVIDIA CUDA 运行时的开发镜像：</p>
<pre class="crayon-plain-tag">FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive

# Layer 1: 系统工具 + Python 3.11 + 所有 PPA（在切换默认 Python 之前添加）
RUN apt-get update &amp;&amp; \
    apt-get install -y --no-install-recommends \
      software-properties-common gnupg2 wget curl ca-certificates &amp;&amp; \
    add-apt-repository -y ppa:deadsnakes/ppa &amp;&amp; \
    add-apt-repository -y ppa:graphics-drivers/ppa &amp;&amp; \
    wget -qO /tmp/cuda-keyring.deb \
      https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2204/x86_64/cuda-keyring_1.1-1_all.deb &amp;&amp; \
    dpkg -i /tmp/cuda-keyring.deb &amp;&amp; rm /tmp/cuda-keyring.deb &amp;&amp; \
    apt-get update &amp;&amp; \
    apt-get install -y --no-install-recommends \
      python3.11 python3.11-venv python3.11-dev python3-pip \
      git make vim jq postgresql-client \
      openssh-server procps iproute2 iputils-ping \
      rsync htop telnet &amp;&amp; \
    update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 &amp;&amp; \
    update-alternatives --install /usr/bin/python  python  /usr/bin/python3.11 1 &amp;&amp; \
    apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/*

# Layer 2: NVIDIA 驱动工具（nvidia-smi 等）
RUN apt-get update &amp;&amp; \
    apt-get install -y --no-install-recommends nvidia-utils-580-server &amp;&amp; \
    apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/*

# Layer 3: CUDA 运行时库（独立层，便于单独更新）
RUN apt-get update &amp;&amp; \
    apt-get install -y --no-install-recommends cuda-libraries-12-8 &amp;&amp; \
    apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/*</pre>
<p>几个关键设计决策：</p>
<ul>
<li>所有 PPA 和 GPG 密钥在 Layer 1 中、<pre class="crayon-plain-tag">update-alternatives</pre> 之前添加。切换默认 Python 后，<pre class="crayon-plain-tag">add-apt-repository</pre> 会因 <pre class="crayon-plain-tag">apt_pkg</pre> 模块绑定系统原生 Python 而报错 <pre class="crayon-plain-tag">No module named 'apt_pkg'</pre>。</li>
<li>NVIDIA 驱动工具和 CUDA 库分别放在独立层中。这样更新驱动版本时只需重建 Layer 2，不影响 Layer 1 的缓存。</li>
<li>安装 <pre class="crayon-plain-tag">nvidia-utils-xxx-server</pre> 而非 <pre class="crayon-plain-tag">nvidia-utils-xxx</pre>。后者在 Ubuntu 仓库中是过渡空壳包，不包含实际的 <pre class="crayon-plain-tag">nvidia-smi</pre> 二进制。</li>
<li>选择 <pre class="crayon-plain-tag">cuda-libraries-12-8</pre>（运行时库，约 1.2 GB）而非 <pre class="crayon-plain-tag">cuda-toolkit-12-8</pre>（完整工具包，约 10 GB）。开发环境通常只需要运行时库来执行 CUDA 程序，不需要编译器和调试器。</li>
</ul>
<div class="blog_h2"><span class="graybg">镜像与 devcontainer.json 的配合</span></div>
<p>当所有工具都已烘焙进镜像后，<pre class="crayon-plain-tag">devcontainer.json</pre> 可以极大简化：</p>
<pre class="crayon-plain-tag">{
  "image": "registry.example.com/dev/ubuntu:22.04-cuda12.8",
  "onCreateCommand": "true",
  "workspaceFolder": "/root"
}</pre>
<p><pre class="crayon-plain-tag">onCreateCommand</pre> 设为 <pre class="crayon-plain-tag">"true"</pre> 表示跳过——因为没有需要在容器首次启动时安装的东西。Pod 创建后立即可用。</p>
<div class="blog_h1"><span class="graybg">Pod规格定制</span></div>
<p>Pod Manifest 是 K8s Provider 的核心配置，控制着 DevPod 无法通过 <pre class="crayon-plain-tag">devcontainer.json</pre> 表达的 K8s 原生能力。</p>
<div class="blog_h2"><span class="graybg">模板变量</span></div>
<p>DevPod 在创建 Pod 前会对 Manifest 进行模板渲染，支持以下占位符：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 30%;">变量</td>
<td>含义</td>
</tr>
</thead>
<tbody>
<tr>
<td>{{.WorkspaceId}}</td>
<td>Workspace 名称，用作 Pod 名和标签。</td>
</tr>
<tr>
<td>{{.Image}}</td>
<td>devcontainer.json 中声明的镜像地址。</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">安全上下文</span></div>
<p>远程开发容器通常需要比生产容器更宽松的权限。常见配置项及其用途：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 35%;">配置</td>
<td>用途</td>
<td>风险</td>
</tr>
</thead>
<tbody>
<tr>
<td>privileged: true</td>
<td>Docker-in-Docker、设备访问、调试工具</td>
<td>容器可访问宿主内核全部能力</td>
</tr>
<tr>
<td>SYS_ADMIN</td>
<td>mount、cgroup 操作</td>
<td>中等</td>
</tr>
<tr>
<td>SYS_PTRACE</td>
<td>strace、gdb 等调试</td>
<td>低</td>
</tr>
<tr>
<td>NET_ADMIN</td>
<td>网络调试、iptables</td>
<td>中等</td>
</tr>
<tr>
<td>hostNetwork: true</td>
<td>直接使用宿主网络栈，避免 CNI 开销</td>
<td>端口冲突、网络隔离丧失</td>
</tr>
<tr>
<td>hostPID: true</td>
<td>查看宿主进程，便于系统级调试</td>
<td>进程隔离丧失</td>
</tr>
</tbody>
</table>
<p>原则：开发环境按需放宽权限，但仍应限定在专用命名空间和节点上，避免影响生产负载。</p>
<div class="blog_h2"><span class="graybg">资源声明</span></div>
<pre class="crayon-plain-tag">resources:
  requests:
    cpu: "500m"
    memory: "1Gi"
  limits:
    cpu: "16"
    memory: "64Gi"</pre>
<p><pre class="crayon-plain-tag">requests</pre> 设低一些确保 Pod 能调度成功，<pre class="crayon-plain-tag">limits</pre> 设高一些保留突发空间。开发环境通常不会持续占满资源，但编译、测试时可能有短暂峰值。</p>
<div class="blog_h1"><span class="graybg">文件同步策略</span></div>
<div class="blog_h2"><span class="graybg">DevPod 默认同步 vs 自定义 rsync</span></div>
<p>DevPod 内置了基于 <pre class="crayon-plain-tag">devpod up</pre> 的文件同步机制，对于小型项目效果良好。但在大型多仓库工作区（数十个子项目、上百万文件）下，默认同步存在两个问题：</p>
<ul>
<li>首次同步耗时极长，且无法精细控制排除规则。</li>
<li>DevPod 会尝试上传整个 <pre class="crayon-plain-tag">workspaceFolder</pre> 内容，包括 <pre class="crayon-plain-tag">node_modules</pre>、<pre class="crayon-plain-tag">.git</pre> 等不需要的目录。</li>
</ul>
<p>解决方案是使用 <pre class="crayon-plain-tag">--ide none</pre> 启动 DevPod（跳过 IDE 自动同步），然后用自定义的 rsync 命令精确控制同步内容。</p>
<div class="blog_h2"><span class="graybg">存根目录技巧</span></div>
<p>即使使用 <pre class="crayon-plain-tag">--ide none</pre>，DevPod 仍会在 <pre class="crayon-plain-tag">devpod up</pre> 阶段尝试同步 <pre class="crayon-plain-tag">workspaceFolder</pre> 对应的本地目录。如果该目录很大，首次 up 会非常慢。一个技巧是在 up 之前临时创建一个空的存根目录来替代：</p>
<pre class="crayon-plain-tag">STUB_DIR=$(mktemp -d)
devpod up "$STUB_DIR" --ide none --provider K8s ...
rm -rf "$STUB_DIR"
# 然后用 rsync 同步真正的源码</pre>
<div class="blog_h2"><span class="graybg">rsync 实践</span></div>
<pre class="crayon-plain-tag">SSH_CMD="ssh my-workspace.devpod"

rsync -az \
  --exclude='node_modules' \
  --exclude='.git' \
  --exclude='__pycache__' \
  --exclude='venv' \
  --exclude='.venv' \
  --exclude='dist' \
  --exclude='.next' \
  --exclude='.temp' \
  --exclude='.logs' \
  --exclude='.vscode/sessions.json' \
  --copy-unsafe-links \
  ./my-project/ my-workspace.devpod:/root/Projects/my-project/</pre>
<p>关键参数说明：</p>
<ul>
<li><pre class="crayon-plain-tag">-az</pre>：归档模式 + 压缩传输。不要加 <pre class="crayon-plain-tag">--progress</pre>，大量小文件时进度输出会拖慢 SSH 管道，甚至导致 Broken pipe。</li>
<li><pre class="crayon-plain-tag">--copy-unsafe-links</pre>：将指向同步目录之外的符号链接（Symbolic Link）解引用为实际文件。在多仓库工作区中，项目间的符号链接（如共享 Skills 目录）在远端无法解析，此选项可以自动将其替换为文件副本。</li>
<li><pre class="crayon-plain-tag">--exclude</pre>：排除所有不需要同步的目录。<pre class="crayon-plain-tag">.vscode/sessions.json</pre> 会频繁变更且与远端状态冲突，应排除。</li>
</ul>
<div class="blog_h1"><span class="graybg">IDE 远程连接</span></div>
<p>VS Code 和 Cursor 的远程开发功能通过在容器内安装一个 Server 端（Remote Extension Host）来工作。IDE 通过 SSH 隧道与 Server 通信。</p>
<div class="blog_h2"><span class="graybg">Server 安装机制</span></div>
<p>IDE 的 Server 端与客户端版本严格绑定（通过 commit hash 匹配）。安装流程通常是：</p>
<ol>
<li>从本地客户端获取当前版本的 commit hash。</li>
<li>下载对应版本的 Server 二进制包。</li>
<li>通过 SSH 传输并解压到远端的 <pre class="crayon-plain-tag">~/.cursor-server/cli/servers/Stable-{commit}/</pre>。</li>
</ol>
<p>编排脚本应实现幂等的安装检查：</p>
<pre class="crayon-plain-tag">COMMIT=$(get_ide_commit_hash)
SERVER_BIN="$HOME/.cursor-server/cli/servers/Stable-$COMMIT/server/bin/code-server"

if $SSH_CMD "test -x $SERVER_BIN"; then
  echo "Server already installed"
else
  # 下载并安装 Server
  install_ide_server "$COMMIT"
fi</pre>
<div class="blog_h2"><span class="graybg">扩展持久化</span></div>
<p>IDE 扩展安装在 <pre class="crayon-plain-tag">~/.cursor-server/extensions/</pre>（或 <pre class="crayon-plain-tag">~/.vscode-server/extensions/</pre>）。当 PVC 挂载到 <pre class="crayon-plain-tag">$HOME</pre> 时，扩展天然持久。</p>
<p>一个常见的陷阱是在重新安装 Server 时误删整个 <pre class="crayon-plain-tag">~/.cursor-server</pre> 目录，导致扩展全部丢失。正确做法是<span style="background-color: #c0c0c0;">只清理 Server 二进制目录</span>：</p>
<pre class="crayon-plain-tag"># 错误：会删除扩展
rm -rf ~/.cursor-server

# 正确：只删除 Server 二进制，保留扩展
rm -rf ~/.cursor-server/cli</pre>
<div class="blog_h2"><span class="graybg">扩展批量同步</span></div>
<p>首次设置远端环境时，可以将本地已安装的扩展批量同步到远端，避免逐个从 Marketplace 下载：</p>
<pre class="crayon-plain-tag">rsync -az \
  ~/.cursor-server/extensions/ \
  my-workspace.devpod:~/.cursor-server/extensions/</pre>
<p>同步后需要检查扩展中是否有断裂的符号链接。某些扩展包含指向本地 Node.js 路径的符号链接，在远端无法解析。修复方式是用实际文件替换：</p>
<pre class="crayon-plain-tag"># 在远端查找断裂的符号链接
find ~/.cursor-server/extensions/ -type l ! -exec test -e {} \; -print

# 对每个断裂链接，用目标文件的副本替换
# （需要从本地获取原始文件）</pre>
<div class="blog_h2"><span class="graybg">首次连接缓慢</span></div>
<p>首次通过 IDE 连接远端 Workspace 时，通常需要 30 秒到数分钟。这是因为 IDE 需要：</p>
<ul>
<li>建立 SSH 隧道（DevPod 的 SSH 代理有一定开销）。</li>
<li>下载并安装 Server 端（如果尚未安装）。</li>
<li>初始化所有已安装的扩展。</li>
</ul>
<p>后续连接会快很多，因为 Server 和扩展都已在 PVC 上就绪。</p>
<div class="blog_h1"><span class="graybg">K8s 容器中的 GPU 接入</span></div>
<p>在 K8s 中使用 GPU 需要多个组件协同工作：节点上的驱动、设备插件（Device Plugin）、容器运行时钩子。任何一层配置不当都会导致容器内看不到 GPU 设备。</p>
<div class="blog_h2"><span class="graybg">NVIDIA 设备插件的工作原理</span></div>
<p>NVIDIA 提供的 <span style="background-color: #c0c0c0;">Device Plugin</span> 以 DaemonSet 形式运行在每个 GPU 节点上，向 K8s 注册 <pre class="crayon-plain-tag">nvidia.com/gpu</pre> 扩展资源。Pod 通过在 <pre class="crayon-plain-tag">resources.limits</pre> 中声明 GPU 数量来请求分配：</p>
<pre class="crayon-plain-tag">resources:
  limits:
    nvidia.com/gpu: "4"
  requests:
    nvidia.com/gpu: "4"</pre>
<p>调度器根据 <pre class="crayon-plain-tag">requests</pre> 选择有足够 GPU 的节点，设备插件负责将具体的 GPU 设备（<pre class="crayon-plain-tag">/dev/nvidia0</pre> 等）注入到容器中。</p>
<div class="blog_h2"><span class="graybg">runtimeClassName: nvidia</span></div>
<p>仅声明 GPU 资源不够。K8s 还需要知道使用哪个<span style="background-color: #c0c0c0;">容器运行时</span>来处理 GPU 设备的挂载。这通过 Pod 的 <pre class="crayon-plain-tag">runtimeClassName</pre> 字段指定：</p>
<pre class="crayon-plain-tag">spec:
  runtimeClassName: nvidia
  containers:
    - name: devpod
      # ...</pre>
<p>如果不指定 <pre class="crayon-plain-tag">runtimeClassName</pre>，即使 Pod 获得了 GPU 资源配额，容器运行时也不会调用 NVIDIA 的 prestart hook，导致 <pre class="crayon-plain-tag">/dev/nvidia*</pre> 设备节点不会出现在容器内。这是最常见的 GPU 接入失败原因之一。</p>
<div class="blog_h2"><span class="graybg">AppArmor 拦截</span></div>
<p>一个容易忽略的事实是：<pre class="crayon-plain-tag">privileged: true</pre> 并不等同于 AppArmor <pre class="crayon-plain-tag">unconfined</pre>。在启用了 AppArmor 的节点上，即使容器以特权模式运行，默认的 AppArmor profile（如 <pre class="crayon-plain-tag">cri-containerd.apparmor.d</pre>）仍然可能阻止容器访问 GPU 设备节点。</p>
<p>解决方式是在 Pod 的 <pre class="crayon-plain-tag">metadata.annotations</pre> 中显式声明 AppArmor 为 <pre class="crayon-plain-tag">unconfined</pre>：</p>
<pre class="crayon-plain-tag">metadata:
  annotations:
    container.apparmor.security.beta.K8s.io/devpod: unconfined</pre>
<p>其中 <pre class="crayon-plain-tag">devpod</pre> 是容器名称。该 annotation 需要与容器名精确匹配。</p>
<div class="blog_h2"><span class="graybg">NVIDIA_VISIBLE_DEVICES 陷阱</span></div>
<p>直觉上可能会在 Pod Manifest 中设置环境变量 <pre class="crayon-plain-tag">NVIDIA_VISIBLE_DEVICES=all</pre> 来暴露所有 GPU。然而，当与 <pre class="crayon-plain-tag">runtimeClassName: nvidia</pre> 同时使用时，<span style="background-color: #c0c0c0;">手动设置此变量会干扰设备插件的自动注入逻辑</span>。</p>
<p>NVIDIA Container Runtime 的行为是：</p>
<ul>
<li>如果 <pre class="crayon-plain-tag">NVIDIA_VISIBLE_DEVICES</pre> 由设备插件注入，运行时会根据该值精确挂载对应设备。</li>
<li>如果用户在 Manifest 中手动设置了 <pre class="crayon-plain-tag">NVIDIA_VISIBLE_DEVICES=all</pre>，该值会覆盖设备插件的注入，导致运行时在设备映射阶段产生冲突。</li>
</ul>
<p>正确做法是<span style="background-color: #c0c0c0;">不要手动设置 <pre class="crayon-plain-tag">NVIDIA_VISIBLE_DEVICES</pre></span>，让设备插件自动管理。可以保留 <pre class="crayon-plain-tag">NVIDIA_DRIVER_CAPABILITIES=all</pre> 来开放全部驱动能力（compute、utility、graphics 等）。</p>
<div class="blog_h2"><span class="graybg">容器内的 nvidia-smi</span></div>
<p><pre class="crayon-plain-tag">nvidia-smi</pre> 是验证 GPU 可用性的首选工具。在容器中安装它有一个陷阱：某些 Linux 发行版的官方仓库中，名为 <pre class="crayon-plain-tag">nvidia-utils-xxx</pre> 的包是<span style="background-color: #c0c0c0;">过渡空壳包</span>（transitional dummy package），安装后不包含实际的 <pre class="crayon-plain-tag">nvidia-smi</pre> 二进制文件。</p>
<p>以 Ubuntu 22.04 为例，正确的做法是：</p>
<ol>
<li>添加 <pre class="crayon-plain-tag">ppa:graphics-drivers/ppa</pre>。</li>
<li>安装 <pre class="crayon-plain-tag">nvidia-utils-xxx-server</pre>（注意 <pre class="crayon-plain-tag">-server</pre> 后缀），这个包包含实际的命令行工具。</li>
</ol>
<p>如果不便修改镜像，临时方案是通过 <pre class="crayon-plain-tag">hostPath</pre> 挂载宿主机的驱动库和工具到容器内：</p>
<pre class="crayon-plain-tag">volumeMounts:
  - name: host-root
    mountPath: /host
    readOnly: true
volumes:
  - name: host-root
    hostPath:
      path: /</pre>
<p>然后在容器启动后将 <pre class="crayon-plain-tag">/host/usr/lib/x86_64-linux-gnu</pre> 加入 <pre class="crayon-plain-tag">LD_LIBRARY_PATH</pre>，直接调用 <pre class="crayon-plain-tag">/host/usr/bin/nvidia-smi</pre>。这是临时手段，长期方案应将驱动工具烘焙进镜像。</p>
<div class="blog_h2"><span class="graybg">NVML Unknown Error 排查路径</span></div>
<p>当 <pre class="crayon-plain-tag">nvidia-smi</pre> 报出 <pre class="crayon-plain-tag">Failed to initialize NVML: Unknown Error</pre> 时，按以下顺序排查：</p>
<ol>
<li>AppArmor：检查 Pod annotation 是否设置为 <pre class="crayon-plain-tag">unconfined</pre>。用 <pre class="crayon-plain-tag">cat /proc/1/attr/current</pre> 确认容器实际 profile。</li>
<li>设备节点：检查 <pre class="crayon-plain-tag">ls /dev/nvidia*</pre> 是否存在。如果不存在，问题在运行时或设备插件。</li>
<li>运行时类：确认 Pod spec 中是否设置了 <pre class="crayon-plain-tag">runtimeClassName: nvidia</pre>，以及集群中是否存在对应的 RuntimeClass 资源。</li>
<li>环境变量：检查 <pre class="crayon-plain-tag">NVIDIA_VISIBLE_DEVICES</pre> 是否被手动覆盖。</li>
<li>驱动版本：确认容器内的 NVIDIA 用户态库版本与宿主机内核驱动版本兼容。</li>
</ol>
<div class="blog_h1"><span class="graybg">常见故障排查</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 30%;">现象</td>
<td style="width: 30%;">原因</td>
<td>解决</td>
</tr>
</thead>
<tbody>
<tr>
<td>Pod 进入 Dead / Failed 状态</td>
<td>OOM、节点问题或配置错误</td>
<td>devpod stop → 修复 Manifest → devpod up。PVC 数据不丢。</td>
</tr>
<tr>
<td>SSH exit code 255</td>
<td>Pod 未就绪或 SSH 隧道中断</td>
<td>检查 Pod 状态，等待 Running 后重试。若 Server 安装中断，手动重执行安装脚本。</td>
</tr>
<tr>
<td>rsync 报 Broken pipe</td>
<td>大量文件的进度输出压垮 SSH 管道</td>
<td>使用 rsync -az，不加 --progress 或 --info=progress2。</td>
</tr>
<tr>
<td>add-apt-repository 报 No module named 'apt_pkg'</td>
<td>默认 Python 被切换，apt_pkg 绑定旧版本</td>
<td>在 update-alternatives 之前完成所有 PPA 添加。</td>
</tr>
<tr>
<td>IDE 扩展在 Pod 重建后丢失</td>
<td>Server 重装脚本误删了 extensions 目录</td>
<td>只清理 cli/ 子目录，保留 extensions/。</td>
</tr>
<tr>
<td>nvidia-smi: command not found</td>
<td>安装了空壳过渡包</td>
<td>从 ppa:graphics-drivers/ppa 安装 nvidia-utils-xxx-server。</td>
</tr>
<tr>
<td>NVML Unknown Error</td>
<td>AppArmor / 运行时类 / 设备注入 / 环境变量</td>
<td>按 AppArmor → 设备节点 → runtimeClassName → 环境变量的顺序逐层排查。</td>
</tr>
<tr>
<td>/dev/nvidia* 不存在</td>
<td>缺少 runtimeClassName: nvidia 或设备插件未运行</td>
<td>确认 RuntimeClass 资源存在且 DaemonSet 正常运行。</td>
</tr>
</tbody>
</table>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/devpod">DevPod 远程开发环境搭建笔记</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/devpod/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<item>
		<title>Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager</title>
		<link>https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager</link>
		<comments>https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager#comments</comments>
		<pubDate>Mon, 14 Oct 2024 06:45:45 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[Cloud]]></category>
		<category><![CDATA[K8S]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=40243</guid>
		<description><![CDATA[<p>In this blog post, I will walk through my journey investigating and resolving an issue where my certificate request from ZeroSSL, using <a class="read-more" href="https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager">Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager</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>In this blog post, I will walk through my journey investigating and resolving an issue where my certificate request from ZeroSSL, using Cert-Manager, remained in a "not ready" state for over two days. I'll cover the tools involved, provide background information, and show how I eventually identified and fixed the problem.</p>
<div class="blog_h1"><span class="graybg">Background on ACME</span></div>
<div class="blog_h2"><span class="graybg">ACME</span></div>
<p>ACME (Automatic Certificate Management Environment) is a protocol developed by the Internet Security Research Group (ISRG) to automate the process of obtaining and managing SSL/TLS certificates from Certificate Authorities (CAs). It simplifies the traditionally manual steps involved in certificate issuance by using an automated process. ACME is widely used by services like Let’s Encrypt and ZeroSSL to secure websites with HTTPS.</p>
<p>The ACME protocol automates interactions between a client (such as Cert-Manager or Certbot) and a CA, allowing the client to request, renew, and manage certificates without human intervention. ACME operates through a series of challenges that prove ownership of the domain for which a certificate is requested. Once the ownership is verified, the CA can issue the certificate.</p>
<div class="blog_h3"><span class="graybg">HTTP-01 Challenge</span></div>
<p>In this challenge, the client proves ownership of a domain by hosting a specific file at a designated path (e.g., http://gmem.cc/.well-known/acme-challenge/). The CA attempts to retrieve this file, and if successful, the challenge is validated. This challenge is commonly used for publicly accessible web servers.</p>
<div class="blog_h3"><span class="graybg">DNS-01 Challenge</span></div>
<p>In this challenge, the client proves domain ownership by creating a special DNS TXT record for the domain. The CA checks the DNS record to confirm ownership. DNS-01 is typically used for wildcard certificates or when the server is not publicly accessible because it doesn’t rely on serving files over HTTP.</p>
<div class="blog_h3"><span class="graybg">TLS-ALPN-01 Challenge</span></div>
<p>This challenge requires the client to prove control of a domain by configuring a TLS server with a special certificate during the ACME validation process. The CA then connects to the server via TLS and checks the certificate. This challenge is less common and usually used in specialized environments.</p>
<div class="blog_h1"><span class="graybg">Background on Cert-Manager</span></div>
<p>Cert-Manager is an open-source Kubernetes add-on that automates the management, issuance, and renewal of certificates within Kubernetes clusters. It integrates with various Certificate Authorities (CAs) and protocols, including ACME (used by providers like Let’s Encrypt and ZeroSSL). Cert-Manager is widely used to ensure that certificates remain valid and secure without manual intervention.</p>
<div class="blog_h2"><span class="graybg">Cert-Manager Components</span></div>
<p>When deploying Cert-Manager in Kubernetes, several key components work together to handle certificate management.</p>
<div class="blog_h3"><span class="graybg">cert-manager</span></div>
<p>The core component of the Cert-Manager system, responsible for managing the lifecycle of certificates and interacting with Issuers (such as ACME servers like Let’s Encrypt or ZeroSSL). It runs as a Kubernetes controller and is responsible for:</p>
<ol>
<li>Watching Certificate, CertificateRequest, Issuer, ClusterIssuer, Order, and Challenge resources.</li>
<li>Requesting certificates from CAs.</li>
<li>Automatically renewing certificates before expiration.</li>
<li>Handling the interactions with external CAs (via ACME, Vault, Venafi, etc.).</li>
</ol>
<p>This component performs the actual management of certificates, from creation to renewal, ensuring that the requested certificates are stored securely as Kubernetes Secrets.</p>
<div class="blog_h3"><span class="graybg">cert-manager-cainjector</span></div>
<p>The CA Injector is an additional component that works alongside Cert-Manager to inject CA data into other Kubernetes resources. It primarily operates on Kubernetes ValidatingWebhookConfiguration and MutatingWebhookConfiguration resources, injecting certificates into them automatically. This is necessary for:</p>
<ol>
<li>Mutating admission controllers that require TLS certificates for secure communication.</li>
</ol>
<ol>
<li>Ensuring that Kubernetes components relying on CA certificates have up-to-date CA data.</li>
</ol>
<p>The cainjector is critical in environments where certain Kubernetes components (e.g., webhooks) require their certificates to be signed by a trusted CA.</p>
<div class="blog_h3"><span class="graybg">cert-manager-webhook</span></div>
<p>The Webhook component provides an admission controller that validates Cert-Manager resources like Certificate, Issuer, ClusterIssuer, and CertificateRequest upon creation or update. It ensures that the Cert-Manager resources are correctly configured by:</p>
<ol>
<li>Validating resources before they are accepted into the Kubernetes API (syntax and structure).</li>
</ol>
<ol>
<li>Mutating resources to provide defaults (for example, setting default values in a Certificate resource).</li>
</ol>
<ol>
<li>Providing a layer of security and correctness by ensuring invalid configurations are caught early.</li>
</ol>
<p>The webhook helps catch configuration issues early, improving the reliability of certificate management workflows.</p>
<div class="blog_h3"><span class="graybg">cert-manager-webhook-dnspod</span></div>
<p>This webhook, which is maintained by the <a href="https://github.com/imroc/cert-manager-webhook-dnspod">community</a>, specifically handles DNS-01 challenges for domains hosted in Tencent Cloud’s DNSPod. When Cert-Manager requests a certificate using the DNS-01 challenge, it needs to create a DNS TXT record in the domain's DNS zone. cert-manager-webhook-dnspod facilitates this by interacting with the DNSPod API to manage DNS records.</p>
<p>Cert-Manager invokes this webhook when it needs to solve a DNS-01 challenge using DNSPod as the DNS provider. The webhook receives instructions from Cert-Manager, communicates with the DNSPod API to create or delete DNS TXT records, and reports back to Cert-Manager when the challenge is complete.</p>
<p>In this post the webhook was created with the following manifest:</p>
<pre class="crayon-plain-tag">apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cert-manager-webhook-dnspod
  namespace: argocd
spec:
  destination:
    namespace: cert-manager
    server: https://kubernetes.default.svc
  project: default
  source:
    repoURL: https://github.com/imroc/cert-manager-webhook-dnspod
    targetRevision: master
    path: charts/cert-manager-webhook-dnspod
    helm:
      releaseName: cert-manager-webhook-dnspod
      values: |
        groupName: acme.dnspod.tencent.com
        clusterIssuer:
          name: letsencrypt-issuer
          secretId: DNSPOD_SECRETID
          secretKey: DNSPOD_SECRETKEY
          email: gmem@me.com
  syncPolicy:
    syncOptions:
      - CreateNamespace=true
    automated:
      prune: true
      selfHeal: true
 </pre>
<div class="blog_h2"><span class="graybg">Cert-Manager CRDs</span></div>
<p>Cert-Manager relies on several types of custom resources that work together to manage the certificate lifecycle:</p>
<ol style="list-style-type: undefined;">
<li>Issuer/ClusterIssuer:
<ol>
<li>An Issuer or ClusterIssuer is a Cert-Manager resource that defines how certificates should be requested from a Certificate Authority (CA). The difference between them is that an Issuer is scoped to a single namespace, whereas a ClusterIssuer can be used cluster-wide.</li>
<li>Issuers can be configured to use different CA backends, such as ACME, Vault, or self-signed certificates.</li>
</ol>
</li>
<li>Certificate: The Certificate resource defines which certificates should be issued and managed by Cert-Manager. It specifies details such as the domain names, issuer to use, renewal period, and where to store the certificate (usually in a Kubernetes secret).</li>
<li>CertificateRequest: When a Certificate resource is created, Cert-Manager generates a CertificateRequest. This resource represents the actual request to the Issuer for a certificate. Cert-Manager manages these requests and handles the approval and signing process.</li>
<li>Order: When Cert-Manager requests a certificate from an ACME-based Issuer, it creates an Order resource to track the status of the certificate issuance. The Order keeps track of challenges and interactions with the ACME server.</li>
<li>Challenge: A Challenge resource represents the ACME challenge issued by the CA (such as DNS-01 or HTTP-01). The challenge proves domain ownership by requiring the client (Cert-Manager) to respond to a domain validation request from the CA.</li>
</ol>
<div class="blog_h2"><span class="graybg">Cert-Manager  Challenge Workflows</span></div>
<div class="blog_h3"><span class="graybg">HTTP-01 Challenge<br /></span></div>
<p>The HTTP-01 challenge is used when the domain is publicly accessible over HTTP. The CA validates ownership of the domain by requesting a specific file over HTTP. Cert-Manager sets up the challenge response using a Kubernetes Ingress resource.</p>
<ol>
<li>Create an Issuer or ClusterIssuer: The Issuer defines the connection to the CA (such as Let’s Encrypt or ZeroSSL) and specifies that ACME should be used with the HTTP-01 challenge.</li>
<li>Create a Certificate Resource: A Certificate resource is created that specifies the domain names for which a certificate is needed and the Issuer to use.</li>
<li>Cert-Manager Creates a CertificateRequest: Cert-Manager generates a CertificateRequest based on the Certificate resource.</li>
<li>Cert-Manager Creates an Order: Cert-Manager creates an Order resource to track the status of the certificate request with the ACME server.</li>
<li>Cert-Manager Creates an HTTP-01 Challenge: An HTTP-01 challenge is created, and Cert-Manager configures an Ingress resource to serve the challenge response at the path /.well-known/acme-challenge/.</li>
<li>ACME Server Attempts to Validate: The CA attempts to access the challenge file via the HTTP URL (e.g., http://example.com/.well-known/acme-challenge/&lt;token&gt;). If successful, the challenge is validated.</li>
<li>Certificate Issued: Once the challenge is validated, the CA issues the certificate, and Cert-Manager stores it in the specified Kubernetes Secret.</li>
</ol>
<div class="blog_h3"><span class="graybg">DNS-01 Challenge </span></div>
<p>The DNS-01 challenge is used when domain ownership must be validated via DNS records. This method is often preferred for wildcard certificates or domains that are not publicly accessible over HTTP.</p>
<ol>
<li>Create an Issuer or ClusterIssuer: The Issuer specifies that ACME should be used with the DNS-01 challenge and includes configuration for interacting with the DNS provider (e.g., AWS Route53, Cloudflare, or a custom webhook like dnspod).</li>
<li>Create a Certificate Resource: A Certificate resource is created that defines the domains for which the certificate is needed and the Issuer to use.</li>
<li>Cert-Manager Creates a CertificateRequest: Cert-Manager generates a CertificateRequest based on the Certificate resource.</li>
<li>Cert-Manager Creates an Order: Cert-Manager creates an Order resource to track the status of the certificate request with the ACME server.</li>
<li>Cert-Manager Creates a DNS-01 Challenge: A DNS-01 challenge is created, and Cert-Manager interacts with the configured DNS provider to automatically create a special TXT record for the domain (e.g., _acme-challenge.example.com).</li>
<li>ACME Server Attempts to Validate: The CA checks for the presence of the _acme-challenge.example.com TXT record in the domain’s DNS records. If the correct value is found, the challenge is validated.</li>
<li>Certificate Issued: Once the DNS challenge is validated, the CA issues the certificate, and Cert-Manager stores it in the specified Kubernetes Secret.</li>
</ol>
<div class="blog_h1"><span class="graybg">The Problem, Investigation and Fix<br /></span></div>
<div class="blog_h2"><span class="graybg">Problem Statement </span></div>
<p>I created a ClusterIssuer and a Certificate for the wildcard domain *.gmem.cc. However, after waiting for more than two days, the certificate status still showed as not ready, with the following condition in the certificate's status:</p>
<pre class="crayon-plain-tag">status:
  conditions:
    - lastTransitionTime: "2024-10-14T06:02:10Z"
      message: Issuing certificate as Secret does not exist
      observedGeneration: 1
      reason: DoesNotExist
      status: "False"
      type: Ready</pre>
<p>I checked on DNSPod and found a <pre class="crayon-plain-tag">TXT</pre> record <pre class="crayon-plain-tag">_acme-challenge.gmem.cc</pre> with the TTL set to 600 seconds.</p>
<div class="blog_h2"><span class="graybg">Initial Investigation</span></div>
<p>To diagnose the issue, I checked the status of the related Cert-Manager resources: <pre class="crayon-plain-tag">CertificateRequest</pre>, <pre class="crayon-plain-tag">Order</pre>, and <pre class="crayon-plain-tag">Challenge</pre>. These are the key resources that Cert-Manager uses to interact with ACME and handle certificate issuance. Here’s an overview of my findings:</p>
<p>CertificateRequest:</p>
<pre class="crayon-plain-tag">status:
  conditions:
    - lastTransitionTime: "2024-10-14T06:02:14Z"
      message: Certificate request has been approved by cert-manager.io
      reason: cert-manager.io
      status: "True"
      type: Approved
    - lastTransitionTime: "2024-10-14T06:02:14Z"
      message: 'Waiting on certificate issuance from order istio-system/wildcard-ssl-5pgsr-3353861729: "pending"'
      reason: Pending
      status: "False"
      type: Ready</pre>
<p>The certificate request was approved, but the system was waiting for the certificate issuance to complete.</p>
<p>Order: </p>
<pre class="crayon-plain-tag">status:
  state: pending
  authorizations:
    - challenges:
        - token: iQMwrfsFRmJ_MytUY3N4NW6QehtTn0-IEvJWAmYEw_k
          type: dns-01
          url: https://acme.zerossl.com/v2/DV90/chall/DDRBMBd9jnJo_W4EcQfSWQ
      wildcard: true</pre>
<p>The order was still in a "pending" state, and the DNS-01 challenge had not been completed yet.</p>
<p>Challenge:</p>
<pre class="crayon-plain-tag">status:
  presented: true
  processing: true
  reason: 'Waiting for DNS-01 challenge propagation: DNS record for "gmem.cc" not yet propagated'
  state: pending</pre>
<p>The challenge was waiting for DNS propagation, but apparently the <pre class="crayon-plain-tag">TXT</pre> record I mentioned above had been created two days ago.</p>
<div class="blog_h2"><span class="graybg">Global DNS Propagration Check</span></div>
<p>Mutifarious reasons can cause delay on DNS propagation, we can check whether TXT record  _acme-challenge.gmem.cc is synchronized all over the world using <a href="https://www.whatsmydns.net/">whatsmydns.net</a>:</p>
<p style="padding-left: 30px;">https://www.whatsmydns.net/#TXT/_acme-challenge.gmem.cc</p>
<p>In this case, the check result was that the record had been fully propagated.</p>
<div class="blog_h2"><span class="graybg">Cert-Manager Logs</span></div>
<p>I then checked the logs for the Cert-Manager pod to see if any errors were being reported. The relevant and repeating error message from the logs was:</p>
<p style="padding-left: 30px;">E1014 05:52:58.647839 1 sync.go:190] "cert-manager/challenges: propagation check failed" err="DNS record for \"gmem.cc\" not yet propagated"</p>
<p>At this point, it seemed that Cert-Manager was unable to see the propagated DNS records, even though I had confirmed their existence.</p>
<p>After enabling verbose logging (--v5 log level) in Cert-Manager, I finally discovered the root cause. The detailed logs revealed that Cert-Manager was checking the DNS record at an intermediate CNAME:</p>
<p style="padding-left: 30px;">I1014 06:20:33.919849 1 dns.go:116] "cert-manager/challenges/Check: checking DNS propagation"<br />I1014 06:20:33.921190 1 wait.go:90] Updating FQDN: _acme-challenge.gmem.cc. with its CNAME: lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com.<br />I1014 06:25:35.886683 1 wait.go:298] Searching fqdn "lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com." using seed nameservers [10.231.18.121:53]<br />I1014 06:25:35.886696 1 wait.go:329] Returning cached zone record "sg-tencentclb.com." for fqdn "lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com."<br />I1014 06:20:33.987786 1 wait.go:141] Looking up TXT records for "lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com."</p>
<p>The challenge was failing because the DNS record for  <pre class="crayon-plain-tag">*.gmem.cc</pre> was a  CNAME pointing to another domain, lb-db1nok14-foh4te5vrj0dya3c.clb.sg-tencentclb.com, which caused Cert-Manager to search for a wrong TXT record.</p>
<div class="blog_h2"><span class="graybg">The Fix</span></div>
<p>The solution was to<span style="background-color: #c0c0c0;"> remove the wildcard record *.gmem.cc which was CNAMEed to Tencent Cloud Loadbalancer address.</span> After a few minutes, the DNS cache was invalidated and Cert-Manager finally logged something different:</p>
<p style="padding-left: 30px;">I1014 06:25:45.974354 1 wait.go:298] Searching fqdn "_acme-challenge.gmem.cc." using seed nameservers [10.231.18.121:53]<br />I1014 06:25:46.453434 1 wait.go:383] Returning discovered zone record "gmem.cc." for fqdn "_acme-challenge.gmem.cc."<br />I1014 06:25:46.454660 1 wait.go:316] Returning authoritative nameservers [c.dnspod.com., a.dnspod.com., b.dnspod.com.]<br />I1014 06:25:46.462705 1 wait.go:141] Looking up TXT records for "_acme-challenge.gmem.cc."</p>
<p>indicating that the correct TXT record was found. And from the subsequent logs some working detailed of Cert-Manager was revealed:</p>
<p style="padding-left: 30px;">I1014 06:25:47.050500 1 dns.go:128] "cert-manager/challenges/Check: waiting DNS record TTL to allow the DNS01 record to propagate for domain" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" dnsName="gmem.cc" type="DNS-01" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" domain="gmem.cc" ttl=60 fqdn="_acme-challenge.gmem.cc."</p>
<p style="padding-left: 60px;">This line indicated that after Cert-Manager validated the TXT record locally, it would wait for TTL ( 60 here ) seconds, just in case that Zero SSL server hadn't been able to see te record.</p>
<p style="padding-left: 30px;">014 06:26:47.051650 1 sync.go:359] "cert-manager/challenges/acceptChallenge: accepting challenge with ACME server" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" dnsName="gmem.cc" type="DNS-01"<br />I1014 06:26:47.051665 1 logger.go:81] "cert-manager/acme-middleware: Calling Accept"<br />I1014 06:26:49.708221 1 sync.go:376] "cert-manager/challenges/acceptChallenge: waiting for authorization for domain" resource_name="wildcard-ssl-5pgsr-3353861729-3026093647" resource_namespace="istio-system" resource_kind="Challenge" resource_version="v1" dnsName="gmem.cc" type="DNS-01"<br />I1014 06:26:49.708250 1 logger.go:99] "cert-manager/acme-middleware: Calling WaitAuthorization"<br />I1014 06:26:50.194610 1 logs.go:199] "cert-manager/controller: Event(v1.ObjectReference{Kind:\"Challenge\", Namespace:\"istio-system\", Name:\"wildcard-ssl-5pgsr-3353861729-3026093647\", UID:\"a4b2f2d7-6185-4072-92f0-b7a89418cdb1\", APIVersion:\"acme.cert-manager.io/v1\", ResourceVersion:\"463570645\", FieldPath:\"\"}): type: 'Normal' reason: 'DomainVerified' Domain \"gmem.cc\" verified with \"DNS-01\" validation"</p>
<p style="padding-left: 60px;">After the wait, Cert-Manager called Zero SSL server for Accept and WaitAuthorization operation and the server verified that we were the owner of the domain name.</p>
<div class="blog_h1"><span class="graybg">Conclusion</span></div>
<p>In this case, the root cause of the certificate issuance failure was a CNAME record interfering with Cert-Manager's DNS-01 challenge. It had nothing to do with the ACME server but was linked to Cert-Manager's internal implementation.</p>
<p>To aviod similar issues in the future, we need to stop using wildcard domain records.</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager">Investigating and Solving the Issue of Failed Certificate Request with ZeroSSL and Cert-Manager</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/investigating-solving-issue-failed-certificate-request-zerossl-cert-manager/feed</wfw:commentRss>
		<slash:comments>0</slash:comments>
		</item>
		<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>Terraform快速参考</title>
		<link>https://blog.gmem.cc/terraform</link>
		<comments>https://blog.gmem.cc/terraform#comments</comments>
		<pubDate>Wed, 20 Oct 2021 02:15:51 +0000</pubDate>
		<dc:creator><![CDATA[Alex]]></dc:creator>
				<category><![CDATA[IaaS]]></category>

		<guid isPermaLink="false">https://blog.gmem.cc/?p=38551</guid>
		<description><![CDATA[<p>简介 Terraform用于实现基础设施即代码（infrastructure as code）—— 通过代码（配置文件）来描述基础设施的拓扑结构，并确保云上资源和此结构完全对应。Terraform有三个版本，我们主要关注Terraform CLI。 Terraform CLI主要包含以下组件： 命令行前端 Terraform Language（以下简称TL，衍生自HashiCorp配置语言HCL）编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”（Configuration）来表示一整套描述基础设施的Terraform配置文件 针对各种云服务商的驱动（Provider），实现云资源的创建、更新和删除 云上资源不单单包括基础的IaaS资源，还可以是DNS条目、SaaS资源。事实上，通过开发Provider，你可以用Terraform管理任何资源。 Terraform会检查配置文件，并生成执行计划。计划描述了那些资源需要被创建、修改或删除，以及这些资源之间的依赖关系。Terraform会尽可能并行的对资源进行变更。当你更新了配置文件后，Terraform会生成增量的执行计划。 命令行 安装命令行 直接到https://www.terraform.io/downloads.html下载，存放到$PATH下即可。 基本特性 切换工作目录 使用选项 [crayon-69e7a0e67189f042318132-i/] Shell自动完成 使用[crayon-69e7a0e6718a4393640090-i/]安装自动完成脚本，使用[crayon-69e7a0e6718a6630102674-i/]删除自动完成脚本。 <a class="read-more" href="https://blog.gmem.cc/terraform">[...]</a></p>
<p>The post <a rel="nofollow" href="https://blog.gmem.cc/terraform">Terraform快速参考</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" style="padding-left: 30px;"><span class="graybg">简介</span></div>
<p>Terraform用于实现基础设施即代码（infrastructure as code）—— <span style="background-color: #c0c0c0;">通过代码（配置文件）来描述基础设施的拓扑结构</span>，并确保云上资源和此结构完全对应。Terraform有三个版本，我们主要关注Terraform CLI。</p>
<p>Terraform CLI主要包含以下组件：</p>
<ol>
<li>命令行前端</li>
<li>Terraform Language（以下简称TL，衍生自HashiCorp配置语言HCL）编写的、描述基础设施拓扑结构的配置文件。配置文件的组织方式是模块。本文使用术语“配置”（Configuration）来表示一整套描述基础设施的Terraform配置文件</li>
<li>针对各种云服务商的驱动（Provider），实现云资源的创建、更新和删除</li>
</ol>
<p>云上资源不单单包括基础的IaaS资源，还可以是DNS条目、SaaS资源。事实上，<span style="background-color: #c0c0c0;">通过开发Provider，你可以用Terraform管理任何资源</span>。</p>
<p>Terraform会检查配置文件，并生成执行计划。<span style="background-color: #c0c0c0;">计划描述了那些资源需要被创建、修改或删除，以及这些资源之间的依赖关系</span>。Terraform会<span style="background-color: #c0c0c0;">尽可能并行</span>的对资源进行变更。当你更新了配置文件后，Terraform会生成增量的执行计划。</p>
<div class="blog_h1"><span class="graybg">命令行</span></div>
<div class="blog_h2"><span class="graybg">安装命令行</span></div>
<p>直接到<a href="https://www.terraform.io/downloads.html">https://www.terraform.io/downloads.html</a>下载，存放到$PATH下即可。</p>
<div class="blog_h2"><span class="graybg">基本特性</span></div>
<div class="blog_h3"><span class="graybg">切换工作目录</span></div>
<p>使用选项 <pre class="crayon-plain-tag">-chdir=DIR</pre></p>
<div class="blog_h3"><span class="graybg">Shell自动完成</span></div>
<p>使用<pre class="crayon-plain-tag">terraform -install-autocomplete</pre>安装自动完成脚本，使用<pre class="crayon-plain-tag">terraform -uninstall-autocomplete</pre>删除自动完成脚本。</p>
<div class="blog_h2"><span class="graybg">资源地址</span></div>
<p>很多子命令接受资源地址参数，下面是一些例子：</p>
<pre class="crayon-plain-tag"># 资源类型.资源名
aws_instance.foo
# 资源类型.资源列表名[索引]
aws_instance.bar[1]
# 子模块foo的子模块bar中的
module.foo.module.bar.aws_instance.baz</pre>
<div class="blog_h2"><span class="graybg">配置文件</span></div>
<p>配置文件的路径可以通过环境变量<pre class="crayon-plain-tag">TF_CLI_CONFIG_FILE</pre>设置。非Windows系统中，<pre class="crayon-plain-tag">$HOME/.terraformrc</pre>为默认配置文件路径。配置文件语法类似于TF文件：</p>
<pre class="crayon-plain-tag"># provider缓存目录
plugin_cache_dir   = "$HOME/.terraform.d/plugin-cache"
# 
disable_checkpoint = true

# 存放凭证信息，包括模块仓库、支持远程操作的系统的凭证
credentials "app.terraform.io" {
  token = "xxxxxx.atlasv1.zzzzzzzzzzzzz"
}

# 改变默认安装逻辑
provider_installation {
  # 为example.com提供本地文件系统镜像，这样安装example.com/*/*的provider时就不会去网络上请求
  # 默认路径是：
  # ~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target}
  # 例如：
  # ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1
  filesystem_mirror {
    path    = "/usr/share/terraform/providers"
    include = ["example.com/*/*"]
  }
  direct {
    exclude = ["example.com/*/*"]
  }
  
  # Terraform会在terraform init的时候，校验Provider的版本和checksum。Provider从Registry或者本地
  # 目录下载Provider。当我们开发Provider的时候，常常需要方便的测试临时Provider版本，这种Provider还
  # 没有关联版本号，也没有在Registry中注册Chencksum
  # 为了简化开发，可以配置dev_overrides，它能覆盖所有配置的安装方法
  dev_overrides {
    "hashicorp.com/edu/hashicups-pf" = "$(go env GOBIN)"
  }
}</pre>
<div class="blog_h2"><span class="graybg">init</span></div>
<p>配置工作目录，为使用其它命令做好准备。</p>
<p>Terraform命令需要在一个编写了Terraform配置文件的目录（配置根目录）下执行，它会在此目录下存储设置、缓存插件/模块，以及（默认使用Local后端时）存储状态数据。此目录必须进行初始化。</p>
<p>初始化后，会生成以下额外目录/文件：</p>
<p style="padding-left: 30px;">.terraform目录，用于缓存provider和模块<br />如果使用Local后端，保存状态的terraform.tfstate文件。如果使用多工作区，则是terraform.tfstate.d目录。</p>
<p>对配置的某些变更，需要<span style="background-color: #c0c0c0;">重新运行初始化，包括provider需求的变更、模块源/版本约束的变更、后端配置的变更</span>。需要重新初始化时，其它命令可能会无法执行并提示你进行初始化。</p>
<p>命令<pre class="crayon-plain-tag">terraform get</pre>可以仅仅下载依赖的模块，而不执行其它init子任务。</p>
<p>运行 <pre class="crayon-plain-tag">terraform init -upgrade</pre>会强制拉取最新的、匹配约束的版本并更新依赖锁文件。</p>
<div class="blog_h2"><span class="graybg">validate</span></div>
<p>校验配置是否合法。</p>
<div class="blog_h2"><span class="graybg">plan</span></div>
<p>显示执行计划，即当前配置将请求（结合state）哪些变更。Terraform的核心功能时创建、修改、删除基础设施对象，使基础设施的状态和当前配置匹配。当我们说运行Terraform时，主要是指plan/apply/destroy这几个命令。</p>
<p>terraform plan命令评估当前配置，确定其声明的所有资源的期望状态。然后比较此期望状态和真实基础设施的当前状态。它<span style="background-color: #c0c0c0;">使用state来确定哪些真实基础设施对象和声明资源的对应关系，并且使用provider的API查询每个资源的当前状态</span>。当确定到达期望状态需要执行哪些变更后，Terraform将其打印到控制台，它并不会执行任何实际的变更操作。</p>
<div class="blog_h3"><span class="graybg">保存计划</span></div>
<p>terraform plan命令得到的计划可以保存起来，并被后续的terraform apply使用：</p>
<pre class="crayon-plain-tag">terraform plan -out=FILE </pre>
<div class="blog_h3"><span class="graybg">计划模式</span></div>
<p>plan命令支持两种备选的工作模式：</p>
<ol>
<li>销毁模式：创建一个计划，其目标是销毁所有当前存在于配置中的远程对象，留下一个空白的state。对应选项<pre class="crayon-plain-tag">-destroy</pre></li>
<li>仅刷新模式：创建一个计划，其目标仅仅是<span style="background-color: #c0c0c0;">更新state和根模块的输出值</span>，以便<span style="background-color: #c0c0c0;">和从Terraform之外对基础设施对象的变更匹配</span>。对应选项<pre class="crayon-plain-tag">-refresh-only</pre></li>
</ol>
<div class="blog_h3"><span class="graybg">指定输入变量</span></div>
<p>使用选项<pre class="crayon-plain-tag">-var 'NAME=VALUE'</pre>可以指定输入变量，该选项可以使用多次。</p>
<p>使用选项<pre class="crayon-plain-tag">-var-file=FILENAME</pre>可以从文件读取输入变量，某些文件会自动读取，参考<a href="#input-vars">输入变量</a>一节。</p>
<div class="blog_h3"><span class="graybg">并发度</span></div>
<p>选项<pre class="crayon-plain-tag">-parallelism=n</pre>限制操作最大并行度，默认10。</p>
<div class="blog_h3"><span class="graybg">其它选项</span></div>
<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>-refresh=false</td>
<td>
<p>默认情况下，Terraform在检查配置变更之前，会将state和远程基础设施进行同步。此选项禁用此行为</p>
<p>该选项可能会提升性能，因为减少了远程API请求数量。但是可能会无法识别某些Terraform外部对基础设施资源的变更</p>
</td>
</tr>
<tr>
<td>-replace=ADDRESS</td>
<td>
<p>提示Terraform去计划替换掉具有指定ADDRESS的单个资源。对于0.15.2+可用，老版本可以使用terraform taint命令代替</p>
<p>ADDRESS就是针对某个资源实例的引用表达式，例如<pre class="crayon-plain-tag">aws_instance.example[0]</pre></p>
</td>
</tr>
<tr>
<td>-target=ADDRESS</td>
<td>提示Terraform仅仅针对指定ADDRESS的资源（以及它依赖的资源）指定执行计划</td>
</tr>
<tr>
<td>-input=false</td>
<td>禁止Terraform交互式的提示用户提供根模块的输入变量，对于批处理方式运行Terraform很重要</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">apply</span></div>
<p>应用执行计划，创建、更新设施对象。</p>
<p>apply会做plan的任何事情，并在其基础上，直接执行变更操作。默认情况下，apply即席的执行一次plan，你也可以直接使用已保存的plan</p>
<p>命令格式： <pre class="crayon-plain-tag">terraform apply [options] [plan file]</pre></p>
<div class="blog_h3"><span class="graybg">自动确认</span></div>
<p>选项<pre class="crayon-plain-tag">-auto-approve</pre>可以自动确认并执行所需操作，不需要人工确认。</p>
<div class="blog_h3"><span class="graybg">使用已有计划</span></div>
<p>如果指定plan file参数，则读取先前保存的计划并执行。</p>
<div class="blog_h3"><span class="graybg">计划模式</span></div>
<p>支持plan命令中关于计划模式的选项。</p>
<div class="blog_h3"><span class="graybg">其它选项</span></div>
<p>-input=false、-parallelism=n等选项含义和plan命令相同。特有选项：</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>-lock-timeout=DURATION</td>
<td>对状态加锁的最大时间</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">destroy</span></div>
<p>删除先前创建的基础设施对象。</p>
<p>当前配置（+工作区）管理的所有资源都会被删除，destroy会使用状态数据确定哪些资源需要删除。</p>
<div class="blog_h2"><span class="graybg">console</span></div>
<p>在交互式命令中估算Terraform表达式。</p>
<div class="blog_h2"><span class="graybg">fmt</span></div>
<p>格式化配置文件</p>
<div class="blog_h2"><span class="graybg">force-unlock</span></div>
<p>强制解除当前工作区的状态锁。当其它terraform进程锁定状态后，没有正常解锁时使用。如果其它进程仍然在运作，可能导致状态不一致。</p>
<div class="blog_h2"><span class="graybg">get</span></div>
<p>安装或升级远程Terraform模块。格式：<pre class="crayon-plain-tag">terraform get [options] PATH</pre>。</p>
<p>选项：</p>
<p style="padding-left: 30px;">-update 检查已经下载的模块的新版本，如果存在匹配约束的新版本则更新<br />-no-color 禁用彩色输出</p>
<div class="blog_h2"><span class="graybg">graph</span></div>
<p>生成操作中包含步骤的图形化表示。</p>
<div class="blog_h2"><span class="graybg">import</span></div>
<p>导入现有的基础设施对象，让其关联到配置中的资源定义。</p>
<div class="blog_h2"><span class="graybg">login</span></div>
<p>获取并保存远程服务（例如模块私服）的登录凭证。</p>
<div class="blog_h2"><span class="graybg">logout</span></div>
<p>删除远程服务（例如模块私服）的登录凭证。</p>
<div class="blog_h2"><span class="graybg">output</span></div>
<p>显示根模块的输出值。格式：<pre class="crayon-plain-tag">terraform output [options] [NAME]</pre></p>
<div class="blog_h2"><span class="graybg">providers</span></div>
<p>显示此模块依赖的providers</p>
<div class="blog_h2"><span class="graybg">refresh</span></div>
<p>更新状态，使其和远程基础设施匹配。</p>
<div class="blog_h2"><span class="graybg">show</span></div>
<p>显示当前状态或保存的执行计划。</p>
<div class="blog_h2"><span class="graybg">taint</span></div>
<p>将资源实例标记为“非功能完备（fully functional）”的。</p>
<p>所谓<span style="background-color: #c0c0c0;">非功能完备，通常意味着资源创建过程出现问题，存在部分失败</span>。此外taint子命令也可以强制将资源标记为非功能完备。</p>
<p>因为上述两种途径，进入tainted状态的资源，<span style="background-color: #c0c0c0;">不会立即影响基础设施对象。但是在下一次的plan中，会销毁并重新创建对应基础设施对象</span>。</p>
<p>命令格式：<pre class="crayon-plain-tag">terraform [global options] taint [options] &lt;address&gt;</pre></p>
<div class="blog_h2"><span class="graybg">untaint</span></div>
<p>解除资源的tainted状态。</p>
<div class="blog_h2"><span class="graybg">workspace</span></div>
<p>管理和切换工作区。</p>
<div class="blog_h1"><span class="graybg">TL语言</span></div>
<div class="blog_h2"><span class="graybg">块</span></div>
<p>配置文件由若干块（Block）组成，块的语法如下：</p>
<pre class="crayon-plain-tag"># Block header, which identifies a block
&lt;BLOCK TYPE&gt; "&lt;BLOCK LABEL&gt;" "&lt;BLOCK LABEL&gt;" "..." {
  # Block body
  &lt;IDENTIFIER&gt; = &lt;EXPRESSION&gt; # Argument
}</pre>
<p>块是一个容器，它的作用取决于块的类型。块常常用来描述某个资源的配置。</p>
<p>取决于块的类型，<span style="background-color: #c0c0c0;">标签的数量可以是0-N个</span>。对于resource块，标签数量为两个。某些特殊的块，可能支持任意数量的标签。某些内嵌的块，例如<span style="color: #1d1e23;">network_interface，则不支持标签。</span></p>
<p>块体中可以<span style="background-color: #c0c0c0;">包含若干参数（Argument），或者其它内嵌的块</span>。参数用于将一个表达式分配到一个标识符，常常对应某个资源的一条属性。表达式可以是字面值，或者引用其它的值，正是这种引用让Terraform能够识别资源依赖关系。</p>
<p>直接位于配置文件最外层的块，叫做顶级块（Top-level Block），<span style="background-color: #c0c0c0;">Terraform支持有限种类的顶级块</span>。大部分Terraform特性，例如resource，基于顶级块实现。</p>
<p>下面是一个例子：</p>
<pre class="crayon-plain-tag">resource "aws_vpc" "main" {
  cidr_block = var.base_cidr_block
}</pre>
<div class="blog_h2"><span class="graybg">参数</span></div>
<p>在HCL语言中，Argument被称作Attribute。但是在TL中，Attribute术语另有它用。例如<span style="background-color: #c0c0c0;">各种resource都具有一个名为id的属性，它可以被参数表达式饮用，但是不能被赋值（因而不是参数）</span>。</p>
<p>参数其实一个赋值表达式，它将一个值分配给一个名称。</p>
<p>上下文决定了哪些参数可用，其类型是什么。不同的资源（由resource标签识别）支持不同的参数集。</p>
<div class="blog_h2"><span class="graybg">标识符</span></div>
<p>参数名、块类型名、大部分Terraform特有结构的名字（例如resource名，即其第二标签），都是标识符。</p>
<p>标识符由字母、数字、-、_组成，第一个字符不能是数字。</p>
<div class="blog_h2"><span class="graybg">注释</span></div>
<p><pre class="crayon-plain-tag">#</pre>或者<pre class="crayon-plain-tag">//</pre>开头的是单行注释。<pre class="crayon-plain-tag">/**/</pre>作为多行注释的边界。 </p>
<div class="blog_h2"><span class="graybg">数据类型</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 100px; text-align: center;">类型</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td class=" blog_h3">string</td>
<td>
<p style="text-align: left;">Unicode字符序列，基本形式 <pre class="crayon-plain-tag">"hello"</pre></p>
</td>
</tr>
<tr>
<td class=" blog_h3">number</td>
<td>数字，形式<pre class="crayon-plain-tag">6.02</pre></td>
</tr>
<tr>
<td class=" blog_h3">bool</td>
<td><pre class="crayon-plain-tag">true</pre>或<pre class="crayon-plain-tag">false</pre></td>
</tr>
<tr>
<td class=" blog_h3">list/tuple</td>
<td style="text-align: left;">一系列的值，形式<span style="color: #1d1e23;"><pre class="crayon-plain-tag">["us-west-1a", "us-west-1c"]</pre></span></td>
</tr>
<tr>
<td class=" blog_h3">map/object</td>
<td style="text-align: left;">键值对，形式<pre class="crayon-plain-tag">{name = "Mabel", age = 52}</pre></td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">空值</span></div>
<p style="text-align: left;">空值使用<pre class="crayon-plain-tag">null</pre>表示。</p>
<div class="blog_h2"><span class="graybg">字符串和模板</span></div>
<div class="blog_h3"><span class="graybg">转义字符</span></div>
<p>转义字符：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">\n</pre> 换行<br /><pre class="crayon-plain-tag">\r</pre> 回车<br /><pre class="crayon-plain-tag">\t</pre> 制表<br /><pre class="crayon-plain-tag">\"</pre> 引号<br /><pre class="crayon-plain-tag">\\</pre> 反斜杠<br /><pre class="crayon-plain-tag">\uNNNN</pre> Unicode字符<br /><pre class="crayon-plain-tag">\UNNNNNNNN</pre> Unicode字符</p>
<p>注意，在Heredoc中反斜杠不用于转义，可以使用：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">$${</pre> 字符串插值标记${<br /><pre class="crayon-plain-tag">%%{</pre> 模板指令标记%{</p>
<div class="blog_h3"><span class="graybg">Heredoc</span></div>
<p>支持Unix的Heredoc风格的字符串：</p>
<pre class="crayon-plain-tag">block {
  value = &lt;&lt;EOT
hello
world
EOT
}</pre>
<p>Heredoc还支持缩进编写：</p>
<pre class="crayon-plain-tag">block {
  value = &lt;&lt;-EOT
  hello
    world
  EOT
}</pre>
<div class="blog_h3"><span class="graybg">JSON和YAML</span></div>
<p>要将对象转换为JSON或YAML，可以调用函数：</p>
<pre class="crayon-plain-tag">example = jsonencode({
    a = 1
    b = "hello"
})</pre>
<div class="blog_h3"><span class="graybg">字符串模板</span></div>
<p>不管在普通字符串格式，还是Heredoc中，都可以使用字符串模板。模板需要包围在<pre class="crayon-plain-tag">${</pre> 或<pre class="crayon-plain-tag">%{}</pre>中：</p>
<pre class="crayon-plain-tag"># ${ ... } 中包含的是表达式
"Hello, ${var.name}!"


# %{ ... } 则定义了一条模板指令，可以用于实现条件分支或循环

# %{if &lt;BOOL&gt;}/%{else}/%{endif} 条件分支
"Hello, %{ if var.name != "" }${var.name}%{ else }unnamed%{ endif }!"

# %{for &lt;NAME&gt; in &lt;COLLECTION&gt;} / %{endfor} 循环
&lt;&lt;EOT
%{ for ip in aws_instance.example.*.private_ip }
server ${ip}
%{ endfor }
EOT</pre>
<div class="blog_h3"><span class="graybg">空白符去除 </span></div>
<p style="text-align: left;">可以在模板指令开始或结尾添加<pre class="crayon-plain-tag">~</pre>，用于去除前导或后缀的空白符：</p>
<pre class="crayon-plain-tag">&lt;&lt;EOT
%{ for ip in aws_instance.example.*.private_ip ~} # 这后面的换行符被~去除
server ${ip}  # 这后面的换行符被保留
%{ endfor ~}
EOT</pre>
<div class="blog_h2"><span class="graybg">引用</span></div>
<div class="blog_h3"><span class="graybg">资源</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">&lt;RESOURCE TYPE&gt;.&lt;NAME&gt;</pre>引用指定类型、名称的资源。 其值可能是：</p>
<ol>
<li>如果资源没有count/for_each参数，那么值是一个object，可以访问资源的属性</li>
<li>如果资源使用count参数，那么值是list</li>
<li>如果资源使用for_each参数，那么值是map</li>
</ol>
<div class="blog_h3"><span class="graybg">输入变量</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">var.&lt;NAME&gt;</pre>引用指定名称的输入变量。如果变量使用type参数限定其类型，对此引用进行赋值时，Terraform会自动进行必要的类型转换。</p>
<div class="blog_h3"><span class="graybg">本地值</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">local.&lt;NAME&gt;</pre>引用指定名称的本地值。本地值可以引用其它本地值，<span style="background-color: #c0c0c0;">甚至是在同一个local块中，唯一的限制就是不得出现循环依赖</span>。</p>
<div class="blog_h3"><span class="graybg">子模块输出</span></div>
<p><pre class="crayon-plain-tag">module.&lt;MODULE NAME&gt;</pre>引用指定子模块声明的结果输出。如果module块（表示对目标模块的调用）没有count/for_each参数，则其值是一个object，可以直接访问某个输出值：<pre class="crayon-plain-tag">module.&lt;MODULE NAME&gt;.&lt;OUTPUT NAME&gt;</pre></p>
<p style="text-align: left;">如果module块使用了for_each，则引用的值是一个map，其key是for_each中的每个key，其值是子模块输出。</p>
<p style="text-align: left;">如果module块使用了count，则引用的值是一个list，其元素是子模块输出。</p>
<div class="blog_h3"><span class="graybg">数据源</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">data.&lt;DATA TYPE&gt;.&lt;NAME&gt;</pre>引用指定类型的数据资源，取决于数据资源是否使用count/foreach，其值可能是object/list/map。</p>
<div class="blog_h3"><span class="graybg">文件系统信息</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">path.module</pre> 表达式所在模块的路径。</p>
<p style="text-align: left;"><pre class="crayon-plain-tag">path.root</pre>表示当前配置根模块的路径。</p>
<p style="text-align: left;"><pre class="crayon-plain-tag">path.cwd</pre> 表示当前工作模块的路径，默认情况下和path.root一样。但是Terraform CLI可以指定不同的工作目录。</p>
<div class="blog_h3"><span class="graybg">工作区信息</span></div>
<p style="text-align: left;"><pre class="crayon-plain-tag">terraform.workspace</pre>是当前选择的工作区的名称。</p>
<div class="blog_h3"><span class="graybg">块内特殊引用</span></div>
<p style="text-align: left;">在特定的块内部，在特定的上下文下（例如使用count/for_each的情况下），可以引用一些特殊值：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">count.index</pre> 使用count原参数时，表示当前索引<br /><pre class="crayon-plain-tag">each.key</pre> / <pre class="crayon-plain-tag">each.value</pre> 使用for_each原参数时，表示当前迭代的键值<br /><pre class="crayon-plain-tag">self</pre>可以在provisioner和connection块中使用，表示当前上下文资源</p>
<div class="blog_h2"><span class="graybg">操作符</span></div>
<p style="text-align: left;">逻辑操作符：<pre class="crayon-plain-tag">!</pre> <pre class="crayon-plain-tag">&amp;&amp;</pre> <pre class="crayon-plain-tag">||</pre><br />算数操作符：<pre class="crayon-plain-tag">*</pre> <pre class="crayon-plain-tag">/</pre> <pre class="crayon-plain-tag">%</pre> <pre class="crayon-plain-tag">+</pre> <pre class="crayon-plain-tag">-</pre><br />比较操作符：<pre class="crayon-plain-tag">&gt;, &gt;=, &lt;, &lt;= ==, !=</pre></p>
<p style="color: #000000;">函数调用：</p>
<pre class="crayon-plain-tag">&lt;FUNCTION NAME&gt;(&lt;ARGUMENT 1&gt;, &lt;ARGUMENT 2&gt;)

# 支持参数展开
min([55, 2453, 2]...)</pre>
<div class="blog_h2"><span class="graybg">条件表达式</span></div>
<pre class="crayon-plain-tag">condition ? true_val : false_val

var.a != "" ? var.a : "default-a"</pre>
<div class="blog_h2"><span class="graybg">for表达式</span></div>
<p>使用for表达式可以通过转换一种复杂类型输出，生成另一个复杂类型结果。<span style="background-color: #c0c0c0;">输入中的每个元素，可以对应结果的0-1个元素</span>。任何表达式可以用于转换，下面是使用upper函数将列表转换为大写：</p>
<pre class="crayon-plain-tag">[for s in var.list : upper(s)]</pre>
<div class="blog_h3"><span class="graybg">输入类型</span></div>
<p>作为for表达式的输入的类型可以是list / set / tuple / map / object。可以为for声明两个临时符号，前一个表示index或key：</p>
<pre class="crayon-plain-tag">[for k, v in var.map : length(k) + length(v)]</pre>
<div class="blog_h3"><span class="graybg">结果类型 </span></div>
<p>结果的类型取决于包围for表达式的定界符：</p>
<p style="padding-left: 30px;">[] 表示生成的结果是元组<br />{} 表示生成的结果是object，你必须使用<pre class="crayon-plain-tag">=&gt;</pre>符号：<pre class="crayon-plain-tag">{for s in var.list : s =&gt; upper(s)}</pre></p>
<div class="blog_h3"><span class="graybg">输入过滤</span></div>
<p style="text-align: left;">包含一个可选的if子句可以对输入元素进行过滤：<pre class="crayon-plain-tag">[for s in var.list : upper(s) if s != ""]</pre></p>
<p>示例：</p>
<pre class="crayon-plain-tag">variable "users" {
  type = map(object({
    is_admin = boolean
  }))
}

locals {
  admin_users = {
    for name, user in var.users : name =&gt; user
    if user.is_admin
  }
}</pre>
<div class="blog_h3"><span class="graybg">元素顺序</span></div>
<p>for表达式可能将无序类型（map/object/set）转换为有序类型（list/tuple）：</p>
<p style="padding-left: 30px;">对于map/object，键/属性名的字典序，决定结果的顺序<br />对于set，如果值是字符串，则使用其字典序。如果值是其它类型，则结果的顺序可能随着Terraform的版本改变</p>
<div class="blog_h3"><span class="graybg">结果分组</span></div>
<p style="text-align: left;">如果输出是对象，通常要求键的唯一性。Terraform支持分组模式，允许键重复。要激活此模式，在表达式结尾添加<pre class="crayon-plain-tag">...</pre>： </p>
<pre class="crayon-plain-tag">variable "users" {
  type = map(object({
    role = string
  }))
}

locals {
  users_by_role = {
    for name, user in var.users : user.role =&gt; name...
  }
}</pre>
<div class="blog_h2"><span class="graybg">dynamic块</span></div>
<p>对于顶级块，表达式仅能在给参数赋值的时候，用在右侧。某些情况下，我们需要在特定条件下，<span style="background-color: #c0c0c0;">重复、启用/禁用某个子块</span>，表达式就没法实现了，此时可以利用dynamic块。</p>
<p>dynamic块可以遍历一个列表，为每个元素生成一个块：</p>
<pre class="crayon-plain-tag">resource "aws_elastic_beanstalk_environment" "tfenvtest" {
  name                = "tf-test-name"
  application         = "${aws_elastic_beanstalk_application.tftest.name}"
  solution_stack_name = "64bit Amazon Linux 2018.03 v2.11.4 running Go 1.12.6"

  # 对于每个设置，生成一个setting子块。dynamic块的标签，对应生成的块类型
  dynamic "setting" {
    # 迭代对象
    for_each = var.settings
    # 子块的标签，可选
    labels: []
    # 子块的体（参数）
    content {
      namespace = setting.value["namespace"] # 块类型.key对应映射的键或者列表的索引，.value对应当前迭代的值
      name = setting.value["name"]
      value = setting.value["value"]
    }
  }
}</pre>
<p>注意dynamic块仅可能为resource、data、provider、provisioner块生成参数（子块），不能用于生成源参数块，例如lifecycle。</p>
<div class="blog_h3"><span class="graybg">多级嵌套</span></div>
<p>允许dynamic块的多级嵌套。</p>
<div class="blog_h2"><span class="graybg">splat表达式</span></div>
<p>splat表达式提供了更简单语法，在某些情况下代替for表达式：</p>
<pre class="crayon-plain-tag">[for o in var.list : o.id]
# 等价于
var.list[*].id

[for o in var.list : o.interfaces[0].name]
# 等价于
var.list[*].interfaces[0].name</pre>
<p>注意splat表达式仅仅用于列表，不能用于map/object</p>
<p>splat表达式还有个特殊用途，将单值转换为列表：</p>
<pre class="crayon-plain-tag">for_each = var.website[*]</pre>
<div class="blog_h2"><span class="graybg">类型约束</span></div>
<p>module/provider的作者可以利用类型约束来校验用户输入。Terraform的类型系统比较强大，比如他不但可以限制类型为map，还可以规定其中有哪些键，每个键的值是什么类型。</p>
<div class="blog_h3"><span class="graybg">复杂类型</span></div>
<p>复杂类型可以将多个值组合为单个值，复杂类型由类型构造器（<span style="color: #1d1e23;">type constructor）定义，复杂类型分为两类：</span></p>
<ol>
<li>集合类型：组合相似（类型）的值</li>
<li>结构类型：组合可能不同的值</li>
</ol>
<div class="blog_h3"><span class="graybg">集合类型</span></div>
<p>集合类型包括map/list/set等，我们可以限制集合的成员类型：</p>
<pre class="crayon-plain-tag"># 字符串列表
list(string)
# 数字列表
list(number)

# 任意元素列表，等价于list
list(any)</pre>
<div class="blog_h3"><span class="graybg">结构类型</span></div>
<p>结构类型包括object和tuple。</p>
<p>object定义了多个命名的属性，以及属性的类型：</p>
<pre class="crayon-plain-tag">object( { &lt;KEY&gt; = &lt;TYPE&gt;, &lt;KEY&gt; = &lt;TYPE&gt;, ... } )

# 示例
object({ name=string, age=number })

# 下面是符合此类型的实例
{
  name = "John"
  age  = 52
}</pre>
<p>元组则是定义了限定元素个数、每个元素类型的列表： </p>
<pre class="crayon-plain-tag">tuple([string, number, bool])
# 下面是符合此类型的实例
["a", 15, true]</pre>
<div class="blog_h3"><span class="graybg">复杂类型转换 </span></div>
<p>大部分情况下，<span style="background-color: #c0c0c0;">相似的集合类型和结构类型的行为类似</span>。Terraform文档某些时候也不去区分，这是由于以下类型转换行为：</p>
<ol>
<li>可能的情况下，Terraform会自动在相似复杂类型之间进行转换：
<ol>
<li>object和map是相似的，只要map包含object的schema所要求的键集合，即可自动转换。多余的键在转换过程中被丢弃，这意味着map - object - mapl两重转换可能丢失信息</li>
<li>tuple和list是相似的，但是转换仅仅在list元素数量恰好满足tuple的schema时发生</li>
<li>set和tuple/list是相似的：
<ol>
<li>当list/tuple转换为set，重复的值会被丢弃，元素顺序消失</li>
<li>当set转换为list/tuple，元素的顺序是任意的，一个例外是set(string)，将会按照元素字典序生成list/tuple</li>
</ol>
</li>
</ol>
</li>
<li>可能的情况下，Terraform会自动转换复杂类型的元素的类型，如果元素是复杂类型，则递归的处理</li>
</ol>
<p>每当提供的值，和要求的值类型不一致时，自动转换都会发生。</p>
<p>module/provider的作者应该注意不同类型的差别，特别是在限制输入方面能力的不同。</p>
<div class="blog_h3"><span class="graybg">动态类型any</span></div>
<p>特殊关键字any用做尚未决定的类型的占位符，其本身并非一个类型。当解释类型约束的时候，Terraform会尝试寻找单个的实际类型，替换any关键字，并满足约束。</p>
<p>例如，对于list(any)这一类型约束，对于给定的值["a", "b", "c"]其实际类型是tuple([string, string, string])，当将该值赋值给list(any)变量时，Terraform分析过程如下：</p>
<ol>
<li>tuple和list是相似类型，因此应用上问的tuple-list转换规则</li>
<li>tuple的元素类型是string，满足any约束，因此将其替换，结果类型是list(string)</li>
</ol>
<div class="blog_h3"><span class="graybg">可选object属性</span></div>
<p>从0.14开始，支持这一实验特性。可以在object类型约束中，标注某个属性为可选的： </p>
<pre class="crayon-plain-tag">variable "with_optional_attribute" {
  type = object({
    a = string           # 必须属性
    b = optional(string) # 可选属性
  })
}</pre>
<p>默认情况下，对于必须属性来说，如果类型转换时的源（例如map）不具备对应的键，会导致报错。</p>
<div class="blog_h2"><span class="graybg">版本约束</span></div>
<p>版本约束是一个特殊的字符串值，在引用module、使用provider时，或者通过terraform块的required_version时，需要用到版本约束：</p>
<pre class="crayon-plain-tag"># 版本范围区间
version = "&gt;= 1.2.0, &lt; 2.0.0"

# 操作符
=   等价于无操作符，限定特定版本
!=  排除特定版本
&gt; &gt;= &lt; &lt;= 限制版本范围
~&gt;  允许最右侧的版本号片段的变化</pre>
<div class="blog_h1"><span class="graybg">函数</span></div>
<p>所有可用函数：<a href="https://www.terraform.io/docs/language/functions/index.html">https://www.terraform.io/docs/language/functions/index.html</a></p>
<div class="blog_h1"><span class="graybg">资源(resource)</span></div>
<p>资源是TL语言中最重要的元素，由resource块定义。正如其名字所示，资源声明了某种云上基础设施的规格，这些基础设施可以是虚拟机、虚拟网络、DNS记录，等等。</p>
<p>数据源是一种特殊的资源，由data块定义。</p>
<div class="blog_h2"><span class="graybg">行为</span></div>
<p>当你第一次为某个资源编写配置时，它值存在于配置文件中，尚未代表云上的某个真实基础设施对象。通过应用Terraform配置，触发创建/更新/销毁等操作，实现云上环境和配置文件的匹配。</p>
<div class="blog_h3"><span class="graybg">生命周期</span></div>
<p>当一个新的资源被创建后，对应真实基础设施对象的标识符被保存到Terraform的State中。这个标识符作为后续更新/删除的依据。对于State中已经存在关联的标识符的那些Resource块，Terraform会比较真实基础设施对象和Resource参数的区别，并在必要的时候更新对象。</p>
<p>概括起来说，当Terraform配置被应用时：</p>
<ol>
<li>创建存在于配置文件中，但是在State中没有关联真实基础设施对象的资源</li>
<li>销毁存在于State中，但是不存在于配置文件的资源</li>
<li>更新参数发生变化的资源</li>
<li><span style="background-color: #c0c0c0;">删除、重新创建参数发生变化，但是不能原地（in-palce）更新的资源</span>。不能更新通常是因为云API的限制，例如腾讯云VPC的CIDR不支持修改</li>
</ol>
<p>以上4点，适用于所有资源类型。但是需要注意，底层发生的事情，取决于Provider，Terraform只是去调用Provider的相应接口。</p>
<div class="blog_h3"><span class="graybg">访问资源属性</span></div>
<p>在相同模块中，你可以在表达式中访问某个资源的属性，语法<pre class="crayon-plain-tag">&lt;RESOURCE TYPE&gt;.&lt;NAME&gt;.&lt;ATTRIBUTE&gt;</pre></p>
<p>除了配置文件中列出的资源参数之外，资源还提供一些<span style="background-color: #c0c0c0;">只读的属性</span>。属性表示了一些提供云API拉取到的信息。这些信息通畅需要在资源创建后才可获知，例如随机生成的资源ID vpc-d8o3c8vq</p>
<p>很多Provider还<span style="background-color: #c0c0c0;">提供特殊的数据源（data）资源，这种特殊的资源仅仅用来查询信息</span>。</p>
<div class="blog_h3"><span class="graybg">资源依赖关系</span></div>
<p>某些资源必须在另外一些资源之后创建，例如CVM必须在其所属Subnet创建后才能创建，这意味着某些资源存在依赖关系。</p>
<p>Terraform能够自动分析资源依赖关系，其分析依据就是资源的参数的值表达式。<span style="background-color: #c0c0c0;">如果表达式中引用了另一个资源，则当前资源依赖于该资源</span>。</p>
<p>对于无法通过配置文件分析的隐含依赖，需要你手工配置<pre class="crayon-plain-tag">depends_on</pre>元参数。</p>
<p>Terraform会自动并行处理没有依赖关系的资源。</p>
<div class="blog_h3"><span class="graybg">Local-Only资源</span></div>
<p>这类特殊的资源不会对应某个云上基础设施对象，而是仅<span style="background-color: #c0c0c0;">仅存在于Terraform本地State中</span>。Local-Only资源用于一些中间计算过程，包括生成随机ID、创建私钥等。</p>
<p>Local-Only资源的<span style="background-color: #c0c0c0;">行为和普通资源一致</span>，只是其结果数据仅仅存在于State中，删除时也仅仅是从State中移除对应数据。</p>
<div class="blog_h2"><span class="graybg">语法</span></div>
<pre class="crayon-plain-tag">resource "resource_type" "local_name" {
  # arguments...
}</pre>
<p>两个标签分别代表资源的类型和本地名称。</p>
<p>资源类型提示正在描述的是那种云上基础设施，资源类型决定可用的参数集。<span style="background-color: #c0c0c0;">本地名称用于在模块的其它地方饮用该资源，此外没有意义</span>。资源类型+本地名称是资源的唯一标识，必须在模块范围内唯一。</p>
<div class="blog_h2"><span class="graybg">Provider</span></div>
<p>每一种资源都由一个Provider来实现。<span style="background-color: #c0c0c0;">Provider是Terraform的插件，它提供若干资源类型</span>。通常一个云服务商提供提供一个Provider。初始化工作目录时Terraform能够自动从Terraform仓库下载大部分所需的Provider。</p>
<p>模块需要知道，利用哪些Provider才能管理所有的资源。此外Provider还需要经过配置才能工作，例如设置访问云API的凭证。这些配置由根模块负责。</p>
<div class="blog_h2"><span class="graybg">元参数</span></div>
<p>元参数可以用于任何资源类型。</p>
<div class="blog_h3"><span class="graybg">depends_on</span></div>
<p>该元参数用于处理隐含的资源/模块依赖，这些依赖无法通过分析Terraform配置文件得到。从0.13版本开始，该元参数可用于模块。之前的版本仅仅用于资源。</p>
<p>depends_on的值是一个列表，其元素具必须是其它资源的引用，不支持任意表达式。</p>
<p>depends_on应当仅仅用作最后手段，避免滥用。</p>
<p>示例：</p>
<pre class="crayon-plain-tag">resource "aws_iam_role" "example" {
  name = "example"
  assume_role_policy = "..."
}

# 这个策略允许运行在EC2中的实例访问S3 API
resource "aws_iam_role_policy" "example" {
  name   = "example"
  role   = aws_iam_role.example.name
  policy = jsonencode({
    "Statement" = [{
      "Action" = "s3:*",
      "Effect" = "Allow",
    }],
  })
}


resource "aws_iam_instance_profile" "example" {
  # 这是可以自动分析出的依赖
  role = aws_iam_role.example.name
}


resource "aws_instance" "example" {
  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  # 这是可以自动分析出的依赖，包括传递性依赖
  iam_instance_profile = aws_iam_instance_profile.example

  # 如果这个实例中的程序需要访问S3接口，我们需要用元参数显式的声明依赖
  # 从而分配策略
  depends_on = [
    aws_iam_role_policy.example,
  ]
} </pre>
<div class="blog_h3"><span class="graybg">count</span></div>
<p>默认情况下，一个resource块代表单个云上基础设施对象。如果你想用一个resource块生成多个类似的资源，可以用count或for_each参数。</p>
<p>设置了此元参数的上下文中，可以访问名为<pre class="crayon-plain-tag">count</pre>的变量，它具有属性<pre class="crayon-plain-tag">index</pre>，为从0开始计数的资源实例索引。 示例：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "server" {
  # 创建4个类似的实例
  count = 4

  ami           = "ami-a1b2c3d4"
  instance_type = "t2.micro"

  tags = {
    #              实例的索引作为tag的一部分
    Name = "Server ${count.index}"
  }
}</pre>
<p>在当前模块的其它地方，可以用这样的语法访问如上资源实例：</p>
<pre class="crayon-plain-tag">&lt;RESOURCE_TYPE&gt;.&lt;NAME&gt;[&lt;INDEX&gt;]

aws_instance.server[0]</pre>
<div class="blog_h3"><span class="graybg">for_each</span></div>
<p>如果资源的规格几乎完全一致，可以用count，否则，需要使用更加灵活的for_each元参数。</p>
<p><span style="background-color: #c0c0c0;">for_each的值必须是一个映射或set(string)</span>，你可以在上下文中访问<pre class="crayon-plain-tag">each</pre>对象， 它具有<pre class="crayon-plain-tag">key</pre>和<pre class="crayon-plain-tag">value</pre>两个属性，如果for_each的值是<span style="background-color: #c0c0c0;">集合，则key和value相等</span>。示例：</p>
<pre class="crayon-plain-tag">resource "azurerm_resource_group" "rg" {
  for_each = {
    a_group = "eastus"
    another_group = "westus2"
  }
  # 对于每个键值对都会生成azurerm_resource_group资源
  name     = each.key
  location = each.value
}


resource "aws_iam_user" "the-accounts" {
  # 数组转换为集合
  for_each = toset( ["Todd", "James", "Alice", "Dottie"] )
  name     = each.key
}</pre>
<p>调用子模块时使用for_each的示例：</p>
<pre class="crayon-plain-tag"># 父模块 my_buckets.tf
module "bucket" {
  for_each = toset(["assets", "media"])
  # 调用publish_bucket子模块
  source   = "./publish_bucket"
  # 将键赋值给子模块的name变量
  name     = "${each.key}_bucket"
}

# 子模块          文件名
# publish_bucket/bucket-and-cloudfront.tf

# 声明模块的输入参数
variable "name" {} 

resource "aws_s3_bucket" "example" {
  # 访问变量
  bucket = var.name
  # ...
}</pre>
<p>关于for_each的值的元素，有如下限制：</p>
<ol>
<li><span style="background-color: #c0c0c0;">键必须是确定的值</span>，如果应用配置前值无法确定会报错。例如，你不能引用CVM的ID，因为这个ID必须在配置应用之后才可知</li>
<li>如果<span style="background-color: #c0c0c0;">键</span>是函数调用，则此函数<span style="background-color: #c0c0c0;">不能是impure的（非幂等）</span>，impure函数包括uuid/bcrypt/timestamp等</li>
<li><span style="background-color: #c0c0c0;">敏感（Sensitive）值不能用做键值</span>。需要注意，大部分函数，在接受敏感值参数后，返回值仍然是敏感的</li>
</ol>
<p>当使用集合的时候，你必须明确的将值转换为集合。例如通过<pre class="crayon-plain-tag">toset</pre>函数将列表、元组转换为集合。使用<pre class="crayon-plain-tag">flatten</pre>函数可以将多层次的嵌套结构转换为列表。</p>
<p>for_each所在块定义的资源A，可以赋值给另一个资源B的for_each参数。这时，<span style="background-color: #c0c0c0;">B那个for_each的键值对，值是资源的完整（创建或更新过的）实例</span>。这种用法叫<span style="background-color: #c0c0c0;">chaining</span>：</p>
<pre class="crayon-plain-tag">variable "vpcs" {
  # 这里定义了map类型的变量，并且限定了map具有的键
  type = map(object({
    cidr_block = string
  }))
}


# 创建多个VPC资源
resource "aws_vpc" "example" {
  for_each = var.vpcs
  cidr_block = each.value.cidr_block
}
# 上述资源作为下面那个for_each的值

# 创建对应数量的网关资源
resource "aws_internet_gateway" "example" {
  # 为每个VPC创建一个网关
  #          资源作为值
  for_each = aws_vpc.example

  # 映射的值，在这里是完整的VPC对象
  vpc_id = each.value.id
}


# 输出所有VPC ID
output "vpc_ids" {
  value = {
    for k, v in aws_vpc.example : k =&gt; v.id
  }

  # 显式依赖网关资源，确保网关创建后，输出才可用
  depends_on = [aws_internet_gateway.example]
}</pre>
<p>引用for_each创建的资源的实例时，使用如下语法： </p>
<pre class="crayon-plain-tag">&lt;TYPE&gt;.&lt;NAME&gt;[&lt;KEY&gt;]

azurerm_resource_group.rg["a_group"]</pre>
<div class="blog_h3"><span class="graybg">provider</span></div>
<p>这个参数用于指定<span style="background-color: #c0c0c0;">使用的provider配置</span>。可以覆盖Terraform的<span style="background-color: #c0c0c0;">默认行为：将资源类型名的第一段（下划线分隔）作为provider的本地名称。同时使用provider的默认配置</span>。例如资源类型google_compute_instance默认识别为google这个provider。</p>
<p>每个provider可以提供多个配置，<span style="background-color: #c0c0c0;">配置常常用于管理多区域服务</span>中的某个特定Region。每个provider可以有一个默认配置。</p>
<p>该参数的值必须是不被引号包围的<pre class="crayon-plain-tag">&lt;PROVIDER&gt;.&lt;ALIAS&gt;</pre>。使用该参数你可以显式的指定provider及其配置：</p>
<pre class="crayon-plain-tag"># 默认配置
provider "google" {
  region = "us-central1"
}

# 备选配置
provider "google" {
  alias  = "europe"
  region = "europe-west1"
}

resource "google_compute_instance" "example" {
  # 通过使用该参数选择备选配置
  provider = google.europe

  # ...
}</pre>
<p>注意：<span style="background-color: #c0c0c0;">资源总是隐含对它的provider的依赖</span>，这确保创建资源前provider被配置好。 </p>
<div class="blog_h3"><span class="graybg">lifecycle</span></div>
<p>该参数可以对资源的生命周期进行控制。示例：</p>
<pre class="crayon-plain-tag">resource "azurerm_resource_group" "example" {
  # ...
  lifecycle {
    create_before_destroy = true
  }
}</pre>
<p>lifecycle作为一个块，只能出现在resources块内。可用参数包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 25%; text-align: center;">参数</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>create_before_destroy</td>
<td>
<p>默认情况下， 当Terraform无法进行in-place更新时，会删除并重新创建资源</p>
<p>该参数可以修改此行为：先创建新资源，然后删除旧资源</p>
</td>
</tr>
<tr>
<td>prevent_destroy</td>
<td>如果设置为true，并且配置会导致基础设施中某个对象被删除，则报错</td>
</tr>
<tr>
<td>ignore_changes</td>
<td>
<p>默认情况下，Terraform会对比真实基础设施中对象和当前配置文件中的所有字段，任何字段的不一致都会引发更新操作</p>
<p>该参数指定，在Terraform评估是否需要更新时，资源的哪些字段被忽略</p>
<p>如果设置为特殊值<pre class="crayon-plain-tag">all</pre>，则Terraform不会进行任何更新操作</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">timeouts参数</span></div>
<p>某些资源类型提供了特殊的<pre class="crayon-plain-tag">timeouts</pre>内嵌块，用于指定多长时间后认定操作失败：</p>
<pre class="crayon-plain-tag">resource /* ... */ {
  # ...
  timeouts {
    create = "60m"
    update = "30s"
    delete = "2h"
  }
}</pre>
<div class="blog_h2"><span class="graybg">资源特定参数</span></div>
<p>资源的绝大部分参数由资源类型决定。需要翻阅Provider的文档了解哪些参数可用。</p>
<p>对于大部分公共的、托管在Terraform仓库的Provider来说，其文档可以直接在<a href="https://registry.terraform.io/browse/providers">仓库网站</a>上获得。</p>
<div class="blog_h2"><span class="graybg">Provisioner</span></div>
<p>对于一些无法使用Terraform声明式模型来表达的某些行为，可以使用<span style="background-color: #c0c0c0;">Provisioner作为最后（总是不推荐的）手段</span>。</p>
<p>使用Provisioner会引入复杂性和不确定性：</p>
<ol>
<li>Terraform无法将Provisioner执行的动作，作为计划的一部分。因为Provisioner理论上可以做任何事情</li>
<li>Provisioner通常需要使用更多细节信息，例如直接访问服务器的网络、使用Terraform的登录凭证</li>
</ol>
<div class="blog_h3"><span class="graybg">self对象</span></div>
<p>Provisioner块不能用名字访问其所在的上下文资源，你必须使用<pre class="crayon-plain-tag">self</pre>对象。这个对象就指代对应的资源，你可以访问它的参数和属性。</p>
<div class="blog_h3"><span class="graybg">when参数</span></div>
<p>该参数指定何时（create/update/destroy）运行Provisioner，下面是一个仅仅在删除资源时才执行的Provisioner：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  provisioner "local-exec" {
    # 在实际删除前调用
    when    = destroy
    command = "echo 'Destroy-time provisioner'"
  }
}</pre>
<p>默认情况下，Provisioner在资源创建的时候调用，在更新/删除时不会调用。最<span style="background-color: #c0c0c0;">常见的用法是使用Provisioner来初始化系统</span>。如果Provisioner失败，则资源被标记为tainted，并且会在下一次<pre class="crayon-plain-tag">terraform apply</pre>时删除、重新创建。</p>
<div class="blog_h3"><span class="graybg">on_failure参数</span></div>
<p>定制Provisioner失败时的行为：</p>
<ol>
<li><pre class="crayon-plain-tag">continue</pre> 忽略错误</li>
<li><pre class="crayon-plain-tag">fail</pre> 默认行为，导致配置应用立即失败，如果正在创建资源，则taint该资源</li>
</ol>
<div class="blog_h3"><span class="graybg">连接设置</span></div>
<p>大部分Provisioner要求通过SSH或WinRM来访问远程资源。你可以在<pre class="crayon-plain-tag">connection</pre>块中声明如何连接。connection块可以内嵌在以下位置：</p>
<ol>
<li>resource，对资源的所有Provisioner生效</li>
<li>provisioner，仅仅对当前Provisioner生效</li>
</ol>
<p>在connection块中，你也可以使用<pre class="crayon-plain-tag">self</pre>来访问包含它的resource，这一点类似于provisioner</p>
<p>示例：</p>
<pre class="crayon-plain-tag">provisioner "file" {
  # Linux
  connection {
    type     = "ssh"
    user     = "root"
    password = "${var.root_password}"
    host     = "${var.host}"
  }
}

provisioner "file" {
  # Windows
  connection {
    type     = "winrm"
    user     = "Administrator"
    password = "${var.admin_password}"
    host     = "${var.host}"
  }
}</pre>
<p>关于如何通过证书进行身份验证，如何通过堡垒机连接，参考<a href="https://www.terraform.io/docs/language/resources/provisioners/connection.html">官方文档</a>。 </p>
<div class="blog_h3"><span class="graybg">null_resource</span></div>
<p>如果你希望运行一个Provisioner，但是不想在任何真实的资源的上下文下运行，可以使用这种特殊的资源。</p>
<pre class="crayon-plain-tag">resource "aws_instance" "cluster" {
  count = 3

  # ...
}

resource "null_resource" "cluster" {
  # Provisioner的触发时机
  triggers = {
    # cluster中任何实例改变会触发Provisioner的重新执行
    #                                  这种通配符语法会得到一个列表
    cluster_instance_ids = "${join(",", aws_instance.cluster.*.id)}"
  }

  # 仅仅连接到第一个实例
  connection {
    #        该函数访问列表的特定元素
    host = "${element(aws_instance.cluster.*.public_ip, 0)}"
  }

  provisioner "remote-exec" {
    # 执行命令
    inline = [
      "bootstrap-cluster.sh ${join(" ", aws_instance.cluster.*.private_ip)}",
    ]
  }
}</pre>
<div class="blog_h3"><span class="graybg">通用Provisioners</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 100px; text-align: center;">Provisioner</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td>file</td>
<td>
<p>从运行Terraform的机器，复制文件或目录到新创建的资源（通常是虚拟机）。示例：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  # ...

  # 文件到文件
  provisioner "file" {
    source      = "conf/myapp.conf"
    destination = "/etc/myapp.conf"
  }

  # 字符串到文件
  provisioner "file" {
    content     = "ami used: ${self.ami}"
    destination = "/tmp/file.log"
  }

  # 目录到目录，拷贝后生成/etc/configs.d目录
  provisioner "file" {
    source      = "conf/configs.d"
    destination = "/etc"
  }

  # Windows下的路径语法
  provisioner "file" {
    # apps/app1/下的所有文件拷贝到D:/IIS/webapp1下
    source      = "apps/app1/"
    destination = "D:/IIS/webapp1"
  }
}</pre>
<p>关于整个目录的拷贝，需要注意：</p>
<ol>
<li>如果连接type是ssh，则目标目录必须已经存在。你可能需要使用remote-exec预先创建好目录</li>
<li>原路径以/结尾，则拷贝目录下的所有文件，而非目录本身</li>
</ol>
</td>
</tr>
<tr>
<td>local-exec</td>
<td>
<p>在资源创建之后，调用一个本地（运行Terraform的机器）可执行程序</p>
<p>注意：即使是在资源创建之后，但是不保证sshd这样的服务已经可用了。因此不要尝试在local-exec中调用ssh命令登录到资源</p>
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  # ...

  provisioner "local-exec" {
    command = "echo ${self.private_ip} &gt;&gt; private_ips.txt"
    # 可选的工作目录
    working_dir = "/root"
    # 可选的解释器
    interpreter = [ "/bin/bash", "-c" ]
    # 可选的环境变量
    environment = {
      FOO = "bar"
    }
  }
}</pre>
</td>
</tr>
<tr>
<td>remote-exec</td>
<td>
<p>在资源创建之后，登录到新创建的资源，执行命令
<pre class="crayon-plain-tag">resource "aws_instance" "web" {
  provisioner "remote-exec" {
    # 命令列表，逐个执行
    inline = [
      "puppet apply",
      "consul join ${aws_instance.web.private_ip}",
    ]
    # 单个脚本路径
    script = ""
    # 多个脚本路径
    scripts = []
  }
} </pre>
</td>
</tr>
</tbody>
</table>
<div class="blog_h1"><span class="graybg">数据资源(data)</span></div>
<p>数据资源，由data块来描述，让Terraform从数据源读取信息。Data是一种特殊的Resource，也是由Provider来提供。<span style="background-color: #c0c0c0;">Data只支持读取操作</span>，相对的，<span style="background-color: #c0c0c0;">普通的Resource支持增删改查操作，普通资源也叫受管（Manged）资源，一般简称Resource</span>。
<p>数据资源要求Terraform去特定数据源（第1标签）读取数据，并将结果导出为本地名称（第2标签）：</p>
<pre class="crayon-plain-tag"># 读取匹配参数的aws_ami类型的数据源，并存放到example
data "aws_ami" "example" {
  # 这些参数是查询条件(query constraints)，具体哪些可用取决于数据源类型
  most_recent = true

  owners = ["self"]
  tags = {
    Name   = "app-server"
    Tested = "true"
  }
}</pre>
<p>两个标签组合起来是data在模块中的唯一标识。</p>
<div class="blog_h2"><span class="graybg">行为</span></div>
<div class="blog_h3"><span class="graybg">生命周期</span></div>
<p>如果数据资源的查询约束（参数）都是<span style="background-color: #c0c0c0;">常量值，或者是已知的变量值</span>，那么<span style="background-color: #c0c0c0;">数据资源的状态会在Terraform的Refresh阶段更新，这个阶段在创建执行计划之前</span>。</p>
<p>查询约束可能引用了某些值，这些值在应用配置文件之前无法获知（例如资源ID）。<span style="background-color: #c0c0c0;">这种情况下，读取数据源的操作将推迟到Apply之后</span>。在Data读取完成之前，所有对它结果（导出名称）的引用都是不可用的。</p>
<div class="blog_h3"><span class="graybg">Local-Only数据源</span></div>
<p>大部分数据源都对应了某种云上基础设施，需要通过云API远程的读取。</p>
<p>某些特殊数据源仅仅供Terraform自己使用，例如Hashicorp的Provider template，它提供的template_file数据源，用于在本地渲染模板文件。这类数据源叫Local-Only数据源，其行为和一般数据源没有区别。</p>
<div class="blog_h3"><span class="graybg">资源依赖关系</span></div>
<p>数据资源的依赖解析行为，和受管资源一致。</p>
<div class="blog_h2"><span class="graybg">元参数</span></div>
<p>数据资源支持的元参数，和受管资源一致。</p>
<div class="blog_h1"><span class="graybg">变量和输出</span></div>
<p>和传统编程语言对比，模块类似于函数，输入变量类似于函数参数，输出值类似于返回值，本地值则类似于函数内的局部变量。</p>
<div class="blog_h2"><span class="graybg"><a id="input-vars"></a>输入变量</span></div>
<p>输入变量作为<span style="background-color: #c0c0c0;">Terraform模块的参数</span>，从而实现模块的参数化、可跨多个配置复用。</p>
<p>对于定义在<span style="background-color: #c0c0c0;">根模块中的变量，其值可以从Terraform CLI选项传入</span>。对于<span style="background-color: #c0c0c0;">子模块中定义的变量，则必须通过module块传入其值</span>。</p>
<div class="blog_h3"><span class="graybg">声明语法</span></div>
<pre class="crayon-plain-tag"># 声明一个字符串类型的输入变量
#        变量名必须在模块范围内唯一
#        不得用做变量名：ource, version, providers, count, for_each, lifecycle, depends_on, locals
variable "image_id" {
  # 类型
  type = string
  # 描述
  description = ""
  # 校验规则
  validation {
    # 如果为true则校验成功
    condition = bool-expr
    # 校验失败时的消息
    error_message = ""
  }
  # 是否敏感，敏感信息不会现在Terraform UI上输出
  sensitive = false
}

# 声明一个字符串的列表，并给出默认值
variable "availability_zone_names" {
  type    = list(string)
  default = ["us-west-1a"]
}

# 声明一个对象的列表，并给出默认值
variable "docker_ports" {
  type = list(object({
    internal = number
    external = number
    protocol = string
  }))
  default = [
    {
      internal = 8300
      external = 8300
      protocol = "tcp"
    }
  ]
}</pre>
<div class="blog_h3"><span class="graybg">类型</span></div>
<p>支持的简单类型：<pre class="crayon-plain-tag">string</pre>、<pre class="crayon-plain-tag">number</pre>、<pre class="crayon-plain-tag">bool</pre></p>
<p>支持的容器类型：</p>
<ol>
<li>列表：<pre class="crayon-plain-tag">list(&lt;TYPE&gt;)</pre></li>
<li>集合：<pre class="crayon-plain-tag">set(&lt;TYPE&gt;)</pre></li>
<li>映射：<pre class="crayon-plain-tag">map(&lt;TYPE&gt;)</pre></li>
<li>对象：<pre class="crayon-plain-tag">object({&lt;ATTR NAME&gt; = &lt;TYPE&gt;, ... })</pre></li>
<li>元组：<pre class="crayon-plain-tag">tuple([&lt;TYPE&gt;, ...])</pre></li>
</ol>
<p>关键字<pre class="crayon-plain-tag">any</pre>表示，任何类型都允许。</p>
<div class="blog_h3"><span class="graybg">默认值</span></div>
<p>如果同时指定了类型和默认值，则提供的默认值必须可以转换为类型。</p>
<div class="blog_h3"><span class="graybg">校验规则</span></div>
<p>validation是一个块，其中condition是一个bool表达式：</p>
<pre class="crayon-plain-tag">variable "image_id" {
  type        = string
  description = "The id of the machine image (AMI) to use for the server."

  validation {
    #               can函数将错误转换为false
    #                  regex函数在找不到匹配的时候会失败
    condition     = can(regex("^ami-", var.image_id))
    error_message = "The image_id value must be a valid AMI id, starting with \"ami-\"."
  }
}</pre>
<div class="blog_h3"><span class="graybg">引用输入变量</span></div>
<p><span style="background-color: #c0c0c0;">在声明输入变量的模块</span>中，可以使用<pre class="crayon-plain-tag">var.&lt;NAME&gt;</pre>引用输入变量的值：</p>
<pre class="crayon-plain-tag">resource "aws_instance" "example" {
  instance_type = "t2.micro"
  ami           = var.image_id
}</pre>
<div class="blog_h3"><span class="graybg">给根模块变量赋值</span></div>
<p>要给根模块中定义的变量赋值，有以下几种方式：</p>
<ol>
<li>使用<pre class="crayon-plain-tag">-var</pre>命令行选项，可以多次使用，每次赋值一个变量，示例：<br />
<pre class="crayon-plain-tag">terraform apply -var="image_id=ami-abc123"
terraform apply -var='image_id_list=["ami-abc123","ami-def456"]' -var="instance_type=t2.micro"
terraform apply -var='image_id_map={"us-east-1":"ami-abc123","us-east-2":"ami-def456"}' </pre>
</li>
<li>作为环境变量传入，示例：<br />
<pre class="crayon-plain-tag"># 环境变量需要TF_VAR_前缀
export TF_VAR_image_id=ami-abc123</pre>
</li>
<li>使用<pre class="crayon-plain-tag">.tfvars</pre>文件，此文件可以自动载入或者通过命令行选项显式载入，示例：<br />
<pre class="crayon-plain-tag">image_id = "ami-abc123"
availability_zone_names = [
  "us-east-1a",
  "us-west-1c",
]</pre><br />
<pre class="crayon-plain-tag">terraform apply -var-file="testing.tfvars"</pre></p>
<p>注意以下文件可以自动识别并载入：</p>
<ol>
<li>名为<pre class="crayon-plain-tag">terraform.tfvars</pre>或<pre class="crayon-plain-tag">terraform.tfvars.json</pre>的文件</li>
<li>以<pre class="crayon-plain-tag">.auto.tfvars</pre>或<pre class="crayon-plain-tag">.auto.tfvars.json</pre>结尾的文件</li>
</ol>
</li>
</ol>
<div class="blog_h3"><span class="graybg">变量值优先级</span></div>
<p>如果通过多种方式给变量赋值，则优先级高的生效。优先级顺序<span style="background-color: #c0c0c0;">从低到高</span>：</p>
<ol>
<li>环境变量文件</li>
<li>terraform.tfvars</li>
<li>terraform.tfvars.json</li>
<li>*.auto.tfvars和*.auto.tfvars.json文件，多个文件，按字典序，后面的优先级高</li>
<li>-var或-var-file命令行选项，多个选项，后面的优先级高</li>
</ol>
<div class="blog_h2"><span class="graybg">输出值</span></div>
<p>输出值是模块的“返回值”，具有以下用途：</p>
<ol>
<li>子模块使用输出值将它创建的<span style="background-color: #c0c0c0;">资源的属性的子集暴露给父模块</span></li>
<li>根模块可以利用输出值，将一些<span style="background-color: #c0c0c0;">信息在terraform apply之后打印到控制台</span>上</li>
<li>当使用远程状态（remote state）时，根模块的输出可以<span style="background-color: #c0c0c0;">被其它配置通过 terraform_remote_state 数据源捕获到</span></li>
</ol>
<div class="blog_h3"><span class="graybg">声明语法</span></div>
<pre class="crayon-plain-tag"># 输出的名字
output "instance_ip_addr" {
  # 值
  value = aws_instance.server.private_ip
  # 描述
  description = ""
  # 是否敏感
  sensitive = ""
  # 依赖
  depends_on = []
}</pre>
<div class="blog_h3"><span class="graybg">访问子模块输出</span></div>
<p>子模块的输出值，通过这样的表达式访问：</p>
<pre class="crayon-plain-tag">module.&lt;MODULE NAME&gt;.&lt;OUTPUT NAME&gt;

# 访问子模块web_server的输出值instance_ip_addr
module.web_server.instance_ip_addr</pre>
<div class="blog_h3"><span class="graybg">输出值的依赖</span></div>
<p>使用depends_on参数可以明确指定输出值依赖什么：</p>
<pre class="crayon-plain-tag">output "instance_ip_addr" {
  value       = aws_instance.server.private_ip
  depends_on = [
    aws_security_group_rule.local_access,
  ]
}</pre>
<div class="blog_h2"><span class="graybg">本地值</span></div>
<p>在一个模块内部，将表达式分配给一个名称。你可以同时声明多个本地值：</p>
<pre class="crayon-plain-tag">locals {
  service_name = "forum"
  instance_ids = concat(aws_instance.blue.*.id, aws_instance.green.*.id)
  common_tags = {
    Service = local.service_name
    Owner   = local.owner
  }
}</pre>
<p>引用本地值时，使用表达式： <pre class="crayon-plain-tag">local.&lt;NAME&gt;</pre></p>
<div class="blog_h1"><span class="graybg">模块(modules)</span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>一套完整的配置文件（Configuration，简称配置），由1个根目录和若干文件/子目录组成。配置文件的扩展名为<pre class="crayon-plain-tag">.tf</pre>，此外HCL语言还提供了基于JSON的变体，这种配置文件的扩展名为<pre class="crayon-plain-tag">.tf.json</pre>。配置文件的编码方式为UTF-8，使用Unix风格的换行符。</p>
<p>所谓模块（Module）是存放在一个目录中的配置文件的集合。子目录中的文件不属于模块，不会自动包含到配置中。</p>
<p>将块存放在不同配置文件中仅仅是方便人类的阅读，和Terraform的行为无关。Terraform总是会评估模块中的所有文件，并将它们合并为单一的文档来看待。</p>
<div class="blog_h3"><span class="graybg">根模块</span></div>
<p>Terraform命令总是在单个根模块的上下文中运行，根模块的目录通常作为命令的工作目录。此根模块会调用其它模块，这种调用关系会递归的发生，从而形成一个子模块树结构。</p>
<div class="blog_h3"><span class="graybg">子模块</span></div>
<p>一个模块（通常是根模块）可以调用其它模块，从而将这些模块中定义的资源包含到配置中。当一个模块被其它模块调用时，它的角色是子模块。</p>
<p>子模块可以被同一个配置调用多次，不同模块也可以同时调用一个子模块。</p>
<div class="blog_h3"><span class="graybg">发布的模块</span></div>
<p>引用子模块时，除了可以从本地文件系统获取，还可以从公共/私有仓库下载。</p>
<p>Terraform Registry托管了大量公共模块，用于管理各种各样的基础设施，这些模块可以被免费使用。Terraform能够自动下载这些模块的正确版本。Terraform Cloud / Enterprise版本都包含一个私服模块可以托管组织私有的模块。</p>
<div class="blog_h3"><span class="graybg">覆盖文件</span></div>
<p>使用名为<pre class="crayon-plain-tag">override.tf</pre>或<pre class="crayon-plain-tag">override.tf.json</pre>，或者后缀为<pre class="crayon-plain-tag">_override.tf</pre>或<pre class="crayon-plain-tag">_override.tf.json</pre>的文件，可以覆盖既有配置的某一部分。这些文件叫做覆盖文件。</p>
<p>加载配置时，Terraform最初会跳过覆盖文件。加载完成功文件后，会按照字典序处理覆盖文件。对于覆盖文件中的每个顶级块，Terraform会尝试寻找已经存在的，定义在常规文件中的对应块，并将块的内容进行合并。内容合并规则如下：</p>
<ol>
<li>覆盖文件中的顶级块、普通配置文件中的顶级块，对应关系通过块头（块类型+标签）识别，相同块头的块被合并</li>
<li>顶级块中的参数被替换</li>
<li>顶级块中的内嵌块被替换，不会递归的进行合并</li>
<li>多个覆盖文件覆盖了同一个块的定义时，按覆盖文件名的字典序依次合并</li>
</ol>
<p>此外，对于resource / data块，有如下特殊规则：</p>
<ol>
<li>内嵌的lifecycle块不是简单的直接替换。假设覆盖文件仅仅设置了lifecycle的create_before_destroy属性，原始配置中任何ignore_changes参数保持原样</li>
<li>对于内嵌的provisioner块，原始配置中的（不管有几个）provisioner块直接被忽略</li>
<li>原始配置中的内嵌connection块被完全覆盖</li>
<li>元参数（meta-argument）depends_on不能出现在覆盖文件中</li>
</ol>
<p>对于variable（变量）块，有如下特殊规则：</p>
<ol>
<li>如果原始块定义了default参数（默认值）并且覆盖块修改了变量的type，则Terraform尝试将default转换为新的type，如果转换无法自动完成则报错</li>
<li>如果覆盖块修改了default，那么其值必须匹配原始块中的type</li>
</ol>
<p>不建议过多的使用覆盖文件，这会降低配置的可读性。</p>
<p>对于output块，有如下特殊规则：</p>
<ol>
<li>元参数depends_on不能出现在覆盖文件中</li>
</ol>
<p>对于local块，有如下特殊规则：</p>
<ol>
<li>每个local块定义（或修改）了若干具有名字的值，覆盖时使用value-by-value的方式，至于值在何处定义不影响</li>
</ol>
<p>对于terraform块，有如下特殊规则：</p>
<ol>
<li>required_providers的值，按element-by-element的方式进行覆盖。这样，覆盖块可以仅仅调整单个provider的配置，而不影响其他providers</li>
<li>覆盖required_version、required_providers时，被覆盖的元素被整个替换</li>
</ol>
<p>下面是原始文件+覆盖文件的示例：</p>
<pre class="crayon-plain-tag"># example.tf
resource "aws_instance" "web" {
  instance_type = "t2.micro"
  ami           = "ami-408c7f28"
}

# override.tf
resource "aws_instance" "web" {
  ami = "foo"
}</pre>
<div class="blog_h2"><span class="graybg">使用</span></div>
<div class="blog_h3"><span class="graybg">调用子模块</span></div>
<p>所谓调用，就是将特定的值传递给子模块作为输入变量，从而将子模块中的配置包含进来。</p>
<pre class="crayon-plain-tag">module "servers" {
  # 源，必须，指定子模块的位置
  source = "./app-cluster"
  # 版本，如果模块位于仓库中，建议制定
  version = "0.0.5"
  
  # 支持一些元参数
  
  # 大部分其它参数，都是为子模块提供输入变量
  servers = 5
}</pre>
<div class="blog_h2"><span class="graybg">元参数</span></div>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 100px; text-align: center;">元参数</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td class=" blog_h3">count</td>
<td>用于创建多个模块实例，参考资源(resources)的元参数</td>
</tr>
<tr>
<td class=" blog_h3">for_each</td>
<td>用于创建多个模块实例，参考资源(resources)的元参数</td>
</tr>
<tr>
<td class=" blog_h3">providers</td>
<td>
<p>将Provider配置传递给子模块：</p>
<pre class="crayon-plain-tag">provider "aws" {
  alias  = "usw2"
  region = "us-west-2"
}

module "example" {
  source    = "./example"
  providers = {
    # 配置名称由provider块的第1标签（+可选的alias参数）构成
    # 键是子模块中的Provider配置名称
    # 值是父模块中的Provider配置名称
    aws = aws.usw2
  }
}</pre>
<p>如果子模块没有定义任何Provider alias，则该元参数是可选的。不指定该元参数时，子模块会从父模块继承所有默认Provider配置，所谓<span style="background-color: #c0c0c0;">默认配置就是没有alias的provider块所定义的配置</span></p>
</td>
</tr>
<tr>
<td class=" blog_h3">depends_on</td>
<td>
<p>显式指定整个模块对特定目标的依赖，参考资源(resources)的元参数</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg"><a id="module-source"></a>源</span></div>
<p>module块的source参数指定了从何处下载子模块的代码。</p>
<p>terraform init中有一个步骤，专门负责模块的安装。它支持从本地路径、Terraform Registry、GitHub、一般性Git仓库、HTTP URL、S3桶等多种来源下载模块。</p>
<div class="blog_h3"><span class="graybg">本地路径</span></div>
<p>对于紧密相关的模块，例如为了减少代码重复，从单一模块拆分得到的，建议使用本地路径方式存储。</p>
<pre class="crayon-plain-tag">module "consul" {
  #        必须以./或../开头，提示这是一个本地模块
  source = "./consul"
}</pre>
<p>本地路径的源具有一个特殊的地方，它没有“安装”这个步骤。</p>
<div class="blog_h3"><span class="graybg">Terraform Registry</span></div>
<p>对于希望公开分享的模块，可以存放在这个Terraform仓库中。这种仓库的源地址格式为<pre class="crayon-plain-tag">&lt;HOSTNAME&gt;/&lt;NAMESPACE&gt;/&lt;NAME&gt;/&lt;PROVIDER&gt;</pre>。示例： </p>
<pre class="crayon-plain-tag"># 这个模块创建一个Consul服务
module "consul" {
  # 为AWS提供的consule模块，使用Terraform公共仓库时HOSTNAME可以省略
  source = "hashicorp/consul/aws"
  version = "0.1.0"
}</pre>
<p>为了访问私有仓库，你可能需要在CLI配置中添加访问令牌。</p>
<div class="blog_h3"><span class="graybg">GitHub</span></div>
<p>如果source以github.com开头，则Terraform会尝试到GitHub拉取模块源码：</p>
<pre class="crayon-plain-tag">module "consul" {
  # 通过HTTPS
  source = "github.com/hashicorp/example"
}

module "consul" {
  # 通过SSH
  source = "git@github.com:hashicorp/example.git"
}</pre>
<div class="blog_h3"><span class="graybg">Git</span></div>
<p>如果source以<pre class="crayon-plain-tag">git::</pre>开头，则Terraform认为模块托管在一般性的Git服务器中：</p>
<pre class="crayon-plain-tag">module "vpc" {
  source = "git::https://example.com/vpc.git"
}

module "storage" {
  source = "git::ssh://username@example.com/storage.git"
}</pre>
<p>Terraform使用git clone命令下载模块，因此本地机器的任何Git配置都可用，包括凭证信息。</p>
<p>默认情况下，使用Git仓库的HEAD，要选择其它修订版，使用ref参数：</p>
<pre class="crayon-plain-tag">module "vpc" {
  source = "git::https://example.com/vpc.git?ref=v1.2.0"
}</pre>
<p>任何可以作为git checkout参数的值，都可以作为ref参数。</p>
<div class="blog_h3"><span class="graybg">HTTP</span></div>
<p>如果source指定为一个普通的URL，那么Terraform会：</p>
<ol>
<li>附加GET参数<pre class="crayon-plain-tag">terraform-get=1</pre>，请求那个URL</li>
<li>如果得到2xx应答，那么尝试从以下位置读取模块实际地址：
<ol>
<li>响应头<pre class="crayon-plain-tag">X-Terraform-Get</pre></li>
<li>HTML元素：<br />
<pre class="crayon-plain-tag">&lt;meta name="terraform-get" content="github.com/hashicorp/example" /&gt; </pre>
</li>
</ol>
</li>
</ol>
<p>如果URL的结尾是可识别的压缩格式扩展名（zip tar.bz2  tbz2 tar.gz  tgz tar.xz txz）则Terraform会跳过上面处理过程，直接下载压缩包：</p>
<pre class="crayon-plain-tag">module "vpc" {
  source = "https://example.com/vpc-module?archive=zip"
}</pre>
<div class="blog_h3"><span class="graybg">位于源子目录的模块</span></div>
<p>如果需要的模块位于源的子目录中，可以使用特殊的<pre class="crayon-plain-tag">//</pre>语法来引用：</p>
<p style="padding-left: 30px;">hashicorp/consul/aws//modules/consul-cluster<br />git::https://example.com/network.git//modules/vpc<br />https://example.com/network-module.zip//modules/vpc<br />s3::https://s3-eu-west-1.amazonaws.com/examplecorp-terraform-modules/network.zip//modules/vpc<br />git::https://example.com/network.git//modules/vpc?ref=v1.2.0</p>
<div class="blog_h1"><span class="graybg">开发模块</span></div>
<p>开发模块就是构思和编写TF文件的过程，你需要考虑模块的输入（变量）、输出（值），模块读取和创建哪些资源。你只需要将这些TF文件放在一个目录中就可以了。</p>
<p>如果开发的模块可能被很多配置复用，建议在独立的版本库中管理。</p>
<p>尽管内嵌多个子目录（作为子模块）是允许的，但是建议尽可能的让目录扁平化，<span style="background-color: #c0c0c0;">可以使用模块组合，而避免深层次的目录树</span>，这可以增强可复用性。</p>
<div class="blog_h2"><span class="graybg">何时模块化</span></div>
<p>过度的模块化容易让配置难以理解。</p>
<p>一个好的模块应该通过描述架构中新概念来提升抽象层次，这个概念由一些Provider中的基本元素组成。例如，我们想基于一些CVM、一个CLB构建一个Redis集群，这样的集群就适合封装在一个模块中。</p>
<p>永远不要开发那种仅仅为了包装单个其它资源的模块。</p>
<div class="blog_h2"><span class="graybg">模块标准结构</span></div>
<pre class="crayon-plain-tag">$ tree complete-module/          # 根模块，这是标准模块结构中唯一必须的元素
.
├── README.md                    # 文档
├── main.tf                      # 建议文件名，模块主入口点，资源在此创建
├── variables.tf                 # 定义模块的输入变量
├── outputs.tf                   # 定义模块的输出值
├── ...
├── modules/                     # 嵌套的子模块
│   ├── nestedA/
│   │   ├── README.md
│   │   ├── variables.tf
│   │   ├── main.tf
│   │   ├── outputs.tf
│   ├── nestedB/
│   ├── .../
├── examples/                    # 使用模块的样例
│   ├── exampleA/
│   │   ├── main.tf
│   ├── exampleB/
│   ├── .../</pre>
<div class="blog_h2"><span class="graybg">模块组合</span></div>
<div class="blog_h3"><span class="graybg">简介</span></div>
<p>一个简单的Terraform配置，仅仅包含一个根模块，我们在这个扁平的结构中创建所有资源： </p>
<pre class="crayon-plain-tag">resource "aws_vpc" "example" {
  cidr_block = "10.1.0.0/16"
}

resource "aws_subnet" "example" {
  vpc_id = aws_vpc.example.id

  availability_zone = "us-west-2b"
  cidr_block        = cidrsubnet(aws_vpc.example.cidr_block, 4, 1)
}</pre>
<p>当引入<pre class="crayon-plain-tag">modules</pre>块后，配置变成层次化的，每个模块可以创建自己的资源，甚至有自己的下级子模块。 无节制的使用子模块会导致很深的树状配置结构，这是反模式。</p>
<div class="blog_h3"><span class="graybg">依赖反转原则</span></div>
<p>Terraform建议保持配置的扁平，仅仅引入一层子模块。假设我们需要在AWS上创建一个Consul集群，它依赖一个子网。我们可以将子网的创建封装到模块：</p>
<pre class="crayon-plain-tag">module "network" {
  source = "./modules/aws-network"

  base_cidr_block = "10.0.0.0/8"
}</pre>
<p>将Consul集群的创建封装到另外一个模块：</p>
<pre class="crayon-plain-tag">module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.subnet_ids
}</pre>
<p>根模块通过上面两个module块使用这两个子模块，这种<span style="background-color: #c0c0c0;">简单的单层结构叫模块组合（ module composition）</span></p>
<p>上面这个例子也体现了<span style="background-color: #c0c0c0;">依赖反转</span>的原则： consul_cluster需要一个网络，但是不是它（这个模块）自己去创建网络，而是由外部创建并注入给它。</p>
<p>在未来进一步的重构中，可能由另外一个配置负责创建网络，而仅仅通过数据资源将读取网络信息：</p>
<pre class="crayon-plain-tag">data "aws_vpc" "main" {
  tags = {
    Environment = "production"
  }
}

data "aws_subnet_ids" "main" {
  vpc_id = data.aws_vpc.main.id
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = data.aws_vpc.main.id
  subnet_ids = data.aws_subnet_ids.main.ids
}</pre>
<div class="blog_h3"><span class="graybg">条件性创建</span></div>
<p>开发可复用模块时，一个很常见的情况是，某个依赖的资源在某些条件下不需要创建 —— 例如在某些环境下，资源预先已经存在。</p>
<p>这种情况下同样要应用依赖反转原则：将这些可能需要创建的<span style="background-color: #c0c0c0;">依赖资源作为模块的输入参数</span>：</p>
<pre class="crayon-plain-tag"># 下面这个变量代表依赖的资源
variable "ami" {
  type = object({
    # 仅仅需要定义对于本模块有意义的属性
    id           = string
    architecture = string
  })
}</pre>
<p>由模块的调用者决定创建依赖资源：</p>
<pre class="crayon-plain-tag">resource "aws_ami" "example" {
  name              = "local-copy-of-ami"
  source_ami_id     = "ami-abc123"
  source_ami_region = "eu-west-1"
}

module "example" {
  source = "./modules/example"
  ami = aws_ami.example
}</pre>
<p>还是直接使用已经存在的依赖资源：</p>
<pre class="crayon-plain-tag">data "aws_ami" "example" {
  owner = "10000"
  tags = {
    application = "example-app"
    environment = "dev"
  }
}

module "example" {
  source = "./modules/example"
  ami = data.aws_ami.example
}</pre>
<p>因为，只有调用者才清楚实际环境是什么样的。 </p>
<div class="blog_h3"><span class="graybg">多云抽象</span></div>
<p>Terraform本身没有提供适配多个云服务商的相似资源的抽象，这会屏蔽云服务商的差异性。尽管如此，作为Terraform用户来说，进行多云抽象是常见需求，特别是云迁移这样的应用场景。 </p>
<p>举例来说，任何一个云服务商的域名系统都支持域名解析。但是某些云服务商可能提供智能负载均衡、地理位置感知的解析这样高级特性。我们可以将域名系统的公共特性抽象为模块：</p>
<pre class="crayon-plain-tag">module "webserver" {
  source = "./modules/webserver"
}

locals {
  fixed_recordsets = [
    {
      name = "www"
      type = "CNAME"
      ttl  = 3600
      records = [
        "webserver01",
        "webserver02",
      ]
    },
  ]
  server_recordsets = [
    for i, addr in module.webserver.public_ip_addrs : {
      name    = format("webserver%02d", i)
      type    = "A"
      records = [addr]
    }
  ]
}</pre>
<p>上面的recordset，抽象了所有域名系统都应该支持的DNS记录集资源。</p>
<p>当针对某个云服务商实现此资源时，我们可以开发一个模块。</p>
<pre class="crayon-plain-tag"># 此模块在AWS Route53上实现了记录集资源
module "dns_records" {
  source = "./modules/route53-dns-records"
  route53_zone_id = var.route53_zone_id
  recordsets      = concat(local.fixed_recordsets, local.server_recordsets)
}</pre>
<p>需要切换云服务商时，只需要替换上述dns_records模块的source即可，指向对应的模块即可。所有这些模块都需要定义输入参数：</p>
<pre class="crayon-plain-tag">variable "recordsets" {
  type = list(object({
    name    = string
    type    = string
    ttl     = number
    records = list(string)
  }))
}</pre>
<div class="blog_h3"><span class="graybg">仅Data模块</span></div>
<p>大部分模块都会描述需要被创建和管理的基础设施对象，偶尔的情况下，模块仅仅去抓取需要的信息。</p>
<p>其中一种情况是，一套系统被划分为多个子系统，这些子系统都需要获取某种信息，这些信息可以用由一个data-only模块抓取。</p>
<div class="blog_h2"><span class="graybg">发布模块</span></div>
<p>出于复用目的的模块可以发布到Terraform Registry， 这样模块可以<a href="#module-source">很容易被所有人使用</a>。如果仅仅在组织内部共享，可以发布到私有仓库。</p>
<p>通过Git、S3、HTTP等方式发布模块也是可以的，模块支持多种源。</p>
<div class="blog_h1"><span class="graybg">提供者(providers) </span></div>
<p>所谓提供者，就是Terraform的插件/驱动，负责和特定云厂商的API或者其它任何API打交道。</p>
<p>每个<span style="background-color: #c0c0c0;">Provider都可以提供若干受管资源、数据资源</span>，供Terraform使用。 反过来说，任何资源都是由Provider提供，没有Provider，Terraform什么都做不了。</p>
<p>Provider和Terraform完全独立的分发，公共的Provider托管在Terraform仓库（Registry）。</p>
<div class="blog_h2"><span class="graybg">安装Provider</span></div>
<p>在每次Terraform运行过程中，需要的Provider会自动被安装。</p>
<p>Terraform CLI会在初始化工作目录的时候安装Provider，它能够<span style="background-color: #c0c0c0;">自动从Terraform Registry下载Provider，或者从本地镜像/缓存加载</span>。要指定缓存位置，在CLI配置文件中设置<pre class="crayon-plain-tag">plugin_cache_dir</pre>。或者设置环境变量<pre class="crayon-plain-tag">TF_PLUGIN_CACHE_DIR</pre></p>
<p>为了保证，针对一套配置，<span style="background-color: #c0c0c0;">总是安装相同版本的Provider，可以使用CLI创建一个依赖锁文件</span>，并将此文件和配置一起纳入版本管理。</p>
<div class="blog_h2"><span class="graybg">引入Provider</span></div>
<p>每个模块都必须声明它需要哪些Provider，这样Terraform才能够安装和使用它们。声明在<pre class="crayon-plain-tag">required_providers</pre>块中进行：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    # 该块的每个参数，启用一个Provider
    # key是Provider的本地名称，必须是模块范围内的唯一标识符
    mycloud = {
      # 源地址
      source  = "mycorp/mycloud"
      # 版本约束
      version = "~&gt; 1.0"
    }
  }
}</pre>
<p>在required_providers块之外，Terraform总是使用本地名称来引用Provider：</p>
<pre class="crayon-plain-tag">provider "mycloud" {
  # 配置mycloud这个Provider
}</pre>
<div class="blog_h3"><span class="graybg">源地址</span></div>
<p>Provider的源地址，是它的全局标识符，这个地址当然也指明了应该从何处下载Provider。</p>
<p>源地址的格式为：<pre class="crayon-plain-tag">[&lt;HOSTNAME&gt;/]&lt;NAMESPACE&gt;/&lt;TYPE&gt;</pre>，各字段说明如下： </p>
<ol>
<li>可选的主机名，默认registry.terraform.io，即Terraform Registry的主机名</li>
<li>命名空间，通常是发布Provider的组织</li>
<li>类型，通常是Provider管理的平台/系统的简短名称</li>
</ol>
<div class="blog_h3"><span class="graybg">版本约束</span></div>
<p><pre class="crayon-plain-tag">&gt;= 1.0</pre>表示要求1.0或更高版本。<pre class="crayon-plain-tag">~&gt; 1.0.4</pre>表示仅仅允许1.0.x版本。</p>
<div class="blog_h2"><span class="graybg">内置Provider</span></div>
<p>目前仅有一个内置于Terraform的Provider，名为terraform_remote_state。你不需要再配置文件中引入它，尽管如此它还是有自己的源地址terraform.io/builtin/terraform。</p>
<div class="blog_h2"><span class="graybg">私有Provider</span></div>
<p>某些组织可能会开发自己的Provider，以管理特殊的基础设施。他们可能希望在Terraform中使用这些Provider，但是却不将其发布到公共仓库中。这种情况下，，构建自己的私有仓库。更简单的，可以使用<span style="background-color: #c0c0c0;">更简单的Provider安装方法，例如自己将其存放在本地文件系统的特定目录</span>。</p>
<p>任何Provider都必须有源地址，源地址必须包含（或者隐含默认值）一个主机名。如果通过本地文件系统分发Provider，则这个主机名只是个占位符，甚至不需要可解析。你通常可以使用terraform.yourcompany.com作为主机名。你可以这样引入私有Provider：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    mycloud = {
      source  = "terraform.example.com/examplecorp/ourcloud"
      version = "&gt;= 1.0"
    }
  }
}</pre>
<p>选择一个隐式本地镜像目录（implied local mirror directories ），并创建目录terraform.example.com/examplecorp/<span style="background-color: #99cc00;">ourcloud</span>/1.0.0，在此目录中创建一个代表运行平台的子目录，例如linux_amd64，并将Provider的可执行文件、以及任何其它需要的文件存放到其中即可。对于Windows，可执行文件的路径可能是terraform.example.com/examplecorp/ourcloud/1.0.0/windows_amd64/<span style="background-color: #c0c0c0;">terraform-provider</span>-<span style="background-color: #99cc00;">ourcloud</span>.exe</p>
<div class="blog_h2"><span class="graybg">配置Provider</span></div>
<p>除了引入Provider，你可能还需要对其进行配置才能使用。</p>
<p>配置时，使用<span style="color: #1d1e23;">provider块。只有根模块才可以配置一个Provider。<span style="background-color: #c0c0c0;">子模块会自动从根模块继承Provider配置</span>。示例：</span></p>
<pre class="crayon-plain-tag">#        本地名称，引入Provider时指定
provider "google" {
  project = "acme-app"
  region  = "us-central1"
}</pre>
<p>具体哪些配置参数可用，取决于Provider。配置时可以使用表达式，但是<span style="background-color: #c0c0c0;">只能引用那些应用配置之前即可知的值 —— 可以安全的引用输入变量，但是不能使用那些由资源导出的属性</span>。</p>
<div class="blog_h3"><span class="graybg">alias</span></div>
<p>Provider支持两个元参数，其中一个是alias，用于定义备选的Provider配置：</p>
<pre class="crayon-plain-tag">provider "aws" {
  alias  = "west"
  region = "us-west-2"
}</pre>
<p>声明资源时，可以指定使用备选的Provider配置： </p>
<pre class="crayon-plain-tag">resource "aws_instance" "foo" {
  provider = aws.west
} </pre>
<div class="blog_h3"><span class="graybg">version</span></div>
<p>这个元参数已经弃用，是旧的管理Provider版本的方式。 </p>
<div class="blog_h2"><span class="graybg">依赖锁文件</span></div>
<p>Terraform配置文件可以引用两类外部依赖：</p>
<ol>
<li>Providers，如上个章节所述，用于和外部系统交互的插件</li>
<li>Modules，可复用的配置文件集合</li>
</ol>
<p>这两类依赖都可以独立发布，并进行版本管理。引用这些依赖时，Terraform需要知道使用什么版本。</p>
<p>配置文件中的版本约束，指定了潜在的兼容性版本范围。但是到底选择（并锁定使用）依赖的哪个版本，由<span style="background-color: #c0c0c0;">名为.terraform.lock.hcl的依赖锁文件</span>决定。注意，当前<span style="background-color: #c0c0c0;">依赖锁文件仅仅管理Provider的版本，对于Module，仍然总是拉取匹配版本约束的最新版本</span>。</p>
<p>每当运行<pre class="crayon-plain-tag">terraform init</pre>命令时，依赖锁文件会自动创建/更新。此文件应该纳入版本管理。依赖锁文件使用和TF类似的语法。</p>
<p>运行terraform init时，如果：</p>
<ol>
<li>依赖没有记录在依赖锁文件中，则尝试拉取匹配版本约束的最新版本。并将获取到的版本信息记录到依赖锁文件</li>
<li>依赖已经记录，则使用记录的版本</li>
</ol>
<p>运行<pre class="crayon-plain-tag">terraform init -upgrade</pre>会强制拉取最新的、匹配约束的版本并更新依赖锁文件。</p>
<div class="blog_h1"><span class="graybg">开发插件</span></div>
<div class="blog_h2"><span class="graybg">核心和插件</span></div>
<p>Terraform逻辑上划分为两个部分：核心和插件。Terraform核心通过RPC来调用插件。Terraform支持多种发现和加载插件的方式。Terraform插件有两类</p>
<ol>
<li>Provider：通常用于对接到某特定云服务商，在其上创建基础设施对象</li>
<li>Provisioner：对接到某种provisioner，例如Bash</li>
</ol>
<p>核心的职责包括：</p>
<ol>
<li>基础设施即代码：读取和解释配置文件和模块</li>
<li>资源状态管理</li>
<li>构造资源依赖图</li>
<li>执行计划</li>
<li>通过RPC和插件交互</li>
</ol>
<p>插件和核心一样，基于Go语言编写。Terraform使用的所有Provider和Provisioner都是插件，它们在独立进程中运行。</p>
<p>Provider插件的职责是：</p>
<ol>
<li>初始化任何必要的库，用于进行API调用</li>
<li>与基础设施提供者进行交互</li>
<li>定义映射到特定服务的资源</li>
</ol>
<p>Provisioner插件的职责是：</p>
<ol>
<li>在特定资源创建后、销毁前执行命令或脚本</li>
</ol>
<div class="blog_h2"><span class="graybg">插件的发现</span></div>
<p>当<pre class="crayon-plain-tag">terraform init</pre>运行后，Terraform会读取工作目录中的配置文件，确定需要哪些插件。并在多个位置搜索以及安装的插件，下载缺失的插件，确定使用插件的什么版本，并且更新依赖锁文件，锁定插件版本。</p>
<p>关于插件发现，有以下规则：</p>
<ol>
<li>如果已经安装了满足版本约束的插件，Terraform会使用其中最新的。即使Terraform Registry有更新的满足版本约束的插件，默认也不会主动下载。使用<pre class="crayon-plain-tag">terraform init -upgrade</pre>可以强制下载最新版本</li>
<li>如果没有安装满足版本约束的插件，且插件托管在Registry，则下载并存放到<pre class="crayon-plain-tag">.terraform/providers/</pre>目录下</li>
</ol>
<div class="blog_h2"><span class="graybg">Provider设计原则</span></div>
<div class="blog_h3"><span class="graybg">专注于单一API或SDK </span></div>
<p>Provider应该基于单一API集合，或者SDK，例如仅仅针对腾讯云API，实现对腾讯云基础实施对象CRUD的封装。</p>
<div class="blog_h3"><span class="graybg">资源应当表示单一API对象</span></div>
<p>Terraform插件定义的资源，应该对应单一的云资源，作为该云资源的声明式表示。资源通常应该提供创建/读取/删除，以及可选的更新方法。</p>
<p><span style="background-color: #c0c0c0;">对多个云资源的组合，或者其它高级特性，应该通过模块来实现</span>。</p>
<div class="blog_h3"><span class="graybg">资源及其属性的Schema应当尽可能和底层API匹配</span></div>
<p>名称、数据类型、结构，应当尽可能匹配，除非这样做会影响Provider用户的体验。</p>
<div class="blog_h3"><span class="graybg">资源应该可导入</span></div>
<p>Terraform资源应该支持<pre class="crayon-plain-tag">terraform import</pre>操作。</p>
<div class="blog_h3"><span class="graybg">注意状态和版本</span></div>
<p>一旦Provider发布，后续就面临向后兼容性问题。</p>
<div class="blog_h2"><span class="graybg">两个SDK</span></div>
<p>开发Provider时，有两个SDK可供选择：</p>
<ol>
<li>SDKv2：当前大部分现有的Provider基于此SDK开发，提供稳定的开发体验</li>
<li>Terraform Plugin Framework：新的SDK，还在积极的开发中。目标是提升开发体验，对齐Terraform新的架构</li>
</ol>
<p>如何选择：</p>
<ol>
<li>如果维护既有Provider，沿用它当前使用的SDK。如果开发全新的Provider，可以考虑使用Framework</li>
<li>如果使用的Terraform版本小于v1.0.3，则只能基于SDKv2开发。Framework基于Terraform Protocol Version 6构建，旧版本的Terraform不能下载基于Version 6的Provider</li>
<li>Framework的接口可能发生改变，考虑成本</li>
<li>是否需要Framework提供的新特性：
<ol>
<li>支持获知一个值是否在配置、状态或计划中设置</li>
<li>支持获知一个值是否null、unknown，或者是空白值</li>
<li>支持object这样的结构化类型</li>
<li>支持嵌套属性</li>
<li>支持以any类型为元素的map</li>
<li>支持获知何时一个optional或计算出的字段从配置中移除了</li>
</ol>
</li>
<li>是否需要Framework尚未实现的，SDKv2已经支持的特性：
<ol>
<li>超时支持</li>
<li>定义资源状态upgraders</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">手工构建和发布Provider</span></div>
<div class="blog_h3"><span class="graybg">构建Provider</span></div>
<p>构建Provider的时候，使用go build命令，按照构建二进制文件的常规方式即可。你也可以使用GoReleaser来自动化构建多平台的、包含checksum的、自动签名的Provider。</p>
<div class="blog_h3"><span class="graybg">准备一个发布</span></div>
<p>每个发布包含以下文件：</p>
<ol>
<li>一个或多个zip文件，其命名格式为<pre class="crayon-plain-tag">terraform-provider-{NAME}_{VERSION}_{OS}_{ARCH}.zip</pre>
<ol>
<li>zip中包含Provider的二进制文件，命名为<pre class="crayon-plain-tag">terraform-provider-{NAME}_v{VERSION}</pre></li>
</ol>
</li>
<li>一个摘要文件<pre class="crayon-plain-tag">terraform-provider-{NAME}_{VERSION}_SHA256SUMS</pre>，包含每个zip的sha256：<br />
<pre class="crayon-plain-tag">shasum -a 256 *.zip &gt; terraform-provider-{NAME}_{VERSION}_SHA256SUMS</pre>
</li>
<li>一个GPG二进制文件<pre class="crayon-plain-tag">terraform-provider-{NAME}_{VERSION}_SHA256SUMS.sig</pre>，使用密钥对上述摘要文件进行前面：<br />
<pre class="crayon-plain-tag">gpg --detach-sign terraform-provider-{NAME}_{VERSION}_SHA256SUMS</pre>
</li>
<li>发布必须是finalized的（不是一个私有的草稿）</li>
</ol>
<div class="blog_h1"><span class="graybg">基于SDKv2开发Provider</span></div>
<p>本章开发和使用一个Provider，它和虚构的咖啡店应用Hashicups进行交互。此咖啡店应用支持查看和订购咖啡，它开放公共API端点：</p>
<ol>
<li>列出咖啡品种</li>
<li>列出特定咖啡的成分</li>
</ol>
<p>以及需要身份认证的API端点：</p>
<ol>
<li>CRUD咖啡订单</li>
</ol>
<p>HashiCups Provider基于一个Golang客户端库，利用REST API访问以上API端点，管理咖啡订单。</p>
<div class="blog_h2"><span class="graybg">使用Provider</span></div>
<p>首先我们从用户角度来感受一下，如何使用HashiCups Provider管理咖啡订单。本章后续会分析和重构该Provider的源码。</p>
<div class="blog_h3"><span class="graybg">下载空白项目</span></div>
<p>执行下面的命令下载使用HashiCups Provider的Terraform配置的空白项目。此项目没有Terraform配置，但是提供了在本地运行HashiCup咖啡店应用的脚本。</p>
<pre class="crayon-plain-tag">git clone https://github.com/hashicorp/learn-terraform-hashicups-provider
cd learn-terraform-hashicups-provider</pre>
<p>执行下面的命令，在本地启动HashiCup咖啡店应用：</p>
<pre class="crayon-plain-tag">cd docker_compose &amp;&amp; docker-compose up</pre>
<p>API监听端口是19090。检查并确认服务器正常工作：</p>
<pre class="crayon-plain-tag">curl localhost:19090/health </pre>
<div class="blog_h3"><span class="graybg">安装Provider</span></div>
<p>从0.13+开始，必须在Terraform配置中声明所有依赖的Provider及其源（从哪里下载）。<span style="background-color: #c0c0c0;">源的格式为[hostname]/[namespace]/[name]，对于Terraform Registry中的hashicorp命名空间，hostname和namespace都是可选的。Terraform Registry对应的hostname为registry.terraform.io</span></p>
<p>这里用到的Provider没有托管在Registry，需要手工下载：</p>
<pre class="crayon-plain-tag">curl -LO https://github.com/hashicorp/terraform-provider-hashicups/releases/download/v0.3.1/terraform-provider-hashicups_0.3.1_linux_amd64.zip</pre>
<p>或者从源码编译：</p>
<pre class="crayon-plain-tag">git clone https://github.com/hashicorp/terraform-provider-hashicups
go mod vendor
go build -o terraform-provider-hashicups
mv terraform-provider-hashicups ~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64</pre>
<p>并且存放到：</p>
<p style="padding-left: 30px;">~/.terraform.d/plugins/${host_name}/${namespace}/${type}/${version}/${target}<br />~/.terraform.d/plugins/hashicorp.com/edu/hashicups/0.3.1/linux_amd64/terraform-provider-hashicups_v0.3.1</p>
<div class="blog_h3"><span class="graybg">创建用户</span></div>
<pre class="crayon-plain-tag">curl -X POST localhost:19090/signup -d '{"username":"education", "password":"test123"}'</pre>
<p>登录以获得Token：</p>
<pre class="crayon-plain-tag">curl -X POST localhost:19090/signin -d '{"username":"education", "password":"test123"}'
{"UserID":1,"Username":"education","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw"}</pre>
<p>将Token设置到环境变量：</p>
<pre class="crayon-plain-tag">export HASHICUPS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MzUyNTA3ODAsInVzZXJfaWQiOjEsInVzZXJuYW1lIjoiZWR1Y2F0aW9uIn0.M4YWgRM-5Jzfy3TLj9MqeVR7nsfRmlG3vZyaeRASnhw</pre>
<div class="blog_h3"><span class="graybg">初始化工作区</span></div>
<p>添加下面的代码到main.tf：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    hashicups = {
      version = "~&gt; 0.3.1"
      source  = "hashicorp.com/edu/hashicups"
    }
  }
}</pre>
<p>并进行初始化：<pre class="crayon-plain-tag">terraform init</pre></p>
<div class="blog_h3"><span class="graybg">创建订单</span></div>
<p>将以下内容添加到main.tf中：</p>
<pre class="crayon-plain-tag"># 配置Provider
provider "hashicups" {
  username = "education"
  password = "test123"
}

# 定义一个名为edu的订单资源
resource "hashicups_order" "edu" {
  # 订单包含2个品种3的咖啡
  items {
    coffee {
      id = 3
    }
    quantity = 2
  }
  # 订单包含2个品种2的咖啡
  items {
    coffee {
      id = 2
    }
    quantity = 2
  }
}

# 输出edu资源，这个输出在资源创建后可用
output "edu_order" {
  value = hashicups_order.edu
}</pre>
<p>执行下面的命令获取执行计划：</p>
<pre class="crayon-plain-tag">terraform plan 

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create # +表示创建操作

Terraform will perform the following actions:
  # 这里会列出计划中包含的操作
  #                             这是一个创建操作
  # hashicups_order.edu will be created
  + resource "hashicups_order" "edu" {
      # 每个属性的值     这个表示未知值
      + id           = (known after apply)
      + last_updated = (known after apply)

      + items {
          + quantity = 2

          + coffee {
              + description = (known after apply)
              + id          = 3
              + image       = (known after apply)
              + name        = (known after apply)
              + price       = (known after apply)
              + teaser      = (known after apply)
            }
        }
      + items {
          + quantity = 2

          + coffee {
              + description = (known after apply)
              + id          = 2
              + image       = (known after apply)
              + name        = (known after apply)
              + price       = (known after apply)
              + teaser      = (known after apply)
            }
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

# 这里显示输出值会发生的变更
Changes to Outputs:
  + edu_order = {
      + id           = (known after apply)
      + items        = [
          + {
              + coffee   = [
                  + {
                      + description = (known after apply)
                      + id          = 3
                      + image       = (known after apply)
                      + name        = (known after apply)
                      + price       = (known after apply)
                      + teaser      = (known after apply)
                    },
                ]
              + quantity = 2
            },
          + {
              + coffee   = [
                  + {
                      + description = (known after apply)
                      + id          = 2
                      + image       = (known after apply)
                      + name        = (known after apply)
                      + price       = (known after apply)
                      + teaser      = (known after apply)
                    },
                ]
              + quantity = 2
            },
        ]
      + last_updated = (known after apply)
    }</pre>
<p>执行<pre class="crayon-plain-tag">terraform apply</pre>即可应用变更。 利用<pre class="crayon-plain-tag">terraform state show</pre>命令可以显示资源状态：</p>
<pre class="crayon-plain-tag">terraform state show hashicups_order.edu 
# hashicups_order.edu:
resource "hashicups_order" "edu" {
    id = "1"

    items {
        quantity = 2

        coffee {
            id     = 3
            image  = "/nomad.png"
            name   = "Nomadicano"
            price  = 150
            teaser = "Drink one today and you will want to schedule another"
        }
    }
    items {
        quantity = 2

        coffee {
            id     = 2
            image  = "/vault.png"
            name   = "Vaulatte"
            price  = 200
            teaser = "Nothing gives you a safe and secure feeling like a Vaulatte"
        }
    }
}</pre>
<p>可以看到Schema中所有属性均被填充。</p>
<div class="blog_h3"><span class="graybg">更新订单</span></div>
<p>我们修改一下订单配置，将items[*].quantity改一下，然后看看执行计划：</p>
<pre class="crayon-plain-tag">terraform plan
hashicups_order.edu: Refreshing state... [id=1]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place  # ~表示原地更新操作

Terraform will perform the following actions:
  # 这里会显示diff
  # hashicups_order.edu will be updated in-place
  ~ resource "hashicups_order" "edu" {
        id = "1"

      ~ items {
          ~ quantity = 2 -&gt; 3

            # (1 unchanged block hidden)
        }
      ~ items {
          ~ quantity = 2 -&gt; 1

            # (1 unchanged block hidden)
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Changes to Outputs:
  # 这里会显示diff
  ~ edu_order = {
      ~ items        = [
          ~ {
              ~ quantity = 2 -&gt; 3
                # (1 unchanged element hidden)
            },
          ~ {
              ~ quantity = 2 -&gt; 1
                # (1 unchanged element hidden)
            },
        ]
        # (2 unchanged elements hidden)
    }</pre>
<p>通过apply命令应用上述执行计划。</p>
<div class="blog_h3"><span class="graybg">读取配料表 </span></div>
<p>本节我们来演示如何读取已经创建的订单的咖啡的配料表：</p>
<pre class="crayon-plain-tag">data "hashicups_ingredients" "first_coffee" {
  #                               声明多次的内嵌块，自动成为数组
  coffee_id = hashicups_order.edu.items[0].coffee[0].id
}

output "first_coffee_ingredients" {
  value = data.hashicups_ingredients.first_coffee
}</pre>
<div class="blog_h3"><span class="graybg">删除订单 </span></div>
<pre class="crayon-plain-tag">terraform destroy
hashicups_order.edu: Refreshing state... [id=1]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy  # -表示删除操作

Terraform will perform the following actions:

  # hashicups_order.edu will be destroyed
  - resource "hashicups_order" "edu" {
      - id           = "1" -&gt; null
      - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST" -&gt; null

      - items {
          - quantity = 3 -&gt; null

          - coffee {
              - id     = 3 -&gt; null
              - image  = "/nomad.png" -&gt; null
              - name   = "Nomadicano" -&gt; null
              - price  = 150 -&gt; null
              - teaser = "Drink one today and you will want to schedule another" -&gt; null
            }
        }
      - items {
          - quantity = 1 -&gt; null

          - coffee {
              - id     = 2 -&gt; null
              - image  = "/vault.png" -&gt; null
              - name   = "Vaulatte" -&gt; null
              - price  = 200 -&gt; null
              - teaser = "Nothing gives you a safe and secure feeling like a Vaulatte" -&gt; null
            }
        }
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  - edu_order                = {
      - id           = "1"
      - items        = [
          - {
              - coffee   = [
                  - {
                      - description = ""
                      - id          = 3
                      - image       = "/nomad.png"
                      - name        = "Nomadicano"
                      - price       = 150
                      - teaser      = "Drink one today and you will want to schedule another"
                    },
                ]
              - quantity = 3
            },
          - {
              - coffee   = [
                  - {
                      - description = ""
                      - id          = 2
                      - image       = "/vault.png"
                      - name        = "Vaulatte"
                      - price       = 200
                      - teaser      = "Nothing gives you a safe and secure feeling like a Vaulatte"
                    },
                ]
              - quantity = 1
            },
        ]
      - last_updated = "Tuesday, 26-Oct-21 10:39:15 CST"
    } -&gt; null
  - first_coffee_ingredients = {
      - coffee_id   = 3
      - id          = "3"
      - ingredients = [
          - {
              - id       = 1
              - name     = "ingredient - Espresso"
              - quantity = 20
              - unit     = "ml"
            },
          - {
              - id       = 3
              - name     = "ingredient - Hot Water"
              - quantity = 100
              - unit     = "ml"
            },
        ]
    } -&gt; null</pre>
<div class="blog_h2"><span class="graybg">实现读操作</span></div>
<p>签出Provider源码：</p>
<pre class="crayon-plain-tag">git clone --branch boilerplate https://github.com/hashicorp/terraform-provider-hashicups</pre>
<p>注意SDKv2的依赖：</p>
<pre class="crayon-plain-tag">github.com/hashicorp/terraform-plugin-sdk/v2 v2.8.0 </pre>
<div class="blog_h3"><span class="graybg">骨架代码</span></div>
<p>分支boilerplate包含一些样板文件。 程序的入口点如下：</p>
<pre class="crayon-plain-tag">package main

import (
	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
	"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"

	"terraform-provider-hashicups/hashicups"
)

func main() {
    // 启动插件的RPC服务器端
	plugin.Serve(&amp;plugin.ServeOpts{
		ProviderFunc: func() *schema.Provider {
			return hashicups.Provider()
		},
	})
}</pre>
<p>Provider函数：</p>
<pre class="crayon-plain-tag">package hashicups

import (
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// 定义了一个Provider
func Provider() *schema.Provider {
  return &amp;schema.Provider{
    // 资源映射，从资源类型名到Schema
    ResourcesMap: map[string]*schema.Resource{},
    // 数据资源映射，从资源类型名到Schema。注意资源和数据资源本质上是同一种结构
    DataSourcesMap: map[string]*schema.Resource{},
  }
}</pre>
<div class="blog_h3"><span class="graybg">定义咖啡数据源 </span></div>
<p>上面已经定义了Provider的骨架，这里我们实现一个咖啡数据源，此数据源能够从HasiCups服务拉取所有售卖的咖啡信息。</p>
<p>建议<span style="background-color: #c0c0c0;">每个数据源都在独立的源文件中编写，并且文件名以<pre class="crayon-plain-tag">data_source_</pre>开头</span>： </p>
<pre class="crayon-plain-tag">package hashicups

import (
  "context"
  "encoding/json"
  "fmt"
  "net/http"
  "strconv"
  "time"

  "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

// 定义了一个Resource，其中包含Schema以及CRUD操作
func dataSourceCoffees() *schema.Resource {
  return &amp;schema.Resource{
    // 由于数据资源仅仅支持读操作，因此仅声明ReadContext
    ReadContext: dataSourceCoffeesRead,
    Schema: map[string]*schema.Schema{},
  }
}</pre>
<p>下面我们完善此数据资源的Schema，根据Terraform的最佳实践，Schema应该尽量和基础实施匹配：</p>
<pre class="crayon-plain-tag">// curl localhost:19090/coffees
[
  {
    "id": 1,
    "name": "Packer Spiced Latte",
    "teaser": "Packed with goodness to spice up your images",
    "description": "",
    "price": 350,
    "image": "/packer.png",
    "ingredients": [
      {
        "ingredient_id": 1
      },
      {
        "ingredient_id": 2
      },
      {
        "ingredient_id": 4
      }
    ]
  }
]</pre>
<p>根据服务器返回的咖啡数据结构，编写对应的Schema并且添加到上面的dataSourceCoffees方法中：</p>
<pre class="crayon-plain-tag">Schema: map[string]*schema.Schema{
			"coffees": {
				// 注意这里是列表
				Type:     schema.TypeList,
				Computed: true,
				Elem: &amp;schema.Resource{
					Schema: map[string]*schema.Schema{
						"id": {
							Type:     schema.TypeInt,
							// 此值是"计算得到的"，也就是在创建资源时（除非手工配置）会得到此值的结果
							Computed: true,
						},
						"name": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"teaser": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"description": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"price": {
							Type:     schema.TypeInt,
							Computed: true,
						},
						"image": {
							Type:     schema.TypeString,
							Computed: true,
						},
						// 复杂类型，一个列表
						"ingredients": {
							Type:     schema.TypeList,
							Computed: true,
							// 列表元素的Schema
							Elem: &amp;schema.Resource{
								Schema: map[string]*schema.Schema{
									"ingredient_id": {
										Type:     schema.TypeInt,
										Computed: true,
									},
								},
							},
							// 如果是list(string)，可以这样声明Elem
							Elem: schema.Schema{
								Type:             schema.TypeString,
							},
						},
					},
				},
			},
		},
	}
}</pre>
<p>定义好Schema后，我们需要实现读操作： </p>
<pre class="crayon-plain-tag">//                                              读结果存放在这里          这个m是meta，元参数，
//                                                                     是配置Provider的返回值，下文有说明
func dataSourceCoffeesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	client := &amp;http.Client{Timeout: 10 * time.Second}

	// 这是一个切片，用于收集警告或者错误信息
	var diags diag.Diagnostics

	// 像云API发起请求
	req, err := http.NewRequest("GET", fmt.Sprintf("%s/coffees", "http://localhost:19090"), nil)
	if err != nil {
		// 收集错误
		return diag.FromErr(err)
	}
	r, err := client.Do(req)
	if err != nil {
		return diag.FromErr(err)
	}
	defer r.Body.Close()

	// 将响应反串行化到临时对象中
	coffees := make([]map[string]interface{}, 0)
	err = json.NewDecoder(r.Body).Decode(&amp;coffees)
	if err != nil {
		return diag.FromErr(err)
	}

	// schema.ResourceData用于查询和设置资源属性
	//        此方法将响应设置到Terraform数据源，并且保证字段设置到Schema对应位置
	if err := d.Set("coffees", coffees); err != nil {
		return diag.FromErr(err)
	}

	// 设置数据资源的ID
	d.SetId(strconv.FormatInt(time.Now().Unix(), 10))

	return diags
}</pre>
<p><span style="background-color: #c0c0c0;">数据资源的ID被设置为非空值，这提示Terraform，目标资源已经被创建</span>。 作为一个列表，此数据资源没有真实的ID，因此我们这里设置为时间戳。</p>
<p>如果数据资源被从Terraform外部删除了，这里应该设置空ID：</p>
<pre class="crayon-plain-tag">if resourceDoesntExist {
  d.SetID("")
  return
}</pre>
<p>这样Terraform会自动将state中的数据资源清除掉。</p>
<p>将上面的数据资源配置到Provider中：</p>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		ResourcesMap: map[string]*schema.Resource{},
		DataSourcesMap: map[string]*schema.Resource{
			"hashicups_coffees": dataSourceCoffees(),
		},
	}
}</pre>
<div class="blog_h3"><span class="graybg">使用咖啡数据源</span></div>
<p>现在我们开发一个模块，使用上面定义的咖啡数据源。首先需要编译好Provider：<pre class="crayon-plain-tag">make install</pre>。 </p>
<p>根模块配置：</p>
<pre class="crayon-plain-tag">terraform {
  required_providers {
    hashicups = {
      // 当前构建的版本是0.2
      version = "0.2"
      source  = "hashicorp.com/edu/hashicups"
    }
  }
}

// 目前Provider不支持配置
provider "hashicups" {}

// 调用coffee子模块
module "psl" {
  source = "./coffee"
  // 传递输入参数
  coffee_name = "Packer Spiced Latte"
}

// 打印coffee模块的coffee输出值
output "psl" {
  value = module.psl.coffee
}</pre>
<p>coffee子模块配置：</p>
<pre class="crayon-plain-tag">// 子模块需要声明自己的依赖
terraform {
  required_providers {
    hashicups = {
      version = "0.2"
      source  = "hashicorp.com/edu/hashicups"
    }
  }
}

// 输入变量，咖啡品类
variable "coffee_name" {
  type    = string
  default = "Vagrante espresso"
}

// 调用上面开发的数据源，拉取所有咖啡品类
data "hashicups_coffees" "all" {}

# 输出所有咖啡
output "all_coffees" {
  value = data.hashicups_coffees.all.coffees
}

output "coffee" {
  value = {
    // 遍历所有咖啡
    for coffee in data.hashicups_coffees.all.coffees :
    // 返回咖啡ID到咖啡资源的映射
    coffee.id =&gt; coffee
    // 过滤，要求名称匹配输入参数
    if coffee.name == var.coffee_name
  }
}</pre>
<p>应用根模块：</p>
<pre class="crayon-plain-tag"># terraform init &amp;&amp; terraform apply --auto-approve

psl = {
  "1" = {
    "description" = ""
    "id" = 1
    "image" = "/packer.png"
    "ingredients" = tolist([
      {
        "ingredient_id" = 1
      },
      {
        "ingredient_id" = 2
      },
      {
        "ingredient_id" = 4
      },
    ])
    "name" = "Packer Spiced Latte"
    "price" = 350
    "teaser" = "Packed with goodness to spice up your images"
  }
}</pre>
<div class="blog_h2"><span class="graybg">添加身份验证 </span></div>
<p>本节我们来演示如何为Provider增加参数，Provider的实现又是如何读取这些参数的。</p>
<div class="blog_h3"><span class="graybg">Provider的Schema </span></div>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		// ...
		// 定义Provider的配置参数
		Schema: map[string]*schema.Schema{
			"username": {
				Type:        schema.TypeString,
				Optional:    true,
				DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_USERNAME", nil),
			},
			"password": {
				Type:        schema.TypeString,
				Optional:    true,
				// 敏感数据，在输出时会处理
				Sensitive:   true,
				// 默认值函数                        从环境变量读取默认值
				DefaultFunc: schema.EnvDefaultFunc("HASHICUPS_PASSWORD", nil),
			},
		},
		ConfigureContextFunc: providerConfigure,
	}
}</pre>
<div class="blog_h3"><span class="graybg">配置Provider </span></div>
<p>用户提供的username/password，如何被Provider的ReadContext函数访问呢？这需要配置Provider。配置过程是由<pre class="crayon-plain-tag">schema.Provider</pre>的<pre class="crayon-plain-tag">ConfigureContextFunc</pre>函数负责的：</p>
<pre class="crayon-plain-tag">func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
	// 读取Provider的配置参数
	username := d.Get("username").(string)
	password := d.Get("password").(string)

	var diags diag.Diagnostics

	if (username != "") &amp;&amp; (password != "") {
		c, err := hashicups.NewClient(nil, &amp;username, &amp;password)
		if err != nil {
			return nil, diag.FromErr(err)
		}

		return c, diags
	}

	c, err := hashicups.NewClient(nil, nil, nil)
	if err != nil {
		return nil, diag.FromErr(err)
	}

	return c, diags
}</pre>
<p>可以看到配置Provider后，会返回一个interface{}，这个对象会传递给CRUD操作的最后一个参数：</p>
<pre class="crayon-plain-tag">// See Resource documentation.
type CreateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics

// See Resource documentation.
type ReadContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics

// See Resource documentation.
type UpdateContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics

// See Resource documentation.
type DeleteContextFunc func(context.Context, *ResourceData, interface{}) diag.Diagnostics</pre>
<p>一般情况下，这个interface{}是配置好的云API客户端，或者是云API配置结构。</p>
<div class="blog_h2"><span class="graybg">复杂读操作 </span></div>
<p>本节我们演示如何在读操作中使用Provider参数，更精确的说是基于这些参数配置Provider后的结果。</p>
<p>首先我们创建几个咖啡订单（用于后续查询）：</p>
<pre class="crayon-plain-tag">curl -X POST -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders -d '[{"coffee": { "id":1 }, "quantity":4}, {"coffee": { "id":3 }, "quantity":3}]'</pre>
<p>看一下订单的数据结构：</p>
<pre class="crayon-plain-tag">// curl -X GET -H "Authorization: ${HASHICUPS_TOKEN}" localhost:19090/orders/2 

{
  "id": 2,
  "items": [
    {
      "coffee": {
        "id": 1,
        "name": "Packer Spiced Latte",
        "teaser": "Packed with goodness to spice up your images",
        "description": "",
        "price": 350,
        "image": "/packer.png",
        "ingredients": null
      },
      "quantity": 4
    },
    {
      "coffee": {
        "id": 3,
        "name": "Nomadicano",
        "teaser": "Drink one today and you will want to schedule another",
        "description": "",
        "price": 150,
        "image": "/nomad.png",
        "ingredients": null
      },
      "quantity": 3
    }
  ]
}</pre>
<p>下面我们定义对应的数据源：</p>
<pre class="crayon-plain-tag">func dataSourceOrder() *schema.Resource {
	return &amp;schema.Resource{
		// 读取操作，见下文
		ReadContext: dataSourceOrderRead,
		Schema: map[string]*schema.Schema{
			"id": {
				Type:     schema.TypeInt,
				Required: true,
			},
			"items": {
				Type:     schema.TypeList,
				Computed: true,
				Elem: &amp;schema.Resource{
					Schema: map[string]*schema.Schema{
						"coffee_id": {
							Type:     schema.TypeInt,
							Computed: true,
						},
						"coffee_name": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"coffee_teaser": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"coffee_description": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"coffee_price": {
							Type:     schema.TypeInt,
							Computed: true,
						},
						"coffee_image": {
							Type:     schema.TypeString,
							Computed: true,
						},
						"quantity": {
							Type:     schema.TypeInt,
							Computed: true,
						},
					},
				},
			},
		},
	}
}</pre>
<p>注意这个数据源的Schema和API的结构没有做对应，进行了扁平化处理。</p>
<p>读取订单的操作如下：</p>
<pre class="crayon-plain-tag">func dataSourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	// 配置Provider后，得到的是一个客户端
	c := m.(*hc.Client)
	var diags diag.Diagnostics

	orderID := strconv.Itoa(d.Get("id").(int))

	// 基于此客户端进行订单查询
	order, err := c.GetOrder(orderID)
	if err != nil {
		return diag.FromErr(err)
	}

	// 订单结构和我们数据源的结构不一致，这里做转换
	orderItems := flattenOrderItemsData(&amp;order.Items)
	if err := d.Set("items", orderItems); err != nil {
		return diag.FromErr(err)
	}

	d.SetId(orderID)

	return diags
}</pre>
<p>将此资源注册到Provider：</p>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		// ...
		DataSourcesMap: map[string]*schema.Resource{
			// ...
			"hashicups_order":   dataSourceOrder(),
		},
		ConfigureContextFunc: providerConfigure,
	}
}</pre>
<p>在配置文件中使用该数据源：</p>
<pre class="crayon-plain-tag">data "hashicups_order" "order" {
  id = 1
}

output "order" {
  value = data.hashicups_order.order
}</pre>
<div class="blog_h2"><span class="graybg">诊断和调试 </span></div>
<p>本节演示如何基于日志来调试Provider，我们会添加定制的错误消息，并且显示详细的Terraform Provider日志。</p>
<p>开发Provider时需要实现很多函数，这些函数常常具有一个返回值<pre class="crayon-plain-tag">diag.Diagnostics</pre>，例如上面的CRUD操作，以及配置Provider的函数：</p>
<pre class="crayon-plain-tag">type ConfigureContextFunc func(context.Context, *ResourceData) (interface{}, diag.Diagnostics)</pre>
<p>警告/错误级别的调试信息，都要放到diag.Diagnostics中，执行Terraform CLI命令时，这些信息会自动打印。</p>
<p>当创建HashiCups客户端失败时，我们可以添加一条针对信息：</p>
<pre class="crayon-plain-tag">func providerConfigure(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
	// ...
	if (username != "") &amp;&amp; (password != "") {
		c, err := hashicups.NewClient(nil, &amp;username, &amp;password)
		if err != nil {
			diags = append(diags, diag.Diagnostic{
				Severity: diag.Error,
				Summary:  "Unable to create HashiCups client",
				Detail:   "Unable to auth user for authenticated HashiCups client",
			})
			return nil, diags
		}

		return c, diags
	}

	c, err := hashicups.NewClient(nil, nil, nil)
	if err != nil {
		diags = append(diags, diag.Diagnostic{
			Severity: diag.Error,
			Summary:  "Unable to create HashiCups client",
			Detail:   "Unable to auth user for authenticated HashiCups client",
		})
		return nil, diags
	}

	return c, diags
}</pre>
<p>执行命令时，错误信息会打印出来：</p>
<pre class="crayon-plain-tag">terraform init &amp;&amp; terraform apply --auto-approve
## ...
module.psl.data.hashicups_coffees.all: Refreshing state...

# 摘要
Error: Unable to create HashiCups client
# 详情
Unable to auth user for authenticated HashiCups client</pre>
<div class="blog_h2"><span class="graybg">实现创建操作 </span></div>
<p>资源支持创建、修改、删除等写操作，上面编写的数据源则仅支持读。尽管资源、数据源可能指向同一类实体，但是Schema不能共用。</p>
<div class="blog_h3"><span class="graybg">定义订单资源</span></div>
<p>资源的文件名前缀通常使用<pre class="crayon-plain-tag">resource_</pre>，下面是订单资源的骨架代码：</p>
<pre class="crayon-plain-tag">package hashicups

import (
  "context"

  "github.com/hashicorp/terraform-plugin-sdk/v2/diag"
  "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
)

func resourceOrder() *schema.Resource {
  return &amp;schema.Resource{
    CreateContext: resourceOrderCreate,
    ReadContext:   resourceOrderRead,
    UpdateContext: resourceOrderUpdate,
    DeleteContext: resourceOrderDelete,
    Schema: map[string]*schema.Schema{},
  }
}

func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  // Warning or errors can be collected in a slice type
  var diags diag.Diagnostics

  return diags
}

func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  // Warning or errors can be collected in a slice type
  var diags diag.Diagnostics

  return diags
}

func resourceOrderUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  return resourceOrderRead(ctx, d, m)
}

func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  // Warning or errors can be collected in a slice type
  var diags diag.Diagnostics

  return diags
}</pre>
<p>我们需要将其注册到Provider：</p>
<pre class="crayon-plain-tag">func Provider() *schema.Provider {
	return &amp;schema.Provider{
		// ...
		ResourcesMap: map[string]*schema.Resource{
			"hashicups_order": resourceOrder(),
		},
		// ...
	}
}</pre>
<div class="blog_h3"><span class="graybg">订单Schema </span></div>
<p>不同于上问的订单数据源，这里我们设计了和API结构更加匹配的Schema：</p>
<pre class="crayon-plain-tag">Schema: map[string]*schema.Schema{
			"items": {
				Type:     schema.TypeList,
				Required: true,
				Elem: &amp;schema.Resource{
					Schema: map[string]*schema.Schema{
						"coffee": {
							Type:     schema.TypeList, // coffee明明是一个，还非得声明为列表
							MaxItems: 1,
							Required: true,
							Elem: &amp;schema.Resource{
								Schema: map[string]*schema.Schema{
									"id": {
										Type:     schema.TypeInt,
										Required: true,
									},
									"name": {
										Type:     schema.TypeString,
										Computed: true,
									},
									"teaser": {
										Type:     schema.TypeString,
										Computed: true,
									},
									"description": {
										Type:     schema.TypeString,
										Computed: true,
									},
									"price": {
										Type:     schema.TypeInt,
										Computed: true,
									},
									"image": {
										Type:     schema.TypeString,
										Computed: true,
									},
								},
							},
						},
						"quantity": {
							Type:     schema.TypeInt,
							Required: true,
						},
					},
				},
			},
		}</pre>
<p>注意和订单数据源Schema的其它几个重要区别：</p>
<ol>
<li>在资源Schema中，顶级的id属性不存在。这是因为资源中你<span style="background-color: #c0c0c0;">无法提前知道id，也不能将id作为输入参数。id是在资源创建过程中自动生成</span>的</li>
<li>在资源Schema中，items是<span style="background-color: #c0c0c0;">必须字段，而不是计算出的字段。这是因为我们在配置中声明订单资源时必须提供订单项信息</span></li>
</ol>
<div class="blog_h3"><span class="graybg">创建操作 </span></div>
<pre class="crayon-plain-tag">func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	// 元参数的真实类型是HashCups客户端
	c := m.(*hc.Client)
	var diags diag.Diagnostics

	// 从ResourceData中获得订单条目（的参数）
	items := d.Get("items").([]interface{})
	ois := []hc.OrderItem{}

	// 遍历订单条目，构造为HashCups客户端所需的OrderItem
	for _, item := range items {
		i := item.(map[string]interface{})

		co := i["coffee"].([]interface{})[0] // 只取第一个coffee，为何非要定义为列表
		coffee := co.(map[string]interface{})

		oi := hc.OrderItem{
			Coffee: hc.Coffee{
				ID: coffee["id"].(int),
			},
			Quantity: i["quantity"].(int),
		}

		ois = append(ois, oi)
	}

	// 调用客户端创建订单
	o, err := c.CreateOrder(ois)
	if err != nil {
		return diag.FromErr(err)
	}

	// 将生成的标识符设置为资源ID
	d.SetId(strconv.Itoa(o.ID))

	return diags
}</pre>
<div class="blog_h3"><span class="graybg">创建后填充状态</span></div>
<p>实现了创建操作后，必须同时实现读操作，并<span style="background-color: #c0c0c0;">在创建操作中调用读操作，这样才能在创建资源后，立即以最新资源填充state</span>：</p>
<pre class="crayon-plain-tag">func resourceOrderCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	// ...
	d.SetId(strconv.Itoa(o.ID))

	resourceOrderRead(ctx, d, m)

	return diags
}</pre>
<div class="blog_h3"><span class="graybg">读操作 </span></div>
<pre class="crayon-plain-tag">func resourceOrderRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
  c := m.(*hc.Client)
  var diags diag.Diagnostics
  // 读操作时，资源的ID是已知的（从state中获取）
  orderID := d.Id()
  // 根据ID查询订单
  order, err := c.GetOrder(orderID)
  if err != nil {
    return diag.FromErr(err)
  }
  // 结构转换
  orderItems := flattenOrderItems(&amp;order.Items)
  if err := d.Set("items", orderItems); err != nil {
    return diag.FromErr(err)
  }

  return diags
}

func flattenOrderItems(orderItems *[]hc.OrderItem) []interface{} {
  if orderItems != nil {
    ois := make([]interface{}, len(*orderItems), len(*orderItems))

    for i, orderItem := range *orderItems {
      oi := make(map[string]interface{})

      oi["coffee"] = flattenCoffee(orderItem.Coffee)
      oi["quantity"] = orderItem.Quantity
      ois[i] = oi
    }

    return ois
  }

  return make([]interface{}, 0)
}

func flattenCoffee(coffee hc.Coffee) []interface{} {
  c := make(map[string]interface{})
  c["id"] = coffee.ID
  c["name"] = coffee.Name
  c["teaser"] = coffee.Teaser
  c["description"] = coffee.Description
  c["price"] = coffee.Price
  c["image"] = coffee.Image

  return []interface{}{c}
}</pre>
<div class="blog_h3"><span class="graybg">编写Terraform配置</span></div>
<p>下面的配置，创建了一个订单，并且输出其内容</p>
<pre class="crayon-plain-tag">resource "hashicups_order" "edu" {
  // 订单的Schema中，items是List，要为其声明多个元素，多次添加items块
  items {
    // 订单的Schema中，coffee也是一个List
    coffee {
      id = 3
    }
    quantity = 2
  }
  items {
    coffee {
      id = 2
    }
    quantity = 2
  }
}

output "edu_order" {
  value = hashicups_order.edu
}</pre>
<div class="blog_h2"><span class="graybg">实现更新操作</span></div>
<div class="blog_h3"><span class="graybg">更新订单Schema </span></div>
<pre class="crayon-plain-tag">func resourceOrder() *schema.Resource {
	return &amp;schema.Resource{
		// ...
		UpdateContext: resourceOrderUpdate,
		Schema: map[string]*schema.Schema{
			"last_updated": &amp;schema.Schema{
				Type:     schema.TypeString,
				Optional: true,  // 可选字段，允许配置时不提供
				Computed: true,  // 在创建时自动计算出（由Provider给出）
			},
			"items": &amp;schema.Schema{ 
			/...</pre>
<div class="blog_h3"><span class="graybg">更新操作</span></div>
<pre class="crayon-plain-tag">// 判断指定的键，对应的值是否改变了。通过对比比较配置文件前后的差异达成
	if d.HasChange("items") {
		items := d.Get("items").([]interface{})
		ois := []hc.OrderItem{}

		for _, item := range items {
			i := item.(map[string]interface{})

			co := i["coffee"].([]interface{})[0]
			coffee := co.(map[string]interface{})

			oi := hc.OrderItem{
				Coffee: hc.Coffee{
					ID: coffee["id"].(int),
				},
				Quantity: i["quantity"].(int),
			}
			ois = append(ois, oi)
		}
		// 调用HashCups API更新灯胆
		_, err := c.UpdateOrder(orderID, ois)
		if err != nil {
			return diag.FromErr(err)
		}

		// 更新 last_updated字段
		d.Set("last_updated", string(time.Now().Format(time.RFC850)))
	}
	
	// 总是重新读取订单最新状态
	return resourceOrderRead(ctx, d, m)</pre>
<div class="blog_h2"><span class="graybg">实现删除操作 </span></div>
<div class="blog_h3"><span class="graybg">删除操作 </span></div>
<pre class="crayon-plain-tag">func resourceOrderDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
	c := m.(*hc.Client)
	var diags diag.Diagnostics

	orderID := d.Id()
	// 调用HashiCups API执行删除
	err := c.DeleteOrder(orderID)
	if err != nil {
		return diag.FromErr(err)
	}

	// 注意：d.SetId("") 会在本方法无错误返回的情况下，自动调用
	// 通常不需要手工调用
	d.SetId("")

	return diags
}</pre>
<p>Terraform处理该回调的返回值的逻辑：</p>
<ol>
<li>如果没有返回错误，则Terraform认为资源被删除，其所有状态信息被从state中移除</li>
<li>如果返回了错误，则Terraform认为资源仍然存在，其所有状态信息会被保留</li>
</ol>
<p>该回调：</p>
<ol>
<li><span style="background-color: #c0c0c0;">永远不应该更新资源的任何状态</span></li>
<li>总是应当处理<span style="background-color: #c0c0c0;">资源已经被删除的场景</span>，这种情况下不应该返回错误</li>
<li>如果云API不支持删除操作，则该回调应该检查资源是否存在，如果不存在则设置ID为""，确保资源从state中删除</li>
</ol>
<div class="blog_h2"><span class="graybg">实现导入操作</span></div>
<p>导入操作能够通过API拉取已经存在的订单，并且同步到Terraform state中，不进行创建订单的操作。 <span style="background-color: #c0c0c0;">导入的资源</span>和通过Terraform创建的资源已有，<span style="background-color: #c0c0c0;">被Terraform管理</span>。</p>
<div class="blog_h3"><span class="graybg">导入操作</span></div>
<pre class="crayon-plain-tag">func resourceOrder() *schema.Resource {
	return &amp;schema.Resource{
		// ...
		Importer: &amp;schema.ResourceImporter{
			StateContext: schema.ImportStatePassthroughContext,
		},
	}
}</pre>
<p>StateContext被设置为Terraform库提供的函数schema.ImportStatePassthroughContext，该函数签名为：</p>
<pre class="crayon-plain-tag">// StateContextFunc用于导入资源到Terraform state。入参是仅仅设置了ID的资源，这个ID
// 由用户提供，因此需要校验
// 返回值是ResourceData，代表需要存入state的资源状态。最简单的情况下，仅仅包含原封不动的入参
type StateContextFunc func(context.Context, *ResourceData, interface{}) ([]*ResourceData, error)</pre>
<p>函数的实现很简单，就是直接把入参返回：</p>
<pre class="crayon-plain-tag">func ImportStatePassthroughContext(ctx context.Context, d *ResourceData, m interface{}) ([]*ResourceData, error) {
	return []*ResourceData{d}, nil
}</pre>
<div class="blog_h3"><span class="graybg">导入命令 </span></div>
<pre class="crayon-plain-tag">terraform import hashicups_order.sample &lt;order_id&gt;</pre>
<p>上述命令将名为sample的hasicups_order和指定的订单ID关联起来。Terraform会调用Importer，并将ID传递给resourceOrderRead来读取完整的状态。</p>
<div class="blog_h3"><span class="graybg">关于ID</span></div>
<p>从导入操作我们可以看到，资源的唯一性ID很重要。某些情况下，我们可能需要从云API的多个字段去构造ID，例如<pre class="crayon-plain-tag">&lt;region&gt;:&lt;resource_id&gt;</pre></p>
<div class="blog_h2"><span class="graybg">发布Provider</span></div>
<p>Terraform Registry是Provider和Module的公共仓库。如果你开发了有复用价值的Provider，可以<a href="https://learn.hashicorp.com/tutorials/terraform/provider-release-publish?in=terraform/providers">考虑上传到其中</a>。</p>
<div class="blog_h2"><span class="graybg">深入Schema </span></div>
<p>几乎所有Provider为用户提供配置参数，以实现云API的访问凭证、Region信息等的可定制化。下面的资源，允许你提供uuid、name两个参数：</p>
<pre class="crayon-plain-tag">func resourceExampleResource() *schema.Resource {
    return &amp;schema.Resource{
        // 每个资源，从根上来说，是一个{键值}结构
        //                 需要为每个值指定Schema
        Schema: map[string]*schema.Schema{
            "uuid": {
                // 指定值的类型
                Type:     schema.TypeString,
                // 这个字段会在资源创建时，自动生成
                Computed: true,
            },

            "name": {
                Type:         schema.TypeString,
                // 这是必须字段，用户必须提供
                Required:     true,
                // 该字段改变后，资源会被删除、重新创建
                ForceNew:     true,
                // 该字段的校验逻辑
                ValidateFunc: validatName,
            },
        },
    }
}</pre>
<div class="blog_h3"><span class="graybg">类型</span></div>
<p>类型可以分为两大类：基本类型、聚合类型。</p>
<p>基本类型包括：</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 90px; text-align: center;">类型</td>
<td style="width: 55%; text-align: center;">Schema示例<br />配置示例</td>
<td style="text-align: center;">状态表示</td>
</tr>
</thead>
<tbody>
<tr>
<td><span style="color: #000000;">TypeBool<br />(bool)</span></td>
<td>
<pre class="crayon-plain-tag">"encrypted": {
  Type:     schema.TypeBool,
}, </pre><br />
<pre class="crayon-plain-tag">resource "example_volume" "ex" {
  encrypted = true
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"encrypted": "true",</pre></p>
<p>&nbsp;</p>
</td>
</tr>
<tr>
<td>TypeInt<br />(int)</td>
<td>
<pre class="crayon-plain-tag">"cores": {
  Type:     schema.TypeInt,
},</pre><br />
<pre class="crayon-plain-tag">resource "example_compute_instance" "ex" {
  cores = 16
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"cores": "16",</pre>
</td>
</tr>
<tr>
<td>TypeFloat<br />(float64)</td>
<td>
<pre class="crayon-plain-tag">"price": {
  Type:     schema.TypeFloat,
},</pre><br />
<pre class="crayon-plain-tag">resource "example_spot_request" "ex" {
  price = 0.37
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"price": "0.37",</pre>
</td>
</tr>
<tr>
<td rowspan="2">
<p>TypeString</p>
<p>(string)</p>
</td>
<td>
<pre class="crayon-plain-tag">"name": {
  Type:     schema.TypeString,
},</pre><br />
<pre class="crayon-plain-tag">resource "example_spot_request" "ex" {
  description = "Managed by Terraform"
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"description": "Managed by Terraform",</pre>
</td>
</tr>
<tr>
<td>
<p>日期时间，也使用TypeString，配合校验函数：</p>
<pre class="crayon-plain-tag">"expiration": {
  Type:         schema.TypeString,
  ValidateFunc: validation.ValidateRFC3339TimeString,
},

resource "example_resource" "ex" {
  expiration = "2006-01-02T15:04:05+07:00"
} </pre>
</td>
<td>
<pre class="crayon-plain-tag">"expiration": "2006-01-02T15:04:05+07:00",</pre>
</td>
</tr>
</tbody>
</table>
<p>聚合类型包括：
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 90px; text-align: center;">类型</td>
<td style="width: 55%; text-align: center;">Schema示例<br />配置示例</td>
<td style="text-align: center;">状态表示</td>
</tr>
</thead>
<tbody>
<tr>
<td>
<p>TypeMap</p>
</td>
<td>
<p>(map[string]interface{})</p>
<pre class="crayon-plain-tag">"tags": {
  Type:     schema.TypeMap,
  Elem: &amp;schema.Schema{
    Type: schema.TypeString,
  },
},</pre><br />
<pre class="crayon-plain-tag">resource "example_compute_instance" "ex" {
  tags {
    env = "development"
    name = "example tag"
  }
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">#     元素数量
"tags.%": "2",
"tags.env": "development",
"tags.name": "example tag",</pre>
</td>
</tr>
<tr>
<td>TypeList</td>
<td>
<p>([]interface{})
<pre class="crayon-plain-tag">"termination_policies": {
  Type:     schema.TypeList,
  Elem: &amp;schema.Schema{
    Type: schema.TypeString,
  },
},</pre><br />
<pre class="crayon-plain-tag">resource "example_compute_instance" "ex" {
  termination_policies = ["OldestInstance",
    "ClosestToNextInstanceHour"]
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">#             元素数量
"name_servers.#": "4",
"name_servers.0": "ns-1508.awsdns-60.org",
"name_servers.1": "ns-1956.awsdns-52.co.uk",</pre>
<p>&nbsp;</p>
</td>
</tr>
<tr>
<td>TypeSet</td>
<td>
<p>(*schema.Set)</p>
<pre class="crayon-plain-tag">"ingress": {
  Type:     schema.TypeSet,
  Elem: &amp;schema.Resource{
    Schema: map[string]*schema.Schema{
      "from_port": {
        Type:     schema.TypeInt,
        Required: true,
      },

      "to_port": {
        Type:     schema.TypeInt,
        Required: true,
      },
  },
}</pre><br />
<pre class="crayon-plain-tag">resource "example_security_group" "ex" {
  name        = "sg_test"              
  description = "managed by Terraform" 

  ingress {                            
    protocol    = "tcp"                
    from_port   = 80                   
    to_port     = 9000                 
    cidr_blocks = ["10.0.0.0/8"]       
  }                                    

  ingress {                            
    protocol    = "tcp"                
    from_port   = 80                   
    to_port     = 8000                 
    cidr_blocks = ["0.0.0.0/0", "10.0.0.0/8"]
  }                                    
}</pre>
</td>
<td>
<pre class="crayon-plain-tag">"ingress.#": "2",
"ingress.1061987227.cidr_blocks.#": "1",
"ingress.1061987227.cidr_blocks.0": "10.0.0.0/8",
"ingress.1061987227.description": "",
"ingress.1061987227.from_port": "80",
"ingress.1061987227.ipv6_cidr_blocks.#": "0",
"ingress.1061987227.protocol": "tcp",
"ingress.1061987227.security_groups.#": "0",
"ingress.1061987227.self": "false",</pre>
</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">行为</span></div>
<p>Schema的一些字段的设置，会对Terraform的plan/apply行为产生影响。
<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>Optional</td>
<td>是否在配置中是可选的</td>
</tr>
<tr>
<td>Required</td>
<td>是否必须在配置中提供</td>
</tr>
<tr>
<td>Computed</td>
<td>提示字段不能由用户提供，并且在terraform apply之前，其值是未知的</td>
</tr>
<tr>
<td>ForceNew</td>
<td>对资源的该字段的修改，会导致删除并重新创建资源</td>
</tr>
<tr>
<td>Default</td>
<td>如果用户没有配置，使用的默认值</td>
</tr>
<tr>
<td>DiffSuppressFunc</td>
<td>
<p>用于计算该字段的（前后值的）差异，下面的例子，不区分大小写：</p>
<pre class="crayon-plain-tag">"base_image": {
  Type:     schema.TypeString,
  DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool {
    if strings.ToLower(old) == strings.ToLower(new) {
      return true 
    }
    return false
  },
}, </pre>
</td>
</tr>
<tr>
<td>DefaultFunc</td>
<td>用于动态提供默认值</td>
</tr>
<tr>
<td>StateFunc</td>
<td>将字段转换为一个字符串，该字符串存储在state中</td>
</tr>
<tr>
<td>ValidateFunc</td>
<td>校验该字段</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">深入资源</span></div>
<div class="blog_h3"><span class="graybg">CustomizeDiff</span></div>
<p>Terrafrom通过比较用户提供的配置、资源的state，来确定是否需要更新。
<p>可以为schema.Resource传递CustomizeDiff，该回调和Terraform生成的Diff（表示资源的变更）一起工作，它可以：</p>
<ol>
<li>修改Diff</li>
<li>否决Diff，终止计划</li>
</ol>
<pre class="crayon-plain-tag">type CustomizeDiffFunc func(context.Context, *ResourceDiff, interface{}) error</pre>
<div class="blog_h3"><span class="graybg">超时</span></div>
<p>云上的很多操作比较耗时，例如启动操作系统、跨越网络边缘复制状态。开发Provider时，应该注意考虑云API的延迟，Terraform支持为资源的各种操作设置超时：</p>
<pre class="crayon-plain-tag">func resourceExampleInstance() *schema.Resource {
    return &amp;schema.Resource{
        // ...
        Timeouts: &amp;schema.ResourceTimeout{
            Create: schema.DefaultTimeout(45 * time.Minute),
        },
    }
}</pre>
<div class="blog_h3"><span class="graybg">重试</span></div>
<p>Terraform提供了一个重试助手函数：</p>
<pre class="crayon-plain-tag">type RetryFunc func() *RetryError

func RetryContext(ctx context.Context, timeout time.Duration, f RetryFunc) error</pre>
<p>RetryContext能够反复重试RetryFunc：</p>
<ol>
<li>timeout指定Terraform调用RetryFunc的最大用时。可以传递<pre class="crayon-plain-tag">schema.TimeoutCreate</pre>给<pre class="crayon-plain-tag">*schema.ResourceData.Timeout()</pre>获取用户配置的超时值</li>
<li>RetryFunc可以返回：
<ol>
<li>resource.NonRetryableError，这样直接导致重试终止</li>
<li>resource.RetryableError，这样会重试</li>
</ol>
</li>
</ol>
<div class="blog_h2"><span class="graybg">可接受测试</span></div>
<p>基于测试用例来组织，每个用例使用1-N个Terraform配置，创建一些资源，并并验证实际创建的基础设施对象符合预期。</p>
<p>Terraform的<pre class="crayon-plain-tag">resource</pre>包提供了<pre class="crayon-plain-tag">Test()</pre>方法，该方法是Terraform可接受测试框架的入口点，它接受两个参数：</p>
<p style="padding-left: 30px;"><pre class="crayon-plain-tag">*testing.T</pre> 来自Go语言的测试框架<br /><pre class="crayon-plain-tag">TestCase</pre>开发者提供的用于设立可接受测试的结构</p>
<p>下面是一个例子，它测试一个名为Example的Provider，被测试的资源是Widget：</p>
<pre class="crayon-plain-tag">package example

var testAccProviders map[string]*schema.Provider
var testAccProvider *schema.Provider

func init() {
	testAccProvider = Provider()
	// 被测试Provider
	testAccProviders = map[string]*schema.Provider{
		"example": testAccProvider,
	}
}

// 方法命名约定TestAccXxx
func TestAccExampleWidget_basic(t *testing.T) {
	var widgetBefore, widgetAfter example.Widget
	rName := acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)

	// 大部分可接受测试，都是仅仅调用Test方法并退出
	// 任何时候（不管是PreCheck还是Steps...）出错，框架都会调用t.Error()方法，导致测试失败并终止
	resource.Test(t, resource.TestCase{
		// 是否单元测试。该参数允许在不考虑TF_ACC环境变量的情况下运行测试。应当仅仅用于本地资源的快速测试
		// 默认情况下，如果没有设置环境变量TF_ACC，测试会立即失败
		IsUnitTest: false,
		// 在任何Steps之前，允许的回调，通常用来检查测试需要的值（例如用于配置Provider的环境变量）存在
		PreCheck: func() { testAccPreCheck(t) },

		// map[string]*schema.Provider类型，表示将被测试的Providers。配置文件中引用的任何Provider都
		// 需要在此配置，否则用例会报错
		Providers:    testAccProviders,
		// 注意：Providers已经废弃，请使用ProviderFactories代替
		ProviderFactories: map[string]func() (*schema.Provider, error),
		// 类似ProviderFactories，但是用于基于terraform-plugin-go ProviderServer接口实现
		// Protocol V5的Provider
		ProtoV5ProviderFactories map[string]func() (tfprotov5.ProviderServer, error)
		// 类似ProviderFactories，但是用于基于terraform-plugin-go ProviderServer接口实现
		// Protocol V6的Provider
		ProtoV6ProviderFactories map[string]func() (tfprotov6.ProviderServer, error)

		// 所有Steps运行之后，并且Terraform已经针对state运行了destroy命令之后执行的回调
		// 该方法以最后一次已知的staet作为入参，通常直接使用基础设施的SDK来确认目标对象已都不存在
		// 如果应该被删除的对象仍然存在，此方法应该报错
		CheckDestroy: testAccCheckExampleResourceDestroy,
		// 允许Provider有选择性的处理错误，例如基于特定错误，跳过某些测试
		ErrorCheck ErrorCheckFunc,
		// 一个TestStep，通常对应单次apply。基础的测试包含1-2个Step，验证资源可以被创建，然后更新
		Steps: []resource.TestStep{
			{
				Config: testAccExampleResource(rName),
				Check: resource.ComposeTestCheckFunc(
					testAccCheckExampleResourceExists("example_widget.foo", &amp;widgetBefore),
				),
			},
			{
				Config: testAccExampleResource_removedPolicy(rName),
				Check: resource.ComposeTestCheckFunc(
					testAccCheckExampleResourceExists("example_widget.foo", &amp;widgetAfter),
				),
			},
		},
	})
}</pre>
<div class="blog_h3"><span class="graybg">TestStep</span></div>
<p>每个TestStep需要Terraform Configuration作为输入，提供多种验证被测试资源行为的方法。</p>
<p>Terraform的测试框架支持两个独立的测试模式：</p>
<ol>
<li>Lifecycle模式，常用，提供1-N个配置文件并测试Provider在terraform apply时的行为</li>
<li>Import模式，测试Provider在terraform import时的行为</li>
</ol>
<p>测试模式被传递给TestStep的字段隐式的确定。</p>
<p>每个TestStep包含一个需要被Apply的配置（由Config字段给出）、0-N个校验（在Check字段中编排），多个TestStep按顺序，依次执行。</p>
<p>当需要执行多个校验时，需要使用下面的函数之一来编排：<pre class="crayon-plain-tag">ComposeTestCheckFunc</pre>、<pre class="crayon-plain-tag">ComposeAggregateTestCheckFunc</pre>。示例：</p>
<pre class="crayon-plain-tag">Steps: []resource.TestStep{
  {
    Check: resource.ComposeTestCheckFunc(
      // 检查资源是否存在于基础设施
      testAccCheckExampleResourceExists("example_widget.foo", &amp;widgetBefore), 
      // 校验资源属性
      resource.TestCheckResourceAttr("example_widget.foo", "size", "expected size"),
    ),
  },
},</pre>
<p><span style="background-color: #c0c0c0;">ComposeAggregateTestCheckFunc</span>的区别是，尽管也是顺序执行校验，但是<span style="background-color: #c0c0c0;">某个校验失败，并不会立即停止</span>，还会继续执行其它校验，收集所有错误。 </p>
<div class="blog_h2"><span class="graybg">调试</span></div>
<div class="blog_h3"><span class="graybg">基于日志</span></div>
<p>Terratform将所有stderr的输出都通过gRPC协议传输到CLI，并打印到控制台。编写插件时，<span style="background-color: #c0c0c0;">绝不要将日志打印到stdout，因为stdout作为Terraform内部到CLI的通信通道</span>。</p>
<p>建议使用Go内置的<pre class="crayon-plain-tag">log.Println</pre>或<pre class="crayon-plain-tag">log.Printf</pre>进行日志输出。日志的每行必须以<span style="background-color: #c0c0c0;">[日志级别]</span>开始，支持的级别包括<span style="background-color: #c0c0c0;">ERROR WARN INFO DEBUG TRACE</span>。示例：</p>
<pre class="crayon-plain-tag">log.Println("[DEBUG] Something happened!")</pre>
<p>Terraform使用环境变量<pre class="crayon-plain-tag">TF_LOG</pre>来控制Provider、CLI的日志输出级别：</p>
<pre class="crayon-plain-tag">export TF_LOG=DEBUG</pre>
<p>可以使用环境变量<pre class="crayon-plain-tag">TF_LOG_CORE</pre>、<pre class="crayon-plain-tag">TF_LOG_PROVIDER</pre>为Terraform核心、Provider设置不同的日志级别。</p>
<p>如果需要输出到文件，使用环境变量<pre class="crayon-plain-tag">TF_LOG_PATH</pre>，默认输出到CLI的stderr。</p>
<div class="blog_h3"><span class="graybg">单步跟踪</span></div>
<p>使用下面的代码，在Provider中启用单步跟踪支持：</p>
<pre class="crayon-plain-tag">func main() {
    var debugMode bool

    flag.BoolVar(&amp;debugMode, "debug", false, "set to true to run the provider with support for debuggers like delve")
    flag.Parse()

    opts := &amp;plugin.ServeOpts{ProviderFunc: provider.New}

    if debugMode {
        //            调试模式
        err := plugin.Debug(context.Background(), "registry.terraform.io/my-org/my-provider", opts)
        if err != nil {
            log.Fatal(err.Error())
        }
        return
    }
    //     正常模式
    plugin.Serve(opts)
}</pre>
<p>关于调试模式，需要注意：</p>
<ol>
<li>在此模式下，Terraform不会启动Provider进程，你需要手工启动它：<br />
<pre class="crayon-plain-tag">dlv exec --headless ./terraform-provider-my-provider -- --debug</pre>
</li>
<li>你需要将IDE连接到到dlv进程，这时Provider会打印日志：<br />
<pre class="crayon-plain-tag">Provider started, to attach Terraform set the TF_REATTACH_PROVIDERS env var:

        TF_REATTACH_PROVIDERS='{"registry.terraform.io/my-org/my-provider":{"Protocol":"grpc","Pid":3382870,"Test":true,"Addr":{"Network":"unix","String":"/tmp/plugin713096927"}}}' </pre>
</li>
<li>你需要export上述日志输出的环境变量，然后执行Terraform CLI</li>
<li>在terraform init时不会对Provider进行约束检查</li>
<li>当遍历了Terraform资源依赖图后，Provider进程不会被重启 </li>
</ol>
<div class="blog_h3"><span class="graybg">可接受测试中的单步跟踪</span></div>
<p>在运行可接受测试的时候，直接在当前测试进程中运行Provider，因此可以进行单步跟踪而不需要特别设置。</p>
<div class="blog_h1"><span class="graybg">基于TPF开发Provider</span></div>
<p>Terraform Plugin Framework是新的（但还不稳定）Provider开发方式，关于它的优缺点，上文已经介绍过。本章主要以例子说明如何使用该框架。</p>
<p>要使用TPF，在go.mod增加依赖：</p>
<pre class="crayon-plain-tag">github.com/hashicorp/terraform-plugin-framework v0.4.2 </pre>
<div class="blog_h2"><span class="graybg">入口点</span></div>
<pre class="crayon-plain-tag">package main

import (
    "context"
    "github.com/hashicorp/terraform-plugin-framework/tfsdk"
    "terraform-provider-hashicups/hashicups"
)

func main() {
    tfsdk.Serve(context.Background(), hashicups.New, tfsdk.ServeOpts{
        Name: "hashicups",
    })
}</pre>
<p>tfsdk.Serve函数的第二个参数，就是任何Provider需要实现的接口：</p>
<pre class="crayon-plain-tag">type Provider interface {
	// 返回配置Provider的Schema，如果Provider不需要配置返回空的Schema
	GetSchema(context.Context) (Schema, diag.Diagnostics)

	// 在Provider生命周期的最初期此方法被调用，Terraform会此方法发送用户在provider块中
	// 提供的参数，并且存放在ConfigureProviderRequest中。注意，Terraform不保证此方法调用时，
	// 所有参数都是Known的。如果Provider在某些参数在Unknown的时候仍然可被配置，建议发出警告
	// 否则发出错误
	Configure(context.Context, ConfigureProviderRequest, *ConfigureProviderResponse)

	// 返回此Provider支持的资源类型列表
	// 资源列表的键，是资源的名字，并且应当以Provider名为前缀
	GetResources(context.Context) (map[string]ResourceType, diag.Diagnostics)

	// 返回此Provider支持的数据源类型列表
	// 资源列表的键，是资源的名字，并且应当以Provider名为前缀
	GetDataSources(context.Context) (map[string]DataSourceType, diag.Diagnostics)
}</pre>
<div class="blog_h2"><span class="graybg">配置Provider</span></div>
<pre class="crayon-plain-tag">package hashicups

import (
    "context"
    "os"

    "github.com/hashicorp-demoapp/hashicups-client-go"
    "github.com/hashicorp/terraform-plugin-framework/diag"
    "github.com/hashicorp/terraform-plugin-framework/tfsdk"
    "github.com/hashicorp/terraform-plugin-framework/types"
)

var stderr = os.Stderr

func New() tfsdk.Provider {
    return &amp;provider{}
}

type provider struct {
    configured bool
    client     *hashicups.Client
}</pre>
<div class="blog_h3"><span class="graybg">Schema</span></div>
<p>GetSchema方法，获取Provider的配置参数的Schema： </p>
<pre class="crayon-plain-tag">// GetSchema
func (p *provider) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) {
    return tfsdk.Schema{
        Attributes: map[string]tfsdk.Attribute{
            "host": {
                Type:     types.StringType,
                Optional: true,
                Computed: true,
            },
            "username": {
                Type:     types.StringType,
                Optional: true,
                Computed: true,
            },
            "password": {
                Type:      types.StringType,
                Optional:  true,
                Computed:  true,
                Sensitive: true,
            },
        },
    }, nil
}

// Provider schema struct
type providerData struct {
    Username types.String `tfsdk:"username"`
    Host     types.String `tfsdk:"host"`
    Password types.String `tfsdk:"password"`
}</pre>
<div class="blog_h3"><span class="graybg">Configure </span></div>
<pre class="crayon-plain-tag">// req 代表Terraform在配置Provider时，发送过来的请求
// resp 代表响应
func (p *provider) Configure(ctx context.Context, req tfsdk.ConfigureProviderRequest, 
                                                  resp *tfsdk.ConfigureProviderResponse) {

	var config providerData
	// 从用户给出的Provider配置中取出数据，存放到providerData中
	diags := req.Config.Get(ctx, &amp;config)
	resp.Diagnostics.Append(diags...)
	if resp.Diagnostics.HasError() {
		return
	}

	// 校验username password host是否提供
	var username string
	// 值不能是未知的
	if config.Username.Unknown {
		// Cannot connect to client with an unknown value
		resp.Diagnostics.AddWarning(
			"Unable to create client",
			"Cannot use unknown value as username",
		)
		return
	}

	// 如果值没有设置（或者被明确的设置为null），则尝试从环境变量读取
	if config.Username.Null {
		username = os.Getenv("HASHICUPS_USERNAME")
	} else {
		username = config.Username.Value
	}

	// 如果配置是空字符串，并且也没有配置环境变量，则报错
	if username == "" {
		// Error vs warning - empty value must stop execution
		resp.Diagnostics.AddError(
			"Unable to find username",
			"Username cannot be an empty string",
		)
		return
	}
	// password, host类似...

	c, err := hashicups.NewClient(&amp;host, &amp;username, &amp;password)
	if err != nil {
		resp.Diagnostics.AddError(
			"Unable to create client",
			"Unable to create hashicups client:\n\n"+err.Error(),
		)
		return
	}

	p.client = c
	p.configured = true
}</pre>
<div class="blog_h2"><span class="graybg">定义资源</span></div>
<div class="blog_h3"><span class="graybg">资源模型</span></div>
<pre class="crayon-plain-tag">package hashicups

import (
    "github.com/hashicorp/terraform-plugin-framework/types"
)

// Order -
type Order struct {
    ID          types.String `tfsdk:"id"`
    Items       []OrderItem  `tfsdk:"items"`
    LastUpdated types.String `tfsdk:"last_updated"`
}

// OrderItem -
type OrderItem struct {
    Coffee   Coffee `tfsdk:"coffee"`
    Quantity int    `tfsdk:"quantity"`
}

// Coffee -
// This Coffee struct is for Order.Items[].Coffee which does not have an
// ingredients field in the schema defined in the provider code. Since the
// resource schema must match the struct exactly (any extra field will return an
// error), this struct has Ingredients commented out.
type Coffee struct {
    ID          int          `tfsdk:"id"`
    Name        types.String `tfsdk:"name"`
    Teaser      types.String `tfsdk:"teaser"`
    Description types.String `tfsdk:"description"`
    Price       types.Number `tfsdk:"price"`
    Image       types.String `tfsdk:"image"`
    // Ingredients []Ingredient   `tfsdk:"ingredients"`
}</pre>
<p>注意：资源模型必须和资源的Schema（如下节）严格匹配，如果模型里面有某字段，而Schema没有，会导致出错。</p>
<p>TPF和SDKv2比起来，一个优势是<span style="background-color: #c0c0c0;">实现了模型字段的自动绑定</span>，不再需要一个个字段手工设置。但是这个模型不一定能和云API返回的资源结构自动的相互转换。</p>
<div class="blog_h3"><span class="graybg">资源类型</span></div>
<p>首先需要声明Provider支持哪些资源，即提供资源名称（对应resource块第2标签）到资源类型<pre class="crayon-plain-tag">tfsdk.ResourceType</pre>的映射：</p>
<pre class="crayon-plain-tag">func (p *provider) GetResources(_ context.Context) (map[string]tfsdk.ResourceType, diag.Diagnostics) {
    return map[string]tfsdk.ResourceType{
        "hashicups_order": resourceOrderType{},
    }, nil
}</pre>
<p>资源类型需要实现下面的接口：</p>
<pre class="crayon-plain-tag">type ResourceType interface {
	// 返回资源的Schema
	GetSchema(context.Context) (Schema, diag.Diagnostics)

	// 创建该资源类型的Resource对象
	NewResource(context.Context, Provider) (Resource, diag.Diagnostics)
} </pre>
<div class="blog_h3"><span class="graybg">资源Schema</span></div>
<pre class="crayon-plain-tag">type resourceOrderType struct{}

func (r resourceOrderType) GetSchema(_ context.Context) (tfsdk.Schema, diag.Diagnostics) {
    return tfsdk.Schema{
        Attributes: map[string]tfsdk.Attribute{
            "id": {
                Type: types.StringType,
                Computed: true,
            },
            "last_updated": {
                Type:     types.StringType,
                Computed: true,
            },
            "items": {
                Required: true,
                Attributes: tfsdk.ListNestedAttributes(map[string]tfsdk.Attribute{
                    "quantity": {
                        Type:     types.NumberType,
                        Required: true,
                    },
                    "coffee": {
                        Required: true,
                        Attributes: tfsdk.SingleNestedAttributes(map[string]tfsdk.Attribute{
                            "id": {
                                Type:     types.NumberType,
                                Required: true,
                            },
                            "name": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                            "teaser": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                            "description": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                            "price": {
                                Type:     types.NumberType,
                                Computed: true,
                            },
                            "image": {
                                Type:     types.StringType,
                                Computed: true,
                            },
                        }),
                    },
                }, tfsdk.ListNestedAttributesOptions{}),
            },
        },
    }, nil
}</pre>
<div class="blog_h3"><span class="graybg">资源 </span></div>
<p>资源类型的NewResource方法返回的是Resource类型，它抽象了针对单个资源的CRUD操作：</p>
<pre class="crayon-plain-tag">type Resource interface {
	// 当Provider需要创建一个新资源的时候调用此方法。配置和planned state可以从
	// CreateResourceRequest读取。新的（实际完成创建后的）state则设置到
	// CreateResourceResponse
	Create(context.Context, CreateResourceRequest, *CreateResourceResponse)

	// 当Provider需要读取资源values来更新state时调用此方法。从
	// ReadResourceRequest读取planned state，新的state则设置到
	// ReadResourceResponse.
	Read(context.Context, ReadResourceRequest, *ReadResourceResponse)

	// 当Provider需要更新资源状态时调用此方法。配置和planned state可以从
	// UpdateResourceRequest读取。新的（更新后的）state则设置到
	// UpdateResourceResponse.
	Update(context.Context, UpdateResourceRequest, *UpdateResourceResponse)

	// 当Provider需要删除资源时调用此方法。配置从DeleteResourceRequest读取
	Delete(context.Context, DeleteResourceRequest, *DeleteResourceResponse)

	// 当前Provider需要导入一个资源时调用此方法。
	// 如果不支持导入，建议返回 ResourceImportStateNotImplemented()
	//
	// If setting an attribute with the import identifier, it is recommended
	// to use the ResourceImportStatePassthroughID() call in this method.
	ImportState(context.Context, ImportResourceStateRequest, *ImportResourceStateResponse)
}</pre>
<div class="blog_h3"><span class="graybg">实现创建接口</span></div>
<p>创建操作的关键步骤：</p>
<ol>
<li>从请求中读取plan数据，读取为模型</li>
<li>将模型转换为云API请求，并发送求</li>
<li>将响应转换并同步到模型，写入state</li>
</ol>
<p>需要注意：plan中的每个已知（known）值，必须和state中对应值的完全一样（逐字节相等），也就是说，<span style="background-color: #c0c0c0;">用户指定的配置不能被改变</span>，否则Terraform抛出错误。<span style="background-color: #c0c0c0;">Provider只能修改计划中unknown的值，而且必须解析所有unknown的值，state中不会有任何unknown的值</span>。</p>
<pre class="crayon-plain-tag">import (
    "context"
    "math/big"
    "strconv"
    "time"


    "github.com/hashicorp-demoapp/hashicups-client-go"
    "github.com/hashicorp/terraform-plugin-framework/diag"
    "github.com/hashicorp/terraform-plugin-framework/tfsdk"
    "github.com/hashicorp/terraform-plugin-framework/types"
)


func (r resourceOrder) Create(ctx context.Context, req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    // 必须确保Provider已经被配置过
    if !r.p.configured {
        resp.Diagnostics.AddError(
            "Provider not configured",
            "The provider hasn't been configured before apply, likely because it depends on an unknown value from another resource. This leads to weird stuff happening, so we'd prefer if you didn't do that. Thanks!",
        )
        return
    }

    // 从执行计划获取订单模型
    var plan Order
    //                注意这里会自动将配置绑定到模型，不需要手工处理
    diags := req.Plan.Get(ctx, &amp;plan)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    // 遍历订单项，将其适配为HashiCups客户端需要的入参
    var items []hashicups.OrderItem
    for _, item := range plan.Items {
        items = append(items, hashicups.OrderItem{
            Coffee: hashicups.Coffee{
                ID: item.Coffee.ID,
            },
            Quantity: item.Quantity,
        })
    }

    // 调用HashiCups客户端创建订单
    order, err := r.p.client.CreateOrder(items)
    if err != nil {
        resp.Diagnostics.AddError(
            "Error creating order",
            "Could not create order, unexpected error: "+err.Error(),
        )
        return
    }

    // 将HasiCups创建的完整订单对象，适配回模型OrderItem
    var ois []OrderItem
    for _, oi := range order.Items {
        ois = append(ois, OrderItem{
            Coffee: Coffee{
                ID:          oi.Coffee.ID,
                Name:        types.String{Value: oi.Coffee.Name},
                Teaser:      types.String{Value: oi.Coffee.Teaser},
                Description: types.String{Value: oi.Coffee.Description},
                Price:       types.Number{Value: big.NewFloat(oi.Coffee.Price)},
                Image:       types.String{Value: oi.Coffee.Image},
            },
            Quantity: oi.Quantity,
        })
    }

    // 完整订单模型
    var result = Order{
        ID:          types.String{Value: strconv.Itoa(order.ID)},
        Items:       ois,
        LastUpdated: types.String{Value: string(time.Now().Format(time.RFC850))},
    }

    // 设置状态
    diags = resp.State.Set(ctx, result)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<div class="blog_h3"><span class="graybg">实现读取接口</span></div>
<p>读取操作更新state，使其反映（通过云API得到的）云基础设施对象的最新的、真实状态。</p>
<p>实现读操作的时候，<span style="background-color: #c0c0c0;">没有plan（用户提供的配置）可用</span>。你需要<span style="background-color: #c0c0c0;">从请求中读取当前的状态，从中取得执行调用云API所需的信息</span>。</p>
<p>在更新时，Provider理论上可以修改state中的任何值，但是主要应当：</p>
<ol>
<li>处理值漂移（drift），也就是外部系统或人员修改了Terraform所拥有（创建并在状态中管理）的资源。<span style="background-color: #c0c0c0;">漂移的值总应该反映到state中</span></li>
<li>处理<span style="background-color: #c0c0c0;">语义上没有改变的值</span>，比如某个值是个JSON，它的字段顺序可能调整了，尽管新旧值并不是逐字节相等的，但是<span style="background-color: #c0c0c0;">并不应当更新state</span></li>
</ol>
<pre class="crayon-plain-tag">func (r resourceOrder) Read(ctx context.Context, req tfsdk.ReadResourceRequest, resp *tfsdk.ReadResourceResponse) {
    // 获取当前状态，状态和计划一样，都是模型
    var state Order
    diags := req.State.Get(ctx, &amp;state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }

    // 得到状态中的ID，则是访问HashiCups所需参数
    orderID := state.ID.Value

    // 通过HashiCups客户端获取资源最新信息
    order, err := r.p.client.GetOrder(orderID)
    if err != nil {
        resp.Diagnostics.AddError(
            "Error reading order",
            "Could not read orderID "+orderID+": "+err.Error(),
        )
        return
    }

    // 转换为模型
    state.Items = []OrderItem{}
    for _, item := range order.Items {
        state.Items = append(state.Items, OrderItem{
            Coffee: Coffee{
                ID:          item.Coffee.ID,
                Name:        types.String{Value: item.Coffee.Name},
                Teaser:      types.String{Value: item.Coffee.Teaser},
                Description: types.String{Value: item.Coffee.Description},
                Price:       types.Number{Value: big.NewFloat(item.Coffee.Price)},
                Image:       types.String{Value: item.Coffee.Image},
            },
            Quantity: item.Quantity,
        })
    }

    // 设置到状态
    diags = resp.State.Set(ctx, &amp;state)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<div class="blog_h3"><span class="graybg">实现更新操作</span></div>
<p>更新操作将用户对资源配置的修改，通过云API同步到基础设施对象，然后更新state。关键步骤：</p>
<ol>
<li>从请求中读取plan数据，并绑定到模型</li>
<li>将模型转换为云API请求</li>
<li>将响应转换并同步到模型，写入state</li>
</ol>
<p>需要注意：known的值在更新前后，必须在state中逐字节对应。state不能包含任何unknown的值。</p>
<div class="blog_h3"><span class="graybg">实现删除操作</span></div>
<p>删除操作读取state信息，发起云API请求删除对应基础设施对象，然后从state中清除资源记录。</p>
<p>清除资源记录时调用<pre class="crayon-plain-tag">State.RemoveResource</pre>方法。</p>
<div class="blog_h3"><span class="graybg">实现导入操作</span></div>
<p>ImportState方法可以创建一个初始的state，将资源纳管起来。此方法的实现必须<span style="background-color: #c0c0c0;">确保后续读操作能够正常刷新状态</span>。 </p>
<p>通常需要从请求中读取<pre class="crayon-plain-tag">req.ID</pre>，并设置到响应中：</p>
<pre class="crayon-plain-tag">resp.State.SetAttribute(ctx, path, req.ID)

// 或者，直接调用：
tfsdk.ResourceImportStatePassthroughID()</pre>
<div class="blog_h2"><span class="graybg">定义数据源</span></div>
<p>数据源的资源模型和资源没有区别，数据源仅仅支持Read方法。该方法无法使用plan或state，它只能从<pre class="crayon-plain-tag">tfsdk.ReadDataSourceRequest</pre>中读取调用云API所需的参数。 </p>
<div class="blog_h2"><span class="graybg">属性/值类型</span></div>
<p>属性就是Provider、资源、数据源的Schema的字段，属性持有最终落地到state的值。每个属性都有对应的类型。当你从config、state或者plan访问属性时，实际上是访问属性的值。</p>
<div class="blog_h3"><span class="graybg">Null和Unknown</span></div>
<p>任何类型的属性，都可以持有这两种值。</p>
<p>Null表示值不存在，通常是由于用户没有给optional属性赋值。required属性永远不会是Null。</p>
<p>Unknown表示属性的值尚不知道。Unknown值和Terraform的依赖管理有关。Terraform会构建资源之间的依赖DAG，当资源A引用了资源B的属性a，并且a此时的值是Unknown，则此时就Terraform就会转而去处理B，通过调用云API取得a的值，然后再处理A。资源创建/读取后，任何字段都不能是Unknown的。</p>
<div class="blog_h3"><span class="graybg">内置类型和值</span></div>
<p>TPF的<pre class="crayon-plain-tag">types</pre>包，提供了一系列内置属性类型。每个属性类型对应两个Go结构，其中一个用做Schema中的属性类型声明（tfsdk.Attribute.Type），另外一个在定义模型时，作为模型的字段类型。</p>
<table class="full-width fixed-word-wrap">
<thead>
<tr>
<td style="width: 200px; text-align: center;">Schema属性类型<br />值（模型字段）类型</td>
<td style="text-align: center;">说明</td>
</tr>
</thead>
<tbody>
<tr>
<td style="text-align: left;">StringType<br />String</td>
<td>
<p>表示UTF-8编码的字符串</p>
</td>
</tr>
<tr>
<td>Int64Type<br />Int64</td>
<td>64bit整数</td>
</tr>
<tr>
<td>Float64Type<br />Float64</td>
<td>64bit浮点数</td>
</tr>
<tr>
<td>NumberType<br />Number</td>
<td>通用数字类型，对应Go语言的<pre class="crayon-plain-tag">*big.Float</pre></td>
</tr>
<tr>
<td>BoolType<br />Bool</td>
<td>布尔类型</td>
</tr>
<tr>
<td>ListType<br />List</td>
<td>
<p>列表类型</p>
<p>types.ListType的<pre class="crayon-plain-tag">ElemType</pre>属性，说明元素的类型</p>
<p>types.List的属性：</p>
<p style="padding-left: 30px;">ElemType，总是和ListType的ElemType一致<br />Elem，是值的列表，每个值的真实类型为ElemType<br />Null，当整个列表的值是null时，为true</p>
<p>types.List的非Null、非Unknown元素，可以直接通过其<pre class="crayon-plain-tag">ElementsAs</pre>方法访问，不需要类型断言</p>
</td>
</tr>
<tr>
<td>SetType<br />Set</td>
<td>类似于列表，但是元素唯一、无序</td>
</tr>
<tr>
<td>MapType<br />Map</td>
<td>
<p>具有string类型键的映射</p>
<p>types.Map的属性：</p>
<p style="padding-left: 30px;">ElemType，总是和MapType的ElemType一致<br />Elem，是一个映射，每个键是字符串，每个值的真实类型为ElemType<br />Null，当整个映射是null时，为true</p>
<p>types.Map的非Null、非Unknown元素，可以直接通过其<pre class="crayon-plain-tag">ElementsAs</pre>方法访问，不需要类型断言</p>
</td>
</tr>
<tr>
<td>ObjectType<br />Object</td>
<td>
<p>所谓对象，是指其它若干其它不限类型的属性的无序集合，每个属性都被赋予名字</p>
<p>你需要通过<pre class="crayon-plain-tag">AttrTypes</pre>为对象的所有属性声明名称和类型</p>
<p>types.Object的属性：</p>
<p style="padding-left: 30px;">AttrTypes，总是和ObjectType.AttrType一致<br />Attrs，从属性名到属性值的映射，每个属性都保证在其中，不管它的值是什么<br />Null，当整个Object是null时，为true</p>
<p>非Null、非Unknown的types.Object的值，可以使用<pre class="crayon-plain-tag">As</pre>方法转换为Go结构，不需要类型断言</p>
</td>
</tr>
</tbody>
</table>
<div class="blog_h2"><span class="graybg">访问状态/配置/计划</span></div>
<p>很多情况下Provider需要访问用户提供的配置数据、Terraform的状态、生成的执行计划中的数据。这些数据通常存放在请求对象中：</p>
<pre class="crayon-plain-tag">func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse)</pre>
<div class="blog_h3"><span class="graybg">整体读写 </span></div>
<p>最简单的访问配置/计划/状态的方法是，将其转换为一个Go类型（模型）：</p>
<pre class="crayon-plain-tag">type resourceData struct {
    Name types.String `tfsdk:"name"`
    Age types.Number `tfsdk:"age"`
    Registered types.Bool `tfsdk:"registered"`
    Pets types.List `tfsdk:"pets"`
    Tags types.Map `tfsdk:"tags"`
    Address types.Object `tfsdk:"address"`
}

func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    var plan resourceData
    diags := req.Plan.Get(ctx, &amp;plan)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    // 可以通过plan.Name.Value访问计划中的值，你需要检查
    // plan.Name.Null 判断值是否是null的
    // plan.Name.Unknown 判断值是否是unknown的
}</pre>
<p>上面这些模型，字段类型都是<pre class="crayon-plain-tag">attr.Value</pre>的实现。其好处是，能够知晓值是不是Null、Unknown的，但是带来了不必要的复杂性：</p>
<ol>
<li>这些类型都是Terraform私有的，不可能在云API的SDK中使用这些类型。因此将模型转换为SDK类型时就有了额外的负担，难以自动化转换</li>
</ol>
<p>好在<span style="background-color: #c0c0c0;">Get方法能够将值转换为Go类型</span>，因此你可以这样声明模型：</p>
<pre class="crayon-plain-tag">type resourceData struct {
  Name string `tfsdk:"name"`
  Age int64 `tfsdk:"age"`
  Registered bool `tfsdk:"registered"`
  Pets []string `tfsdk:"pets"`
  Tags map[string]string `tfsdk:"tags"`
  Address struct{
    Street string `tfsdk:"street"`
    City string `tfsdk:"city"`
    State string `tfsdk:"state"`
    Zip int64 `tfsdk:"zip"`
  } `tfsdk:"address"`
}</pre>
<p><span style="background-color: #c0c0c0;">警告：Null值/Unknown值可能无法转换，会导致报错</span>。参考下文的转换规则。</p>
<div class="blog_h3"><span class="graybg">读写单个属性 </span></div>
<p>另外一种访问配置/计划/状态的方法是，读取单个属性的值。这种情况下，不需要定义模型（除了ObjectType）：</p>
<pre class="crayon-plain-tag">func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    //                                          属性路径 
    attr, diags := req.Config.GetAttribute(ctx, tftypes.NewAttributePath().WithAttributeName("age"))
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
    age := attr.(types.Number)
}</pre>
<div class="blog_h3"><span class="graybg">类型转换规则</span></div>
<p>上面提到过，Terraform能够自动将属性值转换为Go类型，这里列出转换规则：</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>String</td>
<td>只要值不为null/unknown，就可以转换为string</td>
</tr>
<tr>
<td>Number</td>
<td>
<p>只要值不为null/unknown，就可以转换为多种Go数字类型</p>
<p>当转换会导致溢出、丢失精度时，返回错误</p>
</td>
</tr>
<tr>
<td>Boolean</td>
<td>只要值不为null/unknown，就可以转换为bool</td>
</tr>
<tr>
<td>
<p>List</p>
</td>
<td>只要值不为unknown，就可以转换为[]ElemType切片，如果值是unknown则返回错误</td>
</tr>
<tr>
<td>Map</td>
<td>只要值不为unknown，就可以转换为map[string]ElemType，如果值是unknown则返回错误</td>
</tr>
<tr>
<td>Object</td>
<td>
<p>只要值不为null/unknown，在满足以下约束条件时，可以转换为Go结构：</p>
<ol>
<li>结构的每个字段都定义了<pre class="crayon-plain-tag">tfsdk</pre>标签</li>
<li>tfsdk标签必须和对象的属性名字一致，或者设置为<pre class="crayon-plain-tag">-</pre>表示不映射到任何属性</li>
<li>每个属性都对应到一个Go结构字段</li>
</ol>
</td>
</tr>
<tr>
<td>转换为指针</td>
<td>值为null的时候，不会导致报错，其余和转换为非指针类型一致</td>
</tr>
</tbody>
</table>
<div class="blog_h3"><span class="graybg">影响转换的接口</span></div>
<p>Get方法在进行转换时，会自动Go类型实现的特殊接口。</p>
<p>如果Go类型实现了<pre class="crayon-plain-tag">tftypes.ValueConverter</pre>接口，则转换工作代理给此接口进行。</p>
<p>如果Go类型实现了<pre class="crayon-plain-tag">Unknownable</pre>接口，则Terraform认为它能够处理unknown值。类似的，如果实现了<pre class="crayon-plain-tag">Nullable</pre>接口则认为能够处理null值。</p>
<div class="blog_h3"><span class="graybg">写状态</span></div>
<p>配置、计划仅仅支持读操作，但是状态还支持写操作。</p>
<p>写状态的时候，调用响应的State.Set方法：</p>
<pre class="crayon-plain-tag">type resourceData struct {
    Name types.Strings `tfsdk:"name"`
}

func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    var newState resourceData
    newState.Name.Value = "J. Doe"

    // 持久化到状态中
    diags := resp.State.Set(ctx, &amp;newState)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<p>写入单个属性：</p>
<pre class="crayon-plain-tag">func (m myResource) Create(ctx context.Context,
    req tfsdk.CreateResourceRequest, resp *tfsdk.CreateResourceResponse) {
    age := types.Number{Value: big.NewFloat(7)}
    diags := resp.State.SetAttribute(ctx, tftypes.NewAttributePath().WithAttributeName("age"), &amp;age)
    resp.Diagnostics.Append(diags...)
    if resp.Diagnostics.HasError() {
        return
    }
}</pre>
<div class="blog_h2"><span class="graybg">可接受测试 </span></div>
<p>目前TPF框架依赖SDKv2的可接受测试框架，你需要编写和SDKv2可接受测试一样的PreCheck、TestStep...主要区别之处，是如何在TestCase中指定被测试的Provider。</p>
<p>在SDKv2中，通过设置TestCase的Provider属性，来指定map[string]*schema.Provider。在TPF中，则需要指定<pre class="crayon-plain-tag">ProtoV6ProviderFactories</pre>属性。该属性是<pre class="crayon-plain-tag">tfprotov6.ProviderServer</pre>的map。通过下面的方式创建tfprotov6.ProviderServer：</p>
<pre class="crayon-plain-tag">func TestAccTeleportFullVPCMigration(t *testing.T) {
	resource.Test(t, resource.TestCase{
		ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
			// 返回每个需要被测试的Provider
			"teleport": func() (tfprotov6.ProviderServer, error) {
				return tfsdk.NewProtocol6Server(New()), nil
			},
		},
		Steps: []resource.TestStep{
			{
				Config: `
# 不需要使用terraform块声明从何处加载Provider，因为此Provider就在运行在测试进程内
provider "teleport" {
  endpoint = "http://127.0.0.1:6080"
  token = ""
}
resource "teleport_fullvpcmigration" "fvm" {
  region               = "eu-moscow"
  vpc_id               = "vpc-10"
  src_app_id           = "10"
  src_uin              = "10"
  src_sub_account_uin  = "11"
  dest_app_id          = "20"
  dest_uin             = "20"
  dest_sub_account_uin = "21"
  operator             = "alex"
  migrate_timeout      = "15s"
}
`,
				Check:  nil,
			},
		},
	})
}</pre>
<div class="blog_h2"><span class="graybg">调试</span></div>
<div class="blog_h3"><span class="graybg">基于日志</span></div>
<p>参考SDKv2基于日志的调试。</p>
<div class="blog_h3"><span class="graybg">单步跟踪</span></div>
<p>目前TPF不支持向SDKv2那样，以调试模式启动Provider。</p>
<p>尽管如此，在可接受测试中，你可以单步跟踪Provider代码。</p>
<div class="blog_h2"><span class="graybg">dev_overrides</span></div>
<p>在开发阶段，你可以在.terraformrc中配置dev_overrides：</p>
<pre class="crayon-plain-tag">provider_installation {
  dev_overrides {
    "hashicorp.com/edu/hashicups-pf" = "/Users/Alex/.local/bin"
  }
}</pre>
<p>dev_overrides<span style="background-color: #c0c0c0;">必须放在所有安装方法的最前面</span>。 dev_overrides会让Terraform跳过适用版本检查、Checksum匹配检查。但是dev_overrides不能参与正常的Provider安装流程，它没有提供满足版本的元数据、不能产生锁文件。</p>
<p>因此，为了使用dev_overrides，你需要将编译好的插件存放在指定目录。然后<span style="background-color: #c0c0c0;">跳过terraform init，直接运行apply/plan等命令</span>。如果没有配置dev_overrides，跳过init会导致报错，提示Provider的本地缓存不存在或者录制的元数据不匹配。</p>
<div class="blog_h1"><span class="graybg">Terraform设置</span></div>
<p>特殊的块<pre class="crayon-plain-tag">terraform</pre>，可以针对当前配置设置Terraform自身的行为，例如指定应用当前配置所需Terraform的最小版本。</p>
<pre class="crayon-plain-tag">terraform {
  # 配置后端
  backend {
  }

  # 配置Terraform CLI的版本约束
  required_version = ""

  # 模块需要的所有provider的配置
  required_providers {
    aws = {
      version = "&gt;= 2.7.0"
      source = "hashicorp/aws"
    }
  }

  # 启用实验特性
  experiments = [example]
}</pre>
<div class="blog_h2"><span class="graybg">后端配置</span></div>
<p>每个Terraform配置，都可以指定一个后端：<span style="background-color: #c0c0c0;">后端决定了操作在何处执行，状态快照在何处存储</span>：</p>
<ol>
<li>所谓操作（operation）是指调用云API进行资源的CRUD</li>
<li>Terraform利用状态（state）来跟踪它管理的资源。它依赖于state来知晓配置文件中的资源和云上基础设施对象的对应关系，典型情况是将云上对象的ID和配置资源关联起来</li>
</ol>
<p>根据能力的不同，后端分为两类：</p>
<ol>
<li>增强后端，同时支持存储状态、执行操作，只有两个增强后端：<pre class="crayon-plain-tag">local</pre>、<pre class="crayon-plain-tag">remote</pre></li>
<li>标准后端，仅仅存储状态，并且<span style="background-color: #c0c0c0;">依赖于<pre class="crayon-plain-tag">local</pre>后端执行操作</span></li>
</ol>
<p>简单场景下可以使用local后端，不需要任何配置。后端配置仅仅被Terraform CLI使用，Terraform Cloud/Enterprise总是使用他们自己的状态存储，并忽略配置文件中的backend块。</p>
<div class="blog_h3"><span class="graybg">local </span></div>
<p>该后端在本地文件系统存储状态，并且使用系统API锁定状态数据。该后端在本地直接执行操作。不进行任何backend配置，默认使用的就是该后端。</p>
<p>示例配置：</p>
<pre class="crayon-plain-tag">terraform {
  backend "local" {
    # 状态存储路径
    path = "relative/path/to/terraform.tfstate"
    # 如果使用非默认工作区
    workspace_dir = ""
  }
}</pre>
<p>用在terraform_remote_state数据资源中：</p>
<pre class="crayon-plain-tag">data "terraform_remote_state" "foo" {
  backend = "local"
  config = {
    path = "${path.module}/../../terraform.tfstate"
  }
}</pre>
<p>使用此后端时，大部分Terraform CLI的、从后端读写state快照的命令，都支持以下选项：</p>
<p style="padding-left: 30px;">-state=FILENAME 读取先前状态快照时，使用的状态文件<br />-state-out=FILENAME 写入新的状态快照时，使用的状态文件<br />-backup=FILENAME 写入新状态快照时，先前状态的备份文件。取值 - 禁用备份</p>
<div class="blog_h3"><span class="graybg">remote</span></div>
<p>配合Terraform Cloud使用，在云端存储状态和执行操作。</p>
<p>在云端执行操作时，terraform plan / apply等命令在Terraform Cloud的运行环境下执行，日志则打印到本地终端。远程的plan/apply使用关联的Terraform云工作区中的变量。</p>
<div class="blog_h3"><span class="graybg">etcdv3</span></div>
<p>这是个标准后端，只能用于存储状态。示例配置：</p>
<pre class="crayon-plain-tag">terraform {
  backend "etcdv3" {
    # Etcd服务器列表
    endpoints = ["etcd-1:2379", "etcd-2:2379", "etcd-3:2379"]
    # 是否锁定状态访问
    lock      = true
    # 存储前缀
    prefix    = "terraform-state/"
    # 基于口令的身份验证
    username = "$ETCDV3_USERNAME"
    password = "$ETCDV3_PASSWORD"
    # 基于证书的身份验证
    cacert_path = ""
    cert_path = ""
    key_path = ""
    # 最大发送的请求，增大此值可以存放更大的状态，必须配合Etcd服务器配置 --max-request-bytes，默认2MB
  }
}

data "terraform_remote_state" "foo" {
  backend = "etcdv3"
  config = {
    endpoints = ["etcd-1:2379", "etcd-2:2379", "etcd-3:2379"]
    lock      = true
    prefix    = "terraform-state/"
  }
}</pre>
<div class="blog_h1"><span class="graybg">状态 </span></div>
<div class="blog_h2"><span class="graybg">简介</span></div>
<p>Terraform需要存储被管理资源的状态，从而实现三个目的：</p>
<ol>
<li><span style="background-color: #c0c0c0;">将真实基础设施对象绑定到配置中定义的资源（最核心的用途）</span></li>
<li>跟踪元数据，例如资源依赖关系。通常情况下，Terraform直接使用配置文件来跟踪资源依赖，但是当你删除配置文件片段后，此依赖关系可能被破坏，这是它会利用状态总存储的最近的依赖关系</li>
<li>提升管理大规模基础设施时的性能。状态中存储了所有资源的属性值，这样可以避免每次访问属性时都需要请求provider</li>
</ol>
<p>在执行任何操作之前，Terraform会执行refresh操作来将状态和基础设施同步。 当配置发生变动后，Terraform可能会：</p>
<ol>
<li>如果配置文件中定义了新的资源：调用Provider创建对应基础设施对象，并且<span style="background-color: #c0c0c0;">将对象的标识符和配置中的资源定义绑定</span>，存储在状态中。Terraform期望基础设施对象和资源定义的实例（使用count/foreach时一个块定义了多个实例）是一一对应关系</li>
<li>如果配置文件中删除了资源定义：调用Provider删除对应基础设施对象，并且清除状态中对应数据</li>
</ol>
<p>如上一章节所述，默认情况下后端local负责存储状态，它默认将状态存储到名为terraform.tfstate的文件中。Terraform还支持大量其它后端，将状态存储到Etcd、S3等各种存储服务中。</p>
<div class="blog_h3"><span class="graybg">查看和修改状态</span></div>
<p>尽管状态就是JSON文本，也不要直接修改它。可以使用<pre class="crayon-plain-tag">terraform state</pre>命令进行基本的修改。</p>
<p>通过<pre class="crayon-plain-tag">terraform import</pre>导入外部创建对象时，或者通过<pre class="crayon-plain-tag">terraform state rm</pre>让Terraform忘记某个既有对象时，你必须保证基础设施对象和资源定义的一一对应关系。</p>
<div class="blog_h2"><span class="graybg">terraform_remote_state</span></div>
<p>该数据源能够从<span style="background-color: #c0c0c0;">其它Terraform配置</span>的最新状态快照拉取<span style="background-color: #c0c0c0;">根模块的输出值</span>。这个模块是内置的，不需要配置。</p>
<p>目标配置使用local后端，读取其输出值的例子：</p>
<pre class="crayon-plain-tag">data "terraform_remote_state" "vpc" {
  backend = "local"
  config = {
    # 目标配置的状态文件
    path = "..."
  }
}

# Terraform &gt;= 0.12
resource "aws_instance" "foo" {
  #                                           读取输出值
  subnet_id = data.terraform_remote_state.vpc.outputs.subnet_id
}

# Terraform &lt;= 0.11
resource "aws_instance" "foo" {
  # ...
  subnet_id = "${data.terraform_remote_state.vpc.subnet_id}"
}</pre>
<p>注意：仅仅目标配置的根模块的输出值被暴露，其子模块的输出值是不可见的。你必须手工在根模块将子模块的输出值再次输出：</p>
<pre class="crayon-plain-tag">module "app" {
  source = "..."
}

output "app_value" {
  # This syntax is for Terraform 0.12 or later.
  value = module.app.example
}</pre>
<div class="blog_h2"><span class="graybg">状态锁定 </span></div>
<p>如果后端支持，Terraform在执行任何可能修改状态的操作时，会锁定状态，防止并发修改损坏数据。</p>
<p>对于大部分命令，都可以使用命令行选项<pre class="crayon-plain-tag">-lock</pre>禁用锁定，但是不推荐这样做。 </p>
<p>命令<pre class="crayon-plain-tag">force-unlock</pre>用于强制解锁，这个命令存在危险性，会导致并发修改。</p>
<div class="blog_h2"><span class="graybg">工作区</span></div>
<p>存储在backend中的状态数据，属于一个工作区。<span style="background-color: #c0c0c0;">最开始仅有一个名为default的工作区，因而对于一个Terraform配置来说，只有一个关联的state</span>。</p>
<p>某些backend支持多个工作区，包括S3、Local、Kubernetes等。</p>
<p>命令<pre class="crayon-plain-tag">terraform workspace</pre>用于管理工作区。</p>
<div class="blog_h3"><span class="graybg">何时使用工作区</span></div>
<p>工作区可以用于区分测试/生产环境，开发人员可以在测试环境中创建并行的、完整的基础设施，并且测试配置文件的变更。</p>
<p>当管理大规模系统时，应该重构被拆分出多个配置（而不是引入新工作区），这些配置甚至可能由不同团队管理。</p>
<div class="blog_h2"><span class="graybg">敏感数据</span></div>
<p>在Local后端中存储的状态，敏感数据直接明文保存在文件中。</p>
</div><p>The post <a rel="nofollow" href="https://blog.gmem.cc/terraform">Terraform快速参考</a> appeared first on <a rel="nofollow" href="https://blog.gmem.cc">绿色记忆</a>.</p>
]]></content:encoded>
			<wfw:commentRss>https://blog.gmem.cc/terraform/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-69e7a0e677e19943234229/] <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-69e7a0e6797b1071369879/] KeyDB配置 KeyDB通过StatefulSet管理，一共有三个实例：  [crayon-69e7a0e6797b6592342069/] 这三个实例： 由于反亲和设置，会在每个节点上各运行一个实例 启用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-69e7a0e67a78d042577716-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-69e7a0e67a791772945964/] <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-69e7a0e67af64795650854-i/]、[crayon-69e7a0e67af68475040300-i/]、[crayon-69e7a0e67af6a933174317-i/]组成。在访问宿主机端口为[crayon-69e7a0e67af6c211414311-i/]的NodePort类型的Service时，出现了很有趣的现象： 在节点[crayon-69e7a0e67af6e314268321-i/]、[crayon-69e7a0e67af70455642712-i/]节点上[crayon-69e7a0e67af72488701493-i/]，有50%几率卡住 在节点[crayon-69e7a0e67af74895864779-i/]上[crayon-69e7a0e67af76704365718-i/]，100%几率卡住 从集群内部，访问非本节点的30153端口，畅通 从集群外部，访问任意节点的30153端口，畅通 三个节点本身并无差异，卡住几率不同，可能和服务的端点（Endpoint，即Pod）的分布情况有关。 NodePort服务的定义如下： [crayon-69e7a0e67af78063256494/] 该服务的端点有两个： [crayon-69e7a0e67af7a948681650/] 可以看到，端点在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-69e7a0e67af7c720997254/] 这说明故障和容器网络没有直接关系。  分析 内层封包 <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-69e7a0e67bdda270643422/] 在该模式下： Flannel在每个Kubelet节点上分配一个子网，并将其保存在etcd和本地路径[crayon-69e7a0e67bdde482877974-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-69e7a0e67d305303046001-i/]这样的请求头获取，很多应用都能识别这种请求头。 Envoy还提供了[crayon-69e7a0e67d30a803255918-i/]，此过滤器能够从请求头读取真实源地址，并修改底层TCP连接的源地址。此过滤器还能处理单一下游连接携带来自多个源的HTTP请求的情况。此过滤器的缺点包括： 下游连接必须正确设置了x-forwarded-for头 由于连接池方面的限制，会导致些许性能影响 配置较为复杂，可能需要路由的配合，即使在Sidecar场景（Envoy和上游在同一网络命名空间）下，也需要配置好iptables规则 listener.proxy_protocol HAProxy代理协议提供了交换连接元数据的机制，这些元数据就包括真实源IP。Envoy通过监听器过滤器[crayon-69e7a0e67d30c443931500-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>
	</channel>
</rss>
