どうしても syslog に特定の文字列があったら即時通知したかったので Logstash Output Plugin を自作した

# 概要

SNMP Trap を送信できず、syslog でなら目的のログは送信できるが、緊急性の高い syslog が出た場合、どうしてもなるべくリアルタイムに Slack 通知したい、という稀によくある自体に遭遇したので、ログ収集に使っている Logstash を拡張して、特定の文字列があったら Slack 通知する Output Plugin を自作した。


# 構成の概要

現在、各ネットワーク機器は集約サーバを通すことで SNMP メトリック、syslog を収集している。

![無題のプレゼンテーション.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/243e8e2e-0b9d-da14-5cb1-0af8738a250d.jpeg)

![無題のプレゼンテーション (1).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/88428/8fc29fed-11a8-f6a5-2a16-d046d6de5475.jpeg)

また、集約サーバは　snmptrapd　で SNMP Trap も受け付けて、自作のスクリプトを通して Slack 通知を行っている。


# 発生した問題

とあるネットワーク機器（しかも割りと重要ポジション）がやんごとなき事情により「SNMP での監視が不可能」という状況が発生した。
メトリックは幸いエージェント型のツールで取得することができたが、SNMP Trap で Link Up/Down を検知するといったことができず、判断できる情報は syslog にしか出ない、という状況だった。

