MoreRSS

site iconBryan修改

英文昵称 Singee,INTJ,经济学+法学双专业,产品经理、工程师、投资人。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Bryan的 RSS 预览

人生第一次离职,感觉应该写点什么

2026-06-06 00:00:00

感觉我的生活似乎相当简单,毕业后直接进了某网安公司实习、然后就慢慢悠悠待了六年,直到今天。

毕竟是人生的第一次,感觉应该写点什么纪念一下。


刚毕业的时候是 2020 年,选了一个网安公司。当时进这个公司非常重要的一点是,我对网安行业非常有兴趣(初高中「捣鼓」过很多东西,还玩过渗透,甚至年少无知黑过别人论坛 - 幸好人家没报警),而且这家公司还运营着我曾经经常逛的一个安全论坛

当年也正值网安行业如日中天,开心入职了。刚入职的时候其实福利相当可以,各种下午茶、福利、多余的假期、奖金等等,各种活动也不少。


入职的时候面的是 Python ,还记得进公司的时候接手的是当时的一个老项目加功能。读这个项目源码的时候真的非常痛苦,具体记不清了,但大概也就是不遵守命名规范、没有注释、没有类型注解、没有测试这些,我梳理代码的过程中加了一些注释,然后做好了功能,然后提交代码。

然后 Code Review 过程有两件事情让我至今印象十分深刻

  1. 因为写了过多的注释被 leader 要求「不要加注释,因为后面代码变了可能和注释对不上」
  2. 当时做了一个删除功能,然后因为 ES 删除并不是实时的,可能出现添加后立刻删除没有 / 删除后立刻查询还在,当时不太懂 ES ,听从了 leader 给的解决方案「在所有操作后都执行 index 刷新」(后面才知道不止这个方案离谱,就连整个项目的 ES 用法都是错的)

不过巧的是,我的代码质量被另一个团队的 leader 看到欣赏了,把我要了过去 —— 所以这也是我在真正招我入职的这个 leader 手里做的唯一一个项目


在新的小组,用 Python 做了一个新项目以后,就遇到了公司决定放弃 Python 转 Java 😂 所以我那个 Python 项目在线上好像也就存活了一个月

之后,既然公司决定转 Java 了,那我自然是…… 去写 Go……

没错,我所在的新部门因为是中间件部门,因此没有受到应用层转 Java 的影响,而是可以选择合适(喜欢)的语言完成,在职期间我基本上可以说把 Python 2, Python 3, Golang, Java, JavaScript (TypeScript), Rust 玩的都挺明白(主打一个想学什么语言就用什么做)

在这个部门工作的时候非常开心 —— 即使那时候我知道我的能力已经远超我的薪资了我也没有走,我经常觉得好像工作氛围要比钱重要的多。

然而,在这期间,网安行业走下坡路,公司也开始了各种「骚操作」,取消各种奖金、晚发至最后取消年终奖、强制加班(我入职的时候承诺了每天工作 8h 、周末双休 - 当时也确实做到了,但后面就变成了要求工作 9.5h )等,我因为氛围 & 做的东西我比较开心其实也都默默忍了下来。


转折点(开始想离开了)应该是两个因素一起构成的。一是我当时的 leader 因为不满公司 9.5h 的工时制度而离职(公司为了逼他离职甚至做出了在他头顶装摄像头的离谱举动),二则是公司突然搞了一个「王牌项目」,而我也正式从专门的基础架构/中间件的开发去转做了业务开发

坦白说,这是一个非常有前景的项目,但也是一个十分困难的项目 —— 当时的说法是「我们是第一个,没有竞争对手」而我觉得是「因为其他家都知道不可能实现所以我们是第一个」—— 这件事情几年后到了现在也是正确的,我们可以说仍然没有任何同类竞品、到今天也没有

当时我是负责其中的安全动态爬虫的,简单说就是给你一个 url 你要争取全自动的、完美的爬取到整个网站、触发所有可能的 API 请求(然后交由后面的环节做漏扫) —— 这是一个可能在现在基于 AI Agent 来做爬虫都难以达成的目标 —— 但在当时就被要求去做到极致,其中有一段时间一直淹没在对各种奇葩网站的适配,十分痛苦

但其实虽然觉得困难,但我还挺喜欢挑战困难的,所以虽然痛苦,我也一直没有什么一定必须要走的想法


AI 时代来了。我进了 AI 安全项目组。

这次我要做的产品是 AI 网关。

这其实是一个对我来说毫无挑战的项目。作为一个「精通」各种网络协议、代理协议,手搓过 MitM 实现的人,这个需求对我来说简直太简单了 —— 它对我来说最大的难点就是作为一名 ADHD 我怎么说服自己去做这么一个「无聊」的项目。

anyway ,就当做个「 Build your own nginx 」这种练习了,轻松搞定。

但是让我始料未及的是,我又接到了很多 AI 网关的需求 —— 这不正常吗?这正常吗?还记得我开篇吗,我在的是一个「网络安全公司」!而我在的项目组是做 AI 安全的!

4 月我接到了大量的 AI 网关方面的需求,多上游负载均衡、协议转换、计费…… 我其实挺不理解的,为什么会有人期望让一个安全产品支持这些功能。。这不是后面接一个 Kong 甚至开源的 New API / litellm 就能解决的嘛?我们产品经理其实也不理解 —— 也没有客户提 —— 是伟大的 CEO 同志提出的。

而与此同时,一个安全产品却又不给安全研究方面的资源。。只有一位研究员还需要在多个产品之间提供支持、要求训练小模型不给卡。。。

(当然,现在好很多了,但是项目组中网关的研发已经有数个,安全研究人员目前却仍然只有一个。。)


