Analyser son arborescence de disque avec la Suite Elastic

J'ai récemment donné un talk de type BBL et en discutant avec les participants, l'un d'entre eux m'a parlé d'un des cas d'utilisation qu'il a couvert avec Elasticsearch, à savoir l'indexation des métadonnées des fichiers d'un NAS à l'aide d'une simple commande du type ls -lR. Son besoin est de pouvoir facilement retrouver les fichiers à restaurer à la demande d'un utilisateur.

Comme vous pouvez l'imaginer, un moteur de recherche est très utile surtout si vous avez des centaines de million de fichiers !

J'ai trouvé cette idée particulièrement géniale et c'est d'ailleurs l'une des raisons pour lesquelles j'adore parler à des conférences ou dans des entreprises : vous identifiez toujours d'excellentes idées en échangeant avec d'autres personnes !

J'ai donc décidé d'adapter ce principe en utilisant les technologies de la Suite Elastic.

Trouver la ligne de commande

Comme je tourne sur MacOS, j'ai d'abord besoin d'installer coreutils car il me manque un paramètre très utile à la commande ls : --time-style.

brew install coreutils

Je commence par utiliser find et ls qui m'offrent ici un moyen efficace pour afficher le contenu de mon arborescence à partir d'un répertoire donné, ~/Documents dans notre cas.

find ~/Documents -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S"

Ça nous donne :

-rw-r--r-- 1 dpilato staff   6148 2014-09-18T12:49:23 /Users/dpilato/Documents/Elasticsearch/tmp/es/.DS_Store
-rw-r--r-- 1 dpilato staff 110831 2013-01-28T08:47:27 /Users/dpilato/Documents/Elasticsearch/tmp/es/docs/Autoentreprise2012.pdf
-rw-r--r-- 1 dpilato staff 145244 2013-01-15T14:47:28 /Users/dpilato/Documents/Elasticsearch/tmp/es/meetups/Meetup.pdf
-rw-r--r-- 1 dpilato staff     11 2015-05-12T16:34:08 /Users/dpilato/Documents/Elasticsearch/tmp/es/test.txt

Parser avec Logstash

Quel format avons-nous ? Chaque ligne a deux parties principales séparées par un espace :

  • Métadonnées : -rw-r--r-- 1 dpilato staff 11 2015-05-12T16:34:08
  • Chemin complet : /Users/dpilato/Documents/Elasticsearch/tmp/es/test.txt

Les métadonnées contiennent :

  • d : si il s'agit d'un répertoire ou - pour un fichier. Comme nous n'affichons que les fichiers, nous aurons seulement -.
  • rwx : droits pour l'utilisateur : r pour lecture, w pour écriture et x pour exécution
  • r-x : droits pour le groupe : même format
  •  : un espace
  • 1 : nombre de liens
  •  : un espace
  • dpilato : nom d'utilisateur
  •  : un espace
  • staff : nom du groupe
  •  : un espace
  • 11 : taille du fichier. La largeur dépends du fichier le plus gros.
  •  : un espace
  • 2015-05-12T16:34:08 : dernière date de modification.

Grok-ons le !

J'utilise l'outil GROK Constructor pour créer de façon incrémentale le motif grok.

J'arrive au final à :

[d-][r-][w-][x-][r-][w-][x-][r-][w-][x-] %{INT} %{USERNAME} %{USERNAME} %{SPACE}%{NUMBER} %{TIMESTAMP_ISO8601} %{GREEDYDATA}

Avec le filtre grok, ça donne :

(?:d|-)(?<permission.user.read>[r-])(?<permission.user.write>[w-])(?<permission.user.execute>[x-])(?<permission.group.read>[r-])(?<permission.group.write>[w-])(?<permission.group.execute>[x-])(?<permission.other.read>[r-])(?<permission.other.write>[w-])(?<permission.other.execute>[x-]) %{INT:links:int} %{USERNAME:user} %{USERNAME:group} %{SPACE}%{NUMBER:size:int} %{TIMESTAMP_ISO8601:date} %{GREEDYDATA:name}

Testons le !

Je créé un fichier treemap.conf

