はじめに

事案報告では、イベントの発生状況を視覚的にわかりやすく表現したいものです。このとき私がけっこう使う図解として、横軸に時間を、縦軸にアセットをとって、発生したイベントをプロットする方法があります。

実例を出すことはできないので、ここでは ElasticのサンプルApacheログから、 /robots.txtへのアクセスを図解してみることにします。単に図解の材料として手ごろだっただけで、実用的な意味はありません。

準備(データ取得とAS情報付与)

ApacheのCombinedログを取得する関数read_combined()を定義し、変数log_rawに読み込みます。

library(tidyverse)

read_combined <- function(file) {
  names <- c("ip_address", "remote_user_ident", "local_user_ident", "timestamp", 
             "request", "status_code", "bytes_sent", "referer", "user_agent")
  col_types <- list(col_character(), col_character(), col_character(), 
                    col_datetime("%d/%b/%Y:%H:%M:%S %z"), col_character(), 
                    col_character(), col_integer(), col_character(), col_character())
  data <- read_log(file = file, col_names = names, col_types = col_types)
  return(data)
}

src_url <- "https://github.com/elastic/examples/blob/master/Common%20Data%20Formats/apache_logs/apache_logs?raw=true"
log_raw <- read_combined(src_url)

log_raw %>% 
  slice_head(n = 5)
## # A tibble: 5 x 9
##   ip_address   remote_user_ident local_user_ident timestamp           request   
##   <chr>        <chr>             <chr>            <dttm>              <chr>     
## 1 83.149.9.216 <NA>              <NA>             2015-05-17 10:05:03 GET /pres~
## 2 83.149.9.216 <NA>              <NA>             2015-05-17 10:05:43 GET /pres~
## 3 83.149.9.216 <NA>              <NA>             2015-05-17 10:05:47 GET /pres~
## 4 83.149.9.216 <NA>              <NA>             2015-05-17 10:05:12 GET /pres~
## 5 83.149.9.216 <NA>              <NA>             2015-05-17 10:05:07 GET /pres~
## # ... with 4 more variables: status_code <chr>, bytes_sent <int>,
## #   referer <chr>, user_agent <chr>

IPアドレス単体では情報が足りないので、AS情報を付与しましょう。関数make_routeview()は、ASのフルルートを取得して、あとでIPアドレスに対して割り当てられるように基数木(rv_trie)をつくります。次の関数asnames_current()は、 AS番号と名前との最新の組を取得するものです。

make_routeview <- function() {
  rv_df <- astools::routeviews_latest()
  rv_trie <- astools::as_asntrie(rv_df)
  
  return(rv_trie)
}

rv_trie <- make_routeview()
asnames <- astools::asnames_current()
asnames %>% 
  slice_head(n = 5)
## # A tibble: 5 x 4
##   asn   handle       asinfo       iso2c
##   <chr> <chr>        <chr>        <chr>
## 1 1     LVLT-1       LVLT-1       US   
## 2 2     UDEL-DCN     UDEL-DCN     US   
## 3 3     MIT-GATEWAYS MIT-GATEWAYS US   
## 4 4     ISI-AS       ISI-AS       US   
## 5 5     SYMBOLICS    SYMBOLICS    US

初期図解

まずは何も考えずにプロットしてみます。横軸を時刻、縦軸をAS番号としましょう。

log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  ggplot(mapping = aes(x = timestamp, y = asn)) +
  geom_point()

これを改善していくことにします。

データを整える

データについては、

  • 何らかの色分けをして情報量を増やしたい。
  • 縦軸の並びを、ASごとに初めて出てきた順にしたい。

あたりが思い浮かびます。

log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>% 
  group_by(asn) %>% 
  mutate(first_seen = min(timestamp)) %>% 
  ggplot(mapping = aes(x = timestamp, 
                       y = reorder(asn, desc(first_seen)),
                       color = is_bot)) +
  geom_point(alpha = 0.5, size = 2) 

is_botとfirst_seenという、2つの変数を追加しました。

is_botはUserAgentに「bot」が含まれるかどうかで判定しているので、まともなものではありません。きちんとやるなら、 Crawler User Agentsと照合するなどすべきでしょう。

first_seenにはtimestampの最小値を当てていますが、その前にgroup_by(asn)していることが肝になっています。これによって、ASごとにtimestampの最小値が割り当てられているわけです。もしgroup_by()していなかったら、データフレーム全体の最小値(すなわち最初の行のtimestamp)が入ってしまいます。

そしてggplot()において、yにreorder(asn, desc(first_seen))として並び替えの指定を行っています。さらに、color属性としてis_botを加えてあります。あと、おまけで透明度を0.5としました。こうしておけば、プロットが重なると濃く表現されます。サイズも少し大きくしておきました。

X軸を整える

横軸の改善点は、次の通りです。

  • 目盛りの意味が分かるようにする。
  • タイムゾーンがUTCなのでJSTに変える。
library(scales)
log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>% 
  group_by(asn) %>% 
  mutate(first_seen = min(timestamp)) %>% 
  ggplot(mapping = aes(x = timestamp, 
                       y = reorder(asn, desc(first_seen)),
                       color = is_bot)) +
  geom_point(alpha = 0.5, size = 2) +
  scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
                   minor_breaks = breaks_width("6 hours", offset=-9*60*60),
                   labels = label_date(format = "%d(%a)", 
                                       tz = "Asia/Tokyo")) +
  labs(x = "2015年5月(JST)")

scale_x_datetime()で、軸の大分割・小分割・見出しを設定します。 timestampはPOSIXct型として持っているので、UTCかJSTかは「見た目」の問題であってデータの話ではありません。label_date()でタイムゾーンを設定するだけ…… であれば単純なのですが、このとき分割線が追随してくれないので、 breaks_witdhにoffsetを入れています。

