株式会社ホクソエムのブログ

R, Python, データ分析, 機械学習

Python + AsyncSSH によるお手軽非同期SSH接続

1. はじめに

ホクソエムサポーターの藤岡です。普段はデータ分析会社で大規模データを抽出・加工する仕事をしています。

Python 3.4以降、asyncioが導入されたことで非同期処理の実装が簡単にできるようになりました。非同期処理を活用すると、大量のテキストを読み込んだり、通信のレスポンスを待つ時間に他の処理を行うことができるようになります。

ここでは、asyncioをベースに作成されたPythonライブラリ AsyncSSH を使って、簡単に非同期のSSH通信を実現する方法を紹介したいと思います。

AsyncSSHはSSH通信に必要な多くの機能を備えており、リファレンスを読むと色々と目移りしてしまうのですが、実際はある程度の処理ならお手軽に実装できるように作られています。なのでconnectコルーチンとSSHClientConnectionクラスのいくつかのメソッドだけを使って実現できる内容をここで紹介しようと思い立ち、本記事を執筆しました。

なお、ここに書いてある内容はあくまで自分の理解に基づいたものであり、誤った内容が含まれている可能性があります。もしお気付きの際には、ご指摘いただければ幸いです。

2. 導入

Python 3.4以上の入った環境で以下のコマンドを実行します。

$ pip install asyncssh

なお、別のパッケージをインストールすることでいくつかの拡張機能を使うことができるようになりますが、今回紹介する内容に限ればいずれの機能も不要なので省略します。詳しくはこちらをご覧ください。

3. 解説に入る前に

本記事は、以下の環境で検証しながら執筆しました。

  • Python 3.6.8
  • asyncssh 1.18.0

本記事中のサンプルコードは公式のサンプルコードを参考に作成しているため、それらと同様に以下の構文で成り立っています。

import asyncio, asyncssh, sys, getpass

async def run_client():
    << 接続処理(4章の内容) >>
    << ホスト上での処理(5章の内容) >>

try:
    asyncio.get_event_loop().run_until_complete(run_client())
except (OSError, asyncssh.Error) as exc:
    sys.exit('SSH connection failed: ' + str(exc))

各節では、冗長な内容を省いて最小限のコードのみを掲載しておりますが、 それらのコードをこのテンプレートに当てはめると実行できるようになっております。例えば、4.1節5.1節の内容を組み合わせて、パスワード認証 + lsコマンド実行のプログラムを作ると、以下のようになります。

import asyncio, asyncssh, sys, getpass

async def run_client():
    # 接続処理(4節の内容)
    pw = getpass.getpass()
    async with asyncssh.connect("localhost", password=pw) as conn: 
        # ホスト上で実行したい処理(5節の内容)
        conn.run("ls /path/to/the/folder")             

try:
    asyncio.get_event_loop().run_until_complete(run_client())
except (OSError, asyncssh.Error) as exc:
    sys.exit('SSH connection failed: ' + str(exc))

また、テスト用の環境として、Dockerを使ってSSHサーバコンテナを立てると安全に試すことができます。sshdサーバのイメージはdocker公式からこちらで提供されています。ただし、このイメージだとbcコマンドを使うサンプルと多段接続のサンプルが試せないのでご注意ください。

全てのサンプルを動かしたい場合は、こちらに筆者が使用したテスト環境を掲載しておりますので適宜参考にしてください。

4. 接続処理: connect()

シェル上でsshコマンドを叩くだけでホストへのSSH接続ができるのと同様に、asyncsshでもconnect()コルーチンを呼び出すだけでSSH接続が実現できます。async with句を使って通信の切断を内部的に処理させるために、基本的には以下の構文で呼び出します。

async with asyncssh.connect(*args, **kwargs) as conn:
    ...

例えば、

  • user: root
  • host: localhost
  • port: 2222

という設定で接続する場合、

async with asyncssh.connect("localhost", 2222, username="root") as conn:
    ...

とすればOKです。

なお、connect()の引数はhost, port以外全てkeyword-only引数なので注意してください。

このように、接続方法や認証方法などはすべてconnect()の引数で設定します。その種類は多岐にわたるのですが、本章ではその代表的なものを紹介していきます。

4.1 パスワード認証

パスワード認証は、パスワードの文字列をpassword引数に与えるだけで実現できます。

例えば、標準出力からパスワードを受け取って接続する場合は、以下のコードで実現できます。

pw = getpass.getpass()
async with asyncssh.connect("localhost", password=pw) as conn:
    ...

4.2 多段接続

connect()を使って接続する際に、tunnel引数に別の接続オブジェクトを渡すことでその接続を踏み台にすることができます。

例えば、

  1. ローカルからlocalhostに接続
  2. localhostから172.22.0.3に接続

ということを実現する場合、以下のようなコードになります。

async with asyncssh.connect('localhost') as tunnel_conn: #手順1
    async with asyncssh.connect('172.22.0.3', tunnel=tunnel_conn) as conn: #手順2
        ...

4.3 SSH Agent

