如何使用本地 Docker 更好地開發?我們總結了這八條經驗
在 Viget,Docker 已經成為本地開發不可或缺的工具。我們的團隊構建和維護著大量的應用程序,運行著不同的軟件棧和版本,并且能夠將開發環境打包,這讓不同項目的切換和開發人員快速上手新項目變得非常容易。這并不是說在本地使用 Docker 開發就沒有缺點,但它帶來的便利遠遠超過了缺點。
隨著時間的推移,我們總結出了自己的一套最佳實踐,可以有效設置 Docker 開發環境。請注意最后一點(“本地開發”)——如果你是為了部署而創建鏡像,那么這些原則中的大多數都不適用。我們的開發環境一般包括(通過 Docker Compose 編配):
- 應用程序(例如 Rails、Django 或 Phoenix);
- JavaScript 監視器 / 編譯器(例如 webpack-dev-server);
- 數據庫(通常是 PostgreSQL);
- 其他必要的基礎設施(如 Redis、ElasticSearch、Mailhog);
- 有些應用程序實例偶爾也會做一些其他的事情,而不只是運行開發服務器(比如后臺任務)。
基于這樣的架構,以下是我們試圖進行標準化的最佳實踐。
1. 不要將代碼或應用級的依賴項放入鏡像中
你的主 Dockerfile 文件,也就是運行應用程序所需的文件,應該包含運行應用程序所需的所有軟件,但不應該包含應用程序代碼本身——當 docker-compose run 命令開始執行時,它們將被掛載到容器中,并在容器和本地機器之間進行同步。
另外,區分系統級依賴項(如 ImageMagick)和應用級依賴項(如 Rubygems 和 NPM 包)也很重要——前者應該包含在 Dockerfile 中,后者不應該。將應用級依賴項放到鏡像中意味著每次有人添加新依賴項時都必須重新構建鏡像,這既耗時又容易出錯。相反,我們應該將這些依賴項作為啟動腳本的一部分。
2. 非必要不使用 Dockerfile
基于第一點,你可能會發現根本不需要編寫 Dockerfile 文件。如果你的應用程序沒有任何特殊的依賴項,可以將 docker-compose.yml 的入口指向官方的 Docker 倉庫(如 ruby:2.7.6)。這樣做并不常見——大多數應用程序和框架都需要一定數量的鏡像基礎(例如,Rails 需要 Node),但如果你發現自己的 Dockerfile 只包含一個 FROM 行,你就可以不使用這個文件。
3. 只在 docker-compose.yml 中引用一次 Dockerfile
如果你將同一個鏡像用于多個服務(你應該這么做),只需要在一個服務的定義中提供構建說明,給它起一個名字,然后在其他服務中引用這個名字。舉個例子,假設有個 Rails 應用程序使用一個共享的鏡像來運行開發服務器和 webpack-dev-server,那么配置可能像這樣:
services:
rails:
image: appname_rails
build:
context: .
dockerfile: ./.docker-config/rails/Dockerfile
command: ./bin/rails server -p 3000 -b '0.0.0.0'
node:
image: appname_rails
command: ./bin/webpack-dev-server
這樣,當我們在構建服務(使用 docker-compose)時,鏡像就只構建一次。如果我們省略 image: 指令同時復制 build:,就會構建完全相同的鏡像兩次,這樣會浪費磁盤空間和有限的時間。
4. 在命名卷中緩存依賴項
正如第一點所提到的,我們不會將代碼依賴項放到鏡像中,而是在啟動時安裝它們??梢韵胂蟮氖?,如果我們每次重啟服務時都從頭開始安裝 gem/pip/yarn 這樣的庫,速度會非常慢,所以我們使用 Docker 的命名卷來保持緩存。上面的配置可能會變成這樣:
volumes:
gems:
yarn:
services:
rails:
image: appname_rails
build:
context: .
dockerfile: ./.docker-config/rails/Dockerfile
command: ./bin/rails server -p 3000 -b '0.0.0.0'
volumes:
- .:/app
- gems:/usr/local/bundle
- yarn:/app/node_modules
node:
image: appname_rails
command: ./bin/webpack-dev-server
volumes:
- .:/app
- yarn:/app/node_modules
命名卷的掛載點可能因不同的軟件棧而異,但原則是差不多的:將編譯后的依賴項保存在已命名的卷中,以大幅縮短啟動時間。
5. 將臨時的東西放入命名卷中
上一點提到使用命名卷來提高性能,這里有另一個有用的技巧:將保存只讀文件的目錄放入命名卷中,阻止它們被同步回本地機器(這會帶來很大的性能開銷),特別是 log 和 tmp 目錄,以及應用程序存儲上傳文件的地方。
根據經驗,如果一個目錄出現在.gitignore 中,那么最好把它放入命名卷中。
6. 在 apt-get 更新后進行清理
如果在 Dockerfiles 中引用了基于 Debian 的鏡像,你就必須運行 apt-get update,然后才能通過 apt-get install 安裝依賴項。如果不做一些處理,一堆額外的數據會被放到鏡像中,極大增加了鏡像的體積。
我們的最佳實踐是在一個 RUN 命令中執行更新、安裝和清理操作:
RUN apt-get update && \
apt-get install -y libgirepository1.0-dev libpoppler-glib-dev && \
rm -rf /var/lib/apt/lists/*
7 使用 exec 而不是 run
如果需要在容器中運行命令,你有兩個選項:run 和 exec。前者將啟動一個新容器來運行命令,而后者將連接到一個已經在運行中的容器。
在大多數情況下,假設在開發應用程序時總是有其他服務在運行,那么 exec(特別是 docker-compose exec)就是你所需要的,因為它運行起來更快,而且不會留下任何奇怪的文件(如果你忘了在 run 中包含 --rm 標志,就會發生這種情況)。
8. 使用 wait-for-it 協調服務
如果使用了之前提到的共享鏡像和依賴項命名卷,你可能會遇到這樣的問題:一個服務會在另一個服務的入口點腳本執行完畢之前啟動,從而導致發生了錯誤。當出現這種情況時,我們可以引入 wait-for-it 腳本,它將向一個 Web 地址發起請求,當這個地址返回響應時再執行命令。
所以,我們把 docker-compose.yml 修改一下:
volumes:
gems:
yarn:
services:
rails:
image: appname_rails
build:
context: .
dockerfile: ./.docker-config/rails/Dockerfile
command: ./bin/rails server -p 3000 -b '0.0.0.0'
volumes:
- .:/app
- gems:/usr/local/bundle
- yarn:/app/node_modules
node:
image: appname_rails
command: [
"./.docker-config/wait-for-it.sh",
"rails:3000",
"--timeout=0",
"--",
"./bin/webpack-dev-server"
]
volumes:
- .:/app
- yarn:/app/node_modules
這樣,在 Rails 開發服務器完全啟動并運行之前,webpack-dev-server 是不會啟動的。
以上就是我們在過去幾年中總結的一些 Docker 最佳實踐,我們也將努力保持更新這個清單。