需求開發場景
在説docker之前,我們先來看看一般的需求開發和部署場景,是否需要安裝node
需求開發部署場景
開發環境,我們使用windows或mac,開發前端項目,正常來説,都是要安裝好對應node版本 ,使用node提供的npm包管理(構建)工具【除非是一個簡單的只有hello world的html文件不用node】
生產環境,要發佈到服務器上
- 1. 靜態SPA單頁面應用部署
- 服務器上不需要安裝nodejs,使用使用nginx代理一下請求即可
- 就算是多個單頁面項目,我們在本地開發使用nvm管理一下node版本(比如有node12的老項目,也有node24的新項目)
- 打包的dist,丟到服務器上後,依舊不需要在在服務器上安裝node
- 2. SSR服務端渲染部署
- 除了靜態SPA以外,我們也可能也要去寫SSR應用
- SSR實際上就是通過nodejs運行環境,在服務器上執行js代碼
- 比如解析路由、發請求拿後端數據、拼接生成html返回給前端的瀏覽器請求
- 因此,SSR服務端渲染的生產環境的部署,服務器上,必須安裝nodejs(當然,也要使用到nginx代理請求)
- 如果,某個服務器上,只是部署一個SSR項目還好,我們只需要安裝對應的node版本
- 但是,如果有兩個甚至多個SSR項目,且對應的node版本不一致(如需要node12和node24版本)
- 那麼,我們就需要在服務器上,安裝nvm進行node版本的管理,不同的node版本前端項目,使用nvm切換到對應的node版本,然後,npm start跑起來項目【可使用pm2管理多進程】
- 3. BFF中間層部署
- BFF中間層,相當於一個服務層(中間層)
- 就是,把後端的 “通用接口” 轉化為前端的 “專屬接口”
- 比如,使用Express/Koa啓一個服務(依賴node)(當然,也要使用到nginx代理請求)
- 流程:用户 → 前端應用(PC/移動端/小程序) → BFF 中間層 → 後端服務 → 去數據庫撈數據
- 同樣的,這個情況,和上述一樣
- 安裝nvm進行node版本的管理,不同的BFF中間層,要切換對應node才能跑起來【可使用pm2管理多進程】
Web前端常見的三種部署方式
- 對於Vue或React的單頁面應用,打包的dist靜態資源,再搭配nginx
- 對於ssr(服務器渲染)或者bff(接口中間層),使用nvm管理node版本,同時使用pm2統一管理,再搭配nginx
- 使用docker鏡像技術,一次構建,到處運行(最靈活的方式),基本上適合所有的前端部署方式。(輔以nginx)
接下來,説説docker鏡像部署的好處
docker鏡像部署前端項目的好處
1. 徹底解決環境一致性,不用再使用nvm做node版本切換
初學者,可以把docker容器鏡像理解成:一個依賴宿主機的、封閉的、‘微型’服務器的內存運行環境空間嗎(非虛擬機那麼冗餘、且憑藉宿主機的操作系統內核可在此內存運行環境空間跑服務程序)
- 假設多個ssr或者bff且node版本依賴不同的項目,有幾個,就打包幾個鏡像
- 可以對應依賴的node版本等打包到鏡像裏面(當然nginx也可以選擇連帶着打包到鏡像裏面)
- 鏡像與鏡像之間是獨立的,雖然node依賴版本不一樣,但是ssr的服務不會和bff的服務產生衝突
- 不用再想以前那樣,還得額外注意node版本的切換管理
- 收益,比較明顯!!!
2. 實現 “一次構建,各個服務器環境上都能直接運行”
無論是Linux還是Windows服務器,都能運行打包好的鏡像(可移植性強)
- 假設,有一天,原來的生產服務器爆炸了、死機了、因不可抗力直接掛了。
- 老闆趕緊買了一台新的服務器,讓把原來生產的前端項目,移植到新的服務器上,越快越好。
- 傳統情況就是,在新的服務器上裝各個版本的node,安裝nvm,再打包使用pm2管理部署(耗時,約為一個小時)
- 但是,如果是使用docker,直接把原本的鏡像,複製粘貼到新服務器上即可(耗時約10分鐘)
- 收益,十分明顯!!!
3. docker版本管理與回滾更簡單方便安全
- 假設新項目上線後發現bug,需緊急切回上一版本
- 若是傳統項目部署方式,需要本地git回滾,再重新打包,再發布到服務器上(耗時5分鐘)
- 若是docker鏡像部署方式,直接使用其自帶的版本標籤tag管理
- 每個版本的項目對應一個鏡像標籤(如
v1.0.0、v1.0.1),標籤與代碼版本一一對應,可追溯回滾 - 回滾時,只需停止當前容器,用舊版本標籤的鏡像重新啓動容器(如
docker run my-app:v1.0.0) - 整個過程秒級完成,且不會影響當前文件(容器銷燬後文件自動清理),安全可靠(耗時1分鐘)
- 收益,十分明顯!!!
4. docker部署流程標準化、自動化
- 傳統前端部署流程通常是
- 本地打包 → 用winscp等工具傳文件到服務器 → 然後手動配置ngixn或者手動啓動node服務
- 步驟繁瑣且依賴人工操作,有一定概率手抖了,人工操作出問題(雖然概率不大,但也是一個隱患,假設概率千分之一)
- 如果使用docker搭配cicd持續集成工具可以將部署流程自動化
- 實現 代碼在gitlab提交後 → 點一點,就能夠自動構建鏡像 → 然後自動推送到鏡像倉庫 → 最後服務器自動拉取鏡像並重啓容器
- 全程無需人工干預,基本不會出問題(假設出問題概率百萬分之一)
- 節省人工操作時間、隱患概率大大降低
- 收益,十分明顯!!!
如果只是簡單的spa單頁面應用的部署,且暫時沒有cicd工具的公司項目,也可以自己搞一個效能工具,類似於cicd的自動化發佈腳本,參考筆者的這篇文章:https://segmentfault.com/a/1190000044616092
實際上,因為前端部署項目的依賴比較少,主要是可能依賴node環境,如果是後端部署項目,那依賴的就多了,使用docker技術的優勢,能夠進一步加大的明顯體現出來
記錄docker部署一個簡單的單頁面spa前端項目
提前啓動電腦的虛擬化、並下載安裝 Docker Desktop、並啓用 WSL 2
首先,windows電腦的虛擬化要開啓的,如下圖
按下Ctrl + Shift + Esc 打開任務管理器、然後選擇 性能 標籤
然後,打開 powershell 執行 wsl --list --online,查看可安裝的linux發行版,初始條件下,肯定是沒安裝的,然後執行 wsl --install 自動安裝默認linux發行版(筆者用的是Ubuntu)
再然後,就是安裝完成後重啓電腦,使 WSL2 生效
最後,訪問 Docker 官網 下載適用於 Windows或者MAC 的安裝包,這樣docker的前置準備工作,就做好了
本文不做安裝的贅述,可以另行查閲文章,實踐安裝(若是網絡不行,就使用小梯子吧)
單頁面應用的docker打包
- 假設,筆者有一個vue或者react項目
- 這個項目最終,打包成了一個html文件,如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>每天學點新知識——docker</h1>
</body>
</html>
編寫Dockerfile文件
- 想要使用docker打包鏡像,就得告訴docker,這個鏡像要打包那些東西
- 本案例中,是打包一個單純的html文件
- 同時,還要告訴docker,有哪些依賴也需要連帶着打包進去
- 本案例中,打包單純的html文件不太夠用
- 還需要搭配nginx(把nginx也打包進鏡像中去)
- 沒辦法,單純的靜態文件,沒法自己提供網頁服務,必須搭配一個 “服務器軟件”
- 這樣打包出來的鏡像,就像一個 “自帶服務器的小盒子”,不管放到哪台裝了 Docker 的服務器上的機器上,都能直接跑起來
本案例的Dockerfile文件的編寫很簡單,就四行
FROM nginx:alpineCOPY index.html /usr/share/nginx/html/EXPOSE 80CMD ["nginx", "-g", "daemon off;"]
註釋,釋義如下
# 默認從 Docker Hub上下載基於Alpine Linux的輕量級版本的nginx,當執行docker打包鏡像命令後,流程是:
# 自己windows電腦的命令行會觸發Docker Desktop依據 WSL2 Linux內核從而下載nginx:alpine到WSL2文件系統
# 自己的nginx:alpine會下載到C:\Users\lss13\AppData\Local\Docker\wsl\disk文件夾中
# 有一個docker_data.vhdx硬盤映像文件
# 文件很大,類似壓縮包,包含很多東西,其中就有下載的nginx:alpine鏡像,也有構建出的新鏡像和以往構建的老鏡像
FROM nginx:alpine
# 將當前目錄下的HTML文件複製到鏡像中的/usr/share/nginx/html/目錄
# 鏡像最終存儲在docker_data.vhdx虛擬磁盤中
# /usr/share/nginx/html/這個文件夾路徑,是nginx用來默認存放靜態資源的路徑(規定,不用去修改)
# 至此,鏡像文件中,已經包含了nginx的一堆東西和html,當然還有別的docker的一堆東西
COPY index.html /usr/share/nginx/html/
# EXPOSE不會實際開放端口,單純的語法,不寫也行(NGINX默認就是80端口)
EXPOSE 80
# 啓動nginx -g是全局配置命令 daemon off關閉後台運行模式
# (能夠確保 nginx 前台運行,避免容器啓動後立即退出)
# 這個cmd指令,會被存放在鏡像文件中,當鏡像被丟到服務器上後
# 當在服務器上執行docker run這個鏡像的時候,才會進一步觸發鏡像裏面的這個cmd命令執行
# 才會讓鏡像中的nginx啓動起來,有這樣的web服務,才能訪問到鏡像裏面的html文件
CMD ["nginx", "-g", "daemon off;"]
編寫打包構建鏡像的js腳本
構建鏡像,就一個核心的命令:docker build -t ${IMAGE_NAME} .
docker build:Docker 的構建命令,告訴 Docker “我要根據 Dockerfile文件裏面的內容去構建鏡像了”。-t ${IMAGE_NAME}:給鏡像 “貼標籤”(指定名稱),比如-t my-nginx就會把鏡像命名為my-nginx(${IMAGE_NAME}通常是一個變量,實際使用時會替換成具體名稱,比如my-webapp:v1)。.:指定 Dockerfile 所在的路徑(.表示 “當前目錄”),Docker 會從這個目錄找Dockerfile文件,並讀取裏面的構建步驟。
當然,現在的我的 html 和 Dockerfile 和 要準備編寫的 打包構建鏡像的js腳本 在同一個文件夾裏面(同級目錄)
主要的核心,就是使用node的child\_process的execSync,在命令行執行docker命令:
const { execSync } = require('child_process'); // 引入同步執行命令模塊
const IMAGE_NAME = 'my-html-app'; // 給鏡像起個名字
execSync(`docker build -t ${IMAGE_NAME} .`, { stdio: 'inherit' }); // 派發命令執行打包構建鏡像
完整export-image.js腳本如下:
const { execSync } = require('child_process'); // 引入同步執行命令模塊
const fs = require('fs'); // 引入文件系統模塊
console.log('📦 開始構建和導出Docker鏡像...');
// 配置變量
const IMAGE_NAME = 'my-html-app';
const TAR_FILE = `${IMAGE_NAME}.tar`;
try {
// 檢查Dockerfile是否存在
if (!fs.existsSync('./Dockerfile')) {
console.error('❌ 找不到Dockerfile文件');
process.exit(1);
}
// 檢查index.html是否存在
if (!fs.existsSync('./index.html')) {
console.error('❌ 找不到index.html文件');
process.exit(1);
}
console.log('🔨 正在構建Docker鏡像...');
// 構建Docker鏡像
execSync(`docker build -t ${IMAGE_NAME} .`, { stdio: 'inherit' });
console.log('\n✅ 鏡像構建成功,開始導出鏡像...');
// 刪除舊的tar文件(如果存在)
if (fs.existsSync(TAR_FILE)) {
fs.unlinkSync(TAR_FILE);
console.log('🗑️ 已刪除舊的鏡像文件');
}
// 導出鏡像
execSync(`docker save -o ${TAR_FILE} ${IMAGE_NAME}:latest`, { stdio: 'inherit' });
// 檢查文件是否成功創建
if (fs.existsSync(TAR_FILE)) {
const stats = fs.statSync(TAR_FILE);
const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
console.log('\n✅ 鏡像導出成功!');
console.log(`📁 導出文件: ${TAR_FILE} 文件大小: ${fileSizeMB} MB`);
console.log('\n📋 接下來:');
console.log('1. 複製 my-html-app.tar 和 deploy-to-server.sh 到Ubuntu');
console.log('2. 在Ubuntu上運行: ./deploy-to-server.sh');
} else {
console.error('❌ 鏡像導出失敗');
process.exit(1);
}
} catch (error) {
console.error('\n❌ 操作失敗:');
console.error(error.message);
process.exit(1);
}
- 我們可以在命令行中,執行這個腳本,比如:
node export-image.js - 也可以,寫一個bat腳本,這樣也行,直接
./build.bat回車
build.bat
@echo off
echo Let's start building Docker images...
node export-image.js
執行構建腳本
構建出來的鏡像產物
編寫服務器發佈鏡像的shell腳本
邏輯很簡單,就是把當前服務器上的,原來的容器鏡像刪除掉(如果有的話),然後,在執行docker run -d -p $PORT:80 --name $CONTAINER_NAME $IMAGE_NAME:latest 命令
上述命令釋義:
- docker run(Docker 啓動容器的核心命令)
- -d (後台運行模式detach 的縮寫)
-
-p $PORT:80
- -p是端口映射publish的縮寫
- $PORT 是筆者自己的服務器(宿主機)端口,我這裏用20000端口,外部通過這個端口訪問容器;
- 80 是容器內部的端口(Nginx 默認監聽 80 端口)
- 意思是:把我服務器(宿主機)上的20000端口,和容器的80端口連起來,外部訪問我服務器上的20000端口,就會被轉到這個docker容器鏡像的80端口,也就會訪問到鏡像裏面的nginx,也就能夠訪問到鏡像裏面的對應目錄
/usr/share/nginx/html/中的index.html文件(就能看到對應內容了)
當然了 服務器不隨便開放端口,這個20000端口,我不會開放,我只會使用我服務器上nginx,進行請求的轉發到20000端口,如下 nginx 配置
# docker的demo
location /dockerDemo/ {
proxy_pass http://localhost:20000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
部署腳本如下:
#!/bin/bash
echo "🚀 開始部署Docker鏡像到服務器..."
# 配置變量(鏡像名稱在構建時指定,容器名稱在運行時指定)
CONTAINER_NAME="html-app-container"
IMAGE_NAME="my-html-app"
TAR_FILE="${IMAGE_NAME}.tar"
PORT="20000"
# 檢查tar文件是否存在
if [ ! -f "$TAR_FILE" ]; then
echo "❌ 找不到鏡像文件: $TAR_FILE"
echo "請確保已將鏡像文件複製到當前目錄"
exit 1
fi
echo "📁 找到鏡像文件: $TAR_FILE"
# 停止並刪除現有容器(如果存在)
echo "🛑 停止並刪除現有容器..."
docker stop $CONTAINER_NAME 2>/dev/null || true
docker rm $CONTAINER_NAME 2>/dev/null || true
# 刪除現有鏡像(如果存在)
echo "🗑️ 刪除現有鏡像..."
docker rmi $IMAGE_NAME:latest 2>/dev/null || true
# 導入鏡像
echo "📥 導入Docker鏡像..."
docker load -i $TAR_FILE
# 運行容器
echo "🚀 啓動新容器..."
docker run -d -p $PORT:80 --name $CONTAINER_NAME $IMAGE_NAME:latest
# 檢查容器狀態
if [ $? -eq 0 ]; then
echo ""
echo "✅ 部署成功!"
echo "📊 容器狀態:"
docker ps | grep $CONTAINER_NAME
echo ""
echo "🌐 訪問地址:"
echo " 直接訪問: http://localhost:$PORT"
echo " 通過nginx代理: https://ashuai.site/dockerDemo/"
echo ""
echo "📋 有用的命令:"
echo " 查看日誌: docker logs $CONTAINER_NAME"
echo " 停止容器: docker stop $CONTAINER_NAME"
echo " 重啓容器: docker restart $CONTAINER_NAME"
else
echo "❌ 容器啓動失敗"
exit 1
fi
最後一步,在服務器上,部署構建好的鏡像
把鏡像文件和構建腳本都丟到服務器上,在對應文件夾中,執行部署腳本,如下圖:
- 這樣,我們的構建好了的,鏡像,就成功部署了
- 由於筆者是通過nginx代理的
- 所以,訪問:https://ashuai.site/dockerDemo/
- 就可以 看到對應的內容了
docker-compose的應用場景——統一編排管理多個鏡像服務
- 比如我現在有一個項目,其中包括了,前端、後端、數據庫、緩存層
- 對應的我要在本地電腦上,打包構建四個鏡像,分別是nginx.tar、mysql.tar、java-app.tar、redis.tar
- 然後把這四個鏡像上傳到服務器上(通過dockerhub也行)
- 針對以這樣的部署應用場景 如果不使用docker-compose,就得手動執行如下的幾次docker腳本命令,如下:
# 1. 創建自定義網絡,讓容器可以互相通信
docker network create app-network
# 2. 啓動 MySQL
docker run -d \
--name mysql-container \
--network app-network \
-p 3306:3306 \
-e MYSQL_ROOT_PASSWORD=123456 \
-v mysql-data:/var/lib/mysql \
mysql:latest
# 3. 啓動 Redis
docker run -d \
--name redis-container \
--network app-network \
-p 6379:6379 \
redis:alpine
# 4. 啓動 Java 應用(依賴 MySQL 和 Redis)
docker run -d \
--name java-app-container \
--network app-network \
-p 8080:8080 \
-e SPRING_DATASOURCE_URL=jdbc:mysql://mysql-container:3306/mydb \
java-app:latest
# 5. 啓動 Nginx(前端)
docker run -d \
--name nginx-container \
--network app-network \
-p 80:80 \
nginx:latest
當然 ,也可以把上述的五次命令,都放在一個shell腳本里面,但是不優雅
使用docker-compose.yml優雅地管理同一個項目中的多個鏡像
- 首先,
docker-compose.yml默認就會讓所有服務(比如我的四個鏡像)處於同一個網絡中,實現自動互通,自帶此功能 - 其次,有了
docker-compose.yml以後,就可以通過docker-compose up -d一鍵啓動所有服務,也可以通過docker-compose down一鍵關閉所有服務 - 然後,將整個應用棧(前端、後端、數據庫、緩存)的配置都被記錄在一個
.yaml文件中。這樣方便,做版本控制,追蹤每一次變更;保持環境一致性、易於團隊分享和協作 - 可以在
docker-compose.yml文件中,設置鏡像服務啓動順序(比如,要先啓動mysql和redis,然後再啓動java-app,最後再啓動nginx——有服務依賴先後順序) - 另外,日誌,也能夠統一管理,等等等等的好處
docker-compose的功能強大,管理優雅,贊👍👍👍
簡單如下示例,實際項目,會稍微完整完善不少
# 定義 Compose 文件版本
version: '3.8'
services:
# 後端 Java 應用服務
java-app:
image: java-app:latest # 使用本地構建的 Java 應用鏡像
container_name: my-java-app # 指定容器名稱
environment:
# 配置數據庫連接地址(通過服務名訪問)
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/my_database
# 配置 Redis 連接地址
- SPRING_REDIS_HOST=redis
depends_on:
- mysql # 依賴 MySQL 服務先啓動
- redis # 依賴 Redis 服務先啓動
networks:
- app-network # 加入應用內部網絡
# 前端 Nginx 服務
nginx:
image: nginx:latest # 使用 Nginx 鏡像
container_name: my-nginx # 指定容器名稱
ports:
- "80:80" # 將主機80端口映射到容器80端口
volumes:
# 掛載自定義 Nginx 配置文件
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- java-app # 依賴 Java 應用先啓動
networks:
- app-network # 加入應用內部網絡
# MySQL 數據庫服務
mysql:
image: mysql:8.0 # 使用 MySQL 8.0 鏡像
container_name: my-mysql # 指定容器名稱
environment:
# 設置 MySQL root 用户密碼
MYSQL_ROOT_PASSWORD: rootpassword
volumes:
# 持久化數據庫數據到主機
- mysql_data:/var/lib/mysql
networks:
- app-network # 加入應用內部網絡
# Redis 緩存服務
redis:
image: redis:7-alpine # 使用 Redis Alpine 版本(輕量)
container_name: my-redis # 指定容器名稱
networks:
- app-network # 加入應用內部網絡
# 定義自定義網絡,使服務間可通過服務名通信
networks:
app-network:
driver: bridge # 使用橋接網絡驅動
# 定義數據卷,用於持久化數據
volumes:
mysql_data: # MySQL 數據持久化卷
此外,Docker Compose還可以做服務健康檢查和服務等待機制,能通過各種策略,能夠讓部署更可靠,具體看文檔:https://docs.docker.com/compose/
其他:上述的depends\_on僅確保容器按順序啓動,但不確保服務就緒。需要配合應用的重試機制或Docker Compose的healthcheck和condition選項、Compose v2中支持
總結
docker鏡像打包,好像看起來麻煩一點點,還得寫Dockerfile,還得寫構建腳本,和部署腳本啥的,但是它解決了環境版本一致性問題,bff和ssr不同node版本,畢竟管理起來,還是有些麻煩的,還有服務器更換,要是重新安裝各種版本,那的確耗時。(打包前端項目,有點明顯、打包後端項目十分明顯)
如果打包bff,我們可以編寫如下的Dockerfile
# 基礎鏡像:用輕量的 Node.js 16 版本(alpine 版本體積小)
FROM node:16-alpine
# 創建工作目錄(類似在服務器上建一個專門的文件夾放代碼)
WORKDIR /app
# 複製 package.json 和 package-lock.json(先複製依賴文件,利用 Docker 緩存加速構建)
COPY package*.json ./
# 安裝依賴(npm install 會根據 package.json 下載所需的庫)
RUN npm install --production # --production 只裝生產環境依賴,減小體積
# 複製 BFF 源代碼(比如 server.js、路由文件等)
COPY . .
# 暴露 BFF 服務的端口(假設我的BFF的服務監聽3000端口)
EXPOSE 3000
# 啓動命令:運行 BFF 服務
CMD ["node", "server.js"]
所以,docker的優勢有:
- 環境版本依賴一致性、隔離性與安全性
- 簡化團隊配置協作
- 可快速部署與擴展
- 而且,有了docker以後,CI/CD就更加好操作了(更好實現,標準化和自動化)
- 首次一勞————而後永逸
A good memory is better than a bad pen. Record it down...