初學 Docker 要反覆告誡本身,Docker 不是虛擬機。
Docker不是虛擬機,Docker 鏡像也不是虛擬機的 ISO 文件。Docker 的鏡像是分層存儲,每個鏡像都是由不少層,不少個文件組成。而不一樣的鏡像是共享相同的層的,因此這是一個樹形結構,不存在具體哪一個文件是 pull 下來的鏡像的問題。
具體鏡像保存位置取決於系統,通常Linux系統下,在 /var/lib/docker 裏。對於使用 Union FS 的系統(Ubuntu),如 aufs, overlay2 等,能夠直接在 /var/lib/docker/{aufs,overlay2} 下看到找到各個鏡像的層、容器的層,以及其中的內容。
可是,對於CentOS這類沒有Union FS的系統,會使用如devicemapper這類東西的一些特殊功能(如snapshot)模擬,鏡像會存儲於塊設備裏,所以沒法看到具體每層信息以及每層裏面的內容。
須要注意的是,默認狀況下,CentOS/RHEL 使用 lvm-loop,也就是本地稀疏文件模擬塊設備,這個文件會位於 /var/lib/docker/devicemapper/devicemapper/data 的位置。這是很是不推薦的,若是發現這個文件很大,那就說明你在用 devicemapper + loop 的方式,不要這麼作,去參照官方文檔,換 direct-lvm,也就是分配真正的塊設備給 devicemapper 去用。php
這個顯示的大小是計算後的大小,要知道 docker image 是分層存儲的,在1.10以前,不一樣鏡像沒法共享同一層,因此基本上確實是下載大小。可是從1.10以後,已有的層(經過SHA256來判斷),不須要再下載。只須要下載變化的層。因此實際下載大小比這個數值要小。並且本地硬盤空間佔用,也比docker images列出來的東西加起來小不少,不少重複的部分共享了。html
簡單來講,<none> 就是說該鏡像沒有打標籤。而沒有打標籤鏡像通常分爲兩類,一類是依賴鏡像,一類是丟了標籤的鏡像。
依賴鏡像
Docker的鏡像、容器的存儲層是Union FS,分層存儲結構。因此任何鏡像除了最上面一層打上標籤(tag)外,其它下面依賴的一層層存儲也是存在的。這些鏡像沒有打上任何標籤,因此在 docker images -a 的時候會以 <none> 的形式顯示。注意觀察一下 docker pull 的每一層的sha256的校驗值,而後對比一下 <none> 中的相同校驗值的鏡像,它們就是依賴鏡像。這些鏡像不該當被刪除,由於有標籤鏡像在依賴它們。
丟了標籤的鏡像
這類鏡像可能原本有標籤,後來丟了。緣由可能不少,好比:
docker pull 了一個一樣標籤可是新版本的鏡像,因而該標籤從舊版本的鏡像轉移到了新版本鏡像上,那麼舊版本的鏡像上的標籤就丟了;
docker build 時指定的標籤都是同樣的,那麼新構建的鏡像擁有該標籤,而以前構建的鏡像就丟失了標籤。
這類鏡像被稱爲 dangling - 虛懸鏡像。這些鏡像能夠刪除,手動刪除 dangling 鏡像:node
docker image prune
對於 1.13 之前的老版本,使用 dangling=true 過濾條件便可。可使用命令:docker rmi $(docker images -aq -f "dangling=true")
對於頻繁構建的機器,好比 Jenkins 之類的環境。手動清理顯然不是好的辦法,應該按期執行固定腳原本清理這些無用的鏡像。很幸運,Spotify 也面臨了一樣的問題,他們已經寫了一個開源工具來作這件事情:https://github.com/spotify/docker-gcpython
Docker Hub上顯示的是通過 gzip 壓縮後的鏡像大小,這個大小也是你將下載的鏡像大小,通常來講也是 Docker Hub 用戶最關心的大小。
而 docker images 顯示的是pull下來並解壓縮後的大小,由於使用docker images的時候更關心的是本地磁盤空間佔用的大小,因此這裏顯示的是未壓縮鏡像的大小。mysql
簡單的回答就是,不要用 commit,去寫 Dockerfile。nginx
Docker 不是虛擬機。這句話要在學習 Docker 的過程當中反覆提醒本身。因此不要把虛擬機中的一些概念帶過來。git
Docker 提供了很好的 Dockerfile 的機制來幫助定製鏡像,能夠直接使用 Shell 命令,很是方便。並且,這樣製做的鏡像更加透明,也容易維護,在基礎鏡像升級後,能夠簡單地從新構建一下,就能夠繼承基礎鏡像的安全維護操做。github
使用 docker commit 製做的鏡像被稱爲黑箱鏡像,換句話說,就是裏面進行的是黑箱操做,除本人外無人知曉。即便這個製做鏡像的人,過一段時間後也不會完整的記起裏面的操做。那麼當有些東西須要改變時,或者因基礎鏡像更新而須要從新制做鏡像時,會讓一切變得異常困難,就如同從新安裝調試配置服務器同樣,失去了 Docker 的優點了。redis
另外,Docker 不是虛擬機,其文件系統是 Union FS,分層式存儲,每一次 commit 都會創建一層,上一層的文件並不會由於 rm 而刪除,只是在當前層標記爲刪除而看不到了而已,每次 docker pull 的時候,那些沒必要要的文件都會如影隨形,所獲得的鏡像也必然臃腫不堪。並且,隨着文件層數的增長,不只僅鏡像更臃腫,其運行時性能也必然會受到影響。這一切都違背了 Docker 的最佳實踐。sql
使用 commit 的場合是一些特殊環境,好比入侵後保存現場等等,這個命令不該該成爲定製鏡像的標準作法。因此,請用 Dockerfile 定製鏡像。
commit 命令在前一個問答已經說過,這是製做黑箱鏡像,沒法維護,不該該被使用。
import 和 export 的作法,其實是將一個容器來保存爲 tar 文件,而後在導入爲鏡像。這樣製做的鏡像一樣是黑箱鏡像,不該該使用。並且這類導入導出會致使原有分層丟失,合併爲一層,並且會丟失不少相關鏡像元數據或者配置,好比 CMD 命令就可能丟失,致使鏡像沒法直接啓動。
save 和 load 確實是鏡像保存和加載,可是這是在沒有 registry 的狀況下,手動把鏡像考來考去,這是回到了十多年的 U 盤時代😭。這一樣是不推薦的,鏡像的發佈、更新維護應該使用 registry。不管是本身架設私有 registry 服務,仍是使用公有 registry 服務,如 Docker Hub。
最直接也是最簡單的辦法是看官方文檔。
這篇文章講述具體 Dockerfile 的命令語法:https://docs.docker.com/engine/reference/builder/
而後,學習一下官方的 Dockerfile 最佳實踐:https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/
最後,去 Docker Hub 學習那些官方(Official)鏡像 Dockerfile 咋寫的。
也能夠看個人筆記。。。http://www.cnblogs.com/syaving/p/8047183.html
不是這樣的。
Dockerfile 不等於 .sh 腳本
Dockerfile 確實是描述如何構建鏡像的,其中也提供了 RUN 這樣的命令,能夠運行 shell 命令。可是和普通 shell 腳本還有很大的不一樣。
Dockerfile 描述的其實是鏡像的每一層要如何構建,因此每個RUN是一個獨立的一層。因此必定要理解「分層存儲」的概念。上一層的東西不會被物理刪除,而是會保留給下一層,下一層中能夠指定刪除這部份內容,但實際上只是這一層作的某個標記,說這個路徑的東西刪了。但實際上並不會去修改上一層的東西。每一層都是靜態的,這也是容器自己的 immutable 特性,要保持自身的靜態特性。
因此不少新手會常犯下面這樣的錯誤,把 Dockerfile 當作 shell 腳原本寫了:
RUN yum update RUN yum -y install gcc RUN yum -y install python ADD jdk-xxxx.tar.gz /tmp RUN cd xxxx && install RUN xxx && configure && make && make install
這是至關錯誤的。除了無畏的增長了不少層,並且不少運行時不須要的東西,都被裝進了鏡像裏,好比編譯環境、更新的軟件包等等。結果就是產生很是臃腫、很是多層的鏡像,不只僅增長了構建部署的時間,也很容易出錯。
正確的寫法應該是把同一個任務的命令放到一個 RUN 下,多條命令應該用 && 鏈接,而且在最後要打掃乾淨所使用的環境。好比下面這段摘自官方 redis 鏡像 Dockerfile 的部分:
RUN buildDeps='gcc libc6-dev make' \ && set -x \ && apt-get update && apt-get install -y $buildDeps --no-install-recommends \ && rm -rf /var/lib/apt/lists/* \ && wget -O redis.tar.gz "$REDIS_DOWNLOAD_URL" \ && echo "$REDIS_DOWNLOAD_SHA1 *redis.tar.gz" | sha1sum -c - \ && mkdir -p /usr/src/redis \ && tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \ && rm redis.tar.gz \ && make -C /usr/src/redis \ && make -C /usr/src/redis install \ && rm -r /usr/src/redis \ && apt-get purge -y --auto-remove $buildDeps
不是把全部命令都合爲一個 RUN,要合理分層,以加快構建和部署。
合理分層就是將具備不一樣變動頻繁程度的層,進行拆分,讓穩定的部分在基礎,更容易變動的部分在表層,使得資源能夠重複利用,以增長構建和部署的速度。
以 node.js 的應用示例鏡像爲例,其中的複製應用和安裝依賴的部分,若是都合併一塊兒,會寫成這樣:
COPY . /usr/src/app RUN npm install
可是,在 node.js 應用鏡像示例中,則是這麼寫的:
COPY package.json /usr/src/app/ RUN npm install COPY . /usr/src/app
從層數上看,確實多了一層。但實際上,這三行分開是故意這樣作的,其目的就是合理分層,充分利用 Docker 分層存儲的概念,以增長構建、部署的效率。
在 docker build 的構建過程當中,若是某層以前構建過,並且該層未發生改變的狀況下,那麼 docker 就會直接使用緩存,不會重複構建。所以,合理分層,充分利用緩存,會顯著加速構建速度。
第一行的目的是將 package.json 複製到應用目錄,而不是整個應用代碼目錄。這樣只有 pakcage.json 發生改變後,纔會觸發第二行 RUN npm install。而只要 package.json 沒有變化,那麼應用的代碼改變就不會引起 npm install,只會引起第三行的 COPY . /usr/src/app,從而加快構建速度。
而若是按照前面所提到的,合併爲兩層,那麼任何代碼改變,都會觸發 RUN npm install,從而浪費大量的帶寬和時間。
合理分層除了能夠加快構建外,還能夠加快部署,要知道,docker pull 的時候,是分層下載的,而且已存在的層就不會重複下載。
好比,這裏的 RUN npm install 這一層,每每會幾百 MB 甚至上 GB。而在 package.json 未發生變動的狀況下,那麼只有 COPY . /usr/src/app 這一層會被從新構建,而且也只有這一層會在各個節點 docker pull 的過程當中從新下載,每每這一層的代碼量只有幾十 MB,甚至更小。這對於大規模的並行部署中,所節約的東西向流量是很是顯著的。特別是敏捷開發環境中,代碼變動的頻繁度要比依賴變動的頻繁度高不少,每次重複下載依賴,會致使沒必要要的流量和時間上的浪費。
context,上下文,是 docker build 中很重要的一個概念。構建鏡像必須指定 context:
docker build -t xxx <context路徑>
或者 docker-compose.yml 中的
app: build: context: <context路徑> dockerfile: dockerfile
這裏都須要指定 context。
context 是工做目錄,但不要和構建鏡像的Dockerfile 中的 WORKDIR 弄混,context 是 docker build 命令的工做目錄。
docker build 命令其實是客戶端,真正構建鏡像並不是由該命令直接完成。docker build 命令將 context 的目錄上傳給 Docker 引擎,由它負責製做鏡像。
在 Dockerfile 中若是寫 COPY ./package.json /app/ 這種命令,實際的意思並非指執行 docker build 所在的目錄下的 package.json,也不是指 Dockerfile 所在目錄下的 package.json,而是指 context 目錄下的 package.json。
這就是爲何有人發現 COPY ../package.json /app 或者 COPY /opt/xxxx /app 沒法工做的緣由,由於它們都在 context 以外,若是真正須要,應該將它們複製到 context 目錄下再操做。
話說,有一些網文甚至搞笑的說要把 Dockerfile 放到磁盤根目錄,才能構建如何如何。這都是對 context 徹底不瞭解的表現。想象一下把整個磁盤幾十個 GB當作上下文發送給 dockerd 引擎的狀況,😱……
docker build -t xxx . 中的這個.,實際上就是在指定 Context 的目錄,而並不是是指定 Dockerfile 所在目錄。
默認狀況下,若是不額外指定 Dockerfile 的話,會將 Context 下的名爲 Dockerfile 的文件做爲 Dockerfile。因此不少人會混淆,認爲這個 . 是在說 Dockerfile 的位置,其實否則。
通常項目中,Dockerfile 可能被放置於兩個位置。
一個多是放置於項目頂級目錄,這樣的好處是在頂級目錄構建時,項目全部內容都在上下文內,方便構建;
另外一個作法是,將全部 Docker 相關的內容集中於某個目錄,好比 docker 目錄,裏面包含全部不一樣分支的 Dockerfile,以及 docker-compose.yml 類的文件、entrypoint 的腳本等等。這種狀況的上下文所在目錄再也不是 Dockerfile 所在目錄了,所以須要注意指定上下文的位置。
此外,項目中可能會包含一些構建不須要的文件,這些文件不該該被髮送給 dockerd 引擎,可是它們處於上下文目錄下,這種狀況,咱們須要使用 .dockerignore 文件來過濾沒必要要的內容。.dockerignore 文件應該放置於上下文頂級目錄下,內容格式和 .gitignore 同樣。
tmp
db
這樣就過濾了 tmp 和 db 目錄,它們不會被做爲上下文的一部分發給 dockerd 引擎。
若是你發現你的 docker build 須要發送龐大的 Context 的時候,就須要來檢查是否是 .dockerignore 忘了撰寫,或者忘了過濾某些東西了。
ENTRYPOINT 和 CMD 到底有什麼不一樣?
Dockerfile 的目的是製做鏡像,換句話說,其實是準備的是主進程運行環境。那麼準備好後,須要執行一個程序才能夠啓動主進程,而啓動的辦法就是調用 ENTRYPOINT,而且把 CMD 做爲參數傳進去運行。也就是下面的概念:
ENTRYPOINT "CMD"
假設有個 myubuntu 鏡像 ENTRYPOINT 是 sh -c,而咱們 docker run -it myubuntu uname -a。那麼 uname -a 就是運行時指定的 CMD,那麼 Docker 實際運行的就是結合起來的結果:
sh -c "uname -a"
若是沒有指定 ENTRYPOINT,那麼就只執行 CMD;
若是指定了 ENTRYPOINT 而沒有指定 CMD,天然執行 ENTRYPOINT;
若是 ENTRYPOINT 和 CMD 都指定了,那麼就如同上面所述,執行 ENTRYPOINT "CMD";
若是沒有指定 ENTRYPOINT,而 CMD 用的是上述那種 shell 命令的形式,則自動使用 sh -c 做爲 ENTRYPOINT。
注意最後一點的區別,這個區別致使了一樣的命令放到 CMD 和 ENTRYPOINT 下效果不一樣,所以有可能放在 ENTRYPOINT 下的一樣的命令,因爲須要 tty 而運行時忘記了給(好比忘記了docker-compose.yml 的 tty:true)致使運行失敗。
這種用法能夠很靈活,好比咱們作個 git 鏡像,能夠把 git 命令指定爲 ENTRYPOINT,這樣咱們在 docker run 的時候,直接跟子命令便可。好比 docker run git log 就是顯示日誌。
直接去 Docker Hub 上看:大多數 Docker Hub 上的鏡像都會有 Dockerfile,直接在 Docker Hub 的鏡像頁面就能夠看到 Dockerfile 的連接;
若是是本身公司作的,最簡單的辦法就是打個電話、發個消息問一下。別看這個說法看起來很傻,很多人都寧肯本身琢磨也不去問;
若是沒有 Dockerfile,通常這類鏡像就不該該考慮使用了,這類黑箱似的鏡像很容有有問題。若是是什麼特殊緣由,那繼續往下看;
docker history 能夠看到鏡像每一層的信息,包括命令,固然黑箱鏡像的 commit 看不見操做;
docker inspect 能夠分析鏡像不少細節。
直接運行鏡像,進入shell,而後根據上面的分析結果去進一步分析日誌、文件內容及變化。
通過分析後,本身寫 Dockerfile 還原操做。
這裏所提到的是個人那個 LNMP 例子的 php 服務的 Dockerfile:https://coding.net/u/twang2218/p/docker-lnmp/git/blob/master/php/Dockerfile
FROM php:7-fpm RUN set -xe \ # "構建依賴" && buildDeps=" \ build-essential \ php5-dev \ libfreetype6-dev \ libjpeg62-turbo-dev \ libmcrypt-dev \ libpng12-dev \ " \ # "運行依賴" && runtimeDeps=" \ libfreetype6 \ libjpeg62-turbo \ libmcrypt4 \ libpng12-0 \ " \ # "安裝 php 以及編譯構建組件所需包" && apt-get update \ && apt-get install -y ${runtimeDeps} ${buildDeps} --no-install-recommends \ # "編譯安裝 php 組件" && docker-php-ext-install iconv mcrypt mysqli pdo pdo_mysql zip \ && docker-php-ext-configure gd \ --with-freetype-dir=/usr/include/ \ --with-jpeg-dir=/usr/include/ \ && docker-php-ext-install gd \ # "清理" && apt-get purge -y --auto-remove \ -o APT::AutoRemove::RecommendsImportant=false \ -o APT::AutoRemove::SuggestsImportant=false \ $buildDeps \ && rm -rf /var/cache/apt/* \ && rm -rf /var/lib/apt/lists/*
這裏是針對 php 鏡像進行定製,默認狀況下 php:7-fpm 中沒有安裝所需的 mysqli, pdo_mysql, gd 等組件,因此這裏須要安裝,並且,部分組件還須要編譯。
所以,這裏涉及了兩類依賴庫/工具,一類是安裝、編譯階段所須要的依賴;另外一類是運行時所需的依賴。要記住 Dockerfile 的最佳實踐中要求最終鏡像只應該保留最小的所需依賴,所以安裝構建的依賴應該在安裝結束後清除,這一層只保留真正須要的運行時依賴。
所以,遵循最佳實踐的要求,這裏區分了 buildDeps 和 runtimeDeps 後,能夠在安裝結束後,卸載、清理 buildDeps 的依賴。這樣確保沒有無關的東西還在該層中。
兩種方法均可以。
若是代碼變更很是頻繁,好比開發階段,代碼幾乎每幾分鐘就須要變更調試,這種狀況可使用 --volume 掛載宿主目錄的辦法。這樣不用每次構建新鏡像,直接再次運行就能夠加載最新代碼,甚至有些工具能夠觀察文件變化從而動態加載,這樣能夠提升開發效率。
若是代碼沒有那麼頻繁變更,好比發佈階段,這種狀況,應該將構建好的應用放入鏡像。通常來講是使用 CI/CD 工具,如 Jenkins, Drone.io, Gitlab CI 等,進行構建、測試、製做鏡像、發佈鏡像、以及分步發佈上線。
對於配置文件也是一樣的道理,若是是頻繁變動的配置,能夠掛載宿主,或者動態配置文件可使用卷。可是對於並不是頻繁變動的配置文件,應該將其歸入版本控制中,走 CI/CD 流程進行部署。
須要注意的一點是,綁定宿主目錄雖然方便,可是不利於集羣部署,由於集羣部署前還須要確保集羣各個節點同步存在所掛載的目錄及其內容。所以集羣部署更傾向於將應用打入鏡像,方便部署。
這是典型的對 Dockerfile 以及鏡像、容器的基本概念不瞭解。
Dockerfile 不是 shell 腳本,而是定製 rootfs 的腳本。它並非在運行時運行的,而是在構建時運行的。
導入 .sql 文件到數據庫,實際上修改的是數據庫數據文件,而數據庫的數據文件存儲於卷,默認爲匿名卷,所以當導入行爲結束後,構建該層的容器中止運行,匿名卷被拋棄,全部導入行爲都會丟失,所以所謂的導入 .sql 的行爲在 Dockerfile 裏實際上徹底沒有意義。
而 service xxxx start 也徹底沒有意義,這是啓動後臺服務,且不說 Docker 中不用後臺服務,這種啓動行爲對文件系統根本沒影響,這僅僅是讓後臺在構建所用的容器中運行一下,徹底沒有意義。最後運行容器的時候,是另外一個進程了,該沒啓動的東西仍是不會啓動。
可是不要所以就盲目的得出 Dockerfile 沒法初始化數據庫的結論。全部官方鏡像都考慮到了定製的問題,去看特定官方鏡像的文檔,基本都會看到定製、初始化的方法。
好比官方 mysql 鏡像中,能夠把初始化的 .sql 腳本文件在 Dockerfile 中 COPY 至 /docker-entrypoint-initdb.d/ 目錄中,在容器第一次運行的時候,若是所掛載的卷是空的,那麼就會依次執行該目錄中的文件,從而完成數據庫初始化、導入等功能。
FROM mysql:5.7 COPY mysql-data-backup.sql /docker-entrypoint-initdb.d/
Alpine Linux 體積小是由於它所使用的基礎命令來自精簡的 busybox,而且它使用的是簡化實現的 musl 做爲庫支持,而並不是完整的 glibc。musl 體積小,可是有可能有不兼容的狀況,所以通常不用 Alpine 的鏡像,除非空間受限,體積大小很關鍵時纔會使用。
過去出現過兼容問題,可是隨着 Docker 的使用,對 Alpine 的需求會愈來愈多,更多的兼容問題會被發現、修復,因此相信在將來這應該是個不錯的選擇。可是若是如今就要使用,必定要進行重複的測試,確保沒有會影響到本身的 bug。
鏡像是分層存儲的,鏡像之間也能夠依賴,所以利用 Docker 鏡像很容易實現重複的部分複用。那麼咱們有沒有辦法能夠可視化的看到鏡像的依賴關係呢?
很早之前,Docker 有個 docker images --tree 的命令的,後來隨着鏡像分層平面化後,這個命令就取消了。幸運的是,Nate Jones 寫了一個工具,用於可視化鏡像分層依賴,叫作 dockviz:https://github.com/justone/dockviz
對於 Mac 平臺的用戶,能夠很方便的使用 brew 來進行安裝:
brew install dockviz
對於其它平臺的用戶,能夠直接去發佈頁面下載。
安裝好後,直接執行 dockviz images --tree 便可:
$ dockviz images --tree ├─<missing> Virtual Size: 55.3 MB │ └─<missing> Virtual Size: 55.3 MB │ └─<missing> Virtual Size: 55.3 MB │ └─<missing> Virtual Size: 55.3 MB │ └─<missing> Virtual Size: 55.3 MB │ └─<missing> Virtual Size: 108.3 MB │ └─<missing> Virtual Size: 108.3 MB │ └─<missing> Virtual Size: 108.3 MB │ └─<missing> Virtual Size: 108.3 MB │ └─0b5dec81616c Virtual Size: 108.3 MB Tags: nginx:latest └─<missing> Virtual Size: 100.1 MB └─<missing> Virtual Size: 100.1 MB └─<missing> Virtual Size: 123.9 MB └─<missing> Virtual Size: 131.2 MB ├─<missing> Virtual Size: 272.8 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 274.2 MB │ └─<missing> Virtual Size: 737.9 MB │ └─4551430cfe80 Virtual Size: 738.3 MB Tags: openjdk:latest └─<missing> Virtual Size: 132.4 MB └─<missing> Virtual Size: 132.4 MB └─<missing> Virtual Size: 132.4 MB ... └─<missing> Virtual Size: 276.0 MB └─<missing> Virtual Size: 292.4 MB └─<missing> Virtual Size: 292.4 MB └─<missing> Virtual Size: 292.4 MB └─72d2be374029 Virtual Size: 292.4 MB Tags: tomcat:latest
若是以爲文本格式太繁雜,也能夠生成 DOT 圖),使用命令 dockviz images -d | dot -Tpng -o image_tree.png 就能夠將你的鏡像依賴關係繪製成圖(https://imagebin.ca/v/3ZhFvSPeqAi0)。