昨天我們練習了如何利用 Dockerfile 建立 image,今天來把一個 Next.js 專案打包成 image,並在容器裡跑起來吧!
同一個 image 可以啟動多個 container,每個 container 都有自己的可寫層。在其中一個 container 裡新增檔案,不會影響原本的 image,也不會影響其他 container。
如果用 docker commit 把這個 container 的檔案變更保存成新的 image,再用新的 image 啟動 container,就會帶著這些變更。
不過我們之後不會用 commit,都用 Dockerfile 做 image,因為它可重現、可版本化。
從分層的角度來看,image 的層是唯讀的,container 則會在上面加一層自己的可寫層。下圖的綠色代表 image 的唯讀層,藍色代表 container 的可寫層;commit 後,保存的變更成為新 image 的一部分。
開始之前,先用昨天的範例看看 Docker 如何重複利用之前的建置結果。
cd ~/ironman-lab/day08
docker history my-site:v2
docker history 可以查看 image 的建置歷史,其中也包含基底 image 原本的紀錄。
接著,在沒有修改 Dockerfile 和檔案的情況下,再 build 一次:
docker build -t my-site:v2 .
觀察輸出中的 COPY 步驟,應該會看到 CACHED,表示 Docker 直接沿用了之前的結果。
接著修改 site/index.html 的文字,我把'v2'改成'v3',存檔後再 build:
docker build -t my-site:v2 .

這次 COPY 的來源檔案改變了,因此這一步需要重新執行;前面沒有改變的部分仍可沿用 cache。
Image 是由多個唯讀層疊起來的。像 COPY、RUN 這類指令會產生檔案系統層,而 CMD 等指令則用來設定 image 的執行資訊。
建置時,Docker 會檢查每個步驟能否使用 cache。例如 COPY 會檢查要複製的檔案;如果輸入沒有改變,而且有可用的 cache,就不必重新執行。
當某一步的 cache 失效,後面依賴它的步驟也需要重新建置。因此,安排 Dockerfile 時,可以把不常變動的步驟放前面,經常變動的放後面。等一下安裝 Next.js 專案的依賴時,就會用到這個原則。
mkdir -p ~/ironman-lab/day09 && cd ~/ironman-lab/day09
npx create-next-app@latest my-app
先把所有問題設定(TypeScript, ESLint, Tailwind, App Router)都按 Enter(用預設值)
cd my-app
npm run dev
開 http://localhost:3000 看到預設頁,確認後 ^c 關掉
在 next.config.ts 原本的 nextConfig 物件中加入 output: 'standalone',其餘內容不要動:
const nextConfig: NextConfig = {
output: 'standalone',
};
先 build 一次,看看 standalone 做了什麼:
npm run build
ls -a .next/standalone
ls .next/standalone/node_modules | head
-a 可以看到隱藏的檔案或是資料夾
開啟 output: 'standalone' 後,Next.js 會在 build 時追蹤執行需要的檔案,整理到 .next/standalone 裡。
裡面可以看到 server.js、package.json、.next,以及只需要用到 node_modules 裡的檔案。這樣打包 image 時,就不必把開發環境整包 node_modules 都帶進去。
不過,standalone 預設不會複製 public 和 .next/static。稍後寫 Dockerfile 時,我們會把它們一起放進 image,讓圖片、CSS 和前端 JavaScript 能正常載入。
.dockerignoreGit 有 .gitignore,Docker 也有 .dockerignore,用來排除不需要送進建置環境的檔案。
在 my-app 根目錄下建立 .dockerignore:
node_modules
.next
.git
.env*
前端專案的 node_modules 通常很大,而且在 Mac 上安裝的套件可能包含無法直接在 Linux 使用的二進位檔。因此,我們會在 Docker 的建置環境裡重新安裝依賴,避免混入本機的版本。.next 是剛才本機 build 的產物,也先排除,稍後會在 Docker 裡重新 build。.git 是版本控制紀錄,這個範例執行時不需要;.env* 則用來避免把可能含有秘密的環境設定檔一起複製進去。
下一步寫 Dockerfile 時,我們會先安裝依賴並完成 build,再把 standalone 產物和靜態資源複製到最後的 image。
這份 Dockerfile 分成兩個階段:builder 負責安裝套件並 build;runner 則從 builder 複製執行需要的產物。COPY --from=builder 就是「從 builder 階段拿檔案過來」。最後啟動容器時,會執行 node server.js,把網站跑起來
在 my-app 根目錄建 Dockerfile:
# 第一階段:安裝套件、build 專案
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# 第二階段:拿 build 好的產物來執行
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["node", "server.js"]
先把專案打包成 image,命名為 my-app:v1:
docker build -t my-app:v1 .
完成後,查看剛才建立的 image 和大小:
docker image ls my-app

接著用這個 image 啟動容器:
docker run -d --name app -p 3000:3000 my-app:v1
這裡的 -d 表示在背景執行,--name app 是把容器命名為 app。-p 3000:3000 則把本機的 3000 port 對應到容器的 3000 port,左邊是本機,右邊是容器。
打開 http://localhost:3000,應該會看到剛才的 Next.js 頁面。這次網站是由容器裡的 production server 提供的!
如果前面的 npm run dev 還在執行,記得先按 ^C 關掉,避免占用同一個 port。
修改 app/page.tsx 裡的一段文字並存檔。如果專案使用 src 目錄,檔案會在 src/app/page.tsx。
接著重新 build,這次把 image 命名為 my-app:v2:
docker build -t my-app:v2 .

觀察這次的 build 輸出,RUN npm ci 那一步應該會顯示 CACHED,表示沿用了上一次安裝套件的結果。
因為我們只改了頁面,package.json 和 package-lock.json 都沒有變,所以不需要重新安裝套件;從 COPY . . 開始,才需要重新複製程式碼並執行 build。
這就是先複製依賴清單、安裝套件,再複製程式碼的好處:修改頁面時,可以省下重新安裝套件的時間。
此外,這次只建立了 my-app:v2 image,正在執行的 app 容器仍使用 my-app:v1,因此重新整理瀏覽器還不會看到修改。
可以停止並移除剛才的容器:
docker rm -f app
這只會移除 app 容器,剛才建立的 my-app:v1 和 my-app:v2 image 都會保留,明天還會用到。
今天我們把 Next.js 專案打包成 image,並成功在容器裡跑起來。透過 standalone 和多段式 Dockerfile,整理出執行網站需要的產物,也實際看到快取如何省下重複安裝套件的時間。
現在,我們已經知道怎麼把應用程式包起來,接下來就要準備一個讓它持續運作的地方。
明天開始進入 AWS,從 EC2 開始,準備一台用來執行容器的雲端主機,往真正的部署再前進一步!