分布式物聯網邊緣計算服務設計與實現


1 引言

1.1 課題的研究背景和意義

到2020年,全球聯網的設備已超過200億台,隨着IPv6主干網建設和5G移動網絡的部署,將有更多的物聯網設備之間連接到互聯網。雲端需要處理越來越多的邊緣端設備數據,同時邊緣端設備的安全性也面臨着不小的挑戰。

無人駕駛汽車的傳感器和攝像頭,實時捕捉路況信息,每秒大約會產生1GB的數據,如果無人駕駛判斷依賴於雲服務,不能保證服務的隨時可用性和穩定性,而且還會給服務器造成不少的帶寬壓力。所以無人駕駛汽車這類產品需要能自身進行計算而不依賴於互聯網。再以智能家居為例,越來越多的家庭設備依賴於雲計算平台來進行控制,一旦網絡出現故障,即使家里仍然有電,設備也不能很好的控制了。現在市場上幾乎所有的智能音箱產品,只要脫離了互聯網運行,就是電子廢品,不管問什么問題,甚至是問個時間,都只會回答“請先連接到網絡”。

邊緣計算架構是將每個邊緣節點設備賦予自主計算能力,簡單來講就是在邊緣物聯網傳感器網絡中加入一個高級網關,這個網關負責對傳感器數據分析或存儲,將部分信息、結果信息或報警信息上報到雲服務。比如道路上的攝像頭,邊緣網關節點通過機器學習訓練好的識別模型對攝像頭畫面進行汽車違規分析,假如識別到了違規情況和違規的車牌號,網關則會將這條違規信息上報到服務器。

本課題將雲端的分布式服務架構整合到邊緣計算架構中,並引入DevOps開發模型。將雲端技術應用到邊緣物聯網設備中。同時能在邊緣端支持橫向拓展、應用隔離、服務發現、負載均衡、滾動升級。

1.2 國內外研究現狀

1.2.1 國內研究現狀

KubeEdge是華為在2018年開源的面向邊緣環境容器管理平台,KubeEdge能夠接入雲端Kubernetes集群,使得邊緣應用的管理可以跟雲端應用的管理一樣,采用Kubernetes API。KubeEdge是一個開源系統,將本機容器化應用程序業務流程和設備管理擴展到邊緣的主機。它基於 Kubernetes 構建,為雲和邊緣之間的網絡、應用程序部署和元數據同步提供核心基礎設施支持。它還支持 MQTT,並允許開發人員在 Edge 上編寫自定義邏輯並啟用資源受限的設備通信。

KubeEdge架構

KubeEdge分為雲端節點和邊緣節點,雲端節點依賴於Kubernetes集群,雲端節點實現了一套Kubernetes API,將邊緣節點與Kubernetes集群整合到一起管理。目前KubuEdge還處於初級開發版本,很多Kubernetes的基本組件還不支持(如:Persistent Volume、Ingress、Secret),這也給集群管理產生了很多麻煩。

1.2.2 國外研究現狀

K3S是Rancher出品的一個開源、簡化、輕量Kubernetes,為物聯網和邊緣計算建立分布式的Kubernetes。支持ARM64和ARMv7架構,從很小的樹莓派到AWS的a1.4xlarge 32GiB服務器都能運行。K3S只有一個不到40MB的可執行二進制文件,並且內置了容器引擎而不需要額外安裝Docker,減少了安裝、運行和更新Kubernetes集群所需的依賴性和步驟。

K3S架構

K3S移除了所有Beta版的API,與Kubernetes架構基本差別不大。master節點用了更輕便的SQLite代替了k8s的etcd數據庫,邊緣節點使用了內置的容器引擎來代替Docker CE引擎。還可以使用Flannel網絡組件打通集群Pod之間的網絡通訊。K3S除了可以在物聯網邊緣計算場景中使用,還可以在ARM服務器集群中運行分布式應用。

1.3 本課題的研究內容及章節安排

1.3.1 課題研究的內容

通過查閱大量現有的邊緣計算資料和書籍,結合多年來對雲計算技術的學習和了解以及分析現有分布式邊緣計算架構的特點和缺陷,通過雲端K8S集群+邊緣端K3S+邊緣服務發現+DevOps自動化部署,實現了一套易管理、易擴展、易部署的分布式物聯網邊緣計算架構。

首先將分布式物聯網邊緣計算架構分為雲-邊-端三層。雲端包括完整的Kubernetes集群、Azure DevOps Agent服務、Rancher集群管理服務、NFS服務、K3S Server。邊緣端作為物聯網邊緣網關運行K3S Agent,邊緣端與雲端共享Config Map、Secert、Persistent Volume,並集成DDNS以更好的支持IPv6網絡訪問,集成frpc以穿透IPv4 NAT網絡進行直接訪問。設備端可以通過端口掃描、UDP廣播、DNS等方式掃描局域網內的邊緣節點,當前網絡如果不存在邊緣節點,設備端也可以直接連接到雲端工作。

NFS服務器作為Kubernetes的一個Persistent Volume用來保存跨集群節點使用的持久化數據(如:配置文件、用戶上傳的文件等),Rancher集群管理服務可以同時管理雲端的Kubernetes集群和邊緣端的k3s集群,同時給Azure DevOps提供應用部署接口API。DDNS服務通過阿里雲DNS解析API將所有邊緣節點的IPv4和IPv6地址解析在域名<邊緣節點主機名>.zuozishi.online上。frpc服務將邊緣節點需要遠程訪問和管理的端口通過frp服務穿透到公網服務器,這樣就能突破IPv4 NAT網絡直接訪問到邊緣節點,這是一種IPv4到IPv6的過渡解決方案。

所有的雲端、邊緣端、設備端代碼托管在Github和Azure DevOps Repos平台上,當本地代碼推送到在線倉庫時,觸發Azure Pipelines自動化編譯、測試、部署事件,將應用鏡像推送到阿里雲鏡像庫,通過應用部署配置文件將應用部署到雲端、邊緣端或設備端。雲端、邊緣端或設備端會根據配置文件從阿里雲鏡像庫拉取鏡像並運行。

雲端、邊緣端示例應用為網絡音樂播放服務。網絡音樂播放服務使用ASP .Net Core 3.1框架,實現用戶使用酷狗賬號登錄、用戶播放列表、音樂搜索、獲取音樂歌詞和URL等API接口,提供一個簡單的Web訪問界面,通過SignalR協議與設備端連接並通信。 設備端通過UDP廣播包查詢本地邊緣節點,然后使用SignalR協議連接,實時報告音樂播放情況並接收控制指令。手機APP也在這個應用場景中作為設備端,一樣通過UDP廣播包查詢本地邊緣節點然后連接並通信。如果本地不存在邊緣節點,音樂播放設備和手機APP將直接連到雲端部署的應用進行工作。

1.3.2 論文章節安排

本文章節安排如下:

第一章、引言。介紹分布式邊緣計算的背景和意義,介紹國內外分布式邊緣計算架構的發展和研究現狀,介紹論文的主要研究內容和章節安排。

第二章、分布式邊緣計算相關技術。介紹邊緣計算和分布式的概念和特點,介紹虛擬化和容器技術的發展。

第三章、雲端架構的分析與設計。闡述了分布式物聯網邊緣計算架構中服務端服務器的規划和重要服務的部署。

第四章、邊緣端架構的分析與設計。闡述了分布式物聯網邊緣計算架構中邊緣端的配置規划和重要服務。

第五章、應用的設計與實現。以音樂播放服務為例說明邊緣網關和邊緣設備以及雲端的實際應用層設計。

第六章、系統關鍵代碼及配置。列出了從雲端、邊緣網關到設備三方的關鍵代碼和配置文件。

第七章、系統測試及改進。對雲端、邊緣網關和設備進行服務和功能測試,並針對現有不足做出改進方案。

2 分布式邊緣計算相關技術

2.1 邊緣計算的概念和特點

邊緣計算采用一種分散式運算的架構,將之前由網絡中心節點處理的應用程序、數據資料與服務的運算交由網絡邏輯上的邊緣節點處理。邊緣計算將大型服務進行分解,切割成更小和更容易管理的部分,把原本完全由中心節點處理的大型服務分散到邊緣節點。而邊緣節點更接近用戶終端裝置,這一特點顯著提高了數據處理速度與傳送速度,進一步降低時延。

邊緣計算作為雲計算模型的擴展和延伸,直面目前集中式雲計算模型的發展短板,具有緩解網絡帶寬壓力、增強服務響應能力、保護隱私數據等特征。在智慧城市、智能制造、智能交通、智能家居、智能零售以及視頻監控系統等領域,邊緣計算都在扮演着先進的改革者形象,推動傳統的“雲到端”演進為“雲-邊-端”的新型計算架構。

邊緣計算架構

2.2 虛擬化和容器技術

