<Docker> 도커를 잘 모를 때 알았으면 좋았을 것들

본 포스팅은 도커를 시작하는 시점에 이걸 알았다면 삽질을 덜 했을 텐데라는 생각으로 작성합니다. 주관적인 부분들이라 누군가에겐 체감 상 쉽게 느껴질 수도 있습니다.

구성되는 목차는 아래와 같습니다. 이론적인 부분에서는 도커의 동작원리를 그리고 나머지 부분은 업무에 도움이 될 만한 내용으로 구성하였습니다.

도커 동작 원리

컨테이너 이미지를 pull 하고 나서 이를 통해 컨테이너를 구동 시키면 해당 컨테이너를 사용할 수 있다. 배포 환경이나 실험 환경을 구축할 때마다 편하다고 느끼지만 실제 내부적으로 어떻게 컨테이너가 동작하는지에 대해서 외면하고 사용했었다. 이 부분에 대해서 핵심적인 내용만 설명해본다.

우선 CLI 환경에 docker run 등의 명령어를 입력하기만 하면 원하는 동작이 수행된다. 그 이면에는 어떤 일들이 일어날까? 이를 이미지화 시켜보면 대략 아래와 같다.

사용자가 CLI를 통해 Docker Client에 명령어를 전달하면 위 흐름대로 요청이 전달되어 컨테이너가 생성된다. 지금부터 하나씩 살펴보자.

Docker Client의 대표적인 예시는 Docker CLI가 있다. 어렵게 생각할 것 없이 흔히 도커를 사용할 때 docker ...로 시작할 때 사용되는 “docker”라는 명령어이다. 해당 명령어는 도커 데몬으로 전달되어야 하는데 기본값으로 unix socket을 사용한다. docker를 사용하다보면 한번씩 마주치는 /var/run/docker.sock을 통해 설치된 도커 데몬과 통신한다. 해당 방식은 unix socket(IPC socket이라고 불리고 프로세스 간의 소켓 연결 기능 제공)을 활용한다. 다른 활용 방식을 통해 외부 네트워크를 통해 명령어를 전달 할 수도 있다. -H 옵션을 사용할 경우 사용자가 원하는 특정 도커 데몬에 명령어를 전달한다. 이 경우엔 소켓을 unix, tcp, fd 타입으로 설정이 가능하다. 공식문서를 확인해보면 더욱 다양한 정보가 있다.

Docker Daemon은 소켓을 통해 넘어온 명령어를 수신하고 컨테이너를 생성하는 명령어를 containerd에 요청하는 역할을 수행한다. 이 외 다양한 기능을 수행하는데 그 중 하나를 살펴보자. 컨테이너들의 restart-policies를 설정해줄 수 있다. 쉽게 말해 컨테이너가 종료되었을 때, 이를 다시 실행 시키기 위한 정책을 관리해준다. 또한 config 파일을 통해 자신의 메타 데이터들을 관리하기도 한다. 다만, 이러한 부분은 도커 데몬이 무엇인지 이해하는데는 큰 도움이 되지 않을 수도 있다. 그래서 기억할 만한 것은 CLI에서 넘어온 명령어를 통해 컨테이너 생성 명령을 아래 단계로 전달해주는 역할을 수행한다라는 것이다.

containerd는 고수준 컨테이너 런타임 코드이다. 쉽게 말하면 컨테이너를 생성해주고 관리해준다. 이는 고수준과 저수준으로 나뉜다. 고수준의 경우 실제로 컨테이너 인스턴스를 생성하지는 않고 컨테이너의 이미지를 pull 받아오고 이러한 정보를 저수준으로 넘겨주는 역할을 수행한다. 즉, containerd는 직접 컨테이너를 생성 시키지는 않고 생성을 위한 전처리 작업을 수행하고 이를 저수준으로 넘겨준다.

