2014年1月22日水曜日

Java屋からRubyistへの道~2. net-sshでシェルスクリプトを実行する

2013-10-21 20.28.59


猫と一緒にガジェットライフ♪ムチャ(@mutoj_rdm821)です。

SSH(Secure Shell:セキュアシェル)は、認証と暗号化を利用した安全な通信経路を確立してリモートコンピュータと通信するためのプロトコルです。

rubyでSSHを利用するためのライブラリとして、net-sshという物があります。これを使えばSSHを利用した接続の確立は簡単なのですが、基本的にコマンド一発実行して終了です(まあそれで困ることもそんなに無いと思うのですが)。

諸事情により、もっと長いシェルスクリプトをrubyで実行する必要があったのでいろいろ調べたのですが、なかなか情報が無くて苦労しました。結局net-sshのドキュメントとにらめっこして、何とかできるようになったのでまとめておきます。



やりたいこと・問題点

やりたいこと

rubyでSSH接続してシェルスクリプトを流し込みたい。

問題点

ライブラリに用意されているメソッドを叩くだけではうまくいかない。

具体的に説明していきます。



net-sshを使ったリモート接続とコマンド実行

まずnet-sshのインストールです。gemコマンドでOKです。

gem install net-ssh

基本的な使い方は、Net::SSH.startで認証情報を渡し、ブロックに渡されるオブジェクト(Net::SSH::Connection::Session)のexec!メソッドにコマンドを渡します。

require 'net/ssh'
Net::SSH.start('host', 'user', :password => 'password') do |ssh|
  puts ssh.exec!('ls -l')
end

exec!メソッドの戻り値はコマンドを実行した結果(標準出力と標準エラー出力が混ざって出てくる)にになります。

「何か問題でも?」という感じですが、問題点はこうです。



問題点:コマンドごとにシェルが終了してしまうので状態が維持できない

例えば「cd /tmp」と「ls –l」を続けて実行しても、lsの実行結果は/tmpではなく、ログインしたユーザーのホームディレクトリの内容が出てしまいます。

ssh.exec!('cd /tmp')
puts ssh.exec!('ls -l') # → /tmpではなくホームディレクトリの内容が出る

exec!ごとにシェルを起動してコマンドを実行したら終了、という感じです。もちろん環境変数も引き継がれません。



解決法

SSHのプロトコルをひもといていくと、SSHで接続を確立した後は、Channelという物を使ってデータのやりとり(コマンドを渡したり実行結果を受け取ったり)をしているようです。net-sshにもNet::SSH::Connection::Channelというクラスがあり、これを使って次のようにするとうまくいきました。

Net::SSH.start('host', 'user', :password => 'password') do |ssh|
  # チャネルをオープンする
  ssh.open_channel do |channel|

    ♯ 標準出力を受け取るハンドラ
    channel.on_data do |ch, data|
      puts data
    end

    # 標準エラー出力を受け取るハンドラ(ブロック引数は3つなので注意)
    channel.on_extended_data do |ch, type, data|
      p data
    end

    # チャネルリクエストを"shell"で送る
    channel.send_channel_request "shell" do |ch, success|
      if success
        ch.send_data("env\n")
        ch.send_data("export TEST=abc\n")
        ch.send_data("env\n")
        ch.send_data("cd /tmp\n")
        ch.send_data("ls -l\n")
        ch.process
        ch.eof!
      else
        p "channel request error"
      end
    end

    # 終了コードを受け取るハンドラ
    channel.on_request "exit-status" do |ch, data|
      p "exit status"
      p data.read_long
    end
  end

  # チャネル上の通信が終わるまで待機
  ssh.loop
end

手順解説

  1. ブロックに渡されたSessionオブジェクトのopen_channelメソッドを呼んでチャネルをオープンする
  2. 必要であれば標準出力、標準エラー出力を受け取るハンドラを登録する
  3. “shell”のチャネルリクエストを送る
  4. 成功したら第2引数がtrueになるので、Channelオブジェクトのsend_dataメソッドにコマンドを渡していく(改行も)
  5. 全部渡したらprocessメソッドを呼ぶ(これで実際にリモートに文字列が渡る)
  6. eof!メソッドを呼んで終了する

ちょっと複雑ですね。ポイントはChannelをオープンした後にチャネルリクエストを”shell”で送ることです。exec!の場合は”exec”がリクエストされていて、一発実行すると終わるようになっています。プロトコル仕様では他にもいろいろあるようです。

リクエストが成功すると、チャネルにコマンドを流せるようになります。send_dataは内部でバッファリングしているようで、processを呼ぶと実際に送信されます。

そしてeof!メソッドで送信を終了します。・・・と、ここまではこちら側(クライアント側)の処理が終わっただけなので、まだサーバー側は終わってるかどうかは分かりません。しかしrubyの実行は進んでいくので、ssh.loopを呼んでチャネルが完全に閉じるまで待機します

実行した結果の標準出力・標準エラー出力は、事前に登録したハンドラに渡されます。ハンドラが呼ばれるタイミングはサーバーからの応答次第なので不定です。応答が遅い場合、ssh.loopを呼んでいないと先にrubyの処理が終わってしまう可能性があります。

上記コードを実行すると、exportで設定した環境変数はきちんと次のenvの結果に出てきますし、cdでカレントディレクトリが変更された状態で次のlsが実行されます。



おまけ

公開鍵認証でログインする

上のコードはパスワード認証していますが、startメソッドの第3引数にはいろいろなオプションがあります。事前に接続先に公開鍵を設定してあれば、秘密鍵ファイルを指定してログインすることもできます。

options = {
  keys: '/path/to/private.key',
  passphrase: '秘密鍵のパスフレーズ(必要なら)',
}
Net::SSH.start('host', 'user', options) do |ssh|
:

キーペアの生成時にパスフレーズを設定してなかったらpassphraseは不要です。設定済みのキーに対してパスフレーズを指定不要にする方法もあったと思います(ssh agentとかだったか・・・)。



scpでファイルを送る

SSH上で暗号化された通信路を使ってファイルをコピーするscpも使えます。net-scpを追加でインストールして、startの実行後、

require 'net/ssh'
require 'net/scp' # gemで入れておく
:
  ssh.scp.upload!('local/path', 'remote/path')
  ssh.scp.download!('local/path', 'remote/path')
:

でアップロード、ダウンロードができます。

「・・・じゃあシェルスクリプトをファイルに書いてからアップロードして一発実行すればexec!でよくね?」

はい、正解です。普通はそれで問題無いと思います(;´∀`) 事情があってファイルを作らずにやる方法を探してました。




まとめ

リモートのサーバーの管理を自動化するのに便利なnet-ssh。うまく使うと作業が自動化できます。

楽というよりかは、作業内容を定型化することでオペレーションミスを無くすことができるのが大きいのではないでしょうか。

同じように困っている方のお役に立てれば幸いです。

それではみなさま良きガジェットライフを(´∀`)ノ



▼こちらの記事もどうぞ

▼ブログを気に入っていただけたらRSS登録をお願いします!
▼ブログランキング参加中!応援よろしくお願いします。

スポンサーリンク