虛擬化是雲計算的重要技術,可以將物理資源彈性地分配給客戶。主機虛擬化的思想可以追溯到 IBM 機器的邏輯分區,即把一台 IBM 機器划分成若干台邏輯的服務器,每台邏輯服務器擁有獨占的計算資源(CPU、內存、硬盤、網卡),可以單獨安裝和運行操作系統。IBM 機器價格昂貴,相對於當時的計算任務來說,機器的計算能力太過強大,所以需要划分為更小的計算單元。

后來隨着個人計算機處理能力的不斷發展,1998 年 VMware 公司成立,這家公司專注於機器虛擬化的軟件解決方案。也就是說,對於不支持邏輯分區的計算機,可以直接通過安裝 VMware 虛擬化軟件來模擬更多的虛擬機,然后再在這些虛擬機里安裝操作系統和應用軟件,可以給虛擬機靈活配置內存、CPU、硬盤和網卡等資源。

虛擬化技術

但是在每台虛擬機里都要安裝和運行操作系統的做法,仍然浪費了很多計算資源。為此,有公司專門推出了應用軟件容器產品,即在操作系統層上創建一個個容器,這些容器共享下層的操作系統內核和硬件資源,但是每個容器可單獨限制 CPU、內存、硬盤和網絡帶寬容量,並且擁有單獨的 IP 地址和操作系統管理員賬戶,可以關閉和重啟。與虛擬機最大的不同是,容器里不用再安裝操作系統,因此浪費的計算資源也就大大減少了,這樣同樣一台計算機就可以服務於更多的租戶。

容器技術

2.3 分布式架構和Kubernetes

分布式系統是一個硬件或軟件組件分布在不同的網絡計算機上,彼此之間僅僅通過消息傳遞進行通信和協調的系統。

在3G移動網絡發展初期,隨着上網人數的飆升和人均上網帶寬增加,單服務器應用已經不能滿足需求,雲計算開始發展,企業也開始將一個應用的不同組件放在多個服務器上來均衡流量和提高可用性。例如像淘寶這類的網站,要解決的重點問題就是海量商品搜索、下單、支付等問題; 像騰訊這類的網站,要解決的是數億級別用戶的實時消息傳輸;而像百度這類的公司所要解決的又是海量數據的搜索。每一個種類的業務都有自己不同的系統架構。

分布式架構應用

Kubernetes 是用於自動部署,擴展和管理容器化應用程序的開源系統。它將組成應用程序的容器組合成邏輯單元,以便於管理和服務發現。Kubernetes 源自Google 15 年生產環境的運維經驗,同時凝聚了社區的最佳創意和實踐。Google 每周運行數十億個容器,Kubernetes 基於與之相同的原則來設計,能夠在不擴張運維團隊的情況下進行規模擴展。Kubernetes 是開源系統,可以自由地部署在企業內部,私有雲、混合雲或公有雲。

Kubernetes是一個全新的的分布式架構領先方案,它基於容器技術,目的是實現資源管理的自動化,以及多個數據中心的資源利用率的最大化。通常,我們會把Kubernetes看作Docker的上層架構,就好像Java與J2EE的關系一樣:J2EE是以Java為基礎的企業級軟件架構,Kubernetes則以Docker為基礎打造了一個雲計算時代的全新分布式系統架構。

Kubernetes並不是依賴於Docker,也可以支持其他的容器引擎,如:Rocket、lxd 、rkt。

3 雲端架構的分析與設計

3.1 雲端基礎架構配置和規划

雲端服務器使用了五台阿里雲的ECS(Elastic Compute Service)學生免費版入門級服務器,詳細配置如下:

  • CPU:2核

  • 內存:4GiB

  • 操作系統:CentOS 7.7 64位

  • 獨立公網IP

  • 公網帶寬:1Mbps

  • 系統盤:40GiB (2120 IOPS)

  • 地域:阿里雲-華北2(北京)

注:由於個人能力,實驗集群只在阿里雲北京區域中搭建,如果跨不同地區搭建集群,則可以實現多地負載均衡,不過需要收取跨地區通信的流量費。

由於五台機器屬於不同的阿里雲賬號下,所以這五台機器不屬於一個局域網,需要先在阿里雲平台配置跨賬號的雲企業網,配置雲企業網的實質則是配置這五個機器內網網段的路由表,配置完成后則認為這五台機器通過這個路由表進行跨網段通信。

雲企業網

其中一台機器作為master節點,兩台機器作為node工作節點,一台服務器用來部署NFS文件共享服務、Azure Pipelines Agent和Rancher集群管理服務,一台作為k3s的master節點。

主機名 內網IP 外網IP 角色
Zuozishi-Master 172.17.151.242 39.107.139.105 k8s master
Zuozishi-Slave1 172.17.57.231 39.97.161.176 k8s工作節點
Zuozishi-Slave2 172.17.77.128 47.93.236.68 k8s工作節點
Aliyun-YXM 172.17.196.228 39.97.114.49 k3s master、frps服務
Aliyun-TXR 172.17.81.0 47.94.90.203 NFS服務、集群管理者

考慮到外部連接集群和應用的安全性,盡量使用了HTTPS協議,所以先要為主機申請SSL證書,本項目使用在阿里雲申請的一年有效期的免費SSL證書。

SSL證書申請

3.2 Kubernetes集群配置

graph TB subgraph 阿里雲 A[k8s主節點] --> B[k8s工作節點1] A[k8s主節點] --> C[k8s工作節點2] end

修改需要安裝Kubernetes所有主機的Hosts:

echo > /etc/hosts < EOF
::1 localhost
127.0.0.1 localhost
172.17.57.231   Zuozishi-Slave1
172.17.77.128   Zuozishi-Slave2
172.17.196.228  Zuozishi-Slave3
172.17.81.0     Aliyun-TXR      Zuozishi-NFS
172.17.151.242  Zuozishi-Master
172.17.151.242  Zuozishi-Master Zuozishi-Master
EOF

在master節點運行自己編寫的一鍵配置/安裝腳本,腳本大概過程如下:

  1. 關閉防火牆

  2. 配置軟件安裝源(yum)為阿里雲鏡像源

  3. 安裝相關工具(wget、net-tools、sshpass)、docker-ce(容器運行引擎)、docker-ce-cli(容器引擎命令行管理工具)、kubelet(k8s核心)、kubeadm(k8s部署工具)、kubectl(k8s命令行管理工具)

  4. 下載Kubernetes所依賴的Docker鏡像

  5. 配置Kubernetes容器內網絡(安裝weave網絡插件)

  6. 安裝kubernetes-dashboard(Web管理工具)

  7. 配置用於遠程訪問的SSL證書

工作節點配置與master前4個步驟相同,最后運行kubeadm join命令將該主機向master注冊為工作節點即可。

3.3 K3S集群配置

graph TB subgraph 阿里雲 A[k3s主節點] end subgraph 邊緣2 A --> B[邊緣網關] B --> 設備03 B --> 設備04 end subgraph 邊緣1 A --> C[邊緣網關] C --> 設備01 C --> 設備02 end

k3s相比k8s的安裝簡單不少,只需要運行幾句命令:

# master節點
curl -sfL https://get.k3s.io | sh -

# 安裝Web管理工具
GITHUB_URL=https://github.com/kubernetes/dashboard/releases
VERSION_KUBE_DASHBOARD=$(curl -w '%{url_effective}' -I -L -s -S ${GITHUB_URL}/latest -o /dev/null | sed -e 's|.*/||')
k3s kubectl create -f https://raw.githubusercontent.com/kubernetes/dashboard/${VERSION_KUBE_DASHBOARD}/aio/deploy/recommended.yaml

# 編譯節點
export K3S_URL=39.97.114.49
export K3S_TOKEN=<密鑰>
curl -sfL https://get.k3s.io | sh -

3.4 NFS服務器配置

NFS 即網絡文件系統(Network File System),可以通過網絡讓不同機器、不同系統之間可以實現文件共享。在該項目中,NFS服務負責保存集群應用中所需的配置文件和一致性文件(即在集群環境中,多台機器訪問同一份文件)。通過Kubernets的Persistent Volume(持久卷),將NFS掛載到容器內,多個容器就能訪問到同一份文件。

graph TB subgraph 阿里雲 A[k8s主節點] --> B[k8s工作節點1] A[k8s主節點] --> C[k8s工作節點2] D[NFS服務器] D -- 配置文件 --> B D -- 配置文件 --> C end

NFS服務器配置如下:

  1. 安裝 NFS 軟件包yum install -y nfs-common nfs-utils rpcbind

  2. 添加 NFS 共享目錄

    /nfsroot目錄設置為NFS共享目錄,設置改目錄的權限為最寬松的權限chmod 777 /nfsroot

  3. 啟動NFS服務/etc/init.d/nfs-kernel-server start

3.5 多集群管理服務配置

