最近我正在學習如何使用容器管理平台 K8s,過程中深刻體會到容器化所帶來的許多好處。舉例來說,它可以更好地實現水平擴展,同時在更新服務時,可以使用滾動更新 (rolling update) 的方式,讓服務不間斷。因此某一天我開始計劃,想把我自己用 Laravel 開發的部落格也容器化。
在過去,將 PHP 容器化相較於其他語言來說更複雜一些。這是因為 PHP 無法單獨運行,如果想在伺服器上運行 PHP 網站,通常需要搭配 web server 與 FPM。因此,在容器化的 PHP App 時,通常會需要另外打包 web server 與 FPM 的容器。
製作容器時,應盡可能的讓容器主程序單一化。主程序正常執行則代表容器的狀態是健康的。如果一個容器有多個程序在執行,容器管理系統 (Docker 或是 K8s) 會難以知道這個容器是否正常運作。
直到 Swoole 推出之後,PHP 才有了獨立運作的可能性,而 Laravel 團隊也順勢推出了 Laravel Octane,使得 Laravel App 可以使用 Swoole 單獨運行。
在這篇文章中,我將以我將自己的部落格容器化為例,簡單介紹如何使用 Dockerfile 製作映像檔 (Image),並在容器中使用 Laravel Octane 來運行 Laravel App。
避免複製不必要的檔案至映像檔中
我們先在 Laravel 專案底下新增一個 .dockerignore
,這個檔案如同 Git 的 .gitignore
,可以用來避免將檔案複製到映像檔中。
在製作映像檔的過程中,我們時常會使用 Docker 的複製操作 COPY
,將專案的檔案複製到映像檔中,但有些檔案可能是開發用,不是正式環境運行時所需要的檔案,例如 .git
資料夾,此時我們就可以使用 .dockerignore
將這些檔案排除。
在開始寫 Dockerfile
前,建議先寫 .dockerignore
,設定好哪些檔案不需要被複製到映像檔中,以下是我部落格的的 .dockerignore
內容,可以依照自己的專案需求進行調整。
# git 在正式環境並不需要,而且經過多次 commit 的專案,.git 資料夾通常會很大,建議排除
.git
# IDE 相關設定
.idea
.vscode
bootstrap/cache/*.php
storage/app/*
storage/debugbar/*
storage/framework/*
storage/logs/*
# 前端依賴套件
node_modules
# 打包好的前端資源 (可以不排除,這裡會排除的原因是我會在建立過程中重新打包)
public/build
# 測試相關檔案的資料夾
tests
# 後端依賴套件 (可以不排除,這裡會排除的原因是我會在建立過程中重新下載)
vendor
# 其他
.editorconfig
.env
.env.example
.gitattributes
.gitignore
.phpunit.result.cache
_ide_helper.php
_ide_helper_models.php
CHANGELOG.md
docker-compose.yml
phpunit.xml
pint.json
README.md
在官方的 Best practices for writing Dockerfiles 中,就有提到建議使用
.dockerignore
來避免複製不必要的檔案到映像檔中,以減少映像檔的大小。
設定好 .dockerignore
,就可以開始來製作映像檔囉。
製作映像檔案
在開始製作映像檔案前,先來看看我的部落格需要啟動哪些程序。
- Laravel Octane:透過 Swoole 運行 Laravel App。
- Laravel Horizon:處理佇列任務 (Queue),注意 Horizon 只能把 Redis 當作 Queue Driver,所以我們需要使用 PHP 的 Redis 模組。
- Supercronic:處理定時任務 (Schedule),雖然也可以使用系統自帶的 CronJob,但 Supercronic 可以很方便的將執行的 Output 導向到 Docker 建議的
/dev/stdout
或者是/dev/stderr
。
因此,雖然是一個部落格 App,但應該分成三個容器。
盡可能讓容器中的主程序只有一個才符合容器的最佳實踐。
在 Laravel 專案中新增一個 dockerfiles
資料夾,裡面新增三個檔案,分別是:
Dockerfile.app
:用來製作 Laravel Octane 的映像檔。Dockerfile.horizon
:用來製作 Laravel Horizon 的映像檔。Dockerfile.scheduler
:用來製作 Supercronic 的映像檔。
這些檔案裡面會詳細寫著製作映像檔的各個步驟。
如果你是使用 JetBrains 的 IDE,你可能會發現將
Dockerfile
的名稱改成Dockerfile.app
會導致 IDE 無法正確判斷檔案類型,進而遺失 syntax highlight 與 auto complete 等功能。詳細可以參考官方文件,將Dockerfile.*
加入Dockerfile
的檔案類型判斷中。
Dockerfile.app 的內容說明
接下來開始編輯 Dockerfile.app
檔案,首先使用 ARG
來設定我們容器要使用的 PHP 版本。
ARG PHP_VERSION=8.2
接下來使用 Composer 來安裝 Laravel 專案的依賴套件。
# 使用 composer 容器,並把此階段命名為 vendor
FROM composer:latest AS vendor
# 設定容器內的工作目錄,接下來所有要執行的指令,預設都會在這個目錄底下進行
WORKDIR /var/www/html
# 將專案內的 'composer.json' 與 'composer.lock' 檔案複製到容器內的 `/var/www/html` 資料夾中
# 因為上一步已經設定工作目錄為 `/var/www/html`,因此這裡的 `./` (意即當前目錄)就會是 `/var/www/html`
COPY composer* ./
# 使用 composer 安裝正式環境運行時所需要的套件
# 這裡有使用 `--no-dev` 避免安裝開發時用的套件
RUN composer install \
--no-dev \
--no-interaction \
--prefer-dist \
--ignore-platform-reqs \
--optimize-autoloader \
--apcu-autoloader \
--ansi \
--no-scripts \
--audit
後端依賴套件安裝好了,接下來打包前端資源。
# 使用 node 容器,並把此階段命名為 assets
FROM node:latest AS assets
# 設定容器內的工作目錄
WORKDIR /var/www/html
# 將全部專案都複製進去
COPY . .
# 使用 npm 安裝前端依賴套件,並打包前端資源
RUN npm install \
&& npm run build
後端依賴套件與前端資料準備好之後,接下來開始建立最終映像檔,也就是要執行 Laravel Octane 的容器。
首先選擇基底映像檔案,這裡使用 PHP 官方的 8.2-cli 映像檔作為基底映像檔,這裡我們使用剛剛用 ARG
宣告的變數 PHP_VERSION
,也就是 8.2。
使用官方的 8.2-cli 映像檔案算是偏懶人的方式,如果追求更輕量的映像檔大小,建議使用乾淨的作業系統映像檔案 (如 Alpine Linux) ,並自行安裝需要的 PHP 環境。
有一點需要注意,
ARG
只在最終映像有用,所以在剛剛安裝依賴的階段,均無法直接使用ARG
宣告的變數。
FROM php:${PHP_VERSION}-cli-bullseye
設定維護者的標籤 (或稱元數據)。
LABEL maintainer="Allen"
設定工作目錄,可以將工作目錄的值放入環境變數中,方便後續重複使用。
ENV ROOT=/var/www/html
WORKDIR $ROOT
設定預設的 Shell 為 /bin/bash
,並加上一些參數方便 Debug。
-e
:當命令執行時出現非零狀態時立即退出。-x
:在執行指令前,先將執行指令印出。-o pipefail
:默認情況下 Bash 只會檢查管道 (pipeline) 操作最後一個指令的返回值, 這個選項表示在管道連接的命令中,只要有任何一個命令失敗 (返回值非0),則整個管道操作被視為失敗。-u
:使用未定義的變數時會被視為錯誤,並在遇到未定義變數時立即退出。-c
:當 shell 開始運行時,執行以下命令。
SHELL ["/bin/bash", "-exou", "pipefail", "-c"]
更新系統套件。
RUN apt-get update \
&& apt-get upgrade -yqq \
&& apt-get -yqq --no-install-recommends --show-progress \
curl
使用容器提供的指令安裝 PHP 的模組,Laravel Octane 需要安裝 swoole
,使用佇列需要 pcntl
與 redis
模組,詳細可以參考 Laravel 官方文件。
RUN docker-php-ext-install pdo_mysql \
&& docker-php-ext-install opcache \
&& pecl install redis \
&& pecl install swoole \
&& docker-php-ext-install pcntl \
&& docker-php-ext-enable redis swoole
使用 ARG
宣告一組 user id 與 group id ,並新增名為 octane
的 group 與 user。這個 user 會拿來執行容器的主程序。
Docker 容器中預設是 root 權限,雖然可以使用 root 權限執行容器主程序,但為了安全起見,建議在容器中新增一個用戶,並給予適當的權限來執行主程序。
ARG WWWUSER=1000
ARG WWWGROUP=1000
RUN groupadd --force -g $WWWGROUP octane \
&& useradd -ms /bin/bash --no-log-init --no-user-group -g $WWWGROUP -u $WWWUSER octane
接下來將整個專案的檔案複製到映像檔案中。
在 Dockerfile 中,只有 COPY、ADD 與 RUN 會建立 Layer。
其中 COPY 會根據檔案的 Checksum 與 Metadata 是否改變來決定要不要使用 Build Cache,如果偵測到檔案被修改,Docker 就會重新建立 Layer。
需要注意的是,只要某一個步驟不使用 Build Cache 而是重新建立 Layer,後續所有步驟也都會重新建立,不會使用 Build Cache。
因為程式碼會時常更新,所以在寫 Dockerfile 時,應該盡可能將複製專案的 COPY 的位置往後移動。
COPY . .
新增 Laravel 專案中的 bootstrap
和 storage
資料夾。並讓 octane 用戶擁有讀取、寫入和執行這些資料夾的權限。
RUN mkdir -p \
storage/framework/{sessions,views,cache/data} \
storage/logs \
bootstrap/cache \
&& chown -R octane:octane \
storage \
bootstrap/cache \
&& chmod -R ug+rwx storage bootstrap/cache
將 PHP 設定檔案與 entrypoint 的 shell script 檔案複製到容器中。
# copy php config files into container
COPY deployment/php/php.ini /usr/local/etc/php/conf.d/octane.ini
COPY deployment/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# set scripts to start the laravel octane app
COPY deployment/scripts/app-entrypoint.sh deployment/scripts/app-entrypoint.sh
php.ini
的內容如下。
[PHP]
post_max_size = 100M
upload_max_filesize = 100M
expose_php = 0
variables_order = "GPCS"
opcache.ini
的內容如下。
[Opcache]
opcache.enable = 1
opcache.enable_cli = 1
opcache.memory_consumption = 256M
opcache.use_cwd = 0
opcache.max_file_size = 0
opcache.max_accelerated_files = 32531
opcache.validate_timestamps = 0
opcache.revalidate_freq = 0
[JIT]
opcache.jit_buffer_size = 100M
opcache.jit = function
app-entrypoint.sh
的內容如下,在這個 shell script 中,會啟動容器的主要程序,也就是 Laravel Octane。
#!/usr/bin/env bash
set -e
initialStuff() {
php artisan optimize:clear
php artisan package:discover --ansi
php artisan config:cache
php artisan route:cache
php artisan view:cache
}
initialStuff
# 啟動 Laravel Octane,也是容器的主程序
php artisan octane:start --server=swoole --host=0.0.0.0 --port=9000 --workers=auto --task-workers=auto --max-requests=500
將剛剛複製到容器中 entry point shell script 檔案設定為可執行文件。
RUN chmod +x deployment/scripts/app-entrypoint.sh
將剛剛在前置階段安裝好的依賴套件與打包好的前端資源複製到此映像檔案中。
COPY --from=vendor ${ROOT}/vendor vendor
COPY --from=assets ${ROOT}/public/build public/build
提示該映像檔應該開啟 9000 port。
注意這裡只是提示,實際運行容器時還是需要自行開啟 port 對映。
EXPOSE 9000
設定 USER
為 octane,接下來的 RUN
、CMD
與 ENTRYPOINT
。都會用 octane 使用者的身分執行。
USER octane
設定 ENTRYPOINT
,一啟動容器時就會立刻執行。
ENTRYPOINT ["deployment/scripts/app-entrypoint.sh"]
最後使用 HEALTHCHECK
檢查 Laravel Octane 能否正常執行。
HEALTHCHECK --start-period=5s --interval=30s --timeout=5s --retries=8 \
CMD curl --fail localhost:9000 || exit 1
你可以在 Laravel 中寫一個檢查運行狀況的 API,然後使用 curl 打 API 來當作 HEALTHCHECK ,原本我是用
php artisan octane:status
來檢查,但這行指令非常吃資源,所以這裡改成簡單的curl --fail localhost:9000
。
上面的 Dockerfile.app
有使用一種稱為 Multi-stage builds 的技巧,我們先用 composer 容器下載依賴套件,並將其複製到最終的 Laravel App 映像檔中,這麼做我們就可以不用在最終映像檔額外安裝 composer,可以有效地減少最終映像檔的大小,使用 node 容器打包前端資源也是同理。
Dockerfile.horizon 的內容說明
基本上內容與 Dockerfile.app
大致相同,只需要做一些小修改。不再是啟動 Laravel Octane,而是啟動 Laravel Horizon。
首先第一個修改的地方,就是不需要安裝 Swoole 模組。
RUN docker-php-ext-install pdo_mysql \
&& docker-php-ext-install opcache \
&& pecl install redis \
&& docker-php-ext-install pcntl \
&& docker-php-ext-enable redis
修改 ENTRYPOINT
,改為啟動 Laravel Horizon。
ENTRYPOINT ["php", "artisan", "horizon"]
HEALTHCHECK --start-period=5s --interval=30s --timeout=5s --retries=8 CMD php artisan horizon:status || exit 1
移除把 entrypoint-app.sh
複製到映像檔中的步驟。
# 這個步驟可以移除
COPY deployment/scripts/app-entrypoint.sh deployment/scripts/app-entrypoint.sh
RUN chmod +x deployment/scripts/app-entrypoint.sh
Dockerfile.scheduler 的內容說明
一樣與 Dockerfile.app
的內容大致相同,只需要做一些小修改。
首先不需要打包前端資源了,所以下面這個部分可以刪除。
# 這幾行皆可以移除
FROM node:latest AS assets
WORKDIR /var/www/html
COPY . .
RUN npm install \
&& npm run build
...
COPY --from=assets ${ROOT}/public/build public/build
需要安裝 wget,來下載 Supercronic 的安裝檔案。
RUN apt-get update \
&& apt-get upgrade -yqq \
&& apt-get install -yqq --no-install-recommends --show-progress \
wget
安裝 Supercronic,這裡會根據架構為 x86 還是 arm,來下載不同版本的 supercronic 進行安裝。
最後可以使用 HEALTHCHECK
檢查 supercronic 能否正常執行。
RUN if [ "$(uname -m)" = "x86_64" ]; then \
wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.2/supercronic-linux-amd64" \
-O /usr/bin/supercronic; \
elif [ "$(uname -m)" = "aarch64" ]; then \
wget -q "https://github.com/aptible/supercronic/releases/download/v0.2.2/supercronic-linux-arm64" \
-O /usr/bin/supercronic; \
else \
echo "Unsupported platform" && exit 1; \
fi \
&& chmod +x /usr/bin/supercronic \
&& mkdir -p /etc/supercronic \
&& echo "*/1 * * * * php ${ROOT}/artisan schedule:run --verbose --no-interaction" > /etc/supercronic/laravel
HEALTHCHECK --interval=5s --timeout=3s \
CMD supercronic -test /etc/supercronic/laravel
修改 ENTRYPOINT
,改為啟動 Supercronic。
ENTRYPOINT ["supercronic", "/etc/supercronic/laravel"]
一樣把 entrypoint-app.sh
複製到映像檔中的步驟給移除。
# 這個步驟可以移除
COPY deployment/scripts/app-entrypoint.sh deployment/scripts/app-entrypoint.sh
RUN chmod +x deployment/scripts/app-entrypoint.sh
使用 Docker Buildx 打包映像檔案
Dockerfile
寫好之後,接下來就可以開始打包成映像檔案了,我們可以使用 Docker Buildx 來打包不同架構的映像檔案,例如 x86 與 arm。
先查看目前系統支援哪幾種架構。顯示結果中的 *
,表示預設使用的 builder。
$ docker buildx ls
NAME/NODE DRIVER/ENDPOINT STATUS BUILDKIT PLATFORMS
default * docker default default running 20.10.22 linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6
desktop-linux docker desktop-linux desktop-linux running 20.10.22 linux/arm64, linux/amd64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6
建立一個新的 builder,並使用 --platfrom
選擇 x86 與 arm 架構。
$ docker buildx create --name mybuilder --platform linux/amd64,linux/arm64
將剛剛新建好的 builder 設定為預設。
$ docker buildx use mybuilder
接下來以 Dockerfile.app
為例,開始打包映像檔並推送至 Docker Hub (需要先登入 Docker 的帳號),映像檔案名稱與標籤可以自行更改。請確定是在專案目錄底下執行此指令,而非 dockerfiles
資料夾底下。
-f
用來指定要打包的 Dockerfile
檔案,如果沒有設定的話,預設會使用所在資料夾底下名為 Dockerfile
的檔案。
image:tag
為映像檔名稱與標籤,可以修改成自己想要的名稱。
$ docker buildx build -f dockerfiles/Dockerfile.app --platform linux/amd64,linux/arm64 --push -t image:tag .
指令完成之後,就可以從 Docker Hub 上面 pull 下來並執行囉!
這篇文章原本是把 Laravel Octane、Laravel Horizon 與 Supercronic 全都放在同一個容器中。可以說是不太好的示範。在 Laravel Taiwan 各位工程師們的建議下修改成現在的內容,十分感謝!
參考資料
- exaco/laravel-octane-dockerfile: Production-ready Dockerfile for Laravel Octane powered web services and microservices. Done right. (github.com)
- Best practices for writing Dockerfiles
- Best practices for building containers | Cloud Architecture Center | Google Cloud
- Multi-stage builds (docker.com)
- Bash 腳本中的 set -euxo pipefail
- php 7 透過opcache提升執行效能 - ScottChayaa
Hello~
官方文件提到 queue 可以使用
ShouldBeUnique
的 interface,讓你的任務在處理完成前是被鎖定起來的狀態。而且該功能在 redis 上是支援的。