CouchDB,一個主打安裝好之後就可以直接有原生 Http API 進行 CRUD (新增、讀取、修改、刪除) 的 NoSQL 資料庫,對於較簡單的應用程式甚至就直接免去後端的開發成本,直接對接 CouchDB Http API 介面即可。

除此之外,CouchDB 同時也主打所謂的 muti-master cluster 架構,可以輕易地設定多個 CouchDB instances 來達到 HA 的目的,確保服務不會因為伺服器掛掉而無法存取。

而本篇就是在記錄如何透過 Docker Swarm 來佈署跨機器的三個 CouchDB 並且將之設定為 cluster mode.

Prerequisites

在開始之前,由於我是打算要用 docker swarm 做跨機佈署,所以首先要先準備好環境:

  • 三台有不同 Public IP 的 Linux server
  • 三台都裝好 docker
  • 三台機器都設為 docker swarm mode

如何將 docker 設定成 swarm mode 可以參考文件,基本上就只要:

1
2
3
4
5
# init swarm
$ docker swarm init

# add into existing swarm
$ docker swarm join --token <token>

設定好之後可以用 docker node ls 確認一下,結果會類似如下:

1
2
3
4
5
$ docker node ls
ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION
2v2lb55cyes0rf3tbtqe2zp9x * docker-node-1 Ready Active Reachable 19.03.13
2gedpa6dac3c80ilr3f9ji3fw docker-node-2 Ready Active Leader 19.03.13
7zj2xk3up7ce34atj2nme9rf9 docker-node-3 Ready Active Reachable 19.03.13

Setup CouchDB as Single Node

我們要使用的會是官方的 docker image — couchdb:3.1.1

要使用其實不難,這邊可以示範一下在本機佈署 single node 的方式,docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
version: "3.8"

services:
couchdb:
environment:
COUCHDB_USER: "admin"
COUCHDB_PASSWORD: "admin123"
COUCHDB_SECRET: 46d689495ca02e8c35c3a3f683000ef1
NODENAME: "couchdb01"
ERL_FLAGS: "-setcookie a20b37d83ef18efce400b3ace400036e"
image: couchdb:3.1.1
ports:
- "5984:5984"
- "9100:9100"
- "4369:4369"

可以透過 docker compose 來嘗試執行這個檔案:

1
2
3
4
5
$ docker-compose up -d
$ docker-compose ps
Name Command State Ports
-----------------------------------------------------------------------------------------------------------------------------------
couchdb_couchdb_1 tini -- /docker-entrypoint ... Up 0.0.0.0:4369->4369/tcp, 0.0.0.0:5984->5984/tcp, 0.0.0.0:9100->9100/tcp

然後就可以訪問 CouchDB 內建的管理介面: http://<IP>:5984/_utils/,接下來到 setup 頁面依照指示設定 single node,即可。

去 Verify 介面測試是否完成安裝。 去 Verify 介面測試是否完成安裝。

至此就完成 Single Node CouchDB 的安裝,可喜可賀。

Deploy as CouchDB Cluster Mode

剛剛嘗試了一鍵佈署 single node 的 CouchDB,那接下來就來嘗試主角吧 — Cluster Mode

CouchDB 的 cluster mode 設定比起 single node 來的複雜非常多,而且存在許多坑 (都不會有 error log 的坑),我這邊就是紀錄我摸索無數夜晚得出的結果😵

首先我先列出要設定 cluster mode 必須要滿足的條件:

  • 每個 CouchDB 必須要有一樣的 admin & password & secret & erl cookie,這對應到 docker image 的 COUCHDB_USER, COUCHDB_PASSWORD, COUCHDB_SECRET, ERL_FLAGS
  • 每個 CouchDB 必須要可以透過 NODENAME 來互相溝通
  • 每個 CouchDB 必須要有同樣的 uuid

Prepare config.ini

為了要保證大家的 Config 一致,這邊我要用事先準備好的 config.ini,而非透過 yml 的 environment 傳參數,這個方法也是官方建議的方法[1]:

The best way to provide configuration to the %%REPO%% image is to provide a custom ini file to CouchDB, preferably stored in the /opt/couchdb/etc/local.d/ directory. There are many ways to provide this file to the container (via short Dockerfile with FROM + COPY, via Docker Configs, via runtime bind-mount, etc), the details of which are left as an exercise for the reader.

那接下來就來準備 config.ini

1
2
3
4
5
6
7
8
[admins]
admin = -pbkdf2-07fe7c8d94281cafdfa065c0f9dd9b6fae56b649,8a3bfe04b1f4294d89d9e9d250fce77a,10

[couch_httpd_auth]
secret = 46d689495ca02e8c35c3a3f683000ef1

[couchdb]
uuid = 7ff6dd245116a7288b798b003f00099e