多集群管理服務Rancher可以在一個Web應用中管理多個k8s或k3s集群,並通過提供的API接口來通過微軟雲DevOps遠程管理集群和部署應用。

graph TB subgraph 阿里雲 A[Rancher] --> B[k8s主節點] A --> C[k3s主節點] end

本項目的Rancher服務安裝在獨立於k8s集群的服務器中,運行在Docker容器中,使用devops.zuozishi.info域名的HTTPS證書,暴露6443端口訪問。安裝命令如下:

docker run -d --restart=unless-stopped \
  -p 6443:443 \
  -v <FULL_CHAIN.pem>:/etc/rancher/ssl/cert.pem \
  -v <PRIVATE_KEY.pem>:/etc/rancher/ssl/key.pem \
  -v <CA_CERTS.pem>:/etc/rancher/ssl/cacerts.pem \
  rancher/rancher:latest

由於域名未經備案,80端口和443端口無法使用。

安裝完成后通過地址https://devops.zuozishi.info:6443訪問管理頁面,設置初始密碼。然后將k8s和k3s集群加入到管理中心。最后再新建一個遠程訪問API的密鑰,用於遠程部署應用。

3.6 Azure DevOps Agent配置

在雲端架構中,采用了混合雲模式。基礎服務和應用運行在阿里雲ECS中,可執行文件以容器的方式保存在阿里雲容器鏡像服務中。而代碼和自動化測試編譯由微軟雲DevOps負責。在測試編譯環節中,代碼需要編譯成多架構版本的(包括x86_64和arm、arm64)可執行文件,同時滿足在雲端環境(x64)和邊緣環境(arm或arm64)運行。所以當代碼push到倉庫時,Azure DevOps會分別在x64和arm架構服務器中編譯代碼,這里負責編譯代碼的服務器就稱之為Azure DevOps Agent。Azure DevOps中會給賬號分配一個免費的agent(x64架構),因為應用要在編譯節點樹莓派(arm架構)中運行,需要配置一台arm架構的agent服務器負責編譯代碼生成arm架構的可執行程序。值得一提的是,在2020年前后,arm在服務器市場越來越有優勢。

graph TB A([開發者推送代碼]) --> B subgraph Azure DevOps B[(代碼倉庫)] B --> C B --> D B --> E subgraph 編譯 C[x86_64 Agent] D[ARM Agent] E[ARM64 Agent] end end subgraph 阿里雲 G[(阿里雲容器鏡像庫)] C -- x86_64架構應用鏡像 --> G D -- ARM架構應用鏡像 --> G E -- ARM64架構應用鏡像 --> G end

Azure DevOps Agent服務配置過程如下:

  1. 登錄Azure DevOps平台

  2. 在個人中心頁面中創建一個API Key用於agent用戶認證

  3. 下載arm版本的agent服務程序

  4. 運行./config.sh,配置API Key

  5. 運行agent服務

配置Agent

4 邊緣端架構的分析與設計

4.1 邊緣端基礎架構配置和規划

邊緣端包括邊緣網關和設備,邊緣網關主要負責連接設備並接收和處理設備發送的數據。邊緣網關作為k3s工作節點,可以通過k3s主節點進行管理,邊緣應用通過k3s主節點進行下發,以容器方式運行在邊緣網關,應用下發后,即使邊緣網關失去了對主節點的連接,也不會影響現有應用的運行,但是無法及時對應用進行更新。

graph TD subgraph 阿里雲 A[k3s主節點] end subgraph 邊緣 A -.下發應用.-> D[邊緣網關] E[設備1] --> D F[設備2] --> D end

在本項目中,使用虛擬機作為邊緣網關,使用iTop-4413 ARM9開發板和樹莓派4B作為邊緣設備。

虛擬機的主要參數如下:

  • CPU:2核

  • 內存:4GiB

  • 操作系統:Ubuntu 20.04 64位

  • 系統盤:50GiB

  • IPv6支持

樹莓派4B的主要參數如下:

  • SOC:Boradcom BCM2711

  • CPU:64位 1.5GHz 四核(28nm工藝)

  • 內存:4GB LPDDR4

  • GPU:Boradcom VideoCore VI @ 500MHz

  • 供電接口:Type-C(5V 3A)

  • WiFi網絡:802.11AC 無線 2.4GHz/5GHz 雙頻

  • 有線網絡:真千兆以太網

  • 操作系統:Raspbian Buster with desktop(32位系統)

iTop-4412開發板主要參數如下:

  • CPU:Exynos4412,四核Cortex-A9,主頻為1.4GHz-1.6GHz

  • 內存:2GB 雙通道 DDR3

  • 供電接口:5V/2A

  • 有線網絡:10M/100M自適應網口

  • I/O接口:耳機輸出、MIC輸入、2路串口、1路A/D、攝像頭接口、WiFi接口、HDMI輸出、2路USB Host、1路USB OTG、GPIO(20PIN)、LCD接口(2個LVDS接口、1個RGB接口)、MIPI接口、GPS接口、JTAG接口

  • 操作系統:Android 4.4(API Level 19)

4.2 Aliyun-DDNS服務設計

Aliyun-DDNS負責把邊緣網關的外部IPv4和IPv6地址通過阿里雲DNS解析服務解析到域名<邊緣網關主機名>.zuozishi.online,如果邊緣網關使用公網IP或支持IPv6網絡,則可以通過解析的域名直接能遠程訪問到邊緣網關,方便管理。

4.3 frp服務設計

frps服務主要針對於IPv4的NAT網絡和非IPv6網絡環境中,是對邊緣網關進行遠程訪問的一種額外手段,通過端口轉發方式,將處於內網的邊緣網關通過frp服務將邊緣網關的某個端口映射到具有公網IP主機的某個端口上。

sequenceDiagram 內網主機->>公網主機: 通過7000端口連接到frps服務 Note over 內網主機,公網主機: frp服務生命周期內保持連接 內網主機->>公網主機: 注冊配置文件 Note over 內網主機,公網主機: frps和frpc端口映射關系 客戶端->>公網主機: 通過8080端口訪問 公網主機->>內網主機: 通過7000端口長連接請求8080端口數據 內網主機->>公網主機: 網頁數據 公網主機->>客戶端: 網頁數據

frps:指frp server(frp服務端,公網主機)
frpc:指frp client(frp客戶端,內網主機)

4.4 服務發現(SSDP)

邊緣物聯網設備通過服務發現來找到局域網中可以連接的邊緣網關,服務發現使用簡單服務發現協議(SSDP,Simple Service Discovery Protocol)實現。邊緣網關通過SSDP協議在網絡中注冊為UPnP邊緣網關設備,通過Kubernetes API(邊緣網關為k3s,但是API是兼容的)將可用的邊緣應用作為UPnP屬性。物聯網設備端掃描網絡中可用的UPnP設備,通過UPnP設備屬性判斷局域網中有沒有可用的邊緣網關,然后通過SSDP協議中的xml鏈接獲取UPnP設備描述文件,設備描述文件包括邊緣網關中運行的服務和服務端口號,然后設備端進行連接。

簡單服務發現協議(SSDP)是一種應用層協議,是構成通用即插即用(UPnP)技術的核心協議之一。簡單服務發現協議提供了在局部網絡里面發現設備的機制。

sequenceDiagram 邊緣設備->>邊緣網關: 發送M-SEARCH UDP廣播 邊緣網關->>邊緣設備: 響應搜索請求 邊緣設備->>邊緣網關: 獲取UPnP設備描述文件 邊緣網關->>邊緣設備: 響應設備信息和服務信息 邊緣設備->>邊緣網關: 通過服務端口連接服務

邊緣網關SSDP服務使用JavaScript語言開發,運行在NodeJS環境,使用express組件提供HTTP協議服務。
主要代碼如下:

// 注冊為UPnP設備
server.addUSN('upnp:rootdevice')
// 注冊為邊緣網關
server.addUSN('urn:schemas-upnp-org:device:EdgeGateway:1')
// 注冊k3s-nodePort類型服務發現服務
server.addUSN('urn:schemas-upnp-org:service:k3s-nodePort-Service:1')
// 啟動SSDP服務
server.start()
    .catch(e => {
        console.log('SSDP服務啟動失敗:', e)
    })
    .then(() => {
        console.log('SSDP服務已啟動')
    })

// 注冊UPnP設備描述文件訪問路徑
app.get('/device.xml', (req, res) => {
    res.contentType('text/xml')
    // 從文件中讀取
    var xml = fs.readFileSync('./device.xml', 'utf8').toString()
    // 替換主機名
    xml = xml.replace('{hostname}', os.hostname()).replace('{hostname}', os.hostname())
    // HTTP請求返回數據
    res.send(xml)
})

