fightclub

saltstack中的设计模式(二)

上一篇文章中,
我用一个了案例展示了如何将外部系统的数据应用引入到salt里面,
以及通过自开发state扩展的方式实现底层逻辑,并用sls实现业务逻辑。

这次,我们将举例说明在涉及与外部系统进行数据交互的时候,如何利用salt对外部系统进行数据更新。

接下来就进入今天的案例

自动维护主机在jumpserver中的数据

jumpserver,就是所谓的跳板机。普通用户在申请好自己的服务器后,可以通过跳板机登陆自己的系统。
跳板机上面实现了很多安全逻辑,用以实现隔离与命令审计等功能。

这次的任务,我们需要做到以下几点:

  • 能够将主机的信息自动的注册进jumpserver,在主机数据变更的时候,也能进行数据更新
  • 在主机删除的时候,jumpserver的数据也能得到删除

首先,我们设想一下各种方案的可行性。我们可以采取如下的方案中的一种:

  1. 使用pillar + py renderer的方式update数据
  2. 自定义一个execution module, 并使用salt调度器周期性执行
  3. 自定义一个state module,把主要的逻辑写在module中,用sls去调用
  4. 使用sls调用写好的外部程序

对于第一种方案,我们已经在上一篇文章中使用过,既然渲染器能执行python代码,也能够直接执行update操作。但是问题在于在pillar的渲染器中,我们没办法动态的引用其他pillar里面的数据。这涉及到salt核心部分的执行逻辑。我们这次需要将aws pillar中获得的数据一部分导入到jumpserver中。因此不能同样的采用pillar渲染器去执行。

剩下的三种方案中,我更倾向在扩展的module中实现底层功能,把业务逻辑放置在sls中。底层module就像乐高积木的每一个单元,
而业务逻辑,就是把这些积木组装在一起。除了可读性以外,对于业务逻辑来说,主要要面对的问题在于经常的会变更,把业务逻辑写在扩展中,
需要将服务器端的module重新同步到客户端才能生效,而sls是实时生效且更轻量级,更适合快速开发的场景。基于这个考虑,最后一个方案更为合适。

接下来,我们可以考虑的实现方式是把对接jumpserver的api写在scripts中, 在sls中使用cmd.run去调用。但是最大的问题在于如何有效的进行安全隔离,让每个minion只能更新自己的数据,不能获得其他minion的数据,这会形成安全隐患。如果jumpserver 的api本身实现不了这些功能,那么可能需要重新做一个api server,
对jumpserver的api进行封装,实现安全的逻辑。这是可行的,虽然比较麻烦。

我们这边文章主要是基于salt现有的组件,所以肯定是以通过salt实现为最优先选择。条条大路通罗马,但是我们只选择最快捷的方式。

最后我决定采用的方式是使用sls + event -> reactor -> runner来实现这个功能

步骤如下:

编写salt runner用来对接jumpserver api

salt runner是在服务端执行的python程序,salt把runner中的某一个函数映射到salt的一组调用,并能够和salt的其他组件联动。
开发者几乎减去了所有不必要的代码,只需要关注业务逻辑即可。

因为涉及到业务逻辑,我将代码隐藏起来,只写了基本的逻辑。

runner的路径需要单独在master配置文件进行配置,需要注意的是,对于不需要使用 fileserver的,也就是通过salt://xx暴露出来的文件,
都不要放在salt fileserver的路径下面,避免出现安全上的隐患。

/srv/_runners/jumpserver.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def add_host(hostname, ipaddress, port=22, other_info=None):
'''
add host to jumpserver
'''
ret = {'result': True, 'retcode': 0, 'comment': ''}
return ret
def get_host(hostname=None, ipaddress=None):
'''
get host from jumpserver
'''
ret = {'result': True, 'retcode': 0, 'comment': ''}
return ret
def remove_host(hostname):
'''
remove host from jumpserver
'''
ret = {'result': True, 'retcode': 0, 'comment': ''}
return ret
def reset_host(ipaddress, other_info=None):
'''
reset jumpserver information
'''
ret = {'result': True, 'retcode': 0, 'comment': ''}
return ret