這邊就有一個坑,就是 admin 的 password 必須要是 hash 版本的,如果這邊是 plain text 的話,在啟動時 CouchDB 會自動做 hash,然後就會導致三台 CouchDB 的 password 不一致 (同樣的密碼 hash 的結果會不一樣,相關文章: Timing Attack in String Compare );密碼不一致就會出現 unable to sync admin passwords 錯誤。

那關於要如何獲取 hash 過的密碼,官方是推薦透過建立一個 dummy 的 single node,然後去看他 hash 出來的密碼長怎樣,再 copy 過來 (好蠢…)

這邊提供另一個方法,如這篇文章[2]所說,可以透過 python script 產生 hashed password:

1
2
3
$ PASS="admin123" SALT="8a3bfe04b1f4294d89d9e9d250fce77a" ITER=10 \
python3 -c "import os,hashlib; print('-pbkdf2-%s,%s,%s' % (hashlib.pbkdf2_hmac('sha1',os.environ['PASS'].encode(),os.environ['SALT'].encode(),int(os.environ['ITER'].encode())).hex(), os.environ['SALT'], os.environ['ITER']))"
-pbkdf2-07fe7c8d94281cafdfa065c0f9dd9b6fae56b649,8a3bfe04b1f4294d89d9e9d250fce77a,10

Using same config across nodes

官方說了三種方式提供 ini 檔:

  • via Dockerfile COPY — 這太蠢了,每次改 config 都要重新 build
  • via Docker Config — ok 👍
  • via runtime mount — 在 docker swarm 比較不適合,因為不是所有 node 都能夠 mount

所以其實只剩下 Docker Config 較適當。

docker config 設定方式如下,

1
2
3
4
5
6
7
8
9
10
services:
couchdb:
# ...skip
configs:
- source: couchdb_conf
target: /opt/couchdb/etc/local.d/config.ini

configs:
couchdb_conf:
file: ./config.ini

我們將事先準備好的 config.ini 透過 docker config 掛載至所有 CouchDB 的 local.d 資料夾。

這裡有另一個坑,有可能會出現 CrashLoopBackOff 的狀況,我嘗試發現根本沒辦法掛載到 /opt/couchdb/ 底下的任何目錄,大概是 bug 吧,這邊有相關 GitHub issue[3]

為了繞過這個不能 mount 的問題,必須要覆寫 entrypoint,先把 config file copy 到適當的位置再執行原本的 entrypoint,所以會改成這樣:

1
2
3
4
5
6
7
8
9
10
services:
couchdb:
# ...skip
entrypoint: /bin/bash -c "cp -f /couchdb_conf /opt/couchdb/etc/local.d/couch.ini && tini -- /docker-entrypoint.sh /opt/couchdb/bin/couchdb"
configs:
- couchdb_conf

configs:
couchdb_conf:
file: ./config.ini

這樣改意思是先把 config 掛載至別的地方,然後在執行 entrypoint 時先 copy 至正確位置之後再執行原本的指令。

Join All CouchDB Instances as a Cluster

至此我們已經可以撰寫出完整的 docker-swarm.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
version: "3.8"

services:
couchdb:
environment:
NODENAME: "{{.Service.Name}}.{{.Task.Slot}}.{{.Task.ID}}"
ERL_FLAGS: "-setcookie a20b37d83ef18efce400b3ace400036e"
image: couchdb:3.1.1
deploy:
mode: global
networks:
network:
aliases:
- couchdb
ports:
- "5984:5984"
- "9100:9100"
- "4369:4369"
entrypoint: /bin/bash -c "cp -f /couchdb_conf /opt/couchdb/etc/local.d/couch.ini && tini -- /docker-entrypoint.sh /opt/couchdb/bin/couchdb"
configs:
- source: couchdb_conf
target: /opt/couchdb/etc/local.d/config.ini

networks:
network:

configs:
couchdb_conf:
file: ./couchdb-conf.ini

這邊我用 global mode 是因為我希望每一台機器恰好只有一個 CouchDB。(上面的 yml 我也沒有掛 volume 所以資料會在 container 不見時一起消失)。

NODENAME: "{{.Service.Name}}.{{.Task.Slot}}.{{.Task.ID}}" 則是因為透過 docker swarm 佈署時,他的命名規則就是長這樣,在 docker 裡面是可以透過這個 nodename ping 到別台的。要查詢 docker container name 的話只要 docker ps 就可以看到。

接下來就直接佈署:

