Coredns 源码解析:启动流程

Coredns 的启动流程不是很好阅读,因为它本质上是一个 caddy server, 是基于 caddy 开发的。也就是说,它是将自己的逻辑注入到 caddy 里面去,相当于把 caddy 当做一个框架来用,实际的启动流程其实是 caddy 的启动流程,Coredns 里面不会看到很明确的准备启动,Start server 之类的代码,而大部分都是注册逻辑到 caddy 代码。类似于 Openresty 和 Nginx 的关系。这就导致一开始阅读源码不是很好上手,需要搞清楚哪些东西是 Coredns 的,哪些接口是 caddy 的。而 caddy 的文档又不是很多,而且 Coredns 所使用的 caddy 已经不是官方的了,而是自己维护的一个版本,也加大了阅读的难度。所以我将启动流程梳理了这篇博客,希望能理清它的逻辑。

在开始分析源代码之前,读者需要具备的准备工作是:

  1. Golang 的基本语法,但是我发现 Golang 很简单,看完 https://gobyexample.com/ 足矣;
  2. 知道 DNS 的基本工作原理,知道 DNS Resolver, DNS Root Server, DNS TLS Server 的区别,能区分 Cloudflare 1.1.1.1 服务和 AWS 的 Route53 分别是什么角色,知道 Coredns 是哪一种 Server(其实 Coredns 哪一个都能做);
  3. 需要先看完 Coredns 的使用文档,知道它怎么配置;

在完成了这些之后,就基本可以知道 Coredns 是怎么工作的了。在开始进入源码之前,可以通过已知的文档来思考一下:如果 Coredns 需要完成已知的这些功能,需要做哪些事情呢?

猜想的实现模块

因为在之前的博客中已经介绍过,Coredns 其实是基于一个 caddy 的 server,所以我们可以猜想 Coredns 必须要完成以下事情:

  1. 需要能够解析 Corefile,这是 Coredns 的配置文件;
  2. 需要维护一个 Plugin Chain,因为 Coredns 工作方式的本质是一个一个的 Plugin 调用;
  3. Plugin 需要初始化;
  4. Coredns 是基于 caddy 的,那么 Coredns 必须有地方告诉 caddy 自己处理请求的逻辑,监听的端口等等;

你可能还想到了其他的功能,这样,在接下来的源码阅读中,就可以尝试在源代码中找到这些逻辑,看看 Coredns 是怎么实现的。

Coredns 的入口程序是 coredns.go,里面只有两部分,第一部分是 import,第二部分是 main

Golang 的 import 并不是简单地引入了 package 的名字,如果 package 里面有 init() 的话,会执行这个 init()。所以这里的 import 其实做了很多事情。

注册

import package 的时候,imported package 还会 import 其他的 package,最终,这些 package 的 init() 都会被执行。

import 的链路和最终执行过的 init 如下,其中一些不重要的,比如 caddy.init() 在图中忽略了。

重要的注册主要有两部分,第一部分是将所有的 Plugins 都 import 了一遍,并且执行了这些 Plugin 里面的 init()。这个 Plugin 的 import 列表其实是生成的,在之前的博客中提到过。

因为 Golang 是静态编译的语言,所以要修改支持的 Plugin 列表,必须要重新编译 Coredns:

  1. 修改 plugin.cfg 文件
  2. 使用仓库中的 directives_generate.go 生成 zplugin.go 源代码中文,这时 import 列表更新
  3. 重新编译 Coredns

Plugin 中的 init() 做的事情很简单,就是调用 coredns.plugin.Register 函数,将 Plugin 注册到 caddy 中去,告诉 caddy 两个事情:

  1. 这个 Plugin 所支持的 ServerType 是 DNS
  2. Plugin 的 Action,即初始化函数。这里只是注册,并没有运行过。

第二部分是 register 中的 init 函数,主要的动作是使用 caddy 的接口 caddy.RegisterServerType 注册上了一个新的 Server 类型。

注册的时候,要按照 caddy 的接口告诉 caddy:

  1. Directives: 新的 ServerType 支持的 Directives 是什么;
  2. DefaultInput: 在没有配置文件输入的时候,默认的配置文件是什么,这个选项其实不重要;
  3. NewContext: 这个是最重要的,如何生成对应这个 ServerType 的 Context,Context 是后面管理 Config 实例的主要入口;

