본문 바로가기

Programming/DB

MongoDB Replica Set 구성하기 - Docker Swarm

회사에서 MongoDB를 사용하고있다. Stand alone DB로 이중화 구성이 되어있지 않은데 Replica Set 구성을 통해 이중화 구성하는 작업을 맡게 되었다. 구성하면서 찾아본 내용들이 너무 기본적인 내용들만 적혀있었는데, 본 글에서는 찾아본 내용들에 대해 최대한 많은 내용을 담아보려고 한다.

출처: https://www.mongodb.com/compatibility/deploying-a-mongodb-cluster-with-docker

 

목차는 다음과 같다.

1. MongoDB의 Replica Set이란?

2. Replica Set 구성하는 방법 - with Docker Compose

3. DB 이중화하기 - with Docker Swarm

쿠버네티스가 아닌 도커 컴포즈와 스웜 환경으로 구성한 이유는 현재 업무에서 도커 스웜으로 서비스를 운영하고 있기 때문이다.

MongoDB의 Replica Set이란?

Replication이란 DB의 데이터들을 여러 서버에 동기화하는 것이다. 여러 서버가 모두 동일한 데이터를 가지고 있고, 하나의 서버가 다운되더라도 제공하는 서비스에 문제가 생기지 않고 운영할 수 있는 장점이 있다. 각 서버에 데이터를 복구하고, 리포팅하고, 백업을 설정할 수 있다. Replication을 구축하는 이유는 첫 번째로 데이터를 안전하게 보존할 수 있다는 점이다. 또한, 24시간 접근 가능한 데이터의 상태를 유지할 수 있다. 물론 이 부분은 stand alone db로도 가능하지만 해당 db가 다운되는 경우에는 불가능하다.

 

