🔍 elastic search

Elasticsearch + Springboot

beomsic 2022. 9. 27. 15:34

Docker-compose로 Elasticsearch 설치


compose 파일 생성

vi elasticsearch.yml
version: '3.7'
services:
  es:
    image: docker.elastic.co/elasticsearch/elasticsearch:7.15.2
    container_name: elasticsearch
    environment:
      - node.name=single-node
      - cluster.name=beomsic
      - discovery.type=single-node
    ports:
      - 9200:9200
      - 9300:9300
    networks:
      - es-bridge

  kibana:
    container_name: kibana
    image: docker.elastic.co/kibana/kibana:7.15.2
    environment:
      SERVER_NAME: kibana
      # Elasticsearch 기본 호스트는 <http://elasticsearch:9200>
      # 현재 docker-compose 파일에 Elasticsearch 서비스 명 es로 설정
      ELASTICSEARCH_HOSTS: <http://elasticsearch:9200>
    ports:
      - 5601:5601
    # Elasticsearch Start Dependency
    depends_on:
      - es
    networks:
      - es-bridge

networks:
  es-bridge:
    driver: bridge

실행 하기

# 실행, 데몬으로 띄우려면 맨 뒤에 -d를 붙여준다.
# 기본 실행 도커파일은 docker-compose.yml인데 
# elasticsearch.yml로 만들었으므로 지정해주기 위해서 -f 옵션을 사용
docker-compose -f elasticsearch.yml up

# 죽이기
docker-compose -f elasticsearch.yml down

9200 포트는 HTTP 클라이언트 와 통신에 사용

9300 포트는 노드들간 통신 에 사용

이번엔 간단하게 구성하기 위해 volumne 매핑등 많은 부분을 안 함. → 자세한 내용 공식문서 확인

nori 형태소 분석기 추가하기

Dockerfile을 따로 만들어주고 elasticsearch.yml에 이미지로 추가

# dockerfile 생성
vi Dockerfile

# dockerfile 작성
ARG ELK_VERSION
FROM docker.elastic.co/elasticsearch/elasticsearch:${ELK_VERSION}
RUN elasticsearch-plugin install analysis-nori
# elasticsearch.yml 파일 수정
version: '3.7'
services:
  es:
    build:    
      # 도커파일의 위치 알려주기
      context: .
      # 인자 넣어주기
      args:
        ELK_VERSION: 7.15.2
    container_name: elasticsearch
    environment:
      - node.name=single-node
      - cluster.name=beomsic
      - discovery.type=single-node
    ports:
      - 9200:9200
      - 9300:9300
    networks:
      - es-bridge

  kibana:
    container_name: kibana
    image: docker.elastic.co/kibana/kibana:7.15.2
    environment:
      SERVER_NAME: kibana
      ELASTICSEARCH_HOSTS: <http://elasticsearch:9200>
    ports:
      - 5601:5601
    depends_on:
      - es
    networks:
      - es-bridge

networks:
  es-bridge:
    driver: bridge

Spring boot 설정


build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
}

Elasticsearch를 사용하기 위해 추가

yml 파일

elasticsearch:
  host: localhost
  port: 9200
  • 로컬에서 테스트 해볼 예정

코드 작성


AbstractElasticsearchConfig.java

public abstract class AbstractElasticsearchConfig extends ElasticsearchConfigurationSupport {

  @Bean
  public abstract RestHighLevelClient elasticsearchClient();

  @Bean(name = {"elasticsearchOperations", "elasticsearchTemplate"})
  public ElasticsearchOperations elasticsearchOperations(
      ElasticsearchConverter elasticsearchConverter,
      RestHighLevelClient elasticsearchClient) {

    ElasticsearchRestTemplate elasticsearchRestTemplate = new ElasticsearchRestTemplate(
        elasticsearchClient, elasticsearchConverter);

    elasticsearchRestTemplate.setRefreshPolicy(refreshPolicy());

    return elasticsearchRestTemplate;
  }
}

