스프링부트

spring boot 3.1 docker compose support

알쓸개잡 2023. 10. 22.

spring boot 3.1부터 docker compose를 지원한다. docker compose를 수행하기 위한 yaml 정의 파일이 있다면 spring boot에서 docker compose를 자동으로 실행하여 container를 실행시키고 ConnectionDetails 추상화를 통해서 자동으로 연결을 설정한다.

이를 통해서 로컬 환경에서 애플리케이션 구동을 위해서 수동으로 docker container를 실행시키고 애플리케이션 설정에서 외부 서비스 연결에 대한 설정을 따로 지정하지 않아도 된다. 이번 포스팅에서는 spring boot docker compose에 대해서 기록한다.

 

ConnectionDetails 추상화에 대한 설명은 Spring Boot 3.1's ConnectionDetails abstraction 문서를 참고하기 바란다.

 

Dependency

spring boot docker compose를 사용하기 위한 dependency는 다음과 같다.

maven

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-docker-compose</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

gradle

dependencies {
    developmentOnly("org.springframework.boot:spring-boot-docker-compose")
    testAndDevelopmentOnly("org.springframework.boot:spring-boot-docker-compose")
}

testAndDevelopmentOnly("org.springframework.boot:spring-boot-docker-compose") 는 테스트 코드 실행시에도 spring boot docker-compose 를 이용하여 docker container 를 실행하도록 한다.

 

spring-boot-docker-compose 종속성이 추가되면 다음과 같은 동작을 한다.

  • 애플리케이션 디렉토리에서 compose.yaml 및 기타 일반적인 작성 파일 이름을 검색한다.
  • 검색된 docker compose 작성 파일에 대해서 docker compose up 명령을 실행한다.
  • 지원되는 각 컨테이너에 대한 서비스 연결 Bean을 생성한다. (ConnectionDetails 추상화)
  • 애플리케이션이 shutdown 될 때 docker compose stop 명령을 실행한다.

 

starter.spring.io를 통해서 프로젝트를 생성할 때 'Docker Compose support' 종속성을 추가하고 지원되는 Docker Image와 관련된 서비스 driver가 종속성으로 추가되면 자동으로 compose.yaml 파일을 생성해 준다.

 

Service Connections

Container 서비스에 대한 연결은 현재 지원되는 Container인 경우 ConnectionDetails 추상화를 통해서 자동으로 연결 설정을 한다.

일반적으로 Container 내부의 포트와 호스트 포트가 맵핑되는 방식으로써 로컬에서 연결을 할 때는 호스트 포트를 통해서 접속을 해야 하는데 이 호스트 포트는 Container가 새로 실행될 때마다 변경되는데 spring boot에서 맵핑된 호스트 포트를 감지하여 자동 설정한다.

지원되지 않는 Container의 경우에는 별도로 연결 설정이 필요하고 docker compose 정의시 호스트 포트를 고정 포트로 설정해야 한다.

현재 지원되는 container image는 다음과 같다.

Connection Details Matched on
CassandraConnectionDetails Containers named "cassandra"
ElasticsearchConnectionDetails Containers named "elasticsearch"
JdbcConnectionDetails Containers named "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres"
MongoConnectionDetails Containers named "mongo"
R2dbcConnectionDetails Containers named "gvenzl/oracle-xe", "mariadb", "mssql/server", "mysql", or "postgres"
RabbitConnectionDetails Containers named "rabbitmq"
RedisConnectionDetails Containers named "redis"
ZipkinConnectionDetails Containers named "openzipkin/zipkin"

 

주의해야 할 것은 이미지 이름은 Matched on에 기재된 이름이어야 서비스로 자동 연결이 된다.

하지만 이미지에 다른 이름을 사용하는 경우 compose.yaml 파일에 label을 사용하여 이를 해결 할 수 있다.

services:
  redis:
    image: 'mycompany/mycustomredis:7.0'
    ports:
      - '6379'
    labels:
      org.springframework.boot.service-connection: redis

mycompany/mycustomredis 이미지를 spring boot에 redis 라는 것을 알려줘 RedisConnectionDetails를 통해 자동 연결 된다.

 

실제로 spring-boot-docker-compose 디펜던시의 org.springframework.boot.docker.compose.service.connection 패키지에 보면 다음과 같은 서비스들이 하위 패키지에 정의되어 있다.

또한 DockerComposeConnectionDetailsFactory에 DockerComposeConnectionDetails inner class가 정의되어 있는데 이를 상속하는 class는 다음과 같다. 아래 클래스들이 위 표에 지정된 container image와 일맥 상통한다.

각 DockerComposeConnectionDetails 자식 클래스들은 각 서비스에 해당하는 ConnectionDetails 인터페이스를 구현하고 있다.

 

