주요글: 도커 시작하기

이전 글(스프링 웹플럭스(WebFlux) SSE(Server Sent Event) 구현 1)에서 작성한 SSE 예제는 현실적이지 않다. 서로 다른 브라우저에서 /stocks/123 으로 연결하면 서로 다른 값이 표시된다. 실제라면 둘 다 같은 값을 출력해야 한다. 그래서 이 글에서는 조금 더 현실적인 예를 만들어보려 한다. 여기서 만들 예는 사용자 ID별로 데이터를 전송하는 기능이다.

여기서 구현할 클래스는 세 개다.

  • UserChannel : 사용자 ID별로 메시지를 보내기 위한 채널이다. Flux를 이용해서 메시지를 전송한다.
  • UserChannels : 사용자 ID에 대한 UserChannel을 관리한다.
  • UserChannelApi : SSE를 사용자 ID 별로 메시지를 전송하는 기능을 제공한다.

UserChannel 클래스

UserChannel 클래스는 다음과 같다.

public class UserChannel {
    private Flux<String> flux;
    private FluxSink<String> sink;
    private UnicastProcessor<String> processor;

    public UserChannel() {
        processor = UnicastProcessor.create();
        this.sink = processor.sink();
        this.flux = processor.share();
    }

    public void send(String message) {
        sink.next(message);
    }

    public Flux<String> toFlux() {
        return flux;
    }
}

하나의 데이터를 여러 클라이언트에 전송하기 위해 UnicastProcessor와 Flux#share()를 사용해서 Flux를 생성했다. 이 코드는 데이터를 전송하기 위해 UnicastProcessor#sink()로 구한 FluxSink를 사용한다.

send() 메서드는 FluxSink#next() 메서드를 이용해서 데이터를 Flux에 데이터를 보낸다.

UserChannels 클래스

UserChannels 클래스는 UserChannel을 맵으로 관리한다.

public class UserChannels {
    private ConcurrentHashMap<Long, UserChannel> map = new ConcurrentHashMap<>();

    public UserChannel connect(Long userId) {
        return map.computeIfAbsent(userId, key -> new UserChannel());
    }

    public void send(Long userId, String message) {
        UserChannel userChannel = map.get(userId);
        if (userChannel != null) {
            userChannel.send(message);
        }
    }
}

UserChannelApi 클래스

UserChannelApi가 제공하는 API는 두 개다.

  • GET /channels/users/{userId}/messages : 특정 사용자의 메시지를 수신하기 위한 SSE 구현
  • POST /channels/users/{userId}/messages : 특정 사용자에 메시지를 보내기 위한 API

다음은 두 API의 구현이다.

@RestController
public class UserChannelApi {
    private UserChannels channels = new UserChannels();

    @GetMapping("/channels/users/{userId}/messages")
    public Flux<ServerSentEvent<String>> connect(
            @PathVariable("userId") Long userId) {
        return channels.connect(userId) // UserChannel 리턴
            .toFlux() // Flux 리턴
            .map(str -> ServerSentEvent.builder(str).build());
    }

    @PostMapping(path = "/channels/users/{userId}/messages", 
                 consumes = MediaType.TEXT_PLAIN_VALUE)
    public void send(@PathVariable("userId") Long userId, 
                     @RequestBody String message) {
        channels.send(userId, message);
    }
}

connect() 메서드는 클라이언트를 특정 사용자 채널에 연결하고 채널의 메시지를 ServerSentEvent로 변환해서 클라이언트에 전송한다.

send() 메서드는 특정 사용자 채널에 메시지를 전송한다. @PostMapping 애노테이션의 consumes 값이 text/plain이므로 HTTP 요청 몸체로 받은 값을 문자열로 처리한다.

실행

구현을 했으니 동작을 확인할 차례다. 서버를 구동하고 크롬과 같이 SSE를 지원하는 브라우저를 두 개 띄운다. 그리고 Talend API Tester나 Postman 등 send() API를 호출하기 위한 도구를 준비한다. 참고로 서버 포트는 18080으로 변경했다.

첫 번째 브라우저에서 http://localhost:18080/channels/users/1/messages 에 접속한다. 그리고 POST로 데이터를 두 개 전송한다. 그러면 첫 번째 브라우저에 POST로 전송한 두 문자열이 표시되는 것을 확인할 수 있다.

두 번째 브라우저에서 접속한 뒤에 다시 POST로 메시지를 몇 개 더 전송한다. 아래 그림과 같이 첫 번째 브라우저와 두 번째 브라우저가 동일 데이터를 수신한 것을 확인할 수 있다.

 

Server Sent Event, 줄여서 SSE는 웹 서버에서 웹 브라우저로 이벤트를 푸시하고 싶을 때 유용하게 사용할 수 있다. 스프링 웹플럭스를 사용하면 간단하게 SSE를 구현할 수 있다. 이 글에서는 간단한 예를 이용해서 스프링 웹플럭스로 SSE를 어떻게 구현하는지 살펴보자.

[참고] 스프링 리액터에 대한 내용은 스프링 리액터 Reactor 기초 글 목록 글을 참고한다.

프로젝트 생성

먼저 스프링 부트 프로젝트를 생성한다. 이 글에서는 메이븐 프로젝트를 예로 사용했으며 pom.xml 파일은 다음과 같다. 웹플럭스 사용을 위해 spring-boot-starter-webflux를 추가했다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.2.1.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>sse-demo</groupId>
    <artifactId>sse-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>sse-demo</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>13</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

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

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

SSE 서버 구현

SSE 서버 구현은 간단하다. 요청 처리 메서드에서 ServerSentEvent를 제공하는 Flux를 리턴하기만 하면 된다. 다음은 구현 예이다.

@RestController
public class SimpleSSEApi {

    @GetMapping("/stocks/{code}")
    public Flux<ServerSentEvent<Stock>> stocks(@PathVariable("code") String code) {
        return Flux.interval(Duration.ofSeconds(1))
                .map(t -> Stock.builder()
                        .code(code)
                        .value(randomValue())
                        .build())
                .map(stock -> ServerSentEvent.builder(stock).build());
    }

    private int randomValue() {
        return ThreadLocalRandom.current().nextInt(1000) + 10000;
    }
}

이 코드에서 stocks()가 리턴하는 Flux는 1초 간격으로 ServerSentEvent를 제공한다. ServerSentEvent.builder() 메서드는 클라이언트에 전송할 이벤트 데이터를 받는다. 위 코드에서는 임의의 value 값을 갖는 Stock 객체를 이벤트 데이터로 사용했다.

자바 스크립트 구현

자바 스크립트는 EventSource를 이용해서 서버가 보내는 이벤트를 수신할 수 있다. 먼저 첫 화면 요청을 처리할 컨트롤러를 만든다. 이 컨트롤러는 '/' 요청이 오면 뷰로 index를 사용한다.

@Controller
public class IndexController {
    @GetMapping("/")
    public String index() {
        return "index";
    }
}

다음은 index 뷰로 사용할 index.html 파일이다. src/main/resxources/templates 폴더에 위치한 thymeleaf 파일이다.

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="utf-8">
    <title>단순 SSE</title>
</head>
<body>
    <div id="stockValue">
    </div>

    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js"></script>
    <script type="text/javascript">
        var source = null;
        function start() {
            source = new EventSource("/stocks/1234");
            console.log("create EventSource");
            source.onmessage = function(ev) {
                console.log("on message: ", ev.data);
                $("#stockValue").text(ev.data);
            };
            source.onerror = function(err) {
                console.log("on err: ", err);
                stop();
            };
        }
        function stop() {
            if (source != null) {
                source.close();
                console.log("close EventSource");
                source = null;
            }
        }

        $(document).ready(function(){
            start();
        });
        $(window).on("unload", function () {
            stop();
        });
    </script>
</body>

</html>

EventSource는 SSE를 위한 표준 API이다. EventSource 객체를 생성할 때 SSE를 받을 서버 경로를 입력한다. 위 코드에서는 /stocks/1234를 경로로 지정했다.

EventSource#onmessage는 서버가 이벤트를 전송할 때 불린다. onmessage에 전달한 이벤트 처리 함수는 서버가 이벤트를 전송할 때마다 인자로 이벤트를 받는다. 서버가 전송한 데이터는 이벤트 객체의 data 속성에 담긴다. 예제에서는 서버가 전송한 데이터를 stockValue 영역에 표시하게 구현했다.

EventSource#onerror는 에러가 발생했을 때 불린다. 예제에서는 에러가 발생하면 EventSource#close()를 이용해서 연결을 끊도록 했다.

실행 결과

mvnw spring-boot:run 명령어로 서버를 실행한 뒤에 http://localhost:8080으로 접속해보자. 그러면 1초마다 stockValue 영역의 값이 바뀌는 것을 볼 수 있다.

서버의 로그 레벨을 debug로 변경하면 1초 간격으로 메시지를 생성하는 로그를 볼 수 있다.

2019-12-06 08:10:22.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@7769d30b]
2019-12-06 08:10:23.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@5efe1c65]
2019-12-06 08:10:24.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@5eb98269]
2019-12-06 08:10:25.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@67b3812f]
2019-12-06 08:10:26.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@5e7be759]
2019-12-06 08:10:27.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@70c8d5c7]
2019-12-06 08:10:28.609 DEBUG 37036 --- [     parallel-5] o.s.http.codec.json.Jackson2JsonEncoder  : [0a3a9b2d] Encoding [ssedemo.Stock@6270bb7d]

 

정리

이 글에서는 간단하게 스프링 웹 플럭스로 SSE를 구현하는 방법을 살펴봤는데 다음 글(스프링 웹플럭스(WebFlux) SSE(Server Sent Event) 구현 2)에서는 조금 더 현실적인 예제를 만들어보자.

참고자료

 

출처: H2 DB 홈페이지

테스트 실행 시간을 단축하기 위해 인메모리 DB인 H2 DB를 종종 사용한다. JDBC URL에서 MODE 프로퍼티를 지정하면 MySQL, 오라클 용 쿼리를 사용할 수 있어 편리한 점도 있다. 예를 들어 다음과 같이 MODE 값으로 MySQL을 주면 H2 DB를 사용하면서 MySQL의 limit을 사용할 수 있다.

jdbc:h2:mem:progress;DATABASE_TO_UPPER=false;MODE=MySQL;DB_CLOSE_DELAY=-1

 

MODE를 사용해도 특정 DB에 특화된 기능을 사용할 수 없을 때가 발생한다. 예를 들면 MySQL의 inet_aton() 함수나 inet_ntoa() 함수는 H2 DB에서는 사용할 수 없다. 통합 테스트 코드를 실행할 때 H2 DB에서 지원하지 않는 함수를 쿼리에서 사용하면 에러가 발생하기 때문에 이런 함수를 대체할 수 있는 수단을 찾아야 한다.

다행히 H2는 사용자 정의 함수 기능을 지원해서 이 문제를 어렵지 않게 해결할 수 있다. 사용자 정의 함수 기능을 사용하려면 다음 두 가지 작업만 하면 된다.

  • 사용자 정의 함수에서 호출할 정적 메서드 구현
  • create alias를 이용한 커스텀 함수 생성

