zookeeper在公网上的数据安全

在上一篇文章中提到过如何保证zookeeper在节点访问控制上的ACL实现机制。这种机制依赖于zookeeper的配置和支持,今天看了下阿里云的zookeeper引擎和相关的API,大致猜测阿里云是怎么实现zookeeper的公网访问安全。

方法很简单,就是计算机里经常说的,加一层代理。

zookeeper部署在阿里云内部网络,通过防火墙或者物理网络隔离,保证只有代理能够访问zookeeper服务。

在代理上实现一套服务,对外部以Restful接口的形式,封装了一系列对zk节点进行增删读写的接口,这些接口需要通过阿里云的公共鉴权才能调用成功。

也就是说,用阿里云的鉴权保证zookeeper读写安全。

但是这种方式也有一个没有解决的问题:就目前阿里云提供的API接口来看,zookeeper的watch机制是完全用不上了。

如果应用依赖于zookeeper的watch机制,那么可能就需要改造成轮训的方式去实现,但是这样又会有一个问题,因为代理的存在,zookeeper对调用阿里云Restful接口的客户端来说,客户端最后拿到的应答结果,不代表zookeeper真实的状态。

zookeeper数据密码保护验证

package main

import (
	"fmt"
	"time"

	"github.com/samuel/go-zookeeper/zk"
)

func getzkConnection() *zk.Conn {
	conn, _, err := zk.Connect([]string{"127.0.0.1:2283"}, 5*time.Second)
	if err != nil {
		panic(err)
	}
	return conn
}

func testZookeeperAuth() {
	path, user, pwd := "/auth_test", "panxie", "123456"
	conn := getzkConnection()
	acl := zk.DigestACL(zk.PermAll, user, pwd)
	// 创建节点,带auth
	p, err := conn.Create(path, []byte("hello,world"), 0, zk.WorldACL(zk.PermAll))
	if path != p || err != nil {
		panic(err.Error() + p)
	}
	p, err = conn.Create(path+"/hello", []byte("hello,world"), 0, acl)
	if path+"/hello" != p || err != nil {
		panic(err.Error() + p)
	}
	conn.SetACL(path, acl, -1)
	// 读取节点,不带auth
	_, _, err = conn.Get(path)
	if err == nil {
		panic("read content without auth but no error occured.")
	}
	// 读取节点,带auth
	err = conn.AddAuth("digest", []byte(fmt.Sprintf("%s:%s", user, pwd)))
	if err != nil {
		panic(err)
	}
	cont, _, err := conn.Get(path)
	if err != nil {
		panic(err)
	}
	fmt.Printf("content read:%s\n", string(cont))

	conn.Close()
	conn = getzkConnection()
	// 删除节点,不带auth
	err = conn.Delete(path+"/hello", -1)
	if err == nil {
		panic("delete node without auth but no error occured.")
	}
	// 删除节点,带auth
	err = conn.AddAuth("digest", []byte(fmt.Sprintf("%s:%s", user, pwd)))
	if err != nil {
		panic(err)
	}
	err = conn.Delete(path+"/hello", -1)
	if err != nil {
		panic(err)
	}
	// 读取节点的ACL
	conn.SetACL(path, zk.WorldACL(zk.PermAll), -1)
	acls, _, err := conn.GetACL(path)
	if err != nil {
		panic(err)
	}
	fmt.Printf("acl get=%v\n", acls)
	// 设置已有节点的ACL
	_, err = conn.SetACL(path, acl, -1)
	if err != nil {
		panic(err)
	}
	// 获取已有节点的ACL
	acls, _, err = conn.GetACL(path)
	if err != nil {
		panic(err)
	}
	fmt.Printf("acl get=%v\n", acls)
}

func main() {
	testZookeeperAuth()
}

输出如下:

[root@VM_15_146_centos ~/shuidi/src/srv_module/sdi/tools/lex]# go run main.go 
2020/11/03 14:53:42 Connected to 127.0.0.1:2283
2020/11/03 14:53:42 authenticated: id=218273669347934225, timeout=4000000
2020/11/03 14:53:42 re-submitting `0` credentials after reconnect
content read:hello,world
2020/11/03 14:53:42 recv loop terminated: err=EOF
2020/11/03 14:53:42 send loop terminated: err=<nil>
2020/11/03 14:53:42 Connected to 127.0.0.1:2283
2020/11/03 14:53:42 authenticated: id=218273669347934226, timeout=4000000
2020/11/03 14:53:42 re-submitting `0` credentials after reconnect
acl get=[{31 world anyone}]
acl get=[{31 digest panxie:ls2Y30BnQBe3KJYoYp1ylpd7oTo=}]

符合预期,基于digest的身份认证生效。

zookeeper密码权限验证

假定使用账户密码为panxie:123456,那么zookeeper生成的鉴权字段为BASE64(SHA1(panxie+123456)),使用zookeeper自带的工具生成鉴权字段auth,命令为:

java -Djava.ext.dirs=/home/oicq/zkcluster/zk1/lib -cp /home/oicq/zkcluster/zk1/zookeeper-3.4.14.jar org.apache.zookeeper.server.auth.DigestAuthenticationProvider panxie:123456