asyncsshでは、デフォルトでSSH Agentから秘密鍵を取得する設定になっています。読み込むAgentは環境変数SSH_AUTH_SOCKに設定されているパスによって決定されます。この環境変数を使わずにAgentを指定する場合には、agent_path引数でagentのパスを指定します。Agentを使わない場合にはagent_path=Noneとしましょう。

また、agent_forwarding引数をTrueに設定することで、Agent Forwarding (SSH Agentの情報をホストへと転送すること) も可能です。

4.4 known_hosts

SSHで新しい接続先に接続する際には確認が行われますが、テスト時などにはこの機能が邪魔になることがあります。そんなときには、引数known_hostsNoneに指定することで、このステップをスキップすることができます。

ただし、この状態は中間者攻撃の被害に気づきにくくなるリスクがあるため、特に理由がなければNoneに指定しないようにしましょう。また、この引数を使って読み込むknown_hostsファイルのパスを指定することもできます。デフォルトでは~/.ssh/known_hostsに設定されています。

4.5 その他

ここまで紹介した以外にも、connect()の引数で設定できる項目は多く存在します。それらを調べる際に注意していただきたいのが、これらの引数はconnectのページだけでなくSSHClientConnectionOptionsのページにも記載されていることです。

なお、上記のように実装上は分けられているのですが、引数として渡す際にはどちらもconnect()のキーワード引数として与えることができます。

5. ホスト上での処理: SSHClientConnection

SSHClientConnectionとは、connect()で返される接続オブジェクトconnのクラスです。接続オブジェクトから種々のメソッドを呼び出すことで、接続先でコマンドを実行したりシグナルを送ったりできます。本章では、このクラスのメソッドを通じて、ホスト上で種々の処理を実現する方法を紹介します。

ここで紹介する3つのメソッドのうちconn.runconn.create_processはどちらもsubprocessモジュールと近いインターフェースで実装されているので、その辺りに慣れていれば使いやすいと思います。なお、conn.runsubprocess.run に似ていて、conn.create_processsubprocess.Popenに似ています。

5.1 コマンドの実行

コマンドを引数にとってconn.run()コルーチンを呼び出すだけです。例えば、lsコマンドを呼び出す場合には以下のように呼び出します。

await conn.run("ls /path/to/the/folder")

引数に渡すコマンドはトークンのリストでは無く単一の文字列なので注意が必要です。subprocess.run()同様にリストを渡したい場合には、subprocess.list2cmdline()を使って結合してから渡すのが良いでしょう。

一つの接続に対して複数回呼び出すこともできます。その場合、それぞれの呼び出しに対してホスト側でプロセスが発行されます。

subprocess.run()はプロセス実行結果オブジェクトSSHCompletedProcess を返します。このオブジェクトは実行結果の種々のパラメータをプロパティとして保持しています。各プロパティはこちらにリストアップされていますが、よく使うものを以下を列挙しておきます。

  • stdout: 標準出力
  • stderr: 標準エラー出力
  • exit_status: 終了ステータス (exit signalを受け取った場合は-1が入る)
  • returncode: 終了ステータス (exit signalを受け取った場合はその番号(負の数)が入る)

5.2. プロセス生成: SSHClientConnection.create_process()

インタラクティブなコマンドを実行する場合には、一つのプロセスとやり取りをする必要があるので、conn.run()ではなく、conn.create_process()を使います。例えば、以下はサンプルコードに載っている、対話的な処理の一部です。

async with conn.create_process('bc') as process:
    for op in ['2+2', '1*2*3*4', '2^32']:
        process.stdin.write(op + '\n')
        result = await process.stdout.readline()
        print(op, '=', result, end='')

1行目でbcコマンドを対話モードで呼び出し、そのプロセスオブジェクトprocessを受け取っています。次に、3行目でstdin.writeメソッドを使って数式を書き込み、4行目でreadlineコルーチンを使って計算結果を受け取っています。これはsubprocess.Popenstdin, stdout引数にsubprocess.PIPEを設定した場合と同様です。

command引数を省略すると、コマンドの代わりにホスト側のシェルが呼び出されます。例えば、上の例をシェルから実行すると、以下の通りです。

async with conn.create_process() as process:
    for op in ['2+2', '1*2*3*4', '2^32']:
        cmd = "echo {} | bc\n".format(op)
        process.stdin.write(cmd)
        result = await process.stdout.readline()
        print(op, '=', result, end='')

シェルを呼ぶ場合もコマンドを呼ぶ場合も、実行したい区切りで改行記号を入れることを忘れないようにしましょう。

5.3 I/Oのリダイレクト

ホスト側のプロセスのI/Oをローカルのプロセスや別のSSHClientProcessにリダイレクトすることができます。指定方法はsubprocess.Popen()とほぼ同じです。つまり、stdinstdoutstderr引数にそれぞれ特定の定数(e.g. asyncssh.PIPE)、もしくはファイルオブジェクトを与えます。

