Deis 架構分析(二)

延續上一篇的內容,這篇文章要先來討論比較好懂的 Router 部分。

首先,在 Deis 的設計裡面,基本上所有的服務都是包成一個 Image 作為 Continaer 在 CoreOS 運行的。就這點來看,其實是非常符合 Mircoservice 架構的設計。同時我們也可以很輕鬆地將這些服務獨立出來使用,這篇文章討論的 Router 除了原本的用途外,也很適合用來學習透過 etcd 部署自動化更新設定檔的環境。

Deis 的原始碼都放在一起,其中 Router 部分是裡面的一個子目錄,那麼就讓我們開始了解運行的架構吧!

Dockerfile

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
30
FROM alpine:3.2
# install common packages
RUN apk add --update-cache \
bash \
curl \
geoip \
libssl1.0 \
openssl \
pcre \
sudo \
&& rm -rf /var/cache/apk/*
# install confd
RUN curl -sSL -o /usr/local/bin/confd https://s3-us-west-2.amazonaws.com/opdemand/confd-git-73f7489 \
&& chmod +x /usr/local/bin/confd
# add nginx user
RUN addgroup -S nginx && \
adduser -S -G nginx -H -h /opt/nginx -s /sbin/nologin -D nginx
COPY rootfs /
# compile nginx from source
RUN build
CMD ["boot"]
EXPOSE 80 2222 9090
ENV DEIS_RELEASE 1.13.0-dev

透過 Dockerfile 可以簡單了解到這個服務是怎麼啟動的,整體上來說非常簡單,除了安裝 confd 之外。就是把名為 rootfs 的目錄加進去,並且編譯客製化的 Nginx 接著啟動伺服器。

這邊比較特別的地方是客製化編譯 Nginx 的部份,主要是因為 Deis 除了基本的 Nginx 功能外,也增加了防火牆模組(NAXSI)跟一些模組在裡面,有興趣的可以自行閱讀 rootfs/bin/build 這個檔案的內容。

boot

我想大家會覺得奇怪,為什麼不是直接執行 Nginx 而是執行一個名為 boot 的指令呢?
這是因為 Deis 除了啟動 Nginx 之外,還要將像是 confd 之類的服務也一併啟動。

這邊比較有趣的是,一般會用 Shell Script 來處理。但是 Deis 使用 Golang 來做處理。

從 Makefile 可以看出 boot 這個檔案是透過 Golang 的 Cross-compile 功能所編譯後,再構出 Docker 的 Image 來使用。

1
2
3
4
5
build: check-docker
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 godep go build -a -installsuffix -v -ldflags '-s' -o $(BINARY_DEST_DIR)/boot cmd/boot/boot.go || exit 1
@$(call check-static-binary,rootfs/bin/boot)
docker build -t $(IMAGE) .
rm rootfs/bin/boot

所以如果要自己封裝,記得要用 make build 的指令,而不是直接 docker build 否則是會包出無法執行的 Image。

cmd/boot/boot.go 可以發現引用了 logger/stdout_formatter.go 這個檔案,基本上就是統一格式化 Deis 輸出的紀錄檔訊息,因此這邊就不多做討論。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
func main() {
// 略
log.Debug("reading environment variables...")
host := getopt("HOST", "127.0.0.1")
etcdPort := getopt("ETCD_PORT", "4001")
etcdPath := getopt("ETCD_PATH", "/deis/router")
hostEtcdPath := getopt("HOST_ETCD_PATH", "/deis/router/hosts/"+host)
externalPort := getopt("EXTERNAL_PORT", "80")
client := etcd.NewClient([]string{"http://" + host + ":" + etcdPort})
// wait until etcd has discarded potentially stale values
time.Sleep(timeout + 1)
log.Debug("creating required defaults in etcd...")
mkdirEtcd(client, "/deis/config")
mkdirEtcd(client, "/deis/controller")
mkdirEtcd(client, "/deis/services")
mkdirEtcd(client, "/deis/domains")
mkdirEtcd(client, "/deis/builder")
mkdirEtcd(client, "/deis/certs")
mkdirEtcd(client, "/deis/router/hosts")
mkdirEtcd(client, "/deis/router/hsts")
setDefaultEtcd(client, etcdPath+"/gzip", "on")
log.Info("Starting Nginx...")
go tailFile(nginxAccessLog)
go tailFile(nginxErrorLog)
nginxChan := make(chan bool)
go launchNginx(nginxChan)
<-nginxChan
// FIXME: have to launch cron first so generate-certs will generate the files nginx requires
go launchCron()
waitForInitialConfd(host+":"+etcdPort, timeout)
go launchConfd(host + ":" + etcdPort)
go publishService(client, hostEtcdPath, host, externalPort, uint64(ttl.Seconds()))
log.Info("deis-router running...")
exitChan := make(chan os.Signal, 2)
signal.Notify(exitChan, syscall.SIGTERM, syscall.SIGINT)
<-exitChan
tail.Cleanup()
}

基本上,整個 main() 分為兩個部分。

第一個部分是讀取環境變數的部份(透過 getopt() 方法),第二個部分則是啟動各項服務的部份。這邊比較特別的是利用 Golang 的 Channel 功能,做出依序啟動服務的效果。

Golang 的 Channel 在做 receive 動作時,會阻止程式繼續運作。

最後的 signal.Notify 可以設定當接收到一些 Signal 時要做出什麼對應的處理。

這邊接受的是常見的中斷訊號,一般就是 Ctrl + C 會送過去的訊號。

另外,這邊透過 tail 這個函式庫將 Nginx 的記錄檔讀取出來,並且格式化後輸出到 stdout 顯示。

使用 Docker 的好習慣就是要將當前運行的程式輸出一律導向 stdout / stderr 讓 Docker 來幫忙做記錄,否則所有的 Log 都會被存在 Container 裡面反而難以除錯。

confd

confd 會監聽 etcd 的 key-value 變動情況,然後動態的執行一些指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[template]
src = "nginx.conf"
dest = "/opt/nginx/conf/nginx.conf"
uid = 0
gid = 0
mode = "0644"
keys = [
"/deis/config",
"/deis/services",
"/deis/router",
"/deis/domains",
"/deis/controller",
"/deis/builder",
"/deis/store/gateway",
"/deis/certs",
]
check_cmd = "check {{ .src }}"
reload_cmd = "/opt/nginx/sbin/nginx -s reload"

以 Nginx 的重起來說,當上述指定的 Key (如 /deis/domains)有更動,那麼就會先從樣板檔案產生新的設定檔,並且重新啟動 Nginx。

有興趣的話可以去看看 rootfs/etc/confd 的設定檔是如何撰寫的。

比較有趣的是 /bin/generate-certs 這個指令,他也是透過 confd 去產生的。

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
30
31
32
33
34
35
#!/usr/bin/env bash
# create or truncate the file
> /etc/ssl/deis_certs
{{ range $cert := ls "/deis/certs" }}
echo {{ $cert }} >> /etc/ssl/deis_certs
{{ end }}
CERT_PATH=/etc/ssl/deis/certs
KEY_PATH=/etc/ssl/deis/keys
# clean up all certs
rm -rf $CERT_PATH
rm -rf $KEY_PATH
# ...then re-create the paths
mkdir -p $CERT_PATH
mkdir -p $KEY_PATH
{{ if gt (len (lsdir "/deis/certs")) 0 }}
while read etcd_path; do
{{ range $cert := ls "/deis/certs" }}
if [[ "$etcd_path" == "{{ $cert }}" ]]; then
cat << EOF > "$CERT_PATH/$etcd_path.cert"
{{ getv (printf "/deis/certs/%s/cert" $cert) }}
EOF
cat << EOF > "$KEY_PATH/$etcd_path.key"
{{ getv (printf "/deis/certs/%s/key" $cert) }}
EOF
fi{{ end }}
done < /etc/ssl/deis_certs
{{ else }}
# there is no certificates to generate
{{ end }}

這邊很有趣的是,他會從 etcd 裡面讀取每一組 SSL 的 Private Key / Certificates 並且依照 Key 產生一組檔案來寫入檔案。

如此一來每一個不同 Domain 所需的 SSL 設定就可以透過 etcd 來做管理。不過其實另一方面來看,這個檔案其實會不斷地增長⋯⋯
/bin/boot 中,初次執行會設定一個 Cron Job 去執行這個指令,理由還不清楚不過應該是為了修正檔案沒有順利產生之類的問題吧(就註解來看是一個修正)


到此為止,基本上一個簡單的 Router 就算是設定完成了。
如果對防火牆設定有興趣的話,可以參考 rootfs/opt/nginx 裡面的設定是如何撰寫的。

大致上剩下的都是設定檔的部份,稍微詳讀之後就可以瞭解其背後運作的原理。
若要建構自己的簡易 Router 參考這樣的方式設計,其實也沒有想像中的困難。

留言