使用 Docker 把自己的 Laravel 專案容器化

程式技術
sharkHead
使用 Docker 把自己的 Laravel 專案容器化

最近我正在學習如何使用容器管理平台 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 的檔案類型判斷中。

2023_04_13_00_06_59_b92a7a90074e.png

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,使用佇列需要 pcntlredis 模組,詳細可以參考 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 專案中的 bootstrapstorage 資料夾。並讓 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,接下來的 RUNCMDENTRYPOINT。都會用 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 各位工程師們的建議下修改成現在的內容,十分感謝!

參考資料

sharkHead
written by
sharkHead

後端打工仔,在下班後喜歡研究各種不同的技術。稍微擅長 PHP,並偶爾涉獵前端開發。個性就像動態語言般隨興,但渴望做事能像囉嗦的靜態語言那樣嚴謹。

16 則留言
sharkHead sharkHead 2023 年 12 月 05 日 (已編輯)

Hello~

官方文件提到 queue 可以使用 ShouldBeUnique 的 interface,讓你的任務在處理完成前是被鎖定起來的狀態。而且該功能在 redis 上是支援的。

<?php
 
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Contracts\Queue\ShouldBeUnique;
 
class UpdateSearchIndex implements ShouldQueue, ShouldBeUnique
{
    ...
}
訪客 2023 年 12 月 05 日

hello~ 想問一下,你有在 k8s 上面開兩個 horizon 來處理queue過嗎? 不知道兩個同時取會不會取到重覆的queue來發送 謝謝

sharkHead sharkHead 2023 年 09 月 13 日 (已編輯)

Hello ~

當你的 Laravel App 與資料庫容器啟動之後,就可以使用 artisan migrate 來建立 Laravel App 需要的資料表。

但注意資料庫要先新增好,我是直接使用 MySQL 官方的 docker image,在啟動時可以藉由設定環境變數來新增資料庫與使用者,詳細可以參考我部署用的 k8s yaml 檔案

因為是在 production 環境,artisan migrate 應該會詢問你是否真的要在 production 環境執行該指令,直接確認即可。

但假如你已經有資料了,可以將資料使用 mysqldump 匯出一份 .sql 檔案,日後建立好資料庫後直接匯入 .sql 就好,就不用再使用 artisan migrate

訪客 2023 年 09 月 13 日

blog没有提到数据库迁移artisan migrate 应该在什么时候执行,请教下这个问题

sharkHead sharkHead 2023 年 08 月 04 日

Hello ~

Docker compose 並不是拿來製作容器的。

當你的服務需要多個容器同時運行時,例如 web app、資料庫與 redis,你可以使用 docker compose 一起啟動它們,並將這些服務放在同一個虛擬網路層中,使其可以互相連線。

訪客 2023 年 08 月 03 日

想請問這部分為何當時沒有使用docker-compose的方式做容器呢?

sharkHead sharkHead 2023 年 07 月 14 日 (已編輯)

用戶上傳的檔案我放到 S3 上面。

而資料庫的部分,我有使用 k8s 提供的 persist volume。

訪客 2023 年 07 月 14 日

Redis cache 那方面了解了👍🏻 那用你那設計上,用戶上傳的檔案放去那了?去了s3等的服務上嗎?🙏🏻還是另一種方式儲存起來?MySQl也用database服務嗎?🙏🏻🙏🏻

sharkHead sharkHead 2023 年 07 月 13 日

Hello ~

Laravel 相關的 App (包含 horizon 與 scheduler),我都沒有在 k8s 上面使用 volume 存放 storage 的資料。

因為 cache 的 driver 我是選擇用 Redis,所以就算 Laravel App 有多個 replica,都可以從 Redis 上獲取 cache。比較重要的 cache 如使用者登入的 session,並不會存放在 storage 資料夾中。

訪客 2023 年 07 月 13 日

謝謝你, 另外我看了那幾個dockerfile, 每一個都有以下的storage 如果真的是在k8s運行, container自動擴展, 那你那設計的上傳檔案如果在多個container's 共用 storage ?

create bootstrap and storage files if they do not exist

gives the 'horizon' user read/write and execute privileges to those files

RUN mkdir -p
storage/framework/sessions
storage/framework/views
storage/framework/cache/data
storage/logs
bootstrap/cache
&& chown -R horizon:horizon
storage
bootstrap/cache
&& chmod -R ug+rwx storage bootstrap/cache