まず、これらの引数のデフォルト値は全てasyncssh.PIPEに設定されています。この設定では、ホスト側のプロセスの当該標準ストリームを対応するプロパティを通じて読み書きできます。前節でプロセスの標準ストリームがprocess.stdin, process.stdoutを通じて読み書き可能だったのは、デフォルト値がこのように設定されていたためです。

他には、asyncssh.STDOUTという定数があります。これをstderr引数に渡すことで、stderrに渡される出力をstdoutに流すこと(つまり、シェルでの2>&1)が可能です。asyncssh.STDERR定数をstdout引数に渡して、逆のこともできます。

それ以外のファイルオブジェクトに読み書きをさせたい場合には、そのファイルオブジェクトを直接渡します。例えば、以下のコードは、bcの対話プロセスをホストで開いて、それをローカルから操作する例です。

async with conn.create_process("bc", stdin=sys.stdin, stdout=sys.stdout) as process:
    await process.wait()

以下、上記のコードをテンプレートに当てはめたプログラム(remote_bc.py)の実行例です。

$ python remote_bc.py 
1+1
2
2+3
5
quit

1行目でローカルのstdin/stdoutをホストのstdin/stdoutへとリダイレクトするように設定し、2行目でホストのプロセスが終了するまで待機します。

ただし、stdin, stdout, stderr引数に渡したファイルオブジェクトはホスト側のプロセス終了時に強制的にcloseされます。例えば、上記コードだとstdoutが閉じられるのでprint関数が呼び出せなくなります。なので、リダイレクトするファイルオブジェクトは、閉じても構わないものにして、それ以外の場合はasyncssh.PIPEを指定しておくのがいいかと思います。

5.4 Send Signal

ホスト側に中断信号(SIG_INT)のようなシグナルを送る場合には conn.send_signalメソッドを使います。

async with conn.create_process() as process:
    process.send_signal("INT")
    await process.wait()

process.send_signal()は信号を送るだけなので、実際に信号が受け取られて処理されるまでprocess.wait()で待機します。SIG_TERMとSIG_KILLについてはそれぞれprocess.terminate(), process.kill()と関数が用意されているので、そちらを使うのが楽です。他の信号は全てsignalライブラリにあるものに対応していて、指定方法はSIG_以下の文字列(SIG_INTなら"INT")です。

ただし、send_signal()はホスト側の環境によっては動かないので注意が必要です。自分の知る限りでは、OpenSSHについてはv7.9p1以降でないと動きません。詳しくはこちらのissueを参照してください。どうしてもシグナルを送りたい場合の実現方法も載っています。

6. MISC

ここでは、本稿で扱いたい内容とは少しずれるものの重要な内容をさっくりと紹介します。いずれも、もし機会と需要があればしっかりと勉強して記事を書きたい内容です。

6.1 SSHサーバ

ここで扱った内容は全てSSHクライアントの話ですが、AsyncSSHではSSHサーバを実装することもできます。クライアントだけで実現できることには限界があるので、サーバも自前で実装すればできることの可能性が大きく広がります。

6.2 ssh_config

接続先の設定は基本的にssh_configファイル(e.g. ~/.ssh/config)に書きますが、 AsyncSSHにはこのファイルをパースする機能はありません。 ssh_configファイルから接続先の情報を取得したい場合は、Paramikoのパーサ を利用するのがいいかと思います。

6.3 コールバック

connect()を呼び出す際に、SSHClientもしくはその派生クラスをclient_factory引数に渡すことで、接続オブジェクトの挙動を変更できます。あるいは、conn.create_connection()の代わりにconn.create_sessionを使い、SSHClientProcessの派生クラスをそのclient_factory引数に渡すことで、プロセスの挙動を変更できます。

特に、接続完了時、通信遮断時など種々のフェーズで呼び出されるコールバックの挙動を弄れば、例えば通信を簡単にロギングやデバッグできます。

7. おわりに

PythonでSSHといえばParamiko一強なイメージもありますが、こちらのライブラリも非同期処理ができるという独自の強みを持っています。また、とても簡単に扱えるのでasyncioの事始めとしてもいい教材だと思います。なので、この機会にぜひ一度触ってみてはいかがでしょうか。

8. おまけ: テスト環境の構築例

上記サンプルコードを試すために筆者が使用した環境を簡単に紹介します。ユーザ、パスワードはそれぞれrootです。entranceからinternalへのipアドレスの確認方法はこちらの記事が参考になると思います。

8.1 環境

  • Docker 18.09.2
  • docker-compose 1.24.1

    8.2 ディレクトリ構成

test_server/
 ├── docker-compose.yml
 └── Dockerfile

8.2.1 docker-compose.yml

version: '3'
services:
  entrance:
    build: .
    ports:
      - "2222:22"
    networks:
      - sshtest
  internal:
    build: .
    networks:
      - sshtest
networks:
  sshtest:

8.2.2 Dockerfile

FROM rastasheep/ubuntu-sshd

RUN apt-get update && apt-get install -y \
    bc \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*

8.3 起動方法

$ docker-compose up -d

8.4 終了方法

$ docker-compose down

9. References

Effective Python ―Pythonプログラムを改良する59項目

Effective Python ―Pythonプログラムを改良する59項目