与此同时,公司正在实行 All in AI 的政策 —— 坦白说,我觉得方向是对的,然而实行却一言难尽

先说我负责的 AI 网关。4 月直接接到了海量的、人力根本不可能完成的需求,而上面给的解决方案就是 —— 用 AI —— 并且号称给「无限的 AI Token 」 —— 然后对于项目的要求是「做出来就行,不要求质量」。

「无限的 AI Token 」,听起来很美好是吧?结果给的是某个廉价中转的 API Key —— 这个离谱的廉价中转的可用性是我见过、甚至听过的最差的 —— 一个流式请求的 TTFT 平均 1min 、最长能干到 3min 的中转你们听说过吗?(对比下,直连 OpenAI / Claude 官方基本在数秒)

哦对,几分钟的 TTFT 还是后话,4 月初刚刚给到我的时候,它甚至根本没办法正常使用当时最先进的 gpt 模型 —— 非常稳定的,不开思考(默认 none thinking )就是智障、开了思考会无限等待直到超时。

(当然,当时秉持着绝不自费打工的想法,我申请到了 ChatGPT Pro 报销 —— 没强制要求发票,invoice 就给报了,这点公司倒是挺好的,我还以为到财务那里会卡我呢)


AI 现在的发展阶段到处是什么样子?我觉得是一个挺尴尬的境界。AI 可以去完成一些简单的任务、复杂的任务、甚至多步工作流的任务,AI 也能完成一些小项目的完整维护。

但是对于一个 AI 网关这种量级的项目呢?我的评价是,根本无法正常维护 —— 哪怕是最先进的 gpt-5.5 。

被迫用 AI 开始这种级别的「 Vibe Coding 」我其实是极度痛苦的。因为给的时间去完整审阅 AI 代码是不可能的,我只能尽力保证大方向的架构是我期望的、而放任中间的细节由 AI 把控。

我的「掌控欲」其实还挺强的。我之前几乎可以保证只要我交付出去的代码,几乎不会有我写的 bug ;而我自己没测出来的 bug ,测试也几乎不可能测出来 —— 我有另一个中大规模的项目,完全由我开发、没有测试介入、运行数年、在无数个不同的环境中新装、升级,累计 bug 数不超过 10

但是现在呢?我再也不敢直接去笃定的说「配置问题」了,而是接到可能的 bug 永远要自己先审查一遍 —— 当然,我做了个 AI Skill 让 AI 完成这件事情 —— 但这种事情带来的心智负担是我不能/不愿意接受的。

而且也算是不得不对代码质量放任了。之前做 Code Review 我都会非常仔细的一行行代码看,包括考虑各种性能和可能的边界条件。而现在呢?别说审查了,我自己都不知道哪里会埋雷。看了下只要 CI 过了、别提交不该提交的文件、没有什么明显改动不该改的文件,就 Merge 了。

—— 哦,说到 CI ,可能只有我负责的项目有 CI 这种东西吧,公司完全不管

—— 当然,没有 CI ,也没有 CD ;我明确提过,这种 AI 的代码 bug 会很多,必须要做到遇到 bug 敏捷开发、快速迭代、快速上线(目前是采用月发版、中间非常紧急的 hotfix 才会手动去部署升级 —— 但我现在几乎每天都能发现 AI 之前埋藏的数个惊喜),要搭建好 CI/CD 做好自动的上线,正好趁着新项目直接上全套的 k8s + helm + argocd —— 然而,nobody cares —— 没有人敢使用新技术、没有人敢担拍板的责任


与此同时,公司正在执行更加激进的 AI 政策。

配备统一的(公司自己开发的)( Vibe Coding 的)(极其难用的)(充满 bug 的) AI 工具,禁止使用自己的工具、禁止使用自己的 API Key ,将 AI 使用率纳入考评、所有 commit 必须 AI 提交……

CEO 期望的肯定是「降本增效」,但如此激进的政策,我也很难说公司到底是先驱还是先烈了。


至于离职的导火索,则是公司又开始了一个新的离谱的制度 —— 之前制度好歹「装一下」有个什么征求意见期,这次直接当天公布、当天实行、不完成就罚款。

坦白说,在公司工作还是挺轻松的,但是各种制度太恶心了。技术部氛围其实还是相当好的,但是上面离谱的 CEO 和 HRD 可以说把公司搞的乌烟瘴气(听说每年流动率能有 1/3 这个离谱的数量)。


断断续续,不知道写啥,纯属觉得应该画个句号,写个流水账记录下吧。

下一步怎么走也没想好,先去玩玩?自己做点什么?再换一家小而美不加班的公司?

无贷未婚无娃在这种情况下,还挺自由的 XD

在 Python 中复现 Race Condition

2026-01-24 09:18:00

背景

在学习 Race 或原子操作时,往往会有一个很经典的示例

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
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <pthread.h>

static long long counter = 0;
static long long loops = 0;

static void *worker(void *arg) {
(void)arg;
for (long long i = 0; i < loops; i++) {
counter += 1; // 故意不加锁:会丢更新
}
return NULL;
}

static long long parse_ll(const char *s, const char *name) {
errno = 0;
char *end = NULL;
long long v = strtoll(s, &end, 10);
if (errno != 0 || end == s || *end != '\0' || v < 0) {
fprintf(stderr, "invalid %s: %s\n", name, s);
exit(1);
}
return v;
}

int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s <loops> <threads>\n", argv[0]);
return 1;
}

loops = parse_ll(argv[1], "loops");
long long num_threads_ll = parse_ll(argv[2], "threads");

if (num_threads_ll <= 0 || num_threads_ll > 1000000) {
fprintf(stderr, "threads out of range: %lld\n", num_threads_ll);
return 1;
}

int num_threads = (int)num_threads_ll;