Replica Set을 구성하는데에 필요한 용어들에 대해 알아보자.

  • Primary node와 Secondary node
    • Primary node는 메인으로 사용하게 되는 노드이고, Secondary node는 보조로 사용되는 노드이다.
    • 모든 node들은 write 작업을 수행한다.
    • oplog(operation log)는 primary node에 요청되는 모든 연산들이 로그로 기록되는 파일이다. local이라는 이름의 db내의 oplog.rs(Replication Set의 이름)이라는 이름의 컬렉션 내에 저장된다.
    • oplog는 primary와 secondary간의 데이터를 동기화하는 것이 주목적이므로 데이터의 쓰기 등과 관련된 연산만 기록된다. (비동기적)
      (아래 이미지 출처: https://engineering.linecorp.com/ko/blog/LINE-integrated-notification-center-from-redis-to-mongodb)

  • Election
    • Primary node가 이용 불가능한 상태일때, Secondary node중에서 어떤 노드를 Primary node로 정할 것인지 선정하는 과정이다.
    • 선거를 통해 Secondary node가 Primary node가 된다.
  • Heart Beat
    • Replica set내의 모든 노드들은 정해진 초마다 서로에게 Hearbeat를 보내서 상태를 확인한다.
    • 기본값은 2초마다 heart beat을 보내고, 최장 10초동안 응답이 없을 경우 다른 멤버들은 해당 멤버를 접속 불가로 설정한다.
  • Arbiter node
    • 투표권은 있으나 Primary node로 선출될 수는 없는 노드
    • 데이터 셋 복사본을 들고 있지 않기 때문에 하드웨어 리소스를 많이 소비하지않는 경량 프로세스이다.
  • P-S-S(Primary + Secondary + Secondary)
  • P-S-A(Primary + Secondary + Arbiter)

Replica Set 구성하는 방법 - with Docker Compose

Docker 설치에 대한 부분은 생략한다. 하나의 인스턴스에 세 개의 서비스를 띄워 docker compose로 구성하는 방법에 대해 먼저 작성해볼 것이다. 그 이후, 3개의 인스턴스에 각각 MongoDB를 배포하여 docker swarm으로 구성하는 방식에 대해서도 알아볼 것이다.

 

구성은 간단하다.

  • 3개의 MongoDB 서비스를 서로 다른 포트로 실행한다. 편의를 위해 Primary node로 사용할 서비스의 포트를 27017,  나머지는 27018, 27019로 설정해보았다. (원하는 포트로 직접 변경도 가능하다.)
  • 실습용으로 실행하는 환경에서는 authentication이 필요없겠지만, 실무에서 적용할때 가장 중요한 것은 바로 authentication이다. keyfile을 하나 생성해서, 각 컨테이너의 특정 위치에 복사한다. 해당 keyfile로 인증을 진행할 수 있다.
    https://www.mongodb.com/docs/manual/tutorial/enforce-keyfile-access-control-in-existing-replica-set/
  • keyfile 생성
    • openssl을 사용하여 랜덤 1024 character 문자열을 생성하여 파일로 저장한다.
    • owner에게만 읽기 권한을 부여한다.
openssl rand -base64 756 > 키파일저장경로
chmod 400 키파일저장경로
  • docker compose 실행하기
 docker-compose -f docker-compose 파일 경로 up -d
[+] Running 7/7
 ✔ Network mongo-net                        Created                                                                            0.0s
 ✔ Volume "mongo-replica-set_mongo-data"    Created                                                                            0.0s
 ✔ Volume "mongo-replica-set_mongo-data-2"  Created                                                                            0.0s
 ✔ Volume "mongo-replica-set_mongo-data-3"  Created                                                                            0.0s
 ✔ Container mongo-replica-set-mongo1-1     Started                                                                            0.6s
 ✔ Container mongo-replica-set-mongo3-1     Started                                                                            0.7s
 ✔ Container mongo-replica-set-mongo2-1     Started                                                                            0.5s

docker-compose-local.yml에 명시한대로 네트워크를 생성하고, 볼륨 3개를 생성하고, 컨테이너 3개를 생성한다.

docker container ls
CONTAINER ID   IMAGE         COMMAND                   CREATED         STATUS         PORTS                                 NAMES
7c412831eb4f   mongo:4.2.3   "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes   27017/tcp, 0.0.0.0:27018->27018/tcp   mongo-replica-set-mongo2-1
6b0cceaf6880   mongo:4.2.3   "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes   0.0.0.0:27017->27017/tcp              mongo-replica-set-mongo1-1
9878ea71cee6   mongo:4.2.3   "docker-entrypoint.s…"   3 minutes ago   Up 3 minutes   27017/tcp, 0.0.0.0:27019->27019/tcp   mongo-replica-set-mongo3-1

Primary Node로 사용할 컨테이너에 접속한다.

  • docker container ls: 컨테이너 목록을 조회하면 볼 수 있는 Container ID로 해당 컨테이너로 접속할 수 있다.
  • docker exet -it contianerID mongo -u 아이디 -p 비밀번호: Root 아이디 비밀번호로 Mongo Shell에 접속할 수 있다.
docker exec -it mongo1컨테이너ID mongo -u 아이디 -p 비밀번호
MongoDB shell version v4.2.3
connecting to: mongodb://127.0.0.1:27017/?compressors=disabled&gssapiServiceName=mongodb
Implicit session: session { "id" : UUID("641b6d72-c8f4-4d5f-a53d-8caa395d8e37") }
MongoDB server version: 4.2.3
Server has startup warnings:
2023-06-04T07:52:05.426+0000 I  STORAGE  [initandlisten]
2023-06-04T07:52:05.426+0000 I  STORAGE  [initandlisten] ** WARNING: Using the XFS filesystem is strongly recommended with the WiredTiger storage engine
2023-06-04T07:52:05.426+0000 I  STORAGE  [initandlisten] **          See http://dochub.mongodb.org/core/prodnotes-filesystem
---
Enable MongoDB's free cloud-based monitoring service, which will then receive and display
metrics about your deployment (disk utilization, CPU, operation statistics, etc).

The monitoring data will be available on a MongoDB website with a unique URL accessible to you
and anyone you share the URL with. MongoDB may use this information to make product
improvements and to suggest MongoDB products and deployment options to you.

To enable free monitoring, run the following command: db.enableFreeMonitoring()
To permanently disable this reminder, run the following command: db.disableFreeMonitoring()
---

>

Replica Set을 설정하기전에 기존에 설정된 값이 있는지 확인해보자.

> rs.status()
{
        "ok" : 0,
        "errmsg" : "no replset config has been received",
        "code" : 94,
        "codeName" : "NotYetInitialized"
}

Primary Node가 될 컨테이너에서 Replica Set을 설정해보자.

> rs.initiate({
...     "_id": "rs0",
...     "members": [
...         {
...             "_id": 0,
...             "host": "mongo1:27017",
...             "priority": 2
...         },
...         {
...             "_id": 1,
...             "host": "mongo2:27017",
...             "priority": 0.5
...         },
...         {
...             "_id": 2,
...             "host": "mongo3:27017",
...             "priority": 0.5
...         }
...     ]
... });
{ "ok" : 1 }
  • host: "컨테이너명:포트"
    • 설정 파일을 보면 각 서비스 포트 매핑을 '27017:27017', '27018:27017', '27019:27017'로 해주었다. 컨테이너끼리 통신을 할 때는 각각 27017로 통신하고, 외부에서 접속할 때는 27017, 27018, 27019로 접속할 수 있다.
    • 여기서는 컨테이너끼리 통신하므로 27017 포트로 연결하면 된다.
  • priority: prioirty가 높은 순서대로 Primary Node가 된다. 이때 주의해야할 점은 0으로 설정하게되면 해당 멤버는 Arbiter node가 되어, 투표권만 갖고 데이터 복제를 진행하지 않는다. 만약 Arbiter mode를 의도적으로 사용하는게 아니라면 priority 값을 0보다 큰 값으로 설정해야한다.

제대로 설정했다면, mongo1은 PRIMARY로, mongo2, mongo3은 SECONDARY로 설정된 상태를 확인할 수 있을 것이다.

2023-06-04 17:10:05 2023-06-04T08:10:05.014+0000 I  CONNPOOL [Replication] Connecting to mongo3:27019
2023-06-04 17:10:05 2023-06-04T08:10:05.014+0000 I  CONNPOOL [Replication] Connecting to mongo2:27018

설정한 후에 docker 로그를 확인해보면 위와 같이 secondary node들에 연결한다는 로그가 남아있는 것을 볼 수 있다.

 

정말 제대로 설정되었을까? 테스트를 한 번 해보자.

  • dummy data를 생성한다.
for (var i = 1; i <= 25; i++) {
   db.testData.insert( { x : i } )
}
  • 생성되었는지 확인한다.
rs0:PRIMARY> db.testData.find()
{ "_id" : ObjectId("647c494e1015cc3bc74d34a4"), "x" : 1 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a5"), "x" : 2 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a6"), "x" : 3 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a7"), "x" : 4 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a8"), "x" : 5 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a9"), "x" : 6 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34aa"), "x" : 7 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ab"), "x" : 8 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ac"), "x" : 9 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ad"), "x" : 10 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ae"), "x" : 11 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34af"), "x" : 12 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b0"), "x" : 13 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b1"), "x" : 14 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b2"), "x" : 15 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b3"), "x" : 16 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b4"), "x" : 17 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b5"), "x" : 18 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b6"), "x" : 19 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b7"), "x" : 20 }
Type "it" for more
  • Primary에서 생성한 25개의 데이터를 Secondary Node들도 갖고 있는지 확인해보자.
