Changkun's Blog欧长坤的博客

Science and art, life in between.科学与艺术,生活在其间。

  • Home首页
  • Ideas想法
  • Posts文章
  • Tags标签
  • Bio关于
  • TOC目录
  • Overview概览
Changkun Ou

Changkun Ou

Human-AI interaction researcher, engineer, and writer.人机交互研究者、工程师、写作者。

Bridging HCI, AI, and systems programming. Building intelligent human-in-the-loop optimization systems. Informed by psychology, sociology, cognitive science, and philosophy.连接人机交互、AI 与系统编程。构建智能的人在环优化系统。融合心理学、社会学、认知科学与哲学。

Science and art, life in between.科学与艺术,生活在其间。

276 Blogs博客
165 Tags标签
  • Migration History
    • The Wild Age (2008–2013): BBS
    • The Ancient Age (2013–2015): WordPress (LAMP Stack)
    • The First Age (2015–2016): Hexo + GitHub Pages
    • The Second Age (2016–2017): Hexo + Git Server + HTTPS
    • The Third Age (2017–2020): Hexo + GitHub + Travis CI + VPS
  • Motivation for Migrating
  • The Cost of Migration
  • The New Age (2021–)
    • Migrating from Hexo to Hugo
    • Building with the Go 1.16 Embed Feature
    • Lightweight Container Orchestration
    • Monitoring, Alerting, and Other Services
  • Retrospective
  • 迁移历史
    • 蛮荒纪元(2008-2013):BBS
    • 远古纪元(2013-2015):Wordpress (LAMP Stack)
    • 第一纪元(2015-2016):Hexo + GitHub Pages
    • 第二纪元(2016-2017):Hexo + Git Server + HTTPS
    • 第三纪元(2017-2020):Hexo + GitHub + Travis CI + VPS
  • 进行迁移的动机
  • 迁移的成本
  • 新纪元(2021-)
    • 从 Hexo 迁移到 Hugo
    • 利用 Go 1.16 新特性进行编译
    • 容器化轻量级编排
    • 监控报警和其他服务
  • 回顾
Changkun's Blog欧长坤的博客

The All in Go Stack全面转向 Go 技术栈

Published at发布于:: 2020-12-24   |   Reading阅读:: 26 min   |   PV/UV: /

Since graduating from university I have had very little time to tinker with my own website. Most of the time I would ssh into the server, throw together some service, and then leave it to fend for itself — even if a service went down I did not pay much attention. After all, it was something I ran for my own use, and as far as visitors were concerned: ABSOLUTELY NO WARRANTY. But as the site’s user numbers have recently grown at an almost unbelievable rate (despite doing essentially nothing), I naturally find myself wanting to make it more “reliable”.

As some of the last items on my 2020 TODO list, I finally spent the first three days of my Christmas holiday completing a full “architecture upgrade” of changkun.de. The previous setup was an organisationally chaotic, dependency-heavy mess of native Nginx + Docker + tmux-served binaries + Jupyter notebooks + hypervisors + crontabs + … relying on C/C++/Python/Node.js/Go/… and countless third-party dependencies. All of that has now been replaced by a pure Go backend guided by the principle: “build in-house wherever possible, avoid dependencies wherever you can, and when you must depend on something, depend on something written in Go”.

This post describes changkun.de as a personal website — from its early days of fewer than a hundred active users per year to today’s tens of thousands of active users per month — covering what services (public but not especially visible) have accumulated along the way, and the migration story behind them.

Migration History

Every “major” migration of the blog changkun.de/blog has been documented in a post, and they can all be found here.

Looking back, changkun.de has quite a long migration history. From the earliest days of content scattered across various BBS platforms (Tieba, Sina Blog, QQ Zone), to WordPress during my undergraduate years (2012–2015), then to the first and second iterations of Hexo (2015–2017), the third Hexo iteration that lasted until recently (2017–2020), and now the freshly launched pure Go application (2021–present).

The Wild Age (2008–2013): BBS

Before 2013, I was a pure mathematics enthusiast (in high school) who knew absolutely nothing about computing and had once looked down on it. I spent my time on early BBS communities like the geometry and mathematics boards on Tieba, Mop, and Tianya, chatting about maths with strangers — naturally I had no idea what “building a website” meant. Much of my early content is scattered across those platforms; a lot of what appears in the blog as early writing was later collected from those sources. The specific history is almost impossible to trace, and at that point I had not yet registered changkun.de.

The Ancient Age (2013–2015): WordPress (LAMP Stack)

The LAMP Stack Figure 1: The LAMP Stack

I had expected to study mathematics at university, but ended up in computer science almost by accident. That was when I truly started learning the subject. A year in, while still sneaking into mathematics department lectures, I spent a small amount of time picking up various C tricks (reading books like C and Pointers and Expert C Programming) and formed a basic understanding of what computer science was about — and it was then that I built my first website. The domain I registered back then is not the one I use now (I no longer own it, so I will not mention it here). In 2013 there was no convenient tool like Docker; building an independent site meant going through roughly these steps:

  1. Purchase a domain and a server.
  2. Manually compile and install MySQL on the server.
  3. Manually install WordPress on the server.
  4. No HTTPS.

The First Age (2015–2016): Hexo + GitHub Pages

The Hexo + GitHub Pages Stack Figure 2: Hexo + GitHub Pages

Three years of undergraduate study came and went quickly. I had grown from someone who knew nothing about computing into someone who could write a parser in C by hand — but before I could enter the workforce, in the second half of 2015 I found myself in Germany for a semester of study abroad. At the time I had very little knowledge of web technology, until I sat in on a course called Online Multimedia. That opened up a whole new world: Polymer, Angular, MongoDB. Curious about various web technologies, I discovered the concept of static blogs and came across the relatively mature Hexo.

Given that the blog was a personal space with fewer than a hundred active users per year, continuing with Linux+Apache+MySQL+PHP was clearly overkill and unwieldy. On top of that, my server was on Alibaba Cloud in China while I was in Germany, and a direct SSH connection would typically drop after just a few commands — making it impossible to get anything done. So I bit the bullet and migrated the site to GitHub Pages. That migration involved:

  1. Exporting posts from WordPress/MySQL and converting them to Markdown.
  2. Compiling the static blog locally and pushing to GitHub by hand.
  3. Still no HTTPS.

The Second Age (2016–2017): Hexo + Git Server + HTTPS

The First Age brought liberation from the heavy LAMP stack, cutting the whole thing down to zero-cost “serverless” computing.

But this was not without drawbacks. The internet environment back then was not as good as it is today. My early WordPress site had been well indexed by search engines and received a fair amount of organic traffic. After deploying to GitHub Pages, however, the blog was never indexed again — the site had effectively vanished from the internet. Another drawback was that GitHub Pages at the time did not support custom HTTPS, and although it is arguably unnecessary for a static blog, being able to enable HTTPS was already a mark of prestige.

