DevConda — blog-workspace

Connect Spring Boot to Elasticsearch on EC2

When should you read this?

You already finished Replace SQL LIKE With Elasticsearch Search: local Docker, NDJSON bulk, one multi_match _search. The Spring app on EC2 still hits MySQL with LIKE '%…%', or has no search server at all. Opening :9200 on the public IP gets the box scanned overnight. This post runs ES on 127.0.0.1:9200 next to the jar, then connects Spring so one API path runs the same _search.

It follows the one-box EC2 arc (Nginx + Spring, Actions deploy, LIKE search). Those posts cover a single host and a local search proof. Here the next step is ES on that EC2, called from Spring.

What you are building

One EC2 box: Nginx terminates TLS, Spring serves GET /api/posts/search, ES answers on loopback only, MySQL keeps writes and id lookups. Security group opens 443 and keeps 9200 closed.

Client
  -> Nginx :443
      -> Spring :8080  GET /api/posts/search?q=
           -> Docker es-ec2  127.0.0.1:9200  (devconda-posts)
      -> MySQL  (writes / id lookup)
SG: 443 open / 9200 closed
Final system: Nginx, Spring, Elasticsearch, and MySQL on one EC2
Final system: Nginx, Spring, Elasticsearch, and MySQL on one EC2

For the search API alone, keep the URL and move the store from MySQL LIKE to ES _search.

Before LIKE search vs after Elasticsearch _search
Before LIKE search vs after Elasticsearch _search

Size the box, then Docker ES

Leave about 2 GB free RAM after Nginx and Java. A small instance that already runs a fat jar often OOMs if ES shares the box – raise instance size or keep the ES heap at 512m.

SSH in. Docker must be installed. Bring ES up in this order:

Bring up Elasticsearch on the instance
Bring up Elasticsearch on the instance
sudo sysctl -w vm.max_map_count=262144
echo 'vm.max_map_count=262144' | sudo tee /etc/sysctl.d/99-es.conf
sudo mkdir -p /var/lib/es-data
sudo chown 1000:1000 /var/lib/es-data

docker run -d --name es-ec2 --restart unless-stopped \
  -p 127.0.0.1:9200:9200 \
  -e discovery.type=single-node \
  -e xpack.security.enabled=false \
  -e "ES_JAVA_OPTS=-Xms512m -Xmx512m" \
  -v /var/lib/es-data:/usr/share/elasticsearch/data \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.0

curl -s http://127.0.0.1:9200

Bind loopback only. xpack.security.enabled=false is fine only while 9200 is not public. From outside the VPC, curl http://PUBLIC_IP:9200 must fail; the security group must not open 9200.

Loopback 9200 OK; public 9200 must stay closed
Loopback 9200 OK; public 9200 must stay closed

Create the index, bulk the NDJSON from the earlier LIKE post, then run _search once on the instance. Fix NDJSON newlines before blaming networking. One-node yellow health is normal.

How the Spring request runs

On each search request the path is fixed:

  1. Nginx forwards GET /api/posts/search?q=... to Spring :8080
  2. PostSearchController reads q
  3. PostSearchService builds the same multi_match as the LIKE post (title^2, body)
  4. ElasticsearchClient POSTs to http://127.0.0.1:9200/devconda-posts/_search
  5. Hits return as JSON; confirm with curl on the public URL
Search request path through Spring to Elasticsearch
Search request path through Spring to Elasticsearch

Add the dependency (match the Docker image tag, here 8.15.0):

<dependency>
  <groupId>co.elastic.clients</groupId>
  <artifactId>elasticsearch-java</artifactId>
  <version>8.15.0</version>
</dependency>
<dependency>
  <groupId>org.elasticsearch.client</groupId>
  <artifactId>elasticsearch-rest-client</artifactId>
  <version>8.15.0</version>
</dependency>

application.properties (or the prod profile on EC2):

app.elasticsearch.host=127.0.0.1
app.elasticsearch.port=9200
app.elasticsearch.index=devconda-posts

Add three classes: config opens the client, the service runs _search, the controller exposes the URL.

@Configuration
public class ElasticsearchConfig {

  @Bean(destroyMethod = "close")
  RestClient restClient(
      @Value("${app.elasticsearch.host}") String host,
      @Value("${app.elasticsearch.port}") int port) {
    return RestClient.builder(new HttpHost(host, port, "http")).build();
  }

  @Bean
  ElasticsearchClient elasticsearchClient(RestClient restClient) {
    ElasticsearchTransport transport = new RestClientTransport(
        restClient, new JacksonJsonpMapper());
    return new ElasticsearchClient(transport);
  }
}
@Service
public class PostSearchService {
  private final ElasticsearchClient es;
  private final String index;

  public PostSearchService(
      ElasticsearchClient es,
      @Value("${app.elasticsearch.index}") String index) {
    this.es = es;
    this.index = index;
  }

  public List<Map<String, Object>> search(String q) throws IOException {
    SearchResponse<Map> res = es.search(s -> s
        .index(index)
        .size(20)
        .query(query -> query.multiMatch(m -> m
            .query(q)
            .fields("title^2", "body"))),
        Map.class);

    List<Map<String, Object>> out = new ArrayList<>();
    for (Hit<Map> hit : res.hits().hits()) {
      Map<String, Object> row = new LinkedHashMap<>();
      row.put("id", hit.id());
      row.put("score", hit.score());
      if (hit.source() != null) row.putAll(hit.source());
      out.add(row);
    }
    return out;
  }
}
@RestController
@RequestMapping("/api/posts")
public class PostSearchController {
  private final PostSearchService search;

  public PostSearchController(PostSearchService search) {
    this.search = search;
  }

  @GetMapping("/search")
  public List<Map<String, Object>> search(@RequestParam String q) throws IOException {
    return search.search(q);
  }
}
Deploy and verify with curl
Deploy and verify with curl

Build, deploy (systemctl restart api), then verify:

curl -fsS "http://127.0.0.1:8080/api/posts/search?q=nginx%20spring"
curl -fsS "https://api.example.com/api/posts/search?q=nginx%20spring"

A document that matches nginx spring should rank near the top with a score. Connection refused on 9200 – start es-ec2 before changing Spring. Empty [] while ES is up usually means bulk never ran on this instance. Keep id lookups on MySQL; this path is text search only.

Writes still go to MySQL. Index sync on publish is a later topic; until then re-bulk or index when content is published.

Minimal checklist

  1. LIKE search post done; NDJSON and curl notes ready.
  2. es-ec2 on 127.0.0.1:9200, map count set, no public 9200.
  3. Index + bulk + curl _search OK on the instance.
  4. Three Spring classes and properties point at loopback; client version matches the image.
  5. After restart, GET /api/posts/search?q= returns hits through Nginx.

After that, the same _search contract runs on the server, and the search API no longer needs LIKE '%…%'.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *