Qwik 博客全栈 Docker 本地部署实战
1. 部署目标与服务结构
“整体容器化”是把项目运行需要的服务分别放入容器,再用 Compose 管理网络、配置、数据挂载、健康检查和启动顺序。
| Compose 服务 | 职责 | 本地访问地址 |
|---|---|---|
blog | Node 运行 Qwik,提供 SSR、SSG 页面和博客接口 | http://localhost:7001 |
admin | Nginx 提供 React 构建产物,并代理管理接口 | http://localhost:7002 |
api | Go/Gin 管理接口 | http://localhost:7003 |
mysql | 存储博客与管理端数据 | 仅在 Docker 内部网络中访问 |
content-sync | 把 Markdown 文章同步到数据库,执行完退出 | 不开放端口 |
浏览器访问管理后台时,请求链路如下:
浏览器 → localhost:7002 → Nginx
├─ 静态页面
└─ /api/admin/... → api:7003/admin/...
浏览器 → localhost:7001 → Qwik / Node → mysql:3306
└→ api:7003(影视接口)
Gin → mysql:3306
容器之间使用服务名通信。mysql 指向数据库容器,api 指向 Gin 容器;容器中的 127.0.0.1 指向当前容器自身。
2. 第一步:确认本地 Docker 与项目依赖
在项目根目录打开 PowerShell:
Set-Location '<项目目录>'
docker version
docker compose version
docker ps --format '{{.Names}} {{.Image}} {{.Ports}}'
docker version 应同时显示 Client 和 Server,说明 Docker Desktop 的引擎已经启动。此次使用 Linux 容器模式。
再检查常用端口是否被占用:
Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue |
Where-Object LocalPort -In 7001,7002,7003,3306 |
Select-Object LocalAddress,LocalPort,OwningProcess
本次开始时 7001、7002、7003 端口空闲。MySQL 最终没有映射宿主机端口,即使本机已有数据库,也可以使用独立容器。
检查源码后确认:管理页面是 Vite + React;博客是 Qwik SSR/SSG 混合应用;Gin 与博客使用同一个 MySQL 数据库;文章来自文件系统。
3. 第二步:准备文章和共享图片目录
代码和构建脚本读取的是:
qwik-blog-serve/content/
├─ 学习/
└─ _resources/
本次文章最初放在 qwik-blog-serve/src/content,随后移到了项目根目录下的 qwik-blog-serve/content。文章与附件必须一起保留,否则文章内的图片可能无法访问。
若需要执行相同调整,先确认目标目录不存在同名内容,再移动;有冲突时先合并文件,不要覆盖原文件:
New-Item -ItemType Directory -Force -Path 'qwik-blog-serve/content' | Out-Null
Test-Path -LiteralPath 'qwik-blog-serve/content/学习'
Test-Path -LiteralPath 'qwik-blog-serve/content/_resources'
# 仅在上述目标不存在、源目录确实存在时执行。
Move-Item -LiteralPath 'qwik-blog-serve/src/content/学习' -Destination 'qwik-blog-serve/content'
Move-Item -LiteralPath 'qwik-blog-serve/src/content/_resources' -Destination 'qwik-blog-serve/content'
共享图片目录单独持久保存:
New-Item -ItemType Directory -Force -Path 'qwik-blog-serve/public/share-images'
Gin 将运行时上传的分享图片写入这里,博客从同一个目录读取并提供 /share-images/* 访问。
4. 第三步:新增部署文件
最终文件结构如下:
<项目目录>/
├─ compose.yaml
├─ docker.ps1
├─ DOCKER.md
├─ .env.docker.example
├─ .env.docker.local # 本机生成,不提交
├─ .gitignore
├─ gin-server/
│ ├─ Dockerfile
│ └─ .dockerignore
├─ qwik-blog-admin/
│ ├─ Dockerfile
│ ├─ .dockerignore
│ └─ docker/
│ ├─ nginx.conf
│ └─ pnpm-workspace.yaml
└─ qwik-blog-serve/
├─ Dockerfile
├─ .dockerignore
├─ content/
└─ scripts/sync-articles.mjs # 增加 --manifest-only
compose.yaml 定义服务和依赖;各个 Dockerfile 定义镜像构建过程;.dockerignore 排除本机依赖、旧产物和环境文件;docker.ps1 把常用操作封装成统一入口。
本次镜像配置使用 node:22-bookworm-slim、golang:1.26-alpine、alpine:3.23、mysql:8.4 和 nginx:stable-alpine,pnpm 固定为 12.8.1。这些是此次配置的版本,并非“永久最新版本”;重新部署其他项目时需要按自己的依赖验证。
5. 配置与脱敏:密钥在运行时注入
首次运行 docker.ps1 时,如果 .env.docker.local 不存在,脚本会生成随机密码和密钥。
以下仅展示变量名和占位符:
SITE_URL=http://localhost:7001
MYSQL_PASSWORD=<本地数据库用户密码>
MYSQL_ROOT_PASSWORD=<本地数据库管理员密码>
AUTH_SESSION_SECRET=<博客会话密钥>
ADMIN_JWT_SECRET=<后台JWT密钥>
ADMIN_BOOTSTRAP_PASSWORD=<首次初始化后台管理员密码>
HERMES_INGEST_TOKEN=<内容摄取接口令牌>
JOPLIN_PUBLIC_SECRET=<分享接口HMAC签名密钥>
这些变量各有用途,不应该全部填写同一个令牌。Joplin Web Clipper 的访问令牌用于连接本机 Joplin Data API;JOPLIN_PUBLIC_SECRET 用于本项目 Joplin 分享接口的 HMAC 签名,两者不同。
.env.docker.local 加入根目录 .gitignore。各项目 .dockerignore 排除 .env 和 .env.*,避免把原有数据库密码等环境文件复制进镜像。
后端密码在容器启动时通过 environment 注入。前端 VITE_* 变量通常在构建时固化,本次通过构建参数设置本地站点地址、后台代理路径和分享地址。
配置校验时使用:
docker compose --env-file .env.docker.local config --quiet
--quiet 校验配置而不打印展开后的环境值。不要把完整环境文件、登录返回的令牌或可能包含密钥的展开配置粘贴到公开文档中。
6. 博客构建:先生成索引,再独立同步数据库
原项目的 pnpm build 会先执行 sync:articles。该脚本既生成文章索引,也连接 MySQL、建表并写入文章。直接放进镜像构建流程,会使构建依赖正在运行的数据库。
解决方式是在 scripts/sync-articles.mjs 中,生成本地索引后增加提前返回:
// 生成文章索引之后、连接数据库之前执行。
if (process.argv.includes('--manifest-only')) return;
博客 Dockerfile 中实际使用以下构建步骤。这些命令运行在镜像构建阶段的 Linux 环境中:
node scripts/sync-articles.mjs --manifest-only
pnpm build.client
pnpm exec qwik check-client src dist
pnpm exec vite build -c adapters/node-server/vite.config.ts
node scripts/verify-ssg-output.mjs
构建阶段保留打包所需的开发依赖,并设置 NODE_OPTIONS=--max-old-space-size=4096。随后通过 pnpm prune --prod 准备运行依赖。
最终运行镜像包含 dist、server、文章索引、同步脚本与生产依赖,用 node server/entry.node-server.js 启动,并以 node 用户运行。文章源目录在运行时挂载,因此没有再把整份源文章与附件复制到最终镜像中。
静态页面及其资源仍包含在 dist 中,附件多时镜像仍会较大。只对需要写入的 public、tmp 目录调整所有者,避免递归修改整个应用目录造成额外文件层。
7. 管理后台与 Gin 的构建方式
React 管理页面在 Node 构建阶段执行:
pnpm install --frozen-lockfile
pnpm build:prod
最终镜像只把 dist 复制到 Nginx,不运行 Vite 开发服务器。try_files 支持前端路由直接刷新,代理配置如下:
location /api/ {
proxy_pass http://api:7003/;
proxy_http_version 1.1;
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;
}
location / {
try_files $uri $uri/ /index.html;
}
这里 proxy_pass 末尾带 /,会把匹配到的 /api/ 前缀替换掉,因此 /api/admin/auth/login 转给 Gin 后是 /admin/auth/login。
Gin 在 Go 构建阶段执行:
go mod download
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api .
最终 Alpine 镜像只复制 Go 可执行文件,并安装 CA 证书和时区数据,以普通用户运行。服务监听容器内的 7003 端口。
8. Compose 的启动顺序与数据持久化
正常启动顺序是:
MySQL 健康
→ content-sync 完成文章同步并以 0 退出
→ Gin 完成数据库迁移与初始化,健康检查通过
→ 博客和管理后台启动
数据库依赖使用 condition: service_healthy,文章同步依赖使用 condition: service_completed_successfully。
content-sync 是一次性任务,状态为 Exited (0) 表示执行成功,不需要一直运行。文章同步脚本会按源文章集合更新 blog_articles,也会删除不再存在于源目录中的文章记录,所以同步前应确认挂载的是完整文章目录。
| 数据 | 保存位置 | 权限与用途 |
|---|---|---|
| MySQL 数据 | qwik-blog-local_mysql-data 命名数据卷 | 容器更新后继续保留 |
| Markdown 与附件源文件 | 宿主机 qwik-blog-serve/content | 博客、Gin、同步任务只读挂载 |
| 分享图片 | 宿主机 qwik-blog-serve/public/share-images | Gin 可写、博客只读 |
本次新建了独立本地数据库,只同步文件型文章,没有迁移原数据库的用户、评论或其他互动数据。
宿主机端口绑定 127.0.0.1,只供本机访问;MySQL 不开放宿主机端口。stop 不删除数据库数据卷,仍需为重要数据安排备份。
9. 实际执行的构建与启动命令
配置生成后,本次分阶段下载、构建、启动与排障。下面保留可复现的命令,省略探测性搜索和临时进程编号:
# 拉取 Node 基础镜像,确认能够访问镜像仓库。
docker pull node:22-bookworm-slim
# 通过脚本生成本地配置并构建镜像。
./docker.ps1 build
# 先启动数据库,等待初始化。
docker compose --env-file .env.docker.local up -d mysql
# 修正配置后,分别构建应用镜像。
docker compose --env-file .env.docker.local build api admin
docker compose --env-file .env.docker.local build blog
# 所有镜像完成后按依赖启动,并等待健康检查。
docker compose --env-file .env.docker.local up -d --wait --wait-timeout 300
# 查看服务状态及近期日志。
docker compose --env-file .env.docker.local ps -a
docker compose --env-file .env.docker.local logs --tail 30 content-sync blog
首次部署推荐直接执行 ./docker.ps1 start:它会生成缺失的本地配置,创建分享图片目录,然后执行包含 --build 和 --wait 的 Compose 启动命令。原始 Compose 命令要求 .env.docker.local 已经存在。
排障期间曾提前单独启动 Gin 和后台,但日常启动应使用完整依赖链路。
10. 本次遇到的问题与修复
10.1 pnpm 拒绝执行 esbuild 的安装脚本
管理后台首次安装依赖出现 ERR_PNPM_IGNORED_BUILDS,提示 esbuild 的构建脚本未获准执行。
为 Docker 构建准备 qwik-blog-admin/docker/pnpm-workspace.yaml:
allowBuilds:
esbuild: true
在安装依赖前将它复制到镜像工作目录。构建结果确认 esbuild 正常工作;管理员页面依赖下载也配置了 BuildKit 缓存。
10.2 pnpm 12 不接受原脚本中的 -s
博客原脚本包含 pnpm -s verify:ssg,在此次固定的 pnpm 12 中报 unexpected argument '-s' found。
Dockerfile 改用 pnpm exec 执行 Qwik 与 Vite,最后直接执行 node scripts/verify-ssg-output.mjs。这次调整发生在 Docker 构建流程中,未重写原有 package.json 脚本。
10.3 MySQL 访客日志索引过长
Gin 在新库建表时出现:
Error 1071 (42000): Specified key was too long; max key length is 3072 bytes
visitor_access_logs.path 定义为 varchar(1000),在 utf8mb4 下直接为整列建索引超过限制。
在 gin-server/internal/model/visitor_access_log.go 中给该索引增加 length:191,与博客现有建表脚本的前缀索引保持一致:
Path string `gorm:"column:path;type:varchar(1000);not null;index:idx_visitor_access_logs_path,length:191;comment:归一化后的访问路径"`
重新构建 Gin 后,新库迁移成功。执行 SHOW INDEX FROM visitor_access_logs 确认该索引的 Sub_part 为 191。
10.4 大附件、递归 chown 和旧构建造成镜像重复打包
文章和附件源文件约 1.2 GB。早期运行镜像既复制了完整内容目录,也复制了静态构建产物,还对整个 /app 递归执行 chown,产生额外文件层。
最终运行镜像不再复制完整源内容目录,只挂载宿主机内容,并仅调整需要写入的目录权限。
排障中,中止的旧构建仍在后台完成并覆盖了同名镜像。最终等旧任务结束后,用当前 Dockerfile 重新构建,核对镜像层,再重新创建容器。检查命令是:
docker history qwik-blog-local/blog:local
docker image inspect qwik-blog-local/blog:local --format '{{.Id}}'
因此更新同一个镜像标签时,应避免重叠启动新旧构建,完成后确认容器使用了预期镜像。
10.5 博客健康检查被规范域名跳转影响
站点地址配置为 http://localhost:7001,健康检查却访问 http://127.0.0.1:7001/。博客会把后者重定向到规范地址,返回 308,导致严格要求 200 的检查失败。
最终检查仍连接本地回环地址,但用配置中的站点地址填入转发头:
const site = new URL(process.env.VITE_SITE_URL);
fetch('http://127.0.0.1:7001/', {
headers: {
'x-forwarded-host': site.host,
'x-forwarded-proto': site.protocol.slice(0, -1),
},
redirect: 'manual',
})
.then((r) => process.exit(r.status === 200 ? 0 : 1))
.catch(() => process.exit(1));
本次尝试仅设置 Host 头仍然得到跳转,使用上述转发头后验证返回 200。修改健康检查配置后需重新创建博客容器,不只是重启旧容器。
10.6 附件的 HEAD 检查与实际 GET 结果不同
检查一张附件时,HEAD 请求返回 404,但同一地址的 GET 返回 200,图片内容正常。因此最终用实际读取图片的 GET 请求验证附件访问,没有直接将 HEAD 结果判定为图片丢失。
11. 验证步骤与脱敏命令
11.1 容器状态
./docker.ps1 status
最终 mysql、api、admin、blog 均为 healthy,content-sync 为 Exited (0)。
11.2 页面与文章接口
(Invoke-WebRequest 'http://localhost:7001/' -UseBasicParsing).StatusCode
(Invoke-WebRequest 'http://localhost:7002/login' -UseBasicParsing).StatusCode
Invoke-RestMethod 'http://localhost:7003/admin/healthz'
$articles = Invoke-RestMethod 'http://localhost:7001/article/public/list?current=1&pageSize=1'
$articles.total
$articles.list[0].title
$encodedSlug = ($articles.list[0].slug.Split('/') |
ForEach-Object { [Uri]::EscapeDataString($_) }) -join '/'
(Invoke-WebRequest ('http://localhost:7001/posts/detail/' + $encodedSlug) -UseBasicParsing).StatusCode
本次文章列表返回 1301,首页和抽查的中文文章详情均为 200,浏览器中正文与代码块正常显示。构建校验确认 1300 个文章详情页的静态文件存在;文章接口总数与静态详情页数是两项不同的验证指标。
11.3 后台登录与后续授权接口
账号为 admin,密码从本机配置取出,验证时不打印密码和返回令牌。PowerShell 7 可使用以下脱敏示例交互式输入:
$credential = Get-Credential -UserName 'admin' -Message '输入本地后台管理员密码'
$loginBody = @{
username = $credential.UserName
password = $credential.GetNetworkCredential().Password
} | ConvertTo-Json
$login = Invoke-RestMethod 'http://localhost:7002/api/admin/auth/login' `
-Method Post -ContentType 'application/json' -Body $loginBody
# 只输出是否成功,不输出登录响应中的访问令牌。
$login.code
$login.data.profile.username
[bool]$login.data.tokens.accessToken
$headers = @{ Authorization = 'Bearer ' + $login.data.tokens.accessToken }
$profile = Invoke-RestMethod 'http://localhost:7002/api/admin/auth/profile' -Headers $headers
$dashboard = Invoke-RestMethod 'http://localhost:7002/api/admin/dashboard/overview' -Headers $headers
$profile.code
$dashboard.code
本次登录、用户资料和仪表盘接口均返回业务成功码 0。
11.4 数据库文章数量与索引
以下命令从容器环境读取数据库密码,不把密码明文写在命令行中。PowerShell 中的外层单引号保留变量,交给容器内的 sh 展开:
docker compose --env-file .env.docker.local exec -T mysql sh -c 'MYSQL_PWD="$MYSQL_PASSWORD" mysql -u "$MYSQL_USER" "$MYSQL_DATABASE" -e "SELECT COUNT(*) AS article_count FROM blog_articles; SHOW INDEX FROM visitor_access_logs;"'
数据库文章数量验证为 1301,路径索引前缀验证为 191。
11.5 目录权限与分享图片
docker compose --env-file .env.docker.local exec -T api sh -c 'test -w /app/public/share-images && test -r /app/content'
本次还使用唯一命名的临时文件验证了完整链路:由 Gin 容器写入共享目录,通过博客 /share-images/ 读取并比对内容,然后清理该临时文件。文章附件则用 /content-resources/ 下的真实图片完成 GET 验证。
12. 日常操作
./docker.ps1 start # 构建并启动,等待健康检查
./docker.ps1 stop # 停止容器,保留数据
./docker.ps1 status # 查看全部服务,包括一次性任务
./docker.ps1 logs # 跟随近期日志,Ctrl+C 退出查看
./docker.ps1 build # 只构建镜像
./docker.ps1 sync # 只重新同步数据库文章索引
更新 Markdown 后执行 ./docker.ps1 start,重建静态页面并完成数据库同步。单独执行 sync 不会刷新已经构建的静态页面。
首次初始化后,修改 ADMIN_BOOTSTRAP_PASSWORD 不会重置数据库中的现有管理员密码;更改已有账号密码应通过应用提供的功能完成。
如果您觉得这篇文章有帮助,请点个赞吧~
评论
请登录后发表评论
去登录