coremain 里面也有一个 init() 主要是处理了 Coredns 启动时候的命令行参数,然后注册了 caddyFileLoader,即读取(注意还没有解析)配置文件的函数。

到这里,import 阶段就结束了,目前为止所做的工作大部分都是将函数注册到 caddy,告诉 caddy 应该做什么,函数并没有运行。

然后回到 coredns.go 文件的第二部分:coremain.Run()

启动

数据结构

启动的大致流程是,初始化好各种 Instance, Context, 和 Config, 然后启动 Server. 不同的阶段所初始化的数据结构不同,要理解这个过程,最好先明白这些数据结构之间的关系。主要的数据结构以及它们之间的互相应用如下图。黄色的表示 caddy 中的数据结构,绿色的表示 Coredns 中的数据结构。

其中,caddy.Controllercaddy.Instance 是 caddy 中定义的结构,主要是 caddy server 在使用,Coredns 中并没有看到很多用到的地方。

dnsContext 是实现了 caddy.context,内部保存了和 Config 之间的关系,实现了 caddy 中定义的 InspectServerBlocks 和 MakeServers 接口,是一个主要的数据结构。对应 caddy.Instanse 全局只有一个,由 caddy 创建。

Config 就完全是 Coredns 内部的结构了,是最重要的一个结构,里面保存了 Plugin 列表,在处理 DNS 请求的时候,主要通过 Config 去调用 Plugin. 对于每一个 Corefile 配置文件中的 ServerBlock 和 Zone 都会有一个 Config 实例。

启动流程

Coredns 的启动流程之所以复杂,一个原因是真正的流程在 caddy 中而不是在 Coredns 中,另一个原因是随之而来的各种逻辑,本质上是 Coredns 定义的,然后注册到 caddy 中,caddy 执行的代码实际上是 Coredns 写的。

所以为了说明白这个启动的流程,我先画了一个图。启动流程的本职是初始化好上文中描述的各种数据结构。下图中,上面是数据结构,下面是代码的执行流程。在下图中,实线表示实际调用关系,虚线表示这段代码初始化了数据机构实例。

从 coremain.Run 开始,这里逻辑很简单,先是执行了上文提到过的注册的 caddyFileLoader 。然后调用了 caddy.Start,由 caddy 负责主要的启动流程。

下面我们找深度优先描述这个启动过程。

Caddy 先创建了一个 Instnace ,然后调用 ValidateAndExecuteDirectives。

ValidateAndExecuteDirectives 中,根据我们之前 load 出来的 caddy file 中的 ServerType string,拿到 DNS Server Type,就是上文提到的我们在 init 的过程中注册进去的。

然后执行 loadServerBlocks,这是 caddy 内置的函数,根据我们之前注册的 ServerType.Directives 返回的 Directives 去解析 caddy file,这时候是作为一个普通的 caddy 文件解析的,没有 coredns 的解析逻辑。这时候原来的 caddy file 被解析成 Tokens。由此也可以看出,Coredns 的配置文件和 Corefile 和 caddy 的格式必须是一样的,遵循一样的语法规范,因为解析器都是用的 caddy 中的(如果 Coredns fork 的版本没有修改的话)。

下一步是调用 ServerType 注册的第二个重要方法:NewContext 创建出来一个 context,实质的类型是 dnsContext

然后调用 context.InspectServerBlocks,这个逻辑也是 Coredns 中实现的,是 dnsContext 实现的接口。主要做了两件事,一是检查 Corefile 是否合法,有无重复定义等。然后是创建 dnsserver.Config,Config 主要是和 dnsContext 关联,我们后面拿到这个 Config 主要也是通过 Context。比如在 config.go 中通过 Controller 拿到对应 Config 的方法实现

实际上是通过 Controller 找到 Instance, 然后找到 Instance 上的 Context (c.Context() 逻辑)。然后通过一个 Utils 去从 Context 上找到对应的 Config。这个实现和上图也是完全符合的。

上文提到过,每一个 Server Block 中的每一个 Key 都会有一个对应的 Config,那么这么多的 Config,我们怎么找到对应的呢?

其实就是 keyForConfig 的逻辑,context 中记录了 Server Block Index + Server Block Key Index 组合,和 Config 的一一对应关系。

回到 ValidateAndExecuteDirectives 的逻辑,调用完 InspectServerBlocks 之后,就是针对每一个 Directive 去拿到 Action 然后去执行。即,初始化每一个 Plugin。