输出结果为:

panxie:123456->panxie:ls2Y30BnQBe3KJYoYp1ylpd7oTo=

对于不存在的节点,使用create创建节点时指定auth,命令为:

create "/auth_test" "hello,world" digest:panxie:ls2Y30BnQBe3KJYoYp1ylpd7oTo=:cdrwa

最后的cdrwa对应的权限表格如下。

至此,访问/auth_test节点之前必须使用命令addauth来完成认证,否则会提示鉴权无效。

[zk: 127.0.0.1:2283(CONNECTED) 3] get /auth_test
Authentication is not valid : /auth_test

addauth使用方法如下:

addauth digest panxie:123456

完后认证后再次访问时成功。

[zk: 127.0.0.1:2283(CONNECTED) 8] get /auth_test                                                   
hello,world
cZxid = 0x1000000e54
ctime = Tue Nov 03 10:05:25 CST 2020
mZxid = 0x1000000e54
mtime = Tue Nov 03 10:05:25 CST 2020
pZxid = 0x1000000e54
cversion = 0
dataVersion = 0
aclVersion = 0
ephemeralOwner = 0x0
dataLength = 11
numChildren = 0

golang json.Unmarshal()不清除结构体原有变量的值

背景
在某项目中,由于技术升级或者提升产品适用场景,需要对寻址参数进行修改,公司内寻址用的modid和cmdid两个整数统一使用字符串addr替代,便于兼容ipvs等。
为减少兼容代码,存储在zookeeper中的存量配置数据需要做结构转换,这活我干了。

故障代码

多么简单单纯的代码,每天都这样搬砖,职业生涯是道不了35岁的,也许30岁就可以退休了。

func (act *AccessCfgsTool) convert() {
	// 读出Task信息后,清理掉L5的两个整数后写回
        var task mc_proto.Task
	for _, v := range act.taskIDs {		
		//处理access_cfg配置
		path := fmt.Sprintf("%s/%d", TASK_PATH, v)
		fmt.Printf("converting %s.\n", path)
		_, err := zkutil.ZkReadJson(act.conn.conn, path, &task)		
		//做转换处理 XXXXXXXX
		err = zkutil.ZkUpdateJson(act.conn.conn, path, -1, &task)		
		fmt.Printf("convert %s done.\n", path)
	}
}

故障现象

在测试环境上线后,过了几天,一位大哥跟我说,数据怎么是乱的,为什么这个任务的配置上,会有其他任务的信息?

我蒙蔽了,但是无知让我无畏:怎么可能,一定是你们做了什么操作,搞乱了。
外强中干的我赶紧看看转换代码,发现了可能的原因,并简单的测试了一下。

func TestUnMarshal() {
   type T struct {
      A string
      B string
   }
   t := T{A: "hello,world,A"}
   str := "{\"B\":\"hello,world,B\"}"
   json.Unmarshal([]byte(str), &t)
   fmt.Printf("%v\n", t)
}

D:\xxx\src\paper>go run main.go
{hello,world,A hello,world,B}

故障原因

到这里大概都知道故障的原因了,对转换代码做下修正。

func (act *AccessCfgsTool) convert() {
	// 读出Task信息后,清理掉L5的两个整数后写回
        //移动到下面第二行 var task mc_proto.Task
	for _, v := range act.taskIDs {		
                var task mc_proto.Task
		//处理access_cfg配置
		path := fmt.Sprintf("%s/%d", TASK_PATH, v)
		fmt.Printf("converting %s.\n", path)
		_, err := zkutil.ZkReadJson(act.conn.conn, path, &task)		
		//做转换处理 XXXXXXXX
		err = zkutil.ZkUpdateJson(act.conn.conn, path, -1, &task)		
		fmt.Printf("convert %s done.\n", path)
	}
}

问题解决了。

结论

golang json.Unmarshal()不会替我们清除已有变量的值,只会用解析出的值进行覆盖赋值。

zookeeper Watch丢通知故障的定位

在下面的描述中,ZK指的是zookeeper,Watch丢通知故障简称为丢消息,因个人水平的原因,文章中定位出的原因,未必是真实的原因,仅供参考。

背景介绍

在我深度参与的一个计算平台项目中,团队第一次使用ZK作为配置中心,ZK的功能:(1)存储和固化配置;(2)在配置发生更新的时候,通知多个工作节点拉取新的配置。这两项是ZK作为分布式服务框架最常用的功能之一。项目使用github上开源的go-zookeeper库来实现ZK的操作,库地址为:github.com/samuel/go-zookeeper/zk

工作节点任务的升级,依赖于ZK通过Watch消息通知给客户端代理,以下简称agent,agent是由我开发维护的模块。ZK一共3个节点,按照IP最后的数字,分别命名为144、227、229。故事发生在agent和三台ZK服务器之间。

系统简化结构

故障现象

