overlay2 在打包發佈流水線中的應用


背景

自從去年五月份入職後一直在負責公司 PaaS toB 產品的打包發佈及部署運維工作,工作性質上有點類似於 Kubernetes 社區的 SIG Release 團隊[1]。試用期的主要工作就是優化我們先有的打包發佈流程。在這期間產品打包發佈流水線做了很多優化,其中最突出的是鏡像同步的優化,將鏡像同步的速度提升了 5 到 15 倍。大大縮短了整個產品的發佈耗時,也得到了同事們的一致好評。於是今天就想着把這項優化和背後的原理分享出來。

我們的產品打包時會有一個鏡像列表,並根據這個鏡像列表在 CI/CD 的流水線鏡像倉庫裏將鏡像同步到一個發佈歸檔的鏡像倉庫和一個打包的鏡像倉庫。最終會將打包的鏡像倉庫的 registry 存儲目錄打包一個未經 gzip 壓縮的 tar 包。最終在客戶環境部署的時候將這個 tar 包解壓到部署的鏡像倉庫存儲目錄中,供集羣部署和組件部署使用。至於部署的時候爲什麼可以這樣做,其中的原理可以參考我之前寫過的文章 docker registry 遷移至 harbor[2]

在打包的過程中鏡像同步會進行兩次,每次都會根據一個 images.list 列表將鏡像同步到不同的鏡像倉庫中,同步的方式使用的是 docker pull –> docker tag –> docker push。其鏡像同步的流程如下圖所示:

第一次是從 CI/CD 流水線鏡像倉庫(cicd.registry.local)中拉取鏡像並 push 到發佈歸檔的鏡像倉庫(archive.registry.local)中,其目的是歸檔並備份我們已經發布的鏡像,這一步稱其爲保存備份同步(save sync)。

第二次將鏡像從發佈歸檔的鏡像倉庫 (archive.registry.local) 同步鏡像到打包鏡像倉庫(package.registry.local)中。不同於第一次的鏡像同步,這次同步鏡像的時候會對鏡像倉庫做清理的操作,首先清理打包鏡像倉庫的存儲目錄,然後容器 registry 容器讓 registry 重新提取鏡像的元數據信息到內存中。其目的是清理舊數據,防止歷史的鏡像帶入本次發佈版本的安裝包中。

鏡像同步完成之後會將整個打包鏡像倉庫的存儲目錄(/var/lib/registry)打包成一個 tar 包,並放到產品安裝包中。

問題

我剛入職的時候,我們的產品發佈耗時最久的就是鏡像同步階段,記得最長的時候耗時 2h30min。耗時這麼久的主要原因分析如下:

docker 性能問題

在做鏡像同步的時候使用的是 docker pull –> docker tag –> docker push 的方式。木子在《深入淺出容器鏡像的一生》[3] 中分析過:在 docker pull 和 docker push 的過程中 docker 守護進程都會對鏡像的 layer 做解壓縮的操作,這是及其耗時和浪費 CPU 資源的。

又因爲我們的內網機器的磁盤性能實在是太爛了,有時甚至連 USB 2.0 的速度(57MB/s)都不如!那慢的程度可想而知。這就導致了每次同步一兩百個鏡像時用時很久,最長的時候需要兩個半小時。

無法複用舊數據

在第二次鏡像同步時會對打包鏡像倉庫做清理的操作,導致無法複用歷史的鏡像。其實每次發佈的時候,變更和新增的鏡像很少,平均爲原來的 1/10 左右,增量同步的鏡像也就那麼一丟丟。因爲要保證這次打包發佈的鏡像倉庫中只能包好這個需要的鏡像,不能包含與本次無關的鏡像,因此每次都需要清理打包鏡像倉庫,這無法避免。一直沒有找到能夠複用這些歷史鏡像的方法。

優化

根據上面提到的兩個問題,經過反覆的研究和測試終於都完美地解決了,並將鏡像同步從原來最長需要兩個半小時優化到了平均五分鐘。

skopeo 替代 docker