runc는 앞서 말한것처럼 저수준 컨테이너 런타임 코드이고 containerd가 넘겨준 정보를 통해 컨테이너를 실행시킨다. 해당 레이어에서는딱 한가지, 컨테이너를 생성하는 역할만 수행해준다. 좀 더 설명하면 도커는 사실 Host의 커널을 공유한다. 그래서 컨테이너를 생성하기 위해서 Host 커널에서 필요한 요소들이 존재한다. 이러한 것들(namespace, cgroup 등..)을 하나의 단위로 묶고 이를 runc라고 한다. runc에 대해서 살펴보면 OCI라는 용어가 함께 나오는데, 이는 커널 내에서 컨테이너를 다루는 인터페이스의 표준이다. runc는 이러한 표준을 구현한 구현체이다. (JPA와 Hibernate의 관계랄까)

이렇게 도커 명렁어가 입력되고 컨테이너가 생성되는 과정을 살펴보았다.

컨테이너 간의 통신

사실 업무을 하다보면 standalone 형태로 도커 컨테이너를 하나 띄워두고 무언가를 작업하는 경우는 굉장히 드물다. 보통 클러스터 구성 또는 (WAS + DB)의 구성 등을 통해 N개의 컨테이너들을 구동 시키고 이들간에 통신도 맺고 원하는 요청 / 응답을 처리하는 형태이다. 이러한 형태의 컨테이너는 주로 docker-compose로 간단히 구성할 수 있다.

위와 같은 상황에서 두 컨테이너 간에 TCP 연결을 맺는다고 가정해보자. 컨테이너 1은 8080:8080으로 포트 설정이 되고 컨테이너 2는 2181:2181로 포트가 설정이 된 상태이다.

대략 아래와 같은 그림으로 나타낼 수 있다.

밝은 회색 영역이 사용자의 장비를 나타내는 영역이고 짙은 회색 영역이 컨테이너의 영역이다. 도커의 포트 설정은 <컨테이너가 구동된 장비의 포트>:<컨테이너 내부 포트>와 같이 포트가 구성된다. 즉, 8080 포트로 한개의 요청이 발생하면 해당 장비의 8080포트에 들어온 요청이 컨테이너의 8080포트로 이어져 해당 컨테이너가 최종적으로 컨테이너에서 요청을 처리하는 형태로 워크로드가 구성된다.

이러한 형태에서 종종 오해하는 부분이 발생한다. 컨테이너 1을 스프링 부트 컨테이너라고 가정해보자. 스프링 부트 내부 로직에서 컨테이너 2에 연결을 맺어야 하는 로직이 필요하다. 여기서 컨테이너 2에 해당하는 Host 정보는 어떻게 줘야할까? 단순히 생각하면 localhost:2181로 보내면 될 것만 같다. 컨테이너 1의 입장에서 곰곰이 생각해보자. 현재 열려있는 포트가 컨테이너 1의 2181번 포트일까 로컬 장비의 포트일까? 정답은 로컬 장비의 2181번 포트가 열려있는것이다. localhost:2181와 같이 요청을 보내면 해당 요청은 거절될 수 밖에 없는 구조이다. 즉, 컨테이너가 구동된 가상 환경과 실제 자신이 사용하는 물리 장비의 환경을 잘 따져보는 시야를 가져야 한다.

이렇게 얘기하면 참 별 거 없어 보이는 문제인데, 익숙치 않다면 헷갈린만한 상황이다. 그러면컨테이너 1번이 컨테이너 2번에게 네트워크 연결을 맺으려면 어떻게 해야할까? 요청을 맺는 방법은 크게 두가지로 나뉜다.

  1. 환경변수로 설정 : 만약 컨테이너2의 이름이나 도커 컴포즈 서비스의 이름 등의 값을 안다며 해당 값으로 쉽게 설정 가능하다. 예를 들어 zoo라는 서비스가 2181번에 띄워져 있다고 가정해본다. 그러면 zoo:2181을 도커 컴포즈 설정 파일 내에서 사용하면 컨테이너 2번의 2181번 포트에 접근 시 호스트 정보로 활용 할 수 있다. 주로 환경 변수에 해당 호스트 정보를 넣어서 로직에서 활용하도록 한다.
  2. 그 외 환경변수로 넘길 수 없는 환경 : 컨테이너2의 이름 또는 서비스명을 공유할 수 없는 상황이면 직접 해당 IP 또는 도메인 네임을 사용하면 된다. 위 그림에 빗대어 설명하면 장비의 공인 IP 또는 해당 IP에 연결된 도메인 네임을 넣어주게 되면 접근 가능하다. 만약 장비의 hosts 정보로부터 dns look up을 해야한다면 로컬 장비의 hosts 정보 또한 해당 컨테이너에 공유시켜줘야한다.

컨테이너 간의 순서 (with Docker Compose)

다음으로는 컨테이너 간의 순서를 보장해줘야 하는 상황을 다루는 방법이다. 특정 프로세스가 구동되기 위한 선수 조건으로 특정 프로세스가 필요한 경우는 흔한 경우이다. 간단히 도커 컴포즈를 통해 이러한 경우를 어떻게 제어하는지 확인한다.

대표적인 예시로 WAS와 DB를 도커 컴포즈를 통해 구동 시키는 경우이다. 이러한 경우 DB 컨테이너가 완전히 구동된 뒤 WAS가 구동되어 해당 DB와 커넥션을 맺어야 한다. 이를 스프링 부트와 MySql 예시로 살펴보자.

version: '3'
services:
  db:
    image: mysql:8.0.32
    ports:
      - "3306:3306"
    environment:
      MYSQL_DATABASE: basic
      MYSQL_ROOT_HOST: '%'
      MYSQL_ROOT_PASSWORD: 1234
      TZ: Asia/Seoul
    healthcheck:
      test: [ 'CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -u root --password=$$MYSQL_ROOT_PASSWORD' ]
      interval: 10s
      timeout: 2s
      retries: 100


  springboot:
    depends_on:
      db:
        condition: service_healthy

    # Dockerfile 또한 설정 가능
    image: <내가 받아올 spring boot image>
    ports:
      - "8080:8080"
    environment:
      SPRING_PORT: 8080
      SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/basic?useSSL=false&allowPublicKeyRetrieval=true
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: 1234

눈여겨 볼 부분은 depends_onhealthcheck 프로퍼티이다. “springboot” 서비스가 “db”라는 서비스에 의존성을 갖도록 하는 설정이 depends_on이다. 다만 단순히 아래와 같이 설정한다면 문제가 있다. MySQL 컨테이너가 먼저 실행되고 나서 springboot 컨테이너가 실행 되지만 MySQL 컨테이너 정상적으로 구동되기 전에 springboot 컨테이너에서 연결 시도를 하게 된다. 즉, 커넥션이 성립하지 않게된다.

  springboot:
    depends_on:
      - db

이러한 문제를 해결하기 위해 condition을 통해 구체적인 조건을 달 수 있다. 여기서 사용한 것을 해당 컨테이너가 healthy한 상태인지를 확인하는 것이다. MySQL과 같은 공식 이미지는 벤더 사에서 공식적으로 HealthCheck를 컨테이너 이미지에 구현해두지 않은 경우가 많다. (참고할만한 글) 그래서 커스텀하게 ping 명령어를 통해서 정상적인 서비스인지를 확인할 수 있다. 이를 통해 MySQL이 완전히 구동된 후에 springboot 컨테이너를 구동시킬 수 있게 된다.

그 외 또 볼만한 지점은 SPRING_DATASOURCE_URL 환경 변수의 값이다. “db”라는 서비스의 명을 hostname으로 사용하고 있다. 앞선 장에서 살펴본 내용을 확인할 수 있다. 그리고 MYSQL_DATABASE이라는 횐경 변수는 컨테이너 구동 시, 사용할 database를 선택한다. 만약 해당하는 database가 없을 경우는 생성해준다. 번거롭게 init.sql에 DDL을 적을 필요가 없게 된다.

추가로 구글링을 통해 springboot + mysql 조합을 찾으면 항상 network 설정을 한다. 그래서 꼭 필요한 싶어서 찾아보니 network를 별도 설정하지 않으면 컨테이너들이 default 네트워크를 사용한다. 그러면 각각의 컨테이너가 동일한 네트워크이기에 통신하는데 문제가 없다라고 판단해서 제외시켰다. 별 문제 없이 잘 동작한다. (이는 개발용에서만 적용하고 서비스 배포시에는 별도 네트워크를 구성하는게 좋을 것 같다)

아무튼 이 글을 읽고 도커가 익숙치 않은 분들에게 모쪼록 도움이 되었으면 좋겠다.