蒼時弦也
蒼時弦也
資深軟體工程師
發表於

Deis 架構分析(二)

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

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

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

Dockerfile

 1FROM alpine:3.2
 2
 3# install common packages
 4RUN apk add --update-cache \
 5  bash \
 6  curl \
 7  geoip \
 8  libssl1.0 \
 9  openssl \
10  pcre \
11  sudo \
12  && rm -rf /var/cache/apk/*
13
14# install confd
15RUN curl -sSL -o /usr/local/bin/confd https://s3-us-west-2.amazonaws.com/opdemand/confd-git-73f7489 \
16  && chmod +x /usr/local/bin/confd
17
18# add nginx user
19RUN addgroup -S nginx && \
20  adduser -S -G nginx -H -h /opt/nginx -s /sbin/nologin -D nginx
21
22COPY rootfs /
23
24# compile nginx from source
25RUN build
26
27CMD ["boot"]
28EXPOSE 80 2222 9090
29
30ENV 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 來使用。

1build: check-docker
2  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
3  @$(call check-static-binary,rootfs/bin/boot)
4  docker build -t $(IMAGE) .
5  rm rootfs/bin/boot

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

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

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

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

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

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

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

 1#!/usr/bin/env bash
 2
 3# create or truncate the file
 4> /etc/ssl/deis_certs
 5
 6{{ range $cert := ls "/deis/certs" }}
 7echo {{ $cert }} >> /etc/ssl/deis_certs
 8{{ end }}
 9
10CERT_PATH=/etc/ssl/deis/certs
11KEY_PATH=/etc/ssl/deis/keys
12
13# clean up all certs
14rm -rf $CERT_PATH
15rm -rf $KEY_PATH
16
17# ...then re-create the paths
18mkdir -p $CERT_PATH
19mkdir -p $KEY_PATH
20
21{{ if gt (len (lsdir "/deis/certs")) 0 }}
22while read etcd_path; do
23   {{ range $cert := ls "/deis/certs" }}
24   if [[ "$etcd_path" == "{{ $cert }}" ]]; then
25     cat << EOF > "$CERT_PATH/$etcd_path.cert"
26{{ getv (printf "/deis/certs/%s/cert" $cert) }}
27EOF
28     cat << EOF > "$KEY_PATH/$etcd_path.key"
29{{ getv (printf "/deis/certs/%s/key" $cert) }}
30EOF
31   fi{{ end }}
32done < /etc/ssl/deis_certs
33{{ else }}
34# there is no certificates to generate
35{{ end }}

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

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


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

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