runner函数写好以后,可以通过salt-run module.function [args]的方式来直接调用写好的runner

编写pillar,让客户端拿到jumpserver服务器的信息

和上一篇文章中用到的方法是一样的。
不过有一个问题特别值得注意,就是在缓存中我们选择了主机的ip地址为主键,这是因为对于我们的场景,
主机名变更的比ip地址更换的更加频繁。

而对于salt来说,我们使用主机名称作为主键。我们对主机名称使用了特别的编码,让主机名称包含有结构化信息。
方便salt进行定位。

主键不同,需要付出很多额外的代码。因此在设计主键时,要全方面的考虑到各种情况。

/srv/pillar/jumpserver/init.sls

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!py
try:
from redis_cache import *
cache = SimpleCache(50000, expire=7200, namespace='jumpserver')
HAS_CACHE = True
except:
HAS_CACHE = False
def run():
ret = {}
ip = [ip for ip in __grains__['ipv4'] if ip.startswith("10.")][0]
try:
ret = cache.get_json(ip)
except:
try:
import salt.runner
runner = salt.runner.RunnerClient(__opts__)
info = runner.cmd(fun='jumpserver.get_host', kwarg={'ipaddress': ip}, print_event=False)
ret = info['data'][0]
if HAS_CACHE:
cache.store_json(ip, ret)
except:
pass
return {'jumpserver': ret}

在代码中,我们直接引用了写好的runner程序获得jumpserver的数据。
我们把缓存的逻辑处理放在pillar里面,get_host函数里面就可以不通过缓存。

通过自定义grains module挖掘minion上的信息

jumpserver需要知道minion的端口号作为其中一个参数。我们可以使用grains,先将这部分数据挖掘出来。
而不必每次都去执行对应的逻辑,如果其他任务也用到这部分数据,也可以从grains中提取

/srv/salt/_grains/sshd.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/usr/bin/env python
def _sshd_conf(conf_file):
'''
parse sshd config file
'''
sshdconf = {'enable_passwd': True, 'permit_root': True, 'port': 22}
try:
with open(conf_file, 'r') as f:
for l in f:
if l.startswith('Port'):
sshdconf['port'] = int(l.split()[1].lower())
if l.startswith('PasswordAuthentication'):
sshdconf['enable_passwd'] = True if l.split()[1].lower() == 'yes' else False
elif l.startswith('PermitRootLogin'):
sshdconf['permit_root'] = True if l.split()[1].lower() == 'yes' else False
break
except:
return None
return sshdconf
def run():
grains = {}
sshd = _sshd_conf('/etc/ssh/sshd_config')
if sshd:
grains['sshd'] = sshd
return grains

在sls中使用py render实现复杂逻辑

我们在sls中,需要实现的是通过event.send将信息通过salt的事件系统传送到salt-master进行处理。

sls默认的渲染器是jinja + yaml. 这本身可以实现大部分的逻辑功能,但是在某些异常复杂的场景下,依然会显得力不从心。
jinja只是一个模板语言,表达能力有限,用py渲染器反而会简单许多。

/srv/salt/jobs/jumpserver/init.sls

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#!py
def run():
jump = __pillar__.get('jumpserver', {})
aws = __pillar__.get('aws', {})
qcloud = __pillar__.get('qcloud', {})
current_info = {}
other_info = {}
other_info['os'] = __grains__['os']
other_info['os_version'] = __grains__['osrelease']
other_info['os_arch'] = __grains__['osarch']
ip = [ip for ip in __grains__['ipv4'] if ip.startswith('10.')][0]
hostname = __grains__['id']
port = __salt__['grains.get']('sshd:port', 22)
if aws:
ip = aws['PrivateIpAddress']
hostname = aws['Tags']['Name']
other_info['vendor'] = 'aws'
other_info['model'] = aws['InstanceType']
public_ip = aws.get('PublicIpAddress')
if public_ip:
other_info['public_ip'] = public_ip
created_by = aws['Tags'].get('Creator')
if created_by:
other_info['created_by'] = created_by
elif qcloud:
ip = qcloud['PrivateIpAddresses'][0]
hostname = qcloud['InstanceName']
other_info['vendor'] = 'qcloud'
public_ips = qcloud.get('PublicIpAddresses')
if public_ips:
other_info['public_ip'] = public_ips[0]
created_by = qcloud['Tags'].get('Creator')
if created_by:
other_info['created_by'] = created_by
current_info['ipaddress'] = ip
if __salt__['config.get']('master') != __salt__['pillar.get']('salt_info:core_master'):
current_info['_syndic_handle'] = True
if not jump:
current_info['hostname'] = hostname
current_info['port'] = port
current_info['other_info'] = other_info
ret = {'jumpserver/add_host': {'event.send': [{'data': current_info}]}}
return ret
else:
if hostname != jump.get('hostname'):
other_info['hostname'] = hostname
if port != jump.get('port'):
other_info['port'] = port
fix_info = {k:other_info[k] for k in other_info.keys() if other_info[k] != jump[k]}
if fix_info:
current_info['other_info'] = fix_info
ret = {'jumpserver/reset_host': {'event.send': [{'data': current_info}]}}
return ret