Elasticsearch 관련 작업을 할 때, 주로 ElasticsearchOperations 인터페이스의 구현체를 사용.

  • AbstractElasticsearchConfig 클래스에서 ElasticsearchOperations을 Bean 으로 등록

elasticsearchClient 추상 메서드로 등록되어 있어 상속받아 구현하여 빈으로 등록을 해준다.

ElasticsearchProperties.java

@Component
public class ElasticsearchProperties {

  @Value("${elasticsearch.host}")
  private String host;

  @Value("${elasticsearch.port}")
  private Integer port;

  public HttpHost httpHost() {
    return new HttpHost(host, port, "http");
  }

}
  • Elasticsearch 을 연결하기 위한 필요한 데이터들을 설정해주는 파일

ElasticsearchConfig.java

@Configuration
@EnableElasticsearchRepositories
public class ElasticsearchConfig extends AbstractElasticsearchConfig {

  private final ElasticsearchProperties elasticsearchProperties;

  public ElasticsearchConfig(ElasticsearchProperties elasticsearchProperties) {
    this.elasticsearchProperties = elasticsearchProperties;
  }

  @Override
  public RestHighLevelClient elasticsearchClient() {
    return new RestHighLevelClient(RestClient.builder(elasticsearchProperties.httpHost()));
  }
}

Client는 기본적으로 High Level REST Client 를 사용한다.

ElasticsearchProperties에서 가져온 host:port 에 떠있는 Elasticsearch와 연결

→ ElasticsearchClient를 사용할 수 있게 되었다.

실제로 사용할 때는 ElasticsearchOperations 또는 ElasticsearchRepository를 사용

User.java / UserDocument.java

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;

  private String nickname;

  private int age;

  private String description;

}
@Document(indexName = "user")
@Mapping(mappingPath = "elastic/user-mapping.json")
@Setting(settingPath = "elastic/user-setting.json")
@Getter
@Builder
public class UserDocument {

  @Id
  private Long id;

  private String name;

  private String nickname;

  private int age;

  private String description;

  @Field(type = FieldType.Date, format = {DateFormat.date_hour_minute_second_millis, DateFormat.epoch_millis})
  private LocalDateTime createdAt;

}

User 엔티티와 ES에 매핑할 UserDocument 클래스를 따로 만들었다.

  • 클래스 하나에 Entity와 ES를 매핑할 수 있지만 JPA repository와 ES repository를 사용할 때 문제가 생긴다.
해결 방법
  • @EnableJpaRepository@EnableElasticsearchRepositories 의 속성을 사용
  • 어떤 것은 JPA에만 어떤 것은 ES에만 적용되도록 별도의 세팅을 한다.
  • 이런 해결방법은 새로운 Repository를 추가할 때마다 코드가 추가되어 매우 힘들다.

ES에 데이터 타입을 매핑하는 2가지 방법

  1. @Field 를 사용 - 간단한 경우
  2. @Setting, @Mapping을 사용 - 복잡한 경우

@Field

  @Field(type = FieldType.Date, format = {DateFormat.date_hour_minute_second_millis, DateFormat.epoch_millis})
  private LocalDateTime createdAt;

@Setting, @Mapping

복잡한 경우 resource 부분에 json 파일을 만들어 사용한다.

// resource/elastic/user-setting.json
// 노리 분석기 정의

{
  "analysis" : {
    "analyzer" : {
      "korean" : {
        "type" : "nori"
      }
    }
  }
}
// resource/elastic/user-mapping.json
// 데이터 타입을 정의
// nori 분석기를 사용할 곳에는 분석기도 등록

{
  "properties" : {
    "id" : {"type" :  "keyword"},
    "age" : {"type" :  "keyword"},
    "name" : {"type" :  "keyword"},
    "nickname" : {"type" :  "text"},
    "description" : {
      "type" : "text",
      "analyzer" : "korean"
    },
    "createdAt" : {
      "type" : "date",
      "format" : "yyyy-MM-dd'T'HH:mm:ss.SSS||epoch_millis"
    }
  }
}