먼저 커스텀 함수에서 호출할 정적 메서드를 구현한다.

package util;

public class H2CustomFunc {
    public static Long inet_aton(String value) {
        if (value == null) return null;
        else return ...생략;
    }

    public static String inet_ntoa(Long value) {
        if (value == null) return null;
        else return ...생략;
    }
}

다음은 인메모리 H2에 테이블을 생성할 때 다음 쿼리를 실행한다.

CREATE ALIAS IF NOT EXISTS inet_ntoa FOR "util.inet_ntoa"
CREATE ALIAS IF NOT EXISTS inet_aton FOR "util.inet_aton"

이제 H2에 연동할 때 inet_ntoa() 함수나 inet_aton() 함수를 사용할 수 있다.

return jdbcTemplate.query("select " +
                "uid, content_div, use_yn, " +
                "view_start_dt, view_end_dt, " +
                "settings, memo, " +
                "reg_id, reg_dt, inet_ntoa(reg_ip) as reg_ip, " +
                "upd_id, upd_dt, inet_ntoa(upd_ip) as upd_ip " +
                "from progress.featured_contents_mgt " +
                "order by uid desc limit ?, ?",
        JdbcFeaturedContentsRepository::mapRow,
        start, limit);

사용자 정의 함수에 대한 내용은 자세한 내용은 아래 문서를 참고한다.

 

예전에 '아이들이 열중하는 수업에는 법칙이 있다'는 책을 읽다가 너무 와 닿는 글귀가 있어 사진으로 남겨둔 적이 있다. 아래가 그 사진이다.

출처: 아이들이 열중하는 수업에는 법칙이 있다

책에서도 언급하고 있지만 이 글은 당연히 교사뿐만 아니라 개발자에게도 적용된다. 종종 경력이 쌓이고 프로젝트를 경험하면 실력이 는다고 말하는 이가 있지만 이는 어디까지나 신입일 때 얘기다. 이런 식으로는 실력 향상에 한계가 있다. 조금만 시간이 지나도 더 이상 실력이 늘지 않고 정체된다.

이 업계에서 정체는 곧 후퇴다. 기량 향상을 위해 노력하지 않는 것은 본인의 선택이지만 정체는 곧 후퇴라는 것은 잊지 말자.

도커 시작하기 전체 글 목록

스웜과 오버레이 네트워크

docker network ls 명령어는 네트워크 목록을 보여준다. 스웜에 스택을 배포했다면 범위가 swarm인 오버레이 네트워크를 볼 수 있다. 오버레이 네트워크는 서로 다른 노드에 생성된 컨테이너 간 연결을 처리한다.

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
c6ef5b2ea943        bridge              bridge              local
da6c7575bf20        docker_gwbridge     bridge              local
b30ace236245        host                host                local
c8c1e9h5214o        ingress             overlay             swarm
vwes54i1ysyh        simple_default      overlay             swarm

오버레이 네트워크 중 ingress 네트워크는 호스트(노드)에서 서비스로의 포워딩을 담당한다. 외부에 포트를 공개한 서비스를 스웜에 배포하면 ingress 네트워크에 서비스를 참여시키고 다음의 두 IP를 할당한다.

  • 서비스에 대한 가상 IP
  • 서비스의 각 컨테이너에 대한 IP

각 노드는 외부에 개시된 서비스 포트로 요청이 오면 ingress 네트워크의 가상 IP로 전달한다. 가상 IP는 ingress 네트워크에서 서비스에 접근할 때 사용할 IP이다. 가상 IP에 전달된 요청은 다시 컨테이너의 IP로 전달된다. ingress 네트워크에 참여한 서비스의 각 컨테이너는 ingress 네트워크 내에서 고유 IP를 갖는다.

docker network inspect ingress 명령어 실행하면 ingress 네트워크 내에 생성된 서비스의 VIP와 각 컨테이너에 할당된 IP를 확인할 수 있다.

스웜에 스택을 배포하면 스택을 위한 오버레이 네트워크도 생긴다. 다음 컴포즈 파일을 보자.

version: "3.6"
services:
  mysql:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=rootpw
    deploy:
      replicas: 1
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    deploy:
      replicas: 1

이 파일에는 네트워크 설정이 없다. 이 경우 스웜은 스택을 위한 네트워크를 생성한다. 이때 네트워크 이름은 "스택명_default"가 된다. 예를 들어 위 설정을 이용해서 이름이 dbadmin인 스택을 배포하면 dbadmin_default인 네트워크를 생성한다.

스택을 위한 오버레이 네트워크는 스택에 속한 서비스가 서로 통신할 때 사용된다. 위 설정의 경우 mysql 서비스와 adminer 서비스가 서로 통신할 때 스택을 위한 오버레이 네트워크를 사용하게 된다.

오버레이 네트워크와 서비스

default 이름 대신에 직접 네트워크 이름을 지정할 수 있다. 컴포즈 파일에 networks 키를 이용해서 사용할 네트워크를 지정하면 된다. 다음은 설정 예이다.

version: "3.6"
services:
  mysql:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=rootpw
    networks:
      internal:
        aliases:
          - db
    deploy:
      replicas: 1
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    networks:
      - internal
    deploy:
      replicas: 1

networks:
  internal:

가장 하단에 networks 키는 네트워크 이름 목록을 값으로 갖는다. 위 설정에서는 이름이 internal인 네트워크만 설정했다. 여기서 internal은 오버레이 네트워크다.

서비스 설정에 networks 키 값으로 internal을 추가하면 해당 서비스는 internal 네트워크에 묶인다. 위 설정은 mysql 서비스와 adminer 서비스를 둘 다 internal 네트워크에 연결했다.

aliases는 네트워크 내에서 서비스를 참조할 때 사용할 별칭을 추가한다. mysql 설정은 db를 별칭으로 추가했다. 같은 네트워크를 사용하는 서비스는 서비스 이름과 별칭을 사용해서 다른 서비스에 연결할 수 있다. 위 설정에서 adminer 서비스의 컨테이너는 'mysql'이나 'db'를 이용해서 mysql 서비스에 연결할 수 있다. 실제 서비스 이름인 '스택명_mysql'로도 접근할 수 있다. 물론 다른 네트워크에서는 이 이름들로 접근할 수 없다.

외부 오버레이 네트워크 사용

각 스택의 서비스를 같은 네트워크에 참여시키고 싶다면 오버레이 네트워크를 스웜 수준에서 생성하고 스택에서 이 네트워크를 참조하면 된다. 오버레이 네트워크를 생성할 때는 -d (--driver) 옵션 값으로 overlay를 지정하면 된다.

$ docker network create -d overlay service-net

컴포즈 파일에서는 external 옵션을 true로 지정해서 해당 네트워크가 스택 외부 자원임을 명시한다.

version: "3.6"
services:
  mysql:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=rootpw
    networks:
      service-net:
        aliases:
          - db
    deploy:
      replicas: 1
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    networks:
      service-net:
        aliases:
          - web
    deploy:
      replicas: 1

networks:
  service-net:
    external: true

위 설정을 이용해서 스택을 생성하면 스택의 서비스는 service-net 네트워크에 참여한다. service-net에 참여하는 서비스는 서비스 이름과 alias를 이용해서 각 서비스에 연결할 수 있다.

 

 

 

 

컴포즈 파일과 스택

컴포즈 파일을 이용하면 서비스를 보다 쉽게 만들고 업데이트할 수 있다. 다음 컴포즈 파일을 보자.

version: "3.6"
services:
  web:
    image: madvirus/simplenode:0.1
    ports:
      - "5000:5000"
    deploy:
      mode: replicated
      replicas: 2
      update_config:
        parallelism: 1
        order: start-first
        delay: 10s
        failure_action: rollback
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

컴포즈 파일은 YAML을 사용해서 작성한다. 최상위 services는 서비스 목록을 정의한다. services의 바로 아래는 정의할 서비스 이름이 온다. 위 설정의 경우 이름이 web인 서비스를 정의한다. 실제 생성되는 서비스의 이름은 web이 아니라 스택 이름과 조합한 이름을 사용한다. 스택은 뒤에서 설명한다.

서비스 이름 아래 표시한 속성은 도커 서비스를 생성할 때 사용한 옵션과 이름이 유사하다. 서비스를 생성할 때 사용한 옵션과 유사한 이름을 사용한다. 각 속성의 값은 서비스를 설명할 때 사용한 옵션과 동일한 값을 갖는다.

컴포즈 파일에 대한 내용은 https://docs.docker.com/compose/compose-file/ 문서를 참고한다.

컴포즈 파일을 작성했다면 이제 스택을 배포할 차례다. 위 파일이 stack01.yml이라고 할 경우 다음 명령어를 이용해서 스택을 배포할 수 있다. docker stack deploy 명령어는 컴포즈 파일을 이용해서 새로운 스택을 배포한다. -c(또는 --compose-file) 옵션은 사용할 컴포즈 파일을 지정한다. 명령어에서 마지막의 simple은 배포할 스택의 이름이다.

$ docker stack deploy -c stack01.yml simple
Creating network simple_default
Creating service simple_web

스택을 배포하면 simple_web 서비스를 만든다. simple_web에서 simple은 스택 이름이고 web은 컴포즈 파일에서 지정한 서비스 이름이다. 스택을 배포하면 docker stack ls 명령어로 배포한 스택을 확인할 수 있다.

$ docker stack ls
NAME                SERVICES            ORCHESTRATOR
simple              1                   Swarm

실제로 docker service ps simple_web 명령어를 실행하면 simple_web 서비스에 두 개의 컨테이너가 실행 중이다.

$ docker service ps simple_web
ID                  NAME                IMAGE                     NODE                DESIRED STATE       CURRENT STATE            ERROR               PORTS
x8cq0l5i6jwg        simple_web.1        madvirus/simplenode:0.1   docker-node1        Running             Running 13 minutes ago
hu97kwyc8oon        simple_web.2        madvirus/simplenode:0.1   docker-node2        Running             Running 13 minutes ago

실행 중인 서비스의 이미지 버전이나 리플리케이션 개수를 변경하고 싶다면 컴포즈 파일을 알맞게 만들어 다시 스택을 배포하면 된다. 컴포즈 파일을 다시 만들자.

version: "3.6"
services:
  web:
    image: madvirus/simplenode:0.2
    ports:
      - "5000:5000"
    deploy:
      mode: replicated
      replicas: 4
      update_config:
        parallelism: 1
        order: start-first
        delay: 10s
        failure_action: rollback
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

이미지 태그를 0.1에서 0.2로 바꾸고 리플리카 개수를 4로 바꿨다. 새로 바꾼 컴포즈 파일을 이용해서 스택을 다시 배포해 보자.

$ docker stack deploy -c stack02.yml simple
Updating service simple_web (id: oj33pustknvntz1ud4t9g5egs)

simple_web 서비스를 업데이트한다는 메시지가 출력된다. 배포 후에 서비스 목록을 보면 컨테이너가 4개로 증가하고 이미지가 0.2로 바뀐 것을 볼 수 있다.

환경 변수를 이용한 설정 변경

