Python使用SSH代理访问远程Docker

Python使用SSH代理访问远程Docker

Docker 20.10.17

Python 2.7

1 前言

Python中有个叫docker-py的客户端库用来操作docker,关于docker-py的基本使用可以参考https://pypi.org/project/docker/。本篇文章主要记录一下前段时间工作中涉及到的一个问题的解决方案,仅供有同样需求的同学参考。这个问题就是:如何使用Python操作其它机器上远程的Docker服务?

使用过docker-py客户端的同学,肯定都知道,创建client实例的时候,需要在构造函数中传入base_url这个参数,或者指定指定环境变量DOCKER_HOST,例如:

import docker
client = docker.DockerClient(base_url='unix://var/run/docker.sock')

其中unix协议表示使用的是本地的UNIX Domain Socket,这是Docker用于同一台主机的进程通讯,顾名思义要想使用这种方式客户端需要跟Docker进程在同一台主机上。当然docker-py还支持别的协议,通过源码注释我们可以看到它还支持tcp的方式,例如:

import docker
client = docker.DockerClient(base_url='tcp://127.0.0.1:1234')

看到这里,上面的问题通过tcp访问远程的Docker服务不就可以了吗,但是这里需要注意的是,默认的Docker服务是不会启用TCP的监听端口的,需要在启动服务式做一些改造,修改启动脚本/etc/systemd/system/docker.service.d/tcp.conf

ExecStart=/usr/bin/dockerd -H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375

这种方式需要修改Docker服务,而且暴露端口可能会引起一些安全问题,我们的生产环境中通常都是使用的默认的UNIX Domain Socket的方式,显然TCP的方式不适合我们,那还有什么方式呢?继续查看源码,我们看到,原来docker-py是支持ssh的,例如:

import docker
client = docker.DockerClient(base_url='ssh:/[email protected]:22')

当时我看到这里仿佛看到了新大路,之前的问题迎刃而解,但通过实验发现行不通

Python使用SSH代理访问远程Docker_第1张图片

这需要docker-py客户端所在的机器要与Docker服务的机器进行免密登录,设置互信,显然这种方式也不是很合适。

那有没有那种不需要进行免密操作,又能通过ssh用户名密码进行连接呢?通过上面的报错信息,我们可以看到,docker-py的ssh协议底层使用的是paramiko,这个库熟悉啊,之前实现Python远程主机终端不就是用的这个库嘛,可以指定用户名密码连接的,再深挖一下源码一看:
Python使用SSH代理访问远程Docker_第2张图片
稍微改造一下上面的代码,加上用户名密码的认证,我们就可以通过ssh访问远程主机了。所以本篇文章将通过两个方案实现访问远程docker:通过改造SSH用户密码认证访问远程Docker,通过SSH命令行隧道方式访问远程Docker。

2 通过改造SSH用户密码认证访问远程Docker

这种方式自己实现的代码逻辑比较简单,两个关键类需要重写SSHHTTPAdapter和DockerClient。思路是:

  1. 解析base_url中的用户密码信息
  2. 通过用户名密码创建SSH连接
  3. 让DockerClient使用我们创建的SSHHTTPAdapter

2.1 解析base_url

定义一个函数用来解析base_url,例如:ssh://Username:[email protected]:22

def docker_urlparse(url):
    # fix for url with '#'
    mark = '_a5s7m3_'
    r = urlparse.urlparse(url.replace('#', mark))
    hostname = r.hostname.replace(mark, '#')
    username = r.username.replace(mark, '#')
    password = r.password.replace(mark, '#')
    port = r.port
    scheme = r.scheme
    return {
        'hostname': hostname,
        'port': port,
        'username': username,
        'password': password,
        'scheme': scheme
    }

2.2 重写SSHHTTPAdapter

重写SSHHTTPAdapter的目的是能够通过用户名密码创建连接,主要是重写方法_create_paramiko_client

class SSHHTTPAdapter(transport.SSHHTTPAdapter):

    def __init__(self, ssh_params, timeout=60, pool_connections=DEFAULT_NUM_POOLS,
                 max_pool_size=DEFAULT_MAX_POOL_SIZE, shell_out=False):
        self.ssh_params = ssh_params
        del ssh_params['scheme']
        super(SSHHTTPAdapter, self).__init__('', timeout, pool_connections, max_pool_size, shell_out)

    def _create_paramiko_client(self, _):
        logging.getLogger("paramiko").setLevel(logging.WARNING)
        self.ssh_client = paramiko.SSHClient()
        ssh_config_file = os.path.expanduser("~/.ssh/config")
        if os.path.exists(ssh_config_file):
            conf = paramiko.SSHConfig()
            with open(ssh_config_file) as f:
                conf.parse(f)
            host_config = conf.lookup(self.ssh_params['hostname'])
            self.ssh_conf = host_config
            if 'proxycommand' in host_config:
                self.ssh_params["sock"] = paramiko.ProxyCommand(
                    self.ssh_conf['proxycommand']
                )
            if 'hostname' in host_config:
                self.ssh_params['hostname'] = host_config['hostname']
            if self.ssh_params['port'] is None and 'port' in host_config:
                self.ssh_params['port'] = self.ssh_conf['port']
            if self.ssh_params['username'] is None and 'user' in host_config:
                self.ssh_params['username'] = self.ssh_conf['user']

        self.ssh_client.load_system_host_keys()
        self.ssh_client.set_missing_host_key_policy(paramiko.WarningPolicy())