UserSearchRepository.java

public interface UserSearchRepository extends ElasticsearchRepository<UserDocument, Long> {

  List<UserDocument> findByAge(int age);

  Page<UserDocument> findByNickname(String nickname, Pageable pageable);

}

ElasicsearchRepository 인터페이스를 확장해 정의

UserSearchQueryRepository.java

@Repository
public class UserSearchQueryRepository {

  private final ElasticsearchOperations operations;

  public UserSearchQueryRepository(
      ElasticsearchOperations operations) {
    this.operations = operations;
  }

  public List<UserDocument> findByCondition(SearchCondition searchCondition, Pageable pageable) {

    CriteriaQuery query = createConditionCriteriaQuery(searchCondition)
        .setPageable(pageable);

    SearchHits<UserDocument> search = operations.search(query, UserDocument.class);

    return search.stream()
        .map(SearchHit::getContent)
        .collect(Collectors.toList());
  }

  public List<UserDocument> findByStartWithNickname(String nickname, Pageable pageable) {
    Criteria criteria = Criteria.where("nickname").startsWith(nickname);
    Query query = new CriteriaQuery(criteria).setPageable(pageable);
    SearchHits<UserDocument> search = operations.search(query, UserDocument.class);
    return search.stream()
        .map(SearchHit::getContent)
        .collect(Collectors.toList());
  }

  public List<UserDocument> findByMatchedDescription(String description, Pageable pageable) {
    Criteria criteria = Criteria.where("description").matches(description);
    Query query = new CriteriaQuery(criteria).setPageable(pageable);
    SearchHits<UserDocument> search = operations.search(query, UserDocument.class);
    return search.stream()
        .map(SearchHit::getContent)
        .collect(Collectors.toList());
  }
  
  public List<UserDocument> findByContainsDescription(String description, Pageable pageable) {
    Criteria criteria = Criteria.where("description").contains(description);
    Query query = new CriteriaQuery(criteria).setPageable(pageable);
    SearchHits<UserDocument> search = operations.search(query, UserDocument.class);
    return search.stream()
        .map(SearchHit::getContent)
        .collect(Collectors.toList());
  }

  private CriteriaQuery createConditionCriteriaQuery(SearchCondition searchCondition) {

    CriteriaQuery query = new CriteriaQuery(new Criteria());

    if (searchCondition == null)
      return query;

    if (searchCondition.getId() != null)
      query.addCriteria(Criteria.where("id").is(searchCondition.getId()));

    if(searchCondition.getAge() > 0)
      query.addCriteria(Criteria.where("age").is(searchCondition.getAge()));

    if(StringUtils.hasText(searchCondition.getName()))
      query.addCriteria(Criteria.where("name").is(searchCondition.getName()));

    if(StringUtils.hasText(searchCondition.getNickname()))
      query.addCriteria(Criteria.where("nickname").is(searchCondition.getNickname()));

    return query;
  }

}

QueryDSL을 사용할 때 처럼 복잡한 쿼리를 처리해야 하는 경우 리포지토리를 분리했다.

검색 조건에 따라서 동적 쿼리를 만들어 처리

UserService.java

@Service
@Transactional(readOnly = true)
public class UserService {

  private final UserRepository userRepository;
  private final UserSearchRepository userSearchRepository;
  private final UserSearchQueryRepository userSearchQueryRepository;

  public UserService(
      UserRepository userRepository,
      UserSearchRepository userSearchRepository,
      UserSearchQueryRepository userSearchQueryRepository) {
    this.userRepository = userRepository;
    this.userSearchRepository = userSearchRepository;
    this.userSearchQueryRepository = userSearchQueryRepository;
  }

  @Transactional
  public void saveAllUser(SaveAllUserRequest userRequest) {
    List<SaveUserRequest> userRequestList = userRequest.getUserRequestList();
    System.out.println(userRequestList.size());

    List<User> userList = userRequest.getUserRequestList()
        .stream()
        .map(UserConverter::to)
        .collect(Collectors.toList());

    userRepository.saveAll(userList);
    System.out.println("finish");
  }