完成之后,逻辑回到 caddy.Start中,会调用 dnsContext 中实现的第二个重要的接口:MakeServers. 初始化好 Coredns 中的 Server.

最后一步,就是 startServers 了,这里实际的逻辑又回到了 Coredns 实现的接口上。感觉比较清晰,没有什么难以理解的地方。主要是实现了两个接口,一个是 Listen, 一个是 Serve

然后就可以开始处理请求了。

开始处理请求

最后 Server 实现了 caddy.Server 的 Serve 方法, 里面做的事情主要就是根据 DNS 查询请求里面的 zone 匹配到对应的 Config,然后 PluginChain 保存在 Config 里面,通过调用 h.pluginChain.ServeDNS 来完成请求的处理

本文的代码解析就到这里,至此启动流程就完成了。本文尽量少贴代码,试图缕清启动的流程,具体的代码如果对照本文的图片和解释应该都找到的,并且剩下的部分应该不难看懂。如果发现错误或者有疑问,欢迎在评论区交流。

 

本文基于 Coredns 源码 Commit:3288b111b24fbe01094d5e380bbd2214427e00a4

对应最近的 tag 是:v1.8.6-35-gf35ab931

 

Side Project 成本最小化运行

有时候,人们会忘了今天的计算机资源已经如此强大,一台 $5/月 的机器可以干多少事情。

之前有人在讨论 redis.io 这个官网上,访问量一定巨大,而且可以实时运行 Redis 命令,一定用了很多机器,是一个分布式的系统。但其实就是跑在一个 $5/月的 VPS 单机上。

原文,后来替换成 4G 内存的机器了。

我在工作之余会做一些出于兴趣的 Hobby Project,这些 Project 可以分成两种:

  1. 工具和库之类的,只要下载就可以使用,用户量再大也不会增加我的成本;
  2. 提供服务的网站类型,需要花钱购买域名和服务器,并且要付出维护成本;

对于第二种类型,只有减少成本,项目才可能持续:

  1. 每一个项目刚开始的时候可能都没有什么人,如果不节省成本,很可能在一开始就没什么用户,以及高昂的运行成本而做不下去
  2. 成本低可以长时间积累用户

这篇博客写一些如何降低运行成本的方法。

运行平台

如果是静态网站,选择就很多了,cloudflare, Vercel, netlify, 都可以。只需要把前端的文件上传上去就可以了。

如果是动态网站,近几年也有很多不错的 SaaS 平台可以选择:Sass 部署可以选择 fly.io, heroku, serverless 可以选择 cloudflare, aws 等等。这些平台的免费额度基本都可以覆盖很多场景了。

但是我做的小东西基本上都需要运行用户提交的代码,所以我一般用 DigitalOcean 的虚拟机。好处是便宜,自己完全可以用控制 VM,而且账单透明。不会有 vendor lock, 随时可以换另一家的 VM 用。

部署

如果选择 SaaS 的话,就需要根据使用的 SaaS 平台写部署描述文件。

如果使用虚拟机来部署的话,我一般使用 ansible 来管理部署:

  • 将代码放在一个 repository, 可以公开,也可以私有
  • 使用一个 私有的 repository, 存放 ansible 配置文件,包括一些 secret 文件,host inventory 都放在这里面
  • 每次发布版本的时候,在代码 repository 提交 tag,build binary
  • 然后在 ansible repository 修改部署的 tag,提交一次部署(这一步其实可以集成到 CI 里面去,每次改动都去自动运行一下 Ansible)
  • 保持一个原则:就是给你一组新的 IP,能够在几分钟之内搭建好一模一样的集群(这样可以不用担心原来的集群坏掉,没有运维负担)

Logs 和 Metrics

Logs 也有一些 Saas 平台可以使用,比如 Datadog, sematext, 但是我都没有用过,一个是会提高管理成本,也会提高运行成本,即使 Saas 是免费的,你也需要流量把 log 发出去,而且我比较习惯使用命令行的工具看日志。

关于 Metrics, 其实可以不必关心,小型的服务一开始用 jvns 介绍的方法完全足够了:Monitoring tiny web services,即使用黑盒监控探测,以及定时 curl 网站的方式,从小时级别确保网站在正常运行。这样已经可以检测足够多的东西了,比如 SSL 证书过期,数据库挂了等情况。