HTTPS is now required Figure 3: HTTPS is now required

A key historical turning point that pushed the entire internet toward HTTPS was Apple’s decision to require iOS app APIs to use HTTPS. At the time, the mobile internet boom was in full swing and I was very keen to build an app for my blog. If any early readers of the blog are still around, they may remember that this blog once had both a desktop client (based on Electron) and a mobile client (based on React Native). To support API access, I even wrote a Hexo RESTful API Generator.

So this migration involved:

  1. Configuring Nginx as a gateway on the server.
  2. Setting up a Git server and configuring a post-hook script.
  3. Compiling the static blog locally, pushing to the Git server, and triggering the post-hook to update the static files under /www/blog.
  4. Enabling HTTPS.

Worth noting: in 2016, Let’s Encrypt did not yet exist, and self-signed certificates were not trusted by mainstream browsers, so finding a reliable and free SSL certificate took some time.

The Third Age (2017–2020): Hexo + GitHub + Travis CI + VPS

HTTPS is now required Figure 3: HTTPS is now required

The approach of the Second Age was not particularly secure, and it was heavily dependent on the safety of the local source files. If those files were lost, recovering Markdown from compiled HTML would have been considerable work (handling images, equations, and so on). Furthermore, pushing the compiled static files to the server each time was severely limited by the transfer speed between the local machine and the server. This was manageable for plain text, but pushing images or other binary files was a disaster. I still remember opening the laptop, running push, and then waiting a full hour for a single blog post update — uploads from Germany to an Alibaba Cloud server in southern China were around 10 KB/s at the time.

To address the slow connection, the blog was given a CI/CD pipeline. The core purpose was simple: once I pushed the source, the CI system would handle the compilation and upload automatically, reducing each blog update to a single commit. While CI did not completely solve the upload speed problem to Alibaba Cloud, the upload was now asynchronous, which was acceptable on the whole.

The dominant CI/CD system at the time was Travis CI, so that is what went in. The migration itself was not large, but a significant amount of time was spent tuning the pipeline — for example using gulp (a bundler that predates Webpack) to minify assets and compress images. These things seem trivial today, but were not so straightforward back then.

There was also one notably unusual aspect: route design. The site’s URLs were structured as:

  • Posts: /archives/{{year}}/{{month}}/{{id}}
  • Images: /images/posts/{{id}}/{{image}}.{{format}}

The ID here was a global post ID. Since Hexo does not assign global IDs to posts the way a database would, I wrote a dedicated script for this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import yaml
import glob
import time

POST_DIR = 'source/_posts/'


def loader():
    posts = glob.glob(POST_DIR + '*.md')
    settings = []

    for post in posts:
        raw = open(post).read()
        head = raw.split('---')[1]
        try:
            setting = yaml.load(head)
            if set(['title', 'date', 'tags', 'id']).issubset(setting):
                setting['path'] = post
                settings.append(setting)
            else:
                print('YAML parsing error: ', post)
        except Exception as e:
            print('YAML head not avaliable: ', post)

    return len(settings) == len(posts), settings


def id_repair():
    all_correct, settings = loader()
    if all_correct:
        print('ALL SUCCESS')
        fixed = sorted(settings, key=lambda head: head['date'])
        for index, fix in enumerate(fixed):
            fix['id'] = index + 1
        return fixed, settings
    else:
        print('PLEASE FIX YAML HEAD FORMAT FIRST')


def id_writer():
    # goal:
    # repair id: ../images/posts/old/* => /images/posts/fixed/*
    fixed, old = id_repair()
    for i, fix in enumerate(fixed):
        try:
            with open(fix['path'], 'r', encoding='utf-8') as f:
                raw = f.read()
                try:
                    content = raw.split('---', 1)[1].split('---', 1)[1]
                    content = content.replace('](../images/posts/', '](/images/posts/')
                    content = content.replace(
                        '/images/posts/' + str(old[i]['id']), '/images/posts/' + str(fix['id']))
                except Exception as e:
                    print('ERROR: ', fix['path'])
                    print(e)

            with open(fix['path'], 'w', encoding='utf-8') as f:
                f.write('---\n')
                stream = yaml.dump(
                    fix, default_flow_style=False, allow_unicode=True)
                f.write(stream.replace('\n- ', '\n  - '))
                f.write('---')
                f.write(content)
                f.close()
            print('SUCCESS: ', fix['path'])
        except Exception as e:
            print('FAILED: ', fix['path'])
            print('REASON: ', e)


def main():
    id_writer()


if __name__ == '__main__':
    main()

The script iterates over all posts, sorts them by publication date, and assigns IDs in chronological order. The reasons for this approach were:

  1. A new post might need to be inserted at an old date (for example, recovering something written on an early BBS that was worth archiving).
  2. During writing, using relative paths for images makes previewing convenient; after generation, the image URLs are rewritten to ensure correct routing.

Looking back, the /year/month/id routing was frankly user-hostile, because it conveys no useful information whatsoever. At the time it was perhaps motivated by a desire to make scraping harder — but that now seems pointless, and it has only complicated the current migration.

Motivation for Migrating

The first major reason for this migration was data-driven. As mentioned above, this blog once had fewer than a hundred active users per year, but has now grown to tens of thousands of active users per month — something that genuinely astonished me.

This is partly thanks to some anonymous promotions this year that I had nothing to do with — for example, the clumsy C++ tutorial I wrote as an undergraduate was promoted by the well-known Chinese publication Jiqizhixin without ever contacting me. That vaulted me into the ranks of people with a GitHub project boasting 11k+ stars. If any reader has spotted the many typos in that tutorial, I sincerely apologise — but seeing one’s own work recognised by others is genuinely pleasing, whatever the circumstances.

The second reason for migrating is that it had become increasingly difficult to compile the entire Hexo-based blog on a fresh machine. My personal habit is to wrap every build step in a single make command. But over time I found the Hexo ecosystem and everything around it making ever more breaking changes:

  1. The gulp build script no longer ran correctly — fixing the breaking changes required re-learning how gulp works and rewriting the build script from scratch.
  2. It was impossible to upgrade the blog cleanly to Hexo 5.0 or later.
  3. The upstream theme, hexo-theme-next, had long since stopped being maintained.
  4. My own fork, changkun/hexo-theme-next, contained a great deal of bespoke customisation.
  5. …and so on.

All of this made the entire Node.js ecosystem feel profoundly unreliable. Combined with my increasingly heavy use of Go over recent years, and a growing attraction to the New Jersey Style philosophy that Go embodies, I found I trusted Go’s authors far more than I trusted the wildly inconsistent Node.js and npm ecosystem. That set a clear goal for this migration:

