How to read this page. Each section is collapsed under its heading: click "Show …" to open it, and close it when you are done to keep the page readable. Reading order: Objective, then In Brief (the commands to type), then The Dataset (to read before any query), then the Elasticsearch and Neo4j queries, ordered from the simplest (
GET _cat/indices,MATCH (n) RETURN n) to the most impressive, with an explanation after each. The detailed step-by-step, with the expected output of each command and the failures to cause, is in the appendix: appendix A for Windows (PowerShell), appendix B for Linux, macOS, WSL 2, and Git Bash. Open a single appendix, the one for your system. Appendix C, shared, gathers the cases where things get stuck.
You join the team building the search engine of the online course platform. Your manager hands you the lab kit: "Tomorrow morning, I want a lab running on your workstation, the data loaded, and the certainty that you can troubleshoot it without calling me." So you will start the stack, prove that the three engines contain what they should, then stop a service on purpose to see how the failure reads in etat, in the browser, and in the log, and bring it back up. Recognizing "this service is stopped" in ten seconds is what saves hours of searching in the wrong place.
The nine steps of this diagram are detailed, with the expected output of each command, in appendix A (Windows) or appendix B (Linux, macOS) at the bottom of the page.
Lab kit: https://github.com/hrhouma2/aiopsatlas-recherche-graphes-labo-fr
You clone the kit into a lab1 folder, check that Docker is ready, start the three services (Elasticsearch, Kibana, Neo4j), open their three web pages, then load the data. You run importer and charger-graphe twice: the second run must not change the counters at all — that is the proof that the loading can be replayed without duplicates. At the end, etat must show (healthy) everywhere, acces 12000 avis 609 cours 504, and nœuds : 872. Start by running this block.
Windows (PowerShell)
git clone https://github.com/hrhouma2/aiopsatlas-recherche-graphes-labo-fr.git lab1
cd lab1
ls # explorer le contenu : docker-compose.yml, labo.ps1, labo.sh, elasticsearch/, neo4j/, outils/
.\labo.ps1 prerequis
.\labo.ps1 demarrer
.\labo.ps1 etatCheck the three URLs in the browser:
Kibana http://localhost:5601 (Dev Tools : menu ☰ → Management → Dev Tools)
Elasticsearch http://localhost:9200
Neo4j Browser http://localhost:7474 (utilisateur neo4j · mot de passe aiopsatlas2026).\labo.ps1 importer
.\labo.ps1 charger-graphe
.\labo.ps1 importer # 2e passage : mêmes compteurs, rien ne double
.\labo.ps1 charger-graphe
.\labo.ps1 etat # attendu : acces 12000 avis 609 cours 504 nœuds : 872If PowerShell refuses .\labo.ps1 ("running scripts is disabled"): Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, rerun.
Linux, macOS, WSL 2, Git Bash
git clone https://github.com/hrhouma2/aiopsatlas-recherche-graphes-labo-fr.git lab1
cd lab1
ls # explorer le contenu : docker-compose.yml, labo.sh, labo.ps1, elasticsearch/, neo4j/, outils/
./labo.sh prerequis
./labo.sh demarrer
./labo.sh etatCheck the three URLs in the browser:
Kibana http://localhost:5601 (Dev Tools : menu ☰ → Management → Dev Tools)
Elasticsearch http://localhost:9200
Neo4j Browser http://localhost:7474 (utilisateur neo4j · mot de passe aiopsatlas2026)./labo.sh importer
./labo.sh charger-graphe
./labo.sh importer # 2e passage : mêmes compteurs, rien ne double
./labo.sh charger-graphe
./labo.sh etat # attendu : acces 12000 avis 609 cours 504 nœuds : 872Before typing a single query, look at the data. The whole lab revolves around a fictional online course platform: a course catalog, the reviews left by students, the log of the web server that serves the pages, and the links between students, instructors, courses, and skills. The same data is loaded into Elasticsearch (to search) and into Neo4j (to follow links). It is in the kit, in plain text, in two folders:
lab1/
├── elasticsearch/donnees/ ← ce qui va dans Elasticsearch (3 fichiers NDJSON)
│ ├── cours.ndjson 504 cours
│ ├── avis.ndjson 609 avis
│ └── acces.ndjson 12 000 lignes de journal web
└── neo4j/import/ ← ce qui va dans Neo4j (8 fichiers CSV)
├── cours.csv 504 cours → nœuds Cours
├── etudiants.csv 300 étudiants → nœuds Etudiant
├── professeurs.csv 30 professeurs → nœuds Professeur
├── competences.csv 22 compétences → nœuds Competence
├── villes.csv 16 villes → nœuds Ville
├── inscriptions.csv 1 654 inscriptions → relations INSCRIT_A
├── couvre.csv 994 liens → relations COUVRE
└── prerequis.csv 230 liens → relations PREREQUIS_DEOpen them yourself; it takes ten seconds and you will know exactly what you are handling:
# Windows (PowerShell), depuis le dossier lab1
Get-Content elasticsearch\donnees\cours.ndjson -TotalCount 2
Get-Content elasticsearch\donnees\avis.ndjson -TotalCount 2
Get-Content elasticsearch\donnees\acces.ndjson -TotalCount 2
Get-Content neo4j\import\cours.csv -TotalCount 3
Get-Content neo4j\import\inscriptions.csv -TotalCount 3# Linux, macOS, WSL 2, Git Bash, depuis le dossier lab1
head -n 2 elasticsearch/donnees/cours.ndjson
head -n 2 elasticsearch/donnees/avis.ndjson
head -n 2 elasticsearch/donnees/acces.ndjson
head -n 3 neo4j/import/cours.csv
head -n 3 neo4j/import/inscriptions.csvA .ndjson file (newline-delimited JSON) contains one JSON object per line. In the kit, the lines come in pairs: an instruction line ("file what follows in the cours index under identifier C0001"), then the document itself. This is the format the importer command sends to Elasticsearch in bulk.
{"index":{"_index":"cours","_id":"C0001"}}
{"id":"C0001","titre":"Docker expliqué simplement","description":"…","categorie":"DevOps", … }cours: 504 Documents, One per Course in the CatalogThe first document, as it is in Elasticsearch:
{
"id": "C0001",
"titre": "Docker expliqué simplement",
"description": "Dans ce cours accessible sans prérequis, vous apprenez à automatiser vos applications avec Docker. …",
"categorie": "DevOps",
"sujet": "Docker",
"niveau": "debutant",
"langue": "en",
"prix": 129,
"gratuit": false,
"duree_heures": 5,
"tags": ["docker", "linux", "helm", "devops"],
"date_publication": "2024-08-14",
"note_moyenne": 4.4,
"nb_avis": 327,
"professeur": { "id": "P001", "nom": "Karim Caron", "ville": "Gatineau" },
"competences": ["Conteneurisation", "Intégration continue"]
}| Field | Example | What it is |
|---|---|---|
id | C0001 | Course identifier. C as in course, then a number. It is also the document's _id. |
titre | Docker expliqué simplement | The title. Free text; this is what word searches will run on. |
description | Dans ce cours… | A presentation paragraph. Free text too. |
categorie | DevOps | One of the six main families: Cloud, DevOps, Données, Développement web, IA, Sécurité. 84 courses each. |
sujet | Docker | More precise than the category: Docker, Kubernetes, Neo4j, Elasticsearch… |
niveau | debutant | debutant, intermediaire, or avance. |
langue | en | fr or en. |
prix | 129 | In dollars. 0 for a free course. |
gratuit | false | True or false. |
duree_heures | 5 | Total duration of the course. |
tags | ["docker", "linux", …] | A list of keywords. A field can contain several values. |
date_publication | 2024-08-14 | A date. |
note_moyenne | 4.4 | Average of the ratings received, out of 5. |
nb_avis | 327 | Number of reviews received. |
professeur | { "id": "P001", "nom": …, "ville": … } | An object inside the object: the instructor is described directly in the course record, with their identifier, name, and city. |
competences | ["Conteneurisation", …] | List of skills the course covers. |
avis: 609 Documents, One per Review Left by a Student{
"id": "A00001",
"cours_id": "C0028",
"etudiant": "Nathan",
"ville": "Sherbrooke",
"pays": "Canada",
"note": 3,
"texte": "Les vidéos sont bonnes, la partie théorique est dense.",
"date": "2024-06-21",
"utile": 33
}| Field | Example | What it is |
|---|---|---|
id | A00001 | Review identifier. A as in avis (review). |
cours_id | C0028 | The course concerned. This is the link to the cours index: this C0028 is the id of a document in cours. |
etudiant | Nathan | First name of the author. |
ville, pays | Sherbrooke, Canada | Where they write from. |
note | 3 | The rating given, from 1 to 5. |
texte | Les vidéos sont bonnes… | The comment. Free text. |
date | 2024-06-21 | Date of the review. |
utile | 33 | Number of people who found this review useful. |
acces: 12,000 Documents, One Line per Request Received by the Web Server{
"id": "L000001",
"@timestamp": "2026-08-10T08:17:19.000Z",
"methode": "GET",
"chemin": "/robots.txt",
"cours_id": null,
"categorie": null,
"statut": 200,
"octets": 108506,
"duree_ms": 100,
"ip": "108.190.166.1",
"pays": "CA",
"appareil": "desktop",
"navigateur": "Edge",
"referent": "google"
}| Field | Example | What it is |
|---|---|---|
id | L000001 | Line identifier. L as in log line. |
@timestamp | 2026-08-10T08:17:19.000Z | Exact date and time of the request. The @ is a convention: it is the time field that Kibana detects automatically. |
methode | GET | GET or POST. |
chemin | /robots.txt | The address requested on the site: /, /cours, /catalogue, /contact, /tarifs, or a course page. |
cours_id | null or C0042 | The course viewed, if the page concerns one; null (empty) otherwise, as here for /robots.txt. Link to the cours index. |
categorie | null or DevOps | Category of the course viewed, copied over to simplify charts. |
statut | 200 | The HTTP code of the response: 200 OK, 301 and 304 redirect or cache, 404 not found, 500 and 503 server error. |
octets | 108506 | Size of the response sent. |
duree_ms | 100 | Response time in milliseconds. |
ip | 108.190.166.1 | Visitor's IP address. |
pays | CA | Visitor's country, two-letter code. |
appareil | desktop | desktop, mobile, or tablette. |
navigateur | Edge | Chrome, Firefox, Safari, Edge… |
referent | google | Where the visitor came from: direct, google, linkedin, youtube, or newsletter. |
The three indexes are linked by cours_id: a review talks about a course, a log line views a course. But Elasticsearch does not do joins: each index is searched separately. To follow the links, we move to Neo4j.
Neo4j receives the same courses (same C0001… identifiers, same titles), plus what is not in Elasticsearch: the students, their enrollments, the instructors, the skills, the cities, and the prerequisites between courses. The model fits in one drawing:
The five arrows read like sentences: a student is enrolled in a course; an instructor teaches a course; a course covers a skill; a course is a prerequisite of another course; a student or an instructor lives in a city. In total 872 nodes and 3,712 relationships.
The data arrives as classic CSV files, a header line then one line per element:
cours.csv id,titre,categorie,sujet,niveau,prix,duree_heures,date_publication,professeur_id
C0001,Docker expliqué simplement,DevOps,Docker,debutant,129,5,2024-08-14,P001
etudiants.csv id,prenom,nom,ville,pays,inscription_le,interet
E0001,Nathan,Ben Ali,Sherbrooke,Canada,2023-05-15,DevOps
professeurs.csv id,prenom,nom,ville,pays,specialite,annees_experience
P001,Karim,Caron,Gatineau,Canada,DevOps,23
competences.csv id,nom
K01,Conteneurisation
villes.csv nom,pays,latitude,longitude
Montréal,Canada,45.5019,-73.5674
inscriptions.csv etudiant_id,cours_id,date,progression,note
E0001,C0028,2024-04-01,100,3
couvre.csv cours_id,competence_id
C0001,K01
prerequis.csv prerequis_id,cours_id
C0001,C0003Look at how this translates into a graph. The first five files become nodes; each line is a node, each column a property. The last three become relationships; each line links two nodes by their identifiers. The line E0001,C0028,2024-04-01,100,3 of inscriptions.csv becomes the arrow (Etudiant E0001)-[:INSCRIT_A {date, progression, note}]->(Cours C0028): Nathan Ben Ali is enrolled in course C0028, completed it at 100%, and gave it a 3. Compare with the first review of the avis index: it is the same Nathan, the same course C0028, the same rating 3.
| Node | Properties | Example |
|---|---|---|
Cours | id, titre, categorie, sujet, niveau, prix, duree_heures, date_publication | C0001, "Docker expliqué simplement", $129 |
Etudiant | id, prenom, nom, interet, inscription_le | E0001, Nathan Ben Ali, interest DevOps |
Professeur | id, prenom, nom, specialite, annees_experience | P001, Karim Caron, DevOps, 23 years |
Competence | id, nom | K01, Conteneurisation |
Ville | nom, pays, latitude, longitude | Montréal, Canada |
| Relationship | From → to | Properties | Meaning |
|---|---|---|---|
INSCRIT_A | Etudiant → Cours | date, progression, note | The student takes this course |
ENSEIGNE | Professeur → Cours | — | This instructor gives this course (one per course) |
COUVRE | Cours → Competence | — | This course teaches this skill |
PREREQUIS_DE | Cours → Cours | — | You must have taken the first before the second |
HABITE | Etudiant or Professeur → Ville | — | Where the person lives |
All the data uses the same convention: a letter, then a number. When you see an identifier, you immediately know what we are talking about.
| Prefix | Thing | Example | Where it is found |
|---|---|---|---|
C | Course | C0001 | Elasticsearch cours, avis.cours_id, acces.cours_id, Neo4j Cours |
A | Review | A00001 | Elasticsearch avis |
L | Log line | L000001 | Elasticsearch acces |
E | Student | E0001 | Neo4j Etudiant |
P | Instructor | P001 | cours.professeur.id in Elasticsearch, Neo4j Professeur |
K | Skill | K01 | Neo4j Competence |
Keep a single thread in mind for everything that follows: course C0001, "Docker expliqué simplement", taught by P001, Karim Caron. You will find it again in Elasticsearch (query E9), in Neo4j (queries N7 and N13), and it will serve to prove that the two engines really do contain the same thing.
The etat counters prove that the data is there; the queries below make you see it. They are all read-only: you can replay them as many times as you want, nothing will be modified. They are identical on Windows and Linux; everything happens in the browser.
The rule of this section: a single new concept per query. We start with "show me what is there", with no parameter at all, and we add one notion at each step. After each query, a short explanation; when digging deeper is needed, a collapsible "To understand better" block. Paste the queries one by one, in order, and read the response before moving on to the next.
Open http://localhost:5601, then ☰ menu → Management → Dev Tools (or « Outils de développement »). The left pane is an editor: paste a query, place the cursor on it, then Ctrl+Enter or the ▶ triangle. The response is displayed on the right, with the HTTP code (200 - OK) at the bottom.
An image to keep in mind for everything that follows: Elasticsearch is a big filing cabinet.
L'armoire = Elasticsearch
Un tiroir = un index (le tiroir « cours », le tiroir « avis », le tiroir « acces »)
Une fiche = un document (une fiche par cours, une fiche par avis, une fiche par ligne de journal)
_cat/indices = lire les étiquettes collées sur les tiroirs : nom, état, nombre de fiches, épaisseur
_search = ouvrir un tiroir et lire les fiches qui sont dedans(The cabinet = Elasticsearch; a drawer = an index; a card = a document; _cat/indices = read the labels stuck on the drawers; _search = open a drawer and read the cards inside.)
Queries E1 to E4 look at the labels on the drawers. From E5 on, we open the drawers. Never confuse the two: it is beginners' mistake number one.
GET _cat/indicesWhat the query asks: "Elasticsearch, show me the list of all your drawers." Response on the course machine:
green open avis W9j_JrwJT4mdzpWcS5k7xg 1 0 609 0 74.6kb 74.6kb 74.6kb
green open .internal.alerts-security.alerts-default-000001 YJq3VGeRQKqD0fqJW6Fw3w 1 0 0 0 249b 249b 249b
green open acces aii68fsfQXKyt5wqkE1mPA 1 0 12000 0 1.5mb 1.5mb 1.5mb
green open cours pmq403ZgSZWeHJY9uNw1Qw 1 0 504 0 183.6kb 183.6kb 183.6kbFour lines = four drawers. Three are yours: avis, acces, cours. The fourth, the one whose name starts with a dot (.internal.alerts-security…), is not yours: Kibana created it on its own for internal use (security alert management). It is empty (0 cards), it does no harm, do not delete it. In what follows, we will leave it out of the display.
What is annoying here: there are numbers everywhere and no column title. It is like an Excel sheet without a header row. We fix that in E2.
To read the query itself, word by word:
| Piece | What it means |
|---|---|
GET | "I want to read something." I modify nothing, I delete nothing, I look. Risk-free. |
_cat | "Answer me as a text table", readable by a human, not JSON. The leading underscore signals a command of Elasticsearch itself, not an index name. |
indices | "… the table of indexes." It is the plural of index. |
An index is a drawer: a place where you file cards that resemble each other. All the "course" cards go in the cours drawer, all the "review" cards in the avis drawer, and each line of the web server log is a card in the acces drawer. If you know SQL, the index is what SQL calls a table:
| Elasticsearch | In the image | SQL |
|---|---|---|
Index cours | The "cours" drawer | Table cours |
| JSON document | A card in the drawer | Row |
| Field | A box on the card (title, price…) | Column |
Document _id | The number written at the top of the card | Primary key |
The lab's three drawers:
Elasticsearch
├── index cours → 504 documents (un document = un cours du catalogue)
├── index avis → 609 documents (un document = un avis laissé par un étudiant)
└── index acces → 12 000 documents (un document = une ligne de journal du serveur web)Two vocabulary traps. The singular is an index, and Elasticsearch writes the plural as indices (the Latin-style plural), while everyday English says indexes. And it is not an "index" in the mathematical or statistical sense: it is an index like the one at the end of a book, the list that says on which page each word appears. Elasticsearch does exactly that, at very large scale.
Now, let's take a single line of the E1 response and read it value by value, from left to right:
green open avis W9j_JrwJT4mdzpWcS5k7xg 1 0 609 0 74.6kb 74.6kb 74.6kb| Value | What it means |
|---|---|
green | The drawer is in good shape. Green = all is well. |
open | The drawer is open: you can read and write in it. (The opposite would be close.) |
avis | The drawer's name. |
W9j_JrwJT4mdzpWcS5k7xg | A technical serial number, generated by Elasticsearch. You will never use it. |
1 | The drawer is in one single piece (one primary shard). A large index can be cut into several pieces spread over several machines; not here. |
0 | Zero backup copies (replica). Normal in a single-machine lab: a copy would have nowhere to go. |
609 | The drawer contains 609 cards. This is the value we look at first. |
0 | Zero cards marked "to discard" awaiting cleanup. |
74.6kb | Total space taken on disk. |
74.6kb | Space taken by the primary piece alone (identical: there is only one piece and no copy). |
74.6kb | Size of the data itself. |
Three times 74.6kb because, with a single piece and zero copies, the three ways of measuring give the same result. On a real cluster with copies, store.size would be larger than pri.store.size.
In summary, your three drawers:
avis → 609 fiches → 74,6 Ko
acces → 12 000 fiches → 1,5 Mo
cours → 504 fiches → 183,6 Ko?vGET _cat/indices?vhealth status index uuid pri rep docs.count docs.deleted store.size pri.store.size dataset.size
green open avis W9j_JrwJT4mdzpWcS5k7xg 1 0 609 0 74.6kb 74.6kb 74.6kb
green open .internal.alerts-security.alerts-default-000001 YJq3VGeRQKqD0fqJW6Fw3w 1 0 0 0 249b 249b 249b
green open acces aii68fsfQXKyt5wqkE1mPA 1 0 12000 0 1.5mb 1.5mb 1.5mb
green open cours pmq403ZgSZWeHJY9uNw1Qw 1 0 504 0 183.6kb 183.6kb 183.6kbSame response as in E1, plus a title line above. That is all ?v does. Now you no longer have to guess: the docs.count column is the number of cards (609, 0, 12000, 504), the store.size column is the space on disk.
?v — what does it mean, exactly?
? says: "what follows are options". It separates the command (_cat/indices) from its settings.v is the verbose option, "talkative": "also display the column titles".So ?v = "answer me with the headers, so that I understand what I read". Get into the habit of always putting it on _cat commands. Without ?v, you have numbers; with ?v, you have information.
| Column | In plain words |
|---|---|
health | Drawer health. green: all is well. yellow: the cards are there, but some backup copies are missing. red: some cards are inaccessible. |
status | open: usable. close: closed, you can neither read nor write. |
index | The drawer's name. |
uuid | Technical serial number. Never used by hand. |
pri | Into how many pieces (primary shards) the drawer is cut. 1 in the lab. |
rep | How many backup copies (replicas) of each piece. 0 in the lab. |
docs.count | Number of cards. The column to look at first. |
docs.deleted | Cards marked "to discard" not yet cleaned from disk. |
store.size | Total space on disk. |
pri.store.size | Space of the primary pieces alone, without copies. |
dataset.size | Size of the data itself. |
Why everything is green in the lab: rep is 0, so there is no copy to make, so no copy can be missing. On a real cluster, rep 1 with a single machine would give yellow, because the copy has nowhere to go.
GET _cat/indices/cours?vhealth status index uuid pri rep docs.count docs.deleted store.size pri.store.size dataset.size
green open cours pmq403ZgSZWeHJY9uNw1Qw 1 0 504 0 183.6kb 183.6kb 183.6kbWhat the query asks: "Show me the label of the cours drawer." A single new thing: the drawer's name added after _cat/indices/. A single line in response, that of cours, with its 504 cards. What follows the last / acts as a filter.
Do the same for the two other drawers:
GET _cat/indices/avis?v
GET _cat/indices/acces?v"Show me the label of the reviews drawer": 609 in docs.count. "Show me the label of the access logs drawer": 12000. In Dev Tools, when several queries are pasted one after another, only the one where the cursor is gets sent: place it on the desired line before Ctrl+Enter.
GET _cat/indices/acces,avis,cours?v&s=index&h=index,health,docs.count,store.sizeindex health docs.count store.size
acces green 12000 1.5mb
avis green 609 74.6kb
cours green 504 183.6kbWhat the query asks: "Show me a summary table of the acces, avis, and cours drawers. Sort them by name, and display only their name, their health, their number of cards, and their size." Four lines, four columns, everything needed and nothing more. This is the query to keep at hand to check the lab at a glance.
It looks complicated because it is long, but it is just E3 with three more options. Breakdown, piece by piece:
| Piece | In plain words |
|---|---|
GET | I am asking for information, I modify nothing. |
_cat/indices | The drawer labels, as a readable table. |
/acces,avis,cours | Only these three drawers (names separated by commas, no spaces). Kibana's internal drawer disappears. |
?v | With the column titles. |
&s=index | s as in sort: sort the lines alphabetically by the index column. |
&h=index,health,docs.count,store.size | h as in headers: display only these columns, in this order. |
One rule and one only for options: the first starts with ?, all the following ones with &. That is why we read ?v&s=…&h=….
And the four chosen columns:
| Column | In plain words |
|---|---|
index | The drawer's name. |
health | Its health: green everything works; yellow the cards exist but backup copies are missing; red some cards are unavailable. |
docs.count | How many cards it contains. |
store.size | How much space it takes on disk. |
In one sentence: your three drawers are open, healthy, and contain exactly the expected cards, 12,000, 609, and 504.
Without h=, Elasticsearch returns eleven columns, most of which teach you nothing day to day. Without s=, the order of the lines is arbitrary and changes from one call to the next (look at E2: avis came before acces). By naming the three drawers, you also leave out Kibana's. The result fits on four lines and can be compared at a glance with the expected numbers. This is exactly what the kit's etat command does behind the scenes.
Useful variant if you also want to see that the drawers are indeed open: add status to the h= list:
GET _cat/indices/acces,avis,cours?v&s=index&h=index,health,status,docs.count,store.sizeindex health status docs.count store.size
acces green open 12000 1.5mb
avis green open 609 74.6kb
cours green open 504 183.6kbSummary of E1 to E4: you have not yet read a single card. You have only looked at the labels on the drawers. You know there are 504 courses, but you have not yet seen a single course title. That is what we do now.
GET cours/_count{
"count": 504,
"_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }
}What the query asks: "Count the cards in the cours drawer." Answer: 504. Look closely at the shape of the query, it is new: we are no longer talking to the cabinet (_cat/…), we are talking to a drawer. The drawer's name comes first (cours), then what we want it to do (_count), separated by a /. All the queries that follow will have this shape: drawer-name/_action.
Another change: the response is no longer a text table but JSON, with braces and quotes. This is the normal shape of Elasticsearch responses; _cat was the exception. Ignore the _shards part; it only says "the drawer's piece answered, nothing failed".
Try GET avis/_count (609) and GET acces/_count (12000). In SQL: SELECT COUNT(*) FROM cours.
GET cours/_searchWhat the query asks: "Open the cours drawer and show me the cards inside." This is the first time you see a real course: its title, its price, its instructor.
The response is long: that is normal, it contains ten complete cards. Elasticsearch gives you only the first 10, even though the drawer contains 504; it is a protection, so as not to send you 504 cards at once without you asking. Look at the structure rather than the content:
{
"took": 1,
"timed_out": false,
"_shards": { … },
"hits": {
"total": { "value": 504, "relation": "eq" },
"max_score": 1,
"hits": [
{
"_index": "cours",
"_id": "C0001",
"_score": 1,
"_source": {
"id": "C0001",
"titre": "Docker expliqué simplement",
"categorie": "DevOps",
"niveau": "debutant",
"prix": 129,
"tags": ["docker", "linux", "helm", "devops"],
"professeur": { "id": "P001", "nom": "Karim Caron", "ville": "Gatineau" },
…
}
},
… 9 autres documents …
]
}
}_search means "search"; with no further precision, it means "give me cards, any of them". The two numbers to spot: "total": { "value": 504 } (there are 504 cards in the drawer) and the hits list which contains only 10 (the ones you are shown). Each card is in _source, with all its boxes: titre, prix, categorie…
Do the same with the two other drawers: GET avis/_search ("open the avis drawer, show me the first 10 reviews") and GET acces/_search (the first 10 log lines). In SQL: SELECT * FROM cours LIMIT 10.
The essential difference, never to be confused again.
GET _cat/indices/cours?vlooks at the drawer's label: its state, its number of cards, its size. One line. You see no course.
GET cours/_searchopens the drawer and reads the cards: titles, prices, categories, instructors. Ten cards. You see the courses themselves.The first answers "there are 504 courses". The second answers "here are some courses".
| Key | In plain words |
|---|---|
took | The time Elasticsearch took, in milliseconds (here 1 ms). |
hits.total.value | The total number of cards that match: 504. Even if you are only shown 10, it tells you how many there are in all. |
hits.hits | The list of cards you are shown: the first 10 by default. |
_index | The drawer the card comes from (cours). |
_id | The number written at the top of the card (C0001, C0002…). |
_score | A relevance score. Equals 1 everywhere here because we searched for nothing specific: all cards are equal. |
_source | The card itself, as it was filed in the drawer, with all its boxes. |
The word hit is a shooting metaphor: a card "hit" by the query. The professeur field is a card within the card (a JSON inside the JSON): the instructor is written directly on the course card, with their name and city. SQL does not do that naturally; it would take a second table and a join.
GET cours/_search
{
"size": 20
}What the query asks: "Open the cours drawer and show me 20 cards instead of the usual 10."
A single new thing, but an important one: the query now has two parts. The first line (GET cours/_search) says what to do; the block between braces below, called the body, gives details. Here the detail is "size": 20: "batch size = 20". That is all. size simply means "how many cards you want to be shown".
Check in the response: hits.total.value is still 504 (the drawer has not changed), but the hits list now contains 20 cards instead of 10. In SQL: LIMIT 20.
In classic HTTP, GET has no body; Elasticsearch accepts it anyway because it is convenient in Dev Tools. If a tool refuses, POST cours/_search with the same body does exactly the same thing. Another limit to know: size cannot exceed 10,000 at once (index.max_result_window setting); to go through more, you paginate. For our 504 courses, "size": 504 would work, but the response would be thousands of lines long: that is not how you read data; aggregations (E13) are made for that.
GET cours/_search
{
"size": 3,
"_source": ["titre", "prix"]
}"hits": [
{ "_id": "C0001", "_source": { "titre": "Docker expliqué simplement", "prix": 129 } },
{ "_id": "C0002", "_source": { "titre": "Docker avancé : industrialiser des conteneurs en production", "prix": 29 } },
{ "_id": "C0003", "_source": { "titre": "Docker : le guide complet", "prix": 19 } }
]What the query asks: "Show me 3 cards, but only the titre and prix boxes of each card." New thing: _source with a list of fields. Instead of the whole card, we keep only the boxes that interest us. The response becomes readable at a glance. In SQL: SELECT titre, prix FROM cours LIMIT 3. We will use _source in almost all the following queries, precisely to keep responses short.
GET cours/_doc/C0001{
"_index": "cours",
"_id": "C0001",
"_version": 6,
"found": true,
"_source": {
"id": "C0001",
"titre": "Docker expliqué simplement",
"categorie": "DevOps",
"sujet": "Docker",
"niveau": "debutant",
"prix": 129,
"note_moyenne": 4.4,
"nb_avis": 327,
"professeur": { "id": "P001", "nom": "Karim Caron", "ville": "Gatineau" },
…
}
}No search here: we ask for the document whose identifier is C0001, and Elasticsearch returns it directly ("found": true). It is the fastest access that exists. In SQL: SELECT * FROM cours WHERE id = 'C0001'. Remember this course, "Docker expliqué simplement", by Karim Caron: we will find it again in Neo4j shortly, to prove that the two engines contain the same data.
_id (with an underscore) is the document's technical identifier in Elasticsearch; id (without underscore) is an ordinary field inside _source. The kit deliberately made them identical (C0001 on both sides) so that it reads easily. This is also what makes importer replayable: sending a document twice with the same _id replaces the first instead of creating a second, hence _version: 6 (the document was rewritten six times on the course machine, without ever being duplicated).
GET cours/_search
{
"query": { "match": { "titre": "kubernetes" } },
"_source": ["titre"]
}"hits": {
"total": { "value": 7, "relation": "eq" },
"hits": [
{ "_score": 5.0897474, "_source": { "titre": "Les bases de Kubernetes" } },
{ "_score": 5.0897474, "_source": { "titre": "Kubernetes en pratique" } },
{ "_score": 5.0897474, "_source": { "titre": "Kubernetes pour les débutants" } },
…
]
}New thing: query, the part of the body that says what to search for. match is the basic search: "the documents whose titre field contains the word kubernetes". Seven courses match, and for the first time _score is no longer 1: this is relevance, and the results are ranked from most to least relevant. In SQL, the approximate equivalent would be WHERE titre LIKE '%kubernetes%', but look closely: we typed kubernetes in lowercase and we find "Kubernetes" with a capital letter. LIKE would not have done that.
When a course is indexed, Elasticsearch splits its title into words, lowercases them, removes accents, and reduces each word to its root ("conteneurs" becomes "conteneur"): this is analysis, done here by the french analyzer defined in the kit's mapping. When you search, your query goes through the same processing, then Elasticsearch compares word to word. Result: capital letters, accents, and plurals no longer matter. The _score rises when the word is rare in the index and frequent in the document. Module 3 devotes several lessons to this; here, just remember: match searches for words, not sequences of characters.
First the proof that, by default, a real typo finds nothing. "kubrenetes" (two letters swapped):
GET cours/_count
{
"query": { "match": { "titre": "kubrenetes" } }
}{ "count": 0, … }Then the same query with a single extra line, fuzziness, which allows one or two letters of difference:
GET cours/_search
{
"query": { "match": { "titre": { "query": "kubrenetes", "fuzziness": "AUTO" } } },
"size": 3,
"_source": ["titre", "niveau", "prix"]
}"total": { "value": 7, "relation": "eq" },
"hits": [
{ "_score": 4.4535294, "_source": { "titre": "Les bases de Kubernetes", "niveau": "debutant", "prix": 0 } },
{ "_score": 4.4535294, "_source": { "titre": "Kubernetes en pratique", "niveau": "debutant", "prix": 129 } },
{ "_score": 4.4535294, "_source": { "titre": "Kubernetes pour les débutants", "niveau": "debutant", "prix": 29 } }
]The 7 Kubernetes courses come back, despite the typo. This is the moment that always surprises in class, and it is the reason Elasticsearch exists in a search bar: the user types badly, the engine understands anyway. A LIKE '%kubrenetes%' in SQL would never have returned anything. Note the syntax: when match needs options, the field's value becomes an object { "query": …, "fuzziness": … } instead of a simple string.
fuzziness counts the edits (a letter added, removed, changed, or swapped with its neighbor) tolerated between the typed word and the indexed word. AUTO adapts the tolerance to the word's length: 0 errors for a word of 1 or 2 letters, 1 error from 3 to 5 letters, 2 errors beyond. "kubrenetes" has 10 letters, so 2 errors allowed; it has only one (the re ↔ er swap): found. The _score is a bit lower than in E10 (4.45 versus 5.09): Elasticsearch slightly penalizes approximate matches, which keeps exact matches at the top.
GET cours/_search
{
"query": { "range": { "nb_avis": { "gte": 10 } } },
"sort": [{ "note_moyenne": "desc" }, { "nb_avis": "desc" }],
"size": 5,
"_source": ["titre", "note_moyenne", "nb_avis", "categorie"]
}Two new things, which are understood on reading. range with gte (greater than or equal): only the courses that have at least 10 reviews, to rule out ratings based on a single vote. sort: rank by descending note_moyenne, then by descending nb_avis to break ties. In SQL: WHERE nb_avis >= 10 ORDER BY note_moyenne DESC, nb_avis DESC LIMIT 5. When you sort yourself, _score becomes null: relevance is no longer used; your order is what counts.
GET cours/_search
{
"size": 0,
"aggs": {
"par_categorie": { "terms": { "field": "categorie" } }
}
}"aggregations": {
"par_categorie": {
"buckets": [
{ "key": "Cloud", "doc_count": 84 },
{ "key": "DevOps", "doc_count": 84 },
{ "key": "Données", "doc_count": 84 },
{ "key": "Développement web", "doc_count": 84 },
{ "key": "IA", "doc_count": 84 },
{ "key": "Sécurité", "doc_count": 84 }
]
}
}New thing: aggs (aggregations). terms on categorie makes one bucket per distinct value and counts the documents in it. size: 0 says "return me no document, only the counters": the response is tiny and instantaneous, even on millions of lines. In SQL: SELECT categorie, COUNT(*) FROM cours GROUP BY categorie. Result: six categories of 84 courses each. par_categorie is a name you choose; it only serves to find the result in the response.
Every Kibana chart, every pie chart, every histogram is an aggregation like this one, executed by Elasticsearch and drawn by Kibana. When you build a dashboard in module 4, you will click "field: categorie, aggregation: terms" and Kibana will send exactly this query. Knowing how to read it is knowing what the dashboard does behind the scenes.
GET cours/_search
{
"size": 0,
"aggs": {
"par_categorie": {
"terms": { "field": "categorie" },
"aggs": { "par_niveau": { "terms": { "field": "niveau" } } }
}
}
}Same query as E13, with a second aggs inside the first. In each category bucket, we make buckets again by niveau. Result: for "Cloud", 45 beginner, 28 advanced, 11 intermediate; and so on for the six categories, in a single pass. In SQL: GROUP BY categorie, niveau, but the response is already hierarchical, ready for a stacked chart.
GET acces/_search
{
"size": 0,
"query": { "range": { "statut": { "gte": 500 } } },
"aggs": { "codes": { "terms": { "field": "statut" } } }
}"hits": { "total": { "value": 178, "relation": "eq" } },
"aggregations": {
"codes": {
"buckets": [
{ "key": 500, "doc_count": 122 },
{ "key": 503, "doc_count": 56 }
]
}
}Nothing new: we combine range (E12) and terms (E13) on the acces index. Among the 12,000 log lines, 178 have an HTTP code ≥ 500: 122 500 errors and 56 503. This is the question a platform manager asks every morning, and it is answered in one millisecond. In SQL: SELECT statut, COUNT(*) FROM acces WHERE statut >= 500 GROUP BY statut. This is exactly what you will put on a dashboard in module 4.
Open http://localhost:7474. Login screen: URL neo4j://localhost:7687, user neo4j, password aiopsatlas2026. At the top, an editing bar that starts with neo4j$: paste a single query, then Ctrl+Enter or the ▶ triangle. The result appears in a frame below, with tabs on the left: Graph (a drawing, when the result contains nodes), Table (rows and columns), and Text.
MATCH (n) RETURN nThe result is a cloud of colored bubbles, linked by arrows, which you can move with the mouse. Neo4j Browser displays at most 300 nodes at a time (a message at the top of the result says so); the 872 are really there, it only draws part of them to stay readable.
This is the simplest query in Cypher, Neo4j's language. MATCH means "find", (n) designates any node (the parentheses draw a circle, like a bubble) to which we give the name n, and RETURN n means "show it to me". In SQL, there is no equivalent: it would be "SELECT * from all tables at once", which SQL cannot do.
A graph is made of two things: nodes (the bubbles) and relationships (the arrows between bubbles). Each node carries a label that says what it is (Cours, Etudiant…) and properties (title, price…). Each relationship carries a type (INSCRIT_A, ENSEIGNE…) and a direction.
| Neo4j | SQL |
|---|---|
Label Cours | Table cours |
| Node | Row |
| Property | Column |
Relationship INSCRIT_A | Link table inscriptions + joins |
The lab graph:
Neo4j
├── 504 nœuds Cours
├── 300 nœuds Etudiant
├── 30 nœuds Professeur
├── 22 nœuds Competence
└── 16 nœuds Ville
= 872 nœuds, reliés par 3 712 relationsThe 504 courses are the same as the 504 documents of the Elasticsearch cours index: these are two engines that store the same data in two ways, each to answer different questions.
MATCH (n) RETURN n LIMIT 25A single new thing: LIMIT 25, the same word as in SQL. Twenty-five bubbles instead of three hundred: you finally see something. Hover over a bubble: its properties are displayed at the bottom of the frame. Double-click it: its neighbors unfold.
MATCH (n) RETURN count(n) AS totaltotal
872count(n) counts instead of drawing; AS total names the column. No more drawing: the result switches on its own to Table view, since a number cannot be drawn. 872, the etat number. In SQL: SELECT COUNT(*).
MATCH (c:Cours) RETURN c LIMIT 5New thing: :Cours after the variable name. This is the label: "only the nodes that are courses". Five bubbles, all the same color. In SQL: SELECT * FROM cours LIMIT 5. By habit, we name the variable with the initial of the label (c for Cours, e for Etudiant), but n would work too.
MATCH (c:Cours) RETURN c.titre, c.prix LIMIT 5c.titre c.prix
"Docker expliqué simplement" 129.0
"Docker avancé : industrialiser des conteneurs en production" 29.0
"Docker : le guide complet" 19.0
…New thing: c.titre, c.prix. The dot gives access to a property of the node. When you return properties instead of whole nodes, Neo4j Browser switches to Table view. In SQL: SELECT titre, prix FROM cours LIMIT 5. Compare with E8: same titles, same prices, same order. To know all the properties of a course: MATCH (c:Cours) RETURN keys(c) LIMIT 1 answers prix, duree_heures, date_publication, sujet, niveau, categorie, id, titre.
MATCH (n) RETURN labels(n)[0] AS type, count(*) AS nombre ORDER BY nombre DESCtype nombre
"Cours" 504
"Etudiant" 300
"Professeur" 30
"Competence" 22
"Ville" 16labels(n) returns the list of the node's labels (a node can have several; here only one, hence the [0], the first element). The count(*) groups automatically by everything that is not an aggregation: no need to write GROUP BY, Cypher infers it. ORDER BY nombre DESC sorts. The sum of the five rows is 872. In SQL, it would take five SELECT COUNT(*) and some UNIONs.
MATCH (c:Cours {id: 'C0001'}) RETURN c.titre, c.prix, c.niveauc.titre c.prix c.niveau
"Docker expliqué simplement" 129.0 "debutant"New thing: the braces {id: 'C0001'} in the pattern. They filter on a property, like a WHERE id = 'C0001'. And it is the same course as in E9 in Elasticsearch: same title, same price. Here is the proof that the two engines really contain the same data; what changes is what you can ask them.
MATCH (c:Cours) WHERE c.titre CONTAINS 'Kubernetes' RETURN c.titre ORDER BY c.titrec.titre
"Kubernetes : de zéro à la production"
"Kubernetes : le guide complet"
"Kubernetes avancé : superviser des conteneurs en production"
"Kubernetes en pratique"
"Kubernetes expliqué simplement"
"Kubernetes pour les débutants"
"Les bases de Kubernetes"WHERE is written as in SQL and CONTAINS searches for a sequence of characters. Seven courses, the same as in E10. But try CONTAINS 'kubernetes' in lowercase: zero results. And CONTAINS 'kubrenetes': zero too. Neo4j compares characters, exactly; it knows neither case, nor words, nor typos. This is precisely why the lab has both engines: the search bar is Elasticsearch; the links between things are Neo4j.
MATCH (c:Cours) RETURN c.titre, c.prix ORDER BY c.prix DESC LIMIT 5c.titre c.prix
"Helm par la pratique : superviser un pipeline CI/CD" 199.0
"Atelier GitHub Actions : vos applications" 199.0
"Docker pour les débutants" 199.0
…Nothing new: ORDER BY … DESC LIMIT 5, as in SQL. The five most expensive courses, all at $199.
MATCH (c:Cours) RETURN c.categorie AS categorie, count(*) AS nombre ORDER BY nombre DESCcategorie nombre
"DevOps" 84
"Données" 84
"IA" 84
"Développement web" 84
"Sécurité" 84
"Cloud" 84Exactly the result of aggregation E13 in Elasticsearch: six categories of 84. Same data, two engines, two syntaxes. So far, Neo4j has done nothing that SQL cannot do. That changes with the next query.
MATCH (p:Professeur)-[r:ENSEIGNE]->(c:Cours) RETURN p, r, c LIMIT 30Switch to Graph view: "instructor" bubbles linked by ENSEIGNE arrows to "course" bubbles. Grab an instructor with the mouse, and you see all their courses follow.
New thing: the arrow. (p:Professeur)-[r:ENSEIGNE]->(c:Cours) reads literally as "an instructor, who teaches, a course". The parentheses are nodes, the brackets are the relationship, -> gives the direction. It is an ASCII drawing of what we are looking for, and Neo4j finds every place in the graph that resembles this drawing. In SQL, it would be SELECT * FROM professeurs JOIN cours ON cours.professeur_id = professeurs.id, and it would not be drawn.
In SQL, a relationship between two rows does not really exist: it is recomputed at each query by a join, which compares identifiers. With ten chained joins, it becomes slow and unreadable. In Neo4j, the relationship is stored as a physical arrow between two nodes: following an arrow costs the same price whatever the number of nodes in the database. This is what makes queries N14 to N16 possible, which chain several hops effortlessly.
MATCH ()-[r]->() RETURN type(r) AS relation, count(*) AS nombre ORDER BY nombre DESCrelation nombre
"INSCRIT_A" 1654
"COUVRE" 994
"ENSEIGNE" 504
"HABITE" 330
"PREREQUIS_DE" 230() is any node whose name we do not even keep; [r] a relationship of any type; type(r) its type. Five relationship types, 3,712 in total: the number displayed by the Database information panel of Neo4j Browser. You read the lab's model in one line: students enrolled in courses, courses that cover skills, instructors who teach courses, people who live in cities, and courses that are prerequisites of other courses. The ENSEIGNE at 504 says there is exactly one relationship per course: each course has one and only one instructor.
MATCH (p:Professeur {id: 'P001'})-[:ENSEIGNE]->(c:Cours)
RETURN p.prenom + ' ' + p.nom AS professeur, count(c) AS nb_coursprofesseur nb_cours
"Karim Caron" 11We combine N7 (the {id: 'P001'} filter) and N11 (the arrow). P001 is the instructor of "Docker expliqué simplement" seen in E9 and N7; he teaches 11 courses. The + concatenates strings, as in SQL with ||. Note [:ENSEIGNE] without a variable name: when you do not need the relationship in the RETURN, you do not name it.
MATCH (debut:Cours {id: 'C0111'}), (fin:Cours {id: 'C0110'}),
chemin = shortestPath((debut)-[:PREREQUIS_DE*]-(fin))
RETURN length(chemin) AS sauts, [n IN nodes(chemin) | n.titre] AS parcourssauts parcours
5 ["Neo4j : de zéro à la production", "Neo4j en pratique", "Neo4j par la pratique : interroger des journaux applicatifs", "Neo4j avancé : modéliser des journaux applicatifs", "Neo4j : le guide complet", "Maîtriser Neo4j"]The first query that no longer looks like anything in SQL. [:PREREQUIS_DE*] with the asterisk means "following this relationship as many times as needed". shortestPath asks for the shortest of the possible paths between the two courses. The answer: 5 hops, and the list of the six titles to follow, in order, to go from the first to the last course of the Neo4j track. It is a training plan computed on the fly. In SQL, it would take a recursive query of several dozen lines, and it would be slow.
nodes(chemin) gives the list of nodes traversed. [n IN nodes(chemin) | n.titre] reads as "for each node n in this list, keep its title": it is a compact way to turn a list of nodes into a list of titles. length(chemin) counts the relationships traversed (5 relationships for 6 nodes). Module 6 details these functions; here the important thing is the result: Neo4j found a route in the graph.
MATCH (x:Cours {id: 'C0213'})<-[:INSCRIT_A]-(e:Etudiant)-[:INSCRIT_A]->(autre:Cours)
WHERE autre <> x
RETURN autre.titre AS recommandation, autre.categorie AS categorie,
count(DISTINCT e) AS etudiants_communs
ORDER BY etudiants_communs DESC, recommandation
LIMIT 5recommandation categorie etudiants_communs
"Agents IA par la pratique : évaluer un pipeline de prédiction" "IA" 3
"Atelier NLP : des agents autonomes" "IA" 2
"Deep learning par la pratique : orchestrer un assistant …" "IA" 2
…The recommendation engine of an e-commerce site, in five lines. Read the pattern from left to right: we start from course x, we go up the INSCRIT_A arrow (it points to x, hence the <-) to the students e who take it, then we go back down another INSCRIT_A arrow to the other courses autre of those same students. WHERE autre <> x excludes the starting course itself. count(DISTINCT e) counts the students in common, without duplicates. The courses at the top are those that C0213 students most often take in parallel: these are the recommendations. In SQL: two joins on the enrollments table, a GROUP BY, and a query you reread three times before understanding it.
MATCH chemin = (x:Cours {id: 'C0213'})<-[:INSCRIT_A]-(:Etudiant)-[:INSCRIT_A]->(autre:Cours)
WHERE autre <> x
RETURN chemin LIMIT 50Switch to Graph view: the starting course in the center, its students around it, and the courses they share on the periphery. It is query N15 without the counting: we return the whole paths rather than columns, and Neo4j Browser draws them. This is the image to show when someone asks "what is a graph database for".
The message to take away. Fifteen Elasticsearch queries, sixteen Cypher queries, and we saw, in order: list, count, display, filter, sort, group — which SQL does too; then forgive a typo, aggregate 12,000 lines in a millisecond, compute a shortest path and produce recommendations — which SQL does poorly or not at all. The same course "Docker expliqué simplement" appeared in both engines: same data, different questions. This is exactly why this lab exists, and the whole rest of the course details how each of these queries works.
Redo step 7 of your appendix with docker compose stop neo4j: what happens to the line Neo4j répond — nœuds : 872 in etat? What does Neo4j Browser, already connected, say when you rerun query N3 (MATCH (n) RETURN count(n))? What is the last line of journal neo4j? Restart with docker compose start neo4j, check that the 872 nodes are still there without reloading anything, and note which of the two services restarts fastest.
All the commands in this appendix are typed in PowerShell (Windows Terminal, or PowerShell 7), with .\labo.ps1 …. The reproduced outputs are those of the course machine, on Windows 11 and Docker Desktop.
docker-compose.yml and labo.ps1)..\labo.ps1 ("running scripts is disabled on this system"): Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, rerun..\labo.ps1 etat must show (healthy) everywhere, "status":"green", acces 12000 avis 609 cours 504, and nœuds : 872.elasticsearch/requetes/01-pratique-demarrer-verifier-reparer.txt. None writes to the cluster.Before turning anything on, make the script talk.
.\labo.ps1 prerequisCheckpoint: only green check marks and the final sentence.
== Prérequis ==
✔ docker : Docker version 29.3.1, build c2be9cc
✔ le démon Docker répond
✔ docker compose : 5.1.1
✔ mémoire disponible pour Docker : 31 Go
✔ processeurs : 20
✔ port 9200 : utilisé par le labo lui-même
✔ port 5601 : utilisé par le labo lui-même
✔ port 7474 : utilisé par le labo lui-même
✔ port 7687 : utilisé par le labo lui-même
Tout est prêt. Lancez : .\labo.ps1 demarrerOn your machine, versions and memory differ, and if the lab has never run the last four lines say port 9200 : libre. Both readings are fine.
If you see something else: a cross ✘ contains its remedy in the sentence (Docker Desktop not launched, memory under 4 GB, port taken); fix, rerun, only move on with the green line. For memory: Docker Desktop → Settings → Resources, or the %UserProfile%\.wslconfig file if Docker Desktop uses WSL 2.
Launch the startup and, this time, read what scrolls by instead of waiting for the end.
.\labo.ps1 demarrerCheckpoint: three == … == blocks then three prêt.
== Téléchargement des images (long la première fois : ~6 Go, ~12 Go avec OpenSearch) ==
== Démarrage ==
== Attente que chaque service soit prêt ==
elasticsearch prêt (0 s)
kibana prêt (0 s)
neo4j ..... prêt (15 s)
Le labo est prêt.
Kibana http://localhost:5601 (Dev Tools : menu ☰ → Management → Dev Tools)
Elasticsearch http://localhost:9200
Neo4j Browser http://localhost:7474 (utilisateur neo4j · mot de passe aiopsatlas2026)
Étape suivante : .\labo.ps1 importer puis .\labo.ps1 charger-grapheThe first block is empty if the images are already there; the second contains the Compose lines (Container labo-elasticsearch Started, or Running if it was already running); the third adds a dot every three seconds until prêt (… s). Cold lab: one to two minutes; lab already running: prêt (0 s).
If you see something else: unhealthy, exited, or délai dépassé → .\labo.ps1 journal <service> and the lesson 04 catalog; the most frequent cause on Windows: the memory allocated to Docker Desktop (Exited (137)).
etatYou are going to type etat several times; first learn to extract the four numbers that matter from it.
.\labo.ps1 etatCheckpoint: real output from the course machine, where the module 5 OpenSearch profile is active. On your machine, the two labo-opensearch… lines and ✔ OpenSearch are replaced by — OpenSearch non démarré (profil optionnel : .\labo.ps1 demarrer opensearch).
== Conteneurs ==
NAME STATUS PORTS
labo-elasticsearch Up 10 hours (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp
labo-kibana Up 10 hours (healthy) 0.0.0.0:5601->5601/tcp, [::]:5601->5601/tcp
labo-neo4j Up 37 seconds (healthy) 0.0.0.0:7474->7474/tcp, [::]:7474->7474/tcp, 0.0.0.0:7687->7687/tcp, [::]:7687->7687/tcp
labo-opensearch Up 10 hours (healthy) 0.0.0.0:9201->9200/tcp, [::]:9201->9200/tcp
labo-opensearch-dashboards Up 10 hours 0.0.0.0:5602->5601/tcp, [::]:5602->5601/tcp
== Services ==
✔ Elasticsearch : {"status":"green","number_of_nodes":1}
index : acces 12000 avis 609 cours 504
✔ Kibana répond (http://localhost:5601)
✔ Neo4j répond — nœuds : 872
✔ OpenSearch : {"status":"green","number_of_nodes":1}Note the four expected numbers: 12000, 609, 504, 872. On a brand-new lab you read index : aucun index du labo and nœuds : 0: normal, step A.4 fills them.
If you see something else: ✘ Kibana ne répond pas encore in the minute following demarrer → Kibana is finishing creating its internal indexes; retype etat thirty seconds later.
Load the indexes then the graph, and rerun both commands a second time: the counters must not change by a single unit.
.\labo.ps1 importer
.\labo.ps1 charger-graphe
.\labo.ps1 importer
.\labo.ps1 charger-grapheCheckpoint: on the second pass, importer reports that the indexes already exist and returns the same counters:
== Import dans elasticsearch ==
— index cours existe déjà — conservé
✔ données cours chargées
— index avis existe déjà — conservé
✔ données avis chargées
— index acces existe déjà — conservé
✔ données acces chargées
index docs.count store.size
acces 12000 1.5mb
avis 609 74.8kb
cours 504 183.6kb
Import terminé. Attendu : cours = 504, avis = 609, acces = 12000.and charger-graphe gives the same summary as the first time:
== Chargement du graphe Neo4j ==
✔ contraintes et index en place
etiquette, noeuds
"Competence", 22
"Cours", 504
"Etudiant", 300
"Professeur", 30
"Ville", 16
Graphe chargé. Attendu : Competence 22, Cours 504, Etudiant 300, Professeur 30, Ville 16.The documents carry their own _id (C0001, A00001…), so a second _bulk replaces each document instead of adding it; on the Neo4j side, everything uses MERGE. Only store.size may move by a few kilobytes (Lucene keeps the old versions for a while); the docs.count values, never.
If you see something else: a docs.count different from 504 / 609 / 12000 → someone wrote to these indexes; .\labo.ps1 reinitialiser then demarrer, importer, charger-graphe gives you back the reference state.
The script says everything is fine; verify it without the script. Open http://localhost:5601, ☰ menu → Management → Outils de développement (Dev Tools), and send these queries one by one (Ctrl + Enter or the ▶ button « Cliquer pour envoyer la requête », Click to send request).
GET _cluster/healthCheckpoint: "status": "green", "unassigned_shards": 0, and 200 - OK at the bottom right of the response panel.
{
"cluster_name": "labo",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"active_primary_shards": 53,
"active_shards": 53,
…
"unassigned_shards": 0,
…
"active_shards_percent_as_number": 100
}GET _cat/indices/cours,avis,acces?v&s=indexCheckpoint: three green lines, pri 1, rep 0, and the three counters in docs.count.
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size dataset.size
green open acces aii68fsfQXKyt5wqkE1mPA 1 0 12000 0 1.5mb 1.5mb 1.5mb
green open avis W9j_JrwJT4mdzpWcS5k7xg 1 0 609 0 74.8kb 74.8kb 74.8kb
green open cours pmq403ZgSZWeHJY9uNw1Qw 1 0 504 0 183.6kb 183.6kb 183.6kbFinish with GET cours/_count, GET avis/_count, GET acces/_count: "count": 504, 609, 12000. You have the same numbers by two independent paths: the script (curl inside the container) and Dev Tools (through Kibana). If they ever diverge, the path is at fault, not the data.
If you see something else: "status": "yellow" → an index has unallocated replicas, impossible with the kit's mappings (number_of_replicas: 0); GET _cat/indices?v&health=yellow points to the culprit, usually an index created by hand.
Same exercise for Neo4j. Open http://localhost:7474, connect (neo4j / aiopsatlas2026, URL localhost:7687), type in the neo4j$ editor and click Run:
MATCH (n) RETURN labels(n)[0] AS label, count(*) ORDER BY labelCheckpoint: a frame with two views, Table and Raw (no Graph: the query returns numbers, not nodes), two columns label and count(*), five sorted rows, and at the bottom right Started streaming 5 records after … ms and completed after … ms.
label count(*)
"Competence" 22
"Cours" 504
"Etudiant" 300
"Professeur" 30
"Ville" 1622 + 504 + 300 + 30 + 16 = 872, the etat number. The Database information panel (Database overview icon, the first in the sidebar) shows Nodes (872) and Relationships (3,712).
If you see something else: a sixth unknown label → nodes created outside the kit (module 6 will teach you how to delete them cleanly); Nodes (0) → you skipped charger-graphe, back to step A.4.
You know what a healthy lab looks like; cause a failure whose cause you know in order to learn how to read it. Stop only Kibana, with Compose (not arreter, which would stop everything):
docker compose stop kibana Container labo-kibana Stopping
Container labo-kibana StoppedThen the three moves from lesson 04, in order: etat, the browser, journal.
Checkpoint 1, .\labo.ps1 etat: the labo-kibana line has disappeared from the == Conteneurs == block (Compose only shows running containers by default) and the == Services == block marks a cross:
== Conteneurs ==
NAME STATUS PORTS
labo-elasticsearch Up 10 hours (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp
labo-neo4j Up 3 minutes (healthy) 0.0.0.0:7474->7474/tcp, [::]:7474->7474/tcp, 0.0.0.0:7687->7687/tcp, [::]:7687->7687/tcp
labo-opensearch Up 10 hours (healthy) 0.0.0.0:9201->9200/tcp, [::]:9201->9200/tcp
labo-opensearch-dashboards Up 10 hours 0.0.0.0:5602->5601/tcp, [::]:5602->5601/tcp
== Services ==
✔ Elasticsearch : {"status":"green","number_of_nodes":1}
index : acces 12000 avis 609 cours 504
✘ Kibana ne répond pas encore
✔ Neo4j répond — nœuds : 872
✔ OpenSearch : {"status":"green","number_of_nodes":1}To see the stopped container anyway: docker compose ps -a shows labo-kibana Exited (0) 31 seconds ago. The 0 says "clean stop, requested"; a 137 would say "killed, memory".
Checkpoint 2, the browser: reload http://localhost:5601. No "Kibana server is not ready yet" (that sentence comes from Kibana, and there is no more Kibana to say it) but the browser's own connection error: in Chrome or Edge, "This site can't be reached", code ERR_CONNECTION_REFUSED. Nobody is listening on port 5601. To remember: a Kibana page that apologizes = Kibana is running but waiting for Elasticsearch; a browser error = Kibana is not running.
Checkpoint 3, .\labo.ps1 journal kibana: the last hundred lines end with a clean shutdown, timestamped to the second you typed stop:
labo-kibana | [2026-09-09T23:44:06.893+00:00][INFO ][root] SIGTERM received - initiating shutdown
labo-kibana | [2026-09-09T23:44:06.894+00:00][INFO ][root] Kibana is shutting down
labo-kibana | [2026-09-09T23:44:06.902+00:00][INFO ][plugins-system.standard] Stopping all plugins.
…
labo-kibana | [2026-09-09T23:44:07.265+00:00][INFO ][plugins-system.standard] All plugins stopped.SIGTERM received signals a requested stop (by you, by docker compose stop, by a Docker Desktop restart). No ERROR or FATAL line: nothing to repair, you just need to restart. The dozens of at OperatorSubscriber… lines in between are a call stack emitted by a plugin during shutdown: noise.
If you see something else: no configuration file provided: not found → you are not in the kit folder; no such service: kibana → you typed the container name (labo-kibana) instead of the Compose service (kibana).
Restart the service and wait for its healthcheck to go back to green: 40 to 60 seconds, the time to reconnect to Elasticsearch and check its internal indexes.
docker compose start kibana Container labo-elasticsearch Waiting
Container labo-elasticsearch Healthy
Container labo-kibana Starting
Container labo-kibana StartedCompose first checked that Elasticsearch was Healthy (the depends_on … service_healthy from lesson 03), then started Kibana. Monitor every five seconds:
docker inspect --format '{{.State.Health.Status}}' labo-kibanaCheckpoint: starting for 40 to 50 seconds, then healthy (on the course machine: starting from 0 to 45 s, healthy at 50 s). etat then shows the line again:
labo-kibana Up 52 seconds (healthy) 0.0.0.0:5601->5601/tcp, [::]:5601->5601/tcp
…
✔ Kibana répond (http://localhost:5601)and journal kibana ends with the lines we want to see:
labo-kibana | [2026-09-09T23:47:49.408+00:00][INFO ][http.server.Kibana] http server running at http://0.0.0.0:5601
labo-kibana | [2026-09-09T23:47:50.374+00:00][INFO ][status] Kibana is now availableReopen Dev Tools (if you land on "Kibana server is not ready yet", you were faster than the healthy: wait ten seconds) and send the query that etat itself uses:
GET _cluster/health?filter_path=status,number_of_nodes{
"status": "green",
"number_of_nodes": 1
}Elasticsearch answered green throughout the failure: Kibana is a window onto the data, not the data. Stopping Kibana erased nothing and reindexed nothing.
If you see something else: unhealthy after two minutes → journal kibana and look for ECONNREFUSED (Elasticsearch went down in the meantime); port is already allocated → another program took port 5601 during the stop (lesson 04, failure 1).
You recognize a stopped service; now learn to recognize a wrong query on a healthy service, the most frequent confusion in class. In Dev Tools, write a search query that returns 404 with "type": "index_not_found_exception", then explain in one sentence why the cluster stays green.
Hint: Elasticsearch never guesses an index name. Choose one that does not exist, with the pratique- prefix; nothing to create, nothing to write.
GET pratique-inexistant/_searchResponse, with the 404 - Not Found badge at the bottom right of the output panel:
{
"error": {
"root_cause": [
{
"type": "index_not_found_exception",
"reason": "no such index [pratique-inexistant]",
"resource.type": "index_or_alias",
"resource.id": "pratique-inexistant",
"index_uuid": "_na_",
"index": "pratique-inexistant"
}
],
"type": "index_not_found_exception",
"reason": "no such index [pratique-inexistant]",
…
},
"status": 404
}Expected explanation: the 404 is a normal and complete response from Elasticsearch: "I understood your query, but this resource does not exist". The service is running, the cluster stays green; only the name needs correcting (GET _cat/indices?v gives the list). At step A.7, there was no response at all.
Two variants to try: GET pratique-inexistant/_count?ignore_unavailable=true returns 200 and "count": 0 (you ask to ignore the missing index); GET cours/_serch returns 400 with "no handler found for uri [/cours/_serch] and method [GET]". A 400: "I do not understand the query"; a 404: "I understand, but it does not exist".
A single command proves that everything is done: etat, with Kibana back and the four numbers.
.\labo.ps1 etat== Conteneurs ==
NAME STATUS PORTS
labo-elasticsearch Up 10 hours (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp
labo-kibana Up About a minute (healthy) 0.0.0.0:5601->5601/tcp, [::]:5601->5601/tcp
labo-neo4j Up 7 minutes (healthy) 0.0.0.0:7474->7474/tcp, [::]:7474->7474/tcp, 0.0.0.0:7687->7687/tcp, [::]:7687->7687/tcp
labo-opensearch Up 10 hours (healthy) 0.0.0.0:9201->9200/tcp, [::]:9201->9200/tcp
labo-opensearch-dashboards Up 10 hours 0.0.0.0:5602->5601/tcp, [::]:5602->5601/tcp
== Services ==
✔ Elasticsearch : {"status":"green","number_of_nodes":1}
index : acces 12000 avis 609 cours 504
✔ Kibana répond (http://localhost:5601)
✔ Neo4j répond — nœuds : 872
✔ OpenSearch : {"status":"green","number_of_nodes":1}(Without the OpenSearch profile, the two labo-opensearch… containers do not appear and the last line says — OpenSearch non démarré …: this is the expected state until module 5.)
prerequis ends with Tout est prêt.labo-elasticsearch, labo-kibana, and labo-neo4j are Up … (healthy), Kibana included.etat shows "status":"green", acces 12000 avis 609 cours 504, nœuds : 872, unchanged after the second importer / charger-graphe.etat, the browser, and journal kibana show when Kibana is stopped, and how that differs from a 404.etat output above (copy or screenshot) as the deliverable.This practice creates nothing: no index, no node, no Kibana object. Two things to guarantee: that Kibana is running (otherwise docker compose start kibana from the kit folder), and that no working index is left lying around:
GET _cat/indices/pratique-*?vExpected response: the header line alone (health status index uuid pri rep docs.count …). Do not touch cours, avis, acces, or the graph: they serve all the following modules.
All the commands in this appendix are typed in a bash (or zsh) terminal, with ./labo.sh …. The outputs are identical to those on Windows except for the script name: the kit is the same, only the launchers change.
docker service on Linux: docker info must answer), the kit cloned, a terminal open in the kit folder (the one that contains docker-compose.yml and labo.sh).Permission denied on ./labo.sh: chmod +x labo.sh, once only../labo.sh etat must show (healthy) everywhere, "status":"green", acces 12000 avis 609 cours 504, and nœuds : 872.elasticsearch/requetes/01-pratique-demarrer-verifier-reparer.txt. None writes to the cluster.Before turning anything on, make the script talk.
./labo.sh prerequisCheckpoint: only green check marks and the final sentence.
== Prérequis ==
✔ docker : Docker version 29.3.1, build c2be9cc
✔ le démon Docker répond
✔ docker compose : 5.1.1
✔ mémoire disponible pour Docker : 31 Go
✔ processeurs : 20
✔ port 9200 : utilisé par le labo lui-même
✔ port 5601 : utilisé par le labo lui-même
✔ port 7474 : utilisé par le labo lui-même
✔ port 7687 : utilisé par le labo lui-même
Tout est prêt. Lancez : ./labo.sh demarrerOn your machine, versions and memory differ, and if the lab has never run the last four lines say port 9200 : libre. Both readings are fine.
If you see something else: a cross ✘ contains its remedy in the sentence (Docker not launched, memory under 4 GB, port taken); fix, rerun, only move on with the green line. On native Linux, the memory is the machine's; on macOS and WSL 2, it is the amount allocated in Docker Desktop → Settings → Resources.
Launch the startup and, this time, read what scrolls by instead of waiting for the end.
./labo.sh demarrerCheckpoint: three == … == blocks then three prêt.
== Téléchargement des images (long la première fois : ~6 Go, ~12 Go avec OpenSearch) ==
== Démarrage ==
== Attente que chaque service soit prêt ==
elasticsearch prêt (0 s)
kibana prêt (0 s)
neo4j ..... prêt (15 s)
Le labo est prêt.
Kibana http://localhost:5601 (Dev Tools : menu ☰ → Management → Dev Tools)
Elasticsearch http://localhost:9200
Neo4j Browser http://localhost:7474 (utilisateur neo4j · mot de passe aiopsatlas2026)
Étape suivante : ./labo.sh importer puis ./labo.sh charger-grapheThe first block is empty if the images are already there; the second contains the Compose lines (Container labo-elasticsearch Started, or Running if it was already running); the third adds a dot every three seconds until prêt (… s). Cold lab: one to two minutes; lab already running: prêt (0 s).
If you see something else: unhealthy, exited, or délai dépassé → ./labo.sh journal <service> and the lesson 04 catalog; most frequent causes: memory (Exited (137)) and, on native Linux, vm.max_map_count too low (sudo sysctl -w vm.max_map_count=262144, then ./labo.sh demarrer).
etatYou are going to type etat several times; first learn to extract the four numbers that matter from it.
./labo.sh etatCheckpoint: real output from the course machine, where the module 5 OpenSearch profile is active. On your machine, the two labo-opensearch… lines and ✔ OpenSearch are replaced by — OpenSearch non démarré (profil optionnel : ./labo.sh demarrer opensearch).
== Conteneurs ==
NAME STATUS PORTS
labo-elasticsearch Up 10 hours (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp
labo-kibana Up 10 hours (healthy) 0.0.0.0:5601->5601/tcp, [::]:5601->5601/tcp
labo-neo4j Up 37 seconds (healthy) 0.0.0.0:7474->7474/tcp, [::]:7474->7474/tcp, 0.0.0.0:7687->7687/tcp, [::]:7687->7687/tcp
labo-opensearch Up 10 hours (healthy) 0.0.0.0:9201->9200/tcp, [::]:9201->9200/tcp
labo-opensearch-dashboards Up 10 hours 0.0.0.0:5602->5601/tcp, [::]:5602->5601/tcp
== Services ==
✔ Elasticsearch : {"status":"green","number_of_nodes":1}
index : acces 12000 avis 609 cours 504
✔ Kibana répond (http://localhost:5601)
✔ Neo4j répond — nœuds : 872
✔ OpenSearch : {"status":"green","number_of_nodes":1}Note the four expected numbers: 12000, 609, 504, 872. On a brand-new lab you read index : aucun index du labo and nœuds : 0: normal, step B.4 fills them.
If you see something else: ✘ Kibana ne répond pas encore in the minute following demarrer → Kibana is finishing creating its internal indexes; retype etat thirty seconds later.
Load the indexes then the graph, and rerun both commands a second time: the counters must not change by a single unit.
./labo.sh importer
./labo.sh charger-graphe
./labo.sh importer
./labo.sh charger-grapheCheckpoint: on the second pass, importer reports that the indexes already exist and returns the same counters:
== Import dans elasticsearch ==
— index cours existe déjà — conservé
✔ données cours chargées
— index avis existe déjà — conservé
✔ données avis chargées
— index acces existe déjà — conservé
✔ données acces chargées
index docs.count store.size
acces 12000 1.5mb
avis 609 74.8kb
cours 504 183.6kb
Import terminé. Attendu : cours = 504, avis = 609, acces = 12000.and charger-graphe gives the same summary as the first time:
== Chargement du graphe Neo4j ==
✔ contraintes et index en place
etiquette, noeuds
"Competence", 22
"Cours", 504
"Etudiant", 300
"Professeur", 30
"Ville", 16
Graphe chargé. Attendu : Competence 22, Cours 504, Etudiant 300, Professeur 30, Ville 16.The documents carry their own _id (C0001, A00001…), so a second _bulk replaces each document instead of adding it; on the Neo4j side, everything uses MERGE. Only store.size may move by a few kilobytes (Lucene keeps the old versions for a while); the docs.count values, never.
If you see something else: a docs.count different from 504 / 609 / 12000 → someone wrote to these indexes; ./labo.sh reinitialiser then demarrer, importer, charger-graphe gives you back the reference state.
The script says everything is fine; verify it without the script. Open http://localhost:5601, ☰ menu → Management → Outils de développement (Dev Tools), and send these queries one by one (Ctrl + Enter, or Cmd + Enter on macOS, or the ▶ button « Cliquer pour envoyer la requête », Click to send request).
GET _cluster/healthCheckpoint: "status": "green", "unassigned_shards": 0, and 200 - OK at the bottom right of the response panel.
{
"cluster_name": "labo",
"status": "green",
"timed_out": false,
"number_of_nodes": 1,
"number_of_data_nodes": 1,
"active_primary_shards": 53,
"active_shards": 53,
…
"unassigned_shards": 0,
…
"active_shards_percent_as_number": 100
}GET _cat/indices/cours,avis,acces?v&s=indexCheckpoint: three green lines, pri 1, rep 0, and the three counters in docs.count.
health status index uuid pri rep docs.count docs.deleted store.size pri.store.size dataset.size
green open acces aii68fsfQXKyt5wqkE1mPA 1 0 12000 0 1.5mb 1.5mb 1.5mb
green open avis W9j_JrwJT4mdzpWcS5k7xg 1 0 609 0 74.8kb 74.8kb 74.8kb
green open cours pmq403ZgSZWeHJY9uNw1Qw 1 0 504 0 183.6kb 183.6kb 183.6kbFinish with GET cours/_count, GET avis/_count, GET acces/_count: "count": 504, 609, 12000. You have the same numbers by two independent paths: the script (curl inside the container) and Dev Tools (through Kibana). If they ever diverge, the path is at fault, not the data.
Under bash, you even have a third path, without a browser:
curl -s 'http://localhost:9200/_cat/indices/cours,avis,acces?v&s=index'If you see something else: "status": "yellow" → an index has unallocated replicas, impossible with the kit's mappings (number_of_replicas: 0); GET _cat/indices?v&health=yellow points to the culprit, usually an index created by hand.
Same exercise for Neo4j. Open http://localhost:7474, connect (neo4j / aiopsatlas2026, URL localhost:7687), type in the neo4j$ editor and click Run:
MATCH (n) RETURN labels(n)[0] AS label, count(*) ORDER BY labelCheckpoint: a frame with two views, Table and Raw (no Graph: the query returns numbers, not nodes), two columns label and count(*), five sorted rows, and at the bottom right Started streaming 5 records after … ms and completed after … ms.
label count(*)
"Competence" 22
"Cours" 504
"Etudiant" 300
"Professeur" 30
"Ville" 1622 + 504 + 300 + 30 + 16 = 872, the etat number. The Database information panel (Database overview icon, the first in the sidebar) shows Nodes (872) and Relationships (3,712).
If you see something else: a sixth unknown label → nodes created outside the kit (module 6 will teach you how to delete them cleanly); Nodes (0) → you skipped charger-graphe, back to step B.4.
You know what a healthy lab looks like; cause a failure whose cause you know in order to learn how to read it. Stop only Kibana, with Compose (not arreter, which would stop everything):
docker compose stop kibana Container labo-kibana Stopping
Container labo-kibana StoppedThen the three moves from lesson 04, in order: etat, the browser, journal.
Checkpoint 1, ./labo.sh etat: the labo-kibana line has disappeared from the == Conteneurs == block (Compose only shows running containers by default) and the == Services == block marks a cross:
== Conteneurs ==
NAME STATUS PORTS
labo-elasticsearch Up 10 hours (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp
labo-neo4j Up 3 minutes (healthy) 0.0.0.0:7474->7474/tcp, [::]:7474->7474/tcp, 0.0.0.0:7687->7687/tcp, [::]:7687->7687/tcp
labo-opensearch Up 10 hours (healthy) 0.0.0.0:9201->9200/tcp, [::]:9201->9200/tcp
labo-opensearch-dashboards Up 10 hours 0.0.0.0:5602->5601/tcp, [::]:5602->5601/tcp
== Services ==
✔ Elasticsearch : {"status":"green","number_of_nodes":1}
index : acces 12000 avis 609 cours 504
✘ Kibana ne répond pas encore
✔ Neo4j répond — nœuds : 872
✔ OpenSearch : {"status":"green","number_of_nodes":1}To see the stopped container anyway: docker compose ps -a shows labo-kibana Exited (0) 31 seconds ago. The 0 says "clean stop, requested"; a 137 would say "killed, memory".
Checkpoint 2, the browser: reload http://localhost:5601. No "Kibana server is not ready yet" (that sentence comes from Kibana, and there is no more Kibana to say it) but the browser's own connection error: in Chrome, "This site can't be reached", code ERR_CONNECTION_REFUSED; in Firefox, "Unable to connect". On the command line, curl -s http://localhost:5601 || echo REFUSE prints REFUSE: nobody is listening on port 5601. To remember: a Kibana page that apologizes = Kibana is running but waiting for Elasticsearch; a browser error = Kibana is not running.
Checkpoint 3, ./labo.sh journal kibana: the last hundred lines end with a clean shutdown, timestamped to the second you typed stop:
labo-kibana | [2026-09-09T23:44:06.893+00:00][INFO ][root] SIGTERM received - initiating shutdown
labo-kibana | [2026-09-09T23:44:06.894+00:00][INFO ][root] Kibana is shutting down
labo-kibana | [2026-09-09T23:44:06.902+00:00][INFO ][plugins-system.standard] Stopping all plugins.
…
labo-kibana | [2026-09-09T23:44:07.265+00:00][INFO ][plugins-system.standard] All plugins stopped.SIGTERM received signals a requested stop (by you, by docker compose stop, by a Docker restart). No ERROR or FATAL line: nothing to repair, you just need to restart. The dozens of at OperatorSubscriber… lines in between are a call stack emitted by a plugin during shutdown: noise.
If you see something else: no configuration file provided: not found → you are not in the kit folder; no such service: kibana → you typed the container name (labo-kibana) instead of the Compose service (kibana).
Restart the service and wait for its healthcheck to go back to green: 40 to 60 seconds, the time to reconnect to Elasticsearch and check its internal indexes.
docker compose start kibana Container labo-elasticsearch Waiting
Container labo-elasticsearch Healthy
Container labo-kibana Starting
Container labo-kibana StartedCompose first checked that Elasticsearch was Healthy (the depends_on … service_healthy from lesson 03), then started Kibana. Monitor every five seconds:
docker inspect --format '{{.State.Health.Status}}' labo-kibanaor, to avoid retyping, watch -n 5 docker inspect --format '{{.State.Health.Status}}' labo-kibana (Ctrl + C to exit; watch is absent from macOS by default, retype the command by hand).
Checkpoint: starting for 40 to 50 seconds, then healthy (on the course machine: starting from 0 to 45 s, healthy at 50 s). etat then shows the line again:
labo-kibana Up 52 seconds (healthy) 0.0.0.0:5601->5601/tcp, [::]:5601->5601/tcp
…
✔ Kibana répond (http://localhost:5601)and journal kibana ends with the lines we want to see:
labo-kibana | [2026-09-09T23:47:49.408+00:00][INFO ][http.server.Kibana] http server running at http://0.0.0.0:5601
labo-kibana | [2026-09-09T23:47:50.374+00:00][INFO ][status] Kibana is now availableReopen Dev Tools (if you land on "Kibana server is not ready yet", you were faster than the healthy: wait ten seconds) and send the query that etat itself uses:
GET _cluster/health?filter_path=status,number_of_nodes{
"status": "green",
"number_of_nodes": 1
}Elasticsearch answered green throughout the failure: Kibana is a window onto the data, not the data. Stopping Kibana erased nothing and reindexed nothing.
If you see something else: unhealthy after two minutes → journal kibana and look for ECONNREFUSED (Elasticsearch went down in the meantime); port is already allocated → another program took port 5601 during the stop (lesson 04, failure 1).
You recognize a stopped service; now learn to recognize a wrong query on a healthy service, the most frequent confusion in class. In Dev Tools, write a search query that returns 404 with "type": "index_not_found_exception", then explain in one sentence why the cluster stays green.
Hint: Elasticsearch never guesses an index name. Choose one that does not exist, with the pratique- prefix; nothing to create, nothing to write.
GET pratique-inexistant/_searchResponse, with the 404 - Not Found badge at the bottom right of the output panel:
{
"error": {
"root_cause": [
{
"type": "index_not_found_exception",
"reason": "no such index [pratique-inexistant]",
"resource.type": "index_or_alias",
"resource.id": "pratique-inexistant",
"index_uuid": "_na_",
"index": "pratique-inexistant"
}
],
"type": "index_not_found_exception",
"reason": "no such index [pratique-inexistant]",
…
},
"status": 404
}The same thing on the command line, to see the bare HTTP code: curl -s -o /dev/null -w '%{http_code}\n' http://localhost:9200/pratique-inexistant/_search prints 404.
Expected explanation: the 404 is a normal and complete response from Elasticsearch: "I understood your query, but this resource does not exist". The service is running, the cluster stays green; only the name needs correcting (GET _cat/indices?v gives the list). At step B.7, there was no response at all.
Two variants to try: GET pratique-inexistant/_count?ignore_unavailable=true returns 200 and "count": 0 (you ask to ignore the missing index); GET cours/_serch returns 400 with "no handler found for uri [/cours/_serch] and method [GET]". A 400: "I do not understand the query"; a 404: "I understand, but it does not exist".
A single command proves that everything is done: etat, with Kibana back and the four numbers.
./labo.sh etat== Conteneurs ==
NAME STATUS PORTS
labo-elasticsearch Up 10 hours (healthy) 0.0.0.0:9200->9200/tcp, [::]:9200->9200/tcp
labo-kibana Up About a minute (healthy) 0.0.0.0:5601->5601/tcp, [::]:5601->5601/tcp
labo-neo4j Up 7 minutes (healthy) 0.0.0.0:7474->7474/tcp, [::]:7474->7474/tcp, 0.0.0.0:7687->7687/tcp, [::]:7687->7687/tcp
labo-opensearch Up 10 hours (healthy) 0.0.0.0:9201->9200/tcp, [::]:9201->9200/tcp
labo-opensearch-dashboards Up 10 hours 0.0.0.0:5602->5601/tcp, [::]:5602->5601/tcp
== Services ==
✔ Elasticsearch : {"status":"green","number_of_nodes":1}
index : acces 12000 avis 609 cours 504
✔ Kibana répond (http://localhost:5601)
✔ Neo4j répond — nœuds : 872
✔ OpenSearch : {"status":"green","number_of_nodes":1}(Without the OpenSearch profile, the two labo-opensearch… containers do not appear and the last line says — OpenSearch non démarré …: this is the expected state until module 5.)
prerequis ends with Tout est prêt.labo-elasticsearch, labo-kibana, and labo-neo4j are Up … (healthy), Kibana included.etat shows "status":"green", acces 12000 avis 609 cours 504, nœuds : 872, unchanged after the second importer / charger-graphe.etat, the browser, and journal kibana show when Kibana is stopped, and how that differs from a 404.etat output above (copy or screenshot) as the deliverable.This practice creates nothing: no index, no node, no Kibana object. Two things to guarantee: that Kibana is running (otherwise docker compose start kibana from the kit folder), and that no working index is left lying around:
GET _cat/indices/pratique-*?vExpected response: the header line alone (health status index uuid pri rep docs.count …). Do not touch cours, avis, acces, or the graph: they serve all the following modules.
docker compose stop kibana answers no configuration file provided: not found → Compose looks for docker-compose.yml in the current folder. cd to the root of the kit (the one that contains labo.sh and labo.ps1) and start again. The script repositions itself on its own; docker compose commands typed by hand do not.
After stop, etat no longer shows labo-kibana and you think you deleted it → No: docker compose ps hides stopped containers. docker compose ps -a lists it as Exited (0), and docker compose start kibana restarts it with its data. A truly deleted container would not appear even with -a; demarrer would recreate it.
Kibana stays starting then goes unhealthy, and journal kibana repeats Unable to retrieve version information from Elasticsearch nodes. connect ECONNREFUSED 172.x.x.x:9200 → Kibana is back but Elasticsearch went down in the meantime (often Exited (137), memory). Fix Elasticsearch first (lesson 04, failure 2); Kibana reconnects on its own.
Dev Tools returns 400 instead of the expected 404, with no handler found for uri [/pratique-inexistant/_serch] and method [GET] → The mistake is in the API (_serch), not the index. Elasticsearch validates the route first, then the index: fix it to _search and the 404 appears.
Windows only — under PowerShell 5.1, demarrer shows in red docker : Image docker.elastic.co/kibana/kibana:9.5.3 Pulling … NativeCommandError but ends with Le labo est prêt → This only happens if you redirect the output (2>&1, | Tee-Object): Compose writes its progress to the error stream and PowerShell 5.1 dresses it up as an exception. Not an error; run the script without redirection, or switch to PowerShell 7.
Windows only — .\labo.ps1 is refused: "running scripts is disabled on this system" → Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, rerun. Once per machine.
Native Linux only — Elasticsearch exits with Exited (78) and journal elasticsearch says max virtual memory areas vm.max_map_count [65530] is too low → sudo sysctl -w vm.max_map_count=262144 then ./labo.sh demarrer. For it to survive a reboot: add vm.max_map_count=262144 to /etc/sysctl.conf.
macOS and bash — ./labo.sh answers Permission denied → chmod +x labo.sh, once only. If bash: ./labo.sh: /bin/bash^M: bad interpreter, the file has Windows line endings: git config core.autocrlf input then re-clone, or sed -i '' 's/\r$//' labo.sh.