<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
	<channel>
		<title>Posts on Aylei&#39;s Blog</title>
		<link>https://www.aleiwu.com/post/</link>
		<description>Recent content in Posts on Aylei&#39;s Blog</description>
		<generator>Hugo -- gohugo.io</generator>
		<language>zh-hans</language>
		<lastBuildDate>Tue, 29 Oct 2019 13:27:12 +0800</lastBuildDate>
		<atom:link href="https://www.aleiwu.com/post/index.xml" rel="self" type="application/rss+xml" />
		
		<item>
			<title>简化 Pod 故障诊断: kubectl-debug 介绍</title>
			<link>https://www.aleiwu.com/post/kubectl-debug-intro/</link>
			<pubDate>Sat, 27 Jul 2019 12:19:07 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/kubectl-debug-intro/</guid>
			<description>背景 容器技术的一个最佳实践是构建尽可能精简的容器镜像。但这一实践却会给排查问题带来麻烦：精简后的容器中普遍缺失常用的排障工具，部分容器里甚至</description>
			<content type="html"><![CDATA[

<h2 id="背景">背景</h2>

<p>容器技术的一个最佳实践是构建尽可能精简的容器镜像。但这一实践却会给排查问题带来麻烦：精简后的容器中普遍缺失常用的排障工具，部分容器里甚至没有 shell (比如 <code>FROM scratch</code> ）。
在这种状况下，我们只能通过日志或者到宿主机上通过 docker-cli 或 nsenter 来排查问题，效率很低。Kubernetes 社区也早就意识到了这个问题，在 16 年就有相关的 Issue
<a href="https://github.com/kubernetes/kubernetes/issues/27140">Support for troubleshooting distroless containers</a> 并形成了对应的 <a href="https://github.com/kubernetes/community/blob/master/contributors/design-proposals/node/troubleshoot-running-pods.md">Proposal</a>。 遗憾的是，由于改动的涉及面很广，相关的实现至今还没有合并到 Kubernetes 上游代码中。而在
一个偶然的机会下（PingCAP 一面要求实现一个 kubectl 插件实现类似的功能），我开发了 <a href="https://github.com/aylei/kubectl-debug">kubectl-debug</a>: <strong>通过启动一个安装了各种排障工具的容器，来帮助诊断目标容器</strong> 。</p>

<h2 id="工作原理">工作原理</h2>

<p>我们先不着急进入 Quick Start 环节。 <code>kubectl-debug</code> 本身非常简单，因此只要理解了它的工作原理，你就能完全掌握这个工具，并且还能用它做 debug 之外的事情。</p>

<p>我们知道，容器本质上是带有 cgroup 资源限制和 namespace 隔离的一组进程。因此，我们只要启动一个进程，并且让这个进程加入到目标容器的各种 namespace 中，这个进程就能
&ldquo;进入容器内部&rdquo;（注意引号），与容器中的进程&rdquo;看到&rdquo;相同的根文件系统、虚拟网卡、进程空间了——这也正是 <code>docker exec</code> 和 <code>kubectl exec</code> 等命令的运行方式。</p>

<p>现在的状况是，我们不仅要 &ldquo;进入容器内部&rdquo;，还希望带一套工具集进去帮忙排查问题。那么，想要高效管理一套工具集，又要可以跨平台，最好的办法就是把工具本身都打包在一个容器镜像当中。
接下来，我们只需要通过这个&rdquo;工具镜像&rdquo;启动容器，再指定这个容器加入目标容器的的各种 namespace，自然就实现了 &ldquo;携带一套工具集进入容器内部&rdquo;。事实上，使用 docker-cli 就可以实现这个操作：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash"><span class="nb">export</span> <span class="nv">TARGET_ID</span><span class="o">=</span><span class="m">666666666</span>
<span class="c1"># 加入目标容器的 network, pid 以及 ipc namespace</span>
docker run -it --network<span class="o">=</span>container:<span class="nv">$TARGET_ID</span> --pid<span class="o">=</span>container:<span class="nv">$TARGET_ID</span> --ipc<span class="o">=</span>container:<span class="nv">$TARGET_ID</span> busybox</code></pre></div>
<p>这就是 kubectl-debug 的出发点： <strong>用工具容器来诊断业务容器</strong> 。背后的设计思路和 sidecar 等模式是一致的：每个容器只做一件事情。</p>

<p>具体到实现上，一条 <code>kubectl debug &lt;target-pod&gt;</code> 命令背后是这样的：</p>

<figure>
    <img src="/arch-2.jpg" width="800px"/> 
</figure>


<p>步骤分别是:</p>

<ol>
<li>插件查询 ApiServer：demo-pod 是否存在，所在节点是什么</li>
<li>ApiServer 返回 demo-pod 所在所在节点</li>
<li>插件请求在目标节点上创建 <code>Debug Agent</code> Pod</li>
<li>Kubelet 创建 <code>Debug Agent</code> Pod</li>
<li>插件发现 <code>Debug Agent</code> 已经 Ready，发起 debug 请求（长连接）</li>
<li><code>Debug Agent</code> 收到 debug 请求，创建 Debug 容器并加入目标容器的各个 Namespace 中，创建完成后，与 Debug 容器的 tty 建立连接</li>
</ol>

<p>接下来，客户端就可以开始通过 5，6 这两个连接开始 debug 操作。操作结束后，Debug Agent 清理 Debug 容器，插件清理 Debug Agent，一次 Debug 完成。效果如下图：</p>

<figure>
    <img src="/kube-debug.gif" width="800px"/> 
</figure>


<h2 id="开始使用">开始使用</h2>

<p>Mac 可以直接使用 brew 安装:</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">brew install aylei/tap/kubectl-debug</code></pre></div>
<p>所有平台都可以通过下载 binary 安装:</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash"><span class="nb">export</span> <span class="nv">PLUGIN_VERSION</span><span class="o">=</span><span class="m">0</span>.1.1
<span class="c1"># linux x86_64</span>
curl -Lo kubectl-debug.tar.gz https://github.com/aylei/kubectl-debug/releases/download/v<span class="si">${</span><span class="nv">PLUGIN_VERSION</span><span class="si">}</span>/kubectl-debug_<span class="si">${</span><span class="nv">PLUGIN_VERSION</span><span class="si">}</span>_linux_amd64.tar.gz
<span class="c1"># macos</span>
curl -Lo kubectl-debug.tar.gz https://github.com/aylei/kubectl-debug/releases/download/v<span class="si">${</span><span class="nv">PLUGIN_VERSION</span><span class="si">}</span>/kubectl-debug_<span class="si">${</span><span class="nv">PLUGIN_VERSION</span><span class="si">}</span>_darwin_amd64.tar.gz

tar -zxvf kubectl-debug.tar.gz kubectl-debug
sudo mv kubectl-debug /usr/local/bin/</code></pre></div>
<p>Windows 用户可以在 <a href="https://github.com/aylei/kubectl-debug/releases/tag/v0.1.1">Release 页面</a> 进行下载。</p>

<p>下载完之后就可以开始使用 debug 插件:</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">kubectl debug target-pod --agentless --port-forward</code></pre></div>
<blockquote>
<p>kubectl 从 1.12 版本之后开始支持从 PATH 中自动发现插件。1.12 版本之前的 kubectl 不支持这种插件机制，但也可以通过命令名 <code>kubectl-debug</code> 直接调用。</p>
</blockquote>

<p>可以参考项目的 <a href="https://github.com/aylei/kubectl-debug/blob/master/docs/zh-cn.md">中文 README</a> 来获得更多文档和帮助信息。</p>

<h2 id="典型案例">典型案例</h2>

<h3 id="基础排障">基础排障</h3>

<p>kubectl debug 默认使用 <a href="https://github.com/nicolaka/netshoot">nicolaka/netshoot</a> 作为默认的基础镜像，里面内置了相当多的排障工具，包括：</p>

<p>使用 <strong>iftop</strong> 查看容器网络流量：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">➜  ~ kubectl debug demo-pod

root @ /
 <span class="o">[</span><span class="m">2</span><span class="o">]</span> 🐳  → iftop -i eth0
interface: eth0
IP address is: <span class="m">10</span>.233.111.78
MAC address is: <span class="m">86</span>:c3:ae:9d:46:2b
<span class="c1"># (图片略去)</span></code></pre></div>
<p>使用 <strong>drill</strong> 诊断 DNS 解析：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">root @ /
 <span class="o">[</span><span class="m">3</span><span class="o">]</span> 🐳  → drill -V <span class="m">5</span> demo-service
<span class="p">;;</span> -&gt;&gt;HEADER<span class="s">&lt;&lt;- opcode: QUERY, rcode: NOERROR, id: 0
</span><span class="s">;; flags: rd ; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0
</span><span class="s">;; QUESTION SECTION:
</span><span class="s">;; demo-service.	IN	A
</span><span class="s">
</span><span class="s">;; ANSWER SECTION:
</span><span class="s">
</span><span class="s">;; AUTHORITY SECTION:
</span><span class="s">
</span><span class="s">;; ADDITIONAL SECTION:
</span><span class="s">
</span><span class="s">;; Query time: 0 msec
</span><span class="s">;; WHEN: Sat Jun  1 05:05:39 2019
</span><span class="s">;; MSG SIZE  rcvd: 0
</span><span class="s">;; -&gt;&gt;HEADER&lt;&lt;- opcode</span>: QUERY, rcode: NXDOMAIN, id: <span class="m">62711</span>
<span class="p">;;</span> flags: qr rd ra <span class="p">;</span> QUERY: <span class="m">1</span>, ANSWER: <span class="m">0</span>, AUTHORITY: <span class="m">1</span>, ADDITIONAL: <span class="m">0</span>
<span class="p">;;</span> QUESTION SECTION:
<span class="p">;;</span> demo-service.	IN	A

<span class="p">;;</span> ANSWER SECTION:

<span class="p">;;</span> AUTHORITY SECTION:
.	<span class="m">30</span>	IN	SOA	a.root-servers.net. nstld.verisign-grs.com. <span class="m">2019053101</span> <span class="m">1800</span> <span class="m">900</span> <span class="m">604800</span> <span class="m">86400</span>

<span class="p">;;</span> ADDITIONAL SECTION:

<span class="p">;;</span> Query time: <span class="m">58</span> msec
<span class="p">;;</span> SERVER: <span class="m">10</span>.233.0.10
<span class="p">;;</span> WHEN: Sat Jun  <span class="m">1</span> <span class="m">05</span>:05:39 <span class="m">2019</span>
<span class="p">;;</span> MSG SIZE  rcvd: <span class="m">121</span></code></pre></div>
<p>使用 <strong>tcpdump</strong> 抓包：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">root @ /
 <span class="o">[</span><span class="m">4</span><span class="o">]</span> 🐳  → tcpdump -i eth0 -c <span class="m">1</span> -Xvv
tcpdump: listening on eth0, link-type EN10MB <span class="o">(</span>Ethernet<span class="o">)</span>, capture size <span class="m">262144</span> bytes
<span class="m">12</span>:41:49.707470 IP <span class="o">(</span>tos 0x0, ttl <span class="m">64</span>, id <span class="m">55201</span>, offset <span class="m">0</span>, flags <span class="o">[</span>DF<span class="o">]</span>, proto TCP <span class="o">(</span><span class="m">6</span><span class="o">)</span>, length <span class="m">80</span><span class="o">)</span>
    demo-pod.default.svc.cluster.local.35054 &gt; <span class="m">10</span>-233-111-117.demo-service.default.svc.cluster.local.8080: Flags <span class="o">[</span>P.<span class="o">]</span>, cksum 0xf4d7 <span class="o">(</span>incorrect -&gt; 0x9307<span class="o">)</span>, seq <span class="m">1374029960</span>:1374029988, ack <span class="m">1354056341</span>, win <span class="m">1424</span>, options <span class="o">[</span>nop,nop,TS val <span class="m">2871874271</span> ecr <span class="m">2871873473</span><span class="o">]</span>, length <span class="m">28</span>
  0x0000:  <span class="m">4500</span> <span class="m">0050</span> d7a1 <span class="m">4000</span> <span class="m">4006</span> 6e71 0ae9 6f4e  E..P..@.@.nq..oN
  0x0010:  0ae9 6f75 88ee 094b 51e6 <span class="m">0888</span> 50b5 <span class="m">4295</span>  ..ou...KQ...P.B.
  0x0020:  <span class="m">8018</span> <span class="m">0590</span> f4d7 <span class="m">0000</span> <span class="m">0101</span> 080a ab2d 52df  .............-R.
  0x0030:  ab2d 4fc1 <span class="m">0000</span> <span class="m">1300</span> <span class="m">0000</span> <span class="m">0000</span> <span class="m">0100</span> <span class="m">0000</span>  .-O.............
  0x0040:  000e 0a0a 08a1 86b2 ebe2 ced1 f85c <span class="m">1001</span>  .............<span class="se">\.</span>.
<span class="m">1</span> packet captured
<span class="m">11</span> packets received by filter
<span class="m">0</span> packets dropped by kernel</code></pre></div>
<p>访问目标容器的根文件系统：</p>

<p>容器技术(如 Docker）利用了 <code>/proc</code> 文件系统提供的 <code>/proc/{pid}/root/</code> 目录实现了为隔离后的容器进程提供单独的根文件系统（root filesystem）的能力（就是 <code>chroot</code> 一下）。当我们想要访问
目标容器的根文件系统时，可以直接访问这个目录：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">root @ /
 <span class="o">[</span><span class="m">5</span><span class="o">]</span> 🐳  → tail -f /proc/1/root/log_
Hello, world!</code></pre></div>
<p>这里有一个常见的问题是 <code>free</code> <code>top</code> 等依赖 <code>/proc</code> 文件系统的命令会展示宿主机的信息，这也是容器化过程中开发者需要适应的一点（当然了，各种 runtime 也要去适应，比如臭名昭著的
<a href="https://blog.softwaremill.com/docker-support-in-new-java-8-finally-fd595df0ca54">Java 8u121 以及更早的版本不识别 cgroups 限制</a> 问题就属此列）。</p>

<h3 id="诊断-crashloopbackoff">诊断 CrashLoopBackoff</h3>

<p>排查 <code>CrashLoopBackoff</code> 是一个很麻烦的问题，Pod 可能会不断重启， <code>kubectl exec</code> 和 <code>kubectl debug</code> 都没法稳定进行排查问题，基本上只能寄希望于 Pod 的日志中打印出了有用的信息。
为了让针对 <code>CrashLoopBackoff</code> 的排查更方便， <code>kubectl-debug</code> 参考 <code>oc debug</code> 命令，添加了一个 <code>--fork</code> 参数。当指定 <code>--fork</code> 时，插件会复制当前的 Pod Spec，做一些小修改，
再创建一个新 Pod：</p>

<ul>
<li>新 Pod 的所有 Labels 会被删掉，避免 Service 将流量导到 fork 出的 Pod 上</li>
<li>新 Pod 的 <code>ReadinessProbe</code> 和 <code>LivnessProbe</code> 也会被移除，避免 kubelet 杀死 Pod</li>
<li>新 Pod 中目标容器（待排障的容器）的启动命令会被改写，避免新 Pod 继续 Crash</li>
</ul>

<p>接下来，我们就可以在新 Pod 中尝试复现旧 Pod 中导致 Crash 的问题。为了保证操作的一致性，可以先 <code>chroot</code> 到目标容器的根文件系统中：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">➜  ~ kubectl debug demo-pod --fork

root @ /
 <span class="o">[</span><span class="m">4</span><span class="o">]</span> 🐳  → chroot /proc/1/root

root @ /
 <span class="o">[</span><span class="c1">#] 🐳  → ls</span>
 bin            entrypoint.sh  home           lib64          mnt            root           sbin           sys            tmp            var
 dev            etc            lib            media          proc           run            srv            usr

root @ /
 <span class="o">[</span><span class="c1">#] 🐳  → ./entrypoint.sh</span>
 <span class="c1"># 观察执行启动脚本时的信息并根据信息进一步排障</span></code></pre></div>
<h2 id="结尾的碎碎念">结尾的碎碎念</h2>

<p><code>kubectl-debug</code> 一开始只是 PingCAP 在面试时出的 homework，第一版完成在去年年底。当时整个项目还非常粗糙，不仅文档缺失，很多功能也都有问题：</p>

<ul>
<li>不支持诊断 CrashLoopBackoff 中的 Pod</li>
<li>强制要求预先安装一个 Debug Agent 的 DaemonSet</li>
<li>不支持公有云（节点没有公网 IP 或公网 IP 因为防火墙原因无法访问时，就无法 debug）</li>
<li>没有权限限制，安全风险很大</li>
</ul>

<p>而让我非常兴奋的是，在我无暇打理项目的情况下，隔一两周就会收到 Pull Request 的通知邮件，一直到今天，大部分影响基础使用体验的问题都已经被解决，
<code>kubectl-debug</code> 也发布了 4 个版本（ <code>0.0.1</code>, <code>0.0.2</code>, <code>0.1.0</code>, <code>0.1.1</code> )。尤其要感谢 <a href="https://github.com/tkanng">@tkanng</a> , TA 在第一个 PR 时还表示之前没有写过 Go，
而在 <code>0.1.1</code> 版本中已经是这个版本绝大部分 feature 的贡献者，解决了好几个持续很久的 issue，感谢！</p>

<p>最后再上一下项目地址： <a href="https://github.com/aylei/kubectl-debug">https://github.com/aylei/kubectl-debug</a></p>

<p>假如在使用上或者对项目本身有任何问题，欢迎提交 issue，也可以在 <a href="https://www.aleiwu.com/post/kubectl-debug-intro/#结尾的碎碎念">文章评论区</a> 或 <a href="mailto:rayingecho@gmail.com">我的邮箱</a> 留言讨论。</p>
]]></content>
		</item>
		
		<item>
			<title>KubeCon 2019 上海 CRD 相关 Session 小记</title>
			<link>https://www.aleiwu.com/post/kubecon-shanghai-2019/</link>
			<pubDate>Sat, 27 Jul 2019 12:19:24 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/kubecon-shanghai-2019/</guid>
			<description>这周借着参加 KubeCon 之名跑到会场划了三天水，最后一天良心发现顿觉需要记录一下，同时也顺带再消化一遍，遂有此文。 整个三天里除了 keynote 之外跑去听得最多的就</description>
			<content type="html"><![CDATA[

<p>这周借着参加 KubeCon 之名跑到会场划了三天水，最后一天良心发现顿觉需要记录一下，同时也顺带再消化一遍，遂有此文。</p>

<p>整个三天里除了 keynote 之外跑去听得最多的就是大家在 CRD 和自定义控制器上的各种实践，也就是各种 &ldquo;Operator&rdquo;。虽然 Operator 本身已经是一种大家司空见惯的模式，但具体如何为生产环境中的
场景与问题定义 CRD 还是一个很有意思的事情。有些设计能够启发我去修正一些以往浅薄的认知，比如蚂蚁的 <code>CafeDeployment</code> 试图去解决的问题让我感受到 k8s 的 API 离&rdquo;一个好用的 PaaS&rdquo;还是有距离的；
有些 talk 能在范式方面给我一些启发，比如某个 talk 里提到的多个业务组之间如何基于 CRD 协作；还有一些&rdquo;意料之外但又情理之中&rdquo;的设计，让人大开眼界，直呼&rdquo;我怎么没想到呢？&rdquo;，比如 OpenCruise 里
的 <code>SidecarSet</code> 。</p>

<p>另外还听了一些偏 ops 的 session 和我司 TiKV 的 session，也有很多收获。但我想写的更聚焦一点，因此就只提一下这些 session 里和 CRD 或自定义控制器有关联的部分。下面就开始吧！</p>

<h2 id="crd-no-longer-2nd-class-thing">CRD no longer 2nd class thing</h2>

<p><a href="https://static.sched.com/hosted%5Ffiles/kccncosschn19chi/da/Jing%20Xu%20Xing%20Yang%20June%2024%20Chinese%20UPDATED%20V2.pptx">slides</a></p>

<p>第一天傍晚一个专门安利 CRD 的 Keynote。大致内容是讲了一个故事：</p>

<p>（以下内容经过博主个人演绎，我也不知道有没有记岔&hellip;）</p>

<blockquote>
<ul>
<li>sig-storage: 我们想加一个 PV 备份的功能，希望在 k8s 内增加一个内置 API 对象 &ldquo;VolumeSnapshot&rdquo; 来描述对一个 PV 的快照。</li>
<li>sig-architecture: 不同意增加内置对象，请使用 CRD</li>
<li>sig-storage: 什么！CRD 不是给第三方扩展 k8s 用的吗? 我们现在是要 <strong>给 Kubernetes 主干增加功能</strong></li>
</ul>
</blockquote>

<p>然后呢，keynote 里表示 sig-architecture 是对的，CRD 在 Kubernetes 中已经是 &ldquo;一等公民&rdquo;，最后带大家入了个门，讲了一下 CRD 的概念，用法以及 kubebuilder。</p>

<p>假如由我来给这个 keynote 一句总结，那就是 <strong>Kubernetes 本身都在用 CRD 加新功能了，我们还有啥理由不用吗？</strong></p>

<p><img src="/crd-1st-class.png" alt="crd-1st-class.png" width="800px" />
(一张 slides 的截图)</p>

<h2 id="to-crd-or-not-to-crd">To CRD or not to CRD</h2>

<p><a href="https://static.sched.com/hosted%5Ffiles/kccncosschn19chi/1a/To%20CRD%20v2.0%20%281%29.pdf">Slides</a></p>

<p>这个 session 虽然叫 &ldquo;使用还是不使用 CRD，这是一个问题&rdquo;，最后却没有给出确切的标准来区分某个场景该不该使用 CRD（当然，即使有这样的&rdquo;标准&rdquo;，那也是充满争议的）。但这个 session
仍然诚意十足，不仅简明扼要地列出了使用 CRD 需要考虑的问题，还探讨了 CRD 除了扩展 Kubernetes 之外本身的架构意义。这一点让我觉得，很多本身不需要和 k8s 做整合的场景有可能
也可以通过 CRD 放到 k8s 上来做，进而得到一些架构和编程模型上的收益。</p>

<p>先看 slides 里的一个例子，我们有一个微服务体系，分别有 Room、Light、Lock 三个 service：</p>

<figure>
    <img src="/crd-microservices.png" width="800px"/> 
</figure>


<p>而当用户想打开房间里的某盏灯时，则需要发送一个 Rest 请求：</p>
<div class="highlight"><pre class="chroma"><code class="language-nil" data-lang="nil">{
 “action”: “switch_on”,
 “lights”: [
 “lamp-1”,
 “lamp-2”
 ],
 “room”: “kitchen”
}</code></pre></div>
<p>接下来一个可能的流程是：</p>

<ol>
<li>Room Service 调用 Light Service，打开 <code>lamp-1</code> 和 <code>lamp-2</code> 这两盏灯</li>
<li>Light Service 打开这两盏灯，更新数据库中等的状态，返回响应给 Room Service</li>
<li>Room Service 收到影响，更新 Room 对象中灯的亮度</li>
<li>Room Service 返回响应给用户</li>
</ol>

<p>这个系统要做好，其实要解决不少问题：</p>

<ul>
<li>每个服务都要解决自己的存储问题：字面意思</li>
<li>每个服务都要解决高可用问题：字面意思</li>
<li>可靠性问题：个人解读一下，我们发送请求给 Room 服务更新房间，Room 服务再调用 Light 服务打开灯，这时候假如 Light 服务有问题，怎么办？诸如此类的问题最后会需要去做服务间的重试限流熔断这类事</li>
<li>服务间的 API 规范：这个大家应该都有感受，每个公司都会制定服务间的调用规范</li>
<li>组与组之间围绕 API 的协作：&rdquo;过程式&rdquo; 的 API 其实协作起来有很多问题，比如过长的链式调用，循环调用，这些都得通过架构和框架设计去防患于未然</li>
</ul>

<p>接下来就开脑洞了：我们把这三个 service，全部用 k8s 的自定义 controller 来实现怎么样？</p>

<figure>
    <img src="/crd-controllers.png" width="800px"/> 
</figure>


<p>这时候，我们就可以声明式 API 来开灯了：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">apiVersion<span class="p">:</span><span class="w"> </span>v1<span class="w">
</span><span class="w"></span>kind<span class="p">:</span><span class="w"> </span>Room<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w"> </span>name<span class="p">:</span><span class="w"> </span>kitchen<span class="w">
</span><span class="w"> </span>namespace<span class="p">:</span><span class="w"> </span>default<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w"> </span>lights<span class="p">:</span><span class="w">
</span><span class="w"> </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>lamp<span class="m">-1</span><span class="w">
</span><span class="w"> </span>brightness<span class="p">:</span><span class="w"> </span><span class="m">0.5</span><span class="w">
</span><span class="w"> </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>lamp<span class="m">-2</span><span class="w">
</span><span class="w"> </span>brightness<span class="p">:</span><span class="w"> </span><span class="m">1.0</span></code></pre></div>
<p>接下来的流程就是：</p>

<ol>
<li>Room Controller watch 到这个对象的期望状态（spec）变更；</li>

<li><p>Room Controller 更新对应的 Light 对象的亮度（更新 spec）；</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">apiVersion<span class="p">:</span><span class="w"> </span>v1<span class="w">
</span><span class="w"></span>kind<span class="p">:</span><span class="w"> </span>Light<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>lamp<span class="m">-1</span><span class="w">
</span><span class="w">  </span>namespace<span class="p">:</span><span class="w"> </span>default<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w">  </span>brightness<span class="p">:</span><span class="w"> </span><span class="m">0.5</span><span class="w">
</span><span class="w"></span>status<span class="p">:</span><span class="w">
</span><span class="w">  </span>currentBrightness<span class="p">:</span><span class="w"> </span><span class="m">0</span></code></pre></div></li>

<li><p>Light Controller watch 到 <code>lamp-1</code> 和 <code>lamp-2</code> 这两个对象的期望亮度(spec)发生变化</p></li>

<li><p>Light Controller 调整这两个灯的亮度，并更新目标对象的 <code>.status.currentBrightness</code></p></li>

<li><p>Room Controller watch 到两个灯的 status 发生变化，以此为依据更新自己的 status</p></li>
</ol>

<p>可以发现整个协作的核心是 k8s 的 api-server，并且所有组件和逻辑都围绕着声明式 API 进行设计。这个设计下：</p>

<ul>
<li>存储问题简化（etcd 解决）</li>
<li>高可用问题简化（k8s 部署 3 个 api-server 自然就高可用了，controller 反正随时可以拉起来）</li>
<li>可靠性优化（控制循环与声明式 API 这种可以不断自我修正的机制本身就适合解决可靠性）</li>
<li>不用考虑 API 规范和 API 协作（自定义控制器基本上就按这个模式写了）</li>
</ul>

<p>这里要额外解释一下 API 协作，大家可以想象一下，这个例子里用 controller 的方式，协作的心智成本是很低的，我们只需要看一下其它组的 API（也就是 CRD）里的字段含义，然后开始&rdquo;声明自己要干嘛&rdquo;
就可以了。</p>

<p>当然，其实这两者的对比是不公平的，因为例子一基本上等于没有框架也没有 PaaS 在裸写应用，而例子二是在现成的架子上搭东西。真正搞开发的时候，底下的数据库，高可用以及由框架或 Mesh 定义的规范与
协作形式基本也都是做完一遍之后开箱即用的，整体成本和自己整一个生产级 kubernetes 孰高孰低还不好说。另外，CRD 虽然改声明方便，但用过 k8s 原生对象的都知道，改 spec 前我们必须得对下面的
机制有所了解，才能明白改了之后到底能否实现自己的需求，因此这个&rdquo;心智成本&rdquo;也只是变了一下形式而已。最后，这个&rdquo;房间、灯、锁&rdquo;的例子其实选得很好，因为这个系统里组件明确并且都需要去协调一个不可靠的
模块（灯、锁这些设备），假如是我们只是在虚拟账户间转个账对个账啥的，恐怕就很难塞进这个模式里去了。</p>

<p>因此，这是个挺有启发性的例子，但不是一个&rdquo;安利 CRD&rdquo;的例子，整体还是非常中立的。</p>

<p>于是乎，Slides 里紧接着就讲了使用与不使用 CRD 的优缺点对比：</p>

<ul>
<li>数据模型受限：etcd 并不是关系型数据库</li>
<li>系统整体性能基本取决于 etcd</li>
<li>声明式 vs 命令式，没有好不好，只有适不适合</li>
<li>团队合作：CRD 很有优势，每个团队提供 CRD 和 controller，可以互相 watch 对方的 API 对象</li>
</ul>

<p>当然了，到底用不用 CRD 还是取决于个人理解的，只是不要忘了(Slides 的最后一页)：</p>

<figure>
    <img src="/crd-quote.png" width="800px"/> 
</figure>


<h2 id="cafedeployment">CafeDeployment</h2>

<p><a href="https://static.sched.com/hosted%5Ffiles/kccncosschn19eng/7b/extending%5Fdeployment%5Ffor%5Finternet%5Ffinancial%5Fmission%5Fcritical%5Fscenarios.pdf">Slides</a></p>

<p>这个 talk 的中文题目是&rdquo;为互联网金融关键任务场景扩展部署&rdquo;，相当&hellip;不知所云，假如不是看了眼英文名&rdquo;Extending Deployment for Internet Financial Mission-Critical Scenarios&rdquo;,
我差点错过了这个精彩的 session&hellip;</p>

<p>Session 的主角是 <a href="https://zhuanlan.zhihu.com/p/69753427">CafeDeployment</a>，后来看到在 KubeCon 前几天蚂蚁的公众号就发文章讲了这个东西，大家可以直接前往 <a href="https://zhuanlan.zhihu.com/p/69753427">原文</a> 看看它解决的问题和具体的技术场景，假如觉得太长不看，也可以看下面的
三个 Key Takeaways：</p>

<ol>
<li><code>CafeDeployment</code> 是一个顶级对象，就像 Deployment 管理 ReplicaSet 一样， <code>CafeDeployment</code> 下面管理一个叫做 InPlaceSet 的对象。 <code>CafeDeployment</code> 的职责主要是按照策略 *在多个机房内各自创建一个 InPlaceSet*，来做容灾，同时提供部署策略的控制，比如现场演示的就是新版本分三批发布，每批升级中间需要手工确认（改 Annotation）</li>
<li>InPlaceSet 提供了 Pod 本地升级的能力（实现细节没讲，但是要 PoC 的话其实在 Controller 里依次修改 Pod 的镜像就行，修改后 Pod 里的容器会 Restart 使用新镜像，Pod 并不会重建）</li>
<li>用 Readiness Gate 控制了 Pod 本地升级时的上下线过程。大体就是通过设置 readiness gate 为 true 或 false 来设置 Pod 的状态是否为 Ready，从而协调 endpoints，具体逻辑可以看原文</li>
</ol>

<p>一开始听这个 session 的时候，讲需求的时候说现在的策略无法满足跨机房高可用部署、无法实现优雅升级我其实是很疑惑的：这不是 anti-affinity 以及 preStopHook + readinessGate 就能实现了吗？
听到 demo 和实现部分才知道， <code>CafeDeployment</code> 的跨机房高可用部署是指均匀分布在多个机房中，并且分批升级时每次要从多个机房中选择一部分进行升级，以实现全面的灰度验证；而原地升级的特性也没法自动
摘干净流量。对这些实际业务需求的梳理和展示其实是这个 session 给我最大的收获。</p>

<p>还有几个比较有意思的事情：</p>

<ul>
<li><code>CafeDeployment</code> 用一个 annotation 来控制升级过程中的手动确认：升级完一个批次后，annotation 被置为 <code>false</code> ，用户修改为 <code>true</code> 后继续开始下一批次的升级。其实我本来觉得这个做法不符合声明式 API，status 和 spec 是对不上的，像 statefulset 这样用 paritition 更合理。但这种办法也有好处，就是用户界面最简化，用户只需要关心是否能继续升级即可，这里也是一个权衡；</li>
<li><code>CafeDeployment</code> 没有用 CRD，而是用 AA （Aggregated ApiServer）实现的。原因是 CRD 不能定义 <code>/scale</code> 这样的 subresource，另外，听说 <code>CafeDeployment</code> 也正在打算把自定义的 APIServer 的存储换掉，不用 etcd；</li>
</ul>

<h2 id="alibaba-cloudnative">Alibaba CloudNative</h2>

<p><a href="https://static.sched.com/hosted%5Ffiles/kccncosschn19chi/7e/Moving%20E-business%20Giant%20to%20Cloud%20Native-0.2.pdf">Slides</a></p>

<p>也就是&rdquo;电商巨头的原生云迁移经验&rdquo;这个 session，张磊老师的 session 是一定要去听的 —— 当然，还有几百个小伙伴也都是这么想的，因此在开始前 10 分钟 Room 609 就直接出现了爆场，外面的小伙伴都进不来
的情况，火爆程度可见一斑。事实上内容也确实是干货满满。</p>

<p>记得 KubeCon 听到的 talk 里，阿里似乎还在说 &ldquo;把 Sigma 的 Control Plane 换成 Kubernetes&rdquo;，而这次的架构图就已经直接是 Kubernetes 原生 ApiServer + 一个完全自研的 Scheduler + 自研 Controllers （Cruise）了，Pouch 也换成了 containerd。session 里还着重说了消灭富容器，全面拥抱了社区的最佳实践，阿里这样的体量展现出如此敏捷的技术升级，当时在会场听的时候确实是很震撼。</p>

<p>另外要说的一点是，关于最佳实践的文档和博客我们大家都常常看，但真正要去讲给别人的听，要用最佳实现说服别的人的时候，却总是感觉讲不生动，词不达意，最后讲来讲去变成复读机 &ldquo;这是最佳实践，这是最佳实践&hellip;&rdquo;
而张磊老师讲的时候总是能恰到好处地&rdquo;我举个例子&rdquo;一波讲明白，这个姿势要能学来可就不得了了&hellip;</p>

<p>还有一个重头是 <a href="https://github.com/openkruise/kruise">OpenCruise</a>，提供三种 CRD：SidecarSet，(Advanced)StatefulSet，BroadcastJob，具体的作用大家看一下项目文档就能了解个大概。我想写的还是一些体会。</p>

<p>首先是 OpenCruise 里的 StatefulSet 提供的原地升级功能。我原本以为原地升级不就是保持 IP 不变吗，这不就是迁就虚拟机时代的基础设施吗，一点也不云原生。当然，你让我说为什么不支持原地升级，我也讲
不明白，可能只会说 Pod 的设计意图就是 Mortal 的，你们现在逆天而为要给 Pod 续命实在是搞得太丑了。事实证明我完全错了，这些认知根本是没有见过实际的业务场景在意淫（还好我还晓得参加 KubeCon）。
讲师之一的酒祝讲了个例子，阿里自研的调度器支持 Batch Scheduling，对于一批十几万个 Pod，可能要尝试非常多种方案才能得出一个排布拓扑，这时候再去做删除式的滚动升级代价太大了；同时，类似双十一
这样的大促之前还要搞全链路压测，压测完之后要是一个滚动拓扑变了，系统承压量又不一样了怎么办？</p>

<p>这个例子说服力超强，而且也凸显出本地升级&rdquo;稳定为先&rdquo;的优势。</p>

<p>然后是 SidecarSet，与 session 前半部分的内容结合来看，这个也是符合去富容器历史进程的产物。因为大量富容器中的非业务进程需要做成 sidecar，而把 Sidecar 和业务容器的管理剥离开始，则是一个
四两拨千斤的创新，不说了不说了&hellip;我都感觉快成软广了(其实是写不动了 orz)</p>

<p>总之，假如你没去听的话，看一下 slides 绝对值得。</p>

<h2 id="结语">结语</h2>

<p>上面的各种 session 再结合 KubeCon 前几天发布的 Kubernetes 1.15 对 CRD 的大量增强以及社区里俯拾皆是的 xxx-operator，无疑印证了 CRD 和自定义控制器已经是最 &ldquo;稀松平常&rdquo; 的 Kubernetes 扩展模式。这时候，CRD 本身也就不再有意思，需要把光彩让给 <code>SidecarSet</code>, <code>CafeDeployment</code> 这些匠心独运的业务实践了。另外，要插个硬广，我司在做的 <a href="https://github.com/pingcap/tidb-operator">tidb-operator</a> 面临的场景同样极富挑战，假如你想知道在云上编排一个复杂的分布式数据库是一种怎样的体验，欢迎通过邮箱 wuyelei@pingcap.com 联系我！</p>
]]></content>
		</item>
		
		<item>
			<title>文稿分享记录：TiDB Operator 设计与实现</title>
			<link>https://www.aleiwu.com/post/tidb-operator-design/</link>
			<pubDate>Sat, 27 Jul 2019 12:19:11 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/tidb-operator-design/</guid>
			<description>文章整理自在 DockOne 社区进行的一次文稿分享，由于临到开始前才知道分享形式是纯文稿的，所以只列了一下提纲，现场的文字会比较口语化，当然这也原汁原味还</description>
			<content type="html"><![CDATA[

<blockquote>
<p>文章整理自在 DockOne 社区进行的一次文稿分享，由于临到开始前才知道分享形式是纯文稿的，所以只列了一下提纲，现场的文字会比较口语化，当然这也原汁原味还原了分享现场🤣（什么鬼）</p>
</blockquote>

<h2 id="正文">正文</h2>

<h3 id="tidb-简介">TiDB 简介</h3>

<p>大家好，我是 PingCAP 的 Cloud 工程师吴叶磊，目前在做 TiDB Operator 相关的开发工作，很高兴今天能跟大家分享一下 TiDB Operator 这个项目背后的一些东西。</p>

<p>首先要说的当然是我们为什么要做 TiDB Operator，这得从 TiDB 本身的架构开始说起。下面是 TiDB 的架构图：</p>

<figure>
    <img src="/tidb-architecture.png" width="800px"/> 
</figure>


<p>其中，TiKV 是一套分布式的 Key-Value 存储引擎，它是整个数据库的存储层，在 TiKV 中，数据被分为一个个 Region，而一个 Region 就对应一个 Raft Group，使用 Raft 协议做 Log Replication 来保证数据的强一致性。那么自然容易想到，我们只要水平增加 TiKV 节点数，再把数据切成更多的 Region 均匀分布在这些节点上，就能实现存储层的水平扩展。</p>

<p>TiDB 则是计算执行层，负责 SQL 的解析和查询计划优化，真正执行 SQL 时则通过 TiKV 提供的 API 来访问数据。</p>

<p>最后是 PD，PD 是集群的“大脑”，它一方面是是集群的 metadata server，TiKV 中的数据分布情况都会通过心跳上报给 PD 存储起来；另一方面又承担集群数据调度的任务，我们前面说 TiKV 要把数据拆成更多的 Region 均匀分布到节点上，什么时候拆、怎么拆、拆完分配到哪些节点上这些事情就都是 PD 通过调度算法来决定的。从直观上，PD 其实有点像 Kubernetes 里的 Control Plane。</p>

<h3 id="tidb-operator-简介">TiDB Operator 简介</h3>

<p>这么一套架构的优势是分层清晰，指责明确，每一层都可以独立地做功能扩展和规模上的水平伸缩。但是这对于运维管理来说，是一个巨大的挑战，再加上 TiDB 本身的一些比较复杂的分布式共识算法和事务算法，可以说是把整个 TiDB 的运维入门门槛拉得相当高。另一方面，传统的基于虚拟机的部署方式也不能很好地发挥 TiDB 水平伸缩和故障自动转移的潜力。所以其实我们在内部很早就在尝试使用 Kubernetes 来编排管理 TiDB 集群，甚至在这个开源的 TiDB Operator 之前，我们还有一版废弃掉的 TiDB Operator。最后的事实也确实证明我们一直以来的选择和投入是正确的，相信大家听了后面的分析，也会认同这一点。</p>

<p>接下来我们正式进入 TiDB Operator 的解读。其实 Operator 模式在 Kubernetes 社区已经不新鲜了，现在大部分流行的有状态应用都有自己的 Operator。但回顾一下 Operator 的一些概念仍然非常必要。</p>

<p>我们知道 Kubernetes 里两个很重要的概念就是声明式 API 和控制循环。所有的 API 对象都是对用户意图的记录，再由控制器去 watch 这些意图，对比实际状态，执行调谐（reconcile）操作来驱动集群达成用户意图。Kubernetes 本身有很多的内置 API 对象，比如 ReplicaSet 表达我们需要一个应用有几个实例，DaemonSet 表达我们希望在部分被选中的节点上每个节点运行且只运行一个实例。那我们该怎么向 Kubernetes 表达 “我需要一个 TiDB 集群呢“？答案就是定义一个用于描述 TiDB 集群的对象，在 Kubernetes 中，目前有两种方式可以定义一个新对象，一是 CustomResourceDefinition（CRD）、二是 Aggregation ApiServer（AA），其中 CRD 是相对简单也是目前应用比较广的方法。TiDB Operator 就用 CRD 定义了一个 ”TidbCluster” 对象。</p>

<p>有了对象还没完，这个对象现在谁都还不认识它呢。这时候就是自定义控制器出场的时候了，我们的自定义控制器叫 tidb-controller-manager，它会 watch TidbCluster 对象和其它一些相关对象，并且按照我们编写的逻辑做调谐来驱动真实的 TiDB 集群向我们定义的终态转移。</p>

<p>CRD 加上控制器就是典型的 Operator 模式了。当然这还没完，很多逻辑控制器也是无能为力的，比如 Pod 的调度逻辑。这一块为了实现 TiDB 容器的自定义调度策略，我们编写了 Scheduler Extender。还有一些验证逻辑，比如某些特殊情况下，我们要阻止集群的变更，这样的逻辑就用 Admission Webhook 来实现。而在用户侧，我们则开发了 kubectl plugin 来做 TiDB 的一些特定操作。所以大家就可以知道，TiDB Operator 其实不止于 Operator，我们的核心理念是利用 Kubernetes 大量的扩展点，为 Kubernetes 全面注入 TiDB 的领域知识，把 Kubernetes 打造成 TiDB 的一个最佳底座。</p>

<p>这样做有两大好处(划重点）：</p>

<ul>
<li><strong>一是在 Kubernetes 基于控制循环的自运维模式下，我们可以把 TiDB 的运维门槛降到最低，让入门用户也能轻松搞定水平伸缩和故障转移这些高级玩法；</strong></li>
<li><strong>二是我们基于 Kubernetes 的 Restful API 提供了一套标准的集群管理 API，用户可以拿着这个 API 把 TiDB 集成到自己的工具链或 PaaS 平台中，真正赋能用户去把 TiDB 玩好玩精。</strong></li>
</ul>

<h3 id="tidb-operator-部分特性解析">TiDB Operator 部分特性解析</h3>

<p>上面说了一些比较玄乎的、方法论上的东西，可能大家都觉得脚快够不着地了 。下面我们就讲一些技术干货，用一些功能场景来解析 TiDB Operator 的实现机制，更为重要的是，我们认为这里面的一些套路对于在 Kubernetes 上管理有状态应用是通用的，可能能给大家带来一些启发。</p>

<p>第一是 TiDB Operator 该怎么去构建一个 TiDB 集群。我们尝试过直接操作 Pod，最后的结论是工作量太大了，k8s 自己的控制器里处理了大量的 corner case，并且有大量的单测和 e2e 测试来保障正确性，我们要自己再去实现一遍成本很高。因此我们最后的选型是 TiDB Operator 分别为 PD、TiKV、TiDB 创建一个 StatefulSet，再去管理这些 StatefulSet 来实现优雅升级和故障转移等功能。大家也可以看到有很多社区的 Operator 都是这么做的，而且部分没有这么做的 Operator 已经开始反思了，比如 <a href="https://github.com/elastic/cloud-on-k8s/issues/1173。确实按照我们的经验，管理有状态应用的">https://github.com/elastic/cloud-on-k8s/issues/1173。确实按照我们的经验，管理有状态应用的</a> Operator 往往做到后来发现是需要自己实现 statefulset 80%的功能的，选择直接管理裸 Pod 就有点吃力不讨好了 。🤔</p>

<p>第二个是 Local PV，大部分存储型应用对磁盘性能是相当敏感的，因此 Local PV 是一个必选项。好在现在 Kubernetes 的 Local PV 支持已经比较成熟了，也有 local-pv-provisioner 来辅助创建 Local PV。但一个很让人头大的问题是用了 Local PV 之后，Pod 就和特定节点绑死了，节点故障后要调度到其它机器必须手动删除 PVC，这其实不是编排层能解决的问题，因为本地磁盘相比于背后通常会有三副本的网络存储本身就是不可靠的，使用本地磁盘的应用必须得在应用层做数据冗余。当然，TiDB 的存储层 TiKV 本身就是多副本高可用的，这种情况下我们采取的策略是不管旧的 Pod，直接创建新 Pod 来做故障转移，利用 TiKV 本身的数据调度把数据在新 Pod 上补齐。</p>

<p>接下来就是故障转移怎么做的问题。我们知道 StatefulSet 提供的语义保证是相同名字的 Pod 集群中同时最多只有一个，也就是假如发生了节点宕机，StatefulSet 是不会帮助我们做故障转移的，因为这时候 Kubernetes 并不知道是节点宕机还是网络分区，也就是它无法确定节点上的 Pod 还在不在跑。我们假设挂掉的 Pod 叫 tikv-0，那这时候 k8s 再创建一个 tikv-0 就脑裂了。当然了，在公有云上不会有这个问题，因为公有云上 Node Controller 会通过公有云 API 检查节点是不是真的消失了，假如是的话就会移除节点，那 k8s 就知道 tikv-0 不可能再运行，可以做故障转移了。可惜一难接一难😂，故障转移之后 Pod 又会碰到找不到 Local PV 的问题而 Pending🤣……</p>

<p>我们最终的解决方案是在 Tide cluster 对象的 status 中记录当前挂掉的 Pod，这个挂掉是指一方面 k8s 认为 Pod 挂了，另一方面，TiDB 集群，也就是 PD 也认为这个实例挂了，这个非常重要，因为我们实际场景中就遇到过因为单边网络问题 apiserver 认为节点掉线而其实正常运行的，这时候 PD 就救了我们一命。我们在控制循环中专门同步这些状态，一旦两面都确认某个 Pod 以及 Pod 中的实例挂了，我们就在 status 里记录下来。而另一个扩缩容控制循环会检查这个 status，假如有挂掉的实例，就给 StatefulSet 的副本数+1，实现故障转移。示意图如下：</p>

<figure>
    <img src="/operator-collect.png" width="800px"/> 
</figure>


<p>大家可以看到，控制器里是结合了 k8s 的信息和 PD 的信息去更新这个 failureStore 字段。</p>

<figure>
    <img src="/operator-failover.png" width="800px"/> 
</figure>


<p>当我们确认 k8s 层面和业务层面（PD）都认为实例恢复正常后，我们就会从 failureStore 中删除对应实例，自动把实例数降下来，当然对于 PD 我们是这么做的，对于 TiKV，我们把删除 failureStore 这一步交给了用户，避免节点迁移次数过多，数据迁移太频繁影响集群性能。从这个 case 我们可以看到，在自定义控制器里糅合业务状态（来自业务，比如 TiDB 的 PD）与基础设施状态（来自 k8s）是重要且必要的。</p>

<p>第三个想说的是优雅升级，以 TiKV 为例，优雅升级就是在升级前主动逐出待升级实例上的所有 Raft Group 的 Leader，避免出现请求失败。大家可能会说这个用 preStopHook 可以做，但 preStopHook 的超时时间是一个比较难确定的东西，而且也不够灵活，我们最后选择是在 Controller 中实现，示意如下：</p>

<figure>
    <img src="/operator-rolling-update.png" width="800px"/> 
</figure>


<p>大家可以看到，我们其实用 StatefulSet 的 partition 字段来控制哪些序号可以被升级到新版本，哪些要呆在旧版本。升级开始时，partition = 节点数-1（这里分享时写错了，见后文 QA），也就是所有的 Pod 都不升级，然后呢，我们会去判断下一个待升级的 Pod 上是否存在 Leader，假如存在就进行逐出，逐出之后就 return 了，因为控制循环会不断进入，所以我们就会不断检查目标 Pod 上的 leader 是否逐出完了，一旦逐出完毕，就会往下走，将 partition - 1，让 k8s 把目标 Pod 升级到新版本，这样不断循环，确保每个节点在升级前都已经清干净了 leader，做到业务完全无损。后续呢，我们希望把这个功能放到 ValidatingAdmissionWebhook 上来实现，这样呢，可以做到功能与 controller 完全正交，大大提升可维护性，具体的方案我在个人博客里也有记录 <a href="https://aleiwu.com/post/tidb-opeartor-webhook/">https://aleiwu.com/post/tidb-opeartor-webhook/</a> (不是广告（才怪 🤪</p>

<p>我们在控制器里还有很多这样和 TiDB 本身的架构和特性深度集成的功能设计，所以大家可以看到，做一个 Operator 的前提条件是要对你要运维的系统架构做到了若指掌，甚至对源码也要有所了解。</p>

<p>说了很多的 tidb-controller-manager，最后说一下 tidb-scheduler，tidb-scheduler 其实是利用 k8s 本身的调度器扩展机制开发的，我们把 kube-scheduler 和 tidb-scheduler 打到了一个 pod 里，并且整个注册为 ”tidb-scheduler“，这样所有标记了使用该 schduler 的 pod 就能走到我们所定制的调度逻辑。</p>

<figure>
    <img src="/tidb-scheduler.png" width="800px"/> 
</figure>


<p>这里讲一个调度策略作为例子，PD 的高可用调度，PD 里内嵌了一个 etcd，所以它是一个基于 quorum 的共识系统，需要 majority 也就是超过一半的节点存活来保证可用性，我们的调度目标就是不在一台机器上部署超过半数的 PD 节点。你可能认为用 inter-pod anti-affinity 也能实现这个需求，但其实不是这样的。</p>

<p>anti-affinity 有两种，soft 和 hard，对于 soft 的反亲和性，当无法满足反亲和时，Pod 仍会被调度到同一个节点上，而 hard 则禁止这种情况出现。我们举个一个看看反亲和性为什么不能完美满足 quorum based 的系统调度需求：</p>

<p>我们假设现在有 3 个 node，5 个 pd 实例，那么下面这样的排布是能接受的：</p>

<figure>
    <img src="/pd-ha-topology.png" width="800px"/> 
</figure>


<p>假如我们使用 hard 的反亲和性，这个拓扑无法接受；
假如我们使用 soft 的 f反亲和性，假设现在其中一个节点挂了，那么 Pod 就会转移到其它节点上，这时候由于 k8s 没有 de-schedule 机制，即使我们恢复了挂掉的节点，集群拓扑也不会转移回来；
那么 tidb-scheduler 中是怎么做的呢？策略也很简单，对于每个 Node，我们假设 Pod 调度到了目标 Node 上，再计算上面的实例数是否大于一半，假如是的话，就在 filter 阶段剔除这个 Pod。使用了这样的策略之后，大家可以推演一下，上面的拓扑是可以调度出来的，而且当节点挂掉之后，PD 实例会 Pending，不会带来一个存在风险的拓扑结构。</p>

<h3 id="小结">小结</h3>

<p>时间有限，只能分享这么多了。最后呢，是用 operator 管理有状态应用的一点点总结：</p>

<ol>
<li>站在巨人的肩膀上，尽量复用 k8s 原生对象；</li>
<li>使用 local pv 必须在应用层实现数据冗余；</li>
<li>operator 要尽可能多地去结合业务状态，通过 apiserver 推导出的业务状态在大规模集群下未必准确；</li>
<li>不要只着眼于自定义控制器，k8s 的扩展点还有很多，善加利用能够大幅降低复杂度；</li>
</ol>

<p>最后的最后，TiDB Operator <a href="https://github.com/pingcap/tidb-operator">https://github.com/pingcap/tidb-operator</a> 也即将在本月 GA 了，还有很多来不及分享的特性等着大家，欢迎大家到时候关注。</p>

<h2 id="qa">QA</h2>

<p>Q1：升级开始时，partition = 节点数-1，也就是所有的 Pod 都不升级,为啥是partition = 节点数-1？</p>

<p>A：这里要纠错一下，是 pod ordinal 从 0 开始计数，大于或等于 partition 序号的 pod 会被升级 ，所以最大的序号是节点数-1，最开始的 partition 是等于节点数，分享时表达错了（我自己也记错了），抱歉😅；</p>

<p>（这里其实是我在分享时犯错了，虽然看到问题反应过来了，但还是非常尴尬，感觉自己像个沙雕）</p>

<p>Q2：还有就是驱逐leader成功了怎么防止要升级的pod重新被选为leader</p>

<p>A：我们实际上是在 PD 中提交了一个驱逐 leader 的任务，PD 会持续保证驱逐完毕后没有新 leader 进来，直到升级完毕后，由控制器移除这个任务；</p>

<p>Q3：集群规模多大？多少 pod node ?</p>

<p>A：我们在 Kubernetes 上内部测试的规模较大的集群有 100 + TiKV 节点 50+ TiDB 节点，而每位研发都会部署自己的集群进行性能测试或功能测试；</p>

<p>Q4： 请问你们实现精准下线某一个pod 的功能了嘛，因为statefulset是顺序的？如何实现的？可以分享下思路嘛？</p>

<p>A：这个功能在 1.0 中还没有实现，我们计划在 1.1 中实现这个特性。</p>

<p>Q5：想了解下数据库容器化，推荐使用localpv吗，有没有哪些坑或最佳实践推荐？我们在考虑mysql数据库容器化以及中间件容器化，是选择localpv还是线下自建ceph集群？</p>

<p>A：Local PV 其实不是一个选项，而是一个强制因素，因为网络盘的 IOPS 是达不到在线存储应用的生产环境需求的，或者说不是说线上完全不能用，而是没法支撑对性能要求比较高的场景。MySQL 的运维我相对不是很清楚，假如 MM 能够做到双副本冗余强一致的话，那理论上就能用。大多数中间件比如 Kafka、Cassandra 都有数据冗余，这些使用 local pv 在理论上都是没问题的。</p>

<p>Q6：看你的方案感觉k8s和pd的逻辑结合在一起了，二者之间如何互通？会有代码互相侵入吗？明白了，就好像问题2驱逐问题，pd收到驱逐任务，k8s控制器不断的检查是否驱逐成功，如果成功就开始升级，对吧？</p>

<p>A：这就是自定义控制器的绝佳场景了，k8s 和 pd 本身完全没有交互，是控制循环在同步两边的状态，一方面控制循环会把 PD 记录的集群状态塞到 TidbCluster 对象的 status 里面，另一方面控制循环在将实际状态向期望状态转移时，也会生成一些 PD 的任务和操作子（Opeartor）提交到 PD 中来调谐集群状态。</p>

<h2 id="最后">最后</h2>

<p>最后当然是招人啦，假如你对我们正在做的事情感兴趣，无论是 Cloud 也好数据库研发也好，都以联系 wuyelei@pingcap.com 投递简历勾搭。</p>
]]></content>
		</item>
		
		<item>
			<title>高效编排有状态应用 —— TiDB 的云原生实践与思考</title>
			<link>https://www.aleiwu.com/post/qcon-tidb-operator/</link>
			<pubDate>Mon, 21 Oct 2019 16:41:00 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/qcon-tidb-operator/</guid>
			<description>本文为 2019 QCon 全球软件开发大会（上海） 专题演讲实录。 导语 云原生时代以降，无状态应用以其天生的可替换性率先成为各类编排系统的宠儿。以 Kubernetes 为代表的编排</description>
			<content type="html"><![CDATA[

<blockquote>
<p>本文为 2019 QCon 全球软件开发大会（上海） 专题演讲实录。</p>
</blockquote>

<h2 id="导语">导语</h2>

<p>云原生时代以降，无状态应用以其天生的可替换性率先成为各类编排系统的宠儿。以 Kubernetes 为代表的编排系统能够充分利用云上的可编程基础设施，实现无状态应用的弹性伸缩与自动故障转移。这种基础
能力的下沉无疑是对应用开发者生产力的又一次解放。
然而，在轻松地交付无状态应用时，我们应当注意到，状态本身并没有消失，而是按照各类最佳实践下推到了底层的数据库、对象存储等有状态应用上。那么，“负重前行”的有状态应用是否能充分利云与
Kubernetes 的潜力，复制无状态应用的成功呢？</p>

<p>或许你已经知道，Operator 模式已经成为社区在 Kubernetes 上编排有状态应用的最佳实践，脚手架项目 KubeBuilder 和 operator-sdk 也已经愈发成熟，而对磁盘 IO 有严苛要求的数据库等应用所必须的 Local
PV（本地持久卷）也已经在 1.14 中 GA。这些积木似乎已经足够搭建出有状态应用在平稳运行在 Kubernetes 之上这一和谐景象。然而，书面上的最佳实践与生产环境之间还有无数工程细节造就的鸿沟，要在
Kubernetes 上可靠地运行有状态应用仍需要相当多的努力。这场主题演讲中，我将以 TiDB 与 Kubernetes 的“爱恨情仇”为例，总结有状态应用走向云原生的工程最佳实践。</p>

<h2 id="tidb-简介">TiDB 简介</h2>

<p>首先让我们先熟悉熟悉研究对象。TiDB 是一个分布式的关系型数据库，它采用了存储和计算分离的架构，并且分层十分清晰：</p>

<p><img src="/qcon-tidb-arch.png" alt="qcon-tidb-arch.png" width="800px" />
<em>（图一：TiDB 架构）</em></p>

<p>其中 TiDB 是 SQL 计算层，TiDB 进程接收 SQL 请求，计算查询计划，再根据查询计划去查询存储层完成查询。</p>

<p>存储层就是图中的 TiKV，TiKV 会将数据拆分为一个个小的数据块，比如一张 1000000 行的表，在 TiKV 中就有可能被拆分为 200 个 5000 行的数据块。这些数据块在 TiKV 中叫做 Region，而为了确保可用性，
每个 Region 都对应一个 Raft Group，通过 Raft Log 复制实现每个 Region 至少有三副本。</p>

<p><img src="/qcon-tikv-arch.png" alt="qcon-tikv-arch.png" width="800px" />
<em>（图二：TiKV Region 分布）</em></p>

<p>而 PD 则是集群的大脑，它接收 TiKV 进程上报的存储信息，并计算出整个集群中的 Region 分布。借由此，TiDB 便能通过 PD 获知该如何访问某块数据。更重要的是，PD 还会基于集群 Region 分布与负载情况进行
数据调度。比如，将过大的 Region 拆分为两个小 Region，避免 Region 大小由于写入而无限扩张；将部分 Leader 或数据副本从负载较高的 TiKV 实例迁移到负载较低的 TiKV 实例上，以最大化集群性能。这引出
了一个很有趣的事实，也就是 TiKV 虽然是存储层，但它可以非常简单地进行水平伸缩。这有点意思对吧？在传统的存储中，假如我们通过分片打散数据，那么加减节点数往往需要重新分片或手工迁移大量的数据。而在
TiKV 中，以 Region 为抽象的数据块迁移能够在 PD 的调度下完全自动化地进行，而对于运维而言，只管加机器就行了。</p>

<p>了解有状态应用本身的架构与特性是进行编排的前提，比如通过前面的介绍我们就可以归纳出，TiDB 是无状态的，PD 和 TiKV 是有状态的，它们三者均能独立进行水平伸缩。我们也能看到，TiDB 本身的设计就是云
原生的——它的容错能力和水平伸缩能力能够充分发挥云基础设施提供的弹性，既然如此，云原生“操作系统”Kubernetes不正是云原生数据库 TiDB 的最佳载体吗？TiDB Operator 应运而生。</p>

<h2 id="tidb-operator-简介">TiDB Operator 简介</h2>

<p>Operator 大家都很熟悉了，目前几乎每个开源的存储项目都有自己的 Operator，比如鼻祖 etcd-operator 以及后来的 prometheus-operator、postgres-operator。Operator 的灵感很简单，Kubernetes 自身
就用 Deployment、DaemonSet 等 API 对象来记录用户的意图，并通过 control loop 控制集群状态向目标状态收敛，那么我们当然也可以定义自己的 API 对象，记录自身领域中的特定意图，并通过自定义的 control
loop 完成状态收敛。在 Kubernetes 中，添加自定义 API 对象的最简单方式就是 CustomResourceDefinition（CRD），而添加自定义 control loop 的最简单方式则是部署一个自定义控制器。自定义控制器 + CRD
就是 Operator。具体到 TiDB 上，用户可以向 Kubernetes 提交一个 TidbCluster 对象来描述 TiDB 集群定义，假设我们这里描述说“集群有 3 个 PD 节点、3 个 TiDB 节点和 3 个 TiKV 节点”，这是我们的意图。
而 TiDB Operator 中的自定义控制器则会进行一系列的 Kubernetes 集群操作，比如分别创建 3 个 TiKV、TiDB、PD Pod，来让真实的集群符合我们的意图。</p>

<p><img src="/qcon-operator-arch.png" alt="qcon-operator-arch.png" width="800px" />
<em>（图三：tidb-opeartor）</em></p>

<p>TiDB Operator 的意义在于让 TiDB 能够无缝运行在 Kubernetes 上，而 Kubernetes 又为我们抽象了基础设施。因此，tidb-opeartor 也是 TiDB 多种产品形态的内核。对于希望直接使用 TiDB Operator 的用户，
TiDB Operator 能做到在既有 Kubernetes 集群或公有云上开箱即用；而对于不希望有太大运维负载，又需求一套完整的分布式数据库解决方案的用于，我们则提供了打包 Kubernetes 的 on-premise 部署解决方案，用户可以直接
通过方案中打包的 GUI 操作 TiDB 集群，也能通过 OpenAPI 将集群管理能力接入到自己现有的 PaaS 平台中；另外，对于完全不想运维数据库，只希望购买 SQL 计算与存储能力的用户，我们则基于 TiDB Operator 提供
托管的 TiDB 服务，也即 DBaaS（Database as a Service）。</p>

<p><img src="/qcon-operator-products.png" alt="qcon-operator-products.png" width="800px" />
<em>（图四：tidb-opeartor的多种产品形态）</em></p>

<p>多样的产品形态对作为内核的 TiDB Operator 提出了更高的要求与挑战——事实上，由于数据资产的宝贵性和引入状态后带来的复杂性，有状态应用的可靠性要求与运维复杂度往往远高于无状态应用，这从 TiDB Operator 所面临
的挑战中就可见一斑。</p>

<h2 id="挑战">挑战</h2>

<p>描绘架构总是让人觉得美好，而生产中的实际挑战则将我们拖回现实。</p>

<p>TiDB Operator 的最大挑战就是数据库的场景极其严苛，大量用户的期盼都是我的数据库能够“永不停机”，对于数据不一致或丢失更是零容忍。很多时候大家对于数据库等有状态应用的可用性要求甚至是高于承载线上服务的
Kubernetes 集群的，至少线上集群宕机还能补救，而数据一旦出问题，往往意味着巨大的损失和补救成本，甚至有可能“回天乏术”。这本身也会在很大程度上影响大家把有状态应用推上 Kubernetes 的信心。</p>

<p>第二个挑战是编排分布式系统这件事情本身的复杂性。Kubernetes 主导的 level driven 状态收敛模式虽然很好地解决了命令式编排在一致性、事务性上的种种问题，但它本身的心智模型是更为抽象的，我们需要考虑每一种
可能的状态并针对性地设计收敛策略，而最后的实际状态收敛路径是随着环境而变化的，我们很难对整个过程进行准确的预测和验证。假如我们不能有效地控制编排层面的复杂度，最后的结果就是没有人能拍胸脯保证
TiDB Operator 能够满足上面提到的严苛挑战，那么走向生产也就无从谈起了。</p>

<p>第三个挑战是存储。数据库对于磁盘和网络的 IO 性能相当敏感，而在 Kubernetes 上，最主流的各类网络存储很难满足 TiDB 对磁盘 IO 性能的要求。假如我们使用本地存储，则不得不面对本地存储的易失性问题——磁盘
故障或节点故障都会导致某块存储不可用，而这两种故障在分布式系统中是家常便饭。</p>

<p>最后的问题是，尽管 Kubernetes 成功抽象了基础设施的计算能力与存储能力，但在实际场景的成本优化上考虑得很少。对于公有云、私有云、裸金属等不同的基础设施环境，TiDB Operator 需要更高级、特化的调度策略
来做成本优化。大家也知道，成本优化是没有尽头的，并且往往伴随着一些牺牲，怎么找到优化过程中边际收益最大化的点，同样也是非常有意思的问题之一。</p>

<p>其中，场景严苛可以作为一个前提条件，而针对性的成本优化则不够有普适性。我们接下来就从编排和存储两块入手，从实际例子来看 TiDB 与 TiDB Operator 如何解决这些问题，并推广到一般的有状态应用上。</p>

<h2 id="控制器-剪不断-理还乱">控制器 —— 剪不断，理还乱</h2>

<p>TiDB Operator 需要驱动集群向期望状态收敛，而最简单的驱动方式就是创建一组 Pod 来组成 TiDB 集群。通过直接操作 Pod，我们可以自由地控制所有编排细节。举例来说，我们可以：</p>

<ul>
<li>通过替换 Pod 中容器的 image 字段完成原地升级；</li>
<li>自由决定一组 Pod 的升级顺序；</li>
<li>自由下线任意 Pod；</li>
</ul>

<p>事实上我们也确实采用过完全操作 Pod 的方案，但是当真正推进该方案时我们才发现，这种完全“自己造轮子”的方案不仅开发复杂，而且验证成本非常高。试想，为什么大家对 Kubernetes 的接受度越来越高，
即使是传统上较为保守的公司现在也敢于拥抱 Kuberentes？除了 Kubernetes 本身项目素质过硬之外，更重要的是有整个社区为它背书。我们知道 Kubernetes 已经在各种场景下经受过大量的生产环境考验，
这种信心是各类测试手段都没法给到我们的。回到 TiDB Operator 上，选择直接操作 Pod 就意味着我们抛弃了社区在 StatefulSet、Deployment 等对象中沉淀的编排经验，随之带来的巨大验证成本
大大影响了整个项目的开发效率。</p>

<p>因此，在目前的 TiDB Operator 项目中，大家可以看到控制器的主要操作对象是 StatefulSet。StatefulSet 能够满足有状态应用的大部分通用编排需求。当然，StatefulSet 为了做到通用化，做了很多
不必要的假设，比如高序号的 Pod 是隐式依赖低序号 Pod 的，这会给我们带来一些额外的限制，比如：</p>

<ul>
<li>无法指定 Pod 进行下线缩容；</li>
<li>滚动更新顺序固定；</li>
<li>滚动更新需要后驱 Pod 全部 Ready；</li>
</ul>

<p>StatefulSet 和 Pod 的抉择，最终是灵活性和可靠性的权衡，而在 TiDB 面临的严苛场景下，我们只有先做到可靠，才能做开发、敢做开发。最后的选择自然就呼之欲出——StatefulSet。当然，这里并不是说
使用基于高级对象进行编排的方案要比基于 Pod 进行编排的方案更好，只是说我们在当时认为选择 StatefulSet 是一个更好的权衡。当然这个故事还没有结束，当我们基于 StatefulSet 把第一版 TiDB Operator
做稳定后，我们正在接下来的版本中开发一个新的对象来水平替换 StatefulSet，这个对象可以使用社区积累的 StatefulSet 测试用例进行验证，同时又可以解除上面提到的额外限制，给我们提供更好的灵活性。
假如你也在考虑从零开始搭建一个 operator，或许也可以参考“先基于成熟的原生对象快速迭代，在验证了价值后再增强或替换原生对象来解决高级需求”这条落地路径。</p>

<p>接下来的问题是控制器如何协调基础设施层的状态与应用层的状态。举个例子，在滚动升级 TiKV 时，每次重启 TiKV 实例前，都要先驱逐该实例上的所有 Region Leader；而在缩容 TiKV 时，则要先在 PD 中将
待缩容的 TiKV 下线，等待待缩容的 TiKV 实例上的 Region 全部迁移走，PD 认为 TiKV 下线完成时，再真正执行缩容操作调整 Pod 个数。这些都是在编排中协调应用层状态的例子，我们可以怎么做自动化呢？</p>

<p>大家也注意到了，上面的例子都和 Pod 下线挂钩，因此一个简单的方案就通过 container lifecycle hook，在 <code>preStop</code> 时执行一个脚本进行协调。这个方案碰到的第一个问题是缺乏全局信息，脚本中无法区分
当前是在滚动升级还是缩容。当然，这可以通过在脚本中查询 apiserver 来绕过。更大的问题是 <code>preStop</code> hook 存在 grace period，kubelet 最多等待 <code>.spec.terminationGracePeriodSeconds</code> 这么长的
时间，就会强制删除 Pod。对于 TiDB 的场景而言，我们更希望在自动的下线逻辑失败时进行等待并报警，通知运维人员介入，以便于最小化影响，因此基于 container hook 来做是不可接受的。</p>

<p>第二种方案是在控制循环中来协调应用层的状态。比如，我们可以通过 partition 字段来控制 StatefulSet 升级进度，并在升级前确保 leader 迁移完毕：</p>

<p><img src="/qcon-operator-control.png" alt="qcon-operator-control.png" width="800px" />
<em>（图五：在控制循环中协调状态）</em></p>

<p>在伪代码中，每次我们因为要将所有 Pod 收敛到新版本而进入这段控制逻辑时，都会先检查下一个要待升级的 TiKV 实例上 leader 是否迁移完毕，直到迁移完毕才会继续往下走，调整 partition 参数，开始升级
对应的 TiKV 实例。缩容也是类似的逻辑。但你可能已经意识到，缩容和滚动更新两个操作是有可能同时出现在状态收敛的过程中的，也就是同时修改 replicas 和 image 字段。这时候由于控制器需要区分缩容与
滚动更新，诸如此类的边界条件会让控制器越来越复杂。</p>

<p>第三种方案是使用 Kubernetes 的 Admission Webhook 将一部分协调逻辑从控制器中拆出来，放到更纯粹的切面当中。针对这个例子，我们可以拦截 Pod 的 Delete 请求和针对上层对象的 Update 请求，检查
缩容或滚动升级的前置条件，假如不满足，则拒绝请求并触发指令进行协调，比如驱逐 leader，假如满足，那么就放行请求。控制循环会不断下发指令直到状态收敛，因此 webhook 就相应地会不断进行检查直到条件
满足：</p>

<p><img src="/qcon-operator-webhook.png" alt="qcon-operator-webhook.png" width="800px" />
<em>（图六：在 Webhook 中协调状态）</em></p>

<p>这种方案的好处是我们把逻辑拆分到了一个与控制器垂直的单元中，从而可以更容易地编写业务代码和单元测试。当然，这个方案也有缺点，一是引入了新的错误模式，处理 webhook 的 server 假如宕机，会造成集群
功能降级；二是该方案适用面并不广，只能用于状态协调与特定的 Kubernetes API 操作强相关的场景。在实际的代码实践中，我们会按照具体场景选择方案二或方案三，大家也可以到项目中一探究竟。</p>

<p>上面的的两个例子都是关于如何控制编排逻辑复杂度的，关于 Operator 的各类科普文中都会用一句“在自定义控制器中编写领域特定的运维知识”将这一部分轻描淡写地一笔带过，而我们的实践告诉我们，真正编写生产级
的自定义控制器充满挑战与抉择。</p>

<h2 id="local-pv-想说爱你不容易">Local PV —— 想说爱你不容易</h2>

<p>接下来是存储的问题。我们不妨看看 Kubernetes 为我们提供了哪些存储方案：</p>

<p><img src="/qcon-storage.png" alt="qcon-storage.png" width="800px" />
<em>（图七：存储方案）</em></p>

<p>其中，本地临时存储中的数据会随着 Pod 被删除而清空，因此不适用于持久存储。</p>

<p>远程存储则面临两个问题：</p>

<ul>
<li>通常来说，远程存储的性能较差，这尤其体现在 IOPS 不够稳定上，因此对于磁盘性能有严格要求的有状态应用，大多数远程存储是不适用的；</li>
<li>通常来说，远程存储本身会做三副本，因此单位成本较高，这对于在存储层已经实现三副本的 TiDB 来说是不必要的成本开销；</li>
</ul>

<p>因此，最适用于 TiDB 的是本地持久存储。这其中，hostPath 的生命周期又不被 Kubernetes 管理，需要付出额外的维护成本，最终的选项就只剩下了 Local PV。</p>

<p>Local PV 并非免费的午餐，所有的文档都会告诉我们 Local PV 有以下限制：</p>

<ul>
<li>数据易失（相比于远程存储的三副本）</li>
<li>节点故障会影响数据访问</li>
<li>难以垂直扩展容量（相当一部分远程存储可以直接调整 volume 大小）</li>
</ul>

<p>这些问题同样也是在传统的虚拟机运维场景下的痛点，因此 TiDB 本身设计就充分考虑了这些问题：</p>

<ul>
<li>本地存储的易失性要求应用自身实现数据冗余

<ul>
<li>TiDB 的存储层 TiKV 默认就为每个 Region 维护至少三副本</li>
<li>当副本缺失时，TiKV 能自动补齐副本数</li>
</ul></li>
<li>节点故障会影响本地存储的数据访问

<ul>
<li>节点故障后，相关 Region 会重新进行 leader 选举，将读写自动迁移到健康节点上</li>
</ul></li>
<li>本地存储的容量难以垂直扩展

<ul>
<li>TiKV 的自动数据切分与调度能够实现水平伸缩</li>
</ul></li>
</ul>

<p>存储层的这些关键特性是 TiDB 高效使用 Local PV 的前提条件，也是 TiDB 水平伸缩的关键所在。当然，在发生节点故障或磁盘故障时，由于旧 Pod 无法正常运行，我们需要自定义控制器
帮助我们进行恢复，及时补齐实例数，确保有足够的健康实例来提供整个集群所需的存储空间、计算能力与 IO 能力。这也就是自动故障转移。</p>

<p>我们先看一看为什么 TiDB 的存储层不能像无状态应用或者使用远程存储的 Pod 那样自动进行故障转移。假设下图中的节点发生了故障，由于 TiKV-1 绑定了节点上的 PV，只能运行在该节点上，因此
在节点恢复前，TiKV-1 将一直处于 Pending 状态：</p>

<p><img src="/qcon-failover.png" alt="qcon-failover.png" width="800px" />
<em>（图八：节点故障）</em></p>

<p>此时，假如我们能够确认 Node 已经宕机并且短期无法恢复，那么就可以删除 Node 对象（比如 NodeController 在公有云商会查询公有云的 API 来删除已经释放的 Node）。此时，控制器通过 Node 对象不存在
这一事实理解了 Node 已经无法恢复，就可以直接删除 pvc-1 来解绑 PV，并强制删除 TiKV-1，最终让 TiKV-1 调度到其它节点上。当然，我们同时也要做应用层状态的协调，也就是先在 PD 中下线 TiKV-1，再将
新的 TiKV-1 作为一个新成员加入集群，此时，PD 就会通知 TiKV-1 创建 Region 副本来补齐集群中的 Region 副本数。</p>

<p><img src="/qcon-failover-delete.png" alt="qcon-failover-delete.png" width="800px" />
<em>（图九：能够确定节点状态时的故障转移）</em></p>

<p>当然，更多的情况下，我们是无法在自定义控制器中确定节点状态的，此时就很难针对性地进行原地恢复，因此我们通过向集群中添加新 Pod 来进行故障转移：</p>

<p><img src="/qcon-failover-append.png" alt="qcon-failover-append.png" width="800px" />
<em>（图十：无法确定节点状态时的故障转移）</em></p>

<p>上面讲的是 TiDB 特有的故障转移策略，但其实可以类推到大部分的有状态应用上。比如对于 MySQL 的 Slave，我们同样可以通过新增 slave 来做 failover，而在 failover 时，我们同样也要做应用层的一些事情，
比如说去 S3 上拉一个全量备份，再通过 binlog 把增量数据补上，当 lag 达到可接受的程度之后开始对外提供读服务。因此大家就可以发现，对于有状态应用的 failover 策略是共通的，也都需要应用本身支持某种
failover 形式。比如对于 MySQL 的 master，我们只能通过 M-M 模式做一定程度上的 failover，而且还会损失数据一致性。这当然不是 Kubernetes 或云原生本身有什么问题，而是说 Kubernetes 只是改变了
应用的运维模式，但并不能影响应用本身的架构特性。假如应用本身的设计就不是云原生的，那只能从应用本身去解决。</p>

<h2 id="总结">总结</h2>

<p>通过 TiDB Operator 的实践，我们有这么几条总结：</p>

<ul>
<li>Operator 本身的复杂度不可忽视；</li>
<li>Local PV 能满足高 IO 性能需求，代价则是编排上额外的复杂度；</li>
<li>应用本身必须迈向云原生（meets kubernetes part way）；</li>
</ul>

<p>最后，言语的描述总是不如代码本身来得简洁有力，TiDB Operator 是一个完全开源的项目，眼见为实，大家可以尽情到<a href="https://github.com/pingcap/TiDB Operator">项目仓库</a>中拍砖。也欢迎大家加入社区一起玩起来，期待你的 issue 和 PR！</p>

<p>假如你对于文章有任何问题或建议，或是想直接加入原厂鼓捣相关项目，欢迎通过我的邮箱 wuyelei@pingcap.com 联系我。</p>
]]></content>
		</item>
		
		<item>
			<title>我的 Promtheus 到底啥时候报警？</title>
			<link>https://www.aleiwu.com/post/prometheus-alert-why/</link>
			<pubDate>Tue, 29 Oct 2019 13:27:12 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/prometheus-alert-why/</guid>
			<description>最近又被问到了 Prometheus 为啥不报警，恰好回忆起之前经常解答相关问题，不妨写一篇文章来解决下面两个问题： 我的 Prometheus 为啥报警？ 我的 Prometheus 为啥不报警？ 从 for 参数开始</description>
			<content type="html"><![CDATA[

<p>最近又被问到了 Prometheus 为啥不报警，恰好回忆起之前经常解答相关问题，不妨写一篇文章来解决下面两个问题：</p>

<ul>
<li><strong>我的 Prometheus 为啥报警？</strong></li>
<li><strong>我的 Prometheus 为啥不报警？</strong></li>
</ul>

<h2 id="从-for-参数开始">从 for 参数开始</h2>

<p>我们首先需要一些背景知识：Prometheus 是如何计算并产生警报的？</p>

<p>看一条简单的警报规则：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">-<span class="w"> </span>alert<span class="p">:</span><span class="w"> </span>KubeAPILatencyHigh<span class="w">
</span><span class="w">  </span>annotations<span class="p">:</span><span class="w">
</span><span class="w">    </span>message<span class="p">:</span><span class="w"> </span>The<span class="w"> </span>API<span class="w"> </span>server<span class="w"> </span>has<span class="w"> </span>a<span class="w"> </span>99th<span class="w"> </span>percentile<span class="w"> </span>latency<span class="w"> </span>of<span class="w"> </span>{{<span class="w"> </span>$value<span class="w"> </span>}}<span class="w"> </span>seconds<span class="w">
</span><span class="w">      </span>for<span class="w"> </span>{{<span class="w"> </span>$labels.verb<span class="w"> </span>}}<span class="w"> </span>{{<span class="w"> </span>$labels.resource<span class="w"> </span>}}.<span class="w">
</span><span class="w">  </span>expr<span class="p">:</span><span class="w"> </span><span class="sd">|
</span><span class="sd">    cluster_quantile:apiserver_request_latencies:histogram_quantile{job=&#34;apiserver&#34;,quantile=&#34;0.99&#34;,subresource!=&#34;log&#34;} &gt; 4</span><span class="w">
</span><span class="w">  </span>for<span class="p">:</span><span class="w"> </span>10m<span class="w">
</span><span class="w">  </span>labels<span class="p">:</span><span class="w">
</span><span class="w">    </span>severity<span class="p">:</span><span class="w"> </span>critical</code></pre></div>
<p>这条警报的*大致*含义是，假如 kube-apiserver 的 P99 响应时间大于 4 秒，并持续 10 分钟以上，就产生报警。</p>

<p>首先要注意的是由 `for` 指定的 Pending Duration。这个参数主要用于降噪，很多类似响应时间这样的指标都是有抖动的，通过指定 Pending Duration，我们可以
过滤掉这些瞬时抖动，让 on-call 人员能够把注意力放在真正有持续影响的问题上。</p>

<p>那么显然，下面这样的状况是不会触发这条警报规则的，因为虽然指标已经达到了警报阈值，但持续时间并不够长：</p>

<figure>
    <img src="/prometheus-peaks.png" width="800px"/> 
</figure>


<p>但偶尔我们也会碰到更奇怪的事情。</p>

<h2 id="为什么不报警">为什么不报警？</h2>

<p><img src="/no-alert.jpg" alt="no-alert.jpg" width="800px" />
<em>(图二: 为啥不报警)</em></p>

<p>类似上面这样持续超出阈值的场景，为什么有时候会不报警呢？</p>

<h2 id="为什么报警">为什么报警？</h2>

<p><img src="/why-alert.jpg" alt="why-alert.jpg" width="800px" />
<em>(图三: 为啥会报警)</em></p>

<p>类似上面这样并未持续超出阈值的场景，为什么有时又会报警呢？</p>

<h2 id="采样间隔">采样间隔</h2>

<p>这其实都源自于 Prometheus 的数据存储方式与计算方式。</p>

<p>首先，Prometheus 按照配置的抓取间隔(`scrape_interval`)定时抓取指标数据，因此存储的是形如 (timestamp, value) 这样的采样点。</p>

<p>对于警报， Prometheus 会按固定的时间间隔重复计算每条警报规则，因此警报规则计算得到的只是稀疏的采样点，而警报持续时间是否大于
`for` 指定的 Pending Duration 则是由这些稀疏的采样点决定的。</p>

<p>而在 Grafana 渲染图表时，Grafana 发送给 Prometheus 的是一个 Range Query，其执行机制是从时间区间的起始点开始，每隔一定的时间点（由 Range Query 的 `step` 请求参数决定）
进行一次计算采样。</p>

<p>这些结合在一起，就会导致警报规则计算时“看到的内容”和我们在 Grafana 图表上观察到的内容不一致，比如下面这张示意图：</p>

<figure>
    <img src="/alert-firing.jpg" width="800px"/> 
</figure>


<p>上面图中，圆点代表原始采样点：</p>

<ul>
<li>40s 时，第一次计算，低于阈值</li>
<li>80s 时，第二次计算，高于阈值，进入 Pending 状态</li>
<li>120s 时，第三次计算，仍然高于阈值，90s 处的原始采样点虽然低于阈值，但是警报规则计算时并没有”看到它“</li>
<li>160s 时，第四次计算，高于阈值，Pending 达到 2 分钟，进入 firing 状态</li>
<li>持续高于阈值</li>
<li>直到 360s 时，计算得到低于阈值，警报消除</li>
</ul>

<p>由于采样是稀疏的，部分采样点会出现被跳过的状况，而当 Grafana 渲染图表时，取决于 Range Query 中采样点的分布，图表则有可能捕捉到
被警报规则忽略掉的”低谷“（图三)或者也可能无法捕捉到警报规则碰到的”低谷“（图二）。如此这般，我们就被”图表“给蒙骗过去，质疑起警报来了。</p>

<h2 id="如何应对">如何应对</h2>

<p>首先嘛， Prometheus 作为一个指标系统天生就不是精确的——由于指标本身就是稀疏采样的，事实上所有的图表和警报都是&rdquo;估算&rdquo;，我们也就不必
太纠结于图表和警报的对应性，能够帮助我们发现问题解决问题就是一个好监控系统。当然，有时候我们也得证明这个警报确实没问题，那可以看一眼
`ALERTS` 指标。`ALERTS` 是 Prometheus 在警报计算过程中维护的内建指标，它记录每个警报从 Pending 到 Firing 的整个历史过程，拉出来一看也就清楚了。</p>

<p>但有时候 ALERTS 的说服力可能还不够，因为它本身并没有记录每次计算出来的值到底是啥，而在我们回头去考证警报时，又无法选取出和警报计算过程中一模一样的计算时间点，
因此也就无法还原警报计算时看到的计算值究竟是啥。这时候终极解决方案就是把警报所要计算的指标定义成一条 Recording Rule，计算出一个新指标来记录计算值，然后针对这个
新指标做阈值报警。kube-prometheus 的警报规则中就大量采用了<a href="https://github.com/coreos/kube-prometheus/blob/03b36af546c26ef6106c4fd141a948293ec0a18f/manifests/prometheus-rules.yaml#L201">这种技术</a>。</p>

<h2 id="到此为止了吗">到此为止了吗？</h2>

<p>Prometheus 警报不仅包含 Prometheus 本身，还包含用于警报治理的 Alertmanager，我们可以看一看上面那张指标计算示意图的全图：</p>

<figure>
    <img src="/alert-overview.jpg" width="800px"/> 
</figure>


<p>在警报产生后，还要经过 Alertmanager 的分组、抑制处理、静默处理、去重处理和降噪处理最后再发送给接收者。而这个过程也有大量的因素可能会导致警报产生了却最终
没有进行通知。这部分的内容，之前的文章 <a href="https://aleiwu.com/post/alertmanager/">搞搞 Prometheus：Alertmanager</a> 已经涵盖，这两篇内容加在一起，也算是能把开头的两个问题解答得差不多了吧😂。</p>
]]></content>
		</item>
		
		<item>
			<title>Kubernetes 中的 ConfigMap 配置更新(续)</title>
			<link>https://www.aleiwu.com/post/configmap-rollout-followup/</link>
			<pubDate>Wed, 15 May 2019 21:03:16 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/configmap-rollout-followup/</guid>
			<description>之前的文章 Kubernetes Pod 中的 ConfigMap 更新 中，我总结了三种 ConfigMap 或 Secret 的更新方法: 通过 Kubelet 的周期性 Remount 做热更新，通过修改对象中的 PodTemplate 触发滚动更新，以及通过自定义 Controller 监听 ConfigMap 触</description>
			<content type="html"><![CDATA[

<blockquote>
<p>之前的文章 <a href="https://aleiwu.com/post/configmap-hotreload/">Kubernetes Pod 中的 ConfigMap 更新</a> 中，我总结了三种 ConfigMap 或 Secret 的更新方法: <a href="https://aleiwu.com/post/configmap-hotreload/#%E7%83%AD%E6%9B%B4%E6%96%B0%E4%BA%8C-%E4%BD%BF%E7%94%A8-sidecar-%E6%9D%A5%E7%9B%91%E5%90%AC%E6%9C%AC%E5%9C%B0%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6%E5%8F%98%E6%9B%B4">通过 Kubelet 的周期性 Remount 做热更新</a>，<a href="https://aleiwu.com/post/configmap-hotreload/#pod-%E6%BB%9A%E5%8A%A8%E6%9B%B4%E6%96%B0%E4%B8%80-%E4%BF%AE%E6%94%B9-ci-%E6%B5%81%E7%A8%8B">通过修改对象中的 PodTemplate 触发滚动更新</a>，以及<a href="https://aleiwu.com/post/configmap-hotreload/#pod-%E6%BB%9A%E5%8A%A8%E6%9B%B4%E6%96%B0%E4%BA%8C-controller">通过自定义 Controller 监听 ConfigMap 触发更新</a>。但在最近的业务实践中，却碰到了这些办法都不好使的情况。这篇文章就将更为深入地讨论这个主题。</p>
</blockquote>

<h1 id="问题在哪">问题在哪？</h1>

<p>这次碰到的问题，并不是上面的办法<strong>无法实现配置热更新</strong>，而是<strong>无法实现配置滚动发布</strong>。我们不妨看一个常见的例子，一个 <code>Deployment</code> 引用了一个 <code>ConfigMap</code> (简洁起见，删去了 selector 和 labels 等字段)：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">apiVersion<span class="p">:</span><span class="w"> </span>apps/v1<span class="w">
</span><span class="w"></span>kind<span class="p">:</span><span class="w"> </span>Deployment<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>nginx-deployment<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w">  </span>replicas<span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w">
</span><span class="w">  </span>template<span class="p">:</span><span class="w">
</span><span class="w">    </span>annotations<span class="p">:</span><span class="w">
</span><span class="w">      </span>nginx-config-md5<span class="p">:</span><span class="w"> </span>d41d8cd98f00b204e9800998ecf8427e<span class="w">
</span><span class="w">    </span>spec<span class="p">:</span><span class="w">
</span><span class="w">      </span>containers<span class="p">:</span><span class="w">
</span><span class="w">      </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>nginx<span class="w">
</span><span class="w">        </span>image<span class="p">:</span><span class="w"> </span>nginx<span class="w">
</span><span class="w">        </span>volumeMounts<span class="p">:</span><span class="w">
</span><span class="w">        </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>nginx-config<span class="w">
</span><span class="w">          </span>mountPath<span class="p">:</span><span class="w"> </span>/etc/config<span class="w">
</span><span class="w">      </span>volumes<span class="p">:</span><span class="w">
</span><span class="w">      </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>config-volume<span class="w">
</span><span class="w">        </span>configMap<span class="p">:</span><span class="w"> 
</span><span class="w">          </span>name<span class="p">:</span><span class="w"> </span>nginx-config<span class="w">
</span><span class="w"></span>---<span class="w">
</span><span class="w"></span>apiVersion<span class="p">:</span><span class="w"> </span>v1<span class="w">
</span><span class="w"></span>kind<span class="p">:</span><span class="w"> </span>ConfigMap<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>nginx-config<span class="w">
</span><span class="w"></span>data<span class="p">:</span><span class="w">
</span><span class="w">  </span>nginx.conf<span class="p">:</span><span class="w"> </span>|<span class="sd">-
</span><span class="sd">    ## some configurations...</span></code></pre></div>
<p>这里我们采用了 <a href="https://aleiwu.com/post/configmap-hotreload/#pod-%E6%BB%9A%E5%8A%A8%E6%9B%B4%E6%96%B0%E4%B8%80-%E4%BF%AE%E6%94%B9-ci-%E6%B5%81%E7%A8%8B">通过修改对象中的 PodTemplate 触发滚动更新</a> 这个方案来做 ConfigMap 的更新：</p>

<ul>
<li>每次部署时，计算 ConfigMap 的摘要(e.g. MD5)，并填入 PodTemplate 的 Annotation 中;</li>
<li>假如 ConfigMap 发生变化，则摘要也会变化[^1],此会触发一次 Deployment 的滚动更新;</li>
</ul>

<p>现在，我们更新了一次配置，但很不幸新的配置是<strong>错误</strong>的，使用错误配置的 Pod 将无法正常工作（比如无法通过 <code>readinessProbe</code> 的检查）。最终，滚定更新的过程会卡住，错误的配置并不会让你的 Deployment 整个宕掉。</p>

<p>真的是这样吗？</p>

<p>并不是！</p>

<p>问题在这里就显现出来了，<code>nginx-config</code> 已经更新成了错误的值，尽管<strong>尚未重建的 Pod</strong>目前暂且健康，但是，一旦这些 Pod 宕掉发生 Pod 重建，或者 Pod 中的容器重新读取了一次配置，这些 Pod 就会进入异常状态：整个集群现在是摇摇欲坠的。</p>

<p>问题的根源是，<strong>在原地更新 ConfigMap 或 Secret 时，我们没有做滚动发布，而是一次性将新配置更新到了整个集群的所有实例中</strong>，我们所谓的&rdquo;滚动更新&rdquo;其实是在控制各个实例<strong>何时读取新配置</strong>，但由于 Pod 随时可能挂掉重建，我们是无法做到准确控制这个过程的。</p>

<p>假如你认为“错误的配置“很少见，那一个更有力的例子是 StatefulSet 的灰度发布（使用 StatefulSet 的 Partition 字段控制只把部分副本更新到新的 ControllerRevision)，假如我们 StatefulSet 的配置也做灰度发布，那配置更新的问题就更明显了。</p>

<p>显然，同样的问题对于另两种更新方案也存在。当然，这并不是说这三种方式是错误的，它们也有自己适用的场景，只是当我们需要配置文件的更新滚动发布时，它们就不再适用了。</p>

<h1 id="解决方案">解决方案</h1>

<p>上述方案的问题在于原地更新，要解决这个问题，我们只需要在每次 ConfigMap 变化时，重新生成一个 ConfigMap，再更新 Deployment 使用这个新的 ConfigMap 即可。而重新生成 ConfigMap 最简单的方式就是在 ConfigMap 的命名中加上 ConfigMap 的 data 值计算出的摘要，比如:</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">apiVersion<span class="p">:</span><span class="w"> </span>v1<span class="w">
</span><span class="w"></span>kind<span class="p">:</span><span class="w"> </span>ConfigMap<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>nginx-config-d41d8cd98f00b204e9800998ecf8427e<span class="w">
</span><span class="w"></span>data<span class="p">:</span><span class="w">
</span><span class="w">  </span>nginx.conf<span class="p">:</span><span class="w"> </span>|<span class="sd">-
</span><span class="sd">    ## some configurations...</span></code></pre></div>
<p>事实上，ConfigMap 滚动更新（ConfigMap Rollout）是社区中历时最久而尚未解决的问题之一（<a href="22368">#22368</a>), 到目前为止，解决这个问题的方向也正是&rdquo;每次更新新建一个 ConfigMap&rdquo;这种&rdquo;Immutable ConfigMap&rdquo;模式(详见<a href="https://github.com/kubernetes/kubernetes/issues/22368#issuecomment-421141188">这条评论</a>)。</p>

<p>这个方案自然就带来两个问题：</p>

<ul>
<li>如何做到每次配置文件更新时，都创建一个新的 ConfigMap？

<ul>
<li>目前社区的态度是把这一步放到 Client 来解决，比如 kustomize 和 helm</li>
</ul></li>
<li>历史 ConfigMap 会不断积累，怎么回收？

<ul>
<li>针对这点，社区希望在服务端实现一个 GC 机制来清理没有任何资源引用的 ConfigMap
<br /></li>
</ul></li>
</ul>

<p>当然，把逻辑放到 Client 里势必造成重复造轮子的问题：每一个工具都必须实现一遍类似的逻辑。因此也有人提议通过 Snapshot 的方式把逻辑全都推到服务端，这个方向目前八字都还没一撇，我们且按下不表。至少到现在为止，在 Client 做 ConfigMap 的新建与 Deployment 等对象的更新是最成熟的 ConfigMap 滚动更新方案。</p>

<p>因此，我们就在最后一节来说明 Helm 和 Kustomize 里怎么实现这个方案。</p>

<h1 id="helm-和-kustomize-的实践方式">Helm 和 Kustomize 的实践方式</h1>

<h2 id="kustomize">Kustomize</h2>

<p>kustomize 对这个方案有内置的支持，只需要使用 <code>configGenerator</code> 即可：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">configMapGenerator<span class="p">:</span><span class="w">
</span><span class="w"></span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>my-configmap<span class="w">
</span><span class="w">  </span>files<span class="p">:</span><span class="w">
</span><span class="w">  </span>-<span class="w"> </span>common.properties</code></pre></div>
<p>这段 yaml 在 kustomize 中就会生成一个 ConfigMap 对象，这个对象的 data 来自于 <code>common.properties</code> 文件，并且 name 中会加上该文件的 SHA 值作为后缀。</p>

<p>在 kustomize 的其它 layer 中，只要以 <code>my-configmap</code> 作为 name 引用这个 ConfigMap 即可，当最终渲染时，kustomize 会自动进行替换操作。</p>

<h2 id="helm">Helm</h2>

<p>首先注意，Helm 在 <a href="https://github.com/helm/helm/blob/master/docs/charts_tips_and_tricks.md#automatically-roll-deployments-when-configmaps-or-secrets-change">tips and tricks</a> 里提到的修改 Annotation 的方案是无法做到滚动更新的，原因见第一节，假如你想要的是合理的滚动更新的话，注意不要踩到坑里去。</p>

<p>Helm 对于这个方案没有比较好的支持，需要依托于 named template 机制来封装一下。思路是定义一个 named template，用于渲染 ConfigMap 的 data，然后针对这个 named template 计算 SHA 值并添加到 ConfigMap 名字中。</p>
<div class="highlight"><pre class="chroma">{{/*
定义一个 Named Template，将配置文件渲染为 ConfigMap
*/}}
{{- define &#34;my-configmap.data&#34; -}}
config.toml: |-
{{ include (print $.Template.BasePath &#34;/_config.toml.tpl&#34;) . | indent 2 }}
another-config.yaml: |-
{{ include (print $.Template.BasePath &#34;/_another-config.yaml.tpl&#34;) . | indent 2 }}
{{- end -}}

{{/*
ConfigMap 部分
*/}}
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-configmap-{{ include &#34;my-configmap.data&#34; . | sha256sum | trunc 8 }}
data:
{{ include &#34;my-configmap.data&#34; . | indent 2 }}
---
{{/*
Deployment 部分
*/}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  replicas: 3
  template:
    spec:
      volumes:
        - name: my-config
          configMap:
            name: my-configmap-{{ include &#34;my-configmap.data&#34; . | sha256sum | trunc 8 }}
...(其它字段略)</pre></div>
<p>关键点只有一个，那就是把 configmap 的 data 定义成 Named Template，这样就可以很容易地访问这个模板的渲染值并且计算 SHA。</p>

<p>当然上面只是个示例，正式写 chart 时，还要做好合理的文件切分，比如把 Named Template 放到 <code>_helpers.tpl</code>文件中统一维护。</p>

<blockquote>
<p>当资源名字改变后，Helm 会自动删除旧的资源，因此使用 Helm 时不必担心 ConfigMap 累计过多的问题。缺点就是我们无法脱离 helm 去做 rollback。</p>
</blockquote>

<h1 id="结语">结语</h1>

<p>事实上这种从 Client 端出发的解决方案并不是很优雅，或多或少都有比较 Hack 的感觉。尽管客观情况是 ConfigMap 和 Secret 现在的用法已经完全深入到所有的 Kubernetes 使用场景中，对于如此基础的资源，很难大刀阔斧地去做改进，甚至于小修小补都是&rdquo;牵一发而动全身&rdquo;，需要非常慎重地去考虑。但社区的创造力是无穷的，最近又有相关的 <a href="https://github.com/kubernetes/enhancements/pull/948">KEP</a> 涌现出来(尽管还很粗糙)，相信在不远的将来，我们会看到更好用的 ConfigMap Rollout 管理机制。</p>
]]></content>
		</item>
		
		<item>
			<title>搞搞 Prometheus: Alertmanager</title>
			<link>https://www.aleiwu.com/post/alertmanager/</link>
			<pubDate>Sun, 21 Apr 2019 21:55:47 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/alertmanager/</guid>
			<description>警报是监控系统中必不可少的一块, 当然了, 也是最难搞的一块. 我们乍一想, 警报似乎很简单一件事: 假如发生了异常情况, 发送或邮件/消息通知给某人或</description>
			<content type="html"><![CDATA[

<p>警报是监控系统中必不可少的一块, 当然了, 也是最难搞的一块. 我们乍一想, 警报似乎很简单一件事:</p>

<blockquote>
<p>假如发生了异常情况, 发送或邮件/消息通知给某人或某频道</p>
</blockquote>

<p>一把梭搞起来之后, 就不免有一些小麻烦:</p>

<ul>
<li>这个啊&hellip;一天中总有那么几次波动, 也难修难查了, 算了算了不看了</li>
<li>警报太多了, 实在看不过来, 屏蔽/归档/放生吧&hellip;</li>
<li>有毒吧, 这个阈值也太低了</li>
<li>卧槽, 这些警报啥意思啊, 发给我干嘛啊?</li>
<li>卧槽卧槽卧槽, 怎么一下子几十百来条警报, 哦&hellip;原来网络出问题了全崩了</li>
</ul>

<p>到最后我们还能总结出一个奇怪的规律:</p>

<blockquote>
<p>这世界上只有两种警报，一种是疯狂报警但是没有卵用完全没人看的警报，一种是非常有效大家都想看但在用户反馈前从来都报不出来的警报。—— 鲁迅(</p>
</blockquote>

<p>玩笑归玩笑，但至少我们能看出，警报不是一个简单的计算+通知系统。只是，&rdquo;做好警报&rdquo;这件事本身是个综合问题，代码能解决的也只是其中的一小部分，更多的事情要在组织、人事和管理上去做。我掰不出那么有深度的文章，这篇文章就专注一点，只讲代码部分里的<strong>通知</strong>，也就是 Prometheus 生态中的 Alertmanager 这个组件。</p>

<h1 id="为什么要-alertmanager">为什么要 Alertmanager？</h1>

<p>我们先介绍一点背景知识，Prometheus 生态中的警报是在 Prometheus Server 中计算警报规则(Alert Rule)并产生的，而所谓计算警报规则，其实就是周期性地执行一段 PromQL，得到的查询结果就是警报，比如:</p>
<div class="highlight"><pre class="chroma">node_load5 &gt; 20</pre></div>
<p>这个 PromQL 会查出所有&rdquo;在最近一次采样中，5分钟平均 Load 大于 20&rdquo;的时间序列。这些序列带上它们的标签就被转化为警报。</p>

<p>只是，当 Prometheus Server 计算出一些警报后，它自己并没有能力将这些警报通知出去，只能将警报推给 Alertmanager，由 Alertmanager 进行发送。</p>

<p>这个切分，一方面是出于单一职责的考虑，让 Prometheus &ldquo;do one thing and do it well&rdquo;, 另一方面则是因为警报发送确实不是一件&rdquo;简单&rdquo;的事，需要一个专门的系统来做好它。可以这么说，Alertmanager 的目标不是简单地&rdquo;发出警报&rdquo;，而是&rdquo;发出高质量的警报&rdquo;。它提供的高级功能包括但不限于：</p>

<ul>
<li>Go Template 渲染警报内容；</li>
<li>管理警报的重复提醒时机与消除后消除通知的发送；</li>
<li>根据标签定义警报路由，实现警报的优先级、接收人划分，并针对不同的优先级和接收人定制不同的发送策略；</li>
<li>将同类型警报打包成一条通知发送出去，降低警报通知的频率；</li>
<li>支持静默规则: 用户可以定义一条静默规则，在一段时间内停止发送部分特定的警报，比如已经确认是搜索集群问题，在修复搜索集群时，先静默掉搜索集群相关警报；</li>
<li>支持&rdquo;抑制&rdquo;规则(Inhibition Rule): 用户可以定义一条&rdquo;抑制&rdquo;规则，规定在某种警报发生时，不发送另一种警报，比如在&rdquo;A 机房网络故障&rdquo;这条警报发生时，不发送所有&rdquo;A 机房中的警报&rdquo;；</li>
</ul>

<p>假如你很忙，那么读到这里就完全 OK 了，反正这类文章最大的作用就是让我们&rdquo;<strong>知道有 X 这回事，大概了解有啥特性，当有需求匹配时，能想到试试看 X 合不合适</strong>&ldquo;，其中 X = Alertmanager。当然，假如你是个好奇宝宝，那么还可以看看下面的解析。</p>

<h1 id="alertmanager-内部架构">Alertmanager 内部架构</h1>

<p>先看官方文档中的架构图：</p>

<p><img src="/img/alertmanager/alertmanager.png" alt="" /></p>

<ol>
<li>从左上开始，Prometheus 发送的警报到 Alertmanager;</li>
<li>警报会被存储到 AlertProvider 中，Alertmanager 的内置实现就是包了一个 map，也就是存放在本机内存中，这里可以很容易地扩展其它 Provider;</li>
<li>Dispatcher 是一个单独的 goroutine，它会不断到 AlertProvider 拉新的警报，并且根据 YAML 配置的 <code>Routing Tree</code> 将警报路由到一个分组中;</li>
<li>分组会定时进行 flush (间隔为配置参数中的 group_interval), flush 后这组警报会走一个 <code>Notification Pipeline</code> 链式处理;</li>
<li><code>Notification Pipeline</code> 为这组警报确定发送目标，并执行抑制逻辑，静默逻辑，去重逻辑，发送与重试逻辑，实现警报的最终投递;</li>
</ol>

<p>下面就分开讲一讲核心的两块：</p>

<ol>
<li>Dispatcher 中的 Routing Tree 的实现与设计意图</li>
<li>Notification Pipeline 的实现与设计意图</li>
</ol>

<h2 id="routing-tree">Routing Tree</h2>

<p>Routing Tree 的是一颗多叉树，节点的数据结构定义如下：</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="c1">// 节点包含警报的路由逻辑
</span><span class="c1"></span><span class="kd">type</span> <span class="nx">Route</span> <span class="kd">struct</span> <span class="p">{</span>
    <span class="c1">// 父节点
</span><span class="c1"></span>    <span class="nx">parent</span> <span class="o">*</span><span class="nx">Route</span>
    <span class="c1">// 节点的配置，下文详解
</span><span class="c1"></span>    <span class="nx">RouteOpts</span> <span class="nx">RouteOpts</span>
    <span class="c1">// Matchers 是一组匹配规则，用于判断 Alert 与当前节点是否匹配
</span><span class="c1"></span>    <span class="nx">Matchers</span> <span class="nx">types</span><span class="p">.</span><span class="nx">Matchers</span>
    <span class="c1">// 假如为 true, 那么 Alert 在匹配到一个节点后，还会继续往下匹配
</span><span class="c1"></span>    <span class="nx">Continue</span> <span class="kt">bool</span>
    <span class="c1">// 子节点
</span><span class="c1"></span>    <span class="nx">Routes</span> <span class="p">[]</span><span class="o">*</span><span class="nx">Route</span>
<span class="p">}</span></code></pre></div>
<p>具体的处理代码很简单，深度优先搜索：警报从 root 开始匹配（root 默认匹配所有警报），然后根据节点中定义的 Matchers 检测警报与节点是否匹配，匹配则继续往下搜索，默认情况下第一个&rdquo;最深&rdquo;的 match (也就是 DFS 回溯之前的最后一个节点)会被返回。特殊情况就是节点配置了 <code>Continue=true</code>，这时假如这个节点匹配上了，那不会立即返回，而是继续搜索，用于支持警报发送给多方这种场景（比如&rdquo;抄送&rdquo;)</p>
<div class="highlight"><pre class="chroma"># 深度优先搜索
func (r *Route) Match(lset model.LabelSet) []*Route {
    if !r.Matchers.Match(lset) {
    return nil
    }

    var all []*Route
    for _, cr := range r.Routes {
        // 递归调用子节点的 Match 方法
        matches := cr.Match(lset)

        all = append(all, matches...)

        if matches != nil &amp;&amp; !cr.Continue {
          break
        }
    }

    // 假如没有任何节点匹配上，那就匹配根节点
    if len(all) ==0 {
        all = append(all, r)
    }
    return all
}</pre></div>
<p>为什么要设计一个复杂的 Routing Tree 逻辑呢？我们看看 Prometheus 官方的配置例子：
为了简化编写，Alertmanager 的设计是根节点的所有参数都会被子节点继承（除非子节点重写了这个参数）</p>
<div class="highlight"><pre class="chroma">route:
  # 根节点的警报会发送给默认的接收组
  # 该节点中的警报会按’cluster’和’alertname’做 Group，每个分组中最多每5分钟发送一条警报，同样的警报最多4小时发送一次
  receiver:’default-receiver’
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  group_by: [cluster, alertname]
  # 没有匹配到子节点的警报，会默认匹配到根节点上
  # 接下来是子节点的配置：
  routes:
    # 所有 service 字段为 mysql 或 cassandra 的警报，会发送到’database-pager’这个接收组
    # 由于继承逻辑，这个节点中的警报仍然是按’cluster’和’alertname’做 Group 的
  - receiver:’database-pager’
    group_wait: 10s
    match_re:
    service: mysql|cassandra
    # 所有 team 字段为 fronted 的警报，会发送到’frontend-pager’这个接收组
    # 很重要的一点是，这个组中的警报是按’product’和’environment’做分组的，因为’frontend’面向用户，更关心哪个’产品’的什么’环境’出问题了
  - receiver:’frontend-pager’
    group_by: [product, environment]
    match:
    team: frontend</pre></div>
<p>总结一下，Routing Tree 的设计意图是<strong>让用户能够非常自由地给警报归类，然后根据归类后的类别来配置要发送给谁以及怎么发送</strong>：</p>

<ul>
<li><strong>发送给谁？</strong>上面已经做了很好的示例，<strong>’数据库警报’</strong>和<strong>’前端警报’</strong>都有特定的接收组，都没有匹配上那么就是<strong>’默认警报’</strong>, 发送给默认接收组</li>

<li><p><strong>怎么发送？</strong>对于一类警报，有个多个字段来配置发送行为：</p>

<ul>
<li><strong>group_by</strong>：决定了警报怎么分组，每个 group 只会定时产生一次通知，这就达到了降噪的效果，而不同的警报类别分组方式显然是不一样的，举个例子：

<ul>
<li>配置中的 ‘数据库警报’ 是按 ‘集群’ 和 ‘规则名’ 分组的，这表明对于数据库警报，我们关心的是“<strong>哪个集群的哪个规则出问题了</strong>”，比如一个时间段内，’华东’集群产生了10条 ‘API响应时间过长’ 警报，这些警报就会聚合在一个通知里发出来；</li>
<li>配置中的 ‘前端警报’ 是按 ‘产品’ 和 ‘环境’ 分组的， 这表明对于前端警报，我们关心的是<strong>“哪个产品的哪个环境出问题了”</strong></li>
</ul></li>

<li><p><strong>group_interval 和 group_wait</strong>: 控制分组的细节，不细谈，其中 group_interval 控制了这个分组<strong>最快多久执行一次 Notification Pipeline</strong></p></li>

<li><p><strong>repeat_interval</strong>: 假如一个相同的警报一直 FIRING，Alertmanager 并不会一直发送警报，而会等待一段时间，这个等待时间就是 repeat_interval，显然，不同类型警报的发送频率也是不一样的</p></li>
</ul></li>
</ul>

<p>group_interval 和 repeat_interval 的区别会在下文中详述</p>

<h2 id="notification-pipeline">Notification Pipeline</h2>

<p>由 Routing Tree 分组后的警报会触发 Notification Pipeline:</p>

<ul>
<li>当一个 AlertGroup 新建后，它会等待一段时间（group_wait 参数)，再触发第一次 Notification Pipeline</li>
<li>假如这个 AlertGroup 持续存在，那么之后每隔一段时间（group_interval 参数)，都会触发一次 Notification Pipeline</li>
</ul>

<p>每次触发 Notification Pipeline，AlertGroup 都会将组内所有的 Alert 作为一个列表传进 Pipeline, Notification Pipeline 本身是一个按照责任链模式设计的接口，MultiStage 这个实现会链式执行所有的 Stage：</p>
<div class="highlight"><pre class="chroma">// A Stage processes alerts under the constraints of the given context.
type Stage interface {
    Exec(ctx context.Context, l log.Logger, alerts …*types.Alert) (context.Context, []*types.Alert, error)
}

// A MultiStage executes a series of stages sequencially.
type MultiStage []Stage

// Exec implements the Stage interface.
func (ms MultiStage) Exec(ctx context.Context, l log.Logger, alerts …*types.Alert) (context.Context, []*types.Alert, error) {
    var err error
    for _, s := range ms {
        if len(alerts) ==0{
            return ctx, nil, nil
        }

        ctx, alerts, err = s.Exec(ctx, l, alerts…)
        if err != nil {
            return ctx, nil, err
        }
    }
    return ctx, alerts, nil
}</pre></div>
<p>MultiStage 里塞的就是开头架构图里画的 InhibitStage、SilenceStage…这么一条链式处理的流程，这里要提一下，官方的架构图画错了，RoutingStage 其实处在整个 Pipeline 的首位，不过这个顺序并不影响逻辑。
要重点说的是<strong>DedupStage</strong>和<strong>NotifySetStage</strong>它俩协同负责去重工作，具体做法是：</p>

<ul>
<li>NotifySetStage 会为发送成功的警报记录一条发送通知，key 是<strong>’接收组名字’+’GroupKey 的 key 值’</strong>，value 是当前 Stage 收到的 []Alert (这个列表和最开始进入 Notification Pipeline 的警报列表有可能是不同的，因为其中有些 Alert 可能在前置 Stage 中已经被过滤掉了)</li>
<li>DedupStage 中会以<strong>’接收组名字’+’GroupKey 的 key 值’</strong>为 key 查询通知记录，假如：

<ul>
<li>查询无结果，那么这条通知没发过，为这组警报发送一条通知；</li>
<li>查询有结果，那么查询得到已经发送过的一组警报 S，判断当前的这组警报 A 是否为 S 的子集：

<ul>
<li>假如 A 是 S 的子集，那么表明 A 和 S 重复，这时候要根据 repeat_interval 来决定是否再次发送：

<ul>
<li>距离 S 的发送时间已经过去了足够久（repeat_interval)，那么我们要再发送一遍；</li>
<li>距离 S 的发送时间还没有达到 repeat_interval，那么为了降低警报频率，触发去重逻辑，这次我们就不发了；</li>
</ul></li>
<li>假如 A 不是 S 的子集，那么 A 和 S 不重复，需要再发送一次；
上面的表述可能有些抽象，最后表现出来的结果是：</li>
</ul></li>
</ul></li>
<li>假如一个 AlertGroup 里的警报一直发生变化，那么虽然每次都是新警报，不会被去重，<strong>但是</strong>由于 group_interval （假设是5分钟）存在，这个 AlertGroup 最多 5 分钟触发一次 Notification Pipeline，因此最多也只会 5 分钟发送一条通知；</li>
<li>假如一个 AlertGroup 里的警报一直不变化，就是那么几条一直 FIRING 着，那么虽然每个 group_interval 都会触发 Notification Pipeline，<strong>但是</strong>由于 repeate_interval（假设是1小时）存在，因此最多也只会每 1 小时为这个重复的警报发送一条通知；
再说一下 Silence 和 Inhibit，两者都是基于用户主动定义的规则的：</li>
<li>Silence Rule：静默规则用来关闭掉部分警报的通知，比如某个性能问题已经修复了，但需要排期上线，那么在上线前就可以把对应的警报静默掉来减少噪音；</li>
<li>Inhibit Rule：抑制规则用于在某类警报发生时，抑制掉另一类警报，比如某个机房宕机了，那么会影响所有上层服务，产生级联的警报洪流，反而会掩盖掉根本原因，这时候抑制规则就有用了；
因此 Notification Pipeline 的设计意图就很明确了：<strong>通过一系列逻辑（如抑制、静默、去重）来获得更高的警报质量，由于警报质量的维度很多（剔除重复、类似的警报，静默暂时无用的警报，抑制级联警报），因此 Notification Pipeline 设计成了责任链模式，以便于随时添加新的环节来优化警报质量</strong></li>
</ul>

<h1 id="结语">结语</h1>

<p>Alertmanager 整体的设计意图就是奔着治理警报（通知）去的，首先它用 Routing Tree 来帮助用户定义警报的归类与发送逻辑，然后再用 Notification Pipeline 来做抑制、静默、去重以提升警报质量。这些功能虽然不能解决&rdquo;警报&rdquo;这件事中所有令人头疼的问题，但确实为我们着手去解决&rdquo;警报质量&rdquo;相关问题提供了趁手的工具。</p>
]]></content>
		</item>
		
		<item>
			<title>编写 Prometheus Exporter: 以阿里云 Exporter 为例</title>
			<link>https://www.aleiwu.com/post/aliyun-exporter-bp/</link>
			<pubDate>Sun, 14 Apr 2019 19:13:26 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/aliyun-exporter-bp/</guid>
			<description>去年底我写了一个阿里云云监控的 Prometheus Exporter, 后续迭代的过程中有一些经验总结, 这篇文章就将它们串联起来做一个汇总, 讲讲为什么要写 Exporter 以及怎么写一个好用的 Exporter?</description>
			<content type="html"><![CDATA[

<p>去年底我写了一个阿里云云监控的 <a href="https://github.com/aylei/aliyun-exporter">Prometheus Exporter</a>, 后续迭代的过程中有一些经验总结, 这篇文章就将它们串联起来做一个汇总, 讲讲为什么要写 Exporter 以及怎么写一个好用的 Exporter?</p>

<h1 id="何为-prometheus-exporter">何为 Prometheus Exporter?</h1>

<p><a href="https://prometheus.io/">Prometheus</a> 监控基于一个很简单的模型: 主动抓取目标的指标接口(HTTP 协议)获取监控指标, 再存储到本地或远端的时序数据库. Prometheus 对于指标接口有一套固定的<a href="https://prometheus.io/docs/instrumenting/exposition_formats/">格式要求</a>, 格式大致如下:</p>
<div class="highlight"><pre class="chroma"># HELP http_requests_total The total number of HTTP requests.
# TYPE http_requests_total counter
http_requests_total{method=&#34;post&#34;,code=&#34;200&#34;} 1027
http_requests_total{method=&#34;post&#34;,code=&#34;400&#34;}    3</pre></div>
<p>对于自己写的代码, 我们当然可以使用 Prometheus 的 SDK 暴露出上述格式的指标. 但对于大量现有服务, 系统甚至硬件, 它们并不会暴露 Prometheus 格式的指标. 比如说:</p>

<ul>
<li>Linux 的很多指标信息以文件形式记录在 <code>/proc/</code> 下的各个目录中, 如 <code>/proc/meminfo</code> 里记录内存信息, <code>/proc/stat</code> 里记录 CPU 信息;</li>
<li>Redis 的监控信息需要通过 <code>INFO</code> 命令获取;</li>
<li>路由器等硬件的监控信息需要通过 `SNMP** 协议获取;</li>
<li>&hellip;</li>
</ul>

<p>要监控这些目标, 我们有两个办法, 一是改动目标系统的代码, 让它<strong>主动</strong>暴露 Prometheus 格式的指标, 当然, 对于上述几个场景这种办法完全是不现实的. 这时候就只能采用第二种办法:</p>

<ul>
<li><strong>编写一个代理服务, 将其它监控信息转化为 Prometheus 格式的指标</strong></li>
</ul>

<p>这个代理服务的基本运作方式, 可以用下面这张图来表示:</p>

<p><img src="/img/exporter/exporter.png" alt="exporter" /></p>

<p>而这样的代理服务, 就称作 Prometheus Exporter, 对于上面那些常见的情形, 社区早就写好了成熟的 Exporter, 它们就是 <code>node_exporter</code>, <code>redis_exporter</code> 和 <code>snmp_exporter</code>.</p>

<h1 id="为什么要写-exporter">为什么要写 Exporter?</h1>

<p>嗯, 写 exporter 可以把监控信息接进 Prometheus, 那为什么非要接进 Prometheus 呢?</p>

<p>我们不妨以阿里云云监控为例, 看看接进 Prometheus 的好处都有啥:</p>

<p>阿里云免费提供了一部分云监控服务, 但云监控的免费功能其实很有限, 没办法支持这些痛点场景:</p>

<ul>
<li>Adhoc TopN 查询: 比如&rdquo;找到当前对公网带宽消耗最大的 10 台服务器&rdquo;;</li>
<li>容量规划: 比如&rdquo;分析过去一个月某类型服务的资源用量&rdquo;;</li>
<li>高级报警: 比如&rdquo;对比过去一周的指标值, 根据标准差进行报警&rdquo;;</li>
<li>整合业务监控: 业务的监控信息存在于另一套监控系统中, 两套系统的看板, 警报都很难联动;</li>
</ul>

<p>幸好, 云监控提供了获取监控信息的 API, 那么我们很自然地就能想到: 只要写一个阿里云云监控的 Exporter, 不就能将阿里云的监控信息整合到 Prometheus 体系当中了吗?</p>

<p>当然, Exporter 就是做这个的!</p>

<p>集成到 Prometheus 监控之后, 借助 PromQL 强大的表达能力和 Alertmanager, Grafana 的强大生态, 我们不仅能实现所有监控信息的整合打通, 还能获得更丰富的报警选择和更强的看板能力. 下面就是一个对 RDS 进行 TopN 查询的例子:</p>

<p><img src="/img/exporter/RDS.png" alt="" /></p>

<p>这个动机对于其它类型的 Exporter 也都是适用的: 当一个系统本身暴露了监控信息, 却又无法接入 Prometheus, 我们就可以考虑写一个 exporter 把它接进来了.</p>

<h1 id="写一个好用的-exporter">写一个好用的 Exporter</h1>

<p>类似 &ldquo;阿里云 Exporter&rdquo; 这种形式的 Exporter 是非常好写的, 逻辑就是一句话:</p>

<ul>
<li>写一个 Web 服务, 每当 Prometheus 请求我们这个服务问我们要指标的时候, 我们就请求云监控的 API 获得监控信息, 再转化为 Prometheus 的格式返回出去;</li>
</ul>

<p>但这样写完之后仅仅是&rdquo;能用&rdquo;, 要做到&rdquo;好用&rdquo;, 还有诸多考量.</p>

<h2 id="从文档开始">从文档开始</h2>

<p>Prometheus 官方文档中 <a href="https://prometheus.io/docs/instrumenting/writing_exporters/">Writing Exporter</a> 这篇写得非常全面, 假如你要写 exporter 推荐先通读一遍, 限于篇幅, 这里只概括一下:</p>

<ul>
<li>做到开箱即用(默认配置就可以直接开始用)</li>
<li>推荐使用 YAML 作为配置格式</li>
<li>指标使用下划线命名</li>
<li>为指标提供 HELP String (指标上的 <code># HELP</code> 注释, 事实上这点大部分 exporter 都没做好)</li>
<li>为 Exporter 本身的运行状态提供指标</li>
<li>可以提供一个落地页</li>
</ul>

<p>下面几节中, 也会有和官方文档重复的部分, 但会略去理论性的部分(官方文档已经说的很好了), 着重讲实践例子.</p>

<h2 id="可配置化">可配置化</h2>

<p>官方文档里讲了 Exporter 需要开箱即用, 但其实这只是基本需求, 在开箱即用的基础上, 一个良好的 Exporter 需要做到高度可配置化. 这是因为大部分 Exporter 暴露的指标中, 真正会用到的大概只有 20%, 冗余的 80% 指标不仅会消耗不必要的资源还会拖累整体的性能. 对于一般的 Exporter 而言, BP 是默认只提供必要的指标, 并且提供 extra 和 filter 配置, 允许用户配置额外的指标抓取和禁用一部分的默认指标. 而对于阿里云 Exporter 而言, 由于阿里云有数十种类型的资源(RDS, ECS, SLB&hellip;), 因此我们无法推测用户到底希望抓哪些监控信息, 因此只能全部交给用户配置. 当然, 项目还是提供了包含 SLB, RDS, ECS 和 Redis 的默认配置文件, 尽力做到开箱即用.</p>

<h2 id="info-指标">Info 指标</h2>

<p>针对指标标签(Label), 我们考虑两点: &ldquo;唯一性&rdquo; 和 &ldquo;可读性&rdquo;:</p>

<p><strong>&ldquo;唯一性&rdquo;</strong>: 对于指标, 我们应当只提供有&rdquo;唯一性&rdquo; 的(Label), 比如说我们暴露出 &ldquo;ECS 的内存使用&rdquo; 这个指标. 这时, &ldquo;ECS ID&rdquo; 这个标签就可以唯一区分所有的指标. 这时我们假如再加入 &ldquo;IP&rdquo;, &ldquo;操作系统&rdquo;, &ldquo;名字&rdquo; 这样的标签并不会增加额外的区分度, 反而会在某些状况下造成一些问题. 比方说某台 ECS 的名字变了, 那么在 Prometheus 内部就会重新记录一个时间序列, 造成额外的开销和部分 PromQL 计算的问题, 比如下面的示意图:</p>
<div class="highlight"><pre class="chroma">序列A {id=&#34;foo&#34;, name=&#34;旧名字&#34;} ..................
序列B {id=&#34;foo&#34;, name=&#34;新名字&#34;}                   .................</pre></div>
<p><strong>&ldquo;可读性&rdquo;</strong>: 上面的论断有一个例外, 那就是当标签涉及&rdquo;可读性&rdquo;时, 即使它不贡献额外的区分度, 也可以加上. 比如 &ldquo;IP&rdquo; 这样的标签, 假如我们只知道 ECS ID 而不知道 IP, 那么根本对不上号, 排查问题也会异常麻烦.</p>

<p>可以看到, 唯一性和可读性之间其实有一些权衡, 那么有没有更好的办法呢?</p>

<p>答案就是 Info 指标(Info Metric). 单独暴露一个指标, 用 label 来记录实例的&rdquo;额外信息&rdquo;, 比如:</p>
<div class="highlight"><pre class="chroma">ecs_info{id=&#34;foo&#34;, name=&#34;DIO&#34;, os=&#34;linux&#34;, region=&#34;hangzhou&#34;, cpu=&#34;4&#34;, memory=&#34;16GB&#34;, ip=&#34;188.188.188.188&#34;} 1</pre></div>
<p>这类指标的值习惯上永远为 1, 它们并记录实际的监控值, 仅仅记录 ecs 的一些额外信息. 而在使用的时候, 我们就可以通过 PromQL 的 &ldquo;Join&rdquo;(<code>group_left</code>) 语法将这些信息加入到最后的查询结果中:</p>
<div class="highlight"><pre class="chroma"># 这条 PromQL 将 aliyun_meta_rds_info 中记录的描述和状态从添加到了 aliyun_acs_rds_dashboard_MemoryUsage 中
aliyun_acs_rds_dashboard_MemoryUsage 
    * on (instanceId) group_left(DBInstanceDescription,DBInstanceStatus) 
    aliyun_meta_rds_info </pre></div>
<p>阿里云 Exporter 就大量使用了 Info 指标这种模式来提供实例的详细信息, 最后的效果就是监控指标本身非常简单, 只需要一个 ID 标签, 而看板上的信息依然非常丰富:</p>

<p><img src="/img/exporter/ECS-detail.png" alt="" /></p>

<h2 id="记录-exporter-本身的信息">记录 Exporter 本身的信息</h2>

<p>任何时候元监控(或者说自监控)都是首要的, 我们不可能依赖一个不被监控的系统去做监控. 因此了解怎么监控 exporter 并在编写时考虑到这点尤为重要.</p>

<p>首先, 所有的 Prometheus 抓取目标都有一个 <code>up</code> 指标用来表明这个抓取目标能否被成功抓取. 因此, <strong>假如 exporter 挂掉或无法正常工作了</strong>, 我们是可以从相应的 <code>up</code> 指标立刻知道并报警的.</p>

<p>但 <code>up</code> 成立的条件仅仅是指标接口返回 200 并且内容可以被解析, 这个粒度太粗了. 假设我们用 exporter 监控了好几个不同的模块, 其中有几个模块的指标无法正常返回了, 这时候 <code>up</code> 就帮不上忙了.</p>

<p>因此一个 BP 就是针对各个子模块, 甚至于各类指标, 记录细粒度的 <code>up</code> 信息, 比如阿里云 exporter 就选择了为每类指标都记录 <code>up</code> 信息:</p>
<div class="highlight"><pre class="chroma">aliyun_acs_rds_dashboard_MemoryUsage{id=&#34;foo&#34;} 1233456
aliyun_acs_rds_dashboard_MemoryUsage{id=&#34;bar&#34;} 3215123

aliyun_acs_rds_dashboard_MemoryUsage_up 1</pre></div>
<p>当 <code>aliyun_acs_rds_dashboard_MemoryUsage_up</code> 这个指标出现 0 的时候, 我们就能知道 aliyun rds 内存信息的抓取不正常, 需要报警出来人工介入处理了.</p>

<p>另外, 阿里云的指标抓取 API 是有流控和每月配额的, 因此阿里云 exporter 里还记录了各种抓取请求的次数和响应时间的分布, 分别用于做用量的规划和基于响应时间的监控报警. 这也是&rdquo;监控 exporter&rdquo;本身的一个例子.</p>

<h2 id="设计落地页">设计落地页</h2>

<p>用过 <code>node_exporter</code> 的会知道, 当访问它的主页, 也就是根路径 <code>/</code> 时, 它会返回一个简单的页面, 这就是 exporter 的落地页(Landing Page).</p>

<p>落地页什么都可以放, 我认为最有价值的是放文档和帮助信息(或者放对应的链接). 而文档中最有价值的莫过于对于每个指标项的说明, 没有人理解的指标没有任何价值.</p>

<h2 id="可选-一键起监控">可选: 一键起监控</h2>

<p>这一点超出了 exporter 本身的范畴, 但确确实实是 exporter &ldquo;好用&rdquo; 的一个极大助力. exporter 本身是无法单独使用的, 而现实情况是 Prometheus, Grafana, Alertmanager 再对接 Slack, 钉钉啥的, 这一套假如需要从头搭建, 还是有一定的门槛(用 k8s 的话至少得看一下 helm chart 吧), 甚至于有些时候想搭建监控的是全栈(gan)工程师, 作为全公司的独苗, 很可能更多的精力需要花在跟进前端的新技术上(不我没有黑前端&hellip;). 这时候, 一个一键拉起整套监控系统的命令诱惑力是非常大的.</p>

<p>要一键拉起整套监控栈, 首先 kubernetes 就不考虑了, 能无痛部署生产级 kubernetes 集群的大佬不会需要这样的命令. 这时候, 反倒凉透的 docker-compose 是一个很好的选择. 还是以阿里云 exporter 为例, 仓库提供的 docker-compose stack 里提供了 Prometheus, aliyun-exporter, Grafana(看板), Alertmanager(发警报), alertmanager-dingtalk-webhook(适配 alertmanager 的警报到钉钉机器人) 的一键部署并且警报规则和 Grafana 看板页一并配置完毕. 这么一来, 只要用户有一台装了 docker 的机器, 他就能在5分钟之内打开 Grafana 看到这些效果(还有钉钉警报&hellip;假如这位用户的服务器不太健康的话):</p>

<p><img src="/img/exporter/stack.gif" alt="" /></p>

<p>当然了, 想要稳固地部署这套架构, 还是需要多机做高可用或者直接扔到 k8s, swarm 这样的编排系统上. 但假如没有&rdquo;一键部署&rdquo;的存在, 很多对 Prometheus 生态不熟悉的开发者就会被拒之门外; 另外, 对于有经验的用户, &ldquo;一键部署&rdquo;也能帮助他们快速理解这个 exporter 的特性, 帮助他们判断是否需要启用这个组件.</p>

<h1 id="结语">结语</h1>

<p>你可能已经看出来了, 这篇文章的本意是打广告(当然, 我已经非常努力地写了我所认为的&rdquo;干货&rdquo;!). <a href="https://github.com/aylei/aliyun-exporter">aliyun-exporter</a> 这个项目其实最开始只是我练习 Python 用的, 但在前几天碰到一位用户告诉我他们在生产中使用了这个项目, 这给了莫大的鼓舞, 正好我还没有在公开场合 Promote 过这个项目, 因此这周就捞一把, 希望项目本身或这些衍生出来的经验中有一样能帮到大家吧.</p>

<p>都看到这了, 不如<a href="https://github.com/aylei/aliyun-exporter">点个 star</a>?</p>
]]></content>
		</item>
		
		<item>
			<title>Jsonnet 简明教程与应用</title>
			<link>https://www.aleiwu.com/post/jsonnet-grafana/</link>
			<pubDate>Sun, 07 Apr 2019 13:48:47 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/jsonnet-grafana/</guid>
			<description>Jsonnet 的功能用主流语言也能很快实现, 因此我一直都不关注这门语言. 直到最近做 Grafana 声明式看板的时候才重新审视了一遍这门语言, 认识到 Jsonnet 其实在&amp;rdquo</description>
			<content type="html"><![CDATA[

<blockquote>
<p>Jsonnet 的功能用主流语言也能很快实现, 因此我一直都不关注这门语言. 直到最近做 Grafana 声明式看板的时候才重新审视了一遍这门语言, 认识到 Jsonnet 其实在&rdquo;灵活&rdquo;和&rdquo;限制&rdquo;上有一个很好的平衡. 同时, k8s 和 grafana 社区也有很多 jsonnet 的库, 这两点给学习 jsonnet 提供了足够的理由.</p>
</blockquote>

<h1 id="jsonnet">Jsonnet</h1>

<p><a href="https://jsonnet.org/">Jsonnet</a> 是 Google 推出的一门 JSON 模板语言. 它的基本思想是在 JSON 的基础上扩展语法, 将 JSON 的部分字段用代码来表达, 并在运行期生成这些字段. Jsonnet 本身非常简单, 花五分钟跟着下面的代码在命令行走一遍就能掌握基本用法:</p>

<p>PS: 我不会列出所有的语法和细节, 只会写必要的部分, 掌握了这些部分, 我们就可以看懂所有的 jsonnet 库并且动手修改它们 (详细的文档请见: <a href="https://jsonnet.org/learning/tutorial.html">Jsonnet Tutorial</a>
PSS: <code>cat&gt;test.jsonnet&lt;&lt;EOF</code> 的作用是使用两个 EOF 之间的文本覆写 <code>test.jsonnet</code> 文件, 因此假如你使用 IDE 的话, 复制两个 EOF 之间的内容即可</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell"><span class="c1"># 安装 Jsonnet(C 实现)</span>
$ brew install jsonnet

<span class="c1"># 也可以安装 Go 实现</span>
$ go get github.com/google/go-jsonnet/cmd/jsonnet

<span class="c1"># 基本用法: 解释运行一个 jsonnet 源码文件</span>
$ <span class="nb">echo</span> <span class="s2">&#34;{key: 1+2}&#34;</span> &gt; test.jsonnet
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;key&#34;</span>: <span class="m">3</span>
<span class="o">}</span>

<span class="c1"># 对于简单的代码也可以使用 -e 直接运行</span>
$ jsonnet -e <span class="s1">&#39;{key: 1+2}&#39;</span>
<span class="o">{</span>
   <span class="s2">&#34;key&#34;</span>: <span class="m">3</span>
<span class="o">}</span>

<span class="c1"># format 源码文件</span>
$ jsonnet fmt test.jsonnet

<span class="c1"># Jsonnet 语法比 JSON 更宽松(类似 JS):</span>
<span class="c1"># 字段可以不加引号; 支持注释; 字典或列表的最后一项可以带逗号</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">/* 多行
</span><span class="s">注释 */
</span><span class="s">{key: 1+2, &#39;key with space&#39;: &#39;key with special char should be quoted&#39;}
</span><span class="s">// 单行注释
</span><span class="s">EOF</span>
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;key&#34;</span>: <span class="m">3</span>,
   <span class="s2">&#34;key with space&#34;</span>: <span class="s2">&#34;key with special char should be quoted&#34;</span>
<span class="o">}</span>

<span class="c1"># 类比: Jsonnet 支持与主流语言类似的四则运算, 条件语句, 字符串拼接,</span> 
<span class="c1"># 字符串格式化, 数组拼接, 数组切片以及 python 风格的列表生成式</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">{
</span><span class="s">  array: [1, 2] + [3],
</span><span class="s">  math: (4 + 5) / 3 * 2,
</span><span class="s">  format: &#39;Hello, I am %s&#39; % &#39;alei&#39;,
</span><span class="s">  concat: &#39;Hello, &#39; + &#39;I am alei&#39;,
</span><span class="s">  slice: [1,2,3,4][1:3],
</span><span class="s">  &#39;list comprehension&#39;: [x * x for x in [1,2,3,4]],
</span><span class="s">  condition:
</span><span class="s">    if 2 &gt; 1 then 
</span><span class="s">    &#39;true&#39;
</span><span class="s">    else 
</span><span class="s">    &#39;false&#39;,
</span><span class="s">}
</span><span class="s">EOF</span>
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;array&#34;</span>: <span class="o">[</span>
      <span class="m">1</span>,
      <span class="m">2</span>,
      <span class="m">3</span>
   <span class="o">]</span>,
   <span class="s2">&#34;concat&#34;</span>: <span class="s2">&#34;Hello, I am alei&#34;</span>,
   <span class="s2">&#34;condition&#34;</span>: <span class="s2">&#34;true&#34;</span>,
   <span class="s2">&#34;format&#34;</span>: <span class="s2">&#34;Hello, I am alei&#34;</span>,
   <span class="s2">&#34;list comprehension&#34;</span>: <span class="o">[</span>
      <span class="m">1</span>,
      <span class="m">4</span>,
      <span class="m">9</span>,
      <span class="m">16</span>
   <span class="o">]</span>,
   <span class="s2">&#34;math&#34;</span>: <span class="m">6</span>,
   <span class="s2">&#34;slice&#34;</span>: <span class="o">[</span>
      <span class="m">2</span>,
      <span class="m">3</span>
   <span class="o">]</span>
<span class="o">}</span>

<span class="c1"># 使用变量:</span>
<span class="c1">#   使用 :: 定义的字段是隐藏的(不会被输出到最后的 JSON 结果中),</span> 
<span class="c1">#   这些字段可以作为内部变量使用(非常常用)</span>

<span class="c1">#   使用 local 关键字也可以定义变量</span>
<span class="c1">#   JSON 的值中可以引用字段或变量, 引用方式:</span>
<span class="c1">#     变量名</span>
<span class="c1">#     self 关键字: 指向当前对象</span>
<span class="c1">#     $ 关键字: 指向根对象</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">{
</span><span class="s">  local name = &#39;aylei&#39;,
</span><span class="s">  language:: &#39;jsonnet&#39;,
</span><span class="s">  message: {
</span><span class="s">    target: $.language,
</span><span class="s">    author: name,
</span><span class="s">    by: self.author,
</span><span class="s">  }
</span><span class="s">}
</span><span class="s">EOF</span>
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;message&#34;</span>: <span class="o">{</span>
      <span class="s2">&#34;author&#34;</span>: <span class="s2">&#34;aylei&#34;</span>,
      <span class="s2">&#34;by&#34;</span>: <span class="s2">&#34;aylei&#34;</span>,
      <span class="s2">&#34;target&#34;</span>: <span class="s2">&#34;jsonnet&#34;</span>
   <span class="o">}</span>
<span class="o">}</span>

<span class="c1"># 使用函数:</span>
<span class="c1"># 函数(或者说方法)在 Jsonnet 中是一等公民,</span> 
<span class="c1"># 定义与引用方式与变量相同, 函数语法类似 python</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">{
</span><span class="s">  local hello(name) = &#39;hello %s&#39; % name,
</span><span class="s">  sum(x, y):: x + y,
</span><span class="s">  newObj(name=&#39;alei&#39;, age=23, gender=&#39;male&#39;):: {
</span><span class="s">    name: name,
</span><span class="s">    age: age,
</span><span class="s">    gender: gender,
</span><span class="s">  },
</span><span class="s">  call_sum: $.sum(1, 2),
</span><span class="s">  call_hello: hello(&#39;world&#39;),
</span><span class="s">  me: $.newObj(age=24),
</span><span class="s">}
</span><span class="s">EOF</span>
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;call_hello&#34;</span>: <span class="s2">&#34;hello world&#34;</span>,
   <span class="s2">&#34;call_sum&#34;</span>: <span class="m">3</span>,
   <span class="s2">&#34;me&#34;</span>: <span class="o">{</span>
      <span class="s2">&#34;age&#34;</span>: <span class="m">24</span>,
      <span class="s2">&#34;gender&#34;</span>: <span class="s2">&#34;male&#34;</span>,
      <span class="s2">&#34;name&#34;</span>: <span class="s2">&#34;alei&#34;</span>
   <span class="o">}</span>
<span class="o">}</span>

<span class="c1"># Jsonnet 使用组合来实现面向对象的特性(类似 Go)</span>
<span class="c1">#   Json Object 就是 Jsonnet 中的对象</span>
<span class="c1">#   使用 + 运算符来组合两个对象, 假如有字段冲突,</span> 
<span class="c1">#   使用右侧对象(子对象)中的字段</span>

<span class="c1">#   子对象中使用 super 关键字可以引用父对象,</span> 
<span class="c1">#   用这个办法可以访问父对象中被覆盖掉的字段</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">local base = {
</span><span class="s">  f: 2,
</span><span class="s">  g: self.f + 100,
</span><span class="s">};
</span><span class="s">base + {
</span><span class="s">  f: 5,
</span><span class="s">  old_f: super.f,
</span><span class="s">  old_g: super.g,
</span><span class="s">}
</span><span class="s">EOF</span> 
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;f&#34;</span>: <span class="m">5</span>,
   <span class="s2">&#34;g&#34;</span>: <span class="m">105</span>,
   <span class="s2">&#34;old_f&#34;</span>: <span class="m">2</span>,
   <span class="s2">&#34;old_g&#34;</span>: <span class="m">105</span>
<span class="o">}</span>

<span class="c1"># 有时候我们希望一个对象中的字段在进行组合时不要</span>
<span class="c1"># 覆盖父对象中的字段, 而是与相同的字段继续进行组合</span>

<span class="c1"># 这时可以用 +: 来声明这个字段 (+:: 与 +: 的含义相同,</span> 
<span class="c1"># 但与 :: 一样的道理, +:: 定义的字段是隐藏的)</span>

<span class="c1"># 对于 JSON Object, 我们更希望进行组合而非覆盖, 因此在定义 Object</span> 
<span class="c1"># 字段时, 很多库都会选择使用 +: 和 +::, 但我们也要注意不能滥用</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">local child = {
</span><span class="s">  override: {
</span><span class="s">    x: 1,
</span><span class="s">  },
</span><span class="s">  composite+: {
</span><span class="s">    x: 1,
</span><span class="s">  },
</span><span class="s">};
</span><span class="s">{
</span><span class="s">  override: { y: 5, z: 10 },
</span><span class="s">  composite: { y: 5, z: 10 },
</span><span class="s">} + child
</span><span class="s">EOF</span>
$ jsonnet test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;composite&#34;</span>: <span class="o">{</span>
      <span class="s2">&#34;x&#34;</span>: <span class="m">1</span>,
      <span class="s2">&#34;y&#34;</span>: <span class="m">5</span>,
      <span class="s2">&#34;z&#34;</span>: <span class="m">10</span>
   <span class="o">}</span>,
   <span class="s2">&#34;override&#34;</span>: <span class="o">{</span>
      <span class="s2">&#34;x&#34;</span>: <span class="m">1</span>
   <span class="o">}</span>
<span class="o">}</span>

<span class="c1"># 库与 import:</span>
<span class="c1">#  jsonnet 共享库复用方式其实就是将库里的代码整合到当前文件中来,</span>
<span class="c1">#  引用方式也很暴力, 使用 -J 参数指定 lib 文件夹, 再在代码里 import 即可</span>

<span class="c1">#  jsonnet 约定库文件的后缀名为 .libsonnet</span>
$ mkdir some-path
$ cat&gt;some-path/mylib.libsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">{
</span><span class="s">  newVPS(ip, 
</span><span class="s">      region=&#39;cn-hangzhou&#39;, 
</span><span class="s">      distribution=&#39;CentOS 7&#39;, 
</span><span class="s">      cpu=4, 
</span><span class="s">      memory=&#39;16GB&#39;):: {
</span><span class="s">    ip: ip,
</span><span class="s">    distribution: distribution,
</span><span class="s">    cpu: cpu,
</span><span class="s">    memory: memory,
</span><span class="s">    vendor: &#39;Alei Cloud&#39;,
</span><span class="s">    os: &#39;linux&#39;,
</span><span class="s">    packages: [],
</span><span class="s">    install(package):: self + {
</span><span class="s">      packages+: [package],
</span><span class="s">    },
</span><span class="s">  }
</span><span class="s">}
</span><span class="s">EOF</span>
$ cat&gt;test.jsonnet<span class="s">&lt;&lt;EOF
</span><span class="s">local vpsTemplate = import &#39;some-path/mylib.libsonnet&#39;;
</span><span class="s">vpsTemplate
</span><span class="s">  .newVPS(ip=&#39;10.10.44.144&#39;, cpu=8, memory=&#39;32GB&#39;)
</span><span class="s">  .install(&#39;docker&#39;)
</span><span class="s">  .install(&#39;jsonnet&#39;)
</span><span class="s">EOF</span>
$ jsonnet -J . test.jsonnet
<span class="o">{</span>
   <span class="s2">&#34;cpu&#34;</span>: <span class="m">8</span>,
   <span class="s2">&#34;distribution&#34;</span>: <span class="s2">&#34;CentOS 7&#34;</span>,
   <span class="s2">&#34;ip&#34;</span>: <span class="s2">&#34;10.10.44.144&#34;</span>,
   <span class="s2">&#34;memory&#34;</span>: <span class="s2">&#34;32GB&#34;</span>,
   <span class="s2">&#34;os&#34;</span>: <span class="s2">&#34;linux&#34;</span>,
   <span class="s2">&#34;packages&#34;</span>: <span class="o">[</span>
      <span class="s2">&#34;docker&#34;</span>,
      <span class="s2">&#34;jsonnet&#34;</span>
   <span class="o">]</span>,
   <span class="s2">&#34;vendor&#34;</span>: <span class="s2">&#34;Alei Cloud&#34;</span>
<span class="o">}</span>

<span class="c1"># 上面这种 Builder 模式在 jsonnet 中非常常见,</span> 
<span class="c1"># 也就是先定义一个构造器, 构造出基础对象然后用各种方法进行修改.</span> 
<span class="c1"># 当对象非常复杂时, 这种模式比直接覆盖父对象字段更易维护</span>

<span class="c1"># 了解上面这些基本用法之后我们就能看懂几乎所有 jsonnet 的库并且能够自己动手修改了</span></code></pre></div>
<h1 id="jsonnet-使用场景">Jsonnet 使用场景</h1>

<p>虽然 Jsonnet 本身是图灵完备的, 但它本身是专门为了生成 JSON 设计的模板语言, 因此使用场景主要集中在配置管理上. 社区的实践主要是用 jsonnet 做 Kubernetes, Prometheus, Grafana 的配置管理, 相关的库有:</p>

<ul>
<li><a href="https://github.com/bitnami/kubecfg">kubecfg</a>: 使用 jsonnet 生成 kubernetes API 对象 并 apply</li>
<li><a href="https://github.com/ksonnet/ksonnet-lib">ksonnet-lib</a>: 一个 jsonnet 的库, 用于生成 kubernetes API 对象(在 hepstio 被 IBM 收购之后 IBM 放弃了 ksonnet 这个项目)</li>
<li><a href="https://github.com/coreos/prometheus-operator/tree/master/contrib/kube-prometheus">kube-prometheus</a>: 使用 jsonnet 生成 Prometheus-Operator, Prometheus, Grafana 以及一系列监控组件的配置</li>
<li><a href="https://github.com/grafana/grafonnet-lib/tree/master/grafonnet">grafonnet-lib</a>: 一个 jsonnet 的库, 用于生成 json 格式的 Grafana 看板配置</li>
</ul>

<p>还有一些公司的应用例子:</p>

<ul>
<li><a href="https://github.com/grafana/jsonnet-libs">grafana 的 jsonnet-lib</a>: Grafana 内部使用的 jsonnet-libs, 包含各种配置的管理</li>
<li><a href="https://github.com/databricks/jsonnet-style-guide">databrikcs 的 jsonnet-style-guide</a>: Databricks 的 jsonnet 风格指南, 里面还讲了 databricks 是怎么使用 jsonnet 的</li>
</ul>

<p>k8s 资源对象生成相关的场景对我吸引力不大, 因为 kustomize, helm 甚至 kubernetes operator 从某种程度上来说都是做这件事的, jsonnet 相比之下并没有特别的优势. 但在监控这一块, 由于 Prometheus 相关社区(Grafana, Prometheus-Operator, kube-prometheus) 都使用 jsonnet 做配置管理, 我们也不得不入乡随俗了.</p>

<blockquote>
<p>prometheus 也有 helm 的 chart, 但完善程度以及可定制性被基于 jsonnet 的 kube-prometheus 完爆</p>
</blockquote>

<p>我们就以 <a href="https://github.com/grafana/grafonnet-lib/tree/master/grafonnet">grafonnet-lib</a> 为例, 探究如何使用 jsonnet 库来便捷地生成复杂 JSON 并根据自己的需求改进 jsonnet 库.</p>

<h1 id="grafonnet">Grafonnet</h1>

<p>首先 clone grafonnet 到本地:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">$ git clone https://github.com/grafana/grafonnet-lib.git
$ <span class="nb">cd</span> grafonnet-lib
<span class="c1"># 简单看一眼这个库提供的 .libsonnet, 可以看到, 库的入口文件聚合了各个模块, 这也是一种 jsonnet 的常见模式</span>
$ cat grafonnet/grafana.libsonnet
<span class="o">{</span>
  dashboard:: import <span class="s1">&#39;dashboard.libsonnet&#39;</span>,
  template:: import <span class="s1">&#39;template.libsonnet&#39;</span>,
  text:: import <span class="s1">&#39;text.libsonnet&#39;</span>,
  timepicker:: import <span class="s1">&#39;timepicker.libsonnet&#39;</span>,
  row:: import <span class="s1">&#39;row.libsonnet&#39;</span>,
  link:: import <span class="s1">&#39;link.libsonnet&#39;</span>,
  annotation:: import <span class="s1">&#39;annotation.libsonnet&#39;</span>,
  graphPanel:: import <span class="s1">&#39;graph_panel.libsonnet&#39;</span>,
  tablePanel:: import <span class="s1">&#39;table_panel.libsonnet&#39;</span>,
  singlestat:: import <span class="s1">&#39;singlestat.libsonnet&#39;</span>,
  influxdb:: import <span class="s1">&#39;influxdb.libsonnet&#39;</span>,
  prometheus:: import <span class="s1">&#39;prometheus.libsonnet&#39;</span>,
  sql:: import <span class="s1">&#39;sql.libsonnet&#39;</span>,
  graphite:: import <span class="s1">&#39;graphite.libsonnet&#39;</span>,
  alertCondition:: import <span class="s1">&#39;alert_condition.libsonnet&#39;</span>,
  cloudwatch:: import <span class="s1">&#39;cloudwatch.libsonnet&#39;</span>,
  elasticsearch:: import <span class="s1">&#39;elasticsearch.libsonnet&#39;</span>,
<span class="o">}</span></code></pre></div>
<p>通过查看各个模块的 jsonnet 代码, 我们就能探索出这个库的所有接口, 可以磕磕绊绊地使用它来生成 Grafana 看板的 JSON 配置了:</p>
<div class="highlight"><pre class="chroma"><code class="language-jsonnet" data-lang="jsonnet">local grafana = import &#39;grafonnet/grafana.libsonnet&#39;;
local dashboard = grafana.dashboard;
local template = grafana.template;
local singlestat = grafana.singlestat;
local prometheus = grafana.prometheus;

dashboard.new(
  &#39;Test&#39;,
  schemaVersion=16,
)
.addTemplate(
  grafana.template.datasource(
    &#39;PROMETHEUS_DS&#39;,
    &#39;prometheus&#39;,
    &#39;Prometheus&#39;,
    hide=&#39;label&#39;,
  )
)
.addPanel(
  singlestat.new(
    &#39;prometheus-up&#39;,
    format=&#39;s&#39;,
    datasource=&#39;Prometheus&#39;,
    span=2,
    valueName=&#39;current&#39;,
  )
  .addTarget(
    prometheus.target(
      &#39;up{job=&#34;prometheus&#34;}&#39;,
    )
  ), 
  gridPos= { x: 0, y: 0, w: 24, h: 3, }
)</code></pre></div>
<p>将上面的内容保存为 <code>dashboard.jsonnet</code> 并执行 <code>jsonnet -J . dashboard.jsonnet</code>, 你就能看到生成的一大串 JSON.</p>

<p>可以看到, grafonnet 采用的正是我们上面讲到的 Builder 模式.</p>

<p>由于 grafonnet 是一个通用库, 因此我们的 jsonnet 还是比较复杂, 仅仅添加一个图表就写了这么多行, 很显然, 我们可以再封装一层, 去掉很多在自己内部没必要定制的东西, 提供一个更简单的接口. 这时候 jsonnet 的灵活性就展现得很明显了.</p>

<h1 id="结语">结语</h1>

<p>其实看完 Jsonnet 的功能之后, 我们会发现 jsonnet 的功能用其它语言也能实现, 甚至用 javascript 来实现的话写的代码和 jsonnet 都是有点类似的, 那为什么还要选 Jsonnet 呢? 其实对我而言, 仅仅是因为相关的工作中 Jsonnet 有现成的库可以用, 而且有丰富的文档.</p>

<p>但从另一个角度来想, Jsonnet 确实有它独特的优越性, 那就是<strong>限制非常大</strong>. 在 Jsonnet 中, 我们无法去访问一个外部的数据库或者 Web 服务来生成配置, 也没法搞各种语言中有趣的奇技淫巧. 这种限制带来的好处是, Jsonnet 每次生成都只依赖于代码文件以及被依赖的代码文件, 那假如用一个放 jsonnet 的 git 仓库来做配置管理, 这个库就是百分之百的 Single Source of Truth, 没有惊喜, 没有意外. 这是把领域性的 Best Practice 从规范和 Code Review 下沉到工具乃至语言本身当中的一个绝佳例子.</p>
]]></content>
		</item>
		
		<item>
			<title>Kubectl 效率提升指北</title>
			<link>https://www.aleiwu.com/post/kubectl-guru/</link>
			<pubDate>Sun, 31 Mar 2019 16:15:33 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/kubectl-guru/</guid>
			<description>写水文啦啦啦啦啦啦啦 kubectl 可能是 Kubernetes(k8s) 最好用的用户接口, 但各种工具都得自己打磨打磨才能用得顺手, kubectl 也不例外. 日常使用起来仍然有比较繁琐的地方, 比如同</description>
			<content type="html"><![CDATA[

<blockquote>
<p>写水文啦啦啦啦啦啦啦</p>
</blockquote>

<p>kubectl 可能是 Kubernetes(k8s) 最好用的用户接口, 但各种工具都得自己打磨打磨才能用得顺手, kubectl 也不例外. 日常使用起来仍然有比较繁琐的地方, 比如同时查看多个容器的日志, 自定义 <code>get</code> 的输出格式. 下面就讲一些 kubectl 的使用经验(具体操作大多以 <code>zsh</code> 和 <code>brew</code> 为例).</p>

<h1 id="准备工作-rtfm-读文档">准备工作: RTFM (读文档!)</h1>

<p>根据<a href="https://kubernetes.io/docs/reference/kubectl/cheatsheet/">官方速查表</a>配置好 kubectl 的<strong>自动补全</strong>:</p>

<ul>
<li><p>Bash</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell"><span class="nb">echo</span> <span class="s2">&#34;source &lt;(kubectl completion bash)&#34;</span> &gt;&gt; ~/.bashrc</code></pre></div></li>

<li><p>Zsh</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell"><span class="nb">echo</span> <span class="s2">&#34;if [ </span><span class="nv">$commands</span><span class="s2">[kubectl] ]; then source &lt;(kubectl completion zsh); fi&#34;</span> &gt;&gt; ~/.zshrc</code></pre></div></li>
</ul>

<p>假如你对 kubectl 不太熟悉, 速查表里余下的内容能快速让你上手, 建议一读. 另外, github 上还有一份更全面的适合打印的速查表 <a href="https://github.com/dennyzhang/cheatsheet-kubernetes-A4/blob/master/cheatsheet-kubernetes-A4.pdf">cheatsheet-kubernetes-A4</a></p>

<h1 id="0-别名">0.别名</h1>

<p>执行下面的命令:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">cat&gt;&gt;~/.zshrc<span class="s">&lt;&lt;EOF
</span><span class="s">alias k=&#39;kubectl&#39;
</span><span class="s">alias ka=&#39;kubectl apply --recursive -f&#39;
</span><span class="s">alias kex=&#39;kubectl exec -i -t&#39;
</span><span class="s">alias klo=&#39;kubectl logs -f&#39;
</span><span class="s">alias kg=&#39;kubectl get&#39;
</span><span class="s">alias kd=&#39;kubectl describe&#39;
</span><span class="s">EOF</span></code></pre></div>
<p>我习惯把 <code>kubectl</code> alias 成 <code>k</code>. 而剩下几个都是很固定的命令与参数组合. 后面还会讲到 kubectl plugin, 所有的命令都要以 kubectl 开头, 因此(<del>研究表明</del>)用 <code>k</code> 能大大保护我们使用 kubectl 时的键盘寿命.</p>

<p>另外 Github 上有一个项目叫做<a href="https://github.com/ahmetb/kubectl-aliases">kubectl-aliases</a>, 能够自动生成一份巨长的 bash 别名列表. 不过我并没有使用, 因为足足有 800 多个别名, 摁 tab 自动补全的时候会卡.</p>

<h1 id="1-kubectl-plugin-机制">1.kubectl plugin 机制</h1>

<p>从 1.12 开始, kubectl 就支持从用户的 PATH 中通过<strong>名字</strong>自动发现插件: 所有名字为<code>kubectl-{pluginName}</code>格式的可执行文件都会被加载为 kubectgl 插件, 而调用方式就是 kubectl pluginName.</p>

<p>举个例子, 我们有一个可执行文件叫 <code>kubectl-debug</code>, 那么就可以用 <code>kubectl debug</code> 来执行它, 在设置了别名之后只需要敲 <code>k debug</code> 就行了. 我习惯将所有 kubernetes 相关的命令行工具都重命名成 kubectl 插件的形式, 这有两个好处, 一是方便记忆, 二是假如那个工具本身没有自动补全的话, 可以复用 kubectl 的自动补全(比如 <code>--namespace</code>). 下面好多地方我们都会利用这个机制来组织命令.</p>

<h1 id="2-context-和-namespace-切换">2.Context 和 Namespace 切换</h1>

<p>一直敲 <code>--context=xxx -n=xxx</code> 是很麻烦的事情, 而 kubectl 切换 context 和 namespace 又比较繁琐. 好在 <a href="https://github.com/ahmetb/kubectx">kubectx</a> 项目能很好地解决这个问题(结合 <a href="https://github.com/junegunn/fzf">fzf</a> 体验更棒)</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">brew install kubectx
brew install fzf <span class="c1"># 辅助做 context 和 namespace 的模糊搜索</span></code></pre></div>
<p>装完之后基本操作, 重命名成 kubectl 插件的格式:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">mv /usr/local/bin/kubectx /usr/local/bin/kubectl-ctx
mv /usr/local/bin/kubens /usr/local/bin/kubectl-ns</code></pre></div>
<p>示例:</p>

<p><img src="/img/kubectl/kubectx.gif" alt="kubectx" /></p>

<h1 id="3-tail-多个-pod-的日志">3.tail 多个 Pod 的日志</h1>

<p><code>kubectl logs</code> 有一个限制是不能同时 tail 多个 pod 中容器的日志(可以同时查看多个, 但是此时无法使用 <code>-f</code> 选项来 tail). 这个需求很关键, 因为请求是负载均衡到网关和微服务上的, 要追踪特定的访问日志最方便的办法就是 tail 所有的网关再 grep. 比较好的解决方案是 <a href="https://github.com/wercker/stern">stern</a> 这个项目, 除了可以同时 tail 多个容器的日志之外, stern 还:</p>

<ul>
<li>允许使用正则表达式来选择需要 tail 的 PodName</li>
<li>为不同 Pod 的日志展示不同的颜色</li>
<li>tail 过程中假如有符合规则的新 Pod 被创建, 那么会自动添加到 tail 输出中</li>
</ul>

<p>可以说是非常方便了. 老样子, 还是加入到我们的 kubectl 插件中:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">brew install stern
mv /usr/local/bin/stern /usr/local/bin/kubectl-tail
<span class="c1"># tail 当前 namespace 所有 pod 中所有容器的日志</span>
k tail .</code></pre></div>
<p>示例:</p>

<p><img src="/img/kubectl/tail.gif" alt="tail" /></p>

<h1 id="4-使用-jid-和-jq">4.使用 jid 和 jq</h1>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">brew install jq
brew install jid</code></pre></div>
<p>这个跟 kubectl 放在一起其实不太合适, 因为 <a href="https://github.com/simeji/jid">jid</a> 和 <a href="https://github.com/stedolan/jq">jq</a> 适合所有要操作 json 的场景, 但假如你还经常用 <code>kubectl get pod -o yaml | grep xxx</code>, 那就可以考虑一下 jid + jq 了. 简单说, jq 是对 json 做过滤和转换的, 比如:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">kubectl get pod -o json <span class="p">|</span> jq <span class="s1">&#39;.items[].metadata.labels&#39;</span></code></pre></div>
<p>这条命令就能提取出所有 pod 对象的 labels, 而 k8s 的对象都很复杂, 我自己是记不住具体的字段位置的, 这时就可以用 jid(<strong>j</strong>son <strong>i</strong>ncremental <strong>d</strong>igger) 来交互式地探索 json 对象的内容:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell"><span class="c1"># jid 交互式查询的管道输出是最后确定的 JsonPath, 我们直接拷贝到剪切板里给 jq 用</span>
kubectl get pod -o json <span class="p">|</span> jid -q <span class="p">|</span> pbcopy</code></pre></div>
<p>看动图, 基本上用 <code>tab</code> 就可以完成探索, 完成之后把对应的 jsonpath 贴到 jq 里即可(注意 <code>jid</code> 不支持 Array/Map 通配, 需要手动把 <code>[0]</code> 里的下标去掉):</p>

<p><img src="/img/kubectl/jidjq.gif" alt="jq" /></p>

<p>假如需要输出多个字段, 那可以反复用 jid 去把字段都找出来, 最后再 jq 里用逗号分隔字段.</p>

<p>另外, <code>jq</code> 本身远比动图里展示得强大(它甚至是一个图灵完备的函数式编程语言), 你可以看看 jq 的 <a href="https://github.com/stedolan/jq/wiki/Cookbook">cookbook</a> 来体会一下(我只会搞搞基本的 json 转化, 假如真的像 cookbook 里那样去钻研一下的话下面两节估计都不需要了&hellip;)</p>

<h1 id="5-使用-custom-columns">5.使用 Custom Columns</h1>

<p>jq 的默认输出格式是 json, 看起来不如 <code>kubectl get</code> 的表格格式那么清晰, 这时候可以用 <code>-r</code> 参数和 <code>@tsv</code> 操作符输出成表格格式, 比如下面这个查询所有 <code>deployment</code> 使用的 docker image 的指令:</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">$ kg deploy -o json <span class="p">|</span> jq -r <span class="s1">&#39;.items[] | [.metadata.name, .spec.template.spec.containers[].image] | @tsv&#39;</span>
aylei-master-discovery	pingcap/tidb-operator:latest
aylei-master-monitor	prom/prometheus:v2.2.1	grafana/grafana:4.6.5
qotm	datawire/qotm:1.3
rss-site	nginx
tiller-deploy	gcr.io/kubernetes-helm/tiller:v2.12.3</code></pre></div>
<blockquote>
<p>tips: API 对象的 shortname 可以用 <code>kubectl api-resources</code> 查看</p>
</blockquote>

<p>其实效果仍然一般. 还好, 对于大部分 <code>jq</code> 能实现的转化, <code>kubectl get</code> 命令的 <code>-o=custom-columns</code> 参数也能实现, 并且输出结果的对齐与表头更友好(同时可以不依赖 <code>jq</code>):</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">$ k get deploy -o<span class="o">=</span>custom-columns<span class="o">=</span>NAME:<span class="s1">&#39;.metadata.name&#39;</span>,IMAGES:<span class="s1">&#39;.spec.template.spec.containers[*].image&#39;</span>
NAME                     IMAGES
aylei-master-discovery   pingcap/tidb-operator:latest
aylei-master-monitor     prom/prometheus:v2.2.1,grafana/grafana:4.6.5
qotm                     datawire/qotm:1.3</code></pre></div>
<blockquote>
<p>虽然语法略有不同, 但 custom-columns 里的 jsonpath 仍然可以通过 jid 去探索式地获取</p>
</blockquote>

<p>(注意 <code>custom-columns</code> 中使用的 <a href="https://kubernetes.io/docs/reference/kubectl/jsonpath/">JSONPath 语法</a> 和 <code>jq</code> 是不同的, 通配 list 需要用 <code>[*]</code>)</p>

<h1 id="6-定制自己的输出格式">6.定制自己的输出格式</h1>

<p><code>jq</code> 和 <code>custom-columns</code> 都有一个问题是命令太长了, 即使我们用 alias 这么一行巨长的命令也不好维护. 还好, <code>jq</code> 和 <code>custom-columns</code> 都支持从文件中选择查询, 考虑到 <code>custom-columns</code> 的输出效果比较好, 更适合作为默认输出(jq 我一般用来做 adhoc query), 因此我们可以在 <code>custom-columns</code> 的基础上再封装一下.</p>

<p><code>kubectl get</code> 的 <code>-o=custom-columns-file=&lt;file&gt;</code> 这个参数可以选定一个文件来提供 <code>custom-columns</code> 的信息, 文件格式非常简单:</p>
<div class="highlight"><pre class="chroma">NAME           IMAGE
.metadata.name .spec.template.spec.containers[*].image</pre></div>
<p>但要指定文件感觉还是很麻烦, 怎么办呢? 刚刚讲的 kubectl 插件机制就派上用场了, 我们可以实现一个插件来展示自定义的输出格式, 而编写方式嘛, Bash 就足够啦, 执行下面的命令直接写完:</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash">cat&gt;&gt;kubectl-ls<span class="s">&lt;&lt;EOF
</span><span class="s">#!/bin/bash
</span><span class="s">
</span><span class="s"># see if we have custom-columns-file defined
</span><span class="s">if [ ! -z &#34;$1&#34; ] &amp;&amp; [ -f $HOME/.kube/columns/$1 ];
</span><span class="s">then
</span><span class="s">    kubectl get -o=custom-columns-file=$HOME/.kube/columns/$1 $@
</span><span class="s">else
</span><span class="s">    kubectl get $@
</span><span class="s">EOF</span></code></pre></div>
<p>这个脚本的意思是假如某种资源存在对应的 <code>~/.kube/columns/{resourceKind}</code> 这个文件, 就使用这个文件作为 columns 的模板. 为了和 get 命令区分开来(插件不能和内置指令同名), 就用了 <code>ls</code> 这个名字. 接下来, 我们将刚刚创建的 <code>kubectl-ls</code> 文件安装到 PATH 中, 再创建一个针对 <code>deploy</code> 资源的模板文件:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">chmod a+x kubectl-ls
mv kubectl-ls /usr/local/bin/kubectl-ls
mkdir -p ~/.kube/columns
cat&gt;&gt;~/.kube/columns/deploy<span class="s">&lt;&lt;EOF
</span><span class="s">NAME           IMAGE
</span><span class="s">.metadata.name .spec.template.spec.containers[*].image
</span><span class="s">EOF</span></code></pre></div>
<p>大功告成, 接下来, 我们的 <code>kubectl ls</code> 就能根据文件配置自动转换 kubectl 输出:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">$ k ls deploy
NAME                     IMAGE
aylei-master-discovery   pingcap/tidb-operator:latest
aylei-master-monitor     prom/prometheus:v2.2.1,grafana/grafana:4.6.5
qotm                     datawire/qotm:1.3
rss-site                 nginx
tiller-deploy            gcr.io/kubernetes-helm/tiller:v2.12.3
tmp-shell                netdata/netdata</code></pre></div>
<p>这个脚本对 CRD(Custom Definition Resources) 尤其有用, 很多 CRD 没有配置 <code>additionalPrintColumns</code> 属性, 导致 <code>kubectl get</code> 输出的内容就只有一个名字, 比如 Prometheus Operator 定义的 Prometheus 对象, 根本没有信息嘛:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">$ kg prometheus
NAME   CREATED AT
k8s    7d</code></pre></div>
<p>其实定制一下我们就能看到更合理的输出:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">cat&gt;&gt;~/.kube/columns/prometheus<span class="s">&lt;&lt;EOF
</span><span class="s">NAME          REPLICAS      VERSION      CPU                         MEMORY                         ALERTMANAGER
</span><span class="s">metadata.name spec.replicas spec.version spec.resources.requests.cpu spec.resources.requests.memory spec.alerting.alertmanagers[*].name
</span><span class="s">EOF</span></code></pre></div>
<p>噔噔噔噔:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">$ k ls prometheus k8s
NAME   REPLICAS   VERSION   CPU      MEMORY   ALERTMANAGER
k8s    <span class="m">2</span>          v2.5.0    &lt;none&gt;   400Mi    alertmanager-main</code></pre></div>
<h1 id="结语">结语</h1>

<p>OK(终于水完了&hellip;), 这些配置其实都带有很强的个人色彩, 我自己用着非常顺手, 但可能到大家那边就未必如此了. 因此这篇文章只能勉强算是抛砖引玉, 假如能有一两点帮助大家提升了效率, 那也就达到目的了.</p>
]]></content>
		</item>
		
		<item>
			<title>搞搞 Prometheus：Prometheus Operator</title>
			<link>https://www.aleiwu.com/post/prometheus-operator/</link>
			<pubDate>Sun, 24 Mar 2019 14:37:32 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/prometheus-operator/</guid>
			<description>前言 我对 Prometheus 是又爱又恨。 一方面吧，它生态特别好：作为 Kubernetes 监控的事实标准，（几乎）所有 k8s 相关组件都暴露了 Prometheus 的指标接口，甚至在 k8s 生态之外，绝大部分传</description>
			<content type="html"><![CDATA[

<h1 id="前言">前言</h1>

<p>我对 <a href="https://github.com/prometheus/prometheus">Prometheus</a> 是又爱又恨。</p>

<ul>
<li>一方面吧，它生态特别好：作为 Kubernetes 监控的事实标准，（几乎）所有 k8s 相关组件都暴露了 Prometheus 的指标接口，甚至在 k8s 生态之外，绝大部分传统中间件（比如 MySQL、Kafka、Redis、ES）也有社区提供的 Prometheus Exporter。我们已经可以去掉 k8s 这个定语，直接说 Prometheus 是开源监控方案的&rdquo;头号种子选手&rdquo;了；</li>
<li>另一方面吧，都 2019 年了，一个基础设施领域的&rdquo;头号种子&rdquo;选手居然还不支持分布式、不支持数据导入/导出、甚至不支持通过 api 修改监控目标和报警规则，这是不是也挺匪夷所思的？</li>
</ul>

<p>不过 Prometheus 的维护者们也有充足的理由：Prometheus does one thing, and it does it well<a href="https://www.oreilly.com/library/view/prometheus-up/9781492034131/ch01.html">1</a>. 那其实也无可厚非，Prometheus 最核心的&rdquo;指标监控&rdquo;确实做得出色，只是当我们要考虑 scale、考虑 long-term storage、考虑平台化(sth. as a Service)的时候，自己就得做一些扩展与整合了。&rdquo;搞搞 Prometheus&rdquo;这个主题可能会针对这些方面做一些讨论，抛砖引玉（不过要是弃坑的话这就是第一篇也是最后一篇了ε=ε=ε=ε=┌(;￣▽￣)┘）。</p>

<h1 id="prometheus-operator">Prometheus Operator</h1>

<p>这篇文章的主角是 <a href="https://github.com/coreos/prometheus-operator">Prometheus Operator</a>，由于 Prometheus 本身没有提供管理配置的 API 接口（尤其是管理监控目标和管理警报规则），也没有提供好用的多实例管理手段，因此这一块往往要自己写一些代码或脚本。但假如你还没有写这些代码，那就可以先看一下 Prometheus Operator，它很好地解决了 Prometheus 不好管理的问题。</p>

<blockquote>
<p>什么是 Operator？Operator = Controller + CRD。假如你不了解什么是 Controller 和 CRD，可以看一个 Kubernetes 本身的例子：我们提交一个 <strong>Deployment 对象</strong>来声明<strong>期望状态</strong>，比如 3 个副本；而 Kubernetes 的 Controller 会不断地干活（跑控制循环）来达成<strong>期望状态</strong>，比如看到只有 2 个副本就创建一个，看到有 4 个副本了就删除一个。在这里，Deployment 是 Kubernetes 本身的 API 对象。那假如我们想自己设计一些 API 对象来完成需求呢？Kubernetes 本身提供了 CRD(<a href="https://kubernetes.io/docs/tasks/access-kubernetes-api/custom-resources/custom-resource-definitions/">Custom Resource Definition</a>)，允许我们定义新的 API 对象。但在定义完之后，Kubernetes 本身当然不可能知道这些 API 对象的<strong>期望状态</strong>该如何到达。这时，我们就要写对应的 <strong>Controller</strong> 去实现这个逻辑。而这种自定义 API 对象 + 自己写 Controller 去解决问题的模式，就是 <strong>Operator</strong> Pattern。</p>
</blockquote>

<h1 id="概览">概览</h1>

<p>假如你有一个测试用的 k8s 集群，可以直接按照<a href="https://github.com/coreos/prometheus-operator/tree/master/contrib/kube-prometheus#quickstart">这里</a>把 Prometheus Operator 以及基于 Operator 的一大坨对象全都部署上去，部署完之后就可以用 <code>kubectl get prometheus</code>, <code>kubectl get servicemonitor</code> 来摸索新增的 API 对象了(不部署也没关系，咱们纸上谈兵）。新的对象有四种：</p>

<ul>
<li>Alertmanager: 定义一个 Alertmanager 集群;</li>
<li>ServiceMonitor: 定义一组 Pod 的指标应该如何采集;</li>
<li>PrometheusRule: 定义一组 Prometheus 规则;</li>
<li>Prometheus: 定义一个 Prometheus &ldquo;集群&rdquo;，同时定义这个集群要使用哪些 <code>ServiceMonitor</code> 和 <code>PrometheusRule</code>;</li>
</ul>

<p>看几个简化版的 yaml 定义就很清楚了：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">kind<span class="p">:</span><span class="w"> </span>Alertmanager<span class="w"> </span>➊<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>main<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w">  </span>baseImage<span class="p">:</span><span class="w"> </span>quay.io/prometheus/alertmanager<span class="w">
</span><span class="w">  </span>replicas<span class="p">:</span><span class="w"> </span><span class="m">3</span><span class="w"> </span>➋<span class="w">
</span><span class="w">  </span>version<span class="p">:</span><span class="w"> </span>v0<span class="m">.16.0</span></code></pre></div>
<ul>
<li>➊ 一个 Alertmanager 对象</li>

<li><p>➋ 定义该 Alertmanager 集群的节点数为 3</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">kind<span class="p">:</span><span class="w"> </span>Prometheus<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w"> </span><span class="c"># 略</span><span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w"></span>alerting<span class="p">:</span><span class="w">
</span><span class="w"></span>alertmanagers<span class="p">:</span><span class="w">
</span><span class="w"></span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>alertmanager-main<span class="w"> </span>➊<span class="w">
</span><span class="w">  </span>namespace<span class="p">:</span><span class="w"> </span>monitoring<span class="w">
</span><span class="w">  </span>port<span class="p">:</span><span class="w"> </span>web<span class="w">
</span><span class="w"></span>baseImage<span class="p">:</span><span class="w"> </span>quay.io/prometheus/prometheus<span class="w">
</span><span class="w"></span>replicas<span class="p">:</span><span class="w"> </span><span class="m">2</span><span class="w"> </span>➋<span class="w">
</span><span class="w"></span>ruleSelector<span class="p">:</span><span class="w"> </span>➌<span class="w">
</span><span class="w"></span>matchLabels<span class="p">:</span><span class="w">
</span><span class="w">  </span>prometheus<span class="p">:</span><span class="w"> </span>k8s<span class="w">
</span><span class="w">  </span>role<span class="p">:</span><span class="w"> </span>alert-rules<span class="w">
</span><span class="w"></span>serviceMonitorNamespaceSelector<span class="p">:</span><span class="w"> </span>{}<span class="w"> </span>➍<span class="w">
</span><span class="w"></span>serviceMonitorSelector<span class="p">:</span><span class="w"> </span>➎<span class="w">
</span><span class="w"></span>matchLabels<span class="p">:</span><span class="w">
</span><span class="w">  </span>k8s-app<span class="p">:</span><span class="w"> </span>node-exporter<span class="w">
</span><span class="w"></span>query<span class="p">:</span><span class="w"> 
</span><span class="w"></span>maxConcurrency<span class="p">:</span><span class="w"> </span><span class="m">100</span><span class="w"> </span>➏<span class="w">
</span><span class="w"></span>version<span class="p">:</span><span class="w"> </span>v2<span class="m">.5.0</span></code></pre></div></li>

<li><p>➊ 定义该 Prometheus 对接的 Alertmanager 集群名字为 main, 在 monitoring 这个 namespace 中;</p></li>

<li><p>➋ 定义该 Proemtheus &ldquo;集群&rdquo;有两个副本，说是集群，其实 Prometheus 自身不带集群功能，这里只是起两个完全一样的 Prometheus 来避免单点故障;</p></li>

<li><p>➌ 定义这个 Prometheus 需要使用带有 <code>prometheus=k8s</code> 且 <code>role=alert-rules</code> 标签的 PrometheusRule;</p></li>

<li><p>➍ 定义这些 Prometheus 在哪些 namespace 里寻找 ServiceMonitor，不声明则默认选择 Prometheus 对象本身所处的 Namespace;</p></li>

<li><p>➎ 定义这个 Prometheus 需要使用带有 <code>k8s-app=node-exporter</code> 标签的 ServiceMonitor，不声明则会全部选中;</p></li>

<li><p>➏ 定义 Prometheus 的最大并发查询数为 100，<a href="https://github.com/coreos/prometheus-operator/blob/master/Documentation/api.md#prometheusspec">几乎所有配置</a>都可以通过 Prometheus 对象进行声明(包括很重要的 RemoteRead、RemoteWrite)，这里为了简洁就不全部列出了;</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">kind<span class="p">:</span><span class="w"> </span>ServiceMonitor<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w"></span>labels<span class="p">:</span><span class="w">
</span><span class="w"></span>k8s-app<span class="p">:</span><span class="w"> </span>node-exporter<span class="w"> </span>➊<span class="w">
</span><span class="w"></span>name<span class="p">:</span><span class="w"> </span>node-exporter<span class="w">
</span><span class="w"></span>namespace<span class="p">:</span><span class="w"> </span>monitoring<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w"></span>selector<span class="p">:</span><span class="w">
</span><span class="w"></span>matchLabels<span class="p">:</span><span class="w"> </span>➋<span class="w">
</span><span class="w">  </span>app<span class="p">:</span><span class="w"> </span>node-exporter<span class="w"> 
</span><span class="w">  </span>k8s-app<span class="p">:</span><span class="w"> </span>node-exporter<span class="w">
</span><span class="w"></span>endpoints<span class="p">:</span><span class="w">
</span><span class="w"></span>-<span class="w"> </span>bearerTokenFile<span class="p">:</span><span class="w"> </span>/var/run/secrets/kubernetes.io/serviceaccount/token<span class="w">
</span><span class="w"></span>interval<span class="p">:</span><span class="w"> </span>30s<span class="w"> </span>➌<span class="w">
</span><span class="w"></span>targetPort<span class="p">:</span><span class="w"> </span><span class="m">9100</span><span class="w"> </span>➍<span class="w">
</span><span class="w"></span>scheme<span class="p">:</span><span class="w"> </span>https<span class="w">
</span><span class="w"></span>jobLabel<span class="p">:</span><span class="w"> </span>k8s-app</code></pre></div></li>

<li><p>➊ 这个 ServiceMonitor 对象带有 <code>k8s-app=node-exporter</code> 标签，因此会被上面的 Prometheus 选中;</p></li>

<li><p>➋ 定义需要监控的 <a href="https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.12/#endpoints-v1-core">Endpoints</a>，带有 <code>app=node-exporter</code> 且 <code>k8s-app=node-exporter</code>标签的 Endpoints 会被选中;</p></li>

<li><p>➌ 定义这些 Endpoints 需要每 30 秒抓取一次;</p></li>

<li><p>➍ 定义这些 Endpoints 的指标端口为 9100;</p></li>
</ul>

<blockquote>
<p>Endpoints 对象是 Kubernetes 对一组地址以及它们的可访问端口的抽象，通常和 Service 一起出现。</p>
</blockquote>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">kind<span class="p">:</span><span class="w"> </span>PrometheusRule<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>labels<span class="p">:</span><span class="w"> </span>➊<span class="w">
</span><span class="w">    </span>prometheus<span class="p">:</span><span class="w"> </span>k8s<span class="w">
</span><span class="w">    </span>role<span class="p">:</span><span class="w"> </span>alert-rules<span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>prometheus-k8s-rules<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w">  </span>groups<span class="p">:</span><span class="w">
</span><span class="w">  </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>k8s.rules<span class="w">
</span><span class="w">    </span>rules<span class="p">:</span><span class="w"> </span>➋<span class="w">
</span><span class="w">    </span>-<span class="w"> </span>alert<span class="p">:</span><span class="w"> </span>KubeletDown<span class="w">
</span><span class="w">      </span>annotations<span class="p">:</span><span class="w">
</span><span class="w">        </span>message<span class="p">:</span><span class="w"> </span>Kubelet<span class="w"> </span>has<span class="w"> </span>disappeared<span class="w"> </span>from<span class="w"> </span>Prometheus<span class="w"> </span>target<span class="w"> </span>discovery.<span class="w">
</span><span class="w">      </span>expr<span class="p">:</span><span class="w"> </span><span class="sd">|
</span><span class="sd">        absent(up{job=&#34;kubelet&#34;} == 1)</span><span class="w">
</span><span class="w">      </span>for<span class="p">:</span><span class="w"> </span>15m<span class="w">
</span><span class="w">      </span>labels<span class="p">:</span><span class="w">
</span><span class="w">        </span>severity<span class="p">:</span><span class="w"> </span>critical</code></pre></div>
<ul>
<li>➊ 定义该 PrometheusRule 的 label, 显然它会被上面定义的 Prometheus 选中;</li>
<li>➋ 定义了一组规则，其中只有一条报警规则，用来报警 kubelet 是不是挂了;</li>
</ul>

<p>串在一起，它们的关系如下:</p>

<p><img src="https://raw.githubusercontent.com/coreos/prometheus-operator/master/Documentation/custom-metrics-elements.png" alt="" /></p>

<p>看完这四个真实的 yaml，你可能会觉得，这不就是把 Prometheus 的配置打散了，放到了 API 对象里吗？<strong>和我自己写 StatefulSet + ConfigMap 有什么区别呢？</strong></p>

<p>确实，Prometheus 对象和 Alertmanager 对象就是对 StatefulSet 的封装：实际上在 Operator 的逻辑中，中还是生成了一个 StatefuleSet 交给 k8s 自身的 Controller 去处理了。对于 PrometheusRule 和 ServiceMonitor 对象，Operator 也只是把它们转化成了 Prometheus 的配置文件，并挂载到 Prometheus 实例当中而已。</p>

<p>那么，Operator 的价值究竟在哪呢？</p>

<h1 id="prometheus-operator-的好处都有啥">Prometheus Operator 的好处都有啥？</h1>

<p>首先，这些 API 对象全都是用 CRD 定义好 Schema 的，<strong>api-server 会帮我们做校验</strong>。</p>

<p>假如我们用 ConfigMap 来存配置，那就没有任何的校验。万一写错了（比如 yaml 缩进错误）：</p>

<ul>
<li>那么 Prometheus 做配置热更新的时候就会失败，假如配置更新失败没有报警，那么 Game Over;</li>
<li>热更新失败有报警，但这时 Prometheus 突然重启了，于是配置错误重启失败，Game Over;</li>
</ul>

<p>而在 Prometheus Operator 中，所有在 Prometheus 对象、ServiceMonitor 对象、PrometheusRule 对象中的配置都是有 Schema 校验的，校验失败 apply 直接出错，这就大大降低了配置异常的风险。</p>

<p>其次，Prometheus Operator 借助 k8s 把 Prometheus 服务平台化了，<strong>实现 Prometheus as a Service</strong>。</p>

<p>在有了 Prometheus 和 Alertmanager 这样非常明确的 API 对象之后，用户就能够以 k8s 平台为底座，自助式地创建 Prometheus 服务或 Alertmanager 服务。这一点我们不妨退一步想，假如没有 Prometheus Operator，我们要怎么实现这个平台化呢？那无非就是给用户一个表单， 限定能填的字段，比如存储盘大小、CPU内存、Prometheus 版本，然后通过一段逻辑填充成一个 StatefuleSet 的 API 对象再创建到 k8s 上。没错，这些逻辑 Prometheus Operator 都帮我们做掉了，而且是用非常 Kubernetes 友好的方式做掉了，我们何必再造轮子呢？</p>

<p>最后，也是最重要的，<code>ServiceMonitor</code> 和 <code>PrometheusRule</code> 这两个对象<strong>解决了 Prometheus 配置难维护这个痛点问题</strong>。</p>

<p>要证明这点，我得先亮一段 Prometheus 配置：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml"><span class="w">      </span>-<span class="w"> </span>job_name<span class="p">:</span><span class="w"> </span><span class="s1">&#39;kubernetes-service-endpoints&#39;</span><span class="w">
</span><span class="w">        </span>kubernetes_sd_configs<span class="p">:</span><span class="w">
</span><span class="w">          </span>-<span class="w"> </span>role<span class="p">:</span><span class="w"> </span>endpoints<span class="w">
</span><span class="w">        </span>relabel_configs<span class="p">:</span><span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__meta_kubernetes_service_annotation_prometheus_io_scrape<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>keep<span class="w">
</span><span class="w">            </span>regex<span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__meta_kubernetes_service_annotation_prometheus_io_scheme<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>replace<span class="w">
</span><span class="w">            </span>target_label<span class="p">:</span><span class="w"> </span>__scheme__<span class="w">
</span><span class="w">            </span>regex<span class="p">:</span><span class="w"> </span>(https<span class="p">?</span>)<span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__meta_kubernetes_service_annotation_prometheus_io_path<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>replace<span class="w">
</span><span class="w">            </span>target_label<span class="p">:</span><span class="w"> </span>__metrics_path__<span class="w">
</span><span class="w">            </span>regex<span class="p">:</span><span class="w"> </span>(.+)<span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__address__<span class="p">,</span><span class="w"> </span>__meta_kubernetes_service_annotation_prometheus_io_port<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>replace<span class="w">
</span><span class="w">            </span>target_label<span class="p">:</span><span class="w"> </span>__address__<span class="w">
</span><span class="w">            </span>regex<span class="p">:</span><span class="w"> </span>(<span class="p">[</span>^<span class="p">:]</span>+)(<span class="p">?::</span>\d+)<span class="p">?</span>;(\d+)<span class="w">
</span><span class="w">            </span>replacement<span class="p">:</span><span class="w"> </span>$<span class="m">1</span><span class="p">:</span>$<span class="m">2</span><span class="w">
</span><span class="w">          </span>-<span class="w"> </span>action<span class="p">:</span><span class="w"> </span>labelmap<span class="w">
</span><span class="w">            </span>regex<span class="p">:</span><span class="w"> </span>__meta_kubernetes_service_label_(.+)<span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__meta_kubernetes_namespace<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>replace<span class="w">
</span><span class="w">            </span>target_label<span class="p">:</span><span class="w"> </span>kubernetes_namespace<span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__meta_kubernetes_service_name<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>replace<span class="w">
</span><span class="w">            </span>target_label<span class="p">:</span><span class="w"> </span>kubernetes_name<span class="w">
</span><span class="w">          </span>-<span class="w"> </span>source_labels<span class="p">:</span><span class="w"> </span><span class="p">[</span>__meta_kubernetes_pod_node_name<span class="p">]</span><span class="w">
</span><span class="w">            </span>action<span class="p">:</span><span class="w"> </span>replace<span class="w">
</span><span class="w">            </span>target_label<span class="p">:</span><span class="w"> </span>kubernetes_node</code></pre></div>
<p>通过 Prometheus 的 <a href="https://prometheus.io/docs/prometheus/latest/configuration/configuration/#relabel_config">relabel_config</a> 文档可以知道，上面这段&rdquo;天书&rdquo;指定了:</p>

<ul>
<li>这个 Prometheus 要针对所有 annotation 中带有 <code>prometheus.io/scrape=true</code> 的 Endpoints 对象，按照 annotation 中的 <code>prometheus.io/port</code>,<code>prometheus.io/scheme</code>,<code>prometheus.io/path</code>来抓取它们的指标。</li>
</ul>

<p>Prometheus 平台化之后，势必会有不同业务线、不同领域的各种 Prometheus 监控实例，大家都只想抓自己感兴趣的指标，于是就需要改动这个配置来做文章，但这个配置在实际维护中有不少问题：</p>

<ul>
<li>复杂：复杂是万恶之源；</li>
<li>没有分离关注点：应用开发者（提供 Pod 的人）必须知道 Prometheus 维护者的配置是怎么编写的，才能正确提供 annotation；</li>
<li>没有 API：更新流程复杂，需要通过 CI 或 k8s ConfigMap 等手段把配置文件更新到 Pod 内再触发 webhook 热更新；</li>
</ul>

<p>而 <code>ServiceMonitor</code> 对象就很巧妙，它解耦了&rdquo;监控的需求&rdquo;和&rdquo;需求的实现方&rdquo;。我们通过前面的分析可以知道，<code>ServiceMonitor</code> 里只需要用 label-selector 这种简单又通用的方式声明一个 <strong>&ldquo;监控需求&rdquo;，也就是哪些 Endpoints 需要收集，怎么收集就行了</strong>。而这个需求本身则会被 Prometheus 按照 label 来选中并且满足。让用户只关心需求，这就是一个非常好的关注点分离。当然了，<code>ServiceMonitor</code> 最后还是会被 Operator 转化成上面那样复杂的 Scrape Config，但这个复杂度已经完全被 Operator 屏蔽掉了。</p>

<p>另外，<code>ServiceMonitor</code> 还是一个字段明确的 API 对象，用 <code>kubectl</code> 就可以查看或更新它，在上面包一个 web-ui，让用户通过 ui 选择监控对象也是非常简单的事情。这么一来，很多&rdquo;内部监控系统&rdquo;的造轮子工程又可以简化不少。</p>

<p><code>PrometheusRule</code> 对象也是同样的道理。再多想一点，基于 <code>PrometheusRule</code> 对象的 Rest API，我们可以很容易地开发一个 Grafana 插件来帮助应用开发者在 UI 上定义警报规则。这对于 devops 流程是非常重要的，我们可不想在一个团队中永远只能去找 SRE 添加警报规则。</p>

<p>还有一点，这些新的 API 对象天生就能够复用 kubectl, RBAC, Validation, Admission Control, ListAndWatch API 这些 Kubernetes 开发生态里的东西，相比于脱离 Kubernetes 写一套 &ldquo;Prometheus 管理平台&rdquo;，这正是基于 Operator 模式基于 Kubernetes 进行扩展的优势所在。</p>

<h1 id="结语">结语</h1>

<p>其实大家可以看到，Prometheus Operator 干的事情其实就是平常我们用 CI 脚本、定时任务或者手工去干的事情，逻辑上很直接。它的成功在于借助 Operator 模式（拆开说就是控制循环+声明式API这两个 k8s 的典型设计模式）封装了大量的 Prometheus 运维经验，提供了友好的 Prometheus 管理接口，而这对于平台化是很重要的。另外，这个例子也可以说明，即使对 Prometheus 这样运维不算很复杂的系统，Operator 也能起到很好的效果。</p>
]]></content>
		</item>
		
		<item>
			<title>Kubernetes 中如何保证优雅地停止 Pod</title>
			<link>https://www.aleiwu.com/post/tidb-opeartor-webhook/</link>
			<pubDate>Sun, 17 Mar 2019 15:42:40 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/tidb-opeartor-webhook/</guid>
			<description>一直以来我对优雅地停止 Pod 这件事理解得很单纯: 不就利用是 PreStop hook 做优雅退出吗? 但这周听了组里大哥的教诲之后，发现很多场景下 PreStop hook 并不能很好地完成需求</description>
			<content type="html"><![CDATA[

<p>一直以来我对优雅地停止 Pod 这件事理解得很单纯: 不就利用是 <a href="https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks">PreStop hook</a> 做优雅退出吗? 但这周听了组里大哥的教诲之后，发现很多场景下 PreStop hook 并不能很好地完成需求，这篇文章就简单分析一下&rdquo;优雅地停止 Pod&rdquo;这回事儿.</p>

<h1 id="何谓优雅停止">何谓优雅停止?</h1>

<p>优雅停止(Graceful shutdown)这个说法来自于操作系统，我们执行关机之后都得 OS 先完成一些清理操作，而与之相对的就是硬中止(Hard shutdown)，比如拔电源。</p>

<p>到了分布式系统中，优雅停止就不仅仅是单机上进程自己的事了，往往还要与系统中的其它组件打交道。比如说我们起一个微服务，网关把一部分流量分给我们，这时:</p>

<ul>
<li>假如我们一声不吭直接把进程杀了，那这部分流量就无法得到正确处理，部分用户受到影响。不过还好，通常来说网关或者服务注册中心会和我们的服务保持一个心跳，过了心跳超时之后系统会自动摘除我们的服务，问题也就解决了；这是硬中止，虽然我们整个系统写得不错能够自愈，但还是会产生一些抖动甚至错误;</li>
<li>假如我们先告诉网关或服务注册中心我们要下线，等对方完成服务摘除操作再中止进程，那不会有任何流量受到影响；这是优雅停止，将单个组件的启停对整个系统影响最小化;</li>
</ul>

<p>按照惯例，SIGKILL 是硬终止的信号，而 SIGTERM 是通知进程优雅退出的信号，因此很多微服务框架会监听 SIGTERM 信号，收到之后去做反注册等清理操作，实现优雅退出.</p>

<h1 id="prestop-hook">PreStop Hook</h1>

<p>回到 Kubernetes(下称 k8s)，当我们想干掉一个 Pod 的时候，理想状况当然是 k8s 从对应的 Service(假如有的话)把这个 Pod 摘掉，同时给 Pod 发 SIGTERM 信号让 Pod 中的各个容器优雅退出就行了。但实际上 Pod 有可能犯各种幺蛾子:</p>

<ul>
<li>已经卡死了，处理不了优雅退出的代码逻辑或需要很久才能处理完成;</li>
<li>优雅退出的逻辑有 BUG，自己死循环了;</li>
<li>代码写得野，根本不理会 SIGTERM;</li>
</ul>

<p>因此，k8s 的 Pod 终止流程中还有一个&rdquo;最多可以容忍的时间&rdquo;，即 grace period (在 pod 的 <code>.spec.terminationGracePeriodSeconds</code> 字段中定义)，这个值默认是 30 秒，我们在执行 <code>kubectl delete</code> 的时候也可通过 <code>--grace-period</code> 参数显式指定一个优雅退出时间来覆盖 pod 中的配置。而当 grace period 超出之后，k8s 就只能选择 SIGKILL 强制干掉 Pod 了.</p>

<p>很多场景下，除了把 Pod 从 k8s 的 Service 上摘下来以及进程内部的优雅退出之外，我们还必须做一些额外的事情，比如说从 k8s 外部的服务注册中心上反注册。这时就要用到 PreStop hook 了，k8s 目前提供了 <code>Exec</code> 和 <code>HTTP</code> 两种 PreStop hook，实际用的时候，需要通过 Pod 的 <code>.spec.containers[].lifecycle.preStop</code> 字段为 Pod 中的每个容器单独配置，比如:</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">spec<span class="p">:</span><span class="w">
</span><span class="w">  </span>contaienrs<span class="p">:</span><span class="w">
</span><span class="w">  </span>-<span class="w"> </span>name<span class="p">:</span><span class="w"> </span>my-awesome-container<span class="w">
</span><span class="w">    </span>lifecycle<span class="p">:</span><span class="w">
</span><span class="w">      </span>preStop<span class="p">:</span><span class="w">
</span><span class="w">        </span>exec<span class="p">:</span><span class="w">
</span><span class="w">          </span>command<span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;/bin/sh&#34;</span>，<span class="s2">&#34;-c&#34;</span>，<span class="s2">&#34;/pre-stop.sh&#34;</span><span class="p">]</span></code></pre></div>
<p><code>/pre-stop.sh</code> 脚本里就可以写我们自己的清理逻辑.</p>

<p>最后我们串起来再整个表述一下 Pod 退出的流程(<a href="https://kubernetes.io/docs/concepts/workloads/pods/pod/#termination-of-pods">官方文档里更严谨哦</a>):</p>

<ul>
<li>1.用户删除 Pod

<ul>
<li>2.1.Pod 进入 Terminating 状态;</li>
<li>2.2.与此同时，k8s 会将 Pod 从对应的 service 上摘除;</li>
<li>2.3.与此同时，针对有 preStop hook 的容器，kubelet 会调用每个容器的 preStop hook，假如 preStop hook 的运行时间超出了 grace period，kubelet 会发送 SIGTERM 并再等 2 秒;</li>
<li>2.4.与此同时，针对没有 preStop hook 的容器，kubelet 发送 SIGTERM</li>
</ul></li>
<li>3.grace period 超出之后，kubelet 发送 SIGKILL 干掉尚未退出的容器</li>
</ul>

<p>这个过程很不错，但它存在一个问题就是我们无法预测 Pod 会在多久之内完成优雅退出，也无法优雅地应对&rdquo;优雅退出&rdquo;失败的情况。而在我们的产品 <a href="https://github.com/pingcap/tidb-operator">tidb-operator</a> 中，这就是一个无法接受的事情.</p>

<h1 id="有状态分布式应用的挑战">有状态分布式应用的挑战</h1>

<p>为什么说无法接受这个流程呢? 其实这个流程对无状态应用来说通常是 OK 的，但下面这个场景就稍微复杂一点:</p>

<p><a href="https://github.com/pingcap/tidb">TiDB</a> 中有一个核心的分布式 KV 存储层 <a href="https://github.com/tikv/tikv">TiKV</a>。TiKV 内部基于 Multi-Raft 做一致性存储，这个架构比较复杂，这里我们可以简化描述为一主多从的架构，Leader 写入，Follower 同步。而我们的场景是要对 TiKV 做计划性的运维操作，比如滚动升级，迁移节点.</p>

<p>在这个场景下，尽管系统可以接受小于半数的节点宕机，但对于预期性的停机，我们要尽量做到优雅停止。这是因为数据库场景本身就是非常严苛的，基本上都处于整个架构的核心部分，因此我们要把抖动做到越小越好。要做到这点，就得做不少清理工作，比如说我们要在停机前将当前节点上的 Leader 全部迁移到其它节点上.</p>

<p>得益于系统的良好设计，大多数时候这类操作都很快，然而分布式系统中异常是家常便饭，优雅退出耗时过长甚至失败的场景是我们必须要考虑的。假如类似的事情发生了，<strong>为了业务稳定和数据安全，我们就不能强制关闭 Pod，而应该停止操作过程，通知工程师介入。</strong> 这时，上面所说的 Pod 退出流程就不再适用了.</p>

<h1 id="小心翼翼-手动控制所有流程">小心翼翼: 手动控制所有流程</h1>

<p>这个问题其实 k8s 本身没有开箱即用的解决方案，于是我们在自己的 Controller 中(TiDB 对象本身就是一个 CRD) 与非常细致地控制了各种操作场景下的服务启停逻辑.</p>

<p>抛开细节不谈，最后的大致逻辑是在每次停服务前，由 Controller 通知集群进行节点下线前的各种迁移操作，操作完成后，才真正下线节点，并进行下一个节点的操作.</p>

<p>而假如集群无法正常完成迁移等操作或耗时过久，我们也能&rdquo;守住底线&rdquo;，不会强行把节点干掉，这就保证了诸如滚动升级，节点迁移之类操作的安全性.</p>

<p>但这种办法存在一个问题就是实现起来比较复杂，我们需要自己实现一个控制器，在其中实现细粒度的控制逻辑并且在 Controller 的控制循环中不断去检查能否安全停止 Pod。</p>

<h1 id="另辟蹊径-解耦-pod-删除的控制流">另辟蹊径: 解耦 Pod 删除的控制流</h1>

<p>复杂的逻辑总是没有简单的逻辑好维护，同时写 CRD 和 Controller 的开发量也不小，能不能有一种更简洁，更通用的逻辑，能实现&rdquo;保证优雅关闭(否则不关闭)&ldquo;的需求呢?</p>

<p>有，办法就是 <a href="https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/#validatingadmissionwebhook">ValidatingAdmissionWebhook</a></p>

<p>这里先介绍一点点背景知识，Kubernetes 的 apiserver 一开始就有 AdmissionController 的设计，这个设计和各类 Web 框架中的 Filter 或 Middleware 很像，就是一个插件化的责任链，责任链中的每个插件针对 apiserver 收到的请求做一些操作或校验。举两个插件的例子:</p>

<ul>
<li><code>DefaultStorageClass</code>，为没有声明 storageClass 的 PVC 自动设置 storageClass</li>
<li><code>ResourceQuota</code>，校验 Pod 的资源使用是否超出了对应 Namespace 的 Quota</li>
</ul>

<p>虽然说这是插件化的，但在 1.7 之前，所有的 plugin 都需要写到 apiserver 的代码中一起编译，很不灵活。而在 1.7 中 k8s 就引入了 <a href="https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/">Dynamic Admission Control</a> 机制，允许用户向 apiserver 注册 webhook，而 apiserver 则通过 webhook 调用外部 server 来实现 filter 逻辑。1.9 中，这个特性进一步做了优化，把 webhook 分成了两类: <code>MutatingAdmissionWebhook</code> 和 <code>ValidatingAdmissionWebhook</code>，顾名思义，前者就是操作 api 对象的，比如上文例子中的 <code>DefaultStroageClass</code>，而后者是校验 api 对象的，比如 <code>ResourceQuota</code>。拆分之后，apiserver 就能保证在校验(Validating)之前先做完所有的修改(Mutating)，下面这个示意图非常清晰:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g1644436v0j211s0bcabh.jpg" alt="" /></p>

<p>而我们的办法就是，利用 <code>ValidatingAdmissionWebhook</code>，在重要的 Pod 收到删除请求时，先在 webhook server 上请求集群进行下线前的清理和准备工作，并直接返回拒绝。这时候重点来了，Control Loop 为了达到目标状态(比如说升级到新版本)，会不断地进行 reconcile，尝试删除 Pod，而我们的 webhook 则会不断拒绝，除非<strong>集群已经完成了所有的清理和准备工作.</strong></p>

<p>下面是这个流程的分步描述：</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fgy1g1659ahje9j21380metc0.jpg" alt="" /></p>

<ol>
<li>用户更新资源对象;</li>
<li>controller-manager watch 到对象变更;</li>
<li>controller-manager 开始同步对象状态，尝试删除第一个 Pod;</li>
<li>apiserver 调用外部 webhook;</li>
<li>webhook server 请求集群做 tikv-1 节点下线前的准备工作(这个请求是幂等的)，并查询准备工作是否完成，假如准备完成，允许删除，假如没有完成，则拒绝，整个流程会因为 controller manager 的控制循环回到第 2 步;</li>
</ol>

<p>好像一下子所有东西都清晰了，这个 webhook 的逻辑很清晰，就是要保证所有相关的 Pod 删除操作都要先完成优雅退出前的准备，完全不用关心外部的控制循环是怎么跑的，也因此，<strong>它非常容易编写和测试</strong>，非常优雅地满足了我们&rdquo;保证优雅关闭(否则不关闭)&ldquo;的需求，目前我们正在考虑用这种方式替换线上的旧方案.</p>

<h1 id="后记">后记</h1>

<p>其实 <a href="https://kubernetes.io/docs/reference/access-authn-authz/extensible-admission-controllers/">Dynamic Admission Control</a> 的应用很广，比如 Istio 就是用 <code>MutatingAdmissionWebhook</code> 来实现 envoy 容器的注入的。从上面的例子中我们也可以看到它的扩展能力很强，而且常常能站在一个正交的视角上，非常干净地解决问题，与其它逻辑做到很好的解耦.</p>

<p>当然了，Kubernetes 中还有<a href="https://kubernetes.io/docs/concepts/extend-kubernetes/extend-cluster/">非常多的扩展点</a>，从 kubectl 到 apiserver，scheduler，kubelet(device plugin，flexvolume)，自定义 Controller 再到集群层面的网络(CNI)，存储(CSI) 可以说是处处可以做事情。以前做一些常规的微服务部署对这些并不熟悉也没用过，而现在面对 TiDB 这样复杂的分布式系统，尤其在 Kubernetes 对有状态应用和本地存储的支持还不够好的情况下，得在每一个扩展点上去悉心考量，做起来非常有意思，因此后续可能还有一些 <a href="https://github.com/pingcap/tidb-operator">tidb-operator</a> 中思考过的解决方案分享.</p>

<p>最后，既然这篇文章提到了 TiDB，那就顺便打个广告，我司和我所在的团队(Cloud Team)目前都在招人，我个人是非常推荐大家来的，假如您对<a href="https://www.pingcap.com/recruit-cn/join/#positions">这些社招岗位</a>或<a href="https://www.pingcap.com/recruit-cn/join/#positions">这些校招/实习岗位</a>感兴趣，欢(ken)迎(qing)您投递简历到 wuyelei@pingcap.com 我来帮您内推(另外，简历我自己这边也会初步筛一下，推过去的话效率很高，一般隔天内电话联系)。</p>
]]></content>
		</item>
		
		<item>
			<title>白话 Kubernetes Runtime</title>
			<link>https://www.aleiwu.com/post/cncf-runtime-landscape/</link>
			<pubDate>Wed, 06 Mar 2019 13:21:59 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/cncf-runtime-landscape/</guid>
			<description>回想最开始接触 k8s 的时候, 经常搞不懂 CRI 和 OCI 的联系和区别, 也不知道为啥要垫那么多的 &amp;ldquo;shim&amp;rdquo;(尤其是 containerd-shim 和 dockershim 这两个完全没</description>
			<content type="html"><![CDATA[

<p>回想最开始接触 k8s 的时候, 经常搞不懂 CRI 和 OCI 的联系和区别, 也不知道为啥要垫那么多的 &ldquo;shim&rdquo;(尤其是 containerd-shim 和 dockershim 这两个完全没啥关联的东西还恰好都叫 shim). 所以嘛, 这篇就写一写 k8s 的 runtime 部分, 争取一篇文章把下面这张 Landscape 里的核心项目给白话明白:</p>

<p>(<del>以上理由其实都是为了说服自己写写水文也是可以的&hellip;</del>)</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0wkhtwdlij217c0si44d.jpg" alt="" /></p>

<h1 id="典型的-runtime-架构">典型的 Runtime 架构</h1>

<p>我们从最常见的 runtime 方案 Docker 说起, 现在 Kubelet 和 Docker 的集成还是挺啰嗦的:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0ws7jziucj21hy0dmtb0.jpg" alt="" /></p>

<p>当 Kubelet 想要创建一个<strong>容器</strong>时, 有这么几步:</p>

<ol>
<li>Kubelet 通过 <strong>CRI 接口</strong>(gRPC) 调用 dockershim, 请求创建一个容器. <strong>CRI</strong> 即容器运行时接口(Container Runtime Interface), 这一步中, Kubelet 可以视作一个简单的 CRI Client, 而 dockershim 就是接收请求的 Server. 目前 dockershim 的代码其实是内嵌在 Kubelet 中的, 所以接收调用的凑巧就是 Kubelet 进程;</li>
<li>dockershim 收到请求后, 转化成 Docker Daemon 能听懂的请求, 发到 Docker Daemon 上请求创建一个容器;</li>
<li>Docker Daemon 早在 1.12 版本中就已经将针对容器的操作移到另一个守护进程: containerd 中了, 因此 Docker Daemon 仍然不能帮我们创建容器, 而是要请求 containerd 创建一个容器;</li>
<li>containerd 收到请求后, 并不会自己直接去操作容器, 而是创建一个叫做 containerd-shim 的进程, 让 containerd-shim 去操作容器. 这是因为容器进程需要一个父进程来做诸如收集状态, 维持 stdin 等 fd 打开等工作. 而假如这个父进程就是 containerd, 那每次 containerd 挂掉或升级, 整个宿主机上所有的容器都得退出了. 而引入了 containerd-shim 就规避了这个问题(containerd 和 shim 并不需要是父子进程关系, 当 containerd 退出或重启时, shim 会 re-parent 到 systemd 这样的 1 号进程上);</li>
<li>我们知道创建容器需要做一些设置 namespaces 和 cgroups, 挂载 root filesystem 等等操作, 而这些事该怎么做已经有了公开的规范了, 那就是 <a href="https://github.com/opencontainers/runtime-spec">OCI(Open Container Initiative, 开放容器标准)</a>. 它的一个参考实现叫做 <a href="https://github.com/opencontainers/runc">runc</a>. 于是, containerd-shim 在这一步需要调用 <code>runc</code> 这个命令行工具, 来启动容器;</li>
<li><code>runc</code> 启动完容器后本身会直接退出, containerd-shim 则会成为容器进程的父进程, 负责收集容器进程的状态, 上报给 containerd, 并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理, 确保不会出现僵尸进程;</li>
</ol>

<p>这个过程乍一看像是在搞我们: Docker Daemon 和 dockershim 看上去就是两个不干活躺在中间划水的啊, Kubelet 为啥不直接调用 containerd 呢?</p>

<p>当然是可以的, 不过咱们先不提那个, 先看看为什么现在的架构如此繁冗.</p>

<h1 id="小插曲-容器历史小叙-不负责任版">小插曲: 容器历史小叙(不负责任版)</h1>

<p>其实 k8s 最开始的 Runtime 架构远没这么复杂: kubelet 想要创建容器直接跟 Docker Daemon 说一声就行, 而那时也不存在 containerd, Docker Daemon 自己调一下 <code>libcontainer</code> 这个库把容器跑起来, 整个过程就搞完了.</p>

<p>而熟悉容器和容器编排历史的读者老爷应该知道, 这之后就是容器圈的一系列政治斗争, 先是大佬们认为运行时标准不能被 Docker 一家公司控制, 于是就撺掇着搞了开放容器标准 OCI. Docker 则把 <code>libcontainer</code> 封装了一下, 变成 runC 捐献出来作为 OCI 的参考实现.</p>

<p>再接下来就是 <a href="https://github.com/rkt/rkt">rkt</a> 想从 docker 那边分一杯羹, 希望 k8s 原生支持 rkt 作为运行时, 而且 PR 还真的合进去了. 维护过一块业务同时接两个需求方的读者老爷应该都知道类似的事情有多坑, k8s 中负责维护 kubelet 的小组 sig-node 也是被狠狠坑了一把.</p>

<p>大家一看这么搞可不行, 今天能有 rkt, 明天就能有更多幺蛾子出来, 这么搞下去我们小组也不用干活了, 整天搞兼容性的 bug 就够呛. 于是乎, k8s 1.5 推出了 CRI 机制, 即容器运行时接口(Container Runtime Interface), k8s 告诉大家, 你们想做 Runtime 可以啊, 我们也资瓷欢迎, 实现这个接口就成, 成功反客为主.</p>

<p>不过 CRI 本身只是 k8s 推的一个标准, 当时的 k8s 尚未达到如今这般武林盟主的地位, 容器运行时当然不能说我跟 k8s 绑死了只提供 CRI 接口, 于是就有了 shim(垫片) 这个说法, 一个 shim 的职责就是作为 Adapter 将各种容器运行时本身的接口适配到 k8s 的 CRI 接口上.</p>

<p>接下来就是 Docker 要搞 Swarm 进军 PaaS 市场, 于是做了个架构切分, 把容器操作都移动到一个单独的 Daemon 进程 containerd 中去, 让 Docker Daemon 专门负责上层的封装编排. 可惜 Swarm 在 k8s 面前实在是不够打, 惨败之后 Docker 公司就把 <a href="https://github.com/containerd/containerd">containerd 项目</a>捐给 CNCF 缩回去安心搞 Docker 企业版了.</p>

<p>最后就是我们在上一张图里看到的这一坨东西了, 尽管现在已经有 CRI-O, containerd-plugin 这样更精简轻量的 Runtime 架构, dockershim 这一套作为经受了最多生产环境考验的方案, 迄今为止仍是 k8s 默认的 runtime 实现.</p>

<p>了解这些具体的架构有时能在 debug 时候帮我们一些忙, 但更重要的是它们能作为一个例子, 帮助我们更好地理解整个 k8s runtime 背后的设计逻辑, 我们这就言归正传.</p>

<h1 id="oci-cri-与被滥用的名词-runtime">OCI, CRI 与被滥用的名词 &ldquo;Runtime&rdquo;</h1>

<p>OCI, 也就是前文提到的&rdquo;开放容器标准&rdquo;其实就是一坨文档, 其中主要规定了两点:</p>

<ol>
<li>容器镜像要长啥样, 即 <a href="https://github.com/opencontainers/image-spec">ImageSpec</a>. 里面的大致规定就是你这个东西需要是一个压缩了的文件夹, 文件夹里以 xxx 结构放 xxx 文件;</li>
<li>容器要需要能接收哪些指令, 这些指令的行为是什么, 即 <a href="https://github.com/opencontainers/runtime-spec">RuntimeSpec</a>. 这里面的大致内容就是&rdquo;容器&rdquo;要能够执行 &ldquo;create&rdquo;, &ldquo;start&rdquo;, &ldquo;stop&rdquo;, &ldquo;delete&rdquo; 这些命令, 并且行为要规范.</li>
</ol>

<p><a href="https://github.com/opencontainers/runc">runC</a> 为啥叫参考实现呢, 就是它能按照标准将符合标准的容器镜像运行起来(当然, 这里为了易读性略去了很多细节, 要了解详情建议点前文的链接读文档)</p>

<p>标准的好处就是方便搞创新, 反正只要我符合标准, 生态圈里的其它工具都能和我一起愉快地工作(&hellip;当然 OCI 这个标准本身制订得不怎么样, 真正工程上还是要做一些 adapter 的), 那我的镜像就可以用任意的工具去构建, 我的&rdquo;容器&rdquo;就不一定非要用 namespace 和 cgroups 来做隔离. 这就让各种虚拟化容器可以更好地参与到游戏当中, 我们暂且不表.</p>

<p>而 CRI 更简单, 单纯是一组 gRPC 接口, 扫一眼 <a href="https://github.com/kubernetes/kubernetes/blob/8327e433590f9e867b1e31a4dc32316685695729/pkg/kubelet/apis/cri/services.go">kubelet/apis/cri/services.go</a> 就能归纳出几套核心接口:</p>

<ul>
<li>一套针对容器操作的接口, 包括创建,启停容器等等;</li>
<li>一套针对镜像操作的接口, 包括拉取镜像删除镜像等;</li>
<li>还有一套针对 PodSandbox (容器沙箱环境) 的操作接口, 我们之后再说;</li>
</ul>

<p>现在我们可以找到很多符合 OCI 标准或兼容了 CRI 接口的项目, 而这些项目就大体构成了整个 Kuberentes 的 Runtime 生态:</p>

<ul>
<li>OCI Compatible: <a href="https://github.com/opencontainers/runc">runC</a>, <a href="https://github.com/kata-containers/kata-containers">Kata</a>(以及它的前身 <a href="https://github.com/hyperhq/runv">runV</a> 和 <a href="https://github.com/clearcontainers/runtime">Clear Containers</a>), <a href="https://github.com/google/gvisor">gVisor</a>. 其它比较偏门的还有 Rust 写的 <a href="https://github.com/oracle/railcar">railcar</a></li>
<li>CRI Compatible: Docker(借助 dockershim), <a href="https://github.com/containerd/containerd">containerd</a>(借助 CRI-containerd), <a href="https://github.com/kubernetes-sigs/cri-o">CRI-O</a>, <a href="https://github.com/kubernetes/frakti">frakti</a>, etc.</li>
</ul>

<p>最开始 k8s 的时候我经常弄不清 OCI 和 CRI 的区别与联系, 其中一大原因就是社区里糟糕的命名: 这上面的项目统统可以称为容器运行时(Container Runtime), 彼此之间区分的办法就是给&rdquo;容器运行时&rdquo;这个词加上各种定语和从句来进行修饰. Dave Cheney 有条推说:</p>

<blockquote>
<p>Good naming is like a good joke. If you have to explain it, it’s not funny.</p>
</blockquote>

<p>显然 Container Runtime 在这里就不是一个好名字了, 我们接下来换成一个在这篇文章的语境中更准确的说法: <strong>cri-runtime</strong> 和 <strong>oci-runtime</strong>. 通过这个粗略的分类, 我们其实可以总结出整个 runtime 架构万变不离其宗的三层抽象:</p>
<div class="highlight"><pre class="chroma"><code class="language-shell" data-lang="shell">Orchestration API -&gt; Container API -&gt; Kernel API</code></pre></div>
<p>这其中 k8s 已经是 Orchestration API 的事实标准, 而在 k8s 中, Container API 的接口标准就是 CRI, 由 cri-runtime 实现, Kernel API 的规范是 OCI, 由 oci-runtime 实现.</p>

<p>根据这个思路, 我们就很容易理解下面这两种东西:</p>

<ul>
<li>各种更为精简的 cri-runtime (<del>反正就是要干掉 Docker</del>)</li>
<li>各种&rdquo;强隔离&rdquo;容器方案</li>
</ul>

<h1 id="containerd-和-cri-o">containerd 和 CRI-O</h1>

<p>我们在第一节就看到现在的 runtime 实在是有点复杂了, 而复杂是万恶之源(<del>其实本质上就是想干掉 docker</del>), 于是就有了直接拿 containerd 做 oci-runtime 的方案. 当然, 除了 k8s 之外, containerd 还要接诸如 Swarm 等调度系统, 因此它不会去直接实现 CRI, 这个适配工作当然就要交给一个 shim 了.</p>

<p>containerd 1.0 中, 对 CRI 的适配通过一个单独的进程 <code>CRI-containerd</code> 来完成:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0ws7vtgq2j21de0cmgmo.jpg" alt="" /></p>

<p>containerd 1.1 中做的又更漂亮一点, 砍掉了 CRI-containerd 这个进程, 直接把适配逻辑作为插件放进了 containerd 主进程中:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0ws87espcj21ao0d00tz.jpg" alt="" /></p>

<p>但在 containerd 做这些事情之情, 社区就已经有了一个更为专注的 cri-runtime: <a href="https://github.com/kubernetes-sigs/cri-o">CRI-O</a>, 它非常纯粹, 就是兼容 CRI 和 OCI, 做一个 k8s 专用的运行时:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0ws8dqqsoj21am0d0q4q.jpg" alt="" /></p>

<p>其中 <code>conmon</code> 就对应 containerd-shim, 大体意图是一样的.</p>

<p>CRI-O 和 (直接调用)containerd 的方案比起默认的 dockershim 确实简洁很多, 但没啥生产环境的验证案例, 我所知道的仅仅是 containerd 在 GKE 上是 beta 状态. 因此假如你对 docker 没有特殊的政治恨意, 大可不必把 dockershim 这套换掉.</p>

<h1 id="强隔离容器-kata-gvisor-firecracker">强隔离容器: Kata, gVisor, firecracker</h1>

<p>一直以来 k8s 都有一个被诟病的点: 难以实现真正的多租户.</p>

<p>为什么这么说呢, 我们先考虑一下什么样是理想的多租户状态:</p>

<blockquote>
<p>理想来说, 平台的各个租户(tenant)之间应该无法感受到彼此的存在, 表现得就像每个租户独占这整个平台一样. 具体来说, 我不能看到其它租户的资源, 我的资源跑满了不能影响其它租户的资源使用, 我也无法从网络或内核上攻击其它租户.</p>
</blockquote>

<p>k8s 当然做不到, 其中最大的两个原因是:</p>

<ul>
<li>kube-apiserver 是整个集群中的单例, 并且没有多租户概念</li>
<li>默认的 oci-runtime 是 runC, 而 runC 启动的容器是共享内核的</li>
</ul>

<p>对于第二个问题, 一个典型的解决方案就是提供一个新的 OCI 实现, 用 VM 来跑容器, 实现内核上的硬隔离. <a href="https://github.com/hyperhq/runv">runV</a> 和 <a href="https://github.com/clearcontainers/runtime">Clear Containers</a> 都是这个思路. 因为这两个项目做得事情是很类似, 后来就合并成了一个项目 <a href="https://github.com/kata-containers/kata-containers">Kata Container</a>. Kata 的一张图很好地解释了基于虚拟机的容器与基于 namespaces 和 cgroups 的容器间的区别:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0wnfdxo9fj20hs0anmyb.jpg" alt="" /></p>

<blockquote>
<p>当然, 没有系统是完全安全的, 假如 hypervisor 存在漏洞, 那么用户仍有可能攻破隔离. 但所有的事情都要对比而言, 在共享内核的情况下, 暴露的攻击面是非常大的, 做安全隔离的难度就像在美利坚和墨西哥之间修 The Great Wall, 而当内核隔离之后, 只要守住 hypervisor 这道关子就后顾无虞了</p>
</blockquote>

<p>嗯, 一个 VM 里跑一个容器, 听上去隔离性很不错, 但不是说虚拟机又笨重又不好管理才切换到容器的吗, 怎么又要走回去了?</p>

<p>Kata 告诉你, 虚拟机没那么邪恶, 只是以前没玩好:</p>

<ul>
<li><strong>不好管理</strong>是因为没有遵循&rdquo;不可变基础设施&rdquo;, 大家都去虚拟机上这摸摸那碰碰, 这台装 Java 8 那台装 Java 6, Admin 是要 angry 的. Kata 则支持 OCI 镜像, 完全可以用上 Dockerfile + 镜像, 让不好管理成为了过去时;</li>
<li><strong>笨重</strong>是因为之前要虚拟化整个系统, 现在我们只着眼于虚拟化应用, 那就可以裁剪掉很多功能, 把 VM 做得很轻量, 因此即便用虚拟机来做容器, Kata 还是可以将容器启动时间压缩得非常短, 启动后在内存上和IO 上的 overhead 也尽可能去优化;</li>
</ul>

<p>不过话说回来, k8s 上的调度单位是 Pod, 是<strong>容器组</strong>啊, Kata 这样一个虚拟机里一个容器, 同一个 Pod 间的容器还怎么做 namespace 的共享?</p>

<p>这就要说回我们前面讲到的 CRI 中针对 PodSandbox (容器沙箱环境) 的操作接口了. 第一节中, 我们刻意简化了场景, 只考虑创建一个<strong>容器</strong>, 而没有讨论创建一个<strong>Pod</strong>. 大家都知道, 真正启动 Pod 里定义的容器之前, kubelet 会先启动一个 infra 容器, 并执行 /pause 让 infra 容器的主进程永远挂起. 这个容器存在的目的就是维持住整个 pod 的各种 namespace, 真正的业务容器只要加入 infra 容器的 network 等 namespace 就能实现对应 namespace 的共享. 而 infra 容器创造的这个共享环境则被抽象为 <strong>PodSandbox</strong>. 每次 kubelet 在创建 Pod 时, 就会先调用 CRI 的 <code>RunPodSandbox</code> 接口启动一个沙箱环境, 再调用 <code>CreateContainer</code> 在沙箱中创建容器.</p>

<p>这里就已经说出答案了, 对于 Kata Container 而言, 只要在 <code>RunPodSandbox</code> 调用中创建一个 VM, 之后再往 VM 中添加容器就可以了. 最后运行 Pod 的样子就是这样的:</p>

<p><img src="http://ww1.sinaimg.cn/large/bf52b77fly1g0ws90i7ttj21mq0d440r.jpg" alt="" /></p>

<p>说完了 Kata, 其实 gVisor 和 firecracker 都不言自明了, 大体上都是类似的, 只是:</p>

<ul>
<li><a href="https://github.com/google/gvisor">gVisor</a> 并不会去创建一个完整的 VM, 而是实现了一个叫 &ldquo;Sentry&rdquo; 的用户态进程来处理容器的 syscall, 而拦截 syscall 并重定向到 Sentry 的过程则由 KVM 或 ptrace 实现.</li>
<li><a href="https://github.com/firecracker-microvm/firecracker">firecracker</a> 称自己为 microVM, 即轻量级虚拟机, 它本身还是基于 KVM 的, 不过 KVM 通常使用 QEMU 来虚拟化除CPU和内存外的资源, 比如IO设备,网络设备. firecracker 则使用 rust 实现了最精简的设备虚拟化, 为的就是压榨虚拟化的开销, 越轻量越好.</li>
</ul>

<h1 id="安全容器与-serverless">安全容器与 Serverless</h1>

<p>你可能觉得安全容器对自己而言没什么用: 大不了我给每个产品线都部署 k8s, 机器池也都隔离掉, 从基础设施的层面就隔离掉嘛.</p>

<p>这么做当然可以, 但同时也要知道, 这种做法最终其实是以 IaaS 的方式在卖资源, 是做不了真正的 PaaS 乃至 Serverless 的.</p>

<p>Serverless 要做到所有的用户容器或函数按需使用计算资源, 那必须满足两点:</p>

<ul>
<li><strong>多租户强隔离</strong>: 用户的容器或函数都是按需启动按秒计费, 我们可不能给每个用户预先分配一坨隔离的资源,因此我们要保证整个 Platform 是多租户强隔离的;</li>
<li><strong>极度轻量</strong>: Serverless 的第一个特点是运行时沙箱会更频繁地创建和销毁, 第二个特点是切分的粒度会非常非常细, 细中细就是 FaaS, 一个函数就要一个沙箱. 因此就要求两点: 1. 沙箱启动删除必须飞快; 2. 沙箱占用的资源越少越好. 这两点在 long-running, 粒度不大的容器运行环境下可能不明显, 但在 Serverless 环境下就会急剧被放大. 这时候去做MicroVM 的 ROI 就比以前要高很多. 想想, 用传统的 KVM 去跑 FaaS, 那还不得亏到姥姥家了?</li>
</ul>

<h1 id="结尾">结尾</h1>

<p>这次的内容是越写越多, 感觉怎么都写不完的样子, rkt, lxd 其实都还没涉及, 这里就提供下类比, 大家可以自行做拓展阅读: rkt 跟 docker 一样是一个容器引擎, 特点是无 daemon, 目前项目基本不活跃了; lxc 是 docker 最早使用的容器工具集, 位置可以类比 runc, 提供跟 kernel 打交道的库&amp;命令行工具, lxd 则是基于 lxc 的一个容器引擎, 只不过大多数容器引擎的目标是容器化应用, lxd 的目标则是容器化操作系统.</p>

<p>最后, 这篇文章涉及内容较多, 如有纰漏, 敬请指正!</p>
]]></content>
		</item>
		
		<item>
			<title>Kubernetes Pod 中的 ConfigMap 配置更新</title>
			<link>https://www.aleiwu.com/post/configmap-hotreload/</link>
			<pubDate>Sun, 24 Feb 2019 20:04:16 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/configmap-hotreload/</guid>
			<description>业务场景里经常会碰到配置更新的问题，在 &amp;ldquo;GitOps&amp;ldquo;模式下，Kubernetes 的 ConfigMap 或 Secret 是非常好的配置管理机制。但</description>
			<content type="html"><![CDATA[

<p>业务场景里经常会碰到配置更新的问题，在 &ldquo;<a href="https://www.weave.works/blog/gitops-operations-by-pull-request">GitOps</a>&ldquo;模式下，Kubernetes 的 <code>ConfigMap</code> 或 <code>Secret</code> 是非常好的配置管理机制。但是，Kubernetes 到目前为止(1.13版本)还没有提供完善的 <code>ConfigMap</code> 管理机制，当我们更新 <code>ConfigMap</code> 或 <code>Secret</code> 时，引用了这些对象的 <code>Deployment</code> 或 <code>StatefulSet</code> 并不会发生滚动更新。因此，我们需要自己想办法解决配置更新问题，让整个流程完全自动化起来。</p>

<blockquote>
<p>GitOps 的大体来说就是使用 git repo 存储资源描述的代码(比如各类 k8s 资源的 yaml 文件)，再通过 CI 或控制器等手段保证集群状态与仓库代码的同步，最后通过 Pull Request 流程来审核,执行或回滚运维操作</p>

<p>这篇文章中的所有知识对 <code>Secret</code> 对象也是通用的，为了简明，下文只称 <code>ConfigMap</code></p>
</blockquote>

<h1 id="概述">概述</h1>

<p>首先，我们先给定一个背景，假设我们定义了如下的 <code>ConfigMap</code>：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">apiVersion<span class="p">:</span><span class="w"> </span>v1<span class="w">
</span><span class="w"></span>kind<span class="p">:</span><span class="w"> </span>ConfigMap<span class="w">
</span><span class="w"></span>metadata<span class="p">:</span><span class="w">
</span><span class="w">  </span>name<span class="p">:</span><span class="w"> </span>test-config<span class="w">
</span><span class="w"></span>data<span class="p">:</span><span class="w">
</span><span class="w">  </span>config.yml<span class="p">:</span><span class="w"> </span>|<span class="sd">-
</span><span class="sd">    start-message: &#39;Hello, World!&#39;</span><span class="w">
</span><span class="w">    </span>log-level<span class="p">:</span><span class="w"> </span>INFO<span class="w">
</span><span class="w">  </span>bootstrap.yml<span class="p">:</span><span class="w">
</span><span class="w">    </span>listen-address<span class="p">:</span><span class="w"> </span><span class="s1">&#39;127.0.0.1:8080&#39;</span></code></pre></div>
<p>这个 <code>ConfigMap</code> 的 <code>data</code> 字段中声明了两个配置文件，<code>config.yml</code> 和 <code>bootstrap.yml</code>，各自有一些内容。当我们要引用里面的配置信息时，Kubernetes 提供了两种方式：</p>

<ul>
<li>使用 <code>configMapKeyRef</code> 引用 <code>ConfigMap</code> 中某个文件的内容作为 Pod 中容器的环境变量；</li>
<li>将所有 <code>ConfigMap</code> 中的文件写到一个临时目录中，将临时目录作为 volume 挂载到容器里，也就是 configmap 类型的 volume;</li>
</ul>

<p>好了，假设我们有一个 <code>Deployment</code>，它的 Pod 模板中以引用了这个 <code>ConfigMap</code>。现在的问题是，<strong>我们希望当 <code>ConfigMap</code> 更新时，这个 <code>Deployment</code> 的业务逻辑也能随之更新，有哪些方案？</strong></p>

<ul>
<li>最好是在当 <code>ConfigMap</code> 发生变更时，直接进行热更新，从而做到不影响 Pod 的正常运行</li>
<li>假如无法热更新或热更新完成不了需求，就需要触发对应的 <code>Deployment</code> 做一次滚动更新</li>
</ul>

<p>接下来，我们就探究一下不同场景下的几种应对方案</p>

<h1 id="场景一-针对可以做热更新的容器-进行配置热更新">场景一：针对可以做热更新的容器，进行配置热更新</h1>

<p>当 <code>ConfigMap</code> 作为 volume 进行挂载时，它的内容是会更新的。为了更好地理解何时可以做热更新，我们要先简单分析 <code>ConfigMap</code> volume 的更新机制:</p>

<p>更新操作由 kubelet 的 Pod 同步循环触发。每次进行 Pod 同步时（默认每 10 秒一次），Kubelet 都会将 Pod 的所有 <code>ConfigMap</code> volume 标记为&rdquo;需要重新挂载(RequireRemount)&ldquo;，而 kubelet 中的 volume 控制循环会发现这些需要重新挂载的 volume，去执行一次挂载操作。</p>

<p>在 <code>ConfigMap</code> 的重新挂载过程中，kubelet 会先比较远端的 <code>ConfigMap</code> 与 volume 中的 <code>ConfigMap</code> 是否一致，再做更新。要注意，&rdquo;拿远端的 <code>ConfigMap</code>&rdquo; 这个操作可能是有缓存的，因此拿到的并不一定是最新版本。</p>

<p>由此，我们可以知道，<code>ConfigMap</code> 作为 volume 确实是会自动更新的，但是它的更新存在延时，最多的可能延迟时间是:</p>

<p><strong>Pod 同步间隔(默认10秒) + ConfigMap 本地缓存的 TTL</strong></p>

<blockquote>
<p>kubelet 上 ConfigMap 的获取是否带缓存由配置中的 <code>ConfigMapAndSecretChangeDetectionStrategy</code> 决定</p>

<p>注意，假如使用了 <code>subPath</code> 将 ConfigMap 中的某个文件单独挂载到其它目录下，那这个文件是无法热更新的（这是 ConfigMap 的挂载逻辑决定的）</p>
</blockquote>

<p>有了这个底，我们就明确了：</p>

<ul>
<li>假如应用对配置热更新有实时性要求，那么就需要在业务逻辑里自己到 ApiServer 上去 watch 对应的 <code>ConfigMap</code> 来做更新。或者，干脆不要用 <code>ConfigMap</code>，换成 <code>etcd</code> 这样的一致性 kv 存储来管理配置；</li>
<li>假如没有实时性要求，那我们其实可以依赖 <code>ConfigMap</code> 本身的更新逻辑来完成配置热更新；</li>
</ul>

<p>当然，配置文件更新完不代表业务逻辑就更新了，我们还需要通知应用重新读取配置进行业务逻辑上的更新。比如对于 Nginx，就需要发送一个 SIGHUP 信号量。这里有几种落地的办法。</p>

<h2 id="热更新一-应用本身监听本地配置文件">热更新一：应用本身监听本地配置文件</h2>

<p>假如是我们自己写的应用，我们完成可以在应用代码里去监听本地文件的变化，在文件变化时触发一次配置热更新。甚至有一些配置相关的第三方库本身就包装了这样的逻辑，比如说 <a href="https://github.com/spf13/viper">viper</a>。</p>

<h2 id="热更新二-使用-sidecar-来监听本地配置文件变更">热更新二：使用 sidecar 来监听本地配置文件变更</h2>

<p>Prometheus 的 Helm Chart 中使用的就是这种方式。这里有一个很实用的镜像叫做 <a href="https://github.com/jimmidyson/configmap-reload">configmap-reload</a>，它会去 watch 本地文件的变更，并在发生变更时通过 HTTP 调用通知应用进行热更新。</p>

<p>但这种方式存在一个问题：Sidecar 发送信号（Signal）的限制比较多，而很多开源组件比如 Fluentd，Nginx 都是依赖 SIGHUP 信号来进行热更新的。主要的限制在于，kubernetes 1.10 之前，并不支持 pod 中的容器共享同一个 pid namespace，因此 sidecar 也就无法向业务容器发送信号了。而在 1.10 之后，虽然支持了 pid 共享，但在共享之后 pid namespace 中的 1 号进程会变成基础的 <code>/pause</code> 进程，我们也就无法轻松定位到目标进程的 pid 了。</p>

<p>当然了，只要是 k8s 版本在 1.10 及以上并且开启了 <code>ShareProcessNamespace</code> 特性，我们多写点代码，通过进程名去找 pid，总是能完成需求的。但是 1.10 之前就是完全没可能用 sidecar 来做这样的事情了。</p>

<h2 id="热更新三-胖容器">热更新三：胖容器</h2>

<p>既然 sidecar 限制重重，那我们只能回归有点&rdquo;反模式&rdquo;的胖容器了。还是和 sidecar 一样的思路，但这次我们通过把主进程和sidecar 进程打在同一个镜像里，这样就直接绕过了 pid namespace 隔离的问题。当然，假如允许的话，还是用上面的一号或二号方案更好，毕竟容器本身的优势就是轻量可预测，而复杂则是脆弱之源。</p>

<h1 id="场景二-无法热更新时-滚动更新-pod">场景二：无法热更新时，滚动更新 Pod</h1>

<p>无法热更新的场景有很多：</p>

<ul>
<li>应用本身没有实现热更新逻辑，而一般来说自己写的大部分应用都不会特意去设计这个逻辑；</li>
<li>使用 <code>subPath</code> 进行 <code>ConfigMap</code> 的挂载，导致 <code>ConfigMap</code> 无法自动更新；</li>
<li>在环境变量或 <code>init-container</code> 中依赖了 <code>ConfigMap</code> 的内容；</li>
</ul>

<p>最后一点额外解释一下，当使用 <code>configMapKeyRef</code> 引用 <code>ConfigMap</code> 中的信息作为环境变量时，这个操作只会在 Pod 创建时执行一次，因此不会自动更新。而 <code>init-container</code> 也只会运行一次，因此假如 <code>init-contianer</code> 的逻辑依赖了 <code>ConfigMap</code> 的话，这个逻辑肯定也不可能按新的再来一遍了。</p>

<p>当碰到无法热更新的时候，我们就必须去滚动更新 Pod 了。相信你一定想到了，那我们写一个 controller 去 watch <code>ConfigMap</code> 的变更，watch 到之后就去给 <code>Deployment</code> 或其它资源做一次滚动更新不就可以了吗？没错，但就我个人而言，我更喜欢依赖简单的东西，因此我们还是从简单的方案讲起。</p>

<h2 id="pod-滚动更新一-修改-ci-流程">Pod 滚动更新一：修改 CI 流程</h2>

<p>这种办法异常简单，只需要我们写一个简单的 CI 脚本：给 <code>ConfigMap</code> 算一个 Hash 值，然后作为一个环境变量或 Annotation 加入到 Deployment 的 Pod 模板当中。</p>

<p>举个例子，我们写这样的一个 Deployment yaml 然后在 CI 脚本中，计算 Hash 值替换进去：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">...<span class="w">
</span><span class="w"></span>spec<span class="p">:</span><span class="w">
</span><span class="w">  </span>template<span class="p">:</span><span class="w">
</span><span class="w">    </span>metadata<span class="p">:</span><span class="w">
</span><span class="w">      </span>annotations<span class="p">:</span><span class="w">
</span><span class="w">        </span>com.aylei.configmap/hash<span class="p">:</span><span class="w"> </span>${CONFIGMAP_HASH}<span class="w">
</span><span class="w"></span>...</code></pre></div>
<p>这时，假如 <code>ConfigMap</code> 变化了，那 Deployment 中的 Pod 模板自然也会发生变化，k8s 自己就会帮助我们做滚动更新了。另外，如何 <code>ConfigMap</code> 不大，直接把 <code>ConfigMap</code> 转化为 JSON 放到 Pod 模板中都可以，这样做还有一个额外的好处，那就是在排查故障时，我们一眼就能看到这个 Pod 现在关联的 ConfigMap 内容是什么。</p>

<h2 id="pod-滚动更新二-controller">Pod 滚动更新二：Controller</h2>

<p>还有一个办法就是写一个 Controller 来监听 <code>ConfigMap</code> 变更并触发滚动更新。在自己动手写之前，推荐先看看一下社区的这些 Controller 能否能满足需求：</p>

<ul>
<li><a href="https://github.com/stakater/Reloader">Reloader</a></li>
<li><a href="https://github.com/fabric8io/configmapcontroller">ConfigmapController</a></li>
<li><a href="https://github.com/mfojtik/k8s-trigger-controller">k8s-trigger-controller</a></li>
</ul>

<h1 id="结尾">结尾</h1>

<p>上面就是我针对 <code>ConfigMap</code> 和 <code>Secret</code> 热更新总结的一些方案。最后我们选择的是使用 sidecar 进行热更新，因为这种方式更新配置带来的开销最小，我们也为此主动避免掉了&rdquo;热更新环境变量这种场景&rdquo;。</p>

<p>当然了，配置热更新也完全可以不依赖 <code>ConfigMap</code>，Etcd + Confd, 阿里的 Nacos, 携程的 Apollo 包括不那么好用的 Spring-Cloud-Config 都是可选的办法。但它们各自也都有需要考虑的东西，比如 Etcd + Confd 就要考虑 Etcd 里的配置项变更怎么管理；Nacos, Apollo 这种则需要自己在 client 端进行代码集成。相比之下，对于刚起步的架构，用 k8s 本身的 <code>ConfigMap</code> 和 <code>Secret</code> 可以算是一种最快最通用的选择了。</p>

<h2 id="reference">Reference</h2>

<ul>
<li><a href="https://github.com/kubernetes/kubernetes/issues/22368">Facilitate ConfigMap rollouts / management</a></li>
<li><a href="https://github.com/kubernetes/kubernetes/issues/24957">Feature request: A way to signal pods</a></li>
<li><a href="https://kubernetes.io/docs/tasks/configure-pod-container/share-process-namespace/">Share Process Namespace between Containers in a Pod</a></li>
<li><a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/">Configure a Pod to Use a ConfigMap</a></li>
</ul>
]]></content>
		</item>
		
		<item>
			<title>Prometheus 不完全避坑指南</title>
			<link>https://www.aleiwu.com/post/prometheus-bp/</link>
			<pubDate>Sat, 16 Feb 2019 16:00:05 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/prometheus-bp/</guid>
			<description>Prometheus 是一个开源监控系统，它本身已经成为了云原生中指标监控的事实标准，几乎所有 k8s 的核心组件以及其它云原生系统都以 Prometheus 的指标格式输出自己的运行时监控</description>
			<content type="html"><![CDATA[

<p><a href="https://github.com/prometheus/prometheus">Prometheus</a> 是一个开源监控系统，它本身已经成为了云原生中指标监控的事实标准，几乎所有 k8s 的核心组件以及其它云原生系统都以 Prometheus 的指标格式输出自己的运行时监控信息。我在工作中也比较深入地使用过 Prometheus，最大的感受就是它非常容易维护，突出一个简单省心成本低。当然，这当中也免不了踩过一些坑，下面就总结一下。</p>

<blockquote>
<p>假如你没有用过 Prometheus，建议先看一遍 <a href="https://prometheus.io/docs/introduction/overview/">官方文档</a></p>
</blockquote>

<h2 id="接受准确性与可靠性的权衡">接受准确性与可靠性的权衡</h2>

<p>Prometheus 作为一个基于指标(Metric)的监控系统，在设计上就放弃了一部分数据准确性：</p>

<ul>
<li>比如在两次采样的间隔中，内存用量有一个瞬时小尖峰，那么这次小尖峰我们是观察不到的；</li>
<li>再比如 QPS、RT、P95、P99 这些值都只能估算，无法和日志系统一样做到 100% 准确，下面也会讲一个相关的坑；</li>
</ul>

<p>放弃一点准确性得到的是更高的可靠性，这里的可靠性体现为架构简单、数据简单、运维简单。假如你维护过 ELK 或其它日志架构的话，就会发现相比于指标，日志系统想要稳定地跑下去需要付出几十倍的机器成本与人力成本。既然是权衡，那就没有好或不好，只有适合不适合，<strong>我推荐在应用 Prometheus 之初就要先考虑清楚这个问题，并且将这个权衡明确地告诉使用方。</strong></p>

<h2 id="首先做好自监控">首先做好自监控</h2>

<p>不知道你有没有考虑过一个问题，其它系统都用 Prometheus 监控起来了，报警规则也设置好了，那 Prometheus 本身由谁来监控？</p>

<p>答案是&rdquo;另一个监控系统&rdquo;，而这个监控系统可以是另一个 Prometheus。按照官方的 quickstart 或 helm 部署的 Prometheus 单实例自己监控自己的，我们当然不能指望一个系统挂掉之后自己发现自己挂了。因此我强烈建议<strong>在上生产环境之前，一定要确保至少有两个独立的 Prometheus 实例互相做交叉监控。</strong>交叉监控的配置也很简单，每台 Prometheus 都拉取其余所有 Prometheus 的指标即可。</p>

<p>还有一个点是警报系统(Alertmanager)，我们再考虑一下警报系统挂掉的情况：这时候 Prometheus 可以监控到警报系统挂了，但是因为警报挂掉了，所以警报自然就发不出来，这也是应用 Prometheus 之前必须搞定的问题。这个问题可以通过给警报系统做 HA 来应对。除此之外还有一个经典的兜底措施叫做 <a href="https://en.wikipedia.org/wiki/Dead_man%27s_switch">&ldquo;Dead man&rsquo;s switch&rdquo;</a>: 定义一条永远会触发的告警，不断通知，假如哪天这条通知停了，那么说明报警链路出问题了。</p>

<h2 id="不要使用-nfs-做存储">不要使用 NFS 做存储</h2>

<p>如题，Prometheus 维护者也<a href="https://github.com/prometheus/prometheus/issues/3534">在 issue 中表示过不支持 NFS</a>。这点我们有血泪教训（我们曾经有一台 Prometheus 存储文件发生损坏丢失了历史数据）。</p>

<h2 id="尽早干掉维度-cardinality-过高的指标">尽早干掉维度(Cardinality)过高的指标</h2>

<p>根据我们的经验，Prometheus 里有 50% 以上的存储空间和 80% 以上的计算资源(CPU、内存)都是被那么两三个维度超高的指标用掉的。而且这类维度超高的指标由于数据量很大，稍微查得野一点就会 OOM 搞死 Prometheus 实例。</p>

<p>首先要明确这类指标是对 Prometheus 的滥用，类似需求完全应该放到日志流或数仓里去算。但是指标的接入方关注的往往是业务上够不够方便，假如足够方便的话什么都可以往 label 里塞。这就需要我们防患于未然，一个有效的办法是<strong>用警报规则找出维度过高的坏指标，然后在 Scrape 配置里 Drop 掉导致维度过高的 label。</strong></p>

<p>警报规则的例子：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml"><span class="c"># 统计每个指标的时间序列数，超出 10000 的报警</span><span class="w">
</span><span class="w"></span>count<span class="w"> </span>by<span class="w"> </span>(__name__)({__name__=~<span class="s2">&#34;.+&#34;</span>})<span class="w"> </span>&gt;<span class="w"> </span><span class="m">10000</span></code></pre></div>
<p>&ldquo;坏指标&rdquo;报警出来之后，就可以用 <code>metric_relabel_config</code> 的 <code>drop</code> 操作删掉有问题的 label（比如 userId、email 这些一看就是问题户），这里的配置方式可以查阅文档</p>

<p>对了，这条的关键词是<strong>尽早</strong>，最好就是部署完就搞上这条规则，否则等哪天 Prometheus 容量满了再去找业务方说要删 label，那业务方可能就要忍不住扇你了&hellip;&hellip;</p>

<h2 id="rate-类函数-recording-rule-的坑">Rate 类函数 + Recording Rule 的坑</h2>

<p>可能你已经知道了 PromQL 里要先 <code>rate()</code> 再 <code>sum()</code>，不能 <code>sum()</code> 完再 <code>rate()</code>（不知道也没事，马上讲）。但当 <code>rate()</code> 已经同类型的函数如 <code>increase()</code> 和 recording rule 碰到一起时，可能就会不小心掉到坑里去。</p>

<p>当时，我们已经有了一个维度很高的指标（只能继续维护了，因为没有<strong>尽早干掉</strong>），为了让大家查询得更快一点，我们设计了一个 Recording Rule，用 <code>sum()</code> 来去掉维度过高的 <code>bad_label</code>，得到一个新指标。那么只要不涉及到 <code>bad_label</code>，大家就可以用新指标进行查询，Recording Rule 如下：</p>
<div class="highlight"><pre class="chroma"><code class="language-yaml" data-lang="yaml">sum(old_metric)<span class="w"> </span>without<span class="w"> </span>(bad_label)</code></pre></div>
<p>用了一段时候后，大家发现 <code>new_metric</code> 做 <code>rate()</code> 得到的 QPS 趋势图里经常有奇怪的尖峰，但 <code>old_metric</code> 就不会出现。这时我们恍然大悟：绕了个弯踩进了 <code>rate()</code> 的坑里。</p>

<p>这背后与 <code>rate()</code> 的实现方式有关，<code>rate()</code> 在设计上假定对应的指标是一个 Counter，也就是只有 incr(增加) 和  reset(归0) 两种行为。而做了 <code>sum()</code> 或其他聚合之后，得到的就不再是一个 Counter 了，举个例子，比如 <code>sum()</code> 的计算对象中有一个归0了，那整体的和会<strong>下降</strong>，而不是归零，这会影响 <code>rate()</code> 中判断 reset(归0) 的逻辑，从而导致错误的结果。写 PromQL 时这个坑容易避免，但碰到 Recording Rule 就不那么容易了，因为不去看配置的话大家也想不到 <code>new_metric</code> 是怎么来的。</p>

<p>要完全规避这个坑，可以遵守一个原则：<strong>Recording Rule 一步到位，直接算出需要的值，避免算出一个中间结果再拿去做聚合。</strong></p>

<h2 id="警报和历史趋势图未必-match">警报和历史趋势图未必 Match</h2>

<p>最近半年常常被问两个问题：</p>

<ul>
<li>我的历史趋势图看上去超过水位线了，警报<strong>为什么没报</strong>？</li>
<li>我的历史趋势图看上去挺正常的，警报<strong>为什么报了</strong>？</li>
</ul>

<p>这其中有一个原因是：趋势图上每个采样点的采样时间和警报规则每次的计算时间不是严格一致的。当时间区间拉得比较大的时候，采样点非常稀疏，不如警报计算的间隔来得密集，这个现象尤为明显，比如时序图采样了 0秒，60秒，120秒三个点。而警报在15秒，30秒，45秒连续计算出了异常，那在图上就看不出来。另外，经过越多的聚合以及函数操作，不同时间点的数据差异会来得越明显，有时确实容易混淆。</p>

<p>这个其实不是问题，碰到时将趋势图的采样间隔拉到最小，仔细比对一下，就能验证警报的准确性。而对于聚合很复杂的警报，可以先写一条 Recording Rule, 再针对 Recording Rule 产生的新指标来建警报。这种范式也能帮助我们更高效地去建分级警报（超过不同阈值对应不同的紧急程度）</p>

<h2 id="alertmanager-的-group-interval-会影响-resolved-通知">Alertmanager 的 group_interval 会影响 resolved 通知</h2>

<p>Alertmanager 里有一个叫 group_interval 的配置，用于控制同一个 group 内的警报最快多久通知一次。这里有一个问题是 firing(激活) 和 resolved(已消除) 的警报通知是共享同一个 group 的。也就是说，假设我们的 group_interval 是默认的 5 分钟，那么一条警报激活十几秒后立马就消除了，它的消除通知会在报警通知的 5 分钟之后才到，因为在发完报警通知之后，这个 Group 需要等待 5 分钟的 group_interval 才能进行下一次通知。</p>

<p>这个设计让&rdquo;警报消除就立马发送消除通知&rdquo;变得几乎不可能，因为假如把 group_interval 变得很小的话，警报通知就会过于频繁，而调大的话，就会拖累到消除通知。</p>

<p>这个问题修改一点源码即可解决，不过无伤大雅，不修也完全没问题。</p>

<h2 id="最后一条-不要忘记因何而来">最后一条：不要忘记因何而来</h2>

<p>最后一条撒点鸡汤：<strong>监控的核心目标还是护航业务稳定，保障业务的快速迭代，永远不要忘记因何而来</strong></p>

<p>曾经有一端时间，我们追求&rdquo;监控的覆盖率&rdquo;，所有系统所有层面，一定要有指标，而且具体信息 label 分得越细越好，最后搞出几千个监控项，不仅搞得眼花缭乱还让 Prometheus 变慢了；</p>

<p>还有一段时间，我们追求&rdquo;警报的覆盖率&rdquo;，事无巨细必有要有警报，人人有责全体收警报（有些警报会发送给几十个人）。最后当然你也能预想到了，告警风暴让大家都对警报疲劳了；</p>

<p>这些事情乍看起来都是在努力工作，但其实一开始的方向就错了，监控的目标绝对不是为了达到 xxx 个指标，xxx 条警报规则，这些东西有什么意义？依我看，负责监控的开发就算不是 SRE 也要有 SRE 的心态和视野，不要为监控系统的功能或覆盖面负责（这样很可让导致开发在监控里堆砌功能和内容，变得越来越臃肿越来越不可靠），而要为整个业务的稳定性负责，同时站在稳定性的投入产出比角度去考虑每件事情的性质和意义，不要忘记我们因何而来。</p>

<p>假如你有建议或想法，欢迎在评论区或通过邮件与我讨论，你也可以在 <a href="https://twitter.com/AyleiWu">Aylei Wu@Twitter</a> 或 <a href="https://www.linkedin.com/in/yelei-wu-0850a5141/">Yelei Wu@Linkedin</a> 上找到我。</p>
]]></content>
		</item>
		
		<item>
			<title>写在19年初的后端社招面试经历(两年经验): 蚂蚁 头条 PingCAP</title>
			<link>https://www.aleiwu.com/post/interview-experience/</link>
			<pubDate>Mon, 28 Jan 2019 22:52:03 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/interview-experience/</guid>
			<description>去年（18年）年底想出来看看机会，最后很幸运地拿到了 PingCAP，今日头条的 offer 以及蚂蚁金服的口头 offer。想着可以总结一下经验，分享一下</description>
			<content type="html"><![CDATA[

<p>去年（18年）年底想出来看看机会，最后很幸运地拿到了 PingCAP，今日头条的 offer 以及蚂蚁金服的口头 offer。想着可以总结一下经验，分享一下自己这一段&rdquo;骑驴找马&rdquo;过的心路历程。当然，一家之言，难免粗浅，如有不妥，敬请指正。</p>

<p>全文有点长，假如只对一家公司感兴趣的话可以直接跳过去：</p>

<ul>
<li><a href="#准备过程">准备过程</a></li>
<li><a href="#pingcap">面试: PingCAP</a></li>
<li><a href="#蚂蚁">面试: 蚂蚁</a></li>
<li><a href="#头条">面试: 头条</a></li>
<li><a href="#总结">总结</a></li>
</ul>

<h1 id="准备过程">准备过程</h1>

<p>我自己是本科毕业后在老东家干了两年多，老东家算是一家&rdquo;小公司&rdquo;(毕竟这年头没有 BAT 或 TMD 的 title 都不好意思报出身)，毕业这两年多我也没有在大厂待过，因此找坑的时候是非常非常虚的。迫于心慌，我好好思考了一阵来给自己打气，当时真正找坑和准备面试的过程大概分为这几个阶段：</p>

<ul>
<li>反思：自己是不是真的要离职，假如不离职，在老东家接下来应该做什么才能继续提升？</li>
<li>定位：我在硬性技能（编码、架构）上的长处在哪？我在软技能（沟通，团队）上的长处在哪？这步顺带写了简历</li>
<li>寻找平台：哪些平台能同时满足：1、有挑战有上升空间；2、符合我的定位方向；3、团队氛围和老东家一样好（或更好）</li>
<li>找人内推：基本都是在 v2 上找的（诚挚感谢各位帮助我内推的大佬）</li>
<li>面试谈 offer</li>
</ul>

<p><code>定位</code>这一步其实花了好几天时间，我先是梳理了自己的项目经历和工作内容的专长，把 Java + Golang，做过的面比较广（业务，中间件，基础架构都做过）以及对 k8s 云原生有专长和兴趣作为自己的&rdquo;硬招牌&rdquo;。然后把学习能力强，喜欢沟通合作，渴望挑战作为我的&rdquo;软招牌&rdquo;，最后把自己定位成一个&rdquo;在过往经历中展现出了过人学习能力和钻研能力，同时渴望挑战，不愿意呆在舒适区&rdquo;的形象（妈呀打这段话的时候太羞耻了！！！）。</p>

<p>这个过程中，我的方法论是&rdquo;换位思考&rdquo;。自己过去也面试过不少人（所以平时公司让我去面试，虽然累点苦点，但也都是积累呀），并且也和 HR 以及放出 HC 的业务方聊过我们希望放什么样的人进来。因此全程都在以面试官的心态来考察自己：</p>

<blockquote>
<p>假如我是面试官，我会招怎样的人进来做我的同事？</p>
</blockquote>

<p>举几个例子，下面都是我在这个过程中考虑过的问题（当然只是我自己的喜好）：</p>

<ul>
<li><strong>对于一个毕业两年多的人，我最希望他有什么特质</strong>?这个阶段的人其实还是&rdquo;空杯&rdquo;，我希望他有很强的学习能力和进取心，给自己部门培养出一个超级生产力；</li>
<li><strong>什么样的行为会让我&rdquo;讨厌一份简历&rdquo;</strong>：把每个项目都大写特写，尤其是陈述细节没有重点；罗列框架当能力，用过了一类场景的框架就觉得能解决一类业务诸如此类；</li>
<li><strong>面试的时候我会偏向于问哪些问题</strong>？一是简历上写了&rdquo;理解&rdquo;或&rdquo;精通&rdquo;的语言与中间件；二是简历上写得比较有趣，又没有完全交代清楚的项目；</li>
</ul>

<p><code>定位</code>之后，我要找的下一个位置基本锁定在了 PaaS、云原生、中间件方向。那接下来就是找坑了，这段时间&rdquo;寒冬论&rdquo;炒的火热，好坑确实挺难找，最后兜兜转转找了四家的内推：Shopee（新加坡）、PingCAP、头条、蚂蚁。Shopee 那边挺遗憾的，12月初投完简历后在1月初进行的第一轮 HR Screen，而当时另几家面试已经临近尾声，于是选择了推掉，后来确认是12月 Shopee 正好在休假，会说中文的 HR 都恰好不在，这也算是机缘巧合了😆Shopee 给我的感觉（虽然只有一面）是非常为员工考虑，HR 小姐姐很客观地帮我梳理了很多去新加坡会带来的利弊得失。大家假如对 Shopee 感兴趣，浩松老师 <a href="https://github.com/haosdent">@haosdent</a> 本人就出现在了 issue 区，可以去找找看😆</p>

<p>这几家的简历投递出去之后，我着重把简历里&rdquo;埋的几个坑&rdquo;，也就是自己写了&rdquo;理解并掌握&rdquo;的语言与中间件以及专门用来勾引面试官问的项目好好复习了一遍。事后发现这一步还挺关键的，很多知识性的内容要是不复习一下真就全忘了，这也算临阵磨枪，不快也光了吧。</p>

<p>接下来就进入正题，逐家讲一下自己的面试体验：</p>

<ul>
<li><a href="#pingcap">PingCAP - Cloud 方向</a></li>
<li><a href="#蚂蚁">蚂蚁 - 容器调度方向（CTO线）</a></li>
<li><a href="#头条">头条 - 工程效能方向</a></li>
</ul>

<h1 id="pingcap">PingCAP</h1>

<p><img src="/img/interview/pingcap.jpg" alt="pingcap" /></p>

<ul>
<li><a href="#面试前">面试前</a></li>
<li><a href="#一面">一面</a></li>
<li><a href="#二面">二面</a></li>
<li><a href="#三面">三面</a></li>
<li><a href="#四面">四面</a></li>
<li><a href="#五面">五面</a></li>
<li><a href="#pingcap-小结">PingCAP 小结</a></li>
</ul>

<h2 id="面试前">面试前</h2>

<p>PingCAP 的简历响应是最快的，内推之后第二天 HR 小姐姐就联系了我。电话接通之后先是简单聊了一下人生，然后就是社招三问 [&rdquo;<strong>为啥离职啊?</strong>&rdquo;,&rdquo;<strong>现在待遇咋样啊?</strong>&rdquo;,&rdquo;<strong>期望待遇咋样啊?</strong>&rdquo;]，天知道这通电话是我开始投简历之后的第一通电话面试，之前还完全没有准备过类似的问题，只能稀里哗啦用[&rdquo;<strong>现在自己的技术成长有点碰到瓶颈，加上一直对您公司钦慕有加☺️</strong>&rdquo;,&rdquo;<strong>我现在待遇是xxx但我司除了base之外还有xxx以及我马上要提薪了🤪</strong>&rdquo;,&rdquo;<strong>其实比起待遇我更看重平台和挑战(狗头)，但是基本的薪资需求我还是希望能xxx🤑</strong>&rdquo;]这样和稀泥应付过去。内心稍稍平静之后小姐姐跟我讲了讲我意向部门的结构和主体业务，然后交代了一下接下来的面试流程，约了&rdquo;一面&rdquo;并且加了微信。</p>

<p>&ldquo;一面&rdquo;加了引号，这是因为&rdquo;一面&rdquo;其实是在微信上布置了一个小项目，然后约定好时间验收。</p>

<blockquote>
<p>这里要特别感谢一下 PingCAP 的 HR 小姐姐，加了微信之后全程帮助我协调面试时间并不厌其烦地回答我各种奇奇怪怪的问题，最后谈 offer 的时候还给我准备了一个惊喜。面试体验直接满星&lt;3!</p>
</blockquote>

<h2 id="一面">一面</h2>

<p>早就听说 PingCAP 一面要写小项目，我自己心里其实是跃跃欲试的。面试官给的项目要求大体是这样：</p>

<blockquote>
<p>K8S 容器化之后应用容器里几乎没有什么可用的调试工具，可以利用容器 Namespace 共享的思路，启动一个包含各种调试工具（比如 netstat, gdb）的容器，加入到 pod 的 pid、net 等 namespace 中， 实现对任意 pod 的 debug 功能。现在希望利用 kubectl plugin 机制实现一个插件，用于 debug 任意一个 pod 里的容器，达到 <code>kubectl exec</code> 的使用体验.</p>
</blockquote>

<p>当时因为工作日抽不出时间，就隔了几天到周五晚上开始写，周六晚上写完并且把 <a href="https://github.com/aylei/kubectl-debug">项目地址</a> 分享到了 <a href="https://www.reddit.com/r/devops/comments/a8vnt5/i_wrote_a_tool_to_debug_kubernetes_pods_more/">Reddit 上</a>。没想到运气不错收了 100 多个 star，这下我就觉得&rdquo;哦豁，这轮应该稳了吧！&rdquo;（结果后来发现这几乎是我唯一一把觉得自己&rdquo;稳了的&rdquo;面试&hellip;)</p>

<h2 id="二面">二面</h2>

<p>二面是一位 Cloud 方向的前辈面我，全程大概微信语音聊了50多分钟：</p>

<ul>
<li>问项目经历，聊了两个项目</li>
<li>对 Kubernetes 了解怎么样，看过源码吗？

<ul>
<li>k8s 的代码我以前其实只看过 kubelet，临阵磨枪的时候把 apiserver、scheduler、controller-manager 都看了一遍，笑容渐渐出现。</li>
</ul></li>
<li>Kubernetes 的 Service 是什么概念，怎么实现的？</li>
<li>你刚说到 Informer，Informer 是怎么实现的，有什么作用？</li>
<li>StatefulSet 用过吗？有什么特点？</li>
<li>StatefulSet 的滚动升级是如何实现的？</li>
<li>现在我们希望只升级 StatefulSet 中的任意个节点进行测试, 可以怎么做?

<ul>
<li>这题没有思路，只好强答用&rdquo;两个 StatefulSet&rdquo;，后来一想起一个新的 StatefulSet 那 PV 里的数据就丢了，其实正确办法是利用 partition 机制，笑容渐渐消失。</li>
</ul></li>
<li>Kubernetes 的所有资源约定了版本号, 为什么要这么做?

<ul>
<li>第二个拿不准的问题，我面试前就反复告诉自己&rdquo;<strong>不要强答</strong>&ldquo;以及&rdquo;<strong>不知道的题就讲思路</strong>&ldquo;，于是就说这块代码确实没看过，但是根据微服务 API 的设计理念，版本号的作用有巴拉巴拉。答完似乎面试官还算满意，于是又往下挖了一句：</li>
</ul></li>
<li>假如有多几个版本号并存, 那么 K8S 服务端需要维护几套代码?

<ul>
<li>这题完全不知道，内心逐渐焦灼，立马走老套路&rdquo;这我没看过 k8s 代码怎么写的无法确定（想表达自己真正看过代码才会确认，凸显自己严谨&hellip;我的妈呀），但假如由我来写这份代码（装作非常自信），我会只会维护一份最新的 Model，然后设计对应一个版本段的 Adpater 将老版本的 Model 转化过来巴拉巴拉&rdquo;。到这里我已经虚的不行了</li>
</ul></li>
<li>OK，那接下来我们聊聊 Golang （我：长舒一口气)</li>
<li>看一下这段代码有没有问题(一段 golang for-range 里 goroutine 闭包捕获的代码)，为什么?</li>
<li>goroutine 是怎么调度的？</li>
<li>goroutine 和 kernel thread 之间是什么关系？</li>
<li>有什么想问我的？</li>
</ul>

<p>面完之后感觉答得一般，心里有点忐忑。结果第二天 HR 小姐姐就来安排三面了，长舒了一口气。</p>

<h2 id="三面">三面</h2>

<p>三面是和整个大部门的 Leader 聊，面试官很能聊（声音还很好听！）而且技术非常全面，全程大概微信语音聊了80多分钟：</p>

<ul>
<li>给我介绍 PingCAP 相关团队的职责与挑战</li>
<li>聊为什么出来看机会，以及未来的职业规划</li>
<li>聊我之前做的一个数据同步的项目，大概内容是订阅 MySQL Binlog，sink 到搜索索引、分库分表以及业务事件订阅流中</li>
<li>为什么数据同步里选择了 xxxx 开源项目，优势在哪？</li>
<li>订阅分库分表的 Binlog 怎么订阅？</li>
<li>分库分表的数据源中假如存在主键冲突要怎么解决？</li>
<li>怎么保证下游对 Binlog 的消费顺序？</li>
<li>如何在下游保证消费时的事务原子性？</li>
<li>描述了一下 tidb 的 binlog 架构，问这种场景下怎么保证 Binlog 顺序</li>
<li>聊一个上了 Kubernetes 的项目，问了一些细节和坑</li>
<li>用 Kubernetes 之后，解决了哪些问题？</li>
<li>聊我之前做的监控警报项目，问背景和产出</li>
<li>Prometheus 单实例数据量级 hold 不住了，有什么解决方案？</li>
<li>有什么想问我的？</li>
</ul>

<p>简历里的&rdquo;数据同步&rdquo;这个项目我是好好复习过自己当年写的调研文档和架构文档的，也做了被问的准备（换位思考，是我我也问。这个其实就是我专门希望面试官来挖细节的项目）。最后确实被问最多的就是这个项目，运气真的不错😁。</p>

<h2 id="四面">四面</h2>

<p>四面到了现场面，有两位面试官一起跟我聊，大约聊了 40 多分钟：</p>

<ul>
<li>聊&rdquo;配置中心&rdquo;项目的细节</li>
<li>为什么不用 ZK，要自己再写一个&rdquo;配置中心&rdquo;

<ul>
<li>这个问题让我措手不及，我只好坦白：当时年轻，想刷经验，事后才领悟到不要重复造轮子，当然最后系统的产出也不错（后面这两句是我临时加的，不能让面试官觉得我是一个不看全局只顾自己刷经验的人）</li>
</ul></li>
<li>配置中心怎么做服务发现的？怎么做 failover 的？</li>
<li>用 Kubernetes 碰到过哪些坑？</li>
<li>对 Prometheus 做了哪些改动？</li>
<li>对 Alertmanager 做了哪些改动？</li>
<li>监控系统怎么做&rdquo;自监控&rdquo;？</li>
<li>跨机房的网络问题怎么监控？</li>
<li>有什么想问我们的？</li>
</ul>

<p>四面是纯项目，里面的经验就不太通用了。但这里面有个细节，就是到中途的时候两个面试官互相对了一下&rdquo;还有什么想问的吗？&rdquo;我意识到面试官们想问的问题不多了，可时间大约才过了20分钟（<strong>面试时间过短是一个 bad smell</strong>）。于是之后几个监控的问题我都尽量说得很细，同时顺便提一下&rdquo;还有一个方面我们当时也做了挺多工作&rdquo;，暗示面试官往下挖的线索。不知道这招有没有奏效，反正这一面算是有惊无险过啦。</p>

<h2 id="五面">五面</h2>

<p>技术面到四面就结束了，五面是创始人面（有幸和崔秋大佬聊了20多分钟人生），面完之后就是 offer call 了。</p>

<h2 id="pingcap-小结">PingCAP 小结</h2>

<p>一些主观评价：</p>

<ul>
<li>面试难度：正常</li>
<li>面试体验：我给满分</li>
<li>问题偏向：项目经历、工程能力</li>
</ul>

<blockquote>
<p>这里真的想夸一下 PingCAP（因为面试体验超棒呀！）。投 PingCAP 的初衷是觉得这个团队的工程师文化非常浓，大牛云集，同时 TiDB 够牛逼，项目开源的模式我内心也很认可。只是挂羊头卖狗肉的公司也不少，好多 JD 上写着工程师文化浓郁，其实很多根本不是那么回事儿。但是经过 PingCAP 的五轮面试之后，我实打实地感受到了工程师文化：面试里没有任何一个&rdquo;刁难人的问题&rdquo;，每一位面试官感兴趣的是我的工程思维、学习能力、技术见解，同时还非常热衷于与我讨论和深挖一些坑与技术决策。这种感觉就很爽：<strong>面试官是懂我的，我作为工程师的思维能力与技术见解得到了认可与尊重。</strong> 这种氛围是口号喊不出来的，因为它的硬性指标就是这其中的每一个人要热爱技术并且工程经验丰富。</p>
</blockquote>

<h1 id="蚂蚁">蚂蚁</h1>

<p><img src="/img/interview/antfinacial.jpeg" alt="ant" /></p>

<ul>
<li><a href="#面试前-1">面试前</a></li>
<li><a href="#一面-1">一面</a></li>
<li><a href="#二面-1">二面</a></li>
<li><a href="#三面-1">三面</a></li>
<li><a href="#四面-1">四面</a></li>
<li><a href="#五面-1">五面</a></li>
<li><a href="#六面">六面（HR）</a></li>
<li><a href="#小结">小结</a></li>
</ul>

<h2 id="面试前-1">面试前</h2>

<p>蚂蚁的面试挺独特，每轮面试都没有 HR 约时间，一般是晚上 8 点左右面试官来一个电话，问是否能面试，能的话开始面，不能就约一个其它时间。</p>

<p>全程 6 面，前五面技术面，电话面试，最后一面是 HR 面，现场面。</p>

<h2 id="一面-1">一面</h2>

<ul>
<li>介绍一下自己</li>
<li>问项目经历, 聊&rdquo;数据同步&rdquo;</li>
<li>接着聊上了 K8S 的项目</li>
<li>有没有什么钻研得比较深得技术？（我：kubernetes, golang, prometheus, java）</li>
<li>kubernetes 的架构是怎么样的?

<ul>
<li>这个问题很大，拆成 apiserver、controller、kubelet、scheduler 讲了一下</li>
</ul></li>
<li>golang 与 java 的比较

<ul>
<li>这个问题又很大，当时主要对比了 vm、协程支持、面向对象和泛型的区别、以及自己对各自使用场景的一些理解</li>
</ul></li>
<li>golang 的 gc 算法

<ul>
<li>知道是三色标记，不过细节说不上来</li>
</ul></li>
<li>从无限的字符流中, 随机选出 10 个字符

<ul>
<li>没见过也没想出来，查了一下是<a href="https://www.jianshu.com/p/7a9ea6ece2af">蓄水池采样算法</a>，经典面试题，没刷题吃亏了</li>
</ul></li>
<li>怎么扩展 kubernetes scheduler, 让它能 handle 大规模的节点调度

<ul>
<li>单节点提速：优选阶段随机取部分节点进行优选；水平扩展 scheduler 节点，pod 做一致性 hash 来决定由哪个 scheduler 调度</li>
</ul></li>
<li>你有什么想问我的?</li>
</ul>

<p>一面其实有点僵，我自己完全没放开，面试官对我的回答没有什么反馈和深入，都是&rdquo;哦好的&rdquo;然后就过了。所以我当时面完觉得自己其实已经挂了（我自己要是对候选人不感兴趣，有时候也就问完问题走个过场溜了），后来收到二面电话着实吃惊了一下。</p>

<h2 id="二面-1">二面</h2>

<ul>
<li>先聊了聊项目</li>
<li>给 Prometheus 做了哪些改动？</li>
<li>自研配置中心, 具体做了哪些内容？</li>
<li>有用过 MySQL 的什么高级特性吗?

<ul>
<li>这里不太理解，我问什么算高级特性，面试官就切换到了下一个问题</li>
</ul></li>
<li>配置中心的核心数据表是怎么设计的?</li>
<li>为什么在业务里用 Redis, Redis 有什么优点?

<ul>
<li>单线程：并发安全；高性能；原语与数据结构丰富；采用广泛，踩坑成本低</li>
</ul></li>
<li>对 Redis 里数据结构的实现熟悉吗?

<ul>
<li>说了一个 zset 跳表</li>
</ul></li>
<li>用过 Redis 的哪些数据结构, 分别用在什么场景?</li>
<li>Java 初始化一个线程池有哪些参数可以配置, 分别是什么作用?</li>
<li>自己写的 Java 应用调优过哪些 JVM 参数, 为什么这么调优?

<ul>
<li>这个问住了，我只知道最大堆最小堆，开 G1，开 GC 日志以及 OOM dumper 这些基本的</li>
</ul></li>
<li>用 Jetty 的时候有没有配什么参数, 为什么这么配?</li>
<li>Jetty QTP 等待队列配置成无限的话, 你觉得好吗? 会有什么问题吗?</li>
<li>用过 Linux Bash 里的哪些命令, 分别用它们干嘛?</li>
<li>一道笔试题: 需要在给的链接中作答, 不能 google, 不能跳出, 不能用 IDE:</li>
</ul>

<p>题目是这样的：</p>

<blockquote>
<p>启动两个线程, 一个输出 1,3,5,7…99, 另一个输出 2,4,6,8…100 最后 STDOUT 中按序输出 1,2,3,4,5…100</p>
</blockquote>

<p>我: 我用 Go 实现吧</p>

<p>面试官: 不可以，用 Java 的 notify 机制实现</p>

<p>我: (还没意识到问题的严峻) 那我用 Java BlockingQueue</p>

<p>面试官：说不可以, 要求用 Java 的 wait + notify 机制来实现</p>

<p>我完全没写过 wait + notify，只能表示不会（菜鸡本鸡了）, 面试官说那行吧你可以用 go 写</p>

<p>最后用 go channel 实现了一版, 不过给的网页上不能运行代码，也不知道写得对不对，然后面试结束。</p>

<p>这一轮面试官延续了一面的风格，问完一题就赶忙下一题了，似乎没有表现出对我的回答有兴趣或认可。因此这轮面完，我又觉得自己挂了&hellip;</p>

<h2 id="三面-1">三面</h2>

<ul>
<li>依然先聊项目</li>
<li>对监控警报的项目很感兴趣, 问了挺多细节, 最后问了一个问题: 现在要你实现一个语义不弱于 PromQL 的查询语言, 你能实现吗?

<ul>
<li>这里虽然看过一些 Prometheus 的代码，但其实对 PromQL 的 lexer 和 parser 部分没有细看，还好之前因为数据同步项目里想写声明式 Stream SQL 研究过一点 ANTLR，用 ANTLR 写语法 + AST 遍历塞查询逻辑给糊弄过去了。</li>
</ul></li>
<li>问我觉得做得最深入的项目是什么

<ul>
<li>当然是数据同步（狗头）</li>
</ul></li>
<li>聊数据同步项目（这个很符合我的预期，哈哈哈哈）</li>
<li>问 Linux 掌握得怎么样？

<ul>
<li>没有系统学习过，基本上是自己运维踩坑积累的</li>
</ul></li>
<li>问 Golang 掌握得怎么样？

<ul>
<li>用了半年, 看过 effective go</li>
</ul></li>
<li>问算法掌握得怎么样？

<ul>
<li>到图为止都可以</li>
</ul></li>
<li>问最短路算法

<ul>
<li>只记得 dijkstra 了，描述了代码流程</li>
</ul></li>
<li>k8s 掌握得怎么样?

<ul>
<li>不怎么样，没有自己写过 controller 和 scheduler，但是对概念都很熟悉，看过 xxx 这几部分的源码</li>
</ul></li>
<li>k8s 的 exec 是怎么实现的?

<ul>
<li>这个问题正中下怀，之前写了 PingCAP 的小作业正好对这块特别熟悉</li>
</ul></li>
</ul>

<p>这轮聊得顺畅多了。同时发现蚂蚁的面试官似乎挺喜欢让你自己评价自己的：&rdquo;你觉得自己 xxx 掌握得怎么样？&rdquo;（只有五位面试官，样本不够大，不能作数哦），这类问题其实我慌得要死，怕自己吹过头了答不上来，面试挂了事小，丢了面子事大。早知道就预习一下怎么吹嘘自己了。</p>

<h2 id="四面-1">四面</h2>

<ul>
<li>介绍一下自己</li>
<li>觉得自己基础知识掌握<strong>怎么样</strong></li>
<li>平时一般会用到哪些数据结构？</li>
<li>链表和数组相比, 有什么优劣？</li>
<li>如何判断两个无环单链表有没有交叉点</li>
<li>如何判断两个有环单链表有没有交叉点</li>
<li>如何判断一个单链表有没有环, 并找出入环点</li>
<li>TCP 和 UDP 有什么区别?</li>
<li>描述一下 TCP 四次挥手的过程中</li>
<li>TCP 有哪些状态</li>
<li>TCP 的 LISTEN 状态是什么</li>
<li>TCP 的 CLOSE_WAIT 状态是什么</li>
<li>建立一个 socket 连接要经过哪些步骤</li>
<li>常见的 HTTP 状态码有哪些</li>
<li>301和302有什么区别</li>
<li>504和500有什么区别</li>
<li>HTTPS 和 HTTP 有什么区别</li>
<li>写一个算法题: 手写快排</li>
</ul>

<p>这一轮全程问的基础知识，基础扎实的话就没问题了，不过个人感觉有一点像校招的问法。</p>

<h2 id="五面-1">五面</h2>

<ul>
<li>介绍一下自己</li>
<li>在 k8s 上做过哪些二次开发?</li>
<li>自己用 Helm 构建过 chart 吗？有哪些？</li>
<li>有没有考虑过自己封装一个面向研发的 PaaS 平台？</li>
<li>配置中心做了什么？</li>
<li>为什么不用 zookeeper？</li>
<li>配置中心如何保证一致性？</li>
<li>Spring 里用了单例 Bean, 怎么保证访问 Bean 字段时的并发安全？

<ul>
<li>用并发安全的数据结构，比如 ConcurrentHashMap；或者加互斥锁</li>
</ul></li>
<li>假如我还想隔离两个线程的数据, 怎么办？

<ul>
<li>ThreadLocal，然后举了个例子</li>
</ul></li>
<li>Golang 里的逃逸分析是什么？怎么避免内存逃逸？

<ul>
<li>这个不知道，认怂了</li>
</ul></li>
<li>对比一下 Golang 和 Java 的 GC

<ul>
<li>答了一下 CMS、G1和三色标记，我对比的点是 JVM 有分代回收，Go 的 Runtime 没有，没能深入地讲</li>
</ul></li>
<li>Golang 的 GC 触发时机是什么

<ul>
<li>阈值触发；主动触发；两分钟定时触发；</li>
</ul></li>
<li>有没有写过 k8s 的 Operator 或 Controller？（我：没有写过）</li>
<li>谈一谈你对微服务架构的理解

<ul>
<li>大体思路&rdquo;微服务本质是人员组织架构演进与关注点分离&rdquo;</li>
</ul></li>
<li>谈一谈你对 Serveless 的理解

<ul>
<li>大体思路&rdquo;Serveless 是继 docker 与容器编排之后的又一次应用开发与基础设施提供方之间的边界划分&rdquo;</li>
</ul></li>

<li><p>你认为 Serveless 是未来吗? 为什么?</p>

<ul>
<li>大体思路&rdquo;是云服务的未来，把蛋糕从企业的IT、运维与中间件部门切走，形成规模效应，做得越多赚得越多；公司内的话 servless 能够帮助加速前台业务迭代，但对中后台的收益还看不到，未来可能会有比 servless 更适合中后台的架构&rdquo;</li>
</ul></li>

<li><p>面试官：最后你有什么要问我的？</p></li>

<li><p>我：为什么足足安排了五轮技术面，而且其中有两轮似乎和 k8s 没有关系啊？</p></li>

<li><p>面试官：我们觉得你做过的东西挺多的，各个方向都想让你尝试一下 (我的内心：&hellip;&hellip;)</p></li>

<li><p>我：那这轮是最后一轮技术面吗？</p></li>

<li><p>面试官：不一定（我的内心：&hellip;&hellip;)</p></li>

<li><p>后续还问了面试官一些业务相关的问题，就不赘述了</p></li>
</ul>

<p>五面最后的三个吹水问题我还挺感兴趣，可惜面试官只是听我讲，没有跟我讨论。还有就是问了面试官才知道，二面四面的面试官是 PaaS 平台那边的，因此主要问 Java 没有涉及到 k8s 和 go。</p>

<h2 id="六面">六面</h2>

<p>HR 面，之前就<strong>听说</strong>过阿里系的 HR 是来&rdquo;闻味道的&rdquo;（看你是否适合阿里的风格），而且有一票否决权。所以还是挺有压力的。</p>

<ul>
<li>问经历</li>
<li>为什么要考虑出来看看呢？

<ul>
<li>金句：&rdquo;<strong>现在自己的技术成长有点碰到瓶颈，加上一直对您公司钦慕有加☺️</strong>&rdquo;&rdquo;</li>
</ul></li>
<li>现在公司的主营业务是什么？（这块往技术上问了很多，感觉是想考察我解释复杂问题的能力）</li>
<li>现在带人吗？report 层级是怎样的？</li>
<li>对自己这几年的经历满意吗？</li>
<li>觉得自己有什么缺点？</li>
<li>碰到过什么很挫败的事情吗？</li>
<li>未来的职业规划是怎样的？</li>
<li>看机会的时候，主要考虑的是待遇、平台、人员还是什么其他因素？</li>
<li>现在的待遇如何</li>
<li>有什么想问我的</li>
</ul>

<p>整体聊了 40 多分钟，话题挺广的，面试官也说了系统部这边压力挺大的，优秀的人才才能留下来。个人觉得 HR 面里除了谈薪酬的部分没有什么可准备的，想说什么直说就行。因为到了 HR 面至少证明你的技术没什么问题，直说出来方便 HR 判断两边的价值观是否合拍，假如真的不合拍，<strong>那其实在 HR 这一面挂了比起进去之后再后悔又跳槽要好很多</strong>，毕竟大家都不喜欢频繁跳槽的简历。</p>

<h2 id="小结">小结</h2>

<p>一些主观评价：</p>

<ul>
<li>面试难度：正常</li>
<li>面试体验：正常</li>
<li>问题偏向：基础知识，开发常识，技术见解</li>
</ul>

<p>蚂蚁的面试风格比较&rdquo;高冷&rdquo;，面试官给我的一致感受就是很强，卧虎藏龙。面试内容上在基础知识部分相对考察得多一些，没有偏门和猎奇的问题，基础知识扎实的同学可以大胆投投看蚂蚁。</p>

<h1 id="头条">头条</h1>

<p><img src="/img/interview/bytedance.jpeg" alt="bytedance" /></p>

<ul>
<li><a href="#面试前-2">面试前</a></li>
<li><a href="#一面-2">一面</a></li>
<li><a href="#二面-2">二面</a></li>
<li><a href="#三面-2">三面</a></li>
<li><a href="#四面-2">四面</a></li>
<li><a href="#小结-1">小结</a></li>
</ul>

<h2 id="面试前-2">面试前</h2>

<p>头条每次面试前会有 HR 约时间，并提前发一个 zoom 地址过来，三场技术面与一场 HR 面全都是视频面试。不得不说视频面试体验比电话面试好很多（尤其是对我这种很关注面试官反应的），假如有 HR 同学看到这篇文章，推荐考虑一下用视频面试取代电话面试，效率会更高。</p>

<p>头条的三场技术面风格都很类似：</p>

<ol>
<li>问项目，抓出一些你擅长的领域或场景</li>
<li>问系统设计题，每题都会不断深化需求让你应变和权衡</li>
<li>问一道算法题(不难不偏)，先看思路，再要求写一下伪代码看边界条件能不能一次过</li>
</ol>

<p>这个面试流程我自己也一直在用，尤其是系统设计加上不断的需求变更，能比较全面地考察后端的基本功和工程思维。因此头条的面试套路很对我胃口，甚至好多类似的问题我自己也都问过候选人。</p>

<h2 id="一面-2">一面</h2>

<ul>
<li>介绍一下自己, 为什么选择出来看看机会</li>
<li>聊项目, 警报怎么做的, 统一接入监控项怎么做的</li>
<li>聊项目, 配置中心项目, 问实时配置推送怎么做</li>
<li>讨论为什么选择所有的组件依赖放在配置中心中控制</li>
<li>我现在要做一个限流功能, 怎么做?

<ul>
<li>令牌桶</li>
</ul></li>
<li>这个限流要做成分布式的, 怎么做?

<ul>
<li>令牌桶维护到 Redis 里，每个实例起一个线程抢锁，抢到锁的负责定时放令牌</li>
</ul></li>
<li>怎么抢锁?

<ul>
<li>Redis setnx</li>
</ul></li>
<li>锁怎么释放?

<ul>
<li>抢到锁后设置过期时间，线程本身退出时主动释放锁，假如线程卡住了，锁过期那么其它线程可以继续抢占</li>
</ul></li>
<li>加了超时之后有没有可能在没有释放的情况下, 被人抢走锁

<ul>
<li>有可能，单次处理时间过长，锁泄露</li>
</ul></li>
<li>怎么解决?

<ul>
<li>换 zk，用心跳解决</li>
</ul></li>
<li>不用 zk 的心跳, 可以怎么解决这个问题呢?

<ul>
<li>每次更新过期时间时，Redis 用 MULTI 做 check-and-set 检查更新时间是否被其他线程修改了，假如被修改了，说明锁已经被抢走，放弃这把锁</li>
</ul></li>
<li>假如这个限流希望做成可配置的, 需要有一个后台管理系统随意对某个 api 配置全局流量, 怎么做？

<ul>
<li>在 Redis 里存储每个 API 的令牌桶 key，假如存在这个 key，则需要按上述逻辑进行限流</li>
</ul></li>
<li>某一个业务中现在需要生成全局唯一的递增 ID, 并发量非常大, 怎么做

<ul>
<li>snowflake (这个其实答得不好，snowflake 无法实现全局递增，只能实现全局唯一，单机递增，面试结束后就想到了类似 TDDL 那样一次取一个 ID 段，放在本地慢慢分配的策略）</li>
</ul></li>
<li>算法题, M*N 横向纵向均递增的矩阵找指定数

<ul>
<li>只想到 O(M+N)的解法 <strong>补充</strong>: 这几天刷 leetcode 碰到这题了, <a href="https://leetcode.com/problems/search-a-2d-matrix-ii/">240. Search a 2D Matrix II</a>. 办法是从左下角或右下角开始查找.</li>
</ul></li>
<li>有什么想问我的?</li>
</ul>

<p>限流，分布式锁，UUID 都属于后端的经典面试题，这轮面试的参考价值挺大的。</p>

<h2 id="二面-2">二面</h2>

<ul>
<li>平时用的工具链和技术栈是什么</li>
<li>golang 踩过坑吗?

<ul>
<li>答了之前 PingCAP 面试时面试官问的 for-range 里的 go-routine 闭包捕获问题</li>
</ul></li>
<li>这段 golang 代码有没有 bug（还是一个 for-range 的坑)

<ul>
<li>有 bug，for-range 的 value 引用拷贝问题</li>
</ul></li>
<li>Java 中 HashMap 的存储, 冲突, 扩容, 并发访问分别是怎么解决的

<ul>
<li>Hash 表，拉链法（长度大于8变形为红黑树）,扩容*2 rehash，并发访问不安全</li>
</ul></li>
<li>拉链法中链表过长时变形为红黑树有什么优缺点?

<ul>
<li>优点：O(LogN) 的读取速度更快；缺点：插入时有 Overhead，O(LogN) 插入，旋转维护平衡</li>
</ul></li>
<li>HashMap 的并发不安全体现在哪?

<ul>
<li>拉链法解决冲突，插入链表时不安全，并发操作可能导致另一个插入失效</li>
</ul></li>
<li>HashMap 在扩容时, 对读写操作有什么特殊处理?

<ul>
<li>不知道</li>
</ul></li>
<li>ConcurrentHashMap 是怎么做到并发安全的？

<ul>
<li>segment 分段锁</li>
</ul></li>
<li>Java 有哪些锁机制, 分别有什么特点?

<ul>
<li>Synchronized、可重入锁</li>
</ul></li>
<li>知道 CAS 吗? Java 中 CAS 是怎么实现的?

<ul>
<li>Compare and Swap，一种乐观锁的实现，可以称为&rdquo;无锁&rdquo;(lock-free)，CAS 由于要保证原子性无法由 JVM 本身实现，需要调用对应 OS 的指令(这块其实我不了解细节)</li>
</ul></li>
<li>MySQL 的存储引擎用的是什么?（InnoDB）为什么选 InnoDB?

<ul>
<li>几乎所有公司用 MySQL 都用 InnoDB，降低踩坑成本；聚簇索引，MVCC</li>
</ul></li>
<li>MySQL 的聚簇索引和非聚簇索引有什么区别?

<ul>
<li>聚簇索引的叶子节点是数据节点（比如定义了主键时的主键索引），非聚簇索引叶子节点是指向数据块的指针</li>
</ul></li>
<li>B+树和二叉树有什么区别和优劣?

<ul>
<li>B+树是多叉树，深度更小，B+树可以对叶子节点进行顺序遍历，B+树能够更好地利用磁盘扇区；二叉树：实现简单</li>
</ul></li>
<li>针对一个场景设计索引，具体场景忘记了，反正考察的是联合索引与列选择性的知识</li>
<li>现有一个新的查询场景, 要怎么解决?</li>
<li>假如要查 A in () AND B in (), 怎么建索引?

<ul>
<li>只给选择性高的一列建索引，这里因为两个都是范围查询所以另一个是走不到索引的（这里答的不好，其实也可以建联合索引然后用 （A,B) in ((1,2),(3,4)) 的方式去查）</li>
</ul></li>
<li>查 A in () AND B in () 时, MySQL 是怎么利用索引的?

<ul>
<li>先走一个非聚簇索引，查询出行数据后再用另一列回表做筛选</li>
</ul></li>
<li>假如查询 A in (), MySQL 是针对 N 个值分别查一次索引, 还是有更好的操作?

<ul>
<li>不知道，有了解的同学可以留言 (补充, <a href="https://github.com/BillyLu">@BillyLu</a> 贴出了文档 <a href="https://dev.mysql.com/doc/refman/8.0/en/range-optimization.html#equality-range-optimization">equality-range-optimization</a>, 大意是对非唯一索引 MySQL 会使用 index dive 的方式估算这个 range index 涉及的行数, 结合<a href="https://dev.mysql.com/doc/refman/5.7/en/where-optimization.html">where optimization</a> 中说明的在走 index 时假如涉及行数过多会走 full table scan, 那么假如 estimation 认为这次 IN 不够好, 是会走全表扫描的. 不知道除此之外, 面试官还有没有想考察的点)</li>
</ul></li>
<li>用过 Redis 的哪几种数据结构? (都用过) ZSET 是怎么实现的?

<ul>
<li>跳表</li>
</ul></li>
<li>zrange start, stop, 总长度为 n, 复杂度是多少?

<ul>
<li>O(logN) (答得不好，实际是 O(M+log(N)), M 是结果集基数 stop-start)</li>
</ul></li>
<li>Kafka 的消费者如何做消息去重?

<ul>
<li>MySQL 去重、Redis 去重、假如场景量极大且允许误判，布隆过滤器也可以</li>
</ul></li>
<li>介绍一下 Kafka 的 ConsumerGroup

<ul>
<li>挺长的，略</li>
</ul></li>
<li>Kubernetes 和 Docker 用得怎么样? （我：在公司推行布道）</li>
<li>给它们贡献过代码吗?（我：没有&hellip;）</li>
<li>时序型数据库的存储结构是怎么样的?

<ul>
<li>讲了 prometheus 1.x 和 2.x 的存储结构</li>
</ul></li>
<li>LSM 树了解吗? 是一种什么存储结构?

<ul>
<li>Log-Structured Merge Tree，牺牲读性能换取性能，RocksDB、HBase、Cassandra 都在用，结构有点忘了，只说了先写 memtable 再刷盘成 sstable</li>
</ul></li>
<li>在生产中用过 Cassandra 和 RocksDB 吗? 量有多大?

<ul>
<li>用过，Cassandra 存调用链，RocksDB 做 flink 和 Kafka Stream 的本地状态存储</li>
</ul></li>
<li>Cassandra 的墓碑机制是什么?

<ul>
<li>不知道，对 Cassandra 停留在使用阶段</li>
</ul></li>
</ul>

<p>二面问了好多中间件的基础知识，最后都没有时间问算法了。面完之后心里就想：头条的面试真是耿直啊，Java 的 HashMap、锁机制、CAS 到 MySQL 的索引，Redis 的 zset，再到 LSM 树，全都是后端或中间件相关的热门面试题。当然这些问题热门也是有原因的，即使候选人准备过，多扣一点细节也能很快就能看出来候选人是真的理解还是仅仅只是看了相关资料。</p>

<h2 id="三面-2">三面</h2>

<ul>
<li>聊项目和工作经验</li>
<li>用 Kubernetes 的过程中踩过哪些坑?</li>
<li>考虑一个业务场景: 头条的文章的评论量非常大, 比如说一篇热门文章就有几百万的评论, 设计一个后端服务, 实现评论的时序展示与分页

<ul>
<li>我: 需不需要支持页码直接跳转?</li>
<li>面试官: 支持和不支持两种场景都考虑一下</li>
<li>我: 不需要支持页码翻页就传评论 id 用 offset 翻页</li>
</ul></li>
<li>假如用 id 翻页的方式, 数据库表如何设计? 索引如何设计?

<ul>
<li>(文章id, 评论id) 建联合索引，评论 id 需递增</li>
</ul></li>
<li>假如量很大, 你觉得需要分库分表吗? 怎么分?

<ul>
<li>需要分，分表有个权衡，按文章 id 分表，读逻辑简单，但写有热点问题；按评论 id 分表，读逻辑复杂，但写压力就平均了。写是要首先保证的，而读总是有缓存等方案来折中，因此按评论 id 分表好。</li>
</ul></li>
<li>分库分表后怎么查询分页?

<ul>
<li>每张表查 N 条数据由 client 或 proxy merge</li>
</ul></li>
<li>分库分表后怎么保证主键仍然是递增的?

<ul>
<li>讲了 TDDL 的办法：有一张专门用于分配主键的表，每次用乐观锁的方式尝试去取一批主键过来分配，假如乐观锁失败就重试</li>
</ul></li>
<li>现在需要支持深分页, 页码直接跳转, 怎么实现?

<ul>
<li>不能做精准深分页，否则压力太大，找产品进行妥协，在50或100页后数据分页是否可以不完全精确，假如可以，那么缓存深页码的起始评论 id</li>
</ul></li>
<li>瞬时写入量很大可能会打挂存储, 怎么保护?

<ul>
<li>断路器</li>
</ul></li>
<li>断路器内部怎么实现的?

<ul>
<li>可以用 ringbuffer</li>
</ul></li>
<li>断路器会造成写入失败, 假如我们不允许写入失败呢?

<ul>
<li>先写进消息队列，削峰填谷异步落库</li>
</ul></li>
<li>算法题: N 场演唱会, 以 [{startTime, endTime}…] 的形式给出, 计算出最多能听几场演唱会

<ul>
<li>先讲了思路, 按 endTime 升序排列，再顺序取最多场次</li>
</ul></li>
<li>(讲完思路之后)屏幕共享给我, 用你最熟悉的语言把这个算法实现

<ul>
<li>用 go 实现了一版</li>
</ul></li>
<li>你用了贪心法, 贪心可能会存在什么问题?

<ul>
<li>局部最优，在这个问题里，只能找到一个可能解，无法找到所有排列方式</li>
</ul></li>
</ul>

<p>我觉得三面这个架构设计问得还不错，一个问题把后端的工程能力考的很全面了。</p>

<h2 id="hr-面">HR 面</h2>

<p>大同小异，问经历，问离职原因，问职业规划，问待遇，问期望。</p>

<h2 id="小结-1">小结</h2>

<ul>
<li>面试难度：正常</li>
<li>面试体验：挺好</li>
<li>问题偏向：架构设计，算法</li>
</ul>

<p>头条面试流程很专业：每轮都会提前约好时间，面试时长都在40~50分钟，按时开始面，每轮之后发反馈短信邀请候选人评价面试，精准地过两天再约下一轮。整个像一台精密运作的机器。头条的面试我个人挺欣赏的，考察得比较全面，面试官会抓住你没有说清楚的地方来深入或者变换场景让你应变，大家可以试试看去面一下，即使不打算去也可以作为一次免费的能力评定。</p>

<p>再说说面试官，每位面试官都听得出来是在一线写代码的，而且很认真地在听我说话（这当中有视频的功劳，我可以看到面试官在认真听），感觉工作中也都会是好相处好合作的类型。</p>

<h1 id="总结">总结</h1>

<p>回头看面试的过程，有好多不尽如人意的地方，不过最后能够拿到三家的 offer 还是很幸运。最后再做一些补充性的小结：</p>

<p>一些经验：</p>

<ul>
<li><strong>简历里写了的项目，以及熟练程度在&rdquo;掌握&rdquo;以上的领域与中间件要好好准备</strong>，当面试官问你一个偏门的问题时，他内心其实也没希望你能答上来。而当面试官问你简历上涉及的问题时，假如你答不上来，那面试官就觉得这个人要么是眼界太低，会了一点就觉得自己掌握了，要么是简历造假在胡吹，这两种都非常不利；</li>
<li>在上一条的基础上，<strong>可以准备一个最得意的项目</strong>，在简历上和面试过程中引导面试官往这块聊；</li>
<li>面试前心里可以准备一个方法论：<strong>明确面试官想招怎样的人有哪些特质，在面试过程中努力表现出这些特质</strong>。这听起来是句正确的废话，但面试的过程不可控因素太多，有一个清晰的目标在脑子里能帮你在手足无措时想到说什么。举个例子，有一轮中面试官问我有什么问题时，我就问贵司的对应岗位会面临哪些技术挑战（当然要先说清楚这不是在质疑他们没有挑战，只是自己渴望挑战）；</li>
</ul>

<p>一些各领域的资料与心得：</p>

<ul>
<li><a href="https://github.com/donnemartin/system-design-primer">System Design Primer</a>，入门架构设计必看的一篇资料。看完之后提醒自己始终记得：架构设计的本质是深入理解业务场景之后用工程经验做出最佳权衡。面试时的一个套路是先提纲挈领地把<strong>舍弃什么来换取什么</strong>讲明白；</li>
<li>云原生相关，<a href="https://kubernetes.io/docs/concepts/overview/what-is-kubernetes/">Kubernetes Concepts</a> 部分建议再看一遍，源码部分推荐看 apiserver 中的 CRD 部分与 aggregation layer、kubelet 的 pod 状态同步、scheduler 的调度部分以及<a href="https://github.com/kubernetes/sample-controller">Sample Controller</a> 如何写一个自己的 controller</li>
<li>语言方面，推荐看书《Effective Go》《Effective Java》，都很薄。这两本书我是以前看的，面试前没有专门准备语言相关；</li>
<li>算法相关，这部分我纯鶸，说实话我觉得大学里那本教材《数据结构与算法分析》就写得很不错&hellip;至于 leetcode，面试前没有刷过，最近为了练习 Rust 刷了60多题，并没有碰到面试里出现过的题目，看起来要刷 leetcode 的话就得走量多刷点，刷的少纯拼强运了；</li>
<li><a href="https://studygolang.com/articles/9701">Golang for range 的坑</a> 有两轮面试都涉及到了这个话题，这里贴一下；</li>
</ul>

<p>到这里就全写完了, 你可以直接通过我的邮箱或在评论区留言交流, 感谢您耐心地看完全文!</p>
]]></content>
		</item>
		
		<item>
			<title>云原生下的日志新玩法: Grafana loki 源码解析</title>
			<link>https://www.aleiwu.com/post/grafana-loki/</link>
			<pubDate>Fri, 14 Dec 2018 22:52:03 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/grafana-loki/</guid>
			<description>注意: loki 项目现在还处于早期阶段, 下面的内容可能会很快过时 Grafana loki 简介 在刚刚过去的 KubeCon 北美站上, Grafana 发布了名为 &amp;ldquo;loki&amp;rdquo; 的新项目, 用于解决云原生架构下的日志收</description>
			<content type="html"><![CDATA[

<blockquote>
<p>注意: loki 项目现在还处于早期阶段, 下面的内容可能会很快过时</p>
</blockquote>

<h1 id="grafana-loki-简介">Grafana loki 简介</h1>

<p>在刚刚过去的 KubeCon 北美站上, Grafana 发布了名为 &ldquo;loki&rdquo; 的新项目, 用于解决云原生架构下的日志收集与存储问题. loki 受 Prometheus 影响很深, 它的 <a href="https://grafana.com/loki">landing page</a> 上的标题是 <code>Loki. Prometheus-inspired logging for cloud natives.</code>, <a href="https://github.com/grafana/loki">github 主页</a> 上的简介则是 <code>Like Prometheus, but for logs.</code>.</p>

<p>目前 Grafana 已经发布了很详尽的 loki 相关资料, 包括:</p>

<ul>
<li><a href="https://speakerdeck.com/davkal/on-the-path-to-full-observability-with-oss-and-launch-of-loki">KubeCon 的 Slides</a></li>
<li><a href="https://docs.google.com/document/d/11tjK_lvp1-SVsFZjgOTr1vV3-q6vBAsZYIQ5ZeYBkyM/view">design doc</a></li>
<li><a href="https://grafana.com/blog/2018/12/12/loki-prometheus-inspired-open-source-logging-for-cloud-natives/">一篇介绍性的 Blog</a></li>
<li><a href="https://github.com/grafana/loki#getting-started">Github 主页上的 Getting Started</a></li>
</ul>

<p>这些资料已经把 loki 的设计意图,架构,用法都讲得很清楚了, 假如你时间有限, 那么读一下 blog 就基本了解 loki 的全貌了. 下面就不再重复这些低信息量的内容, 从代码层面来看看 loki 有什么独到之处.</p>

<h1 id="cortex">Cortex</h1>

<p>分析 loki 代码之前, 不得不先提一下 <a href="https://github.com/cortexproject/cortex">cortex</a> 项目. <code>cortex</code> 是一个 Prometheus 的 Remote Backend, 核心价值是为 Prometheus 添加了水平扩展和(廉价的)指标长期存储能力. cortex 的完整设计可以看看它的<a href="https://docs.google.com/document/d/1C7yhMnb1x2sfeoe45f4mnnKConvroWhJ8KQZwIHJOuw/edit#heading=h.nimsq29kl184">设计白皮书</a>, 这里摘几个要点:</p>

<ul>
<li>扩展: 读写分离, 写入端分两层, 第一层 <code>distributor</code> 做一致性哈希, 将负载分发到第二层 <code>ingester</code> 上, <code>ingester</code> 在内存中缓存 Metrics 数据, 异步写入 Storage Backend</li>
<li>易于维护: 所有节点无状态, 随时可以迁移扩展</li>
<li>成本: 以 <code>chunk</code> 作为基本存储对象, 可以用廉价的对象存储(比如 S3)来作为 Storage Backend</li>
</ul>

<p>loki 和 <code>cortex</code> 的作者都是 <a href="https://github.com/tomwilkie">tomwilkie</a>, loki 也完全沿用了 <code>cortex</code> 的<code>distributor</code>, <code>ingester</code>, <code>querier</code>, <code>chunk</code> 这一套.</p>

<h1 id="loki-overview">Loki Overview</h1>

<p>在 <code>cortex</code> 体系中, Prometheus 只是一个 Metrics 采集器: 收集指标, 然后扔给 <code>cortex</code>. 我们只要把 Prometheus 这个组件替换成采集日志的 <code>Promtail</code>, 就(差不多)得到了 loki:</p>

<p><img src="/img/loki/loki-arch.png" alt="loki" /></p>

<p>这个架构图与 <a href="https://github.com/cortexproject/cortex/blob/master/docs/architecture.md">cortex 的架构图</a> 相差无几. 唯一不同的是 Prometheus 可以部署在一个远端集群中, 而 <code>Promtail</code> 必须部署到所有需要日志收集的 Node 上去.</p>

<p>目前 loki 的运行模式还是 <code>All in One</code> 的, 即<code>distributor</code>, <code>querier</code>, <code>ingester</code>这些组件全都跑在 loki 主进程里. 不过这些组件之间的交互全都通过 gRPC 完成, 因此只要稍加改造就能作为一个分布式系统来跑.</p>

<h1 id="promtail-日志采集">Promtail 日志采集</h1>

<p><code>promtail</code> 可以理解为采集日志的 &ldquo;Prometheus&rdquo;. 它最巧妙的设计是完全复用了 Prometheus 的服务发现机制与 label 机制.</p>

<p>以 Kubernetes 服务发现为例, Prometheus 可以通过 <code>Pod</code> 的 <code>Annotations</code> 与 <code>Labels</code> 等信息来确定 <code>Pod</code> 是否需要抓取指标, 假如要的话 <code>Pod</code> 的指标暴露在哪个端口上, 以及这个 <code>Pod</code> 本身有哪些 label, 即 target label.</p>

<p>确定了这些信息之后, Prometheus 就可以去拉应用的指标了. 同时, 这些指标都会被打上 target label, 用于标注指标的来源. 等到在查询的时候, 我们就可以通过 target label, 比方说 <code>pod_name=foo-123512</code> 或 <code>service=user-service</code> 来获取特定的一个或一组 <code>Pod</code> 上的指标信息.</p>

<p><code>promtail</code> 是一样的道理. 它也是通过 <code>Pod</code> 的一些元信息来确定该 <code>Pod</code> 的日志文件位置, 同时为日志打上特定的 target label. 但要注意, 这个 label 不是标注在每一行日志事件上的, 而是被标注在&rdquo;整个日志&rdquo;上的. 这里&rdquo;整个日志&rdquo;在 loki 中抽象为 <code>stream</code>(日志流). 这就是 loki 文档中所说的&rdquo;不索引日志, 只索引日志流&rdquo;. 最终在查询端, 我们通过这些 label 就可以快速查询一个或一组特定的 <code>stream</code>.</p>

<p>服务发现部分的代码非常直白, 可以去 <code>pkg/promtail/targetmanager.go</code> 中自己看一下, 提两个实现细节:</p>

<ul>
<li><code>promtail</code> 要求所有 target 都跟自己属于同一个 node, 处于其它 node 上的 target 会被忽略;</li>
<li><code>promtail</code> 使用 target 的 <code>__path__</code> label 来确定日志路径;</li>
</ul>

<p>通过服务发现确定要收集的应用以及应用的日志路径后, <code>promtail</code> 就开始了真正的日志收集过程. 这里分三步:</p>

<ol>
<li>用 <code>fsnotify</code> 监听对应目录下的文件创建与删除(处理 log rolling)</li>
<li>对每个活跃的日志文件起一个 goroutine 进行类似 <code>tail -f</code> 的读取, 读取到的内容发送给 <code>channel</code></li>
<li>一个单独的 goroutine 会解析 <code>channel</code> 中的日志行, 分批发送给 loki 的 backend</li>
</ol>

<h2 id="日志采集分析">日志采集分析</h2>

<p>首先是 <code>fsnotify</code>(源码里的一些错误处理会简略掉)</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="k">for</span> <span class="p">{</span>
    <span class="k">select</span> <span class="p">{</span>
    <span class="k">case</span> <span class="nx">event</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">t</span><span class="p">.</span><span class="nx">watcher</span><span class="p">.</span><span class="nx">Events</span><span class="p">:</span>
        <span class="k">switch</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Op</span> <span class="p">{</span>
        <span class="k">case</span> <span class="nx">fsnotify</span><span class="p">.</span><span class="nx">Create</span><span class="p">:</span>
            <span class="c1">// protect against double Creates.
</span><span class="c1"></span>            <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">t</span><span class="p">.</span><span class="nx">tails</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">];</span> <span class="nx">ok</span> <span class="p">{</span>
                <span class="nx">level</span><span class="p">.</span><span class="nf">Info</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">logger</span><span class="p">).</span><span class="nf">Log</span><span class="p">(</span><span class="s">&#34;msg&#34;</span><span class="p">,</span> <span class="s">&#34;got &#39;create&#39; for existing file&#34;</span><span class="p">,</span> <span class="s">&#34;filename&#34;</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span>
                <span class="k">continue</span>
            <span class="p">}</span>

            <span class="c1">// newTailer 中会启动一个 goroutine 来读目标文件
</span><span class="c1"></span>            <span class="nx">tailer</span> <span class="o">:=</span> <span class="nf">newTailer</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">logger</span><span class="p">,</span> <span class="nx">t</span><span class="p">.</span><span class="nx">handler</span><span class="p">,</span> <span class="nx">t</span><span class="p">.</span><span class="nx">positions</span><span class="p">,</span> <span class="nx">t</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span>
            <span class="nx">t</span><span class="p">.</span><span class="nx">tails</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">]</span> <span class="p">=</span> <span class="nx">tailer</span>
            
        <span class="k">case</span> <span class="nx">fsnotify</span><span class="p">.</span><span class="nx">Remove</span><span class="p">:</span>
            <span class="nx">tailer</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">t</span><span class="p">.</span><span class="nx">tails</span><span class="p">[</span><span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">]</span>
            <span class="k">if</span> <span class="nx">ok</span> <span class="p">{</span>
                <span class="c1">// 关闭 tailer
</span><span class="c1"></span>                <span class="nx">helpers</span><span class="p">.</span><span class="nf">LogError</span><span class="p">(</span><span class="s">&#34;stopping tailer&#34;</span><span class="p">,</span> <span class="nx">tailer</span><span class="p">.</span><span class="nx">stop</span><span class="p">)</span>
                <span class="nb">delete</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">tails</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">Name</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="k">case</span> <span class="nx">err</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">t</span><span class="p">.</span><span class="nx">watcher</span><span class="p">.</span><span class="nx">Errors</span><span class="p">:</span>
        <span class="nx">level</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">logger</span><span class="p">).</span><span class="nf">Log</span><span class="p">(</span><span class="s">&#34;msg&#34;</span><span class="p">,</span> <span class="s">&#34;error from fswatch&#34;</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">t</span><span class="p">.</span><span class="nx">quit</span><span class="p">:</span>
        <span class="k">return</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></div>
<p>接下来是 <code>newTailer()</code> 这个方法中启动的日志文件读取逻辑:</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="kd">func</span> <span class="nf">newTailer</span><span class="p">()</span> <span class="p">{</span>
    <span class="nx">tail</span> <span class="o">:=</span> <span class="nx">tail</span><span class="p">.</span><span class="nf">TailFile</span><span class="p">(</span><span class="nx">path</span><span class="p">,</span> <span class="nx">tail</span><span class="p">.</span><span class="nx">Config</span><span class="p">{</span>
        <span class="nx">Follow</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
        <span class="nx">Location</span><span class="p">:</span> <span class="o">&amp;</span><span class="nx">tail</span><span class="p">.</span><span class="nx">SeekInfo</span><span class="p">{</span>
            <span class="nx">Offset</span><span class="p">:</span> <span class="nx">positions</span><span class="p">.</span><span class="nf">Get</span><span class="p">(</span><span class="nx">path</span><span class="p">),</span>
            <span class="nx">Whence</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
        <span class="p">},</span>
    <span class="p">})</span>
   
    <span class="nx">tailer</span> <span class="o">:=</span> <span class="o">...</span>
    <span class="k">go</span> <span class="nx">tailer</span><span class="p">.</span><span class="nf">run</span><span class="p">()</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">tailer</span><span class="p">)</span> <span class="nf">run</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">{</span>
        <span class="k">select</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">positionWait</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
            <span class="c1">// 定时同步当前读取位置
</span><span class="c1"></span>            <span class="nx">pos</span> <span class="o">:=</span> <span class="nx">t</span><span class="p">.</span><span class="nx">tail</span><span class="p">.</span><span class="nf">Tell</span><span class="p">()</span>
            <span class="nx">t</span><span class="p">.</span><span class="nx">positions</span><span class="p">.</span><span class="nf">Put</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">path</span><span class="p">,</span> <span class="nx">pos</span><span class="p">)</span>
    
        <span class="k">case</span> <span class="nx">line</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">t</span><span class="p">.</span><span class="nx">tail</span><span class="p">.</span><span class="nx">Lines</span><span class="p">:</span>
            <span class="c1">// handler.Handle() 中是一些日志行的预处理逻辑, 最后将日志行转化为 `Entry` 对象扔进 channel
</span><span class="c1"></span>            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">t</span><span class="p">.</span><span class="nx">handler</span><span class="p">.</span><span class="nf">Handle</span><span class="p">(</span><span class="nx">model</span><span class="p">.</span><span class="nx">LabelSet</span><span class="p">{},</span> <span class="nx">line</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">line</span><span class="p">.</span><span class="nx">Text</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
                <span class="nx">level</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">t</span><span class="p">.</span><span class="nx">logger</span><span class="p">).</span><span class="nf">Log</span><span class="p">(</span><span class="s">&#34;msg&#34;</span><span class="p">,</span> <span class="s">&#34;error handling line&#34;</span><span class="p">,</span> <span class="s">&#34;error&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></div>
<p>这里直接调用了 <code>hpcloud/tail</code> 这个包来完成文件的 tail 操作. <code>hpcloud/tail</code> 的内部实现中, 在读到 EOF 之后, 同样调用了 <code>fsnotify</code> 来获取新内容写入的通知. <code>fsnotify</code> 这个包内部则是依赖了 <code>inotify_init</code> 和 <code>inotify_add_watch</code> 这两个系统调用, 可以参考<a href="http://man7.org/linux/man-pages/man7/inotify.7.html">inotify</a>.</p>

<p>最后是日志发送, 这里有一个单独的 goroutine 会读取所有 tailer 通过 channel 传过来的日志(<code>Entry</code>对象), 然后按批发送给 loki:</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="k">for</span> <span class="p">{</span>
    <span class="c1">// 每次发送之后要重置计时器
</span><span class="c1"></span>    <span class="nx">maxWait</span><span class="p">.</span><span class="nf">Reset</span><span class="p">(</span><span class="nx">c</span><span class="p">.</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">BatchWait</span><span class="p">)</span>
    <span class="k">select</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">quit</span><span class="p">:</span>
        <span class="k">return</span>
    <span class="k">case</span> <span class="nx">e</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">c</span><span class="p">.</span><span class="nx">entries</span><span class="p">:</span>
        <span class="c1">// Batch 足够大之后, 执行发送逻辑
</span><span class="c1"></span>        <span class="k">if</span> <span class="nx">batchSize</span><span class="o">+</span><span class="nb">len</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">Line</span><span class="p">)</span> <span class="p">&gt;</span> <span class="nx">c</span><span class="p">.</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">BatchSize</span> <span class="p">{</span>
            <span class="nx">c</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">batch</span><span class="p">)</span>
            <span class="c1">// 重置 Batch
</span><span class="c1"></span>            <span class="nx">batchSize</span> <span class="p">=</span> <span class="mi">0</span>
            <span class="nx">batch</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="nx">model</span><span class="p">.</span><span class="nx">Fingerprint</span><span class="p">]</span><span class="o">*</span><span class="nx">logproto</span><span class="p">.</span><span class="nx">Stream</span><span class="p">{}</span>
        <span class="p">}</span>

        <span class="c1">// 收到 Entry, 先写进 Batch 当中
</span><span class="c1"></span>        <span class="nx">batchSize</span> <span class="o">+=</span> <span class="nb">len</span><span class="p">(</span><span class="nx">e</span><span class="p">.</span><span class="nx">Line</span><span class="p">)</span>
        
        <span class="c1">// 每个 entry 要根据 label 放进对应的日志流(Stream)中
</span><span class="c1"></span>        <span class="nx">fp</span> <span class="o">:=</span> <span class="nx">e</span><span class="p">.</span><span class="nx">labels</span><span class="p">.</span><span class="nf">FastFingerprint</span><span class="p">()</span>
        <span class="nx">stream</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">batch</span><span class="p">[</span><span class="nx">fp</span><span class="p">]</span>
        <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
            <span class="nx">stream</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">logproto</span><span class="p">.</span><span class="nx">Stream</span><span class="p">{</span>
                <span class="nx">Labels</span><span class="p">:</span> <span class="nx">e</span><span class="p">.</span><span class="nx">labels</span><span class="p">.</span><span class="nf">String</span><span class="p">(),</span>
            <span class="p">}</span>
            <span class="nx">batch</span><span class="p">[</span><span class="nx">fp</span><span class="p">]</span> <span class="p">=</span> <span class="nx">stream</span>
        <span class="p">}</span>
        <span class="nx">stream</span><span class="p">.</span><span class="nx">Entries</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">stream</span><span class="p">.</span><span class="nx">Entries</span><span class="p">,</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Entry</span><span class="p">)</span>
        
    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">maxWait</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
        <span class="c1">// 到达每个批次的最大等待时间, 同样执行发送
</span><span class="c1"></span>        <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">batch</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
            <span class="nx">c</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">batch</span><span class="p">);</span>
            <span class="nx">batchSize</span> <span class="p">=</span> <span class="mi">0</span>
            <span class="nx">batch</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="nx">model</span><span class="p">.</span><span class="nx">Fingerprint</span><span class="p">]</span><span class="o">*</span><span class="nx">logproto</span><span class="p">.</span><span class="nx">Stream</span><span class="p">{}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></div>
<p>这段代码中出现了 <code>Entry</code>(一条日志) 的 label, 看上去好像和一开始说的 &ldquo;loki只索引日志流&rdquo; 自相矛盾. 但其实这里只是代码上的实现细节, <code>Entry</code> 的 label 完全来自于服务发现, 最后发送时, label 也只是用于标识 <code>Stream</code>, 与上层抽象完全符合.</p>

<p>另外, 用 <code>channel</code> + <code>select</code> 写 batch 逻辑真的挺优雅, 简单易读.</p>

<h2 id="一些问题">一些问题</h2>

<p>目前 <code>promtail</code> 的代码还完全不到 production-ready, 它的本地没有 buffer, 并且没有处理 back pressure. 假设 loki 的流量太大处理不过来了, 那么 <code>promtail</code> 日志发送失败或超时直接就会丢日志. 同时, 文件读取位置, LAG(当前行数和文件最新行数的距离) 这些关键的监控指标都没暴露出来, 这是一个提 PR 的好时机.</p>

<h1 id="loki-backend">Loki Backend</h1>

<p>接下来是存储端, 这一部分在<a href="https://grafana.com/blog/2018/12/12/loki-prometheus-inspired-open-source-logging-for-cloud-natives/">官方博客</a>中就已经说得比较多了, 而且图很好看, 我也不拾人牙慧了. 挑几个重点看一下:</p>

<h2 id="distributor">distributor</h2>

<p><code>distributor</code> 直接接收来自 <code>promtail</code> 的日志写入请求, 请求体由 protobuf 编码, 格式如下:</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="c1">// 一次写入请求, 包含多段日志流
</span><span class="c1"></span><span class="kd">type</span> <span class="nx">PushRequest</span> <span class="kd">struct</span> <span class="p">{</span>
    <span class="nx">Streams</span> <span class="p">[]</span><span class="o">*</span><span class="nx">Stream</span> <span class="s">`protobuf:&#34;bytes,1,rep,name=streams&#34; json:&#34;streams,omitempty&#34;`</span>
<span class="p">}</span>
<span class="c1">// 一段日志流, 包含它的 label, 以及这段日志流当中的每个日志事件: Entry
</span><span class="c1"></span><span class="kd">type</span> <span class="nx">Stream</span> <span class="kd">struct</span> <span class="p">{</span>
    <span class="nx">Labels</span>  <span class="kt">string</span>  <span class="s">`protobuf:&#34;bytes,1,opt,name=labels,proto3&#34; json:&#34;labels,omitempty&#34;`</span>
    <span class="nx">Entries</span> <span class="p">[]</span><span class="nx">Entry</span> <span class="s">`protobuf:&#34;bytes,2,rep,name=entries&#34; json:&#34;entries&#34;`</span>
<span class="p">}</span>
<span class="c1">// 一个日志事件, 包含时间戳与内容
</span><span class="c1"></span><span class="kd">type</span> <span class="nx">Entry</span> <span class="kd">struct</span> <span class="p">{</span>
    <span class="nx">Timestamp</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`protobuf:&#34;bytes,1,opt,name=timestamp,stdtime&#34; json:&#34;timestamp&#34;`</span>
    <span class="nx">Line</span>      <span class="kt">string</span>    <span class="s">`protobuf:&#34;bytes,2,opt,name=line,proto3&#34; json:&#34;line,omitempty&#34;`</span>
<span class="p">}</span></code></pre></div>
<p><code>distributor</code> 收到请求后, 会将一个 <code>PushRequest</code> 中的 <code>Stream</code> 根据 labels 拆分成多个 <code>PushRequest</code>, 这个过程使用一致性哈希:</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="nx">streams</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="nx">streamTracker</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Streams</span><span class="p">))</span>
<span class="nx">keys</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="kt">uint32</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Streams</span><span class="p">))</span>
<span class="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">stream</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Streams</span> <span class="p">{</span>
    <span class="c1">// 获取每个 stream 的 label hash
</span><span class="c1"></span>    <span class="nx">keys</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">keys</span><span class="p">,</span> <span class="nf">tokenFor</span><span class="p">(</span><span class="nx">userID</span><span class="p">,</span> <span class="nx">stream</span><span class="p">.</span><span class="nx">Labels</span><span class="p">))</span>
    <span class="nx">streams</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nx">stream</span> <span class="p">=</span> <span class="nx">stream</span>
<span class="p">}</span>

<span class="c1">// 根据 label hash 到 hash ring 上获取对应的 ingester 节点
</span><span class="c1">// 这里的节点指 hash ring 上的节点, 一个节点可能有多个对等的 ingester 副本来做 HA
</span><span class="c1"></span><span class="nx">replicationSets</span> <span class="o">:=</span> <span class="nx">d</span><span class="p">.</span><span class="nx">ring</span><span class="p">.</span><span class="nf">BatchGet</span><span class="p">(</span><span class="nx">keys</span><span class="p">,</span> <span class="nx">ring</span><span class="p">.</span><span class="nx">Write</span><span class="p">)</span>

<span class="c1">// 将 Stream 按对应的 ingester 节点进行分组
</span><span class="c1"></span><span class="nx">samplesByIngester</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">][]</span><span class="o">*</span><span class="nx">streamTracker</span><span class="p">{}</span>
<span class="nx">ingesterDescs</span> <span class="o">:=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">ring</span><span class="p">.</span><span class="nx">IngesterDesc</span><span class="p">{}</span>
<span class="k">for</span> <span class="nx">i</span><span class="p">,</span> <span class="nx">replicationSet</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">replicationSets</span> <span class="p">{</span>
    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">ingester</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">replicationSet</span><span class="p">.</span><span class="nx">Ingesters</span> <span class="p">{</span>
        <span class="nx">samplesByIngester</span><span class="p">[</span><span class="nx">ingester</span><span class="p">.</span><span class="nx">Addr</span><span class="p">]</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">samplesByIngester</span><span class="p">[</span><span class="nx">ingester</span><span class="p">.</span><span class="nx">Addr</span><span class="p">],</span> <span class="o">&amp;</span><span class="nx">streams</span><span class="p">[</span><span class="nx">i</span><span class="p">])</span>
        <span class="nx">ingesterDescs</span><span class="p">[</span><span class="nx">ingester</span><span class="p">.</span><span class="nx">Addr</span><span class="p">]</span> <span class="p">=</span> <span class="nx">ingester</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">for</span> <span class="nx">ingester</span><span class="p">,</span> <span class="nx">samples</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">samplesByIngester</span> <span class="p">{</span>
    <span class="c1">// 每组 Stream[] 又作为一个 PushRequest, 下发给对应的 ingester 节点
</span><span class="c1"></span>    <span class="nx">d</span><span class="p">.</span><span class="nf">sendSamples</span><span class="p">(</span><span class="nx">localCtx</span><span class="p">,</span> <span class="nx">ingester</span><span class="p">,</span> <span class="nx">samples</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">tracker</span><span class="p">)</span>
<span class="p">}</span></code></pre></div>
<p>在 <code>All in One</code> 的运行模式中, hash ring 直接存储在内存中. 在生产环境, 由于要起多个 <code>distributor</code> 节点做高可用, 这个 hash ring 会存储到外部的 Consul 集群中.</p>

<h2 id="ingester">ingester</h2>

<p><code>ingester</code> 接收 <code>distributor</code> 下发的 <code>PushRequest</code>, 也就是多段日志流(<code>[]Entry</code>). 在 <code>ingester</code> 内部会先将收到的 <code>[]Entry</code> Append 到内存中的 Chunk 流(<code>[]Chunk</code>). 同时会有一组 <code>goroutine</code> 异步将 Chunk 流存储到对象存储当中:</p>

<p><img src="/img/loki/loki-ingester.png" alt="loki-ingester" /></p>

<p>第一个 Append 过程很关键(代码有简化):</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="kd">func</span> <span class="p">(</span><span class="nx">i</span> <span class="o">*</span><span class="nx">instance</span><span class="p">)</span> <span class="nf">Push</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">req</span> <span class="o">*</span><span class="nx">logproto</span><span class="p">.</span><span class="nx">PushRequest</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">s</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">req</span><span class="p">.</span><span class="nx">Streams</span> <span class="p">{</span>
        <span class="c1">// 将收到的日志流 Append 到内存中的日志流上, 同样地, 日志流按 label hash 索引
</span><span class="c1"></span>        <span class="nx">fp</span> <span class="o">:=</span> <span class="nx">client</span><span class="p">.</span><span class="nf">FastFingerprint</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">labels</span><span class="p">)</span>
        <span class="nx">stream</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">i</span><span class="p">.</span><span class="nx">streams</span><span class="p">[</span><span class="nx">fp</span><span class="p">]</span>
        <span class="k">if</span> <span class="p">!</span><span class="nx">ok</span> <span class="p">{</span>
            <span class="nx">stream</span> <span class="p">=</span> <span class="nf">newStream</span><span class="p">(</span><span class="nx">fp</span><span class="p">,</span> <span class="nx">req</span><span class="p">.</span><span class="nx">labels</span><span class="p">)</span>
            <span class="c1">// 这个过程中, 还会维护日志流的倒排索引(label -&gt; stream)
</span><span class="c1"></span>            <span class="nx">i</span><span class="p">.</span><span class="nx">index</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="nx">labels</span><span class="p">,</span> <span class="nx">fp</span><span class="p">)</span>
            <span class="nx">i</span><span class="p">.</span><span class="nx">streams</span><span class="p">[</span><span class="nx">fp</span><span class="p">]</span> <span class="p">=</span> <span class="nx">stream</span>
        <span class="p">}</span>
        <span class="nx">stream</span><span class="p">.</span><span class="nf">Push</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">s</span><span class="p">.</span><span class="nx">Entries</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kc">nil</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="p">(</span><span class="nx">s</span> <span class="o">*</span><span class="nx">stream</span><span class="p">)</span> <span class="nf">Push</span><span class="p">(</span><span class="nx">_</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">entries</span> <span class="p">[]</span><span class="nx">logproto</span><span class="p">.</span><span class="nx">Entry</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">entries</span> <span class="p">{</span>
        <span class="c1">// 假如当前 Chunk 已经关闭或者已经到达设定的最大 Chunk 大小, 则再创建一个新的 Chunk
</span><span class="c1"></span>        <span class="k">if</span> <span class="nx">s</span><span class="p">.</span><span class="nx">chunks</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">closed</span> <span class="o">||</span> <span class="p">!</span><span class="nx">s</span><span class="p">.</span><span class="nx">chunks</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">chunk</span><span class="p">.</span><span class="nf">SpaceFor</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">entries</span><span class="p">[</span><span class="nx">i</span><span class="p">])</span> <span class="p">{</span>
            <span class="nx">s</span><span class="p">.</span><span class="nx">chunks</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">chunks</span><span class="p">,</span> <span class="nx">chunkDesc</span><span class="p">{</span>
                <span class="nx">chunk</span><span class="p">:</span> <span class="nx">chunkenc</span><span class="p">.</span><span class="nf">NewMemChunk</span><span class="p">(</span><span class="nx">chunkenc</span><span class="p">.</span><span class="nx">EncGZIP</span><span class="p">),</span>
            <span class="p">})</span>
        <span class="p">}</span>
        <span class="nx">s</span><span class="p">.</span><span class="nx">chunks</span><span class="p">[</span><span class="nb">len</span><span class="p">(</span><span class="nx">s</span><span class="p">.</span><span class="nx">chunks</span><span class="p">)</span><span class="o">-</span><span class="mi">1</span><span class="p">].</span><span class="nx">chunk</span><span class="p">.</span><span class="nf">Append</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">entries</span><span class="p">[</span><span class="nx">i</span><span class="p">])</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kc">nil</span>
<span class="p">}</span></code></pre></div>
<p><code>Chunk</code> 其实就是多条日志构成的压缩包. 将日志压成 <code>Chunk</code> 的意义是可以直接存入对象存储, 而对象存储是最便宜的(便宜是 loki 的核心目标之一). 在 一个 <code>Chunk</code> 到达指定大小之前它就是 open 的, 会不断 Append 新的日志(<code>Entry</code>) 到里面. 而在达到大小之后, <code>Chunk</code> 就会关闭等待持久化(强制持久化也会关闭 Chunk, 比如关闭 <code>ingester</code> 实例时就会关闭所有的 Chunk并持久化).</p>

<p>对 Chunk 的大小控制是一个调优要点:</p>

<ul>
<li>假如 Chunk 容量过小: 首先是导致压缩效率不高. 同时也会增加整体的 Chunk 数量, 导致倒排索引过大. 最后, 对象存储的操作次数也会变多, 带来额外的性能开销;</li>
<li>假如 Chunk 过大: 一个 Chunk 的 open 时间会更长, 占用额外的内存空间, 同时, 也增加了丢数据的风险. 最后, Chunk 过大也会导致查询读放大, 比方说查一小时的数据却要下载整天的 Chunk;</li>
</ul>

<p>丢数据问题: 所有 Chunk 要在 close 之后才会进行存储. 因此假如 ingester 异常宕机, 处于 open 状态的 Chunk, 以及 close 了但还没有来得及持久化的 Chunk 数据都会丢失. 从这个角度来说, ingester 其实也是 stateful 的, 在生产中可以通过给 ingester 跑多个副本来解决这个问题. 另外, <code>ingester</code> 里似乎还没有写 WAL, 这感觉是一个 PR 机会, 可以练习一下写存储的基本功.</p>

<p>异步存储过程就很简单了, 是一个一对多的生产者消费者模型:</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="c1">// 一个 goroutine 将所有的待存储的 chunks enqueue
</span><span class="c1"></span><span class="kd">func</span> <span class="p">(</span><span class="nx">i</span> <span class="o">*</span><span class="nx">Ingester</span><span class="p">)</span> <span class="nf">sweepStream</span><span class="p">(</span><span class="nx">instance</span> <span class="o">*</span><span class="nx">instance</span><span class="p">,</span> <span class="nx">stream</span> <span class="o">*</span><span class="nx">stream</span><span class="p">,</span> <span class="nx">immediate</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>

    <span class="c1">// 有一组待存储的队列(默认16个), 取模找一个队列把要存储的 chunk 的引用塞进去
</span><span class="c1"></span>    <span class="nx">flushQueueIndex</span> <span class="o">:=</span> <span class="nb">int</span><span class="p">(</span><span class="nb">uint64</span><span class="p">(</span><span class="nx">stream</span><span class="p">.</span><span class="nx">fp</span><span class="p">)</span> <span class="o">%</span> <span class="nb">uint64</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">cfg</span><span class="p">.</span><span class="nx">ConcurrentFlushes</span><span class="p">))</span>
    <span class="nx">firstTime</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nx">stream</span><span class="p">.</span><span class="nx">chunks</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">chunk</span><span class="p">.</span><span class="nf">Bounds</span><span class="p">()</span>
    <span class="nx">i</span><span class="p">.</span><span class="nx">flushQueues</span><span class="p">[</span><span class="nx">flushQueueIndex</span><span class="p">].</span><span class="nf">Enqueue</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">flushOp</span><span class="p">{</span>
        <span class="nx">model</span><span class="p">.</span><span class="nf">TimeFromUnixNano</span><span class="p">(</span><span class="nx">firstTime</span><span class="p">.</span><span class="nf">UnixNano</span><span class="p">()),</span> <span class="nx">instance</span><span class="p">.</span><span class="nx">instanceID</span><span class="p">,</span>
        <span class="nx">stream</span><span class="p">.</span><span class="nx">fp</span><span class="p">,</span> <span class="nx">immediate</span><span class="p">,</span>
    <span class="p">})</span>
<span class="p">}</span>

<span class="c1">// 每个队列都有一个 goroutine 作为消费者在 dequeue
</span><span class="c1"></span><span class="kd">func</span> <span class="p">(</span><span class="nx">i</span> <span class="o">*</span><span class="nx">Ingester</span><span class="p">)</span> <span class="nf">flushLoop</span><span class="p">(</span><span class="nx">j</span> <span class="kt">int</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">for</span> <span class="p">{</span>
        <span class="nx">op</span> <span class="o">:=</span> <span class="nx">i</span><span class="p">.</span><span class="nx">flushQueues</span><span class="p">[</span><span class="nx">j</span><span class="p">].</span><span class="nf">Dequeue</span><span class="p">()</span>
        <span class="c1">// 实际的存储操作在这个方法中, 存储完成后, Chunk 会被清理掉
</span><span class="c1"></span>        <span class="nx">i</span><span class="p">.</span><span class="nf">flushUserSeries</span><span class="p">(</span><span class="nx">op</span><span class="p">.</span><span class="nx">userID</span><span class="p">,</span> <span class="nx">op</span><span class="p">.</span><span class="nx">fp</span><span class="p">,</span> <span class="nx">op</span><span class="p">.</span><span class="nx">immediate</span><span class="p">)</span>

        <span class="c1">// 存储失败的 chunk 会重新塞回队列中
</span><span class="c1"></span>        <span class="k">if</span> <span class="nx">op</span><span class="p">.</span><span class="nx">immediate</span> <span class="o">&amp;&amp;</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
            <span class="nx">op</span><span class="p">.</span><span class="nx">from</span> <span class="p">=</span> <span class="nx">op</span><span class="p">.</span><span class="nx">from</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="nx">flushBackoff</span><span class="p">)</span>
            <span class="nx">i</span><span class="p">.</span><span class="nx">flushQueues</span><span class="p">[</span><span class="nx">j</span><span class="p">].</span><span class="nf">Enqueue</span><span class="p">(</span><span class="nx">op</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span></code></pre></div>
<p>最后是清理过程, 同样是一个单独的 goroutine 定时在跑. <code>ingester</code> 里的所有 <code>Chunk</code> 会在持久化之后隔一小段时间才被清理掉. 这个&rdquo;一小段时间&rdquo;由 <code>chunk-retain-time</code> 参数进行控制(默认 15 分钟). 这么做是为了加速热点数据的读取(真正被人看的日志中, 有99%都是生成后的一小段时间内被查看的).</p>

<h2 id="querier">Querier</h2>

<p>最后是 <code>Querier</code>, 这个比较简单了, 大致逻辑就是根据<code>chunk index</code>中的索引信息, 请求 <code>ingester</code> 和对象存储. 合并后返回. 这里主要看一下&rdquo;合并&rdquo;操作:</p>

<blockquote>
<p>这里的代码其实可以作为一个简单的面试题: 假如你的日志按 class 分成了上百个文件, 现在要将它们合并输出(按时间顺序), 你会怎么做?</p>
</blockquote>

<p><code>loki</code> 里用了堆, 时间正序就用最小堆, 时间逆序就用最大堆:</p>
<div class="highlight"><pre class="chroma"><code class="language-go" data-lang="go"><span class="c1">// 这部分代码实现了一个简单的二叉堆, MinHeap 和 MaxHeap 实现了相反的 `Less()` 方法
</span><span class="c1"></span><span class="kd">type</span> <span class="nx">iteratorHeap</span> <span class="p">[]</span><span class="nx">EntryIterator</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">iteratorHeap</span><span class="p">)</span> <span class="nf">Len</span><span class="p">()</span> <span class="kt">int</span>            <span class="p">{</span> <span class="k">return</span> <span class="nb">len</span><span class="p">(</span><span class="nx">h</span><span class="p">)</span> <span class="p">}</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">iteratorHeap</span><span class="p">)</span> <span class="nf">Swap</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">j</span> <span class="kt">int</span><span class="p">)</span>       <span class="p">{</span> <span class="nx">h</span><span class="p">[</span><span class="nx">i</span><span class="p">],</span> <span class="nx">h</span><span class="p">[</span><span class="nx">j</span><span class="p">]</span> <span class="p">=</span> <span class="nx">h</span><span class="p">[</span><span class="nx">j</span><span class="p">],</span> <span class="nx">h</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">}</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">iteratorHeap</span><span class="p">)</span> <span class="nf">Peek</span><span class="p">()</span> <span class="nx">EntryIterator</span> <span class="p">{</span> <span class="k">return</span> <span class="nx">h</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="p">}</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">iteratorHeap</span><span class="p">)</span> <span class="nf">Push</span><span class="p">(</span><span class="nx">x</span> <span class="kd">interface</span><span class="p">{})</span> <span class="p">{</span>
    <span class="o">*</span><span class="nx">h</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="o">*</span><span class="nx">h</span><span class="p">,</span> <span class="nx">x</span><span class="p">.(</span><span class="nx">EntryIterator</span><span class="p">))</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="o">*</span><span class="nx">iteratorHeap</span><span class="p">)</span> <span class="nf">Pop</span><span class="p">()</span> <span class="kd">interface</span><span class="p">{}</span> <span class="p">{</span>
    <span class="nx">old</span> <span class="o">:=</span> <span class="o">*</span><span class="nx">h</span>
    <span class="nx">n</span> <span class="o">:=</span> <span class="nb">len</span><span class="p">(</span><span class="nx">old</span><span class="p">)</span>
    <span class="nx">x</span> <span class="o">:=</span> <span class="nx">old</span><span class="p">[</span><span class="nx">n</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
    <span class="o">*</span><span class="nx">h</span> <span class="p">=</span> <span class="nx">old</span><span class="p">[</span><span class="mi">0</span> <span class="p">:</span> <span class="nx">n</span><span class="o">-</span><span class="mi">1</span><span class="p">]</span>
    <span class="k">return</span> <span class="nx">x</span>
<span class="p">}</span>
<span class="kd">type</span> <span class="nx">iteratorMinHeap</span> <span class="kd">struct</span> <span class="p">{</span>
    <span class="nx">iteratorHeap</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">iteratorMinHeap</span><span class="p">)</span> <span class="nf">Less</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">h</span><span class="p">.</span><span class="nx">iteratorHeap</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nf">Entry</span><span class="p">().</span><span class="nx">Timestamp</span><span class="p">.</span><span class="nf">Before</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nx">iteratorHeap</span><span class="p">[</span><span class="nx">j</span><span class="p">].</span><span class="nf">Entry</span><span class="p">().</span><span class="nx">Timestamp</span><span class="p">)</span>
<span class="p">}</span>
<span class="kd">type</span> <span class="nx">iteratorMaxHeap</span> <span class="kd">struct</span> <span class="p">{</span>
    <span class="nx">iteratorHeap</span>
<span class="p">}</span>
<span class="kd">func</span> <span class="p">(</span><span class="nx">h</span> <span class="nx">iteratorMaxHeap</span><span class="p">)</span> <span class="nf">Less</span><span class="p">(</span><span class="nx">i</span><span class="p">,</span> <span class="nx">j</span> <span class="kt">int</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">return</span> <span class="nx">h</span><span class="p">.</span><span class="nx">iteratorHeap</span><span class="p">[</span><span class="nx">i</span><span class="p">].</span><span class="nf">Entry</span><span class="p">().</span><span class="nx">Timestamp</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nx">iteratorHeap</span><span class="p">[</span><span class="nx">j</span><span class="p">].</span><span class="nf">Entry</span><span class="p">().</span><span class="nx">Timestamp</span><span class="p">)</span>
<span class="p">}</span>

<span class="c1">// 将一组 Stream 的 iterator 合并成一个 HeapIterator
</span><span class="c1"></span><span class="kd">func</span> <span class="nf">NewHeapIterator</span><span class="p">(</span><span class="nx">is</span> <span class="p">[]</span><span class="nx">EntryIterator</span><span class="p">,</span> <span class="nx">direction</span> <span class="nx">logproto</span><span class="p">.</span><span class="nx">Direction</span><span class="p">)</span> <span class="nx">EntryIterator</span> <span class="p">{</span>
    <span class="nx">result</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">heapIterator</span><span class="p">{}</span>
    <span class="k">switch</span> <span class="nx">direction</span> <span class="p">{</span>
    <span class="k">case</span> <span class="nx">logproto</span><span class="p">.</span><span class="nx">BACKWARD</span><span class="p">:</span>
        <span class="nx">result</span><span class="p">.</span><span class="nx">heap</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">iteratorMaxHeap</span><span class="p">{}</span>
    <span class="k">case</span> <span class="nx">logproto</span><span class="p">.</span><span class="nx">FORWARD</span><span class="p">:</span>
        <span class="nx">result</span><span class="p">.</span><span class="nx">heap</span> <span class="p">=</span> <span class="o">&amp;</span><span class="nx">iteratorMinHeap</span><span class="p">{}</span>
    <span class="k">default</span><span class="p">:</span>
        <span class="nb">panic</span><span class="p">(</span><span class="s">&#34;bad direction&#34;</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="c1">// pre-next each iterator, drop empty.
</span><span class="c1"></span>    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">is</span> <span class="p">{</span>
        <span class="nx">result</span><span class="p">.</span><span class="nf">requeue</span><span class="p">(</span><span class="nx">i</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nx">result</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="p">(</span><span class="nx">i</span> <span class="o">*</span><span class="nx">heapIterator</span><span class="p">)</span> <span class="nf">requeue</span><span class="p">(</span><span class="nx">ei</span> <span class="nx">EntryIterator</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">if</span> <span class="nx">ei</span><span class="p">.</span><span class="nf">Next</span><span class="p">()</span> <span class="p">{</span>
        <span class="nx">heap</span><span class="p">.</span><span class="nf">Push</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">heap</span><span class="p">,</span> <span class="nx">ei</span><span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">ei</span><span class="p">.</span><span class="nf">Error</span><span class="p">();</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
        <span class="nx">i</span><span class="p">.</span><span class="nx">errs</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">errs</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="nx">helpers</span><span class="p">.</span><span class="nf">LogError</span><span class="p">(</span><span class="s">&#34;closing iterator&#34;</span><span class="p">,</span> <span class="nx">ei</span><span class="p">.</span><span class="nx">Close</span><span class="p">)</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="p">(</span><span class="nx">i</span> <span class="o">*</span><span class="nx">heapIterator</span><span class="p">)</span> <span class="nf">Next</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
    <span class="k">if</span> <span class="nx">i</span><span class="p">.</span><span class="nx">curr</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
        <span class="nx">i</span><span class="p">.</span><span class="nf">requeue</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">curr</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="nx">i</span><span class="p">.</span><span class="nx">heap</span><span class="p">.</span><span class="nf">Len</span><span class="p">()</span> <span class="o">==</span> <span class="mi">0</span> <span class="p">{</span>
        <span class="k">return</span> <span class="kc">false</span>
    <span class="p">}</span>
    <span class="nx">i</span><span class="p">.</span><span class="nx">curr</span> <span class="p">=</span> <span class="nx">heap</span><span class="p">.</span><span class="nf">Pop</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">heap</span><span class="p">).(</span><span class="nx">EntryIterator</span><span class="p">)</span>
    <span class="nx">currEntry</span> <span class="o">:=</span> <span class="nx">i</span><span class="p">.</span><span class="nx">curr</span><span class="p">.</span><span class="nf">Entry</span><span class="p">()</span>
    <span class="c1">// keep popping entries off if they match, to dedupe
</span><span class="c1"></span>    <span class="k">for</span> <span class="nx">i</span><span class="p">.</span><span class="nx">heap</span><span class="p">.</span><span class="nf">Len</span><span class="p">()</span> <span class="p">&gt;</span> <span class="mi">0</span> <span class="p">{</span>
        <span class="nx">next</span> <span class="o">:=</span> <span class="nx">i</span><span class="p">.</span><span class="nx">heap</span><span class="p">.</span><span class="nf">Peek</span><span class="p">()</span>
        <span class="nx">nextEntry</span> <span class="o">:=</span> <span class="nx">next</span><span class="p">.</span><span class="nf">Entry</span><span class="p">()</span>
        <span class="k">if</span> <span class="p">!</span><span class="nx">currEntry</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">nextEntry</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">break</span>
        <span class="p">}</span>

        <span class="nx">next</span> <span class="p">=</span> <span class="nx">heap</span><span class="p">.</span><span class="nf">Pop</span><span class="p">(</span><span class="nx">i</span><span class="p">.</span><span class="nx">heap</span><span class="p">).(</span><span class="nx">EntryIterator</span><span class="p">)</span>
        <span class="nx">i</span><span class="p">.</span><span class="nf">requeue</span><span class="p">(</span><span class="nx">next</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="kc">true</span>
<span class="p">}</span></code></pre></div>
<h1 id="结语">结语</h1>

<p>写源码分析的文章还是挺累的, 虽然前面说了不拾人牙慧, 但最后还是要重复一下 Grafana 官方已经说了的一些要点, 那就是 loki 的思路和 <code>ELK</code> 这样的思路确实完全不同. loki 不索引日志内容大大减轻了存储成本, 同时聚焦于 <code>distribute grep</code>, 而不再考虑各种分析,报表的花架子, 也让&rdquo;日志&rdquo;的作用更为专一: 服务于可观察性.</p>

<p>另外, <code>Grafana loki</code> 为 Grafana 生态填上了可观察性中的重要一环, logging. 再加上早已成为 CloudNative 中可观察性事实标准的 Prometheus + Grafana Stack, Grafana 生态已经只缺 Trace 这一块了(他们的 Slides 中提到已经在做了), 未来可期.</p>

<p>最后想说的是, 现今摩尔定律已近失效, 没有了每年翻一番的硬件性能, 整个后端架构需要更精细化地运作. 像以前那样用昂贵的全文索引或者列式存储直接存大量低价值的日志信息(99%没人看)已经不合时宜了. 在程序的运行信息(&ldquo;日志&rdquo;)和埋点,用户行为等业务信息(也是&rdquo;日志&rdquo;)之间进行业务,抽象与架构上的逐步切分, 让各自的架构适应到各自的 ROI 最大的那个点上, 会是未来的趋势, 而 <code>Grafana Loki</code> 则恰到好处地把握住了这个趋势.</p>
]]></content>
		</item>
		
		<item>
			<title>Linux Slab 导致的内存使用率误报警</title>
			<link>https://www.aleiwu.com/post/linux-memory-monitring/</link>
			<pubDate>Tue, 27 Nov 2018 00:00:00 +0000</pubDate>
			
			<guid>https://www.aleiwu.com/post/linux-memory-monitring/</guid>
			<description>free 命令的输出 buff/cache 部分并不等同于 /proc/meminfo 中的 Buffer + Cached。警报规则中最常见的内存使用率计算方式也存在一些问题。 背景 最近在整理监控，碰到一位同事反馈</description>
			<content type="html"><![CDATA[

<blockquote>
<p><code>free</code> 命令的输出 <code>buff/cache</code> 部分并不等同于 <code>/proc/meminfo</code> 中的 <code>Buffer</code> + <code>Cached</code>。警报规则中最常见的内存使用率计算方式也存在一些问题。</p>
</blockquote>

<h1 id="背景">背景</h1>

<p>最近在整理监控，碰到一位同事反馈问题：</p>

<blockquote>
<p>内存监控报警报过来是大于 90%，但是我们看了下，实际使用只有 50% 多。</p>
</blockquote>

<p>这个 &ldquo;50%&rdquo; 来自于 <code>free -g</code>:</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash"><span class="c1"># free - g</span>
            total   used    free    shared  buff/cache  available
Mem:        <span class="m">31</span>      <span class="m">17</span>      <span class="m">0</span>       <span class="m">0</span>       <span class="m">13</span>          <span class="m">6</span>    
Swap:       <span class="m">0</span>       <span class="m">0</span>       <span class="m">0</span></code></pre></div>
<p>这么看来，机器上约有 13G 的内存属于 <code>buff/cache</code>，<strong>应当能在需要时被回收</strong>。而在我们的警报规则中，<strong>内存使用率</strong>的计算方式是(<a href="https://github.com/hyperic/sigar">Sigar</a> 和 <a href="https://help.aliyun.com/knowledge_detail/38842.html">阿里云云监控</a> 也都是这么算的)：</p>
<div class="highlight"><pre class="chroma">(MemTotal - (MemFree + Buffers + Cached)) / MemTotal</pre></div>
<p>那么理论上根据 <code>free</code> 的输出结果，使用率算出来应该是 60% 不到，为什么会触发报警呢？</p>

<h1 id="排查">排查</h1>

<p>登录到机器上看了一眼 <code>/proc/meminfo</code>，问题一下子就清楚了：</p>
<div class="highlight"><pre class="chroma"><code class="language-bash" data-lang="bash"><span class="c1"># cat /proc/meminfo</span>
MemTotal:       <span class="m">32780412</span> kB
MemFree:          <span class="m">715188</span> kB
MemAvailable:    <span class="m">7264108</span> kB
Buffers:           <span class="m">16708</span> kB
Cached:           <span class="m">799300</span> kB
SwapCached:            <span class="m">0</span> kB
Active:         <span class="m">17793344</span> kB
Inactive:         <span class="m">375212</span> kB
Active<span class="o">(</span>anon<span class="o">)</span>:   <span class="m">17354764</span> kB
Inactive<span class="o">(</span>anon<span class="o">)</span>:     <span class="m">1132</span> kB
Active<span class="o">(</span>file<span class="o">)</span>:     <span class="m">438580</span> kB
Inactive<span class="o">(</span>file<span class="o">)</span>:   <span class="m">374080</span> kB
Unevictable:           <span class="m">0</span> kB
Mlocked:               <span class="m">0</span> kB
SwapTotal:             <span class="m">0</span> kB
SwapFree:              <span class="m">0</span> kB
Dirty:               <span class="m">832</span> kB
Writeback:             <span class="m">0</span> kB
AnonPages:      <span class="m">17352688</span> kB
Mapped:           <span class="m">146796</span> kB
Shmem:              <span class="m">3328</span> kB
Slab:           <span class="m">13096500</span> kB
SReclaimable:    <span class="m">6139152</span> kB
SUnreclaim:      <span class="m">6957348</span> kB
KernelStack:       <span class="m">58912</span> kB
PageTables:       <span class="m">100304</span> kB
NFS_Unstable:          <span class="m">0</span> kB
Bounce:                <span class="m">0</span> kB
WritebackTmp:          <span class="m">0</span> kB
CommitLimit:    <span class="m">16390204</span> kB
Committed_AS:   <span class="m">37371552</span> kB
VmallocTotal:   <span class="m">34359738367</span> kB
VmallocUsed:      <span class="m">299088</span> kB
VmallocChunk:   <span class="m">34359282792</span> kB
HardwareCorrupted:     <span class="m">0</span> kB
AnonHugePages:   <span class="m">8628224</span> kB
HugePages_Total:       <span class="m">0</span>
HugePages_Free:        <span class="m">0</span>
HugePages_Rsvd:        <span class="m">0</span>
HugePages_Surp:        <span class="m">0</span>
Hugepagesize:       <span class="m">2048</span> kB
DirectMap4k:      <span class="m">460672</span> kB
DirectMap2M:    <span class="m">22607872</span> kB
DirectMap1G:    <span class="m">12582912</span> kB</code></pre></div>
<p><strong><code>Slab</code> 部分使用了近 13G 的内存</strong>，而 <code>Buffer</code> 和 <code>Cached</code> 加起来只使用了几百兆而已，将上面的结果绘制一下，更加直观：</p>

<p><img src="/img/memory/memory-graph.png" alt="memory-graph" /></p>

<p>既然 <code>Buffer</code> 和 <code>Cache</code> 用量并不高，那么为什么 <code>free</code> 的输出当中 <code>buffer/cached</code> 足足占了 13G 呢? <code>free</code> 的 man page 是这么说的:</p>
<div class="highlight"><pre class="chroma">       buffers
              Memory used by kernel buffers (Buffers in /proc/meminfo)
 
       cache  Memory used by the page cache and slabs (Cached and Slab in /proc/meminfo)
 
       buff/cache
              Sum of buffers and cache
 
       available
              Estimation of how much memory is available for starting new applications, without swapping. Unlike the data provided by the cache or free fields, this  field  takes  into
              account  page  cache and also that not all reclaimable memory slabs will be reclaimed due to items being in use (MemAvailable in /proc/meminfo, available on kernels 3.14,
              emulated on kernels 2.6.27+, otherwise the same as free)</pre></div>
<p>可见 <code>buff/cache</code> = <code>buffers</code> + <code>cache</code>, <code>cache</code> 则包含了 <code>Cached</code> 和 <code>Slab</code> 两部分。 而<code>Slab</code> 本身也是可回收的(除去正在被使用的部分), <code>/proc/meminfo</code> 中用 <code>SReclaimable</code> 和 <code>SUnreclaim</code> 这两个指标标明了可回收的 <code>Slab</code> 大小与不可回收的 <code>Slab</code> 大小。在上面的场景中，<code>SReclaimable</code> 约有 6GB, 确实不应该发出警报。</p>

<p>到这里事情就明确了，文章开头的内存实际使用率算法其实是不科学的，我们需要找到一个更好的计算方式来做内存报警。</p>

<h1 id="内存使用率怎么算最有报警价值">内存使用率怎么算最有报警价值?</h1>

<p>根据上面的分析，我们首先要把 <code>SReclaimable</code>（可回收的 Slab 内存) 考虑进来:</p>
<div class="highlight"><pre class="chroma">(MemTotal - (MemFree + Buffers + Cached + SReclaimable)) / MemTotal</pre></div>
<p>但是 <code>/proc/meminfo</code> 里还有那么多数据，是否还有需要我们额外考虑的呢? 还有，在上面的算法中，我们认为所有的 <code>Buffers</code>,<code>Cached</code>,<code>SReclaimable</code> 部分都可以随时被回收，这科学吗？</p>

<p>这时候, <code>free</code> 命令的 &ldquo;available&rdquo; 部分的描述让我产生了兴趣：</p>
<div class="highlight"><pre class="chroma">available
          Estimation of how much memory is available for starting new applications, without swapping. Unlike the data provided by the cache or free fields, this  field  takes  into
          account  page  cache and also that not all reclaimable memory slabs will be reclaimed due to items being in use (MemAvailable in /proc/meminfo, available on kernels 3.14,
          emulated on kernels 2.6.27+, otherwise the same as free)</pre></div>
<p><code>available</code> 的算法似乎非常符合我们的场景：它会考虑 <strong>Page Cache</strong> 和无法回收的 <strong>Slab</strong> 的内存，最后估算出一个&rdquo;当前可用内存&rdquo;。<a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=34e431b0ae398fc54ea69ff85ec700722c9da773">这个 PR</a> 里是 <code>available</code> 的相关计算代码：</p>
<div class="highlight"><pre class="chroma"><code class="language-C" data-lang="C"><span class="o">+</span>   <span class="n">for_each_zone</span><span class="p">(</span><span class="n">zone</span><span class="p">)</span>
<span class="o">+</span>       <span class="n">wmark_low</span> <span class="o">+=</span> <span class="n">zone</span><span class="o">-&gt;</span><span class="n">watermark</span><span class="p">[</span><span class="n">WMARK_LOW</span><span class="p">];</span>
<span class="o">+</span>
<span class="o">+</span>   <span class="cm">/*
</span><span class="cm">+    * Estimate the amount of memory available for userspace allocations,
</span><span class="cm">+    * without causing swapping.
</span><span class="cm">+    *
</span><span class="cm">+    * Free memory cannot be taken below the low watermark, before the
</span><span class="cm">+    * system starts swapping.
</span><span class="cm">+    */</span>
<span class="o">+</span>   <span class="n">available</span> <span class="o">=</span> <span class="n">i</span><span class="p">.</span><span class="n">freeram</span> <span class="o">-</span> <span class="n">wmark_low</span><span class="p">;</span>
<span class="o">+</span>
<span class="o">+</span>   <span class="cm">/*
</span><span class="cm">+    * Not all the page cache can be freed, otherwise the system will
</span><span class="cm">+    * start swapping. Assume at least half of the page cache, or the
</span><span class="cm">+    * low watermark worth of cache, needs to stay.
</span><span class="cm">+    */</span>
<span class="o">+</span>   <span class="n">pagecache</span> <span class="o">=</span> <span class="n">pages</span><span class="p">[</span><span class="n">LRU_ACTIVE_FILE</span><span class="p">]</span> <span class="o">+</span> <span class="n">pages</span><span class="p">[</span><span class="n">LRU_INACTIVE_FILE</span><span class="p">];</span>
<span class="o">+</span>   <span class="n">pagecache</span> <span class="o">-=</span> <span class="n">min</span><span class="p">(</span><span class="n">pagecache</span> <span class="o">/</span> <span class="mi">2</span><span class="p">,</span> <span class="n">wmark_low</span><span class="p">);</span>
<span class="o">+</span>   <span class="n">available</span> <span class="o">+=</span> <span class="n">pagecache</span><span class="p">;</span>
<span class="o">+</span>
<span class="o">+</span>   <span class="cm">/*
</span><span class="cm">+    * Part of the reclaimable swap consists of items that are in use,
</span><span class="cm">+    * and cannot be freed. Cap this estimate at the low watermark.
</span><span class="cm">+    */</span>
<span class="o">+</span>   <span class="n">available</span> <span class="o">+=</span> <span class="n">global_page_state</span><span class="p">(</span><span class="n">NR_SLAB_RECLAIMABLE</span><span class="p">)</span> <span class="o">-</span>
<span class="o">+</span>            <span class="n">min</span><span class="p">(</span><span class="n">global_page_state</span><span class="p">(</span><span class="n">NR_SLAB_RECLAIMABLE</span><span class="p">)</span> <span class="o">/</span> <span class="mi">2</span><span class="p">,</span> <span class="n">wmark_low</span><span class="p">);</span>
<span class="o">+</span>
<span class="o">+</span>   <span class="k">if</span> <span class="p">(</span><span class="n">available</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span>
<span class="o">+</span>       <span class="n">available</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="o">+</span>
    <span class="cm">/*
</span><span class="cm">     * Tagged format, for easy grepping and expansion.
</span><span class="cm">     */</span>
    <span class="n">seq_printf</span><span class="p">(</span><span class="n">m</span><span class="p">,</span>
        <span class="s">&#34;MemTotal:       %8lu kB</span><span class="se">\n</span><span class="s">&#34;</span>
        <span class="s">&#34;MemFree:        %8lu kB</span><span class="se">\n</span><span class="s">&#34;</span>
<span class="o">+</span>       <span class="s">&#34;MemAvailable:   %8lu kB</span><span class="se">\n</span><span class="s">&#34;</span></code></pre></div>
<p>在 <code>available</code> 的计算中，很重要的一点是 <code>page cache</code> 和 <code>SReclaimable</code> <strong>也不是全都能安全回收的</strong>。因为在极端情况下，假设系统的 <code>Cache</code> 和 <code>Slab</code> 内存都被压缩得非常小，那 Performance 已经非常差了，基本等同于<strong>不可用状态</strong>。于是作者做了一个简单的经验估算：<strong>我们预估 pagecache / 2 和 slab_reclaimable / 2 是不可回收的</strong></p>

<p>既然是估算，那泛用性就多多少少存在问题——我们并不知道上层的业务究竟是怎样的，业务代码究竟是怎么写的。比如说：</p>

<ul>
<li>假设机器上跑了一个 Kafka Broker，Kafka 本身的 High Performance 非常依赖 page cache, 这时那我们认为它有 <sup>1</sup>&frasl;<sub>2</sub> 的 page cache 可以安全地被回收显然是有问题的；</li>
<li>假设机器上跑了一个 Storm 节点，频繁做文件操作产生大量 <code>dentry_cache</code>，那么这时候认为它只有 <sup>1</sup>&frasl;<sub>2</sub> 的<code>SReclaimable</code>内存可被回收又太保守了一点；</li>
</ul>

<p>看来，真正要做好内存报警，必须得对上层业务的内存使用模型有透彻的认识。</p>

<h1 id="最终方案">最终方案</h1>

<p>作为通用的规则提供方，我们自然没办法照顾到所有类型的上层业务。考虑到绝大部分线上的 workload 都是 web server，我们认为用 <code>available</code> 来计算<strong>内存使用率</strong>还是足够通用的，计算方式如下：</p>
<div class="highlight"><pre class="chroma">( MemTotal - MemAvailable) / MemTotal</pre></div>
<p>修改计算方式之后，大部分服务的&rdquo;内存使用率&rdquo;的计算结果都比旧的计算结果减少了近 10%，减少很多误报警。当然更重要的是，我们同步调整了内存的监控图表，加入了 Slab 相关的部分，让 oncall 的工程师能够第一时间掌握内存报警时 server 的内存使用状况。</p>

<h2 id="参考资料">参考资料</h2>

<p><a href="https://access.redhat.com/solutions/406773">Interpreting /proc/meminfo and free output for Red Hat Enterprise Linux 5, 6 and 7</a>, 关于 free 命令和 /proc/meminfo 的关联解释</p>

<p><a href="https://haydenjames.io/measure-web-server-memory-usage-correctly/">Measure Linux web server memory usage correctly</a>, 如何正确衡量服务器内存使用</p>
]]></content>
		</item>
		
		<item>
			<title>基于 Kafka 与 Debezium 构建实时数据管道</title>
			<link>https://www.aleiwu.com/post/vimur.cn/</link>
			<pubDate>Wed, 06 Sep 2017 16:52:03 +0800</pubDate>
			
			<guid>https://www.aleiwu.com/post/vimur.cn/</guid>
			<description>前言 这篇文章我最初是发表于公司技术部公众号, 原题&amp;rdquo;实时数据管道探索&amp;rdquo;, 公开后就搬运到了自己的博客上, 基本上算是对自己</description>
			<content type="html"><![CDATA[

<h2 id="前言">前言</h2>

<blockquote>
<p>这篇文章我最初是发表于公司技术部公众号, 原题&rdquo;实时数据管道探索&rdquo;, 公开后就搬运到了自己的博客上, 基本上算是对自己2017年上半年工作的一些总结. 对于其中提到的 Kafka, Debezium, Otter, Canal 等项目, 其实都踩了不少坑. 下面的内容是一个概览的方案分享, 各个环节的坑与一些细节可能会在后续的文章中进行一些讨论</p>
</blockquote>

<h2 id="起源">起源</h2>

<p>在进行架构转型与分库分表之前，我们一直采用非常典型的单体应用架构：主服务是一个 Java WebApp，使用 Nginx 并选择 Session Sticky 分发策略做负载均衡和会话保持；背后是一个 MySQL 主实例，接了若干 Slave 做读写分离。在整个转型开始之前，我们就知道这会是一块难啃的硬骨头：我们要在全线业务飞速地扩张迭代的同时完成架构转型，因为这是实实在在的&rdquo;给高速行驶的汽车换轮胎&rdquo;。</p>

<p>为了最大限度地减少服务拆分与分库分表给业务带来的影响（不影响业务开发也是架构转型的前提），我们采用了一种温和的渐进式拆分方案：</p>

<ol>
<li>对于每块需要拆分的领域，首先拆分出子服务，并将所有该领域的数据库操作封装为 RPC 接口；</li>
<li>将其它所有服务中对该领域数据表的操作替换为 RPC 调用；</li>
<li>拆分该领域的数据表，使用数据同步保证旧库中的表与新表数据一致；</li>
<li>将该子服务中的数据库操作逐步迁移到新表，分批上线；</li>
<li>全部迁移完成后，切断同步，该服务拆分结束。</li>
</ol>

<p>这种方案能够做到平滑迁移，但其中却有几个棘手的问题：</p>

<ul>
<li>旧表新表的数据一致性如何保证？</li>
<li>如何支持异构迁移？（由于旧表的设计往往非常范式化，因此拆分后的新表会增加很多来自其它表的冗余列）</li>
<li>如何保证数据同步的实时性？（往往会先迁移读操作到新表，这时就要求旧表的写操作必须准实时地同步到新表）</li>
</ul>

<p>典型的解决方案有两种：</p>

<blockquote>
<p>双写(dual write): 即所有写入操作同时写入旧表和新表，这种方式可以完全控制应用代码如何写数据库，听上去简单明了。但它会引入复杂的分布式一致性问题：要保证新旧库中两张表数据一致，双写操作就必须在一个分布式事务中完成，而分布式事务的代价太高了。</p>

<p>数据变更抓取(change data capture, CDC): 通过数据源的事务日志抓取数据源变更，这能解决一致性问题(只要下游能保证变更应用到新库上)。它的问题在于各种数据源的变更抓取没有统一的协议，如 MySQL 用 Binlog，PostgreSQL 用 Logical decoding 机制，MongoDB 里则是 oplog。</p>
</blockquote>

<p>最终我们选择使用数据变更抓取实现数据同步与迁移，一是因为数据一致性的优先级更高，二是因为开源社区的多种组件能够帮助我们解决没有统一协议带来的 CDC 模块开发困难的问题。在明确要解决的问题和解决方向后，我们就可以着手设计整套架构了。</p>

<h2 id="架构设计">架构设计</h2>

<p>只有一个 CDC 模块当然是不够的，因为下游的消费者不可能随时就位等待 CDC 模块的推送。因此我们还需要引入一个变更分发平台，它的作用是：</p>

<ul>
<li>提供变更数据的堆积能力；</li>
<li>支持多个下游消费者按不同速度消费；</li>
<li>解耦 CDC 模块与消费者；</li>
</ul>

<p>另外，我们还需要确定一套统一的数据格式，让整个架构中的所有组件能够高效而安全地通信。</p>

<p>现在我们可以正式介绍 Vimur [ˈviːmər] 了，它是一套实时数据管道，设计目标是通过 CDC 模块抓取业务数据源变更，并以统一的格式发布到变更分发平台，所有消费者通过客户端库接入变更分发平台获取实时数据变更。</p>

<p>我们先看一看这套模型要如何才解决上面的三个问题：</p>

<ul>
<li>一致性：数据变更分发给下游应用后，下游应用可以不断重试保证变更成功应用到目标数据源——这个过程要真正实现一致性还要满足两个前提，一是从数据变更抓取模块投递到下游应用并消费这个过程不能丢数据，也就是要保证至少一次交付；二是下游应用的消费必须是幂等的。</li>
<li>异构迁移：异构包含多种含义：表的 Schema 不同、表的物理结构不同(单表到分片表)、数据库不同(如 MySQL -&gt; EleasticSearch) ，后两者只要下游消费端实现对应的写入接口就能解决；而 Schema 不同，尤其是当新库的表聚合了多张旧库的表信息时，就要用反查源数据库或 Stream Join 等手段实现。</li>
<li>实时性：只要保证各模块的数据传输与写入的效率，该模型便能保证实时性。</li>
</ul>

<p>可以看到，这套模型本身对各个组件是有一些要求的，我们下面的设计选型也会参照这些要求。</p>

<h3 id="开源方案对比">开源方案对比</h3>

<p>在设计阶段，我们调研对比了多个开源解决方案：</p>

<ul>
<li><a href="https://github.com/linkedin/databus">databus</a>: Linkedin 的分布式数据变更抓取系统；</li>
<li><a href="https://engineeringblog.yelp.com/2016/11/open-sourcing-yelps-data-pipeline.html">Yelp&rsquo;s data pipeline</a>: Yelp 的数据管道；</li>
<li><a href="https://github.com/alibaba/otter">Otter</a>: 阿里开源的分布式数据库同步系统；</li>
<li><a href="http://debezium.io/">Debezium</a>: Redhat 开源的数据变更抓取组件；</li>
</ul>

<p>这些解决方案关注的重点各有不同，但基本思想是一致的：使用变更抓取模块实时订阅数据库变更，并分发到一个中间存储供下游应用消费。下面是四个解决方案的对比矩阵：</p>

<table>
<thead>
<tr>
<th align="center">方案</th>
<th align="center">变更抓取</th>
<th align="center">分发平台</th>
<th align="center">消息格式</th>
<th align="center">额外特性</th>
</tr>
</thead>

<tbody>
<tr>
<td align="center">databus</td>
<td align="center">DatabusEventProducer, 支持 Oracle 和 MySQL 的变更抓取</td>
<td align="center">DatabusRelay, 基于 Netty 的中间件, 内部是一个 RingBuffer 存储变更消息</td>
<td align="center">Apache Avro</td>
<td align="center">有 BootstrapService 组件存储历史变更用以支持全量</td>
</tr>

<tr>
<td align="center">Yelp&rsquo;s data pipeline</td>
<td align="center">MySQL Streamer, 基于 binlog 抓取变更</td>
<td align="center">Apache Kafka</td>
<td align="center">Apache Avro</td>
<td align="center">Schematizer, 作为消息的 Avro Schema 注册中心的同时提供了 Schema 文档</td>
</tr>

<tr>
<td align="center">Otter</td>
<td align="center"><a href="https://github.com/alibaba/canal">Canal</a>, 阿里的另一个开源项目, 基于 binlog</td>
<td align="center">work node 内存中的 ring buffer</td>
<td align="center">protobuf</td>
<td align="center">提供了一个完善的 admin ui</td>
</tr>

<tr>
<td align="center">Debezium</td>
<td align="center">提供 MySQL, MongoDB, PostgreSQL 三种 Connector</td>
<td align="center">Apache Kafka</td>
<td align="center">Apache Avro / json</td>
<td align="center">Snapshot mode 支持全量导入数据表</td>
</tr>
</tbody>
</table>

<p><img src="/img/vimur/databus.png" alt="databus" /></p>

<p><center><em>（Linkedin databus 的架构图）</em></center></p>

<p>Linkedin databus 的<a href="https://915bbc94-a-62cb3a1a-s-sites.googlegroups.com/site/acm2012socc/s18-das.pdf?attachauth=ANoY7crwQB80nxV3-WSGP4pAwYBeaOIakeXd2khyKM-g0HFDjrZ0D2BqPMjmCSEuJbTEIDnU78rjXMFAwIUbJbrXTPlPLyLxYiE2BjZ5QKvTpl3VyGVxMf9DZrFUeMN3U8Zs3SsDcDWZfRHTAcbjVD86YudzqhckC2FjVDJYlTAOj8R3vkOeR7J5ENXs8cK4QttN-iMBQ493maJO3Yul2IR-9x49grUD7w%3D%3D&amp;attredirects=1">论文</a>有很强的指导性，但它的 MySQL 变更抓取模块很不成熟，官方支持的是 Oracle，MySQL 只是使用另一个开源组件 <a href="https://github.com/whitesock/open-replicator">OpenReplicator</a> 做了一个 demo。另一个不利因素 databus 使用了自己实现的一个 Relay 作为变更分发平台，相比于使用开源消息队列的方案，这对维护和外部集成都不友好。</p>

<p><img src="/img/vimur/otter.jpeg" alt="otter" /></p>

<p><center><em>(otter 的架构图)</em></center></p>

<p>Otter 和 Canal 在国内相当知名，Canal 还支持了阿里云 DRDS 的二级索引构建和小表同步，工程稳定性上有保障。但 Otter 本身无法很好地支持多表聚合到新表，开源版本也不支持同步到分片表当中，能够采取的一个折衷方案是直接将 Canal 订阅的变更写入消息队列，自己写下游程序实现聚合同步等逻辑。该方案也是我们的候选方案。</p>

<p>Yelp&rsquo;s data pipeline 是一个大而全的解决方案。它使用 Mysql-Streamer（一个通过 binlog 实现的 MySQL CDC 模块）将所有的数据库变更写入 Kafka，并提供了 Schematizer 这样的 Schema 注册中心和定制化的 Python 客户端库解决通信问题。遗憾的是该方案是 Python 构建的，与我们的 Java 技术栈相性不佳。</p>

<p>最后是 Debezium , 不同于上面的解决方案，它只专注于 CDC，它的亮点有:</p>

<ul>
<li>支持 MySQL、MongoDB、PostgreSQL 三种数据源的变更抓取，并且社区正在开发 Oracle 与 Cassandra 支持；</li>
<li>Snapshot Mode 可以将表中的现有数据全部导入 Kafka，并且全量数据与增量数据形式一致，可以统一处理；</li>
<li>利用了 Kafka 的 Log Compaction 特性，变更数据可以实现&rdquo;不过期&rdquo;永久保存；</li>
<li>利用了 Kafka Connect，自动拥有高可用与开箱即用的调度接口；</li>
<li>社区活跃：Debezium 很年轻，面世不到1年，但它的 <a href="https://gitter.im/debezium/dev">Gitter</a>上每天都有百余条技术讨论，并且有两位 Redhat 全职工程师进行维护；</li>
</ul>

<p>最终我们选择了 Debezium + Kafka 作为整套架构的基础组件，并以 Apache Avro 作为统一数据格式，下面我们将结合各个模块的目标与设计阐释选型动机。</p>

<h3 id="cdc-模块">CDC 模块</h3>

<p>变更数据抓取通常需要针对不同数据源订制实现，而针对特定数据源，实现方式一般有两种：</p>

<ul>
<li>基于自增列或上次修改时间做增量查询；</li>
<li>利用数据源本身的事务日志或 Slave 同步等机制实时订阅变更；</li>
</ul>

<p>第一种方式实现简单，以 SQL 为例：
<script src="https://gist.github.com/AleiHanami/44034658ca9d7f496eead62a676119f7.js"></script>
相信大家都写过类似的 SQL, 每次查询时，查询 <code>[last_query_time, now)</code> 区间内的增量数据，lastmodified 列也可以用自增主键来替代。这种方式的缺点是实时性差，对数据库带来了额外压力，并且侵入了表设计 —— 所有要实现变更抓取的表都必须有用于增量查询的列并且在该列上构建索引。另外，这种方式无法感知物理删除(Delete), 删除逻辑只能用一个 <code>delete</code> 列作为 flag 来实现。</p>

<p>第二种方式实现起来相对困难，但它很好地解决了第一种方式的问题，因此前文提到的开源方案也都采用了这种方式。下面我们着重分析在 MySQL 中如何实现基于事务日志的实时变更抓取。</p>

<p>MySQL 的事务日志称为 binlog，常见的 MySQL 主从同步就是使用 Binlog 实现的：</p>

<p><img src="/img/vimur/oracle.jpg" alt="binlog同步" /></p>

<p><center><em>（来自: <a href="https://www.slideshare.net/davidmstokes/mysql-for-oracle-dba-rocky-mountain-oracle-user-group-training-days-15）">https://www.slideshare.net/davidmstokes/mysql-for-oracle-dba-rocky-mountain-oracle-user-group-training-days-15）</a></em></center></p>

<p>我们把 Slave 替换成 CDC 模块，CDC 模块模拟 MySQL Slave 的交互协议，便能收到 Master 的 binlog 推送：</p>

<p><img src="/img/vimur/cdc.png" alt="cdc" /></p>

<p>CDC 模块解析 binlog，产生特定格式的变更消息，也就完成了一次变更抓取。但这还不够，CDC 模块本身也可能挂掉，那么恢复之后如何保证不丢数据又是一个问题。这个问题的解决方案也是要针对不同数据源进行设计的，就 MySQL 而言，通常会持久化已经消费的 binlog 位点或 <a href="https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html">Gtid</a>(MySQL 5.6之后引入)来标记上次消费位置。其中更好的选择是 Gtid，因为该位点对于一套 MySQL 体系（主从或多主）是全局的，而 binlog 位点是单机的，无法支持主备或多主架构。</p>

<p>那为什么最后选择了 Debezium 呢？</p>

<p>MySQL CDC 模块的一个挑战是如何在 binlog 变更事件中加入表的 Schema 信息(如标记哪些字段为主键，哪些字段可为 null)。Debezium 在这点上处理得很漂亮，它在内存中维护了数据库每张表的 Schema，并且全部写入一个 backup 的 Kafka Topic 中，每当 binlog 中出现 DDL 语句，便应用这条 DDL 来更新 Schema。而在节点宕机，Debezium 实例被调度到另一个节点上后，又会通过 backup topic 恢复 Schema 信息，并从上次消费位点继续解析 Binlog。</p>

<p>在我们的场景下，另一个挑战是，我们数据库已经有大量的现存数据，数据迁移时的现存数据要如何处理。这时，Debezium 独特的 Snapshot 功能就能帮上忙，它可以实现将现有数据作为一次&rdquo;插入变更&rdquo;捕捉到 Kafka 中，因此只要编写一次客户端就能一并处理全量数据与后续的增量数据。</p>

<h3 id="变更分发平台">变更分发平台</h3>

<p>变更分发平台可以有很多种形式，本质上它只是一个存储变更的中间件，那么如何进行选型呢？首先由于变更数据数据量级大，且操作时没有事务需求，所以先排除了关系型数据库，
剩下的 NoSQL 如 Cassandra，mq 如 Kafka、RabbitMQ 都可以胜任。其区别在于，消费端到分发平台拉取变更时，假如是 NoSQL 的实现，那么就能很容易地实现条件过滤等操作(比如某个客户端只对特定字段为 true 的消息感兴趣); 但 NoSQL 的实现往往会在吞吐量和一致性上输给 mq。这里就是一个设计抉择的问题，最终我们选择了 mq，主要考虑的点是：消费端往往是无状态应用，很容易进行水平扩展，因此假如有条件过滤这样的需求，我们更希望把这样的计算压力放在消费端上。</p>

<p>而在 mq 里，Kafka 则显得具有压倒性优势。Kafka 本身就有大数据的基因，通常被认为是目前吞吐量最大的消息队列，同时，使用 Kafka 有一项很适合该场景的特性：Log Compaction。Kafka 默认的过期清理策略(<code>log.cleanup.policy</code>)是<code>delete</code>，也就是删除过期消息，配置为<code>compact</code>则可以启用 Log Compaction 特性，这时 Kafka 不再删除过期消息，而是对所有过期消息进行&rdquo;折叠&rdquo; —— 对于 key 相同的所有消息会，保留最新的一条。</p>

<p>举个例子，我们对一张表执行下面这样的操作：
<script src="https://gist.github.com/AleiHanami/19e3752639850c3c7bea0a88223671fb.js"></script>
对应的在 mq 中的流总共会产生 4 条变更消息，而最下面两条分别是 <code>id:1</code> <code>id:2</code> 下的最新记录，在它们之前的两条 INSERT 引起的变更就会被 Kafka 删除，最终我们在 Kafka 中看到的就是两行记录的最新状态，而一个持续订阅该流的消费者则能收到全部4条记录。</p>

<p>这种行为有一个有趣的名字，流表二相性(Stream Table Durability)：Topic 中有无尽的变更消息不断被写入，这是流的特质；而 Topic 某一时刻的状态，恰恰是该时刻对应的数据表的一个快照(参见上面的例子)，每条新消息的到来相当于一次 Upsert，这又是表的特性。落到实践中来讲，Log Compaction 对于我们的场景有一个重要应用：全量数据迁移与数据补偿，我们可以直接编写针对每条变更数据的处理程序，就能兼顾全量迁移与之后的增量同步两个过程；而在数据异常时，我们可以重新回放整个 Kafka Topic —— 该 Topic 就是对应表的快照，针对上面的例子，我们回放时只会读到最新的两条消息，不需要读全部四条消息也能保证数据正确。</p>

<p>关于 Kafka 作为变更分发平台，最后要说的就是消费顺序的问题。大家都知道 Kafka 只能保证单个 Partition 内消息有序，而对于整个 Topic，消息是无序的。一般的认知是，数据变更的消费为了逻辑的正确性，必须按序消费。按着这个逻辑，我们的 Topic 只能有单个 Partition，这就大大牺牲了 Kafka 的扩展性与吞吐量。其实这里有一个误区，对于数据库变更抓取，我们只要保证 <strong>同一行记录的变更有序</strong> 就足够了。还是上面的例子，我们只需要保证对<code>id:2</code> 这行的 <code>insert</code> 消息先于 <code>update</code> 消息，该行数据最后就是正确的。而实现&rdquo;同一行记录变更有序&rdquo;就简单多了，Kafka Producer 对带 key 的消息默认使用 key 的 hash 决定分片，因此只要用数据行的主键作为消息的 key，所有该行的变更都会落到同一个 Parition 上，自然也就有序了。这有一个要求就是 CDC 模块必须解析出变更数据的主键 —— 而这点 Debezium 已经帮助我们解决了。</p>

<h3 id="统一数据格式">统一数据格式</h3>

<p>数据格式的选择同样十分重要。首先想到的当然是 <code>json</code>, 目前最常见的消息格式，不仅易读，开发也都对它十分熟悉。但 <code>json</code> 本身有一个很大的不足，那就是契约性太弱，它的结构可以随意更改：试想假如有一个接口返回 <code>String</code>，注释上说这是个<code>json</code>，那我们该怎么编写对应的调用代码呢？是不是需要翻接口文档，提前获知这段 <code>json</code> 的 schema，然后才能开始编写代码，并且这段代码随时可能会因为这段 <code>json</code> 的格式改变而 break。</p>

<p>在规模不大的系统中，这个问题并不显著。但假如在一个拥有上千种数据格式的数据管道上工作，这个问题就会很麻烦，首先当你订阅一个变更 topic 时，你完全处于懵逼状态——不知道这个 topic 会给你什么，当你经过文档的洗礼与不断地调试终于写完了客户端代码，它又随时会因为 topic 中的消息格式变更而挂掉。</p>

<p>参考 Yelp 和 Linkedin 的选择，我们决定使用 <a href="https://avro.apache.org/docs/1.8.1/">Apache Avro</a> 作为统一的数据格式。Avro 依赖模式 Schema 来实现数据结构定义，而 Schema 通常使用 json 格式进行定义，一个典型的 Schema 如下：
<script src="https://gist.github.com/AleiHanami/f354e1fcd5435f09088153b0cea3c6e9.js"></script>
这里要介绍一点背景知识，Avro 的一个重要特性就是支持 Schema 演化，它定义了一系列的<a href="https://avro.apache.org/docs/1.8.1/spec.html#Schema+Resolution">演化规则</a>，只要符合该规则，使用不同的 Schema 也能够正常通信。也就是说，使用 Avro 作为数据格式进行通信的双方是有自由更迭 Schema 的空间的。</p>

<p>在我们的场景中，数据库表的 Schema 变更会引起对应的变更数据 Schema 变更，而每次进行数据库表 Schema 变更就更新下游消费端显然是不可能的。所以这时候 Avro 的 Schema 演化机制就很重要了。我们做出约定，同一个 Topic 上传输的消息，其 Avro Schema 的变化必须符合演化规则，这么一来，消费者一旦开始正常消费之后就不会因为消息的 Schema 变化而挂掉。</p>

<h2 id="应用总结">应用总结</h2>

<p><img src="/img/vimur/vimur.png" alt="Vimur 与下游消费端整体拓扑" />
上图展现了以变更分发平台(Kafka) 为中心的系统拓扑。其中有一些上面没有涉及的点：我们使用 Kafka 的 <a href="https://cwiki.apache.org/confluence/pages/viewpage.action?pageId=27846330">MirrorMaker</a> 解决了跨数据中心问题，使用 <a href="https://kafka.apache.org/documentation/#connect">Kafka Connect</a> 集群运行 Debezium 任务实现了高可用与调度能力。</p>

<p>我们再看看 Vimur 是如何解决数据迁移与同步问题的，下图展示了一次典型的数据同步过程：
<img src="/img/vimur/to_cassandra.png" alt="同步到 Cassandra" /></p>

<p>下图是一次典型的数据迁移过程，数据迁移通常伴随着服务拆分与分库分表：
<img src="/img/vimur/refactor_rds.png" alt="服务拆分与分库分表" /></p>

<p>这里其实同步任务的编写是颇有讲究的，因为我们一般需要冗余很多新的列到新表上，所以单个流中的数据是不够的，这时有两种方案：</p>

<ol>
<li>反查数据库：逻辑简单，只要查询所需要的冗余列即可，但所有相关的列变动都要执行一次反查会对源库造成额外压力；</li>
<li>Stream Join：Stream Join 通常需要额外存储的支持，无论用什么框架实现，最终效果是把反查压力放到了框架依赖的额外存储上；</li>
</ol>

<p>这两种方案见仁见智，Stream Join 逻辑虽然更复杂，但框架本身如 Flink、Kafka Stream 都提供了 DSL 简化编写。最终的选型实际上取决于需不需要把反查的压力分散出去。</p>

<p>Vimur 的另一个深度应用是解决跨库查询，分库分表后数据表 JOIN 操作将很难实现，通常我们都会查询多个数据库，然后在代码中进行 JOIN。这种办法虽然麻烦，但却不是不采取的妥协策略（框架来做跨库 JOIN ，可行但有害，因为有很多性能陷阱必须手动编码去避免）。然而有些场景这种办法也很难解决，比如多表 INNER JOIN 后的分页。这时我们采取的解决方案就是利用 Vimur 的变更数据，将需要 JOIN 的表聚合到搜索引擎或 NoSQL 中，以文档的形式提供查询。</p>

<p>除了上面的应用外，Vimur 还被我们应用于搜索索引的实时构建、业务事件通知等场景，并计划服务于缓存刷新、响应式架构等场景。回顾当初的探索历程，很多选择可能不是最好的，但一定是反复实践后我们认为最适合我们的。假如你也面临复杂数据层中的数据同步、数据迁移、缓存刷新、二级索引构建等问题，不妨尝试一下基于 CDC 的实时数据管道方案。</p>
]]></content>
		</item>
		
	</channel>
</rss>
