以 Docker Compose 建立 Node.js 全端開發環境(五) — Image 打包

Toki Lee
11 min readSep 3, 2022

--

在上個章節我們完成了前後端的功能與內容,在這章節我們將會把這些內容進行編譯,並將成果打包成對應的 Image。

在閱讀本篇文章前,有些 Dockerfile 相關的知識會比較容易閱讀,而關於縮小 Image 體積的經驗談上,這邊筆者有閱讀到有篇很受用的文章,做了很詳細的介紹,很建議讀者可以在閱讀本篇文章前參閱下:

這邊先附上上個章節與這個章節目標的樣子:

上個章節結尾

這個章節目標

後端 Build

雖然以 ts-node 我們依然可以將服務跑起,但正式環境上我們並不需要 TypeScript ,並且也不需要關於 TypeScript 的任何套件,所以我們最終還是要將 TypeScript 編譯成 JavaScript 並以 Node 執行。

那麼我們在後端的 package.json 加入以下的指令:

apps/backend/package.json

因為目前後端的 tsconfig.json 中已經設置過 outDir 的位置了,所以執行 tsc 將會把編譯好的內容放在 dist 的目錄下,而 start 的指令則是到時候要在正式環境容器中執行的指令。

如果我們想要測試 start 的指令,我們可以像是第一章建構環境時一樣的方法調整 command 為tail -f 進到容器內確認結果正不正確:

確認編譯與執行都順利

前端 Build

前端的部分則會麻煩點,有多一些內容,因為這邊我們要將前端的部分編譯、打包成靜態頁面,但在沒有 server 或是使用 SSR 的技術上,我們會有些環境變數在編譯後被替換掉,而因此在打包後的 Image 將無法使用來自 Docker Compose 的環境變數進行控制。

我們還是來看一下這是什麼狀況,因為原本的指令就包含 build 了,我們直接進到前端的容器執行 build:

在我這邊編譯出來的檔案為 main.fbd3a122.js 這隻檔案,我們可以觀察編出來的結果,雖然一般來說這個檔案已經經過混淆,基本上不是要讓人閱讀的:

混淆過後的輸出結果

不過我們的目標是要確認剛剛所說的環境變數,所以我們使用 Crtl + FCmd + F 直接搜尋 PUBLIC_BACKEND_URL 這個環境變數我們所設定的值,這邊我是設定 http://127.0.0.1:8081

可以找到相同內容的字串

我們一眼就能發現這邊就是 axios 的部分,其實往後看我們也能看到熟悉的 listTodos, createTodo 這些 methods。

在這邊我們確認了一件事情,那就是一旦我們將這個編譯後的產物包進 Docker Image 後,我們將失去任何的方式能夠再次指定 Backend URL 的機會,我個人希望可以在這個範例保持一定的彈性能再次指定 URL,所以我們要找看看有沒其他的方式能夠解決這個問題。

在思索後,我這邊決定將這個 Backend Url 放到一個靜態的檔案,而在之後讓 React App 以打 Api 的方式拿到 URL 後注入到 axios 中,這樣的話就能讓這個環境變數能夠在打包後客製化。

那我們就開始對環境變數相關的部分做些調整,首先是 api

apps/frontend/src/api.ts

我加入了個 refreshBackendURL ,讓他可以在正式環境的時候打一個靜態的檔案獲取 Backend Url 並注入到 axios 中。

再來調整 App.tsx 的內容:

apps/frontend/src/App.tsx

這邊則是在一開始先進行 refreshBackendURL 這樣之後打 api 就會使用到設定好的後端 URL 了。

概念上來說,我將會在之後 Image 打包的過程中產生一個 backend-url 的 txt 檔案,而 REACT_APP_BUILD_HASH 的部分是為了防止 Cache 導致頁面沒有更新所設立的一串 Hash 字串。

這樣的話我們 production 的環境時,會使用來自另個靜態檔案所描述的 URL 路徑並注入給 api ,這樣就能夠動態的調整一些需要的環境變數。

記得這個方式只限可以公開的變數環境。

最後的問題是如何產生 backend-url.[hash].txt 這個檔案,不過這個部分我要留在等等打包 Image 的流程中完成,這邊就再稍等一下。

另外提一下,筆者在這個部分第一個版本是把環境變數塞在 window 下,但在思索後,先換成目前的版本,我認為如果是嚴謹的項目,URL 應該是可以確認好並放在 CI/CD 的變數中,直接固定包到 Image 中,這邊僅是為了保留彈性的情況下所附加的功能,如果這個方案不妥或有更正確的方式,可以告訴我。

打包 Image

首先我們先把開發用的容器先關掉,為了等等不要影響到接下來的工作,然後我們要產生另個 Docker Compose 的配置檔來協助我們打包 Image,這邊命名為 docker-compose.local.yml

docker-compose.local.yml

