MongoDB replica set을 Docker compose로 구축하는 것은 생각보다 쉽지 않았다. 알아야 할 개념들과 community에서 오간 내용들까지 정리하는 시간을 가져보려고 한다.
이번 글에서 다룰 예시는 3개의 파일로 구성되어 있다:
3개의 파일을 하나의 폴더에 넣는다.
docker compose up -d
를 통해 container를 생성한다.
docker compose down -v
를 통해 생성한 volume 까지 같이 삭제해준다.
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:
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();
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD node connectionTest.js
우선 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"로 설정--bind_ip_all
를 주어서 모든 ipv4 address에 bindingbind_ip localhost,mongo1,mongo2,mongo3
과 같이 설정하는 것은 에러를 발생하면서 인스턴스를 즉시 종료시킨다. --port
을 주어서 client connection을 받는 port를 기본 포트인 27017에서 40001번 포트로 설정이제 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
option을 사용하는 이유는 2가지가 있다:
rs.inintiate()
함수를 실행하는 것은 실수를 유발할 가능성이 높다.healthcheck
이전에 container 생성순서를 정하는 depends_on
option에 대해서 먼저 설명하고 가겠다.
아래의 예시를 보자:
services:
web:
build: .
depends_on:
- db
- redis
redis:
image: redis
db:
image: postgres
service container의 생성과 소멸은 dependency order에 따라서 실행된다:
web
service를 redis
와 db
service가 생성되고 나서 생성한다.web
service가 소멸된 다음 redis
와 db
service가 소멸된다.하지만 여기에는 문제가 하나 있다. depends_on
option은 docker가 container의 상태를 바라보아서 순서를 결정한다. 따라서 db의 bootstrap이 완료 될때까지 기다려주지 않고 db container가 생성되자마자 바로 다음 container를 생성한다. 이렇게 되면 server에서 db connection으로 인한 문제가 발생할 여지가 있다(bootstrap되지 않은 db에의 연결을 시도하여 timeout 발생 등).
따라서 db가 어느정도 bootstrap이 될 때까지 기다려주고 다음 container를 생성하도록 지시하기 위해서 healthcheck
option을 사용한다.
다시 우리의 예시(docker-compose.yaml
)파일로 돌아오자. healthcheck
와 depends_on
을 같이 사용하기 위해서 healthcheck
를 long syntax로 표현하고 condition을 service_healthy
로 설정해준다:
services:
...
server:
build:
context: .
dockerfile: Dockerfile
depends_on:
mongo1:
condition: service_healthy
위와 같이 설정하면 mongo1
의 healthcheck
결과가 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
위의 설명을 적용하면 현재 - 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가 실행되지 않는것을 발견할 수 있다.
네 안됩니다. 이에 대한 내용은 #339 와 #246 의 github issue를 통해서 열띤 논의가 되었었습니다. 결론을 말하자면 docker image는 이러한 것을 지원하기 위해 design 되지 않았기 때문에 community 내에서의 요구가 분명함에도 이런 기능에 대한 추가를 고려하지 않겠다는 입장이었습니다(링크). Orchestration platform을 사용하여 적절한 initialization을 구성하라는 대답입니다.
docker-entrypoint.initdb.d
script로 안되는 이유 에 대한 docker official member의 답변입니다. container가 오로지 localhost만을 listening 하고 있는 초기 시점에 해당 폴더 아래의 파일들이 실행되기 때문에 이 곳에서 cluster initiate는 부적절하다고 답변했습니다.
네 저도 tag를 바꾸어가면서 수행해보니 mongo version 4, 5 image에는 legacy mongo
shell이 있지만 6 버전 image 부터는 legacy mongo
shell은 없어지고 mongosh
의 새로운 shell로 교체되었습니다. 따라서 이 글의 예시는 mongosh
를 사용하도록 하였습니다.
일주일간 docker-compose로 replica set 을 구축하는데 애먹다가, 이 글 덕분에 드디어 성공했습니다 정말 감사합니다!!!