針對 docker pull –> docker tag –> docker push 的性能問題,當時第一個方案想到的就是使用 skopeo 來替代它。使用 skopeo copy 直接將鏡像從一個 registry 複製到另一個 registry 中。這樣可以避免 docker 守護進程對鏡像的 layer 進行解壓縮而帶來的性能損耗。關於 skopeo 的使用和其背後的原理可以參考我之前的博客 鏡像搬運工 skopeo 初體驗[4] 。使用 skopeo 之後鏡像同步比之前快了很多,平均快了 5 倍左右。

overlay2 複用舊數據

解決了 docker 的性能問題,剩下的就是無法複用舊數據的問題了。在如何保留歷史鏡像的問題上可煞費苦心。當時也不知道爲什麼就想到了 overlay2 的特性寫時複製。就好比如 docker run 啓動一個容器,在容器內進行修改和刪除文件的操作,這些操作並不會影響到鏡像本身。因爲 docker 使用 overlay2 聯合掛載的方式將鏡像的每一層掛載爲一個 merged 的層。在容器內看到的就是這個 merged 的層,在容器內對 merged 層文件的修改和刪除操作是通過 overlay2 的 upper 層完成的,並不會影響到處在 lower 層的鏡像本身。從 docker 官方文檔 Use the OverlayFS storage driver[5] 裏偷來的一張圖片:

關於上圖中這些 Dir 的作用,下面是一段從 StackOverflow[6] 上搬運過來的解釋。如果想對 overlayfs 文件系統有詳細的瞭解,可以參考 Linux 內核官網上的這篇文檔 overlayfs.txt[7]

LowerDir: these are the read-only layers of an overlay filesystem. For docker, these are the image layers assembled in order.

UpperDir: this is the read-write layer of an overlay filesystem. For docker, that is the equivalent of the container specific layer that contains changes made by that container.

WorkDir: this is a required directory for overlay, it needs an empty directory for internal use.

MergedDir: this is the result of the overlay filesystem. Docker effectively chroot’s into this directory when running the container.

總之 overlay2 大法好!根據 overlay2 的特性,我們可以將歷史的數據當作 overlay2 裏的 lowerdir 來使用。而 upperdir 則是本次鏡像同步的增量數據,merged 則是最終實際需要的數據。

overlay2

雖然在上文中提到了使用 overlay2 的方案,但到目前爲止還是沒有一個成熟的解決方案。需要解決的問題如下:

  • 如何清理舊數據

  • 如何複用歷史的鏡像?

  • 如何區分出歷史的鏡像和本次的鏡像?

  • 如何保障本次鏡像同步的結果只包含本次需要的鏡像?

registry 存儲結構

既然要使用歷史的鏡像倉庫數據來作爲 overlay2 的 lowerdir。那麼如何解決之前提到的清理舊數據問題,以及如何使用歷史的鏡像的問題?那麼還是需要再次回顧一下 registry 存儲目錄結構。

根據 registry 的存儲結構可以得知:在 blobs 目錄下保存的是鏡像的 blob 的文件。blob 文件大體上有三種:鏡像的 manifests;鏡像的 image config 文件;以及鏡像的 layer 層文件。其中 manifests 和 images config 文件都是 json 格式的文本文件,鏡像的 layer 層文件則是經過壓縮的 tar 包文件(一般爲 gzip)。如果要複用歷史的鏡像,很大程度上覆用的是鏡像的 layer 層文件,因爲這些文件是鏡像當中最大的,在 docker pull 和 docker push 的時候就是對鏡像的 layer 層文件進行解壓縮的。

而且對於同一個鏡像倉庫來講,blobs 下的文件都是由 repositories 下的 link 文件指向對應的 data 文件的。這就意味着,多個鏡像可以使用相同的 layer。比如假如多個鏡像的 base 鏡像使用的都是 debian:buster,那麼對於整個 registry 鏡像倉庫而言,只需要存一份 debian:buster 鏡像即可。

同理,在使用歷史的鏡像時,我們是否可以只使用它的 layer 呢?這一點可能比較難理解 😂。我們使用下面這個例子來簡單說明下。

k8s.gcr.io/kube-apiserver:v1.18.3
k8s.gcr.io/kube-controller-manager:v1.18.3
k8s.gcr.io/kube-scheduler:v1.18.3
k8s.gcr.io/kube-proxy:v1.v1.18.3