pthread_t *threads = (pthread_t *)malloc(sizeof(pthread_t) * (size_t)num_threads);
if (!threads) {
perror("malloc");
return 1;
}

printf("Initial value: %lld\n", counter);

for (int i = 0; i < num_threads; i++) {
int rc = pthread_create(&threads[i], NULL, worker, NULL);
if (rc != 0) {
fprintf(stderr, "pthread_create failed (rc=%d)\n", rc);
free(threads);
return 1;
}
}

for (int i = 0; i < num_threads; i++) {
pthread_join(threads[i], NULL);
}

long long expected = loops * num_threads_ll;
printf("Expected: %lld\n", expected);
printf("Final value: %lld\n", counter);
printf("Lost updates: %lld\n", expected - counter);

free(threads);
return 0;
}

./race 2000 5 来运行它,最终会创建 5 个线程并发对一个全局变量自增,因为这个「自增」操作不是原子的,所以最终的更新结果往往是一个小于 10000 的数字

但是,如果把这个代码翻译成 Python

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
import sys
import threading


counter = 0
loops = 0


def worker():
global counter
for _ in range(loops):
counter += 1


def main():
global loops

if len(sys.argv) != 3:
print("usage: python race.py <loops> <threads>", file=sys.stderr)
sys.exit(1)

loops = int(sys.argv[1])
num_threads = int(sys.argv[2])

threads = []

print("Initial value:", counter)

for _ in range(num_threads):
t = threading.Thread(target=worker)
threads.append(t)
t.start()

for t in threads:
t.join()

print("Expected:", loops * num_threads)
print("Final value:", counter)
print("Lost updates:", loops * num_threads - counter)


if __name__ == "__main__":
main()

你会发现无论执行 python race.py 2000 5 多少次,最终的结果永远是稳定的 10000 —— 竞争消失了

GIL

没错,稍有经验的人几乎可以一下子反应过来这是 GIL 的锅!

Python 3.10 起引入了一个优化,并不是所有操作都会去进行获取 + 释放 GIL 的操作。而我们想要产生竞态的核心逻辑 [LOAD_GLOBAL counter, LOAD_CONST 1, INPLACE_ADD, STORE_GLOBAL counter] 正好都不在这些 GIL 操作上,因此上述代码看似是两个线程并行,但实际上是分开执行的,所以并没有产生竞争、没有同时读写一个全局变量的情况出现,导致无法复现

解决办法

关闭 GIL

既然它是 GIL 导致的,那我们关了 GIL 不就好了🤔 Python 3.13 起开始有了一个 free-threaded 版本(常称为 python3.13t ),将 GIL 剔除了,所以我们如果直接用这种不含 GIL 的发行版去执行上面的程序,可以发现竞态成功复现了

引入 GIL 切换

既然问题出在我们的代码「过于简单」以至于不会给 GIL 释放锁的机会,那我们就手动给它引入释放的机会就好了,最简单方法就是利用「函数返回」—— 我们只需要将 += 1 替换成一个函数调用即可

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
import sys
import threading
import time


counter = 0
loops = 0

def one():
return 1


def worker():
global counter
for _ in range(loops):
counter += one()


def main():
global loops

if len(sys.argv) != 3:
print("usage: python race.py <loops> <threads>", file=sys.stderr)
sys.exit(1)

loops = int(sys.argv[1])
num_threads = int(sys.argv[2])

threads = []

print("Initial value:", counter)

for _ in range(num_threads):
t = threading.Thread(target=worker)
threads.append(t)
t.start()

for t in threads:
t.join()

print("Expected:", loops * num_threads)
print("Final value:", counter)
print("Lost updates:", loops * num_threads - counter)


if __name__ == "__main__":
main()

counter += one() 的指令是 [LOAD_GLOBAL counter, CALL_FUNCTION x, INPLACE_ADD, STORE_GLOBAL counter] ,而其中的 CALL_FUNCTION 会触发 GIL 切换检查,即在读写的过程中给了 GIL 释放锁的机会、允许了竞争

不过注意一点,因为现在 GIL 是 time-based switching,默认要 5ms 才会触发一次线程切换,所以每轮多跑几次,例如用 python race.py 200000 5 - 少了的话在现代 CPU 上可能根本来不及满足切换的条件程序就运行完了

参考

https://www.reddit.com/r/learnprogramming/comments/16mlz4h/race_condition_doesnt_happen_from_python_310/

再看看 SQL 中的 null

2025-10-16 13:52:00

NULL 与任何值的运算结果都是 NULL

NULL 有一个很重要的特性:NULL 与任何值的运算结果都是 NULL

1
2
3
4
5
SELECT 1 = 1; -- true
SELECT 1 = NULL; -- null
SELECT NULL = NULL; -- null
SELECT 1 != NULL; -- null
SELECT NULL != NULL; -- null

这也就意味着,对于存在列 col BOOLEAN NULLABLE 的表,如果一行中 col 列的值为 NULL,则下面的四条查询都不会包含这行

1
2
3
4
5
6
7
-- 等于自不必说
SELECT * FROM "table" WHERE col = true;
SELECT * FROM "table" WHERE col = false;

-- 但不等于也不会包括值为 null 的行
SELECT * FROM "table" WHERE col != true;
SELECT * FROM "table" WHERE col != false;

IS NULL

我们其实基本都知道这点,所以我们在进行不等运算时通常会用 IS NULL / IS NOT NULL 特殊处理 NULL 列

1
2
SELECT * FROM "table" WHERE col is null OR col != true; -- 筛选 col = null 或 false
SELECT * FROM "table" WHERE col is null OR col != false; -- 筛选 col = null 或 true

IS TRUE / IS FALSE