환경 변수를 사용하면 컴포즈 파일을 수정하지 않고 값을 변경할 수 있다. 보통 컴포즈 파일을 사용해서 스택을 배포할 때는 새로 적용할 버전만 바뀔 때가 많은데 환경 변수를 사용하면 배포할 버전을 컴포즈 파일에 하드 코딩하지 않고 환경 변수로 전달할 수 있다.

version: "3.6"
services:
  web:
    image: madvirus/simplenode:${VER:-latest}
    ports:
      - "5000:5000"
    deploy:
      mode: replicated
      replicas: ${REPLICAS:-3}
      update_config:
        parallelism: 1
        order: start-first
        delay: 10s
        failure_action: rollback
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:5000/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

${VER}와 ${REPLICAS}는 각각 같은 이름의 환경 변수의 값을 사용한다. 해당 환경 변수가 존재하지 않으면 각각 latest와 3을 값으로 사용한다. 이제 도커 스택을 배포할 때 환경 변수를 이용해서 컴포즈 파일 변경없이 원하는 값을 쉽게 지정할 수 있다.

$ VER=0.2 REPLICAS=2 docker stack deploy -c stack.yml simple
Creating network simple_default
Creating service simple_web

컨테이너 환경 변수 설정: environment

컴포즈 파일에서 컨테이너에 전달할 환경 변수를 전달할 수도 있다. environment 속성에 컨테이너에 전달할 환경 변수 이름과 값 목록을 설정하면 된다. 다음은 설정 예이다.

version: "3.6"
services:
  web:
    image: madvirus/simplenode:0.3
    ports:
      - "5000:5000"
    environment:
      - RUNTIME_ENV=${ENV:-dev}
    deploy:
      mode: replicated
      replicas: 2

스택과 서비스 묶음

한 스택에는 한 개 이상의 서비스, 네트워크, 볼륨을 정의할 수 있다. 이 중 지금은 두 개 이상의 서비스를 에 대한 내용만 살펴보자. 다음은 두 서비스를 정의한 컴포즈 파일 예이다.

version: "3.6"
services:
  mysql:
    image: mysql:5.7
    environment:
      - MYSQL_ROOT_PASSWORD=rootpw
    deploy:
      replicas: 1
  adminer:
    image: adminer
    ports:
      - "8080:8080"
    deploy:
      replicas: 1

이름이 mysql이고 adminer인 두 서비스를 정의하고 있다. 앞서 봤듯 실제로는 스택 이름을 앞에 붙인 서비스 이름을 사용한다. 위 컴포즈 파일을 이용해서 dbadmin이란 이름의 스택을 생성하자.

$ docker stack deploy -c stack03.yml dbadmin
Creating network dbadmin_default
Creating service dbadmin_adminer
Creating service dbadmin_mysql

스택을 생성하면 dbadmin_adminer와 dbadmin_mysql의 두 서비스가 만들어진다. 그리고 dbadmin_default 네트워크도 생성한 것을 알 수 있다. 스택을 생성하면 이름이 '스택_default'인 네트워크를 기본으로 생성한다. 이 네트워크는 오버레이 네트워크로 스택에 속한 서비스를 위한 네트워크이다. 스택에 속한 서비스는 서비스 이름을 사용해서 다른 서비스의 컨테이너와 통신할 수 있다.

dbadmin 스택의 경우 dbadmin_adminer 서비스에 속한 컨테이너에서 dbadmin_mysql라는 이름을 사용해서 해당 서비스의 컨테이너에 접근할 수 있다. 웹 브라우저에서 http://호스트:8080 주소를 입력하면 dbadmin_adminer 서비스가 생성한 컨테이너에 접속한다. 여기서 서버 이름에 dbadmin_mysql을 입력해서 dbadmin 스택에 속한 mysql DB에 연결할 수 있다.

같은 스택에 속한 컨테이너는 서비스 이름으로 접근

도커 스택과 네트워크에 대한 내용은 다음 글에서 간단히 살펴본다.

스택 삭제

docker stack rm 명령어를 사용하면 스택을 삭제한다.

$ docker stack rm dbadmin
Removing service dbadmin_adminer
Removing service dbadmin_mysql
Removing network dbadmin_default

관련 글

서비스 생성과 리플리케이션

도커 스웜 클러스터를 만들었다면 서비스를 생성할 수 있다. 서비스를 만드는 것은 어렵지 않다. 매니저 노드에서 docker create service 명령어를 실행하면 된다. 서비스를 생성할 때 --replicas 옵션을 사용해서 생성할 컨테이너 개수를 지정한다.

docker service create --name simple \
--publish published=8000,target=5000 \
--replicas 2 \
madvirus/simplenode:0.1

서비스를 생성한 뒤 "docker service ps 서비스명" 명령어를 실행하면 각 컨테이너가 어느 도커 노드에서 실행 중인지 확인할 수 있다.

vagrant@docker-node1:~$ docker service ps simple
ID                  NAME                IMAGE                     NODE                DESIRED STATE       CURRENT STATE           ERROR               PORTS
lecwvwjcs7lm        simple.1            madvirus/simplenode:0.1   docker-node2        Running             Running 5 minutes ago
wap8ua3proil        simple.2            madvirus/simplenode:0.1   docker-node3        Running             Running 6 minutes ago

결과에서 NAME은 컨테이너의 완전한 이름이 아닌 서비스 내에서 구분하기 위한 이름이다. 실제 컨테이너 이름은 simple.2.wap8ua3proil1aqircsy7tifq와 같이 NAME 뒤에 ID를 결합한 문자열을 사용한다.

docker servie scale 명령어를 사용하면 운영 중에 리플리케이션 개수를 변경할 수 있다.

$ docker service scale simple=4
simple scaled to 4
overall progress: 4 out of 4 tasks
1/4: running   [==================================================>]
2/4: running   [==================================================>]
3/4: running   [==================================================>]
4/4: running   [==================================================>]
verify: Service converged

$ docker service ps simple
ID                  NAME                IMAGE                     NODE                DESIRED STATE       CURRENT STATE                ERROR               PORTS
lecwvwjcs7lm        simple.1            madvirus/simplenode:0.1   docker-node2        Running             Running 18 minutes ago
wap8ua3proil        simple.2            madvirus/simplenode:0.1   docker-node3        Running             Running 19 minutes ago
v90ftwuywm0d        simple.3            madvirus/simplenode:0.1   docker-node1        Running             Running about a minute ago
674upghwilm2        simple.4            madvirus/simplenode:0.1   docker-node1        Running             Running about a minute ago

docker service ps 명령어를 실행하면 simple 서비스의 컨테이너 개수가 4개가 된 것을 확인할 수 있다. 개수를 늘리는 것 뿐만 아니라 줄이는 것도 가능하다.

$ docker service scale simple=1
simple scaled to 1
overall progress: 1 out of 1 tasks
1/1: running   [==================================================>]
verify: Service converged

$ docker service ps simple
ID                  NAME                IMAGE                     NODE                DESIRED STATE       CURRENT STATE            ERROR               PORTS
lecwvwjcs7lm        simple.1            madvirus/simplenode:0.1   docker-node2        Running             Running 20 minutes ago

리플리케이션 개수를 줄일 때는 가장 최근에 생성한 컨테이너를 먼저 중지한다.

서비스 제거

서비스 제거는 간단하다. "docker service rm 서비스명" 명령어를 사용하면 된다. 서비스를 제거하면 해당 컨테이너도 함께 삭제된다.

simplenode:0.2 버전

롤링 업그레이드와 헬스 체크를 테스트하기 위해 madvirus/simplenode:0.2 버전 이미지를 새로 추가했다. 이 이미지는 다음의 app.js를 실행한다.

const http = require('http');
const url = require('url');

const server = http.createServer().listen(5000);
let slow = false;

server.on('request', (req, res) => {
    console.log('request arrived.');
    const query = url.parse(req.url, true).query;
    if (query.stop === 'true') {
        res.write("Process down: " + process.env.HOSTNAME);
        res.end();
        setTimeout((arg) => { process.exit(1); }, 1000, "shutdown");
    } else if (query.slow === 'true') {
        slow = true;
        res.write("Health check slowdown");
        res.end();
    } else if (req.url === '/health') {
        if (slow) {
            setTimeout((arg) => { res.write("SLOW"); res.end(); }, 5000, "slow");
        } else {
            res.write("OK");
            res.end();
        }
    } else {
        res.write("Hello V0.2: " + process.env.HOSTNAME);
        res.end();
    }
});

server.on('connection', (socket) => {
    console.log("Connected");
});

다음은 이 app.js의 주요 내용이다.

  • 헬스체크를 위한 /health 경로를 처리한다.
    • slow 값이 true면 /health 응답을 5초 뒤에 한다. 이것으로 헬스체크 실패를 흉내낸다.
    • slow 기본 값은 false이다.
    • slow 파라미터가 'true'면 slow를 true로 바꾼다.
  • stop 파라미터가 'true'면 1초 뒤에 프로세스를 중지한다.

롤링 업그레이드

madvirus/simplenode:0.1 버전을 이용한 서비스를 madvirus/simplenode:0.2 버전으로 업그레이드해보자. docker service update 명령어를 사용하면 된다. 다음은 실행 예이다.

$ docker service update \
--update-parallelism 1 \
--update-delay 10s \
--update-order start-first \
--image madvirus/simplenode:0.2 \
simple

업데이트 관련 주요 옵션은 옵션은 다음과 같다.

  • --update-parallelism : 동시에 업데이트할 컨테이너 개수를 지정한다.
  • --update-delay : 업데이트 간 간격을 지정한다.
  • --update-order : start-first면 새 컨테이너를 먼저 생성한 뒤에 기존 컨테이너를 삭제한다. stop-first면 기존 컨테이너를 먼저 삭제하고 그 다음에 새 컨테이너를 생성한다.
  • --update-failure-action : 업데이트에 실패할 경우 이 값이 pause면 업데이트를 멈추고, continue면 업데이트를 계속하고, rollback이면 업데이트를 롤백한다.
  • --update-max-failure-ratio : 실패 비율이 지정한 값 이상이면 업데이트 실패로 간주한다.

서비스 업데이트 후에 docker service ps 명령어로 서비스를 조회하면 다음과 같이 simplenode:0.1 버전은 중지되고 simplenode:0.2 버전이 실행 중인 것을 확인할 수 있다.

vagrant@docker-node1:/vagrant/sample/simplenode$ docker service ps simple
ID                  NAME                IMAGE                     NODE                DESIRED STATE       CURRENT STATE             ERROR               PORTS
sqyy4m1846qu        simple.1            madvirus/simplenode:0.2   docker-node3        Running             Running 35 seconds ago
78c9glgwxr01         \_ simple.1        madvirus/simplenode:0.1   docker-node2        Shutdown            Shutdown 31 seconds ago
1zynj40j8a5v        simple.2            madvirus/simplenode:0.2   docker-node2        Running             Running 19 seconds ago
z0pz24xqpmna         \_ simple.2        madvirus/simplenode:0.1   docker-node1        Shutdown            Shutdown 15 seconds ago

상태 유지와 헬스 체크

