古人学问无遗力，少壮工夫老始成。 纸上得来终觉浅，绝知此事要躬行。
从手动部署到一键上线：我用 Jenkins + Docker 搭了一套自动化流水线
遇到一个项目，代码仓库是 GitLab 托管的，部署方式嘛……让我说得好听点叫"半自动化"——开发在本地跑完测试，打个 tar 包，scp 传到服务器，然后手动重启服务。出过几次事故之后，组里终于下定决心：搞一套像样的 CI/CD。我选了 Jenkins + Docker 的方案。不是因为它最新潮（GitHub Actions、GitLab CI 都更"现代"），而是因为团队里有人用过 Jenkins，而且我们的构建环境比较特殊——有几个依赖只能在特定 OS 版本上编译。Docker 的好处就是可以把这些乱七八糟的环境依赖全打包进镜像，构建环境一劳永逸。环境准备：别小看权限问题Jenkins 装起来很快，官方提供 WAR 文件和 Docker 镜像两种方式。我选的是 Docker 方式跑 Jenkins 本身——是的，用 Docker 跑 Jenkins，然后让 Jenkins 再去操作 Docker 构建应用镜像。这就是所谓的 "Docker in Docker"（DinD），听起来套娃，但实际操作中很常见。不过这里有个坑我必须提一下。Jenkins 容器里的用户默认是 jenkins，它没有权限访问宿主机的 Docker daemon。你会看到类似这样的报错：Got permission denied while trying to connect to the Docker daemon socket解决办法是把 jenkins 用户加入 docker 组：sudo usermod -aG docker jenkins然后重启 Jenkins 和 Docker 服务。如果你是用容器跑的 Jenkins，还需要把宿主机的 /var/run/docker.sock 挂载进去：docker run -d \ -v /var/run/docker.sock:/var/run/docker.sock \ -v jenkins_home:/var/jenkins_home \ -p 8080:8080 \ jenkins/jenkins:lts这样 Jenkins 容器就能和宿主机的 Docker 通信了。说实话，第一次搞这个的时候我折腾了半天，因为忘了重启服务，一直以为是配置写错了……插件：装对了事半功倍Jenkins 的插件生态是它的核心竞争力，也是它被人吐槽"太重"的原因。对于 Docker 集成，有两个必装的：Docker Pipeline Plugin：让你在 Jenkinsfile 里直接用 docker.image() 这种 DSL 来操作容器Pipeline Plugin：这个是基础，没它就没法写声明式 Pipeline装插件的路径是 Manage Jenkins → Manage Plugins → Available，搜索 "Docker Pipeline" 就行。我还额外装了 Blue Ocean（让 Pipeline 可视化变好看）和 Git plugin（连接 GitLab 仓库）。插件不要贪多，装太多会拖慢 Jenkins 启动速度，而且插件之间偶尔还会冲突。Jenkinsfile：Pipeline as Code这是整个方案的核心。我们把 CI/CD 的流程定义写成一个 Jenkinsfile 放在项目根目录，和业务代码一起版本管理。这意味着任何人改了构建流程，都有 Git 记录可以追溯——谁改的、什么时候改的、为什么改的。我们的 Jenkinsfile 长这样（做了脱敏处理）：pipeline { agent { docker { image 'maven:3-alpine' } } environment { DOCKER_REGISTRY = 'registry.company.com' IMAGE_NAME = 'myapp' IMAGE_TAG = "${env.BUILD_NUMBER}" } stages { stage('Build') { steps { sh 'mvn clean install -DskipTests' } } stage('Test') { steps { sh 'mvn test' } post { always { junit '**/target/surefire-reports/*.xml' } } } stage('Build Image') { steps { sh "docker build -t ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} ." sh "docker push ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" } } stage('Deploy') { steps { sh """ docker pull ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} docker stop ${IMAGE_NAME} || true docker rm ${IMAGE_NAME} || true docker run -d --name ${IMAGE_NAME} \ -p 8080:8080 \ --restart unless-stopped \ ${DOCKER_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG} """ } } } post { failure { echo '构建失败，发送告警...' // 这里可以对接钉钉/企业微信通知 } } }注意看 agent { docker { image 'maven:3-alpine' } } 这行——它告诉 Jenkins：这个 Pipeline 的构建环境是一个 Maven 容器。Jenkins 会自动拉取这个镜像，启动一个临时容器来执行构建步骤，构建完了自动销毁。这就是为什么 Docker 和 Jenkins 配合这么好：每次构建都是干净的环境，不会有依赖残留污染。之前我们有过一个诡异的 bug：某次构建明明代码没改，但产出的 jar 包行为不对。排查了很久发现是上一次构建残留的依赖缓存导致的。换成容器化构建后这个问题彻底消失了。Docker Compose：当你的应用不止一个容器真实项目往往不是一个孤立的应用。我们的服务依赖 MySQL 和 Redis，集成测试需要这些组件同时跑起来。这时候 Docker Compose 就派上用场了。我在项目里维护了一个 docker-compose.yml：version: '3' services: web: image: 'registry.company.com/myapp:latest' ports: - '8080:8080' depends_on: - db - cache environment: - SPRING_PROFILES_ACTIVE=prod - DB_HOST=db - REDIS_HOST=cache db: image: 'mysql:5.7' environment: MYSQL_ROOT_PASSWORD: 'changeme' MYSQL_DATABASE: 'myapp' volumes: - mysql_data:/var/lib/mysql cache: image: 'redis:7-alpine' volumes: mysql_data:在 Jenkinsfile 的测试阶段，我们用 docker-compose up -d 启动整套环境，跑完集成测试再 docker-compose down 清理。这样每次测试都是在一个完整的、隔离的环境里进行的，不会因为别人在同一台机器上跑了什么东西而相互影响。有个小技巧：集成测试阶段可以给 docker-compose 文件加 --project-name 参数，用 Jenkins 的 BUILD_NUMBER 作为前缀。这样即使多个构建并行跑，容器名也不会冲突。Shared Libraries：当多个项目共用流水线项目多了之后你会发现，很多 Jenkinsfile 长得差不多——都是"拉代码 → 构建 → 测试 → 打镜像 → 部署"。每个项目都复制一份这样的文件很蠢，改起来也痛苦。Jenkins 提供了 Shared Libraries 机制，你可以把通用的 Pipeline 逻辑抽成一个 Git 仓库里的 Groovy 脚本，然后各项目的 Jenkinsfile 只需要一行：@Library('my-shared-lib') _ standardPipeline(appName: 'myapp', port: 8080)底下具体怎么 build、怎么 push 镜像、怎么部署，全在 Shared Library 里定义。项目只需要告诉它"我叫什么名字、暴露什么端口"就行了。这个模式在团队规模大了之后非常有价值。我们现在有十几个微服务，如果构建流程需要调整（比如加一个安全扫描步骤），只需要改 Shared Library，所有项目下次构建就自动生效。几个我踩过的坑聊聊实际操作中碰到的问题吧。磁盘空间。Docker 镜像和构建缓存会默认占用 Jenkins 服务器的磁盘，如果不定期清理，几个月下来磁盘就满了。我的做法是写了个定时任务，每周清理超过 7 天的无用镜像和停止的容器：docker system prune -af --filter "until=168h"构建慢。如果每次构建都要重新下载 Maven 依赖，那真的很慢。解决方法是在 Jenkins 里配置一个持久化的 Maven 本地仓库目录，或者用 Docker volume 把 .m2 目录挂载出来。网络问题。Docker 容器里如果需要访问内网的私有镜像仓库或 Maven 私服，需要配置 Docker 的 daemon.json 添加 insecure-registries，或者把证书挂载进容器。并发构建冲突。如果两个分支同时触发构建，它们可能会抢占同一个端口或容器名。用 BUILD_NUMBER 或分支名作为容器名后缀可以避免这个问题。现在的状态搭完这套之后，开发提交代码到 GitLab，Jenkins 自动触发（通过 Webhook），5 分钟之内就能知道构建是否成功、测试是否通过。如果一切正常，镜像自动推送到私有仓库，然后部署到测试环境。生产环境的发布我们还是保留了手动确认的步骤——点一下按钮就行，但至少不用再 SSH 上去手动操作了。整个流程从之前的"半小时手工操作 + 提心吊胆"变成了"推代码 → 喝杯咖啡 → 看结果"。偶尔有构建失败也不怕，钉钉群里会自动收到通知，点进去就能看到哪个阶段挂了、日志是什么。这套方案不完美——Jenkins 确实比较"重"，配置有时候有点繁琐，UI 也不如新一代工具好看。但它稳定、生态丰富、团队熟悉，对于我们这种不追求最新潮但要求可靠的团队来说，是个务实的选择。如果你也在考虑搞 CI/CD，我的建议是别纠结于工具选型太久。Jenkins、GitLab CI、GitHub Actions 都能干这个事，关键是先跑起来，让团队习惯"代码提交后自动化流程就开始运转"这个节奏。等跑顺了再根据实际痛点去优化，比一开始就追求完美架构要靠谱得多。觉得有用就转发给还在手动部署的同事吧（公众号：耕云躬行录个人博客：躬行笔记
我最近在帮一个初创团队改造他们的发布流程。他们之前用 Jenkins 跑在自己的服务器上，每次部署都得 SSH 登到机器手动检查日志，有时候还会翻车。后来我们逐步把整套系统搬到了 AWS，用 GitHub Actions + CodePipeline + ECS，现在一个 git push 就能自动走完从构建到上线的全过程。这个过程中我学到了很多关于 CI/CD 与云计算结合的妙处。从自建 Jenkins 到云上的自动化说实话，自建 Jenkins 的时代确实很折腾。你需要维护 Jenkins 服务器本身、管理磁盘空间、备份构建历史、处理执行器的故障……这些事情都不直接产生业务价值，但却占用了大量精力。最烦的是，当构建任务突然暴增时，自建的基础设施经常顶不住，你只能去扩容，但扩容又需要时间。云计算改变了这一切。AWS CodePipeline 这样的托管服务，天生就具有弹性。你定义好 pipeline 的各个阶段，剩下的交给云平台处理——它会自动扩展、自动管理，你不用操心基础设施，只需要关注业务流程。我们的做法是这样的：代码推送到 GitHub，GitHub Actions 触发单元测试和代码质量检查（这部分很快，通常 5 分钟内完成）。测试通过后自动构建 Docker 镜像，推送到 ECR（AWS 的容器镜像仓库）。然后 CodePipeline 接手，先部署到测试环境，跑集成测试，通过后再部署到预发布和生产。整个流程大约 20 分钟，全程零人工介入。GitHub Actions vs CodePipeline：我的选择这两个工具有重叠，但适用场景不同，我来说说我的体会。GitHub Actions 更轻，直接在 repository 里配置 workflow，天生和 GitHub 深度整合。如果你的整个工作流都在 GitHub 生态里（代码、issue、PR、release），用 Actions 很顺手。我们用它来做本地验证、单元测试、代码风格检查这些快速反馈。CodePipeline 是 AWS 的托管 CI/CD 服务，优势在于和其他 AWS 服务无缝连接。CodeBuild 可以在 Docker 容器里跑构建，CodeDeploy 可以直接部署到 EC2、ECS、Lambda，CloudFormation 可以自动更新基础设施。如果你的应用需要复杂的多环境部署、跨账户部署、或者需要 Blue/Green 发布策略，CodePipeline 就显得很强大。我们最后的决策是：GitHub Actions 负责快速反馈和初步验证，CodePipeline 负责多环境编排和正式发布。这样既保留了 GitHub 原生工作流的便利，又借助 CodePipeline 的强大编排能力。容器化部署的转变以前我们部署应用，经常遇到"在我的机器上能跑"这个老问题。有时候本地 Python 版本是 3.8，测试环境是 3.9，生产环境又是 3.10，依赖库的版本也不一样……每次上线前都得额外验证。还有一个隐藏的坑：某个环境装了旧版本的依赖包，跑的还是老代码，但没人发现。容器彻底解决了这个问题。我们把应用打成 Docker 镜像，环境就冻结了——本地、测试、生产用的镜像完全一样，只是配置参数不同。这是 DevOps 理想中的"build once, run everywhere"。Artifact 和 Cache：CodePipeline 的隐形优化这里我想细说一个很多人忽视的点。CodePipeline 在各个 stage 之间传递的是 artifact（通常是压缩包），这些文件都存在 S3 里。第一次看到账单时我有点吓到：这些 artifact 占了不少空间，而且每次部署都要从 S3 读写一遍。后来我发现，CodeBuild 有个 Docker layer caching 机制。简单说就是：构建 Docker 镜像时，如果某一层的内容没变，就直接用上次构建缓存的层，不用重新 build。这能把构建时间从十几分钟降到几分钟，成本也下降 50-70%。不过要启用这个功能需要在 buildspec.yml 里配置 cache 模式，而且 CodeBuild 需要有权限访问 S3（存放缓存）。我们的做法是给 CodeBuild project 配置 S3 本地缓存，指定要缓存哪些目录。比如 node_modules 和 pip packages，这两个目录通常最大，缓存效果最明显。这样一来，如果只改了应用代码，依赖没变，构建就能直接用缓存，速度快得多。ECS 的滚动更新和版本一致性现在的流程是这样的：开发提交代码到 GitHubActions 自动构建镜像，跑单元测试（直接在容器里）镜像推送到 ECRCodePipeline 从 ECR 拉镜像，部署到 ECS（AWS 的容器编排服务）ECS 基于任务定义启动容器，自动处理负载均衡、健康检查、自动扩缩容这里的妙处在于，你不需要再手工登到服务器 scp 文件或 git pull。整个部署都是声明式的：我在代码里定义好"我需要 2 个副本、每个副本分配 512MB 内存"，ECS 会自动满足这个声明，还会监控容器健康状态，挂掉的自动重启。更有意思的是，ECS 的滚动更新机制。传统部署通常是"停止旧容器，启动新容器"，这会导致短暂的服务不可用。ECS 不一样，它用 minimumHealthyPercent 和 maximumPercent 这两个参数来控制：minimumHealthyPercent 保证在更新过程中，至少有 X% 的任务在运行（比如设成 100%，就是不能一个都停，得新旧并行）maximumPercent 则允许暂时超过目标副本数（比如设成 150%，部署时可以最多跑 3 个副本——2 个旧的 + 1 个新的）这样新旧版本并行跑一段时间，新版本没问题再杀掉旧的。整个过程应用一直在线，用户无感知。还有个更强的特性叫"版本一致性"——ECS 会自动把镜像 tag（比如 "latest"）解析成不可变的 SHA256 digest，确保一次部署里的所有任务用的镜像完全一致，不会出现"有的任务用新镜像有的用旧镜像"这种诡异情况。ECR 的镜像清理Docker 镜像存在 ECR 里，不清理的话会逐月累积。我们一开始没在意，结果三个月下来 ECR 里堆了几百个镜像，存储费用变多了。后来我们配置了 ECR 的生命周期策略，自动删除超过 30 天的旧镜像（除了标记为"latest"的）。这是个很小的配置，却能显著降低成本，尤其是频繁发布的应用。GitOps 的新思路最近我们又接触了 GitOps 这个概念，简单说就是：Git 仓库是唯一的真实来源。不仅代码在 Git 里，基础设施配置、部署配置也都在 Git 里。你想改什么，就改 YAML 文件，提交 PR，合并后自动生效。我们用的是 ArgoCD，一个 Kubernetes 的 GitOps 工具。它的原理是：你在 Git 仓库维护 Kubernetes manifests（deployment、service 等资源定义），ArgoCD 持续监听这个仓库，一旦发现不一致（比如有人直接 kubectl apply 了新的 yaml），ArgoCD 就把集群拉回到 Git 定义的状态。这听起来像是"过度工程"，但在多人协作的场景里特别有用。为什么？因为部署历史完全透明：谁改了什么、什么时候改的、为什么改的，全在 Git commit 里。需要回滚？checkout 到上一个 commit 就行。需要审查部署？PR review 就能搞定，不用登服务器查看。成本的隐形杀手我想特别提醒一点：用云计算做 CI/CD，成本往往是隐形的。比如 CodeBuild，按构建时间计费，用的计算资源越多越贵。我们一开始没注意，给每个构建分配 4GB 内存的环境，结果账单一个月就几千块。后来改成 1GB 内存（对于大多数应用构建是够的），账单立即下降。另外，Docker 镜像也要存储在 ECR，虽然不贵，但如果你不定期清理旧镜像，几个月下来也是钱。GitHub Actions 是按分钟计费的，但如果你用的是自己的 runner（不用 GitHub 的托管 runner），成本会低很多，尤其是高频构建的场景。还有一个隐藏成本：数据传输。如果你的镜像库和应用集群不在同一个区域，每次拉镜像都要跨区域传输，这会产生传输费。我们的做法是用 ECR 跨区域复制，把镜像在多个区域保持副本，这样每个区域的部署都从本区域的 ECR 拉镜像。小建议如果你也在考虑改造 CI/CD 流程，我的建议是：不要一步到位。先从 GitHub Actions 开始，把基本的构建和测试自动化起来，这个成本很低。等到你真正需要复杂的多环境编排、跨账户部署时，再引入 CodePipeline。如果用 Kubernetes，GitOps（比如 ArgoCD）能大大提高可维护性。监控成本。云平台的便利会诱使你消费更多资源。建立成本告警，定期审查账单，看哪些资源闲置了、哪些流程太冗长了。保留本地调试能力。即便有了自动化 pipeline，开发还是需要在本地快速迭代。确保 Dockerfile、测试命令、部署脚本都能在本地跑通，这样开发的反馈循环才够快。最后一个感受：CI/CD 和云计算的结合，不是简单地把旧流程搬到新平台，而是一次思想的升级。从"我如何手动部署"到"我如何声明期望状态让系统自动满足"，这个转变会让你的运维工作轻松很多。公众号：耕云躬行录个人博客：躬行笔记关注我们，一起探索云计算和运维的有趣之处~
我还记得那天下午，当老板问"我们能把线上应该能跑在 K8s 上吧"时，我才意识到我们不能再靠 Docker Compose 撑下去了。当时我们用 Compose 运行了 3 年的微服务：一个 Node.js API、两个 Python 后台任务、一个 Redis 缓存、PostgreSQL 数据库——都在一个 docker-compose.yml 文件里定义，在单台虚拟机上跑。每次发布新版本就是：停容器、拉新镜像、docker-compose up -d。工作是能正常工作，但问题开始浮现了。一个晚上，那台虚拟机的硬盘满了。所有容器崩了，我们直到用户打电话才知道。自动扩容？没有。服务发现？靠写死的 IP。滚动更新？做不了，停一个容器就有几秒的请求失败。当时我就明白，是时候升到 Kubernetes 了。第一个坑：别被"一键转换"骗了我最初的想法很天真——用 Kompose 工具自动转换。# 多么诱人的命令 kompose convert -f docker-compose.yml -o k8s/Kompose 确实能把你的 Compose 文件转成 Kubernetes YAML。我当时用了这个工具，结果生成了一堆 yaml 文件扔进去——然后一切都崩了。问题出在细节上。Kompose 把 depends_on 转成了 initContainer，但它不知道 PostgreSQL 实际上需要 30 秒才能启动。API 容器一启动就连不上数据库，立刻被 crash loop 了。更糟的是，Kompose 没有给任何 pod 设置资源限制（requests/limits），所以 scheduler 没法做合理的资源分配。我的建议是： Kompose 只能当做一个参考。你还是得手动写 YAML，理解每一行在干什么。第二个坑：资源限制这个"可选项"在 Docker Compose 里，我们从没想过资源限制是什么。容器想用多少内存就用多少。移到 Kubernetes 后，我设置了这样的资源定义：apiVersion: apps/v1 kind: Deployment metadata: name: api-server spec: replicas: 3 selector: matchLabels: app: api-server template: metadata: labels: app: api-server spec: containers: - name: api-server image: myregistry/api-server:v1.2.3 ports: - containerPort: 3000 resources: requests: memory: "128Mi" cpu: "100m" limits: memory: "256Mi" cpu: "500m"太保守了。一上生产，API 在突发流量下直接被 OOMKill 了。Kubernetes 发现 pod 用了超过 256MB 内存，就杀掉它重启。服务抖动得厉害。反过来，我又把 limits 设得很松（512Mi、1000m），结果一个有内存泄漏的服务逐渐吞掉了整个节点的资源，其他 pod 被无情地驱逐。现在我的做法是：先在本地或测试环境压测一遍，看真实的内存和 CPU 占用，然后 requests 设为实际用量的 1.2 倍左右，limits 设为 1.5-2 倍。这样既给了应用突发的空间，又能保护集群。对了，这里还有个坑：Kubernetes 有三个 QoS class——Guaranteed、Burstable、BestEffort。如果你设置了 requests 和 limits（且相等），pod 会被标记为 Guaranteed，在资源紧张时最后被驱逐。如果只设了 requests，就是 Burstable，被驱逐的优先级更高。理解这个很重要，尤其是你在跑有状态服务时。第三个坑：网络和存储不是"自动的"在 Docker Compose 里，容器之间通过服务名通信，自动有个 bridge network。迁到 K8s 后，我以为 DNS 名字可以一样用——结果踩坑了。Kubernetes 里，service 的 DNS 名字是 service-name.namespace.svc.cluster.local。我的 Node.js API 之前通过 postgres://db:5432 连接数据库（db 是 Compose 里的服务名）。换到 K8s 后，我试了各种办法，最后才意识到应该用 postgres://postgres-service.default.svc.cluster.local:5432。更复杂的是存储。Compose 里，我们用 volumes 来持久化 PostgreSQL 的数据：services: db: image: postgres:14 volumes: - db-data:/var/lib/postgresql/data volumes: db-data:这在 K8s 里变复杂了。单纯的 emptyDir 只能在 pod 重启时保留数据，node 重启就没了。我需要 PersistentVolume 和 PersistentVolumeClaim。如果用的是云服务（AWS、阿里云），还得配置存储类（StorageClass）。apiVersion: v1 kind: PersistentVolumeClaim metadata: name: postgres-pvc spec: accessModes: - ReadWriteOnce storageClassName: standard resources: requests: storage: 20Gi --- apiVersion: apps/v1 kind: StatefulSet metadata: name: postgres spec: serviceName: postgres-service replicas: 1 selector: matchLabels: app: postgres template: metadata: labels: app: postgres spec: containers: - name: postgres image: postgres:14 ports: - containerPort: 5432 name: postgres env: - name: POSTGRES_PASSWORD valueFrom: secretKeyRef: name: postgres-secret key: password volumeMounts: - name: postgres-storage mountPath: /var/lib/postgresql/data volumeClaimTemplates: - metadata: name: postgres-storage spec: accessModes: - ReadWriteOnce storageClassName: standard resources: requests: storage: 20Gi对了，我还有个坑没提——数据库密码。在 Compose 里，我们就直接在 docker-compose.yml 里写 POSTGRES_PASSWORD=mypassword。上了 K8s，这太不安全了。必须用 Secret。我专门花了半天时间学怎么用 Secret 和密钥管理。第四个坑：健康检查从"可有可无"到"必不可少"Compose 下，容器挂了就挂了，我们靠监控和告警来发现。K8s 不一样，它有 liveness probe 和 readiness probe。Readiness probe：检查容器是否准备好接收流量。失败的话，pod 从 service endpoints 里被移除，但不会重启。Liveness probe：检查容器是否还活着。失败的话，K8s 会重启这个 pod。我一开始没设置这两个，结果 API 服务有时候启动完成但还在初始化数据库连接池，请求打过来直接失败。后来我加了健康检查：apiVersion: apps/v1 kind: Deployment metadata: name: api-server spec: template: spec: containers: - name: api-server image: myregistry/api-server:v1.2.3 ports: - containerPort: 3000 livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 15 periodSeconds: 20 timeoutSeconds: 5 failureThreshold: 3 readinessProbe: httpGet: path: /ready port: 3000 initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 3 failureThreshold: 2这样 K8s 会等 15 秒让容器完全启动，然后定期检查。这个改动之后，我们的服务稳定性明显提升。第五个坑：环境变量和配置管理Compose 用 .env 文件，K8s 用 ConfigMap 和 Secret。听起来很简单，但实际迁移时坑很多。我之前在 .env 里定义了一大堆变量：NODE_ENV=production DB_HOST=db DB_PORT=5432 DB_USER=postgres LOG_LEVEL=info REDIS_URL=redis://redis:6379迁到 K8s，我把敏感信息（DB_USER、DB_PASSWORD）放进 Secret，其他的放进 ConfigMap：apiVersion: v1 kind: ConfigMap metadata: name: api-config data: NODE_ENV: production DB_HOST: postgres-service.default.svc.cluster.local DB_PORT: "5432" LOG_LEVEL: info REDIS_URL: redis://redis-service.default.svc.cluster.local:6379 --- apiVersion: v1 kind: Secret metadata: name: db-credentials type: Opaque stringData: DB_USER: postgres DB_PASSWORD: your-secret-password然后在 deployment 里引用：env: - name: NODE_ENV valueFrom: configMapKeyRef: name: api-config key: NODE_ENV - name: DB_USER valueFrom: secretKeyRef: name: db-credentials key: DB_USER - name: DB_PASSWORD valueFrom: secretKeyRef: name: db-credentials key: DB_PASSWORD这样管理起来更清晰，也更安全。第六个坑：发布流程和回滚Compose 下，发新版本就是：docker pull、docker-compose up -d。简单粗暴，但会有一瞬间的 downtime。K8s 的 Deployment 支持滚动更新（rolling update），默认配置下会一个一个替换 pod，保证服务不中断。但这需要你正确配置：apiVersion: apps/v1 kind: Deployment metadata: name: api-server spec: replicas: 3 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 template: # ...maxSurge: 1 表示最多可以有 1 个额外的 pod（总共 4 个）。maxUnavailable: 0 表示不能有 pod 不可用。这样更新过程中，始终有 3 个 pod 在处理请求。但我一开始没配这个，导致发布时有 pod 直接被杀掉替换，用户请求出现了短暂的 503。后来加了这个配置才解决。回滚也很方便。如果新版本有问题，一条命令就能回到上个版本：kubectl rollout undo deployment/api-server这比 Compose 下手动拉旧镜像方便多了。平滑过渡的方案我最后采用的迁移步骤是这样的：先在测试环境试水。用现有的 Compose 文件启动，然后手写 K8s YAML，部署到测试集群。逐个服务迁移。我们先迁了无状态的 API 服务，跑了一周没问题，再迁后台任务，最后才迁数据库。灰度发布。线上同时跑 Compose 和 K8s，用负载均衡器分流。验证 K8s 里的服务正常后再全量切换。保留回滚方案。K8s 的 Deployment 能自动回滚，但我还是在虚拟机上保留了 Compose 配置，以防万一。这样做虽然慢，但稳。我们最后花了三个月从 Compose 完全迁到 K8s，期间没有任何生产事故。最后的收获现在回头看，Compose 和 K8s 就像自行车和汽车的区别。自行车简单轻快，你可以很快上手。但当你需要跑长距离、经常爬坡、要拉货的时候，汽车就是更好的选择，虽然学习曲线陡一点。最关键的是要理解两者的差异，不要期待一键转换。花时间理解 K8s 的概念——Pod、Service、Deployment、PVC——然后一个一个解决迁移中遇到的坑。这样出来的系统才是稳定可靠的。如果你现在也在用 Compose，我的建议是：别等着有一天被逼迫升级。趁着系统还不太复杂，主动做这个迁移。一开始会累一点，但长期收益绝对值得。公众号：耕云躬行录个人博客：躬行笔记
线上CPU飙到100%？这套排查方法论建议收藏，关键时刻能救命
CPU异常飙升这个问题，说大不大说小不小，有时候就是一个死循环，有时候是一个隐藏很深的GC问题，有时候甚至是一行正则表达式搞的鬼。但不管根因是什么，排查思路其实是有套路的。今天我就把这几年在生产环境中积累的CPU排查经验整理出来，从告警接入到最终定位，全流程走一遍。先搞清楚：CPU到底在忙什么很多人收到CPU告警的第一反应是直接top看一眼哪个进程吃CPU，然后就去翻代码了。这个习惯不能说错，但效率不高。CPU使用率这个指标本身是个很粗粒度的东西，你得先搞清楚是哪部分在消耗CPU。Linux里top命令展示的那几个指标——us、sy、wa、si、hi，每一个都代表不同的含义。us（user）是用户态CPU时间，你的业务代码跑在用户态，如果us高，说明是业务代码本身在烧CPU。sy（system）是内核态CPU时间，如果sy异常高，可能是系统调用太频繁，或者内核处理中断太忙。wa（iowait）是等待IO的时间，严格来说wa高不代表CPU忙，而是CPU闲着没事干在等磁盘。si（softirq）是软中断，网络收发包、定时器这些都会触发软中断。我之前遇到过一个案例，top看CPU使用率80%多，第一反应是业务代码有问题，结果仔细一看sy占了60%多，us才十几。顺着sy高这条线往下查，发现是一个定时器间隔设得太小，500微秒触发一次，内核疯狂处理定时器中断。最后把定时器间隔改成1ms，CPU直接降了一半。所以拿到CPU告警，第一件事不是急着翻代码，而是搞清楚CPU时间花在哪了。排查工具箱：从粗到细的武器库2.1 top / htop —— 快速定位top命令大家都会用，但有几个小技巧值得说一下。# 按CPU排序，只看前20 top -bn1 | head -20 # 看某个具体进程的线程级CPU top -H -p <PID>-H这个参数很关键，它能展开到线程级别。有些Java应用你用top看进程CPU不高，但用top -H一看，某个线程CPU吃满了，这种情况下进程级别的数据是有欺骗性的。2.2 mpstat —— 各核CPU详情mpstat -P ALL 1 3这个命令能看到每个CPU核心的使用率，包括usr、sys、iowait、softirq等细分指标。如果发现某个核CPU特别高而其他核正常，那可能是单线程瓶颈或者中断都绑在一个核上。# 看软中断详情 mpstat -I SCPU 1 5 # 看硬中断详情 mpstat -I CPU 1 5这两个命令在排查网络中断、定时器中断类问题时特别有用。之前那个定时器的案例，就是通过mpstat -I SCPU发现HRTIMER软中断从正常的200次/秒飙升到2000多次/秒，才锁定了问题方向。2.3 pidstat —— 进程级CPU分析# 每秒输出一次，输出5次 pidstat 1 5 # 针对特定进程 pidstat -p <PID> 1 5pidstat比top好的地方在于它能持续输出，方便你观察CPU使用率的波动趋势。而且它还能区分usr和system时间，对判断问题方向很有帮助。2.4 strace —— 系统调用追踪当你发现某个进程sy（内核态）CPU高的时候，strace是直接有效的工具。# 追踪进程的所有系统调用，统计耗时 strace -c -p <PID> # 输出示例： # % time seconds usecs/call calls errors syscall # ------ ----------- ----------- --------- --------- ---------------- # 45.23 0.045123 45 1002 3 futex # 23.15 0.023115 23 998 read # 15.67 0.015670 31 500 write-c参数会统计每个系统调用的次数和耗时，一眼就能看出哪个系统调用在烧CPU。不过要注意，strace本身有性能开销，生产环境慎用，特别是高QPS的服务。2.5 perf —— 性能分析神器perf是Linux内核自带的性能分析工具，可以说是CPU排查的核心武器。# 实时查看CPU热点函数 sudo perf top -p <PID> # 采样30秒 sudo perf record -F 99 -g -p <PID> -- sleep 30 # 查看采样报告 sudo perf report --stdio -g fractal # 指令级分析某个热点函数 sudo perf annotate --symbol=<function_name>perf top适合快速看一下当前CPU在执行什么函数，perf record则是采样后做深度分析。关于采样频率-F 99，为什么不用100？因为100Hz容易和系统的定时器产生共振，导致采样偏差。99Hz是个经验值，Brendan Gregg推荐的。调用栈展开方式有三种：fp（帧指针，开销最小但需要编译时加-fno-omit-frame-pointer）、dwarf（不需要特殊编译参数但开销5-15%）、lbr（Intel硬件支持，零开销但调用栈深度有限）。生产环境我一般用lbr，开发环境用fp。2.6 火焰图 —— 可视化利器perf report的文本输出虽然信息量大，但看起来确实费劲。火焰图把这些信息可视化成一张SVG图，哪个函数吃CPU一目了然。# 生成火焰图的三步走 sudo perf record -F 99 -g -p <PID> -- sleep 30 sudo perf script > perf.txt ./stackcollapse-perf.pl perf.txt > folded.txt ./flamegraph.pl folded.txt > flame.svg火焰图怎么看？横轴是采样比例（可以理解为CPU时间占比），纵轴是调用栈。越宽的函数占用的CPU时间越多。找到最宽的那个"平顶"，沿着调用栈往下找，就能定位到是哪段代码在烧CPU。火焰图的配色也有讲究。标准的CPU火焰图用暖色调（红橙黄），Off-CPU火焰图用冷色调（蓝绿），这样一眼就能区分是CPU繁忙还是IO等待。三、不同语言的不同套路3.1 Java应用Java应用的CPU排查有自己的特点，因为JVM本身就是个复杂的运行时。# 第一步：找到CPU高的Java进程 top -bn1 | grep java # 第二步：找到CPU高的线程 top -H -p <PID> # 第三步：把线程ID转成16进制 printf "%x\n" <TID> # 第四步：用jstack抓线程栈 jstack <PID> | grep -A 30 <十六进制TID>这套组合拳是Java CPU排查的标准流程。拿到线程栈之后，看这个线程在干什么，是执行业务代码、GC、还是锁等待。如果是GC导致的CPU高，jstat -gcutil <PID> 1000能看GC频率和各代内存使用情况。Full GC频繁的话，CPU不飙才怪。还有个工具叫async-profiler，比jstack好用很多，能直接生成火焰图，强烈推荐。# async-profiler生成火焰图 ./profiler.sh -d 30 -f flame.html <PID>3.2 Go应用Go的CPU分析用pprof就够了。# 如果应用已经集成了pprof go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 # 在pprof交互界面 (pprof) top 10 # 看CPU消耗最多的10个函数 (pprof) web # 生成调用图 (pprof) list <func> # 看某个函数的代码级耗时Go的pprof还可以用-http参数启动一个web界面，直接在浏览器里看火焰图，体验很好。3.3 Python应用Python应用可以用cProfile或者py-spy。# py-spy生成火焰图，不需要修改代码 py-spy record -o flame.svg --pid <PID>py-spy的好处是不需要侵入代码，直接attach到进程上就行，生产环境也能用。几个真实案例案例一：正则表达式导致的CPU 100%这个案例印象特别深。一个风控服务，平时CPU也就20%左右，某天突然飙到100%。top看是Java进程，jstack抓线程栈发现一个线程卡在Pattern.matcher上。代码里有一段正则校验逻辑，用来校验用户输入的邮箱格式。正常输入没问题，但有个用户输入了一串超长的特殊字符，触发了正则表达式的回溯灾难（ReDoS）。那个正则写得有问题，存在嵌套量词，面对特定输入时复杂度变成指数级。修复方案很简单，换一个不存在回溯问题的正则表达式，或者限制输入长度。但这个问题排查过程花了不少时间，因为谁也没想到一个正则能把CPU干满。案例二：死循环导致的CPU飙升这个比较经典。一个数据处理服务，某天CPU持续100%。perf采样后生成火焰图，发现CPU全耗在一个while(true)循环里。代码逻辑大概是这样的：从队列里取任务，处理完继续取。正常情况下队列有数据就处理，没数据就阻塞等待。但某次代码改动，把阻塞取改成了非阻塞取，队列空的时候也不sleep，直接疯狂空转。// 问题代码 while (true) { Task task = queue.poll(); // 非阻塞，队列空返回null if (task != null) { process(task); } // 没有else分支，队列空时CPU空转 } // 修复后 while (true) { Task task = queue.poll(1, TimeUnit.SECONDS); // 阻塞等待1秒 if (task != null) { process(task); } }这种问题在代码review时其实很容易发现，但在生产环境排查时，如果没有火焰图，光看top和日志很难定位。案例三：Full GC导致的CPU飙升一个Spring Boot应用，CPU周期性飙升，每隔大概30秒就飙一次然后降下来。jstat看GC情况，发现每隔30秒就有一次Full GC，每次Full GC耗时2-3秒，这期间CPU直接拉满。进一步排查发现是老年代空间设置太小了，大对象直接进老年代，很快就触发Full GC。调大堆内存和新生代比例后，Full GC频率降到了几分钟一次，CPU恢复正常。GC导致的CPU问题有个特征：CPU是周期性波动的，不是持续高。看到这种模式，第一时间查GC。案例四：锁争用导致的多核满载一个多线程数据处理服务，从2线程扩展到8线程后CPU接近满载，但吞吐量只增加了15%。火焰图一看，45%的CPU时间花在pthread_mutex_lock上。代码里一个全局锁保护了共享数据，但临界区包含了耗时200ms的数据处理操作，8个线程基本串行执行。修复方案是把耗时操作移到锁外面，临界区只保护共享数据的读写。修复后CPU降到290%，吞吐量提升了5倍多。锁争用在火焰图上的特征很明显——你会看到大量的__lll_lock_wait或futex_wait，而且这些函数的宽度占比很高。排查流程总结把上面的内容梳理一下，CPU异常飙升的排查流程大概是这样的：第一步：确认问题范围。 用top和mpstat看是系统级CPU高还是单个进程高，是us高还是sy高。这决定了你后续的排查方向。第二步：定位到进程和线程。 top -H或pidstat找到CPU消耗最高的线程。第三步：分析线程在干什么。 根据语言选择合适的工具——Java用jstack/async-profiler，Go用pprof，C/C++用perf，Python用py-spy。第四步：生成火焰图。 把采样数据可视化，找到最宽的那个函数调用栈。第五步：结合代码分析。 拿着火焰图上的热点函数名去找代码，分析为什么这个函数会消耗这么多CPU。第六步：修复并验证。 改完代码后重新采样，用差分火焰图对比优化效果。整个过程中，最关键的是不要跳步。很多人拿到CPU告警直接就去翻代码了，没有先做数据收集和分析，结果翻半天也找不到问题。先收集数据，让数据告诉你方向，这比凭直觉猜要高效得多。还有一点想强调的是，生产环境采样要注意控制影响。perf用LBR模式零开销，采样30秒就够了。不要在生产环境跑strace，不要用高频率采样，采样完把perf.data传到开发机分析，别在生产服务器上生成火焰图。CPU排查这事儿，工具会用是基础，关键是思路要对。先定位方向再深入分析，一步一步缩小范围，再隐蔽的问题也能揪出来。如果觉得这篇文章对你有帮助，欢迎转发给身边的同事，说不定哪天就用上了。公众号：耕云躬行录个人博客：躬行笔记
AWS 出了个桌面端 AI 助手，用了两周之后我觉得它可能会改变我的工作方式
前阵子 AWS 在 2026 年的 What's Next 大会上发布了 Amazon Quick，一个 AI 工作助手。说实话刚看到新闻的时候我没太当回事——又是一个 ChatGPT 套壳？但后来发现它有个桌面端应用 Amazon Quick Desktop，能直接访问本地文件、连接 Slack 和邮箱、还能跑定时任务和浏览器自动化，这就有意思了。我申请了预览版用了两周多，今天把使用体验和一些实际应用场景整理出来。不吹不黑，确实有些地方设计得挺聪明的，也有些地方还比较粗糙。它到底是个什么东西官网：https://quick.aws.com/sn现在凭邮箱注册免费试用plus版本一个月哦**简单说，Amazon Quick Desktop 是一个跑在你电脑上的原生应用（支持 macOS 和 Windows），核心是一个 AI 对话界面，但它不只是聊天。它能读写你的本地文件、连接你的 Slack/Outlook/Gmail、跑后台定时 Agent、操控浏览器、还有一套知识图谱系统。跟网页版 Amazon Quick 最大的区别在于：桌面端采用的是 local-first 架构。AI 后端跑在本地，你的文件不会上传到云端。唯一的网络请求是调 AI 模型的 API 和连接你授权的第三方服务。这一点对企业用户来说很关键——数据不出本机。系统要求不算高：macOS 要 Apple Silicon（M1 及以上），Windows 要 64 位 Win10 以上，内存最低 8GB。磁盘空间安装本体只要 500MB，但如果你要用搜索索引和知识图谱功能，建议预留 10GB 以上。安装和登录下载方式有两种：一种是从 Amazon Quick 网页版的 Extensions 页面下载，另一种直接用链接：macOS：https://desktop.downloads.quick.aws.com/mac/arm64/Amazon-Quick.dmgWindows：https://desktop.downloads.quick.aws.com/windows/x64/Amazon-Quick.exemacOS 装完第一次打开大概率会弹安全提示，去系统设置 → 隐私与安全性里点一下"仍然打开"就行。Windows 上 SmartScreen 也会拦一下，点"更多信息"然后"仍然运行"。登录支持两种模式：个人用户用 Free/Plus 账号登录（支持邮箱、Amazon、Apple、Google、GitHub），企业用户走 Enterprise login 通过公司的 IdP 认证。本地文件访问——这个是真正好用的地方装完之后第一件事就是去 Settings → My Computer → Local Folders 添加你要让 Quick 访问的文件夹。注意是你主动授权的文件夹，不是它想访问哪就访问哪。加完文件夹之后，每个文件夹可以独立配置三种索引模式：索引类型干什么的Keyword search建关键词索引，做全文搜索用Semantic search建语义向量索引，按意思搜而不只是关键词匹配Knowledge graph extraction从文件里提取人名、项目、事件等实体关系这三个可以独立开关。我的建议是常用的项目文档文件夹三个全开，临时文件夹只开 Keyword 就行——语义索引和知识图谱会占不少磁盘空间。索引建好之后，你跟 Quick 聊天的时候直接问"帮我找一下上次那个 Terraform 模块的配置文件在哪"，它就能从你本地文件里搜出来并且给你分析内容。不需要先上传。还有个细节：索引有存储上限和单文件大小上限的设置，硬盘剩余空间低于 8GB 的时候索引会自动暂停。这个设计挺合理的，不会把你磁盘撑爆。连接外部服务Settings → Capabilities → Connections 里可以连各种外部服务。内置支持的挺全的：Slack、Microsoft Outlook、Microsoft Teams、Gmail、Google Calendar、Google Meet、OneDrive、SharePoint、Google Drive、Google Docs/Sheets/Slides、Dropbox、Google Analytics、Airtable、QuickBooks、Zoom、Zapier……连接方式就是点 Sign in 然后走 OAuth 授权流程。连好之后 Quick 就能读取你这些服务里的信息了。我连了 Slack 和 Gmail 之后做了个测试——问它“帮我看看最新的邮件”。这比自己去翻方便太多了。AI 模型和对话聊天界面底部有个模型选择器，四种模式：Fast：最便宜最快，适合简单问题Balanced：平衡模式，日常够用Smart：最强模型，适合复杂分析还有个 Thinking Effort 的设置（Off / Low / Med / High / Max），控制模型在回答前"思考"多深。Max 只有 Smart 模式下才能用。说实话，日常用 Balanced 就够了。Smart 模式用来写长文、做复杂数据分析或者需要精确推理的时候再切过去。对话历史保存在本地，支持文件夹分类、置顶、搜索。还有个 Thread routing 功能，可以在一个对话里开子线程，讨论不同话题的时候不会混在一起。定时 Agent——这个很有想象空间这是我最喜欢的功能之一。你可以创建定时运行的 Agent，让它按固定频率帮你干活。举几个我实际在用的场景：场景一：每天早上汇总 Slack 消息我设了一个 Agent 每天早上 9 点跑一次，帮我扫描几个重要 Slack 频道，把过去 12 小时的讨论汇总成摘要推送给我。这样我不用一上班就花半小时翻消息。场景二：监控告警频道设了一个 Agent 每 30 分钟检查一次运维告警频道，如果有 critical 级别的告警就弹桌面通知。这样就算我没盯着告警群也不会漏掉重要信息。场景三：周报自动生成每周五下午 4 点跑一个 Agent，从我这周的 Slack 消息、邮件、日历事件里提取关键信息，自动生成一份周报草稿。我再花 10 分钟改改就能发出去。Agent 跑完之后结果会推送到 Activity Feed（一个统一的通知流），也可以弹桌面通知。这些 Agent 都是在你本地跑的，不需要服务器。MCP 扩展——给 Quick 接上你自己的工具链MCP（Model Context Protocol）是个开放协议，可以让 AI 助手连接外部工具和数据源。Quick Desktop 原生支持 MCP，这意味着你可以把自己的数据库、内部 API、开发工具全接进来。三种接入方式：Local：在你本地跑一个 MCP Server。最常见的玩法，比如：Name: AWS Pricing Command: npx Arguments: -y @awslabs/aws-pricing-mcp-serverImport：从已有的配置文件导入。如果你已经在 Kiro、Claude Code 或者 AIM 里配好了 MCP Server，可以直接导入配置文件，不用重新配一遍。Remote：连接远程 MCP Server。给一个 URL 和 Token 就行。适合团队共享的 MCP 服务。我接了AWS官方知识库和官方文档mcp服务器，现在直接在 Quick 里问"根据官方文档，rds如何配置蓝绿部署"就能查到。之前得登官网文档网页去翻，现在一句话搞定。浏览器自动化这个功能有点酷。Quick 可以启动 Chrome 帮你操作网页——点击、输入、截图、提取数据都行。两种模式：Default 模式：启动一个独立的 Chrome 实例，会拷贝你的 profile（保留登录状态）但隔离运行Use my Chrome 模式：直接连你正在运行的 Chrome，用你的 cookies 和插件实际场景举个例子：我让它帮我每天去某个内部平台截个数据报表的图发到群里。之前这个活是手动的——打开浏览器、登录、点到报表页面、截图、发消息。现在一个 Agent 搞定。还有一个场景：帮我填一些重复的内部系统表单。比如每次上线都要去某个审批系统填工单，字段都差不多，让 Quick 用浏览器自动化帮我填。省了不少无脑操作的时间。知识图谱Quick 会自动从你连接的 Slack、邮箱、日历、本地文件里提取实体和关系，构建一个个人知识图谱。实体类型包括：Person、Customer、Channel、Event、Project、Action、Product、Defined Term 等。在 Settings → My Context → Knowledge Graph 里可以看到一个交互式的图谱可视化界面。节点大小用 PageRank 算法来定——连接越多的实体节点越大。这个东西的实际用处是什么呢？就是当你问 Quick 一个问题的时候，它不只是搜文本，还会利用实体关系来理解上下文。比如你问"Project Meridian 最近有什么进展"，它能通过知识图谱关联到相关的人、会议记录、Slack 讨论，给你一个更全面的回答。知识图谱的数据全部存在本地，不上传。支持从 Slack、Email、本地文件夹分别开关自动提取。权限控制——这个做得比较细致每个系统工具（Web Search、File Operations、Browser Automation、Image Generation 等）都可以单独设置权限级别：级别含义Full Access读写都放开，不用确认Read Only只能读不能改Ask Each Time每次操作都问你而且支持到具体操作粒度。比如 Browser Automation，你可以设成整体 Full Access，但"Run JavaScript"这一项单独设成 Ask Each Time。这样日常浏览操作不打扰你，但要在页面里执行 JS 的时候先问一声。对安全敏感的场景来说这个设计很有必要。Skills 系统Quick Desktop 有一套 Skill 系统，类似插件。内置了 19 个技能：文档生成（Word/PDF/PPT/Excel/Markdown）、浏览器自动化、图片生成、深度分析、知识图谱管理、定时 Agent 管理、音视频转录等。比较有意思的是你还能自己创建 Skill。两种方式：一种是用 AI 帮你生成（描述你要什么，它自动生成 SKILL.md 文件），另一种是手动上传 SKILL.md。Skill 不只是一个提示词模板，它可以包含详细的分步工作流、验证条件、失败处理逻辑，还能绑定工具和参考文件。比如你写了个 Skill 专门用来做线上故障排查，里面定义了先查监控、再查日志、然后定位根因的完整流程，Quick 会按这个流程来走。还有个"Skill Authoring"功能——当你在聊天里完成了一个复杂任务之后，Quick 会问你要不要把这个工作流保存成 Skill。下次遇到类似任务直接调用就行。这个设计挺聪明的，相当于把你的工作经验沉淀下来了。语音交互支持两种语音模式：Dictation（语音转文字，转完你可以编辑再发送）和 Talkback（语音对话，Quick 会朗读回复）。Talkback 模式还有个 Live mode——开启之后麦克风一直开着，你说三个词以上就能打断它的朗读，像真人对话一样。说实话我不怎么用语音，但在做饭或者走路的时候偶尔用一下 Talkback 问个问题还是挺方便的。我的实际使用感受用了两周下来，最高频的使用场景大概是这几个：找文件和分析文档——不用记文件在哪，直接问就行Slack/邮件摘要——早上看一眼 Activity Feed 就知道昨晚发生了什么写文档——让它帮我起草各种文档，格式都支持查 AWS 文档——接了 AWS 文档的 MCP Server 之后，查文档比自己去翻方便太多自动化重复工作——浏览器操作、定时任务、格式转换之类这是quick生成的《AWS迁移与现代化-通用信息收集模板》，很不错！！！不太满意的地方：预览版偶尔会有 bug，模型响应慢的时候等得有点烦，知识图谱的提取准确度还有提升空间。另外只支持 Apple Silicon 不支持 Intel Mac，有些老机器用不了。总体来说，如果你日常工作涉及大量信息处理（消息、邮件、文档），而且对数据隐私有要求（不想把文件传到云端），Quick Desktop 目前是一个值得尝试的工具。它不是那种"用一次就忘了"的东西，而是那种用习惯了之后回不去的类型。现在还是预览版，免费和付费账号都能用。感兴趣的可以去下载试试，反正不花钱。等正式版出了再看看定价策略。觉得有用就转发一下，这种一手体验的信息还是比较稀缺的。公众号：耕云躬行录个人博客：躬行笔记
生产环境 Prometheus 告警从一天 500 条到只剩 30 条，我做了这些事
上个月我负责的业务线出了一次不大不小的事故，事后复盘发现一个很尴尬的事情：告警其实提前 20 分钟就触发了，但当时值班的同事压根没看到。为什么？因为那天光钉钉群里的告警就有 400 多条，绝大部分是无意义的抖动和重复，大家早就免疫了。这事之后我花了两周把告警体系重新治理了一遍，从每天 500 条干到了 30 条左右。这 30 条里没有一条是废的。整个过程记录一下。动手之前先做了个统计。Alertmanager 有 API，直接用脚本把过去两周的告警 dump 下来，Python 做个分类。结果挺触目惊心的：CPU/内存瞬时抖动触发的：45%同一个故障不同实例重复报的：25%刚恢复又触发来回抖的：15%真正需要人处理的：15%85% 是噪音。这还算好的，我见过有些团队 95% 以上都是噪音。问题集中在几个地方。一个一个说。最容易改也最有效的就是 for 持续时间。很多人写告警规则 for 要么不写，要么就 1m，结果任何毛刺都会触发。原来的 CPU 告警长这样：- alert: HighCPUUsage expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[2m])) * 100) > 80 for: 1m labels: severity: warning annotations: summary: "CPU使用率超过80%"问题是 for: 1m 太短了。线上服务流量高峰 CPU 蹦到 85% 很正常，过几十秒就下来了，这种报警没有意义。改成：- alert: HighCPUUsage expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80 for: 5m labels: severity: warning annotations: summary: "{{ $labels.instance }} CPU持续5分钟超过80%"rate 窗口从 2m 拉到 5m，计算结果更平滑。for 从 1m 改 5m，得持续超才报。就这一个改动，CPU 相关告警直接少了 70%。不过不是所有告警都能加长 for。磁盘空间这种变化慢的，for 可以长；5xx 错误率飙升的，for 就不能太长，不然等你收到告警用户早骂了半天了。我大概是这么分的：资源类（CPU/内存/磁盘）5-10m，网络类 3-5m，业务类（5xx/SLA）1-2m，基础设施宕机 30s-1m。然后是 group_by。解决"同一个问题报 N 遍"的问题。Redis 集群挂了，依赖它的 50 个服务实例全报"连接超时"。不做合并就是 50 条基本一样的消息刷屏。route: receiver: 'default' group_by: ['alertname', 'cluster', 'service'] group_wait: 30s group_interval: 5m repeat_interval: 4hgroup_wait: 30s 是收到第一条告警后等 30 秒，把这段时间内同组的合一起发。设太短合并效果不好，设太长收到告警就晚了。repeat_interval 我建议别设太短。之前有同事设了 30 分钟，一个没人处理的告警一天发了 48 次。改成 4h，critical 的 1h，既提醒又不烦。接下来是抑制（Inhibition），这个功能很多人不知道，但真的好用。场景：宿主机挂了，上面 20 个容器全触发"容器不可达"。但根因是宿主机，容器不可达只是衍生问题，不需要单独报。inhibit_rules: - source_match: severity: 'critical' alertname: 'NodeDown' target_match: severity: 'warning' equal: ['instance']当 NodeDown 这个 critical 告警存在时，同 instance 的 warning 全部抑制。还加了一条：整个集群网络不通时，抑制掉集群内所有服务的健康检查失败：inhibit_rules: - source_match: alertname: 'ClusterNetworkPartition' target_match_re: alertname: 'ServiceHealthCheckFailed|PodCrashLooping|EndpointDown' equal: ['cluster']遇到大面积故障的时候告警从上百条压缩到个位数，值班人员可以迅速看到根因在哪。这个在去年双十一前的压测里帮了大忙，模拟了一次网络分区的情况，如果没做抑制，光告警就够喝一壶的。告警分级这个事说起来简单，做好了效果很明显。我们分三级：critical：服务完全不可用或数据丢失风险。打电话 + 短信 + 钉钉warning：有问题但服务还在跑。钉钉 + 邮件info：仅供参考。只发邮件摘要路由配置：route: receiver: 'default-email' group_by: ['alertname', 'service'] group_wait: 30s routes: - match: severity: critical receiver: 'pager-duty' repeat_interval: 1h continue: false - match: severity: warning receiver: 'dingtalk-oncall' repeat_interval: 4h continue: false - match: severity: info receiver: 'email-digest' group_wait: 10m repeat_interval: 12h还有时间段策略。非工作时间只有 critical 打电话，warning 攒到第二天早上：time_intervals: - name: 'offhours' time_intervals: - weekdays: ['saturday', 'sunday'] - times: - start_time: '00:00' end_time: '09:00' - times: - start_time: '18:00' end_time: '24:00'warning 在 offhours 期间静默，critical 不受影响。周末终于能安心睡觉了，之前三天两头半夜被 warning 吵醒真的崩溃。Grafana Alerting 我们也用了，主要做两类事情。一是多条件联合判断，比如"CPU 超过 80% 且 QPS 下降 30%"。在 PromQL 里写起来很别扭，Grafana 里可以用 Multi-dimensional alert 很方便地做。二是基于 Loki 日志的告警。检测到"OOM Killed"、"panic"、"fatal"这些关键词就报警。这种用 Prometheus 本身没法做，得靠 Grafana 查 Loki 数据源来实现。有个坑：Grafana Alerting 和 Alertmanager 的告警别混用同一个通知渠道。我们的做法是在钉钉消息模板里标注 [Grafana] 还是 [Prometheus] 来源，不然接到告警都不知道去哪里看。最后一个不是技术活但很重要的事：定期 Review。告警规则不是写完就不管了。业务在变架构在变，之前合理的阈值过段时间可能就不合理了。我们每两周做一次，半小时搞定，主要看：触发频率最高的 Top 10，逐一分析是否需要调阈值或者删掉触发后没人响应的告警——说明大家觉得不重要，那就降级或删新上线的服务有没有漏加告警误报率坚持做两三个月之后你会发现告警质量越来越高。还有个实用的做法：告警 annotation 里加 runbook_url。如果一条告警连处理手册都没有，那它存在的必要性就值得质疑。要么补手册，要么删告警。annotations: summary: "磁盘使用率超过85%" runbook_url: "https://wiki.internal/runbooks/disk-usage-high" description: "实例 {{ $labels.instance }} 磁盘 {{ $labels.mountpoint }} 使用率 {{ $value }}%"对了，消息模板也值得花点时间搞。区别有多大你感受一下：差的：HighCPUUsage - FIRING好的：🔥 [CRITICAL] 生产环境-订单服务 CPU持续10分钟超过90%（当前值：93.5%） 实例：10.0.3.47:9100 影响范围：订单创建 P99 延迟从 200ms 升至 1.2s 处理手册：https://wiki.internal/runbooks/order-svc-cpu-high一眼就能知道出了什么问题、在哪里、严重程度、怎么处理。别小看这个细节，凌晨两点被叫醒看到第一种消息和第二种消息，心理状态完全不一样。记得设 send_resolved: true，告警恢复了也通知一下，省得值班人还以为问题在持续。最后说一下效果。优化前日均 500 条告警，噪音率 85%，平均响应时间 15 分钟以上，MTTR 大概 45 分钟。优化后日均 30 条，噪音率低于 5%，响应时间 3 分钟，MTTR 缩短到 20 分钟。最直观的变化是现在群里一有告警大家会真的去看，因为知道既然报了就是有问题。之前那种一天到晚响个不停、所有人都当没看见的状态没了。说白了核心就一件事：把噪音干掉，让每一条告警都值得被认真对待。技术上没什么高深的，都是 Prometheus 和 Alertmanager 本身的能力，关键是得有意识去做这个事情。监控系统搭完不是终点，告警治理才是真正影响你能不能睡好觉的东西。觉得有用的话转发给你的同事看看，这些都是线上踩出来的坑，能帮一个是一个。公众号：耕云躬行录个人博客：躬行笔记
Linux 7.1 来了：新 NTFS 驱动、干掉 i486、FRED 默认开启，这次更新有点东西
上周末 Linus 把 7.1 推出来了。说实话，一般 .1 版本我不太会单独写文章聊，但这次翻了一下 changelog，发现几个改动还挺有意思的，尤其是搞存储和运维的兄弟可能会比较关注。先说最让我兴奋的——新的 NTFS 驱动终于合进主线了。NTFS 驱动重写：不只是"能写了"这么简单做过双系统或者处理过 Windows 磁盘数据迁移的应该都知道，Linux 下挂 NTFS 一直是个半残废状态。内核自带的老 NTFS 驱动只能读，想写得靠 ntfs-3g。那个 FUSE 方案用过的都懂——每次 I/O 都要在用户态和内核态之间来回切换，大文件拷贝的时候 CPU 占用高得离谱，实际吞吐量也就机械硬盘的水平，就算你底下是 NVMe 也白搭。这次 7.1 合进来的新驱动是 Namjae Jeon 搞了四年的成果。不是在 NTFS3 上打补丁，是基于现代内核基础设施重新实现的。技术架构上有几个关键变化值得展开说：第一，用 iomap 取代 buffer_head。这个变化对熟悉内核文件系统开发的人来说意义很大。buffer_head 是 Linux 内核里非常古老的 I/O 抽象层，每个 block 对应一个 buffer_head 结构体，管理脏页、回写、映射关系。问题是这套东西太重了——一个 4KB 的页面对应一个 buffer_head，一个大文件可能产生成千上万个 buffer_head 链表节点，内存开销和遍历开销都很可观。iomap 是现代替代方案。它不再按 block 粒度管理，而是按 extent（连续区间）来做映射。文件系统只需要告诉 iomap 层"这段文件偏移对应磁盘上哪段连续区域"，后面的 buffered I/O、direct I/O、writeback 全都由 iomap 框架统一处理。XFS 最早用这套，后来 ext4 也在逐步迁移。对于 NTFS 驱动来说，换到 iomap 意味着：大文件顺序读写性能显著提升（减少了元数据管理开销）内存占用更低（不用维护海量 buffer_head 链表）writeback 路径更短、更干净第二，folio 转换。folio 是 5.16 开始引入的页面管理抽象，用来替代 struct page。简单说就是把 compound page（多个物理页组成的大页）当作第一公民来处理，而不是每次都要判断"这个 page 是不是某个 compound page 的尾页"。新 NTFS 驱动原生支持 folio，意味着在大页（huge page）场景下效率更高，pagecache 管理也更现代化。内核文档里已经明确说了：buffer_head is deprecated, new filesystems should use iomap instead。这个新驱动从设计上就走了正确的路。第三，实际测试数据。新驱动通过了 326 个 xfstests 用例，之前的 NTFS3 是 273 个。差的这 53 个用例覆盖了 fallocate、idmapped mounts、权限控制等场景，都是生产环境里真会遇到的东西。# 新驱动的 Kconfig 选项 CONFIG_NTFS_FS=m # NTFS3 暂时还保留着，但内核文档已经指向新驱动 # 未来 NTFS3 大概率会被标记 deprecated # 配套的用户态工具 # ntfsprogs-plus 提供了 fsck.ntfs 等 # 之前 ntfs-3g 那套工具依赖 FUSE，新方案不需要还有个小插曲——Linus 在合并的时候因为 Git 树的结构问题把代码 un-pull 了一次。具体原因是 pull request 的 commit 组织方式不对，Linus 要求重新 rebase 后才接受。他给的评价是 "ntfs resurrection"，复活。这个词用得确实到位。对运维的实际影响：如果你的工作涉及 Windows/Linux 混合存储环境——比如跨平台的 NAS、双系统数据盘、或者从 Windows 服务器迁移数据——升级到 7.1 之后可以直接 mount NTFS 分区读写，不需要再装 ntfs-3g，性能和稳定性都有本质提升。Intel FRED：四十年来最大的中断处理改革FRED 全称 Flexible Return and Event Delivery。7.1 之前需要手动加 fred=on 启动参数，7.1 开始默认开启。先说这东西解决什么问题。x86 的中断处理机制依赖一个叫 IDT（Interrupt Descriptor Table）的结构，这玩意儿是 80286 时代设计的，距今 40 多年了。整个流程大概是这样：CPU 收到中断/异常 → 查 IDT 找到对应的 handler 地址 → 根据 DPL/CPL 判断是否需要切换特权级 → 如果要切换，从 TSS 里取新的 SS:RSP → 压栈：SS, RSP, RFLAGS, CS, RIP（可能还有 error code） → 跳到 handler → handler 处理完后 IRET 返回 → IRET 反向恢复所有寄存器和栈看起来清晰，但实际写过内核中断处理代码的都知道这里面坑有多深：IRET 指令不是原子的。 它恢复 SS 和 RSP 的时候如果中间又来了中断，状态可能不一致。内核为了规避这个问题写了大量的 workaround 代码。ring 切换的开销。 从 ring 3 到 ring 0 的 transition 要读 TSS、切栈、压一堆寄存器，每次中断都来这么一套。特权级有四个（ring 0-3），但实际只用了两个。 ring 1 和 ring 2 几乎没有 OS 在用，但 IDT 的设计要兼顾所有情况，增加了无谓的复杂度。缺少对嵌套中断的良好支持。 NMI 嵌套、#MC 嵌套等场景需要各种 IST（Interrupt Stack Table）hack。FRED 怎么解决这些问题？它把事件分成两种入口：ring 0（内核态事件）和 ring 3（用户态事件），各自有独立的处理路径。核心改变：用 ERETS/ERETU 替代 IRET。 ERETS 用于内核态返回（supervisor），ERETU 用于向用户态返回。两条指令语义清晰，不需要 IRET 那样靠栈内容来判断返回目标。事件入口固定，不再查 IDT。 CPU 直接根据事件类型和当前特权级跳到预设地址，减少了一次内存读取。栈帧格式统一。 所有事件共用一个标准化的栈帧布局，包含 event type、error code、原始 CS/RIP/RFLAGS/SS/RSP，不用再 case-by-case 处理。原子性保证。 FRED 的 transition 是单指令完成的原子操作，不存在中间状态被打断的问题。AMD 也宣布 Zen 6 会支持 FRED。Linus 在邮件列表里公开表态支持 FRED 方案，说它是 "a more complete clean-room solution that gets rid of legacy cruft entirely"。对性能的影响主要体现在：I/O 密集型场景：每秒中断次数多的负载（高频网络包、存储 IOPS）受益最大虚拟化：VM exit/entry 涉及大量 ring transition，FRED 减少了每次 transition 的 cycle 数实时性：中断延迟更低且更可预测# 验证 FRED 是否生效 dmesg | grep -i "fred" # 预期输出类似： # x86/fred: FRED enabled # 如果你的 CPU 不支持 FRED，这行不会出现 # 目前只有 Intel Panther Lake / Arrow Lake 以及未来的 AMD Zen 6 支持 # 如果想关闭（比如怀疑兼容性问题） # 内核启动参数加 fred=offPhoronix 在 Panther Lake 上的测试表明，FRED 开启后 I/O 密集型 benchmark（fio randread/randwrite、nginx 高并发）有单位数百分比的性能提升。看起来不多，但考虑到这是"免费的"——不需要改任何应用层代码，只是升了个内核——还是很香的。告别 i486：一个 37 年的句号7.1 正式移除了 i486 的构建支持。M486、M486SX、MELAN 这几个 Kconfig 选项被删除，你没法再编译出一个能在 486 上跑的内核了。这是继 2012 年砍掉 i386 支持之后，Linux 第二次移除一个主要的 x86 子架构。Linus 的态度很明确："zero real reason to continue support"。Ingo Molnar 在去年 4 月提出这个 patch 的时候给的理由也很充分——为了兼容 486，内核里有一些模拟指令和条件编译路径，比如 486 不支持的某些 CPUID 指令需要用软件模拟、缺少 CMPXCHG8B 的 fallback 实现等等。这些代码不仅占空间，还会偶尔引入难以复现的 bug。// 以前内核里这类代码到处都是 #ifdef CONFIG_M486 /* i486 doesn't support this, use software fallback */ ... #endif // 现在可以全部干掉了实际影响对绝大多数人为零。但从代码卫生的角度，每砍掉一块死代码意味着：减少编译时的条件分支（CI 测试矩阵变小）原子操作相关的代码可以直接用 CMPXCHG8B/CMPXCHG16B，不用 fallbacklock prefix 相关的优化可以更激进复古玩家不用慌，LTS 内核照样跑，只是主线不管了。QAT Zstd 硬件卸载：日志压缩场景的福音这个改动比较细但对特定场景非常有用。Intel QAT（QuickAssist Technology）是集成在 Xeon 里的硬件加速器，主要做两件事：加解密和压缩解压缩。之前内核里的 QAT 驱动只支持 deflate 算法，7.1 加了 Zstd 支持。具体来说：Gen4/Gen5 QAT（4th/5th Gen Xeon）：基本的 Zstd 压缩卸载Gen6 QAT（Diamond Rapids 平台）：原生 Zstd 实现，覆盖压缩和解压缩为什么这个有用？举个真实场景：假设你有一个日志收集管道 —— 几百台机器的日志通过 Filebeat 采集，中间经过 Kafka，最终写入对象存储或者 Elasticsearch。日志在传输和存储前通常会做压缩。如果用 CPU 做 Zstd 压缩，假设单核 Zstd level 3 能跑到 400-500 MB/s，听起来不错，但当你有几十个 TB 日志要处理的时候，压缩这一步就得吃掉好几个核。QAT 硬件卸载的意思是把压缩操作丢给专用加速器，CPU 核心完全不参与。根据 Intel 的数据，QAT 在 deflate 场景下能释放 43% 的 CPU 占用。Zstd 场景虽然官方还没有公开 benchmark，但原理一样。# 检查系统是否有 QAT 设备 lspci | grep -i "quickassist\|qat" # 预期看到类似： # 76:00.0 Co-processor: Intel Corporation Device 4944 # 内核模块 modprobe intel_qat modprobe qat_4xxx # Gen4 # 验证设备就绪 cat /sys/bus/pci/drivers/4xxx/*/qat/state # 预期输出: up对运维的建议：如果你管的机器是 4th Gen Xeon 以上，而且有大量压缩需求（日志、备份、数据传输），可以调研一下 QAT 加速。虽然 7.1 目前只是加了内核层面的支持，应用层（比如 zstd 命令行工具或者各种框架的 codec）还需要适配 QAT plugin 才能真正用上，但路已经铺好了。其他运维相关改动速览AMD amd-pstate 驱动：加了三个新特性——CPPC Performance Priority 允许给不同核设不同的优先级；Dynamic EPP 可以根据负载动态调整能效策略；Raw EPP 给你直接写寄存器的能力。如果你在 EPYC 上跑混合负载（比如同一台物理机上既有延迟敏感的数据库又有吞吐型批处理），这些提供了更细粒度的调控手段。ARM32 RT 内核：32 位 ARM 现在可以不打 out-of-tree 补丁直接编译 PREEMPT_RT 内核。嵌入式和工控场景的兄弟终于不用自己 backport 了。AMDGPU GCN 1.1 支持：Kaveri/Mullins 这些老 APU 现在能用 AMDGPU 驱动（之前只能用 radeon 驱动）。如果你还有一批这类芯片的瘦客户端或工控机在服役，升级内核之后显示稳定性会好很多。网络驱动：新增 Realtek RTL8157（2.5G USB 网卡）支持。如果你买过那种 USB-C 转 2.5G 网卡的加密狗，可能之前在 Linux 下不好使，现在应该能直接识别了。Scheduler 改进：一些调度器的 bugfix 和微优化。7.2 里会有更重磅的 Cache Aware Scheduling，这个值得到时候单独聊一篇。升级建议滚动发行版（Arch、Fedora）：正常更新，这周应该就推过来了。企业发行版（RHEL/Rocky/Alma/Ubuntu LTS）：别急。等厂商测试完打包好再说，除非你有明确的 blocker 需要 7.1 解决。手动编译：只建议在测试环境玩，或者你明确知道自己需要某个新特性。# 查看当前版本 uname -r # 想尝鲜的话 # 1. 从 kernel.org 下载 7.1 tarball # 2. cp /boot/config-$(uname -r) .config # 3. make olddefconfig (继承当前配置，新选项用默认值) # 4. make -j$(nproc) # 5. make modules_install && make install # 6. update-grub 或者 grub2-mkconfig # 7. 重启选新内核 # 不过说真的，在生产环境别这么干7.1 整体来看是一个质量很高的功能版本。NTFS 驱动填补了一个十几年的坑，FRED 对新硬件的中断处理性能有实质性改善，QAT Zstd 给特定场景提供了硬件加速路径。下个版本 7.2 的 merge window 已经开了，Cache Aware Scheduling 和 Rust Zerocopy 看起来都很有意思。如果觉得这篇文章对你有帮助，欢迎点赞、转发、在看三连，让更多人看到。公众号：耕云躬行录个人博客：躬行笔记
上周刷技术新闻，看到一条消息差点以为是恶搞：微软在 Build 2026 上正式发布了 Coreutils for Windows，把 75+ 个 Linux/Unix 命令原生搬到了 Windows 的 CMD 和 PowerShell 里。ls、grep、cat、cp、mv、rm、find、head、tail、sort……全都有了。注意——这不是 WSL，不是 Cygwin，不是 Git Bash，是原生跑在 Windows 上的。一个 4.6MB 的 Rust 编译二进制，winget install Microsoft.Coreutils 就装好了。Satya Nadella 在 Build 的主题演讲上亲自演示了一把 grep："Grep in full glory is now available for full Windows access." 那场面，Steve Ballmer 看了怕不是棺材板都按不住。为什么我说这事比看起来大如果你觉得这只是"方便了一下偶尔用 Windows 的 Linux 用户"——那格局小了。先说个背景。微软那个 "Linux is a cancer" 的名言大家都还记得吧？那是 2001 年 Ballmer 说的。后来 WSL 出来的时候我们就已经感叹"时代变了"，再后来 Azure 上 Linux 跑得比 Windows 还多也没人惊讶了。但这次不一样——这次微软是把 Linux 的命令行基础设施直接焊死在 Windows 内部了。过去 Windows 开发者想用 Linux 命令有多痛苦呢？我列一下选项：装个 Git Bash，但只有一部分命令开个 WSL，但你的文件系统访问跨了两个世界搞个 Cygwin/MSYS2，但维护负担重，版本经常滞后用 PowerShell 的等价命令，但语法完全不一样每次写个 README 里的命令示例，你都得加一句"Windows 用户请用 xxx 替代"。每次跨平台脚本，都得维护两套逻辑。这些年我在团队里见过无数次：明明是一个简单的 grep -r "ERROR" /var/log/ 的事，Windows 上就得折腾半天。现在呢？一条命令装完，ls 就是 ls，grep 就是 grep，cat 就是 cat。flag 一样，行为一样，输出一样。底层是 Rust 重写的 uutils微软没有直接搬 GNU Coreutils——那玩意儿是 GPL 授权的，微软碰 GPL 就像碰了高压线。他们选了 uutils/coreutils 项目，这是一个社区用 Rust 从零重写 GNU Coreutils 的开源项目，MIT 授权。用 Rust 重写有几个好处：内存安全。Coreutils 这种底层工具如果有内存漏洞那影响面太大了，Rust 天然规避了这类问题。跨平台设计。uutils 从一开始就是为了在 Windows/macOS/Linux 上都能跑而写的。MIT 协议。微软可以放心用，不用担心 GPL 传染的问题。当然也有坑。Windows 和 Linux 的文件系统语义差异不是 Rust 能抹平的——盘符、路径分隔符、ACL 权限模型、大小写不敏感、符号链接行为，这些都是真实的差异。所以有些命令是缺席的：chmod、chown、chroot 这些跟 POSIX 权限模型绑死的命令没有，因为 Windows 用 ACLkill、timeout 没有，因为 Windows 没有 POSIX 信号机制dd、shred、sync 暂时也没有dir、date、whoami 这些跟 Windows 内置命令冲突的，需要注意 PATH 优先级实际用下来，日常 80% 的场景是覆盖的。我前两天在一台 Windows 开发机上试了下，ls -la、grep -rn "pattern" .、cat file | sort | uniq -c，这些管道操作跑得很顺，速度甚至比 Cygwin 快不少（毕竟是 Rust 原生编译的）。真正的野心：AI Agent 时代的基础设施好了，到这里如果你觉得微软只是在"讨好开发者"，那还是格局小了。Build 2026 上微软同时宣布了另一个东西：OpenClaw for Windows。OpenClaw 是今年爆火的开源 AI Agent 框架，能让自主 Agent 在本地机器上执行复杂任务。这些 Agent 的执行逻辑是什么？跑 shell 命令、写脚本、操作文件系统、搜索日志——全是命令行操作。之前 OpenClaw 用户疯狂买 Mac Mini 来跑，因为 macOS 天然有完整的 Unix 命令行。Windows 用户想跑？得装 WSL、配环境、折腾半天。现在 Coreutils 一装，Windows 直接具备了 Agent 执行所需的命令行基础设施。OpenClaw 的 Agent 可以直接在 Windows 原生环境里 grep 日志、cat 配置文件、find 搜索目标文件、sort/uniq 处理数据。The Register 的报道里有一段话很有意思：Nadella's newfound love for grep probably has more to do with its recent popularity as an agent tool for finding the needles in the haystack.翻译一下：Nadella 突然爱上 grep，不是因为他自己要在终端里搜日志，而是因为 AI Agent 需要 grep。这就说通了。微软这波操作的时间线：今年初 OpenClaw 爆火，但在 Windows 上跑不利索微软发布安全警告，说无限制运行 OpenClaw 有安全风险Build 2026 同时发布 Coreutils + OpenClaw for Windows + Microsoft Execution Containers（MXC，Agent 沙箱策略层）一套组合拳下来，微软想说的是：Windows 不只是能跑 Agent，而且是最安全、最合规的 Agent 运行平台。配合企业级权限管理、合规策略、Copilot 集成——这是在争 Agentic AI 时代的基础设施入口。对我们意味着什么站在一个做运维和 DevOps 的角度，我觉得这事有几个实际影响：1. 跨平台脚本终于不用维护两套了以前写部署脚本、CI pipeline，涉及 Windows 构建环境的时候总是头疼。现在如果目标机器装了 Coreutils，一套 shell 脚本基本能通吃。当然前提是你别用太多 bash-specific 的语法，尽量用 POSIX 兼容的写法。2. Windows 上的故障排查姿势能统一了以前在 Windows 上翻日志，要么用 PowerShell 的 Select-String（语法跟 grep 差得太远），要么开 WSL 绕一圈。现在直接 grep -rn "ERROR" C:\logs\ 就完事了。对于偶尔需要在 Windows Server 上排查问题的同学，这是实打实的效率提升。3. 新人上手门槛降低团队里如果有新来的 Windows 开发者，以前你得跟人解释"先装个 WSL"或者"用 PowerShell 的话 grep 的等价物是 Select-String"。现在直接一句 winget install Microsoft.Coreutils，完事。4. 容器时代的最后一块拼图Build 上还预览了 WSL Containers——直接在 WSL 里跑 Linux 容器，不需要 Docker Desktop。加上原生 Coreutils，Windows 作为容器开发环境的体验又近了一步。一些槽点和注意事项当然也不是没坑：命令冲突。Windows 自带的 sort、find、date 跟 Linux 版本同名但行为不同。如果你 PATH 里的顺序不对，可能会调错版本。建议安装后检查一下 where sort 确认优先级。PowerShell 的 alias 冲突。PowerShell 默认把 ls alias 到 Get-ChildItem，cat alias 到 Get-Content。装了 Coreutils 后可能需要手动处理 alias 优先级，或者直接在 CMD 里用。文件路径格式。Linux 用 /，Windows 用 \。虽然 Coreutils 做了兼容处理，但极端情况下还是可能踩坑，特别是路径中有空格或特殊字符的时候。还在预览阶段。官方说了，还是 preview，有些命令的行为可能跟 GNU 版本有细微差异。生产环境脚本建议先测试。开源世界的态度有意思的是，开源社区对这事的反应很分裂。一边是务实派："终于不用在 Windows 上受苦了，管它是谁出的。"另一边是怀疑派："微软搞 Embrace, Extend, Extinguish 那套又不是第一次了，这次会不会慢慢把标准带偏？"我个人倒觉得还好。uutils 项目本身是开源的，MIT 协议，社区驱动。微软是在上面做了 Windows 适配和打包分发，但核心代码的方向还是社区控制的。而且老实说，开发者用脚投票——如果这东西真的好用，管它政治正确不正确呢。实际上同一周还有另一个相关新闻：Drew DeVault（SourceHut 的作者）发布了 Vim Classic 的第一个 release——一个 fork 自 Vim 8.2 的版本，理由是不满 Vim 和 NeoVim 接受 AI 生成的代码贡献。一边是微软拥抱 AI Agent 生态、把 Linux 命令搬进 Windows 给 Agent 用；另一边是资深开发者因为 AI 代码贡献而 fork 经典编辑器。这个对比挺有意思的——技术世界正在围绕 AI 发生深层次的分裂。总的来说，微软 Coreutils for Windows 这事，表面上是"方便了一下开发者"，实质上是在 Agentic AI 时代抢占操作系统层面的入口。从 WSL 到 Azure Linux 到 Coreutils，微软对 Linux 的态度早就从"cancer"变成了"if you can't beat them, absorb them"。对我们这些日常跨平台工作的人来说，管它动机是什么呢——好用就完事了。下次在 Windows 上排查问题，不用再打开 WSL 绕路了。这就够了。如果觉得这篇文章对你有帮助，欢迎点赞、转发、在看三连，让更多人看到。公众号：耕云躬行录个人博客：躬行笔记
线上服务突然 fork 不出新进程？聊聊我排查僵尸进程的那些事
前阵子半夜被告警叫醒，一台线上机器的 worker 进程起不来了。打开监控一看，CPU 不高、内存也还行，但服务就是 fork 失败。第一反应是 ulimit 的 nproc 打满了？跑了一下 ps aux | wc -l，进程数确实很高，再仔细一看——一堆 <defunct> 的进程静静地躺在那。好家伙，僵尸进程爆了。僵尸进程到底是什么鬼说白了，僵尸进程就是一个已经执行完了的子进程，但它的父进程没有调用 wait() 来回收它的退出状态。这个子进程已经"死了"——不占 CPU，不占内存，但它仍然在内核的进程表里占着一个 PID 的坑位。你可以把它想象成快递柜里一个没人取的包裹。包裹送到了（子进程执行完了），快递员也走了（进程代码已经退出），但取件码还在占着格子（进程表里还有条目），你不去签收（父进程不 wait），这个格子就一直被占着。一个两个僵尸进程无所谓，但如果大量堆积起来就麻烦了。Linux 64 位系统默认 PID 空间最大是 4194304，听着很大对吧？但实际生产环境里 pid_max 经常被调小，而且 nproc 的 ulimit 限制通常更紧。一旦进程表被僵尸填满，任何需要新进程的操作全部歇菜——新连接进不来、worker 起不了、cron job 跑不了。之前看到一个案例挺典型的：某 K8s 集群里一个 DNS pod 因为 Golang 的 channel 泄漏，单个 node 上堆积了超过 26000 个僵尸进程，直接把整个集群的 DNS 解析搞挂了。和孤儿进程搞清楚区别很多人容易搞混僵尸进程和孤儿进程，但这俩完全不是一回事：僵尸进程（Zombie）：子进程已经退出了，但父进程没回收。子进程是"死的"，状态是 Z。孤儿进程（Orphan）：父进程先挂了，子进程还活着。子进程会被 init（PID 1）领养，还在正常运行。孤儿进程其实不算问题，因为 init/systemd 会自动接管并在合适的时候回收它们。真正头疼的是僵尸——它已经死了，你 kill -9 都杀不掉它，因为它本来就不在运行。实战排查流程回到那天晚上的故事。发现一堆 defunct 之后，我的排查流程大概是这样的：第一步：确认僵尸进程数量ps aux | grep -w Z | grep -v grep | wc -l或者直接 top 看头部信息：top -bn1 | grep zombie那天一看，300 多个。心一凉。第二步：找到它们的父进程僵尸进程你杀不掉，关键是找到谁生了它们又不管。ps -eo pid,ppid,stat,comm | grep -w Z输出大概长这样： 3457 3425 Z my-worker 3533 3425 Z my-worker 3612 3425 Z my-worker ...好嘛，PPID 全是 3425。看看 3425 是啥：ps -p 3425 -o pid,comm,args发现是我们的一个任务调度进程，它 fork 子进程去执行任务，但执行完之后没有正确 wait。第三步：用 pstree 看清楚父子关系pstree -p -s 3457这个命令会从 init 一路画到目标进程，树状结构很清晰：systemd(1)───supervisord(1205)───task-scheduler(3425)───[my-worker](3457)一目了然。第四步：尝试通知父进程回收先温柔地来：kill -SIGCHLD 3425发一个 SIGCHLD 给父进程，意思是"嘿，你的子进程退出了，快 wait 一下"。如果父进程代码写得还行——比如注册了 SIGCHLD handler——那它就会老老实实调 waitpid 把僵尸收了。等了几秒再看，嗯，僵尸数没变。说明这个父进程压根没处理 SIGCHLD。第五步：重启父进程没办法了，直接重启：kill 3425父进程一死，它下面的僵尸就会被 init/systemd 接管，然后立刻被回收。ps aux | grep -w Z | wc -l # 0清爽了。然后再把调度进程拉起来，盯了一会儿确认不会再产生新的僵尸。当然，实际生产中你不能总这么暴力，因为杀父进程意味着它正在处理的所有子任务也全部中断。如果是重要服务，需要先评估影响。根因分析和修复排查完之后就该看代码了。翻了一下那个 task-scheduler 的源码，问题一目了然——它 fork 出子进程之后，用了一个 epoll 循环等事件，但 SIGCHLD 信号被忽略了，也没有任何地方调用 waitpid。修复方案有几种，看场景选：方案一：直接忽略子进程退出状态如果你不关心子进程的退出码，最简单的方式：signal(SIGCHLD, SIG_IGN);设了这个之后，子进程退出时内核会自动清理，根本不会产生僵尸。缺点是你拿不到子进程的 exit status。方案二：异步回收注册一个 SIGCHLD 的 handler，在里面循环 waitpid：void sigchld_handler(int sig) { while (waitpid(-1, NULL, WNOHANG) > 0); } // 注册 struct sigaction sa; sa.sa_handler = sigchld_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; sigaction(SIGCHLD, &sa, NULL);WNOHANG 是关键，它让 waitpid 非阻塞地回收所有已退出的子进程。这个方案更推荐，因为你还可以在 handler 里做些日志记录。方案三：Python 的话注意 subprocess如果是 Python 写的服务，用 subprocess.run() 就不会有这个问题，因为它内部会自动 wait。但如果用了 Popen 又没有调 proc.wait() 或 proc.communicate()，那僵尸就来了：# 错误写法 - 会产生僵尸 import subprocess proc = subprocess.Popen(['./worker.sh']) # 然后就不管了... # 正确写法 proc = subprocess.Popen(['./worker.sh']) proc.wait() # 或者 proc.communicate()方案四：Bash 脚本里别忘了 wait如果你的脚本 fork 了后台任务：./task1.sh & ./task2.sh & ./task3.sh & # 别漏了这行 wait没有 wait 的话，脚本如果一直在跑（比如是个 daemon 脚本），后台任务结束后就变僵尸了。systemd 层面的防护如果你的服务是 systemd 管理的，unit 文件里有几个配置可以帮你兜底：[Service] KillMode=control-group TimeoutStopSec=30 WatchdogSec=60KillMode=control-group 确保服务停止时，整个 cgroup 里的进程（包括子进程）都会被清理。WatchdogSec 让 systemd 监控你的服务，如果它卡死不响应就自动重启——这能兜住那种父进程 hang 住不 wait 的场景。监控建议经历了这次之后，我加了个简单的监控脚本，每 5 分钟跑一次：#!/bin/bash ZOMBIE_COUNT=$(ps aux | grep -w Z | grep -v grep | wc -l) THRESHOLD=10 if [ "$ZOMBIE_COUNT" -gt "$THRESHOLD" ]; then echo "Warning: $ZOMBIE_COUNT zombie processes detected" | \ mail -s "Zombie Process Alert" ops@example.com fi还可以更进一步，监控 PID 使用率：CURRENT=$(ls /proc | grep -c '^[0-9]') MAX=$(cat /proc/sys/kernel/pid_max) USAGE=$((CURRENT * 100 / MAX)) if [ "$USAGE" -gt 80 ]; then echo "PID table usage at ${USAGE}%" fi其实 Prometheus 的 node_exporter 本身就会暴露 node_processes_zombies 这个指标，Grafana 里设个告警更省事。还有个容器环境的坑最后提一嘴容器里的情况。在 Docker/K8s 里，容器内的 PID 1 默认是你的应用进程，不是 init/systemd。如果你的应用 fork 了子进程但不 wait，那些僵尸在容器里永远没人收。解决办法有几个：用 tini 作为容器的 init 进程：RUN apt-get install -y tini ENTRYPOINT ["tini", "--"] CMD ["./your-app"]tini 会自动回收所有孤儿和僵尸。Docker 自带的 --init 参数：docker run --init your-imageK8s 的话可以设置 shareProcessNamespace: true，让 pod 的 pause 容器充当 PID 1。这些容器环境的坑踩过一次就记住了，现在我写 Dockerfile 基本都会默认加 tini。僵尸进程这个东西，平时不出事你都想不起它，一出事就是大半夜的事。说到底就是父进程不负责任——生了孩子不管，系统替你兜着。养成好习惯：写代码的时候记得 wait，线上服务配好监控和 systemd 保护，容器里别忘了 init 进程。这些都做到位了，基本不会再被它坑。如果觉得这篇文章对你有帮助，欢迎点赞、转发、在看三连，让更多人看到。公众号：耕云躬行录个人博客：躬行笔记
Docker 代码沙箱防逃逸实战：从 Namespace 到 gVisor，我是怎么一层层锁死容器的
前阵子有个做在线编程平台的朋友找到我，说他们平台要支持用户在线提交代码并执行，问我用 Docker 跑用户代码安不安全。我当时就笑了——这事儿我太熟了，之前在做一个类似 LeetCode 的 OJ 系统的时候，就踩过不少坑。说白了，Docker 容器不是什么铜墙铁壁，它本质上就是跑在宿主机内核上的进程，只不过套了几层"隔离马甲"。你要是觉得 docker run 一下就万事大吉了，那迟早要出事。今天我就把自己在生产环境中是怎么一步步加固 Docker 代码沙箱的经验，原原本本讲出来。先搞清楚一个问题：Docker 容器到底隔离了什么？很多人对 Docker 有个误解，觉得容器就是轻量级虚拟机。这个想法很危险。虚拟机有自己独立的内核，而容器是共享宿主机内核的，区别大了去了。Docker 的隔离主要靠两样东西：Namespace 和 Cgroup。Namespace 做的是资源视角的隔离，让容器里的进程觉得自己是系统唯一的主人。Linux 目前提供了 6 个主要的 Namespace：PID Namespace：容器里的进程看不到宿主机和其他容器的进程Mount Namespace：容器有自己独立的文件系统挂载点Network Namespace：容器有独立的网络栈、IP地址、端口IPC Namespace：进程间通信隔离UTS Namespace：主机名隔离User Namespace：用户和用户组隔离Cgroup 做的是资源使用的限制，防止一个容器把宿主机的 CPU、内存、磁盘 IO 全吃光。听着挺完善的对吧？但问题在于，这些隔离都是"软隔离"，不是"硬隔离"。共享内核意味着，一旦内核有漏洞，或者你的配置有疏漏，容器里的恶意代码就有可能突破这些限制，逃到宿主机上。我之前看过一个案例，某在线编程平台因为配置不当，用户通过 Python 的 os.system() 直接在宿主机上执行了命令，把整台服务器搞崩了。所以，做代码沙箱，安全这根弦必须绷紧。第一道锁：资源限制，别让恶意代码把机器吃干抹净做代码沙箱，最基本的就是限制资源。你想想，要是用户提交一个死循环，或者疯狂 fork 进程的 fork 炸弹，不限制的话宿主机直接就废了。我一般会在 docker run 的时候加上这些参数：docker run \ --memory="512m" \ --memory-swap="512m" \ --cpus="1" \ --pids-limit="50" \ --cpu-shares=512 \ sandbox-runner逐个解释下：--memory="512m"：限制容器最多使用 512MB 内存，超过就直接 OOM Kill，不给任何商量余地--memory-swap="512m"：这个和 memory 设成一样的值，意思就是禁用 swap。别小看这个，有些恶意代码会利用 swap 来绕过内存限制--cpus="1"：最多使用 1 个 CPU 核心的算力--pids-limit="50"：限制容器内最多 50 个进程。这个是防 fork 炸弹的关键，:(){ :|:& };: 这种东西直接就废了--cpu-shares=512：CPU 时间的权重分配，多个容器竞争 CPU 时按这个比例来还有一点容易忽略的，磁盘 IO 也得限制：# 创建限速的 cgroup mkdir /sys/fs/cgroup/blkio/sandbox echo "8:0 1048576" > /sys/fs/cgroup/blkio/sandbox/blkio.throttle.read_bps_device不过说实话，磁盘 IO 限制在实际操作中比较麻烦，我更倾向于直接把容器的文件系统设成只读，后面会讲。第二道锁：Capability 裁剪，别给容器不需要的权限Linux 把 root 的权限拆分成了几十个 Capability，Docker 默认只保留了容器运行所需的一小部分。但即便如此，默认配置下容器还是有一些不必要的权限。你可以用这个命令看看默认情况下容器有哪些 Capability：docker run --rm alpine capsh --print我这边输出大概有这些：CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_FSETID, CAP_FOWNER, CAP_MKNOD, CAP_NET_RAW, CAP_SETGID, CAP_SETUID, CAP_SETFCAP, CAP_SETPCAP, CAP_NET_BIND_SERVICE, CAP_SYS_CHROOT, CAP_KILL, CAP_AUDIT_WRITE。对于代码沙箱来说，这里面很多都是多余的。我的做法是把所有 Capability 都去掉，只加回必须的：docker run \ --cap-drop=ALL \ --cap-add=NET_BIND_SERVICE \ sandbox-runner--cap-drop=ALL 先把所有权限都干掉，然后 --cap-add=NET_BIND_SERVICE 只加回绑定 1024 以下端口的权限（如果你的沙箱不需要网络，这个也省了）。千万别用 --privileged 参数！这个参数等于把所有安全机制全关了，容器里的 root 和宿主机的 root 基本没区别。我见过有人在生产环境用 --privileged 跑容器，被领导骂了三天。第三道锁：Seccomp 系统调用过滤，把攻击面缩到最小Seccomp 是我最喜欢的一个安全机制。它可以在内核层面限制容器进程能调用哪些系统调用，相当于给容器加了一层"系统调用防火墙"。Docker 默认就启用了 Seccomp，使用一个白名单配置文件，默认会屏蔽大约 44 个危险或不常用的系统调用。你可以通过 docker info 确认：Security Options: seccomp Profile: default但是，默认配置对于代码沙箱来说还是太宽松了。我一般会写一个自定义的 Seccomp Profile，只允许代码执行真正需要的系统调用。举个实际的例子，如果沙箱只用来运行 Python 代码，我可以通过 strace 先抓取 Python 运行时需要哪些系统调用：strace -c -f python3 /tmp/test.py 2>&1 | head -50然后根据抓取结果编写 Seccomp Profile。一个精简版的 Profile 大概长这样：{ "defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"], "syscalls": [ { "names": [ "read", "write", "open", "close", "fstat", "lseek", "mmap", "mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask", "ioctl", "access", "pipe", "select", "poll", "mremap", "nanosleep", "clock_gettime", "clone", "fork", "vfork", "execve", "exit", "wait4", "uname", "fcntl", "flock", "fsync", "dup", "dup2", "getpid", "getppid", "getuid", "getgid", "geteuid", "getegid", "getrlimit", "gettimeofday", "arch_prctl", "set_tid_address", "set_robust_list", "futex", "clock_getres", "clock_nanosleep" ], "action": "SCMP_ACT_ALLOW" } ] }关键点在于 defaultAction 设成了 SCMP_ACT_ERRNO，意思就是默认拒绝所有系统调用，只在白名单里的才放行。这比默认配置安全得多。使用自定义 Profile 启动容器：docker run \ --security-opt seccomp=/path/to/seccomp-profile.json \ sandbox-runner有个真实的案例很有说服力：CVE-2017-16995 这个内核漏洞，攻击者可以通过 bpf 系统调用实现提权。但 Docker 默认的 Seccomp 配置屏蔽了 bpf 系统调用，所以这个漏洞在 Docker 容器里根本没法利用。这就是 Seccomp 的价值。不过写 Seccomp Profile 是个苦力活，不同语言运行时需要的系统调用不一样，你得一个一个测。我建议先用 SCMP_ACT_LOG 模式跑一段时间收集日志，确认没问题再切换到 SCMP_ACT_ERRNO。第四道锁：AppArmor / SELinux，强制访问控制再加一道门Seccomp 管的是系统调用，AppArmor 管的是资源访问。它可以在文件读写、网络访问、Capability 等层面做更细粒度的限制。Ubuntu 默认使用 AppArmor，CentOS/RHEL 默认使用 SELinux。Docker 在 Ubuntu 上默认会给容器加载一个 AppArmor Profile，叫做 docker-default。对于代码沙箱，我一般会写一个更严格的自定义 AppArmor Profile：#include <tunables/global> profile sandbox-profile flags=(attach_disconnected,mediate_deleted) { #include <abstractions/base> # 只允许读 /usr 和 /lib /usr/** r, /lib/** r, /lib64/** r, # 允许写临时目录 /tmp/** rw, /dev/null rw, /dev/urandom r, # 禁止网络访问（除了已建立的连接） deny network, deny network inet, deny network inet6, # 禁止 ptrace（防止调试其他进程） deny ptrace, # 禁止 mount deny mount, # 禁止加载内核模块 deny /sys/module/** rw, }加载并使用这个 Profile：# 编译并加载 Profile apparmor_parser -r /etc/apparmor.d/sandbox-profile # 使用 Profile 启动容器 docker run \ --security-opt apparmor=sandbox-profile \ sandbox-runnerAppArmor 还有个好处是它的日志很好用。当容器进程尝试访问被禁止的资源时，/var/log/syslog 或 /var/log/audit/audit.log 里会有详细记录，方便你排查问题。如果你的系统是 CentOS，那就用 SELinux，思路是一样的，只是配置方式不同。SELinux 用标签和策略来管理访问控制，比 AppArmor 更严格但也更复杂。不过对于代码沙箱场景，AppArmor 够用了。第五道锁：User Namespace，让容器里的 root 变成宿主机的 nobody这是个很容易被忽略但非常重要的安全机制。默认情况下，Docker 容器里的 root 用户（UID 0）和宿主机的 root 用户在文件权限上是等价的。也就是说，一旦容器逃逸，攻击者拿到的是宿主机的 root 权限，想想都后怕。User Namespace 的作用是把容器内的 UID 映射到宿主机的非特权 UID。比如容器内的 root（UID 0）在宿主机上实际是 UID 100000，这样就算逃逸了，攻击者在宿主机上也没有任何特权。Docker 启用 User Namespace 的方式：# 先在 /etc/docker/daemon.json 中配置 { "userns-remap": "default" } # 重启 Docker systemctl restart dockerDocker 会自动创建一个叫 dockremap 的用户，并配置 subuid 和 subgid 映射。你可以验证一下：cat /etc/subuid # dockremap:100000:65536 cat /etc/subgid # dockremap:100000:65536这意味着容器内的 UID 0-65535 会映射到宿主机的 UID 100000-165535。启用 User Namespace 之后有个副作用：一些需要特权的操作（比如挂载 NFS、使用 --net=host）就不能用了。但对于代码沙箱来说，这些功能本来就不需要，所以影响不大。第六道锁：只读文件系统 + 临时存储，不给恶意代码留后门代码沙箱最怕的就是恶意代码往文件系统里写东西，比如写个后门脚本、改个配置文件什么的。我的做法是把容器的根文件系统设成只读：docker run \ --read-only \ --tmpfs /tmp:size=100m,mode=1777 \ --tmpfs /run:size=10m,mode=0755 \ sandbox-runner--read-only 把整个根文件系统挂载为只读，--tmpfs 给需要写入的目录单独挂载内存文件系统。/tmp 给 100MB，/run 给 10MB，够用了。有些运行时还需要 /dev/shm，也可以加上：--tmpfs /dev/shm:size=64m,mode=1777这样恶意代码就算想写东西，也只能写到 /tmp 里，而且容器一销毁就没了，啥也留不下。还有一点，别把宿主机的目录挂载到容器里，尤其是敏感目录。我见过有人把 /var/run/docker.sock 挂进容器的，这等于把宿主机的 Docker 控制权拱手让人，攻击者可以直接创建一个特权容器然后逃逸。第七道锁：网络隔离，让恶意代码连不上外网代码沙箱里的程序一般不需要网络访问。就算需要，也应该严格限制。最简单粗暴的方式就是直接禁用网络：docker run \ --network=none \ sandbox-runner这样容器里连 ping 都用不了，恶意代码想外传数据？没门。如果你的沙箱确实需要网络（比如代码里要请求某个 API），那可以用自定义网络 + iptables 规则来做白名单：# 创建自定义网络 docker network create --driver bridge sandbox-net # 启动容器 docker run \ --network=sandbox-net \ sandbox-runner # 在宿主机上用 iptables 限制容器只能访问特定 IP iptables -I FORWARD -s 172.20.0.0/16 -d 允许的IP -j ACCEPT iptables -I FORWARD -s 172.20.0.0/16 -j DROP这样容器只能访问你允许的 IP，其他一概不通。第八道锁：超时机制，别让代码跑到天荒地老恶意代码不一定要搞破坏，它也可以就是跑个死循环，占着你的计算资源不放。所以超时机制必须有。我一般这样处理：timeout 10 docker run \ --rm \ --memory="512m" \ --cpus="1" \ sandbox-runnertimeout 10 表示 10 秒后强制终止。--rm 表示容器结束后自动删除，不留痕迹。但在生产环境中，我更倾向于在代码层面控制超时，因为 timeout 命令发的是 SIGTERM，有些进程可能捕获信号后不退出。更可靠的做法是在容器内部用进程管理器来控制：import subprocess import signal try: result = subprocess.run( ["python3", "/tmp/user_code.py"], timeout=10, capture_output=True, preexec_fn=lambda: signal.signal(signal.SIGALRM, lambda *_: (_ for _ in ()).throw(TimeoutError())) ) except subprocess.TimeoutExpired: # 超时处理 pass终极大招：gVisor / Kata Containers，真正的沙箱容器前面说的那些加固措施，都是基于传统 Docker 容器的，本质上还是共享内核。如果你的安全要求特别高（比如多租户环境、运行完全不可信的代码），那可以考虑使用沙箱容器运行时。gVisor 是 Google 开源的一个项目，它实现了一个用户态内核叫 Sentry，拦截容器进程的所有系统调用，在用户态处理后再转发给宿主机内核。这样容器进程和宿主机内核之间就多了一层防护，攻击面大幅缩小。gVisor 和宿主机内核的通信只用了不到 20 个系统调用，而传统容器可以用 300 多个。这差距，安全程度可想而知。安装和使用 gVisor 也比较简单：# 安装 runsc curl -fsSL https://gvisor.dev/archive.key | sudo gpg --dearmor -o /usr/share/keyrings/gvisor-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/gvisor-archive-keyring.gpg] https://storage.googleapis.com/gvisor/releases release main" | sudo tee /etc/apt/sources.list.d/gvisor.list sudo apt-get update && sudo apt-get install runsc # 配置 Docker 使用 runsc # 在 /etc/docker/daemon.json 中添加 { "runtimes": { "runsc": { "path": "/usr/bin/runsc" } } } # 重启 Docker systemctl restart docker # 使用 gVisor 运行容器 docker run --runtime=runsc sandbox-runnergVisor 的缺点也很明显：系统调用开销导致性能有损耗，不适合系统调用密集型的应用；不支持 GPU 直通；没有实现全部的 Linux 系统调用，有些应用可能跑不起来。Kata Containers 是另一种方案，它本质上是在轻量级虚拟机里跑容器。每个容器（或 Pod）都有自己独立的内核，隔离性接近虚拟机，但使用体验和普通容器一样。Kata 的安全边界比 gVisor 更强，但资源开销也更大，每个容器至少需要几十 MB 的额外内存。对于在线编程平台这种需要同时运行成百上千个沙箱的场景，gVisor 可能更合适。我的最终方案：层层叠加，纵深防御说了这么多，最后晒一下我在生产环境中实际使用的完整配置。我把它封装成了一个脚本：#!/bin/bash docker run \ --rm \ --runtime=runsc \ --network=none \ --read-only \ --memory="512m" \ --memory-swap="512m" \ --cpus="1" \ --pids-limit="50" \ --cap-drop=ALL \ --security-opt seccomp=/etc/docker/seccomp/sandbox.json \ --security-opt apparmor=sandbox-profile \ --tmpfs /tmp:size=100m,mode=1777 \ --tmpfs /run:size=10m,mode=0755 \ --user=1000:1000 \ sandbox-runner \ timeout 10 python3 /tmp/user_code.py这里有几个值得注意的点：--runtime=runsc：使用 gVisor 运行时，从内核层面加强隔离--network=none：完全禁用网络--read-only：只读文件系统--cap-drop=ALL：去掉所有 Linux Capabilities--security-opt seccomp=...：自定义 Seccomp Profile--security-opt apparmor=...：自定义 AppArmor Profile--user=1000:1000：以非 root 用户运行timeout 10：10 秒超时这套配置不是一次性就定下来的，是在实际运行中不断调整的。刚开始的时候，因为 Seccomp 太严格导致很多正常代码跑不了，我就把 SCMP_ACT_ERRNO 改成 SCMP_ACT_LOG 先观察了一周，收集了所有被拒绝的系统调用，确认哪些是必须的后才逐步放开。还有个经验：每次更新语言运行时版本后，都要重新测试 Seccomp Profile，因为新版本可能会用到之前不需要的系统调用。我就吃过这个亏，升级 Python 版本后用户代码全报错，排查了半天才发现是 Seccomp 屏蔽了一个新加的系统调用。一些血的教训最后分享几个我在实战中遇到的真实问题：1. docker.sock 千万别挂进去这个前面提过了，但还是要强调。我见过不止一次，有人为了在容器里操作 Docker，把 /var/run/docker.sock 挂进容器。这就等于把宿主机的 root 权限交出去了。2. 别用 latest 标签跑生产环境镜像版本要固定，用 digest 更好。恶意镜像替换这种攻击虽然少见，但不是不可能。3. 定期更新内核Docker 的安全很大程度上依赖内核。脏牛漏洞（CVE-2016-5195）可以直接从容器里逃逸，就是因为内核的问题。保持内核更新是最基本的安全措施。4. CVE-2019-5736 的启示这个漏洞非常经典。攻击者通过覆写宿主机上的 runc 二进制文件，实现了容器逃逸。根本原因是 runc 进入容器命名空间时，/proc/self/exe 指向了宿主机的 runc 程序，而容器内的 root 用户可以写入这个文件。这个漏洞的修复方式是在 runc 进入容器前先克隆一份自身的二进制文件。这个案例告诉我们，不要低估攻击者的创造力，即使看起来很小的疏漏也可能被利用。5. 监控和日志不能少再安全的沙箱也需要监控。我会在宿主机上用 auditd 记录所有和容器相关的系统调用，用 Falco 做运行时安全检测。一旦发现异常行为（比如容器进程尝试访问 /proc 或 /sys 下的敏感文件），立即告警。总结做 Docker 代码沙箱，没有银弹，只有纵深防御。单靠任何一层安全机制都不够可靠，必须把 Namespace、Cgroup、Capability、Seccomp、AppArmor、User Namespace、只读文件系统、网络隔离这些手段叠加起来，再加上 gVisor 这类沙箱运行时，才能把风险降到可接受的水平。记住一个原则：最小权限。容器只需要能完成任务的最低权限，多给一点都不行。安全这事儿，宁可过度，不能不足。还有就是，安全是一个持续的过程，不是一次性的配置。新的漏洞会不断出现，你的防御措施也要跟着更新。保持关注 Docker 和 Linux 内核的安全公告，及时打补丁，这是最基本的运维素养。如果你觉得这篇文章对你有帮助，欢迎关注公众号【耕云躬行录】，我会持续分享更多运维和云原生安全方面的实战经验。也欢迎转发给你的同事朋友，让更多人少踩坑。有问题可以在公众号留言，我看到都会回复的。公众号：耕云躬行录个人博客：躬行笔记