  @Transactional
  public void saveAllUserDocuments() {
    List<UserDocument> userDocumentList = userRepository
        .findAll()
        .stream()
        .map(UserConverter::from)
        .collect(Collectors.toList());

    System.out.println("done1");

    userSearchRepository.saveAll(userDocumentList);
  }

  public Page<UserResponse> findAllByNickname(String nickname, Pageable pageable) {
    return userSearchRepository.findByNickname(nickname, pageable)
        .map(UserConverter::toUserResponse);
  }

  public List<UserResponse> findAllByAge(int age) {
    return userSearchRepository.findByAge(age)
        .stream()
        .map(UserConverter::toUserResponse)
        .collect(Collectors.toList());
  }

  public List<UserResponse> searchByCondition(SearchCondition searchCondition, Pageable pageable) {
    return userSearchQueryRepository.findByCondition(searchCondition, pageable)
        .stream()
        .map(UserConverter::toUserResponse)
        .collect(Collectors.toList());
  }

  public List<UserResponse> findAllByDescription(String description, Pageable pageable) {
    return userSearchQueryRepository.findByMatchedDescription(description, pageable)
        .stream()
        .map(UserConverter::toUserResponse)
        .collect(Collectors.toList());
  }

  public List<UserResponse> findAllByContainedDescription(String description, Pageable pageable) {
    return userSearchQueryRepository.findByContainsDescription(description, pageable)
        .stream()
        .map(UserConverter::toUserResponse)
        .collect(Collectors.toList());
  }
}

saveAllUser 메서드는 DB에 유저 정보를 저장하는 메서드,

saveAllUserDocuments 메서드는 DB에서 유저 정보를 꺼내 Document로 변환해서 Elasticsearch에 저장하는 메서드이다.

변환하는 과정은 UserConverter라는 클래스를 따로 만들어 처리했다.

UserConverter

public class UserConverter {

  public static UserDocument from(User user) {
    return UserDocument.builder()
        .id(user.getId())
        .name(user.getName())
        .nickname(user.getNickname())
        .age(user.getAge())
        .description(user.getDescription())
        .createdAt(user.getCreatedAt())
        .build();
  }

  public static User to(SaveUserRequest request) {
    return User.builder()
        .name(request.getName())
        .nickname(request.getNickname())
        .age(request.getAge())
        .description(request.getDescription())
        .build();
  }

  public static UserResponse toUserResponse(UserDocument userDocument) {
    return UserResponse.builder()
        .name(userDocument.getName())
        .nickname(userDocument.getNickname())
        .age(userDocument.getAge())
        .description(userDocument.getDescription())
        .build();
  }
}

UserController.java

@RestController
@RequestMapping("/api/v1/users")
public class UserController {

  private final UserService userService;

  public UserController(UserService userService) {
    this.userService = userService;
  }

  @PostMapping()
  public ResponseEntity<Void> saveAll(@RequestBody SaveAllUserRequest userRequest) {
    userService.saveAllUser(userRequest);
    return ResponseEntity.ok().build();
  }

  @PostMapping("/Documents")
  public ResponseEntity<Void> saveAllUserDocuments() {
    userService.saveAllUserDocuments();
    System.out.println("done2");
    return ResponseEntity.ok().build();
  }

  @GetMapping()
  public ResponseEntity<List<UserResponse>> searchByName(
      SearchCondition searchCondition,
      @PageableDefault(page = 0, size = 10) Pageable pageable) {

    List<UserResponse> userList = userService.searchByCondition(searchCondition, pageable);
    return ResponseEntity.ok(userList);
  }

  @GetMapping("/age")
  public ResponseEntity<List<UserResponse>> getByAge(@RequestParam int age) {

    List<UserResponse> userList = userService.findAllByAge(age);
    return ResponseEntity.ok(userList);
  }

