複数サーバのログを1つのコンソールでまとめてtailする

## はじめに
アプリケーションサーバの再起動・配置時などにはロードバランサや各サーバ用のコンソールで煩雑になるため、
各サーバのログをtailするためだけに新たな面積を割くことが困難だったりします。

そこで、複数サーバにあるログを1つのコンソールでtailできるようにしてみました。
流れが速すぎて読めない可能性もありますが、
適宜grepなども組み合わせつつ使ってみると便利かもしれません。

機能は限定的ですが、概要は下記の通りです。

- 指定された複数のサーバにSSHログインし、対象のファイルをtailし、標準出力に出力する
- 対象のファイルが日別・時間別にローテートされている場合などに対応するため、ファイル名に日時のパターンを指定できる
- 日時の経過により、対象のファイルを変更する必要がある場合は、自動で切り替える
- 1つのサーバで問題が生じたら、すべてのtailを停止し、Exceptionをthrowしてアプリケーションを終了する
- tail開始後、標準入力で'quit'(+エンター)を入力されたら、すべてのtailを停止し、アプリケーションを終了する

事前に以下のライブラリを用意します。

- JSch
    - http://www.jcraft.com/jsch/
    - ※"jsch-0.1.54.jar"のリンクからダウンロード

## 実装例
サンプルでは、動作確認しやすいようにmainメソッドで実行できるようにしてあります。