用户在客户端执行一些配置更新后,经常反馈计算节点的配置没有更新成功,还在跑着旧版本。登录计算节点查看日志,可以发现在用户执行更新后的几分钟内,Agent没有进入任何通知消息的回调处理。故障的紧急恢复采用的方法是重启agent,重启后会全量拉取新的配置。另外注意到,重启后一段时间内(在几个小时到几天不等),可以正常收到ZK的消息。

定位过程

首先简单介绍代码。在zk.Connect连接上conf.ZkHost中的某一台ZK节点后,在go-zookeeper的sendLoop中会按照指定的时间间隔,由agent主动发起ping操作并等待应答。没有收到或者收到错误的应答之后,连接将被关闭,并且在一个for循环中主动去尝试conf.ZkHost中的其他节点,如果迅速恢复,使用的sessionid不发生变化。

//连接ZK服务器,注册回调函数
if agent.zkConn, _, err = zk.Connect(conf.ZkHost, time.Second, 
                           zk.WithEventCallback(ZkCallback), 
                           zk.WithMaxBufferSize(10*1024*1024), 
                           zk.WithMaxConnBufferSize(10*1024*1024)); 
                           err != nil {
   Logger.LogError("connect %v err, %s", conf.ZkHost, err)  
}
//ZK回调函数
func ZkCallback(ev zk.Event) {
	go agent.HandleEvent(ev)
}
//ZK消息处理函数
func (m *McAgent) HandleEvent(ev zk.Event) {	
	switch ev.Type {
	case zk.EventNodeChildrenChanged:
		//
	case zk.EventNodeDataChanged:
		//
	case zk.EventNodeDeleted:
		//	
	}
}

根据代码,结合go-zookeeper实现,发现了第一个问题:没有处理ZK的状态变化消息。在agent与ZK节点之间由于网络偶发故障或延时,导致Agent ping ZK节点失败的时候,ZK会连接其他ZK节点,这时候之前通过GetW方式注册的Watch事件会全部丢失。导致这个Agent再也收不到原先监控的ZK节点变化的消息。

针对这个故障,考虑到在网络故障的短暂时间内存在丢消息的可能,因此解决方案比较直接:

func (m *McAgent) HandleEvent(ev zk.Event) {
   
   switch ev.Type {
   case zk.EventNodeChildrenChanged:
      //
   case zk.EventNodeDataChanged:
      //
   case zk.EventNodeDeleted:
     //
   case zk.EventSession:
      if ev.State == zk.StateExpired {
        //继续使用本地缓存的数据,可能是脏的
        //打告警
      } else if ev.State == zk.StateConnected {
        //从ZK全量拉取新数据,重新注册Watch
      }
   }
}

作为一个严谨求实的程序员,本着对自己代码高度负责任的态度,我使用iptables模拟网络断开重连的情况,能及时的给出告警,并且可以自动重连,重连之后还能实时收到ZK服务器发出的事件通知。

iptables -A OUTPUT -p tcp --dport 2181 -j DROP
iptables -D OUTPUT -p tcp --dport 2181 -j DROP

更新上线,平静了好几天,没人找我说怎么任务又更新失败了。完美。

在我以为这事终于消停之后,又偶尔有用户在群里怼我,说有更新失败的情况,但是我没有收到任何告警信息。感觉颜面扫地,年底可以one星走人了。不过离年底还有点时间,先再找找原因。

从故障Agent的日志看,没有任何异常,也没有任何ZK连接变化相关的日志信息。去ZK节点上捞取日志,通过一系列检索过程,发现了故障场景的共性。

故障相关的ZK节点日志

日志不太直观,下面的图形更直观一些。简单的说,就是Agent所连接的ZK服务器,在静默的情况下,由一台(144)迅速迁移到了另一台(227),使用相同的sessionid重建与新服务器的连接。由于客户端注册的Watch在ZK服务器上是以本地存储的方式记录,并没有同步给其他机器。因此,在连接静默迁移到新的服务器之后,Watch又丢掉了。所谓静默迁移,就是agent端没有收到连接变化相关的任何回调消息。这可能是go-zookeeper的bug,但是时间精力原因,没有继续深入下去了。

故障过程描述

解决方案

从ZK相关的文档可以看出,ZK消息并不保证一定送达,在网络短暂故障的重连期间,仍然可能存在消息丢失的情况,所以在ZK服务器压力不大并且数据不大的情况下,彻底的解决方案是废弃Watch机制不用,周期性全量拉取ZK数据。

使用定期全量拉取数据之后,类似问题再也没有出现过。

优化空间

在ZK节点数量比较多、节点数据比较大的情况下,大量重新拉去数据会给ZK服务器和网络带来压力。准备执行的优化是设置一个节点,存储数据版本信息,在数据版本未发生变化的时候,不执行重新拉取的操作。

还有一个方向是去调试go-zookeeper代码,比较简单的方式是在其中起连接ZK IP的监控代码,在调用Next函数的时候,使用setState方式发送一下连接变化的消息。但是由于通过网络传输的消息存在丢失的可能,这仍然不是最终的解决方法。

再次强调,相关结论存在模糊和不清楚的地方,不要轻信。有更好方法的,请留言告知。