如果要更精确的监控,一个比较好的方法是部署 Prometheus + Grafana, 两个组件一个是收集 metrics 的一个是用来展示 metrics 的,都可以用 docker 来启动,然后用 Ansible 管理配置文件,做监控代码化,也能做到随时迁移。

如果要运行用户代码的话……

之前做的一个项目叫 clock.sh, 是一个定时执行用户代码的服务,可以理解成 serverless crontab. 最近还在跑的一个项目叫 xbin.io, 提供一些命令行工具,不用安装就可以运行。这两个项目都需要运行用户代码。

Jvns 在她的博客写过很多解决类似需求的工具分析,基本上和我想要的一样,非常推荐阅读一下:

我现在使用的方案是:Docker + runsc, 使用最新版的 docker,避免 0day 攻击。然后用 runsc 只能允许有限的 syscall,就可以满足大部分的安全需要了(暂时还没出过问题)。

开发和测试

前面说了这么多有关运维部署的事情,其实写代码也有一些技巧。就是尽量不要自己从头自己实现一个功能或者模块,你的代码应该只去实现核心模块。(除非你要写的东西很好玩)。因为业余的时间有限,要把一件事情做成,就要把时间用在关键的地方。几个原则:

  1. 如果一个功能需要很复杂的实现,就用现成的库。比如登录功能,涉及反垃圾,验证邮箱,管理 session,那么直接用 Oauth 库 + Github 账号登录,几行配置就解决了;
  2. 如果一个功能不算复杂,但是现有的库的实现都比较复杂,那么自己写。比如你的一个服务去调用另一个服务,只有一个 API,用 protobuf + gRPC 太复杂了,不如自己设计一个 TCP 协议,或者直接用 HTTP;
  3. 实现的时候写的代码越少越好。代码越少,bug 越少,1年之后你在看自己的代码也看的懂。

有关测试:

  1. 可以只写关键地方的测试,以及复杂的地方的单元测试,核心目的是避免自己以后修改代码的时候不注意改错了复杂的地方;
  2. 可以不写集成测试,因为集成测试都很复杂,即使通过了集成测试,你也不会很有信心一定没问题。所以集成测试即使通过了,一般你也会手动再去验证一下,所以意义不大;
  3. 不是一定需要 CI 自动运行测试,你可以在 merge 之前手动在本地跑一下测试。因为 CI 的调试和维护也很花时间,只有自己一个人的话,makefile 反而更好用。

最后一点

对于业余的项目,应该让自己的精力放在比较重要的(快乐)事情上。自己维护的东西要越少越好。

比如要实现一个功能的话,现成的 SaaS > 基于库实现 > 自己从软件方面实现 > 使用新的开源项目额外部署系统来实现。问题尽量从软件自身解决。举个例子,比如每小时要清理一下不在运行的容器,一个方法是写一个脚本做成 crontab 运行,但是一个更好的方法是在软件本身开一个后台线程,每隔一个小时就检查一遍。这样,就不需要维护 crontab 了(实际上是由 crond 运行的)。

 

长连接负载均衡的问题

在分布式的系统中,如果服务 A 要调用服务 B,并且两个服务都部署了多个实例的话,就要解决负载均衡的问题。即,我们希望到达 B 的 QPS 在 B 的所有实例中都是均衡的。

以前的类似 HTTP/1.1 的实现中,服务 A 每发起一次请求,都需要跟 B 建立一个 TCP 连接。所以负载均衡的实现方式一般都是基于连接数的。但是每次都建立一个新的连接,性能会很低。所以就有了后来长连接的实现方式:建立一个 TCP 连接,在这上面发送很多请求,复用这个 TCP 连接。gRPC 就是基于 HTTP/2 实现的,用了这种长连接的方式。

用长连接会提高性能,因为不用每次都去重新建立一个 TCP 连接。但是也有一些问题。

第一个问题就是负载均衡。Kubernetes 这篇博客讲了为什么 gRPC 需要特殊的负载均衡。很显然,HTTP/1.1 的方式,每次随机选择一个实例去调用,负载是均衡的。但是 HTTP/2 这种一直用一个连接的方式,一旦连接上了就会一直用,使用哪一个实例就取决于最开始选择的谁。

即使是一开始有办法让它连接均衡,但是有一些情况会打破这种均衡。比如说一台一台重启 service instance。