input { stdin {} }
filter {
  grok {
    match => { "message" => "(?:d|-)(?<permission.user.read>[r-])(?<permission.user.write>[w-])(?<permission.user.execute>[x-])(?<permission.group.read>[r-])(?<permission.group.write>[w-])(?<permission.group.execute>[x-])(?<permission.other.read>[r-])(?<permission.other.write>[w-])(?<permission.other.execute>[x-]) %{INT:links:int} %{USERNAME:user} %{USERNAME:group} %{SPACE}%{NUMBER:size:int} %{TIMESTAMP_ISO8601:date} %{GREEDYDATA:name}" }
  }
}
output { stdout { codec => rubydebug } }

Puis je lance Logstash :

find ~/Documents -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf

On obtient la ligne initiale que nous avons vue précédemment :

"message" => "-rw-r--r-- 1 dpilato staff     11 2015-05-12T16:34:08 /Users/dpilato/Documents/Elasticsearch/tmp/es/test.txt",
                "@version" => "1",
              "@timestamp" => "2015-12-11T11:27:06.386Z",
                    "host" => "MacBook-Air-de-David.local",
    "permission.user.read" => "r",
   "permission.user.write" => "w",
 "permission.user.execute" => "-",
   "permission.group.read" => "r",
  "permission.group.write" => "-",
"permission.group.execute" => "-",
   "permission.other.read" => "r",
  "permission.other.write" => "-",
"permission.other.execute" => "-",
                   "links" => 1,
                    "user" => "dpilato",
                   "group" => "staff",
                    "size" => 11,
                    "date" => "2015-05-12T16:34:08",
                    "name" => " /Users/dpilato/Documents/Elasticsearch/tmp/es/test.txt"

Quand j'essaye d'écrire les permissions utilisateur directement dans des champs imbriqués, je tombe sur le bug #66. Je dois donc faire quelques transformations manuelles.

Corriger les permissions

Comme nous venons de le voir, nous souhaitons écrire les permissions dans une structure de données imbriquées. Nous pouvons utiliser le filtre  mutate.

Premièrement, remplaçons les valeurs rwx par true et - par false :

mutate {
  gsub => [
    "permission.user.read", "r", "true",
    "permission.user.read", "-", "false",
    "permission.user.write", "w", "true",
    "permission.user.write", "-", "false",
    "permission.user.execute", "x", "true",
    "permission.user.execute", "-", "false",
    "permission.group.read", "r", "true",
    "permission.group.read", "-", "false",
    "permission.group.write", "w", "true",
    "permission.group.write", "-", "false",
    "permission.group.execute", "x", "true",
    "permission.group.execute", "-", "false",
    "permission.other.read", "r", "true",
    "permission.other.read", "-", "false",
    "permission.other.write", "w", "true",
    "permission.other.write", "-", "false",
    "permission.other.execute", "x", "true",
    "permission.other.execute", "-", "false"
  ]
}

Ça donne maintenant :

"permission.user.read" => "true",
"permission.user.write" => "true",
"permission.user.execute" => "false",
"permission.group.read" => "true",
"permission.group.write" => "false",
"permission.group.execute" => "false",
"permission.other.read" => "true",
"permission.other.write" => "false",
"permission.other.execute" => "false",

Nous pouvons déplacer les valeurs dans des structures imbriquées à l'aide d'un nouveau filtre mutate :

mutate {
  rename => { "permission.user.read" => "[permission][user][read]" }
  rename => { "permission.user.write" => "[permission][user][write]" }
  rename => { "permission.user.execute" => "[permission][user][execute]" }
  rename => { "permission.group.read" => "[permission][group][read]" }
  rename => { "permission.group.write" => "[permission][group][write]" }
  rename => { "permission.group.execute" => "[permission][group][execute]" }
  rename => { "permission.other.read" => "[permission][other][read]" }
  rename => { "permission.other.write" => "[permission][other][write]" }
  rename => { "permission.other.execute" => "[permission][other][execute]" }
}

Cela donne :

"permission" => {
         "user" => {
               "read" => "true",
              "write" => "true",
            "execute" => "false"
        },
        "group" => {
               "read" => "true",
              "write" => "false",
            "execute" => "false"
        },
        "other" => {
               "read" => "true",
              "write" => "false",
            "execute" => "false"
        }
    }

Et voilà !

"permission" => {
         "user" => {
               "read" => true,
              "write" => true,
            "execute" => false
        },
        "group" => {
               "read" => true,
              "write" => false,
            "execute" => false
        },
        "other" => {
               "read" => true,
              "write" => false,
            "execute" => false
        }
    }