simple 서비스를 생성할 때 리플리카의 개수를 2로 지정했다. 도커 스웜은 simple 서비스의 컨테이너 개수를 2개로 유지하기 위해 노력한다. 예를 들어 컨테이너 한 개가 종료되면 새로운 컨테이너를 구동해서 정상 동작하는 컨테이너 개수를 맞춘다.

실제로 컨테이너 한 개를 종료해보자. http://192.168.1.101:8000/?stop=true을 요청하면 컨테이너 중 한 개가 종료된다. 컨테이너가 종료되면 도커 스웜은 새로운 컨테이너를 바로 생성해서 컨테이너 개수를 2로 맞춘다.

헬스 체크 옵션을 주면 컨테이너가 정상 상태가 아닐 때 컨테이너를 중지하고 새로운 컨테이너를 생성할 수 있다. 헬스 체크 관련 옵션을 사용하면 생성한 컨테이너가 정상 상태인지 주기적으로 확인한다. 도커 스웜은 컨테이너의 상태를 주기적으로 확인해서 정상이 아니면 컨테이너를 제거하고 리플리케이션 개수에 맞게 새로 생성한다. 헬스 체크와 관련된 옵션은 --health로 시작하며 다음은 주요 옵션의 사용 예를 보여준다.

docker service create \
--name simple \
--publish published=8000,target=5000 \
--health-cmd 'curl -f http://localhost:5000/health' \
--health-timeout=3s \
--health-retries=3 \
--health-interval=10s \
--health-start-period=10s \
--replicas=2 \
madvirus/simplenode:0.2

주요 옵션은 다음과 같다.

  • --health-cmd : 정상 상태인지 확인할 때 사용할 명령어를 입력한다. 이 명령어는 컨테이너 내에서 실행된다.
  • --health-timeout : 명령어의 실행 시간 제한을 지정한다. 이 시간 내에 명령어 실행이 끝나면 정상이 아닌 것으로 판단한다.
  • --health-retries : 지정한 횟수만큼 연속해서 실패하면 정상이 아닌 것으로 간주한다.
  • --health-interval : 헬스 체크를 실행할 주기를 지정한다.
  • --health-start-period : 컨테이너 시작 후 지정한 시간 동안은 헬스 체크에 실패해도 실패로 간주하지 않는다.

헬스 체크가 잘 되는지 확인해보자. 두 개의 컨테이너가 떠 있다고 가정하고 http://192.168.1.101:8000/?slow=true 요청을 보내자. 앞서 app.js 코드를 보면 slow 파라미터 값이 true이면 /health 요청의 응답 시간을 5초로 바꾼다. 헬스 체크 제한 시간은 3초이므로 이 URL을 실행하면 컨테이너의 헬스체크에 실패한다.

헬스 체크 간격이 10초이고 3회 시도하므로 잠시 뒤에 simple service ps simple 명령어로 컨테이너 목록을 보자. 한 컨테이너가 실패 상태에 있고(Failed) 이를 대체할 새로운 컨테이너를 준비한 것을 확인할 수 있다.

$ docker service ps simple
ID                  NAME                IMAGE                     NODE                DESIRED STATE       CURRENT STATE                   ERROR                              PORTS
qzxmt802h7b9        simple.1            madvirus/simplenode:0.2   docker-node2        Running             Running 2 minutes ago
drmlj2bh7ltw        simple.2            madvirus/simplenode:0.2   docker-node3        Ready               Ready less than a second ago
cdumn8jafb56         \_ simple.2        madvirus/simplenode:0.2   docker-node3        Shutdown            Failed less than a second ago   "task: non-zero exit (143): do…"

서비스와 호스트 연결

서비스를 생성할 때 --publish (또는 -p) 옵션을 사용하면 호스트의 포트에 서비스의 포트를 연결해서 외부에 서비스를 개시할 수 있다.

docker service create \
--name simple \
--publish published=8000,target=5000 \
--replicas=2 \
madvirus/simplenode:0.2

스웜에서 외부에 서비스를 개시하면 스웜에 참여하는 모든 노드가 서비스에 대한 요청을 처리한다. 위 코드는 호스트의 8000 포트를 이용해서 서비스를 개시했는데 이 경우 스웜에 참여한 모든 노드는 8000 포트로 오는 요청을 서비스의 5000 포트로 전달한다. 호스트로 오는 요청을 실제 컨테이너로 전달하는 것은 ingress 네트워크를 통해 처리되는데 이에 대한 내용은 뒤에서 다시 살펴본다.

모드 종류

도커 서비스는 리플리케이션과 글로벌의 두 모드가 있다. 서비스를 생성할 때 --mode 옵션을 이용해서 모드를 지정할 수 있다. 모드에는 다음 두 값이 올 수 있다.

  • replicated : 기본 값으로 지정한 개수만큼 컨테이너를 생성한다.
  • global : 각 노드마다 한 개의 컨테이너를 생성한다.
https://docs.docker.com/engine/reference/commandline/service_create/ 문서에서 더 많은 서비스 관련 옵션을 확인할 수 있다.

관련 글

 

 

도커 스웜 모드를 사용하면 클러스트 환경에서 서비스를 관리할 수 있다. 여기서 서비스는 일종의 네트워크에서 찾고 사용할 수 있는 기능으로 컨테이너로 구성되어 있다. 서비스를 사용하면 실제 운영 중에 필요한 컨테이너 헬스체크, 고가용성을 위한 이중화, 롤링 업그레이드 등을 손쉽게 할 수 있다.

이 글에서는 도커 스웜 클러스터를 구축하고 아주 간단한 서비스를 클러스터에서 실행해보겠다.

서비스 테스트를 위한 간단한 이미지

여기서 사용할 이미지는 호스트 이름을 리턴하는 간단한 노드 웹이다. 코드는 다음과 같다.

const http = require('http');

const server = http.createServer().listen(5000);

server.on('request', (req, res) => {
    console.log('request arrived.');
    res.write("Hello: " + process.env.HOSTNAME);
    res.end();
});

server.on('connection', (socket) => {
    console.log("Connected");
});

이 코드를 실행하는 이미지를 도커 허브에 madvirus/simplenode:0.1로 등록해 놨다. 다음은 이미지를 생성할 때 사용한 Dockerfile이다.

FROM node:8.16-alpine
RUN apk add --no-cache tini curl

WORKDIR /app

COPY app.js .

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "app.js"]

도커 스웜 클러스터 생성

도커 스웜 클러스터를 생성하기 위해 다음의 IP를 가진 세 장비를 준비했다. 괄호 안에 표시한 문자열은 호스트 이름이다.

  • 매니저 : 192.168.1.101(docker-node1)
  • 워커: 192.168.1.102(docker-node2), 192.168.1.103(docker-node3)

101 서버에는 스웜 클러스터의 매니저를 설치하고 나머지 102, 103 서버에는 워커를 설치한다.

먼저 101 서버에서 docker swarm init 명령어로 도커 스웜을 초기화하자. 이때 --advertise-addr 옵션으로 스웜 클러스터에 참여할 IP를 지정한다.

$ docker swarm init --advertise-addr=192.168.1.101
Swarm initialized: current node (t8uoehrwcnzdf4q0poh6thlt4) is now a manager.

To add a worker to this swarm, run the following command:

    docker swarm join --token SWMTKN-1-4pldr32x98ytmo60lso4jkhj2mg2zrdqxo33b2n8f50oz01pwp-8y4jflppx6eelxs7cn6fgfmd2 192.168.1.101:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.

docker swram init 명령어는 docker swarm join 명령어를 출력한다. 이 명령을 사용해서 스웜에 워커를 추가할 수 있다. docker swarm join-token worker 명령어를 사용해서 참여 명령어를 조회할 수 있다.

102 서버와 103 서버에서 docker swram join 명령어를 실행하자.

$ docker swarm join --token SWMTKN-1-4pldr32x98ytmo60lso4jkhj2mg2zrdqxo33b2n8f50oz01pwp-8y4jflppx6eelxs7cn6fgfmd2 192.168.1.101:2377
This node joined a swarm as a worker.

스웜 매니저 장비(101 서버)에서 docker node ls 명령어를 실행해보자. 노드 목록을 볼 수 있다. 노드는 스웜에 참여한 도커 인스턴스로 보통 한 호스트에 한 노드를 설치한다.

vagrant@docker-node1:~$ docker node ls
ID                            HOSTNAME            STATUS              AVAILABILITY        MANAGER STATUS      ENGINE VERSION
t8uoehrwcnzdf4q0poh6thlt4 *   docker-node1        Ready               Active              Leader              19.03.2
rmsb5mnofkv8czjgjzpcce1gy     docker-node2        Ready               Active                                  19.03.2
01bul21qtizrxnawg56zngs4v     docker-node3        Ready               Active                                  19.03.2

docker-node1 호스트는 MANAGER STATUS 값이 Leader이다. 도커 스웜은 한 개 이상의 매니저를 가질 수 있는데 이 중에서 리더가 클러스터를 관리한다.

서비스 테스트하기

스웜 클러스터를 만들었으니 이제 클러스터에서 서비스를 실행해보자. docker service create 명령어를 사용해서 서비스를 생성한다.

$ docker service create \
--name simple \
--publish published=8000,target=5000 \
--replicas 2 \
madvirus/simplenode:0.1

ojslsjah1obmlpdyargxp6fg9
overall progress: 2 out of 2 tasks
1/2: running   [==================================================>]
2/2: running   [==================================================>]
verify: Service converged

위 명령어는 madvirus/simplenode:0.1 이미지를 이용해서 이름이 simple인 서비스를 생성한다. --replicas 옵션은 생성할 컨테이너의 개수를 지정한다. 여기서는 2를 주었으므로 클러스터 내에 두 개의 컨테이너를 생성한다. --publish 옵션을 사용해서 외부에서 8000번 포트로 연결하면 컨테이너의 5000번 포트로 연결한다.

서비스를 만들었으니 http://192.168.1.101:8000, http://192.168.1.102:8000, http://192.168.1.103:8000에 각각 연결해보자. 아래 그림처럼 호스트 이름을 응답으로 출력할 것이다(simplenode:0.1이 실행하는 app.js는 호스트 이름을 출력하는 간단한 웹 어플리케이션이다).

스웜 ingress 네트워크

docker service ls 명령어를 실행하면 실행 중인 서비스 목록을 보여준다.

$ docker service ls
ID                  NAME                MODE                REPLICAS            IMAGE                     PORTS
ojslsjah1obm        simple              replicated          2/2                 madvirus/simplenode:0.1   *:8000->5000/tcp

REPLICAS 값이 2/2인데 이는 현재 2개가 실행중이고(앞의 2) 설정한 복제수는 2(뒤의 2)개라는 것을 보여준다. 포트 연결은 서비스 단위에서 처리된다. 서비스 수준에서 8000 포트로 오는 요청을 컨테이너의 5000 포트로 연결한다.

8000 포트를 사용하면 어떤 노드에 요청하더라도 서비스의 컨테이너에 연결된다. 예를 들어 simple 서비스의 컨테이너가 docker-node1과 docker-node2에 각각 하나씩 떠 있고 docker-node3에 요청을 보내도 도커 스웜 네트워크가 알맞게 컨테이너에 전달한다. 이를 처리하는 것이 ingress 네트워크이다.