但其实,除了 IS NULLIS 操作符后面还可以跟随 BOOL 值,那么,用 IS NOT 其实是一个更加精准的不等于操作

1
2
SELECT * FROM "table" WHERE col IS NOT true; -- 筛选 col = null 或 false
SELECT * FROM "table" WHERE col IS NOT false; -- 筛选 col = null 或 true

而对于不是 bool 类型的 nullable 列,我们则可以搭配使用 = !=IS

1
2
3
-- 筛选 col = null 或任何不为 1 的值
SELECT * FROM "table" WHERE col is null OR col != 1;
SELECT * FROM "table" WHERE col = 1 IS NOT TRUE;

concat

NULL 与任何值的运算结果都是 NULL,但在函数中则不一定(取决于具体实现)

以连接字符串为例,如果中间存在 NULL,用 || 连接属于运算符,中间任何一项为 NULL 则结果为 NULL,而用 concat 连接则是函数,中间出现的 NULL 会被忽略

1
2
SELECT 'hello-' || NULL || 'world'; -- null
SELECT concat('hello-', NULL, 'world'); -- hello-world

布尔逻辑

NULL 与任何值的运算结果都是 NULL,但在布尔逻辑中则不一样…… 试试看能不能说出下面的返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
select not NULL;
select null or null;
select null and null;

select null OR true;
select true OR null;
select null OR false;
select false OR null;

select null AND true;
select true AND null;
select null AND false;
select false AND null;
答案
1
2
3
4
5
6
7
8
9
10
11
12
13
14
select not NULL;          -- NULL
select null or null; -- NULL
select null and null; -- NULL

select null OR true; -- TRUE
select true OR null; -- TRUE
select null OR false; -- NULL
select false OR null; -- NULL

select null AND true; -- NULL
select true AND null; -- NULL
select null AND false; -- FALSE
select false AND null; -- FALSE

其实逻辑在于理解 NULL 代表着「未知」,与「真」「假」一起构成了一个三值集合

那么自运算,无论「未知的反面」还是「未知和未知」「未知或未知」都是未知,也因此都是 NULL

OR 的逻辑则是「有任何一个值为 TRUE 就是 TRUE」,因此,在 NULL 参与的运算中,存在 TRUE 则为 TRUE,不存在 TRUE 则为 NULL(继续未知)

AND 的逻辑则是「有任何一个值为 FALSE 就是 FALSE」,因此,在 NULL 参与的运算中,存在 FALSE 则为 FALSE,不存在 FALSE 则为 NULL(继续未知)

在聚合函数中

在聚合时,如果对 NULL 值进行聚合,它的数值是被完全忽略的 —— 一个例子是 avg,如果对 1, 2, 3, 4, NULL 做 avg,结果是 (1+2+3+4)/4

如果期望给 NULL 在聚合时一个默认值,可以用 coalesce 函数为它赋一个「默认值」

COALESCE

上面已经提过 coalesce 函数了,再说一句就是,coalesce 函数可以接受多个参数(而不只是两个),会返回第一个非 NULL 值

例如用 coalesce(first_name, nickname, email) 取用户「昵称」

NULLIF

pg 中还存在一个 nullif 函数,接收两个值,如果两个值相等则返回 NULL 否则返回第一个值,等价于 CASE WHEN a = b THEN NULL ELSE a END

这个函数主要用来「将零值转换为空值」;举个例子就是

1
2
3
4
SELECT 
NULLIF(TRIM(name), '') AS name,
NULLIF(status, 'N/A') AS status
FROM t;

还有就是搭配 NULL 的「可转换为其他类型」的特性

1
2
3
4
SELECT
-- 将 '' 直接转换成 date 是会报错的,但是先转换为 NULL 再转换为 date 就可以了
NULLIF(col_date, '')::date AS date
FROM t;

NULL 值在 PostgresQL 协议中的表示

虽然大概率不会引起混淆,但还是说一下,虽然我们用 psql 规则看到 NULL 值就像是文本一样,但它的底层传输是二进制,PG 规定用 len = -1 代表 NULL 值,所以不存在它与空字符串、null 字符串等混淆的情况

另外,特殊的,在 COPY 中,如果用的 text format(PG 的消息协议中,对于一个值存在 text 和 binary 两种 format),那么会用 \N 代表 NULL 值

参考:https://www.postgresql.org/docs/current/protocol-message-formats.html 中的 Query, Parse, Bind, RowDescription, DataRow

NULL 值在文件系统中的存储

当行中有任一列值为 NULL 时,「行头」HeapTupleHeaderData 中的 t_infomask 字段内的 HEAP_HASNULL flag 会被标记为 1,此时在行头后面、其余数据前面会增加一个 null bitmap,用位图的形式存储所有列的 NULL 情况,且如果某一列的值为 null,后面的 data 中将不会出现这一列的信息

另外,null bitmap 除了用来处理 NULL 值,还会用来处理 drop column —— 当一列被删除时其实际上依然存在着,只是后面所有的行都会带有 null bitmap 将这一列标记为 NULL(更事实上,删除列是懒删除,行内的数据都还在,只有下次更新行时才会清理这些数据 —— 当然,清理的方式也是将它标记为 NULL);

参考:https://www.postgresql.org/docs/current/storage-page-layout.html

迁移 Zeabur 集群

2025-10-06 11:12:00

我之前的 Zeabur 集群是独立跑在一台物理机上的,物理机相比云服务器的劣势之一就是底层存储的数据安全性 —— 云服务器的硬盘通常是由云服务商保证了安全性不同,物理硬盘坏了就是坏了

而不幸的是,不久前我就遇到了…… 所有数据差点消失,幸亏坏的盘并没有完全坏,以只读方式还能读(只是不能写)算是挽留了我的数据