しかしながら、Elasticsearch が得意とするのは集約されたログの（それも曖昧な）検索であり、特定の文字列が含まれたログが見つかったら即通知、ということはしづらい。
[elastalert2](https://github.com/jertel/elastalert2) などを組み込んで、似たようなことは実現できるが、1 分以内など細かい間隔では検知できても通知が 1 回に丸められてしまったりと、望む機能は実現できなかった。
Go などで syslog サーバ自体を自作し、Logstash へ流す（ファイルへ書き込む）機能＋Slack へ通知する機能を持たせる、ということも検討したがこれもメンテナンス性が悪くなりそうだったため最終手段とした。


# Logstash Output Plugin で対応

そもそも、Logstash は（記述の面倒さは置いておいて）設定次第でそれなりに柔軟に送信するログを選別したり、送信先を分けたりといった機能が備わっている。
しかも [Output Plugin を自作する](https://inokara.hateblo.jp/entry/2016/10/23/091906)ことも簡単にできる。
もちろん[同じことを考えた人は過去にもいた](https://github.com/logstash-plugins/logstash-output-slack)ようだが、Legacy Webhook を前提にしていたり、ずっと更新されていなかったりといった問題があったため、必要な機能を備えた Output Plugin を自作することにした。

以下、サーバは Rocky Linux 8 で Logstash のバージョンは 8.x で進める。

## Plugin の作成

~~パクリ元~~ 参考にしたのはこちらの記事。
https://inokara.hateblo.jp/entry/2016/10/23/091906

### 雛形の作成

logstash がインストールされているディレクトリ（`/usr/share/logstash`）へ移動。

```
# cd /usr/share/logstash/
# ls
CONTRIBUTORS  Gemfile  Gemfile.lock  JDK_VERSION  LICENSE.txt  NOTICE.TXT  bin  data  jdk  lib  logstash-core  logstash-core-plugin-api  modules  tools  vendor  x-pack
```

`logstash-plugin generate` でひな形を作成。
`--name` には任意の plugin 名（ここでは `nekoslack`）を入れる。

```
./bin/logstash-plugin generate --type output --name nekoslack --path ./
```

実行結果

```
# ./bin/logstash-plugin generate --type output --name nekoslack --path ./
Using bundled JDK: /usr/share/logstash/jdk
 Creating ./logstash-output-nekoslack
	 create logstash-output-nekoslack/docs/index.asciidoc
	 create logstash-output-nekoslack/lib/logstash/outputs/nekoslack.rb
	 create logstash-output-nekoslack/spec/outputs/nekoslack_spec.rb
	 create logstash-output-nekoslack/CHANGELOG.md
	 create logstash-output-nekoslack/CONTRIBUTORS
	 create logstash-output-nekoslack/DEVELOPER.md
	 create logstash-output-nekoslack/Gemfile
	 create logstash-output-nekoslack/LICENSE
	 create logstash-output-nekoslack/README.md
	 create logstash-output-nekoslack/Rakefile
	 create logstash-output-nekoslack/logstash-output-nekoslack.gemspec
```

`logstash-output-nekoslack` ディレクトリが作成されているので移動。

```
# cd logstash-output-nekoslack
# ls
CHANGELOG.md  DEVELOPER.md  LICENSE    Rakefile  lib                                spec
CONTRIBUTORS  Gemfile       README.md  docs      logstash-output-nekoslack.gemspec
```

`lib/logstash/outputs/nekoslack.rb` が本体なので、これをいじる。

```
# cat lib/logstash/outputs/nekoslack.rb
# encoding: utf-8
require "logstash/outputs/base"

# An nekoslack output that does nothing.
class LogStash::Outputs::Nekoslack < LogStash::Outputs::Base
  config_name "nekoslack"

  public
  def register
  end # def register

  public
  def receive(event)
    return "Event received"
  end # def event
end # class LogStash::Outputs::Nekoslack
```

なお、特に github などで gem として公開する予定は無いので放置しているが、公開する場合には gemspec を変更しておく。

デフォの gemspec ファイル

```
# cat logstash-output-nekoslack.gemspec
Gem::Specification.new do |s|
  s.name          = 'logstash-output-nekoslack'
  s.version       = '0.1.0'
  s.licenses      = ['Apache-2.0']
  s.summary       = 'Logstash Output Plugin for Nekoslack'
  s.description   = 'TODO: Write a longer description or delete this line.'
  s.homepage      = 'TODO: Put your plugin''s website or public repo URL here.'
  s.authors       = ['']
  s.email         = ''
  s.require_paths = ['lib']

  # Files
  s.files = Dir['lib/**/*','spec/**/*','vendor/**/*','*.gemspec','*.md','CONTRIBUTORS','Gemfile','LICENSE','NOTICE.TXT']
   # Tests
  s.test_files = s.files.grep(%r{^(test|spec|features)/})

  # Special flag to let us know this is actually a logstash plugin
  s.metadata = { "logstash_plugin" => "true", "logstash_group" => "output" }

  # Gem dependencies
  s.add_runtime_dependency "logstash-core-plugin-api", "~> 2.0"
  s.add_runtime_dependency "logstash-codec-plain"
  s.add_development_dependency "logstash-devutils"
end
```

### プラグインの開発（出来上がったもの）

このような感じにプラグインを作成した。

```ruby
# encoding: utf-8
require "logstash/outputs/base"
require 'net/http'
require 'json'

# An nekoslack output that does nothing.
class LogStash::Outputs::Nekoslack < LogStash::Outputs::Base
  config_name "nekoslack"
  config :webhook_url, :validate => :string, :required => true
  config :title, :validate => :string, :required => false, :default => "undefined title"
  config :title_link, :validate => :string, :required => false, :default => ""
  config :color, :validate => :string, :required => false, :default => "ffebcd"
  config :text, :validate => :string, :required => false, :default => "undefined text"
  config :notice_fields, :validate => :array, :required => false, :default => []
  config :attachments_short, :validate => :boolean, :required => false, :default => true
  config :attachments_code_block, :validate => :boolean, :required => false, :default => false

  public
  def register
    require 'socket'
    @hostname = Socket.gethostname
  end # def register

  public
  def receive(event)
    @pretext = "[`#{@hostname}`] (Pretext Message)"

    @slack_field = []
    if notice_fields.any?
      notice_fields.each do |field|
        field_value = attachments_code_block ? "```#{event.get(field)}```" : "#{event.get(field)}"
        slack_field = {
          title: field,
          value: field_value,
          short: attachments_short,
        }
        @slack_field.push slack_field
      end
    end

    json = JSON.dump(
      attachments: [
      	mrkdwn_in: ["text"],
        pretext: @pretext,
        title: title,
        title_link: title_link,
        color: color,
        text: text,
        fields: @slack_field,
      ]
    )
    Net::HTTP.new("hooks.slack.com", 443).tap do |http|
      http.use_ssl = true
    end.start do |http|
      req = Net::HTTP::Post.new webhook_url
      req['Content-Type'] = 'application/json'
      req['Content-Length'] = json.bytesize
      req.body = json
      http.request req
    end
  end # def event
end # class LogStash::Outputs::Nekoslack
```

処理としては各オプション（`webhook_url`, `title`, `title_link`, `color`, `text`, `notice_fields`, `attachments_short`, `attachments_code_block`）を元に slack のメッセージを組み立てて `webhook_url` 宛に送信する、というシンプルなもの。
また、Logstash の field name を受け取って、その値を `event.get(field)` で slack のメッセージに組み込んでいる。
こうすることで通知先や通知内容を Logstash 側で柔軟に組み立てられるようにしている（代わりに Logstash の設定ファイルに記述する内容は増える）。


### プラグインのビルドとインストール

gem build を行う必要があるため、事前に ruby のインストールを行っておく必要がある。

```
# ruby --version
ruby 3.1.4p223 (2023-03-30 revision 957bb7cb81) [x86_64-linux]
```

`logstash-output-nekoslack` ディレクトリで gem build を実行。

```
# gem build logstash-output-nekoslack.gemspec
WARNING:  open-ended dependency on logstash-codec-plain (>= 0) is not recommended
  use a bounded requirement, such as '~> x.y'
WARNING:  open-ended dependency on logstash-devutils (>= 0, development) is not recommended
  use a bounded requirement, such as '~> x.y'
WARNING:  See https://guides.rubygems.org/specification-reference/ for help
  Successfully built RubyGem
  Name: logstash-output-nekoslack
  Version: 0.1.0
  File: logstash-output-nekoslack-0.1.0.gem
```

~~めんどいので WARNING は無視。~~

build に成功すれば `logstash-output-nekoslack-0.1.0.gem`　が出来上がる。

```
# ls -l logstash-output-nekoslack-0.1.0.gem
-rw-r--r-- 1 root root 8192  1月  4 15:54 logstash-output-nekoslack-0.1.0.gem
```

logstash-plugin install でインストールする。

```
# /usr/share/logstash/bin/logstash-plugin install ./logstash-output-nekoslack-0.1.0.gem
Using bundled JDK: /usr/share/logstash/jdk
Validating ./logstash-output-nekoslack-0.1.0.gem
Installing logstash-output-nekoslack
Installation successful
```


### プラグインの使い方

インストールが成功すれば Logstash から output plugin として利用可能になる。
今回の要件としては、特定の文字列があったらこのプラグインを使って即時 Slack 通知することが目的で、受信した syslog に Interface の Up/Down に関する文字列があったら Slack へ通知する設定を Logstash に追加する。

例）

```
output {
（その他の分岐）
    if "ifwatchdog" in [syslog_message] {
      if "up->down" in [syslog_message] {
        nekoslack {
          webhook_url => "slack の webhook URL"
          title => "Interface Notification"
          title_link => "必要なリンク"
          text => "Interface Down 検知"
          color => "danger"
          notice_fields => ["hostname", "fromhost_ip", "syslog_message"]
          attachments_short => false
          attachments_code_block => true
        }
      }
      if "down->up" in [syslog_message] {
        nekoslack {
          webhook_url => "slack の webhook URL"
          title => "Interface Notification"
          title_link => "リンク"
          text => "Interface Up 検知"
          color => "good"
          notice_fields => ["hostname", "fromhost_ip", "syslog_message"]
          attachments_short => false
          attachments_code_block => true
        }
      }
    }
（ログ送信先の設定など）
}
```

この例では「`hostname`, `fromhost_ip`（メッセージを流した host の IP）, `syslog_message`（実際に流したログ）」の 3 つをコードブロックで囲み、attachment を short にまとめない形で通知が行われる。
また、Slack へ通知するだけでなく、そのまま Elasticsearch にもログが転送されるので後から検索することもできて便利になった。


# 終わりに

今回は通知内容を増やしたり変更したくなった場合にプログラミングが必要な箇所を極力減らすために汎用的（オプションで色々変えられる形）に作成したが、ruby で簡単にプラグインが自作できるので、Logstash では実現しづらいロジックをプラグインに作り込むこともできそうだし、今後のログ集約・分析にも応用できそう。
ただ、Logstash 自身がかなり重く、メモリの少ないサーバには載せづらいのが難点。

株式会社ねこじゃらしでは人材募集中です。
気になった方は是非お話だけでも聞きにきてください。
https://www.green-japan.com/company/2706