在每重启了一个 instance 之后,原本连接这个 instance 的 client 就会与其断开连接,转而去连接其他的可用 instance。所以,第一台被重启的 instance 重启完成之后是不会有连接的。其他的 instance 会增加:(1/n)/(n-1) * total connections 的连接。n 是总实例数。

因为每一个 instance 重启之后都会增加其他 instance 的连接数,就有两个问题:

  1. 第一个重启的 instance 到头来会有最多的连接数,最后一个重启的,不会有连接,非常不均衡
  2. 最后一个重启的 instance 在重启的时候会造成大量的 client 去重连

第二个问题就是增加服务端 instance 的时候,不会有 client 去连接它。即服务端迁移/上线下线的问题。因为所有的 client 都使用原来已经建立好的连接,不会知道有新的 instance 可用了。其实说到底和第一个问题差不多。

解决的方法,想到 3 个。

第一个就是如同上文博客中提到的那样,在 client 和 server 之间加一个 load balancer,来维护到后端的连接。可以完美解决上面两个问题。缺点是资源会比较高,架构增加复杂性。

第二个方法是从服务端解决:服务端可以不定时给客户端发送 GOAWAY 指令,示意客户端去连接别的 server instance。api-server 有一个选项是可以指定用多少的概率去给 client 发送这个指令:–goaway-chance float.

To prevent HTTP/2 clients from getting stuck on a single apiserver, randomly close a connection (GOAWAY). The client’s other in-flight requests won’t be affected, and the client will reconnect, likely landing on a different apiserver after going through the load balancer again. This argument sets the fraction of requests that will be sent a GOAWAY. Clusters with single apiservers, or which don’t use a load balancer, should NOT enable this. Min is 0 (off), Max is .02 (1/50 requests); .001 (1/1000) is a recommended starting point.

这样还有一个好处,就是下线的时候,不是粗暴地退出,而是可以对自己当前所有的连接都发送 GOAWAY 指令。然后无损地退出。

第三个方法就是从客户端解决:客户端不使用单一连接去连接服务端,而是使用一个连接池:

  1. 客户端每次要发送请求的时候,需要先向自己的连接池请求一个可用连接:
    1. 这时候,如果有,就返回一个连接
    2. 如果没有,就发起建立连接
  2. 使用完成之后,将连接放回连接池
  3. 连接池支持设置一些参数,比如:
    1. 如果 idle 一定的时间,就关闭连接
    2. 一个连接 serve 了多少个 request 之后,或者被使用了多少次之后,就关闭它,不再使用。

这样,一来可以解决一个连接被无限使用的问题,而来关闭连接也是无损的,因为连接池里面的连接没有给任何人使用,由连接池自己管理。其实,像数据库客户端,比如 jdbc,以及 Redis 客户端,都是这么实现的。

 

无法 Patch

最近在试图实现 pdir2 彻底 disable color 的 feature, 让它和其他 cli 的做法一样:在 stdout 是 TTY 的时候默认开启带有颜色的输出,在 stdout 不是 TTY 的时候不输出颜色,即没有颜色相关的 escape code. 这个 pull request 在这里

实现比较简单,在非 TTY 的情况下,新建了一个 Fake 的 Color Render,没有做任何渲染,直接输出。

但是在测试的时候遇到了难题:之前的测试都是按照默认输出颜色来写的,而 pytest 运行的时候,显然是没有 TTY 的,我的代码改变了这个行为,导致 pytest 运行测试的时候,颜色都消失了。

我试图用 patch 来解决这个问题,设定一个全局的 fixture,让 pytest 运行的时候,sys.stdout.isatty() 返回的是 True, 这样所有之前的 test case 都可以依然 pass. 然后就发现了一个非常难解决的问题。

使用 sys.stdout.isatty() 的地方,是在一个 module 的全局的地方,判断之后设置了一个全局变量,类似如下:

而这个 module 在 pytest collecting tests 的时候就已经 import 了,所以这个 use_color 缓存在了 module 的全局中,即使我去 patch isatty() , 实际上也不会调用到了。

 

于是我想到删除这个缓存。在 Python3 里面做这件事很简单,用 importlib.reload("pdir") 就可以了。