docker network ls 명령어를 실행해보자. 이름이 ingress인 네트워크가 생성된 것을 볼 수 있다.

$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
d1a366c95b3a        bridge              bridge              local
da6c7575bf20        docker_gwbridge     bridge              local
b30ace236245        host                host                local
c8c1e9h5214o        ingress             overlay             swarm
918b235ab093        none                null                local

ingress 네트워크는 오버레이(overlay) 드라이버를 사용하고 스웜(swarm) 범위를 갖는다. 오버레이 네트워크는 서로 다른 도커 호스트에서 실행되는 컨테이너 간 통신을 위한 논리적인 네트워크 그룹으로서 ingress 네트워크는 특수한 오버레이 네트워크이다. ingress 네트워크는 스웜 노드로 오는 모든 요청을 알맞게 서비스로 라우팅한다.

오버레이 네트워크와 서비스에 대한 내용은 뒤에서 다시 살펴본다.

관련 글

이전 글에서 도커 이미지에 대해 알아봤는데 이어서 Dockerfile을 도커 이미지 파일을 생성하는 방법을 살펴보자.

초 간단 Dockerfile

다음은 매우 간단한 Dockerfile 예이다.

FROM alpine:3.10

ENTRYPOINT ["echo", "hello"]

FROM은 새로운 이미지를 생성할 때 기반으로 사용할 이미지를 지정한다. 위 코드는 alpine:3.10 이미지를 기반 이미지로 사용한다.

ENTRYPOINT는 컨테이너를 시작할 때 실행할 명령어를 입력한다. 위 코드는 'echo hello'를 실행 명령어로 사용한다. ENTRYPOINT는 두 방식으로 입력할 수 있는데 이에 대한 내용은 뒤에서 다시 설명한다.

Dockerfile을 작성했으면 docker build 명령어로 이미지를 생성할 수 있다.

docker build --tag echoalpine:1.0 .

--tag(또는 -t) 옵션은 새로 생성할 이미지 이름을 지정한다. 여기서는 리포지토리 이름으로 echoalpine을 사용하고 태그로 1.0을 사용했다. 마지막에 점(.)은 Dockerfile의 위치를 경로를 지정한다. 파일 이름이 Dockerfile이 아닌 경우 --file(또는 -f) 옵션을 사용해서 파일 이름을 지정한다.

Dockerfile이 위치한 디렉토리에서 위 명령어를 실행하면 다음과 같이 새로운 도커 이미지를 생성한다. docker images 명령어로 새로 생긴 이미지를 확인할 수 있다.

vagrant@ubuntu-bionic:~/sample$ docker build --tag echoalpine:1.0 .
Sending build context to Docker daemon  2.048kB
Step 1/2 : FROM alpine:3.10
 ---> 961769676411
Step 2/2 : ENTRYPOINT ["echo", "hello"]
 ---> Running in 943587678aaf
Removing intermediate container 943587678aaf
 ---> 109b1eea8279
Successfully built 109b1eea8279
Successfully tagged echoalpine:1.0

새로 생성한 이미지를 사용해서 컨테이너를 생성하고 실행해보자. hello가 콘솔에 출력되고 컨테이너가 종료되는 것을 알 수 있다.

$ docker run --rm echoalpine:1.0
hello

컨테이너는 ENTRYPOINT로 지정한 echo hello 명령어를 실행한다. echo는 콘솔에 문자열을 출력하고 종료되므로 컨테이너도 함께 종료된다.

간단한 예제 구성

앞의 Dockerfile은 너무 간단했다. 조금 더 현실적인 Dockerfile을 만들어보자. 이를 위해 사용할 파일은 다음고 같다.

  • hello.jar : 스프링 부트로 만든 간단한 웹 어플리케이션
  • entrypoint.sh : hello.jar를 실행하기 위한 간단한 쉘 파일

entrypoint.sh 파일은 다음과 같다.

#!/bin/sh

ACTIVE_PROFILE="${PROFILE:-dev}"

echo "ACTIVE_PROFILE=${ACTIVE_PROFILE}"

exec java -Djava.security.egd=file:/dev/./urandom \
          -Dspring.profiles.active=${ACTIVE_PROFILE} \
          -jar hello.jar

이 코드는 spring.profiles.active의 값으로 PROFILE 환경 변수를 사용한다. 이 환경 변수가 존재하면 그 값을 사용하고 존재하지 않으면 dev를 사용한다. hello.jar는 웹 어플리케이션으로 선택한 프로필에 따라 제공하는 URL이 달라진다.

  • prod 프로필 : /actuator/env, /actuator/metric, /actuator/info, /actuator/health 제공
  • 그 외 프로필 : /actuator/beans 등 거의 모든 /actuator 엔드포인트 제공

주요 명령: FROM, RUN, ENV, COPY, ENTRYPOINT

hello.jar와 entrypoint.sh 파일로 도커 이미지를 만들기 위해 사용할 Dockerfile은 다음과 같다.

FROM openjdk:8-jdk-alpine

RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime

WORKDIR /app
COPY hello.jar hello.jar
COPY entrypoint.sh run.sh
RUN chmod 774 run.sh

ENV PROFILE=local

ENTRYPOINT ["./run.sh"]

FROM, ENTRYPOINT 외에 몇 가지 명령어를 추가로 사용했다. 각 명령어는 다음과 같다.

  • FROM : 이미지를 생성할 때 사용할 기반 이미지를 지정한다. 예제에서는 openjdk:8-jdk-alpine 이미지를 사용했다. 이 이미지는 알파인 OS에 JDK 8을 설치한 이미지이다.
  • RUN : 이미지를 생성할 때 실행할 코드를 지정한다. 예제에서는 패키지를 설치하고 파일 권한을 변경하기 위해 RUN을 사용했다.
  • WORKDIR : 작업 디렉토리를 지정한다. 해당 디렉토리가 없으면 새로 생성한다. 작업 디렉토리를 지정하면 그 이후 명령어는 해당 디렉토리를 기준으로 동작한다.
  • COPY : 파일이나 폴더를 이미지에 복사한다. 위 코드에서 두 번째 COPY 메서드는 entrypoint.sh 파일을 이미지에 run.sh 이름으로 복사한다. 상대 경로를 사용할 경우 WORKDIR로 지정한 디렉토리를 기준으로 복사한다.
  • ENV : 이미지에서 사용할 환경 변수 값을 지정한다. 위 코드는 PROFILE 환경 변수의 값으로 local을 지정했는데 이 경우 컨테이너를 생성할 때 PROFILE 환경 변수를 따로 지정하지 않으면 local을 기본 값으로 사용한다.
  • ENTRYPOINT : 컨테이너를 구동할 때 실행할 명령어를 지정한다. 위에서는 run.sh을 실행하도록 설정했다.

위 Dockerfile을 이용해서 생성한 이미지는 /app 디렉토리에 hello.jar 파일과 run.sh 파일을 포함하며 컨테이너를 시작할 때 run.sh 파일을 실행한다.

먼저 docker build 명령어를 이용해서 이미지를 만들어보자.

$ docker build --tag hello:1.0 .
Sending build context to Docker daemon  18.39MB
Step 1/8 : FROM openjdk:8-jdk-alpine
 ---> a3562aa0b991
Step 2/8 : RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
 ---> Running in a6973d93f849
fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/main/x86_64/APKINDEX.tar.gz
fetch http://dl-cdn.alpinelinux.org/alpine/v3.9/community/x86_64/APKINDEX.tar.gz
(1/1) Installing tzdata (2019b-r0)
Executing busybox-1.29.3-r10.trigger
OK: 107 MiB in 55 packages
Removing intermediate container a6973d93f849
 ---> d5c2dda85cbc
Step 3/8 : WORKDIR /app
 ---> Running in c1b187ed05b6
Removing intermediate container c1b187ed05b6
 ---> 85f74df29ec8
Step 4/8 : COPY hello.jar hello.jar
 ---> be5100270e6c
Step 5/8 : COPY entrypoint.sh run.sh
 ---> cca2ad0f28fb
Step 6/8 : RUN chmod 774 run.sh
 ---> Running in dd9b59be1c12
Removing intermediate container dd9b59be1c12
 ---> 854ba56e2a00
Step 7/8 : ENV PROFILE=local
 ---> Running in eae1bb4e9b5c
Removing intermediate container eae1bb4e9b5c
 ---> f4ba7e11a824
Step 8/8 : ENTRYPOINT ["./run.sh"]
 ---> Running in 449c98a2bdf9
Removing intermediate container 449c98a2bdf9
 ---> 023caa2dd38d
Successfully built 023caa2dd38d
Successfully tagged hello:1.0

이미지를 성공적으로 만들었으면 docker run 명령어로 이미지를 이용해서 컨테이너를 시작하자.

$ docker run --rm -p 9090:9090 hello:1.0
ACTIVE_PROFILE=local

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.9.RELEASE)

...생략

docker run 명령어를 실행하면 ACTIVE_PROFILE=local 문자열이 출력되는데 이 문자열은 run.sh이 출력한 것이다. run.sh 파일은 PROFILE 환경 변수의 값을 ACTIVE_PROFILE에 할당하므로 출력 결과를 통해 PROFILE 환경 변수의 값으로 local을 사용한 것을 알 수 있다(run.sh 파일은 PROFILE 환경 변수가 없으면 dev를 기본 값으로 사용하도록 구현했다). 이 값은 Dockerfile에서 ENV 명령어를 사용해서 PROFILE 환경 변수에 지정한 값을 사용한 것이다.

PROFILE 환경 변수의 값을 바꾸고 싶다면 컨테이너를 시작할 때 -e 옵션을 사용해서 다른 값을 지정하면 된다.

$ docker run --rm -p 9090:9090 -e PROFILE=prod hello:1.0
ACTIVE_PROFILE=prod

ENTRYPOINT의 두 형식

ENTRYPOINT는 exec 형식과 shell 형식의 두 가지 형식으로 지정할 수 있다. 이 중 exec 형식을 추천한다. 예에서 사용한 앞서 예에서도 ["echo", "hello"]나 ["./run.sh"]는 모두 exec 형식이다.

exec는 다음 형식을 갖는다.

  • ENTRYPOINT ["실행명령어", "인자1", "인자2", ... ]

배열에서 첫 번째는 실행할 명령어이다. 두 번째부터는 명령어의 인자로 전달된다.

shell 형식은 실행 명령어를 문자열로 입력한다.

ENTRYPOINT echo hello

exec 형식을 사용할 때와의 차이점은 shell 형식을 사용하면 /bin/sh -c로 명령을 실행한다는 것이다. 즉 위와 같은 ENTRYPOINT를 사용하면 실제 실행하는 명령어는 다음과 같다.

/bin/sh -c 'echo hello'

여러 개 ENTRYPOINT를 지정해도 마지막 한 개만 적용된다.

COPY와 ADD 명령어

COPY는 파일을 이미지에 복사한다. 형식은 다음과 같다.

  • COPY src1 src2 ... dest
  • COPY ["src1", "src2", ..., "dest"]