Could these personal services — a mixture of C/C++/Python/JS/Go — be restructured and simplified into a website driven purely by Go?

As the existence of this post demonstrates, the answer is yes.

The Cost of Migration

Any migration naturally prompts the question: it’s 2020 — why aren’t you using Kubernetes yet? Admittedly, a personal website is a technology playground, and experimenting has always been a major motivation for maintaining one. So it is not that I never considered it. Between 2018 and 2020 I made three attempts to move toward Kubernetes and the broader cloud-native ecosystem for this modest static blog. But my answer today remains: do not multiply entities beyond necessity (Occam’s Razor). Kubernetes is excellent and solves real problems in its domain; a personal website has different needs.

Before the migration, all my services ran on a $5/month Digital Ocean Droplet. Let us work out what Kubernetes would actually cost:

  1. Kubernetes Cluster: $30/month (1 Worker Node = $10/month, ×3)
  2. Container Registry: $5/month (Basic Plan, 5 repos, 5 GB storage)
  3. Spaces: $5/month (250 GB)

I did look at other clouds, but most are more expensive than DO (GCP, for instance). It is not that I could not afford it — the undergraduate version of me, obsessed with adopting the latest technology, would certainly have switched. But with experience comes conservatism.

The first reason is having witnessed enough cycles: I was once a heavy Evernote user, but in my view the wildly popular Notion is just another Evernote riding a wave of venture capital, and I no longer use Evernote either — migrating all those notes out is still a headache I have not fully dealt with. There are many other examples. The second reason is my conviction that the essence of technology does not really change; what is called “new” is usually old wine in a new bottle. Rather than chasing every wave, I would rather sit down, read some papers, and ask myself how many mathematically grounded algorithms I can actually derive and implement.

So back to the original question: what Go stack actually makes sense for a personal website in 2020?

The New Age (2021–)

Migrating from Hexo to Hugo

The first major step toward a Go stack was escaping from Hexo. This was not a trivial requirement. Although I could discard the old Next theme entirely, I had used it for so many years that I hoped to find an equivalent for Hugo — and sure enough, someone had already done it: xtfly/hugo-theme-next.

Unfortunately the author built it for their own use, with limited migration support, and the initial experience revealed several problems:

  1. Animation flickering on load: this seems to be a longstanding Next theme bug — the author had apparently ported the JavaScript directly.
  2. Superfluous features: geolocation tracking in the sidebar, post categories (I prefer tags exclusively), and so on.
  3. Required customisations: changing the “Powered by XYZ” footer to “Copyright Changkun Ou”, and similar tweaks.

The route design from the Third Age had to be dealt with in this migration too. Post URLs were unified to the /posts/:slug format. Hugo helpfully provides an alias feature: you can list alternative URLs in a post’s YAML front matter, and visitors to those URLs will be redirected to the canonical slug:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
---
-date: 2020-03-08 21:56:31
+date: "2020-03-08 21:56:31"
+toc: true
id: 264
-path: source/_posts/2020-03-08-我为什么不再写博客了?.md
+slug: /posts/why-i-stopped-blogging
+aliases:
+    - /archives/2020/03/264/
tags:
-  - 随笔
+    - 随笔
+    - 博客
title: 我为什么不再写博客了?
---

This was not done by hand, of course. I wrote a small Go script for it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
type oldh struct {
	Date  string   `yaml:"date"`
	ID    int      `yaml:"id"`
	Path  string   `yaml:"path"`
	Tags  []string `yaml:"tags"`
	Title string   `yaml:"title"`
}
type newh struct {
	Date    string   `yaml:"date"`
	TOC     bool     `yaml:"toc"`
	ID      int      `yaml:"id"`
	Slug    string   `yaml:"slug"`
	Aliases []string `yaml:""`
	Tags    []string `yaml:"tags"`
	Title   string   `yaml:"title"`
}
func main() {
	filepath.Walk("../../content/posts", func(path string, info fs.FileInfo, err error) error {
		if info.IsDir() { return nil }

		b, err := os.ReadFile(path)
		if err != nil { panic(err) }

		ss := strings.Split(string(b), "---")
		header := []byte(ss[1])

		var old oldh
		err = yaml.Unmarshal(header, &old)
		if err != nil {
			log.Fatalf("cannot parse configuration, err: %v\n", err)
		}

		dd, err := time.Parse("2006-01-02 15:04:05", old.Date)
		if err != nil {
			panic(err)
		}

		newHeader := newh{
			Date: old.Date,
			TOC:  true,
			ID:   old.ID,
			Slug: fmt.Sprintf("/posts/%s", strings.Replace(strings.ToLower(old.Title), " ", "-", -1)),
			Aliases: []string{
				fmt.Sprintf("/archives/%d/%02d/%d/", dd.Year(), dd.Month(), old.ID),
			},
			Tags:  old.Tags,
			Title: old.Title,
		}

		b, err = yaml.Marshal(newHeader)
		if err != nil {
			panic(err)
		}

		ss[1] = string(b)

		err = os.WriteFile(path, []byte(strings.Join(ss, "---\n")), os.ModePerm)
		if err != nil {
			panic(err)
		}
		return nil
	})
}

When migrating the routes, the Disqus comments also needed migrating. Disqus provides a migration tool that accepts a URL mapping table, for example:

1
2
3
4
5
6
https://changkun.de/blog/archives/2020/01/262/, https://changkun.de/blog/posts/2018-2019-reading/
https://changkun.de/blog/archives/2020/02/263/, https://changkun.de/blog/posts/2019-summary/
https://changkun.de/blog/archives/2020/03/264/, https://changkun.de/blog/posts/why-i-stopped-blogging/
https://changkun.de/blog/archives/2020/03/265/, https://changkun.de/blog/posts/setup-wordpress-in-10-minutes/
https://changkun.de/blog/archives/2020/09/266/, https://changkun.de/blog/posts/eliminating-a-source-of-measurement-errors-in-benchmarks/
https://changkun.de/blog/archives/2020/11/267/, https://changkun.de/blog/posts/pointers-might-not-be-ideal-for-parameters/

In summary, the final Hugo blog uses a theme I forked and maintain myself. Interested readers can find my customised version in the blog’s source repository. The fork focuses on fixing the issues mentioned above and adds:

  1. Dark mode
  2. Disqus support; removal of some redundant sharing buttons (Baidu, WeChat, Duoshuo — the last of which has long since shut down)
  3. Blogroll (友链)
  4. Tag cloud scaled by frequency
  5. …and more

Building with the Go 1.16 Embed Feature

Go 1.16 introduced a new feature for embedding files directly into a compiled binary.

