首页/文章/docker

Qwik 博客全栈 Docker 本地部署实战

2026-10-08
3892 字约 13 分钟
...

1. 部署目标与服务结构

“整体容器化”是把项目运行需要的服务分别放入容器,再用 Compose 管理网络、配置、数据挂载、健康检查和启动顺序。

Compose 服务职责本地访问地址
blogNode 运行 Qwik,提供 SSR、SSG 页面和博客接口http://localhost:7001
adminNginx 提供 React 构建产物,并代理管理接口http://localhost:7002
apiGo/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-imagesGin 可写、博客只读

本次新建了独立本地数据库,只同步文件型文章,没有迁移原数据库的用户、评论或其他互动数据。

宿主机端口绑定 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 不会重置数据库中的现有管理员密码;更改已有账号密码应通过应用提供的功能完成。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
docker2024-07-10
前端应该掌握的Docker知识
前言 作为一名前端开发者,你可能已经听说过Docker这个词。Docker是一个开源的平台,可以帮助你创建、部署和运行应用程序的容器。它使应用程序可以在任何环境中保持一致的运行状态,这对于前端开发来说,是个巨大的优势。不管是在本地开发、测试环境,还是在生产环境中,Docker都能确保你所编写的代码在不同的环境中运行一致。 为什么前端开发者需要Docker? 在实际的开发过程中,我们经常会遇到各种环境问题。比如:不同团队成员的开发环境配置...
学习
docker2023-12-19
Docker从入门到部署项目
Docker概念 是一个开源的应用容器引擎,它是基于 语言并遵从 协议开源。 可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 机器上,也可以实现虚拟化。通过容器可以实现方便快速并且与平台解耦的自动化部署方式,无论你部署时的环境如何,容器中的应用程序都会运行在同一种环境下。并且它是完全使用沙箱机制,相互之间是隔离的,更重要的是容器性能开销极低。 Docker 作为轻量级虚拟化技术,拥有持续集成、版本...
学习
docker2023-07-03
从零开始的Docker Desktop使用
Docker简介 Docker 就像一个盒子,里面可以装很多物件,如果需要某些物件,可以直接将该盒子拿走,而不需要从该盒子中一件一件的取。 、 Docker 包括三个基本概念: 镜像(Image) Docker的镜像概念类似于虚拟机里的镜像(比如 文件),是一个只读的模板,一个独立的文件系统,包括运行容器所需的数据,可以用来创建新的容器。 例如:一个镜像可以包含一个完整的 ubuntu 操作系统环境,里面仅安装了MySQL或用户需要的其...
学习
http-网络2026-10-15
OAuth 2.0是什么,以及如何正确接入
一、先从一个需求理解 OAuth 2.0 假设你开发了一个相册整理工具,用户希望它读取某个云盘里的照片。 把云盘密码交给工具,会带来几个问题:工具拿到的权限太大,密码可能泄漏,用户也难以只撤销这个工具的访问。 OAuth 2.0 提供一套 委托授权机制 :用户在云盘自己的页面确认授权,工具取得一个有权限范围和有效期的访问令牌,再用它调用云盘 API。用户的云盘密码不交给工具。 可以把它理解为:给工具一张“限定用途的通行证”。但令牌泄漏同...
学习
前端2026-10-15
前端动画全景指南
一、前端动画到底是什么 前端动画,就是让画面随时间、用户输入或页面状态连续变化。变化的对象可以是按钮的位置、弹窗的透明度、SVG 的线条、Canvas 里的粒子,也可以是三维场景中的镜头。 先把三个问题分开: | 问题 | 常见答案 | | | | | 画什么? | DOM/CSS、SVG、Canvas 2D、GPU 渲染的场景、预制动画素材 | | 怎样变化? | CSS 过渡/关键帧、Web Animations API、逐帧计算、...
学习
flutter2026-10-14
Flutter Android 正式签名实战:生成密钥、配置构建与验证 APK
Flutter Android 正式签名实战:生成密钥、配置构建与验证 APK Android 正式签名不需要购买证书。开发者可以自己生成密钥,用它给 APK 签名;密钥的用途是建立稳定的应用身份。签名有效并不等于应用已经通过商店审核,也不等于应用功能已完成真机验收。 本文面向 Flutter Android 项目,介绍从开发签名切换到独立正式签名的完整流程。应用名称、目录、包名和证书信息均使用通用示例,不包含真实项目密码、私钥、签名指...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录