dest가 상대 경로면 WORKDIR 명령어로 지정한 경로에 복사한다. 예를 들어 다음은 entry.sh 파일을 이미지의 /app/run.sh 파일로 복사한다.

WORKDIR /app
COPY entry.sh run.sh

다음은 dest로 절대 경로를 사용했다. 이 경우 entry1.sh 파일은 /usr/bin/ 디렉토리에 복사하고 entry2.sh 파일은 /usr/bin/ 디렉토리에 run.sh로 복사한다.

WORKDIR /app
COPY entry1.sh /usr/bin/
COPY entry2.sh /usr/bin/run.sh

src는 "*"이나 "?"와 같은 와일드카드를 포함한 GO 언어의 매치 패턴을 이용할 수 있다.

ADD 명령어는 COPY 명령어와 비슷하게 이미지에 복사한다. COPY 명령어와의 차이라면 복사한 파일의 압축을 푼다는 것이다. 아래 명령어는 pinpoint-agent-1.8.4.tar.gz 파일을 /pinpoint 디렉토리에 풀어서 복사한다.

ADD pinpoint-agent-1.8.4.tar.gz /pinpoint

빌드 컨텍스트 주의 사항

도커 이미지를 빌드할 때 출력되는 메시지를 보자.

$ docker build --tag hello:1.0 .
Sending build context to Docker daemon  18.39MB
...생략

빌드 컨텍스트를 전송한다는 메시지가 나온다. 빌드 컨텍스트는 빌드를 실행할 때 사용할 파일 집합이다. docker build 명령어는 도커 데몬에 빌드 컨텍스트를 전송한다. 빌드 컨텍스트는 빌드 명령을 실행하는 디렉토리와 그 하위 디렉토리에 포함된 전체 파일이다.

빌드 컨텍스트에는 COPY나 ADD에서 사용하지 않는 파일도 포함되므로 빌드 컨텍스트에는 필요한 파일만 포함해야 한다. 부득이하게 빌드 과정에서 사용하지 않는 파일이 존재할 경우 .dockerignore 파일을 만들어 컨텍스트에서 제외할 대상을 지정하면 된다.

도커 허브를 이용한 이미지 공유

이미지를 생성하면 도커 리포지토리를 이용해서 공유할 수 있다. 다양한 도커 리포지토리가 존재하는데 이 중 대표적인 것이 도커 허브이다. 지금까지 예에서 사용한 nginx나 alpine과 같은 이미지가 위치한 곳이기도 하다. https://hub.docker.com 사이트에 가입하면 도커 허브에 이미지를 푸시해서 공유할 수 있다.

푸시 방법은 어렵지 않다. 먼저 생성한 이미지에 추가로 태그를 붙인다. 추가 태그에는 리포지토리 계정 이름을 사용한다. 예를 들어 도커 허브 계정이 madvirus라면 madvirus/리포지토리이름:태그 형태를 사용한다. 다음은 simplenode:0.1 이미지에 madvirus/simplenode:0.1 태그를 추가하는 예인데 이때 madvirus라 도커 허브 계정이다.

$ docker tag simplenode:0.1 madvirus/simplenode:0.1

 

docker login 명령어를 사용해서 도커 허브에 로그인한다.

$ docker login
Login with your Docker ID to push and pull images from Docker Hub. ...생략
Username: madvirus
Password:
...생략
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded

로그인에 성공했다면 docker push로 이미지를 푸시한다.

$ docker push madvirus/simplenode:0.1
The push refers to repository [docker.io/madvirus/simplenode]
8eac20925476: Pushed
a3f62211f277: Pushed
e7ae04d3f37c: Mounted from library/node
e29ab5067804: Mounted from library/node
ae4ceb8dc557: Mounted from library/node
f1b5933fe4b5: Mounted from library/node
0.1: digest: sha256:d0299960fb25aae581200d143291e3fd48a18dfbdd53333df8557755b00bef14 size: 1572

푸시가 끝나면 도커 허브에서 이미지를 가져와 사용할 수 있다.

 docker run -d --rm -p 5000:5000 madvirus/simplenode:0.1

관련 글

도커를 사용하면 mysql이나 nginx처럼 이미 제공하는 이미지를 사용해서 소프트웨어를 쉽게 실행할 수 있지만 단지 이것만이 도커를 사용하는 아니다. 도커를 사용하는 또 다른 이유는 직접 개발한 소프트웨어를 도커 이미지로 만들어 배포하고 실행하기 위함이다. 즉 도커를 잘 활용하려면 이미지에 대한 이해가 필요하다.

이미지 이름

도커 이미지를 이용해서 컨테이너를 생성할 때 이미지 이름을 사용한다.

docker run -it --rm alpine:3.10 sh

위 코드는 alpine:3.10을 이미지 이름으로 사용했다. alpine은 리포지토리 이름이고 3.10은 태그이다. docker images 명령어를 실행하면 로컬에 존재하는 이미지 목록을 표시한다. 이미지 목록을 보면 REPOSITORY 칼럼과 TAG 칼럼을 통해서 이미지의 리포지토리와 태그 값을 확인할 수 있다.

vagrant@ubuntu-bionic:~$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
adminer             latest              09a06e7c3196        2 days ago          87.6MB
nginx               1.17.4              f949e7d76d63        4 days ago          126MB
nginx               latest              f949e7d76d63        4 days ago          126MB
mysql               5.7                 383867b75fd2        2 weeks ago         373MB
mysql               latest              b8fd9553f1f0        2 weeks ago         445MB
centos              7                   67fa590cfc1c        5 weeks ago         202MB
alpine              3.10                961769676411        5 weeks ago         5.58MB
openjdk             8u212-jdk-alpine    a3562aa0b991        4 months ago        105MB
hello-world         latest              fce289e99eb9        9 months ago        1.84kB

위 결과에서 mysql 리포지토리는 태그가 5.7인 이미지와 태그가 latest인 이미지가 존재한다. 여기에 표시된 리포지토리 명은 완전한 리포지토리 명을 짧게 표시한 것이다. 실제 리포토리명은 다음의 세 가지 요소를 가진다.

  • 리포지토리호스트/조직(계정)이름/짧은이름

alpine이나 nginx와 같이 도커가 제공하는 공식 이미지는 짧은 이름으로 이미지를 사용할 수 있다. 실제 공식 이미지의 리포지토리 이름은 다음 형식을 갖는다.

  • docker.io/library/nginx

도커 허브에 개인 계정을 만들어 리포지토리를 만들 수도 있다. 예를 들어 필자는 도커 허브에 madvirus라는 이름으로 가입했는데 이 경우 리포지토리 이름은 madvirus/openjdk-pinpoint와 같은 형태를 갖는다.

도커 허브가 아닌 사설 도커 리포지토리를 사용할 경우 호스트명을 포함한 완전한 리포지토리 이름을 사용해야 한다.

보통 태그 값으로는 버전을 사용한다. 한 이미지에 여러 태그를 붙일 수 있는데 보통 마지막 버전에 해당하는 이미지에는 latest 태그를 함께 붙인다. 이 글을 쓰는 시점에서 nginx:latest 이미지는 nginx:1.17.4 이미지와 같은 이미지이다. docker images 결과를 보면 nginx의 latest 태그와 1.17.4 태그의 이미지ID가 같은 것을 알 수 있다.

참고로 컨테이너를 생성할 때 태그를 지정하지 않으면 latest를 기본 값으로 사용한다.

이미지 레이어

기존 이미지를 이용해서 새로운 이미지를 만들어보자.

vagrant@ubuntu-bionic:~$ docker run --name alpine_custom alpine:3.10 touch /mycustom.txt

vagrant@ubuntu-bionic:~$ docker commit alpine_custom myimage
sha256:422d23665db418c26a463a3aeb4d92b43a9c51c056aae34dea26a3a4067c1f9a

위 코드는 alpine:3.10 이미지를 이용해서 alpine_custom 컨테이너를 생성한다. 컨테이너를 실행할 때 touch 명령어를 사용해서 루트에 mycustom.txt 파일을 생성한다.

docker commit 명령어는 컨테이너를 이용해서 새로운 이미지를 생성한다. 위 코드는 alpine_custom 컨테이너를 이용해서 myimage라는 이미지를 생성한다. 태그를 지정하지 않았으므로 latest를 태그로 사용한다.

이제 생성한 alpine_custom 컨테이너를 삭제하고 새로 생성한 myimage 이미지를 이용해서 새로운 컨테이너를 사용해보자.

vagrant@ubuntu-bionic:~$ docker rm -v alpine_custom
alpine_custom
vagrant@ubuntu-bionic:~$ docker run --rm myimage ls -la /
total 64
drwxr-xr-x    1 root     root          4096 Sep 29 11:28 .
drwxr-xr-x    1 root     root          4096 Sep 29 11:28 ..
-rwxr-xr-x    1 root     root             0 Sep 29 11:28 .dockerenv
...생략
-rw-r--r--    1 root     root             0 Sep 29 11:15 mycustom.txt
...생략
drwxr-xr-x   11 root     root          4096 Aug 20 10:30 var

myimage 이미지로 생성한 컨테이너에서 ls 명령어를 실행하면 mycustom.txt 파일이 표시된 것을 알 수 있다.

myimage를 조금 더 살펴보자. docker image history 명령어를 실행하면 이미지의 내역을 볼 수 있다. 다음은 실행 결과이다.

vagrant@ubuntu-bionic:~$ docker image history myimage:latest
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
422d23665db4        7 minutes ago       touch /mycustom.txt                             0B
961769676411        5 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           5 weeks ago         /bin/sh -c #(nop) ADD file:fe64057fbb83dccb9…   5.58MB

위 결과에서 IMAGE 칼럼에 표시된 값과 docker images 명령어의 IMAGE ID 칼럼 값을 비교해보자. 결과를 보면 docker image history에서 출력한 9617961769676411가 alpine:3.10 IMAGE ID와 같다.

vagrant@ubuntu-bionic:~$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
myimage             latest              422d23665db4        12 minutes ago      5.58MB
...생략
alpine              3.10                961769676411        5 weeks ago         5.58MB
...생략

 

이번에는 alpine:3.10 이미지의 내역을 보자. myimage 이미지의 내역에서 9617961769676411 부분부터 <missing>의 내용이 완전 동일하다.

vagrant@ubuntu-bionic:~$ docker image history alpine:3.10
IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
961769676411        5 weeks ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
<missing>           5 weeks ago         /bin/sh -c #(nop) ADD file:fe64057fbb83dccb9…   5.58MB

도커 이미지 내역은 도커 이미지를 생성할 때 사용한 변경 내역을 보여준다. 도커는 이미지를 생성할 때 전체 파일을 새로 만들지 않고 변경한 파일만 이용해서 새로운 레이어를 생성한다. 예를 들어 myimage:latest 이미지는 alpine:3.10 이미지에서 변경한 내용만 새로운 레이어로 생성한다. 비슷하게 alpine:3.10 이미지는 <missing>으로 표시된 이미지에서 변경한 내용만 레이어로 생성한다. 즉 myimage:latest 이미지는 alpine 이미지로 생성한 컨테이너에서 변경한 파일인 mycustom.txt 파일만 포함한다.