但那以后我就一直在思考怎么保证数据安全,经过数个不同的方案研究,我最终的选择是 —— 将整机移动到 PVE 中 然后在 PVE 的底层使用 RAID 1 硬盘

方案敲定,行动开始 —— 我整个的迁移不太具有可复制性,因此写一篇完整的「迁移指南」确实不太有意义,但中间确确实实遇到了一些小问题,我觉得记录下来还是比较有价值的

💡 我两台机器(原 Zeabur 物理机和新的在 PVE 里面装的虚拟机):

  1. 在同一个机房
  2. 都是用的 RHEL 系的系统
  3. 内部文件系统都是 LVM + xfs

将 PVE 的硬盘改成 RAID1

我的 PVE 并不是在安装时就直接做好 RAID1 的,因此保证安全性的第一点是将 PVE 的硬盘改成 RAID1

⚠️ 需要注意的是,RAID 只是冗余,不是备份也无法替代备份

这次我的迁移也顺便把备份加上了,但备份又是一个较大的话题,因此本文不会写备份相关的内容,如果未来有机会我会单独写一篇文章,不过可以顺便说一嘴的是,我选用的备份方案是 Velero

我没有阵列卡,那么硬盘改 RAID 就只能是通过软 RAID 来组,在 Linux 下组软 RAID 有两种办法,一是利用 mdadm 组 RAID,另一种则是用 lvm 组 RAID;二者各有优劣,我为了灵活性选择了 lvm 的方案 —— 我现在是 2 块 1TB 的硬盘,使用 lvm 的话后面扩容可以很方便的通过加 1 块 2TB 的硬盘来得到完整的 2TB 可用 RAID1 空间,而如果不用 lvm 虽然也能实现但相对来说更加麻烦,且有难以避免的 degrade 时段

哦对,还有,做冗余不能只将数据做冗余,也要考虑引导,都是老步骤,复制下 ELF 分区、重做下 grub 引导,没什么值得说的,有什么问题问问 ChatGPT 它应该能相当完美的回答。

假设我们现在的环境是:原有的数据卷 /dev/sda3、原有的 VG pve、原有的 LV pve/root /pve/swap pve/data、新的数据卷 /dev/sdb3

首先将新的数据卷转换为 PV pvcreate /dev/sdb3 然后将它加入至 VG vgextend pve /dev/sdb3

pve/root 转换为 RAID1 十分简单:一行命令 lvconvert --type raid1 -m1 pve/root /dev/sda3 /dev/sdb3 搞定

pve/swap 是个 swap 分区,没有做 RAID1 的必要,我们跳过

难点来到了 pve/data 这个逻辑卷 —— 它不是一个普通的 LV 而是一个 Thin Pool,如果我们直接执行 lvconvert --type raid1 -m1 pve/data /dev/sda3 /dev/sdb3 LVM 会报错 Operation not permitted on LV pve/data type thinpool.

可以认为 Thin Pool 是一个「逻辑逻辑卷」,它事实上分成了 meta 和 data 两部分(通过 lvs -a 命令可以看到,它是用 pve/data_tmetapve/data_tdata 组合而成的),所以我们说是对它做 RAID1 但事实上想做的是对它底层依赖的 meta 和 data 做 RAID1 —— 这样,Thin Pool 本身和从这个 Thin Pool 所分配出去的子 LV 也都是 RAID1 了。

所以我们要做的是,将它的底层数据卷转换为 RAID1 即可:

1
2
lvconvert --type raid1 -m1 pve/data_tmeta /dev/sda3 /dev/sdb3
lvconvert --type raid1 -m1 pve/data_tdata /dev/sda3 /dev/sdb3

然后等待 lvs -a 的输出中 Cpy%Sync 列变为 100% 即为 RAID 转换完成。

哦对,还有一件事情,虽然示例中我用的另一块盘也是 SATA 盘做例子,但事实上我的另一块盘是 NVME 的,因此我上面说的「pve/swap 不用做 RAID1」之外,我其实还将 pve/swap 给移动到了新的盘上

将 LV 从一个 PV 移动到另一个 PV 的命令:pvmove -n pve/swap /dev/sda3 /dev/nvme0n1p3

迁移方案的选择

物理机迁移到 PVE 最直接的方法就是直接做整盘磁盘镜像然后导入,这种方案没什么可说的,也是最简单的

但是基于下面几个原因

  1. 我想换个系统,原来的系统是 CentOS Stream,嗯…… 不太适合一个稳定的环境使用
  2. 原来的系统在装 Zeabur 之前还装了一些别的东西,现在已经不需要了
  3. 我所有有价值的数据全都在 k3s 里面了,其实只要迁移 k3s 数据就行,而且我也想试试到底怎么迁移下集群,都要迁移哪些数据
  4. 我讨厌 Live CD 带的 Bash 的体验,特别是我可能还得用 IPMI 的 Remote Console 而不是自己的本地终端 SSH 过去
  5. 我原来其实也不是一块硬盘,而是两块硬盘组的 LVM 卷,两块盘都有数据,整盘迁移还得在 live cd 版系统里面折腾下加载 LVM
  6. 我原来物理机的启动用的 UEFI,但新的跑在虚拟机里面的引导用的 BIOS
  7. 整机迁移我也得迁移完去 PVE 里面改各种网卡、硬盘的映射什么的,也不容易
  8. 嗯…… 我没有一块多余的足够大的盘让我存这么大的磁盘镜像了 = =

