Since the “architecture upgrade” at the end of 2020, I have rarely touched the overall structure of changkun.de. At the end of The All in Go Stack, I expressed the hope that the new “organisational architecture”, backed by Go’s compatibility, would serve reliably for more than three years. Looking back now, it served for almost six years, and for most of that time it indeed had no major problems. But over those six years, more and more things accumulated on the server: whenever I needed a new feature, I would add a service, a route or a GitHub Action, and never come back to tidy up afterwards. Little by little, the website drifted back into an “organisationally chaotic” state.
This weekend (7 to 9 November 2026), I went through the whole thing again and carried out a complete restructuring. After the restructuring, every page on changkun.de is served by a single Go program; the blog no longer depends on any static site generator, and its repository contains only Markdown files; changkun.de, like golang.design, is now behind Cloudflare, with pages cached at edge nodes closer to readers; and upbot, the monitoring service I wrote in 2020, has been replaced by a status page running on GitHub Actions.
This post describes what accumulated on changkun.de over these years, the motivation for this restructuring, the architecture after it, and some of my experience of carrying it out with Claude Code.
Six Years of the New Age (2021–2026)
After the 2020 migration, the structure of changkun.de was roughly as follows: Traefik as the reverse proxy, in front of several Go services managed with Docker Compose, including the home page main, the blog, redir, midgard, upbot and so on. The blog itself was also a Go program, using Go 1.16’s embed feature to bundle the static files generated by Hugo into the binary. The one exception was modern-cpp-tutorial: as mentioned in Migration with Zero Downtime, Traefik cannot serve static files by itself, so an Nginx serving only static files was kept specifically for it.
This structure remained largely unchanged over the following years, but quite a lot was layered on top of it:
- In November 2021, I wrote a simple static service in Go to replace Nginx for serving the static files under /www. The Nginx configuration, however, was never deleted; it stayed in the repository, and only the local development setup still used it. By the time of this restructuring, the static service ran three containers: one serving changkun.de, one serving golang.design, and one whose only job was to answer
go get poly.red. - The blog was later changed to be built with Hugo in GitHub Actions and uploaded with rsync to the /www folder on the server, where the static service served it. Hugo itself stayed at version 0.91.2, from 2021.
- Books, tutorials, the research page and so on were all published as folders under /www, and every folder needed its own route in Traefik.
- The research page had a renderer of its own, and to render one README into one web page it vendored a copy of a Markdown library, adding up to almost sixteen thousand lines of code.
- The home page main also carried an HTTP API for publishing “ideas”, deployed at api.changkun.de.
- upbot probed each service at a fixed interval and emailed me through Mailgun when one was unavailable.
- The DNS records of each domain were edited by hand in dashboards, and golang.design even still carried the mail records from its GoDaddy days.
- The pages of each site had their own styles, essentially frozen in the year the site was born.
Of course, some of the tidying up had started before this. In 2021, redir’s storage moved from Redis to MongoDB; in February this year, urlstat moved from MongoDB to PostgreSQL; in August, redir moved onto the same PostgreSQL instance, and golang.design’s short link service was merged into the same redir along the way. This saved a good deal of data migration work for the restructuring.
Motivation for Restructuring
The first reason for this restructuring is that publishing something new had become more and more troublesome. Take the books and tutorials published this year as an example: each new site required:
- Setting up the build in the repository’s GitHub Actions, and rsyncing the result to /www on the server
- Adding a Traefik route for the folder in the infra repository
- Logging into the server, pulling the infra repository and updating Traefik’s configuration
None of these steps is complicated in itself, but every new site added another section to the Traefik configuration, and it was easy to forget one of the steps.
The second reason is that the blog’s build had become unreliable once again. One of the main reasons for leaving Hexo in 2020 was that it had become hard to compile the whole blog smoothly on a new machine. Over these years Hugo stayed at 0.91.2, and the theme was my own fork with my own customisations, so upgrading Hugo would have meant adapting the theme all over again. In other words, the same problem had simply moved from the Node.js ecosystem to Hugo. Besides the posts, the blog repository also held Hugo’s configuration, the theme and the build scripts, more than three thousand lines in all.
The third reason is consistency. Each site had its own look, so a reader going from the blog to one of the books would see an entirely different page, with a different font, a different header and a different dark mode.
The fourth reason was only discovered during the restructuring: speed. Along the way I set up a new status page, and found that the median response time it measured for changkun.de was above 400 milliseconds. The cause is simple: the server is in Europe, while the status checks run on GitHub’s machines in the United States, so every request has to cross the Atlantic and come back. No amount of optimisation of the Go program itself can solve that.
Putting these reasons together, the goal of the restructuring became fairly clear:
Could all the pages of changkun.de be gathered into a single Go program, so that publishing anything takes only a git pull or copying files into a folder, without changing any configuration?
The post you are reading was rendered directly from Markdown by the restructured website, so the answer is yes.
Of course, it was only with coding agents like Claude Code that a restructuring of this scale became something that could be done in one weekend. We will come back to this later.
After the Restructuring (2026–)
Gathering All Pages into changkun/website
The home page repository changkun/main has been renamed to changkun/website. The pages of changkun.de are now all served by it:
- the home page, bundled into the binary with embed, as in 2020;
- every folder under /www/changkun.de, served at the path of the same name: once the folder exists it can be visited, without adding a route or restarting anything;
- golang.design, served from its own folder;
- the blog and the research page, rendered directly from checkouts of their repositories on the server;
- my Zotero library, under /library/, which can be shared with others by topic.
The corresponding Docker Compose configuration is very simple. The two folders are mounted read-only into the container, and environment variables tell the website where to read from:
|
|
Here WWW_CORS names the paths that allow cross-origin access, and WWW_SITES lets the same program serve another site depending on the requested host.
Accordingly, changkun.de needs only one route in Traefik:
|
|
Traefik’s routes went down from 18 to 14, and most of what remains are redirects of old addresses and other services such as redir, urlstat and midgard. The static service, Nginx, the research page’s renderer and api.changkun.de have all been deleted; go get poly.red is now answered by redir, which was already doing the same for changkun.de/x and golang.design/x anyway. The website now has about 8,800 lines of Go, plus about 4,600 lines of tests.
Worth noting: the website’s Makefile and Dockerfile are almost the same as the ones in the 2020 post. It is still a cross-compile, followed by dropping the binary into an alpine image. Compared with back then, build now runs the tests first, and up removes old images afterwards, keeping only the two most recent versions so that a deploy can still be rolled back when needed:
|
|
The Blog: Dropping Hugo and Rendering Markdown Directly
The blog repository changkun/blog now contains only Markdown files and the images used in the posts. Hugo’s configuration, the theme and the build scripts have all been deleted: 41 files, more than 3,100 lines. On the server, the website reads the checkout of the blog repository directly and renders the pages in Go (with goldmark), rendering them again when it notices that the checkout has changed.
Deployment has become very simple as a result. In 2020, the blog’s GitHub Action had to log into the server, pull the repository, build it and update the container:
|
|
Now it only pulls the repository, with nothing to build:
|
|
The biggest risk in dropping Hugo was the addresses. The posts on this blog have been linked from many places, and over its history the blog has used more than one URL scheme (remember the user-hostile /archives/year/month/id of the Third Age?). So before deleting the old build, I first recorded every address in the folder Hugo had generated on the server, more than 2,100 of them, and wrote down what each address should answer now: 1,079 answer with the page directly, 1,014 redirect for good to their new address, and 91 are content that is deliberately no longer published (such as old drafts, images that no post used, and the scripts and styles left over from old builds), which answer 404. This list was added to the website’s tests, and after the switch it was checked once more, one by one, against the live site.
Writing the Blog in the Browser
The blog now has an online editor at /blog/new. I can write a post or an “idea” in Markdown directly in the browser, with a live preview beside it, and images and videos can simply be dragged in. On publishing, the editor creates a commit in the blog repository through the GitHub API, which then triggers the GitHub Action above; once the server has pulled the repository, the post is online. Posts that are already published can be edited the same way, and on saving the editor checks whether the file on GitHub has changed in the meantime, to avoid overwriting changes made elsewhere. Since the posts here all have an English and a Chinese version, the editor can also translate a post written in one language into the other before it is published.
The “ideas” that used to be published through api.changkun.de now go through this editor as well, so the API was deleted along with it.
One Login for the Whole Site
Both the library and the editor need to confirm that the visitor is me. There is now only one login page for the whole site, at /login: the browser signs in with my latere account (a PKCE flow), and after verifying the token the server issues a cookie of its own. Logging out also logs out of latere, and every page on changkun.de is logged out together.
Moving to Cloudflare: DNS and the Edge Cache
In 2020, in Migration with Zero Downtime, I wrote:
If you ask why I don’t use Cloudflare: laziness. On one hand, I don’t want a personal website to take on too many dependencies; on the other, golang.design has Cloudflare enabled, which makes it a kind of A/B comparison with changkun.de.
This A/B comparison ended up running for six years. This time, changkun.de has finally moved to Cloudflare too.
First, DNS. The records of these domains used to be edited by hand in their dashboards. Now the DNS records of all five domains, changkun.de, changkun.org, golang.design, poly.red and changkun.us, are written in one file in the infra repository and pushed to Cloudflare with DNSControl. For example, part of the records of changkun.de:
|
|
Note that a DNSControl push deletes any record not written in the file, so every push should be preceded by a dnscontrol preview. Also, Cloudflare’s proxy does not carry SSH, so the record ssh.changkun.de must stay unproxied; the publishing workflows of the various repositories all connect to the server through it.
Next, caching. The website adds a Cloudflare-CDN-Cache-Control header to every public response:
|
|
What it means: a page is cached at Cloudflare’s edge nodes for one minute, and within that minute it is answered directly by the edge; after one minute, Cloudflare still answers with the cached page first, while requesting the new version from the server in the background; and if the server cannot be reached for a while, Cloudflare can keep answering with the cached page for up to a week. As a result, after a page is changed, the next visit after one minute makes Cloudflare fetch the new version, so the cache never needs to be purged by hand. The reason for using Cloudflare-CDN-Cache-Control rather than Cache-Control is that only Cloudflare reads the former, and it does not pass it on to the browser, so the browser’s caching behaviour stays the same. The Cache Rule configured on the zone caches only responses that carry this header, so pages that differ from person to person, such as the library, the editor and the login page, are never cached.
There was one more problem to deal with here: caching assumes that a page is the same for everyone. Before this, the “Edit” link on a post was added by the server only after it had confirmed that the visitor was me. So now every post carries this link, hidden by default, and a few lines of script show it when the browser has a cookie indicating that I am signed in. This cookie grants no permission by itself; the editor still checks the real session.
Judging from the results, the effect is quite noticeable: in the first few hours after it went live, the median response time measured by the status checks went from about 420 milliseconds to about 190 milliseconds for changkun.de, and from about 410 milliseconds to about 130 milliseconds for golang.design. It should be said that the status checks open a new connection every time; on a connection that is already open, a page takes only about 30 milliseconds for me in Europe. Of course, a page that nobody has visited for a long time still has to be fetched from the server once on its first visit.
A Status Page on GitHub Actions
upbot has been retired, and its place has been taken by a GitHub Actions workflow in the changkun/status repository, which checks every site and service every five minutes, publishes the results at changkun.github.io/status, and opens an issue and notifies me as soon as a service has a problem.
This may look contrary to the 2020 principle of “building in-house wherever possible”, but the reason is straightforward: upbot ran on the same machine as the services it monitored, so if the machine itself had a problem, upbot had the same problem, whereas GitHub Actions does not depend on this server. I had also planned to give the status page the domain status.changkun.de, but GitHub Pages never issued a certificate for it, even after removing the domain and adding it again, so for now it stays on github.io.
One Design for All Pages
All sites now share one design: the home page, the blog, the research page, the library, the books (Bayesian Optimization, AI Alignment, An Unverifiable World, Modern C++ Tutorial), the Swedish and German tutorials, the dashboards of redir and urlstat, and the status page. They all use the Inter font and a mostly black-and-white palette, share the blog’s header, and the choice of light or dark mode stays the same across the pages of changkun.de. In addition, every reading page now counts its readers with urlstat and shows the page views and visitors in its footer.
The Services Today
After the restructuring, the overall structure of changkun.de is as follows:
|
|
- traefik: the reverse proxy
- website: all pages of changkun.de and golang.design
- redir: short links and Go import paths, for changkun.de, golang.design and poly.red
- urlstat: page view statistics
- postgres: the database shared by redir and urlstat
- midgard: my clipboard service
- gossa: golang.design’s SSA playground
- rankui study: a user study, at poly.red/study
How to rebuild the whole server, from a backup or from scratch, is written in the README of the infra repository.
Restructuring with Claude Code
The 2020 migration took the first three days of my Christmas holiday, and I wrote all of the code myself, line by line. This restructuring took one weekend, from Friday night to Monday morning, and touched 14 repositories with 174 commits in total, including 79 in website, 33 in infra, 15 in status, 11 in blog and 9 in redir. This time, however, most of the code was not typed by me, but written by Claude Code.
In practice, I ran several Claude Code sessions at the same time, each responsible for different repositories: one for the website, one for a book, one for the tutorials, and so on. My work was mainly to describe clearly what I wanted, read the changes it brought back, and then decide whether to accept them or ask for further changes. Compared with writing the code myself, more of the time went into reading and judging.
One rule held throughout: Claude Code could change code, commit and push, but every operation involving production, such as deploying, pushing DNS, changing Cloudflare settings, or deleting folders or containers on the server, had to be run by me. It wrote the command, and I read it before typing it myself. This is indeed somewhat slower, but it guarantees that no change reached production without my having seen it.
Of course, the process was not entirely smooth, and a few of the problems along the way are worth recording:
- The git on the server was older than 2.28, while the website’s tests used
git init -b. Sincemake buildruns the tests first, one deploy on the server was stopped by the failing tests. - After caching was enabled, Cloudflare at one point served the Swedish tutorial with new HTML and an old stylesheet. The fix was to publish the styles and scripts into a folder named by a hash of their content, so that when the content changes, the address changes with it.
- The status.changkun.de certificate problem mentioned above.
- The “Edit” link mentioned above: to make the pages cacheable, I first had to find every place on them that differed from person to person.
Retrospective
This post first reviewed the services and problems that accumulated on changkun.de over the six years from 2021 to 2026, then described the motivation for this restructuring and the architecture after it: all pages are served by one Go program, changkun/website; the blog has dropped Hugo and is rendered directly from Markdown; DNS is managed in one place with DNSControl, and pages are cached at Cloudflare’s edge nodes; and monitoring has been handed to GitHub Actions. Finally, it briefly described the process of carrying out the restructuring with Claude Code.
Compared with 2020, this restructuring did not actually introduce any new technology: Go, Docker Compose and Traefik are all still there, and even the Makefile and Dockerfile have barely changed. The change was mainly to go through what had been layered on over these years again, merge the services that could be merged, and delete the parts that were no longer needed.
These services are all open source. Interested readers can find them at the following links:
- https://github.com/changkun/website
- https://github.com/changkun/blog
- https://github.com/changkun/infra
- https://github.com/changkun/redir
- https://github.com/changkun/urlstat
- https://github.com/changkun/midgard
- https://github.com/changkun/status
The design of the New Age lasted almost six years. I hope the structure after this restructuring will last even longer.
Changkun Ou
9 November 2026
自从 2020 年底那次「架构升级」之后,我就很少再去动 changkun.de 的整体结构了。当时在《全面转向 Go 技术栈》这篇文章的最后,我希望那次的「组织架构」能够借助 Go 的兼容性稳定地服务超过三年。现在回过头来看,它实际上服务了将近六年,大部分时间里也确实没有出过什么大问题。但六年下来,服务器上又陆陆续续多出了不少东西:每当需要一个新功能,我就顺手加一个服务、一条路由或者一个 GitHub Action,加完之后便再也没有回头整理过,整个网站又慢慢回到了「组织混乱」的状态。
这个周末(2026 年 11 月 7 日至 9 日),我把它重新梳理了一遍,进行了完整的重构。重构之后,changkun.de 上所有的网页都由同一个 Go 程序提供;博客不再依赖任何静态站点生成器,仓库里只剩下 Markdown 文件;changkun.de 也和 golang.design 一样接入了 Cloudflare,页面被缓存在离读者更近的边缘节点上;而 2020 年自己写的监控服务 upbot,则被一个运行在 GitHub Actions 上的状态页取代。
这篇文章就来介绍一下这几年 changkun.de 上积累下来的东西、进行这次重构的动机、重构之后的架构,以及这次借助 Claude Code 完成重构的一些体会。
新纪元的六年(2021-2026)
2020 年那次迁移之后,changkun.de 的结构大致是这样的:Traefik 作为反向代理,后面是若干个用 Docker Compose 管理的 Go 服务,包括主页 main、博客、redir、midgard 和 upbot 等等。博客本身也是一个 Go 程序,利用 Go 1.16 的 embed 特性将 Hugo 生成的静态文件打包进二进制文件里。唯一的例外是 modern-cpp-tutorial:正如《零宕机迁移》中提到的那样,Traefik 本身不能服务静态文件,所以当时专门为它保留了一个只服务静态文件的 Nginx。
这个结构在后来的几年里基本没有变,但在它上面又叠加了不少东西:
- 2021 年 11 月,我用 Go 写了一个简单的 static 服务,代替 Nginx 来服务 /www 下的静态文件。但 Nginx 的那套配置并没有被删掉,而是一直留在了仓库里,只有本地开发环境还在用它。到这次重构之前,static 服务一共运行着三个容器:一个服务 changkun.de,一个服务 golang.design,还有一个只负责回答
go get poly.red。 - 博客后来改成了在 GitHub Actions 中用 Hugo 构建,再通过 rsync 上传到服务器的 /www 文件夹下,由 static 服务提供。Hugo 的版本则一直停留在 2021 年的 0.91.2。
- 书、教程、研究主页等等也都以文件夹的形式发布在 /www 下,而每一个文件夹都需要在 Traefik 里配置一条对应的路由。
- 研究主页有一个自己的渲染器,为了把一个 README 渲染成网页,还 vendor 了一份 Markdown 库,加起来差不多有一万六千行代码。
- 主页 main 里还附带了一个发布「想法」(ideas)的 HTTP API,部署在 api.changkun.de。
- upbot 定期探测各个服务,服务不可用时通过 Mailgun 给我发邮件。
- 各个域名的 DNS 记录都是在控制台里手动修改的,golang.design 上甚至还留着当年 GoDaddy 的邮件记录。
- 每个站点的页面都有自己的样式,基本上停留在它诞生的那一年。
当然,也有一些整理工作是在这之前就已经开始了的。redir 的存储在 2021 年从 Redis 换成了 MongoDB;今年 2 月,urlstat 从 MongoDB 迁移到了 PostgreSQL;8 月,redir 也迁移到了同一个 PostgreSQL 实例上,golang.design 的短链接服务也顺带合并进了同一个 redir。这为这次重构省下了不少数据迁移的工作。
进行重构的动机
这次重构的第一个原因是发布一个新的东西变得越来越麻烦。以今年发布的几本书和教程为例,每发布一个新的站点,都需要:
- 在仓库的 GitHub Actions 中配置构建流程,并将构建结果 rsync 到服务器的 /www 下
- 在 infra 仓库里为这个文件夹添加一条 Traefik 路由
- 登录服务器,拉取 infra 仓库并更新 Traefik 的配置
这些步骤本身都不复杂,但每多一个站点,Traefik 的配置就多出一段,而且很容易漏掉其中的某一步。
第二个原因是博客的构建又一次变得不可靠了。2020 年离开 Hexo 的一个重要原因,就是已经很难在新的机器上顺利编译整个博客。而这几年 Hugo 的版本一直停留在 0.91.2,主题也是我自己 fork 并定制过的版本,想要升级 Hugo 就得重新适配一遍主题。换句话说,同样的问题只是从 Node.js 生态换到了 Hugo 身上。除了文章之外,博客仓库里还放着 Hugo 的配置、主题和构建脚本,加起来有三千多行。
第三个原因是统一。每个站点都有自己的样子,读者从博客跳到某一本书上,看到的是一个完全不同的页面,字体、页头和深色模式都不一样。
第四个原因则是在重构的过程中才发现的:速度。重构时我顺手搭了一个新的状态页,结果发现它测得的 changkun.de 响应时间中位数在 400 毫秒以上。原因其实很简单:服务器在欧洲,而状态检查运行在 GitHub 位于美国的机器上,每一次请求都要横跨大西洋往返一趟。这个问题无论怎么优化 Go 程序本身都是解决不了的。
把这些原因放在一起,这次重构的目标也就比较明确了:
能不能把 changkun.de 上所有的网页收拢到一个 Go 程序中,让发布任何东西都只需要一次 git pull 或者向一个文件夹里拷贝文件,而不需要修改任何配置?
你现在看到的这篇文章,就是由重构之后的网站直接从 Markdown 渲染出来的,所以答案是可以的。
当然,也正是有了 Claude Code 这样的编程智能体,这种规模的重构才第一次变得能够在一个周末内完成,这一点我们放到后面再聊。
重构之后(2026-)
将所有网页收拢到 changkun/website
原来的主页仓库 changkun/main 改名为了 changkun/website。现在,changkun.de 上的网页都由它来提供:
- 主页,和 2020 年一样,通过 embed 打包在二进制文件中;
- /www/changkun.de 下的每一个文件夹,都在与文件夹同名的路径下提供,文件夹一旦存在就可以访问,不需要添加路由,也不需要重启;
- golang.design,从它自己的文件夹中提供;
- 博客和研究主页,直接从各自仓库在服务器上的检出(checkout)中渲染;
- 我的 Zotero 文献库,在 /library/ 下,可以按主题分享给其他人。
对应的 Docker Compose 配置也非常简单,只需要把这两个文件夹以只读的方式挂载进容器,再通过环境变量告诉网站从哪里读取即可:
|
|
其中 WWW_CORS 指定了哪些路径允许跨域访问,WWW_SITES 则让同一个程序可以根据请求的域名来服务另一个站点。
相应地,changkun.de 在 Traefik 中也只需要一条路由:
|
|
Traefik 的路由从原来的 18 条减少到了 14 条,剩下的大部分是旧网址的重定向,以及 redir、urlstat、midgard 等其他服务。static 服务、Nginx、研究主页的渲染器和 api.changkun.de 都被删掉了;go get poly.red 则改由 redir 来回答,反正它本来就在为 changkun.de/x 和 golang.design/x 做同样的事情。目前网站一共有大约 8800 行 Go 代码,以及 4600 行左右的测试。
值得一提的是,网站的 Makefile 和 Dockerfile 与 2020 年那篇文章中写的几乎没有区别,依然是交叉编译之后把二进制文件扔进 alpine 镜像。和当时相比,build 会先跑一遍测试,up 之后则会清理旧的镜像,只保留最近的两个版本,以便需要的时候还能回滚:
|
|
博客:去掉 Hugo,直接渲染 Markdown
博客仓库 changkun/blog 现在只剩下 Markdown 文件和文章中用到的图片。Hugo 的配置、主题和构建脚本都被删掉了,一共 41 个文件、三千一百多行。网站在服务器上直接读取博客仓库的检出,用 Go(goldmark)来渲染页面,发现检出发生变化时再重新渲染。
部署方式也因此变得非常简单。2020 年的时候,博客的 GitHub Action 需要登录到服务器上拉取仓库、构建并更新容器:
|
|
现在则只需要拉取仓库,什么都不用构建:
|
|
去掉 Hugo 最大的风险在于网址。这个博客的文章被很多地方引用过,而且历史上用过不止一套网址格式(还记得第三纪元那个反人类的 /archives/year/month/id 吗?)。所以在删除旧的构建之前,我先把服务器上 Hugo 生成的文件夹中的每一个网址都记录了下来,一共两千一百多个,并写明了每个网址现在应该返回什么:1079 个直接返回页面,1014 个永久重定向到新的地址,还有 91 个是有意不再提供的内容(比如旧的草稿、没有被任何文章用到的图片、旧版本构建留下的脚本和样式等等),返回 404。这份清单被加进了网站的测试中,切换之后又在线上逐一核对了一遍。
在浏览器中写博客
博客现在有了一个在线编辑器,地址是 /blog/new。我可以直接在浏览器里用 Markdown 写文章或者「想法」,旁边是实时预览,图片和视频直接拖进去即可。点击发布之后,编辑器会通过 GitHub API 在博客仓库中生成一次提交,随后触发上面提到的 GitHub Action,服务器拉取仓库之后文章就上线了。已经发布的文章也可以用同样的方式修改,保存时编辑器会检查 GitHub 上的文件在此期间有没有被改动过,避免覆盖在别处做的修改。由于这里的文章都有中英两个版本,编辑器还可以在发布之前把用一种语言写好的文章翻译成另一种语言。
以前通过 api.changkun.de 发布的「想法」,现在也通过这个编辑器来发布,API 也就一并删掉了。
全站统一的登录
文献库和编辑器都需要确认访问者是我本人。现在全站只有一个登录页面 /login:浏览器通过我的 latere 账号完成登录(PKCE 流程),服务器验证令牌之后再签发自己的 cookie。退出登录时也会同时退出 latere,changkun.de 上的所有页面都会一起退出。
接入 Cloudflare:DNS 与边缘缓存
2020 年在《零宕机迁移》中,我曾经这样写过:
如果你问我为什么不用 Cloudflare?懒。一方面个人网站不想引入过多依赖;另一方面,golang.design 启用了 Cloudflare,也算是和 changkun.de 做是一个 A/B 对照。
这个 A/B 对照一做就是六年,这次 changkun.de 也终于接入了 Cloudflare。
首先是 DNS。之前这些域名的记录都是在各自的控制台里手动修改的,现在 changkun.de、changkun.org、golang.design、poly.red 和 changkun.us 这五个域名的 DNS 记录全部写在 infra 仓库的一个文件里,通过 DNSControl 推送到 Cloudflare。例如 changkun.de 的部分记录:
|
|
需要注意的是,DNSControl 推送时会删除文件中没有写的记录,所以每次推送之前都要先 dnscontrol preview 一下。另外,Cloudflare 的代理并不转发 SSH,所以 ssh.changkun.de 这条记录必须保持不经过代理,各个仓库的发布流程都是通过它连接服务器的。
然后是缓存。网站会给每一个公开的响应加上一个 Cloudflare-CDN-Cache-Control 头:
|
|
它的含义是:一个页面在 Cloudflare 的边缘节点上缓存一分钟,在这一分钟内直接由边缘节点返回;超过一分钟之后,Cloudflare 仍然会先返回缓存中的页面,同时在后台向服务器请求新的版本;如果服务器暂时无法访问,Cloudflare 在一周之内都可以继续返回缓存中的页面。这样一来,页面修改之后,一分钟后的下一次访问就会让 Cloudflare 取回新的版本,也就不需要手动清除缓存。之所以使用 Cloudflare-CDN-Cache-Control 而不是 Cache-Control,是因为前者只有 Cloudflare 会读取,并且不会转发给浏览器,浏览器的缓存行为也就保持不变。Zone 上配置的 Cache Rule 只缓存带有这个头的响应,所以文献库、编辑器和登录页这些因人而异的页面都不会被缓存。
这里还有一个需要处理的问题:缓存的前提是页面对所有人都是一样的。而在这之前,文章页面上的「编辑」链接是服务器在确认访问者是我之后才加上去的。所以现在每一篇文章中都带有这个链接,只是默认隐藏,当浏览器中存在一个表示已登录的 cookie 时,再由几行脚本将它显示出来。这个 cookie 本身不授予任何权限,编辑器仍然会检查真正的会话。
从结果来看,效果还是比较明显的:上线之后的几个小时里,状态检查测得的响应时间中位数,changkun.de 从约 420 毫秒降到了约 190 毫秒,golang.design 从约 410 毫秒降到了约 130 毫秒。需要说明的是,状态检查每次都会建立新的连接;如果连接已经建立好了,我在欧洲打开一个页面大约只需要 30 毫秒。当然,一个很久没有人访问过的页面,第一次访问时仍然需要回到服务器上取一次。
基于 GitHub Actions 的状态页
upbot 已经退役,取而代之的是 changkun/status 仓库中的一个 GitHub Actions 工作流,它每五分钟检查一遍所有的站点和服务,将结果发布在 changkun.github.io/status 上,一旦有服务出现问题,就开一个 issue 并提醒我。
这和 2020 年「尽可能自研」的原则看起来是相悖的,不过理由也很直接:upbot 和它所监控的服务运行在同一台机器上,如果机器本身出了问题,upbot 也会跟着一起出问题,而 GitHub Actions 并不依赖这台服务器。原本我还打算给状态页配上 status.changkun.de 这个域名,但 GitHub Pages 一直没有为它签发证书,删掉域名再重新添加也没有用,所以它暂时就留在了 github.io 上。
统一的页面设计
所有站点现在都使用同一套设计,包括主页、博客、研究主页、文献库、几本书(贝叶斯优化、AI Alignment、这个无法验证的世界、现代 C++ 教程)、瑞典语和德语教程、redir 与 urlstat 的控制台,以及状态页。它们统一使用 Inter 字体和以黑白为主的配色,共用博客的页头,深浅色模式的选择也会在 changkun.de 的各个页面之间保持一致。另外,每个阅读页面都接入了 urlstat,在页脚显示浏览量和访客数。
目前运行的服务
重构之后,changkun.de 的整体结构如下:
|
|
- traefik:反向代理
- website:changkun.de 和 golang.design 的所有网页
- redir:短链接和 Go 的导入路径,服务 changkun.de、golang.design 和 poly.red
- urlstat:页面浏览统计
- postgres:redir 和 urlstat 共用的数据库
- midgard:我的剪贴板服务
- gossa:golang.design 的 SSA Playground
- rankui 用户研究:部署在 poly.red/study
如何从备份或者从零开始重建整台服务器,都写在了 infra 仓库的 README 中。
借助 Claude Code 完成重构
2020 年的那次迁移花了我圣诞假期的前三天,所有的代码都是自己一行一行写的。这次重构花了一个周末,从周五晚上一直持续到周一早上,涉及 14 个仓库,一共 174 次提交,其中 website 79 次、infra 33 次、status 15 次、blog 11 次、redir 9 次。不过这一次,绝大部分代码并不是我自己敲的,而是由 Claude Code 完成的。
具体的做法是同时开几个 Claude Code 会话,各自负责不同的仓库:一个负责网站,一个负责书,一个负责教程,等等。我的工作主要是描述清楚自己想要什么,阅读它交回来的修改,然后决定是接受还是让它继续修改。和自己写代码相比,更多的时间花在了阅读和判断上。
整个过程中有一条规矩:Claude Code 可以修改代码、提交并推送,但凡是涉及生产环境的操作,例如部署、推送 DNS、修改 Cloudflare 的设置、删除服务器上的文件夹或容器,都必须由我自己执行。它负责把命令写好,我读过之后再自己敲。这样做确实会慢一些,但可以保证没有任何改动是在我没有看过的情况下进入生产环境的。
当然,整个过程也并不是一帆风顺的,中间遇到的几个问题也值得记录一下:
- 服务器上的 git 版本低于 2.28,而网站的测试中用到了
git init -b。由于make build会先运行测试,结果有一次在服务器上部署时就因为测试失败而中断了。 - 接入缓存之后,Cloudflare 一度给瑞典语教程返回了新的 HTML 和旧的样式表。解决的办法是把样式和脚本发布到一个以内容哈希命名的文件夹中,内容一变,地址也就跟着变了。
- 前面提到的 status.changkun.de 证书问题。
- 前面提到的「编辑」链接:想要让页面能够被缓存,首先得找出页面上所有因人而异的地方。
回顾
这篇文章首先回顾了 changkun.de 在 2021 到 2026 年这六年间积累下来的服务和问题,然后介绍了这次重构的动机以及重构之后的架构:所有网页由同一个 Go 程序 changkun/website 提供;博客去掉了 Hugo,直接从 Markdown 渲染;DNS 统一由 DNSControl 管理,页面通过 Cloudflare 缓存在边缘节点上;监控则交给了 GitHub Actions。最后还简单介绍了这次借助 Claude Code 完成重构的过程。
和 2020 年相比,这次重构其实并没有引入什么新的技术,Go、Docker Compose 和 Traefik 都还在,Makefile 和 Dockerfile 甚至都没怎么变。变化主要在于把这几年叠加上去的东西重新整理了一遍,把能够合并的服务合并到一起,并删掉了已经不再需要的部分。
这些服务都是开源的,感兴趣的读者可以在下面的链接中找到:
- https://github.com/changkun/website
- https://github.com/changkun/blog
- https://github.com/changkun/infra
- https://github.com/changkun/redir
- https://github.com/changkun/urlstat
- https://github.com/changkun/midgard
- https://github.com/changkun/status
新纪元的设计用了将近六年,希望这次重构之后的结构能够用得更久一些。
欧长坤
2026 年 11 月 9 日