然而又出现了一个棘手的问题:pdir 这个 module,其实在 import 之后就已经不存在了。作者为了想让用户这么使用:import pdir; pdir(foo) 而不需要 from pdir import pdir; pdir(foo),用了一个 trick:即,在 pdir.__init__ 中,直接将 sys.module['pdir'] 替换掉了: sys.modules[__name__] = PrettyDir,这样的好处是:import 进来的不再是一个 module,而是一个 class,直接可以调用了。但是坏处是,我们再也找不到 pdir 这个 module 了,也就无法使用 importlib 进行 reload.

为了能够让它在 patch 之后进行 reload,我尝试了很多 hack,比如直接 patch 它的全局变量,发现会失败,因为 patch 也找不到 target 了,因为 patch 也找不到 pdir 这个 module,会提示 class 没有你要 patch 的这个属性;另外尝试过,在源代码中,sys.modules[__name__] = PrettyDir替换之前先保存原来的 module 到一个新的名字,发现也不行,这个 import 机制貌似必须让 module 的名字和 module 对应。

睡了一觉之后,我又在思考为什么 pytest 在收集测试的时候就运行了 module 的 init 呢?为什么不让我先 patch 好,它再去 import?

又仔细看了代码,发现,有一些 test.py 文件,在文件的开头就 import pdir 了。这时候,无论我怎么 patch,后面运行测试的时候都会使用已经 init 好的。

所以要解决这个问题,其实很简单:

  1. test 文件不能再任何地方 import pdir,必须要在 test case 里面进行 import,这样,收集测试的时候不会初始化 module
  2. 然后我设置一个全局的 fixture 去 patch isatty()。这样,执行的逻辑就变成了:收集测试(没有 pdir init)-> 全局 fixture 执行, isatty() patch 为 True -> 执行测试 -> 测试内部 import pdir -> pdir 认为 stdout 是一个 TTY

 

这样还有一个不好的地方,就是全局初始化好了,测试中就无法再测试不是 TTY 的逻辑了。

最后在代码中看到一段 sys.modules 的删除逻辑,好像作者也遇到过类似全局变量在测试中需要重新初始化的需求。把这段代码放到 fixture,发现居然神奇的工作了。原理很简单,就是我 patch 了之后,需要删除所有 pdir 的缓存,这样 import 的时候,就会重新 init 一遍。需要注意的是,不能只删除 pdir, pdir.* 都需要删除。

这样,只需要在测试 TTY 的时候,使用 tty 这个 fixture,不使用的话就默认不是一个 TTY。

 

要解决这个问题, 还有其他一些可能的思路有:

  1. 让 pytest 为每一个测试(或者测试文件)重新开启一个 python 解释器,这样就完全干净了。但是没看到 pytest 有这样的 feature
  2. 减少全局变量的使用,每一次调用都判断是否是 TTY 的逻辑,这样就是为了测试去修改原来的逻辑了,不太喜欢
 

像设计 UI 一样去设计配置项

在蚂蚁金服工作的时候,见到和使用了很多设计糟糕的系统,其中涉及最糟糕的叫做一个  AntX 的东西,现在想起来还会吓得发抖。

具体的实现已经记不太清了,因为自己从来也没有真正掌握过。只是记得这个其实是一个动态配置的系统,要使用它,在你的应用中要写入3个模板文件,其中一个模板会先进行 render,再去用这些变量 render 另外一个模板;render 的过程用到的变量是在一个 web 界面上配置的,并不存在于代码中;实际 render 出来的结果,有一些是程序编译的时候注入实际变量,貌似有一部分还是在运行时进行注入的。

复杂的模板文件、每一个模板还是使用不同的语法、需要在 web 配置、配置区分编译时和运行时变量、区分不同的环境变量。这听起来就是一个灾难了,我相信整个公司也找不到几个人能把这个最终配置生成的过程说的明明白白。最终大家也承认这套系统无法继续维护了,程序中已经存在有很多变量其实是没有用到的,但是程序跑的好好的,谁也没有动力去梳理一遍。最后,大家决定写一个新的配置系统来替代它。

Config 是面向用户的东西,应该像 UI 一样追求简洁,易懂,避免歧义。因为配置错误而导致的事故数不胜数,其实,很多都是由配置的作者以及使用者的理解有代沟造成的。这篇博客就来讲一讲我觉得不错的配置实践。

 

There should be one– and preferably only one –obvious way to do it.

软件的输入有很多种,命令行参数、环境变量、stdin、配置文件等都可以作为配置项去控制软件的行为。很多软件对同一个配置提供多种配置方式,增加了复杂度。