當我們使用 skopeo copy 將這些鏡像從 k8s.gcr.io 複製到本地的一個鏡像倉庫時,複製完第一個鏡像後,在 copy 後面的鏡像時都會提示 Copying blob 83b4483280e5 skipped: already exists 的日誌信息。這是因爲這些鏡像使用的是同一個 base 鏡像,這個 base 鏡像只包含了一個 layer,也就是 83b4483280e5 這一個 blob 文件。雖然本地的鏡像倉庫中沒有這些鏡像的 base 鏡像,但是有 base 鏡像的 layer,skopeo 也就不會再 copy 這個相同的 blob。

╭─root@sg-02 /home/ubuntu
╰─# skopeo copy docker://k8s.gcr.io/kube-apiserver:v1.18.3 docker://localhost/kube-apiserver:v1.18.3 --dest-tls-verify=false
Getting image source signatures
Copying blob 83b4483280e5 done
Copying blob 2bfb66b13a96 done
Copying config 7e28efa976 done
Writing manifest to image destination
Storing signatures
╭─root@sg-02 /home/ubuntu
╰─# skopeo copy docker://k8s.gcr.io/kube-controller-manager:v1.18.3 docker://localhost/kube-controller-manager:v1.18.3 --dest-tls-verify=false
Getting image source signatures
Copying blob 83b4483280e5 skipped: already exists
Copying blob 7a73c2c3b85e done
Copying config da26705ccb done
Writing manifest to image destination
Storing signatures
╭─root@sg-02 /home/ubuntu
╰─# skopeo copy docker://k8s.gcr.io/kube-scheduler:v1.18.3 docker://localhost/kube-scheduler:v1.18.3 --dest-tls-verify=false
Getting image source signatures
Copying blob 83b4483280e5 skipped: already exists
Copying blob 133c4d2f432a done
Copying config 76216c34ed done
Writing manifest to image destination
Storing signatures
╭─root@sg-02 /home/ubuntu
╰─# skopeo copy docker://k8s.gcr.io/kube-proxy:v1.18.3 docker://localhost/kube-proxy:v1.18.3 --dest-tls-verify=false
Getting image source signatures
Copying blob 83b4483280e5 skipped: already exists
Copying blob ffa39a529ef3 done
Copying config 3439b7546f done
Writing manifest to image destination
Storing signatures

從上面的實驗我們可以得知,只要 registry 中存在相同的 blob,skopeo 就不會 copy 這個相同的 blob。那麼如何讓 skopeo 和 registry 知道存在這些 layer 了呢?

這時需要再次回顧以下 registry 存儲結構。在 repositories 下,每個鏡像的文件夾中都會有 _layers 這個目錄,而這個目錄下的內容正是指向鏡像 layer 和 image config 的 link 文件。也就是說:只要某個鏡像的 _layers 下有指向 blob 的 link 文件,並且該 link 文件指向的 blobs 下的 data 文件確實存在,那麼在 push 鏡像的時候 registry 就會向客戶端返回該 blob 已經存在,而 skopeo 就會略過處理已經存在的 blob 。以此,我們就可以達到複用歷史數據的目的。

在歷史鏡像倉庫文件中:blobs 目錄是全部都要的;repositories 目錄下只需要每個鏡像的 _layers 目錄即可;_manifests 目錄下是鏡像的 tag 我們並不需要他們;_uploads 目錄則是 push 鏡像時的臨時目錄也不需要。那麼我們最終需要的歷史鏡像倉庫中的文件就如下圖所示:

到此爲止已經解決掉了如何清理舊數據和如何如何複用歷史的鏡像的問題了。接下來要做的如何使用 overlay2 去構建這個鏡像倉庫所需的文件系統了。

套娃:鏡像裏塞鏡像?

提到 overlay2 第一個想到的方案就是容器鏡像:使用套娃的方式,將歷史的鏡像倉庫存儲目錄複製到一個 registry 的鏡像裏,然後用這個鏡像來啓動打包鏡像倉庫的 registry 容器。這個鏡像倉庫的 Dockerfile 如下:

FROM registry:latest

# 將歷史鏡像倉庫的目錄打包成 tar 包,放到 registry 的鏡像中, ADD 指令會自動解開這個 tar 包
ADD docker.tar /var/lib/registry/

