Multi-stage Build

在說明 Multi-stage Build 之前,先來簡單了解持續整合(Continuous Integration,以下簡稱 CI)的 Build 與 DevOps 的 Pipeline。

CI 裡面用了 Build 這個關鍵字,實際它背後做的事包含了 compilation、testing、inspection 與 deployment 等;而 DevOps Pipeline 則提到軟體生命週期有 development、testing、deployment 不同的階段(stage),同時每個階段都有可能會產生 artifacts。

對 CI 有興趣可以參考:CI 從入門到入坑

綜合上述說明,在 build 的過程會做不同的任務,並產生 artifacts,而在不同階段又會做不同的任務。

接著來看範例,延續最佳化 Dockerfile 完之後的結果如下:

FROM php:7.3-alpine

# 全域設定
WORKDIR /source

# 安裝環境
RUN apk add --no-cache unzip

# 安裝 extension
RUN set -xe && \
apk add --no-cache --virtual .build-deps \
autoconf \
g++ \
make \
&& \
docker-php-ext-install \
bcmath \
&& \
pecl install \
redis \
&& \
docker-php-ext-enable \
redis \
&& \
apk del .build-deps \
&& \
php -m

RUN set -xe && \
curl -sS https://getcomposer.org/installer | php && \
mv composer.phar /usr/local/bin/composer

# 加速套件下載的套件
RUN composer global require hirak/prestissimo && composer clear-cache

# 安裝程式依賴套件
COPY composer.* ./
RUN composer install --no-dev --no-scripts && composer clear-cache

# 複製程式碼
COPY . .
RUN composer run post-autoload-dump

CMD ["php", "artisan", "serve", "--host", "0.0.0.0"]

這個 Dockerfile 已可以建置出 Laravel image,但思考以下這兩個奇妙的問題:

  1. Image 最終要部署到線上環境,任何開發或建置工具(如 Composer)都不需要,該怎麼做?
  2. Image 若需要做為開發測試共用環境,需要開發工具(如 PHPUnit),該怎麼辦?

目前 Dockerfile 的最佳化方法,面對這兩個問題會是矛盾大對決,要嘛偏維運,要嘛偏開發,無法同時解決。

幾乎所有語言都會有這個問題,所以回頭看完各種框架如何 build image 後,現在再來看這個問題,相信更有感覺。

Dapper

最單純直接的解決方法,就是針對維運與開發寫兩個 Dockerfile,但這在使用上非常不方便,於是第三方 Rancher 就寫了一個工具--Dapper,主要 Dockerfile 作為維運用,而用另一個指令來處理開發用的 Dockerfile。

Multi-stage Build 概念

若需要在開發用的環境上,處理不固定的任務(如:依狀況進入 container 下不同的指令),Dapper 是非常好用的;如果在 container 上是處理固定的任務(如:執行 phpunit),則使用 Docker 17.05 開始推出的 Multi-stage Build 會更方便。

概念其實很簡單,參考下面的 Dockerfile:

FROM alpine AS build
RUN touch test

FROM alpine
COPY --from=build /test .
RUN ls -l /test

這個 Dockerfile 有三個特別的地方跟過去不大一樣:

  1. 有兩個 FROM 指令,每個 FROM 指令都代表一個 stage,每個 stage 的結果都是 image
  2. 第一個 FROM 指令使用 AS 可以為 image 取別名
  3. COPY 多了一個選項 --from,選項要給的值是 image,實際行為是從該 image 把對應路徑的檔案或 artifacts,複製進 container

執行過程如下:

  1. 第一個 stage 產生了 test 檔案
  2. 第二個 stage 把第一個 stage 產生的 test 檔案複製過來,並使用 ls 觀察

這兩個 stage 是有互相依賴的,因此 touch test 指令若移除的話,就會出現找不到檔案的錯誤。

COPY image 檔案

上面有提到 COPY--form 選項要給值是 image,實際上不只可以使用 stage image,而是連 remote repository 都能使用。因此像 Composer 有 image,且執行檔單一檔案,所以安裝 Composer 的方法,可以改成下面這個寫法:

COPY --from=composer:1 /usr/bin/composer /usr/bin/composer

FROM stage image

因 stage image 的 alias 可以當作 image 用,所以下面這個寫法是可行的:

FROM alpine AS curl
RUN apk add --no-cache curl

FROM curl AS build1
RUN curl --help

FROM curl AS build2
RUN curl --version

實作 Multi-stage Build

最後這裡就舉複雜的範例:一開始提的最佳化 Dockerfile,我們把拿它套用 Multi-stage Build。這個 Dockerfile 它可以分作下面幾個 stage:

# PHP 環境基礎
FROM php:7.3-alpine AS base

# npm 建置 stage
FROM node:12-alpine AS npm_builder

# Composer 安裝依賴
FROM base AS composer_builder

# 上線環境
FROM base

Composer 與上線環境依賴 base 是因為像 bcmathredis 套件依賴是屬於底層共用的套件,所以打一個共用 image 會比較方便一點;另外 Laravel 框架 skeleton 有內帶 npm 相關檔案,這次也加入成一個 stage。

先看 base image,這段應該沒問題,因為它只做安裝 extension:

FROM php:7.3-alpine AS base

# 安裝 extension
RUN set -xe && \
apk add --no-cache --virtual .build-deps \
autoconf \
g++ \
make \
&& \
docker-php-ext-install \
bcmath \
&& \
pecl install \
redis \
&& \
docker-php-ext-enable \
redis \
&& \
apk del .build-deps \
&& \
php -m

再來 npm image 應該也沒有太大問題:

FROM node:14-alpine AS npm_builder

WORKDIR /source

COPY package.* ./
# 依照 npm run production 提示把 vue-template-compiler 先安裝進去
RUN npm install && npm install vue-template-compiler --save-dev --production=false

COPY . .

RUN npm run production

Composer image,包括安裝 Composer 的調整,與安裝依賴套件。在這個 stage 還可以做單元測試:

FROM base AS composer_builder

WORKDIR /source

COPY --from=composer:1 /usr/bin/composer /usr/bin/composer

# 加速套件下載的套件
RUN composer global require hirak/prestissimo && composer clear-cache

# 安裝所有程式依賴的套件,含測試套件
COPY composer.* ./
RUN composer install --no-scripts && composer clear-cache

# 複製程式碼
COPY . .

RUN composer run post-autoload-dump

# 執行測試
RUN php vendor/bin/phpunit

# 移除測試套件
RUN composer install --no-dev

最後是最難的,正如昨天最後的回顧所提到的,對框架要非常了解,才知道如何哪些檔案該複製,還有先後順序等。

FROM base

WORKDIR /var/www/html

COPY --from=composer_builder /source/vendor ./vendor
COPY --from=npm_builder /source/public/js ./public/js
COPY --from=npm_builder /source/public/css ./public/css
COPY --from=npm_builder /source/public/mix-manifest.json ./public

COPY . .

COPY --from=composer_builder /source/bootstrap ./bootstrap

CMD ["php", "artisan", "serve", "--host", "0.0.0.0"]

Build image 是以最後一個 stage 為主。結果又會再更小一些。

$ docker images laravel
REPOSITORY TAG IMAGE ID CREATED SIZE
laravel latest 02b76d8ded9b 12 minutes ago 99.5MB

今日自我回顧

使用 Multi-stage Build 不但可以減少容量,同時還能使用 Docker 創造多個環境執行建置階段的任務。

當建置階段與執行階段間,使用 artifacts 做區隔的時候,通常都適合使用 Multi-stage Build。如:Golang、Java 等,所有編譯語言,需要編譯並產生 artifacts 才能執行。

  • 了解 Multi-stage Build 的概念
  • 練習使用 Multi-stage Build 最佳化 Dockerfile