Docker Compose Lifecycle

기본적으로 spring boot는 애플리케이션이 시작될 때 docker compose up을 호출하고 애플리케이션이 중지될 때 docker compose stop을 호출한다. 다른 lifecycle 관리를 하고자 할 때는 spring.docker.compose.lifecycle-management 속성을 사용할 수 있다.

지정할 수 있는 값은 다음과 같다.

  • none : docker compose를 시작하거나 중지하지 않는다.
  • start-only : 애플리케이션이 시작될 때만 docker compose를 실행한다.
  • start-and-stop : 애플리케이션이 시작될 때 docker compose를 시작하고 중지될 때 docker compose를 중지한다.

spring.docker.compose.start.command 속성을 사용하여 docker compose up 혹은 docker compose start를 설정한다. spring.docker.compose.stop.command 속성을 사용하여 docker compose down 혹은 docker compose stop을 설정한다.

spring:
  docker:
    compose:
      lifecycle-management: start-and-stop
      start:
        command: up
      stop:
        command: down
        timeout: 1m

 

Docker Compose for Test

테스트 코드 실행시실행 시 docker compose는 기본적으로 실행되지 않지만 아래 설정으로 테스트 코드 실행 시 적용할 수 있다.

spring.docker.compose.skip.in-tests=false

 

샘플 코드

docker compose를 사용하는 샘플 코드를 작성해 보자.

샘플 코드는

  • kafka, zookeeper, mariadb 컨테이너를 생성하는 compose.yaml 파일을 정의한다.
  • Member 생성 요청 API를 호출하면 Kafka의 test-topic 으로 메시지를 전송한다.
  • Kafka의 test-topic 으로부터 메시지를 수신하여 Member 테이블에 저장하는 코드이다.

데이터를 kafka를 통해서 송수신 한 뒤에 DB에 저장하는 것이 이상하긴 하지만 어디까지나 docker compose 사용에 대한 샘플이므로 docker compose 사용 방법에 중점을 두길 바란다.

 

dependency

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-devtools</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>
    <!-- spring boot docker compose를 사용하기 위해서 필요한 dependency -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-docker-compose</artifactId>
        <scope>runtime</scope>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.mariadb.jdbc</groupId>
        <artifactId>mariadb-java-client</artifactId>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-configuration-processor</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.kafka</groupId>
        <artifactId>spring-kafka-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

starter.spring.io를 통해서 프로젝트를 생성할 때 spring-boot-docker-compose 디펜던시와 현재 지원되는 container 종류인 mariadb에 대한 mariadb-java-client 디펜던시가 있기 때문에 아래와 같이 compose.yaml 파일이 자동으로 생성된다.

services:
  mariadb:
    image: 'mariadb:latest'
    environment:
      - 'MARIADB_DATABASE=mydatabase'
      - 'MARIADB_PASSWORD=secret'
      - 'MARIADB_ROOT_PASSWORD=verysecret'
      - 'MARIADB_USER=myuser'
    ports:
      - '3306'

여기서 추가로 kafka를 사용할 것이기 때문에 kafka container 생성을 위한 정의도 추가해주자.

services:
  mariadb:
    image: 'mariadb:latest'
    environment:
      - 'MARIADB_DATABASE=mydatabase'
      - 'MARIADB_PASSWORD=secret'
      - 'MARIADB_ROOT_PASSWORD=verysecret'
      - 'MARIADB_USER=myuser'
    ports:
      - '3306'
  zookeeper:
    image: docker.io/bitnami/zookeeper:3.8
    ports:
      - "2181:2181"
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes
  kafka:
    image: docker.io/bitnami/kafka:3.4
    ports:
      - "9092:9092"
      - "9094:9094"
    environment:
      - ALLOW_PLAINTEXT_LISTENER=yes
      - KAFKA_ENABLE_KRAFT=no
      - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT
      - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
      - KAFKA_CFG_LISTENERS=INTERNAL://kafka:9094,EXTERNAL://kafka:9092
      - KAFKA_CFG_ADVERTISED_LISTENERS=INTERNAL://kafka:9094,EXTERNAL://127.0.0.1:9092
      - KAFKA_CFG_INTER_BROKER_LISTENER_NAME=INTERNAL
    depends_on:
      - zookeeper

 

application.yml

spring:
  profiles:
    active: local
  kafka:
    producer:
      bootstrap-servers: localhost:9092
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      client-id: kafka-test-producer
    consumer:
      bootstrap-servers: localhost:9092
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      group-id: test-group-id
      auto-offset-reset: earliest
      client-id: kafka-test-consumer
      properties:
        spring.json.trusted.packages: com.example.spring.dockercompose.dto
    listener:
      concurrency: 3
      ack-mode: record