我最后放弃了整盘迁移的方案,而是选择 rsync 拷贝 k3s 相关的数据:

  1. 在 Zeabur 中添加一个新的 Dedicated Server 让它为我们安装 k3s 的相关内容
  2. 安装好以后,执行 /usr/local/bin/k3s-killall.sh 停止整个集群、删除给 zeabur 用的 ssh key、在 zeabur 中删除这个服务器
  3. 利用 rsync 拷贝所有集群需要的数据
  4. 启动 k3s 服务,耐心等待它把原来所有的镜像下好
  5. 登录 zeabur 面板,执行一次 Reinstall Zeabur Service

将 ufw 换成 firewalld

我之前使用的防火墙时 ufw,它简单易用,但有一个非常重要的缺陷:只要 k8s 暴露的端口,它没办法阻止

因此,趁着这次,换回 firewalld 了;根据 Zeabur 的说明和 k3s 文档,需要执行下面的内容放行相关流量

1
2
3
4
5
6
7
8
9
10
11
12
13
firewall-cmd --permanent --zone=public --add-service=ssh    # 22
firewall-cmd --permanent --zone=public --add-service=http # 80
firewall-cmd --permanent --zone=public --add-service=https # 443

firewall-cmd --permanent --zone=public --add-port=4222/tcp
firewall-cmd --permanent --zone=public --add-port=6443/tcp
firewall-cmd --permanent --zone=public --add-port=30000-32767/tcp
firewall-cmd --permanent --zone=public --add-port=30000-32767/udp

firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16
firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16

firewall-cmd --reload

注:我个人不建议放开 30000-32767 —— 我询问过 Zeabur 支持人员,不放开这些端口不会影响 Zeabur 本身的能力,只是非 http 类型的端口映射无法被访问。Zeabur 会默认将所有端口都暴露到公网,部分端口所对应的服务可能有安全问题,所以我的建议是不要添加 30000-32767 两台规则,而仅在确实需要访问映射的端口时再添加

注 2:如果你是基于你自己需要的目的去访问内部服务(如数据库、Redis),我非常不建议你将这些服务暴露到公网,而是应当使用 zproxy 通过代理访问这些内部服务(当然,你需要将 zproxy 本身使用的端口放开)

不停机更换 IP

zeabur 和 k3s 本身对于「本机 IP 」有一定的偏好,如果要改的话比较麻烦,所以简单起见,我的选择是:

假设原机器 IP 1.1.1.1,现在新的机器 IP 2.2.2.2,我将两个机器的 IP 对调下就好了

云主机换个 IP 轻轻松松,物理机换个 IP 就很麻烦,特别是远程物理机换 IP(好吧,其实可以直接登录 IPMI 在 Remote console 里面改 IP 的,但我们当这个不存在)

不过挺好的一点是,我可以通过引入一个新的临时 IP 让这两台机器都不离线的情况下对调下 IP

💡 我这两台机器在同一个内网、网关相同、DNS 相同,都是手动指定的 IP 没有使用 DHCP

假设两台机器的网卡都是 eno1、临时 IP 是 3.3.3.3

流程是:

  1. 虽然和换 IP 的流程没关系,但是建议先执行 /usr/local/bin/k3s-killall.sh 把整个机器的 k3s 停掉
  2. 在原机器(1.1.1.1)执行 nmcli con mod eno1 +ipv4.addresses "3.3.3.3/24" && nmcli dev reapply eno1 让这个机器有 1.1.1.1 和 3.3.3.3 两个 IP
  3. 通过 3.3.3.3 登录原机器(1.1.1.1 + 3.3.3.3),执行 nmcli con mod eno1 -ipv4.addresses "1.1.1.1/24" && nmcli dev reapply eno1 让这台机器只剩下 3.3.3.3 一个 IP
  4. 登录新机器(2.2.2.2)执行 nmcli con mod eno1 +ipv4.addresses "1.1.1.1/24" && nmcli dev reapply eno1 让这个机器有 1.1.1.1 和 2.2.2.2 两个 IP
  5. 通过 1.1.1.1 登录新机器(2.2.2.2 + 1.1.1.1)执行 nmcli con mod eno1 -ipv4.addresses "2.2.2.2/24" && nmcli dev reapply eno1 让这台机器只剩下 1.1.1.1 一个 IP
  6. 通过 3.3.3.3 登录原机器,执行 nmcli con mod eno1 +ipv4.addresses "2.2.2.2/24" && nmcli dev reapply eno1 让这个机器有 2.2.2.2 和 3.3.3.3 两个 IP
  7. 通过 2.2.2.2 登录原机器(2.2.2.2 + 3.3.3.3)执行 nmcli con mod eno1 -ipv4.addresses "3.3.3.3/24" && nmcli dev reapply eno1 让这台机器只剩下 2.2.2.2 一个 IP
  8. 好了,现在原机器 IP 变成了 2.2.2.2、新机器 IP 则变成了 1.1.1.1 —— 对调完成

🤷 显得繁琐了点,但其实不难

利用 rsync 迁移数据

主要有两部分的信息我们需要迁移

  • Zeabur 连接集群所用的 SSH 凭据
  • 集群本身的各种配置、数据

我们需要用 rsync 同步下面的文件/目录到新的集群:

  • /etc/rancher/k3s k3s 配置及连接集群的凭证
  • /var/lib/rancher/k3s/server/db/ k3s 数据库
  • /var/lib/rancher/k3s/server/token k3s 内部授权 token
  • /var/lib/rancher/k3s/storage 使用的 local volume 的存储路径

同步时使用的命令为(在原机器上执行)

1
2
3
4
5
6
7
8
9
10
11
P=/etc/rancher/k3s # 依次使用上面所需要同步的路径
NEW=2.2.2.2 # 新机器的 IP 地址

# 同步数据
rsync -aHAX --numeric-ids --info=progress2 \
--delete --delete-delay \
$P \
root@$NEW:$P

# 恢复 SELinux 属性(如果需要的话)
ssh root@$NEW "restorecon -RF $P || true"

