初识 Volta & Corepack 前端版本管理工具

前些天在 macOS 上启动 Strapi,发现它依赖特定的 Node.js TLS 版本,之前电脑 node 使用 Brew 安装,切换起来也不是太方便,遂尝试了解前端版本管理工具。 发现 Volta(管理 Node 版本) + Corepack(管理依赖安装工具的版本)正式我要找的,笔记记录如下: Volta 工具 使用 Brew 安装 Volta $ brew install volta 安装不同版本的 Node # 安装 Node.js 22 版本 $ volta install node@22 # 安装 Node.js 24 版本 $ volta install node@24 安装 Tools # 作为演示,暂时先安装,后文有更适合的安装方式 $ volta install pnpm 查看已经安装的工具 $ volta list 输出默认版本 查看已安装的全部 Node 版本 $ volta list node 可以看到多个 Node 版本 设置全局默认 Node 版本 & 设置项目固定的版本 $ volta install node@24 --default # 在前端项目根目录下执行,需要有 package.json 否则报错 $ volta pin node@22 Pin 之后,Volta 会根据项目自动切换 Node 版本! 另外,可临时指定 Node 版本运行脚本 $ echo "console.log('node version:', process.version)" > app.js $ volta run --node 22 node app.js node version: v22.21.1 $ volta run --node 24 node app.js node version: v24.12.0 卸载 Node 版本 $ volta uninstall node@22 # 卸载特定版本 查看当前使用的 node 路径 $ volta which node /Users/dongdong/.volta/tools/image/node/24.12.0/bin/node volta 常用的命令如上,我使用它切换、锁定 Node 版本,很方便! 在项目中,除了锁 Node 版本,对应的依赖工具的版本也推荐锁定,因为 volta 无法锁定 pnpm 的版本,所以使用另外的工具 Corepack 工具 这个 corepack 我是刚听说的工具,它在 Node 16.9 版本中引入,安装了 Node.js 就有。 GPT: Corepack 是 Node 官方内置的工具,用于管理和锁定包管理器(如 pnpm、yarn)的版本,确保项目使用固定的包管理器而不依赖全局安装。 # 卸载 pnpm(之前是由 volta 安装的,无法切换锁定) $ volta uninstall pnpm # 安装(使用 volta 统一管理) $ volta install corepack success: installed corepack@0.34.5 with executables: corepack, pnpm, pnpx, yarn, yarnpkg 启用 Corepack # 查看版本 $ corepack -v 0.34.5 $ corepack enable 使用 Corepack 安装 pnpm(准备并激活) $ corepack prepare pnpm@10.28.0 --activate (或者)在启用 Corepack 后,直接运行 pnpm 命令也会触发其安装提醒 安装后会打印 pnpm 版本 $ pnpm -v 10.28.0 如果项目中已经有 package.json 文件,可以执行命令设置 pnpm 的版本值,需要三位准确版本号 $ pnpm pkg set packageManager=pnpm@10.28.0 执行后会写入 packageManager 字段声明 pnpm 版本,示例 JSON 如下: { "name": "volta-test", "private": true, "version": "0.1.0", "scripts": { "dev": "node index.js" }, "volta": { "node": "22.21.1" }, "packageManager": "pnpm@10.28.0" } 补充说明:Corepack 并非只能管理 pnpm,Yarn、Npm 包管理器也都支持,非常适合多项目的管理。 至此,项目中 Volta 锁 Node,Corepack 锁 pnpm,他们的关系如下: Volta ├─ node └─ corepack └─ pnpm@10.28.0(按项目) 最后总结 GPT:Volta 是 Node 与工具版本管理的核心,Corepack 是包管理器版本管理的核心,两者结合后可以实现项目级、团队级、跨机器环境一致性。

2026/1/13
articleCard.readMore

部署 Beszel 把 “小鸡们” 归拢起来

懒得折腾。但还是部署了,因为足够简单、轻量。 安装 Beszel 服务端 找一台 Linux 机器安装 Beszel 服务 mkdir -p /opt/service/beszel/ && cd /opt/service/beszel/ 编辑 vim /opt/service/beszel/docker-compose.yml 配置内容 配置通常可以直接使用,不用管配置中的 <令牌> 和 <密钥> 值,稍后启动服务后,通过 Console 页面可以获取到 Agent 的 KEY 和 Token services: beszel: image: henrygd/beszel:latest container_name: beszel restart: unless-stopped ports: - 8090:8090 volumes: - ./beszel_data:/beszel_data - ./beszel_socket:/beszel_socket beszel-agent: image: henrygd/beszel-agent:latest container_name: beszel-agent restart: unless-stopped network_mode: host volumes: - ./beszel_agent_data:/var/lib/beszel-agent - ./beszel_socket:/beszel_socket - /var/run/docker.sock:/var/run/docker.sock:ro environment: LISTEN: /beszel_socket/beszel.sock HUB_URL: http://localhost:8090 TOKEN: "<令牌>" KEY: "<密钥>" 启动 docker compose up -d 稍等下请求本地端口 $ curl -I http://localhost:8090 预期打印 HTTP/1.1 200 OK Content-Type: text/html; charset=utf-8 Vary: Origin X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN X-Xss-Protection: 1; mode=block Date: Wed, 07 Jan 2026 10:03:22 GMT Content-Length: 761 安装 Caddy 代理 以往一直使用 Nginx,这还是第一次使用 Caddy,为了 Auto HTTPS 特性。 安装文档:https://caddyserver.com/docs/install 编辑配置文件 vim /etc/caddy/Caddyfile # 格式化 caddy fmt --overwrite /etc/caddy/Caddyfile 配置内容 beszel.yasking.org { reverse_proxy 127.0.0.1:8090 } 校验 sudo caddy validate --config /etc/caddy/Caddyfile # 重载 sudo systemctl reload caddy # 查看状态 sudo systemctl status caddy 将域名地址指向服务器 IP,给 DNS 解析生效留点儿时间 而后在服务器上请求验证 curl -I -L --header "Host: beszel.yasking.org" http://localhost 预期输出 HTTP/1.1 308 Permanent Redirect Connection: close Location: https://beszel.yasking.org/ Server: Caddy Date: Wed, 07 Jan 2026 10:09:33 GMT HTTP/2 200 alt-svc: h3=":443"; ma=2592000 content-type: text/html; charset=utf-8 date: Wed, 07 Jan 2026 10:09:33 GMT vary: Origin via: 1.1 Caddy x-content-type-options: nosniff x-frame-options: SAMEORIGIN x-xss-protection: 1; mode=block content-length: 761 此时部署完成,可以访问:https://beszel.yasking.org/ 添加 Agent 你要监控哪个服务器,就在哪个机器上安装 Agent 添加本机 在部署 Beszel 服务的时候已经同时启动了 Beszel Agent,只是最初的 YAML 配置中 TOKEN 和 KEY 没有设置。 在 Console 上点击「添加客户端」,填入机器名称、主机填写 名称:(自定义名称) 主机:/beszel_socket/beszel.sock(不要修改) 保存后重启服务 $ docker compose up -d 添加其它服务器 也还是点击「添加客户端」 名称:(自定义名称) 主机:11.22.33.44(要监控指标的服务器 IP) 复制 Compose 文件或者 Docker run 命令到要监控指标的服务器上执行。 Beszel 效果 页面美观,部署简单、Golang 开发的也足够轻量。 首页 详情 移动端兼容良好 另外还支持 Webhook 推送告警信息,我暂时不需要就没继续设置了。

2026/1/7
articleCard.readMore

Memos: Claude Code in Action 中文版教程

前两天刷 V2EX 看到站长推荐「Claude Code in Action」教程,收藏了还没看,刚发现有网友翻译整理了中文版: 官方教程地址:https://anthropic.skilljar.com/claude-code-in-action 中文翻译地址:https://cholf5.com/claude-code-in-action/index.html Github 仓库:https://github.com/cholf5/claude-code-in-action

2026/1/6
articleCard.readMore

2025 年度回顾

想了想,还是记录一下。 阅读习惯 今年下半年重拾了阅读习惯,准确的说是从八月份开始,利用碎片化的时间在微信读书上阅读,也看了一些纸质书,平均每天不到一小时。 微信读书上领取体验卡,攒够 60 张 + 6 元可以兑换 30 天的会员(最近我发现如果 30 天会员每天都读书,30 天刚好能攒够 60 张体验卡,一年 72 元是微信读书会员最经济的开通方式) 2025 我阅读的书籍目录,其中《丰乳肥臀》、《一个名叫欧维的男人决定去死》、《置身事内》是我很推荐的书籍。 编程思考 今年初从手写代码转向 AI 辅助编程,使用 Cursor 刚好满一年,Cursor 提供的 2025 年终报告中显示我一年消耗了 1.57B 的 Token(Tab 用的较少),全年新增代码约 15+ 万行(保守统计),而在前两年,每年的编码行数都在 5 万行左右。 看了最近开发的需求,假设一年多前到现在,在编码速度上我没有显著变快的前提下,以下是开发两个需求的统计信息对比: 比对项 2025 项目 A(AI Assisted) 2024 项目 B(Manual) 备注信息 开发时长 7d 23d 借助 AI 显著压缩了开发时间。 新增行数 11,240 6,335 这两个需求难度差异不大,代码量差异主要跟需求大小有关。 删除行数 2,869 83 项目 A 新增模块的同时重构了旧有模块 日均净新增 ≈1,196 ≈272 单位时间产出显著提升。 日均变动量 ≈2,015 ≈279 “吞吐量” 约 7.2× 提升。 代码重构率 ≈25.5% ≈1.3% 使用 AI 开发 “持续重构” 的主观意愿更强。 近一年时间使用 AI 编程最大的感受是模型的能力越来越强,开发者可以越来越 “省力”,手不酸了,等 AI 写代码期间可以冲杯咖啡歇息下。 但 “省力” 不等于 “不费脑”,根据 “Garbage in, garbage out” 原理,代码的质量上限完全取决于使用的人,对使用者的工程化实践和软件架构能力会有更高的要求,编程的核心进一步从 “编” 代码偏向于架构设计,AI 并没有让编程更 “简单”,而是让 “会不会做工程” 这件事更赤裸。 博客改造 以往在博客主要是技术笔记,今年陆续增加了“阅读”、“生活”、“生活技巧” 类别,很多时候适当的记录也有助于思考,技术如此,生活亦是如此,写博客也保持了一定的克制,避免博客内容过于零散、碎片化。 博客的样式在不改变主题的情况下,借助 AI 对样式进行了调优,这在以前对于后端工程师来说是很困难的。 写了一个 Obsidian 插件用于导出文章,这样博文中添加图像与发布能在一定程度上自动化,方便很多。 年度报告 年末各家都出了年度报告,想了下自己就微信读书、B 站、QQ 音乐用的多。 在哔哩哔哩天均半小时,主要看了一些 UP 主自制的熊猫人动画,追一些动漫及纪录片,预计明年会因为读书的等效替代而降低刷视频时间; 中国移动 APP 每个月能领一个月的 QQ 音乐会员(网龄礼),所以今年听歌使用的是 QQ 音乐,2025 年听歌时长 100 小时,年度歌手是 G.E.M 邓紫棋,但是第二名杨瑜婷和第三名程响,对我来说后两者是很陌生的名字,单曲循环最多是《旅人》,出自《凡人修仙传》,这个动漫现在真的是国漫独一档,为了看它我开了 B 站会员。 资源整理 最近的思考是资源如果不整理,那么只会占用磁盘而没有太多的意义。 近五年的照片,有几百 GB 存储在 OneDrive,另外还有一些分散在电脑、存储卡、移动硬盘、还有百度网盘中,大多处于无序的状态;对于重要的内容,加密后遵循 3-2-1 原则进行备份,文字笔记使用 Obsidian 记录。 这两个月在抽空将图像视频去重、归档,需要备份的资料也在分门别类。将已经有序化的数据存储到一个 4TB 的机械硬盘中(使用带有独立电源的硬盘盒),每个月也将上个月手机产生的图像视频进行备份后从手机删除。 对于在 macOS 上从 OneDrive 网盘下载回来的数据,我先导入到相机的 SD 卡,然后再从 SD 卡通过 Windows 电脑导入到 NTFS 格式的机械硬盘中,这个速度其实有些慢,所以妻子问我在做什么,我总是在导数据,我就说 “在备份照片”,并答应她以后也将她的手机照片备份。 难忘记忆 一年中有欢笑、有悲伤,最难过的莫过于 11.27 日,爷爷走了。 突闻噩耗,我手忙脚乱,这是我第一次面对亲人的离世,爷爷今年 79 岁,这两年一直卧床,身体状况越来越差,只能喂流食,过了年的阴历二月就是爷爷八十岁的生日,没人想到在一个寻常的下午,爷爷没了气息,爸爸给我打了电话,妻子陪我回的老家。 想起小时候的关于爷爷的记忆总会心里难受,看他日渐衰残的状态更让人心疼,但也明白爷爷这样每天的生活很遭罪,家人照料也很辛苦,爷爷忘记了所有人,更没办法说话表达,不能抽颗烟,也喝不了他爱的酒。 思绪总在闲暇时上涌,写着写着眼泪又在眼中打转,想起那天,告别仪式我努力想记住爷爷的样子,火化后众人将骨灰埋入坟莹,那天天气晴朗,山上的雪很白,落叶满地,我拾了两叶,就好像能回到那天...

2025/12/31
articleCard.readMore

Memos: 体验 tanaos-text-anonymizer-v1 NER 模型

在网上看到有人推荐 tanaos/tanaos-text-anonymizer-v1 可用于信息脱敏,496 MB 大小很适合本地使用。 一开始我还以为它是大语言模型(LLM),想着用 Ollama 拉起来跑,之后发现完全不是一回事。它本质上是一个用于 NER(命名实体识别)任务的模型。 用人话说就是:输入一段文本,模型会扫描其中的实体,并返回命中的内容和对应的标签。 PERSON John Smith EMAIL john.smith@company.com PHONE_NUMBER +1-202-555-0199 LOCATION New York 测试后感觉英文人名和地址识别还不错,不支持中文,另外想要识别数据库链接、AK/SK 等敏感信息也还是需要搭配正则表达式使用,之前我把它理解成了 “万能脱敏器”。🤷 Python3 的示例代码在:Gist

2025/12/26
articleCard.readMore

Memos: 查询 Google 账号注册时间

查询 Google 账号的注册时间: 访问 https://takeout.google.com/; 只勾选 「Google 账号」选项后提交导出; 半分钟后刷新页面,页面最上方可以看到「您的最新导出作业」,点击下载; 解压缩 zip 包,.ChangeHistory.html 和 .SubscriberInfo.html 网页内有注册时间。 我的 Google 账号注册时间是 2015-01-24,一个寻常的午后。

2025/12/19
articleCard.readMore

Memos: 关于 Z30 在室内摄像被手机降维打击这点儿事儿

周末拍摄一场室内的活动,带了独脚架,设备是 Nikon Z30 + DX 12-28mm 镜头,录制过程中从相机屏幕上看到画面良好,导出到电脑查看发现画面惨不忍睹:对焦飘忽、噪点严重且暗角明显。 首次在活动中尝试使用 Z30 录制视频宣告失败,直面 “入门相机” 在复杂光线下被 “高度优化的计算摄影手机” 降维打击这档子事儿,在室内场景也体验到了半画幅天黑就回家! 反而手持 iPhone 16 数字版录制的一段视频效果还不错,虽然没纵深,但是曝光通过算法控制的很好,视频体积也不大! 极端爆论:普通用户无脑使用苹果手机录像能获得到性价比最高的视频质量,整机一万出头的预算,主要场景是视频拍摄,请放弃相机,闭眼买一台 iPhone,记得钱加在存储空间上!

2025/12/18
articleCard.readMore

阅读《我与地坛》

读过史铁生《我与地坛》,久久没写读书笔记,划线很多,但一味的摘录我觉着不合适。 作者从地坛公园讲起,不同以往的清幽与宁静,被开发为景区的地坛公园,接待了有许多游客,多了几分热闹和喧嚣,单从个人的理解出发:地坛是铁生在现实世界的陪伴者,亦是他心中的地坛,承载着他生命的坚韧和思考。 这本书最让我感动的部分是作者对于母亲的记录,铁生患病后脾气不好,常一个人到公园,母亲总悄悄的来找他,怕被铁生发现、也担心打扰到他,时而铁生看到母亲也当作没瞧见,让母亲一顿好找。母亲几次为铁生的工作奔波,被别人说呛回来,路上拾了个花枝种在院落,母亲走后些许年,枝条长大竟是合欢树,文中字字不提思念,又句句都是想念。“寻常”的文字能承载诸多的情绪,不经意间就能被戳中泪点。 书中还有几篇章节适合多次品读,印象颇深的还有对照顾残障人士思辨,按我的理解,社会有义务在公共场所提供便利的设施,重要的是除掉异样的目光和偏见,身体的残疾无法避免,也不是罪大恶极,世界有时并不美好,但我们总可以修修补补。 此外,儿时的小学堂凸显人性的拉帮结派、紧张时期小姑娘M分众人糖豆事件,庄子那激荡又短暂的人生,都让人印象深刻。 之前没去过地坛公园,有机会我想去看看铁生走过的角落,去看那一如从前的树,跨越着时间,它不言不语,且听风声,或许时间也能让我遇见属于自己的地坛。 最后,“铁生是一个极细腻、温柔,善良的人。” 这是我关于本书推荐语。

2025/12/13
articleCard.readMore

Memos: Ghostty + Neovim + LazyVim

安装了 Ghostty + Neovim + LazyVim,开箱即用、配色舒适,果断放弃了自己配置的 Oh My Zsh 方案,又是迈向现代化的一天。 使用 nvim 打开文件:

2025/12/12
articleCard.readMore

Memos: 找到 Cursor 运行巨慢的一个原因

这两天一直没有办法 Vibe Coding Cursor 巨慢到一定程度,17:45 提交的任务,18:21 还在 “吭哧瘪肚” 的输出。 以至于我不得不使用网页版,降级到手动复制粘贴的方式调优代码。 排查了网络、换用了模型都不行,想到要不重建下索引试试,然后问题就这样解决了 🤷

2025/12/12
articleCard.readMore

Memos: 京东家政

前些天发现京东 PLUS 快过期了,还能兑换家政服务,于是预约了 2 小时的日常清洁。 “阿姨”来了后,跟我沟通清洁哪里,想了下,就跟她说厨房和卫生间,没什么特别需要清理的地方,按她的方式就好,就去客厅窗边歇息了,厨房大概清洁一小时20分钟,另外40分钟清理卫生间,我看时间到了,就去提醒她,也聊了几句天,得知: 她们属于京东家政的员工; 派单模式,每天三四单,在选定地点的几公里范围内派单; 每周休息一天,可以灵活选择休息日。 清洁的非常干净,搞定了很多卫生死角!咸鱼之前 39.9 元两小时,最近已经涨价到 49.9 左右,感觉家政服务有依赖性啊,现在完全不想自己 “大扫除”,只想「专业的事情交给专业的人」可能是因为懒 P.S. 这个工作蛮辛苦的,纯体力劳动、非固定工作区域,每家情况都不同,也无法灵活的像骑手一样兼职,好在月月有稳定的工资,还蛮不错。

2025/12/11
articleCard.readMore

哈尔滨灵活就业人员医保退休待遇申领条件

以下信息咨询医保热线得知,任何疑问咨询哈尔滨市医疗保障服热线:0451-12393 达到国家规定的法定退休年龄(退休年龄计算器); 以灵活就业人员身份办理完成基本养老保险退休手续(医保和养老保险退休身份需要一致); 实缴医疗保险费满 15 年(男女不限),其中哈尔滨地区最低需缴纳 10 年。 补充说明 曾参加单位职工医保的缴费年限可计入上述 15 年实际缴费年限; 在其他省、市参加职工医疗保险的实际缴费年限亦可合并计算(最多使用 5 年); 以上规定为哈尔滨市当前执行口径,政策可能随时间调整,仅对近年具有参考意义; 养老保险最低缴纳年限在 2030 年后会逐年提高(政策),医保最低缴纳 15 年不受影响。

2025/12/9
articleCard.readMore

记地暖不热的维修过程

前段时间哈尔滨进入供暖季,地热供暖的房屋,供热后地面摸着不太对,有些区域温热,有些区域摸着冰凉。 先电话联系了物业,物业维修的师傅说这事儿得找供暖公司,地暖不热由他们负责排查和维修,打通了供暖公司的服务电话,约好了时间上门排查。 一般情况,供暖不热,可能是地暖管道有气体空腔,水流没有很好的循环,这个供暖公司师傅就在地暖出口开始放水,放了一阵后有温水出来后就说 “没问题了”,测了室内的温度 22 度,算是达标了就不想再管。 问题是同小区别家同户型卧室能达到 28 度,显然不能都归结到 ‘房屋气密性’ 问题,供暖费不能花了受冻不是。 了解了一圈,发现地暖不热还可能有几个原因: 分水器坏了; 地暖管道污垢太多,水流通不畅; 想着在网上找个维修地暖的师傅去看看,也是几年没有清洗地暖管道,顺便清洗一遍。 先联系的这位师傅是咸鱼上的个人中介给找的,没介绍费,这个师傅就 “厉害” 了,人还没去就说那是分水器坏了,要给我换一套,他说 “用的都是日丰品牌,纯铜的质量过硬”,提供给我的报价是 180/路,我家是 4 路,那么就是 720 元一套,我说那清洗地热多少钱,300 元/次,都下来的话收一千块抹个零。 (日丰四路分水器) 放下电话,直觉上这价格就不合理,淘宝一搜果然,日丰四路分水器 220 元一套,这师傅狮子大开口过分真实了,就跟他说不用上门了,就在淘宝上又找个师傅咨询。 都说货比三家,找维修师傅也得多问问。新的师傅清洗地热报价 230 元,让他上门看看地暖不热的原因,还真给找到了—— 下图中红色的那个球形止水阀损坏,出水管有往回的水压,导致入户热水无法正常流出,阻塞水循环,进而部分区域不热,出水管处冰凉。 这个日丰止水阀门师傅报价 120 元,是一对拆开卖的,工 + 料最后付师傅 318 元,算是比较合理的价格。 维修完我也让他清洗了地暖,我爸也说地暖水不脏,我是比较好奇这地热管路到底有没有污垢,供暖公司说管线没事儿(可能是利益相关,供暖公司的入户热水脏是他们的问题) 请看 VCR: Your user agent does not support the HTML5 Video element. 在我维修完成后,大开口师傅又给我发来消息:“你要是嫌贵,那不换分水器光清洗地热也行。” 这师傅怕不是把我当傻子了 🤔 经验记录 能淘宝尽量远离咸鱼个人中介(省下‘中介费’找到的人也容易不靠谱); 地暖间隔几年需要清洗,入户水可能携带杂质,如果地暖彻底堵塞,会很麻烦; 找维修师傅也要货比三家,避免被宰; 分水器很不容易损坏,三五年完全没问题,除非十多年的老设备需要注意;

2025/12/6
articleCard.readMore

Memos: 赛博菩萨 Cloudflare 又挂了

Jenkins 部署打包前端报错 npm ERR! code E500 npm ERR! 500 Internal Server Error - GET https://registry.npmjs.org/vue-emoji-picker 网上一搜,Cloudflare 出问题,Status 状态页:https://www.cloudflarestatus.com/ 检测服务是否挂掉的服务因为使用 CF 也挂了 https://downdetector.com/ 好家伙,看到网友分享了一个神奇的网站,独立监控这一块: https://downdetectorsdowndetectorsdowndetectorsdowndetector.com/ https://downdetectorsdowndetectorsdowndetector.com/ https://downdetectorsdowndetector.com/ https://downdetector.com/

2025/12/5
articleCard.readMore

AnyTLS 软件的配置与使用

本文内容仅用于计算机网络与信息安全的学习与研究,不构成任何形式的技术服务或操作建议,亦不鼓励将相关信息用于非学术目的。 项目地址:https://github.com/anytls/anytls-go 项目介绍:一个试图缓解嵌套的 TLS 握手指纹(TLS in TLS) 问题的代理协议。anytls-go 是该协议的参考实现。 在服务器运行 AnyTLS Server 实验环境下,临时关闭防火墙,放行端口 iptables -F firewall-cmd --permanent --add-port 8443/tcp firewall-cmd --reload 到 Release 获取最新版并下载运行 $ wget -O anytls_0.0.11_linux_amd64.zip https://github.com/anytls/anytls-go/releases/download/v0.0.11/anytls_0.0.11_linux_amd64.zip $ unzip anytls_0.0.11_linux_amd64.zip $ ./anytls-server -l 0.0.0.0:8443 -p 密码 本地运行 AnyTLS Client 替换服务器的 IP、端口以及连接密码 ./anytls-client -l 127.0.0.1:1080 -s 服务器IP:8443 -p 密码 本地访问测试 不使用转发 curl https://ip.me 使用 AnyTLS 转发请求 curl -x socks5://127.0.0.1:1080 https://ip.me 预期会打印出服务器的 IP 服务端使用 Mihomo 启动运行 Mihomo 是个高性能、跨平台的代理规则处理核心; 安装 Mihomo 服务 下载地址:https://github.com/MetaCubeX/mihomo/releases $ wget https://github.com/MetaCubeX/mihomo/releases/download/v1.19.17/mihomo-linux-amd64-v2-go123-v1.19.17.gz $ gzip -d mihomo-linux-amd64-v2-go123-v1.19.17.gz $ mv mihomo-linux-amd64-v2-go123-v1.19.17 /usr/local/bin/mihomo $ chmod +x /usr/local/bin/mihomo # 添加目录到环境变量 $ echo 'export PATH="/usr/local/bin:$PATH"' >> ~/.bashrc 我下载的 V2 版本,因为使用 V3 版本报错 “This program can only be run on AMD64 processors with v3 microarchitecture support.” 执行测试 mihomo -v 输出 Mihomo Meta v1.19.17 linux amd64 with go1.23.12 Mon Dec 1 01:06:20 UTC 2025 Use tags: with_gvisor 生成自签证书 openssl req -x509 -newkey ec:<(openssl ecparam -name prime256v1) \ -keyout server.key \ -out server.crt \ -days 825 \ -nodes \ -sha256 \ -subj "/CN=bing.com" \ -addext "subjectAltName=DNS:bing.com,DNS:www.bing.com" # 移动到配置目录 mkdir -p /etc/mihomo mv server.key /etc/mihomo mv server.crt /etc/mihomo 其中 server.crt 是自签证书,server.key 是 EC 私钥。 生成 Mihomo 配置文件 (注意替换配置文件中的用户名密码) cat > config.yaml << 'EOF' listeners: - name: anytls-in-1 type: anytls port: 8443 listen: 0.0.0.0 users: username1: 密码1 username2: 密码2 certificate: /etc/mihomo/server.crt private-key: /etc/mihomo/server.key padding-scheme: | stop=8 0=30-30 1=100-400 2=400-500,c,500-1000,c,500-1000,c,500-1000,c,500-1000 3=9-9,500-1000 4=500-1000 5=500-1000 6=500-1000 7=500-1000 EOF 将配置文件移动到配置目录 mkdir -p /etc/mihomo mv config.yaml /etc/mihomo/ 运行测试 -d 参数是 mihomo 的工作目录,会在其中查找配置文件、规则文件等 mihomo -d /etc/mihomo # 后台运行 nohup /usr/local/bin/mihomo -d /etc/mihomo > /var/log/mihomo.log 2>&1 & # 结束进程 pkill -f mihomo 启动后使用 tail -f /var/log/mihomo.log time="2025-08-15T11:06:20.771313086Z" level=info msg="Start initial configuration in progress" time="2025-08-15T11:06:20.771797889Z" level=info msg="Geodata Loader mode: memconservative" time="2025-08-15T11:06:20.771829776Z" level=info msg="Geosite Matcher implementation: succinct" time="2025-08-15T11:06:20.772283187Z" level=info msg="Initial configuration complete, total time: 0ms" time="2025-08-15T11:06:20.812756242Z" level=info msg="Sniffer is closed" time="2025-08-15T11:06:20.814453853Z" level=info msg="AnyTLS[anytls-in-1] proxy listening at: [::]:8443" time="2025-08-15T11:06:20.814654607Z" level=info msg="Start initial compatible provider default" Mihomo 服务端启动完毕,在本地 anytls-client 客户端启动的情况下,可以在本地请求获取自己的 IP,预期为服务器的 IP curl -x socks5://127.0.0.1:1080 https://ip.me 本地 Clash Verge 加载配置 目前不太会配置本地 Clash Verge 客户端的 config 配置,用 AI 精简了一个订阅的配置,勉强能用 mode: rule mixed-port: 7897 allow-lan: false log-level: warning ipv6: true unified-delay: true external-controller-unix: /var/tmp/verge/verge-mihomo.sock bind-address: '*' profile: store-selected: true proxies: - name: "Mihomo-anytls" type: anytls server: 23.xx.xx.xx # 替换为你的 Mihomo 服务器 IP/域名 port: 8443 # 替换为你的 Mihomo 配置端口 username: dong # 替换为 Mihomo 用户名 password: qq123456 # 替换为 Mihomo 密码 tls: true skip-cert-verify: true # 自签证书保留 true,可信证书可去掉 proxy-groups: - name: "Mihomo-Group" type: select proxies: - "Mihomo-anytls" rules: - MATCH,Mihomo-Group Firefox 浏览器使用的 SmartProxy 扩展,Clash Verge 使用系统代理模式。 访问 https://www.ipaddress.my/ ,已显示为服务器 IP,可以有效的保护个人隐私。 PS:之前有忘记了这个 VPS 是在哪里买的,感谢其显示了 VPS 的 ISP 信息。

2025/12/4
articleCard.readMore

阅读《在巴东》

最开始听说‘陈行甲’这个名字并非源于网络,而是从一友人口中。当年陈老师去他们学校演讲,结束后在校门口,利用上车前的短暂间歇,友人有幸与他合影。 前段时间翻微信读书的免费图书馆碰运气,偶然间看到陈行甲的《在巴东》就领取到了书架,开始阅读就深深着迷,读完后仍意犹未尽,进而又搜索了当年的一些视频,别有一番感受。 《在巴东》是陈行甲《在峡江的转弯处》中最精彩的节选,作者讲述从上任湖北巴东县委书记到五年任期结束这段时间的任职历程,在治理和斗争中政府的公信力得以重建,在温暖和关怀中巴东县的民心也再次凝聚。 作者文笔流畅而富有亲和力,记录详实(得益于作者有写日记的习惯),读来朗朗上口。通读下来,我感觉最难得的是作者面对困难时的心态和担当,难以界定的人情关系、重金诱惑以及切实的家人安全的威胁,很能感受到坚守本心之难,之弥足珍贵,艰难之时,中途一度抑郁住院,清官不易,有作为则更是难上加难! 推荐《在巴东》,短短三万字,能看到陈老师直面困境,守住本心的坚持,看到黑暗中仍有人发光,无形中带给人如火的力量,最终的激流勇退于人民和国家来说是极大的损失,但对个人是智慧而勇敢的抉择,所幸陈书记投身公益,仍能发光发热,身体力行的影响着许多人。 这本书另一个有趣的地方在于可以查考,例如读到一处人名,读者可以通过网络了解到他们的近况,十多年时间,艳平从接班县委书记,现已升任州委常委,成为高级领导干部: 我今天和大家谈这些,是希望大家都放下这个沉重的包袱。我和艳平同志深入沟通过这个问题,我们有决心有信心带好巴东的风气!大家想啊,如果我不收,艳平不收,书记县长都不收了,那么你给别的领导送钱干什么呀?有什么用啊? 而另一位给出陈行甲以下评价的「原湖北省委巡视组组长」樊仁富已经于2023年10月,涉嫌严重违法,接受湖北省监察委员会监察调查。 陈行甲 “一身正气、一身杀气、一身朝气” 最后,摘录两段收藏的佳句,展现了陈老师细腻而温柔,坚定且无愧的人生信念: “我不敢说自己不负苍生,但我敢说自己不负本心,敢说自己是个不收钱的县委书记,敢说自己已经拼尽全力。” “轻轻地我走了,正如我轻轻地来;我挥一挥衣袖,不带走一片云彩。”再见了,我的巴东!天空中没有翅膀的痕迹,而我已飞过。此去经年,山长水阔,你在我的心里,在我的梦里。”

2025/11/20
articleCard.readMore

Memos: Web Archive 暂时离线

想缓存个网页,发现 Web Archive 离线了 地址: https://web.archive.org/sry 最近总能感受到互联网的脆弱 🤷 2025-11-21 更新 目前,服务已经恢复: https://web.archive.org/ 官方 Mastodon 发布公告: Status update: fiber cut between data centers - trucks are rolling. Services are slow but functional, except for the Wayback Machine, which is still offline. 状态更新:数据中心之间的光纤被切断 - 抢修车辆已出动。除Wayback Machine仍处于离线状态外,各项服务运行缓慢但功能正常。

2025/11/20
articleCard.readMore

忆时光:十五年前我的家(动迁前夕)

家族群里叔叔发了几张老照片,看着破旧、熟悉的小二楼,记忆瞬间被拉回到小时候,动迁前的那些记忆时光。 照片的小二楼是我的家,当初盖房子的时候,它可是这片顶时髦的房子,周边都是清一色的平房,照片中的人是我爷爷,90年代初期,爷爷带领十几二十来岁的爸爸以及一些亲戚一砖一瓦将房子盖起来,几年后我出生,这就是我生长的地方。 小二楼的背面有一个二层的阳台,七八岁正式淘气又安静的我会在栏杆上绑上绳子,然后从二楼一点点降下来,现在想来我胆子还蛮大的,大人竟也不觉得危险。 照片右侧的平房是爷爷奶奶家的位置,爷爷家是火炕,小二楼是暖气,烧锅炉的时候,我们家、叔叔家都统一供暖,奶奶负责烧炉子,我最喜欢的事情就是找点稀奇古怪的东西,然后赶在奶奶添柴的时候扔到桔红色的火焰中,感觉一切都会被焚烧。 当然大人们不让我乱扔,‘小孩儿玩火尿炕’;灶坑会 ‘打枪’。我从小就总听说。 阳台上另外一个印象深刻的场景就是叔叔家的鱼缸,叔叔喜欢养鱼,就会 “发酵” 一些鱼食或是其它什么散发出味道的东西,每次上外阳台都会感觉臭臭的。 这幅照片中,阔门的小平房是爷爷家的猪圈,正上方有一个外置 ‘全景天窗’ 用于采光和通风,大门是可以下拉的卷帘门,早年爸爸跑邮政送件的车停在里面,平房中大概养了四五头猪,奶奶负责喂养,主要的饲料是糠和酒糟,以及各种青草,猪圈很臭、苍蝇多多,无聊时我就拿个棍子去给猪抓痒,它们总是乐意被挠痒痒,能看猪猪们舒服的笑着,跟它说说话,它并不回答。 再早一些,爷爷还养过山羊养过狗,想来有二十几只,据说养狗赔了钱,童年中,我印象最深的一条狗叫 “四眼”,他的眼睛上方有两撮跟体毛不一样颜色的毛发,看起来像是四个眼睛,故名四眼。 四眼跟我的关系最好,狗圈栅栏不高,支个板子我就能跳进去,然后一群狗围着我,我吼着它们也不听,我把四眼带出来,给它吃小灶,跟它玩耍,它是我最好的动物朋友。 印象中奶奶挑了我的理,原因是我花了几毛钱给四眼买了淀粉肠,奶奶说她还没吃过,我不言语,也不好意思,印象深刻。 一天上学,听说爷爷要卖狗,我再三恳求要留下四眼,甚至把四眼抱到了我的屋子让妈妈帮忙照看,结果回来时四眼还是被卖了,我难受了好些天。现在想来,家人们着实没有太多心力来替我养一只‘宠物狗’。 北面的 ‘几趟杆’(街巷旁一横排的房屋称为‘一趟杆’,东北话中 ‘我们住一趟杆’ 即我们是邻里),已经动迁盖上了楼房,我们这排动迁的时候,爷爷是很期盼的,都说那时候的拆迁户都会赚的盆满钵满,爷爷没多要什么,几个儿女几间房,就同意了拆迁。 现在想来,顺利的拆迁就是最大的福,除去煤炭价格年年上升每年烧 “钱” 的经济负担外,奶奶年岁渐长,从院子里一桶桶的抬煤也有些力不从心,这片土地开发完,南侧的几趟平房这些许年都没等来开发,他们常说爷爷保守,但十五年的商品房供暖,实打实的解放了奶奶,二十年的老房子,也迎来了它最后的安歇。 照片未及之地,有奶奶种满豆角茄子绿辣椒的菜园;有一条同样跟我友谊深厚的老狗 ‘大黄’;有我用铁锹亲手挖掘的 ‘地道’;也有冬天让人 ‘举步维艰’ 角落里的旱厕。 十五年后看它,样子是陈旧过时的,是漏风也是坚毅的; 再看它,是记忆时光、是童年美好、也是来时的路。

2025/11/2
articleCard.readMore

macOS 系统部署 Valkey 集群模式

两年多前记录了一篇博客《macOS 系统安装 Redis 并启用集群模式》,本机使用 bash 脚本启动多个 Redis 实例组成集群,操作较为繁琐 整理了基于 macOS OrbStack 的 Valkey 的本地集群部署方式,记录如下: 本文基于 OrbStack 软件部署,非 OrbStack 环境未经测试验证 获取配置 创建本地目录并获取配置 $ mkdir -p ~/Portable & cd ~/Portable $ git clone https://github.com/sincerefly/conf-valkey-cluster.git 添加权限 $ cd conf-valkey-cluster $ chmod +x ./init.sh 运行服务 初始化并运行,默认镜像 valkey/valkey:7.2-alpine # valkey/valkey:7.2-alpine $ ./init.sh # valkey/valkey:9.0-alpine $ VALKEY_IMAGE=8.0-alpine ./init.sh 稍等片刻可以看到 [2025-10-30 19:29:08] Cluster is healthy [2025-10-30 19:29:08] Valkey cluster initialized successfully! [2025-10-30 19:29:08] Cluster nodes: 940fbd29e31707348db33d030ec40ec927c13712 192.168.97.2:7001@17001 master - 0 1761823748351 2 connected 5461-10922 5a74931fdf31a55eebfea8f0fb04cde5c50bce0a 192.168.97.5:7002@17002 master - 0 1761823747118 3 connected 10923-16383 9c49963d7285875867c86e3f033c279bce6a951f 192.168.97.3:7004@17004 slave efe7608d5a60031d02f3d239d5cc06aa8bfa2708 0 1761823747941 1 connected 290e3d428b7df6511fabee0b1510d1ab4ebac8e9 192.168.97.6:7003@17003 slave 5a74931fdf31a55eebfea8f0fb04cde5c50bce0a 0 1761823747000 3 connected 3882a00169987d638b1a628f38e8d00c328873aa 192.168.97.7:7005@17005 slave 940fbd29e31707348db33d030ec40ec927c13712 0 1761823746906 2 connected efe7608d5a60031d02f3d239d5cc06aa8bfa2708 192.168.97.4:7000@17000 myself,master - 0 1761823746000 1 connected 0-5460 连接测试 OrbStack 软件页面 $ redis-cli -c -h 127.0.0.1 -p 7000 ping PONG 查询集群信息 > CLUSTER INFO 服务启停 # 停止 # VALKEY_IMAGE=valkey/valkey:7.2-alpine docker compose down $ ./stop.sh # 启动 # VALKEY_IMAGE=valkey/valkey:7.2-alpine docker compose up -d $ ./start.sh # 如果使用了 ENV 需要 VALKEY_IMAGE=9.0-alpine ./start.sh VALKEY_IMAGE=9.0-alpine ./stop.sh 先这样,目前我用着还比较顺手,有时间可以再进行一些更通用化的完善

2025/10/31
articleCard.readMore

阅读《一个名叫欧维的男人决定去死》

最开始决定阅读《一个名叫欧维的男人决定去死》是因为在网上有人问了个问题 ‘你把哪个作家的作品都读过一遍’,我留意到一位名叫弗雷德里克·巴克曼的作家 在微信读书上看有好些本他写的书,问了 GPT 老师推荐先阅读哪一本,它强烈建议我从这本读起,我没理由拒绝 读了前几十页,发现这本书没吸引到我,一个守旧派,脾气也不太好的毒舌老头有什么好看的呢? 相较于同期阅读的《丰乳肥臀》,虽感 “没深度”,但我还是愿意阅读下去,这么畅销的书,肯定不像我想的那样简单 继续读着就有意思起来,我划线了不少 ‘金句’,摘抄几句如下 奔驰男狂摁喇叭。“傻逼。” 欧维心想。 —— 果然最饱满的文学表达就是要用最直接的文字。 就像欧维太太常说的那样:“要是有什么值得写进欧维的讣告,那就是‘无论如何,此人还算省油’。” —— 没忍住笑 欧维家隔壁住着个体重近四分之一吨的二十五岁男人。 —— 高矮胖瘦,欧维对所有人的 ‘毒舌’ 一视同仁,后来发现对他的妻子也不能例外 每个人的生命中总有那么一刻决定他们将成为什么样的人。要是你不了解那个故事,就不了解那个人。 —— 偶尔冒出的小哲思恰到好处 作者很会使用比喻,精准又形象 就像描述心跳的厉害,我脑子里就冒出 “心怦怦跳” 等小学生词汇 而作者会写下: 胸口在外套下沉重地起伏着 也有 心脏猛烈地砰砰作响,就像他的胸腔是世界上最后一座还能用的公厕门 这比喻秒极! 本书写作手法也比较有意思,第一章讲现在的故事,那么下一章就讲到回忆,再下一章再回到现在,时间的交错,让人物变的丰满,欧维这个 “不讨喜的固执老头”,身上的光芒再也无法被掩盖 面对指控,也要秉持不在背后说人闲话的铁律;在邻居家火灾即将蔓延到父亲留给自己的房屋时,选择冲进火海救人;嘴上冷漠但心里总是将邻里拜托的事儿记在心上;连想要自杀都要考虑不给任何人,包括 ‘那只不属于自己的猫’ 带来影响; 对欧维来说,是即是,非即非。诚实、勇敢讲原则,嘴硬搭配一颗柔软的心,这个老头无比可爱! 轻松、简单、治愈,作者通过这本书带给人美好的东西,刚开始阅读本书时对于欧维比较无感,在阅读后很自然的爱上了这个老头,这没什么可说的 欧维的世界非黑即白,她的妻子带给他五颜六色,本书讲诉想要自杀去陪伴妻子的欧维,最终在邻里的 ‘被迫’ 往来中被治愈,人需要关系,也需要 ‘被需要’ 推荐给你这本书,希望你也能被温暖到

2025/10/29
articleCard.readMore

Memos: Cursor 服务故障部分功能不可用

洋洋洒洒写了几百字的需求提交给 Cursor 干活儿,它撂挑子了 Cursor Status 地址: https://status.cursor.com 目前,服务已经恢复 Resolved - This incident has been resolved. Oct 29, 02:33 UTC

2025/10/29
articleCard.readMore

阅读《丰乳肥臀》

读过莫言的《不被大风吹倒》,就打定主意阅读久仰大名的《丰乳肥臀》,在这之前,我一直以为这本书是写自几年前,很可能那时候这本书又火了 《丰乳肥臀》出版于 1995,构思十余年,不到三个月的写作时间,当真佩服,看到有句书评很有意思,读者感慨道:“不想成为作家了,写不过莫言”,我也这么认为,莫言可太会写了 三十岁之前及多愁善感的读者不建议阅读,书中讲述的苦难太多,容易道心破碎,也意难平,我阅读了全书 1/7 的内容后,就打算放弃了,个人感情投射到书中人物,内心的难受很真实,找 GPT 老师开导后决定再坚持下 于是每天阅读《丰乳肥臀》会主动控制时长,从文学角度去感受文字的魅力,同时找了本《一个叫欧维的男人决定去死》篇治愈系的书籍穿插阅读,勉强实现了情感的负载均衡 先从整书写作手法看来,小说开篇就简述了主要人物的关系和结局,“预叙” 的手法对于看电影想先了解大结局才好安心观看的人来说,十分舒适,即便我看了也根本记不住金童七个姐姐的名字,甚至有些小焦虑,但并不妨碍莫言把这些鲜活的人生经历强行塞进我有限的记忆空间,跟媳妇删繁就简的分享寥寥 写作手法上另一个让我印象深刻的是开篇讲述鲁氏生产的时刻,驴子也在生产,驴子有人照看,母亲没有,日本鬼子入侵,风雨欲来... 随着鲁氏死在新时代的破败旧教堂的庭院中,一切看似终结,下一篇却又回到起初,从鲁氏出生开始讲起,此时对鲁氏已不再陌生,儿女们的名字也可以倒背如流,仿佛陪着他们走过一生,又来到她们来时的路,心中五味杂陈,跌宕起伏,艰难一生 鲁氏生于 1900 战乱时期,死于 1995 年,生有九个儿女并拉扯大一箩筐孙辈儿女,最终活下来的只剩几人,全书以金童超越的视角作为观察者,前期姐姐们的故事,后期金童自己成年而无为,无法 “断奶” 的一生,软弱可怜又可气 莫言说,把这本书献给自己的母亲和母亲们。文字的魅力即真真切切的让人感受到母亲的伟大,艰难之后更艰难,宏大叙事下小人物们的苦难也得以被看见,兴,百姓苦;亡,百姓苦。此刻得以具现化 我总感觉这本书表达了很多内容,信息密度也很大,寻常的一本书可能一周就读完了,《丰乳肥臀》我读了 49 天,但我只看到了书的第一层,第二层可能关乎 “母亲中国”,“宗教” 等,我不得而知 但当下我理解有些观点为什么说 “越穷越要多生孩子”,乱世之下多悲苦,半点不由人,传承之事只能交给概率 艰难时期的 “反革命” 为什么选择 “自我的终结” 也不愿挺过那段时光?“没饭吃的日子” 可以饿死很多人,魔幻与荒诞时期,人性的恶叫人难以直视半分 日光之下并无新事,真心祈愿这片土地的人们平安

2025/10/25
articleCard.readMore

爱人回家送奶奶

大上一次见到奶奶,是去年的十一,白天与姐夫在老丈人家里掰苞米,临行前媳妇和大姐一行到奶奶家探望,奶奶握着她们的手,问候着,也安静着,我站在炕边看着墙上的福字,也望向窗外四大爷家的牛犊,离别时两姐妹两眼泪珠乱转,媳妇跟我说今年感觉奶奶对她们的话多了不少,总问她们下次什么时候再回来 上一次见到奶奶,是今年的春节,大炕外边儿砌了一个小炕梢,奶奶坐在上面,她满脸岁月的沟壑,吃着软柿子,时而接两句话跟大家说话,奶奶今年九十七岁,大家都说她能活到一百岁 “奶奶过世了” 妻子发来微信,她下午到的家,奶奶是晚上走的,明早四点起来送她 我在遥远的北京,前几天妻子说奶奶生病了,不吃不喝,也喂不进食物 媳妇讲了她大娘头几年走的时候,她以往没什么感觉,最近想到心里会难受,我说以往你还没有这样丰富细腻的情感,奶奶生病,勾起思绪,感情就会像丰饶土地上雨后天晴的青草般生长 她这些天要准备回家看看,听说很多亲人已经都回去了 我发给妻子 “抱抱” 的表情安慰她,然后就去吃晚饭,晚上一个人在家坐下来,安静时一股说不出来的感受开始蔓延,碎片化的记忆开始涌现 听妻子说,因为姑姑 “轮值” 照顾她时间短,提前回去工作,奶奶很不顺心,骂了许多人 还有段时间,她有些蛮严重 “感冒”,儿女们就为她换上了寿衣,顽强的小老太挺了过来,穿着寿衣那几天,三两个儿孙姑娘们就跟她睡一炕 算起来奶奶生于 1928 年,经历过抗日战争、解放战争,青春年少时见证了新中国的成立,也跨越千禧年,在口罩三年中也健康而清贫的生活着 媳妇说奶奶长寿的秘诀是 “不内耗”,是个厉害的不受欺负的老太太,平时听收音机,里面有养生操时常跟着练习,效果不明,但奶奶很能坚持,一坚持就是多年,牙口胃口都好着 思想中,这就是我对奶奶有限的全部记忆了,人常说老人像小孩子,看着奶奶拿着一个女娃玩具,怅然间,眼角泛红,这个老太可爱、可敬,春节那次,她拿给我一个橘子,也让我嗑瓜子 没有,没有下个春节去探望奶奶的故事了

2025/10/22
articleCard.readMore

Memos: AWS 美东可用区 P0 故障(us-east-1)

因 AWS DynamoDB 故障引发众多基础服务不可用,波及众多互联网应用,目前仍未恢复,状态页面:health/status 还有三天霜降,AWS 北美工程师今夜注定要汗流浃背了 影响时间 开始: 2025 年 10 月 20 日 14:49(北京时间) 恢复: 2025 年 10 月 21 日 06:01(北京时间) 持续时间: 约 15 小时 事件摘要 时间(北京时间) 事件摘要 10月20日 14:11 AWS 发现 US-EAST-1 区多服务错误率、延迟上升。 10月20日 16:26 DynamoDB 请求错误显著增加。 10月20日 17:01 确认与 DynamoDB API 的 DNS 解析问题有关。 10月20日 17:27 部分服务开始恢复。 10月20日 18:35 DNS 故障缓解,但 EC2 实例仍无法启动。 10月20日 19:08–21:42 EC2 启动持续失败,影响 RDS、ECS、Glue;Lambda 出现 SQS 事件延迟。 10月20日 22:14 出现新一轮网络连通性问题,影响多个服务。 10月20日 23:43 确认问题源于网络负载均衡(NLB)健康监控子系统。 10月21日 00:13–02:22 逐步恢复网络与 EC2 启动;Lambda 调用错误减少。 10月21日 03:15 大部分服务恢复正常。 10月21日 04:52 EC2 启动限流解除,依赖服务(ECS、Glue)恢复。 10月21日 05:48 EC2、Lambda、Connect 等全面恢复。 10月21日 06:53 官方确认:所有 AWS 服务恢复正常运行。

2025/10/20
articleCard.readMore

2025 北京社保下限上调|个体户缴费随之上涨

相较于 2024 年 6821 社保缴费下限,2025 年社保缴费下限调整为 7162 元,涨幅 5% 同期,2024 年北京社会平均工资从 11761 元提高到 11937 元/月,上涨 176 元,涨幅约为 1.50% 北京最低工资标准每小时不低于 14.6 元、每月不低于2540元 社保基数上调后,个体工商户最低缴纳费用由 2540.42 元/月上升至 2667.27 元/月(10 月补缴 7、8 月份按新基数计算的差额共计 253.7 元) 数据来源 本市发布2025年社保缴费工资基数上下限 历年北京市全口径城镇单位就业人员平均工资 北京市人力资源和社会保障局等部门关于调整北京市2025年最低工资标准的通知

2025/10/10
articleCard.readMore

铁锅重生记

家里有一个用了有两年的铁锅,媳妇儿炒菜炖菜都用它,前段时间一看,涂层已经面目全非。 可以买个新的,但一想,纯铁锅应该可以抢救下,有句话怎么说的 —— “新三年,旧三年,缝缝补补又三年” 注意:涂层锅不能重新开锅!涂层损坏后建议更换新锅 使用的是「老东北美食」大舅的教程: 【原来旧锅也能开锅养锅润锅,老师傅分享实用技巧,旧锅也能变新锅-哔哩哔哩】 https://b23.tv/4WJDMbz 全程耗时两小时,过程记录如下: 转着圈干烧铁锅,涂层肉眼可见的开始碳化。 烧了好一会儿后,用砂纸刷一刷锅内侧,不太建议用钢丝球(但我只有钢丝球,就用了钢丝球) 擦过后,有明显的铁锈痕迹,等会儿用水刷一下就好了,谁家锅底都有灰,刮刮刮刮刮... 而后用水刷锅内侧,可以放洗洁精,洗刷干净擦干后如下图 开火,上猪肥肉润锅!(两遍) 擦擦擦! 把过多的油倒掉,可以用水冲干净,先不要用洗洁精。 (这里缺失了再用食用油油润一遍锅的步骤,用锅铲抵着厨房用纸推着热油再把锅擦一遍) 倒掉油,清水刷锅后,铁锅重新开锅完成 ✅ 养铁锅注意事项: 炒菜的锅只用来炒菜、不能用来炖煮; 及时刷锅,并擦干水分,如果不怕麻烦,用厨房用纸沾些油擦一遍; 刷锅时可以用洗洁精,但不能用钢丝球等坚硬物品清洁,会破坏涂层; 铁锅满血复活 技能 +1 成就感 +1

2025/8/30
articleCard.readMore

不锈钢盆与放心水源改造计划

厨房用具更新 最近几个月,添置了一些 304、316 不锈钢材质的厨房用具,更早的缘由是前同事群里有人买了检测药水,发现家里很多的伪 304、316 不锈钢盆子。 我也好奇就买了药水检测,真发现了一批假冒伪劣产品,就计划着替换掉,经过一段时间挑选,购买了以下的 ”不锈钢们“ 先吐槽下「无印良品」的不锈钢盆子,价格真贵,但除了贵之外没别的毛病,银色的不锈钢小碟子可以在做饭时放葱姜蒜,小号的盆吃面条的时候用,最大号的用来备菜时泡一下蔬菜,沥水盆有些小了,后续应该会补个大号的 淘汰掉了家里不知道什么材质的 ‘不锈钢盆’,心情很棒 在「XMAN 小满家外贸尾货」淘宝店买了几个厨房小物件,说是卖的外贸尾货微瑕品,但是瑕疵得仔细才能找见,性价比拉满,捣蒜器用料足,小油锅材质扎实,看着就是能用很久的那种 电饭煲内胆,最开始下单的「大良印记」家的不锈钢内胆,到家后煮粥,漏气喷的四处都是,客服说可能是尺寸误差,给退了货,下单了另一家也还是漏气,甚至电饭煲憋气后发出砰一声,我都想要换电饭煲了,客服说换个密封圈试试,他家也卖,换上后果然问题解决,最开始没往密封圈上想是因为小米原装内胆不会有这个问题,想来也是学到了 ”放心水源改造计划“ 要说注意水质还得从 “直把杭州做汴州” 事件说起,网友们把擦脸巾套在水龙头上,放一段时间水,擦脸巾就黄的不行,本着好奇心,我也在洗手盆的龙头上做了为期一周测试,也是黄的不行,还掺杂着不明杂质。 之后两天我再直接用自来水刷牙时,就会有心理和生理上的双重不适,容易恶心干呕,连夜下单了一个水龙头净水器(32元),下图是使用三周后的样子,送了砂纸,滤芯可以打磨后重复使用。 又添置了一个过滤花洒(22.9 元) 最后是厨房净水器 观望了一段时间京东京造的净水器,价格便宜,换滤芯会贵一些,最后选择在淘宝买了通用净水器,考量主要是是京东净水器的定制滤芯换一次比较贵,所宣称 5 年使用寿命,我觉着不靠谱,消耗品还是要常换。 另一个次要因素是研究了一段时间通用净水器,感觉到目前纯水方案都很成熟,完全可以像装电脑一样也组装一台净水器。 但结合实际情况,还是先买一个半成品,后续再自己改造,这样比较折中,购入价格 1245 元,净水器是整个发过来的,需要自己接到厨下(自备扳手) 75G 同量净水器 + 压力桶方案,两三人的小家庭用起来感觉足够,面板显示 3 PPM,实际测试数值 30+ 水源 PPM 自来水 230 小区制水机 10 某矿物质桶装水水 23 怡宝纯净水 0 净水器 30+ (测量日期:2025-07-26) 计划 PP 棉一季度一换、RO 膜两年一换、其余滤芯的半年一换,因为通量小,滤芯价格便宜,滤芯品牌丰俭由人。 水龙头是双水的,洗碗用净水、直饮用纯水,对我来说最大的改变就是不用到小区净水机打水,也不用隔几天就在网上下单怡宝纯净水,方便了太多 写在最后 添置了不锈钢厨具、”放心水源改造计划“ 的实施,得到了媳妇儿的高度评价。 另外也更换了马桶盖、下水道防臭管,感觉出租房也可以适度折腾,“花小钱办大事”,很大程度提升了幸福指数。

2025/8/30
articleCard.readMore

阅读《不被大风吹倒》

阅读莫言老师的第一本书是《不被大风吹倒》 不记得最开始听说莫言是什么时候,大概率是网友发的两只狗狗合影,神回复是“莫言和余华”,也可能因为莫言荣获了诺贝尔文学奖,很小的几率是因为《丰乳肥臀》这本标题有些“劲爆”的大作。 然后从史铁生的这段金句开始了解到余华和莫言,以及他们之间的友谊: 自从我腿残疾后, 家人们都很忌讳提起我的腿。 只有余华 他带我去踢球,让我守门。 他没把我当残疾人,也没把我当人。 —— 史铁生 《不被大风吹倒》这本书加到微信读书的书架有大几个月了,分几次才读完,最开始阅读时,其实我还没下定决心要开始阅读,只是在书店看到这本“畅销书”,好奇想看看写的什么,单从这本书来看,对莫言有了初步的了解—— 性格平和,善于观察、学习,铭记自己来自哪里,能往前看的人 这本书是多篇不同时期写作文章的合集,我觉得蛮有意思的是不同时期写的文章,组合到一起,风格是一致的,简单说就是 “有种质朴感的乡土情怀”。阅读时有一种轻松感,有很多片段,细想下藏着不少艰难,但作者巧妙的叙事,没有将担子放到读者肩上。 书中有作者儿时的回忆,有对阅读的热爱,有关于写作的心得,以及文学大家那里学到的东西,前几些天刚读过卡夫卡的《审判》,莫言老师书中也提到卡夫卡的的好几本书,说来也是很巧,感觉过一阵儿可以再选几本卡夫卡的小说读起来。 这本书很像是作者跟年轻大学生分享交流时的分享,我认为很适合作为阅读莫言老师书籍的入门之选。 写下这篇笔记的时候,我已经选择好了下一本书——《丰乳肥臀》,去看看莫言老师的“高密东北乡”,都诉说着怎样的故事。

2025/8/29
articleCard.readMore

基于 Supabase 构建示例应用(中篇):实现 Vue 前端页面

构建 Vue 项目 Supabase 服务的 Vue 构建官方文档:https://supabase.com/docs/guides/getting-started/quickstarts/vue 创建了一个 Github 仓库,用来存放 Vue 前端项目 克隆项目 git clone git@github.com:sincerefly/vuebase-posty.git 初始化 Vue 项目 cd vuebase-posty npm create vue@latest . 选项 回车确认后的 Oxlint(试验阶段)和 rolldown-vite(试验阶段)都不选择,示例代码也不需要 运行三连 # 安装依赖 npm install # 格式化代码 npm run format # 启动 npm run dev 引入 Supabase 依赖 安装库 npm install @supabase/supabase-js 创建环境变量文件 touch .env.local 将服务地址和 Supabase Publishable Key 填入(注意替换为自己的地址和密钥) VITE_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL> VITE_SUPABASE_PUBLISHABLE_KEY=<SUBSTITUTE_SUPABASE_PUBLISHABLE_KEY> 这里有一些容易混淆的地方需要注意 Supabase 教程页面上显示出用户的 Anon Key,看着需要使用这个 key 作为SUBSTITUTE_SUPABASE_PUBLISHABLE_KEY,那上篇中的 “sb_publishable_JToCFTxxxxxx” 又是什么,用哪个呢? 这个以 sb_publishable_ 开头的密钥,实际上就是 anon key(匿名公钥),只是 Supabase 在不同时期使用了不同的命名格式。 结论就是用谁都行,sb_publishable_ 是旧的,JWT 格式的密钥是更新的格式 新建 src/lib/supabaseClient.js 文件 import { createClient } from '@supabase/supabase-js' const supabaseUrl = import.meta.env.VITE_SUPABASE_URL const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY export const supabase = createClient(supabaseUrl, supabaseAnonKey) 修改 src/App.vue 文件 根据我的表 Posts 做了相应调整 <script setup> import { ref, onMounted } from 'vue' import { supabase } from './lib/supabaseClient' const posts = ref([]) async function getPosts() { const { data } = await supabase.from('posts').select() posts.value = data } onMounted(async () => { await getPosts() }) </script> <template> <ul> <li v-for="post in posts" :key="post.id">{{ post.title }}</li> </ul> </template> 显示出来三篇已发布文章(标题相同) 导出表 Types https://supabase.com/docs/guides/api/rest/generating-types 如果网络抽风,使用本地代理 npm 设置如下 npm config set proxy socks5://127.0.0.1:7897 npm config set https-proxy socks5://127.0.0.1:7897 使用后清理 npm config delete proxy npm config delete https-proxy # 安装命令行工具 npm i supabase@">=1.8.1" --save-dev # 打开浏览器登录 npx supabase login 登录后,如果之前未执行过初始化,先在项目根目录运行 npx supabase init 按需选择,默认都是 N Generate VS Code settings for Deno? [y/N] Generate IntelliJ Settings for Deno? [y/N] Finished supabase init. 获取数据库的类型定义 mkdir -p src/types # 生成 Schema,注意替换 PROJECT_REF,就是服务器 API 地址子域名那串字符 npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > src/types/database.types.tss Vibe Coding 环节 好了,Step by Step 到此,接下来开始氛围编程 这是一个 Vue 项目,后端是 Supabase 服务,请实现以下功能: 表结构如下: CREATE TABLE users ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, username TEXT UNIQUE CHECK (char_length(username) >= 3), email TEXT UNIQUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id UUID REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, content TEXT, published_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); 已经定义了策略,所有用户(匿名、登录)都可以获取到已发布的文章,即 published_at 字段不为空的记录;用户可以创建、修改自己的文章。 以上是服务端一些表结构,另外已经通过 supabase cli 导出了数据库字段类型的 Schema,在 src/types/database.types.tss 前端功能说明 1. 页面顶部有两栏,一个是“广场”,另一个是“我的”栏目,右上角有注册、登录功能,广场展示所有已发布的文章,我的栏目,如果未登录,通过页面上的文字提示请先登录,登录后展示所有用户的文章,登录后的右上角展示用户名,点击弹出下拉,有设置和登出、设置页面目前可以设置语言偏好; 2. 用户我的页面的文章,需要显示是否发布,可以根据单选项过滤全部、已发布、未发布三个状态,文章后方应该有编辑、发布的按钮,支持修改标题和内容; 3. 需要支持多语言,目前仅需要适配中文、英文两种语言,用户选择语言后,应该在浏览器本地进行缓存; 编码风格说明 1.首先,保持现代化、但不要使用过于花哨的颜色,简洁、小清新为主 2.代码实现应注意解耦合和封装,不要多个逻辑放到一个大文件中 3.API 接口和数据库操作需要符合 Supabase 的使用规范和习惯 正好打算试试 Trae,不过目前的智能程度,真是爱了... ⬇️ 还是配置 Proxy,使用 Cursor 3 Hours Later... 页面功能初步完成 多语言也支持的良好 调整策略 因为上篇实验中,缺少部分策略,可以在 Supabase 面板删除掉所有策略,重新创建本示例所需的策略 TODO 再核对下: -- posts 表 alter policy "允许匿名和登录用户查看所有已发布文章" on "public"."posts" to anon, authenticated using ( (published_at IS NOT NULL) ); create policy "允许登录用户创建自己的文章" on "public"."posts" for insert to authenticated with check ( -- 确保用户只能插入自己的帖子 user_id = auth.uid() ); create policy "允许登录用户删除自己的文章" on "public"."posts" as PERMISSIVE for DELETE to authenticated using ( auth.uid() = user_id ); create policy "允许登录用户查看自己所有文章" -- 包含未发布 on "public"."posts" as PERMISSIVE for SELECT to authenticated using ( auth.uid() = user_id ); alter policy "允许登录用户更新自己的帖子" on "public"."posts" to authenticated using ( (auth.uid() = user_id) with check ( (auth.uid() = user_id) ); alter policy "用户每天只能插入10篇文章" on "public"."posts" to authenticated with check ( ((auth.uid() = user_id) AND (( SELECT count(*) AS count FROM posts posts_1 WHERE ((posts_1.user_id = auth.uid()) AND (posts_1.created_at > (now() - '1 day'::interval)))) < 10)) ); -- users 表 create policy "允许用户查看自己的用户信息" on "public"."users" for select to authenticated using ( (select auth.uid()) = id ); CREATE POLICY "允许用户更新自己的用户信息" ON "public"."users" FOR UPDATE USING (auth.uid() = id); 通过 Cloudflare Page 部署 因为编译后是纯前端页面,所以可以托管到 Pages 服务,可选择性很多,优先国外,因为国内 Page 服务可持续性 be like Github Pages、Cloudflare Pages、Vercel 作为 Demo 放到哪里都足够,根据我的个人习惯,选择部署到 Cloudflare Pages,因为我有一个域名由 Cloudflare 管理,绑定自定义域名时可以纵享丝滑 先上传前端代码到 Github,我的仓库是:sincerefly/vuebase-posty 登录 Cloudflare 面板 选择 Workers & Pages,点击创建 注意先切换到 Pages,然后再点击 Get started 选择项目后下一步 选择 Vue Framawork,参数默认,应该还记得 .env.local 文件,将里面的 VITE_SUPABASE_URL 和 VITE_SUPABASE_PUBLISHABLE_KEY 设置到此处环境变量 点击部署,稍后可以看到服务已部署 服务地址:https://vuebase-posty.pages.dev .pages.dev 是 Cloudflare 提供的域名,子域名是服务名,重复会追加随机字符。 添加自定义域名(可选) 由 CF 托管的域名,无需手动配置 稍等片刻 地址:https://posty.donx-done.xyz 配置 Supabase 服务 URL 地址 配置完成后,到页面进行注册测试,当头两棒子 {"code":"over_email_send_rate_limit","message":"For security purposes, you can only request this after 49 seconds."} {"code":"over_email_send_rate_limit","message":"email rate limit exceeded"} 这是一个 Supabase 配置,位置在 Authentication 下的 Rate Limit 改成 20 封邮件后,可以找临时邮箱进行注册验证 Supabase 注册 URL 自动登录逻辑 用户点击邮件中的确认链接(http://web-host/#access_token=xxx&refresh_token=xxx&type=recovery) Supabase 客户端自动检测 URL 参数(detectSessionInUrl: true),自动创建 session 并触发 onAuthStateChange 事件 认证状态监听器处理 (src/stores/auth.ts) 应用初始化 (src/App.vue) 我也没仔细看,因为全程氛围编程,没写几行前端代码 记在最后 Vibe Coding 一些心得,描述需求时要全面,但让其实现代码时要分步实现。Debug 时,让其添加 Console 日志,将问题日志提交给它,定位会更快、更准确 前端使用 Vue 开发,部署到了 Cloudflare:https://posty.donx-done.xyz 前端代码仓库:sincerefly/vuebase-posty 本文阶段性的目标已达成,这篇想了想,定为「中篇」,Supabase 还有不少值得探索的功能,放到「下篇」学习记录。

2025/8/29
articleCard.readMore

基于 Supabase 构建示例应用(上篇):数据库与接口

目标:实现一个文章发布 Web 应用(Demo),包含用户注册、编辑、发布文章的功能,同时有广场(展示所有已发布文章、所有用户可以查看)、并使用管理员进行维护,技术架构是 Supabase + Vue,Vue 部署在哪里还未计划,以此为契机学习了解 Supabase 服务 本篇学习和记录了如下内容 体验 Supabase Console 建表、执行 SQL 创建 RLS 行级安全策略 了解不同的 API Key 类型、创建 API Key、Curl 命令调用接口 了解 Supabase 的 Users 和业务表的 Users 表关联方式(通过触发器) 模拟用户注册、登陆、创建文章、查看广场文章的接口使用场景 Edge Functions 配置一些封控策略(未测试) 设计表 用户表 CREATE TABLE users ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, username TEXT UNIQUE CHECK (char_length(username) >= 3), email TEXT UNIQUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); users 表通过 id 字段扩展了 Supabase 内置的auth.users表 文章表 CREATE TABLE posts ( id SERIAL PRIMARY KEY, user_id UUID REFERENCES users(id) ON DELETE CASCADE, title VARCHAR(255) NOT NULL, content TEXT, published_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); users 表和 posts 表之间存在一对多的关系,即一个用户可以拥有多篇文章。 触发器 -- 首先创建一个函数来更新 updated_at 字段 CREATE OR REPLACE FUNCTION update_updated_at_column() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ language 'plpgsql'; -- 为 users 表创建触发器 CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- 为 posts 表创建触发器 CREATE TRIGGER update_posts_updated_at BEFORE UPDATE ON posts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); Supabase 建表 点击 SQL Editor 侧边栏,右侧输入框执行 SQL 语句,执行后来到 Table Editor,可以看到已经成功创建两张表 但是上方都有 Unrestricted 标注,没有开启 RLS 策略,可以点击页面上的黄色 “RLS disabled” 按钮,或是运行以下 SQL 语句均可开启。 ALTER TABLE users ENABLE ROW LEVEL SECURITY; ALTER TABLE posts ENABLE ROW LEVEL SECURITY; 启用行级策略后,我们可以设置一些策略,例如: 允许用户只能插入(INSERT)属于自己的文章: CREATE POLICY "用户只能发表自己的文章" ON posts FOR INSERT WITH CHECK (auth.uid() = user_id); 这样,用户尝试插入文章时,Supabase 会检查要插入的 user_id 是否等于当前登录用户的 ID 备注:策略也可以在 Authentication 模块的 Policies 子模块中,点击表后进行可视化创建。 创建用户 Supabase 提供邮件邀请和手动创建的方式 可以手动创建用户 也可以点击 “Send invitation” 发送邀请到邮箱。 获取免费的 SMPT Server(可选) 最开始我以为 Supabase 发送邮件需要自己提供服务,就在网上找了一个 Brevo 这个服务,有免费额度,注册过程很丝滑,记录如下,这步可以跳过,直接使用 Supabase 的邮件服务就好,也不用设置。 服务地址:Free SMTP Server | Deliver to the Inbox Every Time 免费套餐每天 300 封,测试使用足够了 注册后点击右上角的组织,选择 “SMTP & API” 可以看到 SMTP 服务的服务器地址、用户名密码 还需要添加 Sender,否则发不出邮件 这里我起的应用名字是 “Reader Bot”,邮箱就是我的 Gmail 邮箱,需要真实的邮箱地址,稍后会发送验证码验证。 点击添加 提示我们的免费邮箱可能大概率进入收件人的垃圾邮箱,建议绑定域名,因为是测试,当然是 Anyway 验证完成后,可以看到新的 Sender 添加成功 此时 Brevo 提供的邮箱服务已可用 在 Supabase 配置自己的 Email SMPT 服务(可选) 如果你申请了邮箱,可以在 Authentication 模块进行配置 填入 Brevo 获取到的邮箱服务信息 Sender email:k********0@gmail.com Sender name:Reader Bot Host:smtp-relay.brevo.com Port number:587 Username:954923002@smtp-brevo.com Password:(YOUR-SMTP key value) 此时,再回到 Authentication 下的 Users 表中发送邮件邀请用户,就可以收到邮件 内容如下 其中的邮件内容模板在 “Emails - Templates” 内进行配置,访问的链接在 “URL Configuration - Site URL” 进行配置,暂时先不用修改 初识 API & Keys 在 Project Settings 的 API Keys 页面可以创建项目 Key 创建后有一个 Publishable key 可以在浏览器使用,格外注意,需要搭配 RLS 策略使用,它是可以公开的 sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj 页面下方还可以看到一个名为 default 的 Server Key 用于服务端机器、或者 Function、Workers 等 点击页面左侧的 API Docs 来到 API 页面 这里有一个知识点:Supabase 通过原生集成的 PostgREST,为数据库提供开箱即用的 Auto API,使开发者无需部署后端即可安全地进行基础的 CRUD 操作。 API 文档很细致,API 分为 Client API 和 Server API,Clinet API 可以 通过 API 注册登陆读写表数据 以下命令的 apikey 就是刚生成的 Publishable key(测试时可以使用临时邮箱, e.g. TEMP MAIL) curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/auth/v1/signup' \ -H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \ -H "Content-Type: application/json" \ -d '{ "email": "nasovec941@chaublog.com", "password": "123456" }' 返回(已格式化) {     "id": "b0fec5be-879d-4912-863e-b7495a8906b9",     "aud": "authenticated",     "role": "authenticated",     "email": "nasovec941@chaublog.com",     "phone": "",     "confirmation_sent_at": "2025-08-23T07:19:42.840305897Z",     "app_metadata": {         "provider": "email",         "providers": [             "email"         ]     },     "user_metadata": {         "email": "nasovec941@chaublog.com",         "email_verified": false,         "phone_verified": false,         "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"     },     "identities": [         {             "identity_id": "c9527fc9-13c9-4c85-a2c1-5b776663f22e",             "id": "b0fec5be-879d-4912-863e-b7495a8906b9",             "user_id": "b0fec5be-879d-4912-863e-b7495a8906b9",             "identity_data": {                 "email": "nasovec941@chaublog.com",                 "email_verified": false,                 "phone_verified": false,                 "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"             },             "provider": "email",             "last_sign_in_at": "2025-08-23T07:19:42.817329844Z",             "created_at": "2025-08-23T07:19:42.81738Z",             "updated_at": "2025-08-23T07:19:42.81738Z",             "email": "nasovec941@chaublog.com"         }     ],     "created_at": "2025-08-23T07:19:42.772681Z",     "updated_at": "2025-08-23T07:19:44.000369Z",     "is_anonymous": false } 会收到 Supabase 注册邮件,目前地址会跳转到 localhost:3000 地址,我们的前端 Demo 还没开发部署(但是需要点击一下确认链接进行 Supabase 的用户激活) 此处仅体验使用 Publishable key 调用 Supabase 后端 API,注册用户后登陆: curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/auth/v1/token?grant_type=password' \ -H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \ -H "Content-Type: application/json" \ -d '{ "email": "nasovec941@chaublog.com", "password": "123456" }' 返回 {     "access_token": "eyJhbGaciOiJ...NULE72KZGcdu6-LfeUdSbp8",     "token_type": "bearer",     "expires_in": 3600,     "expires_at": 1755937882,     "refresh_token": "u4jw6puieja6",     "user": {         "id": "b0fec5be-879d-4912-863e-b7495a8906b9",         "aud": "authenticated",         "role": "authenticated",         "email": "nasovec941@chaublog.com",         "email_confirmed_at": "2025-08-23T07:21:22.503059Z",         "phone": "",         "confirmation_sent_at": "2025-08-23T07:19:42.840305Z",         "confirmed_at": "2025-08-23T07:21:22.503059Z",         "last_sign_in_at": "2025-08-23T07:31:22.263515322Z",         "app_metadata": {             "provider": "email",             "providers": [                 "email"             ]         },         "user_metadata": {             "email": "nasovec941@chaublog.com",             "email_verified": true,             "phone_verified": false,             "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"         },         "identities": [             {                 "identity_id": "c9527fc9-13c9-4c85-a2c1-5b776663f22e",                 "id": "b0fec5be-879d-4912-863e-b7495a8906b9",                 "user_id": "b0fec5be-879d-4912-863e-b7495a8906b9",                 "identity_data": {                     "email": "nasovec941@chaublog.com",                     "email_verified": true,                     "phone_verified": false,                     "sub": "b0fec5be-879d-4912-863e-b7495a8906b9"                 },                 "provider": "email",                 "last_sign_in_at": "2025-08-23T07:19:42.817329Z",                 "created_at": "2025-08-23T07:19:42.81738Z",                 "updated_at": "2025-08-23T07:19:42.81738Z",                 "email": "nasovec941@chaublog.com"             }         ],         "created_at": "2025-08-23T07:19:42.772681Z",         "updated_at": "2025-08-23T07:31:22.270377Z",         "is_anonymous": false     } } 以上都是 GETTING STARTED 的内容,接下来可以看下业务表相关的 API 翻到 Insert 语句,“创建一篇文章” curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts' \ -H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \ -H "Authorization: Bearer eyJhbGaciOiJ...NULE72KZGcdu6-LfeUdSbp8" \ -H "Content-Type: application/json" \ -H "Prefer: return=minimal" \ -d '{ "title": "你好,世界!", "content": "北京今日天气:22-29度 多云 西北风3级" }' 报错 {"code":"42501","details":null,"hint":null,"message":"new row violates row-level security policy for table \"posts\""}% RLS 行级策略不允许,执行以下策略: -- 用户只能插入自己的帖子 CREATE POLICY "允许认证用户插入帖子" ON posts FOR INSERT TO authenticated WITH CHECK (true); -- 用户只能更新自己的帖子 CREATE POLICY "用户只能更新自己的帖子" ON posts AS PERMISSIVE FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); 再次调用,没有返回任何内容,查看 Table Editor 可以看到已经多了一条记录 然后我发现 users 表也没有记录,即 Supabase 的 auth.users 和我的 public.users 表没有关联,同时 posts 表的 user_id 也是空; 分别解决这两个问题,对于 users 未同步,可以创建一个触发器 -- 创建函数 CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS TRIGGER AS $$ BEGIN INSERT INTO public.users (id, email, username) VALUES ( NEW.id, NEW.email, COALESCE(NEW.raw_user_meta_data->>'username', SPLIT_PART(NEW.email, '@', 1)) ); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- 创建触发器 CREATE OR REPLACE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user(); 好了,再注册个用户试试! 用户:oyentreng@deepyinc.com 密码:123456 注册、登陆不再重复粘贴代码,控制台已经可以看到用户 解决 posts 的 user_id 为空的问题可以创建如下触发器 -- 启用 uuid 扩展(如果尚未启用) CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- 创建触发器函数 CREATE OR REPLACE FUNCTION public.set_post_user_id() RETURNS TRIGGER AS $$ BEGIN -- 从 JWT 中获取用户 ID 并设置 NEW.user_id = auth.uid(); RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; -- 创建触发器 CREATE OR REPLACE TRIGGER set_post_user_id_trigger BEFORE INSERT ON posts FOR EACH ROW EXECUTE FUNCTION public.set_post_user_id(); 使用新的用户 Token 创建文章 curl -X POST 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts' \ -H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \ -H "Authorization: Bearer eyJhbGaciOiJ...YNGEpJM2MkFp0pj4Hj45RY-h9L06M4" \ -H "Content-Type: application/json" \ -H "Prefer: return=minimal" \ -d '{ "title": "你好,世界!", "content": "北京今日天气:22-29度 多云 西北风3级(下起了小雨)" }' 解决了 查看 “广场” 文章 以用户身份分页请求 10 篇文章 curl 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts?published_at=not.is.null&select=*' \ -H "apikey: sb_publishable_JToCFTBwo4urIOPnE9cIFg_1Wo-6WYj" \ -H "Authorization: Bearer eyJhbGaciOiJ...YNGEpJM2MkFp0pj4Hj45RY-h9L06M4" \ -H "Range: 0-9" 查询出来 [] 空数组,因为文章最开始我们定义了一个 “只能查询(SELECT)到已发布的文章” 的策略 现在正好试试 Server Key 的管理员 Key 的威力,批量更新 published_at 字段为当前时间(Server Key 要藏好) curl -X PATCH 'https://iurlblwfhmulfdysyqdz.supabase.co/rest/v1/posts?id=in.(3,4,5)' \ -H "apikey: sb_secret_Xr48DdK*************pmR1XA_xLeM45LH" \ -H "Content-Type: application/json" \ -H "Prefer: return=minimal" \ -d '{ "published_at": "now()" }' apikey 设置为 sb_secret_xxxx 管理员密钥,无需 Authorization Header 已更新,预期应该是可以查询到了,但是依然返回 [],到 Supabase 翻翻,发现还是 RLS 策略缺失的问题; Supabase SQL 执行页面有个功能,可以选择作为哪个 ROLE 执行 默认使用 postgres,也可以切换为匿名角色或是 authenticated role,选择后可以选择特定的用户,到表的 RLS policies 页面,添加策略 using 条件就是发布时间不为空,允许了匿名和已登陆用户查看; [     {         "id": 3,         "user_id": null,         "title": "你好,世界!",         "content": "北京今日天气:22-29度 多云 西北风3级",         "published_at": "2025-08-23T08:38:44.481162+00:00",         "created_at": "2025-08-23T07:58:08.479927+00:00",         "updated_at": "2025-08-23T08:38:44.481162+00:00"     },     {         "id": 4,         "user_id": null,         "title": "你好,世界!",         "content": "北京今日天气:22-29度 多云 西北风3级(下起了小雨)",         "published_at": "2025-08-23T08:38:44.481162+00:00",         "created_at": "2025-08-23T08:18:26.565641+00:00",         "updated_at": "2025-08-23T08:38:44.481162+00:00"     },     {         "id": 5,         "user_id": "badab300-959f-42f7-ae75-99d74f937804",         "title": "你好,世界!",         "content": "北京今日天气:22-29度 多云 西北风3级(下起了小雨)",         "published_at": "2025-08-23T08:38:44.481162+00:00",         "created_at": "2025-08-23T08:25:13.986663+00:00",         "updated_at": "2025-08-23T08:38:44.481162+00:00"     } ] “广场” 功能没问题,通过接口查询到了所有已发布的文章; 最后增加些安全策略 RLS 策略:用户每天最多发表 10 篇文章 CREATE POLICY "用户每天只能插入10篇文章" ON posts FOR INSERT TO authenticated WITH CHECK ( auth.uid() = user_id AND -- 可以添加其他限制条件,比如每天最多10篇 (SELECT COUNT(*) FROM posts WHERE user_id = auth.uid() AND created_at > NOW() - INTERVAL '1 day') < 10 ); 在 Edge Functions 可以配置函数,以下的限制借助边缘函数实现 rate-limiter(30 请求每分钟) import "jsr:@supabase/functions-js/edge-runtime.d.ts"; // 内存存储速率限制 const rateLimitMap = new Map<string, { count: number; resetTime: number }>(); Deno.serve(async (req: Request) => { // 获取客户端IP const clientIP = req.headers.get('x-forwarded-for') || 'unknown'; // 速率限制检查 const now = Date.now(); const limitData = rateLimitMap.get(clientIP) || { count: 0, resetTime: now + 60000 }; // 重置计数器(每分钟) if (now > limitData.resetTime) { limitData.count = 0; limitData.resetTime = now + 60000; } // 检查限制(每分钟30次) if (limitData.count >= 30) { return new Response( JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 } ); } // 增加计数 limitData.count++; rateLimitMap.set(clientIP, limitData); // 返回成功响应 return new Response( JSON.stringify({ success: true, method: req.method, remaining: 30 - limitData.count }), { headers: { 'Content-Type': 'application/json' } } ); }); post-content-size(限制 content 字段 10kb 大小) import "jsr:@supabase/functions-js/edge-runtime.d.ts"; Deno.serve(async (req: Request) => { try { // 读取请求内容 const content = await req.text(); // 计算内容大小(字节数) const contentSize = new TextEncoder().encode(content).length; // 检查大小限制(10KB = 10240字节) if (contentSize > 10240) { return new Response( JSON.stringify({ error: 'Content too large', max_size: '10KB', actual_size: `${(contentSize / 1024).toFixed(2)}KB` }), { status: 413 } ); } // 内容大小合格 return new Response( JSON.stringify({ success: true, size: `${contentSize} bytes`, size_kb: `${(contentSize / 1024).toFixed(2)}KB` }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { return new Response( JSON.stringify({ error: 'Invalid request' }), { status: 400 } ); } }); reg-user-limiter(每天限制最多 100 人注册) import "jsr:@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "jsr:@supabase/supabase-js@2"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); Deno.serve(async (req: Request) => { try { // 查询今日注册数量 const today = new Date().toISOString().split('T')[0]; const { count, error } = await supabase .from('auth.users') .select('*', { count: 'exact', head: true }) .gte('created_at', `${today}T00:00:00`) .lte('created_at', `${today}T23:59:59`); if (error) throw error; // 检查是否超过限制 if (count >= 100) { return new Response( JSON.stringify({ error: 'Daily registration limit reached', limit: 100, today_count: count }), { status: 429 } ); } // 允许注册 return new Response( JSON.stringify({ allowed: true, remaining: 100 - count, today_count: count }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error) { return new Response( JSON.stringify({ error: 'Internal server error' }), { status: 500 } ); } }); AI 提供的 Function 函数,我配置上,但没测试是否能正常工作 🤷 不知不觉记录了不少内容,后端目前先了解这些,已覆盖 Demo 所需功能,Web 前端的开发放到下篇文章进行记录。 下篇会根据 Supabase 的文档示例,使用 Vue 开发前端页面,也会调研部署在哪个免费服务比较好。

2025/8/23
articleCard.readMore

阅读《三体》之地球往事

三体可能是我阅读的第一本科幻小说 三部曲之一的地球往事通过片段化的故事连接,将现实与科幻柔和在一起,将‘上世纪六七十年代的社会运动’、现代环保等主题围绕着三体故事的主线逐一展开。 动荡年代的苦难,引人反思人性中的善与恶,对地球文明生态破坏的惋惜,以及对宇宙秩序的大胆想象。 书中人物都做着符合自己人生经历的抉择,叶文洁主动联系外星文明,能感受到她对人类秩序和道德的彻底失望,也在对“高级”文明的盲目崇拜中下了赌注,以至暴露太阳系坐标,引发不可逆的后果。她既是受害者,也是推动历史走向未知的关键人物。 第一部的叙事和铺垫很棒,大名鼎鼎的《三体》,我也算是 ‘已窥’ 一二了 科幻小说的定义 GPT: 科幻小说是一种以科学或未来技术为基础,结合合理想象来探讨人类、社会与宇宙关系的文学体裁。它强调科学逻辑的可能性,同时通过虚构情境反映现实问题与思想。

2025/8/20
articleCard.readMore

Oracle Free 实例重装系统

之前申请了两台 x86 架构的 Oracle 机器,偶尔用来部署开源项目测试,有一台在测试 SSH 相关功能时 “变砖”,网上看重装系统发现很繁琐就没去打理,近期又想到这个机器,发现去年就有了官方重装方法,简单配置下,继续让其发光发热; ’替换引导卷‘ 重装系统 这是官方提供的方式,在实例详情中点击 “操作” - “更多操作” - “替换引导卷”。 无需开启 “保留引导卷开关”,选择 “映像” 和 “输入 OCID” 选项 在 https://docs.oracle.com/en-us/iaas/images/ 可以找到 OCID,注意只能选择同系统的不同版本,例如 Ubuntu 从 20.04 到 24.04 Minimal,不能从 Centos 7 到 Oracle 9 粘贴后可以看到它自动显示了镜像的信息,保持引导卷大小不变。 如想使用新的 SSH Key,可以在高级选项中添加 ssh_authorized_keys,值为公钥内容。 让 Ubuntu 24.04 看起来很忙 忙起来,别闲着 sudo apt update && sudo apt upgrade sudo apt install htop git build-essential curl -o lookbusy-1.4.tar.gz https://devin.com/lookbusy/download/lookbusy-1.4.tar.gz tar xvf lookbusy-1.4.tar.gz cd lookbusy-1.4 chmod a+x configure ./configure make sudo make install nohup lookbusy -c 5-15 -r curve > lookbusy.log 2>&1 & Centos 7 尚能饭否 太久没登录,Yum 安装软件都开始报错 Cannot find a valid baseurl for repo: base/7/x86_64 是因为官方不再支持 Centos 7 的镜像源,编辑 /etc/yum.repos.d/CentOS-Base.repo 文件 取消所有 baseurl 开头的注释。 将所有 mirrorlist.centos.org 替换为 vault.centos.org 将所有 mirror.centos.org 替换为 vault.centos.org 清理缓存,执行 sudo yum clean all 编辑后的内容示例如下: [base] name=CentOS-$releasever - Base mirrorlist=http://vault.centos.org/?release=$releasever&arch=$basearch&repo=os&infra=$infra baseurl=http://vault.centos.org/centos/$releasever/os/$basearch/ gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7 #released updates [updates] name=CentOS-$releasever - Updates mirrorlist=http://vault.centos.org/?release=$releasever&arch=$basearch&repo=updates&infra=$infra baseurl=http://vault.centos.org/centos/$releasever/updates/$basearch/ gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7 #additional packages that may be useful [extras] name=CentOS-$releasever - Extras mirrorlist=http://vault.centos.org/?release=$releasever&arch=$basearch&repo=extras&infra=$infra baseurl=http://vault.centos.org/centos/$releasever/extras/$basearch/ gpgcheck=1 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7 #additional packages that extend functionality of existing packages [centosplus] name=CentOS-$releasever - Plus mirrorlist=http://vault.centos.org/?release=$releasever&arch=$basearch&repo=centosplus&infra=$infra baseurl=http://vault.centos.org/centos/$releasever/centosplus/$basearch/ gpgcheck=1 enabled=0 gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-7 而后更新下软件 sudo yum update -y 本想参考 https://blogs.oracle.com/scoter/post/upgrade-centos-7-to-oracle-linux-8 升级下系统 升级预检给出的报告,一眼就看到了 “Minimum memory request...” 只好放弃 让 Centos 7 也忙起来 sudo yum -y update sudo yum -y install htop git  yum groupinstall "Development Tools" curl -o lookbusy-1.4.tar.gz https://devin.com/lookbusy/download/lookbusy-1.4.tar.gz tar xvf lookbusy-1.4.tar.gz cd lookbusy-1.4 chmod a+x configure ./configure make sudo make install nohup lookbusy -c 5-15 -r curve > lookbusy.log 2>&1 & 题外话 Ubuntu 24.04 Minimal 占用磁盘少很多,安装一些软件后,还剩 46 GB “完整版” Centos 7 安装同样的软件后,还剩 35 GB 参考 甲骨文云Oracle Cloud官方重装系统/官方救砖教程 Fix "Cannot find a valid baseurl for repo" in CentOS

2025/8/15
articleCard.readMore

非京籍个体户缴纳社保(补充):“无有效的汇总预处理信息” 解决办法

8 月份缴纳社保时,进入日常申报提示 “无有效的汇总预处理信息”,如图 到 ”我要办税“ - ”社保业务“ - ”年度缴费工资调整“ 选择 ”生效年度“ 为当前 2025 确定后提示数据初始化成功 注意看页面的提示 因为我只需要添加一个,所以选择的 ”单个添加“ 输入 ”姓名“、”身份证“,设置缴费工资后保存。 回到列表后勾选记录,提交申报,问题解决。

2025/8/15
articleCard.readMore

阅读《芯片简史》

之前读过曹天元的《上帝掷骰子吗?量子物理史话》,书里讲的是 20 世纪初到三四十年代,量子物理从无到有的故事——普朗克、爱因斯坦、玻尔、海森堡、薛定谔等巨匠在理论迷雾中试探前行,至今回想仍让人心潮澎湃。 这次读《芯片简史》,顿感“故事”都连上了!从 1940 年代起,又一个辉煌的三十年:晶体管问世、集成电路出现、微处理器诞生…  书里让我印象最深的是那一批推动半导体革命的人——肖克利、诺伊斯、巴丁、布拉顿、摩尔、法金、霍夫……还有早已熟悉的图灵、冯·诺伊曼、香农。以前只知道“摩尔定律”,却不知道摩尔本人就是“仙童八叛逆”之一,还与诺伊斯一起创立了英特尔;而仙童半导体公司比印象中更厉害,这里堪称是硅谷的“黄埔军校”,从这里走出的工程师后来几乎撑起了整个产业。 我一直做软件相关的工作,对操作系统之下的硬件世界了解有限,像隔着雾看山。这本书以硬件从无到有的视角,帮我把零散的知识串起来,也看到每个硬件的出现都伴随着艰辛,有欢呼、有遗憾,更有争分夺秒的激烈竞争,无形中对技术演进多了一份敬畏之心。 如果我有一个书架,会把它放在上面,等过几年再翻一翻。 (第一颗 CPU,Intel 4004) 摘抄 1952 年 4 月,贝尔实验室召开了第二次研讨会,26 家美国公司和 14 家外国公司的代表参加了为期9天的研讨、参观和制作演示。这些公司包括 IBM、通用电气、飞利浦、西门子、索尼、德州仪器以及一些小公司,如斯普拉格电气公司(Sprague Electric)和中心实验室(Central Lab)。每家公司都收到了一本 792 页的《晶体管:特性与应用参考资料》,大家戏称它为价值 “25 000美元的书”。 蒂尔最后说,大厅后面有他的论文的复印件,大家可以自取。听众立马抢着离开座位去取论文,在一片嘈杂声中,雷神公司(Raytheon)的一位代表在走廊里拿起电话大声叫道:“德州仪器公司做出了硅晶体管!” 信件几经辗转,最后到达了投资人阿瑟·洛克(Arthur Rock)手上。他读完信后,被其中的一点打动了,那就是诺贝尔物理学奖得主肖克利选择了他们这件事本身,足以说明这个团队值得投资。 接下来,洛克火速地赶回纽约,寻觅合适的投资公司。但大部分公司都婉拒了,只有一家仙童摄影器材公司的老板谢尔曼·费尔柴尔德(Sherman Fairchild)表现出浓厚的兴趣。费尔柴尔德的父亲曾是IBM公司最大的个人股东,后来把股票转移给了儿子。费尔柴尔德充满活力,很有想象力,对半导体器件很感兴趣。之前他的公司调研了制造半导体元件的可能性,但一直找不到合适的团队,而此时诺伊斯一行 8 个人正好找上门来,于是他们一拍即合。 在洛克的协调下,仙童摄影器材公司派人来到加州。最后达成的协议规定,公司划分成 1325 股,8位联合创始人每人只需缴纳 500 美元就可以获得 100 股原始股,洛克所属的海登斯通公司占有 225股,剩下的 300 股用于今后招揽人才。仙童摄影器材公司在 18 个月内投资 138 万美元,成立名为“仙童半导体公司” 的子公司。在仙童半导体公司连续三年的利润超过 30 万美元之前,母公司仙童摄影器材公司有权以 300 万美元的价格在任何时候收购它。“这是一个让双方都满意的协议。” 其中一位创始人说。 肖克利在贝尔实验室的同事查尔斯·汤斯(Charles Townes)后来对摩尔说:“肖克利太聪明了,他明白所有的事情,除了人。” 就这样,在这短短的一年时间里,基尔比首先提出并实现了单片集成技术,而诺伊斯首先提出并实现了互连技术,莱霍韦茨则首先提出了电气隔离技术,这三项技术组合起来,成为集成电路技术的基石。 每次听到自己被称作科学家时,基尔比总说自己是一名工程师,是在尝试解决实际问题。在诺贝尔奖领奖礼的演讲中,基尔比展示了自己手工做出的第一颗带着飞线的芯片的照片,并说道:“如果我知道这个电路将来会帮我赢得诺贝尔奖,我会多花些时间好好装点一下。” 摩尔曾说:“仙童半导体公司的可贵之处在于它的组织上是不成熟的。类似的想法也会出现在其他更‘成熟’的大公司,但是一定会被认为在经济上不值得而被否决掉。” 那时萨支唐跟巴丁重新取得了联系,开始兼职去伊利诺伊大学教授晶体管课程,他经常往返于加州和伊利诺伊州之间,几个星期才回一趟公司。这让万拉斯更加不受约束,创造力得以充分施展。 万拉斯刚刚加入仙童半导体公司才几个月,就和萨支唐一起提出了一种新的电路,把一个PMOS场效晶体管和一个 NMOS 场效晶体管组合起来,两者互补形成一个CMOS场效晶体管开关。 接着,主持人抛出了一个犀利的问题:“在这50年中,戈登·摩尔从摩尔定律中学到的最大教训是什么?“, “哦,这个问题挺难回答的。”摩尔沉思了一两秒钟后说道,“对我来说,最大的教训就是:既然已经做出了一个这么准的预测,那么我最好避免再做出第二个预测。”观众席中顿时爆发出一阵笑声。 与其说摩尔定律是一个定律,不如说是一种信仰。正是这种“不待证明而相信”的信仰,推动着摩尔定律不断获得验证。摩尔定律展示的不是永恒不变的物理定律,而是人的想象力和创造力在不同阶段所能达到的极限。 开关也能做计算?不能,但是开关很适合表达逻辑关系。例如,两个开关可以组成“与”“或”等逻辑。我们只要采用二进制,就能把代数计算转化为逻辑计算,从而用开关来实现代数计算。 1948 年,香农在贝尔实验室工作时发现了一种更加小巧、快速的开关。当年上半年的一天,香农拐进了肖克利小组的实验室,他灰色的眼眸立刻被一个只有三根导线的小玩意儿给吸引了。“这是一个固态放大器。”肖克利解释说,那时晶体管还没有正式对外发布。 彼时,仙童半导体公司已经有一大波人出走创业。公司几乎每周都有人跳槽出去创业。这种出走创业模式已蔚然成风,以至于仙童半导体公司成了硅谷培养半导体人才的摇篮,从仙童半导体公司出走的员工总共创立了400多家公司,它们大多坐落于硅谷,被称为 “小仙童们”。 诺伊斯和摩尔开始张罗新公司,他们想仿照硅谷的先驱惠普公司,将创始人的姓氏首字母组成新公司的名字“MN”(Moore Noyce)。但它读起来像是“More Noise”(更多噪声),这对电路来说无疑是个灾难。不久,一个简洁的名字英特尔(Intel)脱颖而出,它是三年前摩尔提出摩尔定律的文章最初的名字“集成电子学”(Integrated Electronics)的缩写。 于是,霍夫大刀阔斧地精简了整体系统的架构,只保留了4颗芯片。这 4 颗芯片分别是 4001(ROM)、4002(RAM)、4003(寄存器)和4004(CPU)。它们的数据总线是4位的,因此以4开头,名为“4000系列”。这些芯片构成了“冯·诺伊曼结构”,前 3 颗组成了存储系统,而4004构成了最关键的计算单元和控制系统。这样一来,英特尔公司承担的整体设计任务就大大减轻了。 诺伊斯后来把英特尔公司的策略归结为:“我无法实现你的要求,所以我想出了一种更简单的方法来绕过它。这就是可编程芯片想法的起源,也正是微处理器芯片想法的精华:完成电路设计,赋予它可编程的能力。这样你就可以有很多不同的应用。” 终于,比吉康公司接受了霍夫的新架构。此后,嶋正利回日本继续完善新架构,他们要求英特尔公司在未来的几个月内完成所有的芯片设计。 然而,英特尔公司的开发承诺却落空了,它卡在了具体的电路设计上。霍夫只熟悉处理器架构和指令集,并不懂逻辑电路设计,而且他还接到了新任务,为CTC公司设计8位处理器架构。于是他把“4000系列”芯片的设计任务转交给了MOS场效晶体管研究部的莱斯利·沃达斯。 然而,沃达斯同样不懂电路设计,他需要招聘一位既懂芯片架构,又懂电路设计,最好还懂MOS场效晶体管的工程师。但是集这些素质于一身的工程师凤毛麟角,要在短时间内找到无异于大海捞针。 不得已,沃达斯想到了在仙童半导体公司的前同事法金,他是完成这项挑战的不二人选:既懂计算机架构,又有计算芯片设计经验,还懂最先进的硅栅MOS场效晶体管工艺。 法金将 4004 放在一台显微镜下面仔细观察,竟发现有整整一层材料没有添加到芯片上,难怪芯片不工作。原来,是技术人员在制造芯片时遗漏了其中一层。法金距离成功如此之近,又是如此失望。直到1971年1月下旬寒冷的一天,法金再一次收到了新的4004样片。他小心翼翼地拿起装着样片的盒子,就好像捧着自己的孩子,把它们轻轻地安放到测试平台上。当他把芯片连接到测试仪器上时,他感到自己的手指在微微颤抖。法金测试了第一个点,波形正常!又测试了几个点,波形正常,并且完全符合预期。他简直不敢相信,在这颗小小的芯片上,一切都按照预期的结果显示在他面前,速度比他19岁时用锗晶体管搭建的计算机还快10倍,而功耗只有0.75瓦,仅仅是锗晶体管计算机的1/1 000,这真是工程技术上的奇迹。 凌晨4点,法金完成所有的测试,拖着疲惫的身子回到家中。妻子已经入睡了,但瞬间被惊醒,她在朦胧中看到了丈夫,问道:“芯片怎么样了?”,“成功了!”法金激动地喊道,和妻子相拥在一起,欣喜若狂的情绪包围着他们,将法金身上的寒气一扫而空。这时他们才意识到,他们见证了一个历史性事件——世界上第一颗CPU芯片诞生了。 接下来,英特尔公司将样片寄往比吉康公司,嶋正利把它装到一台内置纸带打印机的计算器上。他在键盘上敲下了一个加法算式,屏住呼吸,只听纸带打印机在一阵振动后输出了结果。嶋正利内心非常激动,这是4004芯片的第一个成功应用。然而,英特尔公司却没有人对此欢呼庆祝。公司内部对是否大规模销售这颗 CPU 芯片产生了严重分歧。

2025/8/10
articleCard.readMore

阅读《简约至上:交互式设计四策略(第2版)》

简介 《简约至上》提出了四个策略: 删除 - 删除多余的文字、按钮、功能。 组织 - 将功能整合,更容易让用户关注到重点功能。 隐藏 - 将重要但非高频的功能隐藏起来,简化应用操作逻辑。 转移 - 多平台应用,应有所侧重,不是每一个功能都要在多端实现。 内容比较基础但融会贯通不易,只看标题就可以猜到这个章节讲的内容,这本书适合快速阅读,如果你在书店恰巧看到它,1 到 2 个小时就可以翻阅一遍。 如果书就在那里,建议翻翻看,不推荐购买收藏实体书。 摘抄 如果你是赛道上唯一的参赛选手,那只要“足够好”就行。但多数情况下,我们都会面临激烈的竞争。这时候,边际效用——细节—就变得重要了。正如查尔斯•埃姆斯(Charles Eames)所说:“细节并不是细节。它们构成了设计。” 最好与其他的差别在于细节。 传统的观点认为,功能越多,能力就越强,产品的用途也就越广。传统的观点还认为,功能多的产品能打败功能少的产品。但相对简单的产品却频繁地替代更复杂的产品。 删除杂乱的特性可以让设计师专注于把有限的重要问题解决好,而且也有助于用户心无旁骛地完成自己的目标。 法国作家、飞行员安托万•德•圣埃克絮佩里(Antoine de Saint-Exupery)说过:“完美并非加无可加,而是减无可减。”删除作为一个策略难就难在明白怎样做到这一点。 项目经理因为这个功能太难做而砍掉了它… 交工日期迫近,预算资金紧张,都可能导致功能被砍掉。设计团队经常会以提供尽可能多的功能为目标。那些耗时而不容易实现的功能通常会被砍掉。如果有人强烈反对,得到的答复一般是他们的功能会在“第二期”或“第三期”实现。结果呢,无非就是得到一个由简单的功能叠加起来的毫无特色的产品,与市面上现有的平庸货色别无二致。 如果你发现自己(或别人)说:“假如用户需要…”那么只有一个答菜:搞清楚这个功能对用户是否真的重要。问一间:“我的目标用户经常会遇到这个问题吗?”如果回答是“几乎没有遇到过”时论下一个。那么,请放弃这个想法,不要再“假如”了,还是去发现问题吧。 消除错误的来源是简化体验的一个重要思路。 功能多对于没有机会试用的消费者有吸引力。但是消费者使用了产品之后,他们的偏好就会改变,一下子从重视功能变成了更重视可用性。 如果一个元素的重要性为1/2,那就把它的大小做成 1/4。 简单的体验优先于完整的服务。 只有明白推荐的理由、赞同推荐的标准、接受可能的风险,用户才会接受计算机的推荐。如果推荐的结果没有满足上述要求,人们就不会信任它。 让用户感觉简单的一个重要前提,就是先搞清楚把什么工作交给计算机,把什么工作留给用户。

2025/8/6
articleCard.readMore

阅读《审判》

(《审判》主人公 K —— Gen by GPT-4o) 初读体验 第一次读类似题材的长篇小说,我带着 ‘先看两章、能读进去就继续’ 的想法开始阅读。 虽然我不清楚作者所在的十九世纪的奥匈帝国的背景和当时的司法氛围,但读《审判》时,我能感受到作者、K,乃至读者都深陷 ‘困惑’ 之中。 GPT:“当K试图询问罪名时,官员只回答‘你被捕了,但可以继续生活’ —— 这种逻辑的断裂,正是困惑的根源。” 作者对司法的思考深刻,小说文笔精炼、刻画细致,让我有种感觉,书中的 K 就是作者,作者也是 K,卡夫卡将自己融入到了小说中。 GPT:“作为法学出身并长期任职于保险局的卡夫卡,笔下官僚系统的 ‘无意义严谨’,或许正是他对体制的观察。” 文字信息密度很大,每段对话都可能隐藏着作者的思考,等待读者去发掘 —— 这是令人上瘾的阅读体验。 我渐渐意识到,这本小说的品鉴维度是多样的 一个被宣判有罪的个体,从 ‘拒不认罪’,到觉得需要做些什么,借助女人,再到主动周旋,全力以赴,去找可能与法官有私下交情的律师,去找给法官画肖像了解隐秘司法 ‘内幕’ 的画师,从玄学再到一个深奥的 ‘法的大门’ 的教士的论述... 隐秘的司法,充满荒诞。 从 K 的人格角度,从最开始的严谨,轻松,到最终 ‘对抗’ 命运,却毫无一点胜算,坦然接受死亡,从 K 的生日开始,到 K 的生日结束,仅仅只是一年的时间。 K 的生活,某种程度上是作者的映射,得以窥探作者甚至是那个时代对于工作、性、司法的看法和观念,另外隐隐觉着,书中的女性角色总是跟司法相关联,有着不正当的关系,次要角色(如律师、画师)也都是官僚机器的具象化零件,是司法大机器的外部延伸。 司法荒诞 K 遇见画师的章节蛮有意思,K 算是 ‘真正’ 窥探到了法院的一丝隐秘,了解到有三个办法可以 ‘解决问题’。 真正的无罪开释 诡称宣判无罪 无限期延缓审理 真正的无罪开释 是最理想的结果,但仅限于高级法官特权,普通人几乎无法获得 无罪开释首先排除,因为这是大人物——高级司法部门才能享有的特权,低级别预审法官没有这项权力,无罪开释如果成立,那么关于案子的当事人的所有诉讼、案卷都要通通销毁。 诡称宣判无罪 表面“无罪”,实则是一次极度依赖人脉与机会的繁复流程,一旦失败,后续尝试更为艰难 通过关系,联系到几个法官,他们在无罪辩护声明上签字,‘背书’ 的作用,然后提交到案子的主审法官处,他才敢于签署 ‘宣判无罪’,提交到上级部门,这个案卷可能流转到各种不同层级的部门和人员手中,如果有一个人偶然看到,认定其有罪,那么就会被驳回重新审理,这个时间可能在一天内,也可能几年时间,当事人就要重新赶快再疏通关系,走一遍这个流程,而第二次、第三次的 ‘诡称宣判无罪’ 肯定会更加困难,这是显然的,走进诡称宣判无罪流程,也就意味着无法再真正的无罪开释,意味着案子当事人需要集中精力、快速响应眼下的问题。 无限期延缓审理 通过不停“活动”保留自由,代价是持续的精力投入和精神焦虑,实则是被困于系统的慢性折磨 案子当事人需要 ‘持续的输出’ 精力,要跟主审法官保持紧密的联系,经常到审理机关 ‘报道’,以便能够顺利的、缓慢的走着毫无意义的流程,这将是持续性的努力,最终的目的是保有自由,因为未宣判,就没有罪,也就可以一直为自由努力,显然,这两种方式都无法被真正解决。 理解完这三种方案后,我们对这套司法系统的本质,也可想而知了。 思考未竟 正如卡夫卡曾要求销毁手稿,K 那场“未完成的辩护”也未能实现,他们共同构成了这部作品戏剧性的终章。 最后的最后 对于本书,可能留给读者一些思考:如果连 ‘罪名’ 都无法得知,那我们还如何为自己辩护?进一步的,在这个困惑又荒诞的司法体系下,在不可知权力面前,所做的努力会有多少用处呢? 但是转念一想,抗争永远有用,总会有用。 在绝对权力的黑幕下,我们可能永远无法得知自己“错在哪里”,但只要我们还愿意提出质疑、尝试抗争,那就还未完全失去自由的可能。 《审判》值得一读,更值得反复咀嚼,我放弃了到网上或者询问 GPT 了解更多 “正确又细致” 解读的机会,相信以后重读,可以有更多属于自己的思考。

2025/8/3
articleCard.readMore

阅读《统计数字会撒谎》

Chapter 1:内在有偏的样本 案例:“记者在火车上调查大家都买到了回家过节的车票” 第一章讲的是样本因自身特性或选择方式而无法代表总体,导致统计结果失真。 最基本的样本是随机样本,它是指完全遵循随机原则从总体中选出的样本。总体即形成样本的母体。 随机样本的检验方法是:总体中的每个名字或每个事物是否具有相同的几率被选进样本? 纯随机样本是惟一有足够把握经受统计理论审查的样本。但它也有不足之处,在很多情况下,获得这种样本的难度很大并且十分昂贵,以至于单纯考虑成本就会排除它。 Chapter 2:精心挑选的平均数 案例:一个富豪年薪 1000 万,他有 9 个朋友穷光蛋,平均一看,各个年薪百万。 当你听到公司执行总裁或企业所有者宣称,在他的企业中员工的平均收人是多少时,你应该好好思考一下其中的原因。如果这个数是中位数,你可以获得一些显而易见的信息:一半员工赚得比它多,一半比它少。但如果是均值(请相信我,没有确切指出它的种类时,多半是均值),它仅仅是所有者 25000 英镑的高收入与全体工人低水平收入的平均数,根本没有什么意义。“平均年收入为 3800 英镑”既隐瞒了1400英镑的低收人,又隐瞒了所有者以巨额薪金形式抽取的高额利润。 所以,当你被告知某个数是平均数时,除非能说出它的具体种类——均值,中位数,还是众数,否则你对它的具体涵义仍知之甚少。 Chapter 3:没有披露的数据 案例:某人宣称抛硬币正反面的比例是 5:1,但是没有披露他做了 100 次试验,只选择了其中的 5 次试验结果作为样本进行统计 还有另一类没有透露的数据,它的遗漏也同样具有破坏性。这类数据表明了事物的变动范围以及与给定平均数的偏离水平。通常情况下,单凭一个平均数来描述事物过于简单,起不到作用,不管这个平均数是均值还是中位数,也不管平均数的具体类型是否已知。 Chapter 4:毫无意义的工作 案例:某公司投入巨额资金,部署了多条国际专线,调整了 BGP 路由策略,优化了跨境访问链路,最终成功地将国际网络访问速度从 300 毫秒降低了 2% —— 相当于 6 毫秒的提升。 只有当差别有意义时才能称之为差别 换句话说: 只有在毫无意义时,这个差别才如此值得强调 Chapter 5:令人惊奇的图形 案例:(见以下图像) 只需要改变横坐标与纵坐标的比例关系,将纵坐标的每一个刻度缩减为原来的1/10即可,没有人规定不能这么做,而这将会产生一张更加完美的图形。 显然图形比文字更有效,因为图形中不存在任何形容词和副词来破坏它所具有的客观性幻觉,而且谁也无法指责你。 搜索到某龙江省历年出生率数据(21 世纪),这是一个稀疏平常的折线统计图 已经可以看到出生率下降比较多,如果我修改为下图的形式,宽高对调,同时 Y 轴间隔从 0.2 调整为 0.05,图像将更有震撼效果 Chapter 6:一维图的滥用 案例:(见以下 AI 提供的说明) 在图表的世界里,只要敢缩轴、敢立体、敢堆图标,你就能让“微涨”看起来像“暴涨” 跟「令人惊奇的图形」类似,一维图像想要突出或降低一些视觉效果,可以做的文章很多 Chapter 7:不完全匹配的资料 案例:某诺贝尔奖得主每天都喝这种牛奶,但没有提及他也吃面包、鸡肉和沙拉,每天晨跑、骑自行车 如果你想要人相信 “结论A”,但拿不出证据,就展示一个听起来很像、但其实只是“相关”的 B。 这就是“不完全匹配的资料” —— 一种披着数据外衣的偷换概念 Chapter 8:相关关系的误解 案例:研究发现冰淇淋销量高的时候,溺水事件也多。 实际上是因为夏天到了,吃冰淇淋的人变多,游泳的人也变多,共同原因是 “气温升高”,不是冰淇淋和溺水互为因果。 重要的概念即:相关 ≠ 因果 “相关性”(correlation)只是两个变量一同变化,而“因果性”(causation)意味着一个是另一个的原因 但是,很多时候人们会误把 “同时发生” 当作 “有因果关系”——这是一个统计思维中的致命误区 Chapter 9:如何进行统计操控 案例:产品满意率达到 100%,其实只调查了 5 个人,这五个人还是公司的员工 任何建立在小样本容量上的百分数都可能产生误导,直接给出调查对象规模(样本容量)的大小将更有价值。 案例:股票跌了 10%,但是需要上涨百分之 11.11% 才能回到原来水平,下跌的越多,涨回去越难 50%的削减量需要通过提高100%才能加以补偿。 案例:商场打折,折上折的数字陷阱 份“50%折扣再打20%折扣”的报价单时,那并不意味着70%的折扣,实际上只有60% 案例:(这个案例就很经典) 人们询问他的兔肉三明治为什么能卖到如此便宜的价格时,“哦,”他说,“我当然得掺一些马肉,但我的比例是一比一:一匹马,一只兔子。” Chapter 10:如何反驳统计资料 省流:数字不是事实,只是说故事的工具,别轻信涨幅和图标,多问问数据怎么来的,多问问 AI 怎么看

2025/7/28
articleCard.readMore

达达秒送骑士

单价略高 & 定位差 刚跑外卖时,我同时下载了美团众包和达达秒送骑士,后者两个月前被京东收购了,我跑了几周的美团众包后,也过了新手期,就想着换达达试试 网上说达达定位不准,结果第一单就体验到了,是真不准 距目的地差了有两百米,我给用户打了两遍电话才找到,直接导致我手里拿着的第二单被迫超时,好在可以用新手免罚卡抵扣 如何避免定位不准出问题? 别用达达 APP 内的点击跳转到地图功能(错误的坐标,在哪里显示都是错误的),直接打开高德地图,手动输入目的地,开启导航 达达单价整体上感觉比美团众包能高个一两块,但也不能无脑接单,广场挂着没人抢均价还不到1块钱每公里的单可别接,妥妥的甄纯牛马 公里均价能到2元以上的单,对我来说都还算不错(取货送货都是几百米,公里均价3+元的单是真不好抢) 达达近期有个活动,跑一单达标就送 15元奖励,也是被我白嫖到了 说回来配送,跑达达第一天,就送到两个让我蛮开心的单,一个配送到我现在住的小区,另一个配送到前些年住过的城中村公寓,这两单不用开导航 女骑手变多了 最近发现,女骑手好像多起来了,网上买菜送货员、上班去地铁的路上、商场电梯间,都有看见过女骑手,明显比前两年多,不知道有没有关于骑手的数据报告支持主观感受 2025-07-24 更新 还真找到了美团的骑手报告,在 https://www.meituan.com/newsroom/rider-protection 找到《美团骑手年度职业报告(2024-2025)》 (2) 性别特征:男性占比超九成,“夫妻档”稳定性强 2022-2024 年,男性骑手在平台月活骑手中占比始终超过 90%。虽然女性骑手占比不足 10%,但数量 稳步上升:2022-2024 年,在美团平台上获得收入的女骑手数量从 51.7 万人增长至 70.1 万人。田野 调查显示,女骑手以已婚中年女性为主,其中很多女骑手职业选择受配偶影响,即丈夫先成为骑手,后 和妻子组成“夫妻档”。因样本量有限,夫妻档骑手占比难以衡量,但已有样本显示,“夫妻档”骑手 的就业稳定性更强,且女性略强于男性。调研中,多对夫妻档做骑手时间超过 3 年,二人中至少有一人 实现晋升,成为小组长、站长等。除夫妻档外,女骑手的职业选择理由与男骑手无异,收入高、自由度 高是其选择做骑手的主要原因。 报告提及到女骑手人数变多,但比例并没有提升 除了「信息公开」板块,美团网站上的「公益」板块也很好,为公益操场捐款时可以选择到家乡,然后看到正在筹集资金的幼儿园,建设完成后可以获知 "捐赠的每一块地板放到了哪个操场的哪一行哪一列"

2025/7/23
articleCard.readMore

日本关西系列|在动物园前站找到海南本线

大阪今新宫、动物园前是个交通很便利的地方,北上可到难波、心斋桥,往东南方向一站可达天王寺,在附近住了两天,交通很便利 在我返程的最后一天,到 Daikoku Locker 取行李,顺便逛了下附近的唐吉诃德,根据 Google Map 地图,打算坐南海本线往关西机场走 下着小雨直接钻进了「JR 今新宫」站(下图),上站台后走了好长一段,刷卡时错误,被站务拦住,他有一个小对讲机,在跟我确认是 Chinese 后,切换为中文输出,告知我不能从这里过去,但是他没告诉我要怎么走,于是我又掏出手机中文转日语,但是我他表情有些迷茫,然后又打字问他 耽搁了一会儿,我大概理解了他说的,然后从图 ① 位置又出来,冒雨沿路走到图 ② 位置 惯性思维容易害人,这两个站其实不互通 看到 “NANKAI” 字样,就是走对了 坐车前,我还有些分不清海南本线和南海特急,以为是同一车型,后来发现差别很大,一下是南海本线和南海特级的列车 (有些像北京地铁的是海南本线) (网图:酷酷的是南海特急 - 忍者号 Rapit)

2025/7/19
articleCard.readMore

观影《长安的荔枝》

之前没读过《长安的荔枝》原著,直接看的电影,观影上整体感觉良好,节奏比较快,没有彩蛋 剧中大鹏饰演的李善德,被媳妇每天来个大逼斗,着实有些不理解,不知是否要塑造出 “妻管严” 的人设,感觉老实人+爱媳妇的形象的主角,观众都很喜欢 剧中圣人只是想讨皇后的欢心,执行的人就得脑袋别裤腰带上跑断腿,底层的老百姓就要为这些权贵买单,并附上沉重的代价,真就是时代的灰尘… 牛马们能一秒对上号,高台上的权贵们也从不管底层死活(电影中) 电影表达的内容,字谜都在谜面上,所以可挖掘的深度有限 说回大鹏,他拍的影片,都有很强烈的流量风格,从屌丝男士,到后来看过他拍的《年会不能停》 ,都带着幽默、职场、Happy Ending 的标签 大鹏是导演也是主演,可以感觉到演技有所提升,但大鹏的电影目前看还是主打快销 提一嘴白客,从《万万没想到》的王大锤时期积攒了好感度,个人觉着白客演电影很有表现力,荔枝园主阿僮(庄达菲 饰)的率真仗义也让人印象深刻 这个电影适合平时不太看太多电影的观众,许久未去影院,偶尔去看个电影,有泪点、有笑点、有故事也有还不错的结局,对于我这种一年看两三场电影的人来说是友好的

2025/7/19
articleCard.readMore

Memos: 记录两个在线工具

稍微调整了博客的代码,支持了 “Memos” 功能,用来记录一些零碎的想法和备忘录。 线上实施,准备了脚本(是 Pod 的 Yaml 配置文件)用来导出数据,测试环境验证的很完美,正式环境一提交,Pod 起不来,一看日志被 Dockerhub 限流了,想到前两天刚看到个国内镜像源 https://docker.aityp.com/ ,虽然机器在国外,但问题不大,替换了镜像地址,救我老命 导出数据容易,从 k8s 中下载数据到本地有些麻烦,数据量不大,压缩后几十MB,想到可以加密后上传到免登录的公共临时存储,找了 https://0x0.st/ 和 https://catbox.moe/ 前者限制了我的机房 IP,后者用着着实不错 $ zip -r -P "$ZIP_PASSWORD" data.zip data/ $ curl -F "reqtype=fileupload" -F "fileToUpload=@data.zip" https://catbox.moe/user/api.php https://files.catbox.moe/y1xzwa.zip%

2025/7/17
articleCard.readMore

地球 Online:外卖骑手体验报告

有天我在网上闲逛,看到一个「船新」版本的地球 Online 游戏思路 如果你想玩开放世界大作,可以弄个外卖工作做做,有大地图,有每日任务,有随机事件,有动态天气变化,有NPC,有攻略,有打法,除了战斗系统外一应俱全,完成任务委托还能获得对应奖励,为何不来试试呢 外卖是现代冒险者,有任务中心,有结算,有冒险地图,这就是冒险者工会。 研究后发现确实值得一试,在真实物理引擎的加持下,体验肯定会不一样 新手上手最简单的是「美团众包」服,在上号的前几天先下载游戏,注册,学习了解游戏规则,游戏外设至少要准备「电动自行车」和「头盔」以及「外卖箱」 自行车和步行也是可以的,只是能接的任务有限,影响游戏体验,因为游戏过于真实,头盔也必不可少,外卖箱我是在淘宝 18.8 买的,可以使用泡沫箱替代,箱子不是强制的,主要是为了保温和避免餐损 另外电动车上的手机支架,也十分建议配备,路线不熟时,频繁停车看手机很浪费时间,超时罚款会很影响心情(新手期赠送了三张免罚券) 需要缴纳 100 元押金,用来解锁更多类型的订单任务,健康证非强制,一个月新手保护期过后会提示上传,不上传会限制同时挂单数量 新手玩家不建议以挂单量和任务奖励为主要目标,这会增加些焦虑情绪,也能避免任务超时触发惩罚机制,先稳稳当当,一次接个一两单 新手期有时会派发新人专属,绑在一起的两个订单,取餐都在一个区域,送达地点也不远,这种单很好,不要错过 不建议接的任务 送货地址描述不清 就近代买的,费时费力易有纠纷 取货和送货都较远,单公里价格低 蛋糕这类容易给自己过生日的易损品 高峰期的写字楼和医院,写字楼上不去下不来的 不接远单,路线不熟,容易超时,回程空跑浪费时间 就接点正常的任务,在新手村慢慢攒经验 游戏感受 平时下班儿晚,都是赶在周末上号,每次两到三小时,最近总共游戏时常 9小时,报酬124.5 元(把头盔和外卖箱钱赚了回来),平均时薪 13.8元/小时,后期陆续达到 15元/小时(跟职业玩家没法比) 近单模式下,平均四块钱左右一单,六块钱以上要骑的比较久,十块钱以上我就不接了,对我来说性价比不高,更适合专业骑手,他们的设备,一脚油就到了 第一次配送的时候,找不到商家,明明定位就在那里,但是看不到店面,可以询问同为「骑手」职业的玩家,问过几次路,发现大家都很乐意帮忙,不要用“您好”,直接叫“大哥”,融入进去 配送过程最多三单在手,在时间的催促下,不时有闯红灯的冲动 晚上穿梭在车流人群中,感觉人从世界中抽离出来,对时间的感知不再明显,灯火、热闹也都跟自己没有任何关系,只有 “从哪里取,送哪里去” 的最简单又朴素的使命感 外卖送达时,成就感即时反馈 如果你在夏日下午四五点钟点开始送外卖,日光渐隐,热浪还在,辛苦了两个小时后,偶然接到一个林荫路的单,那一刻能体会到凉爽的风是对辛劳者最好的馈赠 游戏收获 对周边更加熟悉,地图 “战争迷雾” 不断在消散,发现商圈中不显眼的外卖档口,进到的城中村感受喧嚣,也送货到千万元别墅区体会宁静 然后,生活中减少了点外卖的次数,且更加慎重,没有实地去过的实体店外卖几乎不再买,宣称 “绝无预制菜” 的小商家不可能厨师现炒,大概率是提前分装好的菜品,小份菜摞成一摞,放在微波炉中加热,两分钟后伴随着“滴”声出餐 实付十来块的外卖,配送费三四元、扣除平台抽成,再扣除商家的房租水电燃气及人力成本,最后想要盈利,一定是在餐盒和食材上能省尽省,高油盐本就不健康,还可以用来掩盖掉食材不新鲜的味道 实体餐饮很难 想起前些天一个朋友弟弟来北京开店,主营山西肉夹馍和凉皮面食,在热门区域租了一个小门市,可以堂食,也接入了外卖,因为我们公司就在附近,就点了吃,肉夹馍里瘦肉多、汁水足,味道正宗 没想到在二十多天后,就听说不做了,了解得知房租每月几万块,我听着都顿感压力,这得卖多少单才能挣回来 美团外卖上显示订单 100+/月,饿了么 24 单,总应该不超过 200 单,按照线下线上 3:1 估计,月总 800 单,均价 25 元/单,毛利润八元算(已经是很高的估算),入账六千四,不算人力成本,房租月亏几万,怎么算都太亏 突然有个不成熟的想法,感觉做餐饮小本经营的个体户,可以跑一个季度的外卖,对于周边消费者的喜好、客单价,会有一个不同的看见,在选址上也会有更多的选择 游戏之外 外卖骑手可能是跟程序员很互补的职业,虽说大多时候在骑车,但是去往商家与送餐过程中,也会走路和小跑,不必排斥接到送货到七楼的单,不赶时间爬爬楼也是锻炼身体 称呼 “游戏”,并非因骑手是容易的,相反,这个职业被称为 “铁人三项”之一,是高强度的体力劳动,我是选择西山的太阳才敢出门,正午的太阳只想在家躺平,只是送外卖这个工作,具体到个人来说,是营生,还是生活的调味剂,心态自不相同 跑外卖想赚钱,真的需要靠着铁人的身体素质和意志,月入一两万的单王是极少数人,春夏秋冬高强度跑外卖,身体也容易落下病,只有体验过才知道辛苦 对于我来说,参与了体力劳动换取报酬,对于消费习惯会有影响,送了几单林里手打柠檬茶后,回家的路上也想要犒劳自己一杯,然后想到要跑一个多小时才能赚回来,就算了,到超市买了两个柠檬回家 闲暇时,我想我还会偶尔上号继续前行,感受并探索世界

2025/7/15
articleCard.readMore

杜师傅夜话:附身与归途

借喉说话的亡者,无人应答的归途 杜师傅有五十多岁,已退休的老一辈程序员,发际线 “后缩”,精干强健,爬山总是在第一梯队上到山顶,见多识广,总能讲述些轶闻趣事 杜师傅说她的岳母亲历过她们村里一个很匪夷所思的怪事,记在如下 村里有一个男人进山打猎,按现在的话说,这个人很衰,生活也不得志,进山后可能是摔了或是怎样,猎枪打到了自己的头,结果就这样死了,因为死的实在窝囊,下葬的过程中就不顺利 下葬当天,村里有个瘦弱的女人,在路旁观望,突然就张嘴开口说话,是男人的声音,神情神态,都跟这个死者一样,都说是被这个死人附了,抓狂暴躁,胡言乱语,有几个年轻人不了解情况,过去想要抓住她,不想她力气却是奇大,几个人也按不住 村里有个老人可能看出些门道,就说把死者的妻子找来,她还有孩子,就一同过来,众人对她说那个人就是你的丈夫,你去劝劝他 这女人就对着瘦弱的女子哭骂,就说“你在世的时候我们娘俩跟着你受苦,现在死了丢下我们也不让人安生”,过了一阵儿,瘦弱女子倒在地上,像生了大病,一连躺在床上几天都起不来 想来被鬼附要透支人的生命力,不然哪里来的那么大力气,称奇的是女人没有喉结,却能发出男人的声音,杜师傅感慨着 …… 杜师傅又说起关于他岳父去世时候的事,那时候他和一些亲人都在照看他岳父 有一天医生就跟他们说再有三四个小时,病人就要去世了,看你们要是需要转院就赶紧,当时是在长沙,病人去世要火化,回到村里就还能土葬,杜师傅一行人就驱车火急火燎的往家赶 后来才听说,她的岳母独自在家的时候,那时她还不知道丈夫病危,在床上午间休息,就听见有人按门铃的声音,这个门铃在一个很偏的位置,没人知道位置平时也不会有人按 又听到院子里的脚步声,这个声音她很熟悉,她确定那就是她丈夫的声音,杜师傅岳母还问:“你回来了”,没有人应答 后来得知,她丈夫在回来的路上就去世了,这是他跟妻子最后的道别

2025/7/8
articleCard.readMore

日本关西系列|使用投放硬币的行李寄存箱

出行前在小红薯上看攻略,有人分享使用行李寄存箱没搞明白花了两份钱,有点好奇,实地使用过一次后发现蛮容易的,只是跟国内寄存箱的逻辑有些差别 简单总结即 先放包, 再投币, 锁门拔钥匙 京都蹴上站的行李寄存箱,工作日来空闲箱很多,没有硬币可以到旁边人工窗口换取 500 日元的存储柜存放一个二十寸行李箱+一个背包,空余空间还很大 请看 VCR ⬇️ Your user agent does not support the HTML5 Video element.

2025/6/23
articleCard.readMore

日本关西系列|将多余零钱充值到西瓜卡

在日本,零钱有很多使用场景,街边的售卖机、乘坐公交车等,但零钱过多,随身携带会不方便,如果你在地铁站看到 “のりこし精算機” 机器,可以将过多的零钱充值到 IC 卡(西瓜卡) 页面支持中文,下一步、下一步即可,如果担心不太会操作,请看 VCR ⬇️ Your user agent does not support the HTML5 Video element.

2025/6/7
articleCard.readMore

日本关西系列|网上购买大阪往返白滨高速巴士

高速巴士概览 日本很多巴士同一条线路的不同班次会由不同公司运营,以大阪南纪白滨高速巴士线为例,本次出行,去程乘坐的 JR 巴士,回程是明光巴士 需要提前买票,不支持西瓜卡等 IC 卡,网上购票后,上车前给司机看电子票 巴士中途会在一个叫 Kinokawa Service Area (Southbound) 的服务区停留半个小时,发车前司机会数人数 巴士上有免费的 WIFI 网络 巴士干净,乘坐较为舒适 (去程 JR 巴士) (回程明光巴士) 高速巴士运行时刻表 链接:https://meikobus.jp/highway/osaka/ 点击上方地址链接进入查看最新时刻表,以下截图仅供说明: 出发站可以是 “アドベンチャーワールド(环球影城)、大阪駅JR高速バスターミナル(大阪站)”、JRなんば駅(OCAT)(湊町 OCAT)、りんくうタウン駅(临空城) “土休日” 指代周末,“↓” 符号代表本站不停 根据运行表,结合出行时间,我选择了 10:10 分在 OCAT 发车(难波站附近)前往白滨的巴士,13:41 到达新湯崎站 线上购票 日本高速巴士网:https://www.kousokubus.net (支持中文) 点击切换语言后,会跳转到 https://www.kousokubus.net/JpnBus/zhcn 如上图所示,选择出发地为 “大阪”,去白滨选择 “和歌山”,路线会自动填充为 “大阪 <=> 白滨” 大阪的出发站可选 “环球影城”、“大阪站”、“湊町 OCAT”、“临空城”,目的地看你住宿酒店的位置,例如 Seamore 酒店旁边就是 “新汤崎站” 点击查询 灰色即票已售罄,可以看到往返同时购买会便宜 JPY ¥700,如果选择了往返,会让选择回程的车次 同理,点击查询后选择车次,再次选择 点击 “请选择座位” 按钮,出来的弹窗选择可选座位 根据实际情况填写乘客信息(邮箱地址要正确填写,会发送支付邮件) 确认后进入价格和车次、作为确认页面 预定完成后,等待邮件 收到支付邮件后,点击其中的链接,输入信用卡信息进行支付(不支持其它支付方式) 支付成功后的页面可以直接查看 PDF 格式的电子车票 忘记保存无需担心,收到的第二封邮件中有电子票据链接;邮件如果丢失,第二张图右上角,也可以补充发送邮件 按照目前汇率,双人往返 JPY ¥11800 相当于 CNY ¥588,费用不到 JR 黑潮方案的一半 示例电子票 去程 回程 乘车位置 看到 🍠 上有人说 OCAT 不好找,可以从街道上走到图中绿色箭头位置,进到楼里二楼就是 OCAT 的站台 图中绿色箭头位置,可以看到车展上有 OCAT 的标志 进到楼里有指示牌,提示位置在一楼,OCAT 在二楼 坐斜梯到二层,可以看到显示屏,我要乘坐的 “白滨”(白滨EX大阪5号)车在 8 站台候车,顺带一提,旁边的 9 号站台可以去关西国际机场,车次很多 等待发车 巴士很准时,司机会下车验票,把行李仓打开存放物品;如果不熟悉路线,建议多预留一些时间 参考 https://www.kousokubus.net/JpnBus/Content/howtouse_zh-CN.pdf

2025/6/6
articleCard.readMore

乌鲁木齐・赛里木湖 “778 老哥” 摄影摘选(转载)

之前加了一个北京的技术微信交流群,当然现在已经 “零活跃” 群里曾经的 “778 老哥” 会分享自己的旅游照片,吃吃喝喝日常,我觉得拍的很好,收集了一些,转载于此(侵删)

2025/4/9
articleCard.readMore

使用 Restic 来备份重要数据

之前使用了相当长一段时间的 Cryptomator 管理内容,它有个好处是本地数据也需要密码才能访问,加密后的内容放到坚果云、Dropbox 等同步盘,每次添加或修改文件后就可以实时上传到网盘 如果存储的内容是无需编辑的增量文件,平时也主要是增加、替换资源,那我还是很推荐试试 Cryptomator,它很适合保存电子扫描件、图像、表格文档这类资源 但在 macOS 下,如果你使用 Obsidian 等软件管理知识库,并且有频繁的编辑需求,使用 Cryptomator 解密后的文本内容编辑就很不友好,为了避免出现异常,编辑纯文本文件时,我不得不把它复制出来,编辑后再替换原有文件,这就很有些麻烦 无意间了解到 Restic,如果你想要对 Obsidian 等笔记软件的 Vault 进行加密备份、上传到网盘、S3 等云平台,那么 Restic 正适合 Quick Start 安装 Restic macOS $ brew install restic 支持主流操作系统,详见:https://restic.readthedocs.io/en/latest/020_installation.html 初始化本地存储库 存储库简单来说就是一个文件夹,用来存储加密后的文件,初始化的过程会生成一些元数据 $ restic init --repo ~/Documents/NoteE2EE enter password for new repository: enter password again: created restic repository 0a40262533 at /Users/xxxxx/Documents/NoteE2EE Please note that knowledge of your password is required to access the repository. Losing your password means that your data is irrecoverably lost. 记住密码,遗忘后加密数据将再无解密途径 备份数据 执行命令将 ~/Note 文件夹备份到 ~/Documents/NoteE2EE 存储库 $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note 首次运行可以看到他备份的文件和目录数,回显了 “snapshot” 快照的 ID 命令可以多次运行(每运行一次创建一个快照) 因为是增量创建,无需担心快照会占用过大空间 使用以下命令可以查看存储库中的快照列表 $ restic -r ~/Documents/NoteE2EE snapshots 这个存储库文件夹 “NoteE2EE” 是加密后的内容,可以根据 3-2-1 原则,安全的存放在本地硬盘、移动硬盘、网盘中,无需担心数据泄露 备份命令参数优化 $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note \ --pack-size 32 \ --exclude="*.tmp" \ --iexclude="*.LOG" \ --limit-upload 1024 \ --json 善用 --pack-size 参数可以有效控制目标文件数量,避免大文件被拆成特别多的小文件,历史文件不会受到影响,只会影响当次增量的备份 与 --limit-upload 相对应的,有一个 --limit-download 参数,用于限制下载速度 如果基于 Restic 开发一些小工具,那么 --json 参数很有必要 {"message_type":"status","percent_done":0,"total_files":29,"total_bytes":204482794} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0.2808864315308279,"total_files":41,"files_done":13,"total_bytes":333776710,"bytes_done":93753349} {"message_type":"status","percent_done":1,"total_files":41,"files_done":41,"total_bytes":333776710,"bytes_done":333776710} {"message_type":"summary","files_new":0,"files_changed":0,"files_unmodified":41,"dirs_new":0,"dirs_changed":2,"dirs_unmodified":8,"data_blobs":0,"tree_blobs":2,"data_added":756,"data_added_packed":608,"total_files_processed":41,"total_bytes_processed":333776710,"total_duration":5.552792709,"backup_start":"2025-04-02T18:30:04.949151+08:00","backup_end":"2025-04-02T18:30:10.501995+08:00","snapshot_id":"74e465ed71f7fb5b7abb562d4cb9d067f20d89a1a9f3ed4ed32a0bc73e8abab1"} 基于这些数据可以做进度条、展示备份文件的数据量 更多 Backends 存储类型 通过 restic init --repo ~/Documents/NoteE2EE 命令,创建了一个本地的存储库 以下是一个 S3-compatible Storage 兼容 S3 协议存储的示例 # 设置环境变量 $ export AWS_ACCESS_KEY_ID=id7O9M0H****tXJ2romrN $ export AWS_SECRET_ACCESS_KEY=bFA0dL0u********ndnxVcrwPh31u $ export AWS_DEFAULT_REGION=cn-east-1 # 初始化存储库并备份 $ restic -r s3:https://s3.bitiful.net/note-e2ee init $ restic -r s3:https://s3.bitiful.net/note-e2ee --verbose backup ~/Documents/NoteE2EE 注意:AWS_DEFAULT_REGION 环境变量不设置默认会使用 us-east-1 作为默认值 其它平台和存储例如如 Amazon S3、Backblaze B2、Google Cloud 等,可参考文档:https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html 快照管理 保留最近的快照 我们可能定时/定期运行 backup 进行备份,但过多的备份会让人困扰,运行以下命令只保留最近的 7 个快照 $ restic -r [仓库路径] forget --keep-last 7 --prune 快照的标签 可以在备份的同时指定多个标签(--tag "v1.0" --tag "daily") $ restic -r ~/Documents/NoteE2EE backup --tag "v1.0" ~/Documents/Note 也可以对已存在的快照标签进行修改 # 追加标签 $ restic -r ~/Documents/NoteE2EE tag --add "important" 8c5c9d50 # 移除标签 $ restic -r ~/Documents/NoteE2EE tag --remove "important" 43547193 # 设置多个标签 $ restic -r ~/Documents/NoteE2EE tag --set "important,project2" a1ff1a78 值得注意的是,每次修改后,快照的 ID 会发生变化 备份完整性核对 存储在网盘上的备份,并不能保证文件百分百不丢失或损毁,通过 check 命令可以进行核对 $ restic -r ~/Documents/NoteE2EE check 现在人为“损坏”,例如找到 NoteE2EE 目录内 data 目录下的一个文件,修改文件名,在前方添加一个下划线,模拟文件损坏/丢失 再次运行 check 命令,提示存储库异常,需要修复,符合预期 下载快照到指定目录 查看现有快照 $ restic -r ~/Documents/NoteE2EE snapshots 导出指定快照 $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ 5fcd966f 为快照 ID,通过 snapshots 命令可以查询到;另外 NoteRestore 目录如果不存在会自动创建 当你的存储库很大,只想要导出部分文件、目录,使用以下命令 $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ --include Epub电子书 其中的「--include Epub电子书」,这个目录是存储库根目录下的一个文件夹 此时使用 copy 而非 backup 如果你仅有一个加密后的存储库,那么每次备份使用 backup 即可,如果根据 3-2-1 原则备份,存在多个加密后的存储库,那么肯定不适合重复运行 backup 多次提交到不同的存储库 这时候需要使用 copy,它会保证严格数据一致性,且性能很好 $ restic init --repo ~/Documents/NoteE2EE-copy $ restic -r ~/Documents/NoteE2EE-copy copy --from-repo ~/Documents/NoteE2EE 来自 AI 的总结(我觉着说的挺好): 如果需要在多个Restic仓库间同步数据,copy 永远是首选——它像专业的仓库间「数据搬运工」,而 backup 则是面向原始数据的「采集器」。 不妨试试 Restic Brower 如果你不是很喜欢命令行操作,那么可以试试这个开源小工具:emuell/restic-browser 使用 Rust + TypeScript 开发,只有几兆大小,只有只读功能,正如工具名中的 “Brower”,可以浏览加密后的存储库,再从快照中翻文件的时候会方便些 可能你也需要 Autorestic 文档地址:Autorestic Quick Start Autorestic is a wrapper around the amazing restic. While being amazing the restic cli can be a bit overwhelming and difficult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂 Autorestic 是基于卓越的 restic 工具开发的封装器。尽管 restic 命令行工具本身非常出色,但当您需要将多个不同位置的备份数据同步至多个存储目标时,其操作可能会显得复杂且难以管理。本工具旨在简化这一流程,让多目标备份管理变得更加轻松便捷。 安装 $ brew install autorestic 配置文件 创建配置文件,内容如下 version: 2 backends: note_primary: type: local path: "~/Documents/NoteE2EE" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" note_backup: type: local path: "~/Documents/NoteE2EE-copy" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" locations: notes: from: "~/Documents/Note" to: - note_primary - note_backup options: forget: keep-last: 7 修改配置文件权限(建议) $ chmod 600 ~/.autorestic.yml Location 与 Backend 在 Autorestic 中,Location 描述了备份的内容和目标(from / to),Backend 定义了备份的目标 配置检验 $ autorestic check Everything is fine. 手动执行备份 全部备份 $ autorestic backup -a 特定 Location 备份 $ autorestic backup -l notes 输出 目前这个方案很切合我的需求,且足够易用,后续使用一段时间有补充再更新 参考 Restic Docs Autorestic Quick

2025/4/3
articleCard.readMore

Backing Up Important Data with Restic

I had been using Cryptomator for quite a long time to manage my content. Its advantage is that local data also requires a password to access. After encryption, the content can be placed on sync drives like Nutstore, Dropbox, etc., and uploaded to the cloud in real-time after each file addition or modification. If you're storing content that doesn't need editing and you mainly add or replace resources, I would still highly recommend trying Cryptomator. It's well-suited for saving electronic scans, images, and spreadsheet documents. However, on macOS, if you use software like Obsidian to manage your knowledge base and frequently need to edit content, editing text files after decrypting with Cryptomator becomes quite unfriendly. To avoid issues, when editing plain text files, I had to copy them out, edit them, and then replace the original files, which was quite cumbersome. I happened to learn about Restic. If you want to encrypt and backup your Obsidian or other note-taking software vaults and upload them to cloud drives or S3-compatible platforms, then Restic is perfect for that. Quick Start Installing Restic macOS $ brew install restic It supports mainstream operating systems. For details, see: https://restic.readthedocs.io/en/latest/020_installation.html Initializing a Local Repository Simply put, a repository is a folder for storing encrypted files. The initialization process generates some metadata. $ restic init --repo ~/Documents/NoteE2EE enter password for new repository: enter password again: created restic repository 0a40262533 at /Users/xxxxx/Documents/NoteE2EE Please note that knowledge of your password is required to access the repository. Losing your password means that your data is irrecoverably lost. Remember your password. Once forgotten, there's no way to decrypt the data. Backing Up Data Execute the command to back up the ~/Note folder to the ~/Documents/NoteE2EE repository. $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note On the first run, you can see the number of files and directories backed up, and the "snapshot" ID is displayed. The command can be run multiple times (each run creates a snapshot). Since it creates incremental backups, you don't need to worry about snapshots taking up too much space. Use the following command to view the list of snapshots in the repository: $ restic -r ~/Documents/NoteE2EE snapshots This repository folder "NoteE2EE" contains encrypted content. Following the 3-2-1 principle, it can be safely stored on local hard drives, external drives, and cloud storage without worrying about data leaks. Optimizing Backup Command Parameters $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note \ --pack-size 32 \ --exclude="*.tmp" \ --iexclude="*.LOG" \ --limit-upload 1024 \ --json Good use of the --pack-size parameter can effectively control the number of target files, preventing large files from being split into too many small files. Historical files won't be affected; it only impacts the current incremental backup. Corresponding to --limit-upload, there's a --limit-download parameter for limiting download speed. If you're developing small tools based on Restic, the --json parameter is quite necessary: {"message_type":"status","percent_done":0,"total_files":29,"total_bytes":204482794} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0.2808864315308279,"total_files":41,"files_done":13,"total_bytes":333776710,"bytes_done":93753349} {"message_type":"status","percent_done":1,"total_files":41,"files_done":41,"total_bytes":333776710,"bytes_done":333776710} {"message_type":"summary","files_new":0,"files_changed":0,"files_unmodified":41,"dirs_new":0,"dirs_changed":2,"dirs_unmodified":8,"data_blobs":0,"tree_blobs":2,"data_added":756,"data_added_packed":608,"total_files_processed":41,"total_bytes_processed":333776710,"total_duration":5.552792709,"backup_start":"2025-04-02T18:30:04.949151+08:00","backup_end":"2025-04-02T18:30:10.501995+08:00","snapshot_id":"74e465ed71f7fb5b7abb562d4cb9d067f20d89a1a9f3ed4ed32a0bc73e8abab1"} Based on this data, you can create progress bars and display the volume of backed-up files. More Backend Storage Types Using the command restic init --repo ~/Documents/NoteE2EE, we created a local repository. Below is an example of an S3-compatible storage: # Setting environment variables $ export AWS_ACCESS_KEY_ID=id7O9M0H****tXJ2romrN $ export AWS_SECRET_ACCESS_KEY=bFA0dL0u********ndnxVcrwPh31u $ export AWS_DEFAULT_REGION=cn-east-1 # Initializing repository and backup $ restic -r s3:https://s3.bitiful.net/note-e2ee init $ restic -r s3:https://s3.bitiful.net/note-e2ee --verbose backup ~/Documents/NoteE2EE Note: If the AWS_DEFAULT_REGION environment variable is not set, us-east-1 will be used as the default value. For other platforms and storage options like Amazon S3, Backblaze B2, Google Cloud, etc., refer to the documentation: https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html Snapshot Management Keeping Recent Snapshots We might run backup on a schedule, but too many backups can be overwhelming. Run the following command to keep only the 7 most recent snapshots: $ restic -r [repository path] forget --keep-last 7 --prune Snapshot Tags You can specify multiple tags when backing up (--tag "v1.0" --tag "daily"): $ restic -r ~/Documents/NoteE2EE backup --tag "v1.0" ~/Documents/Note You can also modify tags for existing snapshots: # Add a tag $ restic -r ~/Documents/NoteE2EE tag --add "important" 8c5c9d50 # Remove a tag $ restic -r ~/Documents/NoteE2EE tag --remove "important" 43547193 # Set multiple tags $ restic -r ~/Documents/NoteE2EE tag --set "important,project2" a1ff1a78 It's worth noting that after each modification, the snapshot ID will change. Verifying Backup Integrity Backups stored in the cloud can't guarantee 100% file preservation or prevent damage. You can verify integrity using the check command: $ restic -r ~/Documents/NoteE2EE check Now let's simulate "damage". For example, find a file in the data directory within the NoteE2EE directory, modify its filename by adding an underscore at the beginning to simulate file damage/loss. Running the check command again shows that the repository is abnormal and needs repair, which is expected. Restoring Snapshots to a Specific Directory View existing snapshots: $ restic -r ~/Documents/NoteE2EE snapshots Export a specific snapshot: $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ 5fcd966f is the snapshot ID, which can be found using the snapshots command; additionally, the NoteRestore directory will be created automatically if it doesn't exist. If your repository is large and you only want to export certain files or directories, use the following command: $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ --include Epub电子书 In this case, "--include Epub电子书" refers to a folder in the root directory of the repository. Using "copy" Instead of "backup" If you only have one encrypted repository, using backup each time works fine. However, if you follow the 3-2-1 principle for backups and have multiple encrypted repositories, running backup multiple times to different repositories is inefficient. In this case, you need to use copy, which ensures strict data consistency and performs well: $ restic init --repo ~/Documents/NoteE2EE-copy $ restic -r ~/Documents/NoteE2EE-copy copy --from-repo ~/Documents/NoteE2EE From an AI summary (which I think is quite good): If you need to synchronize data between multiple Restic repositories, copy is always the preferred choice—it acts like a professional "data mover" between repositories, while backup is like a "collector" for original data. Give Restic Browser a Try If you're not a fan of command-line operations, you can try this open-source tool: emuell/restic-browser Developed with Rust + TypeScript, it's only a few megabytes in size and has read-only functionality. As the name "Browser" suggests, it allows you to browse encrypted repositories and makes it more convenient to look for files in snapshots. You Might Also Need Autorestic Documentation: Autorestic Quick Start Autorestic is a wrapper around the amazing restic. While being amazing the restic cli can be a bit overwhelming and difficult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂 Autorestic is a wrapper developed around the excellent restic tool. Although the restic command-line tool itself is outstanding, when you need to synchronize backup data from multiple different locations to multiple storage targets, its operation can seem complex and difficult to manage. This tool aims to simplify this process, making multi-target backup management easier and more convenient. Installation $ brew install autorestic Configuration File Create a configuration file with the following content: version: 2 backends: note_primary: type: local path: "~/Documents/NoteE2EE" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" note_backup: type: local path: "~/Documents/NoteE2EE-copy" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" locations: notes: from: "~/Documents/Note" to: - note_primary - note_backup options: forget: keep-last: 7 Change the configuration file permissions (recommended): $ chmod 600 ~/.autorestic.yml Locations and Backends In Autorestic, Location describes the backup content and target (from / to), and Backend defines the backup destination. Configuration Verification $ autorestic check Everything is fine. Manual Backup Execution Complete backup: $ autorestic backup -a Specific Location backup: $ autorestic backup -l notes Output: Currently, this solution perfectly fits my needs and is easy to use. I'll update this post with any additional information after using it for a while. References Restic Docs Autorestic Quick

2025/4/3
articleCard.readMore

Sauvegarder des données importantes avec Restic

J'ai utilisé Cryptomator pendant assez longtemps pour gérer mes contenus. Son avantage est que les données locales nécessitent également un mot de passe pour y accéder. Une fois chiffrées, ces données peuvent être synchronisées vers Nutstore, Dropbox ou d'autres services cloud, avec un téléversement en temps réel après chaque modification. Si vous stockez des fichiers qui ne nécessitent pas d'édition fréquente et que vous ajoutez ou remplacez principalement des ressources, je recommande vivement Cryptomator. Il est idéal pour sauvegarder des documents numérisés, des images et des tableaux. Cependant, sous macOS, si vous utilisez des logiciels comme Obsidian pour gérer votre base de connaissances et que vous modifiez fréquemment vos fichiers, l'édition de texte avec Cryptomator devient peu pratique. Pour éviter les problèmes, je devais copier le contenu, le modifier, puis remplacer le fichier original, ce qui était assez fastidieux. J'ai découvert par hasard Restic. Si vous souhaitez sauvegarder et chiffrer votre coffre-fort Obsidian pour l'envoyer vers un cloud ou une plateforme S3, Restic est parfaitement adapté. Quick Start Installation de Restic macOS $ brew install restic Compatible avec la plupart des systèmes d'exploitation, voir : https://restic.readthedocs.io/en/latest/020_installation.html Initialisation d'un dépôt local Un dépôt est simplement un dossier qui stocke vos fichiers chiffrés. L'initialisation crée des métadonnées nécessaires. $ restic init --repo ~/Documents/NoteE2EE enter password for new repository: enter password again: created restic repository 0a40262533 at /Users/xxxxx/Documents/NoteE2EE Please note that knowledge of your password is required to access the repository. Losing your password means that your data is irrecoverably lost. N'oubliez pas votre mot de passe, car les données chiffrées seront irrécupérables en cas d'oubli. Sauvegarde des données Exécutez cette commande pour sauvegarder le dossier ~/Note dans le dépôt ~/Documents/NoteE2EE $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note À la première exécution, vous verrez le nombre de fichiers et répertoires sauvegardés, ainsi que l'ID du "snapshot" (instantané) créé. La commande peut être exécutée plusieurs fois (chaque exécution crée un nouveau snapshot) Comme les sauvegardes sont incrémentielles, ne vous inquiétez pas de l'espace disque. Utilisez cette commande pour afficher la liste des snapshots dans le dépôt: $ restic -r ~/Documents/NoteE2EE snapshots Le dossier "NoteE2EE" contient vos données chiffrées. Vous pouvez le stocker en toute sécurité sur votre disque dur local, un disque externe ou un cloud, selon le principe 3-2-1, sans craindre de fuites de données. Optimisation des paramètres de sauvegarde $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note \ --pack-size 32 \ --exclude="*.tmp" \ --iexclude="*.LOG" \ --limit-upload 1024 \ --json L'option --pack-size permet de contrôler efficacement le nombre de fichiers cibles, évitant que les gros fichiers ne soient divisés en trop de petits fichiers. Les fichiers historiques ne sont pas affectés, seule la sauvegarde incrémentielle actuelle l'est. En complément de --limit-upload, il existe un paramètre --limit-download pour limiter la vitesse de téléchargement. Si vous développez des outils basés sur Restic, le paramètre --json est très utile: {"message_type":"status","percent_done":0,"total_files":29,"total_bytes":204482794} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0.2808864315308279,"total_files":41,"files_done":13,"total_bytes":333776710,"bytes_done":93753349} {"message_type":"status","percent_done":1,"total_files":41,"files_done":41,"total_bytes":333776710,"bytes_done":333776710} {"message_type":"summary","files_new":0,"files_changed":0,"files_unmodified":41,"dirs_new":0,"dirs_changed":2,"dirs_unmodified":8,"data_blobs":0,"tree_blobs":2,"data_added":756,"data_added_packed":608,"total_files_processed":41,"total_bytes_processed":333776710,"total_duration":5.552792709,"backup_start":"2025-04-02T18:30:04.949151+08:00","backup_end":"2025-04-02T18:30:10.501995+08:00","snapshot_id":"74e465ed71f7fb5b7abb562d4cb9d067f20d89a1a9f3ed4ed32a0bc73e8abab1"} Ces données permettent de créer des barres de progression et d'afficher le volume des fichiers sauvegardés. Plus de types de stockage (Backends) La commande restic init --repo ~/Documents/NoteE2EE a créé un dépôt local. Voici un exemple pour un stockage compatible S3: # Configuration des variables d'environnement $ export AWS_ACCESS_KEY_ID=id7O9M0H****tXJ2romrN $ export AWS_SECRET_ACCESS_KEY=bFA0dL0u********ndnxVcrwPh31u $ export AWS_DEFAULT_REGION=cn-east-1 # Initialisation du dépôt et sauvegarde $ restic -r s3:https://s3.bitiful.net/note-e2ee init $ restic -r s3:https://s3.bitiful.net/note-e2ee --verbose backup ~/Documents/NoteE2EE Note: Si la variable AWS_DEFAULT_REGION n'est pas définie, us-east-1 sera utilisé par défaut. Pour d'autres plateformes comme Amazon S3, Backblaze B2, Google Cloud, consultez: https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html Gestion des snapshots Conservation des snapshots récents Si vous exécutez backup régulièrement, vous pouvez avoir trop de sauvegardes. Cette commande permet de ne conserver que les 7 derniers snapshots: $ restic -r [chemin_du_dépôt] forget --keep-last 7 --prune Étiquettes de snapshot Vous pouvez spécifier plusieurs étiquettes lors de la sauvegarde (--tag "v1.0" --tag "daily"): $ restic -r ~/Documents/NoteE2EE backup --tag "v1.0" ~/Documents/Note Vous pouvez également modifier les étiquettes des snapshots existants: # Ajouter une étiquette $ restic -r ~/Documents/NoteE2EE tag --add "important" 8c5c9d50 # Supprimer une étiquette $ restic -r ~/Documents/NoteE2EE tag --remove "important" 43547193 # Définir plusieurs étiquettes $ restic -r ~/Documents/NoteE2EE tag --set "important,project2" a1ff1a78 Notez que l'ID du snapshot change après chaque modification. Vérification de l'intégrité des sauvegardes Les sauvegardes stockées sur le cloud ne sont pas garanties à 100% contre la perte ou la corruption. La commande check permet de vérifier l'intégrité: $ restic -r ~/Documents/NoteE2EE check Simulons maintenant une "corruption" en modifiant le nom d'un fichier dans le répertoire data de NoteE2EE, en ajoutant un underscore au début pour simuler une perte ou corruption: En exécutant à nouveau la commande check, un message indique que le dépôt est anormal et nécessite une réparation, comme prévu: Restauration d'un snapshot dans un répertoire spécifique Visualisation des snapshots existants: $ restic -r ~/Documents/NoteE2EE snapshots Exportation d'un snapshot spécifique: $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ 5fcd966f est l'ID du snapshot, que vous pouvez trouver avec la commande snapshots. Le répertoire NoteRestore sera créé automatiquement s'il n'existe pas. Si votre dépôt est volumineux et que vous souhaitez restaurer uniquement certains fichiers ou dossiers: $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ --include Epub电子书 "Epub电子书" est un dossier situé à la racine du dépôt. Utiliser copy plutôt que backup Si vous n'avez qu'un seul dépôt chiffré, utilisez simplement backup à chaque fois. Mais si vous suivez le principe 3-2-1 avec plusieurs dépôts chiffrés, il n'est pas efficace d'exécuter backup plusieurs fois vers différents dépôts. Dans ce cas, utilisez copy, qui garantit une cohérence stricte des données avec d'excellentes performances: $ restic init --repo ~/Documents/NoteE2EE-copy $ restic -r ~/Documents/NoteE2EE-copy copy --from-repo ~/Documents/NoteE2EE Résumé de l'IA (que je trouve pertinent): Si vous devez synchroniser des données entre plusieurs dépôts Restic, copy est toujours le meilleur choix — il fonctionne comme un "transporteur de données" professionnel entre dépôts, tandis que backup est un "collecteur" orienté vers les données brutes. Essayez Restic Browser Si vous n'aimez pas les opérations en ligne de commande, essayez cet outil open source: emuell/restic-browser Développé avec Rust + TypeScript, il ne pèse que quelques mégaoctets et offre uniquement des fonctions de lecture. Comme son nom "Browser" l'indique, il permet de parcourir les dépôts chiffrés et facilite la recherche de fichiers dans les snapshots. Vous pourriez aussi avoir besoin d'Autorestic Documentation: Autorestic Quick Start Autorestic est un wrapper autour de l'étonnant restic. Bien qu'étant remarquable, l'interface en ligne de commande de restic peut être un peu intimidante et difficile à gérer si vous avez plusieurs emplacements différents que vous souhaitez sauvegarder vers plusieurs destinations. Cet utilitaire vise à faciliter ce processus 🙂 Autorestic est un wrapper développé autour de l'excellent outil restic. Bien que l'outil en ligne de commande restic soit exceptionnel, il peut devenir complexe et difficile à gérer lorsque vous devez synchroniser des sauvegardes de plusieurs emplacements vers différentes destinations. Cet utilitaire vise à simplifier ce processus, rendant la gestion des sauvegardes multi-cibles plus facile et plus agréable. Installation $ brew install autorestic Fichier de configuration Créez un fichier de configuration avec le contenu suivant: version: 2 backends: note_primary: type: local path: "~/Documents/NoteE2EE" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" note_backup: type: local path: "~/Documents/NoteE2EE-copy" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" locations: notes: from: "~/Documents/Note" to: - note_primary - note_backup options: forget: keep-last: 7 Modifiez les permissions du fichier de configuration (recommandé): $ chmod 600 ~/.autorestic.yml Location et Backend Dans Autorestic, Location décrit le contenu et la destination de la sauvegarde (from / to), tandis que Backend définit la destination de sauvegarde. Vérification de la configuration $ autorestic check Everything is fine. Exécution manuelle de la sauvegarde Sauvegarde complète: $ autorestic backup -a Sauvegarde d'une Location spécifique: $ autorestic backup -l notes Sortie: Cette solution répond parfaitement à mes besoins actuels et est suffisamment facile à utiliser. Je mettrai à jour cet article après l'avoir utilisée pendant un certain temps si j'ai des compléments à ajouter. Références Restic Docs Autorestic Quick

2025/4/3
articleCard.readMore

Resticで重要なデータをバックアップする

以前はかなり長い間、Cryptomatorを使ってコンテンツを管理していました。Cryptomatorの利点は、ローカルデータにアクセスするにもパスワードが必要なことです。暗号化されたコンテンツはNutstore、Dropboxなどの同期ドライブに置いておけば、ファイルを追加または変更するたびにリアルタイムでクラウドにアップロードできます。 保存するコンテンツが編集する必要のない増分ファイルで、主に追加・置換するだけなら、私はCryptomatorを試すことを強くお勧めします。電子スキャン、画像、スプレッドシートなどのリソースを保存するのに適しています。 しかし、macOSでObsidianなどのソフトウェアを使って知識ベースを管理し、頻繁に編集する必要がある場合、Cryptomatorで復号化した後のテキストファイルの編集は非常に不便です。問題を避けるために、プレーンテキストファイルを編集するときは、コピーして編集した後、元のファイルを置き換える必要がありました。これはかなり面倒です。 偶然、Resticについて知りました。ObsidianなどのノートソフトウェアのVaultを暗号化してバックアップし、クラウドドライブやS3などのクラウドプラットフォームにアップロードしたい場合、Resticが最適です。 クイックスタート Resticのインストール macOS $ brew install restic 主要なオペレーティングシステムをサポートしています。詳細は:https://restic.readthedocs.io/en/latest/020_installation.html ローカルリポジトリの初期化 リポジトリは簡単に言えば、暗号化されたファイルを保存するためのフォルダです。初期化プロセスでは、いくつかのメタデータが生成されます。 $ restic init --repo ~/Documents/NoteE2EE enter password for new repository: enter password again: created restic repository 0a40262533 at /Users/xxxxx/Documents/NoteE2EE Please note that knowledge of your password is required to access the repository. Losing your password means that your data is irrecoverably lost. パスワードを覚えておいてください。一度忘れると、暗号化されたデータを復号化する方法はなくなります。 データのバックアップ コマンドを実行して、~/Noteフォルダを~/Documents/NoteE2EEリポジトリにバックアップします。 $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note 初回実行時には、バックアップされたファイルとディレクトリの数が表示され、「スナップショット」IDがエコーされます。 コマンドは複数回実行できます(実行するたびに新しいスナップショットが作成されます)。 増分バックアップなので、スナップショットが大量のスペースを占めることを心配する必要はありません。 以下のコマンドを使用して、リポジトリ内のスナップショットリストを表示できます。 $ restic -r ~/Documents/NoteE2EE snapshots このリポジトリフォルダ「NoteE2EE」は暗号化されたコンテンツです。3-2-1原則に従って、ローカルディスク、外部ディスク、クラウドドライブに安全に保存でき、データ漏洩を心配する必要はありません。 バックアップコマンドパラメータの最適化 $ restic -r ~/Documents/NoteE2EE backup ~/Documents/Note \ --pack-size 32 \ --exclude="*.tmp" \ --iexclude="*.LOG" \ --limit-upload 1024 \ --json --pack-sizeパラメータを上手く使用すると、ターゲットファイルの数を効果的に制御でき、大きなファイルが多くの小さなファイルに分割されるのを防ぎます。履歴ファイルは影響を受けず、現在の増分バックアップにのみ影響します。 --limit-uploadに対応して、ダウンロード速度を制限するための--limit-downloadパラメータもあります。 Resticをベースに小さなツールを開発する場合は、--jsonパラメータが非常に重要です。 {"message_type":"status","percent_done":0,"total_files":29,"total_bytes":204482794} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0,"total_files":41,"total_bytes":333776710} {"message_type":"status","percent_done":0.2808864315308279,"total_files":41,"files_done":13,"total_bytes":333776710,"bytes_done":93753349} {"message_type":"status","percent_done":1,"total_files":41,"files_done":41,"total_bytes":333776710,"bytes_done":333776710} {"message_type":"summary","files_new":0,"files_changed":0,"files_unmodified":41,"dirs_new":0,"dirs_changed":2,"dirs_unmodified":8,"data_blobs":0,"tree_blobs":2,"data_added":756,"data_added_packed":608,"total_files_processed":41,"total_bytes_processed":333776710,"total_duration":5.552792709,"backup_start":"2025-04-02T18:30:04.949151+08:00","backup_end":"2025-04-02T18:30:10.501995+08:00","snapshot_id":"74e465ed71f7fb5b7abb562d4cb9d067f20d89a1a9f3ed4ed32a0bc73e8abab1"} このデータに基づいて、進捗バーを作成したり、バックアップファイルのデータ量を表示したりできます。 その他のバックエンドストレージタイプ restic init --repo ~/Documents/NoteE2EEコマンドを使用して、ローカルリポジトリを作成しました。 以下はS3互換ストレージの例です。 # 環境変数の設定 $ export AWS_ACCESS_KEY_ID=id7O9M0H****tXJ2romrN $ export AWS_SECRET_ACCESS_KEY=bFA0dL0u********ndnxVcrwPh31u $ export AWS_DEFAULT_REGION=cn-east-1 # リポジトリの初期化とバックアップ $ restic -r s3:https://s3.bitiful.net/note-e2ee init $ restic -r s3:https://s3.bitiful.net/note-e2ee --verbose backup ~/Documents/NoteE2EE 注意:AWS_DEFAULT_REGION環境変数が設定されていない場合、デフォルトとしてus-east-1が使用されます。 Amazon S3、Backblaze B2、Google Cloudなどの他のプラットフォームとストレージについては、以下のドキュメントを参照してください:https://restic.readthedocs.io/en/latest/030_preparing_a_new_repo.html スナップショット管理 最近のスナップショットを保持する 定期的にbackupを実行してバックアップすることがありますが、バックアップが多すぎると煩わしくなります。以下のコマンドを実行すると、最新の7つのスナップショットのみを保持できます。 $ restic -r [リポジトリパス] forget --keep-last 7 --prune スナップショットのタグ バックアップ時に複数のタグを指定できます(--tag "v1.0" --tag "daily")。 $ restic -r ~/Documents/NoteE2EE backup --tag "v1.0" ~/Documents/Note 既存のスナップショットのタグを変更することもできます。 # タグを追加 $ restic -r ~/Documents/NoteE2EE tag --add "important" 8c5c9d50 # タグを削除 $ restic -r ~/Documents/NoteE2EE tag --remove "important" 43547193 # 複数のタグを設定 $ restic -r ~/Documents/NoteE2EE tag --set "important,project2" a1ff1a78 注目すべきは、変更するたびにスナップショットIDが変わることです。 バックアップの整合性チェック クラウドに保存されたバックアップは、ファイルが100%失われないことや損傷しないことを保証できません。checkコマンドを使用して検証できます。 $ restic -r ~/Documents/NoteE2EE check ここで「損傷」をシミュレートしてみましょう。例えば、NoteE2EEディレクトリ内のdataディレクトリにあるファイルを見つけ、ファイル名を変更して、先頭にアンダースコアを追加し、ファイルの損傷/損失をシミュレートします。 checkコマンドを再度実行すると、リポジトリが異常であり、修復が必要であることが表示されます。これは予想通りです。 スナップショットを特定のディレクトリに復元する 既存のスナップショットを表示: $ restic -r ~/Documents/NoteE2EE snapshots 特定のスナップショットをエクスポート: $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ 5fcd966fはスナップショットIDで、snapshotsコマンドで確認できます。また、NoteRestoreディレクトリが存在しない場合は自動的に作成されます。 リポジトリが大きく、特定のファイルやディレクトリのみをエクスポートしたい場合は、次のコマンドを使用します。 $ restic -r ~/Documents/NoteE2EE restore 5fcd966f --target ~/Downloads/NoteRestore/ --include Epub电子书 ここでの「--include Epub电子书」は、リポジトリのルートディレクトリにあるフォルダを指します。 「copy」を使用して「backup」の代わりに 暗号化されたリポジトリが1つしかない場合は、毎回backupを使用すれば問題ありません。しかし、3-2-1原則に従ってバックアップし、複数の暗号化リポジトリがある場合、異なるリポジトリに対して複数回backupを実行するのは効率的ではありません。 この場合、copyを使用する必要があります。これにより、データの厳密な一貫性が保証され、パフォーマンスも優れています。 $ restic init --repo ~/Documents/NoteE2EE-copy $ restic -r ~/Documents/NoteE2EE-copy copy --from-repo ~/Documents/NoteE2EE AIからの要約(私は良いと思います): 複数のResticリポジトリ間でデータを同期する必要がある場合、copyは常に最適な選択です—それはリポジトリ間のプロフェッショナルな「データ移動者」のように機能し、backupは元のデータの「コレクター」のようなものです。 Restic Browserを試してみる コマンドライン操作が好きでない場合は、このオープンソースツールを試してみることができます:emuell/restic-browser Rust + TypeScriptで開発され、サイズは数メガバイトしかなく、読み取り専用機能しかありません。「Browser」という名前が示すように、暗号化されたリポジトリを閲覧でき、スナップショット内のファイルを探すのに便利です。 Autoresticも必要かもしれません ドキュメント:Autorestic クイックスタート Autorestic is a wrapper around the amazing restic. While being amazing the restic cli can be a bit overwhelming and difficult to manage if you have many different location that you want to backup to multiple locations. This utility is aimed at making this easier 🙂 Autoresticは、優れたresticツールを基に開発されたラッパーです。resticコマンドラインツール自体は素晴らしいですが、複数の異なる場所からのバックアップデータを複数のストレージターゲットに同期する必要がある場合、その操作は複雑で管理が難しくなる可能性があります。このツールは、このプロセスを簡素化し、マルチターゲットバックアップ管理をより簡単で便利にすることを目的としています。 インストール $ brew install autorestic 設定ファイル 以下の内容で設定ファイルを作成します: version: 2 backends: note_primary: type: local path: "~/Documents/NoteE2EE" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" note_backup: type: local path: "~/Documents/NoteE2EE-copy" env: RESTIC_PASSWORD: "your-restic-vault-passowrd" locations: notes: from: "~/Documents/Note" to: - note_primary - note_backup options: forget: keep-last: 7 設定ファイルのパーミッションを変更(推奨): $ chmod 600 ~/.autorestic.yml LocationとBackend Autoresticでは、Locationはバックアップのコンテンツとターゲット(from / to)を記述し、Backendはバックアップの宛先を定義します。 設定の検証 $ autorestic check Everything is fine. 手動バックアップの実行 完全バックアップ: $ autorestic backup -a 特定のLocationバックアップ: $ autorestic backup -l notes 出力: 現在、このソリューションは私のニーズに完全に合っており、使いやすいです。しばらく使用した後、追加情報があれば更新します。 参考 Restic Docs Autorestic Quick

2025/4/3
articleCard.readMore

Cursor 开发 Obsidian 插件记录

最近想到整理笔记、发布博客的自动化程序还不够(需要手动替换 Markdown 格式文章中的图像地址为图床地址),另外不同的图像,显示时需要手动调整下 Width 宽度,这就很枯燥 使用了 Cursor 一段时间,Tab Tab Tab...,突然想到,可以让 AI 协助写一个 Obsidian 插件,编写文章使用 Obsidian,导出即为博客所需的文章格式,开搞!以下是使用 Cursor 编写插件的记录,同时使用到了 DeepSeek 环境准备 确认 Node 环境 Step.1 下载示例插件 从示例开始 https://docs.obsidian.md/Plugins/Getting+started/Build+a+plugin 新建一个空白的仓库(Vault) 到仓库的插件目录并 Clone 示例插件 $ cd ~/Documents/donx-notes/ $ mkdir .obsidian/plugins $ cd .obsidian/plugins Step.2 构建插件 $ cd obsidian-sample-plugin $ npm install $ npm run dev Step.3 启用插件 In Obsidian, open Settings. In the side menu, select Community plugins. Select Turn on community plugins. Under Installed plugins, enable the Sample Plugin by selecting the toggle button next to it. 开启插件 使用 Cursor 打开插件目录 建议:先在 Github 等代码托管仓库创建一个仓库,例如我的仓库名叫 obsidian-article-exportor ,本地路径是 ~/Documents/donx-notes/.obsidian/plugins/obsidian-article-exportor/,我将新的仓库的 .git 目录替换掉 Demo 的,然后将 Demo 的目录名和 manifest.json 文件修改,编写代码随时提交,后续 AI 生成如果不满意可以随时撤销代码 特别留意的是,修改后重载插件使其生效的方式是 Commond + P,搜索并点击 “重新加载...” Step.4 那么,接下来交给 AI 描述:请帮助我修改 obsidian 的 Plugin 插件,它的功能是导出当前文章(笔记)和这个文章内置的图像,存储到指定目录,点击插件的按钮,自动导出,默认导出到用户的 Downloads 目录, 另外导出到目录可以通过配置来修改,或者是点击导出的时候选择目录,都是可以的,由你选择,暂时提供导出文章的代码即可,图像后续再处理 重载插件后,点击插件图标弹出提示,并没有真实的导出文章到用户 Downloads 目录 描述:请不要再保留默认导出到 Downloads 目录的逻辑,如果用户未设置,抛出提示:“请先设置插件的导出目录” 看了下代码,发现它没有修改点击插件图标的事件,而是添加了一个命令去做,键盘 Command + P 调出,搜索执行命令 “Export Current Note” 正确提示了 “请先设置插件的导出目录” 设置目录 再次执行导出笔记,在下载目录已经可以看到 MD 文件 修改代码可用性还是很高的,继续优化,增加功能 先写一个示例笔记,然后描述需求如下 请完善其功能,需要在导出文章的同时导出图片,需要做转换,图像需要根据其在文章内的位置重命名为 01.jpg, 02.jpg, 03.jpg 这样,另外导出的图像要放在一个以时间戳命名的目录中,例如 1740276438,然后相对的路径就变为了 1740276438/01.jpg,替换掉导出文章内的图像地址 存在问题,未正确替换文本内容,也没有复制/重命名图像 代码有一些问题,因为是 Obsidian 的特有的 “[[” 写法,”Pasted image 20250223095948.png“ 并没有被替换掉,同时正确生成了时间戳命名的目录并创建,但是没有将图像复制到目标目录下 然后 Cursor 提供了正确的代码,插件能够正确工作 继续提出一些优化需求 我们知道,在 Obsidian 的图片链接中,会有 “|420” 这样的写法,表示图片设置为 420px 的宽度,这样有助于显示更好的效果,但是在正常的 Markdown 语法中,不支持这种写法,一个折中的方式是使用 HTML image 标签,例如:< img src="..." width = "78%" / > 这样,我希望进行判断,如果 Obsidian 的链接没有 "|xxx" 这样的描述,你获得图像沿用现在的转换逻辑,如果包含 "|xxx" 这样的语法,那么将导出后的链接修改为 < img ...> 这样的链接,width 需要根据实际图像的宽度和 |xxx 这样的数值动态计算出来,例如 "|420",实际宽度是 1000px,那么目标是 < img ... width = "42%" > 请提供修改后的代码 Cursor 修改了代码,再次运行,生成的文件内容如下 Icons ![[1740317458/01.jpg]] 缩小显示尺寸的图像示例 ![[Pasted image 20250223095948.png|360]] 我刚发现导出的文件内的链接地址不对,让 Cursor 再修改下 导出的文件内容不对,首先,需要将 “[[” 这样的链接修改为 ![01](1740317458/01.jpg) 这样的标准 Markdown 图像链接语法,注意我举例中的 \ 符号,是转意用的,另外还有一处错误,|360 这样的描述,需要转换为 < img ... > 这种链接,请修改代码 无法正确输出... 看起来简单的描述它没办法理解我的意思 这个是 Obsidian 的笔记内容 Icons ![[Pasted image 20250223095948.png]] 缩小显示尺寸的图像示例 ![[Pasted image 20250223095948.png|360]] 我希望导出后的内容是以下的格式 Icons ![01.jpg](1740318018/01.jpg) 缩小显示尺寸的图像示例 <img src="1740318018/02.jpg" width = "78%" /> 如果你理解了我的意思,请重新生成代码 我感觉 Cursor 快冒烟了,输出一直是 Icons ![[Pasted image 20250223095948.png]] 缩小显示尺寸的图像示例 !![01.jpg](1740318460/01.jpg) 没办法,懒得改哪怕一行代码,我把 main.ts 粘贴给了 DeepSeek,给它的内容如下 以下是 obsidian 的插件代码,ts 格式的 省略代码... 它的作用是导出笔记和图像,笔记的图像地址按照一定规则转换,规则描述如下 这个是 Obsidian 的笔记内容 Icons ![[Pasted image 20250223095948.png]] 缩小显示尺寸的图像示例 ![[Pasted image 20250223095948.png|360]] 我希望导出后的内容是以下的格式 Icons ![01.jpg](1740318018/01.jpg) 缩小显示尺寸的图像示例 <img src="1740318018/02.jpg" width = "78%" /> 注意这里的 width 比例是根据图像和 “|360” 比例计算出来的,现在的 ts 代码不正确请提供修复后的代码 然后把返回结果粘贴回来 重新运行,可以了!生成的代码符合预期 继续完善,因为最终的文章链接是 URL,需要增加配置 请帮助我修改代码,我希望添加一个设置,名为 host,如果配置了 host,那么生成图像的链接不是现在的 1740390067/02.jpg,而是 http://xxx.com/1740390067/02.jpg 这样的地址 在老的 Chat 聊天感觉它已经神经错乱了 🤪 让 DeepSeek 继续调整了代码,它正确新增了配置项 同时文本内容正确处理(图像未显示符合预期,因为我还没上传到存储) 新建一个 Cursor Chat 聊天窗口,给它个简单的任务找回信心 为代码增加注释 至此,我才开始阅读代码,在插件开发完成之后 一些收获 除了最开始的环境准备参考的官方说明,后续编码全都由 AI 编写,没手写一行功能代码,对程序员来说,AI 辅助编程在开发者进入新领域、使用新语言时,能提供极大的助力 例如我不会 Typescript、也没去了解 Obsidian 的开发文档,但不影响我开发一个满足需求的插件,这真的很棒 另外,本次使用 Cursor 程度依然较低,还有不少 Cursor 的技巧没有去学习和使用,本篇先到这里,本篇文章已借助 Obsidian 插件导出后发布: 枯燥-- 效率++ 2025-02-25 补充 P.S. 昨天脑袋卡了,Cursor 已经内置了 DeepSeek V3 模型,我还复制到 DeepSeek 网页 Chat 做什么呢...? 另外 Cursor 已可使用 claude-3.7-sonnet 和 claude-3.7-connet-thinking,值的一试 昨天一直用的是默认的 gpt-4o (无奈脸

2025/2/24
articleCard.readMore

非京籍个体户缴纳社保(十):新增并缴纳个人所得税-工资薪金

申请工资薪金缴纳个税其中之一的用途是车牌摇号,目前线上不能开通,需到线下办理,如果你没有明确的需求,则无需办理 线上申请(此路不通) 当前无法线上申请,记录一下,万一后续系统又支持了呢 点击 “补充税(费)种” 选择 “个人所得税”,然后选择 “税目”,如果可用,应该会出现 “工资薪金” 如上图,没有「个人所得税」选项,即此路不通,需要到线下办理 添加办税人 自己办理就添加自己,代办人办理则授权代办人 办税人注册 首先,办税人需要先注册,推荐下载使用「电子税务局 APP」注册 重要 Tips:人脸识别时候手机从下往上照,向着鼻孔,容易成功 如果 APP 一直不成功,可以用 Web 版电子税务局注册试试 点击右上角头像 点击弹出的 “账户中心”,跳转后点击 “添加办税人员” 添加办税人后,选择个人所得税 APP 扫码认证 (个体户法人、企业所有者扫) 扫码后,添加成功! 退出登录,再从网页上以「办税人身份」登录,同样来到 “账户中心” 到 “企业授权管理” - “待确认授权” - 点击 “确认” 线下办理需携带的资料 携带以下资料,到个体户所属的税务所,不知道在哪里可以打税务局的电话咨询下 办税人身份证 营业执照正副本及复印件(A4黑白复印件即可) 公章 劳动合同(北京有些区需要工资流水,昌平区目前不需要) 线下办理很快,不超过半小时 办理后线上即时可查 在 “北京电子税务局” - “我要查询” - “一户式查询” - ”纳税人信息查询“ - “税费种认定信息”,已经可以看到税种/税目 重要提醒:开通「个人所得税 - 工资薪金所得」后,每月都需要申报缴纳,否则会有罚款! 个税缴纳步骤 以法人身份登录到 “自然人电子税务局(扣缴端)”,点击 “代扣代缴” 点击 “正常工资薪金所得” 后边的录入 提示如图 您本月[2024年12月]还没有生成过正常工资薪金明细数据,需要使用自动导入正常工资薪金数据向导吗? 选择 “生成零工资记录,用户手工修改”,并点击 “立即生成数据” 填入工资(应发),然后点击上图中左上角的返回 点击「2 税款计算」,新开公司会有个提示,确定即可 可以看到软件自动计算出了应扣除等个税数据,北京征税起点是 5000 元,扣除后 1000,税率 3%,应缴纳 30 元 依次点击「3 附表填写」,没有减免事项则点击「4 申报表报送」 点击 “发送申报” 提示如下 申报反馈信息:申报失败,扣缴单位无有效的税费种认定信息 我遇到的这个问题的原因是我在 2 月初操作个税缴纳,软件上显示的都是 12 月份的,2 月可缴纳一月份的个税,所以申报失败 不清楚为什么进来显示的是 12 月份申报,解决也很简单,关闭软件,重新进入,就显示为 1 月份的申报表了,如下图 重新开始,点击 “确定”,进入光荣的纳税缴税环节 提示新版本纳税功能,当然是 “立即体验” 看一眼提示,勾选后点击 “立即缴税” 选择 “银联在线支付”,点击立即支付 填写「银联」的银行卡号,输入姓名、身份证,获取手机验证码,点击支付即可付款 再次来到综合所得申报,「4 申报表报送」,可以看到申报状态已变为:“申报成功,缴款成功“ 每月缴纳上个月的工资薪金个人所得税 ✅

2025/2/24
articleCard.readMore

非京籍个体户缴纳社保(九):公积金开户增员与缴费

公积金非强制缴纳,如果无需公积金,本文可以跳过,当前时间点,每月最少缴纳 121 元,不像养老和医保,公司缴纳的部分进统筹,公积金公司和个人缴纳后都到自己的个人账户 网站地址:https://gjj.beijing.gov.cn/ 《住房公积金单位登记开户操作详解》 提供了三种开户方式 北京 e 窗通(适合新增企业) 住房公积金网站 柜台(线下) 如果在 e 窗通平台新增企业时未选择公积金,后续想要开户,可以参照本文,即方式2 选择住房公积金网上业务系统: 登录后,左侧只有登记开户 点击 “登记开户” - “单位登记开户” 此时页面上没有信息,点击右上角的 “单位信息核验”,然后点击确认,会自动加载可供修改的单位信息。 填写无误后点击“下一页” 资金来源选择 “单位自筹”,缴存比例选择 5% 经办人至少填写一位 委托收款信息是选填,如果你办理了对公账户,那么就填写上,没有则留空点击提交即可 没有对公账户到时候使用个人银行卡直接转账也是可以的,后文缴费时记录如何操作 提交后显示登记完成 没有法人一证通,点击 “不,谢谢” 此时回到首页,左上角还是只有一个 “登记开户”,问题不大,明天再看看 ⌛️ 第二天查看,可以了!(如果你遇到问题,打客服电话 12329 咨询,按 0 进入语音助手,跟它说“转人工”) 公积金增员 点击 “办理 2024年12月增员” 继续点击 “单笔增员” 增加职工信息录入 2024住房公积金年度继续执行5%至12%的缴存比例政策。 2024住房公积金年度住房公积金月缴存基数上限为35283元,具体缴存比例对应月缴存额上限见附件;月缴存基数下限为2420元,领取基本生活费职工的月缴存基数下限为1694元,新受理的住房公积金个人住房贷款,计算借款申请人贷款金额所使用的月基本生活费标准按1694元执行。 —— 《关于2024住房公积金年度缴存有关问题的通知》 根据公积金规定,月缴存基数下限为2420 元,填写上,这样公司缴纳 121,个人无缴纳,如下图 点击 “确定” 保存 已保存的增员名单可以看到新增信息,确认无误后点击 “提交增员名单”,弹出提示 “请您认真核对信息,确保姓名、证件号码、月工资收入准确无误。”,继续点击 “确认提交” 此时在页面的「本月已成功增员名单」可以看到增员的信息 汇缴 “公积金” - “汇补缴”,点击 “增减员” 进入页面可以看到 “单位缴存信息” 继续往下滚动页面,找到 “缴存” 模块 注意提示:如职工缴存基数有误,可通过首页的【个人缴存基数修改】功能调整职工缴存基数(线上渠道住房公积金年度内仅支持修改一次)。 确认无误点击 “确认申报 2024 年 12 月缴存人员名单” 申报成功 返回后,可以看到 “2018年以来单位缴存明细” 模块中 12 月缴款状态为 “未缴款” 点击 “缴存” 模块中的 “缴款” 按钮,跳转到个体户选择页面 勾选后点击 “下一步”,提示 “此次汇缴清册共1张,您是否确定提交?”,选择 “是” 弹出 “详细信息:贵单位未选择委托收款缴款,建议使用委托收款方式缴款。” 这里只是个提示,先不用管,委托收款需要对公账户,在银行开具对公账户每年要交几百款,可以直接转账缴款(如果你有对公账户,则选择委托收款的方式) 选择中心开户银行,应该是没区别,可以根据自己手里的银行卡选择同样的。 点击提交 点击 “打印缴款通知单” 此时汇补缴页面 12 月状态未 “缴款中” 拿出手机,通过网上银行转账到住房公积金的账号,在刚刚的 “打印缴款通知单” 中可以看到 这是收款公共账号,不过不用担心,公积金中心自有办法知道你是为哪个单位缴纳的 1 Hour later,再次查看缴款状态已变为 “已缴款” 缴款后一两个小时后查看,员工公积金已到账。 后续每月缴款重复 “确认申报XX年YY月缴存人员名单”,然后 “缴款” ✅

2025/1/27
articleCard.readMore

非京籍个体户缴纳社保(八):税务申报与工商年报

不正常申报纳税,轻则收到罚款通知、重则经营异常... 需重视 那如果我没有卖出去东西,还需要报税吗?需要,即 “零申报”,本篇记录北京个体户需要申报的税种和操作流程,Let's Go! 先弄清楚要报什么税 网站:https://etax.beijing.chinatax.gov.cn:8443/ 使用 “电子营业执照” 扫码进行登录,在 “我要查询” 模块,选择 “一户式查询” - “纳税人信息查询” 选择 “税费种认定信息” 可以看到 6 个征收项目 也就是对应着以下几个申报项目 1)增值税及附加税费申报(小规模纳税人)- 每年的 1月 / 4月 / 7月 / 10 月申报,15日前 2)个人所得税(经营所得)- 每年的 1月 / 4月 / 7月 / 10 月申报(A 表) 3)个人所得税(经营所得)年度申报 - 每年的 3月31日前(B 表) 4)工商年报(工商年检)- 每年6月30日前 特别注意:因为当前为 1月份初,本文小标题中 “个人所得税 - 经营所得(xx)” 同时包含了 2)和 3)A 表和 B 表的申报,读者需要自己确定当前时间点是否需要申报 B 表,B 表每年只需申报一次 增值税及附加税费申报(小规模纳税人) 增值税及附加税费申报申报的时间为季度申报,每年 1、4、7、10月份的 1-15 日在 “北京电子税务局” 申报 当前是 2025 年一月份初,申报 2024 年 10 ~ 12 月的税费 登录到 “北京市电子税务局” 网站:https://etax.beijing.chinatax.gov.cn:8443/ 在「我的待办」看到有一个 “增值税及附加税费申报(小规模纳税人)” 事项 如果没看到,可以在「我要办税」中选择 “税费申报及缴纳” - “增值税及附加税费申报(小规模纳税人)” 进入后,提示 2023 年到 2027 年有税费优惠,不用管,点击确定 进入后页面如下 其中可以看到提示这是四季度(2024-10-01 到 2024-12-31)的申报,如果有实际销售额,点击页面右上角的 “我要填表”,你可能需要找更加细致的申报教程跟着操作,本文记录的是未销售出商品的 “零申报”,可以看到 “本期销售情况” 默认都是零 点击 “下一步” 点击 “提交申报” 信息确认,如果你的个体户有实际销售额,不可以零申报,需要如实填写,点击 “确定” 点击刷新,可以看到申报成功 回到首页,我的待办,可以看到状态 “已申报” 增值税及附加税费申报 ✅ 个人所得税 - 经营所得(Web 版) 登录到 “自然人电子税务局”,地址:https://etax.chinatax.gov.cn 目前 1 月份初,需要申报 “经营所得个人所得税月(季)度申报”(A表) 和 “经营所得个人所得税年度申报”(B表) 如果是 4、7、10 月份,则只申报前者 A 表, B 表一年仅提交一次。 PS:我是后来知道的这个在线网站,1月份的申报已经根据其他教程通过 自然人电子税务局(扣缴端) 进行了提交 你可以在 “自然人电子税务局” - “我要办税”,提交 “经营所得(A表)” 和 “经营所得(B表)” 申报步骤 【待补充 Web 端操作步骤】 等到 4 月份申报 “经营所得个人所得税年度申报” 时我将通过 Web 提交并在这里补充步骤,当前节点,你可以参考本文的「个人所得税 - 经营所得(自然人电子税务局-扣缴端)」 小节,进行提交,或是使用 Web 端都可以的 在客户端提交并显示成功后,可以在 Web 端查询申报记录 查询申报记录 Web 端使用 “个人所得税 APP” 扫码登录后,点击 “我要查询” - “申报查询” 如下图所示,“经营所得个人所得税月(季)度申报” 即 A 表,“经营所得个人所得税年度申报” 即 B 表 个人所得税 - 经营所得(自然人电子税务局-扣缴端) 注意:客户端跟 Web 端是等效的操作,如果已通过 Web 申报,则不用再使用客户端 下载页面:https://etax.chinatax.gov.cn/webstatic/download-service/100001 下载后进行安装,一路下一步,注意选择 “扣缴单位版” 单位录入,选择所在省市为 “北京市”,纳税人识别号即电子营业执照号(统一社会信用代码) 下一步,备案办案人员信息,填写自己的信息。 进入首页后,在代扣代缴模块的 “人员信息采集”,先添加人员(企业经营者) 勾选后再点击 “报送” 即时弹出报送结果 预缴纳税申报(A表) 来到「经营所得」模块,点击 ”预缴纳税申报“ 可以看到一条个体户经营者的记录,显示待申报。如果有利润,则点击 “公共信息” 旁的 “修改” 按钮进行修改 如果你有营业额,可以参考这个文章 《个体户百宝手册(二)——个人所得税经营所得申报操作全流程指引》 此处所说的“收入总额”“成本费用”为累计数,以7月申报期内申报为例,纳税人所填的收入总额及成本费用应当为1-6月的累计数,而非4-6月的合计数。 —— 《个体户百宝手册(二)——个人所得税经营所得申报操作全流程指引》 另外这个糊糊的在网上看到的图片也可以参考 继续回来,点击,填写无误后点击提交申报,因为营业额为 0,所以什么都没有改,直接提交 点击反馈信息可以看到申报成功 选择人员后,点击 “导出” 得到 个人所得税经营所得纳税申报表(A表).xls,可以存档 在 “查询” - "申报记录查询" 中可以看到状态为 “申报成功” 年度汇缴申报(B表) 每年第四个季度申报完成后,需进行 “个人经营所得税年度汇缴申报”(B表) 现在是 1 月份,点击 “年度汇缴申报”,会看到一条 “待申报” 的记录 同理,如果有营业额,那么点击修改,保存后点击 “提交申报”,没有则直接提交,申报成功后,状态变为成功 点击导出,你将获得一个 个人所得税经营所得纳税申报表(B表).xls 可以到上文记录的 Web 版本 “查询申报记录” 看到 A 表 B 表已经成功申报 ✅ 工商年报(工商年检) 登录到 “国家企业信息公示系统(北京)” 地址:https://bj.gsxt.gov.cn/index.html 首次使用先点击右上角进行注册,填写个人信息后,需要编辑短信发送,然后点击 “短信验证” 完成后使用手机号 + 密码登录进系统 点击企业信息,选择所在地 点击生成登录二维码,使用 “电子营业执照” 登录 登录后点击 “年度报告填写” 2024 年度报告,点击确定 弹出 “填写须知”,勾选后确定 本着如实填写,能少写就少写的原则录入,没有的值需要填 0 提交后的提示 可以点击 “预览打印”,保存为 PDF 后你将得到一个: 个体工商户年度报告表.pdf 确认无误后点击 “提交并公示” 提示:您的年报已提交成功,将在48小时内通过全国企业信用信息公示系统、企业信用信息网进行公示! 点击 “打印结果告知书”,可以看到以下内容 工商年报申报完成 ✅

2025/1/24
articleCard.readMore

非京籍个体户缴纳社保(七):社保费用申报与缴纳

社保缴费,本文主要以 “社保费客户端” 为例,记录关联、申报与缴纳,并记录了 Web 网页版的申报与缴纳 特别需要注意:本月进行了员工增员后,下个月 10 号才能开始申报缴纳社保费。 自2024年1月1日起,用人单位应当于每月10日至25日自行向税务部门申报缴纳社会保险费,职工个人缴费部分由用人单位根据社会保险相关法律法规代扣代缴。 特别提示:使用 Web 缴纳更加方便,请查看本页面的 “申报与缴费(Web 端)”(推荐) 安装并初始化客户端 客户端下载地址:http://download.bjca.org.cn/download/sbgl.zip 下载后立即安装 选择地区,务必选择正确地址 自动下载安装包... 等待完成 点击 “添加” 用人单位 输入纳税人识别号,即企业社会信用代码 选择用人单位,点击 “去关联” 弹出 “用人单位参保缴费信息关联” 点击提交 提示 不存在完全正配的待关联登保信息(完全匹配的参保信息系统已自动关联),如需关联非完全匹配的参保信息,请携带相关资料前往前台办理 如果你遇到这个提示,需要等待 ⌛️ 我是 12 月 29 日晚间遇到的这个错误,1 月 1 日下午再次尝试关联,报错消失,说明信息已同步 点击 “下一步” 上图所提示的路径不对,参考以下最新的重置地址进行设置 获取社保客户端密码 访问北京电子税务局网站:https://etax.beijing.chinatax.gov.cn:8443/ 电子税务局改版后的正确路径是:“地方特色” - “社保业务” - “社保费客户端密码重置(首次获取)” 弹出页面,填写新密码 设置后 再次回到客户端,勾选 “我已确认无误” 对参保登记信息后点击确认,而后自动获取信息 而后自动进入客户端页面 等待保险同步完成 点击职工参保信息管理 1月1日,元旦没刷出来职工信息 1月2日,在社保客户端查询到员工信息 我这里只看到有医保,没有养老、失业和工伤保险。 网上看也有不少人遇到这个问题,如果已确定社保增员成功(参考:《非京籍个体户缴纳社保(四):北京市社会保险网上服务平台 - 增员与社保卡领取》),可以先等一等,10 号之前一般都会推送完成,等待两天后再查询试试 1月4日,可以看到缺失的保险已显示 年度缴费工资调整 来到 “缴费工资申报” - “年度缴费工资调整”,选择当前的时间段 点击 “确定” 点击 “按人申报” 先输入新缴费工资,例如填写 6000,社保费会自动按照最低档 6821 (2024)基数来计算(不同年度缴费基数不一样),想要缴纳最低档,填写低于 6821 的数字就可以 勾选上新增社保人员,再点击 “提交申报” 而后可以再申报记录模块看到申报正在 “处理中”,别傻等,我就是等了两天,然后翻网上的攻略,需要点击一下 “申报结果查询” 然后它的状态就变为 “全部申报成功” 了 如果你在社保费申报中的 “日常申报”,只能看到医保的记录 需要再次回到 「缴费工资调整」模块,会提示有人员的基数为空,选择 “按险种申报”,可以看到养老、失业、工伤为空,填入跟医保一样的金额 勾选人员前的选择框,点击提交申报,确定 提交后,直接点击 “申报结果查询”,发现已 “全部申报成功” 点击 “查看”,可以看到五个险种均已显示且有工资金额 社保费申报 点击进入 “申报费申报” - “日常申报” 模块 勾选所有的记录 当前时间点的月缴费最低金额为 2540.42 元 拖动横向滚动条,可以看到各个待缴费记录的类别 其中划到医保个账加养老个账共计 682.1 会进到自己的个人账户,1800+ 进了统筹,有言道:侠之大者,为国接盘(手动泪目 先看眼当前日期,社保费申报需要在 10 号及之后申报,当前 7 号,等两天再提交 (等待中... 10 号已到) 社保费提交申报 确保全选,确认金额没问题后,点击 “提交申报” 点击立即提交,弹出提交成功 申报成功,可以直接去缴费 在 “社保费申报” - “申报记录” 可以看到刚刚提交的记录 社保费款缴纳 到 “费款缴纳” - “缴费” 勾选 “立即缴纳” 选择缴费方式,如果你有 “银行公户”,那么选择 “三方协议交费” 没有公户则选 “银行短凭证缴费”,不用跑线下,可以用手机 APP 缴纳 点击 “人工录入账户账号信息”,输入你的支付银行卡信息 1)姓名 2)银行卡号 3)开户行 保存后点击下一步,弹出的页面点击 “确认缴费” 出现的打印页面可以保存为 PDF,记录上的 “银行端查询缴税凭证序号” 和 “纳税人识别码”、“税务机关代码” 稍后会用到 银行线上缴款 在银行 APP 上搜索 “缴税通”(我看了招商和交行都可以找到这个应用模块) 进入后需要输入上面记录的 “银行端查询缴税凭证序号” 和 “纳税人识别码”、“税务机关代码” 缴费成功后,过一会儿刷新记录可以看到 “缴费成功” 申报与缴费(Web 端) 我是在客户端缴费完才了解到的通过 Web 端 “北京电子税务局” 也可以进行申报和缴费,跟客户端流程是一样的,这里简要介绍,跟客户端申报缴费是二选一的,从大趋势来看,最终可能客户端的功能都会迁移到 Web 端 日常申报 2025-08-11 更新: 位置调整,社保业务从 “地方特色” 移动到了 “我要办税”。 在 “地方特色” 进入后点击 “社保业务” - “日常申报” 税费缴纳 进入后点击 “社保业务” - “缴费” 跳转到金额页面 点击立即交费,点击确认 参考客户端版本中记录的手机缴款小节:「银行线上缴款」,查看 “银行端查询缴税凭证序号” 和 “纳税人识别码”、“税务机关代码”,此处不再赘述,这里有个细节:“因为没有公户,Web 端没有展示三方协议交费的选项” 至此,申报与缴费完成 ✅ 离松口气还差一些,需要了解税务申报,否则将面临企业异常和罚款的组合拳法 参考 北京昌平税务 - 社保费管理客户端申报缴费操作指引

2025/1/19
articleCard.readMore

非京籍个体户缴纳社保(六):医保公共服务平台 - 增员确认

员工社保增员后,因为养老和医疗是不同平台,为了确认医保也已自动增员,可以访问北京医保公共服务网上服务大厅进行确认 地址:https://fw.ybj.beijing.gov.cn/hallEnter/#/Index 完善单位信息 点击单位登录,选择 “北京市统一身份认证平台登录” 首次登录提示如下 由于一证通反馈的单位信息不完整,不能进入北京医保公共服务平台。您需要在北京医保公共服务平台补填单位信息后才能进入! 点击 “立即补填” 单位账号类似于 “用户名”,建议填写为企业的信用代码,通讯地址我填写的居住证地址 经办人填写自己的信息,提交后显示成功 另外会收到短信,是单位账号和经办人账号和密码,保存妥当! 添加经办人 点击前往以单位身份登录 点击经办人管理,并点击右侧权限管理,授权并点击 “确定” 保存 之后退出登录,重新选择单位登录,此处不是用统一身份认证,而是填入刚刚短信发送的经办人账号(手机号、密码) 选择个体户并确认登录 员工增员确认 登录后选择左侧的 “网上经办” - “职工信息维护” 能查询到,说明之前社保网上服务平台的增员的确已经在医保上自动增员,确认完成!✅ (备注:如果查询不到,也可以点击左侧的 “职工增员申报” 进行提交)

2025/1/18
articleCard.readMore

非京籍个体户缴纳社保(五):北京电子税务局 - 税务报道

虽然在 e 窗通提交了企业信息,但只是基础数据的流转,后续仍需手动进行关联确认 百度搜索:国家税务总局北京市电子税务局 网站地址:https://etax.beijing.chinatax.gov.cn:8443 两证整合个体工商户信息确认 先别着急登录,先点击页面右侧的 “两证整合个体工商户信息确认” 弹出新的确认页面 填写个体户营业执照同意社会信用代码、企业负责人的身份证、姓名等信息,填写后点击页面右上方的 “我要开始办理” 虽然提示红色星号表示必填,但黄色框内都需要补充,否则会提示需要填写 之后国标行业(附)和家庭经营信息 家庭信息这里我填写的个体户所有者,也就是自己的信息 提交后可以看到 “涉税事项受理系统回执单” 之后可以查询进度 当前显示正在办理中 下午一点多提交,当天下午 6 点查询,已出结果如下 这里可以看到 6 个税种认定,“显示密码” 没啥用,直接点击 “立即登录” 登录网站 选择 “电子营业执照” 方式登录网站 弹出用户协议,弹出 “输入的统一社会心用代码或纳税人识别号错误,请重新输入”,我的情况是没进行 “两证整合个体工商户信息确认” 导致的,如果是其它原因等一两天再试或打税务局电话咨询 点击确认统一协议,然后重新设置密码 成功登录 刚才可能你已经注意到了 “签收文书” 跳转到以下页面 点击签收后显示 “已签收” 再次回到个人首页,可以看到我要查询,点击 “税务文书送达确认” 弹出 “税务文书电子送达确认书签订” 点击确定 跳转后继续点击 “同意签订” 能成功登录就算是报道完成了 ✅ 进一步确认已报道成功(可选) 方式一:确认纳税人和税种信息 在输入框输入 “纳税人信息查询”,进入模块 能看到纳税人信息和税种信息说明 OK 方式二:确认自然人身份新办纳税人开业信息 退出登录,回到登录页面 点击「自然人业务」下的 “用户注册” 我在中间过程中提示用户存在,所以我回到首页,点击忘记密码重新设置的密码,使用手机号密码,外加短信验证码的方式登录,在 “热门服务” 中可以看到 “新办纳税人开业” 点击 “新办纳税人开业”,有个体户信息 点击蓝色的 “统一社会信用代码” ,会弹出提示如下: 能正常通过 “电子营业执照” 登录到电子税务局,就算是报道完成,同时也进一步通过两种方式进行了确认

2025/1/13
articleCard.readMore

非京籍个体户缴纳社保(四):北京市社会保险网上服务平台 - 增员与社保卡领取

本文内容 员工增员(缴纳社保的人员) 领取实体社保卡(非必需,电子社保卡日常也能用) 员工增员 以单位身份登录北京市社会保险网上服务平台 网站地址:https://fuwu.rsj.beijing.gov.cn/zhrs/yltc/yltc-home 在 “在职职工管理” 中,点击 “(增减员)社会保险...” 点击 “零星新增” 模块 进入有有个提示,可以读一下 (后续文章会涉及税务相关知识,此处简单说下,例如 12 月 20 日完成社保人员增员,下个月也就是 1 月 10 日可以为其缴纳社保,医保能正常使用时间时缴纳社保后的次月 1 日,也就是 2 月 1 日) 填写参保人员信息,如果是为自己办理社保就填写自己的信息,为职工则填写职工信息(如果你在公司在职上班,申请的个体户,不要为自己录入社保登记) 填写身份证姓名后,获取共享信息,会自动补充一些内容,变为不可更改,完善剩余信息 补充劳动用工备案信息,劳动合同我填写的是当月营业执照上的日期,三年固定期限合同,岗位选择专业技术岗即可,无电子劳动合同。 其他信息选择的 “专业技术人员” 个体户管理员添加人员时无需上传照片(首次开通北京社保需要照片),参保人需要以个人身份上传照片(如果参保人员就是自己,则以个人身份进行照片上传,见后文) 参保信息有三项 “养老”、“失业”、“工商”,此处没有体现 “医保”,通过后会自动开通,无需担心,后文将记录到医保网站进行确认的步骤 点击提交,提示税务开始缴费时间为 12 月 确认提交后显示受理成功 因为增员无需人工审核,是即时办结事项,点击 “《办结通知单》” 查看详情 同样,不通过原因为空,备注信息显示,医保增员成功 “该业务为医保协同业务,医保部门反馈的处理结果为:成功。您无需到医保部门重复进行申报。” 回到在职职工管理模块,在 “增减员” 页面点击 “劳动合同人员参保登记列表” 可以看到社保增员已成功 ✅ 实体社保卡申领 个体户增员成功后,参保人(职工)需等待短信通知,在这之前上传照片会提示 “卡管系统未查询到该人员信息”,大概需要等待几天到一周多时间不等。(我是在增员第二天收到的短信) 实体社保卡申领过程较慢,整体下来可能一个月的时间,不申领实体卡也 不影响公司正常缴纳社保,在实体卡下来前也可以使用电子社保卡。 参保人员照片上传 如果使用手机操作,关注 “北京人设” 公众号,点击 “微服务” 点击 “个人办理”,社会保障下可以看到 “第一二代社保卡照片采集”,点击进入办理 如果是电脑操作,参保人可以在人社局首页 https://rsj.beijing.gov.cn/ ,找到 “我要办社保卡” 然后以 “个人业务 - 个人登录” 进入后点击 “立即申请” 进入后填入身份证号码,并点击上传照片 (补充)如果提交提示 “卡管系统未查询到该人员信息”,等过些天参保人收到短信要求上传照片后再提交。 点击弹出 “确认” 会提示 “上传成功”(只能提交一次,再次提交应该会提示报错信息,如果照片不符合,应该会再次收到上传照片的短信提醒) 提交后可以到 “社会保障卡制卡进度查询” 模块查询进度 当前查看显示状态为 “您的制卡信息还未由参保单位提交(或制卡信息不符合制卡要求),请与当前参保单位联系提交。”,这是正常的状态。 此时等待即可 ⌛️,能收到上传照片短信提醒,说明新增员工成功,流程正在进行中,可能需要等待几个工作日到半个月的时间能看到进度,可以隔几天再来看下 2024-12-25 进度(提交照片的 12 天后) 卡片已经在制作中... 2025-01-01 进度:卡片制作完成,已寄出 2025-01-10 进度:收到顺丰快递,用时不到一个月,算挺快的 申领申报卡完成 ✅

2025/1/12
articleCard.readMore

非京籍个体户缴纳社保(三):社会保险网上服务平台 - 单位信息登记

网站地址:https://fuwu.rsj.beijing.gov.cn/zhrs/yltc/yltc-home 电子营业执照 在登录 “社会保险网上服务平台” 之前,先下载电子营业执照 在支付宝或微信搜索 “电子营业执照” 小程序 首次登录有提醒,点击 “下载执照”,登记地选择 “北京”,可以看到注册的个体户记录,勾选并点击开始下载 下载后会提示修改密码,建议点击修改密码进行修改 后续需要电子营业执照登录的地方,点击页面上的 “扫一扫” 单位登录 点击 “单位登录” 会跳到统一登录页面,使用 “电子营业执照” 小程序进行扫码登录 首先会弹出登记提示,点击 “去登记” 点击 “开始申报”,正常情况,会代入共享信息,但很可能带不过来,提示如下图 别急,可以尝试等待一两天,可能信息就共享成功了(强烈建议等待同步成功) 同步成功后,可以发现有一些字段已经自动填写且不可更改,例如 “单位类型” 和 “成立日期” “登记注册地址” 我这里填写的居住证地址,一模一样,注册街道按实际地址填写,因为“单位经营(办公)地址” 这里自动代入了网店地址,前面我没有选择。 工商执照部分正常填写,往上翻有实例图,可以看到登记机关和经营范围已自动自动代入。 所属行业自动代入了 “F5219” 同理,法定代表人(负责人)信息自动代入了姓名,其它信息补充填写 信息同步成功后,最大的变化是 无需再上传要件! 翻到 “下一页” 继续提交联系人信息,填写手机,填写验证码 然后点击提交后 点击确定,会重新登录,使用 “电子营业执照” 扫码登录后,点击 “单位中心” 可以看到已 “即时办结” 点击 “查看详细信息及表单下载” 进入详情页可以看到结果表单 在 用人单位信息登记_办结通知单.pdf 这个文件中,如果审批不通过,会有原因 此时回到登录后的公司首页:https://fuwu.rsj.beijing.gov.cn/zhrs/yltc/company/index 可以看到左侧出现功能列表(备注:使用 Chrome 谷歌系列浏览器,Firefox 火狐浏览器存在兼容性问题不显示左侧列表) 至此,单位信息登记完成,接下来要做的是员工增员、领取社保卡 ✅

2025/1/5
articleCard.readMore

非京籍个体户缴纳社保(二):北京 e 窗通平台提交申请

通过北京 e 窗通,可以办理营业执照,也可以一站式将资料提交到社保部门和税务部门 北京 e 窗通地址:https://ect.scjgj.beijing.gov.cn/index 点选「个人服务」并登录,登录后完成实名认证(使用微信小程序 「北京企业服务 e 窗通」进行实名认证)。 另外办理过程中,有保存的按钮记得点击,这样进度能够保存,中断重新进入办理上一次进度不会丢失 名称申报 考虑到名称在「立即办理」过程中可能被驳回不通过,我们可以在申请的时候先申请下来名称,名称通过后再点击个体工商户开办的「立即办理」按钮 选择设立申报 点击 “我同意”,下一步 再次点击 “个体名称” 想一个喜欢的字号,检测是否可用(字号就是公司名),因为开设的网店计划售卖服装等,选择的 “商贸中心” 后缀,能经营的品类范围会比较大 如果一些词汇跟现有公司可能存在争议,也可以选择 “继续使用”,由人工审核人员决定是否通过,确定使用这个名称后点击 “继续填写补充信息” 提交后,在首页「名称申报」处可以看到审核进度 没有争议一会儿就能出结果(如果存在争议可能需要等待工作日进入人工审核环节) 个体工商户开办 申报名称成功后,接下来开始申请开通个体工商户 办理业务 您是否使用自主申报通过的名称进行登记 ?如果之前手动申请了名称选择 “是” 您是否在区政务服务中心办理个体工商户一站式开办 ?选择 “是”,这样可以一站式办理营业执照、刻制印章、涉税事项、员工五险一金备案和银行开户业务。 您当前是否涉税事项申报(领用发票 )?是 您是否进行银行开户服务 ?否 (开公户每年有几百元的费用,不开不影响缴纳社保,只是不能自动扣款) 点击 “申请营业执照” 点击进入办理 完善经营者信息 因为已实名,所以自动代入了一条记录,编辑进行完善 其中,「职业状况」可以写 “个体”,我写的 “在职” (按实际填写),「人员类型」选择 “个体户” 后点击下一步 填写基本信息 是否申请网店登记,选择 “是” 地址填写注册微店或淘宝店的地址,注意必须是 “微店:xxx” 这样的格式,否则会审核不通过 经营形式选择 “个体座商” 资金数额无需实缴,但也应填写合理数额,以免政策调整需要缴纳时无法兜底 经营地所处地域,选择 “城镇” 经营范围 点击 “经营范围规范表述查询” 按钮,对经营内容进行查询 按主题选择比较方便 如果你想在网店上卖一些东西,那么可以选择 “服装店”、“玩具店” 等你要卖东西的品类,选择「一般事项」的内容,如烟酒需要前置许可,书店需要后置许可,不要选择这些需要多余手续的 如果你 “不太关心售卖的物品或短期内暂不上架商品”,那么到 “服装店” 类别选择一个主类别即可。 正常选择,不建议跨多个品类选择多个主营,可能会被拒 点击右上角的 “生成结果” 关闭弹窗后可以看到经营范围,点击下一步。 基本信息 - 补充信息 五险一金信息(多证合一) 如上图选择,根据居住证所在区的机构和单位,我在昌平区所以社保等都选择昌平 添加新员工(可选):我在这里没有添加,后续可以单独的办理增员,下图所示,跳过 免费的四章全选 涉税事项 填写电话和邮编,邮编可以通过 https://www.youbianku.com/ 邮编库查询 填写经营项目明细调查,不是特别重要,根据预计填写一些 企业套餐式信息采集表,不填写,直接点击下一步 此时已填写完成,回到首页,最终确认提交,如果提示 “认证失败”,如果你已经实名,多半是节假日提交的,等工作日再提交就可以了 提交完成后状态变为 “待在线签字” 此时还未提交到业务部门,需要我们进行签字确认 签字确认 点击 “详情” 进入详情页,切换到 “在线签字信息” 栏 点击上图中的 “在线签字”,选择 “小程序扫一扫业务确认” 生成文书,进行文书确认 没问题点击下一步 刻章备案信息,跟我们无关,继续点击下一步 签署承诺书,选择 “知道了”,然后点击 “同意并下一步” 上边这个页面无需按照页面提示操作。 需要使用 “北京企业服务e窗通” 小程序进行确认(非扫码) 点击页面上的 “业务确认”,人脸认证后如果提示 未开启办理企业登记注册,请在个人认证时开启办理,则需要点击首页上的 “个人认证”,将 “是否办理登记业务” 的 “是” 勾选后重新认证。 然后在到 “业务确认” 板块,选择 “自然人/法人业务确认” 选择要注册的个体户,点击 “业务确认“,一路下一步,签字确认,完成后页面会清空 确认后再次回到网页端,详情中可以看到 “签字完成 ✅” 看到办理状态已变为 “审查中” 可能会有短信通知,过段时间主动来看下 不到一个小时营业执照已审核通过,效率超高!(此时可以点击 “领取证照”),先到详情看一下状态 涉税事项进行中 领取证照 此时,公章还在刻制中,再等一等(不要 “打印领照单”,如果打印将需要自行去管理局线下领取) 等待几个小时至一天不等(我在当天下午六点刷新页面已变为 “可领取”) 选择 EMS 寄送,填写收章地址,提交后等待即可 订单编号(可能)不是物流的快递单号,因为在两天后已收到快递后,此处还显示的 “尚未配送”,也可能是系统数据同步有延迟 至此,个体工商户开办完成,万里长城走完了第一步 ✅

2025/1/4
articleCard.readMore

非京籍个体户缴纳社保(一):概览与先期准备

缘起 个体经营、非全日制、新就业形态等从业人员,没有入职公司上班,也就没有职工社保(五险一金),一般有几个选择: 以灵活就业人员身份缴纳职工社保 缴纳城乡居民社保 缴纳商业保险 不缴纳 首先排除掉“不缴纳”,因为不缴纳什么都不需要做,“商业保险” 也不在本文讨论范畴 在前两者中,“城乡居民社保” 的保障特别基础,其中的居民医保(新农合)是缴纳一年保障一年,不缴纳则不享受待遇,相比之下,职工医保退休后可以不用再缴纳也享有终身医保待遇。 另外一个重要的区别是 “城乡居民” 和 “企业职工” 是两条线,不能互转,一些群体在上班和不上班之间徘徊,缴纳些许年职工社保但不满足退休条件,或是阶段性不去上班,以后还会找工作上班,灵活就业就是很好的补充,简单说就是公司和个人的钱都由个人缴纳,国家也算这些人为 “企业职工”,这就是灵活就业人员,养老和医疗保险待遇跟企业职工没有区别(灵活就业人员没有工伤、失业和生育保险,2025年有地区已经出台政策,灵活就业人员可以缴纳生育保险,但不能领取生育津贴) 基于以上的差异和对比,很多人会 “以灵活就业人员身份缴纳职工社保”,一般情况下灵活就业人员可以在工作地缴纳,但首都有自己的规则 在北京的灵活就业人员不能单独缴纳医保,需要养老保险同时缴纳 外来的非京籍打工人,不能以灵活就业方式缴纳社保 也有变通的方法,即在北京注册 “个体工商户”,以 “做买卖” 的经营者身份,可以给自己缴纳五险一金 上图是 2024 年下半年的个体户缴纳社保最低费用:2540.42 元/月 从网上分享来看,一般开通个体户缴纳社保有以下几个原因 在北京做小本经营,需开发票的经营者 担心可能被毕业或已毕业的打工人,即使毕业也能给自己续上让社保不断缴 在北京生活的全职妈妈 想要在北京办理退休的灵活就业人员 社保要分开来看,其中的养老保险全国标准统一,计算公式相同,都是多缴多得,差距有限,但是医保待遇的差别就大了,从全国来看,北京的医保待遇是独一档的,报销比例高、门诊没有支付上限等等。 另外值得一提的是个体户也可以为自己缴纳住房公积金,可以用来买房贷款 关于断缴社保的部分影响: 机动车摇号条件:近五年(含)连续在本市缴纳社会保险费,不能断月 医保:医保一般是中断3个月之内,重新续交的,不管是否补交,医保要在重新续交的次月生效。中断3个月以上的,要在重新续交6个月以后,医保才会生效。 铺垫了很多,应该算是讲清楚了 “非京籍个体户缴纳社保” 这个事情的来龙去脉 本系列将会记录从准备到最终成功缴纳社保的所有流程步骤,门槛不高,但着实需要些时间和精力,整理出来以作备忘,同时也希望能帮助到有需要的人 如果你需要开通个体户进行经营活动,或是为自己、家人缴纳社保,那话不多说,快上车! 先期准备 不得不说,北京的政务系统可用性蛮高的,几乎可以全程网上办理,仅居住证确认单需要去一次派出所开具 有两个需要先期准备的资料 开通网店(开通个体工商户需要先开网店,可以是淘宝或者微店,后者微店更简单一些) 拥有北京市居住证,并打印居住证确认单(附近派出所) 微店开店 下载微店(店长版),登录后进行店铺认证。 点击右上角的 “设置” - “店铺设置” - “店铺资料” - “微店开店证明”,保存「微店平台店铺经营证明」图片。 北京市居住证 如果已经有居住证,携带身份证去附近派出所打印居住证确认单(不能使用电子版居住证) 从未申领过居住证,需要先在微信搜索 "北京市居住证" 公众号,提交申领居住证,过两天会有社区或户籍工作人员联系你上门办理 准备好以上材料,就可以申请开通个体工商户了 本篇概览内容就先到这里!

2025/1/3
articleCard.readMore

养老保险零基础入门指南(速通版)

本文涉及到的知识点如下,要点内容将持续更新 什么是 “社平工资”? 社保缴费基数是否等同于社平工资? 缴费档位与月缴费金额 社保个人账户与余额 退休能发多少钱 - 速算版 退休能发多少钱 - 计算规则 “退休” 是什么意思? 延迟退休政策 1)什么是 “社平工资”? 答:被保险人退休时上一年本省(市)职工月平均工资(由当地政府发布) 例一:黑龙江省 2023 年的社平月工资是 7010 元/月 根据黑龙江省人社厅《关于确定2024年度基本养老保险使用的全口径就业人员平均工资的通知》(黑人社函〔2023〕625号),2024年度全省缴纳基本养老保险费及计发基本养老金使用的上年度全口径就业人员平均工资为84120元,上年度全口径就业人员月平均工资为7010元。自2024年1月1日起使用。 —— 《黑龙江省 2024年灵活就业人员养老保险缴费标准(全口径就业人员平均工资/社会平均工资)》 - 2024-07-16 例二:北京市 2023 年的社平月工资是 11761 元/月 北京市全口径城镇单位就业人员平均工资由城镇非私营单位就业人员平均工资和城镇私营单位就业人员平均工资加权计算,仅用于核定北京市社保个人缴费基数上下限。 2023:年平均工资(元)141133 / 月平均工资(元)11761 —— 《历年北京市全口径城镇单位就业人员平均工资》 2)社保缴费基数是否等同于社平工资? 答:社保缴费基数是根据社平工资来确定的,但不一定相等 例一:黑龙江省 2024 年企业职工按 100% 档缴费基数等同于社平工资 7010 元、缴费基数下限为 4206(等于社平工资 7010/月的 60%) 根据黑龙江省人社厅《关于确定2024年度基本养老保险使用的全口径就业人员平均工资的通知》(黑人社函〔2023〕625号),2024年度全省缴纳基本养老保险费及计发基本养老金使用的上年度全口径就业人员平均工资为84120元,上年度全口径就业人员月平均工资为7010元。自2024年1月1日起使用。 —— 《黑龙江省 2024年灵活就业人员养老保险缴费标准(全口径就业人员平均工资/社会平均工资)》 例二:北京市 2024 年企业职工缴费基数下限为 6821 元,机关事业单位职工缴费基数下限为 7057 元(等于社平工资 11761/月的 60%) 一、自2024年7月起,本市2024年度企业职工基本养老保险、失业保险、工伤保险、职工基本医疗保险(含生育)月缴费基数上限确定为35283元,月缴费基数下限为6821元。 四、自2024年7月起,本市2024年度机关事业单位职工基本养老保险月缴费基数上限确定为35283元,月缴费基数下限为7057元。 —— 《北京市人力资源和社会保障局 北京市医疗保障局 国家税务总局北京市税务局关于统一2024年度各项社会保险缴费工资基数上下限的通告》 月缴费基数下限通常是根据上一年度社会平均工资(社平工资)来确定的,根据国家政策,缴费基数下限一般不低于社平工资的 60%,上限一般不超过社平工资的 300% 可见北京企业职工的缴费基数下限(6821元)低于机关事业单位的缴费基数下限(7057元),这说明北京在基数下限的设置上跟社平工资有关,同时也具有一定的灵活性,主要目的应该是为了减轻企业的社保缴费负担 当且仅当此地区以上年度社平月工资作为 100% 档位缴费基数,且参保人选择以 100% 档位缴纳社保时,其缴费基数等同于社平工资 例如,黑龙江省 2024 年社平工资为 7010 元,政策规定 100% 档位缴费基数为 7010 元,因此选择 100% 档位的参保人,其缴费基数就是 7010 元 3)缴费档位与月缴费金额 知道了缴费档位,就能够确定月缴费金额,当前政策,灵活就业人员养老保险月缴额为缴费基数的 20% 黑龙江灵活就业人员,养老保险最低月缴金额为 841.2 元 北京灵活就业人员,养老保险最低月缴金额为 1364.2 元 补充:北京不允许非京籍以灵活就业人员身份缴纳社保 4)社保个人账户与余额 上文提到了养老保险月缴额为缴费基数的 20%,其组成为:缴费基数的 12% 进入统筹、8% 进入个人账户 统筹账户是大池子,到退休后按照一定规则从大池子中拿一些,再从个人账户拿一些,加一起就是每月的退休金 以黑龙江灵活就业最低档缴费为例,841.2 元中, 336.48 累计到个人账户 余额查询 通过各省政务网站或小程序可查询,以哈尔滨为例,可以使用 “黑龙江全省事” 小程序,进入后切换到哈尔滨,热门推荐或更多中可以看到 “养老保险个人账户查询” 北京则通过 “京通” 小程序,通过 “社保个人对帐单” 查询余额 5)退休能发多少钱 - 速算版 注意:速算版非官方计算规则,只是一个经验公式,能够快速获得粗略结果 计算公式 = (缴费年限 + 个人账户余额(单位为万)) x 100 例如:社保共计缴费 20 年,个人账户累计余额 8 万,(20 + 8) x 100 = 2800 / 月 根据经验来看,速算版上下浮动有限, 6)退休能发多少钱 - 计算规则 虽说可以根据社保规则进行计算,但显然没人可以知道几十年后的社平基数、政策调整等情况,只能在当前已知规则前提下进行估算 退休养老金 = 基础养老金 + 个人账户养老金 + 过渡性养老金 基础养老金 = (计发基数 + 计发基数 × 个人缴费指数 ) ÷ 2 × 缴费年限 × 1% 根据国家规定,基本养老金由基础养老金(统筹养老金)和个人账户养老金组成。退休时的基础养老金月标准以国家确定的计发基数和本人指数化月平均缴费工资的平均值为基数,缴费每满1年发给1%。个人账户养老金月标准为个人账户储存额除以计发月数,计发月数根据职工退休时城镇人口平均预期寿命、本人退休年龄、利息等因素确定。 —— 山东坊子人社服务公众号《社会保险常用政策问答——待遇计发篇》 其中的计发基数以黑龙江为例,等同于社平工资 = (P + P × i ) ÷ 2 × n × 1% 例如,黑龙江灵活就业人员以最低档位(60%)缴纳 20 年,假设退休时上年度社平是 8000 假设社平基数,每年上涨 2%,20 年后是 7010×(1+0.03)^20 = 12660 = 12660 × (1 + 0.6)÷ 2 × 20 × 1% 统筹部分 2025(元/月) 个人账户养老金累计约等于 81000 元,算上年化 1% 的利息相当于 90000 元 = 336.48 × 12 × 20 个人养老金每月发放多少取决于计发月数 计发月数:根据平均寿命计算,50岁退休按 195 个月,55岁退休按 170 个月,60 岁退休按 139个月。这个“计发月数”只是用来计算退休当年的养老金,与实际发放月数无关,实际会终身发放。 此时以 139 为计发月数进行计算 个人养老金:90000 / 139 = 647 元 合计:2025 + 647 = 2672 元/月 因为本例中的利息和社平增长率相对很保守,回过头看看速算版,也属于大差不差。为什么没提过渡性养老金,这个跟八零、九零后同学没啥关系,不用了解 7)“退休” 是什么意思? 退休,说的是发放待遇时间 重点要考:什么时候退休意味着你能从什么时候开始领取养老金,不代表你可以一直工作到退休 8)延迟退休政策 政策调整前,男性职工统一六十周岁退休,新华社 2024 年 9 月 13 日刊发文章:《全国人民代表大会常务委员会关于实施渐进式延迟法定退休年龄的决定》 第一条 从2025年1月1日起,男职工和原法定退休年龄为五十五周岁的女职工,法定退休年龄每四个月延迟一个月,分别逐步延迟至六十三周岁和五十八周岁;原法定退休年龄为五十周岁的女职工,法定退休年龄每二个月延迟一个月,逐步延迟至五十五周岁。国家另有规定的,从其规定。 第二条 从2030年1月1日起,将职工按月领取基本养老金最低缴费年限由十五年逐步提高至二十年,每年提高六个月。职工达到法定退休年龄但不满最低缴费年限的,可以按照规定通过延长缴费或者一次性缴费的办法达到最低缴费年限,按月领取基本养老金。 即部分七零后,八零九零、零零后,国家来养老的时间需要再多等三年 倘若 43 岁失业,63 岁领取养老金,上有老下有小的打工人这 20 年会有些难熬... 另外,当前最低缴纳年限现在是十五年,等打工人退休时,最低要缴纳二十年才能办理退休 未完待续...

2024/12/31
articleCard.readMore

了解北京门诊看病工会“二次报销”互助金

本文概览: 记录注册成为北京市工会会员,享受医保二次报销互助金,工会默默给你打钱 费用情况: 注册和开银行卡均免费 适宜群体: 每个在京打工人(除"先天无垢圣体"),建议办理避免错过一个亿 提交入会申请 先下载 “北京工会 APP” 首页的样子 点击页面右上角的 “我要入会”,填写资料提交申请,等待审核。 一般一到两个工作日就会审核完成,会收到短信通知 办理北京银行工会互助卡 审核通过后,携带身份证,就近找到北京银行(必须到「北京银行」办理工会互助卡) 那我已经有了北京银行的银行卡能不能直接使用? 答:不能 跟工作人员说办理 “工会互助卡”,工作人员会指导办理,几分钟就能下卡 出了银行,打开工会 APP,到 “我的” - “个人信息” 页面就可以看到「工会会员互助服务卡」已显示银行卡号,自动绑定成功 至此,你啥都不用再管,看了病过段时间自己就会冒出钱来了 如果想要了解“互助金”的补贴规则,可以继续阅读 门诊 “二次报销” 入会成功后可以享受两类保障 —— “普惠型保障” 与 “会员型保障”,前者入会就有,后者需要所在公司参保(暂不关心),本文只记录了普惠型保障的门诊互助金相关,会员型保障暂不作了解 “普惠型保障” 的互助金的温馨提示 “个人自费”、“自付二” 不享受互助金。 员工需持有工会会员互助服务卡。 拖欠医疗保险费,保障期限内未补交不享受互助金。 需是北京市总工会管理的工会会员。 工伤、生育、职业病导致的医疗费用不享受互助金。 被保障人不可采取欺诈手段骗取互助金。 二次报销的互助金比例 在北京正常参保,门诊费用超过医保起付线 1800 (全年累计)后的 “自付一” 费用,可获得 20% 的互助金 简单说就是门诊看病过了起付线,一般来说,都能得到几杯奶茶钱或几千块的空投互助金 如果你经常走医保门诊看病,花了不少钱,也比较关心自己的 “自付一” 金额情况,可以通过以下步骤查询: 查询当前年度自付一总费用 支付宝 - “京通” 小程序 点击 “医保消费查询” 左右拖动到最后可以看到 “自付一年度总额”,点击 “显示金额” 查看 发放时间 按季度支付,一般来说第一季度的互助金 5 月份发放、第二季度 8 月份、第三季度 11 月份、第四季度在次年 1、2 月份自动发放 工会系统跟医保系统关联,普惠门诊互助金无需提交资料和申请,全程自动支付 经验来看,很可能较高的 “延迟”(一两个月),如果等了一段时间还未发放,可以咨询 12351 了解情况 充要补充 如果你在 12 月份申请入会并成功办理工会卡,那么当年 1- 12 月份的 “自付一” 均可按规则正常发放,如果时间来到了一月份,已进入新的自然年,那么之前的互助金将不能补领! 2025-01 月补充 发放2024年4季度互助金人民币281.49元,等于自付一减去1800后20%的金额 工会卡福利二:可办理畅游公园 100 元年票 北京的畅游公园有两个面值的年卡,100 元和 200 元 前者包含的公园少点儿,日常对一些人也够用,后者包含前者的同时也包含 “京津翼旅游卡” 的景区;另外还的区别即前者只有工会成员和退休人员才能办理 对于住在市属公园周边,没事儿喜欢逛逛公园的打工人来说,畅游公园 100 元年卡性价比还挺高

2024/12/23
articleCard.readMore

注册 US.KG 免费域名(dpdns.org)

2025-04-11 更新:今年三月中旬,us.kg 域名因为滥用等原因已凉,域名被自动迁移到 .dpdns.org, ns 信息丢失,需要重新配置,刚切换后不能绑定 cf,今天测试绑定没有问题,如下图 风险提示:免费域名适合用来临时测试,稳定性可见一斑,不应用于稳定服务 官网地址:https://nic.us.kg/ 注册链接:https://register.us.kg/auth/register 注册 US.KG 域名(dpdns.org) 注册用户的过程很容易,之后会提示选择 KYC 校验 KYC 是注册域名时的一种重要合规措施,旨在验证客户身份、评估潜在风险,并确保客户符合相关法律法规。通过 KYC 验证,可以防止域名被用于非法活动,保护用户权益,并遵守法律法规。 选择推荐的 Github KYC 校验 首先点击 Star 收藏 然后创建一个 Issues,标题需要是固定的格式 "Request GitHub KYC-" 可以点击快捷链接直接到 Issues 页面,它会自动填写标题和内容 填入你的账户信息 几个条件,满足的话勾选上,创建后等待 ⌛️ 页面说会有每 15 分钟运行一次的自动化脚本进行处理(脚本似乎停了,等了一天看到 Issues 被处理) Issues 地址:https://github.com/DigitalPlatDev/US.KG/issues/10666 回到首页 https://nic.us.kg/ 继续申请域名,每个用户最多可以申请三个域名 点击页面左侧的 Domain Registration 选购域名,点击 Check 选择到可注册域名 注册前需要填入 Nameserver,此时来到 Cloudflare,注册网站(注:不存在的域名也可以添加到 Cloudflare) 点击添加域名 填入我们申请的域名,选择 “Manually enter DNS records” 套餐选择页面:免费计划,启动! 进到域名详情页,找到 Nameserver 将其填入 US.KG 域名注册页面 点击注册,提示注册成功 稍等几分钟,刷新可以看到域名已处于 Active 活跃状态 回到 US.KG 面板,进入域名管理可以看到续费说明,域名有效期一年,过期前 180 天内可以点击续费 另外可以免费开启 Whois 保护 最后的吐槽 🤔 要是早些发现 US.KG 免费域名能绑定到 CloudFlare,我何必为了体验 CloudFlare 的服务注册个 xyz 域名呢(摔 免费域名说凉就凉,花小钱买省心,推荐购买便宜的域名使用

2024/10/11
articleCard.readMore

白嫖 Cloudflare R2 + Worker 搭建私有镜像仓库

前些天发现一个 Cloudflare Worker,可以搭配其 R2 存储创建私有镜像仓库,本篇记录部署过程和使用方法 部署服务 克隆 Worker 代码 本地电脑执行,安装依赖 $ git clone https://github.com/cloudflare/serverless-registry.git $ cd serverless-registry # brew install pnpm $ pnpm install 安装完成依赖后,通过模版创建自己的 Wrangler 配置文件 $ cp wrangler.toml.example wrangler.toml Cloudflare Wrangler 是一个命令行工具,用于管理和部署 Cloudflare Workers 修改 R2 存储桶名称(需要是不存在的桶名称)以及用于认证的用户名密码 ## Production [env.production] r2_buckets = [ { binding = "REGISTRY", bucket_name = "r2-image-registry-prod" } ] [env.production.vars] USERNAME = "<your-username>" PASSWORD = "<your-password>" 安装 Wrangler CLI $ npm install -g wrangler $ wrangler --version $ wrangler login 它会调用浏览器弹出网页进行授权,点击同意 创建 R2 存储桶 $ npx wrangler --env production r2 bucket create r2-image-registry-prod 部署 Worker $ npx wrangler deploy --env production 部署成功后会输出镜像仓库地址:https://r2-registry-production.kissbug8720.workers.dev 在 Cloudflare 控制台「Workers & Pages」页面可以看到刚创建的 Worker 更新用户名/密码可以执行以下命令 $ npx wrangler secret put USERNAME --env production $ npx wrangler secret put PASSWORD --env production 推送/拉取镜像进行测试 注意以下命令中的参数替换为你自己的地址和用户名 # 设置环境变量 $ export USERNAME=dong $ export REGISTRY_URL=r2-registry-production.kissbug8720.workers.dev # 登录/推送 $ docker login --username $USERNAME $REGISTRY_URL $ docker pull alpine:latest $ docker tag alpine:latest $REGISTRY_URL/alpine:latest $ docker push $REGISTRY_URL/alpine:latest 在 R2 控制台上可以看到镜像已存储在 R2 桶 # 拉取测试 $ docker rmi alpine:latest $REGISTRY_URL/alpine:latest $ docker pull $REGISTRY_URL/alpine:latest 拉取成功 测试用户权限 可以通过 Curl 指定用户名密码的方式查询镜像列表来验证用户权限 $ curl -X GET -u dong:mypasswd https://r2-registry-production.kissbug8720.workers.dev/v2/_catalog 重新部署 如果修改了 wrangler.toml 配置文件(例如:添加启用 Logs 的配置) # wrangler.toml (wrangler v3.78.6^) [observability] enabled = true 可以重新执行命令部署 $ npx wrangler deploy --env production 启用日志后调试代码看日志很方便 使用 JWT 认证方式 这里官方仓库没有仔细介绍,看代码才捋顺通 需要注意 JWT 认证方式和用户名密码在当前 worker 实现上是二选一的,启用 JWT 后用户名密码认证将失效 生成并配置公钥 生成公钥密码对,详参:《再思 JWT 的使用场景和算法选择》,可直接运行以下命令 # 私钥 $ openssl ecparam -name prime256v1 -genkey -noout -out ec_private.pem # 公钥 $ openssl ec -in ec_private.pem -pubout -out ec_public.pem 接下来使用在线工具:JWK Generator 将 ec_public.pem 的内容转换为 JWK 格式的 Key 粘贴到左侧,其它参数不用选择,点击 “Generate” 生成的结果在下方的 “PEM Generation Results” 将 JSON 格式的 Key 复制 { "kty": "EC", "kid": "5910d7df-3a81-4a08-acd0-5dcd58486ed2", "crv": "P-256", "x": "hBBteH7wFAkCjoEmBMrKM9_5XtxdJLGMXMDL3QaT7nY", "y": "lSFOx774nFGYnV_vluA-Elp5Lv64uszu8pLzH7nOJU0" } 借助在线工具:https://www.base64encode.org/ 将 JSON Key 使用 Base64 Encode 编码,这就得到了 JWK ewogICJrdHkiOiAiRUMiLAogICJraWQiOiAiNTkxMGQ3ZGYtM2E4MS00YTA4LWFjZDAtNWRjZDU4NDg2ZWQyIiwKICAiY3J2IjogIlAtMjU2IiwKICAieCI6ICJoQkJ0ZUg3d0ZBa0Nqb0VtQk1yS005XzVYdHhkSkxHTVhNREwzUWFUN25ZIiwKICAieSI6ICJsU0ZPeDc3NG5GR1luVl92bHVBLUVscDVMdjY0dXN6dThwTHpIN25PSlUwIgp9 这个 Encoded JWK 就是 JWT_REGISTRY_TOKENS_PUBLIC_KEY 环境变量所需的内容 执行以下命令将 Key 应用设置到 Cloudflare $ npx wrangler secret put JWT_REGISTRY_TOKENS_PUBLIC_KEY --env production <<EOF ewogICJrdHkiOiAiRUMiLAogICJraWQiOiAiNTkxMGQ3ZGYtM2E4MS00YTA4LWFjZDAtNWRjZDU4NDg2ZWQyIiwKICAiY3J2IjogIlAtMjU2IiwKICAieCI6ICJoQkJ0ZUg3d0ZBa0Nqb0VtQk1yS005XzVYdHhkSkxHTVhNREwzUWFUN25ZIiwKICAieSI6ICJsU0ZPeDc3NG5GR1luVl92bHVBLUVscDVMdjY0dXN6dThwTHpIN25PSlUwIgp9 EOF 执行成功 生成用于认证的 JWT Token Python3 代码示例(摘自:《再思 JWT 的使用场景和算法选择》),此处不多做介绍 import time import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend # 定义常量 EC_PRIVATE_KEY_PATH = "./ec_private.pem" EC_PUBLIC_KEY_PATH = "./ec_public.pem" # 加载私钥 def load_private_key(private_key_path): with open(private_key_path, "rb") as key_file: private_key = serialization.load_pem_private_key( key_file.read(), password=None, backend=default_backend() ) return private_key # 加载公钥 def load_public_key(public_key_path): with open(public_key_path, "rb") as key_file: public_key = serialization.load_pem_public_key( key_file.read(), backend=default_backend() ) return public_key # 生成 JWT 令牌 def generate_jwt_token(private_key, algorithm): payload = { "iat": int(time.time()), # 使用秒级时间戳 "username": "v0", "capabilities": ["pull", "push"], # "aud": "r2-registry-production.kissbug8720.workers.dev" "iat": int(time.time()) # 使用秒级时间戳 } return jwt.encode(payload, private_key, algorithm=algorithm) # 验证 JWT def verify_jwt_token(token, public_key, algorithm): try: decoded = jwt.decode(token, public_key, algorithms=[algorithm]) return decoded except jwt.InvalidTokenError: return None # 主函数 def __main__(): algorithms = [ ("ES256", EC_PRIVATE_KEY_PATH, EC_PUBLIC_KEY_PATH), ] for algorithm, private_key_path, public_key_path in algorithms: # 加载私钥和公钥 private_key = load_private_key(private_key_path) public_key = load_public_key(public_key_path) # 生成 JWT jwt_token = generate_jwt_token(private_key, algorithm) print(f"生成的 {algorithm} JWT:", jwt_token) # 验证 JWT decoded_payload = verify_jwt_token(jwt_token, public_key, algorithm) if decoded_payload: print(f"{algorithm} JWT 验证成功:", decoded_payload) else: print(f"{algorithm} JWT 验证失败") print("---") if __name__ == "__main__": __main__() 输出 这里的 JWT 即使用 ec_private.pem 签名后的认证所需 Token,可以使用 Curl 请求 Registry 获取镜像列表进行权限验证(执行前注意替换 Token) $ curl -X GET -u dong:<your-jwt> https://r2-registry-production.kissbug8720.workers.dev/v2/_catalog 输出 {"repositories":["alpine"]} 简单在说下 JWT 在此处的流程,我们将 JWK 格式的公钥提交到 Cloudflare 后,推送的代码从环境变量 JWT_REGISTRY_TOKENS_PUBLIC_KEY 可以获取到 Key,然后我们通过 Python 脚本借助私钥生成了 JWT,JWT 的参数中允许 PUSH 和 PULL 能力,签名后的 JWT 相当于密码,需要私密保存,请求 Cloudflare 时,Worder 代码中判断有 JWK 环境变量,就使用 JWT 的认证方式,使用签名验证这个 Token 是不是我们通过私钥签发的,校验没问题后读取出权限,进而允许 PUSH 和 PULL,完成认证过程 本例中的 Worker 代码是从 Github 下载下来的基础功能代码,遇到问题以代码为准,调试代码可以借助 Cloudflare Worker 中的 Logs 查看日志去定位解决 如需停用 JWT 认证,在 Cloudflare 控制台删除 JWT_REGISTRY_TOKENS_PUBLIC_KEY 环境变量即可 $ curl -X GET -u dong:mypasswd https://r2-registry-production.kissbug8720.workers.dev/v2/_catalog 配置镜像回源(未调试通过) 没有测试通过,暂也记录下过程,给后续尝试的人一个参考 从日志看登录到 DockerHub 成功,但后续的请求报错 设置 REGISTRY_TOKEN 这个是 DockerHub 的用户密码 $ npx wrangler secret put REGISTRY_TOKEN --env production 编辑配置文件,添加 REGISTRIES_JSON 配置,注意替换为你的 DockerHub 用户名,配置中的变量 REGISTRY_TOKEN 不要修改,代码回自动从刚设置的 REGISTRY_TOKEN 环境变量中获取 [env.production.vars] REGISTRIES_JSON = "[{ \"registry\": \"https://registry.hub.docker.com\", \"password_env\": \"REGISTRY_TOKEN\", \"username\": \"kissbug8720\" }]" DockerHub 的用户名密码可以使用以下命令登录进行验证确认 $ docker login https://registry.hub.docker.com 执行命令重新部署 $ npx wrangler deploy --env production 拉取镜像进行测试 $ export REGISTRY_URL=r2-registry-production.kissbug8720.workers.dev $ docker rmi nginx:latest $REGISTRY_URL/nginx:latest $ docker pull $REGISTRY_URL/nginx:latest 到这一步,也翻了会儿源码,暂时也就先研究到这里,兴许是个 BUG... 以后真用到再说 小小收获 测试 fallback 功能时发现 README.md 中的配置示例有误, 这不白给的 BUG! 感觉 PR 在向我招手🙋 赶紧提交了一个 PR(#63),一天后被合并,也许是近些年第一次提交 PR 同时也被 Merged,很开心 参考 Github: serverless-registry

2024/10/10
articleCard.readMore

再思 JWT 的使用场景和算法选择

在使用第三方服务的时候,我们总会看到其提供 API Key,这是一种简单的验证用户身份的方法,服务端存储着这个 API Key,可以通过 Key 来确定用户权限、吊销 Key 等 相比于 API Key,JWT 有自己的特点和适用场景,如果你不太了解 JWT,可以阅读阮一峰的:《JSON Web Token 入门教程》 回顾 JWT 特点 自包含 JWT 是一个自包含的 Token,它包含了所有必要的信息,不需要服务端存储 Token 的状态。JWT 的载荷(payload)部分可以包含用户信息、权限信息、有效期等。 无状态 由于 JWT 自包含所有必要的信息,服务端不需要存储 Token 的状态,所以 JWT 非常适合用于无状态的应用架构,减少了服务端的负担。 传输安全 JWT 使用密钥进行签名,可以验证 Token 的合法性和完整性,签名或加密确保了 JWT 在传输过程中不会被篡改。 扩展性 JWT 的载荷部分是一个 JSON 对象,可以包含自定义的声明(claims),这使得 JWT 非常灵活,可以根据具体需求扩展其功能。 注意事项:在 JWT 中,避免包含密码等敏感信息,JWT 可防篡改但内容可见。 不可撤销 JWT 一旦生成,无法单独撤销。如果 JWT 被泄露,攻击者可以滥用 Token,直到 Token 过期,因此,JWT 的有效期通常设置得较短,以减少风险。(撤销需要借助服务器有状态机制) 例举一种 JWT 场景误用 以 HS256 算法为例子,以下是生成和验证的代码示例 import time import jwt KEY_SECRET = "abncdsr462*********zk52hav3x86dm" # 生成 JWT 令牌 def generate_jwt_token(key_secret, algorithm="HS256"): payload = { "iat": int(time.time()) } token = jwt.encode(payload, key_secret, algorithm=algorithm) return token # 校验 JWT 令牌 def verify_jwt_token(token, key_secret, algorithm="HS256"): try: payload = jwt.decode(token, key_secret, algorithms=[algorithm]) return payload except jwt.InvalidTokenError: return None # 生成令牌 t = generate_jwt_token(KEY_SECRET) print(f"生成的 JWT:", t) # 校验令牌 verified_payload = verify_jwt_token(t, KEY_SECRET) if verified_payload: print(f"JWT 验证成功:", verified_payload) else: print("JWT 验证失败") 正确的 JWT 使用场景,会将 JWT eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3Mjc2NjU2MTR9.ItXRitJXiwcDx7X1PHeilgwAs9vt4xaa1lHNKgC3wak 提供给客户端 客户端调用服务端时将其再传给服务端,服务端可以验证这个 Token 没有被篡改、同时从中获取信息 一个错误的用法是将 HS256 的 KEY_SECRET 提供给外部平台,由外部平台生成 JWT。这种方式相当于将 JWT 机制降级为 API Key,使得 JWT 的安全性从“JWT 丢失则其权限会被滥用” 降级为 “API Key 泄露则攻击者可无限制地伪造 JWT,可以对系统进行任意请求”,这属于比较严重的安全漏洞 所以,当决定采用 JWT,就应尽量遵守其规范,避免 “新奇” 的使用方式 不同 JWT 算法使用示例(Python3) JWT 支持 HS256(密钥)、RS256(基于 RSA)、ES256(基于椭圆曲线)、PS256(基于 RSA-PSS)等加密算法,数字表示位数,越长越安全,除 256 外还支持 384 和 512 HS256 在上文已提供示例,它比较简单、使用的是密钥字符串,也是最常用的 JWT 加密算法,另外三者基于的公钥密码对 生成 RS256 公钥密码对 # 私钥 $ openssl genpkey -algorithm RSA -out rsa_private_key.pem -pkeyopt rsa_keygen_bits:2048 # 公钥 $ openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem 生成 ES256 公钥密码对 # 私钥 $ openssl ecparam -name prime256v1 -genkey -noout -out ec_private.pem # 公钥 $ openssl ec -in ec_private.pem -pubout -out ec_public.pem 这里选择的曲线进一步说明,prime256v1 P-256,Go 语言的 x509 包默认支持的椭圆曲线是 P-256 和 P-384,另一种常见的曲线是 secp256k1,它是比特币和其他加密货币中常用的椭圆曲线,Go 语言的 x509 包默认不支持它,Python3 是支持的 生成 PS256 公钥密码对 # 私钥 $ openssl genpkey -algorithm RSA -out ps_private_key.pem -pkeyopt rsa_keygen_bits:2048 # 公钥 $ openssl rsa -in ps_private_key.pem -pubout -out ps_public_key.pem 代码示例 import time import jwt from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend # 定义常量 RSA_PRIVATE_KEY_PATH = "./id_rsa" RSA_PUBLIC_KEY_PATH = "./id_rsa_pem.pub" EC_PRIVATE_KEY_PATH = "./ec_private.pem" EC_PUBLIC_KEY_PATH = "./ec_public.pem" PS_PRIVATE_KEY_PATH = "./ps_private_key.pem" PS_PUBLIC_KEY_PATH = "./ps_public_key.pem" # 加载私钥 def load_private_key(private_key_path): with open(private_key_path, "rb") as key_file: private_key = serialization.load_pem_private_key( key_file.read(), password=None, backend=default_backend() ) return private_key # 加载公钥 def load_public_key(public_key_path): with open(public_key_path, "rb") as key_file: public_key = serialization.load_pem_public_key( key_file.read(), backend=default_backend() ) return public_key # 生成 JWT 令牌 def generate_jwt_token(private_key, algorithm): payload = { "iat": int(time.time()) # 使用秒级时间戳 } return jwt.encode(payload, private_key, algorithm=algorithm) # 验证 JWT def verify_jwt_token(token, public_key, algorithm): try: decoded = jwt.decode(token, public_key, algorithms=[algorithm]) return decoded except jwt.InvalidTokenError: return None # 主函数 def __main__(): algorithms = [ ("RS256", RSA_PRIVATE_KEY_PATH, RSA_PUBLIC_KEY_PATH), ("ES256", EC_PRIVATE_KEY_PATH, EC_PUBLIC_KEY_PATH), ("PS256", PS_PRIVATE_KEY_PATH, PS_PUBLIC_KEY_PATH), ] for algorithm, private_key_path, public_key_path in algorithms: # 加载私钥和公钥 private_key = load_private_key(private_key_path) public_key = load_public_key(public_key_path) # 生成 JWT jwt_token = generate_jwt_token(private_key, algorithm) print(f"生成的 {algorithm} JWT:", jwt_token) # 验证 JWT decoded_payload = verify_jwt_token(jwt_token, public_key, algorithm) if decoded_payload: print(f"{algorithm} JWT 验证成功:", decoded_payload) else: print(f"{algorithm} JWT 验证失败") if __name__ == "__main__": __main__() 执行输出 基于椭圆曲线算法的 ES256 生成的签名长度,相较于其它算法可谓之遥遥领先,在 JWT 长度敏感的场景下可以优先选择 另外 PS256 和 RS256 相比较,查阅的资料如下: PS256 使用 RSASSA-PSS,这是一种概率签名方案,引入了随机性,使得每次签名都是唯一的,因为引入了随机性,PS256 提供了比 RS256 更高的安全性,同样资源消耗也会更高一些 另外从 JWT 的是用场景来看,签发与核验 JWT 都由服务端自己完成,所以不存在跟客户端的兼容性问题,选择算法会更加的灵活 综上:推荐优先使用 ES256 算法,安全性高,同时签名、密钥对都是最小的 线上校验工具 JWT.io 这个调试工具支持的算法广泛,本文中提及的算法生成的 JWT 内容都通过 https://jwt.io/ 进行了二次核对 另外可以在 https://jwt.io/libraries 找到相应语言库支持的算法列表

2024/10/6
articleCard.readMore

黑龙江・木兰县属小村落的星空(2024)

收获的季节 秋高气爽,结实的玉米棒是一年的辛劳 夕阳西下 日落西山、夕阳西下 满天星辰 尼康 Z30 + 18140,M 档(曝光 20s, 2000 ISO,光圈 F3.5),老丈人帮忙打手电,无意间照了几下天空,就出来了上图比较有趣的效果 北斗七星 北斗七星和北极星,值得被记录 天空 远处的灯光渲染的天空泛黄,地景和天空是很好的搭配 星轨・初 人生中第一张星轨,M 档(曝光 20s, 2000 ISO,光圈 F3.5),间隔拍摄 21s,340 张(约 2 小时) 晚上拍完发现相机机身和镜头上都有一层露水,星轨的光点变粗,时常也不太够 下图是第二次尝试 星轨・旋转 第二天的夜晚也是万里如云,参数相同,间隔拍摄了约 500 张(约 3 小时) 有了前一天的经验,这次用个薄衣服将相机包裹,拿下了镜头的 UV 镜(进光更多),不知道是经过调整还是天气湿度下降,没再出现露水 在城市久了,有机会在夜晚的乡村看日落、看夕阳和星空,相机拍出来的照片可能很美,但远没有肉眼可见银河来的震撼 补一张第一天拍摄和第二天拍摄的计划图 计划图 P.S. 拍摄所在地为二级光污染地区

2024/10/3
articleCard.readMore

Nginx 启用 HTTPS/3

前置条件 系统版本:Ubuntu 20.04 Nginx 版本:1.18(稍后需要升级) 用来测试的服务:当前存在可用的 HTTPS/2 网站 OpenSSL:3.0.2(可选升级) 检查 Nginx 版本并升级 我的系统恰好是 Ubuntu 20.04 + Nginx 1.18,可以参考这篇文章升级 Nginx:Upgrading Nginx from version 1.18 to 1.25 (Ubuntu 20.04) # 备份 Nginx 配置 $ sudo cp -r /etc/nginx /etc/nginx-backup # 添加 Nginx Repository $ wget http://nginx.org/keys/nginx_signing.key $ sudo mv nginx_signing.key /etc/apt/trusted.gpg.d/nginx_signing.gpg # sudo apt-key add nginx_signing.key $ echo "deb http://nginx.org/packages/mainline/ubuntu `lsb_release -cs` nginx" | sudo tee /etc/apt/sources.list.d/nginx.list # 更新软件列表 $ sudo apt update # 安装最新的 Nginx $ sudo apt install nginx # 检查版本 $ nginx -v nginx version: nginx/1.27.1 已升级到了 1.27 版本 Nginx 示例配置 没有特别需要说明的,字段含义可以问下 AI,修改配置后记得 nginx -s reload 加载配置 server { # HTTP/3 listen 443 quic reuseport; listen [::]:443 quic reuseport; # HTTP/2 listen 443 ssl; listen [::]:443 ssl; http2 on; server_name yasking.org; merge_slashes off; # 证书 ssl_certificate /etc/letsencrypt/live/yasking.org-0001/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yasking.org-0001/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; # ssl_prefer_server_ciphers on; ssl_ciphers 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; # O-RTT QUIC connection resumption ssl_early_data on; # Add Alt-SvC header to negotiate HTTP/3. add_header Alt-Svc 'h3=":443";ma=86400'; ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; # OCSP Stapling ssl_stapling on; ssl_stapling_verify on; resolver 8.8.8.8; # 启用 HSTS add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header X-Content-Type-Options nosniff; # 日志配置 access_log /var/log/nginx/yasking.access.log; error_log /var/log/nginx/yasking.error.log; root /usr/share/nginx/html; index index.html index.htm; location / { try_files $uri $uri/ =404; } } # 强制 HTTP 重定向到 HTTPS server { listen 80; server_name yasking.org; return 301 https://$server_name$request_uri; } 升级 OpenSSL 版本(可选) 当前版本 3.0.2,当前最新是 3.3.2,不升级也可以,升级步骤如下 # 下载当前最新的 OpenSSL 程序源码 $ wget https://www.openssl.org/source/openssl-3.3.2.tar.gz # 解压缩并生成编译配置 $ sudo tar -zxvf openssl-3.3.2.tar.gz -C /usr/src/ $ cd /usr/src/ $ sudo ln -s openssl-3.3.2 openssl $ cd openssl $ ./config --prefix=/usr/local/ssl # 机器 CPU 不太行的话执行 sudo make -j 1 && sudo make install $ sudo make -j $(nproc) && sudo make install # 新建 /etc/ld.so.conf.d/openssl-3.0.conf 添加一行 /usr/local/ssl/lib64,此处的 /usr/local/ssl 根据 ./config 的 --prefix 参数确定 $ cat /etc/ld.so.conf.d/openssl-3.0.conf /usr/local/ssl/lib64 # 备份原 OpenSSL 可执行程序,创建新的链接 $ sudo mv /usr/bin/openssl /usr/bin/openssl_bak $ sudo ln -s /usr/local/ssl/bin/openssl /usr/bin/openssl # 链接运行时的动态链接库 $ sudo ldconfig # 查看库的安装情况 $ sudo ldconfig -v HTTPS/3 访问验证 Curl 命令 $ docker run -t --rm alpine/curl-http3 curl --http3 -I https://yasking.org:443 macOS Chrome 129 Windows Firefox 130 在火狐上有个叫「HTTP Version Indicator」的扩展,安装后可以显示 HTTP 协议版本 为什么不是 macOS 的 Firefox 测试?因为它一直显示 HTTP/2,清除缓存也没用,我暂不清楚原因 在线网站:https://http3check.net 在线网站:https://http3test.com/ 最后 HTTP/3 虽然不是一个新技术,但也在快速发展和完善中,可能今天的配置和参数符合标准,后天浏览器就不认降级回 HTTP/2 云平台、各个基础组件库都需要陆续迭代支持,需要一个过程,本篇借助 Nginx 使得主域名支持通过 HTTP/3 协议访问 相比于一两年前,部署方便很多,Nginx 和 OpenSSL 都不用自己编译,安装新版即可,先这样 参考 Leveraging HTTP/3 with Nginx: A Step-By-Step Guide How to Enable HTTP/3 Upgrade Your Encryption: Installing the Latest OpenSSL 3 on Ubuntu 20.04

2024/9/29
articleCard.readMore

优化网站的 SSL Labs 总体评级为 A+(禁用旧协议 & 启用 HSTS)

使用 SSL Labs 检测 安全等级为 B,接下来调整服务器配置,使其升到 A+ PS:几年前部署博客,印象中记得测试评分还是 A,本篇文章根据 SSL Labs 提示对博客安全等级进行提升,主要涉及以下两点 禁用 TLS 1.0 和 TLS 1.1,开启 TLS 1.3 支持 启用 HSTS 支持 TLS 支持版本调整 支持 TLS 1.0 也会导致 PCI DSS 不合规,使用 https://myssl.com 进行检测时会提示 PCI 安全标准委员会规定 2018 年 6 月 30 日之后,开启 TLS1.0 将导致 PCI DSS 不合规 具体参考:《更严格的 PCI DSS 合规标准》 简单说,如需符合 PCI DSS 至少禁掉 TLS 1.0,想要评分摆脱 B,TLS 1.1 也要禁掉,同时推荐开启 TLS 1.3,未来主流应该是 TLS1.2 + TLS1.3 设置支持的最低 TLS 版本为 1.2 又拍云 Bitiful 图床 Cloudflare Page & Cloudflare R2 图床 无需设置,Cloudflare 默认关闭 TLS 1.0 和 TLS 1.1,开启 TLS 1.3 支持 测试服务器是否支持 TLS 1.3 版本 命令如下 $ openssl s_client -connect blog.yasking.org:443 -tls1_3 | grep TLS 不支持 TLS 1.3 支持 TLS 1.3 修改 TLS 版本支持后,Rating 将变为 A,想要 A+,需要进一步开启 HSTS 启用 HSTS(HTTP Strict Transport Security) HSTS(HTTP 严格传输安全)是一种机制,服务器可以通过它指示浏览器在与它通信时必须使用安全连接。它可以成为保护用户及其数据的隐私和安全的有效工具。 然而,当首次连接到一个HSTS主机时,浏览器并不知道是否应该使用安全连接,因为它从未从该主机收到过HSTS头。因此,活跃的网络攻击者可能会阻止浏览器进行安全连接(用户可能永远不会意识到有问题),为了缓解这种攻击,主流浏览器中添加了一个默认强制执行 HSTS 的主机列表,当用户首次连接到这些主机之一时,浏览器将知道它必须使用安全连接。如果网络攻击者阻止与服务器的安全连接,浏览器将不会尝试通过不安全的协议进行连接,从而保持用户的安全。 仅开启 HSTS 但不提交到 Preload List HSTS 开启前状态 开启 HSTS 即添加一个 Header 返回头(strict-transport-security: max-age=15552000),Nginx 配置片段如下 add_header Strict-Transport-Security "max-age=15552000"; 这里也以又拍云为示例,如果你仅仅希望单域名(主域名、子域名)的评分上到 A+ 而不关心是否进入 Preload List,那么配置的时候根据情况选择是否包含子域名,同时不要勾选预加载选项,缓存时间推荐 180 天 需要注意不存在满足 “提交 Preload List 要求” 但不提交的 “中间状态”,如果你不想提交到 Preload List,请务必不要满足其要求(如不勾选 “预加载”),否则很可能被其他人提交,提交不需要验证网站所有者 仅配置 max-age 就可以通过 SSL Labs 检测,再次检查子域名,评分变为 A+,目标已经达到了,如果你还不确定是否会长时间保持所有域名都上 HTTPS,可以点到为止 启用 HSTS 并提交到 Preload List 提交到 Preload List 就是提交到 https://hstspreload.org 提交成功大概几周后,主流浏览器将会内置到自身的浏览器中,Firefox 浏览器关于 Preloading HSTS 的说明介绍了它的列表基于 hstspreload.org 的数据整合,虽然其由谷歌维护,但它是一个事实上的通用列表 提交 Preload List 之前,首先要了解其注意事项,同时服务需要满足一定条件 注意:开启后则不能随时关闭,不能再实时降级为 HTTP 如需关闭强制 HTTPS 访问,必须首先在服务器上禁用 HSTS 配置,并等待 max-age 过期,以确保每个浏览器都知晓此更改,然后才能禁用 HTTPS,平均的 max-age 为六个月,如果在禁用 HSTS 之前移除 HTTPS,网站将无法访问,最长可达 max-age 的时间,或者直到您再次支持 HTTPS,由于在启用 HSTS 的网站上禁用 HTTPS 可能会产生这些后果,强烈建议您在启用此功能之前已经有一个稳定的 HTTPS 服务 提交 Preload List 的必备条件 1)提供有效的证书 即网站能够通过 HTTPS 正常访问(e.g. https://yasking.org / https://blog.yasking.org) 建议开启 HSTS 前,你的网站已经启用 HTTPS 一段时间且稳定运行 2)如果你在监听 80 端口,请将 HTTP 重定向到同一主机的 HTTPS 网站能自动重定向 HTTP 到 HTTPS,或者不监听 80 端口的 HTTP 访问,推荐前者 3)所有子域名都通过 HTTPS 提供服务(e.g. *.yasking.org / *.*.yasking.org) 特别注意 HSTS 预加载适用于所有子域名,包括非公开访问的内部子域名 也需要注意如果 www 子域名在 DNS 解析中存在,则它也需要支持 HTTPS 4)在 HTTPS 请求的基域上提供 HSTS 头 max-age 必须至少为 31536000 秒(1年) 必须指定 includeSubDomains 指令 必须指定 preload 指令 如果你在 HTTPS 站点上提供额外的重定向,该重定向仍必须包含HSTS头(而不是它重定向到的页面) 可以放心大胆的在 https://hstspreload.org 点击提交,它会自动检测,不满足条件会给出提示,照着改就好,直到满足条件,最终提交前还有一步确认 如果你的主域名在 Cloudflare 和 又拍云,满足提交 Preload List 的配置如下 位置在 “网站” - “SSL/TLS” - “Edge Certificates” - “HTTP Strict Transport Security (HSTS)” 又拍云 补充说明:当主域名被预加载收录后,子域名的访问也会继承主域名的策略,重复在子域名返回 Header 对提交收录没有用处 为了提交 HSTS,我的网站主域名解析到了 VPS 上的 Nginx 默认欢迎页面,这是我的 Nginx 配置 server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name yasking.org; merge_slashes off; # 证书 ssl_certificate /etc/letsencrypt/live/yasking.org-0001/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yasking.org-0001/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; ssl_ciphers 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384'; ssl_session_timeout 1d; ssl_session_cache shared:SSL:50m; ssl_session_tickets off; # OCSP Stapling ssl_stapling on; ssl_stapling_verify on; resolver 8.8.8.8; # 启用 HSTS add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header X-Content-Type-Options nosniff; # 日志配置 access_log /var/log/nginx/yasking.access.log; error_log /var/log/nginx/yasking.error.log; } # 强制 HTTP 重定向到 HTTPS server { listen 80; server_name yasking.org; return 301 https://$server_name$request_uri; } 我们访问主域名确认已开启 HTTPS 同时查看主域名的 Header,其返回了满足条件的 Strict-Transport-Security 头部 以下是提交到 https://hstspreload.org 时的截图记录 如果提交子域名会提示需要输入网站主域名 如果检查没问题页面会变为绿色,同时有两个选项,提交前需要再次确认 显示提交成功 过几周再次查看当前链接:https://hstspreload.org/?domain=yasking.org 获取提交状态(注意将 domain 换成你自己的地址) 此时查看会显示 Pending 状态 一旦收录,那么恭喜你没有短期的后悔药让你的网站再回退到 HTTP,请确保你当前域名以及其所有子域名持续支持 HTTPS 访问! 后悔药参考链接:https://hstspreload.org/removal/ 启用 HSTS 后,经过一段时间,提交的域名会出现在 Chromium 代码仓库中:transport_security_state_static.json 修改后的检测结果 https://myssl.com 结果 SSL Labs 结果 HSTS 收录了! 算算将近两个月的时间 “Status: yasking.org is currently preloaded.” 在chromium 代码仓库也可以看到网站已进入源码 参考 How do browsers get HSTS preload data?

2024/9/28
articleCard.readMore

了解 OCSP Stapling 证书吊销验证机制

最开始是在 Bitiful CDN 上看到开启 OCSP stapling 开关,才知道有这个技术概念 从吊销证书说起 非对称加密是证书体系的基础,每个公钥都有一个关联的私钥,该私钥由网站所有者私密保存,如果有人窃取到证书的私钥,他们就可以冒充该网站,私钥泄露属于严重风险。 只要存在私钥泄露的概率,那么它就一定就会发生,所以私钥泄露后第一时间应该 吊销证书,证书吊销是降低密钥泄露风险的一种方法,网站所有者可以通过通知证书颁发者不应再信任该证书来撤销受损的证书。 回到用户视角,当网站管理员吊销了证书,用户借助浏览器请求网站,浏览器如何判断网站证书存在安全风险,并给出提示或禁止用户访问呢? 网络浏览器可以通过多种方式检查网站的证书是否被吊销。最著名的机制是证书吊销列表 Certificate revocation list (CRL) 和在线证书状态协议 (OCSP)。 CRL 是由 CA 撤销的证书序列号的签名列表,OCSP 是一种协议,可用于向 CA 查询给定证书的吊销状态,OCSP 响应包含证书未吊销的签名断言 查看网站证书 在进一步了解其它概念之前,我们可以通过浏览器获取到网站的证书,查看其信息以便稍后更好的理解概念 上图是在 FireFox 浏览器,点击绿色锁头,进入到 “View Certificate” 看到的信息,从左到右依次是网站的终端实体证书,R11 是 Let's Encrypt 的中级证书(Intermediate Certificate),用于签发网站终端实体证书 ISRG Root X1 是 Let's Encrypt 的根证书(Root Certificate),这个证书是整个信任链的起点,由受信任的根证书颁发机构(CA)签发 大多数操作系统和浏览器都内置了 ISRG Root X1 证书,因此用户可以信任由 Let's Encrypt 颁发的证书,R11 证书的存在是为了在根证书和终端实体证书之间建立信任链 我们可以点击 PEM (cert) 下载网站的证书,另外两个证书同理,我这里下载了三者并重命名 接下来回到上节提到的 CRL 和 OCSP,之后我们将进一步了解 OCSP Stapling 的作用和实现 证书吊销列表 (CRL) 证书吊销列表(Certificate Revocation List,简称 CRL)是公钥基础设施(PKI)中的一种重要机制,用于列出已被吊销的数字证书。CRL 由证书颁发机构(CA)发布,包含已吊销证书的序列号和其他相关信息。客户端(如浏览器)可以使用 CRL 来检查证书的有效性,确保不会使用已被吊销的证书。 在 FireFox 浏览器查看 Let's Encrypt 签发的证书,切换到证书链中的 R11 中间证书,其通过 CRL 来验证自身证书是否有效 补充:使用命令行也可以查询 CRL URI $ openssl x509 -in r11.pem -text -noout | grep -A 4 "CRL Distribution Points" 证书中列出的 CRL 文件下载地址是:http://x1.c.lencr.org/ 浏览器打开链接可下载到二进制文件,它就是二进制格式的 CRL,不能直接用文本编辑器打开,稍后会补充如何查看和验证 关于 Let's Encrypt 的 lencr.org 域名,有篇文章对其进行了说明,有兴趣了解可以看:Let's Encrypt - lencr.org 简要说就是: 由 c.lencr.org 子域名提供证书吊销列表 (CRL) 由 o.lencr.org 子域名提供在线证书状态协议(OCSP) 由 i.lencr.org 子域名提供中间 “颁发者” 证书的副本,这些证书要么由我们的根证书之一签名,要么由另一个证书颁发机构 (CA)“交叉签名”。 另外使用短域名能减小证书大小,这在互联网海量的证书请求下能节省很多资源 除了域名,我们也可以见到 Let's Encrypt 的证书吊销列表下载地址不带 .crl 后缀(猜测也是为了减少链接长度),很多证书集成的证书吊销列表都带有 .crl 路径 例如 Amazon 证书 CRL 下载地址 以及 Google 证书 CRL 下载地址 接下来可以进一步了解 CRL 文件 CRL 文件的解析与校验(可跳过不影响阅读) 可以使用这个在线工具:Parse CRL 可以选择 “From file” 上传 crl 文件,也可以选择 “From URI” 填写证书中列出的 CRL Endpoints 地址(例如:http://x1.c.lencr.org/),然后点击 “Parse CRL” 从解析的内容来看,Version 版本是 2,Issuer name 是 ISRG Root X1,也就是 Let's Encrypt 的 Root 根证书 其中的 CRL Entries 表示吊销记录,当前为空表示这个 CRL 承载的信息中 ISRG Root X1 没有吊销过其签发的证书 查看完 CRL 文件内容,这里还有一些重点: 为什么 CRL 下载地址提供 HTTP 而不是 HTTPS?(Let's Encrypt 文档对 OCSP HTTP 做了解释,CRL 的获取同理) OCSP 响应始终通过 HTTP 提供。如果通过 HTTPS 提供服务,则会出现“无限循环”问题:为了验证 OCSP 服务器的证书,客户端必须使用 OCSP。 请求 CRL 文件的地址是 HTTP 的 http://x1.c.lencr.org/ 而非 HTTPS 加密,如何确保浏览器请求到的 CRL 未被篡改? OCSP 响应本身带有时间戳和加密签名,因此在这种情况下不需要 TLS 的防篡改属性。 还是这个网页,点击 “Verify CRL” 来验证其有效性 选择 “From certificate”,选择上文下载回来的 ISRG-Root-X1.pem 点击校验,验证签名成功,符合预期 我们手动加载 CRL 文件并校验,浏览器也是这样经过验签确定获取到的 CRL 真实完整,同时确认 R11 证书有效后,R11 中间证书也可以验证其签发的网站终端实体证书有效性,反之,R11 被吊销,那么由其签发的叶子证书也不再可信 CRL 存在的部分问题 CRL(证书吊销列表)存在一些时效性和规模问题,时效性问题主要体现在 CRL 更新频率低和延迟高,可能导致客户端无法及时获取最新的吊销信息。规模问题则表现为 CRL 文件随着时间的推移变得越来越大,下载和解析时间增加,影响性能。 CRL 虽然有写问题,但不影响使用,例如 Delta CRL、Partitioned CRL、Caching 等技术都可以缓解其问题,此处不做过多发散,仅需知道 CRL 并不是被抛弃的技术即可,很多证书中 CRL 和 OCSP 同时使用,互为补充,确保证书的吊销状态能够及时、准确地被验证 接下来了解 OCSP 协议 OCSP 在线证书状态协议 为了克服 CRL 的时效性和规模问题,OCSP(Online Certificate Status Protocol,在线证书状态协议)提供了一种更实时的查询证书状态的方法。 Let's Encrypt 签发的网站叶子证书使用的是 OCSP 验证,所以在学习之前,先在 FireFox 浏览器切换到网站证书的标签页 可以看到 OCSP 服务器地址为:http://r11.o.lencr.org 使用如下命令可以实时验证证书有效性 $ openssl ocsp -issuer r11.pem -cert blog-yasking-org.pem -url http://r11.o.lencr.org -text -no_nonce R11 证书有效性已在上文借助 CRL 通过 Root 证书的有效性验证 输出校验成功,可以确定博客 blog.yasking.org 证书是有效状态 OCSP 也并非完美的方案,下节回讲到可用性验证的左右为难 迷失在 CRL 和 OCSP 中的浏览器 本小节内容整理自:https://blog.cloudflare.com/high-reliability-ocsp-stapling/ 有任何疑问建议阅读原文,讲解的更加详细 支持 OCSP 的证书包含响应者的 URL,同样支持 CRL 的证书包含可获取 CRL 的 URL,当浏览器获得作为 HTTPS 连接一部分的证书时,它可以使用证书中嵌入的 URL 下载 CRL 或 OCSP 响应,并在呈现网页之前检查证书是否已被吊销。那么问题就变成了:如果 CRL 或 OCSP 响应请求失败,浏览器应该做什么? 事实证明,这个问题的两个答案都有问题。 Hard-fail 硬失败 —— 不起作用 当浏览器无法获取吊销信息时,硬失败策略会阻止页面并显示警告,这种策略虽然保守、安全但很容易因为网络波动或其它因素误报,导致用户警告疲劳并教会用户单击忽略安全警告,这是一个坏主意 硬故障策略的另一个缺点是,它增加了证书颁发机构保持 OCSP 和 CRL 端点可用和在线的负担,损坏的 OCSP 或 CRL 服务器会成为证书颁发机构颁发的所有证书的中心故障点,如果浏览器遵循硬故障策略,则 OCSP 中断将成为 Internet 中断,整个网络的可用性受到 CA 始终保持其 OCSP 服务在线的能力的限制,构成系统性风险 Soft-fail 软失败 —— 也好不了多少 为避免硬故障策略的缺点,大多数浏览器采用软失败策略进行证书吊销检查,如果吊销信息可用,他们就会依赖该信息,否则会假设证书未吊销并显示没有任何错误的页面 软故障策略存在严重的安全缺陷:一旦攻击者可以阻止 OCSP 请求并拥有网站私钥,他们就可以拦截该站点的传出连接并将已吊销的证书提供给浏览器,浏览器页面将在不提醒用户的情况下加载攻击者页面 在首次访问尚未缓存证书的吊销信息时,软故障策略也会使连接速度变慢 实时 OCSP 检查还有一个额外的缺点:OCSP 请求通过未加密的 HTTP 发送,并绑定到特定证书,它会泄露私人访问站点信息 另外,还有一种方案 —— 预打包 CRL 浏览器预先打包已撤销证书的列表,并通过浏览器更新来分发它们,由于所有被撤销证书的列表非常庞大,因此该列表中仅包含少数高影响力的证书。该技术在 Firefox 中称为 OneCRL,在 Chrome 中称为 CRLSet,这对于一些备受瞩目的撤销来说是有效的,但它绝不是一个完整的解决方案,这种技术不仅没有覆盖所有证书,而且在证书被吊销和证书列表到达浏览器之间留下了一个漏洞窗口 火候差不多了,我们是时候该请出 OCSP Stapling (OCSP 装订) OCSP Stapling 技术 OCSP 装订就像是服务器在返回证书的同时,也将 OCSP 状态一并返回给客户端,使得客户端不用单独请求证书颁发机构(CA)的服务器进行校验,这个过程就像是用订书器将两份文件订在一起一样,因此得名“装订”。 OCSP 允许 Web 服务器通过与证书颁发机构验证来确定 SSL/TLS 证书的状态。这种提高的安全性伴随着一些性能损失:由于浏览器必须与 Web 服务器和证书颁发机构进行通信,网站的加载时间会增加。 OCSP Stapling 优化了 SSL/TLS 握手过程,它允许服务器在握手过程中直接提供证书的状态信息,而不需要客户端去查询 OCSP 服务器。 在某些情况下,OCSP 装订可将连接时间缩短高达 30%,与此同时, OCSP Stapling 修复了与实时 OCSP 获取相关的一些性能和隐私问题 查询网站是否开启了 OCSP Stapling $ openssl s_client -connect blog.yasking.org:443 -status -servername blog.yasking.org 从输出看到 “OCSP Response Data” 说明网站服务器支持 OCSP 装订 测试了一个未开启 OCSP 装订的网站,返回内容如下 也可以使用在线工具,例如:https://entrust.ssllabs.com 国内用户可以使用在线网站:https://myssl.com/ Nginx 开启 OCSP Stapling 示例配置如下 server { listen 443 ssl; server_name example.com; ssl_certificate /path/to/your/certificate.crt; ssl_certificate_key /path/to/your/private.key; # 启用 OCSP Stapling ssl_stapling on; ssl_stapling_verify on; # 指定用于验证 OCSP 响应的根证书 ssl_trusted_certificate /path/to/your/trusted_ca_certs.pem; # 可选:设置 OCSP 响应的缓存时间 resolver 8.8.8.8 8.8.4.4 valid=300s; resolver_timeout 5s; # 其他配置... } 写在做后 当前大多主流的服务和客户端库都支持 OCSP stapling,如果可以,开启它! 参考 High-reliability OCSP stapling and why it matters

2024/9/25
articleCard.readMore

山东・烟台中秋两三日(2024)

之前去过青岛体验不错,遂趁着中秋节的假期到山东半岛的另一侧走走瞧瞧,这两天的天气很好,主要在金沙滩、养马岛溜达,也到了烟大小吃街周边 烟台有着很棒的沙滩,九月中旬的白天,海里还有人在海里 “泡着”,光脚走在沙滩,被海浪拍打,对于内陆城市的人来说很解压,远望大海无垠,这就是“去看海吧” 的意义 要说养马岛的正确打开方式,一定是小电驴吹海风,我们在烟大附近租的 59 到晚上的哈啰电动自行车,骑了十多公里到达养马岛,沿海的路途虽不能一直看海,但路况很好、空气很好、吹吹海风心情也很好 月光如雪 夜晚的小船 养马岛・獐岛附近 小女孩儿 眺望 日落一 日落二 日落三 骑行 童趣 情侣

2024/9/17
articleCard.readMore

分享改造后的博客发布流程和访问链路

经过陆陆续续几个月的调整改造,博客发布和访问链路都有不同程度的优化,分享和记录如下 博客发布流程 改造后,在 Markdown 编辑器内写笔记,运行一个脚本,它会做以下事项(感谢 DeepSeek Chat,我描述了需求,脚本是它生成的) 将文章内的图片收集到一个文件夹并重命名图片 调用 TinyJPG 的 API 进行压缩 将图片文件夹放置到本地的图片库 使用 Rclone 同步到 S3 兼容协议的存储桶,完成图片的发布 将最终的图片 URL 回写到新的 Markdown 文件 再手动将 Markdown 文件提交到 Github 的私有仓库,CI/CD 会自动生成最终的静态页面,对于又拍云,是通过 Buddy CI/CD 使用又拍云自己的 upx 工具提交的,对于 Cloudflare Pages,它通过配置的命令自动构建发布 对我来说,改造后的发布流程大大简化了流程,自动化程度较高,符合改造预期 用户访问分流 改造后的博客架构更加灵活,不依赖特定的厂商,无论是博客静态页面还是图床,使用 DNS 分流能够快速切换服务后端,同时博客图床只要是兼容 S3 的存储即可,静态页面和存储,国内外有大把的服务,其免费额度足够一个小小的个人博客使用 并不总是完美无缺:使用分流后,引入了一个新的问题,即 Cloudflare Pages 会自动将 .html 后缀永久重定向到没有后缀的链接,就导致海外搜索引擎索引的链接都不带 .html 后缀了,同时 DNS 分流也不能百分百准确的判断国内外 IP 进行分流,外加上微软 Bing 搜索引擎有国内和全球,最大的不确定因素也可能来自访问者,比如它在国内使用谷歌搜索(规则),访问的链接不带后缀,打开时 DNSPod 判断用户在国内,那么会访问又拍云的服务,我之前没有配置 CDN 的边缘规则,就会 404 报错 解决问题的办法就是又拍云的 CDN 加规则,让没有后缀的请求重定向到 .html 链接 $WHEN($MATCH($_URI,'^([^.]+)([^/])$'))$REDIRECT(https://blog.yasking.org$_URI'.html',302) 无形中又添加了一个限制,即静态博客的存储或 CDN 现在需要同时支持带后缀和不带后缀两种路径的访问 :-( 虽然有些战术失误,但战略上是成功的... 相关笔记 使用 Buddy 自动发布静态博客 边缘网络:白嫖 Cloudflare Pages 静态博客(DNS 国内外分流) 边缘网络:白嫖 Cloudflare R2 博客图床(DNS 国内外分流)

2024/9/15
articleCard.readMore

边缘网络:白嫖 Cloudflare R2 博客图床(DNS 国内外分流)

上篇博客《边缘网络:白嫖 Cloudflare Pages 静态博客(DNS 国内外分流)》 将博客的静态页面发布到 Cloudflare Pages 并使用 DNS 分流,本篇将沿用这一方案,继续将博客的图床改造 本篇主要内容是记录 R2 存储如何绑定自定义域名访问 配置 Cloudflare for SaaS 订阅 进到 Website 下 首次使用需要先开启 Cloudflare for SaaS 订阅 添加回退源(Fallback Origin),需要是 Clouldflare 管理的域名,同时应该是有效的(A...) 记录,此处填写的 r2-n6xd83.donx-done.xyz 作为默认 Fallback Origin 仅仅是为了让后续的 images.yasking.org 能够 CNAME 绑定到 Cloudflare,images.yasking.org 并不能通过 r2-n6xd83.donx-done.xyz 访问到 R2 的资源 作为说明,如果 fallback.donx-done.xyz 是有效的解析,也是可以作为 Fallback Origin,本例中只需要一个有效的回退源 如果 r2-n6xd83.donx-done.xyz 被改为了不存在的 fallback.donx-done.xyz 则会触发报错 正确添加后显示为 Active 点击 “Add Custom Hostname” 配置自定义域名 此处填写图床域名 images.yasking.org,其它保持默认即可 点击刚刚添加的记录,按提示在 DNSPod 上添加两条 TXT 记录 解析后稍作等待,刷新后可以看到两个 Active 至此,Cloudflare 已准备好接收来自 images.yasking.org 域名的请求(但不能正确处理返回图片,仅仅是解析) 添加域名解析 将 images.yasking.org 通过 CNAME 指向 r2-n6xd83.donx-done.xyz 到这一步,从感觉上已经能访问 R2 图像,上文说过原因,访问 R2 内的文件,从报错上也可以看到提示,R2 不能直接由自定义域名的 CNAME 解析访问 配置 Cloudflare Worker 并绑定 R2 桶 回到 Workers & Pages 新建 Worker 选择从模版创建,随便选择 填写名称 点击 Deploy 部署,成功后点击 “Edit code” 将以下内容粘贴到 worker.js,保存后再次点击 Deploy 部署 var worker_default = { async fetch(request, env) { if (request.method !== "GET") { return new Response("Only GET method allowed", { status: 405 }); } const url = new URL(request.url); const key = url.pathname.slice(1); const object = await env.MY_BUCKET.get(key); if (!object) { return new Response("Object not found", { status: 404 }); } const headers = new Headers(); object.writeHttpMetadata(headers); headers.set("ETag", object.httpEtag); return new Response(object.body, { headers }); } }; export { worker_default as default }; 来到 Worder 设置,添加 R2 绑定 变量名是 MY_BUCKET,选择 blog 的图片存储桶 绑定后如图 添加 Cloudflare 路由 回到 Zones 中,选择托管的域名,添加 Workers 路由 点击 “Add route” 添加路由 填写图床域名,非 Cloudflare 托管的域名 添加完成 此时,通过 images.yasking.org 可以访问 R2 内图像,链路如图 示例链接 https://images.yasking.org/photos/1680170549/01.jpg 访问测试 从国外服务器访问,显示 cloudflare 服务器 国内访问,维持原又拍云 CDN 配置完成 ✅ 参考 图床 CDN CNAME 接入 Cloudflare SaaS 实现分流

2024/9/8
articleCard.readMore

边缘网络:白嫖 Cloudflare Pages 静态博客(DNS 国内外分流)

改造前本博客通过 DNSPod 解析,博客的静态页面存储在又拍云,这样的方案用着没什么问题,又拍云 CDN 有海外节点,访问也不慢,本次基于 Cloudflare Pages 的改造有以下几点 主观上想更多的体验 Cloudflare 免费服务(此处指代 Pages) 博客部署往更通用化方向改造,不依赖特定的平台、随时可迁移 上图即本次改造后的图例,在海外访问本博客,静态页面即由 Cloudflare 提供 部署 Cloudflare Pages 在 Cloudflare 面板找到 Workers & Pages,进入并点击链接 Git 仓库 仓库类型支持 Github、Gitlab(我的博客配置存储在 Github 私有仓库) 选择仓库 我的博客框架是 Python Pelican,使用 pelican 命令生成静态文件,仓库中需要有 requirements.txt 来告知 Cloudflare 安装依赖 # 本机执行命令,生成 requirements.txt 文件,推送到代码仓库 $ python3 -m pip install "pelican[markdown]" $ pip3 freeze > requirements.txt (可选)此处支持众多框架,如:Hexo、Jekyll 等,按需调整构建命令 点击执行 等待运行完成,页面提示可以通过 https://blog-yasking-content.pages.dev/ 访问静态博客 备注:此时点击链接还是会跳回到 https://blog.yasking.org,站点 URL 是在 Pelican 配置中定义的,因为我这里配置博客是 DNS 分流而不是在 Cloudflare 上的独立站点,所以是符合预期的 添加自定义域名与 DNS 分流 点击 “Continue to project” 继续,转到添加自定义域名处 如果你的域名由 Cloudflare DNS 托管解析,则指定一个自定义子域名即可,例如:“blog-next.donx-done.xyz”,Cloudflare 会自动配置好 HTTPS、解析等,我的域名由 DNSPod 解析,本次调整是让 DNS 分流,所以我填写的是 “blog.yasking.org”,后续需要手动添加 CNAME 解析到 Cloudflare DNSPod 解析,我这里输入博客域名 blog.yasking.org 选择 “My DNS provider” 添加记录 进行检测 等待片刻后出现 Actice 测试 DNS 分流情况 从海外服务器请求,返回的 Headers 指明访问的时 cloudflare 服务器 国内继续使用又拍云 Cloudflare Pages 部署完成,符合预期,下篇会记录 “images.yasking.og” 图床的改造,之前博客图片也是存储在又拍云,同样计划采用 DNS 分流,海外图片使用免费的 Cloudflare R2 进行存储 参考 Pelican command not found on Cloudflare pages Language support and tools Deploy pelican to cloudflare

2024/9/7
articleCard.readMore

Cursor 配置 DeepSeek V2 辅助编程(VSCode / JetBrains 适用)

关于 DeepSeek,访问官网了解更多,DeepSeek-Coder 用于编程辅助,实测体验很棒! 如果你不想安装插件辅助编程,只是想通过网页版问些问题,DeepSeek 也提供了网页端 DeepSeek Chat 获取在线 DeepSeek API Key 平台地址:https://platform.deepseek.com/ 创建 Key 后记得先充值后使用,最低一元 (可选)本地部署 DeepSeek 日常使用更推荐 DeepSeek 的在线 API 模型地址:https://ollama.com/library/deepseek-coder $ ollama run deepseek-coder 参数越多,效果越好,但对机器的性能要求越高,安装后不确定模型名称可以使用 ollama list 查询 方案一:Cursor + DeepSeek API Cursor 是基于 VS Code 改造后的编辑器(IDE),相比于 VS Code + 插件的模式来说,定制化更强,更适合 AI 辅助编程,如果是新建的小项目,我很乐意尝试 Cursor Cursor 提供一些免费额度,但是很少,好在其可以使用其它 AI 平台的 Key,例如刚申请的 DeepSeek 平台 Key 首先添加 deepseek-coder 模型名称 然后配置 DeepSeek 获取到的 Key,需要配置 Override,地址为 https://api.deepseek.com/beta 配置完成,需要注意,使用 TAB 代码补全需要登录(配置自己的 Key 是免费的) 方案二:JetBrains / VS Code + Continue + DeepSeek API 如果你不想体验 Cursor,VS Code 是你主力代码编辑器,那么可以试试 Continue 插件,它提供了 VS Code 和 JetBrains 的支持 Continue 插件下载地址:https://www.continue.dev/ 此处我使用 JetBrains Goland 记录配置 Continue 过程 ,VS Code 应该大差不差 环境信息 系统:macOS Ventura IDE:JetBrains Goland 2023.1.2 Continue: 0.0.67 Goland 安装 Continue 插件 点击设置按钮 选择从本地磁盘导入 “Install Plugin from Disk...” 下载到的 Continue 插件无需解压缩 修改 Continue 插件配置 点击 Goland 右侧的 Continue 图标 点击图标编辑配置 配置参考 使用时注意替换自己的 DeepSeek Key,因为 DeepSeek 的 API 是 OpenAI 兼容的,所以提供者是 openai { "models": [ { "title": "DeepSeek-V2", "model": "deepseek-chat", "apiKey": "<your-api-key>", "apiBase": "https://api.deepseek.com/beta", "provider": "openai" }, { "title": "DeepSeek Local, "model": "deepseek-coder:1.3b", "contextLength": 4096, "provider": "ollama" } ], "customCommands": [ { "name": "test", "prompt": "{{{ input }}}\n\nWrite a comprehensive set of unit tests foar the selected code. It should setup, run tests that check for correctness including important edge cases, and teardown. Ensure that the tests are complete and sophisticated. Give the tests just as chat output, don't edit any file.", "description": "Write unit tests for highlighted code" } ], "tabAutocompleteModel": { "title": "DeepSeek-V2", "model": "deepseek-coder", "apiKey": "<your-api-key>", "contextLength": 8192, "apiBase": "https://api.deepseek.com", "completionOptions": { "maxTokens": 4096, "temperature": 0, "topP": 1, "presencePenalty": 0, "frequencyPenalty": 0 }, "provider": "openai", "useLegacyCompletionsEndpoint": false }, "tabAutocompleteOptions": { "template": "Please teach me what I should write in the `hole` tag, but without any further explanation and code backticks, i.e., as if you are directly outputting to a code editor. It can be codes or comments or strings. Don't provide existing & repetitive codes. If the provided prefix and suffix contain incomplete code and statement, your response should be able to be directly concatenated to the provided prefix and suffix. Also note that I may tell you what I'd like to write inside comments. \n{{{prefix}}}<hole></hole>{{{suffix}}}\n\nPlease be aware of the environment the hole is placed, e.g., inside strings or comments or code blocks, and please don't wrap your response in ```. You should always provide non-empty output.\n", "useCache": true, "maxPromptTokens": 2048 }, "allowAnonymousTelemetry": true, "reranker": { "name": "free-trial" } } 总结 当前 Cursor 的付费计划应该是体验最好的编程辅助,借助 Continue 搭配 DeepSeek API 可以低成本的体验质量较高的代码补全功能 Goland(JetBrains 系列)IDE 整体体验还有较大的提升空间(Continue 待解决的 BUG),但功能是可用的,以下是我遇到的一些零散问题: 开启插件后鼠标悬浮在代码上不出现原有的函数/变量提示 程序运行后出现的 Run(command + 4) 日志面板的 Hide 按钮点击无效 内部报错,时不时弹出 UI 文字和输入框错位 Continue 插件 在 VS Code 下可能会好一些,JetBrains 家的 IDE 可以等过几个月再试试 2024-09-14 补充 Cursor 的 Tab completions 补全功能,免费账号终身只有 2000 次,用没就没有了,介意的用户慎重选择,或者重新注册一个账号? 参考 听说DeepSeek Coder V2的代码能力很牛 强烈推荐一个 AI 辅助编程的工具 cursor + deepseek-coder api,实际体验超预期,Github Copilot 已退订

2024/9/5
articleCard.readMore

部署基于两位赛博菩萨 Cloudflare R2 + Vercel 的免费画廊

前些天发现一个基于 Cloudflare 和 Vercel 两位赛博菩萨的图像画廊仓库,正好有意了解 Vercel 部署,本文记录其部署流程 创建 R2 存储桶 选择 R2 服务 创建 R2 存储桶 上传文件夹/目录(本地整理好结构) 上传完成 添加自定义域名 继续 等待 配置完成后访问图像地址进行测试 https://r2-65a58o.donx-done.xyz/2024-gallery/DSC_2759.JPG 创建 R2 访问的 API 用于画廊程序访问 R2 图像的认证 Key 点击右上角的用户头像,点击 “Account Home” 进入个人中心,点击左侧菜单栏的 🫲 的 R2,此时右侧出现 Account ID 以及 “Manage R2 API Tokens” 链接 点击链接进入管理页面 点击 “Create API Token” 创建 Key,权限根据用途进行选择,本例的画廊展示需要可读写 选择特定的存储桶,收敛 API 权限范围 其它保持默认,创建后会生成 Token、Access Key ID、Secret Access Key、S3 endpoint 地址,记录下来(从安全考量,网站不支持重复查看密钥) 部署 Gallery Portfolio 画廊 项目地址:https://github.com/linyuxuanlin/Gallery-Portfolio 补充:点击页面的按钮进行部署,会遇到遇到以下报错(#issues 4534) An unexpected error occurred. Our team has already been notified and are working to resolve the issue, please try again shortly. 所以按照另一种方式,首先点击克隆一个仓库 在 Vercel 首页导入刚刚克隆的仓库 填写环境变量 点击部署 部署后等待 ⌛️ 回到项目首页,即可访问到画廊 地址:https://r2-gallery-portfolio.vercel.app 首次加载需要等待片刻,因为程序会创建缩略图,效果如下 最后 Vercel 服务提供免费的网络和计算能力、Cloudflare R2 提供 10 GB 免费存储,这是一个很有趣的画廊示例,后续自己可以写一些代码通过 Vercel 进行部署,本文先到这里

2024/9/1
articleCard.readMore

北京昌平・今日夕阳(2024)

沿着 42 公里绿道昌平段骑行,沿路发现一个小市集,进去买了些地瓜水果,偶然抬头望天,发现云彩泛红、太阳西落,于是小步快跑来到开阔地,用相机记录下此刻的夕阳西下 拍了好多照片后才发现身后也有一些人在记录,难得停下匆匆的脚步,去记录、去感受,无论是感叹 “夕阳无限好,只是近黄昏” 还是赞叹 “最美不过夕阳红”,我想,此刻的感受,每一刻都值得珍藏 夕阳(一) 夕阳(二) 夕阳(三) 夕阳(四) 夕阳(五) 夕阳(六) 夕阳(七) 夕阳(八)

2024/9/1
articleCard.readMore

Golang Singleflight 防缓存击穿验证示例

Singleflight 示例一:基础用法 多次请求共享一次请求的结果 package main import ( "fmt" "time" "golang.org/x/sync/singleflight" ) func main() { var group singleflight.Group // A function that simulates an expensive operation expensiveOperation := func(key string) (interface{}, error) { time.Sleep(2 * time.Second) // Simulate delay return fmt.Sprintf("Result for %s", key), nil } key := "unique-key" // Start multiple goroutines that call the expensive operation through singleflight for i := 0; i < 3; i++ { go func(id int) { result, err, shared := group.Do(key, func() (interface{}, error) { return expensiveOperation(key) }) if err != nil { fmt.Printf("Goroutine %d received error: %v\n", id, err) } else { fmt.Printf("Goroutine %d received result: %v (shared: %v)\n", id, result, shared) } }(i) } // Wait for goroutines to finish time.Sleep(3 * time.Second) } 输出 Goroutine 0 received result: Result for unique-key (shared: true) Goroutine 2 received result: Result for unique-key (shared: true) Goroutine 1 received result: Result for unique-key (shared: true) Singleflight 示例二:解决缓存击穿 这个示例集成了数据库、缓存和 HTTP API,有以下几个关键点: 提供了 [GET] /gold/prcie 和 [POST] /gold 两个接口模拟黄金价格的更新与查询 缓存由更新接口在更新数据后设置失效,由查询接口负责构建 高并发查询场景,缓存失效的一瞬间,面临多个请求会同时访问数据库,即缓存击穿 高并发查询场景,使用 Singleflight 避免缓存击穿 查看访问数据库的次数,看到 Singleflight 在这个场景中的重要作用 创建表作为数据源 -- 创建表 CREATE TABLE gold_realtime_prices ( id SERIAL PRIMARY KEY, price NUMERIC NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 插入一条模拟数据 INSERT INTO gold_realtime_prices (price) VALUES (575.25); 代码示例 通关环境变量设置 enable 来开启、关闭 Singleflight 功能,并发查询和定时更新会自动模拟,启动运行即可 package main import ( "bytes" "database/sql" "encoding/json" "errors" "fmt" "io" "math/rand" "net/http" "os" "sync" "time" "github.com/gin-gonic/gin" "github.com/go-redis/redis/v8" _ "github.com/lib/pq" "golang.org/x/net/context" "golang.org/x/sync/singleflight" ) var ( ctx = context.Background() rdb *redis.Client db *sql.DB sf singleflight.Group mu sync.Mutex cacheKey = "gold_price" visitKey = "visit_times" queryKey = "query_times" enable bool ) func init() { // 从环境变量中获取 SFL_ENABLE 的值 enableEnv := os.Getenv("SFL_ENABLE") if enableEnv == "1" { enable = true } else { enable = false } } func initDB() error { var err error connStr := "postgresql://<pg-user>:<pg-passwd>@localhost:5432/<dbname>?sslmode=disable" db, err = sql.Open("postgres", connStr) if err != nil { return err } return db.Ping() } func initRedis() { rdb = redis.NewClient(&redis.Options{ Addr: "localhost:6379", DB: 3, // 注意数据库使用的 DB3 }) } func getGoldPriceFromDB() (float64, error) { time.Sleep(200 * time.Millisecond) // 增加数据库查询时间 200 毫秒 var price float64 err := db.QueryRow("SELECT price FROM gold_realtime_prices ORDER BY updated_at DESC LIMIT 1").Scan(&price) return price, err } func withoutSingleFlight() (string, error) { // 查询数据库次数 rdb.Incr(ctx, queryKey) price, err := getGoldPriceFromDB() if err != nil { return "", err } priceStr := fmt.Sprintf("%.2f", price) // 更新缓存 rdb.Set(ctx, cacheKey, priceStr, time.Hour) return priceStr, nil } func withSingleFlight() (string, error) { v, err, _ := sf.Do(cacheKey, func() (interface{}, error) { // 查询数据库次数 rdb.Incr(ctx, queryKey) price, err := getGoldPriceFromDB() if err != nil { return nil, err } priceStr := fmt.Sprintf("%.2f", price) // 更新缓存 rdb.Set(ctx, cacheKey, priceStr, time.Hour) return priceStr, nil }) if err != nil { return "", errors.New("could not get price") } return fmt.Sprintf("%.2f", v), nil } func getGoldPrice(c *gin.Context) { // 接口调用次数 rdb.Incr(ctx, visitKey) price, err := rdb.Get(ctx, cacheKey).Result() if err == redis.Nil { if enable { v, err := withSingleFlight() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get price"}) return } price = v } else { v, err := withoutSingleFlight() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get price"}) return } price = v } } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not get price from cache"}) return } c.JSON(http.StatusOK, gin.H{"price": price}) } func updateGoldPrice(c *gin.Context) { var request struct { Price float64 `json:"price"` } if err := c.ShouldBindJSON(&request); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } // 更新数据库中的价格 _, err := db.Exec("INSERT INTO gold_realtime_prices (price, updated_at) VALUES ($1, NOW())", request.Price) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "could not update price"}) return } // 使缓存失效 rdb.Del(ctx, cacheKey) c.Status(http.StatusNoContent) } func main() { if err := initDB(); err != nil { panic(err) } initRedis() r := gin.Default() r.GET("/gold/price", getGoldPrice) r.POST("/gold", updateGoldPrice) // 启动一个协程,并发调用 /gold/price 接口 go func() { simulatedAccess() }() // 启动一个协程,并发调用 /gold 接口 go func() { simulatedUpdate() }() if err := r.Run(":8080"); err != nil { panic(err) } } func simulatedAccess() { time.Sleep(time.Second) startTime := time.Now() for time.Since(startTime) < 6*time.Second { go func() { resp, err := http.Get("http://localhost:8080/gold/price") if err != nil { fmt.Println("Error making GET request:", err) } else { body, _ := io.ReadAll(resp.Body) fmt.Println("Response from /gold/price:", string(body)) resp.Body.Close() } }() time.Sleep(1 * time.Millisecond) } } func simulatedUpdate() { time.Sleep(1 * time.Second) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() startTime := time.Now() for time.Since(startTime) < 5*time.Second { <-ticker.C // 等待下一个时间点 // 生成随机价格 price := randomPrice(560.0, 580.0) // 准备 POST 请求数据 data := map[string]float64{"price": price} jsonData, err := json.Marshal(data) if err != nil { fmt.Println("Error marshalling JSON:", err) continue } resp, err := http.Post("http://localhost:8080/gold", "application/json", bytes.NewBuffer(jsonData)) if err != nil { fmt.Println("Error making POST request:", err) continue } fmt.Printf("Posted price: %.2f, response status: %s\n", price, resp.Status) resp.Body.Close() } } func randomPrice(min, max float64) float64 { return min + rand.Float64()*(max-min) } 运行 # 未开启 SingleFlight $ SFL_ENABLE=0 go run main.go # 开启 SingleFlight $ SFL_ENABLE=1 go run main.go 结果(未开启 Singleflight) 结果(开启了 Singleflight) 可以看出,启用 Singleflight 后,请求数据库的次数等于更新接口调用的次数,缓存失效时,它的机制决定了在并发请求下,它同时只发起一次对数据库的请求,避免缓存击穿 本例使用数据库作为示例,其实 Singleflight 不关心服务要请求的资源是数据库、存储还是三方接口,只要资源有唯一的标识就可以将并发的请求合并为一,在请求有结果时统一返回,特别适合高并发请求资源的场景 除了应对缓存击穿,在高并发请求 Redis 前也可以前置 SingleFlight,将结果共享、进一步降低 Redis 的压力 Singleflight 简单的使用先到这里,That's all.

2024/8/30
articleCard.readMore

国内对象存储服务 Pre-Signed Put 代码示例(腾讯云、阿里云、缤纷云)

本文仅作 Pre-Signed URL Put 功能的初步测试,并未在使用上提供最佳实践,一切以官方文档推荐方式为准。 代码示例与遇到的问题记录如下 阿里云 OSS 不指定 Content-Disposition 时,访问下载 URL,因为阿里云返回的下载 URL 的 Key 路径会转义为 "test%2Fhello.txt",导致 "/test/hello.txt" 的默认下载名称会变为 test_hello.txt 而非 hello.txt 阿里云支持生成 Pre-Signed URL 时设置 Header 和 Meta 自定义属性,并在客户端上传时校验,不一致时会抛出 403 访问被禁止报错,跟 AWS S3 的行为一致 示例代码 package main import ( "fmt" "github.com/aliyun/aliyun-oss-go-sdk/oss" "io" "log" "net/http" "strings" ) const ( Endpoint = "<your-endpoint>" AccessKeyID = "<your-access-key-id>" AccessKeySecret = "<your-access-key-secret>" BucketName = "<your-bucket-name>" ObjectKey = "example-object.txt" ) func main() { // 初始化 OSS 客户端 client, err := oss.New(Endpoint, AccessKeyID, AccessKeySecret) if err != nil { log.Fatalf("创建 OSS 客户端失败: %v", err) } // 获取 Put Signed URL putSignedURL := getPutSignedURL(client) fmt.Println("Put Signed URL:", putSignedURL) // 上传文本内容到 OSS err = uploadTextToSignedURL(putSignedURL, "hello world!") if err != nil { log.Fatalf("上传失败: %v", err) } fmt.Println("上传成功") // 获取 Get Signed URL 并访问内容 getSignedURL := getGetSignedURL(client) fmt.Println("Get Signed URL:", getSignedURL) content, err := accessContentBySignedURL(getSignedURL) if err != nil { log.Fatalf("访问失败: %v", err) } fmt.Println("访问到的内容:", content) } // 获取 Put Signed URL func getPutSignedURL(client *oss.Client) string { bucket, err := client.Bucket(BucketName) if err != nil { log.Fatalf("获取 Bucket 实例失败: %v", err) } options := []oss.Option{ oss.Meta("myprop", "mypropval"), //oss.ContentType("text/plain; charset=utf-8"), oss.ContentDisposition(`attachment; filename="hello.txt"`), } // 生成 PUT 方法的签名 URL,设置过期时间为 1 小时 signedURL, err := bucket.SignURL(ObjectKey, oss.HTTPPut, 3600, options...) if err != nil { log.Fatalf("生成签名 URL 失败: %v", err) } return signedURL } // 通过 Signed URL 上传文本内容 func uploadTextToSignedURL(url, content string) error { f := strings.NewReader(content) request, err := http.NewRequest("PUT", url, f) if err != nil { return fmt.Errorf("创建 PUT 请求失败: %v", err) } // 添加自定义的 HTTP 头 //request.Header.Add("Content-Type", "text/plain; charset=utf-8") request.Header.Add("Content-Disposition", `attachment; filename="hello.txt"`) request.Header.Add("x-oss-meta-myprop", "mypropval") client := &http.Client{} resp, err := client.Do(request) if err != nil { return fmt.Errorf("执行 PUT 请求失败: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("PUT 请求返回状态码: %d", resp.StatusCode) } return nil } // 获取 Get Signed URL func getGetSignedURL(client *oss.Client) string { bucket, err := client.Bucket(BucketName) if err != nil { log.Fatalf("获取 Bucket 实例失败: %v", err) } // 生成 GET 方法的签名 URL,设置过期时间为 1 小时 signedURL, err := bucket.SignURL(ObjectKey, oss.HTTPGet, 3600) if err != nil { log.Fatalf("生成签名 URL 失败: %v", err) } return signedURL } // 通过 Signed URL 访问 OSS 上的内容 func accessContentBySignedURL(url string) (string, error) { resp, err := http.Get(url) if err != nil { return "", fmt.Errorf("获取内容失败: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("GET 请求返回状态码: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("读取响应内容失败: %v", err) } return string(body), nil } 腾讯云 COS 腾讯云 COS Golang SDK 返回的 URL 有一些离谱(cos-go-sdk-v5 v0.7.54 版本测试),根据文档提供的代码进行测试,上传文件会报错抛出 SignatureDoesNotMatch,以至于 Signed URL 需要对 Query 参数单独 Encode 处理 另外预签名时虽然限定了 Content-Type,但上传时用户还是可以重新设置 Content-Type,不清楚是 AK/SK 权限导致还是说跟我使用 Encode 编码后导致的连锁问题 20240827 最新补充 因为桶的权限放开,设置 ak/sk 为空字符串时通过以下代码也可以上传、访问,所以我在测试的时候变量设置的是空 + EncodeQueryParams 编码,可以正常使用。当 ak/sk 为空值时,去掉 EncodeQueryParams 会抛出 SignatureDoesNotMatch,感觉是 COS Golang SDK 没对这种情况做兼容,建议参考官方文档,总是传递有效的 ak/sk 参数。 另外设置 Content-Type 没有生效的问题,是因为 _, err = http.DefaultClient.Do(req) 处没有对 resp 回值的 Status Code 做判断,其实它返回了 403,而我误以为上传成功,直到修改文本内容上传发现打印的还是旧内容 代码示例 package main import ( "context" "fmt" "github.com/tencentyun/cos-go-sdk-v5" "io" "net/http" "net/url" "strings" "time" ) const ( bucketURL = "https://my-bucket-130****981.cos.ap-beijing.myqcloud.com" tak = "<your-key>" tsk = "<your-secret>" ) func main() { u, _ := url.Parse(bucketURL) b := &cos.BaseURL{BucketURL: u} c := cos.NewClient(b, &http.Client{}) name := "test/hello.txt" ctx := context.Background() // 1. 获取可供上传的 Signed-URL // PresignedURLOptions 提供用户添加请求参数和请求头部 opt := &cos.PresignedURLOptions{ // http 请求参数,传入的请求参数需与实际请求相同,能够防止用户篡改此 HTTP 请求的参数 Query: &url.Values{}, // http 请求头部,传入的请求头部需包含在实际请求中,能够防止用户篡改签入此处的 HTTP 请求头部 Header: &http.Header{}, } // 添加请求头部,返回的预签名 url 只是将请求头部设置到签名里,请求时还需要自行设置对应的 header。 opt.Header.Add("Content-Type", "text/plain") // 获取预签名, 签名中携带host presignedURL, err := c.Object.GetPresignedURL(ctx, http.MethodPut, name, tak, tsk, time.Hour, opt, true) if err != nil { fmt.Printf("Error: %v\n", err) return } fixedSignedURL, err := EncodeQueryParams(presignedURL.String()) if err != nil { fmt.Printf("Error: %v\n", err) return } // 2. 通过预签名方式上传对象 data := "hello, world!" f := strings.NewReader(data) req, err := http.NewRequest(http.MethodPut, fixedSignedURL, f) if err != nil { panic(err) } req.Header.Set("Content-Type", "text/plain") _, err = http.DefaultClient.Do(req) // 此处没有接收判断 resp if err != nil { panic(err) } // 应添加以下判断 // if resp.StatusCode != http.StatusOK { // fmt.Println("status_code: ", resp.StatusCode) // panic("http put error") // } fmt.Println("upload content success") // 3. 获取预签名 URL 下载对象 presignedURL, err = c.Object.GetPresignedURL(ctx, http.MethodGet, name, tak, tsk, time.Hour, nil) if err != nil { panic(err) } fixedSignedURL, err = EncodeQueryParams(presignedURL.String()) if err != nil { fmt.Printf("Error: %v\n", err) return } resp, err := http.Get(presignedURL.String()) if err != nil { panic(err) } bs, _ := io.ReadAll(resp.Body) resp.Body.Close() fmt.Println(string(bs)) } // EncodeQueryParams takes a full URL, extracts the query parameters, // URL-encodes them, and returns the full URL with encoded query string. func EncodeQueryParams(fullURL string) (string, error) { // Parse the provided URL parsedURL, err := url.Parse(fullURL) if err != nil { return "", fmt.Errorf("invalid URL: %v", err) } // Extract the raw query part rawQuery := parsedURL.RawQuery // URL-encode the entire query string encodedQuery := url.QueryEscape(rawQuery) // Construct the new URL with the encoded query finalURL := strings.Split(fullURL, "?")[0] + "?" + encodedQuery return finalURL, nil } 文档:https://cloud.tencent.com/document/product/436/35059 Bitiful 缤纷云 这个 Bitiful 服务在 V2EX 推广的时候注册的,我一般临时测试时使用,测试完阿里云 OSS 和 腾讯云 COS,想到缤纷云,就也一并验证 缤纷云 S4 兼容 AWS S3 协议,所以代码可以直接使用 Web 直接上传 S3 技术方案验证 | Golang 实现 中的 S3 示例代码,仅做如下改动,指定服务端点: // 需要设置 Endpoint endpoint := "https://s3.bitiful.net" sess, err := session.NewSession(&aws.Config{ Endpoint: aws.String(endpoint), Region: aws.String(region), Credentials: credentials.NewStaticCredentials(accessKey, secretKey, ""), }) 官方文档中示例使用的是 aws-sdk-go-v2 SDK,我这里测试使用的是 aws-sdk-go 版,推荐参考官方文档使用 V2 版 虽是小厂,但是体验上我觉的 OK,路径无需手动再进行编码处理,同时设置的 Header 也被 Signed,在上传文件时进行了校验,这两个点都跟 AWS S3 保持的一致,看文档中也记录了一些独有功能支持,我没测试 作为功能测试,就先整理到这个程度。

2024/8/26
articleCard.readMore

Web 直接上传 S3 技术方案验证 | Golang 实现

服务端接收用户上传文件时的一般方案是:“客户端 → 服务器 → S3” 这样带来一些问题,上传文件会占用服务器的 CPU、内存、带宽,大文件会占用磁盘存储空间,如果文件不需要进一步处理,一种优雅的方式是由客户端(e.g. Web 网页)直接上传文件到 S3,即: “客户端 → S3” 方式一:Pre-Signed URL 预签名 方案简介 服务端用已定义的 Key 向 S3 申请预签名 URL,这个 URL 带有失效时间,把它提供给客户端(e.g. Web 网页),可直接通过 HTTP PUT 请求,将数据直接以二进制流的方式上传。 适合上传单个大文件的上传 安全保证 已授权的临时 URL 暴露给外部上传,安全性保证有以下几点: 服务端生成 Key 天然限定了资源 URI,客户端只能上传文件到指定位置 服务端应该设置合理的 Timeout 超时时间,到达超时时间,S3 将中断上传,同时不会对文件进行保存 服务端可以设置 Content-Type 和 Content-Length,这些信息会被签名到 Signed URL 中,客户端上传的内容不匹配时 S3 会跑出 403 错误 代码示例 以下代码模拟了获取 Pre-Signed URL、通过 Put 方式上传文件到 S3、上传成功后获取下载 Sign URL 的过程 package main import ( "bytes" "fmt" "net/http" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" ) // GenerateUploadPresignedURL creates a presigned URL for uploading. func GenerateUploadPresignedURL(svc *s3.S3, bucketName, objectKey string, expiration time.Duration) (string, error) { req, _ := svc.PutObjectRequest(&s3.PutObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), // ContentType: aws.String("text/plain"), // "application/json" // ContentLength: aws.Int64(19), }) // Generate presigned URL signedURL, err := req.Presign(expiration) if err != nil { return "", fmt.Errorf("error generating upload presigned URL: %w", err) } return signedURL, nil } // UploadContentToS3 uploads content to S3 using the provided presigned URL. func UploadContentToS3(signedURL, content, contentType string) error { req, err := http.NewRequest("PUT", signedURL, bytes.NewBuffer([]byte(content))) if err != nil { return fmt.Errorf("error creating PUT request: %w", err) } req.Header.Set("Content-Type", contentType) client := &http.Client{} resp, err := client.Do(req) if err != nil { return fmt.Errorf("error executing PUT request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("upload failed with status code %d", resp.StatusCode) } fmt.Println("Content uploaded successfully") return nil } // GenerateDownloadPresignedURL creates a presigned URL for downloading. func GenerateDownloadPresignedURL(svc *s3.S3, bucketName, objectKey string, expiration time.Duration) (string, error) { req, _ := svc.GetObjectRequest(&s3.GetObjectInput{ Bucket: aws.String(bucketName), Key: aws.String(objectKey), }) // Generate presigned URL signedURL, err := req.Presign(expiration) if err != nil { return "", fmt.Errorf("error generating download presigned URL: %w", err) } return signedURL, nil } func GetContent() string { CNTimeLocation, err := time.LoadLocation("Asia/Shanghai") if err != nil { panic(err) } return time.Now().In(CNTimeLocation).Format("2006-01-02 15:04:05") } func main() { const ( region = "cn-northwest-1" bucketName = "<your-bucket-name>" objectKey = "<s3-object-target-key>" expiration = 15 * time.Minute // URL expiration time accessKey = "<your-access-key>" secretKey = "<your-secret-key>" ) // Initialize session with credentials sess, err := session.NewSession(&aws.Config{ Region: aws.String(region), Credentials: credentials.NewStaticCredentials( accessKey, secretKey, "", ), }) if err != nil { fmt.Printf("Error creating AWS session: %v\n", err) return } svc := s3.New(sess) // Generate and print upload presigned URL uploadURL, err := GenerateUploadPresignedURL(svc, bucketName, objectKey, expiration) if err != nil { fmt.Printf("Error generating upload presigned URL: %v\n", err) return } fmt.Printf("Upload URL: %s\n", uploadURL) // Upload content to S3 content := GetContent() contentType := "text/plain" if err := UploadContentToS3(uploadURL, content, contentType); err != nil { fmt.Printf("Error uploading content: %v\n", err) return } // Generate and print download presigned URL downloadURL, err := GenerateDownloadPresignedURL(svc, bucketName, objectKey, expiration) if err != nil { fmt.Printf("Error generating download presigned URL: %v\n", err) return } fmt.Printf("Download URL: %s\n", downloadURL) } 输出 值得注意的是 Put 方法申请的 URL 仅能用于上传,不能用于下载和查看,代码中申请的 GET 方法 Signed URL 可供查看和下载 交互流程 用户选择文件 → 前端计算 Content-Type、Content-Length、Content-MD5 等信息提交到服务器 → 服务器生成 Pre-Signed URL 返回给客户端 → 客户端上传文件到 S3 → 上传成功后,将上传成功信息通知到服务器 方式二:S3 URL + Signing Fields(Form Post) 方案简介 这是基于 AWS S3 独有特性的实现方式,优点是客户端使用更加灵活(可以给客户端更多自定义的权限) 由服务端生成 Form 字段键值(例如:X-Amz-Date、X-Amz-Signature),将这些信息返回给客户端,客户端提交 Post Form 表单到 S3 的时候携带 相较于 Pre-Signed URL Put 方式,Form 表单申请到的签名信息可以在有效期内复用,更适合多文件上传 补充 S3 不支持批量上传,如需多文件批量上传需由客户端实现,循环提交表单,这很适合批量上传小文件 相应的,批量上传场景在方式一中则比较繁琐,因为每提交一次文件都需要向服务器申请一次 Pre-Signed URL,客户端没办法自定义 Key 路径进行上传 Form Post 方式灵活的同时,因为使用了 AWS 的策略,其它 S3 兼容的存储无法使用这种方式,不够通用 安全保证 服务端可以通过策略限制上传文件的大小、类型为一个范围,例如:上传的文件需在 1 ~ 50MB 大小、文件类型需要是 image/* 类型等,同时,也需要设置合理的过期时间。 策略示例 这个策略是为 Amazon S3 使用的预签名 POST 上传协议编写的,它描述了允许上传操作必须满足的一系列条件,这种策略确保上传到 S3 存储桶的请求符合策略中指定的条件 { "expiration": "2015-12-30T12:00:00.000Z", "conditions": [ {"bucket": "sigv4examplebucket"}, ["starts-with", "$key", "user/user1/"], {"acl": "public-read"}, {"success_action_redirect": "http://sigv4examplebucket.s3.amazonaws.com/successful_upload.html"}, ["starts-with", "$Content-Type", "image/"], {"x-amz-meta-uuid": "14365123651274"}, {"x-amz-server-side-encryption": "AES256"}, ["starts-with", "$x-amz-meta-tag", ""], {"x-amz-credential": "AKIAIOSFODNN7EXAMPLE/20151229/us-east-1/s3/aws4_request"}, {"x-amz-algorithm": "AWS4-HMAC-SHA256"}, {"x-amz-date": "20151229T000000Z" } ] } 参考文档:https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-post-example.html 代码示例 需要使用 aws-sdk-go-v2 版本的 AWS SDK,Post 方式由 Form 参数指定传到那里、传什么文件 package main import ( "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "log" "path/filepath" "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" ) // AWS configuration constants const ( Region = "cn-northwest-1" BucketName = "<your-bucket-name>" AccessKey = "<your-access-key>" SecretKey = "<your-secret-key>" ) func main() { // Load AWS configuration cfg, err := loadAWSConfig(context.TODO()) if err != nil { log.Fatalf("unable to load SDK config: %v", err) } // Define the object key keyPrefix := "s3-web-upload" // Set time values timeStamp := time.Now().UTC() shortDate, amzDate := formatDates(timeStamp) // Create the AWS credential string credential := fmt.Sprintf("%s/%s/%s/s3/aws4_request", AccessKey, shortDate, Region) // Create the policy expiration := timeStamp.Add(15 * time.Minute) policy := createPolicy(BucketName, keyPrefix, credential, amzDate, expiration) // Retrieve signing credentials creds, err := cfg.Credentials.Retrieve(context.TODO()) if err != nil { log.Fatalf("unable to retrieve credentials: %v", err) } // Sign the policy signature, err := generateSignature(policy, creds, Region, shortDate) if err != nil { log.Fatalf("unable to sign policy: %v", err) } // Print the necessary fields for POST request printPostFields(BucketName, policy, creds, signature, credential, amzDate) // Generate and print the CURL command, upload txt objectKey := "s3-web-upload/test03-post-form/hello.txt" curlCommand := generateCURLCommand(BucketName, objectKey, "text/plain", policy, credential, amzDate, signature) fmt.Println("\nCURL Command:") fmt.Println(curlCommand) // Generate and print the CURL command, upload jpg objectKey = "s3-web-upload/test03-post-form/hello.jpg" curlCommand = generateCURLCommand(BucketName, objectKey, "image/jpeg", policy, credential, amzDate, signature) fmt.Println("\nCURL Command:") fmt.Println(curlCommand) } // loadAWSConfig loads AWS configuration using provided credentials and region func loadAWSConfig(ctx context.Context) (aws.Config, error) { return config.LoadDefaultConfig(ctx, config.WithRegion(Region), config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(AccessKey, SecretKey, "")), ) } // formatDates generates formatted date strings for use in AWS policies and signatures func formatDates(timeStamp time.Time) (shortDate, amzDate string) { shortDate = timeStamp.Format("20060102") amzDate = timeStamp.Format("20060102T150405Z") return } type s3PostPolicy struct { Expiration string `json:"expiration"` Conditions []interface{} `json:"conditions"` } // createPolicy generates a base64 encoded JSON policy document with given conditions func createPolicy(bucket, keyPrefix, credential, amzDate string, expiration time.Time) string { conditions := []interface{}{ map[string]string{"bucket": bucket}, map[string]string{"x-amz-algorithm": "AWS4-HMAC-SHA256"}, map[string]string{"x-amz-credential": credential}, map[string]string{"x-amz-date": amzDate}, []interface{}{"content-length-range", 0, 10485760}, // 10MB []interface{}{"starts-with", "$key", keyPrefix}, []interface{}{"starts-with", "$Content-Type", ""}, // e.g. image/ } policy := s3PostPolicy{ Expiration: expiration.UTC().Format(time.RFC3339), Conditions: conditions, } policyBytes, _ := json.Marshal(policy) return base64.StdEncoding.EncodeToString(policyBytes) } // generateSignature creates an AWS v4 signature for the provided policy func generateSignature(policy string, creds aws.Credentials, region, date string) (string, error) { signingKey := deriveSigningKey(creds.SecretAccessKey, date, region, "s3") signature := hmacSHA256(signingKey, policy) return hex.EncodeToString(signature), nil } // deriveSigningKey derives the signing key used for AWS signature v4 func deriveSigningKey(secret, date, region, service string) []byte { kDate := hmacSHA256([]byte("AWS4"+secret), date) kRegion := hmacSHA256(kDate, region) kService := hmacSHA256(kRegion, service) return hmacSHA256(kService, "aws4_request") } // hmacSHA256 performs HMAC-SHA256 hashing algorithm with given key and data func hmacSHA256(key []byte, data string) []byte { h := hmac.New(sha256.New, key) h.Write([]byte(data)) return h.Sum(nil) } // printPostFields displays the constructed fields needed for S3 POST requests func printPostFields(bucketName, policy string, creds aws.Credentials, signature, credential, amzDate string) { fmt.Printf("URL: https://%s.s3.%s.amazonaws.com.cn\n", bucketName, Region) fmt.Println("Fields:") //fmt.Printf("Key: %s\n", key) fmt.Printf("AWSAccessKeyId: %s\n", creds.AccessKeyID) fmt.Printf("Policy: %s\n", policy) fmt.Printf("x-amz-signature: %s\n", signature) fmt.Printf("x-amz-credential: %s\n", credential) fmt.Printf("x-amz-algorithm: AWS4-HMAC-SHA256\n") fmt.Printf("x-amz-date: %s\n", amzDate) } // generateCURLCommand generates a CURL command for uploading a file to S3 func generateCURLCommand(bucketName, key, contentType, policy, credential, amzDate, signature string) string { filename := filepath.Base(key) curlTemplate := `curl -X POST \ -F "key=%s" \ -F "Content-Type=%s" \ -F "X-Amz-Credential=%s" \ -F "X-Amz-Algorithm=AWS4-HMAC-SHA256" \ -F "X-Amz-Date=%s" \ -F "Policy=%s" \ -F "X-Amz-Signature=%s" \ -F "file=@%s" \ https://%s.s3.%s.amazonaws.com.cn/` return fmt.Sprintf(curlTemplate, key, contentType, credential, amzDate, policy, signature, filename, bucketName, Region) } 输出 运行这两个 CURL 示例命令,即可提交当前文件夹下的 hello.txt 和 hello.jpg 文件(先准备好) 未报错则说明上传成功,否则 S3 会抛出错误原因。 交互流程 用户选择文件 → 从服务器获取 URL + Form Fields → 客户端上传文件到 S3(循环上传多个文件) → 上传成功后,将上传成功信息提交给服务器 参考 Example: Browser-Based Upload using HTTP POST (Using AWS Signature Version 4) Browser-Based Uploads Using POST (AWS Signature Version 4) GPT-4o & Claude 3.5 Sonnet 提供部分示例代码

2024/8/21
articleCard.readMore

Kubernetes 实践:AWS EKS 中的 readinessGates 参数

滚动升级中遇到的问题 AWS EKS 的 Deployment 资源在滚动升级时会导致可用 Pod 数量变少,其根源来自于 K8S 与 Target Group 状态不一致导致,本文将解释这一现象产生的原因,同时使用 readinessGates 参数解决问题 上图描述了 Deployment 滚动更新中的一个状态,解释如下: 首先,K8S 的 Deployment 启动一个新 Pod-3,健康检查通过后,它是一个可以接收流量的 Pod Pod-3 既已启动,K8S 选择一个老 Pod,即本例中的 Pod-1,发送信号让其退出 切换到 Target Group 视角,IP-1(对应的 Pod-1)开始注销,IP-3 正在注册,Target Group 注册注销的时间,相较于 K8S 的容器创建、退出要多一些时间 此时,Pod-3 已准备好,但 Target Group 的健康检查或其它配置还未完成,不会转发流量到 Pod-3 Pod-1 已退出,Target Group 也正在注销 IP-1,不会转发流量到 Pod-1 原本 3 个 Pod 支撑的服务,现在仅有 IP-2 / Pod-2 这个链路的服务是正常的,显然这有问题 使用 readinessGates 解决问题 仅使用 readinessProbe 配置,其检查通过,K8S 即认为服务就绪,可供外部访问,readinessGates 的作用是在 readinessProbe 的基础上等待 readinessGates 的状态也可用时,才认为服务准备妥当,等待的过程中不会退出上例中的 Pod-1,即可解决 Pod 数量变少,服务可用性不够的问题 在 AWS EKS 中,readinessProbe 的工作原理是怎样的? AWS 提供有 aws-load-balancer-controller 插件,它会从 Target Group 获取 Pod 注册状态,在 Target Group 中的 Pod 注册完成后设置 readinessGates 为通过状态,之后 K8S 才会退出旧 Pod,这是 AWS EKS 和 Target Group 联动的具体实现(推荐方式) 接下来使用测试 Nginx Demo 服务进行验证 配置 Namespace 支持 pod-readiness-gate-inject 要启用这一功能,需要在 namespace 添加 elbv2.k8s.aws/pod-readiness-gate-inject=enabled 标签 $ kubectl create namespace readiness namespace/readiness created $ kubectl label namespace readiness elbv2.k8s.aws/pod-readiness-gate-inject=enabled namespace/readiness labeled $ kubectl describe namespace readiness Name: readiness Labels: elbv2.k8s.aws/pod-readiness-gate-inject=enabled Annotations: <none> Status: Active 创建 Service 资源 apiVersion: v1 kind: Service metadata: name: nginx-demo-service namespace: readiness spec: ports: - name: http port: 80 protocol: TCP targetPort: 80 type: ClusterIP selector: app: nginx 创建 TargetGroupBinding 资源 TargetGroupBinding 资源也是 AWS EKS 专属,用于将 Service 和 Target Group 进行绑定,也是 EKS 和 ALB 一起使用时,相较于 Ingress 来说更加推荐的配置方式 apiVersion: elbv2.k8s.aws/v1beta1 kind: TargetGroupBinding metadata: name: nginx-demo-tgb namespace: readiness spec: serviceRef: name: nginx-demo-service port: 80 targetGroupARN: <your-target-group-arn> targetType: ip 备注:TargetGroup 先手动创建,将资源 arn 进行替换 创建 Deployment 资源 apiVersion: apps/v1 kind: Deployment metadata: name: nginx-demo-deployment namespace: readiness spec: replicas: 2 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx:latest ports: - containerPort: 80 readinessProbe: httpGet: path: / port: 80 initialDelaySeconds: 5 periodSeconds: 10 timeoutSeconds: 5 successThreshold: 1 failureThreshold: 3 readinessGates: - conditionType: target-health.elbv2.k8s.aws/nginx-demo-tgb 这里的 target-health.elbv2.k8s.aws/nginx-demo-tgb 值,target-health.elbv2.k8s.aws 是固定前缀,后者 nginx-demo-tgb 是上边儿创建的 TargetGroupBinding 资源名称 滚动更新测试 当滚动更新时,通过 Rancher 面板可以看到提示 The status of pod readiness gate "target-health.elbv2.k8s.aws/nginx-demo-tgb" is not "True", but False 面板没有展示 Readiness Gates 的值,使用 kubectl 命令查看 Pod 状态 在等待中,最后一列是 “0/1”,当 Target Group 注册完成后,状态变为 “1/1” 滚动更新后,此时 Target Group 面板几个老 Pod 的信息还在,显示为 “deregistration” 正在注销 引申:值得一提的 Pod 注销等待 了解了 readinessGates 联动可能会自然的想到,当 Pod 在 Target Group 注销的时候有没有什么优雅的方式,等待 Target Group 注销完成,Pod 再退出? 目前来说是没有的。 实际上,以图一为例,Target Group IP-1 在注销的前期,还是会有少量流量进到 Pod-1,如果 Pod-1 退出的太快,那么这些请求就会 5XX 错误,当下的解决办法是等待 30 秒 Pod 再退出 lifecycle: preStop: exec: command: ["sleep", "30"] 其它补充 查询集群中已安装 aws-load-balancer-controller 的版本 $ kubectl describe deployment aws-load-balancer-controller -n kube-system | grep Image 最后,AWS EKS 的功能在持续迭代,说不定通过什么注解、标签就能更加优雅的解决问题了,本文可能有实效性,一切请以文档为准 参考 AWS LoadBalancer Controller - Pod readiness gate

2024/8/16
articleCard.readMore

公元 4202 年,终于配置上了 pre-commit 提交检查

提要信息 整理照片发现2022年6月5日的截图,当时看到博主安利 pre-commit 也想用下,截图完就忘在脑后,推都变成了 X,两年后迟来的尝试,博主没骗人,真的「早该这么做了」 pre-commit 是什么? Git 提供 4 种 Hooks,可以在时间点自定义执行一些操作,此处暂时只关注其中之一的 pre-commit Hook The pre-commit hook is run first, before you even type in a commit message. It’s used to inspect the snapshot that’s about to be committed, to see if you’ve forgotten something, to make sure tests run, or to examine whatever you need to inspect in the code. Exiting non-zero from this hook aborts the commit, although you can bypass it with git commit --no-verify. You can do things like check for code style (run lint or something equivalent), check for trailing whitespace (the default hook does exactly this), or check for appropriate documentation on new methods. 设置 pre-commit 后,每次执行 git commit -m "..." 前 Git 会自动运行 pre-commit Hook,可以是一些命令或脚本,如果配置了 Lint 检查,不符合规范的代码在检测不通过时就会自动中断 Git 提交过程 同时,有一个工具,也叫 pre-commit pre-commit: A framework for managing and maintaining multi-language pre-commit hooks. 使用这个工具,可以很方便的配置 Git pre-commit Hooks pre-commit 工具安装 通过 pip 在项目中使用 $ python3 -m venv myvenv $ source myvenv/bin/activate $ pip3 install pre-commit 通过 brew 全局安装(macOS 系统) $ brew install pre-commit 查看 pre-commit 版本 $ pre-commit --version pre-commit 3.8.0 添加 pre-commit 配置 在项目的根目录创建名为 .pre-commit-config.yaml 的配置文件 添加以下内容 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-yaml - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black rev: 24.8.0 hooks: - id: black 以上是一个示例配置,意为使用 pre-commit/pre-commit-hooks 和 psf/black 仓库提供的脚本对项目中的文件、代码做检查 check-yaml 用来检查 yaml 配置文件语法是否正确 end-of-file-fixer 可以确保每个文件的末尾都有一个空行 trailing-whitespace 去除文件行末的多余空白字符 black 用于严格地确保相同的代码风格 其中 pre-commit/pre-commit-hooks 提供了一些通用的 Hooks,而 psf/black 提供的 Hook 仅针对于 Python 语言 在 https://pre-commit.com/hooks.html 可以找到不同语言的专属 Hooks 仓库,另外在 Github 上也能找到一些 Hooks 安装 pre-commit 钩子 安装完 pre-commit 工具,也添加了配置文件,接下来将配置文件应用到 Git 在项目根目录运行(即 .pre-commit-config.yaml 所在目录) $ pre-commit install pre-commit installed at .git/hooks/pre-commit pre-commit 写入的 .git/hooks/pre-commit 内容如下,仅作为了解即可,它是个 Bash 脚本,将配置文件和 Git Hook 进行了绑定 #!/usr/bin/env bash # File generated by pre-commit: https://pre-commit.com # ID: 138fd403232d2ddd5efb44317e38bf03 # start templated INSTALL_PYTHON=/Users/projects/fr-compare/myvenv/bin/python3.12 ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit) # end templated HERE="$(cd "$(dirname "$0")" && pwd)" ARGS+=(--hook-dir "$HERE" -- "$@") if [ -x "$INSTALL_PYTHON" ]; then exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}" elif command -v pre-commit > /dev/null; then exec pre-commit "${ARGS[@]}" else echo '`pre-commit` not found. Did you forget to activate your virtualenv?' 1>&2 exit 1 fi 手动触发 pre-commit 工具检查(可选) 可以使用以下命令手动触发检查所有文件,这有助于确保现有代码符合 isort、black 和 flake8 的规范 pre-commit run --all-files 首次使用,会自动修复一些问题、同时抛出不符合规范的错误提示 自动修复的内容从可视化 Diff 视角看,单引号被调整为了双引号、移除了多余的空格、文件尾行保留一行等 其它不能自动修复的问题如行过长,未使用到的代码、更好的实现方式,错误被覆盖等等,需要手动处理,修复完成后显示如下 适用于 Python 项目的配置文件 使用前建议到仓库获取最新 Tag 标签进行替换 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: check-yaml - id: debug-statements - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/pycqa/isort rev: 5.13.2 hooks: - id: isort name: isort (python) args: ["--profile", "black", "--filter-files"] - repo: https://github.com/psf/black-pre-commit-mirror rev: 24.8.0 hooks: - id: black args: [--line-length=79] language_version: python3.12 - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: - id: flake8 适用于 Golang 的配置文件 使用前建议到仓库获取最新 Tag 标签进行替换 repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/dnephin/pre-commit-golang rev: v0.5.1 hooks: - id: go-fmt - id: go-imports - id: no-go-testing - id: golangci-lint args: - --disable=unused # 或者使用 // nolint:gosimple 进行标注,哪个 Hook 报错 nolint 哪个 Hook 名 - id: go-unit-tests # - id: validate-toml - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.16.0 hooks: - id: commitlint stages: [commit-msg] additional_dependencies: ['@commitlint/config-conventional'] 运行检查后的输出效果 提示:使用到的 golangci-lint 和 goimports 工具需要先安装 # macOS 安装 golangci-lint $ brew install golangci-lint # 通过 go install 安装 goimports,需要确认 go bin 目录已在环境变量中 $ go install golang.org/x/tools/cmd/goimports@latest 例如 Golang 项目,它帮我发现了无效的 if (true) { } 空内容分支代码,项目中 _, r := range []rune(s) 去掉了多此一举的 []rune(),可以直接 range s,使用 time.Since() 替换time.Now().Sub() 再比如以下的代码,其实不需要判断 Key 是否存在,直接使用 delete(r.Headers, key) 即可,因为 delete() 再 m 为 nil 或者 m[key] 不存在时什么都不会做 The delete built-in function deletes the element with the specified key (m[key]) from the map. If m is nil or there is no such element, delete is a no-op. if _, exist := r.Headers[key]; exist { delete(r.Headers, key) } 等等其它的提示,实打实的提升代码的质量、统一风格、看到各项检查都是 “Passed”,提交代码也更安心了... 参考 https://pre-commit.com

2024/8/15
articleCard.readMore

Yt-dlp 入门笔记 & 不简明教程

本来计划整理一个常用命令手册,但整理后感觉比较啰嗦,也是笔记、也是不简明教程。 安装 Yt-dlp 依赖确认 确认系统已正确安装 FFmpeg,FFprobe 会随 FFmpeg 一起安装,无需特别留意 $ ffmpeg -version ffmpeg version 7.0 Copyright (c) 2000-2024 the FFmpeg developers built with Apple clang version 15.0.0 (clang-1500.1.0.2.5) 官方安装说明:https://github.com/yt-dlp/yt-dlp/wiki/Installation,以下列出个人常用系统的安装升级命令 MacOS 系统 $ brew install yt-dlp # 更新 $ brew upgrade yt-dlp Ubuntu 系统 $ sudo add-apt-repository ppa:tomtomtom/yt-dlp $ sudo apt update $ sudo apt install yt-dlp # 更新 $ sudo apt update $ sudo apt install yt-dlp 安装后查询 yt-dlp 版本 $ yt-dlp --version 2024.08.01 默认下载命令 不指定任何参数时,默认的格式参数为 -f "bv*+ba/b",意为下载最佳视频音频组合,后文会解释它的含义 $ yt-dlp https://www.youtube.com/watch?v=60ItHLz5WEA 备注:后续使用 替代 https://www.youtube.com/watch?v=60ItHLz5WEA 以降低油管域名浓度 通过截图中 yt-dlp 输出的信息,我们得知: 它分别下载了视频流文件和音频流文件,最终合并为 Alan Walker - Faded [60ItHLZ5WEA] webm 视频文件 616 Alan Walker - Faded [60ItHLz5WEA]. f616.mp4 视频文件(Video Only) 251 Alan Walker - Faded [60ItHLz5WEAT. f251.webm 音频文件 这里值得留意的是 616 和 251,它们是资源 ID,稍后会查看这个视频下的所有资源 查看可供下载的资源列表 $ yt-dlp --list-formats <video_url> 截图中展示了这个视频可获取的全部资源 —— 不同的封装格式、分辨率、编码、码率等 这里选取 720P 分辨率的视频资源进行说明 简要来说第一列为资源ID、第二列是容器后缀、第三列是画面分辨率 第六列的 “345K”、“808K” 是视频的码率,通常越大代表视频质量更好,由此可见 609 是 720P 视频中质量最好的 第七列的 MPEG-DASH(https + mp4_dash)和 m3u8 是不同的流媒体协议,除此之外,还有 HLS 也是同类技术,此处无需过多关心,相同编码的资源,无论通过什么协议下载,内容是一样,这里展示的传输方式 第八列的 av01.0.05M.08、avc1.4D401F、vp09.00.40.08 描述了编码格式,分别对应 AV1、H.264、VP9(AV1 最新、VP9 是 Google 的开放标准,更适合4K等资源编码、H.264 使用最为广泛) 这里要分区下编码格式和容器封装格式:前者 AV1、H.264、VP9 是编码格式,而我们下载到的视频 MP4、MKV、WEBM、AVI 是容器封装技术,容器封装技术将视频流、音频流、字幕等 “打包” 到一个文件中,此处介绍的详细是为了更好区分概念,另外实际使用 yt-dlp 时一般不指定具体资源,会有更易用的命令可供使用 第十列的 “video only” 说明这个资源只包含视频画面,不包含声音,可以查看完整截图的 18 号资源,它是 av1 + mp4a 编码的 360p MP4 视频,同时包含音视频 了解了以上信息,我们可以手动指定下载 398 AV1 编码 720P 分辨率的视频和 140 MP4A 编码的音频作为测试 # mp4 $ yt-dlp -f "398+140" <video_url> 下载的后的文件为 mp4 格式,这是 yt-dlp 自动判断的,如果指定 247 的 VP9 编码,则下载合并后是 mkv 格式,同时也把音频换为 251 的 opus 编码,则会自动合并为 webp 格式,yt-dlp 会选择适宜的容器封装技术来存储视频、音频流 # mkv $ yt-dlp -f "247+140" <video_url> # webm $ yt-dlp -f "247+251" <video_url> 如需强制封装为 mp4 视频格式,可使用 --merge-output-format 参数 # mp4 $ yt-dlp -f "247+251" --merge-output-format mp4 <video_url> 常用命令整理 下载 MP4 格式最佳质量视频 优先下载 MP4,如果没有,则下载最佳画质的其它格式 # 使用默认的 -f 参数 $ yt-dlp -S "ext" <video_url> # 或者 $ yt-dlp -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4] / bv*+ba/b" <video_url> 不关心最终格式,获取「最佳视频流」和「最佳音频流」,合并为一个视频 $ yt-dlp -f "bv+ba/b" <video_url> 下载指定分辨率的视频(720P、1080P、4K) # 720P $ yt-dlp -f "bv[height=720][ext=mp4]+ba[ext=m4a]/b" <video_url> # 1080P $ yt-dlp -f "bv[height=1080][ext=mp4]+ba[ext=m4a]/b" <video_url> # 4K $ yt-dlp -f "bv[height=2060][ext=mp4]+ba[ext=m4a]/b" <video_url> 下载 MP3 格式的音频 $ yt-dlp --extract-audio --embed-thumbnail --audio-format mp3 <video_url> yt-dlp --extract-audio --audio-format mp3 <video_url> "--extract-audio" 参数从资源中解析出音频流 "--embed-thumbnail" 设置文件封面缩略图 "--audio-format" 保存的音频格式,可选值为 best (default), aac, alac, flac, m4a, mp3, opus, vorbis, wav 补充:这里的 --audio-format 参数,如果你指定了 mp3、flac、opus, 最终的文件也会是对应名称的后缀文件、如果指定了 aac,最终保存的文件是 .m4a 文件,指定了 vorbis 则文件后缀为 .ogg,跟视频同理,yt-dlp 会根据你选择的音频编码选择适合的容器进行存储 下载 Playlist 视频列表 $ yt-dlp --download-archive archive.txt https://www.youtube.com/playlist?list=PLSk-fsG1YwI9Zu4e8MJBKM2TRjWcXqCDw "--download-archive archive.txt" 已下载的文件ID会存储在这个文件,即自动跳过已下载文件 更多的 -f 参数说明,参考 yt-dlp 官方文档:format-selection-examples 格式参数的补充说明 参数 -f "bv*+ba/b" 更详细的介绍 使用 yt-dlp 不设置 -f 时,其默认值是 "bv*+ba/b" ,这个命令的全称是 "bestvideo*+bestaudio/best" bv*+ba/b 获取「最佳视频流」和「最佳音频流」,合并为最终视频,相比于 "bv+ba/b",前者不仅限于 Video Only,只要是 Video 就可以,不局限于单一的 “最佳”,在某些情况下会选择一个更合适的流,特别是在不同的编码或容器格式中 “+” 加号代表单一视频和单一音频下载后合并为一个音视频文件,斜线 “/” 表示 “或”,当前方条件不满足时使用后面的条件进行匹配 best 表示获取包含音频流的最佳视频,如果仅指定 -f b,那么如果仅有一个 360P 的同时包含视频和音频的视频资源,也会被下载,虽然它跟通常意义上的 “高清” 不沾边儿,通常作为一个 “保底” 的可选参数 参数 -f "bv+ba" --merge-output-format mp4 和 -f "bv[ext=mp4]+ba[ext=m4a]" 的选择与使用 初看感觉这两个命令差不多 —— 都能下载 MP4 格式的视频,但还是有一些本质区别 前者的意思是分别获取「最佳视频流」和「最佳音频流」合并为 MP4 格式的视频,后者是在选择资源的的时候就从 MP4 视频流及 M4A 音频流中下载其中的最佳画质,最终合并的也是天然的 MP4 视频格式 所以你需要下载 MP4 格式的视频,推荐后者,可以确保更好的兼容性,同时获得最佳画质。 一般容器和编码有一些适宜的固定搭配,前者在你确认下载的视频流、音频流(指定资源ID)适合使用 MP4 容器封装时使用,或是不关心最终文件类型时候使用 -f "bv+ba" 但不指定 --merge-output-format mp4 来获得当前视频的全局最佳质量时使用(可能获取到 webm 格式的视频),两个参数不推荐组合在一起使用,可能将 VP9 编码的视频流放到 MP4 容器中,就不是一个常规的组合,虽然使用一些播放器可以正常播放,但整体来看兼容性不佳。 使用 Cookie 下载视频 我最开始碰见需要设置 Cookie 的场景是下载 Bilibili 视频时,不登录下载到的视频画质低(是的,除了 Youtube,yt-dlp 也支持包括 Bilibili 在内的很多网站) 其中一个可行的方式是导出 Cookie,Firefox Cookies Text 插件:https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/ yt-dlp --cookies cookies.txt https://www.bilibili.com/video/BV1H44y1w7AX 同样的,下载 Y2b 视频时也是这个命令,需要使用 Cookie 的场景是在服务器 Linux 系统下载,不设置 Cookie 下载几个视频后,Y2b 对下载请求进行了屏蔽 另一种使用 Cookie 的方式是借助于本机浏览器的缓存,查询资料的过程中有人提及到 Cookie 不是共享的,使用时需关闭浏览器,我没有进一步测试。 使用 Socket5 设置代理 最后,不得不提的 --proxy 参数使用如下,预祝网络畅通,心舒畅 🌈 yt-dlp --proxy socks5://127.0.0.1:10808 了解至此,对于 yt-dlp 的简单实用场景,就基本能应付的来,更多的参数信息参考 --help 命令或 Document 参考 YTP-DL: The Easiest Way to Download Songs from YouTube Videos 使用Yt-dlp高效下载Youtube的视频 How to Download Highest Quality Video+Audio and Merge to MP4? #3398 Github: yt-dlp repo r/youtubedl cookies

2024/8/14
articleCard.readMore

安装 Vector & Prometheus & Grafana 实验环境简明指南(MacOS)

使用 Vector 收集系统指标(CPU、内存等)信息,Prometheus 主动从 Vector 拉取,最后通过 Grafana 接入 Prometheus 数据源进行查看 因为后续会进一步学习 Prometheus 相关知识,本篇文章作为先行的环境准备,记录以作备忘 安装 Vector Vector 支持的输入输出类别很丰富,此处选择 Vector 作为 Agent $ brew install vector $ brew services start vector 配置 Vector 输出端点 配置文件路径:/opt/homebrew/etc/vector/vector.toml 这个路径定义在:~/Library/LaunchAgents/homebrew.mxcl.vector.plist (通过 Brew 安装) [sources.host_metrics] type = "host_metrics" scrape_interval_secs = 15 [sinks.prometheus_exporter] type = "prometheus_exporter" inputs = ["host_metrics"] address = "0.0.0.0:9598" 在配置文件内追加以上配置段,其定义了一个 host_metrics 类型的输入源,同时定义类型为 prometheus_exporter 的输出端 重启 Vector 使得配置生效 $ brew services restart vector 访问 http://localhost:9598/metrics,如果一切正常,可以看到一些指标数据,有 CPU、内存、磁盘等信息 安装 Prometheus $ brew install prometheus $ brew services start prometheus 配置 Prometheus 拉取 相关的配置文件为 /opt/homebrew/etc/prometheus.args 和 /opt/homebrew/etc/prometheus.yml,前者定义了配置文件、监听地址和数据存储位置的配置,后者是 Prometheus 配置文件 修改后的配置文件如下: global: scrape_interval: 15s scrape_configs: - job_name: "prometheus" static_configs: - targets: ["localhost:9090"] - job_name: 'vector_metrics' static_configs: - targets: ['localhost:9598'] 重启 Prometheus 使得配置生效 $ brew services restart prometheus 访问 http://localhost:9090/targets,可以看到已加载 vector_metrics 数据源 回到首页,输入指标名称:host_memory_available_bytes(指标名称可以在 Vector 的输出源指标中找到) 安装 Grafana # 安装 $ brew install grafana # 启动 $ brew services start grafana 默认访问地址:http://localhost:3000 默认用户密码:admin / admin (首次登录提示修改密码,本地测试可以跳过) 登陆后可以到右上角的个人资料设置时区、语言等,接下来添加 Prometheus 数据源,选择左侧的 "Connection" - "Data sources" 名称随意填写,地址即为刚部署的 Prometheus 地址:http://localhost:9090 点击测试并添加,添加完成后在侧边栏选择 Explore 或 Metrics 功能,输入指标名称就可以查看到数据 Grafana 图表和功能比 Prometheus 自带的 Web Console 要完善很多。 部署完成,本篇内容也先到这里。

2024/8/13
articleCard.readMore

Gin Web 框架设置客户端缓存 & 启用 Gzip 压缩中间件

在服务部署中,如果 Golang 服务有一个前置的 Nginx,可以通过 Nginx 设置资源缓存和资源压缩,如果没有前置服务,服务的一些资源又通过 HTTP 框架接口提供,那么可以通过中间件来设置浏览器缓存及 Gzip 压缩,以下是代码示例 设置缓存中间件 代码示例 package main import ( "crypto/sha1" "encoding/hex" "fmt" "log" "net/http" "strings" "github.com/gin-gonic/gin" ) const ( staticPathPrefix = "/static/" cacheControlHeader = "Cache-Control" cacheControlValue = "public, max-age=2592000" // 缓存一个月 eTagHeader = "ETag" ifNoneMatchHeader = "If-None-Match" ) // CacheMiddleware 是一个中间件,用于设置缓存控制头 func CacheMiddleware(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, staticPathPrefix) { // 设置缓存控制头 c.Header(cacheControlHeader, cacheControlValue) // 生成并设置 ETag 头 eTag := generateETag(c.Request.URL.Path) c.Header(eTagHeader, eTag) // 检查 If-None-Match 头与生成的 ETag 是否匹配,若匹配则返回 304 Not Modified if match := c.GetHeader(ifNoneMatchHeader); match != "" { if match == eTag { log.Printf("Cache hit for: %s", c.Request.URL.Path) c.Status(http.StatusNotModified) c.Abort() return } } } c.Next() } // generateETag 根据文件路径生成一个简单的 ETag func generateETag(filePath string) string { h := sha1.New() _, err := h.Write([]byte(filePath)) // 这里只是为了示例,实际可以基于文件内容 if err != nil { log.Printf("Error generating ETag for %s: %v", filePath, err) return "" } return fmt.Sprintf("\"%s\"", hex.EncodeToString(h.Sum(nil))) } func main() { // 初始化 Gin 引擎 r := gin.Default() // 添加缓存中间件 r.Use(CacheMiddleware) // 静态文件路由 r.Static("/static", "./static") // 开始服务 r.Run(":8080") } 启动程序 $ go run main.go 访问 main.go 同目录下的 static 目录下的资源:http://127.0.0.1:8080/static/1.jpg 第二次访问,就可以看到 Transferred 处显示 “cached”,图像右侧的 Response Headers 显示了 Cache-Control 和 Etag 这里补充一些重点:以上示例代码仅适合文件不会经常变动且需要即时生效的场景,因为 ETag 的计算使用的是路径,路径不变,浏览器请求资源就会得到 304 状态,无法获取替换后的文件。 有两个办法能缓解/解决这个问题 设置更短的 max-age 缓存时间,这样在服务器替换文件后,待浏览器缓存失效后会重新获取。 服务器每次发布的文件,名称中带有随机内容,这种适合前端静态资源,如:app.cb9a2b12.js 如果你不希望修改文件名,同时希望更新文件后能立即生效,解决办法是依赖文件的内容计算 ETag,修改部分代码如下: // CacheMiddleware 是一个中间件,用于设置缓存控制头 func CacheMiddleware(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, staticPathPrefix) { filePath := filepath.Join(".", c.Request.URL.Path) // 检查文件是否存在 if _, err := os.Stat(filePath); err == nil { // 设置缓存控制头 c.Header(cacheControlHeader, cacheControlValue) // 生成并设置 ETag 头 eTag := generateETag(filePath) c.Header(eTagHeader, eTag) // 检查 If-None-Match 头与生成的 ETag 是否匹配,若匹配则返回 304 Not Modified if match := c.GetHeader(ifNoneMatchHeader); match != "" { if match == eTag { log.Printf("Cache hit for: %s", c.Request.URL.Path) c.Status(http.StatusNotModified) c.Abort() return } } } } c.Next() } // generateETag 根据文件内容生成一个 ETag func generateETag(filePath string) string { fileContent, err := os.ReadFile(filePath) if err != nil { log.Printf("Error reading file %s: %v", filePath, err) return "" } h := sha1.New() h.Write(fileContent) return fmt.Sprintf("\"%s\"", hex.EncodeToString(h.Sum(nil))) } 这样使用 Chrome 系的浏览器每次访问资源时,文件未被更新会返回 304,否则返回替换后的内容,符合预期,需要留意的是通过文件内容的方式计算 ETag 会加重接口的资源性能损耗,不适合对性能要求高和资源受限的场景。 这里为什么强调 Chrome 系是因为在使用 Firefox 测试时发现已缓存的文件,在缓存有效期内不会再调用服务器接口,即无法得知服务器的资源已经发生了变更,不再受到服务器控制,此时只能依赖浏览器的强制刷新,需要特别注意。 一个可行的思路是对于需要经常变化的同名资源,不同的浏览器设置不同的策略,Chrome 系使用文件内容,Firefox 则基于 Path 计算 ETag,设置更短的缓存时间 资源压缩中间件 代码示例 package main import ( "compress/gzip" "io" "net/http" "strings" "github.com/gin-gonic/gin" ) // GzipMiddleware 是一个中间件,用于 gzip 压缩响应数据 func GzipMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !strings.Contains(c.GetHeader("Accept-Encoding"), "gzip") { // 如果客户端不支持 gzip,则直接调用下一个处理器 c.Next() return } // 设置响应头,告知客户端采用 gzip 压缩 c.Header("Content-Encoding", "gzip") // 创建一个 gzip.Writer gz := gzip.NewWriter(c.Writer) defer gz.Close() // 确保在响应结束时关闭 gzip.Writer // 包装 ResponseWriter c.Writer = &gzipResponseWriter{ResponseWriter: c.Writer, Writer: gz} c.Next() } } // gzipResponseWriter 包装了 gin.ResponseWriter 和 gzip.Writer type gzipResponseWriter struct { gin.ResponseWriter io.Writer } // Write 方法用于压缩并写出数据 func (w *gzipResponseWriter) Write(b []byte) (int, error) { if w.Header().Get("Content-Type") == "" { w.Header().Set("Content-Type", http.DetectContentType(b)) } return w.Writer.Write(b) } func main() { r := gin.Default() // 使用 Gzip 中间件 r.Use(GzipMiddleware()) // 示例路由 r.GET("/ping", func(c *gin.Context) { c.String(http.StatusOK, "pong") }) // 静态文件路由 r.Static("/static", "./static") r.Run(":8080") } 这里使用的 dpcq.txt 文件作为示例,不设置 Gzip 压缩时文件 18MB 大小,很浪费网络带宽,压缩后减少了 61% 的大小,如果是已压缩后的二进制内容,效果不明显,对于文本压缩比很高。 压缩中间件发散补充 以 Firefox 浏览器为例,浏览器除了支持 gzip,还支持 deflate, br, zstd Accept-Encoding: gzip, deflate, br, zstd 相对于前两者,br 和 zstd 是压缩率更高的算法,现代浏览器也都支持,服务器可以优先判断是否支持 br 和 zstd,相应的,压缩率更高,也会对服务器的性能有更高的要求,需要跟带宽做适当的权衡,是一个 CPU 多出力还是带宽多出力的问题,以下是支持多压缩类型的代码示例: package main import ( "compress/flate" "compress/gzip" "io" "net/http" "strings" "github.com/andybalholm/brotli" "github.com/gin-gonic/gin" "github.com/klauspost/compress/zstd" ) // MultiCompressionMiddleware 是一个中间件,根据客户端的 Accept-Encoding 头部选择相应的压缩方式 func MultiCompressionMiddleware() gin.HandlerFunc { return func(c *gin.Context) { acceptEncoding := c.GetHeader("Accept-Encoding") var encoderWriter io.WriteCloser var encodingType string if strings.Contains(acceptEncoding, "zstd") { encodingType = "zstd" encoder, err := zstd.NewWriter(c.Writer) if err != nil { c.Next() return } encoderWriter = encoder } else if strings.Contains(acceptEncoding, "br") { encodingType = "br" encoderWriter = brotli.NewWriter(c.Writer) } else if strings.Contains(acceptEncoding, "gzip") { encodingType = "gzip" encoderWriter = gzip.NewWriter(c.Writer) } else if strings.Contains(acceptEncoding, "deflate") { encodingType = "deflate" encoderWriter, _ = flate.NewWriter(c.Writer, flate.DefaultCompression) } // 如果没有找到匹配的编码格式则继续执行下一个处理器 if encoderWriter == nil { c.Next() return } defer encoderWriter.Close() c.Header("Content-Encoding", encodingType) c.Writer = &compressionResponseWriter{ResponseWriter: c.Writer, Writer: encoderWriter} c.Next() } } // compressionResponseWriter 包装 gin.ResponseWriter 和 io.Writer type compressionResponseWriter struct { gin.ResponseWriter io.Writer } // Write 方法用于压缩并写出数据 func (w *compressionResponseWriter) Write(b []byte) (int, error) { if w.Header().Get("Content-Type") == "" { w.Header().Set("Content-Type", http.DetectContentType(b)) } return w.Writer.Write(b) } func main() { r := gin.Default() // 使用 MultiCompressionMiddleware 中间件 r.Use(MultiCompressionMiddleware()) // 静态文件路由 r.Static("/static", "./static") r.Run(":8080") } 一般来说,Brotli(br)的压缩率更高,对服务器的性能要求也更高,Zstd 在压缩率和资源消耗上比较平衡,Gzip 的兼容性最好、应用最广泛,Deflate 使用的场景很小,一般都被 Gzip 所替代。 原文件:18.01 MB Gzip 压缩后:6.9 MB Deflate 压缩后:6.9 MB Zstd 压缩后 6.3 MB Brotli 压缩后 5.72 MB 文件压缩可以只针对特定类型进行处理,例如已经是 ZIP 压缩包类型、以及 EXE 可执行程序等二进制内容,再次压缩的收益很小,对资源消耗也大,而针对 css、js、txt、json 文件的压缩就更有作用。 最后的补充 无论是压缩还是设置客户端缓存中间件,本篇介绍的方案都是适合小型 Web 服务(静态资源由服务提供路由访问),更严谨和正规的做法是有专门的网关对资源进行统一的控制,同时对于访问量较大的静态资源,应使用 CDN 进行分发,提升效率同时减少服务器带宽的占用。

2024/7/24
articleCard.readMore

记录 ECS 部署 WordPress 二开程序的曲折 Debug 过程

前情信息 服务部署在 AWS EKS 二次开发的 WordPress 代码通过 wordproess 基础镜像封装,配置通过 Sidecar 镜像拉取,两个容器在一个 Task 内 WordPress 由其它团队二次开发,我协助一同事将服务容器化部署 排查过程 现象:线上二次开发的 WordPress 服务修改后的功能没有生效 猜测:通过外部机器更新的 efs 后,Wordpress 服务没有读取到最新的代码 验证:通过跳板机进到 ECS 机器节点,再通过 Docker 进到容器,此时从外部更新 efs 内文件,容器内可见修改,efs 文件更新没有问题 猜测:因更新 efs 内主题代码为手动复制,猜测复制不完全导致测试环境和正式环境的代码不一致(重点!) 验证:核对了正式环境 wp-content/.../comment.php 等文件,文件内容符合预期,初步认为正式环境代码是跟测试环境保持一致的 猜测:在 efs 内修改主题代码后,WordPress 程序没有使用最新的代码(没有 PHP 语言和 WordPress 经验,不清楚修改代码后是否实时生效) 验证:简单粗暴的修改接口返回值的 Code 为 4XX,从页面看可以立即生效,确认可以动态生效,此时认为排除了 efs 磁盘的嫌疑、也基本排除代码不一致问题 猜想:测试环境和正式环境的数据库数据不一致,导致代码逻辑走到了异常流程 验证:比对数据库表结构,字段均一致,未发现差异 猜想:基本排除数据库不一致问题,但想定位到问题,也需要看下线上代码走到了哪个条件分支导致跟测试环境结果不一样 验证:线上相关代码添加 ERROT LOG,在 ECS 机器上查看容器日志,期间引发 Container 退出,Task 重新启动,调试非常麻烦 验证:先将本地测试环境搭建起来,看是否有同样问题,同时也方便添加日志查看输出 问题:本地没有可使用的数据库,暂连接线上数据库进行访问(正式环境暂无线上用户),访问国外异常缓慢,且容易触发超时,另外本地启动的 WordPress 会修改线上数据库插件的开关,不适合再继续使用 问题:使用外包团队提供的数据库,导入数据出现问题,使用本地 MySQL 数据库问题依然存在。 解决:MySQL 版本不一致,修复测试环境日期为空的字段、或忽略目标 MySQL 的强校验,导入成功。 问题:本地 Docker Desktop 启动容器内的 Service 连不上通过 Brew 安装的本地数据库 解决:换用 ObrStack 容器程序,指定主机网络模式,同时数据库地址从 localhost 调整为 127.0.0.1,访问成功 问题:从浏览器页面上访问不到服务的页面 解决:通过主机模式没有映射,原本 -p 80:8080 应访问 80 到达服务,调整网络模式后需要访问 8080,但 8080 被本地的 Nginx 服务占用,删除相关容器后恢复访问,本地测试环境启动完成 验证:本地开发环境功能正常,未复现线上问题 猜想:问题很隐蔽,为进一步排除数据库问题,正式环境的数据库也同步到本地进行验证 验证:功能正常,彻底排除正式环境可能存在数据差异的猜想 猜想:数据库一致,猜想正式环境和测试环境代码还是存在不一致,可能存在某一个文件不一致,在之前测试时没碰到 验证:通过跳板本 ECS 节点上,导出正在运行的容器的全量代码文件,本地进行比对,发现一些文件不同! 验证:正式环境线上修改代码,功能正常(找到差异,定位就容易了) 猜想:容器构建的时候,代码没有正确打包到镜像中 验证:在 Jenkins 控制台打印了特定文件、在 ECS 节点上导出 Image 内的文件,均是正确的,再次核对容器内文件,不正确,也就是 “镜像中文件正确,容器中文件不正确” 猜想:容器启动的时候,WordPress 镜像有什么特殊逻辑,删除或覆盖了二次开发的代码文件 验证:排查 WordPress 镜像的启动脚本,还真发现了,如果 /var/www/html 目录没有文件,则从 /usr/src/wordpress 复制代码到 /var/www/html 目录,注释掉 docker-entrypoint.yaml 相关代码,/var/www/html 内的内容消失,仅剩 sidecar 拉取的 wp-config.php 及 efs 挂载的 wp-content 文件夹(此时也是一头雾水) 验证:构建镜像时,将二次开发的代码放到 /usr/src/wordpress 目录,代码果真被复制过去,问题临时解决,但是很奇怪的是为什么镜像中存在文件,在 docker-entrypoint.yaml 中走到了 “如果文件不在” 的复制逻辑呢? 猜想:WordPress Dockerfile 模版看到个 VOLUME /var/www/html 指令,可能会导致 /var/www/html 被清空 验证:基于 WordPress 基础镜像制作镜像,使用 COPY 拷贝自定义的文件到 /var/www/html 目录,启动容器后,文件依然存在,启动临时容器挂载这个匿名 volume ,可以在 volume 中查看到自定义文件,说明 WordPress 模版挂载的 VOLUME 不会影响到构建镜像时存入的文件。(对于 VOLUME 命令不理解导致的) 验证:手动从镜像启动容器自定义文件不会消失 猜想:所以还存在其他因素导致镜像中的文件在启动后消失问题,感觉此时临门一脚,但迈不出去 验证:偶然间,使用 Inspect 查看容器,发现启动容器时,/var/www/html 目录被绑定到了卷,进而发现,这个卷也被 sidecar 挂载,它拉取 wp-config.php 放到这个目录中... 至此,真相大白:因为服务容器在运行时绑定了新卷,清空了目录,这个是 ansible 调用 Playbook 部署 ECS Service / Task 脚本中配置的 另外,为什么只有正式环境有问题、手动从镜像启动容器不能复现、明明有代码,WordPress 为什么还走到了复制 src 代码的逻辑,以及换用其它如 php nginx 镜像发现目录内没有代码起不来,都串联起来了。 其实整个排查问题中,最具误导性的点在于 WordPress 复制文件夹的时候不会复制 wp-content 文件夹(如果存在),这就导致在确认 efs 文件的时候,看 wp-content 下主题的修改是更新了的就认为代码已被正确发布,但事实是非 wp-content 文件夹内的其它 php 文件,均被 wordpress 的 src 所覆盖,以至于兜了一大圈才又再次排查代码不一致问题 要点总结 Dockerfile 中使用 VOLUME /dir 命令可以创建一个匿名卷,并将 /dir 目录绑定到这个卷,持久化存储这个目录的内容,后续使用这个镜像作为基础镜像创建镜像,向 /dir 目录复制内容时,也是存储在这个匿名卷中 Container 容器启动时通过 -v 挂载卷,会导致目标文件夹内容丢失,本例中的问题即挂载了(/wp-config.php 到 /var/www/html),导致镜像中的文件丢失,仅剩 wp-config.php 文件 WordPress 镜像有行为,当检测到 /var/www/html 目录没有 index 及 version 文件时会触发复制 src 到这个目录 容器通过 --network host 主机模式启动时,容器内的 localhost 和 127.0.0.1 地址可能不同,从容器内访问电脑上的服务,稳妥的方式是使用 127.0.0.1 efs 磁盘可以同时挂载到多个 EC2 或 ECS Container 上,修改后会立即生效

2024/6/30
articleCard.readMore

内网穿透:白嫖 Cloudflare Tunnel 免费 HTTPS 和访问防护

前置条件 使用 Cloudflare 的 Tunnel 服务需要使用 cf 提供的域名解析。 如果没有域名,建议花钱注册,很便宜(不推荐找免费域名,Freenom 已停止用户申请,如果想碰碰运气,可以申请 nom.za 域名,参考:教程,我等了十多天还在显示 Pending) 如果没有域名,可以注册免费的 US.KG 域名,参考这篇文章:《注册 US.KG 免费域名》 另外,服务有免费的额度,但使用服务前需要绑定信用卡。 添加 Website 选择免费方案 获取到 NS 得到 Cloudflare nameservers 地址 grace.ns.cloudflare.com todd.ns.cloudflare.com 在购买域名的地方配置上这个 NS 地址,等待一段时间后,面板上显示 “Active” 即配置完成 创建隧道(Tunnel) Tunnel 功能的入口不太好找,点击侧边栏 Zero Trust,跳转后找到 “Networks” - “Tunnels” 首次使用需要填写 team name 和付款方式 Free,启动! 点击 “Create a tunnel” 创建隧道,选择 “Cloudflared” 类型 创建名为 “vpc-01” 的 Tunnel 安装 Connector,注意一个 Tunnel 搭配一个 Connector,这个 Connector 作为 Agent 运行在设备上,即 Cf 网络跟设备建立起了一个安全的隧道 根据面板的命令安装 cloudflared 类型的 Connector 执行安装 $ sudo cloudflared service install eyJhIjoiYj... (可选)如果机器已安装过,需要重新安装服务,需要先卸载 $ sudo cloudflared service uninstall 服务启动后,Connectors 处可以看到已有机器连接 下一步,添加转发规则 如上配置即通过 https://abc.donx-done.xyz 访问内网服务器的 http://127.0.0.1:3000 端口提供的 HTTP 服务 这就是 Tunnel 的用法 为服务添加额外验证 可以使用 Application 功能为服务添加额外的安全验证。 还是在 Zero Trust 页面的左侧区域,点击 “Access” - “Applications”,选择 Self-hosted 类型 填写要保护的域名,跟 Tunnel 规则中的域名相同 添加访问策略 允许配置规则如验证邮箱后缀、IP 地址、国家地区等 其余配置暂保持默认,创建即可 刷新网站,可以看到 CF 的访问保护已开启 输入邮箱,点击发送,会收到一个验证码,填入后即可访问服务 补充 使用 CloudFlare Tunnel 有如下用途,摘自参考一 总结 之前体验了 Tailscale 提供的服务 Funnel —— 《Tailscale 异地组网服务使用体验记录》 相较于 Funnel,CloudFlare Tunnel 更容易使用,它将用户内网的服务通过 CF Connector Agent 连接到 CF 网络暴露到互联网,由 CF 确保链路安全,免费用户可白嫖 HTTPS 和访问防护等服务,侧重内网穿透。 而 Tailscale 则更侧重于为用户提供安全的设备间组网,认证设备间的共享和访问,其中的 Funnel 更像是 Tailscale 整体服务的一个子功能,跟 Tunnel 功能类似,单从暴露服务来说,我认为 CloudFlare Tunnel 更加易用。 以个人用户的 NAS 为例,Tailscale 和 CloudFlare Tunnel 选择考量如下(GPT-4o 回答) 如果你希望你的 NAS 服务可以被公网访问(例如分享某些资源给非技术用户或合作伙伴),并且希望利用 Cloudflare 的安全和性能增强功能,选择 Cloudflare Tunnel 如果你主要是自己和少数信任的设备需要访问你的 NAS,并且不想将服务公开到互联网,可以选择 Tailscale 来实现安全内网访问,Tailscale 提供了很好的安全性和便捷性,并且很适合需要私密、点对点连接的场景。 总结来说,Cloudflare Tunnel更适合公开访问和利用高级安全功能,而Tailscale更适合创建一个安全私人网络来访问你的 NAS。根据你的具体需求和首要考虑点(安全性、便捷性、公开访问需求等),你可以做出相应选择。 参考 CloudFlare Tunnel 免费内网穿透的简明教程

2024/5/27
articleCard.readMore

Tailscale 异地组网服务使用体验记录

最近听说了两个服务,Tailscale 和 CloudFlare Tunnel,它们都是商业公司的产品,对于个人用户有免费的额度,之前从未用过,本篇整理了 Tailscale 的使用记录 添加客户端 访问 Tailscale 官网,登陆后,首先添加一个 Linux 客户端 在 Linux 主机运行脚本,安装后根据提示运行 tailscale up 打开链接进行确认 添加后,提示继续添加,接下来我添加了 Windows 和 macOS 系统的机器 添加后,可以看到每台机器都有自己的内网 IP 现在,这些添加的设备都已组网,可以相互访问。 另外,支持群晖 NAS,通过 Tailscale 组网后,即使家庭宽带没有公网 IP,也能从外部组网设备访问到 补充 卸载命令参考 Uninstalling Tailscale # Debian & Ubuntu $ sudo apt-get remove tailscale # Centos $ sudo apt-get remove tailscale # Fedora $ sudo dnf remove tailscale 示例一:SSH 访问 Linux 设备 假设我的 Linux 服务器没有公网 IP,现在可以在 Tailscale 组网的机器上通过内网 IP 访问到。 Host dc3 HostName 100.117.157.2 User root Port 29345 示例二:Ping 设备 不仅可以通过 IP 访问主机,也可以使用设备的 MACHINE 名称访问,以下是 Ping Windows 设备的示例 可以看到返回的 IP could-windows.tail0042e4.ts.net (100.113.198.34) 即设备的内网 IP 这是 MagicDNS 提供的功能,如果不需要,可以在 DNS 页面关闭掉 示例三:分享文件到其它设备 从 macOS 分享文件,在待分享的文件上点击 “右键” - “共享”(如果找不到 Tailscale 则点击 “编辑扩展” 启用后再分享) 出现的页面,点击目标设备即可发送到设备上 Windows 下会存储在 “下载” 文件夹 Linux 下会存储在 /var/lib/tailscale/files/ 下的用户目录 Windows 发送文件同理,右键可以发送到 Tailscale,Linux 发送文件的命令如下: $ tailscale file cp <files> <name-or-ip>: 示例四: Tailscale Funnel Tailscale Funnel 是一项功能,可让您将流量从更广泛的互联网路由到 Tailscale 网络(称为 tailnet)中的计算机上运行的本地服务。您可以将其视为公开共享本地服务,例如网络应用程序,供任何人访问 —— 即使他们自己没有 Tailscale。 关于 Funnel 介绍参考文档:https://tailscale.com/kb/1223/funnel 分配权限 为用户分配权限,否则用户没有权限开启 “外部匿名访问的链接” 以下是操作记录,首先开启 HTTPS (可选)然后在 “Access Controls” 中的右侧面板,启用 Funnel(下图是已启用状态) 如果刚刚没启动 Funnel,会在在运行命令时弹出链接授权,效果是一样的,在 Access Controls 面板查看,都是添加的 nodeAttrs 配置段进行授权。 { "nodeAttrs": [ { // Funnel policy, which lets tailnet members control Funnel // for their own devices. // Learn more at https://tailscale.com/kb/1223/tailscale-funnel/ "target": ["autogroup:member"], "attr": ["funnel"], }, ] } 启动一个示例服务 以下命令在 Linux 设备下执行 index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Hello, World!</title> </head> <body> <h1>Hello, World!</h1> <p>This is a basic HTML page displaying "Hello, World!".</p> </body> </html> 启动 HTTP Demo 服务 $ nohup python3 -m http.server 3000 & 启动 Tailscale Funnel 此时,可以从互联网通过 https://dc3.tail00**.ts.net/ 访问到刚刚启动的服务 唯一的问题:国内访问速度 我在国内云 Windows 服务器上测试,打开其它设备上的网页、传输文件都很慢。 简单查询资料,有两个优化的方向: 只创建和替换 Derper 服务,这里有一个 derper 的 Docker 镜像 derper-docker,可参考:(tailscale内网穿透之自建的derper服务器,无需域名,无需备案,全流程教程) 借助 Headscale 替换了整个 Control Server,可以使用 Tailscale 的客户端连接服务,不能使用 Tailscale 的 Web Console 进行管理。可参考:Headscale 搭建 P2P 内网穿透 或 Tailscale 基础教程:Headscale 的部署方法和使用教程 参考 Tailscale 官方文档

2024/5/14
articleCard.readMore

通过 Helm Bitnami Chart 部署 ElasticSearch + Kibana 服务

ElasticSearch Chart 的选择 在 https://artifacthub.io/ 上搜索 ES 的 Chart,排在前列的有 Elastic Elasticsearch 和 Bitnami Elasticsearch 两个版本 我先部署的 Elastic Elasticsearch 版本,Elastic 封装的 Chart 在变量设置上不方便使用,例如在测试环境,我希望通过 HTTP 访问服务和设置 SC,翻模版、查看变量找了一圈儿才跑起来;而安装 Bitnami 版本过程就很丝滑,推荐选择 Bitnami 社区制作的 Chart,更易用,同时其 ElasticSearch 版本也更高一些。 部署 Bitnami Elasticsearch 基础命令 $ helm install appserver-bitnami-elasticsearch bitnami/elasticsearch --version 21.0.2 测试环境最小化安装,节省资源。 $ helm install bitnami-elasticsearch bitnami/elasticsearch --version 21.0.2 \ --set-string global.storageClass=efs-sc \ --set master.masterOnly=false \ --set master.replicaCount=1 \ --set data.replicaCount=0 \ --set coordinating.replicaCount=0 \ --set ingest.replicaCount=0 \ --set-string master.nodeSelector.platform=true \ -n common 指定存储的 SC、关闭 masterOnly ,即 data 等组件可以跟 master 运行在一个服务中,节省资源,另外设置 master.nodeSelector 调整部署的节点。 删除命令 $ helm uninstall bitnami-elasticsearch -n common 访问测试 $ curl -X GET http://bitnami-elasticsearch:9200 输出 { "name" : "bitnami-elasticsearch-master-0", "cluster_name" : "elastic", "cluster_uuid" : "lN17q_c0Rc-6r13TlBEJUA", "version" : { "number" : "8.13.3", "build_flavor" : "default", "build_type" : "tar", "build_hash" : "617f7b76c4ebcb5a7f1e70d409a99c437c896aea", "build_date" : "2024-04-29T22:05:16.051731935Z", "build_snapshot" : false, "lucene_version" : "9.10.0", "minimum_wire_compatibility_version" : "7.17.0", "minimum_index_compatibility_version" : "7.0.0" }, "tagline" : "You Know, for Search" } 健康检查 $ curl -X GET "http://bitnami-elasticsearch:9200/_cluster/health" 输出 {"cluster_name":"elastic","status":"green","timed_out":false,"number_of_nodes":1,"number_of_data_nodes":1,"active_primary_shards":0,"active_shards":0,"relocating_shards":0,"initializing_shards":0,"unassigned_shards":0,"delayed_unassigned_shards":0,"number_of_pending_tasks":0,"number_of_in_flight_fetch":0,"task_max_waiting_in_queue_millis":0,"active_shards_percent_as_number":100.0} 部署 Elastic Elasticsearch 如果你参考了 Bitnami 部署 Elasticsearch,请跳过本小节,本小节的部署示例仅用于记录 Elastic 版的部署方式。 先创建一个变量配置文件,需设置 createCert 为 false,如果不关闭,那么在 Template 中根据 createCert 写死了 Elasticsearch 的安全配置,会强制开启 HTTPS 访问(这里也有提及这一情况,他解决的方式是修改 Template 模版),关闭后 protocol 变量需要同步修改为 http,esConfig 中将安全开关都关闭(关闭安全选项并非最佳实践) security-values.yml --- clusterName: "elasticsearch" nodeGroup: "master" createCert: false roles: - master - ingest - data protocol: http esConfig: elasticsearch.yml: | cluster.name: "myelasticsearch" network.host: 0.0.0.0 xpack.security.enabled: false xpack.security.transport.ssl.enabled: false xpack.security.http.ssl.enabled: false 设置 xpack.security.http.ssl.enabled 为 false,即可通过 HTTP 访问 9200 端口,那为什么我将 xpack.security.enabled 和 xpack.security.transport.ssl.enabled 也关闭了呢? 这是因为 createCert 已被设置为 false,但是设置 false 后不能自动生成证书,不关闭也无法正常使用 —— 即 xpack.security.http.ssl.enabled 无法独立关闭。 启动服务 $ helm install testing-elasticsearch-master elastic/elasticsearch --version 8.5.1 \ --set-string fullnameOverride=testing-elasticsearch-master \ --set-string persistence.enabled=true \ --set volumeClaimTemplate.accessModes[0]="ReadWriteOnce" \ --set volumeClaimTemplate.resources.requests.storage=35Gi \ --set-string volumeClaimTemplate.storageClassName=efs-sc \ --set replicas=2 \ --set-string nodeSelector.platform=true \ -f security-values.yml \ -n common 注意:测试的时候发现,replicas 最小只能设置为 2,设置为 1 后启动不了,另外如果不设置 fullnameOverride,则 Helm 安装后的服务叫 elasticsearch-master,是由 clusterName + nodeGroup 组成的。 备忘:通过 HTTP 协议访问接口报错内容如下: {"@timestamp":"2024-05-09T06:20:17.790Z", "log.level": "WARN", "message":"received plaintext http traffic on an https channel, closing connection Netty4HttpChannel{localAddress=/10.10.58.175:9200, remoteAddress=/10.10.2.234:52244}", "ecs.version": "1.2.0","service.name":"ES_ECS","event.dataset":"elasticsearch.server","process.thread.name":"elasticsearch[elasticsearch-master-1][transport_worker][T#1]","log.logger":"org.elasticsearch.xpack.security.transport.netty4.SecurityNetty4HttpServerTransport","elasticsearch.cluster.uuid":"kPDKL67vTLeBvUhCucohxw","elasticsearch.node.id":"u1tE7zJ-Sk69c8iDUNehkA","elasticsearch.node.name":"elasticsearch-master-1","elasticsearch.cluster.name":"elasticsearch"} 获取密码 $ kubectl get secrets --namespace=common testing-elasticsearch-master-credentials -ojsonpath='{.data.password}' | base64 -d 从如上记录也可以看到,Elastic 版相较于 Bitnami 版部署会更困难些,也许更安全?Maybe 测试环境,我会选择 Bitnami 版的 ElasticSearch 访问测试 $ curl -X GET http://testing-elasticsearch-master:9200 输出 { "name" : "testing-elasticsearch-master-0", "cluster_name" : "elasticsearch", "cluster_uuid" : "Z7e3gJQ4S_Ot_i_ReNctvQ", "version" : { "number" : "8.5.1", "build_flavor" : "default", "build_type" : "docker", "build_hash" : "c1310c45fc534583afe2c1c03046491efba2bba2", "build_date" : "2022-11-09T21:02:20.169855900Z", "build_snapshot" : false, "lucene_version" : "9.4.1", "minimum_wire_compatibility_version" : "7.17.0", "minimum_index_compatibility_version" : "7.0.0" }, "tagline" : "You Know, for Search" } 健康检查 $ curl -X GET "http://testing-elasticsearch-master:9200/_cluster/health" 输出 {"cluster_name":"elasticsearch","status":"green","timed_out":false,"number_of_nodes":3,"number_of_data_nodes":3,"active_primary_shards":1,"active_shards":2,"relocating_shards":0,"initializing_shards":0,"unassigned_shards":0,"delayed_unassigned_shards":0,"number_of_pending_tasks":0,"number_of_in_flight_fetch":0,"task_max_waiting_in_queue_millis":0,"active_shards_percent_as_number":100.0} 部署 Kibana 面板 启动服务 $ helm install appserver-kibana bitnami/kibana --version 11.0.5 \ --set-string global.storageClass=efs-sc \ --set elasticsearch.hosts[0]=bitnami-elasticsearch \ --set elasticsearch.port=9200 \ --set-string nodeSelector.platform=true \ -n common 删除服务 $ helm uninstall appserver-kibana -n common 健康检查 / # curl -I appserver-kibana:5601/status HTTP/1.1 200 OK x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin permissions-policy: camera=(), display-capture=(), fullscreen=(self), geolocation=(), microphone=(), web-share=() cross-origin-opener-policy: same-origin content-security-policy: script-src 'report-sample' 'self'; worker-src 'report-sample' 'self' blob:; style-src 'report-sample' 'self' 'unsafe-inline' kbn-name: appserver-kibana-5466c99b8d-h7lt7 kbn-license-sig: cf15298b2439c71055026ef4f9cae96e05b766cec95d831c243cb6587dbb3cd9 content-type: text/html; charset=utf-8 cache-control: private, no-cache, no-store, must-revalidate content-length: 206865 vary: accept-encoding Date: Thu, 09 May 2024 09:48:09 GMT Connection: keep-alive Keep-Alive: timeout=120 创建索引及查询测试 新建索引,名为 user_data 进入索引详情,打开执行面板 POST _bulk?pretty { "index" : { "_index" : "user_data" } } {"name": "foo", "title": "bar"} 提交执行 再输入以下命令进行查询 GET _search { "query": { "match_all": {} } } 结果 { "took": 9, "timed_out": false, "_shards": { "total": 14, "successful": 14, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 1, "relation": "eq" }, "max_score": 1, "hits": [ { "_index": "user_data", "_id": "VIzXXI8BYWyosogneXb_", "_score": 1, "_source": { "name": "foo", "title": "bar" } } ] } } 此时,回到刚刚创建的索引可以看到已插入一条记录 接下来从 Discover 浏览数据,点击 “Create data view” 按钮创建一个数据视图 填写内容后可以看到匹配的 Index 索引,确认无误后保存 之后即可浏览、检索数据 从 ES 接口查询数据 从 Kibana 看到数据,我们也可以在集群中直接调用 ElasticSearch 的接口进行查询,命令如下 $ curl -X GET "bitnami-elasticsearch:9200/user_data/_search" -H 'Content-Type: application/json' -d' { "query": { "match_all": {} }, "size": 5 }' 输出 由此,可确定 ElasticSearch 基本部署完成,可供开发及测试环境使用。

2024/5/10
articleCard.readMore

Fork 并翻译部署 Raft 分布式共识演示静态页实践记录(thesecretlivesofdata)

因为国内访问 Github 不稳定,所以一直没太使用 Github Pages 服务,偶然间看到这个网站:http://thesecretlivesofdata.com/raft/ 通过动画演示的方式讲解 Raft 分布式共识原理,发现这个网站就是基于 Github Pages 于是产生了 Fork 仓库,自己部署一个 Page 页面,托管中文翻译版的想法,以下是实践记录 为仓库添加 Pages 服务 Fork 仓库后,到仓库配置页面,在侧边栏找到 Pages 项 选择分支为 gh-pages, 路径为仓库根目录,保存后稍等片刻 可以看到已生成仓库的 Page 页面可供访问。 配置自定义域名 可以直接使用 Github 提供的 io 域名访问,经测试这个网站的代码对 path 解析不太友好,进到 Raft 示例下点击相对路径是 /xx 而不是 /raft/xx,所以对于这个项目,绑定域名是必须的。 到域名服务商处添加一个 CNAME 记录,解析到 sincerefly.github.io(使用自己的 Github Page 地址) 在 Github Settings - Pages 页面添加域名 绑定自定义域名成功,可通过 https://thesecretlivesofdata.yasking.org 进行访问 添加自定义域名后,在仓库中自动新增了一个 CNAME 文件,内容即自定义的域名 项目主页的右侧显示了一个 Deployments 功能区 此时,已可以通过域名访问网站。 我这里切了一个分支翻译文本,提交 PR 到自己仓库中的 gh-pages 分支,合并后页面描述变为中文。 链接地址:https://thesecretlivesofdata.yasking.org/raft 总结补充 为仓库配置 Github Pages 很容器,如果仓库存储的内容已经是静态页面,可以直接创建。 如果仓库中存储的是 Web 框架,需要先 Build 出静态页,可参考链接一的地址,配置 Action 自动生成静态页。 除在仓库配置 Pages,也可参考 pages 主页,创建 https://[用户名].github.io 地址的网站。 参考 github 自动化部署到github pages

2024/5/8
articleCard.readMore

Python 依赖管理工具 uv 使用笔记(“Cargo for Python”)

uv 是一个非常快速的 Python 依赖安装程序和分解器,使用 Rust 编写,旨在替代 pip 和pip-tools 工作流,速度比他们快 8~10 倍,当前可用于替代 pip, pip-tools, virtualenv,根据路线图,它会向着 “Cargo for Python” 方向前行 —— 一个极其快速、可靠且易于使用的综合项目和包管理器。 Github 地址:astral-sh/uv 安装 uv 工具 它是一个二进制文件,因此支持多种方式安装而不依赖 Rust 和 Python 环境(部分方式如下) # pip $ pip install uv # On macOS and Linux. curl -LsSf https://astral.sh/uv/install.sh | sh # macOS $ brew install uv 虚拟环境管理 创建 venv 环境 $ uv venv myenv 激活环境 $ source myenv/bin/activate 当需要指定 python 版本时候,可以通过 --python 参数指定 # uv venv --python <path to executable> myvenv $ uv venv --python /opt/homebrew/bin/python3.8 venv-py3.8 $ source venv-py3.8/bin/activate $ python3 --version Python 3.8.19 退出虚拟环境 $ deactivate 安装卸载依赖 $ uv pip install requests # 卸载 $ uv pip uninstall requests 更新单个依赖 $ uv pip install --upgrade requests 根据依赖清单安装依赖 $ uv pip install -r requirements.txt 生成 requirements.in 文件 生成 requirements.in 依赖文件,而后根据需要手动精简及调整。 $ uv pip freeze > requirements-3.in 生成、更新依赖清单 摘自 py-tools 的示例图 Requirements 清单文件可以锁定 Python 依赖包的版本 通过 requirements.in 生成 requirements.txt $ uv pip compile requirements.in -o requirements.txt 通过 pyproject.toml 生成 requirements.txt $ uv pip compile pyproject.toml -o requirements.txt 其它基于如 setup.py 和 setup.cfg 的生成参考 pip-tools 文档说明 更新依赖版本 全量更新 $ uv pip compile --upgrade -o requirements.txt requirements.in 只更新部分依赖 $ uv pip compile --upgrade-package flask -o requirements.txt requirements.in 备注:能否更新依赖取决于 requirements.in 等文件中有没有锁定版本。 更多关于 pip-compile 的用法示例参考 pip-tools 文档说明 安装、同步环境依赖 使用 pip-tools 的 sync,可以依据 requirements.txt 更新 site-packages 中的依赖包,让 requirements.txt 中删除了软件包,sync 后,环境中的依赖包也会被移除。 正式环境安装依赖 $ uv pip sync requirements.txt 测试环境安装依赖 $ uv pip sync requirements.txt dev-requirements.txt 其它 1. 关于命令格式 uv pip install... ? 而不是 uv install 呢?因为这两个命令有不同的语意,uv 不是 pip by rust, uv 有更广泛的用途,参考文档中的描述 By scoping these "lower-level" commands under uv pip, we retain space in the CLI for the more "opinionated" project management API we intend to ship in the future, which will look more like Rye, or Cargo, or Poetry. (Imagine uv run, uv build, and so on.) 通过在 uv pip 下确定这些“较低级别”命令的范围,在 CLI 中为团队打算在未来发布的更加 “固执己见” 的项目管理 API 保留了空间,它看起来更像 Rye、Cargo 或 Poetry。 (想象一下 uv run、uv build 等) 备注:更窄的 pip-tools 范围使得 uv 能够提供很多底层支持(例如软件包安装),同时也能很方便的提供立即有用的东西。 2. uv pip 基本可以替代 pip While uv supports a large subset of the pip interface, it does not support the entire feature set. In some cases, those differences are intentional; in others, they're a result of uv's early stage of development. 虽然 uv 支持 pip 接口的很大一个子集,但它不支持整个功能集。在某些情况下,这些差异是有意的;在另一些情况下,它们是 uv 早期发展的结果。 更多关于 uv 的内容,可以参考:https://github.com/astral-sh/uv 参考 uv: Python packaging in Rust Github: astral-sh/uv

2024/4/30
articleCard.readMore

使用 pprof 定位 Golang 服务内存占用过高问题的记录

近期有台服务器 “假死”,定为后发现触发条件是其上的 Web 服务的一个压缩包上传解析功能导致的,从代码不好确定是哪个环节导致内存占用过高,决定使用 pprof 排查,以下是定位问题的记录 启用 pprof 引入 pprof,启用 HTTP 服务,监听的端口为 6060(约定俗成,非强制) package main import ( "net/http" _ "net/http/pprof" // ... ) func main() { go func() { // pprof 服务器,将暴露在 6060 端口 if err := http.ListenAndServe(":6060", nil); err != nil { panic(err) } }() // ... } 程序启动后,可以打开网址 http://localhost:6060/debug/pprof/ 查看支持补货的内容,有 CPU、内存等 性能指标 我要排查内存使用情况,所以选择的 heap 性能数据,seconds=30 表示获取一段时间内的指标。在这段时间内,我手动触发了接口,复现高内存占用时刻。 # 获取性能数据 curl -o heap.pprof 'http://localhost:6060/debug/pprof/heap?seconds=30' 将生成的 heap.pprof 获取到本地电脑 可视化分析 Web 可视化功能依赖 graphviz 工具,需要先安装,macOS 通过 brew 安装: $ brew install graphviz 基于刚刚捕获的性能数据进行分析 $ go tool pprof -http=:8081 heap.pprof 执行命令后会自动打开网页,部分截图如下: 发现端倪 压缩包仅有 32MB,解压后也才 75MB 不应该消耗将近 600+MB 的内存... 继续验证,macOS 系统下,我安装了 gnu-time,相比于自带的 time,gtime 能够显示运行期间最大内存消耗。 静默解压,可以看到使用 unzip 工具,内存消耗是很小的,只有 3664KB 猜测应该是之前引入 AI 提供的 Unzip 函数存在性能问题导致的 验证猜测 以下是代码示例 func Unzip(src string, dest string) error { r, err := zip.OpenReader(src) if err != nil { return err } defer r.Close() for _, f := range r.File { fileInArchive, err := f.Open() if err != nil { return err } defer fileInArchive.Close() path := filepath.Join(dest, f.Name) if f.FileInfo().IsDir() { os.MkdirAll(path, f.Mode()) } else { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } _, err = io.Copy(f, fileInArchive) if err != nil { return err } } } return nil } 调用代码 main.go func main() { err := utility.Unzip("assets.zip", "/tmp/unzip-folder") if err != nil { fmt.Println(err) } } 编译执行 $ go build -o unzip-test main.go # 执行 $ gtime ./unzip-test 确实消耗了 728528 KB 的内存,定位到具体函数,问题就好解决了。 代码修复 排查代码发现了问题,以下的代码片段在循环中使用 defer fileInArchive.Close() 关闭文件,而 defer 是在函数执行完成时才会调用,循环时并不会关闭。 for _, f := range r.File { fileInArchive, err := f.Open() if err != nil { return err } defer fileInArchive.Close() // ... } 接下来优化代码,在循环中直接关闭文件。 func Unzip(src string, dest string) error { r, err := zip.OpenReader(src) if err != nil { return err } defer r.Close() for _, f := range r.File { fileInArchive, err := f.Open() if err != nil { return err } path := filepath.Join(dest, f.Name) if f.FileInfo().IsDir() { os.MkdirAll(path, f.Mode()) } else { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) if err != nil { return err } _, err = io.Copy(f, fileInArchive) if err != nil { return err } f.Close() fileInArchive.Close() } } return nil } 重新测试 内存占用降低到了 35.5MB,符合预期,问题修复。 总结 pprof 是 Golang 服务内置的功能,引入并暴露 HTTP 服务即可使用,不应在生产环境暴露 循环内部使用 defer 关闭文件是典型错误,应多留意 AI 提供的代码很容易有 BUG,Review + 单元测试不可或缺

2024/4/19
articleCard.readMore

K8S 基础知识:Endpoint 的作用与实践

Endpoint 简介 Service 并非和 Pod 直接相连,有一种资源介于两者之间 —— 它就是 Endpoint Endpoints 一般由 Kubernetes 根据 Service 选择器自动生成(未指定选择器的 Service 不会自动生成 Endpoint),它包含了与该 Service 相关联的一组 Pod 资源 IP 和端口,当 Service 收到请求时,它会查询 Endpoints 来获取可用的 Pod 地址,然后将请求转发到这些 Pod 上。 可以通过以下命令确认 Pod、Endpoint、Service 之间的关系 kubectl describe pods 查看 Pod 的 IP 地址 external-service 查看 Endpoint 引用的 IP 地址 kubectl describe service myServiceName 查看 Service 的 Endpoints 引用 IP 信息 一般不直接手动创建维护 Endpoint,而是由 Service 自动创建,不少刚刚接触 K8S 的使用者都容易忽略它的存在。 集群内通过 Serivce 的请求链路 在进一步了解 Endpoint 之前,先梳理下服务内 Pod 间访问的链路 当集群 Pod 内部的程序访问集群中的 Service 时,集群内 DNS 将 Service 名称转换为 Cluster IP(Service 的虚拟 IP),Pod 所在节点 Node 上的 kube-proxy 会捕获发往 Cluster IP 的请求,它会考虑与 Service 关联的 Endpoints 和负载均衡机制,最终选择目标 Pod,请求被转发到 Pod,完成集群内的访问。 补充 1)这些 Endpoints 实质上是提供服务的 Pod 的 IP 地址列表。 2)当 Pod 被创建或销毁时,Endpoint Controller 会更新 Endpoint IP 地址列表,kube-proxy 会定期从 API Server 拉取最新的 Endpoints 变更,更新 IPVS 规则(也可能使用的是 iptables),实现请求的转发。 接下来实践手动创建没有选择器的 Service、并创建对应的 Endpoint 让其自动关联 实践:手动配置 Endpoint 访问集群外服务 背景:我们可能会从集群内部访问集群外部的服务(外部的服务后期会迁移到集群内部),对于内网可以通过 IP 访问的服务,一个可行的方案是手动创建 Service 和 Endpoint,示例如下: 首先,创建一个没有选择器的 Service 创建一个服务,它监听 80 端口,它没有定义 Pod 选择器。 external-service.yml apiVersion: v1 kind: Service metadata: name: external-service spec: ports: - port: 80 在集群中应用配置 $ kubectl apply -f external-service.yml service/external-service created 然后为没有选择器的服务创建 Endpoint 资源 我准备了一个外部 HTTP 服务,有公网 IP,访问其 8000 端口输出 “hello world!\n” app.py from flask import Flask app = Flask(__name__) @app.route('/') def hello_world(): return 'hello world!\n' if __name__ == '__main__': app.run(host='0.0.0.0', port=8000) 在外部服务器部署 $ nohup python3 app.py & 注意 Endpoint 的名称(name: external-service)需要跟 Service 名称相同,否则不会自动关联 apiVersion: v1 kind: Endpoints metadata: name: external-service subsets: - addresses: - ip: 150.11.22.33 ports: - port: 8000 应用配置 $ kubectl apply -f endpoint.yml endpoints/external-service created 之后在集群启动一个容器,从这个容器内部请求服务(同一个 namespace 命名空间) 看到其请求到了外部服务,同时查看 Service 详情可见其自动绑定了 Endpoints ➜ kubectl describe service external-service Name: external-service Namespace: default Endpoints: 150.11.22.33:8000 ... 通过这个方式,我们可以定义一个 Service,它将流量转发到集群外部,待后续外部服务迁移到集群内时,更新 Service 添加选择器配置即可平滑迁移,拥有 Pod 选择器的 Service 可以自动管理 Endpoint,另外迁移无需修改程序的配置文件。 扩展:借助 Service 设置域名服务映射 在上小节手动配置 Service + Endpoint 实现了请求转发,如果外部服务拥有域名,K8S 提供了更便捷的方式,无需手动配置 Endpoint,Service 内置的 ExternalName 类型即可实现,以下是一个示例: apiVersion: v1 kind: Service metadata: name: httpbin spec: type: ExternalName externalName: httpbin.org 创建 Service 后,我们可以访问 httpbin/get 代替 httpbin.org/get ,转发域名的好处是平滑迁移和集成外部服务,使用 ExternalName Service 应该仅限于与外部服务集成并需要简单别名映射的场景。 从输出看,httpbin 服务没有 CLUSTER-IP 和 PORT,取而代之的是 EXTERNAL-IP 另外 Service 详情信息也很简单 参考 《Kubernetes in Action 中文版.pdf》- 七牛容器云团队译

2024/4/17
articleCard.readMore

Homebrew 篇二:制作 Formula 安装脚本存入 Tap 仓库

前些天学习 HomeBrew 的常用命令(Homebrew 常用命令使用指北) ,了解到 Formula 是用 Ruby 编写的脚本,将脚本放到 Github 仓库,就实现了一个自定义 Tap 存储库,本篇将以我的测试 Demo easycmd 为例,制作一个 Formula 脚本,之后可以使用 brew 命令安装 easycmd 备注:Easycmd 是使用 Golang 编写的 Demo 类的工具,除用于测试、演示外,目前并没有其它功能。 编写脚本 脚本参考自 brew - yq 的 Formula 脚本 yq.rb class Easycmd < Formula desc "A terminal tool sample " homepage "https://github.com/sincerefly/easycmd/" url "https://github.com/sincerefly/easycmd/archive/refs/tags/v0.0.4.tar.gz" sha256 "79d9b7b9fc28c1cb47ecc5c14da2e911b7da96481f043e87ec1376ca82f7f363" license "MIT" head "https://github.com/sincerefly/easycmd.git", branch: "master" bottle do root_url "https://dd-public-bucket.s3.bitiful.net/homebrew-bottles/easycmd/v0.0.4" #sha256 cellar: :any_skip_relocation, arm64_sonoma: "bc36b4b41929e9e689befbecb557dbf7acf6c743ca17809f65a109ef23833c0b" sha256 cellar: :any_skip_relocation, arm64_ventura: "1ec122e6913fd32af03092a2e7b3897cc77090003373f165072ab019daa2d68c" #sha256 cellar: :any_skip_relocation, arm64_monterey: "1d652cf11ad65dac1d8c772168f62ca6e672ee61f69f9c47b5a46819089f1cfe" #sha256 cellar: :any_skip_relocation, sonoma: "3f23e27ff4f8ea8a39b07ae5b7d808d5a5cbc548124b56154c0b08585737eb23" #sha256 cellar: :any_skip_relocation, ventura: "ccbd38a9b07256344d78bd127fb66f4d2b0f4831385d7458f5e36bed8f796548" #sha256 cellar: :any_skip_relocation, monterey: "85a5394913a5734cef1fc388eee37e4dfb21c69e4414c8c658b8e04cb9963262" #sha256 cellar: :any_skip_relocation, x86_64_linux: "8642969ca0738f0a4e632ee2877edf601e2747220460b29e8ab3368ff3e80a0e" end depends_on "go" => :build def install system "make", "build" bin.install "easycmd" end def post_install # Make sure runtime directories exist (var/"lib/easycmd").mkpath (var/"log/easycmd").mkpath end def caveats s = <<~EOS Data: #{var}/lib/easycmd/ Logs: #{var}/log/easycmd/easycmd.log Config: #{etc}/easycmd/ EOS s end test do output = shell_output("#{bin}/easycmd --version").chomp assert output.start_with?("easycmd ") system "false" end end 将脚本的各字段修改为正确的值,例如: url 填写 github 仓库的代码下载路径,此处是 v0.0.4 的标签的源码,用于本地编译 sha256 是 tar.gz 源码的哈希值,macOS 可以通过 shasum -a 256 v0.0.4.tar.gz 计算得到 bottle 是预编译的二进制文件,匹配到就会跳过源码编译,此处我只构建了 arm64_ventura 架构/系统的二进制文件,并计算得到其 sha256 哈希值填入,这里的 root_url 是我的自定义文件下载地址,文件名为 “easycmd-0.0.4.arm64_ventura.bottle.tar.gz”,稍后会进一步介绍 Bottle 压缩包 depends_on 表示源码编译依赖 go 编译器 def install 会在未匹配到 bottle 的时候执行 bin.install "easycmd" 编译后生成的文件为 easycmd,bin.install 将其安装到 Brew 的 Celler 目录 post_install 预创建一些文件夹,程序中可以使用,无论使用 bottle 还是编译安装,post_install 都会执行 caveats 是在 Console 控制台输出的提示性信息 test 用于执行命令,测试是否安装成功 以上是一个完整的示例(非最小化示例),进一步修改代码可以参考 Github 上不同工具的 Formula Ruby 脚本 Bottle 压缩包制作 首先,构造一个目录结构,在 easycmd 文件夹下是工具版本: easycmd 是 arm64_ventura 架构平台下二进制程序,etc 下的 bash_completion.d 是 Bash 参数自动补全脚本,我是参考 yq 的脚本进行修改的(如果你使用 brew 安装了 yq 命令,那么这个补全脚本可以在 /opt/homebrew/Cellar/yq/4.43.1/etc 找到,注意替换路径中的版本) bash_completion.d 是可选的,此处暂不介绍。 # 创建 Bottle 包 $ tar -czvf easycmd-0.0.4.arm64_ventura.bottle.tar.gz easycmd # 计算 Bottle 包哈希值(用于填写在 rb 脚本的 Bottle 配置段) $ shasum -a 256 ./easycmd-0.0.4.arm64_ventura.bottle.tar.gz 文件名为什么是 easycmd-0.0.4.arm64_ventura.bottle.tar.gz 而不是其它? 是因为我是在其安装时发现它使用的是 root_url + filename, 我没找到修改的命令,姑且认为这里的文件名不能手动指定,是 Brew 的约定俗成。 我把制作好的 Bottle 包上传到 Public 存储 最终路径是 https://dd-public-bucket.s3.bitiful.net/homebrew-bottles/easycmd/v0.0.4/easycmd-0.0.4.arm64_ventura.bottle.tar.gz 上传 Formula 脚本 在上文编写的 ruby 脚本,将其 Bottle 的 Sha256 修改为刚刚计算得到的哈希值 创建 Github 仓库,例如我的测试仓库,名为 homebrew-easycmd 仓库中需要有一个 “Formula” 子文件夹,如果是 Cask 类型,则名为 “Cask” 将 easycmd.rb 文件上传 自定义的 Brew Tap 仓库即创建完成。 测试验证 添加仓库源 $ brew tap sincerefly/homebrew-easycmd 查看工具信息 $ brew info easycmd 安装工具 $ brew install easycmd 执行命令,正确输出 $ easycmd 2024/04/12 21:58:48 No config file used hi,dong 根据上篇学习到的目录知识,对路径进行核对 /opt/homebrew/Cellar/easycmd 查看 opt 和 bin 目录下的软链接,均正确创建 如果需要测试从源码编译安装 $ brew install --build-from-source easycmd 类比扩展 Cask 和 Formula 安装包类似,以下是一个 fuse-t 的示例,可以用来模仿制作 Cask 安装方案。 https://github.com/macos-fuse-t/homebrew-cask/blob/main/Casks/fuse-t.rb cask "fuse-t" do version "1.0.36" sha256 "8102c334ba6bb8cd9ece59fa063b30f64f9a101b45fa5ca8fe1824d3f35b6b06" url "https://github.com/macos-fuse-t/fuse-t/releases/download/#{version}/fuse-t-macos-installer-#{version}.pkg" name "fuse-t" desc "LibFUSE implementation that doesn't use kernel extensions" homepage "https://github.com/macos-fuse-t/fuse-t" pkg "fuse-t-macos-installer-#{version}.pkg" uninstall script: { executable: "/Library/Application Support/fuse-t/uninstall.sh", sudo: true, } caveats do license "https://github.com/macos-fuse-t/fuse-t/blob/main/License.txt" end end 遗留问题 本文创建了一个基础的 Formula 示例,但离工程化的发布/维护 Brew 存储库还有很大举例,例如: 不同架构系统的 Bottle 如何便捷、自动分发 除自定义的 OSS 存储,还有哪些托管平台更适宜存储 Bottle 文件 如何将自己开发的工具提交到 Brew 官方源,有什么标准 如何维护工具的历史版本,Brew 在安装指定版本时的机制又是怎样的 待了解的内容还可以有很多,不过眼下没有软件需要分发,Brew 软件包的封装就先探索到这里。 参考 Formula/y/yq.rb

2024/4/15
articleCard.readMore

排查 AWS ECS 内容器服务 ALB 健康检查失败问题记录

确认 Task 内容器服务已启动 通过跳板机登录到 Task 容器所在 EC2,而后通过 docker 命令进入到容器,确认资源、程序、配置、进程都已启动。 # 通过跳板机进入 ECS Node EC2 节点 $ ssh -i ~/.ssh/your-jump-server-keys.pem ec2-user@10.0.11.22 -J jump-server # 执行 docker 命令 $ docker ps 补充:EC2 IP 获取方法 在 ECS 集群上,找到需要排查问题的 Task 进入详情,详情中可以找到 “Launch type”,EC2 标签后的 i-XXXX 即节点 ID,点击跳转进入 EC2 详情,在 “Networking” 标签页可以看到 “Private IPv4 addresses”,可以看到两个 IP,选择跟 “Private IP DNS name (IPv4 only)” 一致的主 IP 使用 另外 jump-server 需要跟 ECS 的网络打通,建立 VPC 对等连接。 可能有些容器内部没有 curl 命令,同时也没有 apt 命令可供安装,可以在 Node EC2 上下载 curl-static 后复制进容器,命令参考: $ docker cp curl-aarch64 container-name:/tmp/ $ docker exec -it container-name sh $ chmod +x /tmp/curl-aarch64 $ /tmp/curl-aarch64 http://127.0.0.1:3000 备注:ECS Task 内的容器使用 Docker 进行运行,调试 ECS 容器的时候可以通过这种方式获知容器详情。 确认服务监听地址 Web 类型服务需要格外留意,因为 npm start 几乎都是默认绑定的 localhost,需要显式绑定为 0.0.0.0 以 Next 为例: $ next start -H 0.0.0.0 -p 3001 端口访问确认 在第一步已经确认,在容器内部使用 curl 命令请求 localhost:3000(假定监听的是 3000 端口),可以请求通,说明服务正常启动并响应。 在容器内部通过 ifconfig 获取到 pod 的 IP 地址,如:10.0.16.170,退出容器(也可通过 TargetGroup 上注册的服务获取 IP) 在容器所在的 EC2 使用 curl 命令请求 10.0.16.170:3000,确认访问情况,访问不通说明 Task 对应的 Service 的安全组没有对端口放行。 核对 TargetGroup 内注册端口 确认服务访问没问题后,到 TargetGroup 中查看 ECS 注册的容器 IP 和端口跟预期一致。 至此,可确认 ALB 到 Task 容器的访问链路没有问题。 验证结果总结 (1)在 TargetGroup 到 ECS Task 容器的访问链路中,Docker 镜像的 EXPOSE 和 Ansible 的 portMappings 没有实际作用,配置正确或错误都不影响访问结果。(此处提及 Ansible 是因为部署 ECS Task 使用的是 Ansible Playbook) (2)实际验证下,能否正常请求只跟 ALB 能否通过容器IP + 容器端口访问到容器内服务有关,两个因素最为关键 —— 容器内服务正确监听了地址(0.0.0.0)、ECS Service 的安全组放行了要访问的端口。

2024/4/12
articleCard.readMore

使用 Github Action 自动构建 Release 版本

Github 上的很多开源软件、工具都有 Release,用户可以直接从上面下载编译好的软件包。 虽然 Github Release 页面提供了手动发布的页面,但这么多不同平台的软件包,没有人愿意逐个构建再上传到平台发布、不仅容易出错误,同时效率及其低下。 本文配置参考自 yq 项目的 CI/CD,配置 Github Action,让其在某个时刻(例如打标签)自动打包,并创建 Release 的 Draft 草稿。 添加配置文件和脚本 首先,创建文件 .github/workflows/release.yml name: Release EasyCmd on: push: tags: - 'v0.*' - 'draft-*' jobs: publishGitRelease: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '^1.20' check-latest: true - name: Cross compile run: | sudo apt-get install rhash -y go install github.com/mitchellh/gox@v1.0.1 mkdir -p build ./scripts/xcompile.sh - name: Release uses: softprops/action-gh-release@v1 with: files: build/* draft: true fail_on_unmatched_files: true Action 配置文件描述了在用户打 v0. 及 draft- 标签的时候会触发 CI/CD 自动运行,具体做的事情是启动一个 Ubuntu 系统执行指令,检出仓库代码、安装 Golang 1.20 版本。 编译的时候有几条命令,rhash 用来生成 checksums 软件哈希信息,gox 用来将代码编译为不同架构目标 sudo apt-get install rhash -y go install github.com/mitchellh/gox@v1.0.1 mkdir -p build ./scripts/xcompile.sh ./scripts/xcompile.sh 是具体的编译脚本,在项目的根目录新建 scripts 文件夹并新增文件 xcompile.sh #!/bin/bash set -e # you may need to go install github.com/mitchellh/gox@v1.0.1 first echo $VERSION CGO_ENABLED=0 gox -ldflags "-s -w ${LDFLAGS}" -output="build/easycmd_{{.OS}}_{{.Arch}}" --osarch="darwin/amd64 darwin/arm64 linux/386 linux/amd64 linux/arm linux/arm64" cd build rhash -r -a . -o checksums rhash -r -a --bsd . -o checksums-bsd rhash --list-hashes > checksums_hashes_order 内容很好理解,不再说明,唯一需要注意的是在提交代码前,先为 xcomile.sh 添加可执行权限 $ chmod +x ./.github/scripts/xcompile.sh 如果已提交,可以执行命令 $ git update-index --chmod=+x ./.github/scripts/xcompile.sh $ git commit -m "Add execute permission to .github/scripts/xcompile.sh" $ git push 将新增的两个文件提交。这个是我的测试 PR Cicd/add release action #2 可供参考 关于 Pipeline 配置中的 Release 步骤,简单说明一下: files 参数用于指定哪些文件会被发布,即我们可以生成任意目标文件,放置到 build 目录下即可 draft 设置为 true 表示创建一个 Release 草稿,我们需要再核对补充下再真正发布 fail_on_unmatched_files 如果没有目标文件,则失败停止发布 除以上使用的几个参数,action-gh-release@v1 还支持不少参数。如: name 设置 Release 名称,默认为 tag 名 prerelease 设置本次发布是否为 prerelease 版本 generate_release_notes 是否自动生成 Release Notes 更多参考:action-gh-release@v1#customizing 打标签触发构建 Release 在 Commit 上打标签 “draft-0.0.4” 进行测试,到 Github Action 页面可以看到 Action 在运行。 成功执行后 这时候在 Release 页面会显示一个版本,版本旁有 Draft 标记,表示这是一个草稿,需要手动编辑再发布 编辑草稿状态的 Release,可以点击 “Generate Release Notes” 可以自动生成版本说明,而后点击发布 最后,在首页也可以看到 Release 版本信息 下载测试 根据自己的系统,下载对应的目标可执行文件,添加可执行权限进行验证,能跑、构建没有问题 参考 mikefarah/yq

2024/4/10
articleCard.readMore

Homebrew 常用命令使用指北

使用 macOS 系统后,经常会使用 brew 安装工具或软件,但对其了解不多,遂翻看官方文档,了解其常用命令,整理如下 安装 Homebrew $ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 国内使用参考:清华大学开源软件镜像站 - Homebrew / Linuxbrew 镜像使用帮助 设置了默认源后,如需重置为官方地址,执行以下命令 $ cd "$(brew --repo)" $ git remote set-url origin https://github.com/Homebrew/brew.git 更新 Homebrew 自身 $ brew update 使用 Homebrew 安装软件 以下是安装 wget 下载器的示例 $ brew install wget 卸载 $ brew uninstall wget 以下是安装 Zed 编辑器的示例 $ brew install --cask zed 卸载 $ brew uninstall --cask zed 读者应该已经发现安装命令一个带有 --cask 参数,一个不带,原因请往下看 Formulae 和 Cask 首先我们知道在 macOS 下的工具、软件有两种类型,一种是二进制程序或库,如 git、wget,放置到指定目录后可以通过命令行运行,另一类是原生的图形化界面程序,通常需要 “拖拽到文件夹📁” 来安装,如 chrome、vs code 等 Homebrew 支持这两类软件的安装,安装前者的方案称为 Formula、后者称为 Cask 在 Homebrew 中,Formulae 可以翻译为 "配方" 或 "公式"。它是用于安装和管理命令行工具、库和软件包的规范。每个软件包都对应一个配方,它包含了软件包的元数据信息、依赖关系、下载地址和安装脚本等。每个 Formula 是一个 Ruby 脚本,描述了如何安装一个单一的命令行软件包 后文对于 formula 不进行翻译,因为称其为 “配方” 实在有些拗口,而 formula 更为大家所熟知。 检索软件包信息 查找 ffmpeg formula $ brew search ffmpeg 查看 ffmpeg formula 详细信息 $ brew info ffmpeg $ brew upgrade ffmpeg 查看本机已安装的 Formulae,命令如下 $ brew list --formula 相应的,查询已安装的 Casks,添加 --cask 参数即可 $ brew list --cask 不带参数则查询两者 至此,我们安装的软件都是使用官方的 Homebrew 仓库,Homebrew 支持第三方软件仓库,个人可以创建自己的软件安装方案放在其中,在 Homebrew,第三方仓库称为 Tap,它基于文件夹,往往存放在 Github 公开仓库。 存储库 Tap 相关信息 简单说,Tap 就是第三方软件源 添加一个 tap 命令如下 $ brew tap homebrew/cask-fonts 使用命令 brew tap 可以查看本机已添加仓库列表 $ brew tap filosottile/musl-cross homebrew/cask-fonts homebrew/services macos-fuse-t/cask vectordotdev/brew 添加 tap 后,可以安装 tap 提供的软件包,以下安装 Inconsolata 字体 $ brew install font-inconsolata 查看 font-inconsolata 详情,可以看到它对应的配方是:https://github.com/Homebrew/homebrew-cask-fonts/blob/HEAD/Casks/font-inconsolata.rb 添加 tap 源不是安装其内软件的必要条件,直接从仓库安装也是可以的 $ brew install homebrew/cask-fonts/font-inconsolata 了解 Homebrew 存储位置 对于 Formula、Cask、Tap 有了一些概念,在继续进行之前,先看看 Homebrew 的安装目录,对其有更进一步的了解。 获取 Homebrew 目录,我这里显示的目录是 /opt/homebrew,即 brew 自身及安装的软件都在这个目录中,不会在系统中胡乱放置资源,污染磁盘。 $ brew --prefix /opt/homebrew 其目录内容存储的文件及文件夹如下 /opt/homebrew/Cellar 目录 Homebrew 将所有安装的软件包统一放置在这个目录下,每个软件包都会有自己的子目录,里面包含了该软件包的所有文件。 Cellar ├── ack │   └── 3.7.0 ├── aom │   └── 3.8.2 ├── aribb24 │   └── 1.0.4 ├── asciinema │   └── 2.4.0 ├── autoconf │   └── 2.72 以 go 程序为例 $ ls Cellar/go* Cellar/go: 1.22.2 Cellar/go@1.19: 1.19.13 旧版本存储在 go@1.19 中,新版本存储在 go 目录,是便于在机器上能使用 go 的多个版本 /opt/homebrew/opt 目录 opt 目录着存储指向特定软件包最优或者说“被选中”的版本的符号链接(symlinks),Homebrew 不直接使用 Celler 目录下的软件包。 可以看到软链接指向的具体程序 /opt/homebrew/bin 回到 Homebrew 查看,还能看到一个 bin 目录,bin 目录也存储的是软链接,也指向 Celler 目录中的程序。 跟 opt 下的链接作用不同,前者链接通常指向软件的具体可执行文件,后者主要是为了让用户和程序能够引用到一个不随软件版本而变化的路径,指向的是整个软件包的安装目录。 /opt/homebrew/Caskroom 见名知意,它存储着 Cask 类型软件 Caskroom ├── buzz │   └── 0.8.4 ├── font-fira-code │   └── 6.2 ├── font-inconsolata │   └── latest ├── fuse-t │   └── 1.0.36 ├── fuse-t-sshfs │   └── 1.0.1 ├── iterm2 │   └── 3.4.19 ├── visual-studio-code │   └── 1.78.2 └── zed └── 0.129.2 17 directories, 0 files 另外的一些目录,如:“/opt/homebrew/etc”、“/opt/homebrew/var”、“/opt/homebrew/lib”、“/opt/homebrew/include” 等,从名字也能看出,他们作为 “系统目录” 供 Homebrew 使用,后续有需要查看 Lib、修改服务配置的需要,可以到里边找。 知晓了 opt 目录的作用,那么在 Goland 设置 GOROOT 变量就不会很纠结,使用 opt 而不是 Celler 下的目录是更推荐的方式。 同样,出于某些原因,我们需要设置一些环境变量,可以使用 opt 链接 $ export PATH="/opt/homebrew/opt/openjdk@11/bin:$PATH" $ export PATH="/opt/homebrew/opt/redis@6.2/bin:$PATH" 了解了 Homebrew 目录结构,接下来继续看的功能 Services 服务 启动服务(如未安装先执行 brew install redis@6.2) $ brew services start redis@6.2 查询服务列表 $ brew services list 停止命令是 $ brew services stop redis@6.2 启动 redis@6.2 依赖的是 .plist 文件,它描述服务的启动和停止命令及参数。 我们可以在 Cellar 目录下找到 Redis 服务的对应 .plist 文件,我电脑上的路径是:Cellar/redis@6.2/6.2.14/homebrew.mxcl.redis@6.2.plist 同目录还有个 homebrew.redis@6.2.service 文件,打开查看一定不陌生,它是 systemd 的应用配置 [Unit] Description=Homebrew generated unit for redis@6.2 [Install] WantedBy=default.target [Service] Type=simple ExecStart=/opt/homebrew/opt/redis@6.2/bin/redis-server /opt/homebrew/etc/redis.conf Restart=always WorkingDirectory=/opt/homebrew/var StandardOutput=append:/opt/homebrew/var/log/redis.log StandardError=append:/opt/homebrew/var/log/redis.log 切换软件版本 切换版本会更新 bin 目录下的软链接、需要先删除,再创建 使用 go@1.19 版本的 go $ brew unlink go && brew link go@1.19 切换到 go@1.22 版本的 go $ brew unlink go && brew link go@1.22 切换到最新版本的 go,本例是 Go 1.22 版本 $ brew unlink go && brew link go 除切换版本,当我们发现所需的命令未在 /opt/homebrew/bin 目录下,可以使用 link 在 bin 目录下创建到 Celler 的软链接 $ brew link redis@6.2 设置环境变量 最后,如果找不到 /opt/homebrew/bin 下的命令,排查确认 PATH 环境变量中包函路径:/opt/homebrew/bin $ export PATH="/opt/homebrew/bin:$PATH" 临时添加,或将其写入到 ~/.zshrc 文件 基本上了解了 Homebrew 这些知识后,使用起来会更加 “流畅”,高阶使用参考官方文档 参考 Formula-Cookbook

2024/4/9
articleCard.readMore

Loki 查询语言 LogQL 学习笔记

Loki 是 Grafana 实验室开发的一个水平可扩展、高可用、多租户的日志聚合系统。LogQL(Log Query Language)是专门为 Loki 设计的用于查询日志条目的语言。 本文根据官方文档整理了 LogQL 语法示例,以作备忘。 简单 LogQL 示例 查询标签 { filename="/var/log/syslog" } = 相等 != 不相等 =~ 正则匹配 !~ 正则不匹配 这是一些正则示例 {name =~ "mysql.+"} {name !~ "mysql.+"} {name !~ `mysql-\d+`} 标签 + 包含字符 {filename="/var/log/syslog"} |= "Day" 此处 “|” 是管道操作符号,将上一查询结果传递,等号这里同样支持相等、不等、正则。 {container="appserver"} |~ "Error | task.err" 以 Game Server 输出为例,首先过滤容器为 appserver 的日志,然后在这些日志中进一步过滤出包含 “ERROR” 或包含 “task.err” 的记录,包含的内容会高亮显示。 如果需要同时包含多个关键字,使用管道符多次过滤 {container="appserver"} |= "task.err" |= "unexpected end of JSON input" 进一步,可以针对标签再进行过滤,此处添加了 “node_name = "ip-10-10-3-87.ec2.internal"”,表示记录的标签 node_name 需要为“ip-10-10-3-87.ec2.internal”,查看特定机器的日志。 {container="appserver"} |= "task.err" |= "unexpected end of JSON input" | node_name = "ip-10-10-3-87.ec2.internal" LogQL 语法结构总览 以上演示了基础示例,接下来系统的了解 LogQL 语法 每个 LogQL 查询语句都必须包含一个流选择器(Stream Selector),即用 “{” 和 “}” 包裹的查询语句,其后跟随可选的 Log Pipeline 用于过滤。 如果按照类别划分,Log Pipeline 可以分为 过滤表达式:行过滤表达式(Line Filter Expression)和标签过滤表达式(Label Filter Expression) 解析表达式 格式化表达式 过滤表达式 过滤表达式在上文示例已经见过,= "task.err 即行过滤表达式,用于在流选择器选择日志流后,过滤关注日志,缩小后续处理范围,node_name = "ip-10-10-3-87.ec2.internal" 是标签过滤表达式,进一步缩小日志范围。 行过滤表达式 虽然行过滤器表达式可以放在日志管道中的任何位置,但最好在一开始就使用它们,将它们放在开头可以提高查询的性能,因为它只在行匹配时进行进一步处理,一旦应用了日志流选择器,行过滤器表达式是过滤日志的最快方式。 |= 日志行包含字符串,如:|= "error" 包含 “error” 字符串 != 日志行不包含字符串,如:!= "type=ReplicaManager" 不包含 "type=ReplicaManager" 字符串 |~ 日志行匹配正则表达式 !~ 日志行不匹配正则表达式 正则表达式示例可以参考 Line filter expression 章节官方文档 一个包含 “error”,但不包含 “timeout” 的过滤示例: {job="mysql"} |= "error" != "timeout" 标签过滤表达式 标签过滤表达式允许使用原始和提取的标签过滤日志行,它可以包含多个谓词。 支持多个比较符号及值类型,比如: cluster="namespace" duration > 1m bytes_consumed > 20MB size == 20kb duration >= 20ms or method="GET" and size <= 20KB duration >= 20ms or (method="GET" and size <= 20KB) 关于比较符号和值类型,详参 Label filter expression 官方文档。 补充说明:行和标签过滤器都支持 IP 规则进行过滤,详参 Matching IP addresses 章节。 解析表达式 解析表达式能从日志内容中解析出标签,例如一个 JSON Payload 日志行 { "a.b": {c: "d"}, e: "f" } 通过 JSON 解析器,能够解析出如下标签 a_b_c="d" e="f" Loki 支持以下类型的解析器: json JSON 解析器 logfmt 日志解析器 pattern 模版解析器 regexp 正则解析器 unpack Pack 内容解析器 接下来对每个解析器进行说明和举例 JSON 解析器 有两种语法,不带参数的 | json 和带参数的 | json label="expression", another="expression" 解析器 先以不带参数的 json 解析器为例 { "protocol": "HTTP/2.0", "servers": ["129.0.1.1","10.2.1.3"], "request": { "time": "6.032", "method": "GET", "host": "foo.grafana.net", "size": "55", "headers": { "Accept": "*/*", "User-Agent": "curl/7.68.0" } }, "response": { "status": 401, "size": "228", "latency_seconds": "6.031" } } 会解析出标签 "protocol" => "HTTP/2.0" "request_time" => "6.032" "request_method" => "GET" "request_host" => "foo.grafana.net" "request_size" => "55" "response_status" => "401" "response_size" => "228" "response_latency_seconds" => "6.031" ⚠️ 注意事项:json 无参数解析器会忽略数组的处理,上例中的 servers 字段被忽略 使用带参数的 json 解析器(| json label="expression", another="expression")可以只从 JSON Payload 中解析出关注的字段标签 | json first_server="servers[0]", ua="request.headers[\"User-Agent\"]" 以上表达式将从数据中解析出 servers[0] 并绑定 first_server 标签,request.headers[\"User-Agent\"] 绑定到 ua 标签。 "first_server" => "129.0.1.1" "ua" => "curl/7.68.0" 当 label 和 json field 名称相同时,可以省略 expression,即:| json servers 等效于 | json servers="servers" logfmt 日志解析器 跟 json 解析器类似,logfmt 也分为有参数和无参数语法用来解析部分和全部数据。 logfmt 用于处理 https://brandur.org/logfmt 格式的日志数据 日志实例: at=info method=GET path=/ host=grafana.net fwd="124.133.124.161" service=8ms status=200 会解析出标签 "at" => "info" "method" => "GET" "path" => "/" "host" => "grafana.net" "fwd" => "124.133.124.161" "service" => "8ms" "status" => "200" 其有参数使用方式为 | logfmt label="expression", another="expression" 以下表达式会提取 host 和 fwd 字段,其中 fwd 会被重命名为 fwd_ip 标签 | logfmt host, fwd_ip="fwd" 作为了解,logfmt 支持两个参数 --strict 和 --keep-empty,前者可以进行更为严格的校验,默认的宽松模式当字段格式错误会忽略处理,开启严格模式后会报错停止解析。后者在无参数解析时会保留独立的键,值为空作为标签,有参数解析时只要选定了字段,则无需指定 --keep-empty 也会包含键值。 logfmt 使用示例 | logfmt --strict | logfmt --strict host, fwd_ip="fwd" | logfmt --keep-empty --strict host pattern 模式解析器 支持自定义表达式从日志行显式提取字段。格式为:| pattern "<pattern-expression>" 以下以 Nginx 日志为例 0.191.12.2 - - [10/Jun/2021:09:14:29 +0000] "GET /api/plugins/versioncheck HTTP/1.1" 200 2 "-" "Go-http-client/2.0" "13.76.247.102, 34.120.177.193" "TLSv1.2" "US" "" 这行日志可以被以下模式表达式解析 <ip> - - <_> "<method> <uri> <_>" <status> <size> <_> "<agent>" <_> 解析出的标签如下 "ip" => "0.191.12.2" "method" => "GET" "uri" => "/api/plugins/versioncheck" "status" => "200" "size" => "2" "agent" => "Go-http-client/2.0" 模式表达式由两部分组成 —— 捕获和文字。 捕获是由 “<” 和 “>” 框选出的部分,例如 将定义一个标签 example,其值为匹配到的文本内容。文字可以是任何 UTF-8 字符串或空白字符。使用 <_> 下划线可以忽略内容。 默认情况下,表达式从日志行的开始位置开始匹配,如果想要忽略部分起始内容,可以在开始处使用 <_>,以下是一个示例 level=debug ts=2021-06-10T09:24:13.472094048Z caller=logging.go:66 traceID=0568b66ad2d9294c msg="POST /loki/api/v1/push (204) 16.652862ms" 使用表达式:<_> msg=" () " 忽略除 msg 字段前的内容,并解析出 msg 内的字段标签。 注意 ⚠️: 合法的 pattern 解析器至少包含一个命名的捕获 两个捕获之间至少应包含一个空白分隔,不能连在一起 regexp 正则解析器 与 json 和 logfmt 解析器不同,regexp 解析器必须携带参数,格式为:| regexp "" 这里的正则表达式使用 golang/re2 进行解析,每个正则解析器都需要至少包含一个子匹配,例如:(?Pre),每个子匹配的 name 即标签名需要是不同的。 例如,表达式: | regexp "(?P<method>\\w+) (?P<path>[\\w|/]+) \\((?P<status>\\d+?)\\) (?P<duration>.*)" 应用于日志 POST /api/prom/api/v1/query_range (200) 1.5s 将解析出标签 "method" => "POST" "path" => "/api/prom/api/v1/query_range" "status" => "200" "duration" => "1.5s" 能使用模式解析器时,建议优先使用模式解析器,因为正则表达式更难以编写和调试。 技巧 To avoid escaping special characters you can use the `(backtick) instead of " when quoting strings. For example \w+ is the same as "\w+". This is specially useful when writing a regular expression which contains multiple backslashes that require escaping. unpack 解析器 跟 pack 搭配使用,使用 promtail 的 pack 步骤 将日志放入 _entry 后。 { "container": "myapp", "pod": "pod-3223f", "_entry": "original log message" } 可以使用 unpack 解析器将日志的标签解析出来,上例中的 container 和 pod 标签,同时将特殊字段 _entry 中的内容替换为日志行,unpack 后通常搭配 json 和 logfmt 使用,进一步处理 "original log message" Line format 表达式 使用 line_format 表达式可以重写日志。 它遵照 text/template 的模版格式,表达式结构如下 | line_format "{{.label_name}}" 所有的标签都注入到模版中,可以在模版中使用,如以下示例 {container="frontend"} | logfmt | line_format "{{.query}} {{.duration}}" 它重写日志结构,只包含 query 和 duration 标签的内容,如果需要编写复杂的模版,可以使用反撇号(backtick)代替双引号,避免语法报错 line_format 表达式支持 math 函数,例如有如下数据 ip=1.1.1.1, status=200 and duration=3000(ms) 经由以下表达式处理 {container="frontend"} | logfmt | line_format "{{.ip}} {{.status}} {{div .duration 1000}}" 日志将被格式化为以下内容 1.1.1.1 200 3 标签格式化表达式 添加/修改标签 使用 | label_format 表达式可以重命名、修改、添加标签,支持逗号分隔符一次进行多个操作。 当等号两边都是标签时,dst=src 将把 src 标签重命名为 dst,当右侧为模版时,dst="{{.status}} {{.query}}" dst 标签内容值将被替换,如 dst 不存在则新建标签,这里的模版语法跟 line_format 支持的模版语法一致 如果不希望 dst=src 在赋值时销毁 src 标签,则可使用 dst="{{.src}}" 补充:不允许在一个表达式内对一个标签多次修改 | label_format foo=bar,foo="new" ⚠️ 删除标签 删除标签关键字为 drop,语法为 |drop name, other_name, some_name="some_value" 示例数据 {"level": "info", "method": "GET", "path": "/", "host": "grafana.net", "status": "200"} 表达式 {job="varlogs"} | json | drop level, method="GET" 标签 "path" => "/" "host" => "grafana.net" "status" => "200" 保留标签 | keep 表达式只会保留特定的标签,删除所有其它标签,语法为 |keep name, other_name, some_name="some_value" 示例数据 {"level": "info", "method": "GET", "path": "/", "host": "grafana.net", "status": "200"} {"level": "info", "method": "POST", "path": "/", "host": "grafana.net", "status": "200"} 表达式 {job="varlogs"} | json | keep level, method="GET" 标签 level => "info" method =>"GET" {"level": "info", "method": "GET", "path": "/", "host": "grafana.net", "status": "200"} level => "info" {"level": "info", "method": "POST", "path": "/", "host": "grafana.net", "status": "200"} 案例实践 有日志内容如下 2024-04-01T10:09:04.060Z ERROR task_scheduler/task_scheduler.go:54 task.err {"app_name": "mars", "server": "appserver", "name": "activity.update_remote_config", "err": "unexpected end of JSON input", "task_duration": "4.820625ms"} 日志内容都包含在最后的 JSON Payload 中,以下使用两种方式解析到 JSON 内容,使其标签化 模式匹配 首先使用模式匹配,这是推荐且易用的方式,完成语句如下 {container="appserver"} |= "task.err" | pattern "<datatime>\t<log_level>\t<func>\t<msg>\t<json_payload>" | line_format "{{.json_payload}}" | json 正则匹配 {container="appserver"} |= "task.err" |= "unexpected end of JSON input" | regexp "(?P<datatime>[^\\s]+)\\t(?P<level>[\\w]+)\\t(?P<func_line>[^\\s]+)\\t(?P<msg>[^\\s]+)\\t(?P<json_payload>.*)" | line_format "{{.json_payload}}" | json 跟模式匹配的效果是一样的,根据 json 内容生成的标签如下 使用标签过滤 在以上的表达式基础上,可以针对标签再次进行过滤日志 | name = "rank.update_remote_config" 过滤 json 内 name 字段值为 "rank.update_remote_config" 的数据。 参考 Grafana 官方 log_queries 文档

2024/4/8
articleCard.readMore

macOS 部署体验 immich 相册 v2.2.2

持续更新!!! 环境信息 操作系统:macOS Sequoia 15.3.1 容器环境:OrbStack(Docker 桌面版平替,提供容器与虚拟机) Node.js 环境:v25.1.0 安装 Immich(Docker Compose) 首先,创建应用目录,我计划将文件和配置存储在用户的 ~/Portable 文件夹下 $ mkdir -p ~/Portable/immich-app $ cd ~/Portable/immich-app 下载配置文件及环境变量 $ wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml $ wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env 修改 .env 环境变量,我将 UPLOAD_LOCATION 修改为 /Users/dongdong/Portable/immich-app/library .env 文件在 ~/Portable/immich-app 目录下,推荐使用 vim 编辑 如果你不会 vim 等终端命令,可以使用 VS Code 等可视化编辑器,首先右键「访达」,点击「前往文件夹」,输入 ~/Portable/,跳转到文件夹,在这里可以看到刚创建的 immich-app 文件夹,在 VS Code 中选择「打开文件夹」,然后拖动 immich-app 文件夹到编辑器,后续各项配置即可直接编辑,在 VS Code 中可以看到隐藏的 .env 文件 启动服务 $ docker compose up -d 对应的停止命令是 docker compose down,需要在 docker-compose.yml 文件所在目录下执行。 如果你部署的机器在国内,推荐使用国内镜像 https://docker.aityp.com/r/ghcr.io/immich-app/immich-server 替换 docker-compose.yml 中的镜像地址 访问服务 因为是本地搭建,请求地址即:http://127.0.0.1:2283 首次访问会要求输入邮箱,用户名密码(第一个用户会成为管理员) 进入后可以通过页面上传文件 批量导入照片 支持通过命令行批量导入图片,Immich CLI 要求 Node.js 大于等于 v20 版本 $ node --version v25.1.0 $ npm i -g @immich/cli 安装后,即可在命令行使用 immich 命令 首先,从页面获取 API Key,点击 “头像” - “账户设置” - “API 密钥”,添加一个新 Key(权限我给了 asset、album、albumAsset、user),复制下来,关闭页面后将不再展示。 登录 # 命令的格式是 immich login server api-key $ immich login http://127.0.0.1:2283 l87T0wLq0LkcoXSuvcWOGoSByxcyaebvNgxh81NDg Logging in to http://127.0.0.1:2283 Discovered API at http://127.0.0.1:2283/api Logged in as username@gmail.com Wrote auth info to /Users/dongdong/.config/immich/auth.yml CLI 的使用参考:https://immich.app/docs/features/command-line-interface/ 导入命令示例 # 添加 --dry-run 参数,测试导入,但实际不进行任何实际操作 $ immich upload --dry-run --recursive --album-name "20251102-秋季北京公园一角" /Users/joecovert/Downloads/Day20251102 # 相册可以通过 --album-name 重命名 $ immich upload --recursive --skip-hash --album-name "20251102-秋季北京公园一角" /Users/joecovert/Downloads/Day20251102 此处的 --skip-hash 参数,如果你确定本次上传没有重复文件,则可以设置此参数,减少上传前的哈希计算,速度更快。 如果你目录有多个子文件夹,每个子文件夹都想变为一个相册,可以使用 --album 参数,跟 --album-name 参数二选一 导入成功 扩展 Library 当图像存储在部署 Immich 的本地磁盘,Immich 也支持配置后直接使用。 在我的示例中,服务部署在了 ~/Portable/immich-app 下,作为演示,新建一个 ~/Portable/immich-external-library 文件夹,这个文件夹下放置着一个子文件夹 “20251007-北京昌平花卉展” 编辑 docker-compose.yml 配置文件(在 immich-app 文件夹内) 注意 不要将整个配置文件都覆盖,找到对应的字段增量添加 immich-server: volumes: - ${EXTERNAL_PATH}:/mnt/media/immich-external-library - ${EXTERNAL_PATH_2024}:/mnt/media/immich-external-library-2024:ro 之前老版本只支持绑定一个 EXTERNAL_PATH 且为固定的映射路径,现在支持绑定多个 不可编辑的内容后面添加 :ro 表明只读 服务还处于开发阶段,新版的配置文件可能随时调整,截图仅供参考,如配置有问题可参考官方文档 新增的 volume 配置行注意缩进,继续修改 .env 文件,添加 EXTERNAL_PATH 变量值为自己的扩展照片目录 EXTERNAL_PATH=/Users/dongdong/Portable/immich-external-library EXTERNAL_PATH_2024=/Users/dongdong/Portable/immich-external-library-2024 这样物理机上的 /Users/dongdong/Portable/immich-external-library 文件夹就被映射到了容器内部的 /usr/src/app/immich-external-library 目录(EXTERNAL_PATH_2024 同理) 重启服务使配置生效 $ docker compose down $ docker compose up -d 而后通过 Web 页面配置扩展文件夹 点击头像 “系统管理” - “外部图库” - “创建图库”,选择所属用户,选择后不可更改。 选择后点击 “验证”,没问题后点击保存。 然后点击右上角的 “扫描所有图库”,刷新下页面,可以看到成功导入照片 此处填入的路径是容器内部的路径,即:"/usr/src/app/immich-external-library" (上方的配置文件添加了映射) 点击扫描设置可以排除掉目录中不想导入的资源 如果你的内容有添加,点击 “扫描图库按钮” 刷新即可 截止到 2024-03-28 日,不能通过扩展 Library 自动创建 albums,这两个 Discussions(#4279、#4186)是相关讨论,看起来正在计划中 截止到 2025-11-05 日,Immich 还未实现/或未计划该功能,需要借助外部脚本实现 在 Web 支持之前,可以通过 immich-albums 脚本来自动创建 Albums,它基于文件夹创建。 最新的工具:Salvoxia/immich-folder-album-creator 我未进行测试,从文档和更新频率看是可用的。 效果展示 图像页 地图页 统计页 搜索(图像内容) 搜索(支持相机、区域、日期等检索) 另外 OrbStack 启动/关闭服务都很方便,还能快速进入容器以及查看日志,比较推荐使用。 图像坐标中文优化 部署完是这样的: 「沙河」显示为了「Shahe」 借助这个库实现:https://github.com/ZingLix/immich-geodata-cn 它的 README.md 很详细,可以参考,简单来说,步骤如下: 下载 geodata_admin_3_admin_4_full.zip 和 i18n-iso-countries.zip 解压缩,将文件夹复制到 immich-app 下(docker-compose.yml 同级) 重启 Immich 服务,可以看到终端在加载地理编码数据 启动后,进入 “系统管理” “任务” 页面,点击 「提取元数据」中的 “全部”,触发对所有照片位置信息的刷新。任务完成后,所有照片的位置将显示为中文地名并支持中文搜索。 只需手动执行一次,后续添加的照片会自动生成。 新的照片地理信息展示的更加友好 服务备份 更新:本小结备份方式仍有效,不过更加推荐的方式是使用官方提供的脚本:template-backup-script 它默认使用 Borg(可选 Restic),支持一键备份、增量去重、多备份版本、恢复简单的特点,推荐尝试。 Immich 服务需要备份的有两部分:数据库与图像 备份数据库 导出 $ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "immich-dump.sql.gz" 恢复 $ docker compose down -v # 删除所有数据,重新开始 $ docker compose pull # 如果需要,更新 Immich 到最新版本 $ docker compose create # 创建 Docker 容器但不启动他们 $ docker start immich_postgres # 单独启动 Postgres 服务 # 等待 10s 钟等待 Postgres 启动完成 $ gunzip < "immich-dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # 导入 $ docker compose up -d # 启动服务 备份图像 Immich 在磁盘存储两类内容 —— 原始文件与生成文件。 只有原始文件需要备份,文件夹为 UPLOAD_LOCATION 目录下的 library、upload、profile Immich 提供的小工具 # 进入 immich_server 容器 $ docker exec -it immich_server /bin/bash # 列出所有用户 $ immich-admin list-users [ { id: 'd159a19d-b8e9-434c-bebe-51b5e7f10c63', email: 'my-email-addr@gmail.com', name: '东东', profileImagePath: '', avatarColor: 'primary', storageLabel: 'admin', shouldChangePassword: true, isAdmin: true, createdAt: 2024-03-27T04:34:26.222Z, deletedAt: null, updatedAt: 2024-03-27T05:35:30.409Z, oauthId: '', memoriesEnabled: true, quotaSizeInBytes: null, quotaUsageInBytes: 3214286188, status: 'active' } ] immich-admin 目前还支持:重置 admin 密码、启用密码登录、禁用密码登录、启用 oauth 登录、禁用 oauth 登录。 更新到最新版本 Immich 平均一周会发一个小版本, 查看更新说明: immich/releases 更新到最新版本只需要执行如下命令即可 # 先进到应用的目录 $ cd ~/Portable/immich-app $ docker compose pull && docker compose up -d 如果想要更新到指定的版本,修改 .env 内的 IMMICH_VERSION 为版本号后再升级,如: v1.100.0 重要‼️ 更新前,先查看最新的配置文件模版,跟本地进行比较 https://github.com/immich-app/immich/blob/main/docker/docker-compose.yml https://github.com/immich-app/immich/blob/main/docker/example.env 基于最新的 YML 配置,将原有的自定义设置在新的配置中调整(调整前备份原配置文件) 最后 推荐 Immich 的人很多,它的算法和速度都不错,不过目前还属于早期的开发版本,可能随时调整各项功能,每次升级前需要格外留意,做好备份,不要将鸡蛋都放在一个篮子。 再就是 Immich 暂不支持多语言、无法使用中文(页面语言、以及搜索的时候要搜索 “duck” 而不支持 “鸭子”),小 BUG 会多一些(比如截图中的 Storage,显示的不正确 #7482 #4318) Immich 需要导入图像或加载本地磁盘的图像,通过 CLI 工具可以将文件夹导入并自动创建 Album,不过扩展 Library 还不支持导入为 Albums,另外一个在国内使用的困扰就是 Map 地图使用的 OpenStreetMap 的 API,非科学不可用,如果后续能自定义地图 API 或支持配置 OSM 代理就好很多。 现阶段,Immich 还不太适合作为一站式管理图像的稳定服务,虽有不足,但值得关注,未来可期。 20240613 补充 Immich 更新到了 v1.106.3 版本 中间的几个版本改善了分享、硬件转码加速、可选的外部库可编辑,增加了邮件通知、查找重复文件、多语言支持等功能! 中文支持 非常棒! 20240823 补充 有段时间没更新,再次更新发现已经到了 v1.112.1 版本,数据库原有数据从 Volume 调整到了磁盘挂载... 因为一直在测试,就懒得找迁移文档,切换了新的 yaml 配置 docker-compose.yml 重新部署 DB_DATA_LOCATION=/Users/dongdong/Portable/immich-app/pgdata 体验了下,页面整体的美观程度,后台管理项,整体都有一定提升 20240914 补充 更新到 v1.115.0 版本 支持文件夹浏览图片(v.1.113.0 版本支持),点击“用户头像”-“账户管理”-“功能”-“文件夹”,启用并在侧边栏展示,在线管理文件就很便捷 1.114.0 新增了标签🏷️ 1.115.0 增加了管理员按钮等 20241107 补充 更新到 v1.120.0 版本 其中一项比较便利的更新是内置了数据库备份功能,在 “用户头像” - “管理” - “设置” - “备份设置” 中开启 20251105 补充 整一年没更新 Immich,换了电脑,重新安装了 Immich 最新 v2.2.2 版本 Web 版本上传功能看起来很棒,不知道是哪个版本优化的 20251219 补充 Immich 更新到了 v2.4.0 版本 搜索图像的时候支持类别过滤(以图搜图、文件名、描述、文本识别),便于缩小查找范围; 共享相册支持查看所有者; 支持命令面板(macOS 下按 Command + K 调出,中文语言可以输入“用户”、“设置” 查看效果); 移动端部分优化; 参考 Immich Docs - Quick Start

2024/3/28
articleCard.readMore