// 注冊k3s服務發現訪問路徑
app.get('/service', (req, res) => { 
    res.contentType('application/json')
    var services = []
    // 通過Rancher API查詢k3s服務
    rp({
        uri: 'https://devops.zuozishi.info:6443/v3/project/c-cx29q:p-2hlhh/services/',
        headers: {
            'Authorization': 'Bearer <token>'
        },
        json: true
    }).then((obj) => {
        if ('data' in obj) {
            // 遍歷所有服務
            obj['data'].forEach(item => {
                // 找到類型為NodePort的服務
                if (item['kind'] == 'NodePort' && 'publicEndpoints' in item) {
                    item['publicEndpoints'].forEach(endpoint => {
                        var service = endpoint
                        service['name'] = item['name']
                        service['namespace'] = item['namespaceId']
                        services.push(service)
                    })
                }
            });
        }
        // HTTP請求返回數據
        res.send(services)
    }).catch((e) => {
        // HTTP請求返回數據
        res.send(services)
    })
})

5. 應用的設計與實現

5.1 音樂播放服務

5.1.1 概述

音樂播放服務運行在邊緣端,提供用戶登錄、實時控制、播放列表、網絡資源搜索等功能。基於ASP Net Core框架,使用Visual Studio IDE進行開發,開發語言:C#。

.NET Core是一個能在Windows、Linux、macOS平台上運行Web服務和命令行應用的跨平台的.NET框架。

音樂播放服務主要使用了ASP Net Core的三大模塊:API Controller、Razor Pages、SignalR Hub。

  • API Controller: 提供音樂搜索、用戶登錄、播放列表等RESTful API
  • Razor Pages: 提供網頁頁面、渲染頁面
  • SignalR Hub: 提供播放器和播放控制端的實時通信通道(實現實時控制和日志上報)

5.1.2 RESTful API設計

RESTFUL是一種網絡應用程序的設計風格和開發方式,基於HTTP,可以使用XML格式定義或JSON格式定義(項目中使用JSON格式)。API分為登錄、在線資源、播放列表大三類別,服務使用酷狗用戶認證。

  1. 登錄相關:
方法 路徑 功能 請求參數
PSOT api/login/update 更新用戶Cookie Cookie(body)
GET api/login/check 檢測用戶Cookie是否有效 Cookie(header)
  1. 在線資源相關:
方法 路徑 功能 請求參數
GET api/presearch/{keyword} 獲取預搜索結果 keyword-關鍵詞
GET api/search/{keyword} 搜索網絡資源 keyword-關鍵詞
GET api/search/{keyword}/source 搜索並返回第一個播放源 keyword-關鍵詞
GET api/song/{hash} 獲取歌曲信息 hash-歌曲哈希值
GET api/mv/{hash} 獲取MV信息 hash-MV哈希值
GET api/mv/{hash}/source} 獲取MV播放源 hash-MV哈希值
GET api/lrc/{keyword}/{duration} 獲取歌詞 hash-歌曲哈希值
duration-歌曲總時長
  1. 播放列表相關:
方法 路徑 功能 請求參數
GET playlist/isfav/{mid} 判斷是否是喜歡的音樂 mid-音樂ID
GET api/playlist/fav 獲取喜歡的音樂列表 keyword-關鍵詞
POST api/playlist/fav 將歌曲添加到喜歡的音樂 歌曲JSON數據(body)
DELETE api/playlist/fav/{mid} 將歌曲從喜歡的音樂列表中移除 mid-音樂ID
GET api/playlist/all 獲取全部播放列表
POST api/playlist/song/{id} 將歌曲添加到播放列表 歌曲JSON數據(body)
DELETE api/playlist/song/{id}/{mid} 將歌曲從播放樂列表中移除 id-播放列表ID
mid-音樂ID
GET api/playlist/isin/{id}/{mid} 判斷音樂是否在播放列表中 id-播放列表ID
mid-音樂ID

5.1.3 SignalR消息定義

ASP.NET Core SignalR是一種開放源代碼庫,可簡化將實時 web 功能添加到應用程序的功能。 實時 web 功能使服務器端代碼可以立即將內容推送到客戶端。SignalR會自動選擇服務器和客戶端功能內的最佳傳輸方法,包括:WebSocket、服務器發送的事件、長輪詢。

SignalR使用“中心”在客戶端和服務器之間進行通信。“中心”是一種高級管道,它允許客戶端和服務器分別調用方法。 SignalR自動處理跨計算機邊界的調度,使客戶端能夠在服務器上調用方法,反之亦然。 可以將強類型參數傳遞給方法,從而啟用模型綁定。 SignalR提供了兩個內置的集線器協議:基於 JSON 的文本協議和基於MessagePack的二進制協議。 與 JSON 相比,MessagePack 通常會創建較小的消息。

音樂播放服務的SignalR“中心”接口定義如下表:

函數名 參數 功能
WriteLog 日志類別, 消息 上報日志
RegisterPlayer 播放器ID 注冊為播放器
RegisterClient 客戶端ID,客戶端群組ID 注冊為客戶端
PropertyChanged JSON數據 播放器數據變動
RequireData 請求播放器重發數據
PlayListChanged 播放列表ID 播放列表變動
EnumPlayers 枚舉播放器
EnumPlayersCallback 播放器ID 枚舉播放器回調
SearchAndPlay 關鍵詞 搜索並播放
MediaCtrl 類別,數據 媒體控制

無論任何語言和任何平台,只要播放端和控制端滿足上述接口定義,就能實現遠程音樂播放器控制。

5.1.4 網頁頁面設計

服務提供兩個頁面,主頁和API文檔。主頁可以對連接到服務的播放器進行控制,支持歌詞/進度顯示、搜索並播放、播放列表控制、上一曲、下一曲、播放/暫停、音量控制。服務端使用使用Razor渲染頁面,頁面使用bootstrap作為UI框架,使用VUE.js進行數據綁定,使用SignalR Client庫與SignalR“中心”連接。API文檔由Swagger自動從代碼生成API文檔頁面。

5.1.5 容器化服務

使用Docker和Dockerfile文件將程序編譯,並打包成鏡像。Dockerfile打包過程如下:

  1. 使用dotnet/core/aspnet:3.0-buster-slim作為基准鏡像
  2. 使用dotnet/core/sdk:3.0-buster作為編譯環境鏡像
  3. 設定工作目錄為/app
  4. 拷貝代碼到容器中
  5. 下載程序所依賴的第三方庫
  6. 編譯
  7. 將基准鏡像作為最終運行鏡像
  8. 保留服務端口:81
  9. 復制編譯后的dll到當前文件夾
  10. 設置容器入口命令:dotnet MusicCloud.dll

5.1.6 Kubernetes服務定義

項目中的kubernetes.k8s.yaml文件定義了音樂播放服務在Kubernets集群中的注冊信息。kubernetes.k3s.yaml文件定義了音樂播放服務在k3s集群中的注冊信息。
在k8s集群中,使用Deployment調度3個容器運行服務,將NFS PVC映射到容器中/nfs-pvc路徑,使用阿里雲鏡像密鑰從阿里雲鏡像庫拉取鏡像,注冊一個服務,將宿主機的30005端口映射到容器的81端口。
在k3s集群中,使用DaemonSet在每個編譯節點都調度一個音樂播放服務,使用阿里雲鏡像密鑰從阿里雲鏡像庫拉取鏡像,注冊一個服務,將宿主機的30005端口映射到容器的81端口。

5.1.7 自動化編譯/部署

項目中的azure-pipelines.yml文件定義了項目代碼提交到遠程代碼庫時,觸發Azure Pielines運行時所需要執行的編譯/部署步驟。

  1. 編譯鏡像
  2. 將鏡像推送到阿里雲容器鏡像庫
  3. 配置Kubernetes應用部署文件
  4. 上傳應用部署文件
  5. 部署應用

5.2 音樂播放器和音樂控制端

5.2.1 概述

音樂播放器和音樂控制端基於谷歌Flutter框架,使用Dart語言,以Visual Studio Code作為IDE進行開發。音樂播放器運行在Android 4.4系統的ARM開發板上,可以連接外設,音樂控制端支持Android和iOS。

Flutter是谷歌的移動UI框架,可以快速在iOS和Android上構建高質量的原生用戶界面。

音樂播放器和音樂控制端使用同一套源碼,通過獲取設備信息來判斷加載播放器UI還是加載控制端UI。音樂播放器和音樂控制端都通過簡單服務發現協議(SSDP)搜索局域網中可以連接的邊緣網關,連接到音樂播放服務的SignalR“中心”,分別注冊為播放器和客戶端。播放器接收客戶端的控制指令,並上報音樂播放狀態和播放列表。控制端向播放端發送控制指令,並接收音樂播放狀態和播放列表。播放器使用酷狗客戶端掃描二維碼方式登錄賬號,通過API進行資源搜索、訪問/修改播放列表、更新Cookie。