rs0:SECONDARY> db.testData.find()
Error: error: {
        "operationTime" : Timestamp(1685866971, 1),
        "ok" : 0,
        "errmsg" : "not master and slaveOk=false",
        "code" : 13435,
        "codeName" : "NotMasterNoSlaveOk",
        "$clusterTime" : {
                "clusterTime" : Timestamp(1685866971, 1),
                "signature" : {
                        "hash" : BinData(0,"ll+NLu/Sju8UrhPD7hjOEABaDAA="),
                        "keyId" : NumberLong("7240739082035265538")
                }
        }
}

Secondary node의 Mongo Shell에서 쿼리를 실행하려면 권한이 필요하다. 

rs0:SECONDARY> rs.slaveOk()
rs0:SECONDARY> db.testData.find()
{ "_id" : ObjectId("647c494e1015cc3bc74d34a4"), "x" : 1 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a8"), "x" : 5 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b0"), "x" : 13 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ad"), "x" : 10 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34af"), "x" : 12 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ae"), "x" : 11 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a6"), "x" : 3 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ab"), "x" : 8 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a5"), "x" : 2 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a7"), "x" : 4 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34aa"), "x" : 7 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34ac"), "x" : 9 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b1"), "x" : 14 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34a9"), "x" : 6 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b6"), "x" : 19 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b2"), "x" : 15 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b5"), "x" : 18 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b3"), "x" : 16 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34b4"), "x" : 17 }
{ "_id" : ObjectId("647c494e1015cc3bc74d34bb"), "x" : 24 }
Type "it" for more

