使用Fabric做批量远程部署
fabric是上古python开发的三神器之一.它是远程配置工具,重度依赖ssh.
目前fabric已经到了大版本2,其接口已经和之前有很大不同,本文基于fabric 2
fabric有两种使用方式:
-
命令行调用方式.
我们可以先定义好远程任务要执行的操作,然后通过命令行传入要执行的远程主机,要调用的任务等参数. 这种方式的定义过程像是写makefile.也是fabric v1版本的唯一使用方式.好处是使用和开发解耦,使用者并不需要会python也不用看得懂代码.调用就好了.
-
python包调用方式.
这是v2版本增加的方式,现在fabric可以使用接口直接调用执行.这种方式的好处是灵活,坏处就是要用的人能看得懂才行.
安装
fabric的安装依赖于ssh和python,可以使用pip安装.
pip install fabric
它依赖
注意fabric依赖的invoke有一个bug会导致使用python的typehints会报错,可以通过手动修改其中的源文件invoke/tasks.py
第153行(argspec方法中)的代码
spec = inspect.getargspec(func)
改为spec = inspect.getfullargspec(func)
解决
helloworld
我们还是从helloworld开始
命令行调用方式
-
写
fabfile.py
fabric通过读取fabfile定义操作,他的定义方式和一般的python函数差不多
from fabric import task, Connection @task def helloworld(c: Connection) -> None: result = c.run('echo helloworld', hide=True) msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}" print(msg) @task def hello_name(c: Connection, name: str) -> None: result = c.run(f'echo hello {name}', hide=True) msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}" print(msg)
这个fabfile定义了两个任务,一个不带参数的helloworld,一个带参数的hello_name
-
使用
fab
命令运行操作fab -H remot1 helloworld
结果:
Ran 'echo helloworld' on 47.110.255.149, got stdout: helloworld
执行带参数的任务时则需要指定参数的值
fab -H remot1 hello_name --name=hsz
结果:
Ran 'echo hello hsz' on 47.110.255.149, got stdout: hello hsz
python包调用方式
这种方式我们就只需要写个python脚本,然后执行它就行
-
fabcall.py
from fabric import Connection if __name__ == "__main__": conn = Connection('remot1') result = conn.run('echo helloworld', hide=True) msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}" print(msg) conn.close()
然后直接执行python fabcall.py
即可
fabric的核心用法
上面的例子中我们可以看出无论是哪种使用方式,fabric的使用大致都由如下几块构成:
- 定义和构造连接
- 在连接上执行命令
- 获得并处理命令的结果.
这就是fabric的核心用法,下面开始详细介绍
定义和构造连接
fabric的Connection
类用于实例化一个ssh连接.其签名为
Connection(
host, user=None, port=None,
config=None, gateway=None,
forward_agent=None, connect_timeout=None,
connect_kwargs=None, inline_ssh_env=None)
一个连接的生命周期是创建->建立连接->执行任务->关闭连接
.连接是可以使用上下文管理器管理的.
因此上面包调用方式的helloworld我们应该写成
from fabric import Connection
if __name__ == "__main__":
with Connection('xndm_test') as conn:
result = conn.run('echo helloworld', hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
连接的指定主要依靠参数host
参数,它可以是3种情况
- 远程主机的
hostname
,这种情况下需要视情况填写user
,port
这样的参数了 - 远程主机的配置简写形式
user@host:port
,这种情况下就没有必要填写user
,port
这样的参数了 - 远程主机在ssh配置文件种定义的连接别名,这种情况下也没有必要填写
user
,port
这样的参数了
对于有验证的情况我们可以这样设置
验证形式 | 字段 | 命令行参数设置 | 配置示例 |
---|---|---|---|
指定私钥 | — | connect_kwargs={"key_filename":"<path>"} |
|
指定私钥且私钥有密码 | connect_kwargs.key_filename ,connect_kwargs.passphrase |
--prompt-for-passphrase |
- 指定私钥
- 参数字段
connect_kwargs.key_filename
- 参数配置示例
connect_kwargs={ "key_filename":"<path>" }
- 参数字段
- 指定私钥且私钥有密码
- 参数字段
connect_kwargs.key_filename
,connect_kwargs.passphrase
- 参数配置示例
connect_kwargs={ "key_filename":"<path>", "passphrase":"132" }
- 命令行参数设置
--prompt-for-passphrase=132
- 参数字段
- 密码登录
- 参数字段
connect_kwargs.password
- 参数配置示例
connect_kwargs={ "password":"12421" }
- 命令行参数设置
--prompt-for-login-password=12421
- 参数字段
私钥的密码或者登录密码最好不要明文写在代码里,不安全.在接口调用方式下可以通过环境变量传入或者用标准库getpass
来从命令行输入;在命令行调用方式下可以使用命令行参数指定启动,然后在执行前输入.
使用getpass
可以参考如下示例:
import getpass
access_pass = getpass.getpass("登录密码是?")
connect_kwargs={
"password":access_pass
}
连接组
当我们要一组连接都执行同样的任务时有两种方式:
- 遍历连接的配置构造多个连接,然后每个连接执行一遍任务
- 使用fabric的
SerialGroup
或者ThreadingGroup
类(Group
类的子类).
连接组在命令行调用方式下一般用不到,但在包调用方式中还算好用.
连接组的签名为:
Group(*hosts, **kwargs)
其中kwargs
和上面连接中的一致.
可以通过run
,get
接口在每个连接上执行命令行操作,也可以使用close()
接口关闭其中的所有连接.类似连接它也是一个上下文管理器,可以使用with语法.
同时它也可以遍历,其中的元素为各个配置对应的连接.
我们修改上面包调用方式的helloworld,改成可以同时在两个远程服务器上执行的形式.
from fabric import ThreadingGroup as Group
if __name__ == "__main__":
with Group('remote1', 'remote2') as group:
results = group.run('echo helloworld', hide=True)
for conn, result in results.items():
msg = f"conn {conn.host} Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
结果:
conn 47.96.235.24 Ran 'echo helloworld' on 47.96.235.24, got stdout:
helloworld
conn 47.110.255.149 Ran 'echo helloworld' on 47.110.255.149, got stdout:
helloworld
在连接上执行命令
在连接上执行命令有如下1个字段:
字段 | 说明 |
---|---|
cwd |
获取当前的路径信息 |
和几个相关的接口:
操作 | 说明 |
---|---|
run(command:str, **kwargs:Any)->invoke.runners.Result |
远程运行 |
sudo(command:str, **kwargs:Any)->invoke.runners.Result |
sudo权限运行 |
local(command:str, **kwargs:Any)->invoke.runners.Result |
在本地运行 |
cd(path:str) |
执行路径切换 |
prefix(command:str) |
执行run/sudo 前的操作,相当于&& |
put(local:Union[string,file], remote:Optional[str]=None, preserve_mode:bool=True)->fabric.transfer.Result |
将文件传送到远端 |
get(local:Union[string,file], remote:Optional[str]=None, preserve_mode:bool=True)->fabric.transfer.Result |
从远程服务器上下载文件 |
此外连接对象还提供几个特殊接口用于做别的操作:
字段 | 说明 |
---|---|
sftp()->paramiko.sftp_client.SFTP |
利用这个连接构造一个sftp的客户端,在其上可以对远程文件系统做操作 |
forward_local(remote_port:int,local_port:int,local_host:str,remote_host:str)->None |
利用这个连接构造一个本地端口映射 |
forward_remote(remote_port:int,local_port:int,local_host:str,remote_host:str)->None |
利用这个连接构造一个远端端口映射 |
这几个特殊接口与本文主题并无太大关系,因此不做过多介绍
run
执行命令行任务
run
接口是在连接上执行命令的核心,sudo
和local
与他使用方式一样只是执行位置和权限不同.它的接口是
run(command:str, **kwargs:Any)->Result
主要的参数:
command
是要执行的命令字符串warn
当命令执行异常退出时默认是抛出UnexpectedExit
异常,如果设置warn
则不会抛出错误而是改为抛出警告hide
可选的值为"out"/"stdout"
打印远端的stdout输出的结果"err"/"stderr"
打印远端的stderr输出的结果"both"/True
stdout或者stderr输出的结果都打印None
所有都打印(默认)False
都不打印
disown
相当于nohup command &
env
其值为一个字典,用于定义命令执行时的环境变量encoding
stdout和stderr的文本编码watchers
定义监控,一般用于自动填写stdin.
处理需要在命令行中输入值的情况
比如一些安装脚本会要你确认是否同意一些内容条款,你必须输入yes/no
这类的字符串来继续run的执行,这种时候可以定义一个invoke.watchers.Responder
通过正则匹配的方式来自动响应
responder = Responder(
pattern=r"Are you ready? \[Y/n\] ",
response="y\n",
)
c.run("excitable-program", watchers=[responder])
sudo
使用root权限执行命令
通常一些有权限要求的操作我们会使用root权限进行操作,在命令行中就是sudo xxxxx
,sudo
这个接口也就是用于这种功能.
使用sudo
接口需要在connect的配置里设置config
字段作为一次连接中的全局变量
config = Config(overrides={'sudo': {'password': "sudo_pass"}})
c = Connection('db1', config=config)
在命令行方式调用时也可以传入参数--prompt-for-sudo-password
来实现同样的效果
local
用于切换到本地执行操作
这个接口其实用途没那么大,它只是一个糖而已,毕竟到了本地可以操作的方法多的是.但也不是没有应用场景,比如我们要利用远程机器上某个keystore为本地一个程序的的启动程序加密.这种奇葩需求也不是没有.
cd
切换到某个目录下
通常切换目录不是目的,执行程序才是目的,一次对工作目录有要求的命令执行生命周期大致是:
cd <target_path> => command => cd <original_path>
在fabric中使用接口cd
来实现这个操作.它是一个上下文管理器,使用with
语法.其本质是在run|sudo|local
接口前添加cd <target_path> &&
with c.cd("/etc/docker/"):
result = c.run("ls", hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
prefix
命令加前置命令
这个在很多软件或者模块的安装上会有用,比如编译某c代码时指定c编译器cc=xxx&&gcc xxxx
,当然这种其实也可以通过run中设定环境变量来实现.
在fabric中提供了专门的prefix
接口用于加前置命令,它也是一个上下文管理器,用法和cd
类似.同时我们可以有多级的prefix
串联命令
with c.prefix("cc=xxx"):
result = c.run("gcc xxxx", hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
put
从本地向远端传输文件
put
本质上是使用的scp或者sftp.操作方式就是指定好本地文件和远端路径,需要注意只能上传文件不能上传文件夹.默认情况下上传的文件权限设置和本地一致,如果要不一致则可以设置参数preserve_mode=false
这会让远端系统决定权限配置.如果remote
不填,那么它会将文件放在用户的home
目录,注意这个不受cd
上下文控制.可以使用cwd
先获取当前路径再操作.
get
从远端向本地拉取文件
get
本质上是使用的scp或者sftp.操作方式就是指定好远程文件和本地路径(文件夹路径或者文件路径),需要注意只能下载文件不能下载文件夹.默认情况下下载的文件权限设置和远端一致,如果要不一致则可以设置参数preserve_mode=false
这会让本地系统决定权限配置.注意这个接口不受cd
上下文控制.可以使用cwd
先获取当前路径再操作.
获得并处理命令的结果
上面介绍的接口有如下几种返回结果:
run|sudo|local
正常执行- 返回invoke.runners.Result.
这个结果对象的真值情况就是执行的成功与否,而输出结果可以从其属性
stdout
|stderr
上查看到, 其command
属性就是这条结果的执行的命令,而connection
属性就是这条结果是再哪个连接上执行的.return_code
属性可以查到具体的退出码,而tail(stream:Union["stdout","stderr"], count:int=10)->str
方法则可以获取到最后count条输出
- 返回invoke.runners.Result.
这个结果对象的真值情况就是执行的成功与否,而输出结果可以从其属性
run|sudo|local
执行出错- 当任务退出码非0且执行时参数
warn
为False
时抛出invoke.exceptions.UnexpectedExit - 当任务并未正常退出时抛出invoke.exceptions.Failure
- 当后台的i/o出错时抛出invoke.exceptions.ThreadException
- 当任务退出码非0且执行时参数
put|get
正常执行
根据这些结果我们可以利用try...except...
语句组织,结合colorama
,可以大大提高脚本的可用性.
from colorama import init
from termcolor import colored
init()
...
with c.cd("/etc/docker3/"):
try:
result = c.run("ls", hide=True)
except UnexpectedExit as uee:
print(colored(uee, 'write', 'on_red'))
except Failure as fe:
print(colored(fe, 'write', 'on_cyan'))
except ThreadException as fe:
print(colored(fe, 'write', 'on_cyan'))
except Exception as e:
print(colored(str(e), 'write', 'on_yellow'))
else:
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
fabric的任务和命令行工具fab
fab
命令是fabric自带的一个命令行工具,是命令行调用方式的入口.
fab命令会读取fabfile并用它执行操作.我们可以使用-c
指定fabfile.
注意-c
后面跟的是模块名而非文件名,比如指定fabfile.py
应该写成-c fabfile
,如果没有指定则默认是当前目录下的fabfile
模块.这也就意味着我们可以不单单是单文件脚本,也可以是一个文件夹.
fab工具的执行模式有如下几种:
-
如果要查看一个fabfile下有哪些可以执行的任务,可以直接调用
fab --list
-
如果要看一个fabric任务的参数可以使用
fab --help <task name>
来查看. -
如果是想远程执行fabfile中定义的任务,那么需要使用
-H
指定远程主机,命令行类似fab -H host1,host2 [options flag] task1 [task1 params flag]] [task1 [task2 params flag]] ...
命令行方式可以一次指定多个任务一起执行.比如上面的helloworld中我们定义了两个任务,可以使用如下命令一起执行
fab -H remote1,remote2 hello-name --name=hsz helloworld
-
如果只是本地调试fabfile,则不能使用
-H
,这样所有的run
和sudo
都会在本地执行,不过注意本地如果是window需要注意shell的选择,可以参考这个工单.fab [options flag] task1 [task1 params flag]] [task1 [task2 params flag]] ...
执行任务时的常用参数
参数 | 说明 |
---|---|
--hide=str |
run 等命令执行时的hide参数 |
--prompt-for-login-password |
输入ssh登录密码 |
--prompt-for-passphrase |
输入ssh私钥登录的私钥密码 |
--prompt-for-sudo-password |
输入sudo的密码 |
-S|--ssh-config=path |
指定使用的ssh配置文件 |
-i|--identity=path |
指定host的登录私钥,可以指定多次 |
任务定义
我们的任务使用装饰器@task
定义,这个装饰器可以有参数也可以没有参数.无论有没有参数它装饰的函数第一个参数一定是c:Connection
.装饰的函数可以有多个参数,除第一个参数外的其他参数需要在命令行中在任务名之后传入就像上面helloworld一样
- 任务定义
@task
def func1(c: Connection, param1: str) -> None:
"""func1的说明."""
pass
- 任务执行
fab -H xxx func1 --param1=1
默认情况下任务的名字就是函数名,任务参数名就是函数的参数名,如果函数名或者参数名中有_
字符则会统一在任务名或任务参数名中被转成-
.
函数的docstring将会作为fab --help task
命令输出的任务描述信息.其中的第一行则会被作为fab --list
中对这个任务的简介信息.
任务命名
装饰器@task
中可以设置参数name
来重命名任务,使用aliases
来为任务取别名.
name
指定了新名字后函数名将不再作为任务名,而aliases
指定的别名和任务名是一个效果.因此一般更多的是使用aliases
.aliases
的值是一个列表,因此一个任务可以有一个名字多个别名.
我们修改我们的hello_name
任务
@task(name="你好", aliases=["hello", "こんにちは", "Bonjour"])
def hello_name(c: Connection, name: str) -> None:
"""你好加名字.
你好后面加名字.
"""
result = c.run(f'echo hello {name}', hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
fab --list
Available tasks:
helloworld
你好 (Bonjour, hello, こんにちは) 你好加名字.
定义命令行帮助信息
装饰器@task
中可以设置参数help
用于设置执行fab --help task
时的参数描述信息.再修改我们的hello_name
任务
@task(name="你好", aliases=["hello", "こんにちは", "Bonjour"], help={"name": "名字"})
def hello_name(c: Connection, name: str) -> None:
"""你好加名字.
你好后面加名字.
"""
result = c.run(f'echo hello {name}', hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
fab --help 你好
Usage: fab [--core-opts] 你好 [--options] [other tasks here ...]
Docstring:
你好加名字.
你好后面加名字.
Options:
-n STRING, --name=STRING 名字
任务参数的的可选行为
我们在定义任务函数的时候可以给参数设置默认值,如果有默认值则参数就是可选参数,否则就是必填参数.
如果必填参数没有被填,则会抛出一条提示'<task name>' did not receive required positional arguments: '<param_name>'
参数类型推断
命令行中获得的参数默认是字符串,但fabric可以通过参数的默认值以及task
装饰器中的特定参数对其进行一定程度的推断
-
int|float|bool
可以通过参数默认值推断. 其中int
会在help中提示类型,而float
和bool
不会. 而bool
型在输入时只会判断有没有对应参数名的flag被填写,如果有则是True
没有则是False
,但当这个flag有值时则会提示No idea what 'xxx' is!
- 当我们希望一个参数的取值可以是
True
,默认值,或者一个输入的值时可以使用装饰器@task
中optional
参数来定义,它的行为是:- 当flag未被指定则取值为默认值
- 当flag被指定但没有赋值则它会被填上
True
- 当flag被指定且有赋值则会被赋值为flag的值.
-
列表型参数可以在装饰器
@task
中设定iterable
参数,将参数名放入iterable
的序列中即可,这样这个参数的结果就会是一个由str组成的list.需要注意iterable
类型的参数输入时是多次输入而不是一次输入后靠分隔符分开. - 其他的情况则会全部被当作字符串处理
我们继续修改hello_name
任务来演示上面的行为:
@task(name="你好",
aliases=["hello", "こんにちは", "Bonjour"],
help={"name": "名字",
"age": "年龄",
"weight": "体重",
"is-student": "是不是学生",
"has-dog": "有没有狗,叫什么",
"friends": "朋友名字"},
iterable=["friends"],
optional=["has_dog"])
def hello_name(c: Connection, name: str, friends: List[str], age: int = 19, weight: float = 90.4, is_student: bool = False, has_dog: Union[bool, str] = "wangwang") -> None:
"""你好加名字.
你好后面加名字.
"""
print(f"{friends} type: {type(friends)}")
for friend in friends:
print(f"{friend} type: {type(friend)}")
print(f"{age} type: {type(age)}")
print(f"{weight} type: {type(weight)}")
print(f"{is_student} type: {type(is_student)}")
if has_dog is True:
print("has dog")
else:
print(f"has dog named {has_dog}")
任务依赖执行
fabric支持类似makefile一样将任务分解成许多小任务,然后组合形成大任务,这样就可以复用任务节省代码了.
要支持这种任务组合需要在装饰器@task
内定义pre
和post
参数.
这两个参数的接受一个由ConnectionCall对象或者任务函数对象(仅限于没有额外参数的情况)构成的列表,
pre
参数表示任务执行前会执行哪些任务,这也可以直接将这些参数作为装饰器@task
的位置参数;post
表示任务执行后会执行哪些任务.
需要注意直接安装好后的fabric目前依赖执行中pre
和post
参数内的任务都是本地执行的.如果希望在远端执行,需要修改fabric
的源码,executor.py
下Executor
类的方法expand_calls
中,
ret.extend(self.expand_calls(call.pre, apply_hosts=False))
改为ret.extend(self.expand_calls(call.pre, apply_hosts=apply_hosts))
ret.extend(self.expand_calls(call.post, apply_hosts=False))
改为ret.extend(self.expand_calls(call.post, apply_hosts=apply_hosts))
下面是示例代码:
from fabric import task, Connection
from invoke import call
@task
def before(c: Connection) -> None:
result = c.run('echo before', hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
@task
def after(c: Connection, name: str) -> None:
result = c.run(f'echo after {name}', hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
@task(before, post=[call(after, name="hsz")])
def run(c: Connection) -> None:
result = c.run('echo run', hide=True)
msg = f"Ran {result.command!r} on {result.connection.host}, got stdout:\n{result.stdout}"
print(msg)
如果依赖任务有参数,那么可以使用invoke.call
来构造函数的懒执行对象
fab -H remote1 run
Enter login password for use with SSH auth:
Ran 'echo before' on 192.168.31.40, got stdout:
before
Ran 'echo run' on 192.168.31.40, got stdout:
run
Ran 'echo after hsz' on 192.168.31.40, got stdout:
after hsz
默认任务
装饰器@task
中如果设置参数default=True
,那么这个任务就是这个fabfile的默认任务,不指定要执行的任务则会默认执行这个任务.一个fabfile只能指定一个默认任务.