Docker+Halo+cpolar:内网穿透实现博客远程访问
我的博客最早是跑在家里那台小主机的 8090 端口上的,浏览器里敲localhost:8090一切正常,但只要人一出门,这篇博客就等于不存在了。为了在手机上改个错别字、给朋友发个链接看效果,我来回折腾过 U 盘导静态文件、折腾过截图,最后才定下现在的方案:用 Docker 把 Halo 跑起来,再用内网穿透工具 cpolar 把本地端口映射到公网,实现随时随地的远程访问。这篇内容讲的就是这套组合从头到尾的完整落地过程——镜像怎么选、compose 怎么写、数据目录为什么必须挂出来、穿透隧道怎么配,以及跑通之后最容易翻车的几个地方怎么排查。适合手里有一台闲置机器或家用主机、想自己搭博客又不想买云服务器的朋友;就算你完全没碰过 Docker,照着走也能跑起来,因为每一步我都把"为什么这么干"写清楚了。
1. 从静态站换到 Docker 里的 Halo,我到底在换什么
1.1 静态生成器让我抓狂的三个具体场景
在换 Halo 之前,我用的是"本地写 Markdown → 本地构建 → 上传产物"的静态博客方案。这套方案的优点很明显:产物是纯静态文件,扔哪都能跑,几乎不吃资源。但真正用起来,有三件事反复消耗我的耐心。
第一件是"改一个字也要全量重来"。有次在手机上看自己文章,发现一段代码块的高亮标签写错了,想在通勤路上改掉,结果发现必须回到电脑前,打开编辑器改源文件、跑一次构建、再上传一遍产物目录。整个链路里任何一环缺失,改动就落不了地。
第二件是多端写作体验割裂。静态方案的写作端完全依赖本地编辑器配置,我在台式机上装的一套插件、模板片段、图床快捷键,到了笔记本上就得重配一次。第三件是"附加功能全靠外挂"——评论要接第三方、图片要单独配图床、全文搜索要么靠前端索引要么没有,每加一个能力就多一个需要维护的东西。
这三件事指向同一个本质:静态站是"构建期"产物,任何改动都要重新走一遍构建流程。而我真正想要的是一台"一直在跑、随时能登进去改"的东西。
1.2 Halo 是常驻进程型应用,这个差别决定了部署方式
Halo 属于典型的服务端渲染博客系统:它是一个常驻运行的服务进程,内部自带管理后台、文章存储、主题渲染、附件管理、评论模块。你打开浏览器访问它,它现场从数据库里取数据、套模板、吐 HTML 给你。这和静态站的根本差别在于:静态站的"运行"只是把文件交给 Web 服务器,Halo 的"运行"是一个进程在活着。
一旦理解了这个差别,很多后续决策就顺理成章了。进程要活着,就得考虑它跑在哪、崩了谁拉起来、它的数据存哪、它监听哪个端口、外面怎么访问到它。Docker 恰好能一次性解决前三个问题:进程隔离、失败重启、数据挂载。所以我第一反应就是上 Docker,而不是直接java -jar扔在宿主机上——后者我也试过,升级时要手动停进程、换 jar 包、处理 Java 版本冲突,一次升级能耗掉半小时,容器化之后就是改一行镜像 tag 再up -d。
1.3 容器跑起来之后,问题从"怎么生成"变成了"怎么被访问"
容器跑起来的那一刻,你会获得一种虚假的满足感:docker compose ps显示 healthy,浏览器打开127.0.0.1:8090一切正常。但只要你把笔记本合上、换一台设备、或者把链接发给别人,问题立刻暴露——这个地址只在这台机器的本地回环上有效。
这里需要先把网络位置理清楚。你的机器大概率处在一个局域网里,由路由器分配了一个192.168.x.x之类的内网地址。局域网内部的手机、平板可以通过这个内网地址访问到博客,但一旦出了这个局域网,外部世界根本不知道192.168.x.x是谁。绝大多数家用宽带拿到的还是运营商动态分配的地址,甚至根本没有独立的公网地址,路由器上那个"端口映射"功能填了也没用。
这就是整套方案里第二块拼图存在的原因:内网穿透。它的作用是在你的机器和一台公网服务器之间建立一条持续在线的通道,外部访问公网服务器上的某个地址时,流量顺着通道被转发回你本地监听的端口。理解了这一点,后面配置隧道时你就不会把它当成"魔法",而知道它只是"把入口搬到公网"而已。
2. 部署前的盘点:主机规格、目录规划与入口收紧
2.1 配置下限和我实际分配的额度
Halo 官方对资源的要求不算高,但那是"能跑"的下限,不是"跑得舒服"的额度。我这台机器是四核、8G 内存的老机器,实际分配是这样的:Halo 容器限制 1.5G 内存上限,PostgreSQL 容器限制 1G,剩下的留给系统和别的服务。这个配置下,日常访问的响应在几百毫秒级别,后台发文、上传附件也不卡。
有个细节值得单独说:Java 应用的容器内存上限必须和 JVM 的堆参数对齐。如果你只给容器 1G 上限,却让 JVM 按默认策略去用宿主机总内存的 1/4,那么容器很容易在负载稍高时被系统直接终止,日志里只留下一行看不出原因的退出码。稳妥做法是在容器里显式设置堆上限,让它始终低于容器内存上限一到两百兆,把剩余空间留给堆外内存和线程栈。这一点不写进 compose 就会在某个访问小高峰给你一个惊喜。
磁盘方面我留了 40G 给数据目录。文章本身占不了多少,真正吃空间的是附件图片。如果你打算长期写,建议一开始就把附件目录单独规划,别和系统盘混在一起。
2.2 数据目录为什么必须落到宿主机,而不是留在容器里
这是新手最容易踩、后果也最严重的一个点。容器的文件系统是"临时"的:容器被删除重建,里面所有写入都会被一并丢掉。而升级 Halo 的标准动作恰恰就是"拉新镜像 → 删旧容器 → 起新容器"。如果你把数据留在容器内部,第一次升级就是你所有文章消失的那天。
正确做法是把容器内/root/.halo2这个工作目录映射到宿主机的一个真实路径上,比如/opt/halo/data。这样容器只是个"执行体",数据始终躺在你的磁盘上,容器删了重建、镜像换了版本,数据都原封不动。
补充一点常见误解:绑定挂载(bind mount)和命名卷(named volume)都能持久化,但博客这种需要你随时手动备份、打包、迁移的场景,绑定挂载到明确路径更合适。备份时你只要
tar一下那个目录就行,不用去翻 Docker 的内部存储路径。
2.3 端口与防火墙:先把入口收到最小
我见过不少人的做法是-p 8090:8090,意思是对所有网卡开放。在家里局域网里这不算大问题,但如果你这台机器同时还暴露在别的网络环境里,就等于把管理后台直接摆出去了。
我的写法是把宿主侧绑定到回环:-p 127.0.0.1:8090:8090。这样只有本机能访问 8090,局域网里其他设备也进不来。需要给局域网内设备看的时候,再走 Nginx 前置网关转发;需要给外网看的时候,走穿透通道。入口层层收窄,每一层都能单独加访问控制,出问题也容易定位是哪一层挡住的。
防火墙这块,如果宿主机开了防火墙管理,记得放行的是你真正对外的那一层端口(比如 80/443),而不是 8090。放行 8090 却又绑定了回环,等于白配。
3. 用 compose 把 Halo 跑起来,每个字段都讲清理由
3.1 完整编排清单与关键字段拆解
下面这份是我现在在用的结构,做了精简,去掉了和本文无关的监控部分:
services: halo: image: halohub/halo:2.20 container_name: halo restart: unless-stopped depends_on: halodb: condition: service_healthy networks: - halo_net volumes: - ./data:/root/.halo2 ports: - "127.0.0.1:8090:8090" healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8090/actuator/health/readiness"] interval: 30s timeout: 5s retries: 5 start_period: 40s command: - --spring.r2dbc.url=r2dbc:pool:postgresql://halodb:5432/halo - --spring.r2dbc.username=halo - --spring.r2dbc.password=换成你自己的强密码 - --spring.sql.init.platform=postgresql - --halo.external-url=http://127.0.0.1:8090 halodb: image: postgres:15-alpine container_name: halodb restart: unless-stopped networks: - halo_net volumes: - ./db:/var/lib/postgresql/data environment: POSTGRES_DB: halo POSTGRES_USER: halo POSTGRES_PASSWORD: 换成你自己的强密码 healthcheck: test: ["CMD-SHELL", "pg_isready -U halo -d halo"] interval: 10s timeout: 5s retries: 6 networks: halo_net:几个字段值得单独解释。restart: unless-stopped意味着手动停掉的容器开机不会自动起来,而异常退出的会自动重启——这个语义比always更符合我的预期。depends_on配condition: service_healthy是关键,它保证 Halo 启动时数据库已经能接受连接,否则你会看到一串连接被拒的报错,虽然最终会重试成功,但日志脏得没法看。healthcheck里那个start_period是给 JVM 冷启动留的缓冲,设短了会被误判为不健康。
3.2 数据库选型:内嵌 H2 和外置 PostgreSQL 的取舍
Halo 支持内嵌的 H2 数据库,开箱即用,不用额外起容器。很多人图省事就这么上了,结果踩坑。我的建议很明确:正式用就别用 H2。
原因在于 H2 是嵌入式数据库,和应用的进程边界贴得很近,适合本地开发调试,不适合长期对外提供服务的场景。它的并发写入能力有限,多个请求同时写数据时容易出问题;而且一旦数据文件损坏,修复手段非常有限,我见过有人因为一次异常断电导致整个数据文件打不开,最后只能从导出的旧备份恢复。
用 PostgreSQL 的代价是多一个容器、多一份内存,换来的是并发写入可靠、备份恢复路径清晰、升级迁移有成熟工具。这笔账怎么算都划算。如果你只是想把环境先跑起来看看效果,用 H2 无妨,但看完就切过去,别拖到有几十篇文章再迁移,那时候迁移成本会高很多。
3.3 初始化向导、管理员账号与 external-url 的坑
第一次启动后访问http://127.0.0.1:8090,会进到初始化向导,填站点名称、管理员账号密码、邮箱。这一步有个细节:向导里填的"站点地址"会写进数据库,后续很多链接都基于它生成。如果你这里填了http://127.0.0.1:8090,那么后面通过穿透地址访问时,页面里很多资源链接仍然指向 127.0.0.1,样式和图片就会大面积加载失败。
所以我的做法分两步:先按本地地址初始化,把系统跑通、确认功能正常;然后再进后台把站点地址改成最终对外使用的地址。改完之后一定要清一次浏览器缓存再验证,否则你看的还是旧页面。如果站点地址改错了导致后台进不去,可以进容器修改配置,或者直接改数据库里的对应记录,别急着重装。
3.4 前置网关与 HTTPS:什么时候值得加
如果你只在局域网里看,Nginx 这层可以省。但只要准备对外提供访问,我就建议加一层网关,原因是它能统一处理三件事:把 80 端口的访问转发到容器、给静态资源加缓存头、以及集中配置安全相关的响应头。
配 HTTPS 的意义不只是"好看"。现代浏览器对非加密页面的限制越来越多,比如某些接口在明文页面下会被判为不安全而直接拒绝。如果你打算给博客挂一个正式域名,那就走标准的证书申请流程,把证书挂在网关上,容器内部还是走明文 HTTP,这是很常规的做法。
一个实操提醒:网关转发时不要漏掉Host头和真实来源地址的透传。漏了Host,应用生成的绝对链接可能变形;漏了来源地址,后台看到的访问记录全是网关的内网地址,排查问题时完全没用。
4. 把本地端口搬到公网:隧道工具的真实定位与配置
4.1 没有公网地址时,直接访问为什么走不通
前面提过局域网地址的问题,这里说得更具体一点。你家路由器拿到的地址由运营商分配,多数情况下是动态的,重启一次光猫就可能变;更麻烦的是有些接入方式下,你拿到的地址根本不在公网可路由的范围内,外面的人无论如何都找不到你。
路由器上的端口映射功能本质是"把外部到我这条线路某个端口的流量,转给内网某台机器的某个端口"。它成立的前提是你这条线路有一个外部可达的入口。没有这个入口,映射填得再对也没用。
内网穿透工具补的就是这个缺口。它在公网侧提供一台可达的服务器,在你本地跑一个客户端,两者之间保持一条长连接。外部访问公网服务器上的某个地址时,服务器把请求顺着这条连接推给本地客户端,客户端再转给你本机监听的那个端口。它做的事情一句话总结:给你一个公网入口,这个入口的流量最终落到你本地的服务上。
4.2 客户端安装与令牌接入的第一步
我把它装在宿主机上而不是容器里,理由是排错方便:直接看进程、直接看日志、直接改配置,不用进容器折腾。
安装方式按官方提供的包管理器或二进制包来就行。装完之后第一件事是接入账号令牌,通常在工具的账号后台能拿到一串字符,然后执行类似这样的命令:
# 令牌接入,具体参数以工具当前文档为准 cpolar authtoken 你后台拿到的那串令牌这串令牌会被写进本地的配置文件里,后续所有隧道都靠它做身份识别。别把令牌贴在公开的截图或仓库里,它等价于你账号的凭证。
接入完成可以先跑一个最简命令验证通路:
cpolar http 8090如果终端里打印出一个公网地址,用手机流量打开试试——能打开就说明整条链路通了。这一步我建议先去把 Halo 后台的站点地址改成这个公网地址,再重新访问,否则你会看到文章标题正常、但样式全丢的页面,误以为是穿透出了问题。
4.3 隧道配置的两种写法:临时命令行与持久配置文件
命令行方式适合临时验证,进程一关通道就断。如果希望它长期在后台跑,就得写配置文件。配置文件的典型结构是这样(字段名以工具当前版本文档为准):
authtoken: 你的令牌 tunnels: halo: proto: http addr: 8090 subdomain: 你申请到的子域名 region: cn写完之后用指定隧道名的方式启动:
cpolar start haloproto: http表示这是一条 HTTP 隧道,适合博客这种场景;addr写的是本地监听的端口,注意这里要写8090而不是127.0.0.1:8090的完整形式,具体格式看文档。subdomain是子域名,在免费额度下通常是随机的、每次启动可能变化,付费之后才能固定下来。
一个很容易忽略的点:隧道指向的地址必须是本机真正监听的那个。如果你在 compose 里绑了
127.0.0.1:8090,而客户端进程也跑在同一台机器上,没问题;但如果你把客户端跑在另一台机器或容器里,那就访问不到了,因为回环地址只在同一台机器内部有效。
4.4 免费通道的能力边界,别拿它当生产环境
免费额度下有几个现实约束,我踩过之后才明白:子域名不固定,重启客户端就换一个,这意味着任何写死了站点地址的配置都会失效;带宽和并发有限,用来自己看、发给朋友看效果完全够,但如果你打算让几十个人同时访问,页面会明显变慢;连接时长也有限制,长连接会被定期断开重连。
所以我的定位很清楚:穿透通道是"随时能进去改文章、随时能给别人演示"的临时入口,不是博客的长期对外方案。真要让博客长期对外可用,正确路径是买一台有公网入口的小服务器,把容器原样搬过去,或者直接上对象存储加静态托管。这套容器编排的好处就在于迁移成本极低:把数据目录打包、compose 文件带过去、改一下域名,半小时就能换家。
5. 跑通之后翻车的五个现场,以及我怎么一步步定位的
5.1 首页能打开但样式全丢:先怀疑站点地址
这个现象我遇到过两次,第一次折腾了整整一晚上。表现是页面结构在,但完全没有样式,控制台里一堆资源加载失败,失败的地址指向127.0.0.1。原因是站点地址配置成了本地地址,页面里所有资源链接都按这个前缀生成,通过公网地址访问时自然加载不到。
定位顺序我后来固定成了三步:先看浏览器控制台里失败的资源地址是什么,如果指向 127.0.0.1 或 localhost,基本就是这个原因;然后进后台改站点地址;最后清缓存重新验证。这里有个坑,改完刷新还是旧样子,别急着怀疑没生效,先按强制刷新或者换个隐身窗口试试,很多时候只是浏览器缓存。
5.2 后台登录后又被弹回登录页
这个现象比样式问题更迷惑,因为页面看起来完全正常,就是登不进去。我的排查链路是这样的:先看后台的登录请求返回什么状态码,如果是 302 跳回登录页,说明服务端认为会话无效;再检查站点地址和实际访问地址是否一致,不一致的话会话标识可能落在了另一个域下,浏览器不会带上;最后检查是不是网关转发时把某些请求头吃掉了。
还有一个很隐蔽的原因:如果你同时用局域网地址和公网地址访问同一个博客,浏览器会把这两个地址视为不同站点,各自维护自己的会话。一个地址登录了,另一个地址仍然是未登录状态,看起来就像"登录不上去"。解决办法很简单,统一只用一个地址访问。
5.3 上传图片失败:权限和容器内路径
附件上传失败最常见的报错是 500,日志里能看到写入被拒。根因通常是宿主机上那个数据目录的属主和容器内运行用户的标识对不上。容器内的应用以某个固定标识运行,写宿主机目录时如果没有对应权限,就会被拒。
修法不复杂,把宿主机数据目录的属主改成和容器内一致,或者在 compose 里显式指定运行用户标识。但我想强调的是排查思路:遇到写失败,先看日志里报的是哪个路径、哪个操作,再去看那个路径在宿主机上的权限,别一上来就chmod 777。后者虽然能通,但等于把自己的数据目录敞开给所有人,后面会带来新的麻烦。
5.4 重启容器后数据"蒸发":卷没挂对
这是最吓人的一种,我朋友踩过一次,写了一周的文章全没了。他的 compose 里写了 volumes,但路径写错了,挂载到了一个不存在的位置,容器启动时自动创建了空目录,应用初始化成全新状态。表面上看一切正常,实际上是换了一个空数据库在跑。
判断方法很直接:进宿主机看看你预期的数据目录里有没有东西。如果是空的,或者只有一个空壳目录,那说明挂载没生效。另一个信号是容器重启后要求重新走初始化向导,这几乎百分百意味着数据没持久化。
我的习惯是:首次部署完成、写完第一篇文章之后,立刻重启一次容器再访问,确认文章还在。这个动作只用一分钟,但能在最开始就把挂载问题暴露出来。
5.5 换了地址之后旧链接全线失效
穿透通道的免费子域名会变,一变,所有之前分享出去的链接就都打不开了。更麻烦的是,如果文章里引用了自己博客的内部链接,这些链接也会失效。这个问题没有完美解法,只能靠策略规避:对外分享时尽量用固定的正式入口,穿透地址只留给自己临时用;文章内部引用尽量用相对路径,而不是写死完整地址;真要长期对外,就早点把地址固定下来。
6. 长期维护:备份的三样东西、升级的正确顺序
6.1 备份清单:少一样都可能恢复不全
我现在每周做一次备份,清单固定为三样,写在脚本里自动执行:
| 备份对象 | 位置 | 说明 |
|---|---|---|
| 数据库导出 | 宿主机数据目录外 | 用数据库自带的导出工具生成逻辑备份,别直接复制数据文件 |
| 附件与主题目录 | 数据目录内对应子路径 | 图片、上传文件、自己改过的主题 |
| compose 与隧道配置 | 独立目录 | 环境重建时照着这份配置就能还原 |
具体命令大致是这样:
# 数据库逻辑备份 docker exec halodb pg_dump -U halo halo > /opt/backup/halo_$(date +%F).sql # 数据目录整体打包 tar -czf /opt/backup/halo_data_$(date +%F).tar.gz -C /opt/halo data这里强调"逻辑备份"而不是"复制数据文件",是因为数据库运行中直接复制文件可能拿到不一致的快照,恢复时各种报错。导出成 SQL 文本虽然体积大一些,但恢复路径清晰,版本兼容性也好。
6.2 升级顺序:先备份,再改 tag,最后验数据
升级我踩过一次顺序错误:先拉了新镜像、删了容器,才发现备份是三天前的,中间的文章得重写。现在的固定流程是:
- 先跑一次完整备份,并且验证备份文件能打开(这一步很多人跳过,结果备份文件是空的);
- 修改 compose 里的镜像 tag,从
2.20改成目标版本,别用latest,你不会想知道某天早上博客打不开是因为上游推了个不兼容版本; docker compose pull拉新镜像;docker compose up -d重建容器;- 看日志确认启动完成,然后访问首页、后台、随便点开一篇文章、上传一张图。
第 5 步别省。我遇到过一次升级后首页正常但后台编辑器加载不出来,日志里是某个前端资源路径变了,如果只看了首页就收工,第二天发文时才发现问题,排查成本会高很多。
6.3 把穿透入口当临时通道,而不是长期依赖
回到最初那个问题:远程访问到底该怎么定位。我的结论是,穿透通道的价值在于"零成本、五分钟内让本地服务对外可见",它非常适合调试验证、临时演示、自己在外面临时改稿。但如果你的博客开始有人持续访问、开始有内容沉淀,就该把它迁到有正式公网入口的环境里。
迁移本身不难,因为整套东西都在容器里:数据目录打包带走,compose 文件改个域名和站点地址,容器起起来就完事。真正麻烦的是拖到很晚才迁——文章多了、附件大了、读者习惯了旧地址,那时候每一步都得小心翼翼。
我在实际操作中的体会是,这套方案最大的价值不是"省钱",而是把博客变成了一个可以随手带走的东西:所有状态都在一个目录加一份编排文件里,换机器、换网络、换入口,都只是搬一个文件夹的事。至于远程访问这一环,把它理解成"临时开的门"就够了,门开在哪、开多久,你随时能决定。