# 刪除掉所有鏡像的 _manifests 目錄,讓 registry 認爲裏面沒有鏡像只有 blobs 數據
RUN find /var/lib/registry/docker/registry/v2/repositories -type d -name "_manifests" -exec rm -rf {} \;
  • 然後使用這個 Dockerfile 構建一個鏡像,並命名爲 registry:v0.1.0-base ,使用這個鏡像來 docker run 一個容器。
docker run -d --name registry -p 127.0.0.1:443:5000 registry:v0.1.0-base
  • 接着同步鏡像
cat images.list | xargs -L1 -I {} skopeo copy  docker://cidi.registry.local/{} docker://package.registry.local/{}
  • 同步完成鏡像之後,需要刪除掉 repositories 下沒有生成 _manifests 目錄的鏡像。因爲如果本次同步鏡像有該鏡像的話,會在 repositories 目錄下重新生成 _manifests 目錄,如果沒有生成的話就說明本次同步的列表中不包含該鏡像。以此可以解決如何區分出歷史的鏡像和本次的鏡像的問腿,這樣又能何保障本次鏡像同步的結果只包含本次需要的鏡像。
for project in $(ls repositories/); do
  for image in $(ls repositories/${project}); do
    if [[ ! -d "repositories/${project}/${image}/_manifests" ]]; then
    rm -rf repositories/${project}/${image}
  fi
done
  • 最後還需要使用 registry GC 來刪除掉 blobs 目錄下沒有被引用的文件。
docker exec -it registry registry garbage-collect /etc/docker/registry/config.yml
  • 再使用 docker cp 的方式將鏡像從容器裏複製出來並打包成一個 tar 包
docker cp registry:/var/lib/registry/docker docker
tar -cf docker.tar docker

使用這種辦法做了一下簡單的測試,因爲使用 skopeo copy 鏡像的時候會提示很多 blobs 已經存在了,所以實際上覆制的鏡像只是一小部分,性能上的確比之前快了很多。但是這種方案也存在很多的弊端:一是這個 registry 的鏡像需要手動維護和構建;二是使用 docker cp 的方式將容器內的 registry 存儲目錄複製到容器宿主機,性能上有點差;三是不同的產品需要不同的 base 鏡像,維護起來比較麻煩。所以我們還需要更爲簡單一點使用 overlay2 技術。

容器掛載 overlay2 merged 目錄

仔細想一下,將歷史的鏡像數據放到 registry 鏡像中,用它來啓動一個 registry 容器。同步鏡像和進行 registry gc 這兩部實際上是對 overlay2 的 merged 層進行讀寫刪除操作。那我們爲何不直接在宿主機上創建好 overlay2 需要的目錄,然後再使用 overlay2 聯合掛載的方式將這些目錄掛載爲一個 merged 目錄。在啓動 registry 容器的時候通過 docker run -v 參數將這個 merged 目錄以 bind 的方式掛載到 registry 容器內呢?下面我們就做一個簡單的驗證和測試:

  • 首先創建 overlay2 需要的目錄
cd /var/lib/registry
mkdir -p lower upper work merged
  • 將歷史鏡像倉庫數據放到 lower 目錄內
tar -cf docker.tar -C /var/lib/registry/lower
  • 刪除 所有鏡像的 _manifests 目錄,讓 registry 認爲裏面沒有鏡像只有 blobs 數據
find /var/lib/registry/lower/docker/registry/v2/repositories -type d -name "_manifests" -exec rm -rf {} \;
  • 模擬容器的啓動,使用 overlay2 聯合掛載爲一層 merged 層
mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
  • docker run 啓動一個 registry ,並將 merged 目錄掛載到容器內的 /var/lib/registry/docker 目錄
docker run -d -name registry -p 127.0.0.1:443:5000 \
-v /var/lib/registry/merged/docker:/var/lib/registry/docker
  • 同步鏡像,將本次發佈需要的鏡像同步到 registry 中
cat images.list | xargs -L1 -I {} skopeo copy --insecure-policy --src-tls-verify=false --dest-tls-verify=false docker://cicd.registry.local/{} docker://package.registry.local/{}
  • 同步完成鏡像後,進行 registry gc ,刪除無用的 blob 數據
docker exec -it registry registry garbage-collect /etc/docker/registry/config.yml
  • 最後打包 merged 目錄,就是本次最終的結果
