前言

利用kubernetes部署应用越来越流行,而运行在kubernetes中的服务需要的各种各样的配置如何才能实现热更新?难道需要在kubernetes中再部署zookeeper或者etcd之类的服务么?本文采用的方案是利用ConfigMap作为服务配置的持久化方案,并利用kubernetes提供的watch能力主动发现ConfigMap更新并及时更新到服务的配置中。这样运维人员只需要利用kubernetes的控制台(cli或者web)修改线上服务的配置,比如修改日志等级、降级、调整阈值等等。

本文引用源码https://github.com/jindezgm/konfig/blob/master/konfig.go

实现

konfig是利用kubernetes的一个ConfigMap实现的一个配置树,虽然ConfigMap.Data是map[string]string类型,但是konfig会对ConfigMap.Data中的值做进一步(递归)yaml解析,前提条件是值是以"---\n"开头。这样设计的目的是让konfig支持多级配置,而ConfigMap只有一级在一些使用场景并不友好。当然,利用一级也是可以实现多级配置,只是把多级体现在key上,例如:"a.b.c", "c.d",笔者认为视觉上不太优雅。

konfig支持以多种类型获取相同的配置,可以根据需要转换成指定类型,如下接口定义所示:

// 所有接口都会返回配置的版本号,即ConfigMap.ResourceVersion,keys是多级的key,当keys为空时表示根,即整个ConfigMap.
type Interface interface {
    // 如果keys已经被注册某种类型(参看下面的RegValue接口),则返回指定类型的值,否则返回原生类型的值。
	Get(keys ...string) (interface{}, int64)
    // 获取布尔型
	GetBool(keys ...string) (bool, int64)
    // 获取64、32位整型、
	GetInt64(keys ...string) (int64, int64)
	GetInt(keys ...string) (int, int64)
	GetInt32(keys ...string) (int32, int64)
    // 获取64、32位浮点型
	GetFloat64(keys ...string) (float64, int64)
	GetFloat32(keys ...string) (float32, int64)
    // 获取字符串
	GetString(keys ...string) (string, int64)
    // 将指定keys下的值注册为一种类型,配合Get()接口使用可以将keys下的值转换为注册的类型返回,其中tag是成员变量的tag名称,比如json
    RegValue(ptr interface{}, tag string, keys ...string) (interface{}, int64)
    // 获取指定类型的value,konfig会将map[string]interface{}转换为value对象,其中tag是成员变量的tag名称,比如json.
    GetValue(ptr interface{}, tag string, keys ...string) int64
    // 将指定的keys下值挂载/卸载到环境变量,
    MountEnv(keys ...string)
	UnmountEnv()
    // 获取版本号
	Revision() int64
}

konfig在Get指定类型的配置时,除了原生类型外,尽最大努力将原生类型转为指定类型,以下是konfig支持的类型转换:

  • int,int32,int64:支持浮点型转整型、支持字符串转整型(string->float64->intxx)、支持布尔型转整型(true:1,false:0)
  • float32,float64:支持字符串转浮点型
  • bool:支持整型、浮点型转布尔型(非零:true,零:false),支持字符串转布尔型(不区分大小写的"True":true,不区分大小写的"False":false)
  • string:支持所有类型转字符串,采用fmt.Sprintf("%v")返回

konfig保证了单个接口的原子性,但是如果连续调用两次接口可能会返回两个不同的版本,说明在两次调用接口之间ConfigMap发生了变化。如果两次调用接口获得的配置参数对于版本一致性要求比较高的话,就需要重新调用,直到所有的配置的版本相同为止。这种情况发生的概率比较低,并且绝大部分重新调用一次就可以解决,因为ConfigMap的更新频率极低。但是,这种方法貌似有点丑陋,使用者可以将此类的配置定义一种struct,然后通过GetValue()一次性拿到所有的配置。笔者常用的方法就是把整个ConfigMap定义为一个类型,然后一次性读取所有的配置。如下代码所示:

import "github.com/jindezgm/konfig"

// 定义自己的配置类型
type MyConfig struct {
    Bool   bool   `json:"bool"`
    Int    int    `json:"int"`
    String string `json:"string"`
}

kfg, _ := konfig.NewWithClientset(...)
var my MyStruct 
var rev int64

// 应用引用配置的功能实现
for {
    // 版本发生更新时重新获取配置
    if r := kfg.Revision(); r > rev {
        // 此处的keys为空,这是将整个ConfigMap.Data映射为MyStruct
        rev = kfg.GetValue(&my, "json")
    }
    // 使用配置
    ...
}

因为GetValue()接口会执行一次类似Unmarshal的过程,所以是有一定开销的,适用于调用频率不高的场景。如果需要高频调用,建议应用缓存配置(如上代码),并根据revision决定是否调用该接口。如果应用想省去这些麻烦的操作,那就调用RegValue()接口将类型注册到konfig,由konfig按需解析,在配合Get()接口就可以满足高频调用的场景了。如下代码所示:

// 将"my"下的所有值注册为MyConfig类型
kfg.RegValue(&MyConfig{}, "json", "my")
for {
    // 每次引用直接调用Get,konfig保证一致性、隔离性以及原子性
    value, _ = kfg.Get("my")
    my := value.(*MyConfig)
    ...
}

在一些场景,某个功能点只需要引用一个配置项,使用者每次引用时可以直接调用接口,不用在自己的代码中缓存配置(当revision变大再读取配置更新缓存),因为konfig的读取性能还是有保证的。如下代码所示,按配置打印:

for {
    if p, _ := kfg.GetBool("print"); p {
        fmt.Println("Hello world")
    }
}

当然,如果习惯读取环境变量的方法获取配置,而容器更新环境变量又会造成容器重启,那么可以用MountEnv()接口将配置挂载到环境变量,如下代码所示:

// keys为空,将ConfigMap.Data挂载到环境变量. 需要注意的是,MountEnv()的keys下应该只有一级配置,如果是多级,konfig会用fmt.Sprintf("%v")进行格式化
kfg.MountEnv() 
defer kfg.UnmountEnv()
for {
    if strings.ToLower(os.Getenv("print")) == "true" {
        fmt.Println("Hello world")
    }
}

konfig支持两种创建方式:1.利用Clientset;2.利用SharedInformerFactory。前者适用于应用无需不访问kubernetes场景,需要为konfig单独创建一次clientset,这部分可以参考官方实例代码;后者适用于应用需要访问kubernetes的场景,那么konfig与应用共享Informer。无论哪一种情况,都需要授权pod读取ConfigMap的权限。

那么,问题来了,为什么不用ConfigMap挂在成文件的方式?答:时效性不好,因为ConfigMap更新到Pod内文件更新可能需要数秒钟(我记忆中好像是10秒),如果线上需要紧急更新配置(比如降级处理)不是很好用,而konfig就没有这个问题。

不足

  1. 当前konfig递归解析只支持yaml格式,其实json也很容易支持,感觉不是很必要;
  2. konfig不支持回调,即ConfigMap更新后,将变化的部分回调给用户,当前的解决方式是通过revision解决;
  3. GetValue()虽然能够一次获取多个配置,但是需要所有的配置都在一个键下,如果一次获取配置树不同分支的多个配置,konfig还不支持,感觉可以用flag.FlagSet实现;
  4. MountEnv()不会比较两次配置的差异,然后删除新ConfigMap中没有的配置,他只是简单的将每次ConfigMap中的值设置到环境变量中;而UnmountEnv()也不会删除环境变量,只是不再更新环境变量而已;这些都可以实现,只是暂时还没有看到必要性;