我们将grains中的数据,以及从pillar中取出来的数据,同现有的jumpserver的数据进行比较。
因为我们使用的云平台不同,所以获取数据的渠道也有别。aws 的pillar的数据,就是在我们上一篇文章中介绍的做法。
最后根据实际情况,使用jumpserver/add_host或者jumpserver/reset_host。

配置并编辑reactor

salt reactor是一个快捷的事件驱动框架。我们只需要定义一个event和reactor的映射关系,就可以构建一个事件处理系统

reactor配置:

/etc/salt/master.d/reactor.conf

1
2
3
4
5
reator:
- 'jumpserver/add_host':
- /srv/reactor/jumpserver/add_host.sls
- 'jumpserver/reset_host':
- /srv/reactor/jumpserver/reset_host.sls

reactor编写:

/srv/reactor/jumpserver/add_host.sls

1
2
3
4
5
6
7
8
9
10
11
{% if not "_syndic_handle" in data["data"] or "salt-syndic" in salt["grains.get"]("roles") %}
add_host_to_jumpserver:
runner.jumpserver.add_host:
- args:
hostname: {{ data["id"] }}
ipaddress: {{ data["data"]["ipaddress"] }}
port: {{ data["data"]["port"] }}
{% if "other_info" in data["data"] %}
other_info: {{ data["data"]["other_info"] | tojson }}
{% endif %}
{% endif %}

/srv/reactor/jumpserver/reset_host.sls

1
2
3
4
5
6
7
{% if not "_syndic_handle" in data["data"] or "salt-syndic" in salt["grains.get"]("roles") %}
jumpserver_reset_host:
runner.jumpserver.reset_host:
- args:
ipaddress: {{ data["data"]["ipaddress"] }}
other_info: {{ data["data"]["other_info"] | tojson }}
{% endif %}

在reator中,我们将事件直接映射到salt runner。如果有复杂的场景,我们可以把reactor映射到salt的orchestrater系统。
具体用法,可以参考salt官方文档,不再赘述。

这里面有几个问题值得注意一下。 我们用到一个_syndic_handle的参数,这个原因我会再起一篇文章进行描述,暂且不表。
还有一个就是tojson的 jinja过滤器。这是salt2018.3引入的过滤器,用以解决sls不直接支持字典的问题。

处理主机删除后的jumpserver的垃圾清除

这是最后一步工作了。因为主机已经删除了,所以肯定不能通过sls来调用了。
还记得我们在上一篇文章有一个垃圾处理的外部脚本,我们只需要把runner中的remove_host函数附加到主机删除的过程里面就可以了

总结

在本次的案例中,我们采用的技术方案有

  • 使用salt runner作为service,快速的搭建一个自动处理服务
  • 使用在sls中使用py renderer来实现比较更加复杂的逻辑
  • 使用自定义grains,将客户端数据采集到salt中,供salt其他任务使用
  • 使用reactor解决安全问题,每个发出的事件,都由salt认证主机id。不过salt event系统中,并不包含minion的ip地址,因此没办法直接认证ip地址。需要对salt event进行一定的改良