cd /var/lib/registry/merged
tar -cf docker.tar docker

在本地按照上述步驟進行了簡單的驗證,確實可以!在第二次同步鏡像的時候會提示很多 blob 已經存在,鏡像同步的速度比之前又快了 5 倍左右。那麼將上述步驟寫成一個腳本就能反覆使用了。

registry gc 問題 ?

在使用的過程中遇到過 registry GC 清理不乾淨的問題:在進行 GC 之後,一些鏡像 layer 和 config 文件已經在 blobs 存儲目錄下刪除了,但指向它的 link 文件依舊保存在 repositories 目錄下 🙄。GitHub 上有個 PR Remove the layer’s link by garbage-collect #2288[8] 就是專門來清理這些無用的 layer link 文件的,最早的一個是三年前的,但是還沒有合併 😂。

解決辦法就是使用我在 docker registry GC 原理分析[9] 文章中提到的方案:自制 registry GC 腳本 🙃。

#!/bin/bash
v2=$1
v2=${v2:="/var/lib/registry/docker/registry/v2"}
cd ${v2}
all_blobs=/tmp/all_blobs.list
: > ${all_blobs}
# delete unlink blob's link file in _layers
for link in $(find repositories -type f -name "link" | grep -E "_layers\/sha256\/.*"); do
    link_sha256=$(echo ${link} | grep -Eo "_layers\/sha256\/.*" | sed 's/_layers\/sha256\///g;s/\/link//g')
    link_short=${link:0:2}
    link_dir=$(echo ${link} | sed 's/\/link//')
    data_file=blobs/sha256/${link_short}/${link}
    if [[ ! -d ${data_file} ]]; then rm -rf ${link_dir}fi
done
#marking all the blob by all images manifest
for tag in $(find repositories -name "link" | grep current); do
    link=$(cat ${tag} | cut -c8-71)
    mfs=blobs/sha256/${link:0:2}/${link}/data
    echo ${link} >> ${all_blobs}
    grep -Eo "\b[a-f0-9]{64}\b" ${mfs} | sort -n | uniq | cut -c1-12 >> ${all_blobs}
done
#delete blob if the blob doesn't exist in all_blobs.list
for blob in $(find blobs -name "data" | cut -d "/" -f4); do
    if ! grep ${blob} ${all_blobs}then
        rm -rf blobs/sha256/${blob:0:2}/${blob}
    fi
done

流程

好了,至此最終的優化方案已經定下來了,其流程上如下:

  • 第一次同步鏡像的時候不再將鏡像同步歸檔備份的鏡像倉庫(archive.registry.local) 而是同步到 overlay2 的鏡像倉庫,這個鏡像倉庫中的鏡像將作爲第二次鏡像同步的 lower 層。
cat images.list | xargs -L1 -I {} skopeo copy --insecure-policy --src-tls-verify=false --dest-tls-verify=false docker://cicd.registry.local/{} docker://overlay2.registry.local/{}
  • 第一次鏡像同步完成之後,先清理掉 overlay2 的 merged、upper、work 這三層,只保留 lower 層。因爲 lower 層裏保留着第一次鏡像同步的結果。
umount /var/lib/registry/merged
rm -rf /var/lib/registry/{merged,upper,work}
  • 接下來就是使用 mount 掛載 overlay2,掛載完成之後進入到 merged 層刪除掉所有的 _manifests 目錄
mount -t overlay overlay -o lowerdir=lower,upperdir=upper,workdir=work merged
cd /var/lib/registry/merged
find registry/v2/repositories -type d -name "_manifests" -exec rm -rf {} \;
  • 接着進行第二次的鏡像同步,這一次的同步目的是重新建立 _manifests 目錄
cat images.list | xargs -L1 -I {} skopeo copy --insecure-policy --src-tls-verify=false --dest-tls-verify=false docker://overlay2.registry.local/{} docker://package.registry.local/{}
  • 第二次同步完成之後再使用自制的 registry GC 腳本來刪除不必要的 blob 文件和 link 文件。
  • 最後將鏡像倉庫存儲目錄打包就得到了本次需要的鏡像啦。

結尾

雖然比之前的流程複雜了很多,但優化的結果是十分明顯,比以往快了 5 到 15 倍,並在我們的生產環境中已經穩穩地使用了大半年。

