了解Docker
走進docker的世界
介紹docker的前世今生,了解docker的實現原理,以Django項目為例,帶大家如何編寫最佳的Dockerfile構建鏡像。通過本章的學習,大家會知道docker的概念及基本操作,并學會構建自己的業務鏡像,并通過抓包的方式掌握Docker最常用的bridge網絡模式的通信。
why
what
how
需要一種輕量、高效的虛擬化能力
Docker 公司位于舊金山,原名dotCloud,底層利用了Linux容器技術(LXC)(在操作系統中實現資源隔離與限制)。為了方便創建和管理這些容器,dotCloud 開發了一套內部工具,之后被命名為“Docker”。Docker就是這樣誕生的。
Hypervisor: 一種運行在基礎物理服務器和操作系統之間的中間軟件層,可允許多個操作系統和應用共享硬件 。常見的VMware的 Workstation 、ESXi、微軟的Hyper-V或者思杰的XenServer。
Container Runtime:通過Linux內核虛擬化能力管理多個容器,多個容器共享一套操作系統內核。因此摘掉了內核占用的空間及運行所需要的耗時,使得容器極其輕量與快速。
基于操作系統內核,提供輕量級虛擬化功能的CS架構的軟件產品。
基于輕量的特性,解決軟件交付過程中的環境依賴
可以把應用程序代碼及運行依賴環境打包成鏡像,作為交付介質,在各環境部署
可以將鏡像(image)啟動成為容器(container),并且提供多容器的生命周期進行管理(啟、停、刪)
container容器之間相互隔離,且每個容器可以設置資源限額
提供輕量級虛擬化功能,容器就是在宿主機中的一個個的虛擬的空間,彼此相互隔離,完全獨立
Docker 引擎主要有兩個版本:企業版(EE)和社區版(CE)
每個季度(1-3,4-6,7-9,10-12),企業版和社區版都會發布一個穩定版本(Stable)。社區版本會提供 4 個月的支持,而企業版本會提供 12 個月的支持
每個月社區版還會通過 Edge 方式發布月度版
從 2017 年第一季度開始,Docker 版本號遵循 YY.MM-xx 格式,類似于 Ubuntu 等項目。例如,2018 年 6 月第一次發布的社區版本為 18.06.0-ce
13年成立,15年開始,迎來了飛速發展。
Docker 1.8之前,使用LXC,Docker在上層做了封裝, 把LXC復雜的容器創建與使用方式簡化為自己的一套命令體系。
之后,為了實現跨平臺等復雜的場景,Docker抽出了libcontainer項目,把對namespace、cgroup的操作封裝在libcontainer項目里,支持不同的平臺類型。
2015年6月,Docker牽頭成立了 OCI(Open Container Initiative開放容器計劃)組織,這個組織的目的是建立起一個圍繞容器的通用標準 。 容器格式標準是一種不受上層結構綁定的協議,即不限于某種特定操作系統、硬件、CPU架構、公有云等 , 允許任何人在遵循該標準的情況下開發應用容器技術,這使得容器技術有了一個更廣闊的發展空間。
OCI成立后,libcontainer 交給OCI組織來維護,但是libcontainer中只包含了與kernel交互的庫,因此基于libcontainer項目,后面又加入了一個CLI工具,并且項目改名為runC (https://github.com/opencontainers/runc ), 目前runC已經成為一個功能強大的runtime工具。
Docker也做了架構調整。將容器運行時相關的程序從docker daemon剝離出來,形成了containerd。containerd向上為Docker Daemon提供了gRPC接口,使得Docker Daemon屏蔽下面的結構變化,確保原有接口向下兼容。向下通過containerd-shim結合runC,使得引擎可以獨立升級,避免之前Docker Daemon升級會導致所有容器不可用的問題。
也就是說
runC(libcontainer)是符合OCI標準的一個實現,與底層系統交互
containerd是實現了OCI之上的容器的高級功能,比如鏡像管理、容器執行的調用等
Dockerd目前是最上層與CLI交互的進程,接收cli的請求并與containerd協作
為了提供一種更加輕量的虛擬化技術,docker出現了
借助于docker容器的輕、快等特性,解決了軟件交付過程中的環境依賴問題,使得docker得以快速發展
Docker是一種CS架構的軟件產品,可以把代碼及依賴打包成鏡像,作為交付介質,并且把鏡像啟動成為容器,提供容器生命周期的管理
docker-ce,每季度發布stable版本。18.06,18.09,19.03
發展至今,docker已經通過制定OCI標準對最初的項目做了拆分,其中runC和containerd是docker的核心項目,理解docker整個請求的流程,對我們深入理解docker有很大的幫助
## 若未配置,需要執行如下 $ cat <
## 下載阿里源repo文件 $ curl -o /etc/yum.repos.d/Centos-7.repo http://mirrors.aliyun.com/repo/Centos-7.repo $ curl -o /etc/yum.repos.d/docker-ce.repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo $ yum clean all && yum makecache ## yum安裝 $ yum install docker-ce-20.10.6 -y ## 查看源中可用版本 $ yum list docker-ce --showduplicates | sort -r ## 安裝舊版本 ##yum install -y docker-ce-18.09.9 ## 配置源加速 ## https://cr.console.aliyun.com/cn-hangzhou/instances/mirrors mkdir -p /etc/docker vi /etc/docker/daemon.json { "registry-mirrors" : [ "https://8xpk5wnt.mirror.aliyuncs.com" ] } ## 設置開機自啟 systemctl enable docker systemctl daemon-reload ## 啟動docker systemctl start docker ## 查看docker信息 docker info ## docker-client which docker ## docker daemon ps aux |grep docker ## containerd ps aux|grep containerd systemctl status containerd
三大核心要素:鏡像(Image)、容器(Container)、倉庫(Registry)
打包了業務代碼及運行環境的包,是靜態的文件,不能直接對外提供服務。
鏡像的運行時,可以對外提供服務。
存放鏡像的地方
公有倉庫,Docker Hub,阿里,網易...
私有倉庫,企業內部搭建
Docker Registry,Docker官方提供的鏡像倉庫存儲服務
Harbor, 是Docker Registry的更高級封裝,它除了提供友好的Web UI界面,角色和用戶權限管理,用戶操作審計等功能
鏡像訪問地址形式 registry.devops.com/demo/hello:latest,若沒有前面的url地址,則默認尋找Docker Hub中的鏡像,若沒有tag標簽,則使用latest作為標簽。 比如,docker pull nginx,會被解析成docker.io/library/nginx:latest
公有的倉庫中,一般存在這么幾類鏡像
操作系統基礎鏡像(centos,ubuntu,suse,alpine)
中間件(nginx,redis,mysql,tomcat)
語言編譯環境(python,java,golang)
業務鏡像(django-demo...)
容器和倉庫不會直接交互,都是以鏡像為載體來操作。
查看鏡像列表
$ docker images
如何獲取鏡像
從遠程倉庫拉取
$ docker pull nginx:alpine $ docker images https://hub.docker.com/ ---> 官方鏡像地址https://index.docker.io/v1/
使用tag命令
$ docker tag nginx:alpine 172.21.51.143:5000/nginx:alpine $ docker images
本地構建
$ docker build . -t my-nginx:ubuntu -f Dockerfile
如何通過鏡像啟動容器
$ docker run --name my-nginx-alpine -d nginx:alpine # -d 已守護進程運行
如何知道容器內部運行了什么程序?
# 進入容器內部,分配一個tty終端, -i 交互式 $ docker exec -ti my-nginx-alpine /bin/sh # ps aux
docker怎么知道容器啟動后該執行什么命令?
通過docker build來模擬構建一個nginx的鏡像,
創建Dockerfile
# 告訴docker使用哪個基礎鏡像作為模板,后續命令都以這個鏡像為基礎 FROM ubuntu # RUN命令會在上面指定的鏡像里執行命令 RUN apt-get update && apt install -y nginx #告訴docker,啟動容器時執行如下命令 CMD ["/usr/sbin/nginx", "-g","daemon off;"]
構建本地鏡像
$ docker build . -t my-nginx:ubuntu -f Dockerfile
使用新鏡像啟動容器
$ docker run --name my-nginx-ubuntu -d my-nginx:ubuntu
進入容器查看進程
$ docker exec -ti my-nginx-ubuntu /bin/sh # ps aux
如何訪問容器內服務
# 進入容器內部 $ docker exec -ti my-nginx-alpine /bin/sh # ps aux|grep nginx # curl localhost:80
宿主機中如何訪問容器服務
# 刪掉舊服務,重新啟動 $ docker rm -f my-nginx-alpine $ docker run --name my-nginx-alpine -d -p 8080:80 nginx:alpine $ curl 172.21.51.143:8080
docker client如何與daemon通信
# /var/run/docker.sock $ docker run --name portainer -d -p 9001:9000 -v /var/run/docker.sock:/var/run/docker.sock portainer/portainer
查看所有鏡像:
$ docker images
拉取鏡像:
$ docker pull nginx:alpine
如何唯一確定鏡像:
image_id
repository:tag
$ docker images REPOSITORY TAG IMAGE ID CREATED SIZE nginx alpine 377c0837328f 2 weeks ago 19.7MB
導出鏡像到文件中
$ docker save -o nginx-alpine.tar nginx:alpine
從文件中加載鏡像
$ docker load -i nginx-alpine.tar
部署鏡像倉庫
https://docs.docker.com/registry/
## 使用docker鏡像啟動鏡像倉庫服務 $ docker run -d -p 5000:5000 --restart always --name registry registry:2 ## 默認倉庫不帶認證,若需要認證,參考https://docs.docker.com/registry/deploying/#restricting-access
推送本地鏡像到鏡像倉庫中
$ docker tag nginx:alpine localhost:5000/nginx:alpine $ docker push localhost:5000/nginx:alpine ## 查看倉庫內元數據 $ curl -X GET http://172.21.51.143:5000/v2/_catalog $ curl -X GET http://172.21.51.143:5000/v2/nginx/tags/list ## 鏡像倉庫給外部訪問,不能通過localhost,嘗試使用內網地址172.21.51.143:5000/nginx:alpine $ docker tag nginx:alpine 172.21.51.143:5000/nginx:alpine $ docker push 172.21.51.143:5000/nginx:alpine The push refers to repository [172.21.51.143:5000/nginx] Get https://172.21.51.143:5000/v2/: http: server gave HTTP response to HTTPS client ## docker默認不允許向http的倉庫地址推送,如何做成https的,參考:https://docs.docker.com/registry/deploying/#run-an-externally-accessible-registry ## 我們沒有可信證書機構頒發的證書和域名,自簽名證書需要在每個節點中拷貝證書文件,比較麻煩,因此我們通過配置daemon的方式,來跳過證書的驗證: $ cat /etc/docker/daemon.json { "registry-mirrors": [ "https://8xpk5wnt.mirror.aliyuncs.com" ], "insecure-registries": [ "172.21.51.143:5000" ] } $ systemctl restart docker $ docker push 172.21.51.143:5000/nginx:alpine $ docker images # IMAGE ID相同,等于起別名或者加快捷方式 REPOSITORY TAG IMAGE ID CREATED SIZE 172.21.51.143:5000/nginx alpine 377c0837328f 4 weeks ago nginx alpine 377c0837328f 4 weeks ago localhost:5000/nginx alpine 377c0837328f 4 weeks ago registry 2 708bc6af7e5e 2 months ago
刪除鏡像
docker rmi nginx:alpine
查看容器列表
## 查看運行狀態的容器列表 $ docker ps ## 查看全部狀態的容器列表 $ docker ps -a
啟動容器
## 后臺啟動 $ docker run --name nginx -d nginx:alpine ## 映射端口,把容器的端口映射到宿主機中,-p
容器數據持久化
## 掛載主機目錄 $ docker run --name nginx -d -v /opt:/opt nginx:alpine $ docker run --name mysql -e MYSQL_ROOT_PASSWORD=123456 -d -v /opt/mysql/:/var/lib/mysql mysql:5.7
進入容器或者執行容器內的命令
$ docker exec -ti
主機與容器之間拷貝數據
## 主機拷貝到容器 $ echo '123'>/tmp/test.txt $ docker cp /tmp/test.txt nginx:/tmp $ docker exec -ti nginx cat /tmp/test.txt 123 ## 容器拷貝到主機 $ docker cp nginx:/tmp/test.txt ./
掛載已有的數據,重新創建鏡像倉庫容器
## 解壓離線鏡像文件 $ tar zxf registry.tar.gz -C /opt ## 刪除當前鏡像倉庫容器 $ docker rm -f registry ## 使用docker鏡像啟動鏡像倉庫服務 $ docker run -d -p 5000:5000 --restart always -v /opt/registry:/var/lib/registry --name registry registry:2
假設啟動鏡像倉庫服務的主機地址為172.21.51.143,該目錄中已存在的鏡像列表:
查看容器日志
## 查看全部日志 $ docker logs nginx ## 實時查看最新日志 $ docker logs -f nginx ## 從最新的100條開始查看 $ docker logs --tail=100 -f nginx
停止或者刪除容器
## 停止運行中的容器 $ docker stop nginx ## 啟動退出容器 $ docker start nginx ## 刪除非運行中狀態的容器 $ docker rm nginx ## 刪除運行中的容器 $ docker rm -f nginx
查看容器或者鏡像的明細
## 查看容器詳細信息,包括容器IP地址等 $ docker inspect nginx ## 查看鏡像的明細信息 $ docker inspect nginx:alpine
$ docker build . -t ImageName:ImageTag -f Dockerfile
Dockerfile是一堆指令,在docker build的時候,按照該指令進行操作,最終生成我們期望的鏡像
FROM 指定基礎鏡像,必須為第一個命令
格式: FROM
MAINTAINER 鏡像維護者的信息
格式: MAINTAINER
COPY|ADD 添加本地文件到鏡像中
格式: COPY
WORKDIR 工作目錄
格式: WORKDIR /path/to/workdir 示例: WORKDIR /a (這時工作目錄為/a) 注意: 通過WORKDIR設置工作目錄后,Dockerfile中其后的命令RUN、CMD、ENTRYPOINT、ADD、COPY等命令都會在該目錄下執行
RUN 構建鏡像過程中執行命令
格式: RUN
CMD 構建容器后調用,也就是在容器啟動時才進行調用
格式: CMD ["executable","param1","param2"] (執行可執行文件,優先) CMD ["param1","param2"] (設置了ENTRYPOINT,則直接調用ENTRYPOINT添加參數) CMD command param1 param2 (執行shell內部命令) 示例: CMD ["/usr/bin/wc","--help"] CMD ping www.baidu.com 注意: CMD不同于RUN,CMD用于指定在容器啟動時所要執行的命令,而RUN用于指定鏡像構建時所要執行的命令。
ENTRYPOINT 設置容器初始化命令,使其可執行化
格式: ENTRYPOINT ["executable", "param1", "param2"] (可執行文件, 優先) ENTRYPOINT command param1 param2 (shell內部命令) 示例: ENTRYPOINT ["/usr/bin/wc","--help"] 注意: ENTRYPOINT與CMD非常類似,不同的是通過docker run執行的命令不會覆蓋ENTRYPOINT,而docker run命令中指定的任何參數,都會被當做參數再次傳遞給ENTRYPOINT。Dockerfile中只允許有一個ENTRYPOINT命令,多指定時會覆蓋前面的設置,而只執行最后的ENTRYPOINT指令
ENV
格式: ENV
EXPOSE
格式: EXPOSE
基礎環境鏡像
FROM java:8-alpine RUN apk add --update ca-certificates && rm -rf /var/cache/apk/* && \ find /usr/share/ca-certificates/mozilla/ -name "*.crt" -exec keytool -import -trustcacerts \ -keystore /usr/lib/jvm/java-1.8-openjdk/jre/lib/security/cacerts -storepass changeit -noprompt \ -file {} -alias {} \; && \ keytool -list -keystore /usr/lib/jvm/java-1.8-openjdk/jre/lib/security/cacerts --storepass changeit ENV MAVEN_VERSION 3.5.4 ENV MAVEN_HOME /usr/lib/mvn ENV PATH $MAVEN_HOME/bin:$PATH RUN wget http://archive.apache.org/dist/maven/maven-3/$MAVEN_VERSION/binaries/apache-maven-$MAVEN_VERSION-bin.tar.gz && \ tar -zxvf apache-maven-$MAVEN_VERSION-bin.tar.gz && \ rm apache-maven-$MAVEN_VERSION-bin.tar.gz && \ mv apache-maven-$MAVEN_VERSION /usr/lib/mvn RUN mkdir -p /usr/src/app WORKDIR /usr/src/app
前端鏡像
FROM nginx:1.19.0-alpine LABEL maintainer="mritd
java鏡像
FROM java:8u111 ENV JAVA_OPTS "\ -Xmx4096m \ -XX:MetaspaceSize=256m \ -XX:MaxMetaspaceSize=256m" ENV JAVA_HOME /usr/java/jdk ENV PATH ${PATH}:${JAVA_HOME}/bin COPY target/myapp.jar myapp.jar RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime RUN echo 'Asia/Shanghai' >/etc/timezone EXPOSE 9000 CMD java ${JAVA_OPTS} -jar myapp.jar
golang鏡像
多階段構建
https://gitee.com/agagin/href-counter.git
原始構建:
FROM golang:1.13 WORKDIR /go/src/github.com/alexellis/href-counter/ COPY vendor vendor COPY app.go . ENV GOPROXY https://goproxy.cn RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . CMD ["./app"]
$ docker build . -t href-counter:v1 -f Dockerfile
創建目錄
相對路徑
exec的默認路徑
多階構建:
FROM golang:1.13 AS builder WORKDIR /go/src/github.com/alexellis/href-counter/ COPY vendor vendor COPY app.go . ENV GOPROXY https://goproxy.cn RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o app . FROM alpine:3.10 RUN apk --no-cache add ca-certificates WORKDIR /root/ COPY --from=builder /go/src/github.com/alexellis/href-counter/app . CMD ["./app"]
$ docker build . -t href-counter:v2 -f Dockerfile.multi
原則:
不必要的內容不要放在鏡像中
減少不必要的層文件
減少網絡傳輸操作
可以適當的包含一些調試命令
$ docker exec -ti my-nginx-alpine /bin/sh #/ ps aux
容器啟動的時候可以通過命令去覆蓋默認的CMD
$ docker run -d --name xxx nginx:alpine <自定義命令> # <自定義命令>會覆蓋鏡像中指定的CMD指令,作為容器的1號進程啟動。 $ docker run -d --name test-3 nginx:alpine echo 123 $ docker run -d --name test-4 nginx:alpine ping www.luffycity.com
在dockerfile中CMD和entrypoint區別
本質上講容器是利用namespace和cgroup等技術在宿主機中創建的獨立的虛擬空間,這個空間內的網絡、進程、掛載等資源都是隔離的。
$ docker exec -ti my-nginx /bin/sh #/ ip addr #/ ls -l / #/ apt install xxx #/ #安裝的軟件對宿主機和其他容器沒有任何影響,和虛擬機不同的是,容器間共享一個內核,所以容器內沒法升級內核
項目地址:https://gitee.com/agagin/python-demo.git
python3 + django + uwsgi + nginx + mysql
內部服務端口8002
dockerfiles/myblog/Dockerfile
python3
uwigi,mysql,pip3
copy code
mysql數據庫
nginx
# This my first django Dockerfile # Version 1.0 # Base images 基礎鏡像 FROM centos:centos7.5.1804 #MAINTAINER 維護者信息 LABEL maintainer="inspur_lyx@hotmail.com" #ENV 設置環境變量 ENV LANG en_US.UTF-8 ENV LC_ALL en_US.UTF-8 #RUN 執行以下命令 RUN curl -so /etc/yum.repos.d/Centos-7.repo http://mirrors.aliyun.com/repo/Centos-7.repo && rpm -Uvh http://nginx.org/packages/centos/7/noarch/RPMS/nginx-release-centos-7-0.el7.ngx.noarch.rpm RUN yum install -y python36 python3-devel gcc pcre-devel zlib-devel make net-tools nginx #工作目錄 WORKDIR /opt/myblog #拷貝文件至工作目錄 COPY . . # 拷貝nginx配置文件 COPY myblog.conf /etc/nginx #安裝依賴的插件 RUN pip3 install -i http://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun.com -r requirements.txt RUN chmod +x run.sh && rm -rf ~/.cache/pip #EXPOSE 映射端口 EXPOSE 8002 #容器啟動時執行命令 CMD ["./run.sh"]
執行構建:
$ docker build . -t myblog:v1 -f Dockerfile
$ docker run -d -p 3306:3306 --name mysql -v /opt/mysql:/var/lib/mysql -e MYSQL_DATABASE=myblog -e MYSQL_ROOT_PASSWORD=123456 mysql:5.7 --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci ## 參數傳遞 ## 查看數據庫 $ docker exec -ti mysql bash #/ mysql -uroot -p123456 #/ show databases; ## navicator連接
## 啟動容器 $ docker run -d -p 8002:8002 --name myblog -e MYSQL_HOST=172.21.51.143 -e MYSQL_USER=root -e MYSQL_PASSWD=123456 myblog:v1 ## migrate $ docker exec -ti myblog bash #/ python3 manage.py makemigrations #/ python3 manage.py migrate #/ python3 manage.py createsuperuser ## 創建超級用戶 $ docker exec -ti myblog python3 manage.py createsuperuser ## 收集靜態文件 ## $ docker exec -ti myblog python3 manage.py collectstatic
訪問172.21.51.143:8002/admin
docker優勢:
輕量級的虛擬化
容器快速啟停
虛擬化核心需要解決的問題:資源隔離與資源限制
虛擬機硬件虛擬化技術, 通過一個 hypervisor 層實現對資源的徹底隔離。
容器則是操作系統級別的虛擬化,利用的是內核的 Cgroup 和 Namespace 特性,此功能完全通過軟件實現。
命名空間是全局資源的一種抽象,將資源放到不同的命名空間中,各個命名空間中的資源是相互隔離的。
我們知道,docker容器對于操作系統來講其實是一個進程,我們可以通過原始的方式來模擬一下容器實現資源隔離的基本原理:
linux系統中,通常可以通過clone()實現進程創建的系統調用 ,原型如下:
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);
child_func : 傳入子進程運行的程序主函數。
child_stack : 傳入子進程使用的棧空間。
flags : 表示使用哪些 CLONE_* 標志位。
args : 用于傳入用戶參數。
示例一:實現進程獨立的UTS空間
#define _GNU_SOURCE #include
執行編譯并測試:
$ gcc -o ns_uts ns_uts.c $ ./ns_uts $ hostname
示例二:實現容器獨立的進程空間
#define _GNU_SOURCE #include
執行編譯并測試:
$ gcc -o ns_pid ns_pid.c $ ./ns_pid $ echo $$
如何確定進程是否屬于同一個namespace:
$ ./ns_pid Parent [ 8061] - start a container! $ pstree -p 8061 pid1(8061)───bash(8062)───pstree(8816) $ ls -l /proc/8061/ns lrwxrwxrwx 1 root root 0 Jun 24 12:51 ipc -> ipc:[4026531839] lrwxrwxrwx 1 root root 0 Jun 24 12:51 mnt -> mnt:[4026531840] lrwxrwxrwx 1 root root 0 Jun 24 12:51 net -> net:[4026531968] lrwxrwxrwx 1 root root 0 Jun 24 12:51 pid -> pid:[4026531836] lrwxrwxrwx 1 root root 0 Jun 24 12:51 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Jun 24 12:51 uts -> uts:[4026531838] $ ls -l /proc/8062/ns lrwxrwxrwx 1 root root 0 Jun 24 12:51 ipc -> ipc:[4026531839] lrwxrwxrwx 1 root root 0 Jun 24 12:51 mnt -> mnt:[4026531840] lrwxrwxrwx 1 root root 0 Jun 24 12:51 net -> net:[4026531968] lrwxrwxrwx 1 root root 0 Jun 24 12:51 pid -> pid:[4026534845] lrwxrwxrwx 1 root root 0 Jun 24 12:51 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Jun 24 12:51 uts -> uts:[4026534844] ## 發現pid和uts是和父進程使用了不同的ns,其他的則是繼承了父進程的命名空間
綜上:通俗來講,docker在啟動一個容器的時候,會調用Linux Kernel Namespace的接口,來創建一塊虛擬空間,創建的時候,可以支持設置下面這幾種(可以隨意選擇),docker默認都設置。
pid:用于進程隔離(PID:進程ID)
net:管理網絡接口(NET:網絡)
ipc:管理對 IPC 資源的訪問(IPC:進程間通信(信號量、消息隊列和共享內存))
mnt:管理文件系統掛載點(MNT:掛載)
uts:隔離主機名和域名
user:隔離用戶和用戶組
#21199進程號 [root@ecs-k8s-0001 ~]# echo $$ 21199 [root@ecs-k8s-0001 ~]# ll /proc/21199/ns total 0 lrwxrwxrwx 1 root root 0 May 30 16:40 ipc -> ipc:[4026531839] lrwxrwxrwx 1 root root 0 May 30 16:40 mnt -> mnt:[4026531840] lrwxrwxrwx 1 root root 0 May 30 16:40 net -> net:[4026531956] lrwxrwxrwx 1 root root 0 May 30 16:40 pid -> pid:[4026531836] lrwxrwxrwx 1 root root 0 May 30 16:40 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 May 30 16:40 uts -> uts:[4026531838]
通過namespace可以保證容器之間的隔離,但是無法控制每個容器可以占用多少資源, 如果其中的某一個容器正在執行 CPU 密集型的任務,那么就會影響其他容器中任務的性能與執行效率,導致多個容器相互影響并且搶占資源。如何對多個容器的資源使用進行限制就成了解決進程虛擬資源隔離之后的主要問題。
Control Groups(簡稱 CGroups)
cgroups是Linux內核提供的一種機制,這種機制可以根據需求吧一系列系統任務及其子任務整合(或分隔)到按資源劃分等級的不同組中,從而為系統資源管理提供一個統一的框架。
CGroups能夠隔離宿主機器上的物理資源,例如 CPU、內存、磁盤 I/O 。每一個 CGroup 都是一組被相同的標準和參數限制的進程。而我們需要做的,其實就是把容器這個進程加入到指定的Cgroup中。深入理解CGroup,請點此。
[root@ecs-k8s-0001 ~]# ls /sys/fs/cgroup/memory/docker/ 178a7a382c969ce8db28f45287eeb1f553c64eb543f0fd42b2d38ba272301a9f memory.kmem.tcp.limit_in_bytes memory.numa_stat cgroup.clone_children memory.kmem.tcp.max_usage_in_bytes memory.oom_control cgroup.event_control memory.kmem.tcp.usage_in_bytes memory.pressure_level cgroup.procs memory.kmem.usage_in_bytes memory.soft_limit_in_bytes memory.failcnt memory.limit_in_bytes memory.stat memory.force_empty memory.max_usage_in_bytes memory.swappiness memory.kmem.failcnt memory.memsw.failcnt memory.usage_in_bytes memory.kmem.limit_in_bytes memory.memsw.limit_in_bytes memory.use_hierarchy memory.kmem.max_usage_in_bytes memory.memsw.max_usage_in_bytes notify_on_release memory.kmem.slabinfo memory.memsw.usage_in_bytes tasks memory.kmem.tcp.failcnt memory.move_charge_at_immigrate ########### [root@ecs-k8s-0001 ~]# cat /sys/fs/cgroup/memory/docker/178a7a382c969ce8db28f45287eeb1f553c64eb543f0fd42b2d38ba272301a9f/memory.kmem.slabinfo cat: /sys/fs/cgroup/memory/docker/178a7a382c969ce8db28f45287eeb1f553c64eb543f0fd42b2d38ba272301a9f/memory.kmem.slabinfo: Input/output error [root@ecs-k8s-0001 ~]#
Linux namespace和cgroup分別解決了容器的資源隔離與資源限制,那么容器是很輕量的,通常每臺機器中可以運行幾十上百個容器, 這些個容器是共用一個image,還是各自將這個image復制了一份,然后各自獨立運行呢? 如果每個容器之間都是全量的文件系統拷貝,那么會導致至少如下問題:
運行容器的速度會變慢
容器和鏡像對宿主機的磁盤空間的壓力
怎么解決這個問題------Docker的存儲驅動
鏡像分層存儲
UnionFS
Docker 鏡像是由一系列的層組成的,每層代表 Dockerfile 中的一條指令,比如下面的 Dockerfile 文件:
FROM ubuntu:15.04 COPY . /app RUN make /app CMD python /app/app.py
這里的 Dockerfile 包含4條命令,其中每一行就創建了一層,下面顯示了上述Dockerfile構建出來的鏡像運行的容器層的結構:
鏡像就是由這些層一層一層堆疊起來的,鏡像中的這些層都是只讀的,當我們運行容器的時候,就可以在這些基礎層至上添加新的可寫層,也就是我們通常說的容器層,對于運行中的容器所做的所有更改(比如寫入新文件、修改現有文件、刪除文件)都將寫入這個容器層。
對容器層的操作,主要利用了寫時復制(CoW)技術。CoW就是copy-on-write,表示只在需要寫時才去復制,這個是針對已有文件的修改場景。 CoW技術可以讓所有的容器共享image的文件系統,所有數據都從image中讀取,只有當要對文件進行寫操作時,才從image里把要寫的文件復制到自己的文件系統進行修改。所以無論有多少個容器共享同一個image,所做的寫操作都是對從image中復制到自己的文件系統中的復本上進行,并不會修改image的源文件,且多個容器操作同一個文件,會在每個容器的文件系統里生成一個復本,每個容器修改的都是自己的復本,相互隔離,相互不影響。使用CoW可以有效的提高磁盤的利用率。
鏡像中每一層的文件都是分散在不同的目錄中的,如何把這些不同目錄的文件整合到一起呢?
UnionFS 其實是一種為 Linux 操作系統設計的用于把多個文件系統聯合到同一個掛載點的文件系統服務。 它能夠將不同文件夾中的層聯合(Union)到了同一個文件夾中,整個聯合的過程被稱為聯合掛載(Union Mount)。
上圖是AUFS的實現,AUFS是作為Docker存儲驅動的一種實現,Docker 還支持了不同的存儲驅動,包括 aufs、devicemapper、overlay2、zfs 和 Btrfs 等等,在最新的 Docker 中,overlay2 取代了 aufs 成為了推薦的存儲驅動,但是在沒有 overlay2 驅動的機器上仍然會使用 aufs 作為 Docker 的默認驅動。
docker容器是一塊具有隔離性的虛擬系統,容器內可以有自己獨立的網絡空間,
多個容器之間是如何實現通信的呢?
容器和宿主機之間又是如何實現的通信呢?
使用-p參數是怎么實現的端口映射?
帶著這些問題,我們來學習一下docker的網絡模型,最后我會通過抓包的方式,給大家演示一下數據包在容器和宿主機之間的轉換過程。
我們在使用docker run創建Docker容器時,可以用--net選項指定容器的網絡模式,Docker有以下4種網絡模式:
bridge模式,使用--net=bridge指定,默認設置
host模式,使用--net=host指定,容器內部網絡空間共享宿主機的空間,效果類似直接在宿主機上啟動一個進程,端口信息和宿主機共用
container模式,使用--net=container:NAME_or_ID指定
指定容器與特定容器共享網絡命名空間
none模式,使用--net=none指定
網絡模式為空,即僅保留網絡命名空間,但是不做任何網絡相關的配置(網卡、IP、路由等)
那我們之前在演示創建docker容器的時候其實是沒有指定的網絡模式的,如果不指定的話默認就會使用bridge模式,bridge本意是橋的意思,其實就是網橋模式。
那我們怎么理解網橋,如果需要做類比的話,我們可以把網橋看成一個二層的交換機設備,我們來看下這張圖:
交換機通信簡圖
交換機網絡通信流程:
網橋模式示意圖
Linux 中,能夠起到虛擬交換機作用的網絡設備,是網橋(Bridge)。它是一個工作在數據鏈路層(Data Link)的設備,主要功能是根據 MAC 地址將數據包轉發到網橋的不同端口上。 網橋在哪,查看網橋
$ yum install -y bridge-utils $ brctl show bridge name bridge id STP enabled interfaces docker0 8000.0242b5fbe57b no veth3a496ed [root@ecs-k8s-0001 ~]# brctl showmacs docker0 port no mac addr is local? ageing timer 1 ea:45:1f:64:6a:c0 yes 0.00 1 ea:45:1f:64:6a:c0 yes 0.00
有了網橋之后,那我們看下docker在啟動一個容器的時候做了哪些事情才能實現容器間的互聯互通
Docker 創建一個容器的時候,會執行如下操作:
創建一對虛擬接口/網卡,也就是veth pair;
veth pair的一端橋接 到默認的 docker0 或指定網橋上,并具有一個唯一的名字,如 vethxxxxxx;
veth paid的另一端放到新啟動的容器內部,并修改名字作為 eth0,這個網卡/接口只在容器的命名空間可見;
從網橋可用地址段中(也就是與該bridge對應的network)獲取一個空閑地址分配給容器的 eth0
配置容器的默認路由
那整個過程其實是docker自動幫我們完成的,清理掉所有容器,來驗證。
## 清掉所有容器 $ docker rm -f `docker ps -aq` $ docker ps $ brctl show # 查看網橋中的接口,目前沒有 ## 創建測試容器test1 $ docker run -d --name test1 nginx:alpine $ brctl show # 查看網橋中的接口,已經把test1的veth端接入到網橋中 $ ip a |grep veth # 已在宿主機中可以查看到 $ docker exec -ti test1 sh / # ifconfig # 查看容器的eth0網卡及分配的容器ip # 再來啟動一個測試容器,測試容器間的通信 $ docker run -d --name test2 nginx:alpine $ docker exec -ti test2 sh / # sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories / # apk add curl / # curl 172.17.0.8:80 ## 為啥可以通信? / # route -n # Kernel IP routing table Destination Gateway Genmask Flags Metric Ref Use Iface 0.0.0.0 172.17.0.1 0.0.0.0 UG 0 0 0 eth0 172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 eth0 # eth0 網卡是這個容器里的默認路由設備;所有對 172.17.0.0/16 網段的請求,也會被交給 eth0 來處理(第二條 172.17.0.0 路由規則),這條路由規則的網關(Gateway)是 0.0.0.0,這就意味著這是一條直連規則,即:凡是匹配到這條規則的 IP 包,應該經過本機的 eth0 網卡,通過二層網絡(數據鏈路層)直接發往目的主機。 # 而要通過二層網絡到達 test1 容器,就需要有 172.17.0.8 這個 IP 地址對應的 MAC 地址。所以test2容器的網絡協議棧,就需要通過 eth0 網卡發送一個 ARP 廣播,來通過 IP 地址查找對應的 MAC 地址。 #這個 eth0 網卡,是一個 Veth Pair,它的一端在這個 test2 容器的 Network Namespace 里,而另一端則位于宿主機上(Host Namespace),并且被“插”在了宿主機的 docker0 網橋上。網橋設備的一個特點是插在橋上的網卡都會被當成橋上的一個端口來處理,而端口的唯一作用就是接收流入的數據包,然后把這些數據包的“生殺大權”(比如轉發或者丟棄),全部交給對應的網橋設備處理。 # 因此ARP的廣播請求也會由docker0來負責轉發,這樣網橋就維護了一份端口與mac的信息表,因此針對test2的eth0拿到mac地址后發出的各類請求,同樣走到docker0網橋中由網橋負責轉發到對應的容器中。 # 網橋會維護一份mac映射表,我們可以大概通過命令來看一下, $ brctl showmacs docker0 ## 這些mac地址是主機端的veth網卡對應的mac,可以查看一下 $ ip a
我們如何知道網橋上的這些虛擬網卡與容器端是如何對應?
通過ifindex,網卡索引號
## 查看test1容器的網卡索引 $ docker exec -ti test1 cat /sys/class/net/eth0/ifindex ## 主機中找到虛擬網卡后面這個@ifxx的值,如果是同一個值,說明這個虛擬網卡和這個容器的eth0網卡是配對的。 $ ip a |grep @if
整理腳本,快速查看對應:
for container in $(docker ps -q); do iflink=`docker exec -it $container sh -c 'cat /sys/class/net/eth0/iflink'` iflink=`echo $iflink|tr -d '\r'` veth=`grep -l $iflink /sys/class/net/veth*/ifindex` veth=`echo $veth|sed -e 's;^.*net/\(.*\)/ifindex$;\1;'` echo $container:$veth done
上面我們講解了容器之間的通信,那么容器與宿主機的通信是如何做的?
添加端口映射:
## 啟動容器的時候通過-p參數添加宿主機端口與容器內部服務端口的映射 $ docker run --name test -d -p 8088:80 nginx:alpine $ curl localhost:8088
端口映射如何實現的?先來回顧iptables鏈表圖
訪問本機的8088端口,數據包會從流入方向進入本機,因此涉及到PREROUTING和INPUT鏈,我們是通過做宿主機與容器之間加的端口映射,所以肯定會涉及到端口轉換,那哪個表是負責存儲端口轉換信息的呢,就是nat表,負責維護網絡地址轉換信息的。因此我們來查看一下PREROUTING鏈的nat表:
$ iptables -t nat -nvL PREROUTING Chain PREROUTING (policy ACCEPT 159 packets, 20790 bytes) pkts bytes target prot opt in out source destination 3 156 DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
規則利用了iptables的addrtype拓展,匹配網絡類型為本地的包,如何確定哪些是匹配本地,
$ ip route show table local type local 127.0.0.0/8 dev lo proto kernel scope host src 127.0.0.1 127.0.0.1 dev lo proto kernel scope host src 127.0.0.1 172.17.0.1 dev docker0 proto kernel scope host src 172.17.0.1 172.21.51.143 dev eth0 proto kernel scope host src 172.21.51.143
也就是說目標地址類型匹配到這些的,會轉發到我們的TARGET中,TARGET是動作,意味著對符合要求的數據包執行什么樣的操作,最常見的為ACCEPT或者DROP,此處的TARGET為DOCKER,很明顯DOCKER不是標準的動作,那DOCKER是什么呢?我們通常會定義自定義的鏈,這樣把某類對應的規則放在自定義鏈中,然后把自定義的鏈綁定到標準的鏈路中,因此此處DOCKER 是自定義的鏈。那我們現在就來看一下DOCKER這個自定義鏈上的規則。
$ iptables -t nat -nvL DOCKER Chain DOCKER (2 references) pkts bytes target prot opt in out source destination 0 0 RETURN all -- docker0 * 0.0.0.0/0 0.0.0.0/0 0 0 DNAT tcp -- !docker0 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:8088 to:172.17.0.2:80
此條規則就是對主機收到的目的端口為8088的tcp流量進行DNAT轉換,將流量發往172.17.0.2:80,172.17.0.2地址是不是就是我們上面創建的Docker容器的ip地址,流量走到網橋上了,后面就走網橋的轉發就ok了。 所以,外界只需訪問172.21.51.143:8088就可以訪問到容器中的服務了。
數據包在出口方向走POSTROUTING鏈,我們查看一下規則:
$ iptables -t nat -nvL POSTROUTING Chain POSTROUTING (policy ACCEPT 1099 packets, 67268 bytes) pkts bytes target prot opt in out source destination 86 5438 MASQUERADE all -- * !docker0 172.17.0.0/16 0.0.0.0/0 0 0 MASQUERADE tcp -- * * 172.17.0.4 172.17.0.4 tcp dpt:80
大家注意MASQUERADE這個動作是什么意思,其實是一種更靈活的SNAT,把源地址轉換成主機的出口ip地址,那解釋一下這條規則的意思:
這條規則會將源地址為172.17.0.0/16的包(也就是從Docker容器產生的包),并且不是從docker0網卡發出的,進行源地址轉換,轉換成主機網卡的地址。大概的過程就是ACK的包在容器里面發出來,會路由到網橋docker0,網橋根據宿主機的路由規則會轉給宿主機網卡eth0,這時候包就從docker0網卡轉到eth0網卡了,并從eth0網卡發出去,這時候這條規則就會生效了,把源地址換成了eth0的ip地址。
注意一下,剛才這個過程涉及到了網卡間包的傳遞,那一定要打開主機的ip_forward轉發服務,要不然包轉不了,服務肯定訪問不到。
我們先想一下,我們要抓哪個網卡的包
首先訪問宿主機的8088端口,我們抓一下宿主機的eth0
$ tcpdump -i eth0 port 8088 -w host.cap
然后最終包會流入容器內,那我們抓一下容器內的eth0網卡
# 容器內安裝一下tcpdump $ sed -i 's/dl-cdn.alpinelinux.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apk/repositories $ apk add tcpdump $ tcpdump -i eth0 port 80 -w container.cap
到另一臺機器訪問一下,
$ curl 172.21.51.143:8088/
停止抓包,拷貝容器內的包到宿主機
$ docker cp test:/root/container.cap /root/
把抓到的內容拷貝到本地,使用wireshark進行分析。
$ scp root@172.21.51.143:/root/*.cap /d/packages
(wireshark合并包進行分析)
進到容器內的包做DNAT,出去的包做SNAT,這樣對外面來講,根本就不知道機器內部是誰提供服務,其實這就和一個內網多個機器公用一個外網IP地址上網的效果是一樣的,那這也屬于NAT功能的一個常見的應用場景。
容器內部不會創建網絡空間,共享宿主機的網絡空間。比如直接通過host模式創建mysql容器:
$ docker run --net host -d --name mysql -e MYSQL_ROOT_PASSWORD=123456 mysql:5.7
容器啟動后,會默認監聽3306端口,由于網絡模式是host,因為可以直接通過宿主機的3306端口進行訪問服務,效果等同于在宿主機中直接啟動mysqld的進程。
這個模式指定新創建的容器和已經存在的一個容器共享一個 Network Namespace,而不是和宿主機共享。新創建的容器不會創建自己的網卡,配置自己的 IP,而是和一個指定的容器共享 IP、端口范圍等。同樣,兩個容器除了網絡方面,其他的如文件系統、進程列表等還是隔離的。兩個容器的進程可以通過 lo 網卡設備通信。
## 啟動測試容器,共享mysql的網絡空間 $ docker run -ti --rm --net=container:mysql busybox sh / # ip a / # netstat -tlp|grep 3306 / # telnet localhost 3306
在一些特殊的場景中非常有用,例如,kubernetes的pod,kubernetes為pod創建一個基礎設施容器,同一pod下的其他容器都以container模式共享這個基礎設施容器的網絡命名空間,相互之間以localhost訪問,構成一個統一的整體。
只會創建對應的網絡空間,不會配置網絡堆棧(網卡、路由等)。
# 創建none的容器 $ docker run -it --name=network-none --net=none nginx:alpine sh # ifconfig
在宿主機中操作:
# 創建虛擬網卡對 $ ip link add A type veth peer name B # A端插入到docker0網橋 $ brctl addif docker0 A $ ip link set A up # B端插入到network-none容器中,需要借助ip netns,因此需要顯示的創建命名network namespace $ PID=$(docker inspect -f '{{.State.Pid}}' network-none) $ mkdir -p /var/run/netns $ ln -s /proc/$PID/ns/net /var/run/netns/$PID # B端放到容器的命名空間 $ ip link set B netns $PID $ ip netns exec $PID ip link set dev B name eth0 # 修改設備名稱為eth0,和docker默認行為一致 $ ip netns exec $PID ip link set eth0 up # 設置ip $ ip netns exec $PID ip addr add 172.17.0.100/16 dev eth0 # 添加默認路由,指定給docker0網橋 $ ip netns exec $PID ip route add default via 172.17.0.1 # 測試容器間通信
前置知識:
ip netns 命令用來管理 network namespace。它可以創建命名的 network namespace,然后通過名字來引用 network namespace
network namespace 在邏輯上是網絡堆棧的一個副本,它有自己的路由、防火墻規則和網絡設備。 默認情況下,子進程繼承其父進程的 network namespace。也就是說,如果不顯式創建新的 network namespace,所有進程都從 init 進程繼承相同的默認 network namespace。
根據約定,命名的 network namespace 是可以打開的 /var/run/netns/ 目錄下的一個對象。比如有一個名稱為 net1 的 network namespace 對象,則可以由打開 /var/run/netns/net1 對象產生的文件描述符引用 network namespace net1。通過引用該文件描述符,可以修改進程的 network namespace。
清理主機上所有退出的容器
$ docker rm $(docker ps -aq)
調試或者排查容器啟動錯誤
## 若有時遇到容器啟動失敗的情況,可以先使用相同的鏡像啟動一個臨時容器,先進入容器 $ docker run --rm -ti
#### 本章小結 1. 為了解決軟件交付過程中的環境依賴,同時提供一種更加輕量的虛擬化技術,Docker出現了。 2. 2013年誕生,15年開始迅速發展,從17.03月開始,使用時間日期管理版本,穩定版以每季度為準。 3. Docker是一種CS架構的軟件產品,可以把代碼及依賴打包成鏡像,作為交付介質,并且把鏡像啟動成為容器,提供容器生命周期的管理。 4. 使用yum部署docker,啟動后通過操作docker這個命令行,自動調用docker daemon完成容器相關操作。 5. 常用操作,圍繞`鏡像|容器|倉庫`三大核心要素 - systemctl start|stop|restart docker - docker build | pull -> docker tag -> docker push - docker run --name my-demo -d -p 8080:80 -v /opt/data:/data demo:v20200327 ping xx.com - docker cp /path/a.txt mycontainer:/opt - docker exec -ti mycontainer /bin/sh - docker logs -f --tail=100 mycontainer - root@ecs-k8s-0001 ~]# journalctl -u docker -- Logs begin at Sun 2021-05-30 11:14:40 CST, end at Sun 2021-05-30 18:12:29 CST. -- May 30 12:00:10 ecs-k8s-0001 systemd[1]: Starting Docker Application Container Engine... May 30 12:00:10 ecs-k8s-0001 dockerd[8419]: unable to configure the Docker daemon with file /etc/docker/daemon.json: invalid character 's' looking for b May 30 12:00:10 ecs-k8s-0001 systemd[1]: docker.service: main process exited, code=exited, status=1/FAILURE May 30 12:00:10 ecs-k8s-0001 systemd[1]: Failed to start Docker Application Container Engine. - [root@ecs-k8s-0001 ~]# journalctl -fu docker 6. 通過dockerfile構建業務鏡像,先使用基礎鏡像,然后通過一系列的指令把我們的業務應用所需要的運行環境和依賴都打包到鏡像中,然后通過CMD或者ENTRYPOINT指令把鏡像啟動時的入口制定好,完成封裝即可。有點類似于,先找來一個集裝箱模板(基礎鏡像),然后把項目依賴的服務都扔到集裝箱中,然后設置好服務的啟動入口,關閉箱門,即完成了業務鏡像的制作。 7. 容器的實現依賴于內核模塊提供的namespace和control-group的功能,通過namespace創建一塊虛擬空間,空間內實現了各類資源(進程、網絡、文件系統)的隔離,提供control-group實現了對隔離的空間的資源使用的限制。 8. docker鏡像使用分層的方式進行存儲,根據主機的存儲驅動的不同,實現方式會不同,kernel在3.10.0-514以上自動支持overlay2 存儲驅動,也是目前Docker推薦的方式。 9. 得益于分層存儲的模式,多個容器可以通過copy-on-write的策略,在鏡像的最上層加一個可寫層,同時利用存儲驅動的UnionFS的能力,實現一個鏡像快速啟動多個容器的場景。 10. docker的網絡模式分為4種,最常用的為bridge和host模式。bridge模式通過docker0網橋,啟動容器的時候通過創建一對虛擬網卡,將容器連接在橋上,同時維護了虛擬網卡與網橋端口的關系,實現容器間的通信。容器與宿主機之間的通信通過iptables端口映射的方式,docker利用iptables的PREROUTING和POSTROUTING的nat功能,實現了SNAT與DNAT,使得容器內部的服務被完美的保護起來。 11. 本章重點內容是docker的核心要素及基礎的操作,實現原理以及docker的網絡模式為選修包,目的為了幫助有docker基礎及經驗的同學更好的進一步理解docker。
Docker 容器 虛擬化 鏡像服務
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。