写在前面
最近Ansible很火,作为一个后起之秀,在github上他的commitor人数已经超过鼻祖puppet两倍多了,相信很多人也尝试过使用这款工具,如果你所管理的环境足够简单(模块没上50个?),足够变化足够慢(半年不用动?),那么也许你并不需要阅读本文,因为简单的静态配置就可以搞定你的需求了,请参考官方文档。
更多的人遇到的是这样的情形:机器和分组信息需要从服务A中动态获取;资产信息需要从服务B中动态获取…诸如此类,但是我们运行时,往往需要以这些信息作为分组依据来管理。这时候,用静态hosts文件来管理就不现实了。幸运的是,ansible支持动态inventory script,不过官方对这部分的描述甚为模糊,甚至连返回的结构体应该是什么样子都没有提,造成很多同学只能望洋兴叹。本文就以我们自己写一个inventory script的过程,给大家分享下inventory 脚本的写法和要求。
一、背景
在我米,机器的属性是以tag来定义的,通过组合tag就可以唯一的确定一批具有相同属性的机器,这些属性包括但不限于产品划分,服务架构,机器硬件,流程状态,机器归属,控制部署等任何方面,这些tag由一个专门的系统(xbox)来维护和变更。
举例来说,我如何找出一台属于小米公司米聊部门A产品线X服务fe(前端)子系统中m机房正在服务的nginx机器列表呢?我会通过类似如下一个tag串来查询它(仅作示例,司内莫要对号入座):
com.xiaomi_dept.miliao_pdl.A_service.X.sbs.fe_idc.m_job.nginx_status.service
其中com,dept,pdl,service,sbs,job,status,分别是属性中公司,部门产品线,服务,子系统,原子服务,在线状态的tag标签。当我把这个串+token发给系统后,系统会找出在我权限范围内,包含所有这些标签,并且其对应属性值与tag串指明值相等的机器集合,于是我们得到了想要的机器列表。
二、与ansible的结合
在考虑与ansible结合之前,搞清楚ansible本身如何获取、筛选机器列表并考虑与我们自己系统合适的对接是很有必要的,在开始时,我们只是以最粗暴直接的方式修改ansible源码,虽然快速的满足了需求,但也造成一个很严重的后果——给升级带来很大代价,因此最终我们放弃了这个版本。
1. 前期调研和规划
首先我们来看下ansible是怎么调用到inventory脚本的,详细的列出追踪过程无论对作者还是读者都是一个极其痛苦的事情,因此我决定在这里只列出关键代码段:
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 |
def run(self, options, args): ''' use Runner lib to do SSH things ''' pattern = args[0] inventory_manager = inventory.Inventory(options.inventory) …… runner = Runner( module_name=options.module_name, module_path=options.module_path, module_args=options.module_args, remote_user=options.remote_user, remote_pass=sshpass, inventory=inventory_manager, timeout=options.timeout, private_key_file=options.private_key_file, forks=options.forks, pattern=pattern, callbacks=self.callbacks, sudo=options.sudo, sudo_pass=sudopass,sudo_user=options.sudo_user, transport=options.connection, subset=options.subset, check=options.check, diff=options.check, confirm=options.confirm ) …… results = runner.run() return (runner, results) |
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 |
class Inventory(object): """ Host inventory for ansible. """ def __init__(self, host_list=C.DEFAULT_HOST_LIST): # the host file file, or script path, or list of hosts # if a list, inventory data will NOT be loaded self.host_list = host_list …… if isinstance(host_list, basestring): if "," in host_list: host_list = host_list.split(",") host_list = [ h for h in host_list if h and h.strip() ] if host_list is None: self.parser = None elif isinstance(host_list, list): self.parser = None all = Group('all') self.groups = [ all ] ipv6_re = re.compile('\[([a-f:A-F0-9]*[%[0-z]+]?)\](?::(\d+))?') for x in host_list: m = ipv6_re.match(x) if m: all.add_host(Host(m.groups()[0], m.groups()[1])) else: if ":" in x: tokens = x.rsplit(":", 1) # if there is ':' in the address, then this is a ipv6 if ':' in tokens[0]: all.add_host(Host(x)) else: all.add_host(Host(tokens[0], tokens[1])) else: all.add_host(Host(x)) elif os.path.exists(host_list): if os.path.isdir(host_list): # Ensure basedir is inside the directory self.host_list = os.path.join(self.host_list, "") self.parser = InventoryDirectory(filename=host_list) self.groups = self.parser.groups.values() elif utils.is_executable(host_list): self.parser = InventoryScript(filename=host_list) self.groups = self.parser.groups.values() else: self.parser = InventoryParser(filename=host_list) self.groups = self.parser.groups.values() utils.plugins.vars_loader.add_directory(self.basedir(), with_subdir=True) else: raise errors.AnsibleError("Unable to find an inventory file, specify one with -i ?") …… |
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 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
class InventoryScript(object): ''' Host inventory parser for ansible using external inventory scripts. ''' def __init__(self, filename=C.DEFAULT_HOST_LIST): # Support inventory scripts that are not prefixed with some # path information but happen to be in the current working # directory when '.' is not in PATH. self.filename = os.path.abspath(filename) cmd = [ self.filename, "--list" ] try: sp = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except OSError, e: raise errors.AnsibleError("problem running %s (%s)" % (' '.join(cmd), e)) (stdout, stderr) = sp.communicate() self.data = stdout # see comment about _meta below self.host_vars_from_top = None self.groups = self._parse(stderr) def _parse(self, err): all_hosts = {} self.raw = utils.parse_json(self.data) all = Group('all') groups = dict(all=all) group = None if 'failed' in self.raw: sys.stderr.write(err + "\n") raise errors.AnsibleError("failed to parse executable inventory script results: %s" % self.raw) for (group_name, data) in self.raw.items(): # in Ansible 1.3 and later, a "_meta" subelement may contain # a variable "hostvars" which contains a hash for each host # if this "hostvars" exists at all then do not call --host for each # host. This is for efficiency and scripts should still return data # if called with --host for backwards compat with 1.2 and earlier. if group_name == '_meta': if 'hostvars' in data: self.host_vars_from_top = data['hostvars'] continue if group_name != all.name: group = groups[group_name] = Group(group_name) else: group = all host = None if not isinstance(data, dict): data = {'hosts': data} elif not any(k in data for k in ('hosts','vars')): data = {'hosts': [group_name], 'vars': data} if 'hosts' in data: for hostname in data['hosts']: # filter out hosts not in user's permission if hostname not in C.DEFAULT_HOST_LIST: continue if not hostname in all_hosts: all_hosts[hostname] = Host(hostname) host = all_hosts[hostname] group.add_host(host) if 'vars' in data: for k, v in data['vars'].iteritems(): if group.name == all.name: all.set_variable(k, v) else: group.set_variable(k, v) if group.name != all.name: all.add_child_group(group) # Separate loop to ensure all groups are defined for (group_name, data) in self.raw.items(): if group_name == '_meta': continue if isinstance(data, dict) and 'children' in data: for child_name in data['children']: if child_name in groups: groups[group_name].add_child_group(groups[child_name]) return groups def get_host_variables(self, host): """ Runs <script> --host <hostname> to determine additional host variables """ if self.host_vars_from_top is not None: got = self.host_vars_from_top.get(host.name, {}) return got cmd = [self.filename, "--host", host.name] try: sp = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) except OSError, e: raise errors.AnsibleError("problem running %s (%s)" % (' '.join(cmd), e)) (out, err) = sp.communicate() return utils.parse_json(out) |
从中可以得出如下结论:
- ansible config,HOSTLIST的位置可以调整(ansible/constents.py)。
- HOSTLIST可以是文件,目录,可执行程序,并且目录可有无数层子目录,下面可以有无数个脚本或配置文件(ansible/inventory/__init__.py:70-107)。
- inventory Script不限定脚本语言,但至少需要实现–list 选项,并返回一个符合规则的json str,列出全量的机器列表,执行inventory Script阶段不支持传入自定义参数;另外可选的是,实现一个—host方法,返回机器的变量信息(ansible/inventory/script.py),具体的来说,—list返回格式要求如下:
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 |
{ "group1": [ "host1", "host2" ], "group2": [ "host3" ], "_meta": {//可选 "hostvars": {//可选 "host1": { "var1": "value1", "var2": "value2" }, "host2": { "var3": "value3" } }, "group1": {//可选 "children": [ “group2" ] } } } |
等效的hosts配置文件:
1 2 3 4 5 6 7 |
[group1] host1 var1=value1 var2=value2 host2 var3=value2 [group2] host3 [group1:children] group2 |
因为python程序对于python环境十分敏感,所以我们强烈建议大家使用virtualenv将ansible及其环境部署到一个独立的目录,并且得益于结论1,我们可以将所有ansible相关的目录也收敛到此目录下,这样不但方便开发部署,也能避免对其它程序造成影响,我将我的环境统一放到了/usr/local/ansible下,并在activate脚本中export出了所有需要的变量,然后为用户alias了ago/back两个命令来进入和退出ansible环境。
1 2 3 4 |
49,51d48 < VIRTUAL_ENV_DISABLE_PROMPT=1 < ANSIBLE_CONFIG="$VIRTUAL_ENV/etc/ansible.cfg" < |
1 2 3 4 5 |
#!/bin/bash ANSIBLE_HOME=/usr/local/ansible alias ago="source $ANSIBLE_HOME/bin/activate" alias back="deactivate" |
基于2,我们可以将hosts搞成一个目录,然后如果你乐意的话,下面还可以再创建分类目录,方便归类管理。我的目录结构如下:
1 2 3 4 5 |
/usr/local/ansible/etc/ ├── ansible.cfg └── hosts ├── xbox_global.ini └── xbox.py |
结论3中的那个“全量”再加上“不支持传入自定义参数”让我们有些头疼,实际上因为公司的机器成千上万,显然OP并不需要也不能每次运行ansible命令就拉全量机器列表,我们care的可能只有那么百十来台,可人家又不支持传入参数,怎么办?答案是:使用配置+cache,实际上官方提供的两个例子中也都使用了配置,不过配置这事,也需要安全的环境支撑,我们在第一版本中之所以没有用cache,是因为我们运行ansible的机器没有个人账号,呐,你敢不敢把token写文件里面放到上面?恐怕你连cache都不愿意放。说道这里我也特别提醒下,仅在足够安全的环境下使用我们最后提供的脚本。
好了,现在我们脑海中就有个大体的轮廓了:只要写一个脚本,读取自定义配置,然后查询机器信息管理系统获得机器列表,实现到—list中方法,然后吐出一个合理的json,扔到ansible hosts目录下,机器列表的事情就搞定了,如果愿意,甚至可以连分组信息以及机器变量也搞定了。
下面我们就开始动手写一个来试试了。
2. 动手
首先我们来实现一个最简单的脚本,官方的两个示例都是很好的例子,cobber是获取机器列表(—list)的例子,ec2是获取机器信息(—host)的例子。我们下载cobber.py改起,我也画了下这个程序的流程图(这里),也可以到这里查看带附注的版本(翻墙please…),看完后相信你会觉得这事太简单了,总的来说,我们只需要改动他read_settings,update_cache这两个方法。
先来个简化的,不做任何可配置、容错检查以及排错支持,但骨架是全了:获取机器列表,并设置分组和变量信息,要做到这一点只需要添加两个函数,然后再盖一下update_cache就OK了:
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 55 56 57 58 59 60 |
def get_host_list(self): '''根据用户配置从xbox获取机器列表''' http = httplib2.Http() url = “http://xbox.xiaomi.com/getMyHostlist?token=123&tag=com.xiaomi” #示例 try: response, content = http.request(url, 'GET') # 检查返回是否正常 if response.status != 200: raise Exception('%s %s' % (response.status,response.reason)) #返回结果类似: { “succ”:0; “hosts”:[“host1”,”host2”] } hosts = json.loads(content)[‘hosts’] for host in hosts: self.push(self.inventory, 'all', host) # push是对append的一个封装 except Exception, e: print e sys.exit(1) def get_host_tags(self): '''根据机器列表获得所有机器的tag和ip信息,并将tag作为分组名插入inventory’'' http = httplib2.Http() hosts=‘_’.join(self.inventory[‘all']) url = “http://xbox.xiaomi.com/getMyHosttags?token=123&hosts=” + hosts #还是示例。。。 try: response, content = http.request(url, 'GET') data = json.loads(content) #返回结果类似:{”succ“:0; “tag_list”:{“host1”:”com.xiaomi_dept.miliao_pdl.A_service.X,com.xiaomi_dept.miui_pdl.B_service.Y”}; ….} for host in json.loads(content)['tag_list'].keys(): self.cache[host] = dict() ip = socket.gethostbyname(host) self.cache[host]['ip'] = [ip] tagstrs = json.loads(content)['tag_list'][host].split(',') for tagstr in tagstrs: for tag in tagstr.split(‘_'): [key, value] = tag.split('.') # 将tag作为hostvar保存 self.push(self.cache[host], key, value) # 也将tag作为分组依据 group_name = '%s.%s' % (key, value) self.push(self.inventory, group_name, host) #将full tag string也作为hostvar和分组依据 self.push(self.inventory, tagstr, host) self.push(self.cache[host], 'full_tags', tagstr) except Exception, e: print e sys.exit(1) def update_cache(self): """ Make calls to xbox and save the output in a cache """ self.get_host_list() self.get_host_tags() self.write_to_cache(self.cache, self.cache_path_cache) self.write_to_cache(self.inventory, self.cache_path_inventory) |
上面的代码可读性好到屌爆,也许你替换url后还真能用,不过大多数情况下,你得到了一堆exception,恩没错,你的代码需要更多的裹脚布,然后,你需要尽可能的把配置抽取出来,像我还顺手加点小功能,于是到最后我的脚本达到了400多行。。
恩,你一定会说,累死我了,给我脚本我自己看吧,好吧,请摆驾这里
简单的说明下,这个脚本除了上面的主体功能外,还包括以下酱油功能:
· 管理员可配置:API URL , 用户配置模板获取位置,合理超时区间,封禁用户,用户缓存路径
· 用户可配置:token,关心的机器tags(就是all怎么获得),超时间隔
enjoy!
5 Comments
我去,量产啊,可以用MD格式写,记着加下 more
哈哈,我加下
楼主给力!哈哈
你的公司邮箱暴露在git上面了
嗯,谢谢提醒