在上個章節我們完成了前後端的功能與內容,在這章節我們將會把這些內容進行編譯,並將成果打包成對應的 Image。
在閱讀本篇文章前,有些 Dockerfile 相關的知識會比較容易閱讀,而關於縮小 Image 體積的經驗談上,這邊筆者有閱讀到有篇很受用的文章,做了很詳細的介紹,很建議讀者可以在閱讀本篇文章前參閱下:
這邊先附上上個章節與這個章節目標的樣子:
上個章節結尾
這個章節目標
後端 Build
雖然以 ts-node 我們依然可以將服務跑起,但正式環境上我們並不需要 TypeScript ,並且也不需要關於 TypeScript 的任何套件,所以我們最終還是要將 TypeScript 編譯成 JavaScript 並以 Node 執行。
那麼我們在後端的 package.json 加入以下的指令:
因為目前後端的 tsconfig.json 中已經設置過 outDir 的位置了,所以執行 tsc 將會把編譯好的內容放在 dist 的目錄下,而 start 的指令則是到時候要在正式環境容器中執行的指令。
如果我們想要測試 start 的指令,我們可以像是第一章建構環境時一樣的方法調整 command 為tail -f
進到容器內確認結果正不正確:
前端 Build
前端的部分則會麻煩點,有多一些內容,因為這邊我們要將前端的部分編譯、打包成靜態頁面,但在沒有 server 或是使用 SSR 的技術上,我們會有些環境變數在編譯後被替換掉,而因此在打包後的 Image 將無法使用來自 Docker Compose 的環境變數進行控制。
我們還是來看一下這是什麼狀況,因為原本的指令就包含 build 了,我們直接進到前端的容器執行 build:
在我這邊編譯出來的檔案為 main.fbd3a122.js 這隻檔案,我們可以觀察編出來的結果,雖然一般來說這個檔案已經經過混淆,基本上不是要讓人閱讀的:
不過我們的目標是要確認剛剛所說的環境變數,所以我們使用 Crtl + F
或 Cmd + 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
:
我加入了個 refreshBackendURL ,讓他可以在正式環境的時候打一個靜態的檔案獲取 Backend Url 並注入到 axios 中。
再來調整 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
:
這份新的配置與原本開發用的配置差不多,不過有把一些名稱做些修改,像是前綴 dev 改成 prod、或是一些不需要的部分也移除掉,還有我們會把 volume 源碼的部分拿掉,而改為 build 的配置,因為這些內容將進行編譯後打包到新的 Image 中,接下來我們準備在前後端加入個別的 Dockerfile。
後端 Image 打包
在這個 Image 建構的過程,我用到了兩個階段:
- Builder 階段:一般來說第一個階段可以使用相對完整的環境來建構,也比較不容易出錯,所以我這邊就使用了
node:16
的版本作為 builder,而之後如果你很清楚自己的環境需求,可以自行去建構合適的 Builder 的環境來優化整體打包流程。 - 目標階段:最後的階段我在這邊採用
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 中:
像是 dist 與 node_modules 這些都是我們將讓他在打包流程建立的,而不是該從本機 Copy 進去的目錄。
前端 Image 打包
如同後端的部分一樣,我們採用兩個階段:
- Build 階段:使用 node:16 編譯打包前端的 React 專案。
- 目標階段:使用 nginx:stable-alpine 作為來源 Image,將靜態頁面放入其中。在前端的部分,我就採用了 alpine 作為來源,這是因為我們在前端只有靜態頁面的需求,而沒有複雜的套件依賴,那麼我希望體積是越小越好。
到了這邊,我們回想下剛剛在處理前端的編譯時,我們留下了一個問題,就是環境變數的部分。
在這邊我的想法將會是使用一隻 Shell Script 在容器啟動時會去產生 backend-url.[hash].txt 這個檔案,而因為 REACT_APP_BUILD_HASH 會在 Docker 打包時期產生並寫入到檔案中,這邊就能夠在 Shell Scripts 中使用到它,腳本內容如下:
由於上面 Dockerfile 39 行的部分,這隻腳本將會在容器啟動的時執行,這樣就會產生一支 backend-url.[hash].txt 的檔案讓我們可以藉由打 api 的方式拿到後端的 URL。
最後補下 .dockerignore 與 nginx 的 config:
測試打包
終於到達最後一個流程,在前面我們完成了所有打包編譯所需要的內容,我們將在這個環節做好打包的動作。
一般來說,我們通常是使用 docker build
這樣的指令來進行打包,但是這邊我將採用 docker compose build
來作為打包的指令,這也是為何要再寫一份配置檔的原因。
在執行指令前,我們再複製一份 .local.env 並且將這個檔案加入 .gitignore 之中,這樣可以讓我們區分兩個不同環境:
接下來我們就來執行 docker compose -f docker-compose.local.yml --env-file ".local.env" build
,其中:
- 參數
-f
:讓 docker compose 可以選定特定的配置 - 參數
--env-file
:選定特定的環境變數配置 - build 指令:個別 build 配置檔中設定 build 的部分
執行後的畫面如下:
在 Image 打包完成後我們再一次執行 docker compose -f docker-compose.local.yml --env-file ".local.env" up -d
將容器啟動,如果一切順利,你將會得到跟開發時相同的畫面。
Build Scripts
最後我們可以把這個 Build 的指令寫一份到 scripts 目錄下:
這樣子就能直接使用 ./scripts/build-local-docker
就不用打那麼長的指令了。
總結
在這個章節我們從開發環境走到了正式環境的 Image 打包,而本次的內容中也只是提供了一個我個人的做法,可能有不適恰的地方,也歡迎任何的建議,如果我有想到任何更恰當的方式,可能會在文章繼續補充或另外在寫文章記錄下。