首页
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
搜索
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
登录
搜索
23-4.CN
累计撰写
59
篇文章
累计收到
3
条评论
首页
分类
网络与 DNS
加密 DNS 实操
路由器与家庭网络
自建服务
AdGuard Home
服务器运维
开发与工具
安全与隐私
页面
关于本站
私人 DNS 加密设置教程
全部文章
推荐
加密 DNS 服务
DNS 实时战报
过滤规则订阅
注册丨登录
搜索到
3
篇
标签为
SQLite
的文章
2026-10-24
SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO
这篇是自建服务笔记。站内的加密 DNS 服务在dns.23-4.cn。用 SQLite 做博客数据库,最爽的是「整个数据库就是一个文件」——备份应该很简单吧?cp blog.db backup.db,完事。这个动作在大多数时候是对的,在少数时候会给你一个打不开的副本。而且更麻烦的是:SQLite 有几个不同的备份接口,.backup、VACUUM INTO、.dump、cp,网上教程混着讲,但它们的一致性保证、产物大小、适用场景完全不同。这篇把三种可用方式都实测一遍,给出选择依据。所有数字都是我在一台 Ubuntu 20.04(SQLite 3.31.1)上实际跑出来的。一、先看你的库现在是什么状态先查四个关键 PRAGMA:python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) for k in ('journal_mode', 'page_size', 'page_count', 'freelist_count', 'auto_vacuum', 'synchronous', 'encoding'): print('%-16s = %s' % (k, c.execute('pragma ' + k).fetchone()[0])) EOF实测这台机器:journal_mode = delete page_size = 4096 page_count = 151 freelist_count = 2 auto_vacuum = 0 synchronous = 2 encoding = UTF-8逐个解释:PRAGMA值含义journal_modedelete回滚日志模式。有写入时会有 -journal 临时文件page_size4096每页 4 KB,SQLite 默认值page_count151共 151 页 → 151 × 4096 = 618,496 Bfreelist_count2有 2 页是空闲的(删过数据留下的洞)auto_vacuum0关闭自动整理,文件只增不减synchronous2FULL,最安全的写入模式(性能最低)encodingUTF-8文本编码验算文件大小:python3 - <<'EOF' import sqlite3, os DB = '/var/lib/typecho/6abdb5f537ed4.db' c = sqlite3.connect('file:%s?mode=ro' % DB, uri=True) pc = c.execute('pragma page_count').fetchone()[0] ps = c.execute('pragma page_size').fetchone()[0] sz = os.path.getsize(DB) print('page_count × page_size = %d B' % (pc * ps)) print('实际文件大小 = %d B' % sz) print('差值 = %d B' % (sz - pc * ps)) EOF实测:page_count × page_size = 618496 B 实际文件大小 = 618496 B 差值 = 0 B差值为 0,说明文件里没有浪费的空间(除了那 2 个 freelist 页,它们也算在 page_count 里)。关键:看有没有 -wal / -shm / -journalls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件"实测这台机器:-wal 不存在 -shm 不存在 -journal 不存在三个都没有。但这只代表"此刻没有",不代表 cp 的那一瞬间没有。二、为什么 cp 在多数时候能用,在少数时候会坏journal_mode=delete 时写入流程是:把要被修改的原始页复制到 xxx.db-journal;修改 xxx.db 里的页;提交成功 → 删除 -journal;如果中途崩溃 → 下次打开时用 -journal 回滚。如果你在第 2 步和第 3 步之间 cp,得到的副本可能是「一半新一半旧」的状态——文件头的页数、freelist 链表、索引都可能是矛盾的。后果有两种:打开时立刻报 database disk image is malformed(好的情况,你知道坏了);打开成功,但某些表的数据不对/缺行(坏的情况,你可能几个月后才发现)。journal_mode=WAL 时WAL 模式下就更危险了:最近的写入根本不在 xxx.db 里,而在 xxx.db-wal 里。只 cp 主文件,等于丢掉了所有还没 checkpoint 的写入。# 如果你的库是 WAL,必须先看有没有 -wal ls -la /var/lib/typecho/*.db-wal 2>/dev/null判断你的库能不能安全 cp 的完整条件:journal_mode = delete 且 此刻没有 -journal 文件 且 cp 的那一瞬间没有事务在写第三个条件你无法在 shell 里保证。所以严谨的结论是:cp 适合"停机后备份"。只要服务还在跑,就该用 .backup() 或 VACUUM INTO。三、三种方式的实测对比我在这台机器上把三种都跑了一遍(服务仍在运行,未停机):方式 1:cpcp /var/lib/typecho/6abdb5f537ed4.db /tmp/bak_cp.db方式 2:Connection.backup()python3 - <<'EOF' import sqlite3 src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) dst = sqlite3.connect('/tmp/bak_backup.db') src.backup(dst) dst.close(); src.close() EOF方式 3:VACUUM INTOpython3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) c.execute("vacuum into '/tmp/bak_vacuum.db'") c.close() EOF实测结果方式产物大小耗时一致性保证cp618,496 B1.8 ms❌ 无(依赖时机).backup()618,496 B13.1 ms✅ 有(遇写入自动重试)VACUUM INTO585,728 B6.2 ms✅ 有(事务快照)三种产物我都验证过:python3 - <<'EOF' import sqlite3 for name, f in (('cp', '/tmp/bak_cp.db'), ('.backup()', '/tmp/bak_backup.db'), ('VACUUM INTO', '/tmp/bak_vacuum.db')): c = sqlite3.connect('file:%s?mode=ro' % f, uri=True) n = c.execute("select count(*) from sqlite_master where type='table'").fetchone()[0] rows = c.execute("select count(*) from typecho_contents").fetchone()[0] ok = c.execute('pragma integrity_check').fetchone()[0] print('%-14s tables=%-3d rows=%-4d integrity=%s' % (name, n, rows, ok)) c.close() EOF实测:cp tables=7 rows=55 integrity=ok .backup() tables=7 rows=55 integrity=ok VACUUM INTO tables=7 rows=55 integrity=ok这一次 cp 也没坏——因为跑测试的时候没有并发写入。这正是问题所在:cp 的失败是概率性的,你没法靠"试了几次都好"来判断它安全。注意 VACUUM INTO 的产物小了 32,768 B(正好 8 个页)。因为它把 freelist_count=2 的空闲页和页内碎片整理掉了。四、该选哪个日常备份(服务不停)→ .backup()#!/bin/bash # /root/backup-blog.sh DB=/var/lib/typecho/6abdb5f537ed4.db DEST=/root/backups mkdir -p "$DEST" TS=$(date +%Y%m%d-%H%M%S) python3 - "$DB" "$DEST/blog-$TS.db" <<'EOF' import sqlite3, sys src, dst = sys.argv[1], sys.argv[2] s = sqlite3.connect('file:%s?mode=ro' % src, uri=True) d = sqlite3.connect(dst) s.backup(d) d.close(); s.close() print('备份完成:', dst) EOF # 只保留最近 14 份 ls -1t "$DEST"/blog-*.db | tail -n +15 | xargs -r rm -f优点:安全、产物和原库同大小、恢复后行为和原库完全一致。缺点:碎片不整理,文件大小只增不减。长期归档 / 想压缩体积 → VACUUM INTOpython3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) c.execute("vacuum into '/root/backups/archive-2026-10.db'") c.close() EOF优点:产物小、顺带整理碎片、天然是一致快照。缺点:需要 SQLite ≥ 3.27(先查版本)、耗时略长、需要额外磁盘空间(原库 + 产物同时存在)。需要跨版本迁移 / 人工查看 → .dump# 注意:需要 sqlite3 命令行 sqlite3 /var/lib/typecho/6abdb5f537ed4.db .dump > backup.sql但先确认它存在:command -v sqlite3 || echo "sqlite3 CLI NOT FOUND"实测这台机器:sqlite3 CLI NOT FOUND没有。 这是精简系统的常态(sqlite3 命令行不在默认安装里)。替代方案——用 Python 导出 SQL:python3 - <<'EOF' import sqlite3 src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) with open('/root/backup.sql', 'w', encoding='utf-8') as f: for line in src.iterdump(): f.write(line + '\n') src.close() print('导出完成') EOFiterdump() 是 sqlite3 模块的标准方法,和命令行的 .dump 等价。⛔ 别用 .dump 做日常备份:它导出的是 SQL 文本,恢复要重新执行所有语句,慢得多;而且大库的 SQL 文件可能比 db 文件大好几倍。五、恢复怎么做从 .backup() 或 VACUUM INTO 的产物恢复直接替换文件就行——它们就是完整的数据库文件:# 1. 停服务(避免写到旧库) systemctl stop php8.3-fpm # 2. 备份当前(坏掉的)库,别直接删 mv /var/lib/typecho/6abdb5f537ed4.db /root/broken-$(date +%s).db # 3. 复制回去 cp /root/backups/blog-20261024-010000.db /var/lib/typecho/6abdb5f537ed4.db # 4. 修权限(这一步最容易漏) chown www-data:www-data /var/lib/typecho/6abdb5f537ed4.db chmod 640 /var/lib/typecho/6abdb5f537ed4.db # 5. 验证 python3 -c "import sqlite3;c=sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro',uri=True);print(c.execute('pragma integrity_check').fetchone()[0])" # 6. 起服务 systemctl start php8.3-fpm踩坑点:权限。cp 出来的文件属主是执行 cp 的用户(通常是 root),而 PHP-FPM 以 www-data 运行。不改权限的话,站点会报 unable to open database file——而这个报错看起来像"数据库损坏",很容易往错误方向排查。原库的权限供参考:ls -la /var/lib/typecho/实测:-rw-r----- 1 www-data www-data 606208 Oct 24 00:32 6abdb5f537ed4.db640 + www-data:www-data。从 .dump 的 SQL 恢复python3 - <<'EOF' import sqlite3 conn = sqlite3.connect('/var/lib/typecho/6abdb5f537ed4.db') with open('/root/backup.sql', encoding='utf-8') as f: conn.executescript(f.read()) conn.commit(); conn.close() print('恢复完成') EOF六、怎么验证备份真的可用最容易被忽略的一步。 备份文件存在 ≠ 备份可用。# 1. 完整性检查(每次都做) python3 -c " import sqlite3 c = sqlite3.connect('file:/root/backups/blog-latest.db?mode=ro', uri=True) print('integrity:', c.execute('pragma integrity_check').fetchone()[0]) print('quick: ', c.execute('pragma quick_check').fetchone()[0]) " # 2. 对比行数(备份必须和源库一致) python3 - <<'EOF' import sqlite3 def counts(path): c = sqlite3.connect('file:%s?mode=ro' % path, uri=True) r = {} for (t,) in c.execute("select name from sqlite_master where type='table'"): r[t] = c.execute('select count(*) from "%s"' % t).fetchone()[0] c.close() return r src = counts('/var/lib/typecho/6abdb5f537ed4.db') bak = counts('/root/backups/blog-latest.db') same = True for t in src: if src[t] != bak.get(t): print('!! %s 行数不一致: 源=%d 备份=%s' % (t, src[t], bak.get(t))) same = False print('所有表行数一致' if same else '存在差异') EOF第 3 步最重要:真的从备份启动一次。# 在测试端口上起一个临时实例,指向备份文件 # 或者更简单:临时把站点指向备份,看能不能打开如果嫌麻烦,至少做这一条——用备份文件跑一次 Typecho 的查询:python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/root/backups/blog-latest.db?mode=ro', uri=True) print('已发布文章数 =', c.execute( "select count(*) from typecho_contents where type='post' and status='publish'").fetchone()[0]) print('最新一篇 =', c.execute( "select cid, substr(title,1,30) from typecho_contents where type='post' order by cid desc limit 1").fetchone()) EOF能查出正确的结果,才算备份可用。七、自动化:加进 crontab 并验证它在跑# 每天凌晨 3:17 备份(避开整点) crontab -e17 3 * * * /bin/bash /root/backup-blog.sh >> /root/backup.log 2>&1验证它在跑(不是"配置存在"):# 看日志有没有新记录 tail -5 /root/backup.log # 看产物有没有按日期增加 ls -la /root/backups/ | tail -5踩坑点:cron 里的环境变量和交互式 shell 不同。PATH 通常只有 /usr/bin:/bin,你的 python3 如果在 /usr/local/bin 就找不到;HOME 可能是空的。所以脚本里一律写绝对路径:#!/bin/bash PY=/usr/bin/python3 # ← 写死,别依赖 PATH DB=/var/lib/typecho/6abdb5f537ed4.db查一下你的实际路径:command -v python3八、一页速查需求用什么命令服务不停,日常备份.backup()src.backup(dst)想压缩 + 一致快照VACUUM INTOc.execute("vacuum into '...'")停机后备份cp 也行cp db db.bak跨版本迁移.dump / iterdump()for line in src.iterdump()验证备份可用integrity_check + 行数对比 + 真查一次见第六节一句话结论:服务在跑就用 .backup(),别用 cp。 那 11 毫秒的额外耗时,换的是「备份一定可恢复」。相关阅读把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLite
2026年10月24日
31 阅读
0 评论
0 点赞
2026-10-24
把 Typecho 搬到新服务器:数据库、附件、伪静态、定时任务一份不漏
这篇是自建服务笔记。站内的加密 DNS 服务在dns.23-4.cn。给自己的博客换服务器,看起来很简单的四步:装环境、传文件、导数据库、改配置。但真正做过的人都知道,最容易漏的是第四步之后的东西:附件目录权限不对,图片全裂;伪静态规则没带过去,除了首页全是 404;定时任务没迁,备份悄悄停了两个月;数据库导入了但 auto_increment 丢了,新文章 ID 冲突。这篇是按「漏一次就疼一次」的顺序整理的迁移清单,每一节都有可复制的命令和验证方法。以 Typecho + nginx + PHP + SQLite 为例,MySQL 的差异会单独标出。一、先记录旧机器的完整状态⛔ 别急着动手传文件。 先在旧机器上把状态记录下来,后面每一步都要拿它对照。# 1. 系统与版本 cat /etc/os-release | head -3 uname -r # 2. PHP 版本与模块 php -v | head -1 php -m | tr '\n' ' ' # 3. nginx 版本与配置结构 nginx -v nginx -T 2>/dev/null | grep -c "^# configuration file" # 4. 数据库类型与位置(Typecho 的配置文件里就有) grep -E "Db\(|'file'|'database'|'host'" /var/www/typecho/config.inc.php # 5. 站点根目录大小与文件数 du -sh /var/www/typecho find /var/www/typecho -type f | wc -l # 6. 定时任务 crontab -l ls -la /etc/cron.d/ 2>/dev/null # 7. 所有 systemd 服务单元(非系统自带的) systemctl list-unit-files --state=enabled --no-pager | grep -vE "^(systemd|dbus|getty|cron|ssh|rsyslog|networkd|resolved|udev|apparmor|unattended|snap)" | head -20把这份输出存成文件,这是你的迁移基线:{ echo "=== OS ==="; cat /etc/os-release | head -3; uname -r echo "=== PHP ==="; php -v | head -1 echo "=== nginx ==="; nginx -v 2>&1 echo "=== files ==="; find /var/www/typecho -type f | wc -l echo "=== cron ==="; crontab -l 2>/dev/null } > /root/migrate-baseline.txt 2>&1二、备份数据库:别用 cp这一步是全文最重要的技术点。为什么不能直接 cp 数据库文件SQLite 的默认日志模式是 journal_mode=delete(回滚日志)。在有写入进行中的时候,数据可能分散在三个文件里:xxx.db:主文件;xxx.db-journal:回滚日志(delete 模式);xxx.db-wal + xxx.db-shm:WAL 模式(如果开了)。先看你的库是什么模式:python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) for k in ('journal_mode', 'page_size', 'page_count', 'freelist_count'): print(k, '=', c.execute('pragma ' + k).fetchone()[0]) EOF实测我这台机器(注意 SQLite 3.31.1 的 3.31.1 版本号被 python 报出来):journal_mode = delete page_size = 4096 page_count = 151 freelist_count = 2delete 模式下没有 -wal 文件,所以 cp 看起来"没问题"。但如果备份的那一瞬间正好有事务在写,cp 出来的副本可能包含半提交的页——打开时可能报 database disk image is malformed,也可能静默丢掉最后几次写入。判断风险的方法:看有没有 -wal / -journal 文件在存在。ls -la /var/lib/typecho/ | grep -E "wal|shm|journal" || echo "当前无日志文件(但仍不能保证 cp 安全)"实测这台机器:三个都没有。但这只是"此刻没有",不代表 cp 的那一瞬间没有。三种备份方式的实测对比我在这台机器上把三种方式都跑了一遍,产物大小和耗时:方式产物大小耗时能否在线做cp618496 B1.8 ms⚠️ 有写入时不安全.backup() API618496 B13.1 ms✅ 安全VACUUM INTO585728 B6.2 ms✅ 安全,且会整理碎片三种产物我都验证了:tables=7 rows=55 integrity_check=ok,完全一致。推荐 .backup():python3 - <<'EOF' import sqlite3, time src = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) dst = sqlite3.connect('/root/backup-%s.db' % time.strftime('%Y%m%d-%H%M%S')) src.backup(dst) # 内部按页复制,并且会重试被写入的页 dst.close(); src.close() print('备份完成') EOFConnection.backup() 的好处:它在复制过程中如果遇到正在被修改的页,会自动重试,所以能拿到一致快照。VACUUM INTO 是另一个好选择(SQLite ≥ 3.27):python3 - <<'EOF' import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) c.execute("vacuum into '/root/backup-vacuum.db'") c.close() print('VACUUM INTO 完成') EOF它的额外好处:产物是整理过的(585728 B vs 618496 B,小了 32 KB,因为去掉了 freelist_count=2 的空闲页)。适合长期归档。⛔ 不要用 sqlite3 .dump——不是不好,而是这台机器根本没装 sqlite3 命令行:command -v sqlite3 || echo "sqlite3 CLI NOT FOUND"实测:sqlite3 CLI NOT FOUND这是很多精简系统的常态。用 Python 的 sqlite3 模块代替,功能完全够,而且不依赖额外安装。MySQL 的差异如果你用的是 MySQL 而不是 SQLite:# 备份(--single-transaction 保证一致性,不停机) mysqldump --single-transaction --routines --triggers \ -u root -p typecho > /root/typecho-$(date +%F).sql # 恢复 mysql -u root -p typecho < /root/typecho-2026-10-24.sql--single-transaction 是关键——它在一个事务里导出,保证数据一致,不需要锁表。三、传文件:用 rsync,别用 scp# 在旧机器上执行,推到新机器 rsync -avz --progress \ --exclude 'usr/uploads/cache/*' \ --exclude '*.log' \ /var/www/typecho/ root@NEW_IP:/var/www/typecho/rsync 相比 scp 的价值:可断点续传、可增量、可排除。几百 MB 的附件目录用 scp 传到一半断了要重来,rsync 直接续。踩坑点 1:附件目录权限传完之后,附件目录的属主往往是错的(rsync 默认保留 uid/gid,但新机器上可能没有同样的用户)。# 新机器上 chown -R www-data:www-data /var/www/typecho find /var/www/typecho -type d -exec chmod 755 {} \; find /var/www/typecho -type f -exec chmod 644 {} \; # 只有这个目录需要写权限 chmod -R 775 /var/www/typecho/usr/uploads别偷懒 chmod -R 777。那会让 PHP 能写任何文件,包括 config.inc.php——一个上传漏洞就能改你的数据库配置。踩坑点 2:config.inc.php 不要覆盖如果新机器上已经装过一份 Typecho(哪怕是为了拿目录结构),rsync 会把它的 config.inc.php 覆盖掉。而那个文件里的数据库路径、表前缀是新机器的,不该被旧机器的覆盖。# 正确做法:排除它,单独处理 rsync -avz --exclude 'config.inc.php' /var/www/typecho/ root@NEW:/var/www/typecho/ # 然后在新机器上手工确认 config.inc.php 指向正确的库踩坑点 3:验证文件数一致# 旧机器 find /var/www/typecho -type f | wc -l # 新机器 find /var/www/typecho -type f | wc -l两个数字必须一样。不一样就说明有文件没传过去,或者被 exclude 掉了不该 exclude 的东西。四、伪静态:这是 404 的头号原因文件传完、数据库导完,打开首页正常,点进文章 404——九成是伪静态规则没迁。nginx 的 try_filesTypecho 需要把不存在的路径交给 index.php:server { listen 443 ssl http2; server_name www.example.com; root /var/www/typecho; index index.php; location / { try_files $uri $uri/ /index.php$uri?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php83-fpm.sock; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } # 静态文件直接返回,不经过 PHP location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; access_log off; } }注意 /index.php$uri?$query_string 里的 $uri——这个不能漏。对比两个写法:try_files $uri $uri/ /index.php?$query_string; # ⚠️ 丢了路径 try_files $uri $uri/ /index.php$uri?$query_string; # ✅ 带上路径第一种把请求全部改写成「index.php 加查询串」,路径信息丢了。Typecho 靠 PATH_INFO 风格的路由来判断「你在访问哪篇文章」,路径没了就等于告诉它「你在访问首页」——于是所有文章页都返回首页内容,状态码还是 200。这种故障比 404 更难查:状态码是 200,页面能打开,只是内容不对。验证伪静态的三条命令# 1. 首页 curl -sS -o /dev/null -w "home=%{http_code}\n" https://www.example.com/ # 2. 文章页(这条最容易出问题) curl -sS -o /dev/null -w "post=%{http_code}\n" https://www.example.com/archives/1/ # 3. 分类页 curl -sS -o /dev/null -w "cat=%{http_code}\n" https://www.example.com/category/default/三条全 200 才算伪静态通了。只测首页是不够的——首页是 index.php 直接命中,根本用不到 try_files。而且光看状态码也不够,要确认返回的是正确的文章,不是首页顶替的:# 用标题做区分:文章页的 <title> 应该包含文章标题,不是站点名 curl -sS https://www.example.com/archives/1/ | grep -o "<title>[^<]*</title>"如果文章页返回的 <title> 是站点名而不是文章名,就是 $uri 丢了。踩坑点 4:fastcgi_pass 的 socket 路径Ubuntu 上 PHP-FPM 的 socket 路径带版本号。实测这台机器:/etc/nginx/conf.d/typecho.conf:895: fastcgi_pass unix:/run/php/php83-fpm.sock;而实际监听的 socket:srw-rw---- 1 www-data www-data 0 Oct 24 00:20 /run/php/php83-fpm.sock lrwxrwxrwx 1 root root 30 Oct 11 10:58 /run/php/php-fpm.sock -> /etc/alternatives/php-fpm.sock注意有个 php-fpm.sock 是软链接(指向 alternatives)。所以两个路径可能都能用——但如果 alternatives 指向了别的版本,软链接就会指错。从 PHP 8.1 换到 8.3 时,这个路径会变,而 nginx 配置里写死了旧路径:# 看实际在监听的 socket ls -la /run/php/*.sock如果 nginx 日志里有 connect() failed (2: No such file or directory) while connecting to upstream,就是这个原因。五、定时任务:最容易被忘的一步# 旧机器 crontab -l > /root/crontab-backup.txt cat /root/crontab-backup.txt新机器上恢复:crontab /root/crontab-backup.txt crontab -l # 确认踩坑点 5:cron.d 里的任务不会跟着 crontab -l 走ls -la /etc/cron.d/ /etc/cron.daily/ 2>/dev/null这些目录里的任务要单独复制。很多人只迁了用户 crontab,忘了系统级的。验证定时任务真的在跑(不只是"配置存在"):# 方式 1:看 cron 日志 grep CRON /var/log/syslog | tail -10 # 方式 2:让任务写个时间戳,等它执行后检查 # 在 crontab 里临时加一行: # * * * * * date >> /tmp/cron-alive.txt sleep 70 && cat /tmp/cron-alive.txt配置存在 ≠ 任务在跑。 上面第二种方式才能真正验证。六、迁移后必做的 8 项验证SITE=https://www.example.com echo "--- 1. 首页 ---" curl -sS -o /dev/null -w "%{http_code} %{time_total}s\n" $SITE echo "--- 2. 文章页(伪静态)---" curl -sS -o /dev/null -w "%{http_code}\n" $SITE/archives/1/ echo "--- 3. 后台登录页 ---" curl -sS -o /dev/null -w "%{http_code}\n" $SITE/admin/login.php echo "--- 4. RSS ---" curl -sS -o /dev/null -w "%{http_code} %{content_type}\n" $SITE/feed/ echo "--- 5. sitemap ---" curl -sS -o /dev/null -w "%{http_code}\n" $SITE/sitemap.xml echo "--- 6. 随便一个附件 ---" curl -sS -o /dev/null -w "%{http_code} %{size_download}\n" $SITE/usr/uploads/$(ls /var/www/typecho/usr/uploads | head -1) echo "--- 7. 数据库能读 ---" python3 -c "import sqlite3;c=sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro',uri=True);print('posts=',c.execute(\"select count(*) from typecho_contents where type='post' and status='publish'\").fetchone()[0])" echo "--- 8. 写入能落库(发一条测试评论或改一篇文章)---" # 这条必须真做一次,只读测试证明不了写权限第 8 项特别重要。只读验证(前 7 项)全部通过,不代表写入正常。 数据库文件权限、目录权限、SQLite 的临时目录,这些只在写的时候才暴露。最简单的写入测试:# 用后台改一篇文章的标题,点保存,然后看数据库 python3 -c " import sqlite3 c = sqlite3.connect('file:/var/lib/typecho/6abdb5f537ed4.db?mode=ro', uri=True) print(c.execute('select cid,modified from typecho_contents order by modified desc limit 1').fetchone()) "modified 应该是刚才那一刻。七、切换 DNS 之前的最后一件事用 --resolve 先在新机器上验证,别直接改 DNS。# 让 curl 连到新 IP,但用原来的域名(SNI 和 Host 头都不变) curl -sS -o /dev/null -w "new-server=%{http_code}\n" \ --resolve www.example.com:443:NEW_IP \ https://www.example.com/返回 200 就说明新机器能正常响应。返回证书错误,说明新机器上的证书没装好;返回 301/404,说明 server_name 或 try_files 有问题。这一条能避免一次线上故障:改 DNS 是有缓存和传播时间的,如果新机器其实没准备好就切了,你会有一段「部分用户访问失败」的窗口,而且回滚也要等传播。八、迁移清单(打印出来照做)□ 旧机器:记录 OS / PHP / nginx 版本 □ 旧机器:备份数据库(用 .backup() 或 VACUUM INTO,不用 cp) □ 旧机器:导出 crontab 和 /etc/cron.d/ □ 传输:rsync(排除 config.inc.php / *.log / cache) □ 新机器:chown www-data + 正确的目录权限(不是 777) □ 新机器:确认 config.inc.php 的数据库路径 □ 新机器:伪静态规则(try_files) □ 新机器:确认 fastcgi_pass 的 socket 路径 □ 新机器:恢复 crontab □ 验证:首页 / 文章页 / 后台 / RSS / sitemap / 附件 / 读库 / 写库 □ 切换:先用 --resolve 验证,再改 DNS □ 切换后:观察 1 小时,确认错误日志无新增相关阅读SQLite 博客的备份与恢复:为什么不能 cp,该用 .backup 还是 VACUUM INTO在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLitenginx error.log 里那 5 类 warn 到底要不要管
2026年10月24日
24 阅读
0 评论
0 点赞
2026-10-04
在 Alpine Linux 上从零搭一个 Typecho:nginx + php-fpm + SQLite 的完整清单
这篇是服务器搭建笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。Alpine 做 Web 服务器有几个很实际的好处:镜像小、内存占用低、包管理干净。但它的目录结构、服务管理和多数人熟悉的 Debian 系不一样,第一次搭容易在几个固定位置上卡住。这篇是把一个 Typecho 博客在 Alpine 上跑起来的完整清单,按顺序执行即可,重点标出容易踩的坑。一、装包apk update apk add nginx \ php84 php84-fpm php84-session php84-json \ php84-pdo_sqlite php84-mbstring php84-openssl \ php84-curl php84-dom php84-fileinfo php84-iconv \ sqlite注意几点:Alpine 的 PHP 包按大版本命名(php84),不是 php。装之前先 apk search php84 确认版本号,Alpine 升级后包名会变;php84-pdo_sqlite 必须显式安装。Typecho 用 SQLite 时依赖它,缺了会在安装页报「无法连接数据库」,而错误信息不会告诉你缺的是哪个扩展;php84-session 也是必需的,Typecho 的登录和 CSRF 依赖它。二、服务管理是 OpenRC,不是 systemd这是 Debian 用户最容易懵的地方:rc-update add nginx default # 开机自启 rc-update add php-fpm84 default rc-service nginx start rc-service php-fpm84 start rc-service nginx status没有 systemctl,对应的是 rc-service / rc-update;日志不在 journalctl 里,而是各自的日志文件(nginx 在 /var/log/nginx/,PHP 在 /var/log/php84/error.log);服务的 init 脚本在 /etc/init.d/。三、PHP-FPM 监听确认 /etc/php84/php-fpm.d/www.conf 里的监听方式:listen = 127.0.0.1:9000 ; 或者用 unix socket,性能略好 ; listen = /run/php-fpm84/php-fpm.sock用 TCP 更省事,不用处理 socket 的属主和权限。用 socket 的话记得让 nginx 用户能读写。四、nginx 站点配置server { listen 80; server_name example.com; root /var/www/typecho; index index.php index.html; client_max_body_size 20m; location / { try_files $uri $uri/ /index.php$uri?$query_string; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { expires 7d; add_header Cache-Control "public"; try_files $uri =404; } location ~ .*\.php(\/.*)*$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; include fastcgi.conf; fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_read_timeout 60s; } }这里有三个必须理解的点1. try_files ... /index.php$uri?$query_string —— 这是伪静态的关键。Typecho 的「重写」打开后,链接是 /archives/1/、/category/xxx/ 这种没有 .php 的形式。这些路径在磁盘上不存在,必须交给 index.php 处理。写成 /index.php?$query_string 会丢掉路径信息,结果是所有伪静态链接都变成首页或 404。2. fastcgi_split_path_info + PATH_INFO —— 少了它,Typecho 认不出路由。请求 /index.php/archives/1/ 时,PHP 需要知道 .php 后面那一段是「路径信息」。没有这两行,Typecho 拿不到 /archives/1/,就会走 404 分支。这个坑的症状是:首页正常,但所有文章页、分类页、标签页全部 404。3. location ~* \.(js|css|...)$ 这个正则块要放在 location / 后面、php 块前面。nginx 的匹配规则是「正则优先于前缀,正则之间按书写顺序」。顺序写错会让静态资源的 try_files 覆盖掉别的规则,或者反过来让 .php 请求走了静态分支——后者会把 PHP 源码当文本吐出来,非常危险。配完一定要测:# 如果这条命令返回了 PHP 源码,说明 php 块没匹配上 curl -s http://example.com/index.php | head -3五、装 Typecho 与 config.inc.php下载解压到 /var/www/typecho,然后走网页安装。SQLite 的配置在 config.inc.php 里长这样:$db = new \Typecho\Db('Pdo_SQLite', 'typecho_'); $db->addServer(array( 'file' => '/var/lib/typecho/typecho.db', ), \Typecho\Db::READ | \Typecho\Db::WRITE); \Typecho\Db::set($db);把数据库文件放在 Web 根目录之外(上面放在 /var/lib/typecho/)。放在根目录里的后果是:如果哪天 nginx 配置被人改错、或者多了一个不严谨的 location,.db 文件可能被直接下载——里面是所有用户的密码哈希。六、权限# Web 根目录给 web 用户,不要给 root chown -R nobody:nobody /var/www/typecho # 数据库目录只给 web 用户 chown -R nobody:nobody /var/lib/typecho chmod 750 /var/lib/typecho chmod 640 /var/lib/typecho/typecho.dbAlpine 上 nginx 的 worker 默认跑在 nginx 用户、PHP-FPM 的 pool 默认是 nobody。要让写文件的进程(PHP-FPM)对目录有写权限,否则上传附件、写 session、更新配置都会失败。查当前是谁在跑:ps aux | grep -E "nginx|php-fpm" | grep -v grep七、上 HTTPSapk add certbot certbot certonly --webroot -w /var/www/acme -d example.com -d www.example.com在 nginx 里加一个 location /.well-known/acme-challenge/ { root /var/www/acme; },然后 80 端口 301 到 443。续期一定要验证能跑通,别等三个月后证书过期才发现:certbot renew --dry-run八、上线前检查清单# 1. 伪静态是否生效(文章页应该 200,不是 404) curl -s -o /dev/null -w "%{http_code}\n" https://example.com/archives/1/ # 2. 分类 / 标签页 curl -s -o /dev/null -w "%{http_code}\n" https://example.com/category/default/ # 3. feed curl -s -o /dev/null -w "%{http_code}\n" https://example.com/feed/ # 4. PHP 没有被当源码吐出 curl -s https://example.com/index.php | head -1 # 5. 数据库文件不可下载(应该是 403 或 404) curl -s -o /dev/null -w "%{http_code}\n" https://example.com/typecho.db # 6. 根分区还剩多少 df -h /第 5 条尤其要做。小服务器上数据库就几十到几百 KB,很容易被随手放在根目录里。九、最容易踩的五个坑症状原因首页正常,其他页面全 404缺 PATH_INFO / fastcgi_split_path_info所有伪静态链接跳到首页try_files 写成了 /index.php?$query_string,丢了 $uri装 Typecho 时报连不上数据库没装 php84-pdo_sqlite上传附件 / 登录失败PHP-FPM 的运行用户对目录没有写权限浏览器直接下载到了 .db 文件数据库放在 Web 根目录里,且没有 deny 规则相关阅读Alpine 轻量服务器初始化清单:时钟、Nginx、PHP 一篇配齐服务器磁盘满了怎么查:df / du / inode / 日志轮转的可执行排查顺序AdGuard Home 迁移实录:从香港服务器搬到国内阿里云
2026年10月04日
17 阅读
0 评论
0 点赞