Elasticsearch in un’applicazione web Go con Docker e Docker Compose

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) o http://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 campo title e body con 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_type o completion per suggerimenti.

Sicurezza

  • In sviluppo puoi disabilitare xpack.security.enabled per 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_OPTS per 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 _bulk per ridurre overhead HTTP.
  • Pagination: usa from/size per pagine piccole; per paginazione profonda preferisci search_after o scroll.
  • Aggregazioni: per faceting (tag, categorie) sfrutta aggs su campi keyword.
  • 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 ensureIndex sia eseguito, o crea l’indice manualmente.
  • Risultati “mancanti” subito dopo index: in produzione senza refresh esplicito 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.

Torna su