在启动前,删掉下面的目录(k3s 启动时会自动重新创建)

  • /var/lib/rancher/k3s/server/cred
  • /var/lib/rancher/k3s/server/tls

合并卷容量

我为虚拟机分配了 512GB 的硬盘,但我没想到的是我使用的 Automatic Disk Partition 竟然将绝大多数空间给了 /home 导致我迁移数据一半告诉我没空间了 = =

幸好,虽然这个奇怪的硬盘分区有点烦人,但 RHEL 系一律使用 LVM —— 它也只是个 LV 而已!简简单单,删掉这个 LV 把空间匀给 root 就好了

对了,删除 /home 挂载点要记得改 /etc/fstab,不然下次系统可能启动不起来

更新 node

因为 Zeabur 不允许同一个 IP 有两个 Dedicated Server 出现,所以安装的过程中是使用了一个新的 IP 装的

在迁移完,如果想用回原来的 IP,需要修改 /etc/systemd/system/k3s.service.env 文件里面的 K3S_NODE_NAME

然后启动起来看看 kubectl get nodes,如果还有原来的 IP 的 Node,删了就好

Pod 扩容

我数据迁移完一启动各种飙红,仔细一看原来是 Pod 数量超限(默认 110),需要修改 kubelet 配置

/var/lib/rancher/k3s/agent/etc/kubelet.conf.d 目录下,创建一个 01-max-pods.conf ,里面写

1
2
3
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
maxPods: 1000

然后执行 systemctl restart k3s 就好

哦对,这个其实我之前就改过,但是迁移的时候漏了,你可以看看原来机器的 /var/lib/rancher/k3s/agent/etc/kubelet.conf.d 目录下有没有除了 00-k3s-defaults.conf 以外的文件,有的话最好前面 rsync 的时候直接一起迁移了,省事

2025Q3 订阅 Recap

2025-09-19 08:27:00

现存订阅

ChatGPT Plus

ChatGPT 目前已经成了我的长期订阅选择

一方面,GPT-5(和原来的 o3)体验确实好,可以在思考的过程去搜索、执行代码简直是王炸

另一方面,Codex 写代码是真的强,绝大多数能力已经能超过 Claude 了,只有 Codex 软件本身的用户体验差了一点

还有一点,Codex 自带 PR 代码审查!而且是完全免费的,完全可以替代掉 Code Rabbit / Cursor 之类的产品了

这一切只要 $20 真的很值

Proton Visionary

Proton Visionary 依然在我的订阅列表中

但我已经越来越少用它了,目前没有退订一方面这东西退订需要 Deactivate 邮件地址。。另一方面则是我的上古版 Visionary 价格太香了(现在退了再买差不多涨价了得有 70%)

目前我已经停用了绝大多数 Proton 的组件,只在使用 SimpleLogin 和 Mail。

btw,如果你也在用 Proton Mail 但是不想用它的官方客户端(官方客户端实在太难用了,改版前难用,改版后不但难用 bug 还多)可以考虑各种第三方邮件客户端 + protonmail-bridge-docker 方案

AD:Proton Visionary 可以分享给他人加入,如果你想加入我的家庭组请发邮件给我 [email protected],价格为 92 天 ¥109

Cursor Pro

没错,我依然订阅着 Cursor 🤔 Cursor 在折腾它的 plan 把自己折腾的残废的情况下,我依然在订阅着它😂

首先一点,Cursor Tab 是真的强,市面上没有对手。我最初就是完全因为 Tab 而订阅的 Cursor,而现在也经常用它来修一些 AI 搞不定的复杂逻辑。

另外,看似 Cursor 涨价了,但作为老用户还能继续享受一个月 500 次(Opt out of new pricing)。而现在的 500 次是不再限制 20 个工具调用的 500 次,比 max 只差在了 context 长度上(但我基本都会拆分好任务再给 AI,达到 200k 的次数屈指可数 —— 而且就算达到了 Cursor 也会自动 compact),所以整体依然很香(至少在强制 usage-based billing 之前很香😂)

lisa host

在 AI 时代,一个独享的家宽 IP 可能已经是想流畅使用各种服务的必备品了;之前的 IP Royal 虽然便宜但是质量真的不敢说好,目前换成了 lisa,相当棒

AD:要购买欢迎使用我的推广链接 https://lisahost.com/aff.php?aff=3372

Dler Cloud Diamond

Dler 的服务我觉得已经回到了原来的水平,所以我目前也回到了 Diamond Plan,我觉得目前它又一次成为了我心目中的第一(至少比某 N 家强 emm)

iximiuz labs Lifetime

曾经我是 Premium 订阅,在今年中升级到了 Lifetime

对于想学习 k8s、网络、Linux 的,很推荐这个平台,Learn by doing 的形式,边学习边实践还有对应的测试,绝对比单纯看某些文档/博客更适合学习

而且,在过去一年的更新中,iximiuz labs 增加了不少新功能,对我最重要的就是自定义 Playground —— 想学习/体验下什么产品,可以快速创建一个去测试,而且多机器的设计也可以自由去测试集群相关功能。

其他

还有一些仍然在订阅的,但与《2024Q3 订阅 Recap》相比没什么变化或没什么想写的,就不再赘述了,在这里列个清单,感兴趣的可以回看我之前的 Recap

  • Zero to Mastery
  • iCloud 2TB
  • 山姆卓越
  • Setapp
  • GLaDOS
  • Exercism
  • IFTTT
  • labuladong 算法笔记
  • 国内、国外两台独立服务器
  • 杂七杂八的各种国内会员

其他这一年订阅过又退订了的

Claude Max

