
Java Springを使用した、Linuxノードアップデート方法
はじめに
本記事では、毎月アップデートが必要なLinuxサーバーの数が100を超えてきた筆者が、各ディストリビューションに合わせたコマンドを毎回手入力する代わりに自動化する方法をご紹介します。
方針
作業者への負担を減らすため、jarとCSVファイル・コマンドを1行で動作させます。そのためSSH接続に必要なユーザーネームと秘密鍵は同一にします。
また、以下の作業に必要なファイルへのパスは常に同じとします。(パスは定数ファイルなどを適宜使用してください)
- 秘密鍵
- 公開鍵
- CSVファイル
- ログ格納ディレクトリ
コードの動き
- CSV読み込み
- 公開鍵方式によるSSH接続
- ディストリビューションに合わせたコマンドの実行
- 実行結果をログに書き出す
- コマンドの実行
動作環境
今回の自動化は、AWS EC2インスタンスにあるLinuxサーバーを対象にしています。
製造工数を減らたかったためすでに作業PCにインストールされていたJava 1.8/spring bootを使っていますが、Java 1.8なのでPythonやC#への移植も難しくないと思います。
コマンドの自動化方法
CSV読み込み
アップデートが必要なサーバーリストをCSVの形で作成します。CSVを入力に選択した理由ですが、Excelでの作成が可能で作業対象サーバーが変更になっても修正が簡単です。以下は、CSVのサンプルです。
csv:CSVリスト例
CompanyA,AHostName,xxx.xxx.xxx.xxx,Ubuntu
CompanyB,BHostName,xxx.xxx.xxx.xxx,RedHat
次に、CSVを読むコードは以下の通りです。
java:CsvReader.java
private List<String> getColumnInfoList(String filePath, int columnIndex, String columnTitle){
final int csvColumnCount = 4;
List<String> inputList = new ArrayList<>();
try(BufferedReader br = new BufferedReader(new FileReader(filePath))){
String line;
while((line = br.readLine()) != null){
String[] columnList = line.split(",");
if(columnList.length > csvColumnCount - 1 && columnList[columnIndex].trim().isEmpty()){
System.err.println("Warning : Missing the value in column " + columnTitle);
}
if(columnList.length > csvColumnCount - 1){
inputList.add(columnList[columnIndex]);
}
}
}
catch(IOException e){
System.err.println("Fail to read the file for " + columnTitle);
}
return inputList;
}
作業がしやすいようCSVを分割すると、以下のようになります。
java:CsvReader.java
public List<String> getHostNameList(String filePath) {
final int hostNamePositionNum = 1;
final String columnTitle = "Hostname";
return getColumnInfoList(filePath, hostNamePositionNum, columnTitle);
}
public List<String> getIpAddrList(String filePath) {
final int ipAddrPositionNum = 2;
final String columnTitle = "IpAddress";
return getColumnInfoList(filePath, ipAddrPositionNum, columnTitle);
}
public List<String> getDistributionList(String filePath){
final int distributionPositionNum = 3;
final String columnTitle = "Distribution";
return getColumnInfoList(filePath, distributionPositionNum, columnTitle);
}
公開鍵方式によるSSH接続
分割したリストに基づいてそれぞれのサーバーへSSH接続します。
java:SshAccessor.java
public void connect(List<String> hostNameList, List<String> ipAddrList, List<String> distributionList) {
SshClient client = SshClient.setUpDefaultClient();
client.start();
for(int i = 0; i < hostNameList.size(); i++){
String userName= userNameProviderFactory.getUserName(distributionList.get(i));
try (ClientSession session = client.connect(userName, ipAddrList.get(i), port).verify(10000).getSession()) {
char[] passphrase = pass.toCharArray();
KeyPair keyPair = SshKeyCreater.loadKeyPair(privateKeyPath, publicKeyPath, passphrase);
session.addPublicKeyIdentity(keyPair);
session.auth().verify(5000);
sendCommandList(session, hostNameList.get(i), distributionList.get(i));
session.close();
}
catch (Exception e) {
e.printStackTrace();
}
}
client.stop();
int exitCode = SpringApplication.exit(context, () -> 0);
System.exit(exitCode);
}
次に、以下で鍵の読み込みを行います。
java:SshKeyCreater.java
public static KeyPair loadKeyPair(String privateKeyPath, String publicKeyPath, char[] passphrase) throws Exception {
// Read the secret key
PEMParser pemParser = new PEMParser(new FileReader(privateKeyPath));
Object object = pemParser.readObject();
pemParser.close();
JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC");
PrivateKey privateKey;
// Decode the secret key with passphrase
if (object instanceof PEMEncryptedKeyPair) {
PEMEncryptedKeyPair encryptedKeyPair = (PEMEncryptedKeyPair) object;
PEMKeyPair pemKeyPair = encryptedKeyPair.decryptKeyPair(new JcePEMDecryptorProviderBuilder().build(passphrase));
privateKey = converter.getPrivateKey(pemKeyPair.getPrivateKeyInfo());
}
// Without passphrase
else if (object instanceof PEMKeyPair) {
privateKey = converter.getPrivateKey(((PEMKeyPair) object).getPrivateKeyInfo());
}
else {
throw new IllegalArgumentException("Invalid private key format.");
}
// Read the public key
PublicKey publicKey = loadOpenSSHPublicKey(publicKeyPath);
return new KeyPair(publicKey, privateKey);
}
ディストリビューションに合わせたコマンドの実行
以下は、Ubuntuだった場合の流れです。
java:SshAccessor.java
private void sendCommandList(ClientSession session, String hostName, String distribution){
List<Command> commnadList;
commnadList = CommandListProviderFactory.getCommandListProvider(distribution).getCommandList();
for(Command commandSet : commnadList){
sendCommand(session, hostName, commandSet);
}
}
java:CommandListProviderFactory.java
public static CommandListProvider getCommandListProvider(String distribution){
if(distribution.equalsIgnoreCase("Ubuntu")){
return new UbuntuCommandListProvider();
}
else if(distribution.equalsIgnoreCase("RedHat")){
return new RedHatCommandListProvider();
}
throw new IllegalArgumentException();
}
java:UbuntuCommandListProvider.java
public class UbuntuCommandListProvider implements CommandListProvider{
@Override
public List<Command> getCommandList(){
return CommandList.getUbuntuCommandList();
}
}
コマンドは頻繁に変わらない事も多く作業者が管理する必要もないため、プログラムに内蔵しています。
java:CommandList.java
public static List<Command> getUbuntuCommandList(){
List<Command> commandList = new ArrayList<>();
commandList.add(new Command("hostname", null, true, false));
commandList.add(new Command("cat /etc/issue", null, true, false));
commandList.add(new Command("sudo apt update", null, false, false));
commandList.add(new Command("sudo apt upgrade", null, false, true));
commandList.add(new Command("ps aux | grep apache2 | grep -v grep", null, true, false));
return commandList;
}
コマンドのエンティティは以下のとおりです。
java:Command.java
public class Command {
private String command;
private String alternativeCommand;
private boolean isContinuedOrNo;
private boolean isAskedToSayYesOrNo;
}
コマンド実行時の確認対応
コマンドを実行する際、サーバーから確認されることがあります。その場合の対応は以下の通りです。
java:SscAccessor.java
private void sendCommand(ClientSession session, String hostName, Command commandSet){
String responseString;
try (ByteArrayOutputStream responseStream = new ByteArrayOutputStream();
ClientChannel channel = session.createExecChannel(commandSet.getCommand())) {
channel.setOut(responseStream);
channel.open().verify(5, TimeUnit.SECONDS);
channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), TimeUnit.SECONDS.toMillis(60));
responseString = new String(responseStream.toByteArray());
if((responseString.contains("command not found") || responseString.contains("コマンドがありません")) && commandSet.getAlternativeCommand() != null){
sendCommand(session, hostName, commandSet);
return;
}
if(commandSet.getIsContinuedOrNo()){
Scanner scan = new Scanner(System.in);
if(terminalHandler.checkOutputAndWaitForEnterKey(commandSet, responseString, scan)){
return;
};
}
if(commandSet.isAskedToSayYesOrNo()){
Scanner scan = new Scanner(System.in);
String userInput = terminalHandler.inputYesOrNo(commandSet, responseString, scan);
OutputStream out = channel.getInvertedIn();
out.write((userInput + "\\n").getBytes());
out.flush();
}
logCreater.saveLog(hostName, commandSet.getCommand());
logCreater.saveLog(hostName, " ");
logCreater.saveLog(hostName, responseString);
if(!channel.isClosed()){
channel.close();
}
}
catch (IOException e) {
e.printStackTrace();
}
}
実行結果をログに書き出す
java:LogCreater.java
public void saveLog(String hostName, String logContent){
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
String date = dateFormat.format(new Date());
String logFileTitle = logStoragePath + File.separator + date + "-" + hostName + ".txt";
try(BufferedWriter bw = new BufferedWriter(new FileWriter(logFileTitle, true))){
bw.write(logContent);
}
catch(IOException e){
System.err.println("Fail to save the log for the host name is " + hostName);
e.printStackTrace();
}
}
コマンドの実行
次は、以下のように設定します。
java:LinuxUpdatingApplication.java
@SpringBootApplication
public class LinuxUpdatingApplication implements CommandLineRunner{
public void run(String... args) throws Exception {
if (args.length == 0) {
System.out.println("No path is set.");
return;
}
else {
System.out.println("Success to have the csv file");
}
List<String> hostNameList = csvReader.getHostNameList(args[0]);
List<String> ipAddrList = csvReader.getIpAddrList(args[0]);
List<String> distributionList = csvReader.getDistributionList(args[0]);
}
}
次のコマンドを実行したら、完了です。
bash:command
java -jar LinuxUpdating.jar ./path/to/linux/server/list.csv
最後に
本記事では、各ディストリビューションに合わせたコマンドを毎回手入力する代わりに自動化する方法をご紹介しましたが、いかがでしたでしょうか。
管理しているLinuxサーバーが増えると、毎月のアップデートが大変ですよね。ぜひこの方法で自動化をお試しいただき、お役にたてると嬉しいです。