比如 Etcd 的这个 BUG:grpc gateway 在使用 –config-file 的时候默认是 false,不使用 –config-file 的时候是 true。即使 –config-file=/dev/null 也会变成 false。当时花了很长时间去排查。

我觉得对于服务端的软件,其实大部分配置都要求从配置文件中配置就可以。在 lobbyboy 这个项目中验证了一下自己的想法。lobbyboy 作为一个 server 端软件,所有的配置只能从配置文件输入,除了 -c 可以改变配置文件的 Path 之外没有别的命令行参数。

Nginx 的命令行也没有多少参数,大部分配置都是通过配置文件来控制的。

当然对于 Client 端软件来说,就比较复杂了,很多参数同时支持环境变量、args、xxxrc 文件配置,会比较用户友好一些。比如 iredis 的配置文件读取顺序是:

  • Options from command line
  • $PWD/.iredisrc
  • ~/.iredisrc (this path can be changed with iredis --iredisrc $YOUR_PATH)
  • /etc/iredisrc
  • default config in IRedis package.

客户端软件的配置最好遵守 XDG Base Directory Specification

 

Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.

让配置更加简单一些,在设计的时候,可以考虑这几个问题:

  1. 这个配置是否有必要?用户在大部分场景下是否用默认值就足够了?
  2. 多个配置是否可能用一个配置没有歧义地讲清楚?
  3. 可以这样考虑:添加一个新的配置的时候,如何使用最少的语言将这个配置解释清楚?

在 UI 的设计中,每一个像素都是重要的,每一个空间都要想办法争取节省,添加太多没必要的东西,将会把UI变得不直观。配置也是,配置不是一个无限大的文件,配置的添加是会带来成本的,不是程序运行的成本,而是人的成本,这比运行的性能更加重要。

说到动态配置,很多时候我都在怀疑这到底是不是一个伪需求。现在的应用都被设计成无状态的,可以随时重启的。那么我修改配置的时候,是否可以改一下配置文件然后重新部署?当然,如果有上千个实例的话,修改速度可能会成为一个问题。

有动态配置功能的时候,一个误区是开发者会将所有“感觉将来可能会修改”的配置都放到动态配置中。其实我觉得所有的配置都可以先作为 hardcode,如果发现需要修改的多了,再移动到配置文件或者动态配置中。

Json 绝对是一个糟糕的配置语言。它的优点在于机器读取和解析没有歧义,不像 Yaml 那样。

Why JSON isn’t a Good Configuration Language

但是它对于人类编辑来说,实在太不友好了:

  • json 只是易于解析但是太难编辑,非常容易出错。比如 [] 不允许在末位添加 ,,否则会出现格式错误。这样,你每次编辑的时候,添加一行会出现两行 diff.
  • json in json 更是噩梦。我发现一个规律,就是在有 json 的地方,就会有 json in json(指的是 json 里面有一些 value,是字符串,字符串只是添加了转义的 json)。甚至有可能有 json in json in json.
  • 比如很多list都设计成 items:[{key: name, value:jack}],一个list 这已经就已经有两层的嵌套了。
  • Json 对于 line editor,比如 sed,awk 也不够友好。
  • 不支持注释,导致无法在文档里面给出一个 self-explain 的 example.

 

If the implementation is hard to explain, it’s a bad idea.
If the implementation is easy to explain, it may be a good idea.

很多配置的名字都和实现绑定,可能是一个思维定式。为一个配置想出来一个好的名字,可以尝试忘记实现的细节,想一想如何能够直白、没有歧义地、容易被记住地解释这个配置。

一个典型的例子是,配置尽量不要使用双重否定。比如 disallow_publish,最好使用 allow_publish.

uWSGI 里面有一个配置叫做 harakiri,是日语的“切腹自尽”的意思,表示一个进程在规定的时间内如果没有完成请求的处理,就会退出。比较贴切。

Redis 的 save 触发时间的配置我觉得设计的很好:

可惜的是,这种设计好像没有什么模式可以遵循。

后记:其实这篇文章放在草稿箱里面很久了,只是有一些想法,但是不知道怎么描述比较合适。配置如同给变量起名字一样,是一个难以说清的话题。本文也只是潦草的表达了一些凌乱的想法,看了设计服务端软件配置的 4 条建议 这篇文章,决定将草稿箱里面沉睡已久的文章发出来。欢迎读者交流沟通。