容器化(Containerization),這是一個由 Docker 公司所發揚光大的一種技術,它能夠很好地封裝應用程式與所需函式庫,而且通常有著比 虛擬化(Virtualization) 更高的效能。
一般來說,編譯式語言都很容易被容器化,例如 C/C++ 或 Golang,這是因為只需要在容器中設定好相依函式庫(通常是指動態函式庫),其編譯出的執行檔就可以直接在容器中運行。
這對 PHP 這類直譯式語就不是個好消息,其運行環境往往受制於 Apache PHP Module 或 PHP-FPM,再加上現代 PHP 往往會整合 Composer 進行相依性套件管理,這使得其處境更加雪上加霜。
PHP 的運行環境概述
PHP 官方共支援 8 種 伺服器 API(SAPI, Server Application Programming Interface),用於使伺服器(如 Apache 或 Nginx)與 PHP 運行環境相互溝通,其中粗體字表示為較常使用的 SAPI:
- apache2handler
- cgi
- cli
- embed
- fmp
- fuzzer
- litespeed
- phpdbg
摒除 CLI 用於與命令介面交互之外,通常是使用 Apache 及可支援 FPM 的網頁伺服器互相搭配。
近年來,Swoole 發展迅速。開發者能夠利用 CLI 的 SAPI 直接執行一個完整的、可生產使用(Production Ready)的 Web Server 用於執行 PHP 應用程式。
目前 Laravel 官方也提供了 Laravel Octane 作為 Swoole 及 Roadrunner 的 Wrapper。
Apache HTTP Server
這是一個自 1995 年就發佈的老牌 HTTP 伺服器軟體,因為其廣大的用戶群與支援性,至今仍有許多網站選擇使用。
因為其驚人的市佔率,PHP 能夠以模組的形式被載入 Apache2 中,並且依賴 apache2handler SAPI 作為溝通橋樑。
|----------------------|
| |
| Apache2 |
| ============== | apache2handler ###############
| = PHP Module = <-|-----------------> # PHP Runtime #
| ============== | ###############
| |
|----------------------|
在上圖中
- Apache2 可以被視為一個(或一組)執行緒
- PHP Module 是一個被 Apache 載入的 動態連結函式庫(Dynamic Library)
- PHP Runtime 用於實際執行 PHP 程式,它並非一個實際存在的執行緒,只是一個虛擬概念
FPM
FPM 全稱為 FastCGI Process Manager,而 FastCGI 是一種通訊協定,用於使用程式能夠與伺服器互相溝通。
有許多網頁伺服器都支援這項通訊協定,其中最知名的當屬 Nginx,因此 Nginx + PHP-FPM 的組合漸漸取代 Apache2 + PHP Module。順帶一提,Apache2 其實也有支援 FPM,然而因為一些歷史因素導致其設定較為複雜,所以罕有人討論。
|-------| FastCGI |---------| fpm SAPI ###############
| Nginx | <---------> | PHP-FPM | <----------> # PHP Runtime #
|-------| |---------| ###############
在上圖中
- Nginx 可以被視為一個(或一組)執行緒
- PHP-FPM 可以被視為一個(或一組)執行緒
- Nginx 及 PHP-FPM 用 FastCGI 作為其溝通的通訊協定
- PHP Runtime 用於實際執行 PHP 程式,它並非一個實際存在 的執行緒,只是一個虛擬概念
容器化概述
前置知識
- 映像(Image):未啟動的容器,可以視為容器的一種封裝,其目的是為了在多個不同的環境中交換容器資訊
- 容器(Container):已啟動的映像,可以提供服務的對象
行程的考量
根據 Google Cloud Platform 構建容器的最佳做法 一文中提到,應該盡量為每一個「應用程式(Application)」打包一個映像。
其中,應用程式指的是具有唯一 親代行程(Parent Process),且可能具有多個 子代行程(Child Process) 的程式。
舉例來說,一個經典的 Nginx + PHP-FPM + MySQL 架構中,我們固然可以將它們都放在同一個容器中提供服務,但容器管理系統(Docker 或 Kubernetes)就無法針對個別的服務去判斷是否正常執行;所以更好的做法應該是將 Nginx, PHP-FPM 及 MySQL 分成三個不同的容器。
有鑑於此,相較於 Nginx + PHP-FPM 兩個容器的做法,Apache2 + PHP Module 只需要一個容器便能夠完成容器化,是比較簡單的選擇。
在大部份情境下,Apache2 + PHP Module 的方式會比 PHP-FPM 效率慢上一些,這主要是因為 Apache 的 prefork 工作模式造成的性能損耗。
最小化映像
較小的映像可以擁有上傳、下載更快的優勢,而且這也可以顯著降低儲存成本,在某些應用場景下甚至可以提升啟動效率。
通常來說,可以利用以下做法最小化映像:
- 使用較小的基礎映像:
alpine
僅佔用不到 3MB,而ubuntu
卻要使用近 30MB - 只安裝必須的軟體與相依套件:一些編譯、構建用的軟體或套件應該盡量避免安裝
- Dockerfile 中盡量降低「指令」數量:因為每個指令會重新建立一層映像,這會使映像大小膨脹
不良的 Dockerfile 範例
FROM alpine
RUN apk update
RUN apk add nginx
良好的 Dockerfile 範例
FROM alpine
RUN apk update \
&& apk add nginx
PHP 應用程式的容器化
先將 PHP-FPM 與 Apache PHP Module 的爭論暫且擱置一旁。PHP 應用程式本身的容器化也是很值得探討的議題。
相依性套件的安裝
正如同我們所知,現代的 PHP 應用程式往往會整合 Composer 作為其套件管理機制,但在核心理念上應該使最終產出的映像佔用空間盡可能小,這代表我們不應該在裡面安裝 Composer。
所幸使用 Multi-Stage Builds 這項功能,我們可以輕易地使用 Composer 管理相依性套件:
FROM composer AS builder
WORKDIR /src
COPY . .
RUN composer install --no-dev
FROM php:apache
WORKDIR /var/www/html
COPY /src .
- 行 1:使用
composer
這個官方映像作為 builder - 行 4:將當前的 PHP 程式碼複製進容器中
- 行 5:執行對應的 composer 指令
- 行 7:使用
php:apache
這個官方映像,這會是最終建構出來的映像所使用的基礎 - 行 9:將
composer
映像中建構好的相依性套件複製 進當前資料夾中
同理,如果是要建置 Nodejs 套件(例如用 webpack 編譯並構建前端資源),也可以用類似的方法執行。
值得注意的是,由 Docker 官方提供的 composer:latest
映像並不「穩定」,這是因為它可能隨時會更新版本(例如從 PHP 8.0 升級為 PHP 8.1,或是 composer 的版本升級),如果應用程式必須限制在某一個 PHP 版本,請特別注意不要使用 composer:lastest
擴充元件的安裝
有些時候,官方提供的 PHP 映像內建的擴充元件並不能滿足我們應用程式的需求。舉例來說,GD extension 或 redis extension 並不會內建,此時我們就必須自行安裝。
FROM php:fpm
RUN apt-get update && apt-get install -y \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j$(nproc) gd
- 行 3 ~ 6:安裝 GD 所需的函式庫
- 行 7, 8:使用
docker-php-ext-configure
及docker-php-ext-install
進行編譯並建置 GD extension,這是由 PHP 官方容器提供的一系列 Shell Scripts
FROM php:fpm
RUN pecl install redis \
&& docker-php-ext-enable redis
- 行 3:利用
pecl
安裝 redis extension - 行 4:利用
docker-php-ext-enable
啟用 redis extension
利用 Alpine 作為基礎映像
事實上,在映像建置的過程中如果用 docker-php-ext-install
或 pecl
有一些缺點:
- 建置時間被拖長:因為重新編譯元件,這取決於電腦的處理性能與核心數多寡
- 容器大小膨脹:編譯前往往需要加入一些額外的函式庫,例如 GD 使用
libpng-dev
- 連線問題導致失敗:
pecl
偶爾會因為連線問題導致整個映像建置失敗,浪費許多執行成本
更好的做法可以參考使用 alpine
作為基礎映像,並且用 apk
套件管理工具安裝絕大多數的 PHP extension:
FROM alpine
RUN apk add php82 php82-fpm
COPY ["php-fpm82", "-F"]
在使用 Alpine Image 時需要特別小心,有些較新的軟體僅能在 alpine:edge
中被 apk add
安裝,而這是非常不適合用於生產環境的 Image。