Docker+Nginx+Node.js 全栈容器化部署
通过 Docker Compose 统一编排 Nginx、Node.js 服务,实现前后端分离部署。前端使用 Unity WebGL 创建交互式界面,后端基于 Node.js 提供 REST API 通信。重点讲解网络配置、反向代理策略、端口映射、跨域处理等实战中遇到的问题及解决方案,为应用部署提供完整参考。
当部署到部署到服务器的时候不知道服务的ip地址,可以使用nginx代理服务,然后使用请求的时候使用路径请求,然后ngixn返回服务器信息。
可以在nginx的docker-compose的环境配置中写好ip和端口这样就可以不修改conf文件了。
docker配置
项目路径
docker_net_work_test/ # 项目根目录 ├── nginx/ # Nginx 服务目录 │ ├── html/ # WebGL 前端文件 │ │ ├── Build/ # Unity WebGL 构建输出 │ │ ├── StreamingAssets/ # Unity 流媒体资源 │ │ └── index.html # 入口 HTML 文件 │ ├── Dockerfile # Nginx 镜像构建文件 │ └── nginx.conf # Nginx 配置文件 ├── node/ # Node.js 后端服务目录 │ ├── node_modules/ # Node.js 依赖包 │ ├── .env # 环境变量配置 │ ├── app.js # Node.js 主应用文件 │ ├── Dockerfile # Node.js 镜像构建文件 │ ├── package.json # Node.js 项目配置 │ └── package-lock.json # 依赖锁文件 ├── docker-compose.nginx.yml # Nginx 服务编排配置 └── docker-compose.node.yml # Node.js 服务编排配置项目运行
先运行node服务
docker-compose -f docker-compose.node.yml up -d
运行nginx服务
docker-compose -f docker-compose.nginx.yml up -d
前端页面:
http://主机IP:58231
nodejs服务
http://主机IP:13000/
nodejs服务配置
docker-compose.node.yml
services:node:build:./node# 基于node目录下的 Dockerfile 构建镜像container_name:node-app# 容器运行时的名字ports:-"13000:13000"# 宿主机:容器 端口映射networks:-backend# 定义属于的网络名称, backend 网络networks:backend:name:backend-net# 网络名称app.js
/******************************************************************** * - HTTP 13000 (/ /api/health) * 依赖:npm i morgan cors dotenv *******************************************************************/require('dotenv').config();consthttp=require('http');constexpress=require('express');constcors=require('cors');constmorgan=require('morgan');/* ------------------ 配置 ------------------ */constHTTP_HOST=process.env.HTTP_HOST||'0.0.0.0';constHTTP_PORT=process.env.HTTP_PORT||13000;constNODE_ENV=process.env.NODE_ENV||'development';/* ------------------ Express ------------------ */constapp=express();// CORS 配置if(['localhost','development','dev'].includes(NODE_ENV)){app.use(cors());console.log('(本地调试)/(开发),使用跨域');}elseif(['production','pro'].includes(NODE_ENV)){console.log('生产环境不使用跨域');}else{app.use(cors());console.log('未知环境使用跨域');}// 日志中间件morgan.token('date',()=>newDate().toLocaleString('zh-CN'));app.use(morgan(':method :url code:status contentLength:res[content-length] - :response-time ms :date'));app.use(express.json());/* ------------------ 路由 ------------------ */app.get('/api/config',(req,res)=>{console.log('headers:',req.headers);// 1. 优先用代理头letport=req.get('X-Real-Port')||req.get('X-Forwarded-Port');// 2. 代理头都没有 → 从 Referer 提取if(!port){constreferer=req.get('Referer');constm=referer&&referer.match(/:(\d+)\//);port=m?m[1]:(req.secure?443:80);}constproto=req.get('X-Forwarded-Proto')||req.protocol;consthost=req.get('X-Forwarded-Host')||req.get('Host');consthostWithPort=host.includes(':')?host:`${host}:${port}`;constbaseUrl=`${proto}://${hostWithPort}`;res.json({success:true,baseUrl,host:hostWithPort,proto,environment:NODE_ENV});});// 请求方式测试使用apiapp.get('/api/health',(req,res)=>res.json({success:true,message:'服务健康',environment:NODE_ENV,timestamp:newDate().toISOString()}));app.get('/',(req,res)=>res.json({success:true,message:'HTTP 服务正常',environment:NODE_ENV,paths:{http:'/',config:'/api/config',health:'/api/health',post:'/post',put:'/put',delete:'/delete'}}));app.post('/post',(req,res)=>res.json({success:true,method:'POST',message:'POST 通道正常',timestamp:newDate().toISOString(),body:req.body}));app.put('/put',(req,res)=>res.json({success:true,method:'PUT',message:'PUT 通道正常',timestamp:newDate().toISOString(),body:req.body}));app.delete('/delete',(req,res)=>res.json({success:true,method:'DELETE',message:'DELETE 通道正常',timestamp:newDate().toISOString()}));app.use((req,res)=>res.status(404).json({success:false,message:'路由不存在',path:req.path}));/* ------------------ 创建 HTTP 服务器 ------------------ */consthttpServer=http.createServer(app);/* ------------------ 启动 HTTP ------------------ */httpServer.listen(HTTP_PORT,HTTP_HOST,()=>{console.log(`🚀 HTTP 服务 http://${HTTP_HOST}:${HTTP_PORT}`);console.log(`⚡ 环境${NODE_ENV}`);console.log(`📡 可用路由:`);console.log(`GET / - 服务状态`);console.log(`GET /api/config - 配置信息`);console.log(`GET /api/health - 健康检查`);console.log(`POST /post - POST 测试`);console.log(`PUT /put - PUT 测试`);console.log(`DELETE /delete - DELETE 测试`);});Dockerfile
FROM node:22-alpine WORKDIR /app COPY app.js . RUN npm i express morgan cors dotenv EXPOSE 13000 CMD ["node","app.js"]使用node:22-alpine镜像,拷贝node下的app.js到app文件夹下,并安装包,暴露端口是13000,与docker-compose中的端口暴露的一致。并启动服务。
package.json
{ "name": "unity-webgl-server", "version": "1.0.0", "main": "app.js", "scripts": { "start": "node app.js", "dev": "nodemon app.js" }, "dependencies": { "aedes": "^0.51.3", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.18.2", "morgan": "^1.10.1", "websocket-stream": "^5.5.2", "ws": "^8.13.0" }, "devDependencies": { "nodemon": "^3.0.1" } }.env
HTTP_HOST=0.0.0.0 HTTP_PORT=13000 # 应用配置(localhost/development/production) NODE_ENV=localhostnginx配置
配置ngixn,并将html文件拷贝的到容器中,使用外部的配置文件,端口暴露58231对外,8080使用docker内部的端口,网络使用和node在同一个网络下。
docker-compose.nginx.yml
services:nginx:build:./nginx# 构建镜像container_name:nginx-webports:-"58231:8080"# 宿主机 58231 映射到容器 8080networks:-backend# 跟 node 同一网络networks:backend:name:backend-net#复用网络external:true# 不新建网络Dockerfile
FROM nginxinc/nginx-unprivileged:1.29.0-alpine3.22 COPY nginx.conf /etc/nginx/nginx.conf COPY html/ /usr/share/nginx/html/ USER root使用nginxinc/nginx-unprivileged:1.29.0-alpine3.22镜像,可以使用自己的镜像。
拷贝nginx.conf,拷贝html文件
使用root权限。
nginx.conf
worker_processes 1; events { worker_connections 1024; } http { # 引入MIME类型映射表 include mime.types; default_type application/octet-stream; # 开启零拷贝发送文件 sendfile on; keepalive_timeout 65; # 虚拟主机配置 server { # 监听端口 listen 8080; # 服务绑定地址:允许所有地址访问 server_name 0.0.0.0; # Docker容器内Node服务反向代理 location /docker_s/ { # 代理转发地址:指向Docker内的node-app服务(13000端口) proxy_pass http://node-app:13000/; # 传递客户端真实访问端口(优先取上层Nginx的X-Real-Port,无则用当前服务端口) proxy_set_header X-Forwarded-Port $http_x_real_port$server_port; proxy_set_header X-Real-Port $http_x_real_port$server_port; 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; } # 直接使用nginx提供API配置信息 location = /nginx_s/api/config { set $api_proto "http"; set $api_port "58231"; set $api_env "production"; set $api_host "$host:$api_port"; # 设置响应头为JSON格式 add_header Content-Type application/json; return 200 "{ \ \"success\":true, \ \"baseUrl\":\"$api_proto://$api_host\", \ \"host\":\"$api_host\", \ \"proto\":\"$api_proto\", \ \"environment\":\"$api_env\" \ }"; } # 前端静态资源入口 location / { # 静态资源根目录 root /usr/share/nginx/html; # 默认索引文件 index index.html index.htm; # SPA路由兼容:匹配不到文件时重定向到index.html try_files $uri $uri/ /index.html; } # # 5xx服务器错误页面配置 # error_page 500 502 503 504 /50x.html; # location = /50x.html { # root html; # } } }location /docker_s/ {返回将请求反向代理到容器内部的服务器。这里要使用docker的容器名称, 虽然在一个网络里面,使用localhost会访问nginx的本机,而不是访问的实际的服务。
location = /nginx_s/api/config {在nginx中直接返回,因为有时候会获取不到服务的ip地址。
unity代码
unity 使用tmp,使用unitybesthttpv3 进行网络通信。
besthttpv3 不支持路由请求,所以使用unitywebrequest请求获取服务地址,然后在使用unitybesthttpv3进行请求。
usingUnityEngine;usingUnityEngine.UI;usingUnityEngine.Networking;usingSystem.Collections;usingBest.HTTP;usingTMPro;namespaceAssets{internalclasshttp_test_unityrequest:MonoBehaviour{[SerializeField]publicTMP_InputFieldurlInputField_unity;[SerializeField]publicTMP_InputFieldurlInputField_besthttp;[SerializeField]publicButtonunityWebRequestButton;[SerializeField]publicButtonbestHttpRequestButton;[SerializeField]publicTMP_TextresponseText;privatevoidStart(){unityWebRequestButton.onClick.AddListener(()=>StartCoroutine(SendUnityWebRequest()));bestHttpRequestButton.onClick.AddListener(()=>StartCoroutine(SendBestHTTPRequest()));}privateIEnumeratorSendUnityWebRequest(){responseText.text=$"UnityWebRequest: 请求中...";using(UnityWebRequestrequest=UnityWebRequest.Get(urlInputField_unity.text)){yieldreturnrequest.SendWebRequest();responseText.text=request.result!=UnityWebRequest.Result.Success?$"UnityWebRequest错误:\n{request.error}\n{request.downloadHandler?.text}":$"UnityWebRequest成功:\n{request.downloadHandler?.text}";}}privateIEnumeratorSendBestHTTPRequest(){responseText.text=$"BestHTTP: 请求中...";boolrequestCompleted=false;stringresult="";newHTTPRequest(newSystem.Uri(urlInputField_besthttp.text),(req,res)=>{result=res==null?"请求失败:响应为空":!res.IsSuccess?$"BestHTTP错误:\n{res.Message}\n{res.DataAsText}":$"BestHTTP成功:\n{res.DataAsText}";requestCompleted=true;}).Send();while(!requestCompleted)yieldreturnnull;responseText.text=result;}}}测试html代码
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>GET请求发送器</title> </head> <body> <h1>GET请求发送器</h1> <div> <label for="urlInput">请输入请求路径:</label> <br> <input type="text" id="urlInput" placeholder="/api/" size="40" value="/api/"> </div> <br> <button onclick="sendGetRequest()">发送GET请求</button> <br><br> <div id="responseArea"> <h3>响应结果:</h3> <pre id="responseText">等待请求...</pre> </div> <script> function sendGetRequest() { const urlInput = document.getElementById('urlInput'); const responseText = document.getElementById('responseText'); const path = urlInput.value.trim(); if (!path) { responseText.textContent = '错误: 请输入有效的路径'; return; } console.log('请求路径:', path); responseText.textContent = `正在请求: ${path}`; // 直接获取响应,不做任何状态判断 fetch(path) .then(response => { console.log('响应状态:', response.status, response.statusText); console.log('响应URL:', response.url); // 无论什么状态码,都返回响应文本 return response.text().then(text => { return { status: response.status, statusText: response.statusText, headers: Object.fromEntries([...response.headers.entries()]), url: response.url, body: text }; }); }) .then(result => { // 显示完整的响应信息 responseText.textContent = `响应状态: ${result.status} ${result.statusText} 响应URL: ${result.url} 响应头: ${JSON.stringify(result.headers, null, 2)} 响应体: ${result.body}`; }) .catch(error => { responseText.textContent = `请求异常: ${error.message}`; }); } document.addEventListener('DOMContentLoaded', function() { document.getElementById('urlInput').value = '/api/'; }); </script> </body> </html>项目测试
运行项目后访问http://localhost:58231/然后发送请求进行测试,可以转到不同的location块。
转发docker_s/api/health
使用besthttpV3进行请求
网络命令
网络与容器关系命令
$net='backend-net'; write-host "网络名称: $net"; docker network inspect $net -f '{{range .Containers}}{{.Name}}{{println}}{{end}}' | Where-Object { $_ -ne '' } | ForEach-Object { $cid = docker ps -q --no-trunc --filter name=$_; if ($cid) { write-host "容器名称: $_ 容器ID: $($cid.Substring(0,12))" } }可以查看网络下有那些容器。
输出
PS C:\Users\GoodCooking> $net='backend-net'; write-host "网络名称: $net"; docker network inspect $net -f '{{ra nge .Containers}}{{.Name}}{{println}}{{end}}' | Where-Object { $_ -ne '' } | ForEach-Object { $cid = docker ps -q --no-trunc --filter name=$_; if ($cid) { write-host "容器名称: $_ 容器ID: $($cid.Substring(0,12))" } } 网络名称: backend-net 容器名称: node-app 容器ID: 53a5b8a00a08 容器名称: nginx-web 容器ID: e7ad62d8745b PS C:\Users\GoodCooking>