graph TD subgraph 邊緣網關 A[SSDP服務] B[音樂播放服務] B -.服務名和端口號.-> A end C[播放器] -.UDP廣播包.-> A D[控制端] -.UDP廣播包.-> A C --> B D --> B

5.2.2 播放器UI設計

播放器端包含四個頁面:二維碼登錄頁面、播放主頁面、播放列表頁面、推薦頁面。
播放主頁面的元素包括:圖片背景、播放時會旋轉的專輯封面、上一曲/下一曲/播放/暫停控制按鈕、收藏按鈕、播放進度時間文本、歌曲名/歌手標簽、音量控制條、兩行歌詞文本。播放列表頁面顯示用戶所有的播放列表和當前正在播放的列表。推薦頁面根據當前正在播放的音樂顯示推薦的更多音樂和歌單。

5.2.3 播放器程序邏輯

程序開始運行時,首先通過SSDP協議掃描局域網中的邊緣網關,然后通過SignalR協議連接邊緣網關中運行的音樂播放服務,通過SignalR協議調用“中心”的RegisterPlayer方法將當前連接注冊為播放器。通過HTTP調用api/login/check API判斷用戶是否已經登錄,如果沒有登錄則顯示二維碼登錄頁面,如果用戶已經登錄則顯示播放器主頁。播放器注冊SignalR“中心”MediaCtrl方法,監聽控制端的數據。當音樂播放狀態改變時,調用SignalR“中心”的PropertyChanged(播放器數據變動)、PlayListChanged(播放列表變動)方法向控制端上報最新數據。

5.2.4 控制端UI設計

控制端有多個頁面,主要的有:播放主頁面、播放列表、推薦頁面、樂庫、每日歌曲推薦、排行榜、搜索和搜索結果頁面。

  • 播放主頁面:顯示當前音樂播放狀態(進度、歌詞)、音樂控制(上一曲/下一曲/播放/暫停)。
  • 播放列表:顯示當前播放列表,支持刪除、另存為播放列表。
  • 推薦頁面:通過調用酷狗API,基於當前播放的音樂推薦其他音樂和歌單。
  • 每日歌曲推薦:通過調用酷狗API,根據用戶習慣推薦音樂。
  • 搜索和搜索結果頁面:通過調用酷狗API,搜索網絡音樂,選擇可以立即播放或添加到播放列表。

5.2.5 控制端程序邏輯

程序開始運行時,首先通過SSDP協議掃描局域網中的邊緣網關,然后通過SignalR協議連接邊緣網關中運行的音樂播放服務,通過SignalR協議調用“中心”的RegisterClient方法將當前連接注冊為音樂控制器。程序注冊SignalR“中心”的PropertyChanged方法監聽播放器上報的音樂播放狀態。用戶操作時,調用MediaCtrl方法控制播放器。

5.3 基於樹莓派的音樂控制器

5.3.1 概述

在本項目應用示例中,任何設備只需要先通過簡單服務發現協議查找到邊緣網關的服務入口,然后通過SignalR協議連接到音樂播放服務,注冊為播放器或客戶端(控制器)就能根據特定規則(SingalR“中心”方法)完成特定的功能。
基於樹莓派的音樂控制器通過GPIO按鈕實現控制播放器上一曲/下一曲/播放/暫停,通過LCD 20*4顯示屏顯示音樂播放狀態(播放器名、音量、當前歌曲剩余時間、播放進度條、邊緣網關IP),通過RFID讀卡器導入/導出播放列表,通過IR紅外接收器使用遙控器和小愛同學控制音樂播放。

5.3.2 硬件設計

硬件清單:

名稱 數量
樹莓派 4B 1
樹莓派接口擴展器 1
RC522 RFID讀卡器 1
GPIO按鈕 4
I2C LCD2004 液晶顯示屏 1
IR紅外解碼/編碼模塊 1
USB-TTL(USB轉串行口) 1
藍色LED燈 1
100 kΩ電阻 1

硬件接線圖:
接線圖

RFID-RC522

重要外設介紹:

  • RFID-RC522 RFID讀卡器
    RC522模塊是基於射頻基站芯片的Mifare卡讀寫模塊,模塊工作再13.56MHz頻率,可支持Mifare 1S50、Mifare 1S70、Mifare Light、Mifare Ultralight、Mifare Pro。模塊具有易用、高可靠、體積小等特點,可以幫助客戶方便、快捷的將非接觸卡應用到系統中.

  • I2C LCD2004 液晶顯示屏
    眾所周知,LCD液晶顯示屏雖然極大地豐富了人機交互,但他們有一個共同的弱點。當它們連接到主控制器時,會占用主控制器的多個IO接口,同時也限制了主控制器的其他功能。因此,通常利用PCF8574系列芯片通過IIC總線擴展多個IO接口來驅動LCD顯示屏。IIC總線是由PHLIPS發明的一種串行總線。它是一種高性能串行總線,具有多主機系統所要求的總線控制和高速或低速設備同步功能。IIC總線只有兩個雙向信號線,串行數據線(SDA)和串行時鍾線(SCL)。I2C LCD2004上的藍色電位計用於調整背光,使其更易於在I2C LCD2004上顯示。

5.3.3 軟件設計

軟件運行環境基於.Net Core,.Net Core可用於為物聯網設備和場景構建應用程序,物聯網應用通常與需要使用GPIO引腳、串口或類似硬件的傳感器、顯示器和輸入設備進行交互。System.Device.Gpio命名空間中包含了對 硬件底層的訪問(GPIO、SPI、IIC、Serial), Iot.Device.Bindings命名空間包含了硬件的綁定(具體的外設),由社區開發者維護。
使用Visual Studio Code作為IDE進行開發,結合vscode的SFTP插件,通過SFTP協議,代碼變動時自動將代碼上傳到樹莓派。dotnet watch run命令可以監測代碼文件的變動,代碼變動時自動重新編譯運行。

重要類(class)設計:

構造函數 說明 參數
GpioButton(int pin) 表示一個GPIO按鈕,提供按鈕按下的事件 pin - 引腳編號
GpioLed(int pin) 表示一個LED燈,提供開(On)燈、關燈(Off)方法 pin - 引腳編號
IR(string portName, int baudRate) 表示一個紅外接收器,提供收到紅外信號的事件 portName - 串行口設備地址,baudRate - 波特率
LCD2004(int address) 表示LCD屏幕,提供清屏/輸出/顯示進度條方法 address - IIC設備地址
Mfrc522Lib() 表示RFID讀卡器,提供讀卡ID事件
MusicServerDiscover() 通過SSDP協議掃描邊緣網關,提供音樂服務連接入口
RemotePlayer(string url) 表示遠程播放器,提供控制設備方法(MediaCtrl)和播放器屬性變動事件 url-音樂服務連接入口
PlayerReportProperty() 表示音樂播放器屬性(包括:當前播放列表、音量、播放進度、歌詞等)

程序工作流程:

  1. 初始化硬件(GPIO、LCD、紅外接收器、RFID讀卡器)
  2. 掃描邊緣網關
  3. 獲取音樂服務入口
  4. 連接到SignalR“中心”
  5. 注冊為客戶端
  6. 注冊播放器屬性變動事件
  7. 主線程延時死循環

初始化主要代碼:

static GpioController controller;
static GpioLed led;
static Mfrc522 rfid;
static GpioButton btn1;
static GpioButton btn2;
static GpioButton btn3;
static GpioButton btn4;
static IR ir;
static LCD2004 lcd;
static RemotePlayer player;
static PlayerReportProperty playerProperty;
static string rfidMode = "";

static void Main(string[] args)
{
  playerProperty = new PlayerReportProperty();
  try
  {
    controller = new GpioController();
    Console.WriteLine("初始化LED...");
    led = new GpioLed(controller, 26);
    led.Off();
    Console.WriteLine("初始化GPIO按鍵...");
    btn1 = new GpioButton(controller, 12);
    btn1.ButtonPressed += ButtonPressed;
    btn2 = new GpioButton(controller, 16);
    btn2.ButtonPressed += ButtonPressed;
    btn3 = new GpioButton(controller, 20);
    btn3.ButtonPressed += ButtonPressed;
    btn4 = new GpioButton(controller, 21);
    btn4.ButtonPressed += ButtonPressed;
    Console.WriteLine("初始化LCD...");
    lcd = new LCD2004(0x27);
    lcd.WriteLine(0, "Cloud Player");
    lcd.WriteLine(3, "Wait to find server");
    Console.WriteLine("初始化RFID讀卡器...");
    rfid = new Mfrc522();
    rfid.InitIO(controller).Wait();
    rfid.StartFound();
    rfid.CardFound += FoundRfidCard;
    Console.WriteLine("初始化紅外接收器...");
    ir = new IR("/dev/ttyUSB0", 9600);
    ir.IR_Received += IR_Received;
  }
  catch (Exception e)
  {
    Console.WriteLine(e.Message);
    Console.WriteLine("非樹莓派運行!!!");
  }
  Console.WriteLine("初始化完成,等待連接服務器。");
  player = new RemotePlayer();
  // 播放器連接事件
  player.PlayerConnectEvent += (id) =>
  {
    if (id == null)
    {
      led?.Off();
      lcd?.WriteLine(0, "No Player");
    }
    else
    {
      led?.On();
      lcd?.WriteLine(0, id);
    }
  };
  // 服務器連接事件
  player.ServerConnectEvent += (url) =>
  {
    var uri = new Uri(url);
    lcd?.WriteLine(3, uri.Host);
  };
  // 注冊播放器屬性變動事件
  player.PlayerProperty += PlayerProperty;
  while (true)
  {
    Thread.Sleep(1000 * 60);
  }
}