Y軸を整える

同じように縦軸にも手を入れてみます。

log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>% 
  group_by(asn) %>% 
  mutate(first_seen = min(timestamp)) %>% 
  left_join(asnames) %>% 
  mutate(as_iso2c = str_c("AS", asn, " (", iso2c, ") ")) %>% 
  ggplot(mapping = aes(x = timestamp, 
                       y = reorder(as_iso2c, desc(first_seen)),
                       color = is_bot)) +
  geom_point(alpha = 0.5, size = 2) +
  scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
                   minor_breaks = breaks_width("6 hours", offset=-9*60*60),
                   labels = label_date(format = "%d(%a)", 
                                       tz = "Asia/Tokyo")) +
  labs(x = "2015年5月(JST)", y = "")

数字の羅列ではさみしいので、「AS」を接頭辞としてつけて、カッコ内に各ASの国コードを入れることにしました。それがas_iso2cとある部分です。また、縦軸のラベルは見ればわかるので消しています。

ところで1つ、NAになっているASがあります。ASNが「208722_13238」となっていた部分です。これはどちらもYANDEXなので、ちょっと細工しておきましょう。

asn_spec <- 
  tribble(~asn, ~handle, ~asinfo, ~iso2c,
         "208722_13238", "YANDEX", "YANDEX", "RU")

asnnames_1 <- 
  asnames %>% 
  bind_rows(asn_spec)

log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>% 
  group_by(asn) %>% 
  mutate(first_seen = min(timestamp)) %>% 
  left_join(asnnames_1) %>% 
  mutate(as_iso2c = str_c("AS", asn, " (", iso2c, ") ")) %>% 
  ggplot(mapping = aes(x = timestamp, 
                       y = reorder(as_iso2c, desc(first_seen)),
                       color = is_bot)) +
  geom_point(alpha = 0.5, size = 2) +
  scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
                   minor_breaks = breaks_width("6 hours", offset=-9*60*60),
                   labels = label_date(format = "%d(%a)", 
                                       tz = "Asia/Tokyo")) +
  labs(x = "2015年5月(JST)", y = "")

題・備考・凡例を整える

その他の部分も手を入れます。

log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  mutate(is_bot = if_else(str_detect(user_agent, regex("bot", ignore_case = TRUE)), TRUE, FALSE)) %>% 
  group_by(asn) %>% 
  mutate(first_seen = min(timestamp)) %>% 
  left_join(asnnames_1) %>% 
  mutate(as_iso2c = str_c("AS", asn, " (", iso2c, ") ")) %>% 
  ggplot(mapping = aes(x = timestamp, 
                       y = reorder(as_iso2c, desc(first_seen)),
                       color = is_bot)) +
  geom_point(alpha = 0.5, size = 2) +
  scale_x_datetime(breaks = breaks_width("1 day", offset=-9*60*60),
                   minor_breaks = breaks_width("6 hours", offset=-9*60*60),
                   labels = label_date(format = "%d(%a)", 
                                       tz = "Asia/Tokyo")) +
  labs(x = "2015年5月(JST)",
       y = "",
       title = "/robots.txtへのAS別アクセス状況",
       caption = src_url) +
  theme(
        legend.position = "top",
        legend.justification = c("right", "top"),
        legend.title = element_text(size = 9),
        legend.direction = "horizontal",
        legend.box.spacing = unit(0, "pt"),
        legend.margin = margin(2, 2, 2, 2, "pt")) +
  scale_color_hue(name = "UAに「bot」を", labels = c("TRUE" = "含む", "FALSE" = "含まない")) +
  guides(color = guide_legend(reverse = TRUE))

凡例の配置は、実際の描画状況に応じて調整します。たとえば今回は theme()内において legend.position = “top”としてプロット領域の上に置いています。 legend.position = c(0.99, 0.99)とすると、プロット領域の内側(右上端ギリギリ)に配置されます。

凡例の文字列をscale_color_hue()で指定しています。 hueはggplot2の標準のカラーパレットの名前です。そのまま凡例を並べるとFALSE/TRUEの順になるので、最終行で逆転させています。

応用

この手の図解で縦軸に使うアセットとしては、ASNのほかにIPアドレスやホスト名、ユーザーアカウントなどが挙げられます。

また、今回は元データが1万行しかなかったので生データを使いましたが、数千万行になってくるとプロットに時間を要します。そういうときは、first_seenとlast_seenとの2点だけ出して線でつなげるとか、一定時間ごとに集計した時系列データをつくって密度を表示させるなどの工夫をします。下は一例です。

log_raw %>% 
  filter(str_detect(request, "/robots.txt")) %>% 
  mutate(asn = iptools::ip_to_asn(rv_trie, ip_address)) %>% 
  group_by(asn) %>% 
  mutate(first_seen = min(timestamp),
         last_seen = max(timestamp)) %>% 
  distinct(asn, first_seen, last_seen) %>% 
  ggplot() +
  geom_point(mapping = aes(x = first_seen, y = reorder(asn, desc(first_seen)))) +
  geom_point(mapping = aes(x = last_seen,  y = reorder(asn, desc(first_seen)))) +
  geom_linerange(mapping = aes(xmin = first_seen, xmax = last_seen,
                               y = reorder(asn, desc(first_seen))))

以上、ggplot2を使って、横軸に時刻を、縦軸にアセットをとるグラフを描きました。一見すると複雑で、まあ実際単純ではないのですが、規則を理解すれば誰でも描けます。 ggplot2がグラフィックス文法に則っており、一貫した構文体系を提供しているからです。