讀完這篇文章可能你會覺得一頭霧水,不知道究竟在講什麼。什麼鏡像同步、鏡像 blob、layer、overlay2、聯合掛載、寫時複製等等,被這一堆複雜的背景和概念搞混了 😂。本文確實不太好理解,因爲背景可能較特殊和複雜,很少人會遇到這樣的場景。爲了很好地理解本文所講到的內容和背後的原理,過段時間我會單獨寫一篇博客,通過最佳實踐來理解本文提到的技術原理。敬請期待 😝

參考

文檔

  • image [10]
  • OCI Image Manifest Specification [11]
  • distribution-spec [12]
  • debuerreotype/ [13]
  • overlayfs.txt [14]

博客

  • 看盡 docker 容器文件系統 [15]
  • 鏡像倉庫中鏡像存儲的原理解析 [16]
  • Docker 鏡像的存儲機制 [17]

推薦閱讀

  • 深入淺出容器鏡像的一生 🤔 [18]
  • 鏡像搬運工 skopeo 初體驗 [19]
  • mount 命令之 --bind 掛載參數 [20]
  • docker registry GC 原理分析 [21]
  • docker registry 遷移至 harbor [22]

參考資料

[1]

SIG Release 團隊: https://github.com/kubernetes/sig-release

[2]

docker registry 遷移至 harbor: https://blog.k8s.li/docker-registry-to-harbor.html

[3]

《深入淺出容器鏡像的一生》: https://blog.k8s.li/Exploring-container-image.html

[4]

鏡像搬運工 skopeo 初體驗: https://blog.k8s.li/skopeo.html

[5]

Use the OverlayFS storage driver: https://docs.docker.com/storage/storagedriver/overlayfs-driver/

[6]

StackOverflow: https://stackoverflow.com/questions/56550890/docker-image-merged-diff-work-lowerdir-components-of-graphdriver

[7]

overlayfs.txt: https://www.kernel.org/doc/Documentation/filesystems/overlayfs.txt

[8]

Remove the layer’s link by garbage-collect #2288: https://github.com/docker/distribution/issues/2288

[9]

docker registry GC 原理分析: https://blog.k8s.li/registry-gc.html

[10]

image: https://github.com/containers/image

[11]

OCI Image Manifest Specification: https://github.com/opencontainers/image-spec

[12]

distribution-spec: https://github.com/opencontainers/distribution-spec

[13]

debuerreotype/: https://doi-janky.infosiftr.net/job/tianon/job/debuerreotype/

[14]

overlayfs.txt: https://www.kernel.org/doc/Documentation/filesystems/overlayfs.txt

[15]

看盡 docker 容器文件系統: http://open.daocloud.io/allen-tan-docker-xi-lie-zhi-tu-kan-jin-docker-rong-qi-wen-jian-xi-tong/

[16]

鏡像倉庫中鏡像存儲的原理解析: https://supereagle.github.io/2018/04/24/docker-registry/

[17]

Docker 鏡像的存儲機制: https://segmentfault.com/a/1190000014284289

[18]

深入淺出容器鏡像的一生 🤔: https://blog.k8s.li/Exploring-container-image.html

[19]

鏡像搬運工 skopeo 初體驗: https://blog.k8s.li/skopeo.html

[20]

mount 命令之 --bind 掛載參數: https://blog.k8s.li/mount-bind.html

[21]

docker registry GC 原理分析: https://blog.k8s.li/registry-gc.html

[22]

docker registry 遷移至 harbor: https://blog.k8s.li/docker-registry-to-harbor.html


原文鏈接:https://blog.k8s.li/overlay2-on-package-pipline.html



你可能還喜歡

點擊下方圖片即可閱讀

不好,WireGuard 與 Kubernetes CNI 摩擦生火了。。

雲原生是一種信仰 🤘


關注公衆號

後臺回覆◉k8s◉獲取史上最方便快捷的 Kubernetes 高可用部署工具,只需一條命令,連 ssh 都不需要!



點擊 "閱讀原文" 獲取更好的閱讀體驗!


發現朋友圈變“安靜”了嗎?

本文分享自微信公衆號 - 雲原生實驗室(cloud_native_yang)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章