golang父進(jìn)程通過管道向子進(jìn)程傳遞數(shù)據(jù)
我們提供的服務(wù)有:成都網(wǎng)站建設(shè)、成都做網(wǎng)站、微信公眾號開發(fā)、網(wǎng)站優(yōu)化、網(wǎng)站認(rèn)證、臨澧ssl等。為上千企事業(yè)單位解決了網(wǎng)站和推廣的問題。提供周到的售前咨詢和貼心的售后服務(wù),是有科學(xué)管理、有技術(shù)的臨澧網(wǎng)站制作公司
這里例子里面父進(jìn)程launch一個(gè)子進(jìn)程,然后通過管道(stdin)向子進(jìn)程傳遞文本串。
父進(jìn)程:
子進(jìn)程:
地址:
pagent是一個(gè)多進(jìn)程模型的golang庫,具有以下特點(diǎn):
簡單: 父子進(jìn)程只通過stdin和stdout來交互
安全: 多進(jìn)程很安全,子進(jìn)程掛掉一個(gè)不影響其他子進(jìn)程
解耦:子進(jìn)程交互和業(yè)務(wù)分離
例子:
package main
import (
"fmt"
"time"
"github.com/adwpc/pagent"
)
type MyBiz struct {
pagent.Master
}
func NewBiz() *MyBiz {
return MyBiz{}
}
func (a *MyBiz) BizRunning(id, str string) error {
fmt.Println("[MyBiz BizRunning] str=" + str)
return nil
}
func (a *MyBiz) BizFinish(id string, err error) error {
fmt.Println("[MyBiz BizFinish] id=" + id)
return err
}
func main() {
a := NewBiz()
fmt.Println("worker1-------------------------")
a.GetWorker("worker1").Start("bash", a.BizRunning, a.BizFinish)
a.GetWorker("worker1").Input("ls")
time.Sleep(1 * time.Second)
a.DelWorker("worker1")
fmt.Println("worker2-------------------------")
a.GetWorker("worker2").Start("ifconfig", nil, a.BizFinish)
time.Sleep(1 * time.Second)
a.DelWorker("worker2")
fmt.Printf("end!----------------------------")
}
libcontainer 是Docker中用于容器管理的包,它基于Go語言實(shí)現(xiàn),通過管理namespaces、cgroups、capabilities以及文件系統(tǒng)來進(jìn)行容器控制。你可以使用libcontainer創(chuàng)建容器,并對容器進(jìn)行生命周期管理。
容器是一個(gè)可管理的執(zhí)行環(huán)境,與主機(jī)系統(tǒng)共享內(nèi)核,可與系統(tǒng)中的其他容器進(jìn)行隔離。
在2013年Docker剛發(fā)布的時(shí)候,它是一款基于LXC的開源容器管理引擎。把LXC復(fù)雜的容器創(chuàng)建與使用方式簡化為Docker自己的一套命令體系。隨著Docker的不斷發(fā)展,它開始有了更為遠(yuǎn)大的目標(biāo),那就是反向定義容器的實(shí)現(xiàn)標(biāo)準(zhǔn),將底層實(shí)現(xiàn)都抽象化到libcontainer的接口。這就意味著,底層容器的實(shí)現(xiàn)方式變成了一種可變的方案,無論是使用namespace、cgroups技術(shù)抑或是使用systemd等其他方案,只要實(shí)現(xiàn)了libcontainer定義的一組接口,Docker都可以運(yùn)行。這也為Docker實(shí)現(xiàn)全面的跨平臺帶來了可能。
1.libcontainer 特性
目前版本的libcontainer,功能實(shí)現(xiàn)上涵蓋了包括namespaces使用、cgroups管理、Rootfs的配置啟動、默認(rèn)的Linux capability權(quán)限集、以及進(jìn)程運(yùn)行的環(huán)境變量配置。內(nèi)核版本最低要求為2.6,最好是3.8,這與內(nèi)核對namespace的支持有關(guān)。 目前除user namespace不完全支持以外,其他五個(gè)namespace都是默認(rèn)開啟的,通過clone系統(tǒng)調(diào)用進(jìn)行創(chuàng)建。
1.1 建立文件系統(tǒng)
文件系統(tǒng)方面,容器運(yùn)行需要rootfs。所有容器中要執(zhí)行的指令,都需要包含在rootfs(在Docker中指令包含在其上疊加的鏡像層也可以執(zhí)行)所有掛載在容器銷毀時(shí)都會被卸載,因?yàn)閙ount namespace會在容器銷毀時(shí)一同消失。為了容器可以正常執(zhí)行命令,以下文件系統(tǒng)必須在容器運(yùn)行時(shí)掛載到rootfs中。
當(dāng)容器的文件系統(tǒng)剛掛載完畢時(shí),/dev文件系統(tǒng)會被一系列設(shè)備節(jié)點(diǎn)所填充,所以rootfs不應(yīng)該管理/dev文件系統(tǒng)下的設(shè)備節(jié)點(diǎn),libcontainer會負(fù)責(zé)處理并正確啟動這些設(shè)備。設(shè)備及其權(quán)限模式如下。
容器支持偽終端TTY,當(dāng)用戶使用時(shí),就會建立/dev/console設(shè)備。其他終端支持設(shè)備,如/dev/ptmx則是宿主機(jī)的/dev/ptmx 鏈接。容器中指向宿主機(jī) /dev/null的IO也會被重定向到容器內(nèi)的 /dev/null設(shè)備。當(dāng)/proc掛載完成后,/dev/中與IO相關(guān)的鏈接也會建立,如下表。
pivot_root 則用于改變進(jìn)程的根目錄,這樣可以有效的將進(jìn)程控制在我們建立的rootfs中。如果rootfs是基于ramfs的(不支持pivot_root),那么會在mount時(shí)使用MS_MOVE標(biāo)志位加上chroot來頂替。 當(dāng)文件系統(tǒng)創(chuàng)建完畢后,umask權(quán)限被重新設(shè)置回0022。
1.2 資源管理
在《Docker背后的內(nèi)核知識:cgroups資源隔離》一文中已經(jīng)提到,Docker使用cgroups進(jìn)行資源管理與限制,包括設(shè)備、內(nèi)存、CPU、輸入輸出等。 目前除網(wǎng)絡(luò)外所有內(nèi)核支持的子系統(tǒng)都被加入到libcontainer的管理中,所以libcontainer使用cgroups原生支持的統(tǒng)計(jì)信息作為資源管理的監(jiān)控展示。 容器中運(yùn)行的第一個(gè)進(jìn)程init,必須在初始化開始前放置到指定的cgroup目錄中,這樣就能防止初始化完成后運(yùn)行的其他用戶指令逃逸出cgroups的控制。父子進(jìn)程的同步則通過管道來完成,在隨后的運(yùn)行時(shí)初始化中會進(jìn)行展開描述。
1.3 可配置的容器安全
容器安全一直是被廣泛探討的話題,使用namespace對進(jìn)程進(jìn)行隔離是容器安全的基礎(chǔ),遺憾的是,usernamespace由于設(shè)計(jì)上的復(fù)雜性,還沒有被libcontainer完全支持。 libcontainer目前可通過配置capabilities、SELinux、apparmor 以及seccomp進(jìn)行一定的安全防范,目前除seccomp以外都有一份默認(rèn)的配置項(xiàng)提供給用戶作為參考。 在本系列的后續(xù)文章中,我們將對容器安全進(jìn)行更深入的探討,敬請期待。
1.4 運(yùn)行時(shí)與初始化進(jìn)程
在容器創(chuàng)建過程中,父進(jìn)程需要與容器的init進(jìn)程進(jìn)行同步通信,通信的方式則通過向容器中傳入管道來實(shí)現(xiàn)。當(dāng)init啟動時(shí),他會等待管道內(nèi)傳入EOF信息,這就給父進(jìn)程完成初始化,建立uid/gid映射,并把新進(jìn)程放進(jìn)新建的cgroup一定的時(shí)間。 在libcontainer中運(yùn)行的應(yīng)用(進(jìn)程),應(yīng)該是事先靜態(tài)編譯完成的。libcontainer在容器中并不提供任何類似Unix init這樣的守護(hù)進(jìn)程,用戶提供的參數(shù)也是通過exec系統(tǒng)調(diào)用提供給用戶進(jìn)程。通常情況下容器中也沒有長進(jìn)程存在。 如果容器打開了偽終端,就會通過dup2把console作為容器的輸入輸出(STDIN, STDOUT, STDERR)對象。 除此之外,以下4個(gè)文件也會在容器運(yùn)行時(shí)自動生成。 * /etc/hosts * /etc/resolv.conf * /etc/hostname * /etc/localtime
1.5 在運(yùn)行著的容器中執(zhí)行新進(jìn)程
用戶也可以在運(yùn)行著的容器中執(zhí)行一條新的指令,就是我們熟悉的docker exec功能。同樣,執(zhí)行指令的二進(jìn)制文件需要包含在容器的rootfs之內(nèi)。 通過這種方式運(yùn)行起來的進(jìn)程會隨容器的狀態(tài)變化,如容器被暫停,進(jìn)程也隨之暫停,恢復(fù)也隨之恢復(fù)。當(dāng)容器進(jìn)程不存在時(shí),進(jìn)程就會被銷毀,重啟也不會恢復(fù)。
1.6 容器熱遷移(Checkpoint Restore)
目前l(fā)ibcontainer已經(jīng)集成了CRIU作為容器檢查點(diǎn)保存與恢復(fù)(通常也稱為熱遷移)的解決方案,應(yīng)該在不久之后就會被Docker使用。也就是說,通過libcontainer你已經(jīng)可以把一個(gè)正在運(yùn)行的進(jìn)程狀態(tài)保存到磁盤上,然后在本地或其他機(jī)器中重新恢復(fù)當(dāng)前的運(yùn)行狀態(tài)。這個(gè)功能主要帶來如下幾個(gè)好處。
服務(wù)器需要維護(hù)(如系統(tǒng)升級、重啟等)時(shí),通過熱遷移技術(shù)把容器轉(zhuǎn)移到別的服務(wù)器繼續(xù)運(yùn)行,應(yīng)用服務(wù)信息不會丟失。
對于初始化時(shí)間極長的應(yīng)用程序來說,容器熱遷移可以加快啟動時(shí)間,當(dāng)應(yīng)用啟動完成后就保存它的檢查點(diǎn)狀態(tài),下次要重啟時(shí)直接通過檢查點(diǎn)啟動即可。
在高性能計(jì)算的場景中,容器熱遷移可以保證運(yùn)行了許多天的計(jì)算結(jié)果不會丟失,只要周期性的進(jìn)行檢查點(diǎn)快照保存就可以了。
要使用這個(gè)功能,需要保證機(jī)器上已經(jīng)安裝了1.5.2或更高版本的criu工具。不同Linux發(fā)行版都有criu的安裝包,你也可以在CRIU官網(wǎng)上找到從源碼安裝的方法。我們將會在nsinit的使用中介紹容器熱遷移的使用方法。 CRIU(Checkpoint/Restore In Userspace)由OpenVZ項(xiàng)目于2005年發(fā)起,因?yàn)槠渖婕暗膬?nèi)核系統(tǒng)繁多、代碼多達(dá)數(shù)萬行,其復(fù)雜性與向后兼容性都阻礙著它進(jìn)入內(nèi)核主線,幾經(jīng)周折之后決定在用戶空間實(shí)現(xiàn),并在2012年被Linus加并入內(nèi)核主線,其后得以快速發(fā)展。 你可以在CRIU官網(wǎng)查看其原理,簡單描述起來可以分為兩部分,一是檢查點(diǎn)的保存,其中分為3步。
收集進(jìn)程與其子進(jìn)程構(gòu)成的樹,并凍結(jié)所有進(jìn)程。
收集任務(wù)(包括進(jìn)程和線程)使用的所有資源,并保存。
清理我們收集資源的相關(guān)寄生代碼,并與進(jìn)程分離。
第二部分自然是恢復(fù),分為4步。
讀取快照文件并解析出共享的資源,對多個(gè)進(jìn)程共享的資源優(yōu)先恢復(fù),其他資源則隨后需要時(shí)恢復(fù)。
使用fork恢復(fù)整個(gè)進(jìn)程樹,注意此時(shí)并不恢復(fù)線程,在第4步恢復(fù)。
恢復(fù)所有基礎(chǔ)任務(wù)(包括進(jìn)程和線程)資源,除了內(nèi)存映射、計(jì)時(shí)器、證書和線程。這一步主要打開文件、準(zhǔn)備namespace、創(chuàng)建socket連接等。
恢復(fù)進(jìn)程運(yùn)行的上下文環(huán)境,恢復(fù)剩下的其他資源,繼續(xù)運(yùn)行進(jìn)程。
至此,libcontainer的基本特性已經(jīng)預(yù)覽完畢,下面我們將從使用開始,一步步深入libcontainer的原理。
2. nsinit與libcontainer的使用
俗話說,了解一個(gè)工具最好的入門方式就是去使用它,nsinit就是一個(gè)為了方便不通過Docker就可以直接使用libcontainer而開發(fā)的命令行工具。它可以用于啟動一個(gè)容器或者在已有的容器中執(zhí)行命令。使用nsinit需要有 rootfs 以及相應(yīng)的配置文件。
2.1 nsinit的構(gòu)建
使用nsinit需要rootfs,最簡單最常用的是使用Docker busybox,相關(guān)配置文件則可以參考sample_configs目錄,主要配置的參數(shù)及其作用將在配置參數(shù)一節(jié)中介紹??截愐环菝麨閏ontainer.json文件到你rootfs所在目錄中,這份文件就包含了你對容器做的特定配置,包括運(yùn)行環(huán)境、網(wǎng)絡(luò)以及不同的權(quán)限。這份配置對容器中的所有進(jìn)程都會產(chǎn)生效果。 具體的構(gòu)建步驟在官方的README文檔中已經(jīng)給出,在此為了節(jié)省篇幅不再贅述。 最終編譯完成后生成nsinit二進(jìn)制文件,將這個(gè)指令加入到系統(tǒng)的環(huán)境變量,在busybox目錄下執(zhí)行如下命令,即可使用,需要root權(quán)限。 nsinit exec --tty --config container.json /bin/bash 執(zhí)行完成后會生成一個(gè)以容器ID命名的文件夾,上述命令沒有指定容器ID,默認(rèn)名為”nsinit”,在“nsinit”文件夾下會生成一個(gè)state.json文件,表示容器的狀態(tài),其中的內(nèi)容與配置參數(shù)中的內(nèi)容類似,展示容器的狀態(tài)。
2.2 nsinit的使用
目前nsinit定義了9個(gè)指令,使用nsinit -h就可以看到,對于每個(gè)單獨(dú)的指令使用--help就能獲得更詳細(xì)的使用參數(shù),如nsinit config --help。 nsinit這個(gè)命令行工具是通過cli.go實(shí)現(xiàn)的,cli.go封裝了命令行工具需要做的一些細(xì)節(jié),包括參數(shù)解析、命令執(zhí)行函數(shù)構(gòu)建等等,這就使得nsinit本身的代碼非常簡潔明了。具體的命令功能如下。
config:使用內(nèi)置的默認(rèn)參數(shù)加上執(zhí)行命令時(shí)用戶添加的部分參數(shù),生成一份容器可用的標(biāo)準(zhǔn)配置文件。
exec:啟動容器并執(zhí)行命令。除了一些共有的參數(shù)外,還有如下一些獨(dú)有的參數(shù)。
--tty,-t:為容器分配一個(gè)終端顯示輸出內(nèi)容。
--config:使用配置文件,后跟文件路徑。
--id:指定容器ID,默認(rèn)為nsinit。
--user,-u:指定用戶,默認(rèn)為“root”.
--cwd:指定當(dāng)前工作目錄。
--env:為進(jìn)程設(shè)置環(huán)境變量。
init:這是一個(gè)內(nèi)置的參數(shù),用戶并不能直接使用。這個(gè)命令是在容器內(nèi)部執(zhí)行,為容器進(jìn)行namespace初始化,并在完成初始化后執(zhí)行用戶指令。所以在代碼中,運(yùn)行nsinit exec后,傳入到容器中運(yùn)行的實(shí)際上是nsinit init,把用戶指令作為配置項(xiàng)傳入。
oom:展示容器的內(nèi)存超限通知。
pause/unpause:暫停/恢復(fù)容器中的進(jìn)程。
stats:顯示容器中的統(tǒng)計(jì)信息,主要包括cgroup和網(wǎng)絡(luò)。
state:展示容器狀態(tài),就是讀取state.json文件。
checkpoint:保存容器的檢查點(diǎn)快照并結(jié)束容器進(jìn)程。需要填--image-path參數(shù),后面是檢查點(diǎn)保存的快照文件路徑。完整的命令示例如下。 nsinit checkpoint --image-path=/tmp/criu
restore:從容器檢查點(diǎn)快照恢復(fù)容器進(jìn)程的運(yùn)行。參數(shù)同上。
總結(jié)起來,nsinit與Docker execdriver進(jìn)行的工作基本相同,所以在Docker的源碼中并不會涉及到nsinit包的調(diào)用,但是nsinit為libcontainer自身的調(diào)試和使用帶來了極大的便利。
3. 配置參數(shù)解析
no_pivot_root :這個(gè)參數(shù)表示用rootfs作為文件系統(tǒng)掛載點(diǎn),不單獨(dú)設(shè)置pivot_root。
parent_death_signal: 這個(gè)參數(shù)表示當(dāng)容器父進(jìn)程銷毀時(shí)發(fā)送給容器進(jìn)程的信號。
pivot_dir:在容器root目錄中指定一個(gè)目錄作為容器文件系統(tǒng)掛載點(diǎn)目錄。
rootfs:容器根目錄位置。
readonlyfs:設(shè)定容器根目錄為只讀。
mounts:設(shè)定額外的掛載,填充的信息包括原路徑,容器內(nèi)目的路徑,文件系統(tǒng)類型,掛載標(biāo)識位,掛載的數(shù)據(jù)大小和權(quán)限,最后設(shè)定共享掛載還是非共享掛載(獨(dú)立于mount_label的設(shè)定起作用)。
devices:設(shè)定在容器啟動時(shí)要創(chuàng)建的設(shè)備,填充的信息包括設(shè)備類型、容器內(nèi)設(shè)備路徑、設(shè)備塊號(major,minor)、cgroup文件權(quán)限、用戶編號、用戶組編號。
mount_label:設(shè)定共享掛載還是非共享掛載。
hostname:設(shè)定主機(jī)名。
namespaces:設(shè)定要加入的namespace,每個(gè)不同種類的namespace都可以指定,默認(rèn)與父進(jìn)程在同一個(gè)namespace中。
capabilities:設(shè)定在容器內(nèi)的進(jìn)程擁有的capabilities權(quán)限,所有沒加入此配置項(xiàng)的capabilities會被移除,即容器內(nèi)進(jìn)程失去該權(quán)限。
networks:初始化容器的網(wǎng)絡(luò)配置,包括類型(loopback、veth)、名稱、網(wǎng)橋、物理地址、IPV4地址及網(wǎng)關(guān)、IPV6地址及網(wǎng)關(guān)、Mtu大小、傳輸緩沖長度txqueuelen、Hairpin Mode設(shè)置以及宿主機(jī)設(shè)備名稱。
routes:配置路由表。
cgroups:配置cgroups資源限制參數(shù),使用的參數(shù)不多,主要包括允許的設(shè)備列表、內(nèi)存、交換區(qū)用量、CPU用量、塊設(shè)備訪問優(yōu)先級、應(yīng)用啟停等。
apparmor_profile:配置用于SELinux的apparmor文件。
process_label:同樣用于selinux的配置。
rlimits:最大文件打開數(shù)量,默認(rèn)與父進(jìn)程相同。
additional_groups:設(shè)定gid,添加同一用戶下的其他組。
uid_mappings:用于User namespace的uid映射。
gid_mappings:用戶User namespace的gid映射。
readonly_paths:在容器內(nèi)設(shè)定只讀部分的文件路徑。
MaskPaths:配置不使用的設(shè)備,通過綁定/dev/null進(jìn)行路徑掩蓋。
4. libcontainer實(shí)現(xiàn)原理
在Docker中,對容器管理的模塊為execdriver,目前Docker支持的容器管理方式有兩種,一種就是最初支持的LXC方式,另一種稱為native,即使用libcontainer進(jìn)行容器管理。在孫宏亮的《Docker源碼分析系列》中,Docker Deamon啟動過程中就會對execdriver進(jìn)行初始化,會根據(jù)驅(qū)動的名稱選擇使用的容器管理方式。 雖然在execdriver中只有LXC和native兩種選擇,但是native(即libcontainer)通過接口的方式定義了一系列容器管理的操作,包括處理容器的創(chuàng)建(Factory)、容器生命周期管理(Container)、進(jìn)程生命周期管理(Process)等一系列接口,相信如果Docker的熱潮一直像如今這般洶涌,那么不久的將來,Docker必將實(shí)現(xiàn)其全平臺通用的宏偉藍(lán)圖。本節(jié)也將從libcontainer的這些抽象對象開始講解,與你一同解開Docker容器管理之謎。在介紹抽象對象的具體實(shí)現(xiàn)過程中會與Docker execdriver聯(lián)系起來,讓你充分了解整個(gè)過程。
4.1 Factory 對象
Factory對象為容器創(chuàng)建和初始化工作提供了一組抽象接口,目前已經(jīng)具體實(shí)現(xiàn)的是Linux系統(tǒng)上的Factory對象。Factory抽象對象包含如下四個(gè)方法,我們將主要描述這四個(gè)方法的工作過程,涉及到具體實(shí)現(xiàn)方法則以LinuxFactory為例進(jìn)行講解。
Create():通過一個(gè)id和一份配置參數(shù)創(chuàng)建容器,返回一個(gè)運(yùn)行的進(jìn)程。容器的id由字母、數(shù)字和下劃線構(gòu)成,長度范圍為1~1024。容器ID為每個(gè)容器獨(dú)有,不能沖突。創(chuàng)建的最終返回一個(gè)Container類,包含這個(gè)id、狀態(tài)目錄(在root目錄下創(chuàng)建的以id命名的文件夾,存state.json容器狀態(tài)文件)、容器配置參數(shù)、初始化路徑和參數(shù),以及管理cgroup的方式(包含直接通過文件操作管理和systemd管理兩個(gè)選擇,默認(rèn)選cgroup文件系統(tǒng)管理)。
Load():當(dāng)創(chuàng)建的id已經(jīng)存在時(shí),即已經(jīng)Create過,存在id文件目錄,就會從id目錄下直接讀取state.json來載入容器。其中的參數(shù)在配置參數(shù)部分有詳細(xì)解釋。
Type():返回容器管理的類型,目前可能返回的有l(wèi)ibcontainer和lxc,為未來支持更多容器接口做準(zhǔn)備。
StartInitialization():容器內(nèi)初始化函數(shù)。
這部分代碼是在容器內(nèi)部執(zhí)行的,當(dāng)容器創(chuàng)建時(shí),如果New不加任何參數(shù),默認(rèn)在容器進(jìn)程中運(yùn)行的第一條命令就是nsinit init。在execdriver的初始化中,會向reexec注冊初始化器,命名為native,然后在創(chuàng)建libcontainer以后把native作為執(zhí)行參數(shù)傳遞到容器中執(zhí)行,這個(gè)初始化器創(chuàng)建的libcontainer就是沒有參數(shù)的。
傳入的參數(shù)是一個(gè)管道文件描述符,為了保證在初始化過程中,父子進(jìn)程間狀態(tài)同步和配置信息傳遞而建立。
不管是純粹新建的容器還是已經(jīng)創(chuàng)建的容器執(zhí)行新的命令,都是從這個(gè)入口做初始化。
第一步,通過管道獲取配置信息。
第二步,從配置信息中獲取環(huán)境變量并設(shè)置為容器內(nèi)環(huán)境變量。
若是已經(jīng)存在的容器執(zhí)行新命令,則只需要配置cgroup、namespace的Capabilities以及AppArmor等信息,最后執(zhí)行命令。
若是純粹新建的容器,則還需要初始化網(wǎng)絡(luò)、路由、namespace、主機(jī)名、配置只讀路徑等等,最后執(zhí)行命令。
至此,容器就已經(jīng)創(chuàng)建和初始化完畢了。
4.2 Container 對象
Container對象主要包含了容器配置、控制、狀態(tài)顯示等功能,是對不同平臺容器功能的抽象。目前已經(jīng)具體實(shí)現(xiàn)的是Linux平臺下的Container對象。每一個(gè)Container進(jìn)程內(nèi)部都是線程安全的。因?yàn)镃ontainer有可能被外部的進(jìn)程銷毀,所以每個(gè)方法都會對容器是否存在進(jìn)行檢測。
ID():顯示Container的ID,在Factor對象中已經(jīng)說過,ID很重要,具有唯一性。
Status():返回容器內(nèi)進(jìn)程是運(yùn)行狀態(tài)還是停止?fàn)顟B(tài)。通過執(zhí)行“SIG=0”的KILL命令對進(jìn)程是否存在進(jìn)行檢測。
State():返回容器的狀態(tài),包括容器ID、配置信息、初始進(jìn)程ID、進(jìn)程啟動時(shí)間、cgroup文件路徑、namespace路徑。通過調(diào)用Status()判斷進(jìn)程是否存在。
Config():返回容器的配置信息,可在“配置參數(shù)解析”部分查看有哪些方面的配置信息。
Processes():返回cgroup文件cgroup.procs中的值,在Docker背后的內(nèi)核知識:cgroups資源限制部分的講解中我們已經(jīng)提過,cgroup.procs文件會羅列所有在該cgroup中的線程組ID(即若有線程創(chuàng)建了子線程,則子線程的PID不包含在內(nèi))。由于容器不斷在運(yùn)行,所以返回的結(jié)果并不能保證完全存活,除非容器處于“PAUSED”狀態(tài)。
Stats():返回容器的統(tǒng)計(jì)信息,包括容器的cgroups中的統(tǒng)計(jì)以及網(wǎng)卡設(shè)備的統(tǒng)計(jì)信息。Cgroups中主要統(tǒng)計(jì)了cpu、memory和blkio這三個(gè)子系統(tǒng)的統(tǒng)計(jì)內(nèi)容,具體了解可以通過閱讀“cgroups資源限制”部分對于這三個(gè)子系統(tǒng)統(tǒng)計(jì)內(nèi)容的介紹來了解。網(wǎng)卡設(shè)備的統(tǒng)計(jì)則通過讀取系統(tǒng)中,網(wǎng)絡(luò)網(wǎng)卡文件的統(tǒng)計(jì)信息文件/sys/class/net/EthInterface/statistics來實(shí)現(xiàn)。
Set():設(shè)置容器cgroup各子系統(tǒng)的文件路徑。因?yàn)閏groups的配置是進(jìn)程運(yùn)行時(shí)也會生效的,所以我們可以通過這個(gè)方法在容器運(yùn)行時(shí)改變cgroups文件從而改變資源分配。
Start():構(gòu)建ParentProcess對象,用于處理啟動容器進(jìn)程的所有初始化工作,并作為父進(jìn)程與新創(chuàng)建的子進(jìn)程(容器)進(jìn)行初始化通信。傳入的Process對象可以幫助我們追蹤進(jìn)程的生命周期,Process對象將在后文詳細(xì)介紹。
啟動的過程首先會調(diào)用Status()方法的具體實(shí)現(xiàn)得知進(jìn)程是否存活。
創(chuàng)建一個(gè)管道(詳見Docker初始化通信——管道)為后期父子進(jìn)程通信做準(zhǔn)備。
配置子進(jìn)程cmd命令模板,配置參數(shù)的值就是從factory.Create()傳入進(jìn)來的,包括命令執(zhí)行的工作目錄、命令參數(shù)、輸入輸出、根目錄、子進(jìn)程管道以及KILL信號的值。
根據(jù)容器進(jìn)程是否存在確定是在已有容器中執(zhí)行命令還是創(chuàng)建新的容器執(zhí)行命令。若存在,則把配置的命令構(gòu)建成一個(gè)exec.Cmd對象、cgroup路徑、父子進(jìn)程管道及配置保留到ParentProcess對象中;若不存在,則創(chuàng)建容器進(jìn)程及相應(yīng)namespace,目前對user namespace有了一定的支持,若配置時(shí)加入user namespace,會針對配置項(xiàng)進(jìn)行映射,默認(rèn)映射到宿主機(jī)的root用戶,最后同樣構(gòu)建出相應(yīng)的配置內(nèi)容保留到ParentProcess對象中。通過在cmd.Env寫入環(huán)境變量_libcontainer_INITTYPE來告訴容器進(jìn)程采用的哪種方式啟動。
執(zhí)行ParentProcess中構(gòu)建的exec.Cmd內(nèi)容,即執(zhí)行ParentProcess.start(),具體的執(zhí)行過程在Process部分介紹。
最后如果是新建的容器進(jìn)程,還會執(zhí)行狀態(tài)更新函數(shù),把state.json的內(nèi)容刷新。
Destroy():首先使用cgroup的freezer子系統(tǒng)暫停所有運(yùn)行的進(jìn)程,然后給所有進(jìn)程發(fā)送SIGKIL信號(如果沒有使用pid namespace就不對進(jìn)程處理)。最后把cgroup及其子系統(tǒng)卸載,刪除cgroup文件夾。
Pause():使用cgroup的freezer子系統(tǒng)暫停所有運(yùn)行的進(jìn)程。
Resume():使用cgroup的freezer子系統(tǒng)恢復(fù)所有運(yùn)行的進(jìn)程。
NotifyOOM():為容器內(nèi)存使用超界提供只讀的通道,通過向cgroup.event_control寫入eventfd(用作線程間通信的消息隊(duì)列)和cgroup.oom_control(用于決定內(nèi)存使用超限后的處理方式)來實(shí)現(xiàn)。
Checkpoint():保存容器進(jìn)程檢查點(diǎn)快照,為容器熱遷移做準(zhǔn)備。通過使用CRIU的SWRK模式來實(shí)現(xiàn),這種模式是CRIU另外兩種模式CLI和RPC的結(jié)合體,允許用戶需要的時(shí)候像使用命令行工具一樣運(yùn)行CRIU,并接受用戶遠(yuǎn)程調(diào)用的請求,即傳入的熱遷移檢查點(diǎn)保存請求,傳入文件形式以Google的protobuf協(xié)議保存。
Restore():恢復(fù)檢查點(diǎn)快照并運(yùn)行,完成容器熱遷移。同樣通過CRIU的SWRK模式實(shí)現(xiàn),恢復(fù)的時(shí)候可以傳入配置文件設(shè)置恢復(fù)掛載點(diǎn)、網(wǎng)絡(luò)等配置信息。
至此,Container對象中的所有函數(shù)及相關(guān)功能都已經(jīng)介紹完畢,包含了容器生命周期的全部過程。
TIPs: Docker初始化通信——管道
libcontainer創(chuàng)建容器進(jìn)程時(shí)需要做初始化工作,此時(shí)就涉及到使用了namespace隔離后的兩個(gè)進(jìn)程間的通信。我們把負(fù)責(zé)創(chuàng)建容器的進(jìn)程稱為父進(jìn)程,容器進(jìn)程稱為子進(jìn)程。父進(jìn)程clone出子進(jìn)程以后,依舊是共享內(nèi)存的。但是如何讓子進(jìn)程知道內(nèi)存中寫入了新數(shù)據(jù)依舊是一個(gè)問題,一般有四種方法。
發(fā)送信號通知(signal)
對內(nèi)存輪詢訪問(poll memory)
sockets通信(sockets)
文件和文件描述符(files and file-descriptors)
對于Signal而言,本身包含的信息有限,需要額外記錄,namespace帶來的上下文變化使其不易理解,并不是最佳選擇。顯然通過輪詢內(nèi)存的方式來溝通是一個(gè)非常低效的做法。另外,因?yàn)镈ocker會加入network namespace,實(shí)際上初始時(shí)網(wǎng)絡(luò)棧也是完全隔離的,所以socket方式并不可行。 Docker最終選擇的方式就是打開的可讀可寫文件描述符——管道。 Linux中,通過pipe(int fd[2])系統(tǒng)調(diào)用就可以創(chuàng)建管道,參數(shù)是一個(gè)包含兩個(gè)整型的數(shù)組。調(diào)用完成后,在fd[1]端寫入的數(shù)據(jù),就可以從fd[0]端讀取。
// 需要加入頭文件: #include // 全局變量: int fd[2]; // 在父進(jìn)程中進(jìn)行初始化: pipe(fd); // 關(guān)閉管道文件描述符 close(checkpoint[1]);
調(diào)用pipe函數(shù)后,創(chuàng)建的子進(jìn)程會內(nèi)嵌這個(gè)打開的文件描述符,對fd[1]寫入數(shù)據(jù)后可以在fd[0]端讀取。通過管道,父子進(jìn)程之間就可以通信。通信完畢的奧秘就在于EOF信號的傳遞。大家都知道,當(dāng)打開的文件描述符都關(guān)閉時(shí),才能讀到EOF信號,所以libcontainer中父進(jìn)程先關(guān)閉自己這一端的管道,然后等待子進(jìn)程關(guān)閉另一端的管道文件描述符,傳來EOF表示子進(jìn)程已經(jīng)完成了初始化的過程。
4.3 Process 對象
Process 主要分為兩類,一類在源碼中就叫Process,用于容器內(nèi)進(jìn)程的配置和IO的管理;另一類在源碼中叫ParentProcess,負(fù)責(zé)處理容器啟動工作,與Container對象直接進(jìn)行接觸,啟動完成后作為Process的一部分,執(zhí)行等待、發(fā)信號、獲得pid等管理工作。 ParentProcess對象,主要包含以下六個(gè)函數(shù),而根據(jù)”需要新建容器”和“在已經(jīng)存在的容器中執(zhí)行”的不同方式,具體的實(shí)現(xiàn)也有所不同。
已有容器中執(zhí)行命令
pid(): 啟動容器進(jìn)程后通過管道從容器進(jìn)程中獲得,因?yàn)槿萜饕呀?jīng)存在,與Docker Deamon在不同的pid namespace中,從進(jìn)程所在的namespace獲得的進(jìn)程號才有意義。
start(): 初始化容器中的執(zhí)行進(jìn)程。在已有容器中執(zhí)行命令一般由docker exec調(diào)用,在execdriver包中,執(zhí)行exec時(shí)會引入nsenter包,從而調(diào)用其中的C語言代碼,執(zhí)行nsexec()函數(shù),該函數(shù)會讀取配置文件,使用setns()加入到相應(yīng)的namespace,然后通過clone()在該namespace中生成一個(gè)子進(jìn)程,并把子進(jìn)程通過管道傳遞出去,使用setns()以后并沒有進(jìn)入pid namespace,所以還需要通過加上clone()系統(tǒng)調(diào)用。
開始執(zhí)行進(jìn)程,首先會運(yùn)行C代碼,通過管道獲得進(jìn)程pid,最后等待C代碼執(zhí)行完畢。
通過獲得的pid把cmd中的Process替換成新生成的子進(jìn)程。
把子進(jìn)程加入cgroup中。
通過管道傳配置文件給子進(jìn)程。
等待初始化完成或出錯(cuò)返回,結(jié)束。
新建容器執(zhí)行命令
pid():啟動容器進(jìn)程后通過exec.Cmd自帶的pid()函數(shù)即可獲得。
start():初始化及執(zhí)行容器命令。
開始運(yùn)行進(jìn)程。
把進(jìn)程pid加入到cgroup中管理。
初始化容器網(wǎng)絡(luò)。(本部分內(nèi)容豐富,將從本系列的后續(xù)文章中深入講解)
通過管道發(fā)送配置文件給子進(jìn)程。
等待初始化完成或出錯(cuò)返回,結(jié)束。
實(shí)現(xiàn)方式類似的一些函數(shù)
**terminate() **:發(fā)送SIGKILL信號結(jié)束進(jìn)程。
**startTime() **:獲取進(jìn)程的啟動時(shí)間。
signal():發(fā)送信號給進(jìn)程。
wait():等待程序執(zhí)行結(jié)束,返回結(jié)束的程序狀態(tài)。
Process對象,主要描述了容器內(nèi)進(jìn)程的配置以及IO。包括參數(shù)Args,環(huán)境變量Env,用戶User(由于uid、gid映射),工作目錄Cwd,標(biāo)準(zhǔn)輸入輸出及錯(cuò)誤輸入,控制終端路徑consolePath,容器權(quán)限Capabilities以及上述提到的ParentProcess對象ops(擁有上面的一些操作函數(shù),可以直接管理進(jìn)程)。
5. 總結(jié)
本文主要介紹了Docker容器管理的方式libcontainer,從libcontainer的使用到源碼實(shí)現(xiàn)方式。我們深入到容器進(jìn)程內(nèi)部,感受到了libcontainer較為全面的設(shè)計(jì)??傮w而言,libcontainer本身主要分為三大塊工作內(nèi)容,一是容器的創(chuàng)建及初始化,二是容器生命周期管理,三則是進(jìn)程管理,調(diào)用方為Docker的execdriver。容器的監(jiān)控主要通過cgroups的狀態(tài)統(tǒng)計(jì)信息,未來會加入進(jìn)程追蹤等更豐富的功能。另一方面,libcontainer在安全支持方面也為用戶盡可能多的提供了支持和選擇。遺憾的是,容器安全的配置需要用戶對系統(tǒng)安全本身有足夠高的理解,user namespace也尚未支持,可見libcontainer依舊有很多工作要完善。但是Docker社區(qū)的火熱也自然帶動了大家對libcontainer的關(guān)注,相信在不久的將來,libcontainer就會變得更安全、更易用。
1.通過endless包實(shí)現(xiàn)
2.通過shutdown實(shí)現(xiàn)
在go 1.8.x后,golang在http里加入了shutdown方法,用來控制優(yōu)雅退出。什么是優(yōu)雅退出? 簡單說就是不處理新請求,但是會處理正在進(jìn)行的請求,把舊請求都處理完,也就是都response之后,那么就退出。
shutdown通過context上下文實(shí)現(xiàn) 。
社區(qū)里不少http graceful動態(tài)重啟,平滑重啟的庫,大多是基于http.shutdown做的。平滑啟動的原理很簡單,fork子進(jìn)程,繼承l(wèi)isten fd, 老進(jìn)程優(yōu)雅退出。
3.context原理
context 是 Go 并發(fā)編程中常用到一種編程模式。
在并發(fā)程序中,由于超時(shí)、取消操作或者一些異常情況,往往需要進(jìn)行搶占操作或者中斷后續(xù)操作。熟悉 channel 的朋友應(yīng)該都見過使用 done channel 來處理此類問題。比如以下這個(gè)例子:
上述例子中定義了一個(gè) buffer 為0的 channel done , 子協(xié)程運(yùn)行著定時(shí)任務(wù)。如果主協(xié)程需要在某個(gè)時(shí)刻發(fā)送消息通知子協(xié)程中斷任務(wù)退出,那么就可以讓子協(xié)程監(jiān)聽這個(gè) done channel ,一旦主協(xié)程關(guān)閉 done channel ,那么子協(xié)程就可以推出了,這樣就實(shí)現(xiàn)了主協(xié)程通知子協(xié)程的需求。這很好,但是這也是有限的。
如果我們可以在簡單的通知上附加傳遞額外的信息來控制取消:為什么取消,或者有一個(gè)它必須要完成的最終期限,更或者有多個(gè)取消選項(xiàng),我們需要根據(jù)額外的信息來判斷選擇執(zhí)行哪個(gè)取消選項(xiàng)。
考慮下面這種情況:假如主協(xié)程中有多個(gè)任務(wù)1, 2, …m,主協(xié)程對這些任務(wù)有超時(shí)控制;而其中任務(wù)1又有多個(gè)子任務(wù)1, 2, …n,任務(wù)1對這些子任務(wù)也有自己的超時(shí)控制,那么這些子任務(wù)既要感知主協(xié)程的取消信號,也需要感知任務(wù)1的取消信號。
如果還是使用 done channel 的用法,我們需要定義兩個(gè) done channel ,子任務(wù)們需要同時(shí)監(jiān)聽這兩個(gè) done channel 。嗯,這樣其實(shí)好像也還行哈。但是如果層級更深,如果這些子任務(wù)還有子任務(wù),那么使用 done channel 的方式將會變得非常繁瑣且混亂。
我們需要一種優(yōu)雅的方案來實(shí)現(xiàn)這樣一種機(jī)制:
這個(gè)時(shí)候 context 就派上用場了。
我們首先看看 context 的結(jié)構(gòu)設(shè)計(jì)和實(shí)現(xiàn)原理。
先看 Context 接口結(jié)構(gòu),看起來非常簡單。
Context 接口包含四個(gè)方法:
可以看到 Done 方法返回的 channel 正是用來傳遞結(jié)束信號以搶占并中斷當(dāng)前任務(wù); Deadline 方法指示一段時(shí)間后當(dāng)前 goroutine 是否會被取消;以及一個(gè) Err 方法,來解釋 goroutine 被取消的原因;而 Value 則用于獲取特定于當(dāng)前任務(wù)樹的額外信息。而 context 所包含的額外信息鍵值對是如何存儲的呢?其實(shí)可以想象一顆樹,樹的每個(gè)節(jié)點(diǎn)可能攜帶一組鍵值對,如果當(dāng)前節(jié)點(diǎn)上無法找到 key 所對應(yīng)的值,就會向上去父節(jié)點(diǎn)里找,直到根節(jié)點(diǎn),具體后面會說到。
emptyCtx 是一個(gè) int 類型的變量,但實(shí)現(xiàn)了 context 的接口。 emptyCtx 沒有超時(shí)時(shí)間,不能取消,也不能存儲任何額外信息,所以 emptyCtx 用來作為 context 樹的根節(jié)點(diǎn)。
但我們一般不會直接使用 emptyCtx ,而是使用由 emptyCtx 實(shí)例化的兩個(gè)變量,分別可以通過調(diào)用 Background 和 TODO 方法得到,但這兩個(gè) context 在實(shí)現(xiàn)上是一樣的。那么 Background 和 TODO 方法得到的 context 有什么區(qū)別呢?可以看一下官方的解釋:
Background 和 TODO 只是用于不同場景下:
Background 通常被用于主函數(shù)、初始化以及測試中,作為一個(gè)頂層的 context ,也就是說一般我們創(chuàng)建的 context 都是基于 Background ;
而 TODO 是在不確定使用什么 context 的時(shí)候才會使用。
下面將介紹兩種不同功能的基礎(chǔ) context 類型: valueCtx 和 cancelCtx 。
valueCtx 利用一個(gè) Context 類型的變量來表示父節(jié)點(diǎn) context ,所以當(dāng)前 context 繼承了父 context 的所有信息; valueCtx 類型還攜帶一組鍵值對,也就是說這種 context 可以攜帶額外的信息。 valueCtx 實(shí)現(xiàn)了 Value 方法,用以在 context 鏈路上獲取 key 對應(yīng)的值,如果當(dāng)前 context 上不存在需要的 key ,會沿著 context 鏈向上尋找 key 對應(yīng)的值,直到根節(jié)點(diǎn)。
WithValue 用以向 context 添加鍵值對:
這里添加鍵值對不是在原 context 結(jié)構(gòu)體上直接添加,而是以此 context 作為父節(jié)點(diǎn),重新創(chuàng)建一個(gè)新的 valueCtx 子節(jié)點(diǎn),將鍵值對添加在子節(jié)點(diǎn)上,由此形成一條 context 鏈。獲取 value 的過程就是在這條 context 鏈上由尾部上前搜尋:
跟 valueCtx 類似, cancelCtx 中也有一個(gè) context 變量作為父節(jié)點(diǎn);變量 done 表示一個(gè) channel ,用來表示傳遞關(guān)閉信號; children 表示一個(gè) map ,存儲了當(dāng)前 context 節(jié)點(diǎn)下的子節(jié)點(diǎn); err 用于存儲錯(cuò)誤信息表示任務(wù)結(jié)束的原因。
再來看一下 cancelCtx 實(shí)現(xiàn)的方法:
可以發(fā)現(xiàn) cancelCtx 類型變量其實(shí)也是 canceler 類型,因?yàn)? cancelCtx 實(shí)現(xiàn)了 canceler 接口。 Done 方法和 Err 方法沒必要說了, cancelCtx 類型的 context 在調(diào)用 cancel 方法時(shí)會設(shè)置取消原因,將 done channel 設(shè)置為一個(gè)關(guān)閉 channel 或者關(guān)閉 channel ,然后將子節(jié)點(diǎn) context 依次取消,如果有需要還會將當(dāng)前節(jié)點(diǎn)從父節(jié)點(diǎn)上移除。
WithCancel 函數(shù)用來創(chuàng)建一個(gè)可取消的 context ,即 cancelCtx 類型的 context 。 WithCancel 返回一個(gè) context 和一個(gè) CancelFunc ,調(diào)用 CancelFunc 即可觸發(fā) cancel 操作。直接看源碼:
之前說到 cancelCtx 取消時(shí),會將后代節(jié)點(diǎn)中所有的 cancelCtx 都取消, propagateCancel 即用來建立當(dāng)前節(jié)點(diǎn)與祖先節(jié)點(diǎn)這個(gè)取消關(guān)聯(lián)邏輯。
這里或許有個(gè)疑問,為什么是祖先節(jié)點(diǎn)而不是父節(jié)點(diǎn)?這是因?yàn)楫?dāng)前 context 鏈可能是這樣的:
當(dāng)前 cancelCtx 的父節(jié)點(diǎn) context 并不是一個(gè)可取消的 context ,也就沒法記錄 children 。
timerCtx 是一種基于 cancelCtx 的 context 類型,從字面上就能看出,這是一種可以定時(shí)取消的 context 。
timerCtx 內(nèi)部使用 cancelCtx 實(shí)現(xiàn)取消,另外使用定時(shí)器 timer 和過期時(shí)間 deadline 實(shí)現(xiàn)定時(shí)取消的功能。 timerCtx 在調(diào)用 cancel 方法,會先將內(nèi)部的 cancelCtx 取消,如果需要則將自己從 cancelCtx 祖先節(jié)點(diǎn)上移除,最后取消計(jì)時(shí)器。
WithDeadline 返回一個(gè)基于 parent 的可取消的 context ,并且其過期時(shí)間 deadline 不晚于所設(shè)置時(shí)間 d 。
與 WithDeadline 類似, WithTimeout 也是創(chuàng)建一個(gè)定時(shí)取消的 context ,只不過 WithDeadline 是接收一個(gè)過期時(shí)間點(diǎn),而 WithTimeout 接收一個(gè)相對當(dāng)前時(shí)間的過期時(shí)長 timeout :
首先使用 context 實(shí)現(xiàn)文章開頭 done channel 的例子來示范一下如何更優(yōu)雅實(shí)現(xiàn)協(xié)程間取消信號的同步:
這個(gè)例子中,只要讓子線程監(jiān)聽主線程傳入的 ctx ,一旦 ctx.Done() 返回空 channel ,子線程即可取消執(zhí)行任務(wù)。但這個(gè)例子還無法展現(xiàn) context 的傳遞取消信息的強(qiáng)大優(yōu)勢。
閱讀過 net/http 包源碼的朋友可能注意到在實(shí)現(xiàn) http server 時(shí)就用到了 context , 下面簡單分析一下。
1、首先 Server 在開啟服務(wù)時(shí)會創(chuàng)建一個(gè) valueCtx ,存儲了 server 的相關(guān)信息,之后每建立一條連接就會開啟一個(gè)協(xié)程,并攜帶此 valueCtx 。
2、建立連接之后會基于傳入的 context 創(chuàng)建一個(gè) valueCtx 用于存儲本地地址信息,之后在此基礎(chǔ)上又創(chuàng)建了一個(gè) cancelCtx ,然后開始從當(dāng)前連接中讀取網(wǎng)絡(luò)請求,每當(dāng)讀取到一個(gè)請求則會將該 cancelCtx 傳入,用以傳遞取消信號。一旦連接斷開,即可發(fā)送取消信號,取消所有進(jìn)行中的網(wǎng)絡(luò)請求。
3、讀取到請求之后,會再次基于傳入的 context 創(chuàng)建新的 cancelCtx ,并設(shè)置到當(dāng)前請求對象 req 上,同時(shí)生成的 response 對象中 cancelCtx 保存了當(dāng)前 context 取消方法。
這樣處理的目的主要有以下幾點(diǎn):
在整個(gè) server 處理流程中,使用了一條 context 鏈貫穿 Server 、 Connection 、 Request ,不僅將上游的信息共享給下游任務(wù),同時(shí)實(shí)現(xiàn)了上游可發(fā)送取消信號取消所有下游任務(wù),而下游任務(wù)自行取消不會影響上游任務(wù)。
context 主要用于父子任務(wù)之間的同步取消信號,本質(zhì)上是一種協(xié)程調(diào)度的方式 。另外在使用 context 時(shí)有兩點(diǎn)值得注意:上游任務(wù)僅僅使用 context 通知下游任務(wù)不再需要,但不會直接干涉和中斷下游任務(wù)的執(zhí)行,由下游任務(wù)自行決定后續(xù)的處理操作,也就是說 context 的取消操作是無侵入的; context 是線程安全的,因?yàn)? context 本身是不可變的( immutable ),因此可以放心地在多個(gè)協(xié)程中傳遞使用。
網(wǎng)站欄目:go語言父子進(jìn)程 go 子進(jìn)程
網(wǎng)頁路徑:http://jinyejixie.com/article42/dodhshc.html
成都網(wǎng)站建設(shè)公司_創(chuàng)新互聯(lián),為您提供品牌網(wǎng)站制作、標(biāo)簽優(yōu)化、全網(wǎng)營銷推廣、移動網(wǎng)站建設(shè)、網(wǎng)站制作、營銷型網(wǎng)站建設(shè)
聲明:本網(wǎng)站發(fā)布的內(nèi)容(圖片、視頻和文字)以用戶投稿、用戶轉(zhuǎn)載內(nèi)容為主,如果涉及侵權(quán)請盡快告知,我們將會在第一時(shí)間刪除。文章觀點(diǎn)不代表本網(wǎng)站立場,如需處理請聯(lián)系客服。電話:028-86922220;郵箱:631063699@qq.com。內(nèi)容未經(jīng)允許不得轉(zhuǎn)載,或轉(zhuǎn)載時(shí)需注明來源: 創(chuàng)新互聯(lián)