1
2
3
4
5
6
$ docker stack deploy -c docker-swarm.yaml test
$ docker stack ps test
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE
ynacuaj8tx35 test_couchdb.7zj2xk3up7ce34atj2nme9rf9 couchdb:3.1.1 docker-node-3 Running Running 25 seconds ago
5p5w38jtjh7z test_couchdb.2v2lb55cyes0rf3tbtqe2zp9x couchdb:3.1.1 docker-node-1 Running Running 25 seconds ago
bekjdetq739z test_couchdb.2gedpa6dac3c80ilr3f9ji3fw couchdb:3.1.1 docker-node-2 Running Running 26 seconds ago

每個 node 都起來之後就可以去做最後的設定,CouchDB 設定 Cluster 的方式是透過 admin http api 去把其他 CouchDB 加進某台。

1
2
3
4
5
6
7
8
$ curl -X PUT "http://admin:[email protected]<IP>:5984/_node/_local/_nodes/[email protected]_couchdb.2v2lb55cyes0rf3tbtqe2zp9x.strqjl8lsdm58tozn59mp8du7" -d {}
{"ok":true,"id":"[email protected]_couchdb.2v2lb55cyes0rf3tbtqe2zp9x.strqjl8lsdm58tozn59mp8du7","rev":"1-967a00dff5e02add41819138abb3284d"}

$ curl -X PUT "http://admin:[email protected]<IP>:5984/_node/_local/_nodes/[email protected]_couchdb.7zj2xk3up7ce34atj2nme9rf9.u5ce5bl7cmjlhkb2781cye7py" -d {}
{"ok":true,"id":"[email protected]_couchdb.7zj2xk3up7ce34atj2nme9rf9.u5ce5bl7cmjlhkb2781cye7py","rev":"1-967a00dff5e02add41819138abb3284d"}

$ curl -X POST -H "Content-Type: application/json" "http://admin:[email protected]<IP>:5984/_cluster_setup" -d '{"action": "finish_cluster"}'
{"ok":true}

都加好之後,可以透過 /_membership 檢查是否正確:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl "http://admin:[email protected]<IP>:5984/_membership"
{
"all_nodes": [
"[email protected]_couchdb.2gedpa6dac3c80ilr3f9ji3fw.irqxr1k8e9v8xekae1xtuxxab",
"[email protected]_couchdb.2v2lb55cyes0rf3tbtqe2zp9x.strqjl8lsdm58tozn59mp8du7",
"[email protected]_couchdb.7zj2xk3up7ce34atj2nme9rf9.u5ce5bl7cmjlhkb2781cye7py"
],
"cluster_nodes": [
"[email protected]_couchdb.2gedpa6dac3c80ilr3f9ji3fw.irqxr1k8e9v8xekae1xtuxxab",
"[email protected]_couchdb.2v2lb55cyes0rf3tbtqe2zp9x.strqjl8lsdm58tozn59mp8du7",
"[email protected]_couchdb.7zj2xk3up7ce34atj2nme9rf9.u5ce5bl7cmjlhkb2781cye7py"
]
}

這樣就設定完成啦🎉 (記得再去管理介面 verifyinstall 檢查一次)

Test High Availability

設定好 cluster 之後就要來驗證 HA 是否正常,這邊測試的方法會是先在某台 CouchDB 新增資料,理論上其他台也會可以存取這筆資料:

先建立一個新 database 以及一個新 document:

1
2
3
4
5
$ curl -X PUT "http://admin:[email protected]<server01>:5984/mydatabase"  
{"ok":true}

$ curl -X PUT "http://admin:[email protected]<server01>:5984/mydatabase/01" -d '{"key": "val"}'
{"ok":true,"id":"01","rev":"1-00e36163fac5c61bb681fef0c52528e2"}

接下來這個 document 會自動 replicated 到其他兩台 CouchDB,可以透過分別 curl 每一台來驗證是否有一樣的 document:

1
2
3
4
5
6
7
8
$ curl "http://admin:[email protected]<server01>:5984/mydatabase/01"                    
{"_id":"01","_rev":"1-00e36163fac5c61bb681fef0c52528e2","key":"val"}

$ curl "http://admin:[email protected]<server02>:5984/mydatabase/01"
{"_id":"01","_rev":"1-00e36163fac5c61bb681fef0c52528e2","key":"val"}

$ curl "http://admin:[email protected]<server03>:5984/mydatabase/01"
{"_id":"01","_rev":"1-00e36163fac5c61bb681fef0c52528e2","key":"val"}

這樣即使任意機器掛掉,整個系統都還是可以維持運作。

(實務上再去疊一層 Load Balancer 讓 Http endpoint 統一會更方便)

Conclusion

設定 single node 很簡單,但設定 cluster mode 頗複雜,我個人覺得 error log 沒有非常完整,很多各式各樣的坑都會直接死掉根本不會有任何 log,很崩潰…😱。


References:

[1] Configuring CouchDB
[2] How to generate password hash for CouchDB administrator
[3] Configuration from docker config or secret? #73