---
# 로컬 환경에서 애플리케이션을 구동하는 경우에만 docker compose를 사용한다.
# docker compose mariadb container가 지원 되므로 별도의 연결설정 (datasource)은 필요없다.
# 이유는 ConnectionDetails 추상화를 통해서 spring boot에서 자동으로 연결을 해준다.
spring:
  config:
    activate:
      on-profile: local

  jpa:
    hibernate:
      ddl-auto: create

  # spring boot docker compose enable 설정
  docker:
    compose:
      enabled: true
      lifecycle-management: start_and_stop
      stop:
        command: down
        timeout: 1m

---

# production 환경에서는 docker compose disable
# 설정으로 docker compose가 실행되지 않도록 한다.
# mariadb datasource 연결 정보를 설정해야 할 것이다.
spring:
  config:
    activate:
      on-profile: production
  docker:
    compose:
      enabled: false

local profile인 경우에만 docker compose를 사용하도록 설정하였다.

production profile의 경우에는 docker compose 가 실행되지 않는다.

kafka는 현재 spring boot docker compose에서 지원되지 않는 Container이기 때문에 ConnectionDetails 추상화를 통한 자동 연결이 되지 않으므로 연결 정보 설정을 해줘야 한다.

 

Class 코드

@Configuration
@Slf4j
@EnableKafka
public class TopicConfig {
	public static final String TOPIC_NAME = "test-topic";

	@Bean
	public NewTopic testTopic() {
		return new NewTopic(TOPIC_NAME, 3, CreateTopicsRequest.NO_REPLICATION_FACTOR);
	}
}

kafka에 'test-topic'이름의 토픽을 생성한다.

 

@Entity
@Table(name = "Member")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Member{

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

    @Column(name = "name", nullable = false, unique = true)
    private String userName;

    @Column(name = "age", nullable = false, length = 4)
    private Integer age;

    public static Member of(Long id) {
        return new Member(id);
    }

    private Member(Long id) {
        this.id = id;
    }

    @Builder
    protected Member(Long id, String userName, Integer age) {
        this.id = id;
        this.userName = userName;
        this.age = age;
    }
}

Member 테이블 정의 entity 클래스다.

 

@Builder
public record MemberDto(@JsonProperty("id") Long id,
						@JsonProperty("userName") String userName,
						@JsonProperty("age") Integer age) {

	public Member entity() {
		return Member.builder()
			.userName(userName)
			.age(age)
			.build();
	}
}

Member entity 변환을 위한 DTO 클래스다.

 

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
}

Member entity 조회 및 저장을 위한 repository 클래스다.

 

@Slf4j
@Service
@RequiredArgsConstructor
public class ConsumerService {
	private final MemberRepository memberRepository;

	@KafkaListener(
		topics = TopicConfig.TOPIC_NAME,
		clientIdPrefix = "topic1-listener",
		groupId = "${spring.kafka.consumer.group-id}"
	)
	public void listen(MemberDto memberDto, ConsumerRecordMetadata metadata) {
		log.info("received message: {}", memberDto);
		log.info("received topic: {}", metadata.topic());
		log.info("received partition: {}", metadata.partition());
		log.info("received offset: {}", metadata.offset());
		Member saved = memberRepository.save(memberDto.entity());
		log.info("saved member {}", saved);
	}
}

kafka 메시지 수신을 위한 consumer 클래스다.

 

@Service
@Slf4j
@RequiredArgsConstructor
public class ProducerService {
	private final KafkaTemplate<String, Object> kafkaTemplate;

	public void produce(final MemberDto memberDto) {
		String key = UUID.randomUUID().toString();
		kafkaTemplate.send(TopicConfig.TOPIC_NAME, key, memberDto)
			.whenComplete((result, throwable) -> {
				if (throwable != null) {
					log.error("fail to send message, {}", throwable.getMessage());
				} else {
					RecordMetadata metadata = result.getRecordMetadata();
					log.info("send message: {}", memberDto);
					log.info("send topic: {}", metadata.topic());
					log.info("send partition: {}", metadata.partition());
					log.info("send offset: {}", metadata.offset());
				}
			});
	}
}

kafka의 'test-topic'으로 메시지를 전송하기 위한 producer 클래스다.

 

@RestController
@RequiredArgsConstructor
public class MemberController {
	private final MemberRepository memberRepository;
	private final ProducerService producerService;

	@GetMapping("/member/{id}")
	public ResponseEntity<Member> getMember(@PathVariable Long id) {
		Optional<Member> optionalMember = memberRepository.findById(id);
		Member member = optionalMember.orElseThrow(NoSuchElementException::new);
		return ResponseEntity.ok(member);
	}

