从 WordPress 迁移到自建博客系统:富文本编辑 + MySQL + Docker 的完整实践
本文记录了一次从 WordPress 迁移到自建轻量博客的全过程:技术选型、架构设计、关键代码、Docker 部署,以及一路踩过的坑(HSTS 证书问题、宝塔面板看不到数据库、容器网络不一致、登录 401 等)。文中所有真实密码均已用占位符替代。
目录
- 起因:Chrome 打不开 WordPress
- 需求与选型
- 架构总览
- 关键代码
- 部署步骤与命令
- 故障排查记录
- 安全与运维建议
- 总结
1. 起因:Chrome 打不开 WordPress
原博客部署在 timkingblog.cn(WordPress)。某天 Chrome 访问时出现:
此网站通常会使用加密技术来保护您的信息。Chrome 此次尝试连接到 timkingblog.cn 时,该网站发回了异常的错误凭据。……您目前无法访问 timkingblog.cn,因为此网站使用了 HSTS。
现象:Edge 能打开,Chrome 打不开。
1.1 这是什么问题
- 异常的错误凭据(TLS 证书告警):服务器返回的证书与预期不符,常见原因:
- 证书过期、域名不匹配、证书链不完整
- 被 Wi-Fi 登录页 / 运营商劫持(中间人)
- HSTS:浏览器记住该域名"必须使用 HTTPS",一旦证书有问题就直接拒绝连接,不给"继续访问"的选项,所以比 Edge 更严格(Edge 可能没有 HSTS 缓存)。
1.2 排查命令
# 查看证书链与证书有效期 / 签发者
openssl s_client -connect timkingblog.cn:443 -servername timkingblog.cn </dev/null | openssl x509 -noout -dates -subject -issuer
# 查看是否返回 Strict-Transport-Security 头(HSTS 生效)
curl -sI https://timkingblog.cn | grep -i strict-transport
# 测试环境临时清理浏览器 HSTS
# chrome://net-internals/#hsts -> Delete domain security policies
1.3 结论
根本原因是 TLS 证书链路异常(过期或配置错误)叠加 HSTS 缓存。修复需要重新申请/部署有效证书并保证证书链完整。借这个机会,我们决定彻底弃用 WordPress,自建一套轻量、可控、好用的博客系统。
2. 需求与选型
2.1 需求清单
- 后台登录,多用户(可添加用户、修改密码)
- Word 式富文本编辑(不用记 Markdown 语法)
- 同时支持 Markdown / HTML 源码模式
- 图片上传
- 前台:首页简约、文章页"正文 + 左侧目录"双栏、右侧分类、备案号、导航搜索框、响应式
- 文章数据存入 MySQL
- Docker 一键部署
2.2 选型
| 组件 | 选择 | 原因 |
|---|---|---|
| 后台服务 | Python 标准库 http.server | 零依赖,够用 |
| 富文本编辑器 | Quill(本地打包) | 轻量、像 Word |
| 数据存储 | MySQL(服务器已有 Docker 版) | 现成、可靠 |
| 兜底存储 | JSON 文件 | MySQL 连不上时自动回退 |
| 部署 | Docker Compose | 一键重建 |
3. 架构总览
浏览器 ──HTTP 8765──▶ ┌──────────────────────────────┐
浏览器 ──HTTPS 8443─▶ │ admin_server.py(后台 + API)│
│ ├─ /api/login /post /users │
│ ├─ 静态页生成 build_site.py │
│ └─ 图片上传 /images/ │
└──────────┬───────────────────┘
│ pymysql
┌──────────▼──────────┐
│ MySQL 容器 │
│ mysql_docker:3306 │
│ 库:timblog │
│ 表:posts/categories│
│ /users │
└─────────────────────┘
- 静态站与后台一体:保存文章 → 立即重新生成全站 HTML → 刷新即生效。
- 容器与 WordPress、Guacamole 同处
baota_net网络,直接以容器名连接 MySQL。
4. 关键代码
4.1 数据层 store.py:MySQL 优先,JSON 兜底
def load_posts():
conn = connect() # 读取 BLOG_DB_* 环境变量
if conn:
try:
ensure_schema(conn)
cur = conn.cursor()
cur.execute("SELECT id, title, post_date, slug, category_id, "
"content_md, content_html, excerpt, format "
"FROM posts ORDER BY post_date, id")
rows = cur.fetchall()
# ... 组装成统一结构
return posts
except Exception as e:
print('MySQL 读取文章失败,改用本地文件:', e)
return json_load('posts.json') # 兜底
写入采用"双写"策略:先写 MySQL,再落一份 JSON,便于排查与回退。
4.2 后台 API admin_server.py
路由(节选):
if p == '/api/login': self._handle_login()
if p == '/api/change_password': self._handle_change_password()
if p == '/api/add_user': self._handle_add_user()
if p == '/api/cert/upload': self._handle_cert_upload()
if p == '/api/cert/disable': self._handle_cert_disable()
if p == '/api/post': self._handle_save()
if p == '/api/delete': self._handle_delete()
if p == '/api/preview': self._handle_preview()
if p == '/api/upload': self._handle_upload()
- 密码存储:
salt + sha256,校验用secrets.compare_digest防时序攻击。 - 会话:
HttpOnly + SameSite=StrictCookie,8 小时过期。
首次启动引导账号(兼容旧密码文件迁移):
def bootstrap_users():
if store.load_users(): # MySQL 已有用户则跳过
return
if os.path.exists(ADMIN_PASS_FILE):
# 旧版密码文件 -> 迁移为 admin 用户(只执行一次)
store.save_users([{'id': 1, 'username': 'admin',
'salt': old.get('salt'), 'hash': old.get('hash')}])
os.remove(ADMIN_PASS_FILE)
return
# 或从环境变量 / 自动生成,并把初始密码打印到日志
4.3 静态生成 build_site.py
核心:把文章数据渲染成 index.html / categories.html / posts/*.html / search.html,首页底部注入备案号,导航注入搜索框,文章页支持 ?from=admin 时显示"返回管理页"按钮。
4.4 Docker Compose(加入 baota_net,直连 MySQL 容器名)
services:
blog:
build: .
container_name: timblog
restart: unless-stopped
networks:
- baota_net
ports:
- "8765:8765" # HTTP 网站 + 后台
- "8443:8443" # HTTPS(上传证书后启用)
environment:
BLOG_HOST: "0.0.0.0"
BLOG_PORT: "8765"
BLOG_DB_HOST: "mysql_docker-mysql_docker-1"
BLOG_DB_PORT: "3306"
BLOG_DB_USER: "timblog"
BLOG_DB_PASSWORD: "<MySQL密码>"
BLOG_DB_NAME: "timblog"
volumes:
- blog-source:/app/_source
- blog-posts:/app/posts
- blog-images:/app/images
networks:
baota_net:
external: true # 与 WordPress / Guacamole 共用
4.5 后台前端 admin/index.html
- 登录:用户名 + 密码
- 顶部「设置」弹窗:修改密码 / 用户管理(添加用户)/ 证书管理(上传 cert.pem + key.pem,启用 / 停用 HTTPS)
- Quill 富文本 + Markdown / HTML 三种模式切换
- 文章预览带
?from=admin,文章页显示"返回管理页"
5. 部署步骤与命令
5.1 本地打包并上传(Windows -> Ubuntu)
# 打包(排除 git、缓存、bat 等)
tar -czf timblog.tar.gz --exclude='.git' --exclude='__pycache__' --exclude='*.bat' -C . .
# 上传(pscp)
pscp -pw '<服务器密码>' -hostkey '<主机指纹>' timblog.tar.gz <用户名>@192.168.1.3:/tmp/
5.2 服务器部署
cd /home/wj/timblog
tar -xzf /tmp/timblog.tar.gz
docker compose up -d --build
docker logs timblog # 首次启动自动生成 admin 密码,在日志里查看
5.3 数据库核查
# 列出所有库(root 视角)
docker exec mysql_docker-mysql_docker-1 mysql -uroot -p -e "SHOW DATABASES;"
# 查看 timblog 库内容
docker exec mysql_docker-mysql_docker-1 mysql -uroot -p timblog -e \
"SHOW TABLES; SELECT COUNT(*) FROM posts; SELECT username FROM users;"
# 从应用容器内部验证连通性
docker exec timblog python -c "
import pymysql
c = pymysql.connect(host='mysql_docker-mysql_docker-1', port=3306,
user='timblog', password='<MySQL密码>', database='timblog')
cur = c.cursor(); cur.execute('SELECT COUNT(*) FROM posts'); print(cur.fetchone())
"
6. 故障排查记录
6.1 Chrome HSTS 打不开网站
见第 1 节。要点:openssl s_client 查证书、curl -sI 看 HSTS 头、清理 HSTS 缓存后重试。
6.2 宝塔面板看不到 timblog 库
现象:宝塔「数据库」页面能看到 guac / wordpressdb / pressdbdb11,唯独看不到 timblog。
排查:
# 1) 确认服务器上到底有几个 MySQL
docker ps | grep -i mysql # 只有一个:mysql_docker
ss -tln | grep -E ':(3306|13306)' # 只有 13306(Docker 映射)
# 2) root 能看到的库
docker exec mysql_docker-mysql_docker-1 mysql -uroot -p -e "SHOW DATABASES;"
# guac / pressdbdb11 / timblog / wordpressdb ... 8 个库,timblog 就在里面
结论:所有应用的库都在同一个 MySQL 容器里;宝塔没有安装自己的 MySQL(无 /www/server/mysql),它的数据库页展示的是它连接的那个 MySQL。timblog 一直存在,刷新页面 / 确认连接对象即可。"看不到"不等于"没存进去" —— 用 root 一查便知。
6.3 容器网络不一致
现象:Guacamole、WordPress、MySQL 都在 baota_net,timblog 却用的是 host 网络。
排查:
docker network ls
docker inspect -f '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' timblog
# -> host
分析:网络不同不影响数据库读写——timblog 通过 127.0.0.1:13306(宿主端口映射)访问的是同一个 MySQL。但为了统一管理,把 timblog 加入 baota_net,并将连接方式改为容器名直连:
BLOG_DB_HOST: "mysql_docker-mysql_docker-1"
BLOG_DB_PORT: "3306"
验证:
docker logs timblog | grep 数据存储
# 数据存储:MySQL (mysql_docker-mysql_docker-1:3306/timblog)
6.4 登录 401:密码被改过?
现象:自动化测试用旧密码登录返回 401。
排查:
SELECT id, username, salt, LEFT(hash,16) AS hash16 FROM timblog.users;
发现 admin 的 salt 和 hash 都变了,还多了一个新用户,且时间戳相同——说明有人在后台改过密码、添加过用户。这恰好证明"改密码 / 添加用户"功能真实写入了 MySQL(容器重建后依然保留)。结论:不是 bug,是密码真的被改了,用新密码登录即可。
6.5 跨平台脚本调试踩坑(Windows -> Linux)
- PowerShell 管道中文变问号:
@'...'@ | python -会把中文变成?。解决:先把脚本写入临时文件(UTF-8),再python 文件.py执行。 - plink 直接传含引号 / 中文的命令会坏:把复杂逻辑写成
.sh脚本,pscp传上去再bash /tmp/x.sh。 - Python 请求中文 URL 报 ascii 编码错误:先
urllib.parse.quote编码再拼 URL。 - 会话覆盖:测试里先后登录 admin / 新用户,Cookie 被覆盖,后续操作会误以为还是 admin。解决:每个场景使用独立 Cookie 容器或显式切换。
6.6 端口规划:8765 与 8443
8765:HTTP 主端口(网站 + 后台),一直使用。8443:HTTPS 端口,只有上传证书启用后才使用(https://IP:8443/)。- 为什么显式映射:从
host网络改为baota_net桥接网络后,端口必须显式映射才能对外访问。 - 服务器 80 已被 nginx 占用、443 空闲;如需标准 HTTPS 端口,可把映射改为
443:8443。
7. 安全与运维建议
- 密码安全:初始密码自动生成后立即在后台修改;数据库密码不要明文散落在文档 / 聊天记录里。
- HTTPS:后台「证书管理」上传证书启用 HTTPS(8443);公网环境建议前置 Nginx + 正式证书,保证证书链完整,避免再次触发 HSTS 类问题。
- 备份:文章在 MySQL,图片在
blog-images卷。定期备份:
docker exec mysql_docker-mysql_docker-1 mysqldump -uroot -p timblog > timblog.sql
- 防火墙:
ufw allow 8765/tcp;启用 HTTPS 后放行8443/tcp(或改用 443)。 - 多用户:每个编辑人员一个账号;不用的账号及时停用。
8. 总结
一次 WordPress 证书 / HSTS 事故,促成了自建博客系统:
- 轻量:Python 标准库 + Quill + 现成 Docker MySQL,零商业依赖。
- 好用:Word 式富文本编辑、多用户后台、改密码 / 证书管理都集成在网页里。
- 可排查:数据层"MySQL 优先 + JSON 兜底",任何一步都能通过
docker logs或 SQL 快速定位。 - 踩坑清单:HSTS、宝塔数据库可见性、容器网络、登录 401、跨平台脚本编码——都已记录成文。
技术栈可以很小,但"能自己掌控、出了问题能查明白",才是自建的最大价值。