硬件事件監聽代碼:

/// <summary>
/// RFID讀卡事件
/// </summary>
/// <param name="dev">讀卡器</param>
/// <param name="id">卡號</param>
static void FoundRfidCard(Mfrc522 dev, Uid id)
{
    if(rfidMode == "load"){
        // 加載播放列表並播放
        player.MediaCtrl("PlayPlayList", "rfid:" + id.ToString());
        rfidMode = "";
        lcd?.WriteLine(3, new Uri(player.url).Host);
    }else if(rfidMode == "save"){
        // 保存當前播放列表
        player.MediaCtrl("SavePlayList", "rfid:" + id.ToString());
        rfidMode = "";
        lcd?.WriteLine(3, new Uri(player.url).Host);
    }
}

/// <summary>
/// GPIO按鍵按下事件
/// </summary>
/// <param name="pin">引腳號</param>
static void ButtonPressed(int pin)
{
    Console.WriteLine("按鍵: " + pin.ToString());
    if (player == null) return;
    switch (pin)
    {
        // 上一曲
        case 12: player.MediaCtrl("ctrl", "previous"); break;
        // 播放/暫停
        case 16: player.MediaCtrl("ctrl", "play"); break;
        // 下一曲
        case 20: player.MediaCtrl("ctrl", "next"); break;
        default: break;
    }
    if(pin == 21)
    {
        // 切換RFID卡功能模式(加載播放列表或保存播放列表)
        if(rfidMode == "")
        {
            rfidMode = "load";
            lcd?.WriteLine(3, "Load playlist ...");
        }else if(rfidMode == "load")
        {
            rfidMode = "save";
            lcd?.WriteLine(3, "Save playlist ...");
        }else if(rfidMode == "save"){
            rfidMode = "";
            lcd?.WriteLine(3, new Uri(player.url).Host);
        }
    }
}

/// <summary>
/// 紅外接收事件
/// </summary>
/// <param name="code">按鍵碼</param>
static void IR_Received(string code)
{
    Console.WriteLine("紅外接收: " + code);
    switch (code)
    {
        // 播放/暫停
        case "00bf15": player.MediaCtrl("ctrl", "play"); break;
        // 上一曲
        case "00bf16": player.MediaCtrl("ctrl", "previous"); break;
        // 下一曲
        case "00bf17": player.MediaCtrl("ctrl", "next"); break;
        // 音量-
        case "00bf19": player.MediaCtrl("ctrl", "vold"); break;
        // 音量+
        case "00bf18": player.MediaCtrl("ctrl", "volu"); break;
        default: break;
    }
}

6 服務關鍵代碼及配置

6.1 AliyunDDNS服務

  • ddns.py
import os
import json
import requests
from aliyunsdkcore.client import AcsClient
from aliyunsdkcore.request import CommonRequest

# 阿里雲API密鑰
keyid = os.environ["ALIYUN_KEYID"].replace('\n','')
secret = os.environ["ALIYUN_SECRET"].replace('\n','')
# 域名
domain = os.environ["ALIYUN_DOMAIN"].replace('\n', '')
host = "test"

if os.path.exists('/etc/hostname'):
    file = open('/etc/hostname', 'r')
    host = file.read().replace('\n', '')
    file.close()

if "ALIYUN_DDNS_HOST" in os.environ:
    host = os.environ["ALIYUN_DDNS_HOST"].replace('\n', '')

print('主機名:', host)
print('域名:', domain)

client = AcsClient(keyid, secret, 'cn-hangzhou')

# 檢測是否已經有記錄
def checkRecord(type):
    req = CommonRequest()
    req.set_accept_format('json')
    req.set_domain('alidns.aliyuncs.com')
    req.set_method('POST')
    req.set_version('2015-01-09')
    req.set_action_name('DescribeSubDomainRecords')
    req.add_query_param('Type', type)
    req.add_query_param('SubDomain', host+"."+domain)
    response = client.do_action(req)
    obj = json.loads(str(response, encoding='utf-8'))
    print('checkRecord', obj)
    if "TotalCount" not in obj:
        return False
    if obj["TotalCount"] > 0:
        return True
    else:
        return False

# 刪除記錄
def delRecord(type):
    req = CommonRequest()
    req.set_accept_format('json')
    req.set_domain('alidns.aliyuncs.com')
    req.set_method('POST')
    req.set_version('2015-01-09')
    req.set_action_name('DeleteSubDomainRecords')
    req.add_query_param('Type', type)
    req.add_query_param('DomainName', domain)
    req.add_query_param('RR', host)
    response = client.do_action(req)
    obj = json.loads(str(response, encoding='utf-8'))
    print('delRecord', obj)
    if obj["RR"] == host:
        return True
    else:
        return False

# 添加記錄
def addRecord(ip, type):
    req = CommonRequest()
    req.set_accept_format('json')
    req.set_domain('alidns.aliyuncs.com')
    req.set_method('POST')
    req.set_version('2015-01-09')
    req.set_action_name('AddDomainRecord')
    req.add_query_param('DomainName', domain)
    req.add_query_param('RR', host)
    req.add_query_param('Type', type)
    req.add_query_param('Value', ip)
    response = client.do_action(req)
    obj = json.loads(str(response, encoding='utf-8'))
    print('addRecord', obj)
    return obj["RecordId"]

# 獲取IPv4公網地址
def getIp4Addres():
    obj = requests.get(
        "http://ipv4.lookup.test-ipv6.com/ip/?asn=1&testdomain=test-ipv6.com&testname=test_asn4").json()
    print("IPv4", obj)
    return obj["ip"]

# 獲取IPv6公網地址
def getIp6Addres():
    obj = requests.get(
        "http://ipv6.lookup.test-ipv6.com/ip/?asn=1&testdomain=test-ipv6.com&testname=test_asn6").json()
    print("IPv6", obj)
    return obj["ip"]

try:
    if checkRecord('A'):
        print("刪除域名中已有的IPv4解析數據")
        delRecord('A')

    if checkRecord('AAAA'):
        print("刪除域名中已有的IPv6解析數據")
        delRecord('AAAA')

    ip = getIp4Addres()
    print("本機IPv4地址:" + ip)
    recid = addRecord(ip, 'A')
    print("已向域名添加解析記錄,ID:" + recid)

    ipv6 = getIp6Addres()
    print("本機IPv6地址:" + ipv6)
    recid = addRecord(ipv6, 'AAAA')
    print("已向域名添加解析記錄,ID:" + recid)
    pass
except:
    pass

os.system('sleep 1800')

6.2 edge-ssdp服務

  • index.js
const rp = require('request-promise');
const express = require('express')
const convert = require('xml-js')
const fs = require('fs')
const os = require('os')
const app = express()
const port = 49154

var SSDP = require('node-ssdp').Server
    , server = new SSDP({
        location: {
            port: port,
            path: '/device.xml'
        }
    })

// 注冊為UPnP設備
server.addUSN('upnp:rootdevice')
// 注冊為邊緣網關
server.addUSN('urn:schemas-upnp-org:device:EdgeGateway:1')
// 注冊k3s-nodePort類型服務發現服務
server.addUSN('urn:schemas-upnp-org:service:k3s-nodePort-Service:1')
server.on('advertise-alive', function (headers) {})
server.on('advertise-bye', function (headers) {})

// 啟動SSDP服務
server.start()
    .catch(e => {
        console.log('SSDP服務啟動失敗:', e)
    })
    .then(() => {
        console.log('SSDP服務已啟動')
    })

process.on('exit', function () {
    server.stop()
})