제대로 복사된 것을 확인할 수 있다. 실제로 로그를 살펴보면 replication monitoring을 진행하다가 collection 생성 및 쓰기 연산이 추가되었을 때 동일하게 collection을 생성하고, 쓰기 연산을 추가하는 것을 확인할 수 있다.

2023-06-04 17:20:30 2023-06-04T08:20:30.594+0000 I  STORAGE  [repl-writer-worker-1] createCollection: test.testData with provided UUID: bc2f15be-6f45-4e34-ba8a-4aa56af237b9 and options: { uuid: UUID("bc2f15be-6f45-4e34-ba8a-4aa56af237b9") }
2023-06-04 17:20:30 2023-06-04T08:20:30.603+0000 I  INDEX    [repl-writer-worker-1] index build: done building index _id_ on ns test.testData
2023-06-04 17:20:30 2023-06-04T08:20:30.604+0000 I  SHARDING [repl-writer-worker-14] Marking collection test.testData as collection version: <unsharded>
2023-06-04 17:22:37 2023-06-04T08:22:37.785+0000 I  NETWORK  [listener] connection accepted from 127.0.0.1:42836 #23 (5 connections now open)

Replication이 제대로 동작하는 것을 확인되었다. 하지만 이 방식으로 이중화를 구성해도 문제점이 있다. 현재 3개의 컨테이너가 모두 같은 인스턴스에서 실행되고 있다. 만약, 해당 인스턴스가 장애가 발생할 경우 모든 DB에 접근이 불가하다. 따라서 물리적인 서버 분리를 하고, 각각의 서버에 DB를 실행하고, DB를 이중화하는 방식에 대해 알아보자.

Replica Set 구성하는 방법 - with Docker Swarm

docker compose 방식과 거의 유사하지만 세가지 다른 점이 있다.

  1. 인스턴스가 1개에서 3개가 필요하다.
  2. keyfile을 volume mapping 방식이 아닌, docker secret을 생성하여 관리한다.
  3. docker swarm 방식을 사용하여 배포할 것이다.
  • docker swarm 모드 활성화하기
    Manager node로 실행될 인스턴스에서 docker swarm을 활성화한다.
$ docker swarm init
Swarm initialized: current node (p6eocfheixd51i1u3yvo1kg39) is now a manager.

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

    docker swarm join --token SWMTKN-1-30dkfl8x7bho6ggnhgltodyti7t6r7utw3wgza5jzqfgbcxrqk-9w0y1awmmv9nkscbkz0425bjs 192.168.65.4:2377

To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.
  • 제대로 활성화되었는지 확인해본다.
docker info | grep -i swarm: -A 20
 Swarm: active
  NodeID: p6eocfheixd51i1u3yvo1kg39
  Is Manager: true
  ClusterID: igluvvvjj12mi42rx7vjggizo
  Managers: 1
  Nodes: 1
  Default Address Pool: 10.0.0.0/8
  SubnetSize: 24
  Data Path Port: 4789
  Orchestration:
   Task History Retention Limit: 5
  Raft:
   Snapshot Interval: 10000
   Number of Old Snapshots to Retain: 0
   Heartbeat Tick: 1
   Election Tick: 10
  Dispatcher:
   Heartbeat Period: 5 seconds
  CA Configuration:
   Expiry Duration: 3 months
   Force Rotate: 0
  • Worker node로 사용될 나머지 두 개의 인스턴스에 접속하여 Worker node로 추가한다.
$ docker swarm join-token worker
To add a worker to this swarm, run the following command:

    docker swarm join --token 토큰정보

위 명령어를 통해서 join에 필요한  토큰 정보를 얻을 수 있고, 두 개의 인스턴스에 위 명령어를 복사하면 Worker node도 등록되는 것을 확인할 수 있다. 

  • docker secret 생성하기

