stuff(winkyy~

I have but one purpose in this life, seeking the nature of the world.

Linux 磁盘占用问题排查

生产服务器硬盘空间满,服务频繁挂掉。经了解,此问题一直存在。

基本上生产服务器不会有太多额外内容,一般就是 Nginx 日志文件使用默认配置,长期运行会占用一些空间,但可以手动配置最大日志占用空间;另一个可疑的地方是 Docker。

快速定位问题并临时修复

使用 df -h 查看磁盘占用,发现某 overlay 占用过高。立即执行 docker rmi 清理一些旧的 image,虽然只释放了 2GB 左右的空间,但足以暂时稳定生产服务。

当前 CI/CD 配置:自建 GitLab + 自建 Docker Registry,通过 Swarm 管理,每份 image 都会在生产环境拉取。计划添加定时任务,清理 2 天前的 image。

Note (2019): 当时使用 Docker Swarm 进行容器编排。现代生产环境更多采用 Kubernetes。对于 Docker Registry 清理,建议使用 Registry 的垃圾回收机制(registry garbage-collect)而非仅依赖定时任务。

理论上问题不大(除非 2 天内频繁发布数百个版本,虽然按照当前开发规范,这种情况理论上可能发生)。后续如果需要改进,需要考虑权限管理问题。

进一步分析磁盘占用情况

1
docker system df -v

通过命令查看 image、container 和 volume 的具体占用情况。

Image 占用异常的情况,可能是由于不打 tag 而选择定时任务清理,配合开发流程上的缺陷共同导致。

Container 非常轻量化,很多都是以字节为单位,基本不会出现问题。

Volume 常见问题包括:日志文件、使用容器时不指定卷名导致混乱,或者数据库文件占用过大。

发现一个 19GB 的 volume,标识为 prod_redis。自建 Redis 开启了 AOF 持久化,但未配置自动重建,导致 AOF 文件持续增长。

解决方案如下:

首先清理无用资源:

1
docker system prune -a

清理无用的 image 和 container。

然后通过 SSH 或 docker attach 进入 Redis 服务:

1
redis-cli BGREWRITEAOF

重建 AOF 文件。整个重建过程会先生成新的 AOF 文件,再删除旧的,因此如果空间完全不足,需要先扩容硬盘。手动执行的意义在于确认剩余空间是否足够,并重置自动重建的阈值。自动重建是根据上一次重建的文件大小的比例来确认重建时机的,当前 AOF 文件过大,即使启用自动重建也可能达不到阈值。

最后修改 Redis 启动命令:

1
2
3
4
redis-server \
--appendonly yes \
--auto-aof-rewrite-percentage 200 \
--auto-aof-rewrite-min-size 5gb

设置合适的重建时机。由于该 volume 未命名,且 Redis 更新频率低,影响不大。

Note (2025): Redis 7.0+ 引入了 Multi-part AOF 机制,可以更高效地管理 AOF 文件。现代部署建议考虑使用 RDB + AOF 混合持久化模式,并配置合理的 maxmemory-policy

应用无状态

业务场景:公司自建代理服务进行 IP 轮换。处理业务问题时,发现即使是同一个用户,连续访问时代理 IP 也在持续变化。检查代码后发现了一些问题,主要涉及应用无状态这一设计思想。

为什么需要无状态及如何实现

以当前业务为例,需要维护一个可用的 IP 池,设计上应该尽量满足均衡使用,IP 不可用时更换或等待再次可用。均衡使用与无状态关系不大,这里主要关注 IP 可用性的维护。

一个简单的做法是定义一个数组,数组元素是 IP 对象,为每个 IP 对象添加一个属性标记是否可用。这种做法是有状态的应用,每个 IP 的状态都保留在应用实例内。如果业务量不大且是单实例运行,这种方式是可行的。

实际上,目前应用开发普遍采用集群部署。为了保证服务的高可用,生产环境一般会通过 Kubernetes 或 Swarm 配置多个应用副本,既可以分流,又可以保证在部分副本出现问题时,仍有副本可以正常提供服务(同时等待集群启动新的替换副本)。

因此,如果设计了有状态的应用,一个主要问题是:同一个应用的不同副本之间,状态不方便直接同步。以本例为例,实际上部署了双副本,那么用户访问时:

  1. 第一次访问到实例 A,发现 IP 被封,实例 A 将自己维护的 IP 状态改为不可用
  2. 第二次访问时:
    • 如果再次访问到实例 A,该失效 IP 不会给用户,是正确的
    • 如果访问到实例 B,实例 B 并不知道这个 IP 已失效,仍然分配了这个 IP,导致功能失效

而这个分流是随机的,如果没有提前考虑,后续随机出现问题,排查起来会比较困难。

常用的解决方案是:将需要保存的状态存放在 Redis 中,应用保持无状态,需要时从 Redis 读写。

当然后续也有高并发下状态一致性的问题,解决方案需要根据具体业务需求决定。当前业务只要求大致准确、失败时最终检出,因此没有加锁,直接先读判断,布尔值取反,优先改为 false 即可。其他业务需根据自身需求选择合适的方案。

因业务需要,要实现某网站的模拟登录,涉及一个相对简单的图片验证码。

之前的登录方案,同事采用的基本都是 JS 逆向。然而逆向的工作量较大,因此尝试了另一种思路来解决这个问题。

出于隐私考虑,网站名称不便透露,这里仅展示部分测试数据。

image

image

image

image

image

image

思路:

首先参考商业化解决方案,了解图片验证码的常见处理方式。

百度搜索”图像识别”、”图像文本识别”等关键词,出现了阿里云和腾讯云的广告,有一个关键词频繁出现:OCR。进一步搜索 OCR(Optical Character Recognition,光学字符识别),这是一个成熟的技术方向。在 GitHub 搜索该关键词,找到了现成的开源项目

该项目使用 C++ 编写,支持 Windows/Linux 平台,可通过 CLI 操作并指定输出路径。安装后进行测试。

使用画图工具手写了几个算式,识别效果良好。然而直接用目标网站的图片识别效果不佳。

分析原因:第一,图片内容存在问题,可以看上面的测试数据集,存在较多干扰线。第二,Tesseract 的配置需要优化,虽然未识别出任何内容,但结果是一个四行的空文档。针对后者查阅 Tesseract 文档,发现有一个配置项 --psm,用于预指定识别模式,选择模式 13(单行识别模式)。针对前者,本版本采用了简单的图像预处理。

因此,整体处理模块分为两个部分:第一,图像去干扰;第二,调用 OCR 生成结果并处理。

一、图像去干扰

该图片集本身相对简单(这也是能够实现的原因,思路可供参考)。

分析图片特征:第一,图片四周存在单像素的干扰信息,呈现为黑框。第二,图片中的干扰像素和信息像素颜色区分度较高,信息像素基本偏向黑色,虽然不完全是 #000000,但 RGB 值普遍小于干扰像素。因此设置一个阈值,筛选出不够黑的像素点,直接涂白处理。

具体代码如下:

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
const getPixels = require('get-pixels') // 该包较旧,仅支持回调,promisify 无法处理

const fs = require('fs')

const jpeg = require('jpeg-js') // 仅有 getPixels,没有 setPixels。需手动反向操作加密图片内容后再输出

const { exec } = require('child_process')

const detach = 30 // 颜色阈值,需逐步调试到合适水平

let pic = 'math'

process.argv[2] && (pic += process.argv[2])

const solve = () => {

getPixels(`${pic}.jpeg`, (err, pixels) => {

if (err) {

return

}

let x = pixels.shape[0]

let y = pixels.shape[1]

for (let i = 0 ; i < x ; i++) {

for (let j = 0 ; j < y ; j++) {

let r = pixels.get(i, j, 0)

let g = pixels.get(i, j, 1)

let b = pixels.get(i, j, 2)

// let a = pixels.get(i, j, 3)

if (r > detach || g > detach || b > detach || i === 0 || j === 0 || i === (x - 1) || j === (y - 1)) {

pixels.set(i, j, 0, 255)

pixels.set(i, j, 1, 255)

pixels.set(i, j, 2, 255)

}

}

}

let temp = {

width: x,

height: y,

data: pixels.data

}

fs.writeFileSync('./out.jpeg', jpeg.encode(temp).data)

// ocr() // 调用 OCR 处理

})

}

去干扰前后对比

image

image

二、调用 OCR 处理

Windows 下的安装包下载地址

安装过程按提示操作即可,安装完成后需手动设置环境变量。

Linux 用户请参考官方文档进行安装。

对应代码如下:

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
const ocr = () => {

exec('tesseract out.jpeg out --psm 13', () => {

const s = fs.readFileSync('./out.txt').toString('utf8')

let res

let nums = s.match(/\d+/g)

if (s.match(/\+/)) {

res = Number(nums[0]) + Number(nums[1])

} else if (s.match(/-/)) {

res = Number(nums[0]) - Number(nums[1])

} else if (s.match(/x/)) {

res = Number(nums[0]) * Number(nums[1])

} else if (s.match(/÷/)) {

res = Number(nums[0]) / Number(nums[1])

}

console.log(res)

})

}

测试发现两个问题:乘号被识别为小写字母 x,可以通过正则匹配处理;除号基本无法识别,这需要更深入的解决方案。好在识别失败的影响不大,可以重新调用接口。

本篇文章到此结束。下一期计划尝试训练 Tesseract 训练集,专门优化乘号、除号的识别问题。

Update (2025): 由于业务需求变更,本文原计划的下篇(Tesseract 训练集优化)未能完成。不过本文介绍的图像预处理 + OCR 识别的基本思路仍具有参考价值。

简介

本文总结了几个月工作中的主要内容:

完成了一个 CRUD 功能模块;开发了一个竞价排名系统,重点在于改进旧项目架构;参与了部分项目重构工作。

旧项目

竞价需求

需要排名的是 1000+ 个对象(支持实时添加),在未竞价时有一套多维度排序体系,维度有优先级,代称为 A、B。对象 1.A 大于对象 2.A 时,对象 1 排在前面;如果 A 相等,再比较 B,以此类推。竞价本身也作为一个维度,优先级最高,但只选出前 N 名排在前面,超过 N 名的退款,并按未竞价的情况排名。

竞价需要设置每次点击扣费额以及总次数。每次扣费额作为排名依据,总次数点击完后不再扣费,同时排名重新按原体系排序。竞价成功后,到期未用完的次数直接参与下一轮竞价。

产品原本要求竞价后即时排序,点击数用完后即时下撤。经过技术团队与产品沟通后,改为每 10 分钟统一更新一次榜单。

难点

排序问题本身不大,这个数量级不需要自行实现排序算法。

项目背景:基于 Node 6 的自研 Web 框架;启动时将部分不常变化的数据及基于这些数据计算的结果存放在全局变量,称为”静态数据”;存在大量自定义全局函数;MySQL 全局封装,不支持事务;Redis 全局封装,仅支持 get 和 set 方法;无守护进程;无负载均衡或多副本;部分数据每月更新,主项目需要冷重启,停服两小时。

Note (2019): 当时项目使用的 Node 6 已于 2019 年 4 月停止维护。现代项目应使用 LTS 版本(如 Node 18/20)并采用成熟的框架(如 NestJS、Fastify 等)。

一个问题是属性 A、B 实际上不固定,且是分库的统计字段。修改需要结合另一个项目手动运行脚本修改数据库再统计,主项目再冷重启。

另一个问题是 10 分钟排一次,中间可以加价,并且需要保存每次的竞价结果。客户端需要查看本人的时序结果,后台既要看到单人的时序、又要看到每个时间节点全部的时序,保留至少 30 天。维度实际上有很多,数据量是一个需要考虑的问题。

解决方案

应用无状态化

考虑到冷启动频繁,不应该有所谓”静态数据”,首先将”静态数据”迁移到 Redis。这些数据一般只在另一个手动脚本运行时才发生改变,因此统一交给该脚本维护。这样主项目即使冷重启,也只读取 Redis 中维护好的数据,而非重新读数据库重算。这部分工作将 Node 主进程占用内存从 900MB 降低到 200MB 左右,也便于后续实现多副本。

之所以保留这套数据,是因为这些数据过于基础,项目中到处使用。这样改才具有可行性,后期会直接重写。如果没有这种历史包袱,预期不会变的数据(如平台名、项目名)应直接写在 config.js 中;任何预期会改变的数据,不管改动是否频繁,都应该存储在数据库中。

守护进程 + 负载均衡

与同事配合完成,主进程用 PM2 管理;负载均衡直接使用阿里云服务,配置端口、重新配置证书即可。后期有专职运维同事加入。

OSS 存储记录

采用 OSS 存储主要考虑成本。只保留 30 天,按日覆盖文件夹,时序数据和全局数据直接生成 JSON。如果前端配合,可以直接在前端进行 OSS 权限验证,数据不经过服务器。

重写和一些思考

重写的主要原因是旧框架存在诸多问题。

这部分由 Node 组配合完成,我负责将之前开发的 CRUD 模块和相关模块用 Egg 重写。

使用完整的 MySQL 事务支持是一个重要改进。旧项目支付体系之所以没出现问题,主要归功于客户量少,未出现过并发场景。但作为技术人员,需要考虑系统在高并发场景下的健壮性。

重写的要点在于按产品需求编写完整的测试用例,尽管这会延长开发周期。在开发质量和效率之间,团队往往选择效率,即使反复强调,也较少考虑”后续维护欠债”的后果。

一些反思

产品的价值不在于特性列表的长度,而在于是否真正解决用户问题,创造实际价值。这需要用事实证明,而非依赖营销话术。

0%