Réconciliation des dates

Nous avons 2 champs contenant une date :

"@timestamp" => "2015-12-11T11:27:06.386Z",
        "date" => "2015-05-12T16:34:08"

Le filtre date permet de mettre à jour le champ @timestamp de Logstash avec la date du fichier.

date {
    match => [ "date", "ISO8601" ]
    remove_field => [ "date" ]
}

Le champ @timestamp est maintenant correct :

"@timestamp" => "2013-01-15T13:47:28.000Z",

Nettoyage

Quelques champs ne sont désormais plus nécessaires et nous pouvons les supprimer simplement à l'aide de la directive remove_field du filtre mutate : remove_field => [ "message", "host", "@version" ]

Nous sommes maintenant prêts à envoyer les données finales vers Elasticsearch !

{
    "@timestamp" => "2015-05-12T14:34:08.000Z",
         "links" => 1,
          "user" => "dpilato",
         "group" => "staff",
          "size" => 11,
          "name" => "/Users/dpilato/Documents/Elasticsearch/tmp/es/test.txt",
    "permission" => {
         "user" => {
               "read" => true,
              "write" => true,
            "execute" => false
        },
        "group" => {
               "read" => true,
              "write" => false,
            "execute" => false
        },
        "other" => {
               "read" => true,
              "write" => false,
            "execute" => false
        }
    }
}

Envoyer vers Elasticsearch

Comme d'habitude, nous avons juste à connecter le plugin Elasticsearch :

elasticsearch {
  index => "treemap-%{+YYYY.MM}"
    document_type => "file"
}

Utiliser un template

En fait, nous ne souhaitons pas laisser Elasticsearch décider pour nous du mapping. Alors utilisons un template et passons-le à Logstash :

elasticsearch {
    index => "treemap-%{+YYYY.MM}"
    document_type => "file"
    template => "treemap-template.json"
    template_name => "treemap"
}

Configuration de des index

Dans le fichier treemap-template.json, nous définissons les paramètres de l'index suivants :

json
"index" : {
  "refresh_interval" : "5s",
  "number_of_shards" : 1,
  "number_of_replicas" : 0
}

Path Analyzer

Nous avons également besoin d'utiliser un tokenizer de type path afin d'analyser le chemin complet. Pour cela, nous le définissons dans nos settings d'index :

json
"analysis": {
  "analyzer": {
    "path-analyzer": {
      "type": "custom",
      "tokenizer": "path-tokenizer"
    }
  },
  "tokenizer": {
    "path-tokenizer": {
      "type": "path_hierarchy"
    }
  }
}

Mapping

Désactivons le champ _all.

json
"_all": {
  "enabled": false
}

Et nous n'avons pas besoin d'analyser les champs de type "chaine de caractères" sauf pour le champ name qui utilisera notre path-analyzer:

json
"name" : {
  "type" : "string",
  "analyzer": "path-analyzer"
}

Kibana

Pendant que je me mets à créer des visualisations, je lance également l'injection complète de mes données :

sh
find ~/Documents -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Applications -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Desktop -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Downloads -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Dropbox -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Movies -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Music -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Pictures -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf
find ~/Public -type f -print0 | xargs -0 gls -l --time-style="+%Y-%m-%dT%H:%M:%S" | bin/logstash -f treemap.conf

Et finalement, je peux créer mes visualisations et mon tableau de bord.

kibana1.png

SVP ! Ne dites pas à mon chef que j'ai plus de fichiers musicaux que de fichiers de travail (en terme de volume) ! :D

Fichiers complets

Voici les fichiers complets utilisés au cas où vous souhaiteriez faire la même chose chez vous.

Logstash

Fichier treemap.conf :