인증 키 파일을 docker secret에 등록해서 사용해보자. 키 파일이 저장된 경로에서 아래 명령어를 실행하여 docker secret을 생성할 수 있다.

$ docker secret create replica-key ./replica.key
ip93pv92oeywc9syopb7fdhui
  • docker label 추가하기

docker-compose.yml 파일을 보면 아래와 같이 deploy 옵션을 확인할 수 있다. constraints 설정을 통해서 swarm node중에서 name==db인 라벨을 가진 노드에만 서비스가 배포될 수 있도록 제한해두었다.

deploy:
	mode: replicated
	replicas: 1
	placement:
		constraints:
			- node.labels.name == db
	restart_policy:
    	condition: on-failure

이에 맞춰 label을 추가하자.

$ docker node update --label-add <라벨명> <노드명>
$ docker node update --label-add name==db master노드명
$ docker node update --label-add name==db2 worker1노드명
$ docker node update --label-add name==db3 worker2노드명
  • 이제는 서로 다른 인스턴스에서 각각 실행하므로 포트를 다르게 할 필요가 없다.
  • deploy 필드가 추가되었다.
  • volume 필드가 변경되었다.
  • docker swarm을 배포해보자.
$ docker stack deploy -c docker-compose.yml파일경로 스택명
$ docker stack deploy -c docker-compose.yml mongo

Creating network 스택명_mongo-net
Creating service 스택명_mongo1
Creating service 스택명_mongo2
Creating service 스택명_mongo3

스택명에 각자 설정하고자하는 이름을 작성해주면, 아래와 같은 규칙으로 네트워크와 서비스가 생성된다. 이때 주의해야할 점은 volume이다. 만약 기존의 volume으로 매핑하여 DB를 유지하고 싶다면 volumes 옵션을 아래와 같이 설정해줘야한다.

volumes:
  mongo-data:
  	external: true
  mongo-data-2:
  	external: true
  mongo-data-3:
  	external: true

이렇게 설정하게될 경우, 정확히 명시된 이름의 volume으로 매핑하게 된다. external: true 옵션은, 만약 명시한 volume이 없을 경우 에러를 발생시키므로 volume이 있는지 꼭 확인해보자.

  • 제대로 배포되었는지 확인하기
docker service ls
ID             NAME           MODE         REPLICAS   IMAGE         PORTS
wg59os2v9ifb   mongo_mongo1   replicated   1/1        mongo:4.2.3   *:27017->27017/tcp
ioo44nri3zj2   mongo_mongo2   replicated   1/1        mongo:4.2.3   *:27018->27017/tcp
v0hvhczngyik   mongo_mongo3   replicated   1/1        mongo:4.2.3   *:27019->27017/tcp

만약 REPLICAS값이 0/1인 경우, 해당 노드에서 서비스가 실행되지 못한 것이므로 다시 확인해봐야한다.

 

MongoDB Replication 방식에 대해서 알아보았다. Primary node 1개와 Secondary node 2개로 구성하여 Primary node가 다운되더라도, Secondary node가 선거를 통해 Primary가 되어 서비스에 지장없이 운영할 수 있는 방법에 대해 알아보았다. 개인 개발 환경에서는 replica set을 구축할 일이 없었는데 실무에서 replica set을 구축하며 새로운 점을 배울 수 있어서 좋은 기회였다. 또한 docker swarm 배포 방식에 대해 정확한 이해를 하고있지 못하고 있었는데 이번 기회를 통해 구축해보며 배울 수 있어서 좋았다.

 

더 나아가서, docker swarm이 아닌 kubernetes로 인프라를 구축해보는 것도 도전해보고 싶다는 생각이 들었다. 기회가 된다면 구축해보고 또 글을 써보려고 한다.

 

도움받은 글:

- https://www.mongodb.com/compatibility/deploying-a-mongodb-cluster-with-docker

- https://engineering.linecorp.com/ko/blog/LINE-integrated-notification-center-from-redis-to-mongodb

코드는 여기서 볼 수 있습니다. https://github.com/seohyun0120/mongodb-replicaset-docker

혹시 틀린 내용이 있다면 댓글 부탁드립니다.

반응형