```java:MultiTail.java
import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 *
 * @author tool-taro.com
 */
public class MultiTail implements Runnable {

	public static void main(String[] args) throws IOException, InterruptedException, Exception {

		//MultiTailの1インスタンスあたり1台のサーバのtailを担当する
		//複数のサーバをまとめてtailする場合は、相当数のインスタンスを生成してstartする
		MultiTail[] multiTailList = new MultiTail[2];
		//コンストラクタ: 表示名, ホスト, ポート, ユーザ, パスワード, tail対象ファイル(日時のパターン指定にも対応), tail時の文字コード
		multiTailList[0] = new MultiTail("serv1", "ホスト", 22, "ユーザ", "パスワード", "'/var/log/httpd/access_log.'yyyyMMdd", "UTF-8");
		multiTailList[1] = new MultiTail("serv2", "ホスト", 22, "ユーザ", "パスワード", "'/var/log/httpd/access_log.'yyyyMMdd", "UTF-8");
		//tailを開始する
		for (MultiTail multiTail : multiTailList) {
			multiTail.start();
		}

		BufferedReader reader = null;

		try {
			//標準入力で終了用のコマンドを受け付ける
			reader = new BufferedReader(new InputStreamReader(System.in));
			while (true) {
				//いずれかのサーバでExceptionが発生したらアプリケーションを終了する
				for (MultiTail multiTail : multiTailList) {
					if (multiTail.getException() != null) {
						MultiTail.terminateAll(multiTailList);
						throw multiTail.getException();
					}
				}

				if (!reader.ready()) {
					Thread.sleep(50);
					continue;
				}

				//"quit"＋エンターを入力されたらアプリケーションを終了する
				if ("quit".equals(reader.readLine())) {
					MultiTail.terminateAll(multiTailList);
					break;
				}
			}
		}
		finally {
			if (reader != null) {
				try {
					reader.close();
				}
				catch (IOException e) {
				}
			}
		}
	}

	//すべての接続を切断する
	private static void terminateAll(MultiTail[] multiTailList) throws InterruptedException {
		for (MultiTail multiTail : multiTailList) {
			multiTail.terminate();
		}
	}

	private String name = null;
	private String host = null;
	private int port = -1;
	private String user = null;
	private String password = null;
	private String filePath = null;
	private String encoding = null;
	private Thread thread = null;
	private boolean terminated = false;
	private Exception exception = null;

	public MultiTail(String name, String host, int port, String user, String password, String filePath, String encoding) {
		this.name = name;
		this.host = host;
		this.port = port;
		this.user = user;
		this.password = password;
		this.filePath = filePath;
		this.encoding = encoding;
	}

	//SSHログインしてtailするThreadを開始する
	public void start() {
		this.thread = new Thread(this);
		this.thread.start();
		System.out.println("started [" + this.name + "]");
	}

	//Threadの停止を指示して終了まで待機する
	public void terminate() throws InterruptedException {
		this.terminated = true;
		this.thread.join();
		System.out.println("terminated [" + this.name + "]");
	}

	@Override
	public void run() {
		JSch jsch;
		Session session = null;
		ChannelExec channel = null;
		BufferedWriter writer = null;
		BufferedReader reader = null;

		String currentFilePath = null;
		String newFilePath;

		try {
			SimpleDateFormat formatter = new SimpleDateFormat(this.filePath);

			jsch = new JSch();
			session = jsch.getSession(this.user, this.host, this.port);
			//known_hostsのチェックをスキップ
			session.setConfig("StrictHostKeyChecking", "no");
			session.setPassword(this.password);
			session.connect();

			while (true) {
				//日付がかわった場合、tail対象のファイルを変更する必要があるかを判定する
				//変更する必要がある場合、現在のコマンドを破棄し、新たに発行する
				newFilePath = formatter.format(new Date());
				if (!newFilePath.equals(currentFilePath)) {
					if (writer != null) {
						try {
							writer.close();
						}
						catch (IOException e) {
						}
					}
					if (reader != null) {
						try {
							reader.close();
						}
						catch (IOException e) {
						}
					}
					if (channel != null) {
						try {
							channel.disconnect();
						}
						catch (Exception e) {
						}
					}

					currentFilePath = newFilePath;
					channel = (ChannelExec) session.openChannel("exec");
					channel.setCommand("tail -f " + currentFilePath);
					channel.connect();
					System.out.println("tail [" + this.name + ":" + currentFilePath + "]");

					writer = new BufferedWriter(new OutputStreamWriter(channel.getOutputStream(), this.encoding));
					reader = new BufferedReader(new InputStreamReader(channel.getInputStream(), this.encoding), 128);
				}

				//コマンドが予期せず終了している場合
				if (channel.isClosed()) {
					throw new IOException("closed [" + this.name + "]");
				}
				//終了の指示を受けている場合
				if (this.terminated) {
					throw new InterruptedException("terminated [" + this.name + "]");
				}

				if (!reader.ready()) {
					Thread.sleep(50);
					continue;
				}
				//双方のやり取りが一定時間なくなって切断されるケースに(一応)備えてダミーのデータをwriteする
				writer.write(" ");
				writer.flush();

				//readした行を標準出力に出力する
				System.out.println(this.name + ":" + reader.readLine());
			}
		}
		catch (JSchException | IOException | InterruptedException e) {
			this.exception = e;
		}
		finally {
			if (writer != null) {
				try {
					writer.close();
				}
				catch (IOException e) {
				}
			}
			if (reader != null) {
				try {
					reader.close();
				}
				catch (IOException e) {
				}
			}
			if (channel != null) {
				try {
					channel.disconnect();
				}
				catch (Exception e) {
				}
			}
			if (session != null) {
				try {
					session.disconnect();
				}
				catch (Exception e) {
				}
			}
		}
	}

	public Exception getException() {
		return this.exception;
	}
}
```
## 動作確認

```
$ MultiTail.java
$ java MultiTail
$ started [serv1]
started [serv2]
tail [serv1:/var/log/httpd/access_log.20160209]
tail [serv2:/var/log/httpd/access_log.20160209]
serv1:XXX.XXX.XXX.XXX - - [09/Feb/2016:13:03:58 +0900] "GET /hogehoge HTTP/1.1" 404 206 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0" 466
serv2:XXX.XXX.XXX.XXX - - [09/Feb/2016:13:03:59 +0900] "GET /hogehoge HTTP/1.1" 404 206 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0" 400
serv1:XXX.XXX.XXX.XXX - - [09/Feb/2016:13:04:00 +0900] "GET /hogehoge HTTP/1.1" 404 206 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0" 3704
serv2:XXX.XXX.XXX.XXX - - [09/Feb/2016:13:04:01 +0900] "GET /hogehoge HTTP/1.1" 404 206 "-" "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:43.0) Gecko/20100101 Firefox/43.0" 522
quit
terminated [serv1]
terminated [serv2]
```
## 環境
- 開発
	- Windows 10 Pro
	- JDK 1.8.0_121
	- NetBeans IDE 8.2

- 動作検証
	- CentOS Linux release 7.3
	- JDK 1.8.0_121

Webツールも公開しています。
[Web便利ツール@ツールタロウ](http://tool-taro.com/)