Questo articolo mostra come integrare Elasticsearch in una web application scritta in Go, eseguita in container con Docker e orchestrata con Docker Compose. L’obiettivo è avere:
- un cluster Elasticsearch (in modalità single node per sviluppo);
- un servizio web Go che indicizza e cerca documenti via HTTP;
- una configurazione riproducibile, con persistenza dati e healthcheck;
- una base pronta per essere “promossa” verso ambienti di staging/produzione (sicurezza, risorse, scalabilità).
Prerequisiti
- Docker e Docker Compose (plugin) installati.
- Go 1.22+ consigliato (ma il codice è facilmente adattabile).
Panoramica dell’architettura
Costruiremo tre componenti principali:
- Elasticsearch: motore di ricerca e indicizzazione.
- App Go: espone API HTTP per indicizzare e cercare “articoli”.
- Docker Compose: definisce rete, volumi, variabili d’ambiente e dipendenze.
La comunicazione avviene nella rete di Compose: l’app Go contatta Elasticsearch tramite hostname del servizio (es. http://elasticsearch:9200).
Struttura del progetto
.
├─ docker-compose.yml
└─ app/
├─ go.mod
├─ go.sum
├─ Dockerfile
└─ main.go
Docker Compose: Elasticsearch e app Go
Elasticsearch, a partire dalla serie 8.x, abilita la sicurezza (TLS/utente/password) di default. Per sviluppo locale spesso è più semplice disabilitarla; in produzione invece va mantenuta attiva. Qui mostriamo entrambe le opzioni:
Opzione A (sviluppo): sicurezza disabilitata
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.12.2
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
ulimits:
memlock:
soft: -1
hard: -1
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=30s | grep -q '"status"'"]
interval: 10s
timeout: 5s
retries: 20
app:
build: ./app
environment:
- ES_URL=http://elasticsearch:9200
- LISTEN_ADDR=:8080
ports:
- "8080:8080"
depends_on:
elasticsearch:
condition: service_healthy
volumes:
esdata:
Opzione B (più realistica): sicurezza abilitata con password
Se vuoi abituarti a una configurazione simile alla produzione, mantieni la sicurezza attiva e imposta una password per l’utente elastic. In questo caso l’app userà autenticazione Basic su HTTP (nota: in produzione è consigliato anche TLS e gestione certificati).
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.12.2
environment:
- discovery.type=single-node
- ELASTIC_PASSWORD=changeme
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "9200:9200"
volumes:
- esdata:/usr/share/elasticsearch/data
healthcheck:
test: ["CMD-SHELL", "curl -u elastic:changeme -fsS http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=30s | grep -q '"status"'"]
interval: 10s
timeout: 5s
retries: 20
app:
build: ./app
environment:
- ES_URL=http://elasticsearch:9200
- ES_USER=elastic
- ES_PASS=changeme
- LISTEN_ADDR=:8080
ports:
- "8080:8080"
depends_on:
elasticsearch:
condition: service_healthy
volumes:
esdata:
Nei prossimi esempi di codice mostreremo come gestire entrambe le situazioni (con o senza credenziali).
L’app Go: dipendenze e configurazione
go.mod
Useremo il client ufficiale github.com/elastic/go-elasticsearch/v8.
module example.com/es-go-web
go 1.22
require github.com/elastic/go-elasticsearch/v8 v8.12.0
Variabili d’ambiente
ES_URL: URL del cluster, es.http://elasticsearch:9200(da Compose) ohttp://localhost:9200(da host).ES_USER,ES_PASS: credenziali opzionali (necessarie se la sicurezza è attiva).LISTEN_ADDR: indirizzo di ascolto dell’app, es.:8080.
Dockerfile: build multi-stage
# app/Dockerfile
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/app ./main.go
FROM gcr.io/distroless/static:nonroot
WORKDIR /
COPY --from=build /out/app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Implementazione: indicizzazione e ricerca
Creeremo un indice articles con mapping adatto a testo ricercabile. L’app offrirà API minimali:
POST /articles: indicizza un documento JSON.GET /search?q=...: cerca sul campotitleebodycon highlighting.GET /health: verifica che l’app e Elasticsearch siano raggiungibili.
main.go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
elasticsearch "github.com/elastic/go-elasticsearch/v8"
"github.com/elastic/go-elasticsearch/v8/esapi"
)
const indexName = "articles"
type Article struct {
ID string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Tags []string `json:"tags,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type app struct {
es *elasticsearch.Client
}
func main() {
esURL := os.Getenv("ES_URL")
esUser := os.Getenv("ES_USER")
esPass := os.Getenv("ES_PASS")
listen := os.Getenv("LISTEN_ADDR")
cfg := elasticsearch.Config{
Addresses: []string{esURL},
// Se ES_USER/ES_PASS non sono impostati (sviluppo con sicurezza disabilitata),
// il client funzionerà comunque.
Username: esUser,
Password: esPass,
Transport: &http.Transport{
MaxIdleConnsPerHost: 10,
ResponseHeaderTimeout: 10 * time.Second,
},
}
es, err := elasticsearch.NewClient(cfg)
if err != nil {
log.Fatalf("errore init client ES: %v", err)
}
a := &app{es: es}
// Preparazione indice: in produzione fallo in una migrazione/init job.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := a.ensureIndex(ctx); err != nil {
log.Fatalf("errore ensureIndex: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("GET /health", a.handleHealth)
mux.HandleFunc("POST /articles", a.handleIndexArticle)
mux.HandleFunc("GET /search", a.handleSearch)
srv := &http.Server{
Addr: listen,
Handler: logMiddleware(mux),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("listening on %s", listen)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}
func (a *app) ensureIndex(ctx context.Context) error {
// Controlla se esiste già
res, err := a.es.Indices.Exists([]string{indexName}, a.es.Indices.Exists.WithContext(ctx))
if err != nil {
return fmt.Errorf("Indices.Exists: %w", err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
return nil
}
if res.StatusCode != 404 {
b, _ := io.ReadAll(res.Body)
return fmt.Errorf("Indices.Exists status=%d body=%s", res.StatusCode, strings.TrimSpace(string(b)))
}
// Crea indice con mapping esplicito
mapping := map[string]any{
"settings": map[string]any{
"number_of_shards": 1,
"number_of_replicas": 0,
},
"mappings": map[string]any{
"properties": map[string]any{
"id": map[string]any{
"type": "keyword",
},
"title": map[string]any{
"type": "text",
},
"body": map[string]any{
"type": "text",
},
"tags": map[string]any{
"type": "keyword",
},
"created_at": map[string]any{
"type": "date",
"format": "strict_date_optional_time||epoch_millis",
},
},
},
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(mapping); err != nil {
return fmt.Errorf("encode mapping: %w", err)
}
createRes, err := a.es.Indices.Create(
indexName,
a.es.Indices.Create.WithContext(ctx),
a.es.Indices.Create.WithBody(&buf),
)
if err != nil {
return fmt.Errorf("Indices.Create: %w", err)
}
defer createRes.Body.Close()
if createRes.IsError() {
b, _ := io.ReadAll(createRes.Body)
return fmt.Errorf("Indices.Create error status=%d body=%s", createRes.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}
func (a *app) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
res, err := a.es.Info(a.es.Info.WithContext(ctx))
if err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": err.Error()})
return
}
defer res.Body.Close()
if res.IsError() {
b, _ := io.ReadAll(res.Body)
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "error": strings.TrimSpace(string(b))})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *app) handleIndexArticle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
var art Article
if err := json.NewDecoder(r.Body).Decode(&art); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
return
}
if art.ID == "" || art.Title == "" || art.Body == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "id, title e body sono obbligatori"})
return
}
if art.CreatedAt.IsZero() {
art.CreatedAt = time.Now().UTC()
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(art); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "encode failed"})
return
}
req := esapi.IndexRequest{
Index: indexName,
DocumentID: art.ID,
Body: &buf,
Refresh: "wait_for", // comodo in dev per rendere subito ricercabile
}
res, err := req.Do(ctx, a.es)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
defer res.Body.Close()
if res.IsError() {
b, _ := io.ReadAll(res.Body)
writeJSON(w, http.StatusBadGateway, map[string]any{"error": strings.TrimSpace(string(b))})
return
}
writeJSON(w, http.StatusCreated, map[string]any{"ok": true})
}
func (a *app) handleSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "missing q"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Query DSL: multi_match su title e body, con highlighting
body := map[string]any{
"query": map[string]any{
"multi_match": map[string]any{
"query": q,
"fields": []string{"title^2", "body"},
},
},
"highlight": map[string]any{
"pre_tags": []string{"<mark>"},
"post_tags": []string{"</mark>"},
"fields": map[string]any{
"title": map[string]any{},
"body": map[string]any{"fragment_size": 150, "number_of_fragments": 3},
},
},
"size": 10,
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(body); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": "encode failed"})
return
}
res, err := a.es.Search(
a.es.Search.WithContext(ctx),
a.es.Search.WithIndex(indexName),
a.es.Search.WithBody(&buf),
a.es.Search.WithTrackTotalHits(true),
)
if err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}
defer res.Body.Close()
if res.IsError() {
b, _ := io.ReadAll(res.Body)
writeJSON(w, http.StatusBadGateway, map[string]any{"error": strings.TrimSpace(string(b))})
return
}
var raw map[string]any
if err := json.NewDecoder(res.Body).Decode(&raw); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": "bad response from ES"})
return
}
writeJSON(w, http.StatusOK, raw)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func logMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
Avvio con Docker Compose
Con la struttura sopra, avvia tutto dalla root del progetto:
docker compose up --build
Verifica:
# App
curl -fsS http://localhost:8080/health
# Elasticsearch (sviluppo senza security)
curl -fsS http://localhost:9200
# Elasticsearch (con security)
curl -u elastic:changeme -fsS http://localhost:9200
Indicizzare documenti di prova
curl -X POST http://localhost:8080/articles -H 'Content-Type: application/json' -d '{
"id": "a-001",
"title": "Introduzione a Elasticsearch con Go",
"body": "Questo articolo spiega come indicizzare e cercare contenuti usando Go e il client ufficiale.",
"tags": ["go", "elasticsearch", "docker"]
}'
curl -X POST http://localhost:8080/articles -H 'Content-Type: application/json' -d '{
"id": "a-002",
"title": "Ricerca full-text e highlighting",
"body": "Usiamo multi_match per cercare su title e body e restituiamo frammenti evidenziati.",
"tags": ["search", "full-text"]
}'
Eseguire una ricerca
curl -fsS "http://localhost:8080/search?q=highlighting" | jq
La risposta dell’app in questo esempio è volutamente la risposta grezza di Elasticsearch. In una web app reale spesso conviene “proiettare” i risultati in un DTO più stabile, ad esempio estraendo _source, highlight e total.
Note importanti per stabilità e produzione
Gestione della disponibilità di Elasticsearch
Il blocco depends_on: condition: service_healthy in Compose riduce i problemi di avvio. In produzione, però, la rete può essere instabile: è buona pratica aggiungere retry con backoff nel codice (o usare un reverse proxy/service mesh). Il client ufficiale offre configurazioni del trasporto; in alternativa puoi implementare retry a livello applicativo per le operazioni critiche.
Refresh e consistenza
Nel codice usiamo Refresh: wait_for quando indicizziamo: comodo in sviluppo, ma costoso ad alti volumi. In produzione, tipicamente si lascia il refresh automatico e si gestisce la consistenza “eventuale”.
Mapping e analizzatori
Il mapping presentato è minimale. Per migliorare la qualità della ricerca:
- aggiungi analizzatori linguistici (es. italiano) o synonyms;
- usa campi multi-field (
text+keyword) per ordinamenti e aggregazioni; - definisci normalizer per keyword (case-insensitive);
- valuta
search_as_you_typeocompletionper suggerimenti.
Sicurezza
- In sviluppo puoi disabilitare
xpack.security.enabledper semplicità. - In produzione mantieni la sicurezza attiva, usa TLS, ruoli/utenti dedicati (non l’utente
elastic) e segreti gestiti (Docker secrets, vault, ecc.). - Limita l’esposizione della porta 9200: idealmente accessibile solo dalla rete interna.
Risorse e parametri di sistema
Elasticsearch richiede attenzione a memoria e filesystem:
ES_JAVA_OPTSper dimensionare heap in base alla macchina.- Volume dati dedicato per persistenza.
- Impostazioni di sistema (es.
vm.max_map_count) possono essere necessarie su Linux; in caso di errori, consulta i log di Elasticsearch.
Estensioni utili
- Bulk indexing: per carichi importanti, usa l’API
_bulkper ridurre overhead HTTP. - Pagination: usa
from/sizeper pagine piccole; per paginazione profonda preferiscisearch_afteroscroll. - Aggregazioni: per faceting (tag, categorie) sfrutta
aggssu campikeyword. - Osservabilità: esporta metriche (Prometheus) e log strutturati, monitora latenza e errori di ES.
Query DSL: esempio avanzato
Se vuoi combinare filtri e testo, puoi usare bool con must/filter. Esempio (da inviare a Elasticsearch):
{
"query": {
"bool": {
"must": [
{ "multi_match": { "query": "go elasticsearch", "fields": ["title^2", "body"] } }
],
"filter": [
{ "terms": { "tags": ["docker", "go"] } }
]
}
},
"sort": [
{ "created_at": "desc" }
],
"size": 10
}
Risoluzione problemi comuni
- 401 Unauthorized: la sicurezza è attiva, ma l’app (o curl) non sta inviando credenziali.
- Connection refused: Elasticsearch non è ancora pronto; controlla healthcheck e log (
docker compose logs elasticsearch). - Indice non trovato: verifica che
ensureIndexsia eseguito, o crea l’indice manualmente. - Risultati “mancanti” subito dopo index: in produzione senza
refreshesplicito può esserci latenza prima che i documenti diventino ricercabili.
Conclusione
Con Docker Compose ottieni un ambiente riproducibile per sviluppare e testare integrazioni con Elasticsearch. Il client ufficiale Go semplifica la comunicazione con il cluster, mentre un mapping curato e query DSL ben progettate determinano la qualità della ricerca. Parti da questa base e poi aggiungi: bulk indexing, aggregazioni, sicurezza completa (TLS), gestione segreti e monitoraggio per un’adozione robusta in produzione.