  @GetMapping("/nickname")
  public ResponseEntity<Page<UserResponse>> getByNickname(
      @RequestParam String nickname,
      @PageableDefault(page = 0, size = 10) Pageable pageable) {

    Page<UserResponse> userList = userService.findAllByNickname(nickname, pageable);
    return ResponseEntity.ok(userList);
  }

  @GetMapping("/matchedDescription")
  public ResponseEntity<List<UserResponse>> getByMatchedDescription(
      @RequestParam String description,
      @PageableDefault(page = 0, size = 10) Pageable pageable) {

    List<UserResponse> userList = userService.findAllByDescription(description, pageable);
    return ResponseEntity.ok(userList);
  }

  @GetMapping("/containedDescription")
  public ResponseEntity<List<UserResponse>> getByContainedDescription(
      @RequestParam String description,
      @PageableDefault(page = 0, size = 10) Pageable pageable) {

    List<UserResponse> userList = userService.findAllByContainedDescription(description, pageable);
    return ResponseEntity.ok(userList);
  }
}

실행 시키기


IntelliJ에서 제공하는 .http 를 사용

미리 저장할 유저 정보들을 JSON 파일로 만들어 사용

User.json

{
  "userRequestList": [
    {
      "name": "고범석1",
      "nickname": "beomsic1",
      "age": 26,
      "description": "안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 홍길도입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석1",
      "nickname": "beomsic1",
      "age": 26,
      "description": "안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 홍길도입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석1",
      "nickname": "beomsic1",
      "age": 26,
      "description": "안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석2",
      "nickname": "beomsic2",
      "age": 26,
      "description": "안녕하세요 안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석2",
      "nickname": "beomsic2",
      "age": 26,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석3",
      "nickname": "beomsic3",
      "age": 26,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석4",
      "nickname": "beomsic4",
      "age": 26,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석4",
      "nickname": "beomsic4",
      "age": 26,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석4",
      "nickname": "beomsic4",
      "age": 26,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석4",
      "nickname": "beomsic4",
      "age": 25,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석4",
      "nickname": "beomsic4",
      "age": 25,
      "description": "안녕하세요 고범석입니다. 만나서 반갑습니다."
    },
    {
      "name": "고범석4",
      "nickname": "beomsic4",
      "age": 26,
      "description": "안녕하세요"
    }
  ]
}

User.http

# 엔티티(유저 리스트) 저장
POST <http://localhost:8080/api/v1/users>
Content-Type: application/json

< ./User.json

###

# 엔티티 document로 전환해서 ES에 저장 (user -> userDocument)
POST <http://localhost:8080/api/v1/users/Documents>

###

# 닉네임 검색
GET <http://localhost:8080/api/v1/users/nickname?nickname=beomsic1&size=10>

###

# 나이 검색
GET <http://localhost:8080/api/v1/users/age?age=26&size=10>

###

# 조건 검색
GET <http://localhost:8080/api/v1/users?id=1&name=고범석1&nickname=beomsic1&age=26&size=10>

###

# 일부 조건만 넣어 검색
GET <http://localhost:8080/api/v1/users?nickname=beomsic1&age=26&size=10>

###

# nickname으로 시작하는 것들 찾기
GET <http://localhost:8080/api/v1/users/nickname/startwith?nickname=beomsic1&size=10>

###

# description 매치되는 것 찾기
GET <http://localhost:8080/api/v1/users/matchedDescription?description=안녕하세요&size=10>

###

# 포함하는 것 찾기
# 노리 분석기가 안녕하세요를 [안녕,하,시,어요] 로 토큰화 하기 때문에 안녕하세요는 contain으로 찾을 수 없다.
GET <http://localhost:8080/api/v1/users/containedDescription?description=안녕&size=10>

###’ 는 각 요청을 구분해주는 역할

< 를 통해서 json 파일을 지정해 전송할 수 있다.

먼저 2개의 POST 요청을 보내 데이터를 추가

  • localhost:5601 로 들어가 키바나에 접속
  • Discover를 클릭하고 create index pattern을 클릭해
  • Name에 user를 입력 → user가 index로 등록되어 있는 것이 보인다.
    • 코드에서 @Document(indexName = “user”)
  • create
  • 다시 discover 클릭시 데이터가 잘 들어가 있는 것을 확인할 수 있다.