2.3 重写DockerClient

使用自定义的SSHHTTPAdapter创建对应的adapter实例,然后绑定到DockerClient的api上,具体实现如下:

class SSHDockerClient(DockerClient):

    def __init__(self, *args, **kwargs):
        base_url = kwargs.get('base_url')
        ssh_params = docker_urlparse(base_url)
        adapter = SSHHTTPAdapter(ssh_params)
        kwargs['base_url'] = ''
        kwargs['version'] = MINIMUM_DOCKER_API_VERSION
        super(SSHDockerClient, self).__init__(*args, **kwargs)
        self.api.mount('http+docker://ssh', adapter)
        self.api.base_url = 'http+docker://ssh'

2.4 验证测试

base_url格式:ssh://<用户名>:<密码>@<主机>:<端口>

client = SSHDockerClient(base_url='ssh://docker:[email protected]:22')
client.version()

3 通过SSH命令行隧道方式访问远程Docker

网上还有一些资料,是使用SSH命令随带的方式代理远程Docker的Unix Domain Socket,我一开始也是用的这种方式。

3.1 命令行使用

命令行执行SSH命令创建隧道

ssh -nNT -L /tmp/docker.sock:/var/run/docker.sock [email protected]

然后客户端直接这样用

import docker
client = docker.DockerClient(base_url='unix:///tmp/docker.sock')

3.2 使用代码封装命令

上面需要单独执行命令行,不符合需求,我们可以将命令用代码封装起来,在Python中使用subprocess单独启动一个命令行进程,然后再创建对应的客户端实例。思路很简单:

  1. 使用subprocess单独运行ssh命令
  2. 等待本地的/tmp/docker.sock可用
  3. 使用/tmp/docker.sock创建客户端实例

具体代码实现:

class ProxyDockerClient(DockerClient):
    remote_sock = '/var/run/docker.sock'
    local_socks_dir = '/tmp/docker-client-proxy'
    proxy_process = None  # type: subprocess

    def __init__(self, *args, **kwargs):
        base_url = kwargs.get('base_url')
        ssh_params = docker_urlparse(base_url)
        kwargs['base_url'] = self.ssh_proxy(ssh_params)

        super(ProxyDockerClient, self).__init__(*args, **kwargs)

    def ssh_proxy(self, ssh_params):
        if ssh_params['scheme'] == 'ssh':
            local_sock = os.path.join(self.local_socks_dir, ssh_params['hostname'], os.path.basename(self.remote_sock))
            local_sock_dir = os.path.dirname(local_sock)
            shutil.rmtree(local_sock_dir, ignore_errors=True)
            os.makedirs(local_sock_dir)
            base_url = 'unix://%s' % local_sock
            self.proxy_process = subprocess.Popen(
                args=['sshpass', '-p', ssh_params['password'], 'ssh', '-nNT', '-L',
                      '%s:%s' % (local_sock, self.remote_sock),
                      '%s@%s' % (ssh_params['username'], ssh_params['hostname'])],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                close_fds=True)
            connected = self._wait_connectable(local_sock)

            if not connected:
                self.proxy_process.kill()
                raise RuntimeError("Can't connected!")
            return base_url

    @staticmethod
    def _wait_connectable(sock_address, retries=20):
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        connected = False
        retry_times = 0
        while not connected and retry_times < retries:
            try:
                sock.connect(sock_address)
                connected = True
            except socket.error, msg:
                logging.error(msg)
            finally:
                retry_times += 1
                time.sleep(0.5)
        return connected

3.3 验证测试

base_url格式:ssh://<用户名>:<密码>@<主机>:<端口>

client = ProxyDockerClient(base_url='ssh://docker:[email protected]:22')
client.version()

注意:这种方式在CentOS下需要使用非root用户,并且将/var/run/docker.sock分配非root权限,否则会报错。

centos不能使用root用户 https://bugzilla.redhat.com/show_bug.cgi?id=1527565
https://rancher.com/docs/rke/latest/en/troubleshooting/ssh-connectivity-errors/

Python使用SSH代理访问远程Docker_第3张图片

4 总结

对比两种方案的实现,建议使用第一种,第二种是我最早一版本的实现,当时测试机器是Ubuntu,并没有发现问问题,后来切换到CentOS出现问题了,排查了很久才找到原因。关于**如何使用Python操作其它机器上远程的Docker服务?**这个问题的解决方案就总结这么多,同学们有什么更好的方案可以一块交流学习。

你可能感兴趣的:(Python,docker,python,ssh)