// 注冊k3s服務發現訪問路徑
app.get('/service', (req, res) => {
    res.contentType('application/json')
    var services = []
    // 通過Rancher API查詢k3s服務
    rp({
        uri: 'https://devops.zuozishi.info:6443/v3/project/c-cx29q:p-2hlhh/services/',
        headers: {
            'Authorization': 'Bearer token-scvtk:<token>'
        },
        json: true
    }).then((obj) => {
        if ('data' in obj) {
            // 遍歷所有服務
            obj['data'].forEach(item => {
                // 找到類型為NodePort的服務
                if (item['kind'] == 'NodePort' && 'publicEndpoints' in item) {
                    item['publicEndpoints'].forEach(endpoint => {
                        var service = endpoint
                        service['name'] = item['name']
                        service['namespace'] = item['namespaceId']
                        services.push(service)
                    })
                }
            });
        }
        // HTTP請求返回數據
        res.send(services)
    }).catch((e) => {
        // HTTP請求返回數據
        res.send(services)
    })
})

// 注冊UPnP設備描述文件訪問路徑
app.get('/device.xml', (req, res) => {
    res.contentType('text/xml')
    // 從文件中讀取
    var xml = fs.readFileSync('./device.xml', 'utf8').toString()
    // 替換主機名
    xml = xml.replace('{hostname}', os.hostname()).replace('{hostname}', os.hostname())
    // HTTP請求返回數據
    res.send(xml)
})

app.get('/upnp-action.xml', (req, res) => {
    res.contentType('text/xml')
    var xml = fs.readFileSync('./upnp-action.xml', 'utf8').toString()
    res.send(xml)
})


app.get('/icon.png', (req, res) => {
    res.contentType('image/png')
    res.sendfile('./icon.png')
})

app.post('/upnp-action', (req, res) => {
    res.contentType('text/xml')
    var xml = fs.readFileSync('./nodeport.xml', 'utf8').toString()
    var services = []
    rp({
        uri: 'https://devops.zuozishi.info:6443/v3/project/c-cx29q:p-2hlhh/services/',
        headers: {
            'Authorization': 'Bearer token-scvtk:<token>'
        },
        json: true
    }).then((obj) => {
        if ('data' in obj) {
            obj['data'].forEach(item => {
                if (item['kind'] == 'NodePort' && 'publicEndpoints' in item) {
                    item['publicEndpoints'].forEach(endpoint => {
                        var service = endpoint
                        service['name'] = item['name']
                        service['namespace'] = item['namespaceId']
                        services.push(service)
                    })
                }
            });
        }
        xml = xml.replace('{data}',JSON.stringify(services))
        res.send(xml)
    }).catch((e) => {
        xml = xml.xml('{data}', JSON.stringify(services))
        res.send(services)
    })
})

app.listen(port, () => { })
  • UPnP設備描述文件(device.xml)
<root xmlns="urn:schemas-upnp-org:device-1-0" configId="1337">
    <specVersion>
        <major>1</major>
        <minor>1</minor>
    </specVersion>
    <device>
        <deviceType>urn:schemas-upnp-org:device:EdgeGateway:1</deviceType>
        <friendlyName>邊緣網關({hostname})</friendlyName>
        <manufacturer>分布式邊緣計算網關</manufacturer>
        <manufacturerURL>https://devops.zuozishi.info:6443/</manufacturerURL>
        <modelDescription>分布式邊緣計算網關</modelDescription>
        <modelName>{hostname}</modelName>
        <modelNumber>1.0</modelNumber>
        <UDN>uuid:f40c2981-7329-40b7-8b04-27f187aecfb5</UDN>
        <modelURL>https://devops.zuozishi.info:6443/</modelURL>
        <presentationURL>https://devops.zuozishi.info:6443/</presentationURL>
        <iconList>
            <icon>
                <mimetype>image/png</mimetype>
                <width>200</width>
                <height>200</height>
                <depth>24</depth>
                <url>icon.png</url>
            </icon>
        </iconList>
        <serviceList>
            <service>
                <serviceType>urn:schemas-upnp-org:service:k3s-nodePort-Service:1</serviceType>
                <serviceId>urn:schemas-upnp-org:serviceId:k3s-nodePort-Service1</serviceId>
                <SCPDURL>upnp-action.xml</SCPDURL>
                <controlURL>upnp-action</controlURL>
            </service>
        </serviceList>
    </device>
</root>

6.3 Kubernets重要組件聲明

  • nfs-pv(可持續存儲:NFS)
apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs-pv
spec:
  capacity:
    # 總空間
    storage: 10Gi
  accessModes:
    # 支持多機讀寫
    - ReadWriteMany
  # 回收機制:不清除數據
  persistentVolumeReclaimPolicy: Retain
  storageClassName: nfs
  nfs:
    # 掛載路徑
    path: /nfsroot
    # NFS服務器IP
    server: 172.17.81.0

---

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs-pvc
spec:
  accessModes:
    # 支持多機讀寫
    - ReadWriteMany
  resources:
    requests:
      # 單Pod使用空間
      storage: 1Gi
  storageClassName: nfs
  • aliyun-secret(阿里雲密鑰,用於DDNS域名解析)
apiVersion: v1
kind: Secret
metadata:
  name: aliyun-secret
type: Opaque
data:
  id: <id>
  key: <key>
  • aliyun-docker-secret(阿里雲鏡像密鑰,用於拉取私有鏡像)
kind: Secret
apiVersion: v1
metadata:
  name: aliyun-docker-secret
  namespace: default
data:
  .dockerconfigjson: >-
    <base64 data>
type: kubernetes.io/dockerconfigjson

6.4 k8s和k3s應用聲明文件

  • Aliyun-DDNS
# 每個節點都部署
kind: DaemonSet
apiVersion: apps/v1
metadata:
  name: aliyun-ddns
  labels:
    app: aliyun-ddns
spec:
  selector:
    matchLabels:
      app: aliyun-ddns
  # 更新機制:滾動更新
  updateStrategy:
    type: RollingUpdate
  template:
    metadata:
      labels:
        app: aliyun-ddns
    spec:
      containers:
        - name: aliyun-ddns
          # 應用鏡像
          image: registry.cn-beijing.aliyuncs.com/zuozishi/aliyun-ddns:{ver}
          env:
            # 將密鑰映射到環境變量
            - name: ALIYUN_KEYID
              valueFrom:
                secretKeyRef:
                  name: aliyun-secret
                  key: id
            # 將密鑰映射到環境變量
            - name: ALIYUN_SECRET
              valueFrom:
                secretKeyRef:
                  name: aliyun-secret
                  key: key
            # 要解析的域名
            - name: ALIYUN_DOMAIN
              value: zuozishi.online
          # 映射宿主機的主機名
          volumeMounts:
            - mountPath: /etc/hostname
              name: hostname
              readOnly: true
      # 使用密鑰拉取鏡像
      imagePullSecrets:
        - name: aliyun-docker-secret
      # 使用宿主機的文件
      volumes:
        - hostPath:
            path: /etc/hostname
          name: hostname
  • 音樂播放服務(雲端)
# 應用聲明
apiVersion: apps/v1
# 全自動調度
kind: Deployment
metadata:
  name: music-cloud
  labels:
    app: music-cloud
spec:
  # 同時工作的副本數量
  replicas: 1
  selector:
    matchLabels:
      app: music-cloud
  template:
    metadata:
      labels:
        app: music-cloud
    spec:
      containers:
        - name: music-cloud
          # 應用鏡像
          image: registry.cn-beijing.aliyuncs.com/zuozishi/music-cloud:{ver}
          # 每次運行時重新拉取鏡像
          imagePullPolicy: Always
          ports:
            # 聲明外部端口號
            - containerPort: 81
              protocol: TCP
          # 掛載NFS存儲
          volumeMounts:
            - name: data
              mountPath: /nfs-pvc
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: nfs-pvc
      imagePullSecrets:
        - name: aliyun-docker-secret

---

# 服務聲明
apiVersion: v1
kind: Service
metadata:
  name: music-cloud
spec:
  # 將容器內81端口映射到宿主機30005端口
  type: NodePort
  ports:
    - port: 81
      nodePort: 30005
  selector:
    app: music-cloud

6.5 frp服務配置

  • 服務端配置
[common]
# 服務綁定端口
bind_port = 7000
# 開啟密碼認證
authenticate_heartbeats = true
authenticate_new_work_conns = true
authentication_method = token
# 密碼
token = www.123123
# 控制面板綁定端口
dashboard_port = 7500
# 控制面板用戶名
dashboard_user = admin
# 控制面板密碼
dashboard_pwd = www.123123
  • 客戶端配置
[common]
# 服務器IP
server_addr = 47.94.90.203
# 服務器端口號
server_port = 7000
# 開啟密碼認證
authenticate_heartbeats = true
authenticate_new_work_conns = true
authentication_method = token
# 密碼
token = www.123123

# 將本地30005端口映射到遠程81端口
[MusicCloud]
type = tcp
local_ip = 127.0.0.1
local_port = 30005
remote_port = 81

6.6 Azure Pipelines配置

  • AliyunDDNS
# 提交master分支時觸發
trigger:
- master

resources:
- repo: self