이런 특징을 잘 활용하면 다운로드 받을 이미지의 크기를 줄일 수 있다. 예를 들어 10개의 자바 어플리케이션을 이미지로 만들 때 openjdk:8u212-jdk-alpine 이미지를 하위 레이어로 사용하면 openjdk:8u212-jdk-alpine 이미지와 관련된 파일은 한 번만 다운로드 하고 10개 자바 어플리케이션의 변경 부분만 다운로드하므로 어플리케이션을 구동하기 위해 다운로드해야 하는 이미지의 크기가 줄어든다.

docker commit으로 생성한 이미지 파일은 도커 허브나 별도로 구성한 도커 레지스트리를 이용해서 다른 사람과 공유할 수 있다. 그런데 docker commit을 이용해서 도커 이미지를 만드는 과정은 수작업으로 이루어지므로 불편하고 실수하기 좋다. Dockerfile을 사용하면 이미지 생성 과정을 쉽게 자동화할 수 있는데 다음 글에서 Dockerfile을 이용한 이미지 생성 방법을 살펴보자.

관련 글

컨테이너를 구동할 때 -p 옵션을 이용해서 컨테이너의 포트와 연결된 호스트 포트를 설정했다.

docker run --name mysqldb \
-e MYSQL_ROOT_PASSWORD=rootpw \
-p 33060:3306 -d mysql:5.7

실행한 MySQL에 접속하려면 호스트IP:33060으로 연결하면 된다.

도커 컨테이너 간에 연결할 경우에는 어떻게 할까? 각 컨테이너마다 환경 변수를 이용해서 연결할 호스트의 IP와 포트를 설정할 수 있을 것이다. 이 방법이 안 되는 것은 아니지만 이보다 좋은 방법은 도커 네트워크를 이용하는 것이다.

도커 네트워크

docker network ls 명령어를 사용하면 도커가 제공하는 네트워크 목록을 확인할 수 있다.

vagrant@ubuntu-bionic:~$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
e83fd9260276        bridge              bridge              local
f6a428e9d599        host                host                local
2e3d5809c8ac        none                null                local

도커는 기본으로 세 개의 네트워크를 제공한다. 각 네트워크는 다음과 같다.

  • bridge 네트워크 : bridge 드라이버로 제공하는 네트워크로 컨테이너간 연결을 제공하며 컨테이너를 구동하는 호스트 네트워크가 구분된다.
  • host 네트워크 : host 드라이버로 제공하는 네트워크로 컨테이너를 위한 별도 네트워크없이 호스트와 동일한 네트워크를 사용한다.
  • none 네트워크 : null 드라이버를 사용하며 네트워크 연결을 갖지 않는다.

네트워크 SCOPE는 네트워크의 범위를 의미하며 다음의 세 가지 범위가 존재한다.

  • local : 컨테이너간 연결은 네트워크가 존재하는 호스트로 제한된다.
  • global : 클러스터의 모든 노드에 네트워크가 존재하지만 호스트 간 라우팅은 지원하지 않는다.
  • swarm : 도커 스웜에 참여하는 모든 호스트로 연결을 확장한다.

이 글에서는 단일 호스트에서 네트워크를 연결하는 방법을 살펴본다. 여러 호스트에서 실행 중인 컨테이너 간의 연결은 도커 스웜을 설명할 때 살펴볼 것이다.

컨테이너 간 연결 : 기본 bridge 네트워크 이용

컨테이너 간에 연결하는 가장 쉬운 방법은 기본으로 제공하는 bridge 네트워크를 이용하는 것이다. 먼저 다음 명령을 이용해서 mysqldb 컨테이너를 생성하자. -p 옵션을 사용하지 않았으므로 호스트에서 컨테이너의 MySQL DB에 연결할 수 없다.

$ docker run --name mysqldb --rm \
-e MYSQL_ROOT_PASSWORD=rootpw \
-d mysql:5.7

이제 웹 기반 DB 관리도구 중 하나인 adminer를 다음 명령어를 이용해서 구동하자. Ctrl+C를 누르면 컨테이너가 종료되니 주의한다.

$ docker run --rm --name dbadmin --link mysqldb:db -p 8080:8080 adminer
PHP 7.3.10 Development Server started at Sun Sep 29 08:50:22 2019

여기서 --link 옵션이 중요하다. --link 옵션의 값으로 "mysqldb:db"를 주었는데 여기서 앞의 mysqldb는 컨테이너의 이름이며 뒤의 db는 컨테이너 내부에서 사용할 식별자이다. 즉 dbadmin 컨테이너 내부에서 db라는 이름으로 mysqldb 컨테이너에 접근할 수 있다는 것을 의미한다.

실제로 그런지 http://호스트IP:8080으로 연결해서 확인해보자. adminer 첫 화면에 연결하면 DB 로그인 폼이 표시되는데 여기서 서버에 "db:3306"이라고 입력한다. 사용자이름과 비밀번호는 각각 "root", "rootpw"를 입력한다(앞서 mysqldb 컨테이너를 구동할 때 MYSQL_ROOT_PASSWORD 환경 변수의 값으로 rootpw를 주었다).

로그인 버튼을 눌러보자. 다음과 같이 DB에 연결한 결과를 볼 수 있다.

dbadmin 컨테이너에서 db:3306으로 연결한 DB가 실제 mysqldb 컨테이너가 제공하는 DB인지 확인해보자. 먼저 mysqldb 컨테이너의 mysql db에 연결해서 이름이 test인 DB를 생성한다.

vagrant@ubuntu-bionic:~$ docker exec -it mysqldb mysql -u root -p
Enter password: (암호입력)
Welcome to the MySQL monitor.  Commands end with ; or \g.
...생략

mysql> create database test;
Query OK, 1 row affected (0.00 sec)

mysql> exit
Bye
vagrant@ubuntu-bionic:~$

그런 뒤 adminer 웹 화면에서 데이터베이스를 새로 고침해보자. 그러면 생성한 test DB가 목록에 표시될 것이다.

 

컨테이너 간 연결 : bridge 네트워크를 생성해서 연결

bridge 네트워크를 직접 생성할 수도 있다. 

$ docker network create \
--driver bridge \
--attachable \
--scope local \
--subnet 10.0.7.0/24 \
--ip-range 10.0.7.0/24 \
mynet

mynet 네트워크는 bridge 네트워크로 --attachable 옵션은 컨테이너가 언제든지 네트워크에 연결하거나 떨어질 수 있게 설정한다. --subnet과 --ip-range는 서브넷과 할당 가능한 IP 범위를 지정한다.

네트워크를 생성했다면 컨테이너를 네트워크에 붙일 수 있다. 컨테이너를 실행할 때 --network 옵션을 사용하면 된다.

docker run --name mysqldb \
--network mynet \
-e MYSQL_ROOT_PASSWORD=rootpw \
-d mysql:5.7

같은 네트워크에 참여하는 컨테이너는 이름을 사용해서 다른 컨테이너에 연결할 수 있다. 아래 코드를 보자.

vagrant@ubuntu-bionic:~$ docker run --name cent7 -it --network mynet centos:7 bash
[root@fc0936cc3177 /]# ping mysqldb
PING mysqldb (10.0.7.2) 56(84) bytes of data.
64 bytes from mysqldb.mynet (10.0.7.2): icmp_seq=1 ttl=64 time=0.183 ms
64 bytes from mysqldb.mynet (10.0.7.2): icmp_seq=2 ttl=64 time=0.136 ms
^C
--- mysqldb ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1001ms
rtt min/avg/max/mdev = 0.136/0.159/0.183/0.026 ms

이 코드는 mynet 네트워크에 참여하는 cent7 컨테이너를 생성하고 bash 명령어를 실행한다. mynet 네트워크에 mysqldb 컨테이너도 참여했으므로 cent7 컨테이너는 컨테이너 이름을 사용해서 mysqldb에 연결할 수 있다. ping 명령어를 실행할 때 표시된 IP 주소는 10.0.7.2인데 이 IP 주소는 mynet 네트워크를 생성할 때 지정한 IP 범위에 속한다.

이미 생성한 컨테이너에 네트워크를 연결하고 싶거나 연결된 네트워크를 끊고 싶을 때에는 docker network connect 명령어나 docker network disconnet 명령어를 사용한다.

관련 글

도커 시작하기 3에서 컨테이너의 변경 내역을 유지하기 위해 --mount 옵션을 사용해서 호스트 파일 시스템에 컨테이너의 경로를 마운트하는 방법을 살펴봤는데 이 장에서는 스토리지에 대한 추가적인 내용을 살펴보겠다.

컨테이너와 로컬 파일 시스템

컨테이너의 파일은 호스트의 파일 시스템에 마운트되어 있다. 컨테이너는 이미지로부터 만들어지므로 크게 다음의 호스트 파일 경로에 마운트된다.

  • 이미지의 파일 내용을 저장하는 호스트 파일 경로(이미지는 실제로 레이어로 구성되므로 여러 경로 사용)
  • 컨테이너 구동 이후 변경 사항을 저장하기 위한 호스트 파일 경로

이 외에 --mount 옵션을 이용해서 호스트 파일 시스템이나 도커 볼륨에 마운트 할 수 있다.

docker inspect 명령어를 사용하면 호스트 파일 시스템에 마운트된 경로를 확인할 수 있다.

"Data": {
    "LowerDir": "/var/lib/docker/overlay2/8bec........생략",
    "MergedDir": "/var/lib/docker/overlay2/8bec.../merged",
    "UpperDir": "/var/lib/docker/overlay2/8bec.../diff",
    "WorkDir": "/var/lib/docker/overlay2/8bec.../work"
},

docker inspect 명령어의 출력 결과에서 Data 속성은 마운트된 위치를 표시한다. 여기서 UppderDir 속성은 컨테이너의 변경 내역을 저장한다. 콘테이너에 새로운 파일을 생성하고 UpperDir 속성의 디렉토리로 이동하면 컨테이너에 변경한 내역이 존재하는 것을 확인할 수 있다.

읽기 전용 컨테이너

--read-only 옵션을 사용하면 컨테이너를 읽기 전용으로 생성할 수 있다. 

vagrant@ubuntu-bionic:~$ docker run -d --name web --read-only -p 8080:80 nginx:latest
6077fb45aea3b5b71f4161211949b4e6ed4c9eb38c89d49b07daf1887608570d

읽기 전용으로 컨테이너를 구동하면 컨테이너의 파일을 새로 생성하거나 기존 파일을 변경할 수 없다. nginx의 경우 서브를 구동할 때 파일을 새로 생성해야 하는데 파일을 생성할 수 없으므로 위 명령어로 생성한 컨테이너는 정상적으로 시작하지 못하고 종료된다. docker ps 명령어로 web 컨테이너의 상태를 보면 다음과 같이 STATUS가 Exited로 표시된다.

vagrant@ubuntu-bionic:~$ docker ps -a
CONTAINER ID        IMAGE        ...  STATUS                     ... NAMES
6077fb45aea3        nginx:latest ...  Exited (1) 3 minutes ago   ... web

docker logs 명령어로 컨테이너의 로그를 보면 다음고 같이 파일 생성에 실패한 것을 확인할 수 있다.