首当其冲的自然是 Claude 了;其实到现在 Claude 都是桌面 MCP 做的最好的、Claude Code 也是综合体验最好的,奈何 Opus 持续降智的同时 Codex GPT-5 太强了而且性价比真的高……

相比 2024Q3 的变化

传送门:《2024Q3 订阅 Recap》

退订 Monica Unlimited

我曾经对于 Monica 十分满意(可以回看我上次的 Recap)

奈何,它家就是出了 Manus 的那家,然后重心转移了之后 Monica 就再也没什么新功能了,对于新模型的支持也不积极,高级模型还出了个积分制额外收费……

不过最重要的还是,ChatGPT Plus 已经成为了我的常订,而我对于 Claude、Gemini 等模型也没那么高的需求了

当然,如果你想要一个大而全又没那么贵的解决方案,Monica 还是一个很棒的选择(其实我现在依然用者它的浏览器插件,简单的问题随时划词问下体验也不错),如果你想订阅,欢迎使用我的邀请链接 https://monica.im/?ref=bryan

退订 GitHub Copilot

感觉 GitHub Copilot 已经跟不上现在这个时代了……

作为 Cursor + JetBrains 双持的我,前者自不用说,Copilot 对我而言毫无吸引力,而后者嘛

JetBrains 的 AI Assistant 已经自带了 AI 补全,将模式改成 Creative 后(我其实不太理解为啥它不是默认 - 可能是这个模式更耗费服务器资源?)体验十分棒。美中不足的是 NES(Next Edit Suggestions)还在 beta 且仅支持特定语言(没一个我在写的 = =),但 Copilot 虽然支持但也不咋地。。还不如免费的 Trae 呢😂

综上,👋 Copilot —— 一个我从内测就开始用的插件,一个几乎重塑了我的编码习惯的插件

退订 Cloudflare Workers

我曾经是一个 Serverless 的「爱好者」,我觉得它是未来

奈何,现在越来越感觉到对于一个标准的应用来说,数据库是不可或缺的,而在 Serverless 的环境下,用户-边缘计算服务-数据库的延迟会变得十分明显,造成极差的用户体验

我目前几乎所有的服务都已迁移到 fly.io 和 zeabur —— 与之对应,Cloudflare Workers 也退订了

退订 Inoreader

目前我已将 RSS 完全切换到了我自己写的 1Space,也因此退订了 Inoreader

不过,虽然退订了,但我没想到的是,过去的一年多 Inoreader 竟然获得了若干更新 —— 很神奇的事情,获取是换了个激进的产品经理吧,一个停止了七八年没大更新的服务突然更新/优化了不少

退订 WeRSS

这不是我想退订,是官网登录系统直接挂了,给开发者发邮件/微信都不回!

哎,再没有通过 RSS 稳定订阅微信公众号的方式了

  • 基于搜狗、即刻抓取的方案,十分不靠谱(有时候丢,不丢的时候有时候能延迟个一周)
  • 而基于微信读书的 WeWe RSSWeChat2RSS 等确实靠谱,但我珍爱我的微信读书账号不想让它被封啊…… 为啥没有个 SaaS 啊……
  • 哦,(写这篇文章的时候)刚搜到了个 WeRSS - 微信公众号订阅助手,看上去是基于微信公众平台?我感觉有整个微信被封号的风险

🤷 现在只能,已经不看微信公众号了

其他已退订的服务

  • X Premium(优惠到期,没啥续费的动力,后面需要的话可能开个 Basic)
  • IPRoyal(使用 lisa 替代)

再见 xlog

2025-08-01 08:21:00

2023 年 4 月我在 xlog 下写下了第一篇博客。xlog 真的是一个我很喜欢的博客平台,好看,对于 markdown 的第一方支持,不用考虑部署、图床等问题,自带 AI Summary、自带双语翻译,真的是一切只需要「写」即可,哦对,更重要的是,依赖于区块链技术,虽然你写的文章是在平台上的,但所有数据都依然属于你,且所有数据都是永久保存。

然而,两年过去了,xlog 这个平台虽然还在,但我认为它已经死了。GitHub 更新已基本停滞,没有人处理 issue、没有人审阅 pr,xlog 官网上充满了 spam,也没有人去管理社区。

其实这一切的原因都很好理解 —— xlog 的开发者 DIYGod 转去做 Folo 了。是啊,Folo 相比于 xlog 绝对是更有前景的项目,也更容易讲故事……

xlog 已被放弃,再加上 xlog 的母公司 RSS3 最近的动荡,我觉得是时候从 xlog 迁移走了。


我其实无比庆幸,两年前的时候我是打算把我所有博客迁移到 xlog 的。但是我的博客用的是 /yyyy/mm/dd/xxx 的 url 格式,而 xlog 并不支持这种格式,因而我一直是用 blog.singee.me 作为博客主域名 + articles.singee.me 作为 xlog 博客域名的,然后通过自动化将内容进行同步,且配置 xlog 博客的链接作为 Canonical URL。

这让我这次的迁移十分简单:将我原始博客的链接删掉、将 articles.singee.me 的原链接进行跳转即可!

嗯…… 唯一的副作用,通过 RSS 订阅我的博客的人应该会因为 id 变化了重新看一遍我的博客。其实这个是可以解决的,因为我之前 blog.singee.me 的 RSS 是通过 patch 了 hexo-generator-feed 实现的,完全可以特殊处理,但考虑到经历这个事件以后我应该不会再考虑这种「奇葩」的两处链接的形式了,所以我就把之前的 patch 回滚了,顺便增加一下我博客的曝光度 emm


anyway,博客又回来了,我又回到了原来的工作流:Notion 写作 + 同步到博客。已经这样写了两个博客了,一切都挺好,和两年前相比仿佛什么都没变 XD