這份新的配置與原本開發用的配置差不多,不過有把一些名稱做些修改,像是前綴 dev 改成 prod、或是一些不需要的部分也移除掉,還有我們會把 volume 源碼的部分拿掉,而改為 build 的配置,因為這些內容將進行編譯後打包到新的 Image 中,接下來我們準備在前後端加入個別的 Dockerfile。

後端 Image 打包

apps/backend/docker/Dockerfile

在這個 Image 建構的過程,我用到了兩個階段:

  1. Builder 階段:一般來說第一個階段可以使用相對完整的環境來建構,也比較不容易出錯,所以我這邊就使用了 node:16 的版本作為 builder,而之後如果你很清楚自己的環境需求,可以自行去建構合適的 Builder 的環境來優化整體打包流程。
  2. 目標階段:最後的階段我在這邊採用 node:16-buster-slim 作為來源的 Image 。

在縮小 Image 體積上我們一般可以採用 Slim 與 Alpine 這兩種發布的版本,在 node 這邊 Image 預設上是基於 Debian 的發行版,而 Slim 則是前述預設版本在運行時所需最小體積的 Image。

而相對於 Slim ,Alpine 的體積最小,很適合用來極度壓縮 Image 這樣的需求,然而在使用上常常會有相容性的問題,有時候會因為一些套件的需求,使得處理 Image 時有更多細節與編譯相關的問題才能使用,尤其像是在 Python 之類的環境筆者也有遇到不少問題的經驗,建議讀者如果是使用 Python 的環境可以 Survey 下以免踩雷。

而這次在使用 Prisma 上我也有遇到一些相關的問題,這也是我在後端這邊選擇 slim 的原因,在這附上問題的連結,可以參考下:
M1: Provide precompiled binaries for aarch64-unknown-linux-musl target

另外也別忘了要加入 .dockerignore,讓本機的一些檔案不要被 Copy 到 Image 中:

apps/backend/.dockerignore

像是 dist 與 node_modules 這些都是我們將讓他在打包流程建立的,而不是該從本機 Copy 進去的目錄。

前端 Image 打包

apps/frontend/docker/Dockerfile

如同後端的部分一樣,我們採用兩個階段:

  1. Build 階段:使用 node:16 編譯打包前端的 React 專案。
  2. 目標階段:使用 nginx:stable-alpine 作為來源 Image,將靜態頁面放入其中。在前端的部分,我就採用了 alpine 作為來源,這是因為我們在前端只有靜態頁面的需求,而沒有複雜的套件依賴,那麼我希望體積是越小越好。

到了這邊,我們回想下剛剛在處理前端的編譯時,我們留下了一個問題,就是環境變數的部分。

在這邊我的想法將會是使用一隻 Shell Script 在容器啟動時會去產生 backend-url.[hash].txt 這個檔案,而因為 REACT_APP_BUILD_HASH 會在 Docker 打包時期產生並寫入到檔案中,這邊就能夠在 Shell Scripts 中使用到它,腳本內容如下:

apps/frontend/docker/generate-backend-url.sh

由於上面 Dockerfile 39 行的部分,這隻腳本將會在容器啟動的時執行,這樣就會產生一支 backend-url.[hash].txt 的檔案讓我們可以藉由打 api 的方式拿到後端的 URL。

最後補下 .dockerignore 與 nginx 的 config:

apps/frontend/.dockerignore
apps/frontend/docker/nginx.conf

測試打包

終於到達最後一個流程,在前面我們完成了所有打包編譯所需要的內容,我們將在這個環節做好打包的動作。

一般來說,我們通常是使用 docker build 這樣的指令來進行打包,但是這邊我將採用 docker compose build來作為打包的指令,這也是為何要再寫一份配置檔的原因。

在執行指令前,我們再複製一份 .local.env 並且將這個檔案加入 .gitignore 之中,這樣可以讓我們區分兩個不同環境:

.gitignore

接下來我們就來執行 docker compose -f docker-compose.local.yml --env-file ".local.env" build ,其中:

  1. 參數 -f :讓 docker compose 可以選定特定的配置
  2. 參數 --env-file :選定特定的環境變數配置
  3. build 指令:個別 build 配置檔中設定 build 的部分

執行後的畫面如下:

兩個 Image 打包成功

在 Image 打包完成後我們再一次執行 docker compose -f docker-compose.local.yml --env-file ".local.env" up -d 將容器啟動,如果一切順利,你將會得到跟開發時相同的畫面。

Build Scripts

最後我們可以把這個 Build 的指令寫一份到 scripts 目錄下:

這樣子就能直接使用 ./scripts/build-local-docker就不用打那麼長的指令了。

總結

在這個章節我們從開發環境走到了正式環境的 Image 打包,而本次的內容中也只是提供了一個我個人的做法,可能有不適恰的地方,也歡迎任何的建議,如果我有想到任何更恰當的方式,可能會在文章繼續補充或另外在寫文章記錄下。

--

--

Toki Lee

沒有技術上不可行,只是時間上做不到⋯