input { stdin {} }
filter {
  grok {
    match => { "message" => "(?:d|-)(?<permission.user.read>[r-])(?<permission.user.write>[w-])(?<permission.user.execute>[x-])(?<permission.group.read>[r-])(?<permission.group.write>[w-])(?<permission.group.execute>[x-])(?<permission.other.read>[r-])(?<permission.other.write>[w-])(?<permission.other.execute>[x-]) %{INT:links:int} %{USERNAME:user} %{USERNAME:group} %{SPACE}%{NUMBER:size:int} %{TIMESTAMP_ISO8601:date} %{GREEDYDATA:name}" }
  }
  mutate {
    gsub => [
      "permission.user.read", "r", "true",
      "permission.user.read", "-", "false",
      "permission.user.write", "w", "true",
      "permission.user.write", "-", "false",
      "permission.user.execute", "x", "true",
      "permission.user.execute", "-", "false",
      "permission.group.read", "r", "true",
      "permission.group.read", "-", "false",
      "permission.group.write", "w", "true",
      "permission.group.write", "-", "false",
      "permission.group.execute", "x", "true",
      "permission.group.execute", "-", "false",
      "permission.other.read", "r", "true",
      "permission.other.read", "-", "false",
      "permission.other.write", "w", "true",
      "permission.other.write", "-", "false",
      "permission.other.execute", "x", "true",
      "permission.other.execute", "-", "false"
    ]
  }
  mutate {
    rename => { "permission.user.read" => "[permission][user][read]" }
    rename => { "permission.user.write" => "[permission][user][write]" }
    rename => { "permission.user.execute" => "[permission][user][execute]" }
    rename => { "permission.group.read" => "[permission][group][read]" }
    rename => { "permission.group.write" => "[permission][group][write]" }
    rename => { "permission.group.execute" => "[permission][group][execute]" }
    rename => { "permission.other.read" => "[permission][other][read]" }
    rename => { "permission.other.write" => "[permission][other][write]" }
    rename => { "permission.other.execute" => "[permission][other][execute]" }
    convert => { "[permission][user][read]" => "boolean" }
    convert => { "[permission][user][write]" => "boolean" }
    convert => { "[permission][user][execute]" => "boolean" }
    convert => { "[permission][group][read]" => "boolean" }
    convert => { "[permission][group][write]" => "boolean" }
    convert => { "[permission][group][execute]" => "boolean" }
    convert => { "[permission][other][read]" => "boolean" }
    convert => { "[permission][other][write]" => "boolean" }
    convert => { "[permission][other][execute]" => "boolean" }
    remove_field => [ "message", "host", "@version" ]
  }
  date {
    match => [ "date", "ISO8601" ]
    remove_field => [ "date" ]
  }
}
output { 
  stdout { codec => dots } 
  elasticsearch {
    index => "treemap-%{+YYYY.MM}"
    document_type => "file"
    template => "treemap-template.json"
    template_name => "treemap"
  }
}

Template

Fichier treemap-template.json :

json
{
  "order" : 0,
  "template" : "treemap-*",
  "settings" : {
    "index" : {
      "refresh_interval" : "5s",
      "number_of_shards" : 1,
      "number_of_replicas" : 0
    },
    "analysis": {
      "analyzer": {
        "path-analyzer": {
          "type": "custom",
          "tokenizer": "path-tokenizer"
        }
      },
      "tokenizer": {
        "path-tokenizer": {
          "type": "path_hierarchy"
        }
      }
    }
  },
  "mappings" : {
    "file" : {
      "_all": {
        "enabled": false
      },
      "properties" : {
        "@timestamp" : {
          "type" : "date",
          "format" : "strict_date_optional_time||epoch_millis"
        },
        "group" : {
          "type" : "string",
          "index": "not_analyzed"
        },
        "links" : {
          "type" : "long"
        },
        "name" : {
          "type" : "string",
          "analyzer": "path-analyzer"
        },
        "permission" : {
          "properties" : {
            "group" : {
              "properties" : {
                "execute" : {
                  "type" : "boolean"
                },
                "read" : {
                  "type" : "boolean"
                },
                "write" : {
                  "type" : "boolean"
                }
              }
            },
            "other" : {
              "properties" : {
                "execute" : {
                  "type" : "boolean"
                },
                "read" : {
                  "type" : "boolean"
                },
                "write" : {
                  "type" : "boolean"
                }
              }
            },
            "user" : {
              "properties" : {
                "execute" : {
                  "type" : "boolean"
                },
                "read" : {
                  "type" : "boolean"
                },
                "write" : {
                  "type" : "boolean"
                }
              }
            }
          }
        },
        "size" : {
          "type" : "long"
        },
        "user" : {
          "type" : "string",
          "index": "not_analyzed"
        }
      }
    }
  },
  "aliases" : { 
    "files" : {}
  }
}