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 etx
pour exécutionr-x
: droits pour le groupe : même format1
: nombre de liensdpilato
: nom d'utilisateurstaff
: nom du groupe11
: taille du fichier. La largeur dépends du fichier le plus gros.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.
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" : {} } }