stages:
- stage: AutoDeploy
  displayName: 自動化部署
  pool:
    vmImage: 'ubuntu-latest'
  jobs:  
  - job: AutoDeploy
    displayName: 自動化部署
    steps:
    - task: Bash@3
      displayName: 編譯鏡像
      inputs:
        targetType: 'inline'
        script: 'docker build -t registry.cn-beijing.aliyuncs.com/zuozishi/aliyun-ddns:`cat version` .'
    - task: Docker@2
      displayName: 登錄阿里雲鏡像倉庫
      inputs:
        containerRegistry: '阿里雲鏡像倉庫'
        command: 'login'
    - task: Bash@3
      displayName: 推送鏡像
      inputs:
        targetType: 'inline'
        script: |
          docker images
          docker push registry.cn-beijing.aliyuncs.com/zuozishi/aliyun-ddns:`cat version`
    - task: Bash@3
      displayName: 配置應用清單
      inputs:
        targetType: 'inline'
        script: |
          sed -i "s/{ver}/`cat version`/" aliyunddns.yaml
    - task: CopyFilesOverSSH@0
      displayName: 上傳應用清單
      inputs:
          sshEndpoint: 'k3s-master'
          sourceFolder: ./
          contents: 'aliyunddns.yaml'
          targetFolder: /tmp/
    - task: SSH@0
      displayName: '部署應用'
      inputs:
        sshEndpoint: 'k3s-master'
        commands: 'k3s kubectl apply -f /tmp/aliyunddns.yaml --force'
  • 音樂播放服務
trigger:
- master

resources:
- repo: self

stages:
- stage: AutoDeploy
  displayName: 自動化部署
  pool:
    vmImage: 'ubuntu-latest'
  jobs:  
  - job: AutoDeploy
    displayName: 自動化部署
    steps:
    - task: Bash@3
      displayName: 編譯鏡像
      inputs:
        targetType: 'inline'
        script: 'docker build -t registry.cn-beijing.aliyuncs.com/zuozishi/music-cloud:`cat version` .'
    - task: Docker@2
      displayName: 登錄阿里雲鏡像倉庫
      inputs:
        containerRegistry: '阿里雲鏡像倉庫'
        command: 'login'
    - task: Bash@3
      displayName: 推送鏡像
      inputs:
        targetType: 'inline'
        script: |
          docker images
          docker push registry.cn-beijing.aliyuncs.com/zuozishi/music-cloud:`cat version`
    - task: Bash@3
      displayName: 配置應用清單
      inputs:
        targetType: 'inline'
        script: |
          sed -i "s/{ver}/`cat version`/" kubernetes.k8s.yaml
          sed -i "s/{ver}/`cat version`/" kubernetes.k3s.yaml
    - task: CopyFilesOverSSH@0
      displayName: 上傳應用清單(k8s)
      inputs:
          sshEndpoint: 'k8s-master'
          sourceFolder: ./
          contents: 'kubernetes.k8s.yaml'
          targetFolder: /tmp/
    - task: SSH@0
      displayName: '部署應用(k8s)'
      inputs:
        sshEndpoint: 'k8s-master'
        commands: 'kubectl apply -f /tmp/kubernetes.k8s.yaml --force'
    - task: CopyFilesOverSSH@0
      displayName: 上傳應用清單(k3s)
      inputs:
          sshEndpoint: 'k3s-master'
          sourceFolder: ./
          contents: 'kubernetes.k3s.yaml'
          targetFolder: /tmp/
    - task: SSH@0
      displayName: '部署應用(k3s)'
      inputs:
        sshEndpoint: 'k3s-master'
        commands: 'k3s kubectl apply -f /tmp/kubernetes.k3s.yaml --force'

7 系統測試及展示

7.1 雲端服務測試

雲端Kubernetes集群及服務運行狀況:
7-1
k3s集群及服務運行狀況:
7-2
AliyunDDNS服務自動化編譯/部署:
7-3
音樂播放服務自動化編譯/部署:
7-4

7.2 邊緣網關服務測試

用Android測試邊緣網關服務發現服務:
7-5-1
7-5-2
邊緣網關動態域名解析測試:
7-6

7.3 音樂播放服務和客戶端測試

7.3.1 播放器界面

播放頁面:
7-7
推薦頁面:
7-8
播放列表頁面:
7-9
實機運行:
7-10

7.3.2 手機APP客戶端頁面

主頁面:
7-11
播放列表頁面:
7-12
功能頁面:
7-13
歌曲推薦頁面:
7-14

7.3.3 樹莓派音樂控制器

樹莓派控制端程序輸出:
7-19
從程序輸出可以看出,當硬件初始化完畢后,樹莓派先連接到雲端服務節點,然后開啟SSDP掃描,當掃描到本地邊緣網關時,再切換到本地服務節點。樹莓派端實際運行如下圖所示。
7-20

顯示屏顯示:

  • 第一行:播放器名稱
  • 第二行:當前播放曲目序號/總曲目數,音量,當前歌曲剩余時間
  • 第三行:播放進度條
  • 第四行:服務端IP

按鈕功能:

  • 黑色:上一曲
  • 藍色:播放/暫停
  • 紅色:下一曲
  • 白色:按一下導入播放列表,再按一下播放列表

7.3.4 后台網頁

7-21

參考文獻

[1] 張駿.邊緣計算:方法與工程實踐[M].電子工業出版社,2019.
[2] KubeEdge.What is KubeEdge[DB/OL].,2020.
[3] Rancher.k3s Architecture[DB/OL].,2020.
[4] 趙立威 方國偉.讓雲觸手可及:微軟雲計算實踐指南[M].電子工業出版社,2012.
[5] [英] Nigel Poulton(奈吉爾·波爾頓). Docker Deep Dive[M].人民郵電出版社,2018.
[6] 李燕鵬 楊彪.分布式服務架構:原理、設計與實戰[M].人民郵電出版社,2018.
[7] 龔正 吳治輝 崔秀龍 閆健勇. Kubernetes權威指南:從Docker到Kubernetes實踐全接觸[M].電子工業出版社,2019.
[8] [英] Peter Waher(皮特·瓦厄).Learning Internet of Things[M].機械工業出版社,2016.
[9] [美]David Gourley Brian Totty Marjorie Sayer Sailu Reddy Aushu Aggarwal. HTTP: The Definitive Guide. 人民郵電出版社,2012.
[10] Microsoft. ASP.NET Core SignalR 簡介[DB/OL].,2020.
[11] 杜文.Flutter實戰[M].機械工業出版社,2020.
[12] 單承贛 單玉峰 姚磊. 射頻識別(RFID)原理與應用[M].電子工業出版社,2015.
[13] 郭天祥.新概念 51單片機C語言教程:入門、提高、開發、拓展全攻略[M].電子工業出版社,2009.
[14] SUNFOUNDER. I2C LCD2004 Wiki[DB/OL].,2019.
[15] Microsoft. .NET Core IoT Libraries[DB/OL].,2020.

附錄

附錄 A:項目開源說明

AliyunDDNS
Source: https://dev.azure.com/zuozishi/AliyunDDNS
License: MIT

MusicCloud
Source: https://dev.azure.com/zuozishi/MusicCloud
License: MIT

cloud_music
Source: https://dev.azure.com/zuozishi/cloud_music
License: MIT

RaspberrypiController
Source: https://dev.azure.com/zuozishi/RaspberrypiController
License: MIT

ProjectEdgeDoc
Source: https://github.com/zuozishi/ProjectEdgeDoc

附錄 B:第三方軟件說明(Third Party Notices)

Notwithstanding any other terms, you may reverse engineer this software to the extent required to debug changes to any libraries licensed under the GNU Lesser General Public License.

Kubernetes
Source: https://github.com/kubernetes/kubernetes
License: Apache-2.0

k3s
Source: https://github.com/rancher/k3s/
License: Apache-2.0

node-ssdp
Source: https://github.com/diversario/node-ssdp
License: MIT

express
Source: https://github.com/expressjs/express
License: MIT

request
Source: https://github.com/request/request
License: Apache-2.0

request-promise
Source: https://github.com/request/request-promise
License: ISC

request-promise
Source: https://github.com/nashwaan/xml-js
License: MIT

SSDP.Portable
Source: https://github.com/kakone/SSDP
License: GPL-2.0

.NET Core IoT Libraries
Source: https://github.com/dotnet/iot
License: MIT

Microsoft.AspNetCore.SignalR.Client
Source: https://github.com/aspnet/AspNetCore
License: Apache-2.0

HtmlAgilityPack
Source: https://github.com/zzzprojects/html-agility-pack/
License: MIT

Newtonsoft.Json
Source: https://github.com/JamesNK/Newtonsoft.Json
License: MIT

signalr_client
Source: https://github.com/soernt/signalr_client
License: MIT

audioplayers
Source: https://github.com/luanpotter/audioplayer
License: MIT


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM