MongoDB replica set with Docker Compose

woo94·2023년 5월 19일
0

mongo

목록 보기
1/1

MongoDB replica set을 Docker compose로 구축하는 것은 생각보다 쉽지 않았다. 알아야 할 개념들과 community에서 오간 내용들까지 정리하는 시간을 가져보려고 한다.

이번 글에서 다룰 예시는 3개의 파일로 구성되어 있다:

  • docker-compose.yaml
  • connectionTest.js
  • Dockerfile

실행 방법

3개의 파일을 하나의 폴더에 넣는다.
docker compose up -d 를 통해 container를 생성한다.

삭제 방법

docker compose down -v 를 통해 생성한 volume 까지 같이 삭제해준다.

docker-compose.yaml

services:
  mongo1:
    image: mongo
    command: --replSet replDb --bind_ip_all --port 40001
    volumes:
      - db1:/data/db
    ports:
      - 40001:40001
    healthcheck:
      test: test $$(mongosh --port 40001 --quiet --eval "rs.initiate({_id:\"replDb\",members:[{_id:0,host:\"mongo1:40001\"},{_id:1,host:\"mongo2:40002\"},{_id:2,host:\"mongo3:40003\"}]}).ok || rs.status().ok") -eq 1
      interval: 20s
      timeout: 10s
      retries: 6

  mongo2:
    image: mongo
    command: --replSet replDb --bind_ip_all --port 40002
    volumes:
      - db2:/data/db
    ports:
      - 40002:40002

  mongo3:
    image: mongo
    command: --replSet replDb --bind_ip_all --port 40003
    volumes:
      - db3:/data/db
    ports:
      - 40003:40003

  server:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      mongo1:
        condition: service_healthy

volumes:
  db1:
  db2:
  db3:

connectionTest.js

const { MongoClient } = require("mongodb");

const url =
  "mongodb://mongo1:40001,mongo2:40002,mongo3:40002/?replicaSet=replDb";
const client = new MongoClient(url);

async function main() {
  await client
    .connect()
    .then(() => {
      console.log("connect success");
    })
    .catch((err) => {
      console.log(err);
    });
}

main();

Dockerfile

FROM node:18-alpine

WORKDIR /app

COPY . .

RUN npm install

CMD node connectionTest.js

docker-compose.yaml

우선 docker-compose.yaml 파일에 대해서 알아가보자.

services:
	image: mongo
	mongo1:
    	volumes:
        	- db1:/data/db
    
    mongo2:
    	image: mongo
    	volumes:
        	- db2:/data/db
    
    mongo3:
    	image: mongo
    	volumes:
        	- db3:/data/db
    
volumes:
	db1:
    db2:
    db3:
    

3 member replica set을 만들 예정이므로 service에 mongo image service 3개를 만들어준다. db1~db3 의 named volume도 만들어주었다.

  mongo1:
    image: mongo
    command: --replSet replDb --bind_ip_all --port 40001
    volumes:
      - db1:/data/db
    ports:
      - 40001:40001
      

mongod option을 services.command 에 주어서 설정을 해준다.

  • replSet option을 통해서 replica set의 이름을 "replDb"로 설정
    - Replica set을 구성하기 위해서는 이 옵션을 주어야 한다.
  • --bind_ip_all 를 주어서 모든 ipv4 address에 binding
    - bind_ip localhost,mongo1,mongo2,mongo3 과 같이 설정하는 것은 에러를 발생하면서 인스턴스를 즉시 종료시킨다.
  • --port을 주어서 client connection을 받는 port를 기본 포트인 27017에서 40001번 포트로 설정
    - MongoDB Compass와 연결해주기 위해서 host port와 mapping 시켜주어야 한다. Docker network만으로 replica set이 구성되면 연결을 할 수 없다.

이제 docker-compose.yaml 파일의 healthcheck option에 대한 설명이 남았는데 그 이전에 replica set을 manual 하게 구성해보려 한다.

Docker Desktop에서 mongo1 container로 들어가서 터미널 탭을 연다. 그 다음 mongosh --port 40001 을 입력하여 mongo shell로 접속한다:

rs.status()를 입력해보자. 아직 받은 replset config이 없다면서 error가 출력된다:

다음의 코드를 복사하여 붙여넣기 해보자:

rs.initiate({
  _id: "replDb",
  members: [
    {_id: 0, host: "mongo1:40001"},
    {_id: 1, host: "mongo2:40002"},
    {_id: 2, host: "mongo3:40003"}
    ]
})

그럼 { ok: 1 } 과 같은 결과가 나오고 다시 한번 rs.status() 를 입력하여 replica set의 상태를 확인해보자:

이제 MongoDB Compass를 실행하여 연결해보자:

나는 편의상 Advanced Connection Options를 사용해서 구성하였고 이를 통해 나온 URI는 다음과 같다:

mongodb://mongo1:40001,mongo2:40002,mongo3:40003/?replicaSet=replDb

하지만 곧바로 아래와 같은 에러를 마주하게 된다:

mongo1이라는 이름은 docker compose 내부에서 생성된 network alias이기 때문에 docker 외부의 환경(MongoDB Compass)에서는 이 이름(domain name)에 대한 ip주소를 알아낼 수 없다는 것이다.

그렇다면 구성 중에 우리가 host port로 mapping 하였기 때문에 모든 mongo의 이름을 localhost로 치환하면 어떻게 될까?

역시나 에러를 마주하게 된다. 왜냐하면 이전에 rs.initiate() 함수를 mongo shell에서 실행했을 때 members에 추가해준 주소가 mongo1~mongo3으로 되어있기 때문이다.

만약 시간을 더 거슬러가서 rs.initiate() 함수를 실행할 때 localhost를 사용하면 어떨까? 그렇다면 여기에 적어준 localhost는 각각의 mongo instance에서 loop back(자기 자신)을 가리키는 문제로 인해서 인접한 mongo instance들에게 traffic이 보내지지 않게 된다. 즉, replica set을 initiate하는 곳에서부터 문제가 발생하게 된다.

그러면 이 문제에 대한 유일한 해결책은 host pc의 /etc/hosts 파일을 수정하는 방법 밖에 남지 않게 된다.

보통의 방법으로는 이 파일에 대한 write 권한을 얻을 수 없기 때문에 터미널을 열어주고, 다음의 명령어를 실행한다:

sudo vi /etc/hosts

이제 pc의 password를 입력하고 /etc/hosts 파일을 열어본다. 제일 아래로 가서 아래의 3줄을 추가해준다:

127.0.0.1 mongo1
127.0.0.1 mongo2
127.0.0.1 mongo3

이 행위는 현재 host pc에서 mongo1~mongo3 이라는 domain을 만나게 되면 그것의 ip주소를 127.0.0.1 로 resolving 하도록 만들어준다.

이제 getaddrinfo ENOTFOUND mongo2 와 같은 에러는 사라지게 된다. 왜냐하면 mongo2라는 주소를 만나자마자 우리가 수정한 /etc/hosts 파일에 의해서 127.0.0.1의 ip주소(localhost)를 resolving 하게 되기 때문이다.

따라서 지금은 mongodb://localhost:40001,localhost:40002,localhost:40003/?replicaSet=replDb 의 connection string으로 MongoDB Compass에서 replica set으로의 접속이 가능하다.

하지만 docker compose 아래의 다른 service들이 범용적으로 사용할 수 있게 하기 위해서 맨 처음 접속을 시도한 mongodb://mongo1:40001,mongo2:40002,mongo3:40003/?replicaSet=replDb 로 connection string을 사용하겠다. 우리가 /etc/hosts 파일을 수정한 이유는 오로지 MongoDB Compass를 통해 replica set을 접속하기 위해서임을 다시한번 언급한다.

healthcheck

healthcheck option을 사용하는 이유는 2가지가 있다:

  • 매번 수동으로 rs.inintiate() 함수를 실행하는 것은 실수를 유발할 가능성이 높다.
  • db가 준비 된 이후에 server container가 생성되도록 만들고 싶다.

healthcheck 이전에 container 생성순서를 정하는 depends_on option에 대해서 먼저 설명하고 가겠다.

depends_on

아래의 예시를 보자:

services:
  web:
    build: .
    depends_on:
      - db
      - redis
  redis:
    image: redis
  db:
    image: postgres
    

service container의 생성과 소멸은 dependency order에 따라서 실행된다:

  • web service를 redisdb service가 생성되고 나서 생성한다.
  • web service가 소멸된 다음 redisdb service가 소멸된다.

하지만 여기에는 문제가 하나 있다. depends_on option은 docker가 container의 상태를 바라보아서 순서를 결정한다. 따라서 db의 bootstrap이 완료 될때까지 기다려주지 않고 db container가 생성되자마자 바로 다음 container를 생성한다. 이렇게 되면 server에서 db connection으로 인한 문제가 발생할 여지가 있다(bootstrap되지 않은 db에의 연결을 시도하여 timeout 발생 등).

따라서 db가 어느정도 bootstrap이 될 때까지 기다려주고 다음 container를 생성하도록 지시하기 위해서 healthcheck option을 사용한다.

healthcheck

다시 우리의 예시(docker-compose.yaml)파일로 돌아오자. healthcheckdepends_on을 같이 사용하기 위해서 healthcheck를 long syntax로 표현하고 condition을 service_healthy로 설정해준다:

services:
	...
  server:
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      mongo1:
        condition: service_healthy

위와 같이 설정하면 mongo1healthcheck 결과가 healthy로 나와야 server container를 생성한다.

이제 mongo1 service의 healthcheck를 살펴보자. 어떤 조건에 의해서 service가 healthy로 되도록 설정했는지 설명하겠다.

    healthcheck:
      test: test $$(mongosh --port 40001 --quiet --eval "rs.initiate({_id:\"replDb\",members:[{_id:0,host:\"mongo1:40001\"},{_id:1,host:\"mongo2:40002\"},{_id:2,host:\"mongo3:40003\"}]}).ok || rs.status().ok") -eq 1
      interval: 20s
      timeout: 10s
      retries: 6
  • test: healthcheck에 수행될 command
  • interval: 얼마의 주기로 health check를 수행하는지
  • timeout: 얼마나 길게 걸리면 health check를 fail로 만들지
  • retries: container를 unhealthy로 만들때까지 몇번의 fail을 겪게 할 것인지

위의 설명을 적용하면 현재 - container가 매 20초마다 healthcheck(test command)를 수행한다(0s, 20s, 40s ...), 그리고 이 healthcheck에 걸리는 시간이 10초 이상이 걸리면 healthcheck는 fail 한 것으로 간주한다. 그리고 처음 5초동안 발생한 healthcheck failure는 fail로 기록하지 않는다. 총 6번의 healthcheck를 수행하고 모두 실패했다면 이 container는 unhealthy 한것으로 본다.

Container가 "unhealthy"가 될 때까지의 여정을 살펴보자:
0~20s : 첫 실패
20~40s: 두번째 실패
40~60s: 세번째 실패
60~80s: 네번째 실패
80~100s: 다섯번째 실패
100~120s: 여섯번째 실패 >> "unhealthy"

그럼 test command는 어떤 작업을 수행하는지 살펴보자:

test: test $$(mongosh --port 40001 --quiet --eval "rs.initiate({_id:\"replDb\",members:[{_id:0,host:\"mongo1:40001\"},{_id:1,host:\"mongo2:40002\"},{_id:2,host:\"mongo3:40003\"}]}).ok || rs.status().ok") -eq 1

크게 보면 test shell command를 통해서 괄호 안의 값을 구하고, 그것이 -eq 1 즉 1의 값이 나오는지를 확인한다. 1이 나오면 healthcheck는 통과한다.

그 내용은 mongosh 를 사용하여 mongo shell에 quiet하게 접속하여 --eval 을 사용해 명령어를 수행한다. 그 명령어의 내용은 rs.initiate()를 실행하는 것이다. 만약 이전 healthcheck에 의해서 실행된다면 이미 initiate가 됐기 때문에 실패하고 출력 결과물이 { ok: 1 } 이 나오지 않게 된다. 그러면 || operator에 의해서 다음 명령어인 rs.status().ok 를 수행하게 된다. rs.intiiate() 에 의해서 replica set이 초기화 되면 rs.status()의 결과로 나오는 json object의 ok property는 1이 나온다.
그렇다면 test command의 결과로 1이 나오게 되고 이 값이 -eq operator에 의해서 1인지 아닌지를 판별하게 된다.

rs.initiate() 함수에서 "mongo1:40001"이 아니라 \"mongo1:40001\"을 사용한 이유는 --eval 에 의해서 모든 명령어가 ""로 wrapping이 되기 때문에 escaping character인 \"를 대신 사용하여 명령어를 계속 이어가기 위해서이다.

$$() 는 docker compose file에서 variable substitution 중에 literal dollar sign을 쓰기 위해서 사용되었다. 실제 shell command에 $() 로 표현이 되고 이것은 의도된 것이다.

이제 docker compose up -d 명령어를 통해서 container들을 생성해보자. Replica set이 구성되어 mongo1 service의 healthcheck가 통과하기 전까지는 server service가 실행되지 않는것을 발견할 수 있다.

Caveat

Q) Initializing a fresh instance section에 나온 내용처럼 /docker-entrypoint-initdb.d 아래에 .sh나 .js 파일을 넣어서 replica set을 구성하면 안되나요?

네 안됩니다. 이에 대한 내용은 #339#246 의 github issue를 통해서 열띤 논의가 되었었습니다. 결론을 말하자면 docker image는 이러한 것을 지원하기 위해 design 되지 않았기 때문에 community 내에서의 요구가 분명함에도 이런 기능에 대한 추가를 고려하지 않겠다는 입장이었습니다(링크). Orchestration platform을 사용하여 적절한 initialization을 구성하라는 대답입니다.

docker-entrypoint.initdb.d script로 안되는 이유 에 대한 docker official member의 답변입니다. container가 오로지 localhost만을 listening 하고 있는 초기 시점에 해당 폴더 아래의 파일들이 실행되기 때문에 이 곳에서 cluster initiate는 부적절하다고 답변했습니다.

Q) Docker compose로 mongo replica set을 구성하는 타 블로그 글들에서는 mongo image를 5나 4에 고정했습니다. latest로 바꾸니 healthcheck에서 오류가 발생합니다.

네 저도 tag를 바꾸어가면서 수행해보니 mongo version 4, 5 image에는 legacy mongo shell이 있지만 6 버전 image 부터는 legacy mongo shell은 없어지고 mongosh의 새로운 shell로 교체되었습니다. 따라서 이 글의 예시는 mongosh 를 사용하도록 하였습니다.

profile
SwiftUI, node.js와 지독하게 엮인 사이입니다.

1개의 댓글

comment-user-thumbnail
2024년 10월 19일

일주일간 docker-compose로 replica set 을 구축하는데 애먹다가, 이 글 덕분에 드디어 성공했습니다 정말 감사합니다!!!

답글 달기