使用Fabric做远程批量部署

Posted by Hsz on October 23, 2020

使用Fabric做批量远程部署

fabric是上古python开发的三神器之一.它是远程配置工具,重度依赖ssh.

目前fabric已经到了大版本2,其接口已经和之前有很大不同,本文基于fabric 2

fabric有两种使用方式:

  1. 命令行调用方式.

    我们可以先定义好远程任务要执行的操作,然后通过命令行传入要执行的远程主机,要调用的任务等参数. 这种方式的定义过程像是写makefile.也是fabric v1版本的唯一使用方式.好处是使用和开发解耦,使用者并不需要会python也不用看得懂代码.调用就好了.

  2. python包调用方式.

    这是v2版本增加的方式,现在fabric可以使用接口直接调用执行.这种方式的好处是灵活,坏处就是要用的人能看得懂才行.

安装

fabric的安装依赖于ssh和python,可以使用pip安装.

pip install fabric

它依赖

  • Invoke用于作为命令行操作的接口
  • Paramiko用于执行ssh连接操作

注意fabric依赖的invoke有一个bug会导致使用python的typehints会报错,可以通过手动修改其中的源文件invoke/tasks.py第153行(argspec方法中)的代码

spec = inspect.getargspec(func)改为spec = inspect.getfullargspec(func)解决

helloworld

我们还是从helloworld开始

命令行调用方式

  1. 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

  2. 使用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的使用大致都由如下几块构成:

  1. 定义和构造连接
  2. 在连接上执行命令
  3. 获得并处理命令的结果.

这就是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种情况

  1. 远程主机的hostname,这种情况下需要视情况填写user,port这样的参数了
  2. 远程主机的配置简写形式user@host:port,这种情况下就没有必要填写user,port这样的参数了
  3. 远程主机在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接口是在连接上执行命令的核心,sudolocal与他使用方式一样只是执行位置和权限不同.它的接口是

run(command:str, **kwargs:Any)->Result

主要的参数:

  • command是要执行的命令字符串
  • warn当命令执行异常退出时默认是抛出UnexpectedExit异常,如果设置warn则不会抛出错误而是改为抛出警告
  • hide可选的值为
    • "out"/"stdout" 打印远端的stdout输出的结果
    • "err"/"stderr" 打印远端的stderr输出的结果
    • "both"/Truestdout或者stderr输出的结果都打印
    • None所有都打印(默认)
    • False都不打印
  • disown 相当于nohup command &
  • env其值为一个字典,用于定义命令执行时的环境变量
  • encodingstdout和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条输出
  • run|sudo|local执行出错
  • 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,这样所有的runsudo都会在本地执行,不过注意本地如果是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中提示类型,而floatbool不会. 而bool型在输入时只会判断有没有对应参数名的flag被填写,如果有则是True没有则是False,但当这个flag有值时则会提示No idea what 'xxx' is!

  • 当我们希望一个参数的取值可以是True,默认值,或者一个输入的值时可以使用装饰器@taskoptional参数来定义,它的行为是:
    • 当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内定义prepost参数. 这两个参数的接受一个由ConnectionCall对象或者任务函数对象(仅限于没有额外参数的情况)构成的列表, pre参数表示任务执行前会执行哪些任务,这也可以直接将这些参数作为装饰器@task的位置参数;post表示任务执行后会执行哪些任务.

需要注意直接安装好后的fabric目前依赖执行中prepost参数内的任务都是本地执行的.如果希望在远端执行,需要修改fabric的源码,executor.pyExecutor类的方法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只能指定一个默认任务.