다른 GET 요청을 보내 데이터가 제대로 나오는지 확인

테스트 코드 짜기


테스트 코드를 위해 다른 TEST 용 Elasticsearch가 필요하다

  • TestContainer를 사용

build.gradle

testImplementation "org.testcontainers:elasticsearch"1.16.3"

ElasticTestContainer.java

// 기존에 정의한 config를 커스터마이징하고자 할 때 사용
// 테스트가 실행될 때 정의된 빈을 생성하여 등록
@TestConfiguration
// ES 관련 repository 등록
@EnableElasticsearchRepositories(basePackageClasses = {
    UserSearchRepository.class,
    UserSearchQueryRepository.class
})
public class ElasticSearchTestContainer extends AbstractElasticsearchConfig {

	private static final String ELASTICSEARCH_VERSION = "7.15.2";
	private static final DockerImageName ELASTICSEARCH_IMAGE =
      DockerImageName
          .parse("docker.elastic.co/elasticsearch/elasticsearch")
          .withTag(ELASTICSEARCH_VERSION);

	private static final ElasticsearchContainer container;
	
	// test container 띄우기
	static {
	   container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE);
	   container.start();
	}
	
	// ES Client 재정의
	@Override
	public RestHighLevelClient elasticsearchClient() {
	   ClientConfiguration clientConfiguration = ClientConfiguration.builder()
        .connectedTo(container.getHttpHostAddress())
        .build();

    return RestClients.create(clientConfiguration).rest();
  }
}

노리 분석기 사용

// build.gradle

//    testImplementation "org.testcontainers:elasticsearch:1.16.3"
    testImplementation("org.testcontainers:junit-jupiter:1.16.3"

ES testContainer에 Nori 분석기를 설치해주어야 한다.

  • ElasticsearchContainer를 이용해서는 할 수 없다.
  • 따라서 이를 해결하기 위해 상위 클래스인 GenericContainer를 사용

ES container 의존성이 필요 없다.

ElasticSearchTestContainerWithNori.java

// Nori 분석시 사용하려고 할 시 ES container에 Nori 분석기도 설치
@TestConfiguration
@EnableElasticsearchRepositories(basePackageClasses = {
    UserSearchRepository.class,
    UserSearchQueryRepository.class
})
public class ElasticSearchTestContainerWithNori extends AbstractElasticsearchConfig {

  private static final GenericContainer genericContainer;

  static {
    genericContainer = new GenericContainer(
        new ImageFromDockerfile()
            .withDockerfileFromBuilder(dockerfileBuilder -> {
              dockerfileBuilder
                  .from("docker.elastic.co/elasticsearch/elasticsearch:7.15.2") // es 이미지 가져오기
                  .run("bin/elasticsearch-plugin install analysis-nori") // nori 분석기 설치
                  .build();
            })
    )
        .withExposedPorts(9200, 9300)  // 기본 포트 설정
        .withEnv("discovery.type", "single-node"); // ES 가 싱글노드로 돌아가도록 설정

    genericContainer.start();
  }

  @Override
  public RestHighLevelClient elasticsearchClient() {

    // ES container에서 제공해주던 httpHostAddress를 사용할 수 없어 직접 만들어줘야 한다.
    String hostAddress = new StringBuilder()
        .append(genericContainer.getHost())
        .append(":")
        .append(genericContainer.getMappedPort(9200))
        .toString();

    ClientConfiguration clientConfiguration = ClientConfiguration.builder()
        .connectedTo(hostAddress)
        .build();

    return RestClients.create(clientConfiguration).rest();
  }
}

참고

https://backtony.github.io/spring/elk/2022-03-02-spring-elasticsearch-2/

https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/#reference

https://veluxer62.github.io/tutorials/spring-data-elasticsearch-test-with-test-container/

https://www.testcontainers.org/features/creating_images/