	@PostMapping("/member")
	public void postMember(@RequestBody MemberDto memberDto) {
		producerService.produce(memberDto);
	}
}

REST API controller 클래스다.

 

애플리케이션 실행

docker compose를 실행해야 하므로

  • docker 엔진이 구동되어 있어야 한다.
  • docker compose 혹은 docker-compose CLI 명령을 실행할 수 있어야 한다.

조건이 충족되어야 한다.

 

local 프로파일에서 애플리케이션을 실행하면 다음과 같이 container가 생성되는 로그를 확인할 수 있다.

[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Network spring-docker-compose_default  Creating
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Network spring-docker-compose_default  Created
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-zookeeper-1  Creating
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-mariadb-1  Creating
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-mariadb-1  Created
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-zookeeper-1  Created
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-kafka-1  Creating
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-kafka-1  Created
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-zookeeper-1  Starting
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-mariadb-1  Starting
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-zookeeper-1  Started
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-kafka-1  Starting
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-mariadb-1  Started
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-kafka-1  Started
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-mariadb-1  Waiting
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-zookeeper-1  Waiting
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-kafka-1  Waiting
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-mariadb-1  Healthy
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-zookeeper-1  Healthy
[utReader-stderr] o.s.boot.docker.compose.core.DockerCli   : Container spring-docker-compose-kafka-1  Healthy

docker ps -a CLI 명령으로 container 상태를 확인해 보면 다음과 같이 container가 생성됨을 확인할 수 있다.

mariadb container를 사용하기 때문에 MariaDbJdbcDockerComposeConnectionDetails 클래스를 디버깅해보면 다음과 같이 host port와 container port 맵핑이 이루어진 것을 확인할 수 있다.

mariadb container의 port 맵핑을 보면 0.0.0.0:50730 -> 3306/tcp로 host port 50730과 container port 3306이 맵핑된 것을 알 수 있다. 또한 디버깅을 통해서 portMappings를 살펴보면 50730 port와 3306 port가 맵핑되어 연결 정보가 설정된 것을 확인할 수 있다. spring boot에서는 docker compose를 통해서 생성된 container에 대한 inspect 정보를 기반으로 연결 정보 설정이 되는 게 아닌가 싶다.

 

postman을 통해서 Member 생성 API를 호출한다.

로그는 아래와 같이 생성된다.

send message: MemberDto[id=null, userName=test-member, age=100]
send topic: test-topic
send partition: 2
send offset: 0
received message: MemberDto[id=null, userName=test-member, age=100]
received topic: test-topic
received partition: 2
received offset: 0
saved member Member(id=1, userName=test-member, age=100)

kafka container를 통해서 test-topic으로 메시지가 송수신되고 DB에 member가 저장된 것을 알 수 있다.

 

postman을 통해서 Member 정보를 요청한다.

DB에 있는 데이터를 정상적으로 조회함을 알 수 있다.

전체 코드는 gitlab spring-docker-compose에서 확인해 보기 바란다.

 

샘플코드를 작성하면서 spring boot docker compose를 통해서 정말 손쉽게 로컬 테스트 환경을 구축할 수 있었다.

아직 ConnectionDetails 추상화가 지원되는 container가 많지는 않지만 점점 지원되는 container가 늘어날 것이라 기대한다.


참고링크

https://spring.io/blog/2023/06/21/docker-compose-support-in-spring-boot-3-1/

 

Docker Compose Support in Spring Boot 3.1

Docker Compose Support in Spring Boot 3.1 Docker Compose support in Spring Boot 3.1 builds on top of the ConnectionDetails abstraction, which we've featured in a separate blog post. If you haven't already read it, please do so before reading this post. Doc

spring.io

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#features.docker-compose

 

Spring Boot Reference Documentation

This section goes into more detail about how you should use Spring Boot. It covers topics such as build systems, auto-configuration, and how to run your applications. We also cover some Spring Boot best practices. Although there is nothing particularly spe

docs.spring.io

https://spring.io/blog/2023/06/19/spring-boot-31-connectiondetails-abstraction/

 

Spring Boot 3.1's ConnectionDetails abstraction

If you've used Spring Boot for a while, you're probably familiar with setting up connection details using properties. For example, you may have used spring.datasource.url to configure a JDBC connection. In Spring Boot 3.1 this continues to work as you'd ex

spring.io

 

'스프링부트' 카테고리의 다른 글

Spring에서 특정 HTTP 응답 상태 반환하기  (0) 2023.10.28
REST PUT vs PATCH  (0) 2023.10.22
spring boot container 기반 테스트  (0) 2023.10.19
spring boot 로그백 (logback) 설정 방법  (0) 2023.10.14
spring boot logging  (0) 2023.10.12

댓글

💲 추천 글