The following code shows how to use this Go 1.16 feature to bundle all the generated static files under public/* into a single binary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
	"embed"
	"io/fs"
	"net/http"
	"os"
	"path"
)

//go:embed public/*
var public embed.FS

// blogFS implements fs.FS
type blogFS struct {
	content embed.FS
}

// Open opens a given filename from the public folder.
func (b blogFS) Open(name string) (fs.File, error) {
	return b.content.Open(path.Join("public", name))
}

func main() {
	r := http.NewServeMux()
	r.Handle("/", http.FileServer(http.FS(blogFS{public})))
	const addr = "0.0.0.0:9129"
	s := &http.Server{Addr: addr, Handler: r}
	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		panic(fmt.Sprintf("cannot listen on %s, err: %v\n", addr, err))
	}
}

Lightweight Container Orchestration

Having decided to move the entire site to a pure Go stack, we needed to think through the changes required. Given that Kubernetes was ruled out, what about Docker Swarm as a lighter alternative? Equally unnecessary. But containerisation itself was a given: the packaging and distribution convenience it brings, along with runtime isolation and the ability to run multiple replicas, far outweighs its downsides for long-term management. And since Docker is also a Go project (politically correctly called Moby), Docker Compose was the natural companion.

One of the things I love about Go is its near-perfect compilation characteristics. A simple go build resolves almost all dependencies — no static linking to worry about, no runtime dynamic linking to manage. Those steps happen, but they are not my problem as the developer. This makes the entire build pipeline remarkably simple:

1
2
3
4
5
NAME=main
VERSION = $(shell git describe --always --tags)
build:
	CGO_ENABLED=0 GOOS=linux go build # cross-compile
	docker build -t $(NAME):$(VERSION) -t $(NAME):latest -f Dockerfile .

The Dockerfile simply drops the compiled binary into an alpine image:

1
2
3
4
# just copy the binary
FROM alpine
COPY main /app/main
CMD ["/app/main"]

And the Docker Compose file is equally straightforward:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
version: "3"
services:
  main:
    build:
      context: .
      dockerfile: Dockerfile
    restart: always
    image: main:latest
    deploy:
      replicas: 3

Monitoring, Alerting, and Other Services

Beyond the main website and blog, several other services are running on the server. To maintain stability, these need to be monitored and any problems flagged. While a personal website does not come with an SLA, having services down for days at a time is a poor experience for visitors. Personal monitoring can be done very crudely — a crontab that keeps curling an endpoint and fires off an email script when the endpoint is unavailable — or very elaborately, with Prometheus, Grafana, CPU and memory usage dashboards, and so on. Both are viable, but from long experience, only things you write yourself remain maintainable and compatible over the long term.

So as part of this upgrade I wrote a monitoring service for changkun.de called upbot, integrated with the Mailgun email service. It pings each service at a fixed interval to check availability, and sends me an email alert when something goes down.

Alongside the monitoring service, changkun.de also runs several services I use personally:

  • midgard: a cross-platform clipboard service that automatically syncs the clipboard between Linux, Mac, and iOS, and can quickly create a public link for clipboard contents or local files (built out of personal need, though setting it up is somewhat involved — I should record a usage tutorial some day, Orz)
  • occamy: a universal remote desktop service supporting VNC/RDP/SSH protocol translation
  • redir: a URL shortener with PV/UV analytics

Space does not permit going into detail here; we will cover them another time. Interested readers can find the links at the end of this post.

There are also a few services running on the server that I use privately without making public — for instance, a Jupyter server. In the old days these were either spun up with python3 -m http.server directly inside a tmux session, or kept alive with a hypervisor (deployment method depending on mood at the time). This migration has cleaned all of that up and containerised everything properly.

Finally, with this many services running on one server, a reverse proxy is essential. I could write one myself — Go’s standard library does provide httputil.ReverseProxy — but building a reasonably reliable, feature-complete reverse proxy is considerably more complex than writing a simple monitoring service (at minimum it would need request cancellation, retries, caching, and logging). From the ancient Apache, to the third-age Nginx, and now aiming for at least a Go-written solution, Traefik — which grew out of the cloud-native ecosystem — is the obvious choice. It is extremely easy to use; reading the documentation is enough to get started.

Retrospective

This post has traced the migration history of changkun.de as a personal website, the problems and decisions encountered at each step, and the most recent “architecture upgrade” those decisions motivated. It also introduced the services that were built in-house to support the migration and are now running in production. The highlight of this migration is that the entire website now runs entirely on Go, achieving microservices in a meaningful sense. As a bonus, the post also covered how to use Go 1.16’s embedded files feature to bundle a site’s static files directly into the distributable binary.

The overall structure of changkun.de as it stands today is shown in the diagram below:

All of these services are written in Go and open source. Interested readers can find them at the following links:

  • https://changkun.de/s/main
  • https://changkun.de/s/blog
  • https://changkun.de/s/midgard
  • https://changkun.de/s/redir
  • https://changkun.de/s/upbot
  • https://changkun.de/s/occamy
  • https://changkun.de/s/modern-cpp-tutorial (this one is an exception :)

In addition, there are services running on golang.design:

  • https://changkun.de/s/go-under-the-hood
  • https://changkun.de/s/go-history
  • https://changkun.de/s/gossa
  • https://changkun.de/s/code2img

They were once hosted on changkun.de and can be thought of as projects that incubated there. The Third Age design lasted around three years; I hope this new “architecture” — backed by Go’s stability and compatibility — will serve reliably for more than three years to come.

Changkun Ou

24 December 2020, Munich

本科毕业以后就很少有时间来折腾自己的网站了,大部分时间里都是 ssh 到服务器上随便搭 好一个服务后就任其自由生长,即便是服务挂掉了之后也没有太在意——毕竟是自己用的东西, 对于网站的用户而言:ABSOLUTELY NO WARRANTY。不过随着网站近来的用户数量呈现难以置信 的增长势头(虽然什么也没干),自然也就难免希望让网站变得更加「可靠」。

作为 2020 年的最后几项 TODO,我终于在圣诞节休假的第一周的前三天完成了整个 changkun.de 的「架构升级」,从原来组织混乱、 依赖复杂的 Native Nginx + Docker + tmux served binary + Jupyter notebook + hypervisor + crontab + … 等等依赖 C/C++/Python/Node.js/Go/… 以及数不胜数的第三方依赖全面转向了以「尽可能自研、能不依赖就不依赖、 即使以来也要依赖使用 Go 开发的依赖」为指导思想的纯 Go 的后端结构。

这篇文章就介绍了 changkun.de 作为个人网站, 从最初的每年不到一百的活跃用户到现在的每月上万活跃用户这个过程中 究竟积累并承载了哪些(公开的、但不那么可见的)个人以及面向公共的服务,以及它背后的迁移故事。

迁移历史

博客 changkun.de/blog 的每次「重大」迁移,其实都有文章记录, 在这里可以找到。

回顾过去来看,changkun.de 其实有相当长的迁移历史。 从最早的内容散落在各种 BBS(贴吧、新浪博客、QQ 空间)、到后来本科时期 Wordpress(2012-2015) 再到到后来初次迁移到 Hexo 的第一、二版(2015-2017)以及沿用至今的 Hexo 第三版(2017-2020) 和刚刚上线的纯 Go 应用版(2021-至今)。

蛮荒纪元(2008-2013):BBS

在 2013 年以前,我还是一个彻头彻尾的不知计算机技术为何物的纯数学爱好者(高中), 对计算机技术曾嗤之以鼻。混迹在诸如几何吧、数学吧、猫扑天涯这类早期的 BBS 与人版聊数学, 自然也不懂建站为何物。很多早期的内容都散布于此,博客里面很多早期的文字都是从这些地方 后续搜集得来,具体的历史几乎不可考究,那时候还没有注册 changkun.de 这个域名。

远古纪元(2013-2015):Wordpress (LAMP Stack)

The LAMP Stack 图 1: The LAMP Stack

本科本以为稳稳的数学系结果误打误撞来到了计算机系,才开始真正开始学习计算机科学,入学一年后, 在沉迷旁听各种数学系课程的过程中,花费了极少的时间掌握了各种 C 语言的奇淫巧技(比如熟读 《C 和指针》、《C 专家编程》这类书籍)后大致建立了对计算机科学在研究什么样的问题这一基本概念后, 而完成了第一次的建站。那时候的博客所注册的域名并不是现在这个域名(如今我也不再拥有该域名, 所以这里就不提了)。早在 2013 年的时候并没有如今 Docker 这种便利的工具,想要独立建站,不外乎 以下这些步骤:

  1. 购买域名和服务器
  2. 手动在服务器上编译源码并安装 MySQL
  3. 手动在服务器上安装 Wordpress
  4. 没有 HTTPS

第一纪元(2015-2016):Hexo + GitHub Pages

The Hexo + GitHub Pages Stack 图 2: Hexo + GitHub Pages

本科三年很快就结束了,我也从那个对计算机一无所知成长为了一个能够用 C 语言徒手撸 parser 的萌新, 可惜还没来得及进入职场就已经在 2015 年的下半年应差阳错的来到了德国开启了半年的留学访问之旅。 但那时并没有对 Web 技术有多少了解,直到我旁听了一门 Online Multimedia 的课,才了解到了 这个世界上还有 Polymer、Angular 这样的前端技术、以及 MongoDB 这样的非关系型数据库, 就像开启了新世界的大门。由于开始尝鲜各种 Web 技术,了解到了静态博客的概念,发现了相对成熟的 Hexo 的存在。

考虑到博客作为一个年活跃不超过 100 个用户的自留地,继续使用 Linux+Apache+MySQL+PHP 这样的组合无疑是愚蠢而笨重的。与此同时因为当时的服务器在国内阿里云,而我身在德国直接 SSH 上服务器通常敲几个命令就会掉线,做不了任何事情。于是狠下心来决定将网站迁移到 GitHub Pages 上,这次迁移的工作包括:

  1. 从 Wordpress 将文章从 MySQL 中导出并转化为 Markdown 的格式
  2. 在本地编译博客静态文件,手动 push 到 GitHub
  3. 继续没有 HTTPS

第二纪元(2016-2017):Hexo + Git Server + HTTPS

第一纪元的的开启意味着整个网站从繁重的 LAMP 栈中摆脱出来,直接削减为了零成本的 「Serverless」计算。

但这并不是没有缺点,其实当时整个网络环境并不如今天这么好,我早年的 Wordpress 站点曾被搜索引擎 索引得很好,也有相当的流量从搜索引擎直接倒入。但由于 GitHub 自身优化的原因,博客部署到 GitHub Pages 之后便再没有被搜索引擎给索引,相当于整个站点从互联网上直接就消失了。 另一个缺点是当时的 GitHub Pages 也还不支持配置 HTTPS,虽然说作为一个静态博客这种东西没有必要, 但那时候能够启用 HTTPS 已经是一种尊贵的象征。

HTTPS is now required 图 3: HTTPS is now required

推动整个互联网向 HTTPS 迈进的一个历史性的关键节点是苹果的 iOS 开始强制应用 API 必须使用 HTTPS 协议,当时移动互联网热潮翻涌,我也非常热衷于希望给自己的博客开发一个 APP,如果还有博客的 早期读者在的话应该还记得,这个博客曾经是有 桌面客户端(基于 Electron)和移动客户端(基于 ReactNative) 支持的, 而且为了能够支持 API 访问,我编写过 Hexo RESTful API Generator 这种东西。

所以,这次迁移的工作包括:

  1. 在服务器上配置好 Nginx 网关
  2. 设置 Git 服务器,配置 post-hook 脚本
  3. 在本地编译博客静态文件,手动 push 到 Git 服务器并触发 post hook 来更新 /www/blog 下的静态文件
  4. 启用 HTTPS

值得一提的是,这个时候(2016)年还没有 LetsEncrypt,而自签名的证书并不会被主流浏览器判断为可靠证书, 所以当时还花了一些时间来寻找可靠且免费的 SSL 证书。

第三纪元(2017-2020):Hexo + GitHub + Travis CI + VPS

HTTPS is now required 图 3: HTTPS is now required

第二纪元的做法并不安全,而且相当依赖本地原始文件的安全性,如果本地文件丢失,那么从编译好的 HTML 中恢复 markdown 将是不小的工作量(图片、公式的处理)。 此外,每次向服务端 Push 编译后的静态文件严重依赖本地和服务器之间的传输速度,这对于纯文本的更新 还好,但一旦需要向服务器 Push 图片、文件等内容时,将是巨大的灾难。我至今仍然记得为了更新一篇 博客,敲完 push 后开着电脑等待一小时的尴尬场景(当时德国向国内阿里云华南地区服务器上传速度 仅为 10kb/s 左右)。

为了解决网速缓慢的问题,于是这个博客开始引入 CI/CD 系统,核心目的其实是当 push 完毕后, CI 系统能够自动帮我完成编译、上传的过程,这样每次更新博客的流程就简化为了为博客源码提交一个 commit。虽然引入 CI 并没有彻底解决向国内阿里云服务器上传文件的速度问题, 但这个上传过程是异步的,总体上是能接受的。

当时比较主流的 CI/CD 系统是 TravisCI,于是很快就用上了。这次迁移的工作虽然不多, 但是期间花了很多时间将整个流程进行调优,例如使用 gulp (早于 Webpack 的一种打包工具) 对 assets 中的文件进行 minify、图片压制等等。这些操作在今天看来无非就是引入一个基本的依赖, 但当时做起来并不简单。

除此之外,有一个比较特别的地方,那就是路由管理。当时网站的路由被设计成了:

  • 页面:/archives/{{year}}/{{month}}/{{id}}
  • 图片:/images/posts/{{id}}/{{image}}.{{format}}

其中的 ID 是文章的全局 ID,但因为 Hexo 这样的工具并不像数据库那样为文章分配全局的 ID, 所以当时还专门写了一个脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import yaml
import glob
import time

POST_DIR = 'source/_posts/'


def loader():
    posts = glob.glob(POST_DIR + '*.md')
    settings = []

    for post in posts:
        raw = open(post).read()
        head = raw.split('---')[1]
        try:
            setting = yaml.load(head)
            if set(['title', 'date', 'tags', 'id']).issubset(setting):
                setting['path'] = post
                settings.append(setting)
            else:
                print('YAML parsing error: ', post)
        except Exception as e:
            print('YAML head not avaliable: ', post)

    return len(settings) == len(posts), settings


def id_repair():
    all_correct, settings = loader()
    if all_correct:
        print('ALL SUCCESS')
        fixed = sorted(settings, key=lambda head: head['date'])
        for index, fix in enumerate(fixed):
            fix['id'] = index + 1
        return fixed, settings
    else:
        print('PLEASE FIX YAML HEAD FORMAT FIRST')


def id_writer():
    # goal:
    # repair id: ../images/posts/old/* => /images/posts/fixed/*
    fixed, old = id_repair()
    for i, fix in enumerate(fixed):
        try:
            with open(fix['path'], 'r', encoding='utf-8') as f:
                raw = f.read()
                try:
                    content = raw.split('---', 1)[1].split('---', 1)[1]
                    content = content.replace('](../images/posts/', '](/images/posts/')
                    content = content.replace(
                        '/images/posts/' + str(old[i]['id']), '/images/posts/' + str(fix['id']))
                except Exception as e:
                    print('ERROR: ', fix['path'])
                    print(e)

            with open(fix['path'], 'w', encoding='utf-8') as f:
                f.write('---\n')
                stream = yaml.dump(
                    fix, default_flow_style=False, allow_unicode=True)
                f.write(stream.replace('\n- ', '\n  - '))
                f.write('---')
                f.write(content)
                f.close()
            print('SUCCESS: ', fix['path'])
        except Exception as e:
            print('FAILED: ', fix['path'])
            print('REASON: ', e)


def main():
    id_writer()


if __name__ == '__main__':
    main()

这个脚本的主要功能就是遍历所有文章,然后将文章按发表时间排序,然后按时间顺序依此为文章分配 ID。 这样做的原因有:

  1. 可能要在某个旧时间节点上插入新的文章(例如找到了一些新的散落在 BBS 值得回收的文章)
  2. 文章编写过程中使用图片的相对路径可以方便预览,生成后通过替换图片的 URL 来保证图片能够被正确的路由。

现在回想起来这种 /year/month/id 的路由简直反人类,因为这样的路由并没有提供任何有用的信息。 但当时可能只是因为防止爬虫能够轻易爬取,现在想来真是没有必要,反倒给现在的迁移带来了麻烦。

进行迁移的动机

这次的迁移的第一大原因是由数据驱动的。上面提到过,这个博客曾经只有不到一百的年活跃用户, 但如今却已经成长为了每月上万的活跃用户了,这真是着实令我吃惊。

当然,这得益于今年莫名其妙的几次匿名推广,例如我本科时期写的蹩手蹩脚的 C++ 教程被国内知名公众 大号机器之心在没有联系我的情况下直接推广 等等。让我一跃晋升为坐拥 11k+ Star GitHub 项目的「大V」。如果有读者看到那个教程里 typo 百出, 我很是抱歉,但无论如何看到自己的作品被他人认可还是很开心的。

进行迁移的第二个原因是我已经越来越难在新的机器上直接顺利编译整个基于 Hexo 的博客了。 通常我的个人习惯会将所有的打包步骤封装到一个 make 命令。但随着时间的推移,我越发的发现 Hexo 及其所围绕的生态开始不断的进行破坏性的更改:

  1. gulp 的打包脚本已经无法顺利执行,breaking changes 的修复需要重新熟悉 gulp 的工作机制并重写打包脚本
  2. 无法将博客顺利的升级到 Hexo 5.0 之后的版本
  3. 所依赖的主题 hexo-theme-next 的源仓库早已停止维护
  4. 自己定制的版本 changkun/hexo-theme-next 包含了太多主题上的定制
  5. …

等等的这些原因让我开始觉得整个 Node.js 生态变得无比的不可靠,加之近几年对 Go 的使用愈发的频繁, 并且开始被 Go 所崇尚的 New Jersey Style 的魅力而吸引,相比野蛮生长的 Node.js 和参差不齐的 NPM 生态,我更愿意相信 Go 的作者们,这也就成为了这次迁移定下了一个很好的目标:

到底能不能将这些混合了 C/C++/Python/JS/Go 的个人网站改造并简化为一个由纯 Go 驱动的网站?

正如你看到了这篇文章的那样,答案当然是可以的。

迁移的成本

进行迁移自然会问自己这样的问题:都 2020 年了,你怎么还没用上 Kubernetes ? 诚然,个人网站充当的是技术的试验场,早年折腾自己博客的主要目的就是做各种各样的技术试验, 所以我并不是没有想过,而且我为了能够用上 Kubernetes 和诸多 Cloud Native 生态, 在 2018 到 2020 年期间为了这个小小的静态博客网站曾三次尝试向其靠拢,但如今的我的回答依然是: 如无必要,勿增实体(Occam’s Razor 原则)。显然 Kubernetes 很好,它有它专注解决的领域问题, 但个人网站自然有其发展的需要。

迁移前,我的所有服务都搭建在一个 $5/月 的 Digital Ocean 的 Droplet。我们不妨来计算一下为了用上 Kubernetes,需要花费多少成本:

  1. Kubernetes Cluster:$30/月(1 Worker Node = $10/月,3x)
  2. Container Registry:$5/月(Basic Plan,5 Repo,5GB Storage)
  3. Spaces:$5/月(250GB)

当然我不是没有考虑过其他的云,但大都比 DO 的开销更大(比如 GCP)。当然,并不是因为负担不起 这笔费用,换做是本科时期对用上一些潮流技术极度痴迷的我,肯定早就换过去了。但随着自己「阅历」 的增加,逐渐开始在这一方面变得越来越保守。

第一个原因是因为见过了一定数量轮替:比如早年我是 Evernote 的重度用户,但如今大热的 Notion 在我看来无非是资本狂欢下的又一个 Evernote,而且我现在也已经不再使用 Evernote,将里面的笔记 整理迁移出来也成为了我一个非常头疼的待办事项。当然这样的例子还有很多,这里就不一一列举了; 第二个原因是在我看来:技术的本质或者原理并没有发生变化,所谓的新生事物对我而言无非是新瓶装旧酒。 与其追逐一些洪流,不如安下心来读一读 paper 并问问自己能够推导并实现多少个需要用到数学的算法。

那么就还是回到了刚才的问题,在 2020 年的今天究竟什么样的 Go 技术栈才适合个人网站?

新纪元(2021-)

从 Hexo 迁移到 Hugo

想要迁移到 Go 的第一个重大的转变就是摆脱 Hexo。这个要求其实并不简单,虽然我可以 彻彻底底的将之前的 Next 主题抛弃,但毕竟这个主题用了这么多年我还是很希望能够在 Hugo 上找到同类主题,不出意料,果然已经有人做过了: xtfly/hugo-theme-next。

不过可惜位老哥做主题也是给他自己用的,没有太多迁移支持,初次尝试下来这个主题还有很多问题:

  1. 动画闪屏:这似乎是 Next 主题的老毛病,估计这位老哥是直接将 Next 主题的 JS 脚本直接搬了过来
  2. 多余的特性:比如边栏中的地理位置追踪,比如文章分类(我个人倾向于直接使用 Tag)等等
  3. 需要额外的定制:Footer 的 Powered by XYZ 改为 Copyright Changkun Ou 等等

前面提到了第三纪元中网站路由的智障设计,因此这次迁移还需要对路由进行迁移,将博客的文章路由统一为 /posts/:slug 的格式。好在 Hugo 提供链接别名的功能,可以在一篇文章的 YAML Header 中指定别名链接,当访问到这些链接时可以跳转到 slug 指定的路由页面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
---
-date: 2020-03-08 21:56:31
+date: "2020-03-08 21:56:31"
+toc: true
id: 264
-path: source/_posts/2020-03-08-我为什么不再写博客了?.md
+slug: /posts/why-i-stopped-blogging
+aliases:
+    - /archives/2020/03/264/
tags:
-  - 随笔
+    - 随笔
+    - 博客
title: 我为什么不再写博客了?
---

当然这个功能并不是手动完成的,为此我用 Go 编写了一个简单的脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
type oldh struct {
	Date  string   `yaml:"date"`
	ID    int      `yaml:"id"`
	Path  string   `yaml:"path"`
	Tags  []string `yaml:"tags"`
	Title string   `yaml:"title"`
}
type newh struct {
	Date    string   `yaml:"date"`
	TOC     bool     `yaml:"toc"`
	ID      int      `yaml:"id"`
	Slug    string   `yaml:"slug"`
	Aliases []string `yaml:""`
	Tags    []string `yaml:"tags"`
	Title   string   `yaml:"title"`
}
func main() {
	filepath.Walk("../../content/posts", func(path string, info fs.FileInfo, err error) error {
		if info.IsDir() { return nil }

		b, err := os.ReadFile(path)
		if err != nil { panic(err) }

		ss := strings.Split(string(b), "---")
		header := []byte(ss[1])

		var old oldh
		err = yaml.Unmarshal(header, &old)
		if err != nil {
			log.Fatalf("cannot parse configuration, err: %v\n", err)
		}

		dd, err := time.Parse("2006-01-02 15:04:05", old.Date)
		if err != nil {
			panic(err)
		}

		newHeader := newh{
			Date: old.Date,
			TOC:  true,
			ID:   old.ID,
			Slug: fmt.Sprintf("/posts/%s", strings.Replace(strings.ToLower(old.Title), " ", "-", -1)),
			Aliases: []string{
				fmt.Sprintf("/archives/%d/%02d/%d/", dd.Year(), dd.Month(), old.ID),
			},
			Tags:  old.Tags,
			Title: old.Title,
		}

		b, err = yaml.Marshal(newHeader)
		if err != nil {
			panic(err)
		}

		ss[1] = string(b)

		err = os.WriteFile(path, []byte(strings.Join(ss, "---\n")), os.ModePerm)
		if err != nil {
			panic(err)
		}
		return nil
	})
}

当对路由进行迁移时,还需要在 Disqus 中对文章的评论进行迁移,Disqus 提供了这样的迁移功能, 可以通过指定路由映射表来迁移博客的评论,例如:

1
2
3
4
5
6
https://changkun.de/blog/archives/2020/01/262/, https://changkun.de/blog/posts/2018-2019-reading/
https://changkun.de/blog/archives/2020/02/263/, https://changkun.de/blog/posts/2019-summary/
https://changkun.de/blog/archives/2020/03/264/, https://changkun.de/blog/posts/why-i-stopped-blogging/
https://changkun.de/blog/archives/2020/03/265/, https://changkun.de/blog/posts/setup-wordpress-in-10-minutes/
https://changkun.de/blog/archives/2020/09/266/, https://changkun.de/blog/posts/eliminating-a-source-of-measurement-errors-in-benchmarks/
https://changkun.de/blog/archives/2020/11/267/, https://changkun.de/blog/posts/pointers-might-not-be-ideal-for-parameters/

综上所述,最终迁移到 Hugo 之后的博客使用的是我自己 Fork 并维护一份主题, 有兴趣的读者可以在博客的源码中找到我自己定制的版本,其中着重修复前面提到的问题, 并增加了这些特性:

  1. Drak Mode
  2. 支持了 Disqus,删除了一些无用的百度、微信、多说(现已倒闭)分享等功能
  3. 增加了友链
  4. 标签按频次大小进行缩放
  5. …等等

利用 Go 1.16 新特性进行编译

在 Go 的 1.16 中有一项新特性,能够将文件整个打包到二进制的执行文件中。

这段代码展示了如何利用 Go 1.16 的特性将生成的位于 public/* 文件夹中的静态文件 统一打包到一个二进制文件中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
	"embed"
	"io/fs"
	"net/http"
	"os"
	"path"
)

//go:embed public/*
var public embed.FS

// blogFS implements fs.FS
type blogFS struct {
	content embed.FS
}

// Open opens a given filename from the public folder.
func (b blogFS) Open(name string) (fs.File, error) {
	return b.content.Open(path.Join("public", name))
}

func main() {
	r := http.NewServeMux()
	r.Handle("/", http.FileServer(http.FS(blogFS{public})))
	const addr = "0.0.0.0:9129"
	s := &http.Server{Addr: addr, Handler: r}
	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
		panic(fmt.Sprintf("cannot listen on %s, err: %v\n", addr, err))
	}
}

容器化轻量级编排

我们已经决定了要将网站整个切换到纯 Go 的技术栈,那么我们就需要考虑要做那些变更。 既然我们决定了不用 Kubernetes,那要不要考虑削弱版的 Docker Swarm?很明显也没有必要。 但容器化是肯定的,容器化带来的打包和分发的便利性、以及运行时的隔离性、多重 replica 的特点对日后管理带来的方便远远大于其他缺点。同时考虑到 Docker 也是 Go 开发的项目(政治正确 应该叫 Moby),配合 Docker Compose 进行管理是再好不过了。

我个人喜欢 Go 的很大一个原因是他近乎完美的编译特性,一个简单的 go build 几乎解决所有的依赖 问题,不需要静态链接更不需要运行时的动态链接。虽然编译过程中会有这些步骤,但这些步骤并不需要 作为用户的我来操心,这也就让整个打包流程变得异常的简洁:

1
2
3
4
5
NAME=main
VERSION = $(shell git describe --always --tags)
build:
	CGO_ENABLED=0 GOOS=linux go build # 交叉编译
	docker build -t $(NAME):$(VERSION) -t $(NAME):latest -f Dockerfile .

Dockerfile 只需将打包好的二进制文件扔进 alpine 即可:

1
2
3
4
# 拷贝二进制文件即可
FROM alpine
COPY main /app/main
CMD ["/app/main"]

Docker compose 的编写也非常简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
version: "3"
services:
  main:
    build:
      context: .
      dockerfile: Dockerfile
    restart: always
    image: main:latest
    deploy:
      replicas: 3

监控报警和其他服务

除了主网站和博客之外,还有一些其他的服务也运行中在服务器上。为了保证稳定性,我们需要对这些运行 的服务进行监控,一旦出现问题就需要进行提醒,虽然个人网站自用的目的性更强,也就不会有 SLA 保障, 但掉线好几天的对于网站的浏览者来说还是很不好的体验。 个人使用的监控可以做得非常简陋:使用 crontab 不断的 curl 某个接口,当接口不可用时就触发某个 邮件脚本,也可以做得非常花哨,比如用上 Prometheus 和 Grafana 等甚至对 CPU 和内存使用率进行 监控。确实也可以,但作为个人网站,从历史经验来看,只有自己写的东西才能长久且兼容的维护下去。

所以,在这次的升级过程中,我顺手为 changkun.de 编写了一个监控服务 upbot,并接入了邮件服务 mailgun,间隔固定的周期像某个服务发起请求来监视服务的可用性, 当不可用时候给我发邮件报警。

除了监控服务外,changkun.de 还运行了几个自己使用的服务,例如:

  • midgard:一个跨平台的剪贴板服务,可以在 Linux、Mac 和 iOS 之间自动同步剪贴板、 快速为剪贴板中的内容或者本地的文件创建一个公开的链接等等功能(开发这个服务的原因是 自身需要 ,但个服务搭建起来可能相对复杂,改天有时间录个使用教程吧 Orz 挖坑)
  • occamy:一个通用远程桌面服务,支持 VNC/RDP/SSH 三种协议的转译
  • redir:一个短链接服务,支持 PV/UV 统计

限于篇幅,这里就不详细介绍了,我们以后有机会再表,有兴趣的读者可以在文后找到链接。

当然网站上其实还有一些自己在用但没有公开提供的服务,比如服务器上还运行了一个 Jupyter 的 Server 等等。这些服务其实早年要么是直接一个 python3 -m http.server 直接跑在 Tmux 上,要么是用 hypervisor 挂起(部署方式取决于部署时的心情)。这次也一并统一给 清理和容器化了。

最后,既然有这么多服务跑在服务器上,还需要反向代理的支持,虽然也可以自己写一个, 而且 Go 的标准库中有 httputil.ReverseProxy 接口,但实现相对可靠、功能完善的反向代理的复杂性要比写一个简单的监控服务复杂得多(?) (至少在我看来还需要实现请求截断、重试、缓存、日志等等特性)。 从远古纪元的 Apache、到第三纪元的 Nginx,既然我们的目标是至少用上 Go 编写的服务,那么 从 Cloud Native 生态发展而来的 Traefik 就是不二之选了,使用上非常傻瓜,看文档就能秒懂, 也就不再进一步展开了。

回顾

这篇文章首先介绍了 changkun.de 作为个人网站的迁移历史, 以及每次迁移过程中的遇到的问题和决策,并据此展开了最近一次迁移过程中带来的「架构」升级, 同时介绍了由于迁移需求而「自研」并正在运行的线上服务。 此次迁移的亮点是目前整个网站完全依靠 Go 进行支撑,并从某种意义上实现了微服务。 除此之外,文章还顺带介绍了如何利用 Go 1.16 带来的 embedded files 特性将网站的 静态文件一并编译到用于分发的二进制文件中。

目前 changkun.de 这个网站的整体结构如图所示:

这些服务都是用 Go 编写并开源的,感兴趣的读者可以在下面的链接中找到:

  • https://changkun.de/s/main
  • https://changkun.de/s/blog
  • https://changkun.de/s/midgard
  • https://changkun.de/s/redir
  • https://changkun.de/s/upbot
  • https://changkun.de/s/occamy
  • https://changkun.de/s/modern-cpp-tutorial (这是个例外 :)

除了这些之外,还有这些运行在 golang.design 上的服务:

  • https://changkun.de/s/go-under-the-hood
  • https://changkun.de/s/go-history
  • https://changkun.de/s/gossa
  • https://changkun.de/s/code2img

他们也曾早期部署在 changkun.de 上,算是由他孵化的新生儿吧。 第三纪元的设计使用了三年左右的时间,希望这次的「组织架构」能够借助 Go 的兼容性稳定的服务超过三年。

欧长坤

2020 年 12 月 24 日 于 慕尼黑

#博客# #随笔# #Nginx# #Docker# #Tmux# #Jupyter# #Python# #Hexo# #Node.js# #Go# #Hypervisor# #Hugo# #Kubernetes# #Traefik# #Mailgun# #Redis#
  • Author:作者: Changkun Ou
  • Link:链接: https://changkun.de/blog/posts/all-in-go/
  • All articles in this blog are licensed under本博客所有文章均采用 CC BY-NC-ND 4.0 unless stating additionally.许可协议,除非另有声明。
LBRY
"Worse is Better"

Have thoughts on this?有想法?

I'd love to hear from you — questions, corrections, disagreements, or anything else.欢迎来信交流——问题、勘误、不同看法,或任何想说的。

hi@changkun.de
© 2008 - 2026 Changkun Ou. All rights reserved.保留所有权利。 | PV/UV: /
0%