vagrant@ubuntu-bionic:~$ docker logs web
2019/09/28 11:19:30 [emerg] 1#1: mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
vagrant@ubuntu-bionic:~$

호스트 파일 시스템으로 마운트하기

--mount 옵션을 사용하면 컨테이너의 파일 시스템을 로컬 파일 시스템으로 마운트할 수 있다. 다음은 사용 예이다. 호스트 파일 경로에 마운트할 때에는 type 속성을 bind로 설정하고 source에는 호스트의 절대 경로를 지정하고, target에는 호스트 파일 경로에 마운트할 컨테이너의 경로를 지정한다.

$ docker run -d --name web --rm \
   --mount type=bind,source=/home/vagrant/nginx/html,target=/usr/share/nginx/html \
   --mount type=bind,source=/home/vagrant/nginx/run,target=/run \
   --mount type=bind,source=/home/vagrant/nginx/cache,target=/var/cache/nginx \
   -p 8080:80 \
   nginx:latest

위 명령을 실행한 뒤 /home/vagrant/nginx/run 디렉토리를 보면 nginx가 서버를 구동할 때 생성하는 nxinx.pid 파일이 생성된 것을 확인할 수 있다. 비슷하게 /home/vagrant/nginx/cache 디렉토리에는 nginx가 cache 목적으로 생성한 디렉토리가 생성된 것도 확인할 수 있다.

호스트 파일 경로에 마운트할 때 읽기 전용으로 마운트할 수도 있다. --mount 옵션을 지정할 때 readonly 옵션을 추가하면 된다.

docker run -d --name web --rm \
   --mount type=bind,source=/home/vagrant/nginx/html,target=/usr/share/nginx/html,readonly \
   --mount type=bind,source=/home/vagrant/nginx/run,target=/run \
   --mount type=bind,source=/home/vagrant/nginx/cache,target=/var/cache/nginx \
   -p 8080:80 \
   nginx:latest

읽기전용으로 지정한 마운트 경로는 컨테이너 내부에서는 수정이 안 된다.

vagrant@ubuntu-bionic:~/nginx$ docker exec web touch /usr/share/nginx/html/a.txt
touch: cannot touch '/usr/share/nginx/html/a.txt': Read-only file system

--mount 옵션에서 source 속성 대신에 src 속성을, target 속성 대신에 dst 속성을 사용해도 된다.

메모리 파일 시스템에 마운트하기

메모리 파일 시스템에 마운트할 수도 있다. --mount 옵션에서 type 값을 tmpfs로 지정하면 된다.

docker run -d --name web --rm \
  --mount type=bind,source=/home/vagrant/nginx/html,target=/usr/share/nginx/html,readonly \
  --mount type=bind,source=/home/vagrant/nginx/run,target=/run \
  --mount type=tmpfs,target=/var/cache/nginx \
  -p 8080:80 \
  nginx:latest

메모리에 파일 내용을 유지하므로 컨테이너를 종료하면 저장한 파일도 함께 삭제된다.

tmpfs는 메모리를 사용하므로 호스트 파일 시스템의 경로를 지정할 필요가 없다.

도커 볼륨

도커 볼륨은 도커가 관리하는 파일 시스템이다. 컨테이너의 파일 시스템도 도커 볼륨을 이용해 관리한다.

필요하면 도커 볼륨을 직접 생성하고 컨테이너에 마운트할 수 있다. 아래 명령어는 이름이 myvol인 볼륨을 생성한다.

vagrant@ubuntu-bionic:~$ docker volume create --driver local myvol
myvol

--driver 옵션은 볼륨을 생성할 때 사용할 스토리지 드라이버를 지정한다. 위 명령어는 local 드라이버를 사용해서 볼륨을 생성했다. local 드라이버는 호스트의 파일 시스템에 생성한 볼륨이 위치한다.

볼륨을 생성하면 --mount 옵션을 사용해서 컨테이너 경로를 볼륨에 마운트할 수 있다.

docker run -d --name web --rm \
  --mount type=volume,src=myvol,dst=/usr/share/nginx/html \
  -p 8080:80 \
  nginx:latest

--mount 옵션에서 type 속성은 volume을 지정하고 src에는 볼륨 이름을 지정한다.

도커 볼륨은 여러 컨테이너에서 공유할 수 있다. 다음과 같이 새로운 컨테이너에서 같은 볼륨을 사용하는 컨테이너를 만들고 해당 위치에 파일을 생성하자.

vagrant@ubuntu-bionic:~/nginx/html$ docker run -it --name gen --rm \
> --mount type=volume,src=myvol,dst=/website \
> centos:7 bash
[root@f27afdbfac6d /]# echo "hello world" > /website/hello.txt

위 코드는 myvol 볼륨을 /website 경로에 마운트한 컨테이너를 생성하고 이 컨테이너 안에서 /website/hello.txt 파일을 생성한다. 앞서 web 컨테이너는 myvol 볼륨을 /usr/share/nginx/html 경로에 마운트했는데 두 경로는 myvol 볼륨을 사용하므로 web 컨테이너의 /usr/share/nginx/html 경로에 hello.txt 파일이 생성된다. (http://호스트:8080/hello.txt 주소로 연결하면 gen 컨테이너에서 생성한 파일 내용이 표시된다.)

docker volume rm 명령어는 볼륨을 삭제한다. 단 볼륨을 사용중인 컨테이너가 존재하면 볼륨을 삭제할 수 없다.

vagrant@ubuntu-bionic:~/nginx/html$ docker volume rm myvol
Error response from daemon: remove myvol: volume is in use - [dd963...]

 

사용중인 컨테이너가 없는 볼륨을 모두 삭제하고 싶다면 docker volume prune 명령어를 사용하면 된다. 이 명령어를 사용하면 일일이 볼륨 이름을 지정할 필요가 없어 불필요한 볼륨을 삭제할 때 편리하다.

도커가 제공하는 local 스토리지 드라이버는 로컬 파일 시스템에 대한 볼륨을 지원한다. 여러 호스트에서 공유할 수 있는 볼륨을 생성하려면 별도 플러그인을 사용해야 한다. local 스토리지 드라이버는 NFS를 지원하므로 각 호스트에서 NFS로 연결한 볼륨을 같은 이름으로 생성해도 동일한 결과를 얻을 수 있다.

REX-Ray 같은 플러그인을 사용하면 클라우드 환경에서 볼륨을 생성하고 공유할 수 있다.

관련 글

호스트 포트 연결

컨테이너와 호스트 포트를 연결하는 방법은 이미 앞서 nginx 이미지로 컨테이너를 생성할 때 사용했다. -p 옵션을 사용해서 포트를 지정한다(또는 --publish 옵션을 사용).

docker run -d -p 8080:80 --name web nginx:latest

연결 포트는 "호스트포트:컨테이너포트" 형식으로 지정한다.

환경 변수 설정

-e 옵션(--env 옵션)을 사용하면 컨테이너를 실행할 때 환경 변수를 전달할 수 있다. 예를 들어 mysql 이미지의 실행 프로그램은 MYSQL_ROOT_PASSWORD 환경 변수를 이용해서 DB의 root 암호를 설정한다. 따라서 root 암호를 원하는 문자열로 지정하고 싶다면 다음과 같이 컨테이너를 구동할 때 -e 옵션을 사용해서 환경 변수를 전달하면 된다.

docker run --name mysqldb \
           -e MYSQL_ROOT_PASSWORD=rootpw \
           -p 33060:3306 -d mysql:5.7

도커 허브(hub.docker.com)에서 이미지가 어떤 환경 변수를 사용하는지 확인할 수 있다.

로컬 스토리지 연결

아래와 같이 nginx 이미지를 이용해서 생성한 컨테이너에 bash로 연결해서 /usr/share/nginx/html 디렉토리에 echo.txt 파일을 생성해보자.

vagrant@ubuntu-bionic:~$ docker run -d -p 8080:80 --name web nginx:latest

vagrant@ubuntu-bionic:~$ docker exec -it web bash

root@fe306ef365a7:~# cd /usr/share/nginx/html/

root@fe306ef365a7:/usr/share/nginx/html# echo "echo file" > echo.txt

root@fe306ef365a7:/usr/share/nginx/html# exit

웹 브라우저에서 http://호스트:8080/echo.txt를 실행하면 방금 생성한 파일이 출력될 것이다. 컨테이너를 중지하고 다시 시작해도 컨테이너에 생성한 파일은 유지되는 것을 확인할 수 있다.

컨테이너에 파일을 생성하고 수정하고 삭제하는 것이 가능은 하지만 컨테이너의 파일 시스템을 직접 변경하는 것은 추천하지는 않는다. 컨테이너를 삭제하면 변경 내역도 함께 사라지기 때문이다. 컨테이너의 삭제 여부에 상관없이 파일을 유지해야 한다면 로컬 스토리지나 볼륨을 연결해야 한다.

로컬 스토리지와 컨테이너를 연결할 때는 --mount 옵션을 사용한다. 테스트를 위해 앞서 생성한 web 컨테이너를 삭제하고 홈 디렉토리에 html 디렉토리를 생성하고 이 폴더에 index.html 파일과 echo.txt 파일을 생성하자. 그리고 다음 명령어를 사용해서 컨테이너를 생성한다.

vagrant@ubuntu-bionic:~/html$ echo '<html><body>index</body></html>' > index.html

vagrant@ubuntu-bionic:~/html$ echo 'echo file in local' > echo.txt

vagrant@ubuntu-bionic:~/html$ docker run -d --name web --rm \
>   --mount type=bind,src=/home/vagrant/html,dst=/usr/share/nginx/html \
>   -p 8080:80 \
>   nginx:latest
d1530bacb7176c9fe36d0f1097661deaf6f471edd3ddd3d849c51eeeb43b16c0

vagrant@ubuntu-bionic:~/html$

웹 브라우저를 열고 http://호스트:8080/index.html 이나 http://호스트:8080/echo.txt에 연결해보자. 로컬에 생성한 파일이 브라우저에 표시되는 것을 알 수 있다. ~/html 디렉토리에 새로운 파일을 추가하거나 삭제한 뒤에 브라우저에 확인해보자. 바로 반영될 것이다.

--mount 옵션에서 type을 bind로 지정하면 컨테이너의 파일 시스템을 호스트의 파일 시스템으로 대체한다. src는 호스트 경로를 값으로 갖고 dst는 대체할 컨테이너 경로를 값으로 갖는다. 위 설정은 생성한 컨테이너의 /usr/share/nginx/html 경로를 로컬 호스트의 /home/vagrant/html로 연결한다고 설정한다.

컨테이너의 경로를 호스트로 연결하면 컨테이너를 삭제해도 파일 변경 내역이 유지되므로 다음 작업에 이점이 생긴다.

  • 이미지를 변경해서 컨테이너를 새로 만들어도 데이터가 유지된다.
  • 이미지에 이미 존재하는 파일을 다른 파일로 쉽게 교체할 수 있다.

로컬 스토리지 연결뿐만 아니라 메모리 파일 시스템 연결과 볼륨이 있는데 이에 대한 내용은 다음 편에 이어서 살펴본다.

관련 글

+ Recent posts