古人学问无遗力，少壮工夫老始成。 纸上得来终觉浅，绝知此事要躬行。
蓝绿部署实战指南：从零到一搭建生产级无缝发布系统
蓝绿部署实战指南：从零到一搭建生产级无缝发布系统我在公司做运维这几年，最怕的就是发布新版本的时候出问题。记得有一次周五下午发版本，结果服务挂了两个小时，用户投诉电话打爆了，我在机房里忙活到晚上11点才搞定。从那以后，我就开始研究各种发布策略，蓝绿部署就是其中最实用的一种。今天就把我这几年摸索出来的蓝绿部署经验分享给大家，全是生产环境踩过的坑和总结出的干货。什么是蓝绿部署蓝绿部署说白了就是准备两套一模一样的生产环境，一套叫蓝环境，一套叫绿环境。当前用户访问的是蓝环境，我要发新版本的时候，就把新版本部署到绿环境上，测试没问题了再把流量切过去。这样做的好处是什么？如果新版本有问题，我立马就能切回到旧版本，用户基本感受不到服务中断。我之前用滚动更新的方式，每次更新都要重启服务，虽然时间很短，但还是会有那么几秒钟的服务不可用。用了蓝绿部署之后，这个问题彻底解决了。蓝绿部署的核心原理整个流程其实很简单：当前蓝环境在线提供服务在绿环境部署新版本应用绿环境测试通过后，负载均衡器流量从蓝环境切换到绿环境绿环境成为新的生产环境，蓝环境变成备用环境关键点就是这个流量切换，必须要做到瞬间完成，不能有中间状态。实现蓝绿部署的几种方式方式一：基于Nginx的蓝绿部署这是我最常用的方式，成本低，配置简单。先准备两台服务器，192.168.1.10（蓝）和192.168.1.11（绿），前面放一台Nginx做负载均衡。Nginx配置文件关键部分：upstream blue_pool { server 192.168.1.10:8080 weight=1 max_fails=3 fail_timeout=30s; } upstream green_pool { server 192.168.1.11:8080 weight=1 max_fails=3 fail_timeout=30s; } # 当前生产环境指向蓝环境 upstream production { server 192.168.1.10:8080; } server { listen 80; server_name api.example.com; location / { proxy_pass http://production; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }切换的时候，写个脚本：#!/bin/bash # switch.sh NGINX_CONF="/etc/nginx/conf.d/app.conf" CURRENT_ENV=$(grep -A1 "upstream production" $NGINX_CONF | grep server | awk '{print $2}' | cut -d: -f1) if [ "$CURRENT_ENV" = "192.168.1.10" ]; then # 当前是蓝环境，切换到绿环境 sed -i 's/server 192.168.1.10:8080;/server 192.168.1.11:8080;/' $NGINX_CONF echo "已切换到绿环境" else # 当前是绿环境，切换到蓝环境 sed -i 's/server 192.168.1.11:8080;/server 192.168.1.10:8080;/' $NGINX_CONF echo "已切换到蓝环境" fi # 重新加载Nginx配置 nginx -s reload这个方案的优点是简单直接，缺点是需要手动执行切换脚本。不过对于中小型项目来说已经够用了。方式二：基于HAProxy的蓝绿部署HAProxy在流量切换方面比Nginx更灵活，支持动态权重调整。HAProxy配置：global daemon maxconn 4096 defaults mode http timeout connect 5000ms timeout client 50000ms timeout server 50000ms frontend web_frontend bind *:80 default_backend web_servers backend web_servers balance roundrobin # 初始状态：蓝环境权重100，绿环境权重0 server blue 192.168.1.10:8080 weight 100 check server green 192.168.1.11:8080 weight 0 check # 管理接口 stats socket /var/run/haproxy.sock mode 666 level admin stats timeout 2m切换脚本：#!/bin/bash # haproxy_switch.sh SOCKET="/var/run/haproxy.sock" # 获取当前蓝环境权重 BLUE_WEIGHT=$(echo "show stat" | socat stdio $SOCKET | grep "web_servers,blue" | cut -d, -f19) if [ "$BLUE_WEIGHT" = "100" ]; then # 切换到绿环境 echo "set weight web_servers/blue 0" | socat stdio $SOCKET echo "set weight web_servers/green 100" | socat stdio $SOCKET echo "已切换到绿环境" else # 切换到蓝环境 echo "set weight web_servers/blue 100" | socat stdio $SOCKET echo "set weight web_servers/green 0" | socat stdio $SOCKET echo "已切换到蓝环境" fiHAProxy的好处是可以实现渐进式切换，比如先把10%的流量切到新环境，观察一段时间没问题再全量切换。方式三：基于Kubernetes的蓝绿部署如果你们公司已经用了K8s，那蓝绿部署就更简单了。K8s的Service可以通过selector来控制流量指向。先创建两个Deployment：# blue-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: app-blue labels: app: myapp version: blue spec: replicas: 3 selector: matchLabels: app: myapp version: blue template: metadata: labels: app: myapp version: blue spec: containers: - name: app image: myapp:v1.0 ports: - containerPort: 8080 --- # green-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: app-green labels: app: myapp version: green spec: replicas: 3 selector: matchLabels: app: myapp version: green template: metadata: labels: app: myapp version: green spec: containers: - name: app image: myapp:v2.0 ports: - containerPort: 8080Service配置：apiVersion: v1 kind: Service metadata: name: app-service spec: selector: app: myapp version: blue # 当前指向蓝环境 ports: - port: 80 targetPort: 8080 type: LoadBalancer切换的时候只需要修改Service的selector：kubectl patch service app-service -p '{"spec":{"selector":{"version":"green"}}}'这种方式的优势是与K8s生态结合紧密，可以利用K8s的健康检查、自动扩缩容等功能。开源工具推荐1. Argo Rollouts这是专门为K8s设计的渐进式交付工具，支持蓝绿部署、金丝雀发布等多种策略。安装很简单：kubectl create namespace argo-rollouts kubectl apply -n argo-rollouts -f https://github.com/argoproj/argo-rollouts/releases/latest/download/install.yaml配置文件示例：apiVersion: argoproj.io/v1alpha1 kind: Rollout metadata: name: rollout-bluegreen spec: replicas: 2 strategy: blueGreen: activeService: rollout-bluegreen-active previewService: rollout-bluegreen-preview autoPromotionEnabled: false scaleDownDelaySeconds: 30 selector: matchLabels: app: rollout-bluegreen template: metadata: labels: app: rollout-bluegreen spec: containers: - name: rollouts-demo image: argoproj/rollouts-demo:blue ports: - containerPort: 8080Argo Rollouts的界面很友好，可以直观地看到部署状态，手动控制切换时机。2. FlaggerFlagger是另一个优秀的渐进式交付工具，支持多种Service Mesh和Ingress控制器。helm repo add flagger https://flagger.app helm upgrade -i flagger flagger/flagger \ --namespace=istio-system \ --set crd.create=false \ --set meshProvider=istio \ --set metricsServer=http://prometheus:9090Canary配置：apiVersion: flagger.app/v1beta1 kind: Canary metadata: name: podinfo namespace: test spec: targetRef: apiVersion: apps/v1 kind: Deployment name: podinfo progressDeadlineSeconds: 60 service: port: 9898 targetPort: 9898 analysis: interval: 1m threshold: 5 maxWeight: 50 stepWeight: 10 metrics: - name: request-success-rate threshold: 99 interval: 1m3. Jenkins Blue Ocean如果你用Jenkins做CI/CD，Blue Ocean插件提供了很好的流水线可视化界面，配合Pipeline脚本可以实现自动化蓝绿部署。Pipeline脚本示例：pipeline { agent any environment { BLUE_PORT = "8080" GREEN_PORT = "8081" CURRENT_ENV = "" } stages { stage('检测当前环境') { steps { script { def response = sh( script: "curl -s http://localhost/health | jq -r '.env'", returnStdout: true ).trim() if (response == "blue") { env.CURRENT_ENV = "blue" env.TARGET_ENV = "green" env.TARGET_PORT = GREEN_PORT } else { env.CURRENT_ENV = "green" env.TARGET_ENV = "blue" env.TARGET_PORT = BLUE_PORT } } } } stage('部署到目标环境') { steps { sh """ docker stop app-${TARGET_ENV} || true docker rm app-${TARGET_ENV} || true docker run -d --name app-${TARGET_ENV} -p ${TARGET_PORT}:8080 myapp:${BUILD_NUMBER} """ } } stage('健康检查') { steps { script { def maxRetries = 30 def retries = 0 while (retries < maxRetries) { def response = sh( script: "curl -s -o /dev/null -w '%{http_code}' http://localhost:${TARGET_PORT}/health", returnStdout: true ).trim() if (response == "200") { echo "健康检查通过" break } retries++ sleep(10) } if (retries >= maxRetries) { error("健康检查失败") } } } } stage('切换流量') { steps { sh """ sed -i 's/server 192.168.1.10:${CURRENT_ENV == 'blue' ? BLUE_PORT : GREEN_PORT};/server 192.168.1.10:${TARGET_PORT};/' /etc/nginx/conf.d/app.conf nginx -s reload """ } } } post { failure { // 回滚逻辑 sh """ sed -i 's/server 192.168.1.10:${TARGET_PORT};/server 192.168.1.10:${CURRENT_ENV == 'blue' ? BLUE_PORT : GREEN_PORT};/' /etc/nginx/conf.d/app.conf nginx -s reload """ } } }4. GitLab CI/CDGitLab自带的CI/CD功能也很强大，可以通过.gitlab-ci.yml配置蓝绿部署流水线。stages: - build - deploy-staging - switch-traffic - cleanup variables: BLUE_HOST: "blue.example.com" GREEN_HOST: "green.example.com" build: stage: build script: - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA deploy-to-staging: stage: deploy-staging script: - | if [ "$(kubectl get service production -o jsonpath='{.spec.selector.version}')" = "blue" ]; then export TARGET_ENV="green" export TARGET_HOST=$GREEN_HOST else export TARGET_ENV="blue" export TARGET_HOST=$BLUE_HOST fi - kubectl set image deployment/app-$TARGET_ENV app=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - kubectl rollout status deployment/app-$TARGET_ENV environment: name: staging url: http://$TARGET_HOST switch-production: stage: switch-traffic script: - | if [ "$(kubectl get service production -o jsonpath='{.spec.selector.version}')" = "blue" ]; then kubectl patch service production -p '{"spec":{"selector":{"version":"green"}}}' else kubectl patch service production -p '{"spec":{"selector":{"version":"blue"}}}' fi environment: name: production url: http://api.example.com when: manual cleanup-old-version: stage: cleanup script: - | if [ "$(kubectl get service production -o jsonpath='{.spec.selector.version}')" = "blue" ]; then kubectl scale deployment/app-green --replicas=0 else kubectl scale deployment/app-blue --replicas=0 fi when: manual生产环境踩坑记录我在实施蓝绿部署的过程中踩了不少坑，这里分享几个典型的：坑1：数据库迁移问题有一次我们发新版本，新版本的数据库schema有变化。结果切换到新环境后发现数据不一致，只能紧急回滚。后来我们的解决方案是：数据库迁移必须向前兼容先发布兼容新旧schema的代码版本再执行数据库迁移最后发布纯新版本代码坑2：静态资源缓存问题前端的js、css文件有缓存，切换环境后用户可能还在使用旧的静态资源，导致接口调用失败。我们的做法是给静态资源文件名加上版本号或者hash值。坑3：健康检查不充分之前只检查了HTTP状态码是200就认为服务正常，结果有一次数据库连接池耗尽，HTTP接口能访问但是业务逻辑有问题。现在我们的健康检查会验证关键业务接口。坑4：流量切换不够平滑最开始用的是简单的全量切换，有时候会有短暂的502错误。后来改成先切换一小部分流量观察，没问题再全量切换。监控和回滚策略蓝绿部署最重要的是要有完善的监控和快速回滚机制。我们用Prometheus + Grafana做监控，关键指标包括：响应时间错误率吞吐量资源使用率告警规则：groups: - name: bluegreen-deployment rules: - alert: HighErrorRate expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.01 for: 2m labels: severity: critical annotations: summary: "错误率过高" - alert: HighResponseTime expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 1 for: 5m labels: severity: warning annotations: summary: "响应时间过长"回滚脚本我写得特别简单粗暴：#!/bin/bash # rollback.sh echo "开始回滚..." # 获取当前环境 CURRENT=$(kubectl get service production -o jsonpath='{.spec.selector.version}') if [ "$CURRENT" = "blue" ]; then kubectl patch service production -p '{"spec":{"selector":{"version":"green"}}}' echo "已回滚到绿环境" else kubectl patch service production -p '{"spec":{"selector":{"version":"blue"}}}' echo "已回滚到蓝环境" fi echo "回滚完成，请检查服务状态"关键是要快，发现问题后30秒内必须能完成回滚。成本考虑蓝绿部署最大的缺点就是资源消耗翻倍，对于小公司来说成本压力不小。我们的优化策略：非生产环境不用蓝绿部署，用滚动更新就行蓝绿环境可以共享一些无状态的服务，比如Redis、MQ用云厂商的竞价实例降低成本部署完成后可以把备用环境的副本数调小，保留最小可用配置另外，数据库这种有状态的服务不适合做蓝绿部署，成本太高。我们一般是应用层做蓝绿，数据层用主从切换。蓝绿部署确实是个好东西，特别是对于那些不能接受服务中断的业务。虽然成本高了点，但是换来的稳定性和用户体验提升是值得的。我们公司自从用了蓝绿部署后，发布相关的故障基本没有了，周五下午发版本也不再是噩梦。当然，这套方案不是银弹，还是要根据自己的业务场景选择合适的部署策略。如果你们公司也在考虑蓝绿部署，建议先在测试环境搭一套练练手，把流程跑通了再上生产。毕竟运维这行，稳字当头嘛。好了，今天的分享就到这里。如果这篇文章对你有帮助，记得点个赞转发一下，让更多的运维兄弟看到。有什么问题也欢迎在评论区讨论，我会尽量回复大家。关注@运维躬行录，我会持续分享更多生产环境的实战经验，咱们一起在运维这条路上越走越稳！公众号：运维躬行录个人博客：躬行笔记
从零开始搞懂DDoS和CC攻击：运维人员必备的防护攻略
作为一个在运维岗位摸爬滚打好几年的人，我想和大家聊聊DDoS和CC攻击这个话题。这两种攻击方式虽然都能让你的服务器瞬间趴窝，但原理和防护方法却大不相同。今天就把我这些年踩过的坑和积累的经验分享给大家。DDoS攻击到底是个什么鬼DDoS全称是Distributed Denial of Service，翻译过来就是分布式拒绝服务攻击。听起来很高大上，其实原理挺简单的。你可以想象一下，平时你家门口最多同时来3-5个客人，你能应付得过来。但如果突然来了100个人，都要进你家坐坐，你家的门、客厅、厕所...肯定就瘫痪了。DDoS攻击就是这个道理，通过控制大量的"肉鸡"（被感染的计算机）同时向目标服务器发送请求，把服务器的带宽、CPU、内存等资源耗尽。DDoS攻击主要分几种类型：流量型攻击（Volume-based attacks）这种最直接，就是用大量的垃圾流量把你的带宽塞满。常见的有UDP洪水攻击、ICMP洪水攻击等。攻击者会发送大量的UDP包或者ping包，把你的网络带宽占满，正常用户就访问不了了。协议型攻击（Protocol attacks）这种攻击利用网络协议的缺陷，比如SYN洪水攻击。TCP三次握手大家都知道吧，客户端发SYN，服务器回SYN-ACK，然后等客户端的ACK。攻击者只发SYN不发ACK，服务器就一直等着，连接池很快就满了。应用层攻击（Application layer attacks）这种攻击针对应用程序，看起来像正常的请求，但会消耗大量的服务器资源。比如不断请求复杂的数据库查询，或者上传大文件等。CC攻击又是怎么回事CC攻击的全称是Challenge Collapsar，中文叫挑战黑洞。听这名字就知道不是什么好东西。CC攻击和DDoS攻击的区别在于，CC攻击更加"精准"。它不需要很大的流量，而是模拟正常用户的行为，不断地请求那些消耗资源比较大的页面或接口。CC攻击的特点是：流量不大，但请求频繁看起来像正常用户行为针对消耗资源较大的功能难以通过简单的流量限制来防护两种攻击的本质区别说到区别，我觉得可以用一个比喻来解释。DDoS攻击就像是在你家门口堵了一大群人，把路都堵死了，正常客人根本进不来。这种攻击比较"粗暴"，流量大，容易发现。CC攻击则像是派了几个人，每个人都很有礼貌地敲门，但一进来就要你做很复杂的事情，比如帮忙搬家具、修电器...虽然人不多，但你很快就累趴下了。从技术角度来说：DDoS主要消耗带宽和网络资源CC主要消耗服务器的CPU、内存和数据库资源DDoS流量特征明显，CC攻击更隐蔽DDoS防护相对简单粗暴，CC防护需要更精细的策略防护策略：不只是买个高防就完事很多人以为防DDoS和CC攻击就是买个高防服务器或者CDN，其实远没有这么简单。我这些年总结下来，防护需要多层次的策略。基础防护措施首先是服务器层面的优化。我一般会调整一些系统参数，比如增加TCP连接池大小，调整超时时间等。在nginx配置里也会做一些限制：# 限制单个IP的连接数 limit_conn_zone $binary_remote_addr zone=conn_limit_per_ip:10m; limit_conn conn_limit_per_ip 20; # 限制请求频率 limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; limit_req zone=req_limit_per_ip burst=20 nodelay;这些配置能在一定程度上缓解攻击，但面对大规模DDoS还是不够的。应用层防护对于CC攻击，我会在应用层做一些防护。比如给那些消耗资源大的接口加上缓存，限制单个用户的请求频率，对异常请求进行识别和拦截。我记得有次优化一个电商网站的搜索功能，发现搜索结果页面的数据库查询特别复杂。于是我们加了Redis缓存，把热门搜索词的结果缓存起来，这样即使遇到CC攻击，数据库压力也不会太大。CDN和云防护现在各大云厂商的防护产品确实很强大。我用过阿里云的DDoS高防，腾讯云的大禹，华为云的Anti-DDoS等，效果都不错。这些产品的原理基本相似：在你的服务器前面部署一层防护节点，所有流量先经过这些节点进行清洗，正常流量才会转发到你的服务器。流量清洗的那些门道流量清洗听起来很神秘，其实原理并不复杂。就是在海量的网络流量中，把攻击流量识别出来并丢弃，让正常流量通过。特征识别最基础的方法是基于特征识别。比如DDoS攻击的流量往往有一些明显特征：源IP分布异常（大量来自同一地区或AS）请求模式单一（相同的User-Agent、请求路径等）流量突增（短时间内流量暴涨几十倍）我见过一些简单的攻击，所有请求的User-Agent都一样，这种很容易识别。但现在的攻击者也越来越狡猾，会模拟各种真实的浏览器特征。行为分析更高级的清洗技术会分析用户行为。正常用户访问网站是有一定规律的，比如先访问首页，然后点击链接进入详情页，停留一段时间后再进行下一步操作。而攻击流量往往缺乏这种"人性化"的行为模式。比如一个IP在1秒钟内访问了100个不同的页面，这明显不是正常用户的行为。机器学习算法现在很多云厂商都在用AI技术来做流量清洗。通过分析历史数据，训练模型来识别攻击模式。这种方法的好处是能适应新的攻击手法，但也需要大量的数据和计算资源。阿里云的AI防护系统每天要分析TB级别的流量数据，不断优化识别算法。确实挺厉害的。各大云厂商的防护产品对比作为一个经常和各种云服务打交道的人，我对主流厂商的DDoS防护产品还是比较了解的。阿里云DDoS高防阿里云的DDoS高防应该是国内最成熟的产品之一了。我用过他们的高防IP和高防包，防护能力确实很强。高防IP适合那种可以更换IP的场景，比如游戏服务器。你把域名解析到高防IP上，所有流量先经过阿里云的清洗中心，干净的流量再转发到你的源服务器。高防包则适合不能更换IP的场景，直接对你现有的ECS进行防护。价格相对便宜一些，但防护能力也会差一点。阿里云的优势是技术比较成熟，清洗算法不断在优化。而且他们有全球的清洗中心，能就近清洗，延迟相对较低。腾讯云大禹防护腾讯云的DDoS防护产品叫大禹，分为基础防护、DDoS高防包和DDoS高防IP。我觉得腾讯云的一个优势是和他们的CDN结合得比较好。如果你同时用了腾讯云的CDN，可以在CDN层面就进行一些基础的防护，效果会更好。而且腾讯云在游戏行业有很深的积累，对游戏类的攻击防护经验比较丰富。如果你是做游戏的，腾讯云可能是个不错的选择。华为云Anti-DDoS华为云的Anti-DDoS产品我接触得相对少一些，但从技术架构上看还是很不错的。华为在网络设备方面的积累很深厚，这个优势在DDoS防护上也体现出来了。他们的清洗设备据说用的是华为自研的芯片，处理性能比较强。而且华为云在政企市场做得不错，如果你的业务对合规性要求比较高，华为云可能更合适。百度云DDoS防护百度云的DDoS防护产品在AI算法方面有一些优势，毕竟百度在AI领域积累比较深。他们的智能防护能够自动学习和适应新的攻击模式。AWS Shield防护AWS的DDoS防护产品主要是Shield，分为Shield Standard和Shield Advanced两个版本。Shield Standard是免费提供的基础防护，自动保护所有AWS资源免受常见的网络层和传输层DDoS攻击。这个服务默认就开启了，不需要额外配置，能防护SYN/UDP洪水、反射攻击等常见的Layer 3/4攻击。Shield Advanced则是付费的高级防护服务，每月费用大概3000美元起。它提供更强的防护能力，包括：更大规模的DDoS攻击防护实时攻击通知和详细报告DDoS Response Team (DRT) 24/7专家支持攻击期间的费用保护，避免因攻击导致的AWS账单激增与AWS WAF集成，提供应用层防护AWS的一个优势是和他们的全球CDN服务CloudFront结合得很好。流量先经过CloudFront的边缘节点进行初步过滤，然后再到达你的源服务器。而且AWS的基础设施本身就很强大，能够吸收大规模的攻击流量。不过AWS Shield的价格相对较高，特别是Shield Advanced，比较适合对成本不太敏感的大型企业。对于国内用户来说，还要考虑网络延迟的问题，毕竟AWS的主要节点还是在海外。实战防护配置说了这么多理论，我想分享一些具体的防护配置。Nginx层面的防护# 基础限流配置 http { limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s; limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; limit_conn_zone $binary_remote_addr zone=conn_limit:10m; # 针对不同接口的限制 location /login { limit_req zone=login burst=3 nodelay; limit_conn conn_limit 5; } location /api/ { limit_req zone=api burst=20 nodelay; limit_conn conn_limit 10; } }iptables防火墙规则# 限制单个IP的连接数 iptables -A INPUT -p tcp --dport 80 -m connlimit --connlimit-above 20 -j DROP # 限制SYN包的频率 iptables -A INPUT -p tcp --syn -m limit --limit 1/s --limit-burst 3 -j ACCEPT iptables -A INPUT -p tcp --syn -j DROP # 防护ICMP攻击 iptables -A INPUT -p icmp -m limit --limit 1/s -j ACCEPT iptables -A INPUT -p icmp -j DROP应用层防护代码在应用程序中也可以加入一些防护逻辑：import redis import time from flask import request, abort redis_client = redis.Redis(host='localhost', port=6379, db=0) def rate_limit(key, limit, window): """简单的滑动窗口限流""" current = time.time() pipe = redis_client.pipeline() pipe.zremrangebyscore(key, 0, current - window) pipe.zcard(key) pipe.zadd(key, {str(current): current}) pipe.expire(key, int(window) + 1) results = pipe.execute() if results[1] >= limit: return False return True @app.before_request def check_rate_limit(): client_ip = request.remote_addr if not rate_limit(f"rate_limit:{client_ip}", 100, 60): # 每分钟最多100个请求 abort(429) # Too Many Requests监控和告警体系完善的监控告警系统对于及时发现和处理攻击非常重要。关键监控指标我一般会监控这些指标：网络流量（入站/出站）并发连接数请求响应时间错误率（4xx/5xx状态码）服务器资源使用率（CPU、内存、磁盘IO）数据库连接数和查询响应时间告警规则设置# Prometheus告警规则示例 groups: - name: ddos_detection rules: - alert: HighTrafficVolume expr: rate(nginx_http_requests_total[5m]) > 1000 for: 2m labels: severity: warning annotations: summary: "High traffic detected" - alert: HighErrorRate expr: rate(nginx_http_requests_total{status=~"5.."}[5m]) / rate(nginx_http_requests_total[5m]) > 0.1 for: 1m labels: severity: critical annotations: summary: "High error rate detected"应急响应流程当攻击发生时，有一个清晰的应急响应流程很重要：第一阶段：快速识别查看监控告警，确认是否为攻击分析流量来源和攻击类型评估攻击规模和影响范围第二阶段：紧急处置启用高防IP或CDN防护在防火墙层面封禁明显的攻击IP调整限流策略，降低服务压力如果必要，临时下线部分非核心功能第三阶段：深度防护分析攻击特征，调整防护策略优化应用程序，提高抗攻击能力联系云厂商或安全厂商，获得专业支持准备备用方案，如备用域名、备用服务器等第四阶段：事后总结分析攻击过程和应对效果总结经验教训，完善防护体系更新应急预案和操作文档成本效益分析谈到防护，成本是个绕不开的话题。不同规模的业务需要不同的防护策略。小型网站（日PV < 10万）基础防护：使用CDN的基础DDoS防护成本：每月几百到一千元能防护10G以下的攻击中型网站（日PV 10万-100万）推荐方案：CDN + 高防包成本：每月几千到一万元能防护50G以下的攻击大型网站（日PV > 100万）推荐方案：高防IP + 多重防护成本：每月几万到十几万元能防护几百G的攻击未来发展趋势从技术发展趋势来看，我觉得有几个方向值得关注：AI智能防护机器学习在DDoS防护中的应用会越来越广泛。通过分析大量的攻击数据，AI能够识别出更复杂的攻击模式，提高识别准确率。边缘计算防护随着5G和边缘计算的发展，防护节点会更加分散，能够就近处理攻击流量，降低延迟，提高用户体验。云原生安全容器化和微服务架构的普及，推动防护技术向云原生方向发展。防护能力会更好地集成到应用架构中，实现更细粒度的防护。零信任安全模型传统的边界防护模式正在向零信任模型转变，每个请求都需要验证，这种模式对DDoS和CC攻击也有很好的防护效果。实用工具推荐在日常运维中，我经常用到这些工具：流量分析工具Wireshark：网络抓包分析ntopng：实时网络流量监控iftop：查看网络连接和流量压力测试工具Apache Bench (ab)：简单的HTTP压力测试wrk：现代化的HTTP基准测试工具JMeter：功能强大的压力测试工具监控工具Prometheus + Grafana：监控和可视化Zabbix：企业级监控解决方案Nagios：传统但稳定的监控工具法律和合规考虑在防护DDoS攻击时，也要注意法律合规问题：数据保护在收集和分析攻击数据时，要注意保护用户隐私，遵守相关的数据保护法规。证据保全如果攻击造成了重大损失，要及时保存相关证据，为后续的法律追责做准备。合规报告某些行业可能需要向监管部门报告重大安全事件，要了解相关的合规要求。写在最后DDoS和CC攻击虽然可怕，但只要我们了解其原理，采取合适的防护措施，还是能够有效应对的。关键是要有预防意识，不要等到被攻击了才想起来防护。防护不是一次性的工作，而是一个持续的过程。攻击手法在不断进化，我们的防护策略也要跟着调整。同时，要平衡安全性和成本，选择适合自己业务的防护方案。最重要的是，要建立完整的安全体系，包括技术防护、监控告警、应急响应等各个环节。只有这样，才能在面对攻击时从容应对。技术在进步，攻击手法也在进化，我们需要不断学习和适应。希望这篇文章能对大家有所帮助，在实际工作中如果遇到了相关问题，也欢迎交流讨论。防患于未然，永远比事后补救要好。在平时就做好防护准备，真正遇到攻击时才不会手忙脚乱。如果这篇文章对你有帮助，记得点赞转发哦！也欢迎在评论区分享你的经验和想法。公众号：运维躬行录个人博客：躬行笔记
Pingora实战：从零搭建比Nginx还快的反向代理，手把手教你玩转Cloudflare开源神器
前两天看到Cloudflare开源了Pingora这个项目，说是用Rust写的反向代理框架，性能比Nginx还要快，我就坐不住了。咱们做运维的，对新东西总是要保持好奇心，何况这玩意儿号称每秒能处理百万级请求，这不得赶紧试试？说实话，刚开始看到这个消息的时候我还挺怀疑的。Nginx可是咱们运维圈的老伙计了，用了这么多年，稳定可靠，你说一个新出来的框架能比它快？不过转念一想，Cloudflare可是全球最大的CDN服务商之一，他们每天处理的流量那都是天文数字，既然敢开源出来，肯定是有两把刷子的。抱着试试看的心态，我在测试环境折腾了几天，把整个安装配置流程都跑了一遍。不得不说，这东西确实有点东西，今天就把我的实践经验分享给大家，希望能帮到想尝鲜的朋友们。Pingora是个啥？咱们先聊聊Pingora到底是个什么东西。简单来说，它是Cloudflare用Rust语言开发的一个高性能反向代理框架[1]。Cloudflare大家应该都不陌生吧，全球最大的CDN服务商之一，每天处理的HTTP请求那是数以万亿计的。以前Cloudflare也是用Nginx，但是随着业务规模越来越大，Nginx的一些局限性就开始显现出来了。比如说，Nginx的配置文件虽然灵活，但是要做一些复杂的逻辑处理就比较麻烦；还有就是性能瓶颈，在某些极端场景下已经满足不了需求了。于是Cloudflare就开始自己造轮子，用Rust写了这个Pingora。根据官方的说法，Pingora在性能、安全性、可扩展性方面都有很大的提升[2]。特别是在高并发场景下，表现相当亮眼。我看过一些性能测试的对比数据，Pingora在某些场景下确实比Nginx快不少[3]。当然，这个"快"也要看具体场景，不是说什么情况下都快的。咱们做技术的，不能盲目迷信，得自己动手测试才知道。环境准备好了，说了这么多，咱们开始动手吧。不过在安装Pingora之前，得先把环境准备好。Pingora是用Rust写的，所以首先你得有Rust的环境。要是你系统里还没有Rust，那就得先装一下。我这边测试用的是debain13，其他Linux发行版应该也差不多。安装Rust其实挺简单的，官方提供了一个脚本：curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh运行这个脚本，一路回车就好。装完之后记得重启一下终端，或者手动source一下环境变量：source $HOME/.cargo/env然后验证一下安装是否成功：rustc --version cargo --version看到版本号输出就说明OK了。对了，还得提醒一下，Pingora对Rust的版本有要求，最好是1.70以上的版本。要是版本太老，可能编译的时候会报错。我一开始就是用的系统自带的旧版本，结果折腾半天编译不过，后来升级了Rust版本才解决。除了Rust，你还得有一些基础的编译工具，比如gcc、make之类的。debain13的话可以这样装：apt-get install -y gcc make openssl-devel pkg-config这些工具在编译Pingora的时候会用到，少了哪个都会报错，到时候再装就麻烦了。创建项目环境准备好了，接下来就是创建项目。Pingora不是一个独立的可执行程序，而是一个框架，你需要基于它来开发自己的代理服务。这个跟Nginx不太一样，Nginx是直接装好就能用的，Pingora更像是一个开发框架。用Cargo创建一个新项目：cargo new my_pingora_proxy cd my_pingora_proxy创建后的目录结构：然后编辑Cargo.toml文件，添加Pingora的依赖：[package] name = "my_pingora_proxy" version = "0.1.0" edition = "2024" [dependencies] pingora = { version = "0.1", features = ["proxy"] } async-trait = "0.1"这里要注意一下，Pingora的版本号可能会更新，具体可以去crates.io上看最新的版本。我写这篇文章的时候是0.1版本，现在可能已经更新了。添加依赖的时候，那个features = ["proxy"]不能少，不然编译的时候会提示找不到某些模块。我第一次搞的时候就忘了这个，折腾了好久才发现。写第一个代理程序依赖配好了，接下来就是写代码了。说实话，第一次看到Pingora的代码的时候，我还挺懵的。跟Nginx那种配置文件的方式完全不一样，Pingora是用代码来定义代理逻辑的。不过仔细看看，其实也不难理解。咱们先写一个最简单的反向代理，把请求转发到后端服务器。打开src/main.rs，把原来的内容删掉，换成下面的代码：use pingora::prelude::*; use std::sync::Arc; use async_trait::async_trait; // 确保导入了这个 trait pub struct MyProxy; #[async_trait] impl ProxyHttp for MyProxy { // 1. 必须定义上下文类型，通常用空元组 type CTX = (); // 2. 必须实现初始化上下文的方法 fn new_ctx(&self) -> Self::CTX {} async fn upstream_peer( &self, _session: &mut Session, _ctx: &mut Self::CTX, // 注意这里类型要与上面一致 ) -> Result<Box<HttpPeer>> { // 3. 这里的地址只能是 "IP:端口"，不能有 http:// let peer = Box::new(HttpPeer::new( "127.0.0.1:8080", false, "localhost".to_string(), // 通常建议填入域名 )); Ok(peer) } } fn main() { // 初始化服务器 let mut server = Server::new(None).unwrap(); server.bootstrap(); // 创建代理服务 let mut proxy = http_proxy_service( &server.configuration, MyProxy {}, ); // 监听端口 proxy.add_tcp("0.0.0.0:6199"); server.add_service(proxy); println!("Pingora Proxy 正在运行在端口 6199..."); server.run_forever(); }这段代码看起来有点长，但其实逻辑很简单。我来逐行解释一下。开头那几行是引入依赖，这个不用多说。然后定义了一个MyProxy结构体，这个就是咱们的代理主体。接下来是实现ProxyHttp trait，这个是Pingora的核心接口。里面的upstream_peer方法是用来指定后端服务器的，这里我把请求转发到了本地的8080端口。你可以根据实际情况改成你的后端地址。main函数里就是创建服务器、配置监听端口这些操作。我这里监听的是6199端口，你可以改成其他端口，只要不冲突就行。编译运行代码写好了，接下来就是编译运行了。直接用cargo：cargo build --release出现报错，处理下，简单来说：你的 Linux 系统中没有安装 cmake，或者 cmake 不在系统的环境变量路径中。由于 libz-ng-sys 这个库（它是 Pingora 的依赖之一）需要通过 cmake 来编译 C 代码，所以报错了。（感谢ai）第一次编译可能会比较慢，因为要下载和编译很多依赖包。我这边测试的时候花了大概五六分钟，具体时间要看你的网络情况和机器性能。编译完成后，会在target/release目录下生成可执行文件。运行它：./target/release/my_pingora_proxy如果没什么报错，应该就能看到程序跑起来了。不过这个时候你可能会发现，程序启动之后没有任何输出，也不退出。这是正常的，它在等待连接呢。测试一下光跑起来还不行，咱们得测试一下能不能正常工作。先准备一个后端服务。我这边就用Python简单起一个HTTP服务器：cd /tmp echo "Hello from backend" > index.html python3 -m http.server 8080这样就在8080端口起了一个简单的HTTP服务器。然后新开一个终端，用curl测试一下我们的代理：curl http://127.0.0.1:6199/如果一切正常，你应该能看到"Hello from backend"这个输出。这就说明我们的代理服务器工作正常，成功把请求转发到了后端。我当时第一次测试成功的时候还挺激动的，虽然只是个简单的代理，但毕竟是自己用Rust写的第一个能用的程序。哈哈，成就感满满。负载均衡配置简单的代理跑通了，咱们来点进阶的。实际生产环境中，后端通常不会只有一台服务器，这时候就需要负载均衡了[6]。Pingora做负载均衡也挺方便的，咱们稍微改一下代码：use async_trait::async_trait; use pingora::lb::selection::round_robin::RoundRobin; use pingora::lb::LoadBalancer; use pingora::prelude::*; use std::sync::Arc; // 定义我们的代理结构体，现在它持有一个负载均衡器 pub struct MyProxy { pub lb: Arc<LoadBalancer<RoundRobin>>, } #[async_trait] impl ProxyHttp for MyProxy { type CTX = (); fn new_ctx(&self) -> Self::CTX {} async fn upstream_peer( &self, _session: &mut Session, _ctx: &mut Self::CTX, ) -> Result<Box<HttpPeer>> { // --- 负载均衡核心逻辑 --- // 从负载均衡器中选择一个后端节点 let upstream = self .lb .select(b"", 256) // 这里的 b"" 是用于一致性哈希的 key，轮询模式下不生效 .ok_or_else(|| Error::explain(ErrorType::InternalError, "无可用后端节点"))?; println!("正在转发请求至: {:?}", upstream.addr); // 创建 HttpPeer，注意 upstream.addr 已经是 SocketAddr 类型 let peer = Box::new(HttpPeer::new(upstream, false, upstream.host().to_string())); Ok(peer) } } fn main() { let mut server = Server::new(None).unwrap(); server.bootstrap(); // 1. 配置后端列表 // 假设你本地运行了两个服务：8080 和 8081 let mut upstreams = vec![ "127.0.0.1:8080", "127.0.0.1:8081", ]; // 2. 创建负载均衡器 (使用轮询算法) let lb = LoadBalancer::from_backends(upstreams.as_slice()); let arc_lb = Arc::new(lb); // 3. 将负载均衡器注入代理服务 let mut proxy = http_proxy_service( &server.configuration, MyProxy { lb: arc_lb }, ); proxy.add_tcp("0.0.0.0:6199"); server.add_service(proxy); println!("Pingora 负载均衡代理已启动: 0.0.0.0:6199 -> [8080, 8081]"); server.run_forever(); }这段代码跟之前的区别在于，我们引入了RoundRobin，这是一个轮询的负载均衡算法。然后在upstream_peer方法里，通过lb.select()来选择一个后端服务器。backends数组里配置了三个后端服务器地址，你可以根据实际情况修改。Pingora会按照轮询的方式，依次把请求分发给这三台服务器。我测试的时候起了三个Python HTTP服务器，分别监听8080、8081、8082端口，然后用curl连续请求几次，确实能看到请求被分发到了不同的后端。效果还不错。健康检查光有负载均衡还不够，生产环境还得有健康检查。要是某台后端服务器挂了，负载均衡器得知道，不能再把请求往那台服务器上发了。Pingora支持健康检查，不过需要额外配置一下：use pingora::prelude::*; use pingora::load_balancing::{RoundRobin, health_check}; use std::sync::Arc; use std::time::Duration; pub struct MyProxy { lb: Arc<RoundRobin>, } #[async_trait::async_trait] impl ProxyHttp for MyProxy { async fn upstream_peer( &self, _session: &mut Session, _ctx: &mut Box<dyn Send + Sync>, ) -> Result<Box<HttpPeer>> { let peer = self.lb.select(b"").unwrap(); Ok(Box::new(peer)) } } fn main() { let mut server = Server::new(None).unwrap(); server.bootstrap(); let backends = vec![ "127.0.0.1:8080".to_string(), "127.0.0.1:8081".to_string(), "127.0.0.1:8082".to_string(), ]; let mut lb = RoundRobin::try_from(backends).unwrap(); // 配置健康检查 let health_check = health_check::TcpHealthCheck::new(); lb.set_health_check(health_check); lb.health_check_interval = Duration::from_secs(5); let lb = Arc::new(lb); let mut proxy = http_proxy_service( &server.configuration, MyProxy { lb: lb.clone() }, ); proxy.add_tcp("0.0.0.0:6199"); server.add_service(proxy); // 启动健康检查任务 tokio::spawn(async move { lb.run_health_check().await; }); server.run_forever(); }这段代码加了一个TCP健康检查，每5秒检查一次后端服务器的连通性。如果某台服务器连不上了，就会被标记为不健康，负载均衡器就不会再把请求发给它了。不过说实话，Pingora的健康检查功能目前还比较基础，不像Nginx那样有丰富的配置选项。毕竟是新项目，功能还在不断完善中。相信随着版本的更新，这块会越来越完善的。性能测试说了这么多，大家肯定关心性能到底怎么样。我这边简单做了个测试，用的wrk这个压测工具。测试环境：服务器：4核8G内存的云主机后端：简单的Nginx静态页面测试工具：wrk先测试Nginx：wrk -t4 -c100 -d30s http://127.0.0.1:80/然后测试Pingora代理到同样的后端：wrk -t4 -c100 -d30s http://127.0.0.1:6199/测试结果我就不贴具体数字了，因为测试条件有限，数据可能不太准确。但是从趋势来看，Pingora的性能确实不错，跟Nginx基本持平，某些场景下还稍微快一点。当然，这个测试比较简单，不能完全说明问题。实际生产环境的性能表现，还需要更全面的测试。不过从这个简单的测试来看，Pingora的性能确实有潜力。实际应用场景折腾了几天，我对Pingora有了更深的了解。那么问题来了，Pingora适合用在哪些场景呢？我觉得有这么几种情况可以考虑：需要高度定制化的代理逻辑。Nginx虽然功能强大，但是有些复杂的逻辑用配置文件很难实现，这时候用Pingora写代码就灵活多了。对性能要求极高的场景。虽然Nginx已经很快了，但是在某些极端场景下，Pingora可能还能再榨出一些性能来。想学习Rust网络编程的。Pingora的代码质量很高，是一个很好的学习案例。需要更好的安全性。Rust的内存安全特性，让Pingora在安全性方面有天然的优势。不过，Pingora目前还比较新，生态不如Nginx成熟，文档也不够完善。如果是关键业务，还是建议再观望一下，或者先在非核心业务上试试水。踩过的坑最后说说我在折腾过程中踩的一些坑，希望能帮大家少走弯路。第一个坑就是Rust版本问题。我一开始用的系统自带的Rust版本比较老，编译的时候各种报错。后来升级到最新版本才解决。所以大家一定要注意Rust版本，最好用最新的稳定版。第二个坑是依赖问题。Pingora依赖的一些库需要系统安装openssl-devel，不然编译会失败。CentOS用户记得yum install openssl-devel。第三个坑是文档不够详细。Pingora毕竟是个新项目，文档还不完善，有些功能得去看源码才能搞清楚怎么用。建议多看看GitHub上的examples目录，里面有一些示例代码可以参考[7]。第四个坑是性能调优。Pingora默认的配置可能不是最优的，需要根据实际情况调整。比如worker数量、连接池大小这些参数，都需要根据业务场景来优化。总结折腾了几天，总算把Pingora的基本功能都跑通了。整体感觉，Pingora确实是一个有潜力的项目，性能不错，代码质量也很高。不过目前还不够成熟，文档和生态都有待完善。对于想尝鲜的朋友，我建议可以先在测试环境玩玩，熟悉一下Pingora的架构和用法。但是要上生产环境的话，还是得谨慎，毕竟这是个新项目，稳定性还需要时间验证。我相信随着Pingora的不断完善，它会在反向代理领域占据一席之地。毕竟Cloudflare的技术实力摆在那里，而且他们自己就在大规模使用Pingora，这本身就是最好的证明。好了，今天的分享就到这里。如果你对Pingora感兴趣，或者在使用过程中有什么问题，欢迎留言交流。咱们一起学习，一起进步！如果觉得这篇文章对你有帮助，记得点赞、在看、转发三连哦！你的支持是我继续分享的动力！公众号：运维躬行录个人博客：躬行笔记
Linux 内存泄漏排查全攻略：从现象到根因，一文讲透生产环境实战经验
做运维这么多年，内存泄漏这事儿真是没少碰。有些时候程序跑着跑着内存就蹭蹭往上涨，重启一下好了，过两天又不行了。这种问题最烦人，不像CPU高那样容易定位，也不像磁盘满了那样好处理。内存泄漏就像个隐形杀手，藏在代码深处，等你发现的时候往往已经酿成事故了。今天咱们不聊虚的，直接上干货，把Linux下排查内存泄漏的方法系统梳理一遍。从基础的命令行工具到高级的调试技巧，能用的都给大家整理出来。内存泄漏到底是咋回事说白了就是申请了内存用完不释放。程序向系统申请了一块内存空间，用完了却没告诉系统"我不用了"，这块内存就被标记为占用状态。时间一长，这种"僵尸内存"越来越多，可用内存越来越少，最后系统扛不住了，触发OOM Killer把进程干掉，或者整个系统卡死。Linux的内存管理机制本身是没问题的，问题出在应用程序层面。C/C++这种需要手动管理内存的语言是重灾区，Java、Go这种有垃圾回收的语言理论上好点，但也不代表不会泄漏。比如Java里的对象引用没释放，或者堆外内存用多了，照样会出问题。内存泄漏有个特点：渐进性。刚启动的时候没事，运行几天甚至几周才开始显现。所以排查起来特别费劲，你得知道是哪个时间点开始泄漏的，做了什么操作触发的。怎么发现内存泄漏监控是第一道防线。我们生产环境用的是Prometheus + Grafana，内存使用率曲线一目了然。正常的内存使用应该是波动的，有升有降，垃圾回收或者缓存清理后内存会下来。如果看到一条斜向上的直线，那八成有问题。命令行下最常用的是free命令：free -h total used free shared buff/cache available Mem: 15Gi 8.5Gi 2.1Gi 256Mi 4.9Gi 6.2Gi Swap: 8.0Gi 1.2Gi 6.8Gi重点看available这列，这才是真正可用的内存。free那列看起来少，但很多是缓存，系统需要的时候可以回收。如果available持续下降，那就要警惕了。top命令更直观，能看到每个进程的内存占用：top - 14:30:25 up 10 days, 3:45, 2 users, load average: 0.52, 0.58, 0.59 Tasks: 156 total, 1 running, 155 sleeping, 0 stopped, 0 zombie %Cpu(s): 3.2 us, 1.5 sy, 0.0 ni, 94.8 id, 0.5 wa, 0.0 hi, 0.0 si, 0.0 st MiB Mem : 16384.0 total, 2150.5 free, 8720.3 used, 5513.2 buff/cache MiB Swap: 8192.0 total, 6800.2 free, 1391.8 used. 6352.1 avail Mem PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 12345 app 20 0 4.2g 3.5g 123m S 3.2 22.1 12:34.56 java按M可以按内存排序。VIRT是虚拟内存，RES是实际物理内存，SHR是共享内存。排查内存泄漏主要看RES，这个才是真正占用的物理内存。还有个命令vmstat，能看内存变化趋势：vmstat 1 10 procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 1 0 1425400 2150352 451232 5060992 0 0 2 15 12 15 3 1 95 0 0 0 0 1425400 2148320 451240 5061124 0 0 0 0 1234 2156 3 1 95 0 0看free列是不是在持续减少。si和so是swap in和swap out，如果这两个值很大，说明系统在频繁换页，内存已经紧张了。定位问题进程发现系统内存不够了，下一步就是找出是谁在搞鬼。top命令能看到进程级别的内存占用，但有时候进程下面有很多线程，想知道具体是哪个线程占的多，可以用：top -H -p <pid>这样能显示进程下所有线程的资源占用情况。还有一个很好用的命令是ps，可以按内存排序：ps aux --sort=-%mem | head -10这会列出内存占用最高的10个进程。有时候进程名都一样，比如Java程序都叫java，怎么区分？看命令行参数：ps aux | grep java或者看/proc/<pid>/cmdline：cat /proc/12345/cmdline | tr '\0' ' '找到嫌疑进程后，就要深入分析了。深入分析进程内存使用 /proc 文件系统Linux的/proc目录是个宝藏，每个进程的信息都在这里。/proc/<pid>/status里有进程的基本信息：cat /proc/12345/status Name: java Umask: 0022 State: S (sleeping) ... VmSize: 4325432 kB # 虚拟内存 VmRSS: 3521344 kB # 物理内存 VmData: 1234567 kB # 数据段 VmStk: 136 kB # 栈 VmExe: 4832 kB # 代码段 VmLib: 32456 kB # 共享库VmRSS就是进程实际占用的物理内存，这个值最关键。/proc/<pid>/maps记录了进程的内存映射：cat /proc/12345/maps 00400000-00483000 r-xp 00000000 fd:00 123456 /usr/lib/jvm/java-11/bin/java ... 7f3c40000000-7f3c40200000 rw-p 00000000 00:00 0 7f3c40200000-7f3c48000000 ---p 00000000 00:00 0能看到进程加载了哪些库，有哪些内存区域。如果发现有很多匿名映射（没有对应文件），可能是堆外内存或者内存映射文件。更详细的信息在/proc/<pid>/smaps：cat /proc/12345/smaps | head -30 7f3c40000000-7f3c40200000 rw-p 00000000 00:00 0 Size: 2048 kB Rss: 1024 kB Pss: 1024 kB Shared_Clean: 0 kB Shared_Dirty: 0 kB Private_Clean: 0 kB Private_Dirty: 1024 kB Referenced: 1024 kB Anonymous: 1024 kB AnonHugePages: 0 kB ShmemHugePages: 0 kB ShmemPmdMapped: 0 kB Swap: 0 kB KernelPageSize: 4 kB MMUPageSize: 4 kB Locked: 0 kBRss是实际占用的物理内存，Pss是按比例分摊的共享内存。Anonymous是匿名内存，通常是堆和栈。Private_Dirty是私有脏页，被进程修改过还没写入磁盘的内存。可以写个脚本统计各类内存的总量：cat /proc/12345/smaps | grep -E "^Size|^Rss|^Pss" | awk '{arr[$1]+=$2} END {for(k in arr) print k, arr[k]" kB"}'使用 pmap 命令pmap其实就是把/proc下的信息整理了一下，看起来更方便：pmap -x 12345 Address Kbytes RSS Dirty Mode Mapping 0000000000400000 4832 4832 0 r-x-- java 00000000010b3000 68 68 68 rw--- java ... 00007f3c40000000 65536 65532 65532 rw--- [ anon ] ... total kB 4325432 3521344 3521344-x参数显示扩展信息。Address是内存起始地址，Kbytes是区域大小，RSS是实际物理内存，Dirty是脏页。最后一行是汇总。如果发现某个内存区域特别大，或者[ anon ]类型的区域特别多，就要注意了。[ anon ]通常是堆内存或者堆外内存。pmap -X能显示更详细的信息，包括内存区域的保护属性、设备号等。使用 strace 追踪系统调用如果想知道进程在做什么内存操作，可以用strace：strace -p 12345 -e trace=mmap,munmap,brk这会追踪进程的内存分配系统调用。mmap是映射内存，munmap是解除映射，brk是调整堆大小。输出类似这样：mmap(NULL, 1048576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f3c41000000 brk(0x56789000) = 0x56789000 munmap(0x7f3c41000000, 1048576) = 0如果看到大量的mmap但没有对应的munmap，那很可能就是泄漏了。strace的缺点是会影响性能，生产环境慎用。而且输出量大，分析起来比较费劲。内存泄漏检测工具ValgrindValgrind是排查内存问题的神器，不仅能检测泄漏，还能检测非法访问、未初始化变量等问题。用法很简单：valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./your_program程序退出后会输出详细报告：==12345== HEAP SUMMARY: ==12345== in use at exit: 1,024 bytes in 1 blocks ==12345== total heap usage: 100 allocs, 99 frees, 10,240 bytes allocated ==12345== ==12345== 1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1 ==12345== at 0x4C2AB80: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so) ==12345== by 0x400544: main (test.c:10) ==12345== ==12345== LEAK SUMMARY: ==12345== definitely lost: 1,024 bytes in 1 blocks ==12345== indirectly lost: 0 bytes in 0 blocks ==12345== possibly lost: 0 bytes in 0 blocks ==12345== still reachable: 0 bytes in 0 blocks ==12345== suppressed: 0 bytes in 0 blocksdefinitely lost就是确定泄漏的内存，possibly lost是可能泄漏，still reachable是程序结束时还没释放但可能是有意为之。Valgrind的原理是在程序和CPU之间加了一层虚拟化，拦截所有内存操作。这导致程序运行速度会变慢10-100倍，所以生产环境基本没法用，只能在测试环境跑。对于长期运行的服务，可以让它运行一段时间后正常退出，Valgrind会输出报告。或者用kill -SIGTERM让它优雅退出。AddressSanitizer (ASan)ASan是编译器级别的内存检测工具，GCC和Clang都支持。编译时加上参数：gcc -fsanitize=address -g -O1 test.c -o test运行时如果检测到内存问题，会直接报错：================================================================= ==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x60700000eff8 READ of size 4 at 0x60700000eff8 thread T0 #0 0x400544 in main test.c:15 #1 0x7f3c4a0e082f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x2082f) ...ASan比Valgrind快很多，性能损耗大概只有2倍左右，可以在生产环境开启（如果性能要求不是特别高的话）。ASan还能检测很多Valgrind检测不到的问题，比如栈缓冲区溢出、全局缓冲区溢出等。GDB 调试GDB不仅能调试崩溃，排查内存问题也很好用。先attach到进程：gdb -p 12345查看内存映射：(gdb) info proc mappings Start End Size Offset Name 0x400000 0x408000 0x8000 0x0 /path/to/binary ...查看堆信息：(gdb) call malloc_info(0, stdout)这会输出malloc的状态信息（如果glibc版本支持的话）。还可以在malloc和free上设置断点：(gdb) break malloc Breakpoint 1 at 0x7f3c4a123456 (gdb) commands Type commands for breakpoint(s) 1, one per line. End with a line saying just "end". >backtrace >continue >end这样每次malloc都会打印调用栈。但这会让程序变得很慢，因为malloc调用非常频繁。更好的方法是条件断点，比如只追踪大于1MB的分配：(gdb) break malloc if $rdi > 1048576$rdi是第一个参数（x86_64架构），也就是分配的大小。glibc 内存分配调试glibc提供了一些环境变量来调试内存分配：MALLOC_TRACE=/tmp/malloc.log ./your_program这会记录所有的内存分配和释放操作。然后用mtrace命令分析：mtrace your_program /tmp/malloc.log输出类似：Memory not freed: ----------------- Address Size Caller 0x0000000000602010 0x400 at 0x400544: main (test.c:10)还有MALLOC_CHECK_环境变量：MALLOC_CHECK_=3 ./your_program值可以是0-3：0：不输出错误1：输出错误到stderr2：立即abort3：输出错误并abort这对检测double free、堆溢出等问题很有用。custom malloc hooks如果想要更精细的控制，可以自己实现malloc钩子：#include <malloc.h> static void *(*old_malloc)(size_t) = NULL; static void *my_malloc(size_t size, const void *caller) { void *ptr = old_malloc(size); fprintf(stderr, "malloc(%zu) = %p from %p\n", size, ptr, caller); return ptr; } void __attribute__((constructor)) init_hook() { old_malloc = dlsym(RTLD_NEXT, "malloc"); __malloc_hook = my_malloc; }编译成动态库，用LD_PRELOAD加载：gcc -shared -fPIC -ldl hook.c -o hook.so LD_PRELOAD=./hook.so ./your_program这样就能追踪所有的malloc调用了。可以记录调用栈、分配大小等信息，后期分析。Java 程序内存泄漏排查Java程序有垃圾回收，理论上不应该泄漏。但实际上还是会有问题。区分堆内和堆外内存先用jstat看堆内存：jstat -gc 12345 1000 S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT 10240 10240 0.0 8192.0 81920.0 65536.0 204800.0 102400.0 51200 48000 6144 5800 1234 12.345 12 3.456 15.801如果老年代（OC/OU）使用率不高，Full GC（FGC）次数也不多，那堆内存应该没问题。用jmapdump堆内存分析：jmap -histo:live 12345 | head -20这会输出对象统计，看哪种对象数量多、占用大。如果堆内存没问题，但进程RSS还是很高，那可能是堆外内存。Java的堆外内存包括：DirectBuffer：NIO用的直接内存Metaspace：类元数据线程栈：每个线程1MB左右native内存：JNI调用分配的Native Memory Tracking (NMT)NMT是JDK自带的工具，能追踪JVM内部内存使用。启动时加参数：-XX:NativeMemoryTracking=summary或者-XX:NativeMemoryTracking=detail更详细。运行时用jcmd查看：jcmd 12345 VM.native_memory summary输出：Native Memory Tracking: Total: reserved=2048MB, committed=1024MB - Java Heap (reserved=512MB, committed=512MB) - Class (reserved=256MB, committed=128MB) - Thread (reserved=256MB, committed=256MB) - Code (reserved=128MB, committed=64MB) - GC (reserved=128MB, committed=64MB) - Internal (reserved=256MB, committed=128MB)能清楚看到各类内存的占用情况。如果Thread那项很大，可能是线程太多；如果Internal很大，可能是DirectBuffer。DirectBuffer 泄漏DirectBuffer是堆外内存，不受GC管理。如果用完不释放，会一直占用。用JMX可以查看DirectBuffer使用情况：import java.lang.management.ManagementFactory; import java.lang.reflect.Method; public class DirectBufferInfo { public static void main(String[] args) throws Exception { Class<?> clz = Class.forName("sun.misc.SharedSecrets"); Method method = clz.getDeclaredMethod("getJavaNioAccess"); method.setAccessible(true); Object nioAccess = method.invoke(null); Method method2 = nioAccess.getClass().getDeclaredMethod("getDirectBufferPool"); method2.setAccessible(true); Object pool = method2.invoke(nioAccess); Method getCount = pool.getClass().getDeclaredMethod("getCount"); Method getTotal = pool.getClass().getDeclaredMethod("getTotalCapacity"); Method getUsed = pool.getClass().getDeclaredMethod("getMemoryUsed"); System.out.println("Count: " + getCount.invoke(pool)); System.out.println("Total: " + getTotal.invoke(pool)); System.out.println("Used: " + getUsed.invoke(pool)); } }Netty框架有内置的泄漏检测，启动参数：-Dio.netty.leakDetection.level=PARANOID会在日志里输出泄漏报告，包括创建ByteBuf的调用栈。常见内存泄漏场景排查了这么多案例，总结下来常见的泄漏场景就那么几类。缓存没有清理往静态Map里加数据，用完不删：public class Cache { private static Map<String, Object> map = new HashMap<>(); public static void put(String key, Object value) { map.put(key, value); } }这种最坑，因为静态变量生命周期和程序一样长，里面的对象永远不会被回收。解决办法是用WeakHashMap，或者定期清理。监听器没有注销注册了监听器，忘了注销：button.addActionListener(listener); // 窗口关闭时忘了 removeActionListener被监听的对象持有监听器的引用，监听器无法回收。线程没有停止创建了线程，没有停止机制：new Thread(() -> { while (true) { // do something } }).start();线程一直在运行，线程对象和它引用的对象都无法回收。连接没有关闭数据库连接、网络连接用完没关：Connection conn = dataSource.getConnection(); // 忘了 conn.close()连接池里的连接会被耗尽，导致后续请求失败。堆外内存泄漏使用DirectBuffer或者JNI调用native代码，分配的内存不受GC管理，需要手动释放。预防措施与其事后排查，不如事前预防。代码层面使用try-with-resources，确保资源释放缓存设置上限和过期时间使用WeakReference、SoftReference处理可选缓存线程池不要无限制创建线程测试层面压测时监控内存，长时间运行观察趋势使用内存分析工具做静态检查单元测试覆盖资源释放逻辑运维层面设置内存告警阈值定期重启服务（治标不治本，但能争取时间）使用cgroups限制进程内存上限排查思路总结最后总结一下排查思路：确认是内存泄漏还是正常波动：看趋势，泄漏是持续上涨不会下降定位问题进程：用top、ps找出内存占用高的进程分析进程内存分布：用pmap、/proc/smaps看是哪类内存区分堆内堆外：Java程序先用jstat看堆内存选择合适工具：开发环境用valgrind/ASan，生产环境用gdb/pmap分析代码：根据工具报告定位问题代码排查内存泄漏确实需要耐心，有时候要反复测试才能定位。但只要方法对路，总能找到问题根源。公众号：运维躬行录个人博客：躬行笔记如果觉得文章有用，帮忙点个赞、转发一下，让更多人看到。你的支持是我持续输出的动力！有问题也可以在公众号后台留言，看到会回复的。
做了这么多年运维，K8s的存储这块一直是个让人又爱又恨的东西。说它复杂吧，确实概念挺多的，PV、PVC、StorageClass这些名词听起来就头大；说它简单吧，一旦搞明白了原理，用起来还是很顺手的。今天我就把自己这些年在生产环境中踩过的坑、总结的经验分享给大家，让你们少走点弯路。为什么需要持久化存储容器本身是无状态的，这个大家都知道。Pod一重启，里面的数据就没了，这对于数据库、文件存储这些有状态应用来说简直是灾难。我记得刚开始用K8s的时候，有次MySQL的Pod重启了，结果所有数据都丢了，那个心情...简直想死的心都有。从那以后我就深刻认识到，持久化存储在K8s中有多重要。K8s的持久化存储主要解决这么几个问题：数据持久性：Pod重启、迁移后数据不丢失数据共享：多个Pod可以访问同一份数据存储抽象：屏蔽底层存储实现细节PV、PVC、StorageClass的关系这三个概念的关系，我用一个比较接地气的比喻来解释：PV（PersistentVolume）就像是一个仓库，管理员提前准备好的存储空间。PVC（PersistentVolumeClaim）就像是租仓库的申请单，用户说我要多大的空间、什么类型的。StorageClass就像是仓库的分类标准，比如高性能仓库、普通仓库、便宜仓库等。具体来说：PV（持久卷）PV是集群级别的资源，由管理员创建，代表集群中的一块存储。它包含存储实现的具体细节，比如NFS、iSCSI、云存储等。apiVersion: v1 kind: PersistentVolume metadata: name: my-pv spec: capacity: storage: 10Gi accessModes: - ReadWriteOnce persistentVolumeReclaimPolicy: Retain nfs: server: 192.168.1.100 path: /data/nfs这个配置我在生产环境用过很多次，需要注意几个点：capacity定义存储大小accessModes定义访问模式，RWO（ReadWriteOnce）表示只能被一个节点挂载persistentVolumeReclaimPolicy定义回收策略PVC（持久卷声明）PVC是用户对存储的请求，它会去寻找合适的PV进行绑定。apiVersion: v1 kind: PersistentVolumeClaim metadata: name: my-pvc spec: accessModes: - ReadWriteOnce resources: requests: storage: 5Gi这里有个坑要注意，PVC请求的存储大小必须小于等于PV提供的大小，而且访问模式也要匹配。StorageClass（存储类）StorageClass提供了动态创建PV的方式，不需要管理员手动创建PV。apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: fast-ssd provisioner: kubernetes.io/aws-ebs parameters: type: gp2 zone: us-west-2a静态供应 vs 动态供应静态供应静态供应就是管理员提前创建好PV，然后用户创建PVC去绑定。这种方式比较传统，适合小规模或者对存储有特殊要求的场景。我在一个项目中就是用的静态供应，因为客户要求数据必须存储在指定的NFS服务器上，所以只能手动创建PV。动态供应动态供应通过StorageClass自动创建PV，用户只需要在PVC中指定StorageClass即可。apiVersion: v1 kind: PersistentVolumeClaim metadata: name: dynamic-pvc spec: accessModes: - ReadWriteOnce storageClassName: fast-ssd resources: requests: storage: 20Gi这种方式更灵活，特别适合云环境。我现在基本都用动态供应，省事多了。生命周期管理PV和PVC的生命周期有几个关键阶段：Available（可用）PV已创建，但还没有被PVC绑定。Bound（已绑定）PV已经被PVC绑定，正在使用中。Released（已释放）PVC被删除，但PV还没有被回收。Failed（失败）自动回收失败。这里要特别说一下回收策略，有三种：Retain：保留数据，需要手动清理Delete：自动删除PV和底层存储Recycle：已废弃，不建议使用我在生产环境中一般用Retain策略，因为数据安全第一，宁可手动清理也不能误删数据。访问模式详解K8s支持三种访问模式：ReadWriteOnce (RWO)只能被一个节点以读写方式挂载。这是最常用的模式，适合大部分应用。ReadOnlyMany (ROX)可以被多个节点以只读方式挂载。适合配置文件、静态资源等场景。ReadWriteMany (RWX)可以被多个节点以读写方式挂载。这个模式对存储系统要求比较高，不是所有存储都支持。我遇到过一个坑，就是用了不支持RWX的存储系统，结果Pod一直处于Pending状态。后来查了半天才发现是访问模式的问题。实际生产案例案例1：MySQL数据库持久化# StorageClass apiVersion: storage.k8s.io/v1 kind: StorageClass metadata: name: mysql-storage provisioner: kubernetes.io/aws-ebs parameters: type: gp2 encrypted: "true" --- # PVC apiVersion: v1 kind: PersistentVolumeClaim metadata: name: mysql-pvc spec: accessModes: - ReadWriteOnce storageClassName: mysql-storage resources: requests: storage: 100Gi --- # Deployment apiVersion: apps/v1 kind: Deployment metadata: name: mysql spec: replicas: 1 selector: matchLabels: app: mysql template: metadata: labels: app: mysql spec: containers: - name: mysql image: mysql:8.0 env: - name: MYSQL_ROOT_PASSWORD value: "password123" volumeMounts: - name: mysql-storage mountPath: /var/lib/mysql volumes: - name: mysql-storage persistentVolumeClaim: claimName: mysql-pvc这个配置我用了好几年，非常稳定。关键点是：使用加密的EBS卷保证数据安全100Gi的存储空间足够大部分应用使用挂载到MySQL的数据目录案例2：多Pod共享存储有时候需要多个Pod共享同一份数据，比如Web应用的静态文件。# 使用NFS的PV apiVersion: v1 kind: PersistentVolume metadata: name: shared-pv spec: capacity: storage: 50Gi accessModes: - ReadWriteMany nfs: server: nfs-server.example.com path: /shared/data --- # PVC apiVersion: v1 kind: PersistentVolumeClaim metadata: name: shared-pvc spec: accessModes: - ReadWriteMany resources: requests: storage: 50Gi这种场景下必须使用支持RWX的存储系统，NFS是个不错的选择。性能优化技巧选择合适的存储类型不同的存储类型性能差异很大：SSD：IOPS高，延迟低，适合数据库HDD：容量大，成本低，适合日志、备份网络存储：可共享，但性能一般我的经验是，数据库一定要用SSD，日志可以用HDD，配置文件用网络存储。合理设置存储大小存储大小不是越大越好，要根据实际需求来：预留20-30%的空间用于扩展考虑备份和快照的空间需求定期清理无用数据监控存储使用情况我一般会用Prometheus监控存储使用率：# 存储使用率告警规则 groups: - name: storage.rules rules: - alert: PVCStorageUsageHigh expr: (kubelet_volume_stats_used_bytes / kubelet_volume_stats_capacity_bytes) * 100 > 85 for: 5m labels: severity: warning annotations: summary: "PVC storage usage is high"常见问题排查PVC一直Pending这是最常见的问题，可能的原因：没有合适的PV可以绑定StorageClass配置错误存储资源不足排查方法：kubectl describe pvc my-pvc kubectl get pv kubectl get storageclassPod挂载失败挂载失败通常是因为：节点不支持该存储类型权限问题存储系统故障我遇到过一次EBS卷挂载失败，最后发现是AWS的配额限制导致的。数据丢失这个问题最严重，预防措施：使用Retain回收策略定期备份重要数据测试恢复流程最佳实践总结根据我这些年的经验，总结几个最佳实践：生产环境优先使用动态供应，配置合适的StorageClass重要数据使用Retain回收策略，手动管理PV生命周期合理选择访问模式，大部分情况用RWO就够了监控存储使用情况，及时发现问题定期备份数据，做好灾难恢复准备存储选型建议数据库：高性能SSD + RWO + Retain策略文件服务：网络存储 + RWX + Delete策略日志收集：大容量HDD + RWO + Delete策略临时数据：本地存储 + RWO + Delete策略如果觉得这篇文章对你有帮助，别忘了点个赞、转发一下，让更多的运维小伙伴看到。你们的支持是我持续输出干货的动力！关注@运维躬行录，获取更多实战干货，我们一起在运维的道路上躬行实践！公众号：运维躬行录个人博客：躬行笔记
Docker Swarm 搞定高可用集群，生产环境再也不怕服务挂掉了
Docker Swarm是什么？Docker Swarm 是 Docker 官方推出的容器集群管理工具，基于 Go 语言实现。代码开源在：https://github.com/docker/swarm 使用它可以将多个 Docker 主机封装为单个大型的虚拟 Docker 主机，快速打造一套容器云平台。Docker Swarm 是生产环境中运行 Docker 应用程序最简单的方法。作为容器集群管理器，Swarm 最大的优势之一就是 100% 支持标准的 Docker API。各种基于标准 API 的工具比如 Compose、docker-py、各种管理软件，甚至 Docker 本身等都可以很容易的与 Swarm 进行集成。大大方便了用户将原先基于单节点的系统移植到 Swarm 上，同时 Swarm 内置了对 Docker 网络插件的支持，用户可以很容易地部署跨主机的容器集群服务。Docker Swarm 和 Docker Compose 一样，都是 Docker 官方容器编排工具，但不同的是，Docker Compose 是一个在单个服务器或主机上创建多个容器的工具，而 Docker Swarm 则可以在多个服务器或主机上创建容器集群服务，对于微服务的部署，显然 Docker Swarm 会更加适合。为什么选 Docker Swarm 而不是 K8s说实话，Kubernetes 确实强大，但对我们这种中小团队来说，K8s 的学习成本和维护成本都太高了。光是搭建一套高可用的 K8s 集群就得折腾好几天，更别说日常维护了。Docker Swarm 就不一样了，它是 Docker 原生自带的编排工具，学过 Docker 的基本上一个小时就能上手。而且功能足够强大，服务发现、负载均衡、滚动更新、健康检查这些该有的都有，完全能满足大部分生产场景的需求。客户现在的业务规模大概有 30 多个微服务，每天访问量在百万级别，用 Docker Swarm 完全够用。如果你的规模跟我差不多，真心建议别一上来就搞 K8s，Swarm 真的够用了。环境准备5 台服务器，配置如下：3 台 Manager 节点：16G 内存，4 核 CPU2 台 Worker 节点：8G 内存，2 核 CPU为什么 Manager 要 3 台？这个后面会详细说。系统用的是 CentOS 7.9，Docker 版本是 20.10.14。在开始之前，有几件事得先做好：1. 主机名和 hosts 配置每台机器都要设置不同的主机名，不然等会儿集群建起来都叫 localhost，看着就头疼。# 在 manager1 上执行 hostnamectl set-hostname manager1 # 其他机器类似，改成 manager2、manager3、worker1、worker2然后修改 /etc/hosts 文件，把所有节点的 IP 和主机名都写进去：192.168.1.101 manager1 192.168.1.102 manager2 192.168.1.103 manager3 192.168.1.104 worker1 192.168.1.105 worker22. 关闭防火墙或者开放端口Docker Swarm 需要几个端口：2377/tcp：集群管理通信7946/tcp+udp：节点间通信4789/udp：覆盖网络流量生产环境建议还是开防火墙，只开放这些端口。测试环境图省事直接关了也行：systemctl stop firewalld systemctl disable firewalld3. 时间同步这个特别重要！如果节点时间不同步，集群选举会出问题，服务调度也会乱套。我们用 chrony 做时间同步：yum install -y chrony systemctl start chronyd systemctl enable chronyd初始化 Swarm 集群准备工作做完了，现在开始正式搭建集群。在 manager1 上执行初始化命令：docker swarm init --advertise-addr 192.168.1.101执行完会输出一串命令，大概是这样的：Swarm initialized: current node (dxn1zf6l61qsb1josjja83ngz) is now a manager. To add a worker to this swarm, run the following command: docker swarm join --token SWMTKN-1-49nj1cmql0jkz5s954yi3oex3nedyz0fb0xx14ie39trti4wxv-8vxv8rssm7h7g6w7br5d7vwlh 192.168.1.101:2377 To add a manager to this swarm, run 'docker swarm join-token manager' and follow the instructions.这个 token 特别重要，一定要保存好，后面添加节点都要用它。现在集群已经有一个 Manager 节点了，查看一下集群状态：docker node ls输出类似这样：ID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION dxn1zf6l61qsb1josjja83ngz * manager1 Ready Active Leader 20.10.14能看到 manager1 是 Leader 状态。添加其他 Manager 节点前面说了要用 3 个 Manager 节点，为什么要奇数个？这个跟 Raft 共识算法有关。Docker Swarm 用 Raft 算法来管理集群状态，需要半数以上的 Manager 节点在线才能正常工作。如果你有 2 个 Manager，挂掉 1 个，剩下 1 个不满足"半数以上"的条件，集群就不可用了。但如果是 3 个 Manager，挂掉 1 个，剩下 2 个还能正常工作。所以奇数个 Manager 是最佳实践，一般 3 个就够用了，能容忍 1 个节点故障。如果想要更高的容错能力，可以用 5 个，能容忍 2 个节点故障。[2]在 manager1 上获取 Manager 加入令牌：docker swarm join-token manager会输出一个命令，复制到 manager2 和 manager3 上执行：docker swarm join --token SWMTKN-1-49nj1cmql0jkz5s954yi3oex3nedyz0fb0xx14ie39trti4wxv-8vxv8rssm7h7g6w7br5d7vwlh 192.168.1.101:2377执行完后再看集群状态：docker node lsID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION dxn1zf6l61qsb1josjja83ngz * manager1 Ready Active Leader 20.10.14 rfm5vi4vpxpmhjw6vz0t4wqdv manager2 Ready Active Reachable 20.10.14 y6jcg5z4m7x0tz1f0qz3n5k8s manager3 Ready Active Reachable 20.10.14看到 3 个 Manager 都在线了，manager1 是 Leader，其他两个是 Reachable 状态。添加 Worker 节点Worker 节点只负责运行容器，不参与集群管理。在 manager1 上获取 Worker 加入令牌：docker swarm join-token worker复制输出的命令到 worker1 和 worker2 上执行：docker swarm join --token SWMTKN-1-49nj1cmql0jkz5s954yi3oex3nedyz0fb0xx14ie39trti4wxv-8vxv8rssm7h7g6w7br5d7vwlh 192.168.1.101:2377再看集群状态：docker node lsID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION dxn1zf6l61qsb1josjja83ngz * manager1 Ready Active Leader 20.10.14 rfm5vi4vpxpmhjw6vz0t4wqdv manager2 Ready Active Reachable 20.10.14 y6jcg5z4m7x0tz1f0qz3n5k8s manager3 Ready Active Reachable 20.10.14 n8xkp2z3m5q1rt7v9w2j4h6c0 worker1 Ready Active 20.10.14 t3h7m9k2p8q5wv1n4x6j0r2b8 worker2 Ready Active 20.10.145 个节点的集群就搭建完成了。创建覆盖网络集群搭好了，但服务之间怎么通信呢？这就需要创建覆盖网络（Overlay Network）。覆盖网络允许不同节点上的容器互相通信，就像在同一个局域网里一样。docker network create --driver overlay --attachable my-overlay--attachable 参数很重要，它允许独立容器连接到这个网络。如果不加这个参数，只有 Swarm 服务才能用这个网络。查看网络：docker network lsNETWORK ID NAME DRIVER SCOPE n33wq0p5m6q7 ingress overlay swarm r2t5v8x1p4m9 my-overlay overlay swarm部署一个测试服务光搭集群没意思，咱们部署一个实际的服务试试。我这里用 Nginx 做演示，部署 3 个副本：docker service create \ --name nginx-test \ --replicas 3 \ --network my-overlay \ --publish 80:80 \ nginx:latest参数解释一下：--name：服务名称--replicas：副本数量，这里启动 3 个容器--network：使用的网络--publish：端口映射，把容器的 80 端口映射到宿主机的 80 端口查看服务状态：docker service lsID NAME MODE REPLICAS IMAGE PORTS k8z2m9p4n7q1 nginx-test replicated 3/3 nginx:latest *:80->80/tcpREPLICAS 显示 3/3，说明 3 个副本都正常运行。查看服务详情：docker service ps nginx-testID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS t3h7m9k2p8q5 nginx-test.1 nginx:latest worker1 Running Running 2 minutes ago r2t5v8x1p4m9 nginx-test.2 nginx:latest manager2 Running Running 2 minutes ago n8xkp2z3m5q1 nginx-test.3 nginx:latest worker2 Running Running 2 minutes ago 能看到 3 个容器分别运行在 worker1、manager2 和 worker2 上。Swarm 会自动把容器调度到不同的节点，保证负载均衡。现在随便访问哪个节点的 80 端口，都能看到 Nginx 的欢迎页面。这就是 Swarm 的负载均衡功能，外部请求会自动分发到各个容器。服务高可用测试说了半天高可用，到底高可用在哪里？我们来模拟一下节点故障。直接把 worker1 关机：# 在 worker1 上执行 shutdown -h now然后等个几十秒，再查看服务状态：docker service ps nginx-testID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS t3h7m9k2p8q5 nginx-test.1 nginx:latest worker1 Shutdown Shutdown 30 seconds ago k7v2m9p4n7q1 nginx-test.1 nginx:latest manager3 Running Running 10 seconds ago r2t5v8x1p4m9 nginx-test.2 nginx:latest manager2 Running Running 5 minutes ago n8xkp2z3m5q1 nginx-test.3 nginx:latest worker2 Running Running 5 minutes ago看到了吗？原来在 worker1 上运行的容器自动关闭了，Swarm 在 manager3 上重新启动了一个新的容器。整个过程完全自动，不需要人工干预。这就是高可用的核心：当某个节点挂掉，上面的服务会自动迁移到其他健康节点上运行。再测试一下 Manager 节点故障。我们把 manager1（Leader）关机：# 在 manager1 上执行 shutdown -h now等一会儿，在 manager2 或 manager3 上查看集群状态：docker node lsID HOSTNAME STATUS AVAILABILITY MANAGER STATUS ENGINE VERSION dxn1zf6l61qsb1josjja83ngz manager1 Down Active Unreachable 20.10.14 rfm5vi4vpxpmhjw6vz0t4wqdv * manager2 Ready Active Leader 20.10.14 y6jcg5z4m7x0tz1f0qz3n5k8s manager3 Ready Active Reachable 20.10.14 n8xkp2z3m5q1rt7v9w2j4h6c0 worker1 Ready Active 20.10.14 t3h7m9k2p8q5wv1n4x6j0r2b8 worker2 Ready Active 20.10.14manager1 显示 Down 和 Unreachable，而 manager2 自动变成了 Leader。这就是 Raft 协议的功劳，剩下的 2 个 Manager 节点重新选举，manager2 成了新的 Leader。集群依然正常工作，服务不受影响。部署真实业务服务测试服务跑通了，现在部署一个真实的业务。我们有个用户中心服务，用 Spring Boot 写的，依赖 MySQL 和 Redis。1. 创建 MySQL 服务docker service create \ --name mysql \ --replicas 1 \ --network my-overlay \ --mount type=volume,source=mysql-data,target=/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=your_password \ -e MYSQL_DATABASE=user_center \ --constraint 'node.role==worker' \ mysql:5.7这里用了 --constraint 'node.role==worker'，强制 MySQL 只运行在 Worker 节点上。因为 Manager 节点已经够忙了，数据库这种重服务还是放 Worker 上比较好。--mount 参数挂载了数据卷，保证数据持久化。如果不挂载卷，容器重建后数据就丢了。2. 创建 Redis 服务docker service create \ --name redis \ --replicas 1 \ --network my-overlay \ --mount type=volume,source=redis-data,target=/data \ --constraint 'node.role==worker' \ redis:6.23. 创建用户中心服务docker service create \ --name user-center \ --replicas 2 \ --network my-overlay \ -e SPRING_PROFILES_ACTIVE=prod \ -e MYSQL_HOST=mysql \ -e REDIS_HOST=redis \ --publish 8080:8080 \ your-registry.com/user-center:latest注意这里的环境变量 MYSQL_HOST=mysql 和 REDIS_HOST=redis，直接用服务名就能访问，这就是服务发现的好处。Swarm 内置了 DNS 服务器，会自动把服务名解析到对应的容器 IP。服务部署完了，看看整体状态：docker service lsID NAME MODE REPLICAS IMAGE PORTS k7v2m9p4n7q1 mysql replicated 1/1 mysql:5.7 r2t5v8x1p4m9 redis replicated 1/1 redis:6.2 t3h7m9k2p8q5 user-center replicated 2/2 your-registry.com/user-center:latest *:8080->8080/tcp所有服务都正常运行。使用 Docker Compose 文件部署上面的命令行方式适合单个服务，如果服务多了，管理起来很麻烦。Docker Swarm 支持用 Docker Compose 文件部署，方便多了。创建一个 docker-compose.yml 文件：version: '3.8' services: mysql: image: mysql:5.7 environment: MYSQL_ROOT_PASSWORD: your_password MYSQL_DATABASE: user_center volumes: - mysql-data:/var/lib/mysql networks: - my-overlay deploy: replicas: 1 placement: constraints: - node.role == worker redis: image: redis:6.2 volumes: - redis-data:/data networks: - my-overlay deploy: replicas: 1 placement: constraints: - node.role == worker user-center: image: your-registry.com/user-center:latest environment: SPRING_PROFILES_ACTIVE: prod MYSQL_HOST: mysql REDIS_HOST: redis ports: - "8080:8080" networks: - my-overlay deploy: replicas: 2 update_config: parallelism: 1 delay: 10s failure_action: rollback restart_policy: condition: on-failure delay: 5s max_attempts: 3 networks: my-overlay: external: true volumes: mysql-data: redis-data:这个文件里加了几个重要的配置：deploy.update_config：滚动更新配置，一次更新 1 个容器，间隔 10 秒，失败自动回滚deploy.restart_policy：重启策略，失败后重启，最多重试 3 次部署服务：docker stack deploy -c docker-compose.yml myapp查看 stack：docker stack lsNAME SERVICES myapp 3查看 stack 里的服务：docker stack services myapp用 stack 的好处是一次性部署多个服务，而且更新的时候也很方便，改完配置文件重新执行 deploy 命令就行。滚动更新实战业务迭代是常事，更新服务是家常便饭。传统方式更新服务，得先停掉旧版本，再启动新版本，中间会有服务中断。Docker Swarm 的滚动更新就优雅多了，它会逐步用新版本替换旧版本，保证服务始终可用。假设我们要把 user-center 服务从 v1.0 更新到 v2.0：docker service update \ --image your-registry.com/user-center:v2.0 \ --update-parallelism 1 \ --update-delay 10s \ --update-failure-action rollback \ myapp_user-center参数说明：--update-parallelism 1：一次更新 1 个容器--update-delay 10s：更新间隔 10 秒--update-failure-action rollback：更新失败自动回滚更新过程中查看状态：docker service ps myapp_user-centerID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS k7v2m9p4n7q1 myapp_user-center.1 your-registry.com/user-center:v2.0 worker1 Running Running 5 seconds ago t3h7m9k2p8q5 \_ myapp_user-center.1 your-registry.com/user-center:v1.0 worker1 Shutdown Shutdown 6 seconds ago r2t5v8x1p4m9 myapp_user-center.2 your-registry.com/user-center:v1.0 worker2 Running Running 2 minutes ago 能看到 worker1 上的容器已经更新到 v2.0，worker2 上的还是 v1.0。等 10 秒后，worker2 上的也会更新。如果新版本有问题，比如容器启动失败，Swarm 会自动停止更新并回滚到旧版本。这个功能太实用了，再也不用担心更新把服务搞挂了。服务扩缩容业务高峰期，服务扛不住了，需要快速扩容。Docker Swarm 扩容只需要一条命令：docker service scale myapp_user-center=5瞬间就把 user-center 服务扩展到 5 个副本。Swarm 会自动把新容器调度到空闲的节点上。查看扩容结果：docker service lsID NAME MODE REPLICAS IMAGE PORTS t3h7m9k2p8q5 myapp_user-center replicated 5/5 your-registry.com/user-center:v2.0 *:8080->8080/tcp缩容也一样简单：docker service scale myapp_user-center=2Swarm 会自动选择要停止的容器，保证负载均衡。私有镜像仓库配置生产环境的镜像一般都存在私有仓库里，Docker Swarm 拉取私有镜像需要认证。假设我们用 Harbor 搭建了私有仓库，地址是 registry.example.com。1. 创建 Docker 登录认证在任意 Manager 节点登录私有仓库：docker login registry.example.com输入用户名和密码，登录成功后，认证信息会保存在 ~/.docker/config.json 文件里。2. 创建 Docker Secret把认证信息创建成 Swarm Secret：docker secret create registry-auth ~/.docker/config.json3. 创建服务时使用 Secretdocker service create \ --name user-center \ --replicas 2 \ --network my-overlay \ --secret source=registry-auth,target=/root/.docker/config.json \ --publish 8080:8080 \ registry.example.com/user-center:latest这样服务就能从私有仓库拉取镜像了。监控和日志集群跑起来了，监控不能少。我们用 Portainer 来管理 Swarm 集群，用 ELK 收集日志。部署 PortainerPortainer 是个轻量级的容器管理界面，支持 Docker Swarm。docker service create \ --name portainer \ --replicas 1 \ --publish 9000:9000 \ --mount type=bind,src=/var/run/docker.sock,dst=/var/run/docker.sock \ --mount type=volume,src=portainer-data,dst=/data \ portainer/portainer-ce:latest访问任意节点的 9000 端口，就能看到 Portainer 的管理界面。可以查看所有服务、容器、网络、卷，还能在 Web 界面直接操作，非常方便。[5]日志收集容器的日志默认存在本地，不方便查看和分析。我们用 ELK（Elasticsearch + Logstash + Kibana）来收集日志。不过 ELK 部署比较复杂，这里就不展开了。简单点的方案是用 Filebeat 收集日志，发送到 Elasticsearch，然后用 Kibana 查看。踩过的坑搭建过程中遇到不少坑，记录下来给大家避雷。坑 1：Manager 节点不要跑太多服务刚开始我把所有服务都往 Manager 节点上放，结果 Manager 负载太高，集群管理都受影响。后来加了 --constraint 'node.role==worker'，把重服务都限制在 Worker 节点上，问题解决。坑 2：数据卷挂载要小心MySQL 这种有状态的服务，一定要挂载数据卷，不然容器重建后数据就丢了。而且挂载的时候要用 type=volume，不要用 type=bind，因为 bind 挂载依赖宿主机的目录结构，迁移起来很麻烦。坑 3：端口冲突多个服务不能用同一个宿主机端口。我有次部署两个服务都用 8080 端口，结果第二个服务启动失败。解决办法是每个服务用不同的端口，或者用 Ingress 路由。[4]坑 4：节点时间不同步前面说过，节点时间不同步会导致集群选举出问题。我有次重启服务器后，时间同步服务没起来，结果集群状态一直不正常。后来加了监控，定期检查时间同步状态。坑 5：网络问题Docker Swarm 用覆盖网络实现跨节点通信，如果网络配置有问题，容器之间会无法通信。有次我忘了开放 4789/UDP 端口，结果容器之间 ping 不通。排查了半天才发现是防火墙的问题。生产环境的一些建议在测试环境跑通了，上生产环境还得注意几点：1. 资源限制给每个服务设置资源限制，防止某个服务占用太多资源影响其他服务：docker service update \ --limit-cpu 2 \ --limit-memory 4G \ --reserve-cpu 1 \ --reserve-memory 2G \ myapp_user-center2. 健康检查给服务加上健康检查，Swarm 会定期检查容器状态，不健康就自动重启：healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/health"] interval: 30s timeout: 10s retries: 3 start_period: 40s3. 备份策略定期备份 Manager 节点的 /var/lib/docker/swarm 目录，里面存着集群的状态数据。如果所有 Manager 都挂了，可以用备份恢复集群。4. 监控告警部署监控（Prometheus + Grafana），设置告警规则。集群状态、服务状态、资源使用情况都要监控。5. 灰度发布更新服务时，不要一次性全部更新。可以先更新 1 个副本，观察没问题后再继续更新。用 --update-parallelism 参数控制。写在最后Docker Swarm 虽然不如 Kubernetes 强大，但对中小团队来说，绝对够用了。搭建简单，维护成本低，功能也齐全。我们公司用了快一年，除了刚开始踩了几个坑，后面一直很稳定。最重要的是，它真的解决了我们单点故障的问题。现在随便挂一台服务器，服务都能自动迁移到其他节点，再也不用半夜起来重启服务了。如果你们团队也在考虑容器编排方案，不妨先试试 Docker Swarm。等业务规模大了，再考虑迁移到 Kubernetes 也不迟。这篇文章写了好久，把搭建过程和踩过的坑都详细记录下来了。如果你也在用 Docker Swarm，或者准备搭建 Swarm 集群，希望这篇文章能帮到你。有问题欢迎在评论区留言，我会尽量回复。如果觉得文章有用，点个赞再走呗，也欢迎转发给需要的朋友。公众号：运维躬行录个人博客：躬行笔记关注公众号，获取更多运维干货文章，一起成长，一起进步！
提到搭建环境，很多兄弟可能还停留在“手工匠人”的模式。打开AWS控制台，点那个EC2，选镜像，配安全组，搞子网，再加个负载均衡……这一套下来，脑子都大了。最要命的是，你根本记不住上次那个安全组的入站规则到底是开了80端口还是8080端口。这种“雪花服务器”，配置漂移，环境不一致，简直是运维人的噩梦。这就是为什么我们今天必须得聊聊基础设施即代码这档子事儿。这玩意儿不是什么新概念，但真正能落地用好的，真能救命。咱们今天挑两个最重量级的选手——Terraform和CloudFormation，来一场从入门到实战的“保姆级”教学。别被那些英文单词吓到了，咱们用大白话把它拆开了揉碎了讲。咱们先说说这俩到底是啥如果你还没接触过IaC，可能觉得这俩词儿挺高大上。其实吧，说白了，就是把你的服务器、网络、数据库这些基础设施，像写代码一样定义出来。以前我们要建个仓库，得找施工队，拿图纸，一砖一瓦砌墙。现在有了IaC，就像是给了你一个3D打印机，你写好配置文件，告诉它我要几层楼、几个窗户、门朝哪开，一按按钮，房子自己就长出来了。CloudFormation，这可是AWS的亲儿子。它是AWS自家出的模板服务，专门用来管理AWS的资源。你想想，亲儿子肯定最了解家里的情况，所以它对AWS新特性的支持是最快的，而且跟AWS的控制台集成得非常深。你写个JSON或者YAML文件，往S3桶里一扔，CloudFormation引擎一读，哗啦啦给你创建一堆资源。如果中间出错了，它还能自动回滚，就像撤销键一样，这点特别稳。Terraform，则是HashiCorp公司搞的一个开源工具。这家公司简直是运维界的瑞士军刀厂，Vagrant、Consul都是他们家的。Terraform最大的特点就是“花心”，它不绑定任何一家云厂商。它支持AWS、Azure、Google Cloud，甚至还能管理本地的VMware、Kubernetes。这就好比你找了个全能装修队，既能给你家盖别墅，也能给隔壁老王家盖洋房，用的还是同一套图纸。这对于多云架构来说，简直是神器。那到底选哪个？如果你公司只死磕AWS一家，CloudFormation确实稳，毕竟是亲生的。但如果你不想被一家云厂商绑定死，或者将来有上多云的打算，那我强烈建议你从Terraform入手。咱们今天也主要拿Terraform来举例，顺便把CloudFormation的坑也给你填上。工欲善其事，必先装个“破”软件咱们别光说不练，先把环境搭起来。如果你用的是Mac，那简单，打开终端，如果你装了Homebrew，直接敲：brew install terraform要是Linux或者Windows，去官网下个二进制包，解压后把路径加到环境变量里就行。这没什么技术含量，我就不贴那些流水账似的教程了。装好后，敲个 terraform version，只要能蹦出版本号，那就说明成了。CloudFormation这玩意儿不需要你单独装软件，它就在AWS的控制台里，或者说它本身就是AWS的一部分。你只要有个AWS账号，有AK/SK密钥，用AWS CLI或者直接在网页上点都行。不过为了显得咱们专业点，咱们还是推荐用CLI操作，毕竟天天点鼠标那是文员干的事儿。Terraform入门：从Hello World开始咱们先来搞定Terraform。这玩意儿的核心就是那个 .tf 文件。别看到代码就头大，Terraform用的语言叫HCL（HashiCorp Configuration Language），这语言设计得特别人性化，基本上你只要会点英语，就能猜个八九不离十。咱们先建个文件夹，叫 terraform_demo，进去之后，先建个 main.tf 文件。这就像是咱们写文章的大纲，所有资源都在这儿定义。第一步，咱们得告诉Terraform，我们要用哪个云厂商的服务。这叫Provider。terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 4.0" } } } provider "aws" { region = "ap-northeast-1" # 咱们选东京区域，速度还行 # 这里默认会读取你环境变量里的 AWS_ACCESS_KEY_ID 和 AWS_SECRET_ACCESS_KEY }你看，这段代码多直白。terraform 块里声明了我们要用 hashicorp/aws 这个provider，版本大概是4.0以上的。然后 provider "aws" 里指定了区域。这就好比你去饭店，先告诉服务员我要点川菜，然后找个位置坐下。接下来，咱们来点实际的，建个最简单的EC2服务器（虚拟机）。resource "aws_instance" "my_server" { ami = "ami-0c55b159cbfafe1f0" # 这是一个Amazon Linux 2的镜像ID，不同区域不一样，得去控制台查 instance_type = "t2.micro" # 穷人版配置，免费额度能覆盖 tags = { Name = "MyFirstTerraformServer" } }这段代码里，resource 是关键词，后面跟着资源类型 aws_instance 和资源名称 my_server。花括号里面就是参数了。镜像ID（AMI）这东西比较烦人，每个地区都不一样，你得去AWS控制台那个Launch Instance界面里抄一个现成的。t2.micro 是那种能跑得动但快不起来的小水管机型，咱们测试用足够了。代码写好了，接下来就是见证奇迹的时刻。在终端里，先敲 terraform init。这个命令是干啥的呢？它是去下载刚才咱们指定的那个AWS插件。就像你装完游戏得更新一下补丁包一样。你会看到进度条哗啦啦地走，最后提示你 Terraform has been successfully initialized!。然后，敲 terraform plan。这个命令是我最喜欢Terraform的地方。它不会真的去建服务器，而是先预演一遍。它会告诉你，如果你执行了这个计划，AWS上会发生什么变化。你会看到绿色的 + 号，表示要增加资源。如果看到红色的 - 号，那就是要删除。这给了咱们一个后悔的机会。以前用脚本跑，回车键一按，想收都收不回来，手抖一下就是事故。确认无误后，最后一把梭哈：terraform apply。敲完这个命令，它会让你确认一下，输入 yes。然后你就看着终端里的日志在跑，AWS那边的控制台里，实例状态从 Pending 变成了 Running。不用登录控制台，不用点那些眼花缭乱的下一步按钮，一台服务器就这么诞生了。那种感觉，真的比手动点出来爽多了。这就是代码的力量，这就是“声明式”的魅力——我不告诉你怎么做，我只告诉你我要什么，剩下的你自己搞定。CloudFormation：AWS的亲儿子怎么玩？既然Terraform这么好用，那CloudFormation是不是就没人用了？也不是。在很多大厂，为了合规和安全，CloudFormation依然是首选。毕竟它是AWS亲生的，有些AWS的新功能，Terraform可能还得等插件更新，CloudFormation立马就能用。CloudFormation的玩法稍微硬核一点。它不支持HCL，只认JSON或者YAML。说实话，JSON那大括号套中括号的格式，写多了能把人看瞎，所以咱们一般都用YAML。咱们还是建个文件，叫 template.yaml。AWSTemplateFormatVersion: '2010-09-09' Description: 'My First CloudFormation Stack' Resources: MyEC2Instance: Type: 'AWS::EC2::Instance' Properties: ImageId: 'ami-0c55b159cbfafe1f0' InstanceType: 't2.micro' Tags: - Key: Name Value: 'MyFirstCfnServer'你看，这个结构也很清晰。Resources 下面定义资源。MyEC2Instance 是资源的逻辑名称，Type 指定了资源类型，Properties 里面写参数。这跟Terraform的逻辑其实是通的，只是语法换了身衣服。运行CloudFormation，咱们得用AWS CLI。aws cloudformation create-stack --stack-name my-first-stack --template-body file://template.yaml这命令一敲，AWS就开始干活了。你可以去控制台的CloudFormation界面看，它会创建一个叫 Stack 的东西。这个Stack就像是一个容器，把所有相关的资源都包进去了。CloudFormation有个特别牛的功能叫“回滚”。如果创建过程中某一步失败了，比如安全组配置写错了，它会自动把刚才创建成功的那些资源全给删了，保证环境干净，不留一堆烂摊子。这点对于强迫症运维来说，简直是福音。以前用Shell脚本建到一半报错，你得手动去删那一堆半成品，删漏了还得花钱，那叫一个酸爽。进阶一点：变量和状态管理刚才咱们写的都是“硬编码”，把镜像ID直接写死在代码里。这在生产环境是大忌。真正的运维，代码和配置是分离的。在Terraform里，咱们可以定义变量。建个 variables.tf 文件：variable "instance_type" { description = "The type of EC2 instance" default = "t2.micro" }然后在 main.tf 里引用：instance_type = var.instance_type。这样，以后想换个机型，改变量文件就行，不用动主逻辑。甚至可以在运行 terraform apply 的时候，通过命令行传参，或者搞个 terraform.tfvars 文件专门存环境变量。这里得提一句Terraform最让人头疼，但也最核心的东西：状态文件。你每次运行Terraform，它都会在当前目录下生成一个 terraform.tfstate 文件。这文件千万别小看，它记录了当前资源在云上的真实状态。Terraform之所以能那么聪明地知道是该创建还是该修改，全靠这个文件比对。这就带来了一个团队协作的问题。如果你把代码传到了Git，但状态文件在本地，那同事拉取代码后运行，状态就对不上了。所以，生产环境一定要把状态文件存到远端，比如S3桶里，或者用Terraform Cloud。配置远端状态也不难，在 main.tf 里加个backend配置：terraform { backend "s3" { bucket = "my-terraform-state" key = "prod/terraform.tfstate" region = "ap-northeast-1" } }这样，大家就共享一份状态，谁在改代码，谁在跑Apply，一目了然。这事儿CloudFormation就不用操心，因为状态都在AWS自己肚子里存着呢，你只要管好模板就行。这也是CloudFormation省心的地方。咱们再来聊聊“销毁”运维不仅仅是建，还得会拆。以前手动删资源，经常漏删个EIP或者EBS卷，结果月底账单一来，莫名其妙多出几百块。Terraform在这方面简直是神器。你想想，它能创建，就能销毁。只要一句 terraform destroy。这命令一敲，它会根据状态文件，把你刚才创建的所有资源，一个个按顺序删得干干净净。那种看着资源一个个消失，最后只剩下一个空目录的感觉，真的是极度舒适，解压神器。CloudFormation也一样，删掉Stack，里面的资源自然就跟着没了。不过这里有个坑，大家要注意。有些资源如果加了 prevent_deletion 的标签，或者状态文件丢失了，销毁的时候就会报错。特别是状态文件丢了，那真是叫天天不应，只能去控制台手动删，然后导入状态，那过程简直是渡劫。所以，状态文件的备份和权限管理，一定要重视，一定要重视，一定要重视！重要的事情说三遍。实战中的那些坑和心得理论讲了一堆，咱们聊点实战里的“坑”。1. 依赖关系的坑Terraform虽然能自动推断依赖，比如你建个EC2，它知道得先有VPC和Subnet。但有些隐式依赖它看不出来。比如你得等RDS数据库建好，才能往里面导数据。这时候就得显式指定 depends_on。resource "aws_instance" "web" { # ... depends_on = [aws_instance.db] }如果不加这个，Terraform可能会并行创建，结果Web服务器起起来了，数据库还没好，应用启动报错，排查半天才发现是顺序问题。2. 配置漂移这是最恶心的。你代码里写的是t2.micro，结果哪天为了临时救急，你在控制台手动把它改成了t3.medium。这时候，Terraform状态文件里记的还是t2.micro。下次一跑 terraform apply，它一看实际跟状态不符，二话不说给你改回去。这时候业务如果不兼容t2.micro，直接就崩了。解决办法就是：管住手！ 既然上了IaC，就别去控制台瞎点。实在要点，点完记得刷一下状态文件，用 terraform refresh 同步一下。或者用Terraform的 lifecycle 忽略某些变更，但这属于掩耳盗铃，不推荐。3. 模块化写到后面，你会发现 main.tf 越来越长，甚至好几个项目配置都差不多。这时候就该上模块了。把通用的配置抽出来，打个包。比如搞个 modules/web-server，以后新项目直接引用：module "my_web" { source = "./modules/web-server" instance_count = 3 }这就像写代码封装函数一样，复用性极强。Terraform官方Registry上还有好多别人写好的模块，拿来稍微改改就能用，简直是摸鱼利器。但要注意，用别人的模块得看清楚版本，有时候作者更新了接口，你一拉代码就崩了，所以版本锁定很有必要。结尾：从“搬砖”到“建筑师”说了这么多，其实Terraform和CloudFormation只是IaC世界的冰山一角。Ansible、Pulumi这些工具也都各有千秋。但不管用哪个工具，核心思想都是一样的：把基础设施当软件来管理。一旦你习惯了这种“代码即基础设施”的模式，你就再也回不去那个手动点控制台的时代了。你会发现自己从每天重复造轮子的“搬砖工”，变成了设计架构、编写蓝图的“建筑师”。以前熬夜配环境，现在代码提交，CI/CD流水线一跑，环境自动生成。那种掌控感，才是运维工作的核心价值所在。别光看，赶紧找个AWS或者阿里云的免费账号练练手。哪怕先建个VPC，再建个Bucket，慢慢你就会发现其中的乐趣。遇到报错了别怕，看日志，查文档，Stack Overflow上搜一搜，这都是必经之路。运维这条路，技术更新快，但万变不离其宗。掌握了IaC，你就在云原生时代站稳了脚跟。咱们共勉！公众号：运维躬行录个人博客：躬行笔记如果这篇文章对你有点启发，哪怕只是帮你避了一个坑，或者让你对IaC有了那么一丢丢兴趣，哪怕只是觉得这哥们写得还挺接地气，都请帮忙点赞、在看、转发三连。咱们下期见，聊聊怎么用Ansible把服务器配置管理玩出花来！
前两天有个读者在后台问我，说他们公司用了某个开源组件做项目，结果收到律师函了，说违反了开源协议。我当时心里一紧，这种事其实挺常见的，很多开发者压根不知道自己用的那些库到底有什么限制。想起来我刚开始做运维那会儿，也是一脸懵。那时候领导让我搭建个内部代码仓库，我就直接把公司代码往上一推，后来才知道有些依赖包是不能随便用的。好险没出大问题。今天就聊聊开源协议这事儿，说实话，很多人干了几年开发运维，对这个还是一知半解。特别是那个让无数SaaS公司闻风丧胆的AGPL，后面我会专门讲到。开源协议到底是啥玩意说白了，开源协议就是一套规则，告诉你这个代码你能怎么用、能怎么改、能不能商用。就像你去别人家做客，主人得跟你说清楚哪些房间能进，哪些东西能碰。很多人有个误区，觉得开源就是免费的，想咋用咋用。这想法挺危险的。开源不等于免费，也不等于没有约束。代码开源了，作者还是拥有著作权的，只是给了你一些使用权而已。我记得有次跟同事争论，他说开源代码随便用，公司项目里直接复制粘贴都行。我当时就提醒他，你看看那个项目的 LICENSE 文件写的啥。结果一看，GPL 协议，这下尴尬了。为啥要有这东西这事儿得从上世纪 80 年代说起。那时候有个叫 Richard Stallman 的程序员，他在 MIT 人工智能实验室工作。有次打印机坏了，他想修改打印机的驱动程序，结果发现厂商不给他源代码。这事儿让他很不爽，他觉得软件应该自由，用户应该有权利修改自己用的软件。于是 1983 年，他发起了 GNU 项目，1985 年成立了自由软件基金会（FSF）。这个人挺有意思，为了理想，放弃了不少东西。他创造了一个概念叫 copyleft，跟 copyright（版权）对着干。说到这儿，我得插一句。很多人分不清自由软件和开源软件。其实这俩不完全一样。自由软件强调的是用户的自由权利，开源软件更强调开发模式的开放性。不过日常交流中，大家经常混着用。主流开源协议大盘点现在主流的开源协议有好几种，每种都有自己的脾气。我一个个说。MIT 协议这个最宽松，基本上就是"你想咋用咋用，只要保留我的版权声明就行"。具体来说，你可以：随便用，商业用途也行随便改，改完可以闭源随便分发限制就一条：保留原作者的版权声明和许可声明。我见过不少项目用 MIT，比如 React、Vue.js 这些。选择 MIT 的好处是传播广，坏处是别人拿你的代码去赚钱，你也没话说。Apache 协议 2.0这个比 MIT 稍微复杂点，但也很友好。主要特点：可以商用，可以闭源修改后需要说明修改了什么保留版权声明如果有专利，自动授予专利许可这个专利授权挺重要的。有些公司开源项目，可能会涉及专利问题。Apache 2.0 就明确说了，我开源这代码，专利权也给你用了，不能以后拿专利来告你。Android 系统就是用的 Apache 2.0，这也是为什么那么多厂商能基于 Android 做自己的系统。BSD 协议这个有好几个版本：BSD 2-Clause、BSD 3-Clause 等。BSD 2-Clause 跟 MIT 差不多，就是允许自由使用，保留版权声明就行。BSD 3-Clause 多了一条：不能用原作者的名义来做广告。这条挺实用的，避免有人打着原作者的旗号搞事情。GPL 协议这个是开源协议里的刺头，也是争议最大的。GPL 的核心思想是传染性。什么意思呢？如果你用了 GPL 协议的代码，那你的项目也得开源，而且得用 GPL 协议。这就像病毒一样，会传染。所以很多商业公司对 GPL 很警惕，生怕一不小心就中招了。GPL 有两个主要版本：GPL v2 和 GPL v3。GPL v2 是最经典的，Linux 内核就是用的这个。Linus Torvalds 到现在还坚持用 v2，不用 v3。GPL v3 更严格，加了一些反 DRM 的条款。简单说就是，如果你的设备上运行的是 GPL v3 的软件，你得让用户能修改这个软件。这主要是针对 TiVo 这种设备的，TiVo 用了 Linux，但不让用户修改，所以 FSF 在 v3 里专门加了这条款。LGPL 协议这个是 GPL 的弱化版，主要给库文件用的。如果你把 LGPL 的库链接到你的程序，你的程序不需要开源。但如果你修改了这个库本身，那修改的部分得开源。很多底层库用 LGPL，比如 glibc。这样商业软件也能用，不用担心被传染。MPL 协议Mozilla Public License，Mozilla 基金会搞的。这个比较折中，文件级别的 copyleft。就是说，你修改了某个文件，这个文件得开源。但你新增的文件不需要开源。Firefox 就是用的这个协议。AGPL 3.0 - SaaS 公司的噩梦这个协议我得专门拿出来说，因为这几年太重要了，也是很多公司容易踩坑的地方。AGPL 全称是 GNU Affero General Public License v3，是 GPL 的一个变种，而且是个狠角色。为什么会有 AGPL这事儿得从 GPL 的一个"漏洞"说起。GPL 规定，如果你分发了二进制代码，就得提供源代码。但问题是，如果你通过网络提供服务，不分发二进制呢？举个例子，你用 GPL 的代码搭建了一个网站，用户通过浏览器访问你的服务，但你没有把代码给用户。这种情况下，GPL 不要求你开源。很多公司就钻了这个空子。拿 GPL 的代码改改，做成 SaaS 服务，赚钱了，但不用开源。这事儿让 FSF 很不爽。于是 2007 年，AGPL v3 出来了，专门堵这个漏洞。AGPL 的核心要求AGPL 的核心就一句话：通过网络提供服务，也算分发，也得开源。具体来说：如果你用 AGPL 的代码做了 SaaS 服务用户通过网络访问你的服务你就得提供源代码给你的用户这跟 GPL 不一样。GPL 只在你分发软件的时候要求开源，AGPL 是你提供服务就要求开源。AGPL 的传染性跟 GPL 一样，AGPL 也有传染性。而且更强。如果你的 SaaS 服务后端用了 AGPL 的组件，整个后端都得开源。前端如果跟后端紧密耦合，可能也得开源。这个"紧密耦合"怎么界定，其实挺模糊的，容易扯皮。所以很多公司干脆一刀切，禁止用 AGPL 的代码。哪些项目用 AGPL一些数据库、云原生项目用 AGPL，比如：MongoDB（早期版本，后来改成 SSPL 了）InfluxDBGrafana（部分组件）Mattermost这些项目为什么要用 AGPL？因为不想被云厂商白嫖。我辛辛苦苦开发的开源软件，你拿去做成云服务赚钱，还不回馈社区，这谁受得了。MongoDB 以前用 AGPL，后来觉得 AGPL 还不够，改成了 SSPL（Server Side Public License）。SSPL 要求更严格，提供服务的整个技术栈都得开源，包括你写的那些管理工具、自动化脚本。OSI 不承认 SSPL 是开源协议，但 MongoDB 还是坚持用了。这说明什么？开源和商业利益之间，有时候就是有冲突。AGPL 和 GPL 的区别我整理个对比：方面GPL v3AGPL v3分发二进制需要开源需要开源本地使用不需要开源不需要开源网络服务（不分发）不需要开源需要开源传染性有更强适用场景传统软件SaaS/云服务简单说，AGPL = GPL + 网络服务条款。真实案例说几个我知道的。案例一：某公司用 AGPL 组件做内部工具有个公司，运维团队用了一个 AGPL 的监控组件，部署在内网。后来法务审核，发现这个组件是 AGPL 的。内部工具要不要开源？严格来说，内部员工也算"用户"，如果他们通过网络访问这个服务，理论上应该开源。最后这个公司选择把这部分代码开源了，省得麻烦。案例二：云厂商和 AGPLAWS、Google Cloud 这些大厂，对 AGPL 很谨慎。一般禁止在云服务中使用 AGPL 的代码。为什么？因为一旦用了，可能就得把整个服务栈开源。这对商业公司来说，是不可接受的。公司如何应对 AGPL如果你是做 SaaS 的，我的建议：策略一：坚决不用最简单的办法，直接禁止引入 AGPL 的依赖。很多公司都是这么干的。策略二：隔离使用如果非要用，做好隔离。比如用 API 调用，而不是直接引入代码。但这个"隔离"够不够，法律上还有争议。策略三：购买商业许可很多 AGPL 的项目提供商业许可，花钱买就完事了。MongoDB 就是这样，既开源又卖商业版。策略四：开源如果你的商业模式不依赖代码保密，那就开源呗。现在很多公司就是开源+商业服务的模式。各种协议到底限制了啥我整理个表格对比一下，把 AGPL 也加进去，不过先说清楚，这只是一般情况，具体还得看协议原文。协议能否商用能否闭源网络服务是否需要开源传染性专利授权MIT✓✓✗无✗Apache 2.0✓✓✗无✓BSD✓✓✗无✗GPL v3✓✗✗有✓LGPL✓✓✗弱✓AGPL v3✓✗✓更强✓这里有个坑要注意，GPL 和 AGPL 的商用是允许的，但前提是你得开源。所以很多公司直接一刀切，禁止用 GPL 和 AGPL 的代码。历史上那些著名的开源协议事件说几个真实的案例。MySQL 和 GPLMySQL 是 GPL 协议的，但 MySQL AB 公司还提供商业许可。什么意思呢？如果你不想开源你的项目，你可以花钱买商业许可。这种双许可模式挺聪明的，既保证了开源，又能赚钱。后来 Oracle 收购了 MySQL，这套模式还继续用着。BusyBox 和 GPL 诉讼BusyBox 是个嵌入式 Linux 工具集，GPL 协议。有好几家公司因为用了 BusyBox 但不开源，被起诉了。2007 年，Monsoon Multimedia 被起诉，最后和解了，同意开源相关代码。还有几起类似的诉讼，都是 GPL 违规。这事儿告诉我们，GPL 是认真的，不是开玩笑的。React 许可证风波2017 年，Facebook 的 React 项目用的不是标准协议，而是 BSD + 专利授权。这个专利授权条款有争议，说如果用户起诉 Facebook 专利侵权，Facebook 可以撤销 React 的专利授权。Apache 基金会直接宣布禁止在 Apache 项目中使用 React。后来 Facebook 妥协了，把 React 改成了 MIT 协议。这事儿闹得挺大，也让大家意识到开源协议的法律问题有多重要。我踩过的坑说说我自己的经历。坑一：没看 LICENSE 文件有一回，我在项目里用了一个开源工具，做日志分析。当时觉得挺好用的，直接就上了生产环境。后来要给客户交付，法务审核的时候发现，那个工具是 GPL v3 协议的。这下麻烦了，要么我们整个项目开源，要么换工具。最后只能连夜换工具，加班加点重新搞。从那以后，我养成了习惯，引入任何依赖，先看 LICENSE。坑二：以为开源就是免费有个项目用了某个开源数据库，觉得开源的嘛，随便用。后来才知道，商业使用需要付费，或者得开源你的项目。还好那个项目本身就是要开源的，没出问题。但这个教训我记住了，开源不等于免费商用。坑三：修改了代码没说明用了 Apache 2.0 的项目，改了里面的代码，但没在文件里说明修改了什么。严格来说，这是违反协议的。虽然没人追究，但合规意识得有。坑四：SaaS 服务用了 AGPL 组件这个是我同事踩的坑。他们团队用了一个 AGPL 的库做后端服务，后来法务审核才发现。那个库的功能挺核心的，换掉成本很高。最后没办法，只能跟对方谈商业授权，花了一笔钱。从那以后，公司就把 AGPL 列入黑名单了。怎么选择开源协议如果你要开源自己的项目，怎么选协议？我的建议是：想传播得广，不在乎别人拿去赚钱，选 MIT 或 BSD。想传播得广，但有专利问题，选 Apache 2.0。想保证代码永远开源，不想被别人拿去闭源商用，选 GPL。写的是库文件，想让商业软件也能用，选 LGPL 或 MIT。如果你做的是云服务相关的项目，不想被云厂商白嫖，可以考虑 AGPL，但要做好传播受限的准备。说实话，大多数情况下，MIT 或 Apache 2.0 就够了。GPL 虽然理想主义，但确实限制了传播。AGPL 更是双刃剑，用之前要三思。公司里怎么管理开源协议风险这个话题展开说能说一整篇，简单讲几点。建立内部的开源组件审核流程。引入新的依赖，必须经过审核，看看协议是什么，有没有风险。维护一个白名单和黑名单。白名单里的协议可以直接用，黑名单里的坚决不用，灰名单里的需要审批。一般来说，MIT、Apache 2.0、BSD 在白名单；GPL 在灰名单，需要审批；AGPL 直接进黑名单。用工具扫描。有些工具能自动扫描代码库，识别用了哪些开源组件，协议是什么。比如 Black Duck、FOSSA、Sonatype 这些。培训开发人员。很多时候问题出在开发人员不懂，随手就引入了有问题的依赖。定期培训，讲讲开源协议知识，很有必要。一些常见误区说几个我经常听到的错误认识。误区一：开源就是公有的错。开源软件是有版权的，作者只是给了你使用权。你不遵守协议，就是侵权。误区二：没写 LICENSE 就是随便用错。没写 LICENSE，默认是版权所有，你没有任何使用权。联系作者，让他明确授权。误区三：GPL/AGPL 不能商用错。GPL/AGPL 可以商用，只是要求开源。很多公司靠 GPL 软件赚钱，比如 Red Hat。误区四：用了开源代码，只要开源就行不一定。有些协议要求保留版权声明，有些要求说明修改内容，有些要求用同样的协议。光开源不够，还得按规矩来。误区五：内部使用不用管协议也不对。AGPL 对内部使用也有要求，而且内部使用如果涉及分发，GPL 也会触发。实用工具推荐最后推荐几个工具，帮你看懂开源协议。choosealicense.com - GitHub 官方的，帮你选择协议。tldrlegal.com - 用通俗语言解释各种法律条款，包括开源协议。SPDX License List - 标准化的协议标识符列表，方便识别。GitHub 上也有协议识别功能，项目里有 LICENSE 文件，会自动显示协议类型。license-checker - npm 包，能扫描项目的依赖协议。写在最后开源协议这事儿，说复杂也复杂，说简单也简单。核心就一点：尊重原作者的意愿，按规矩来。我见过太多因为不懂开源协议惹麻烦的了。有的是无心之失，有的是心存侥幸。不管哪种，最后都得付出代价。特别是现在 SaaS 和云服务这么普及，AGPL 这种协议越来越常见。做运维和开发的，必须得搞清楚这些协议的区别，不然哪天踩坑了都不知道怎么回事。做技术这么多年，越来越觉得，技术能力是一方面，法律意识、合规意识同样重要。你可以不懂法律的细节，但得有这个意识，知道什么时候该咨询专业人士。开源社区给了我们这么多好东西，我们也应该尊重规则，回馈社区。这样生态才能良性循环。希望这篇文章能帮到大家。如果觉得有用，点个赞，转发给更多人看到。有什么问题，评论区留言，我看到都会回复。公众号：运维躬行录个人博客：躬行笔记
深度拆解Tomcat：从架构设计到生产实践，这些年踩过的坑都在这里了
说起Tomcat，估计每个搞Java开发和运维的同学都不陌生。但是真要问你Tomcat的内部工作原理，能完整说清楚的人其实不多。我在生产环境摸爬滚打这些年，从最开始的小白到现在能独当一面，Tomcat这个"老朋友"没少让我吃苦头。今天就把我这些年对Tomcat的理解和实战经验分享给大家，不是那种照本宣科的理论，而是真正在生产环境中能用得上的干货。为什么要深入了解Tomcat前段时间公司新来了个小伙子，部署应用的时候直接用默认配置就上线了。结果呢？服务刚上线没几天，用户量一上来就开始各种502、504错误。领导找过来问情况，那个场面...想想都尴尬。这种事情其实很常见，很多人觉得Tomcat嘛，下载下来启动就能用，有什么难的？但实际上，默认配置的Tomcat在生产环境基本上就是个定时炸弹。Tomcat的核心架构到底是怎么回事我记得刚开始接触Tomcat的时候，各种概念搞得我云里雾里的。什么Connector、Container、Engine、Host...听起来就头大。后来慢慢摸索，发现其实Tomcat的架构设计还是很有意思的。Server - 最顶层的管理者Server就是整个Tomcat实例，一个JVM进程里只有一个Server。你可以把它理解成一个大管家，负责管理下面所有的组件。在server.xml里面，最外层的<Server>标签就是它。我之前遇到过一个问题，就是在同一台机器上部署多个Tomcat实例的时候，Server的shutdown端口冲突了。默认是8005端口，如果不改的话，第二个实例启动就会报错。这种小细节在生产环境特别容易被忽略。Service - 服务的组织者Service在Server下面，主要作用是把Connector和Engine组织在一起。通常情况下，一个Server里只有一个Service，叫做"Catalina"。Connector - 网络通信的门面Connector是Tomcat对外提供服务的入口，负责监听端口、接收请求。这里面有两个重要的组件：Coyote - 这是Tomcat的HTTP连接器，专门处理HTTP协议。它支持多种I/O模型，比如BIO、NIO、NIO2、APR。我在生产环境一般都用NIO，性能和稳定性都不错。记得有一次线上出现了大量的TIME_WAIT连接，排查了半天发现是BIO模式下的连接没有及时释放。后来切换到NIO模式，问题就解决了。Jasper - 这是JSP引擎，负责把JSP页面编译成Servlet。虽然现在前后端分离比较多，JSP用得少了，但在一些老项目里还是经常能见到。Container - 业务处理的核心Container是一个层次结构，从外到内分别是Engine、Host、Context、Wrapper。Engine - 整个Servlet引擎，一个Service只有一个Engine。它负责处理所有的请求，并将请求分发给合适的Host。Host - 代表一个虚拟主机，可以配置多个域名指向同一个Tomcat实例。比如你可以让www.example.com和api.example.com都指向同一个Tomcat，但处理不同的应用。Context - 代表一个Web应用，也就是我们部署的WAR包或者目录。每个Context有自己独立的类加载器，这样不同应用之间就不会互相影响。Wrapper - 代表一个Servlet，是最小的处理单元。请求处理的完整流程理解了架构，我们来看看一个HTTP请求在Tomcat中是怎么被处理的。当用户在浏览器输入网址访问我们的应用时，请求会这样流转：Connector接收请求 - Coyote监听到8080端口有请求进来，创建Request和Response对象协议解析 - 解析HTTP协议，提取请求头、请求体等信息请求分发 - Engine根据Host头信息找到对应的虚拟主机应用定位 - Host根据URL路径找到对应的Context（Web应用）Servlet匹配 - Context根据URL模式匹配到具体的Servlet业务处理 - Wrapper调用Servlet的service方法处理业务逻辑响应返回 - 处理结果通过Connector返回给客户端这个过程看起来复杂，但实际上Tomcat内部有很多优化。比如对象池、连接池等技术，避免频繁创建销毁对象。生产环境的配置优化说了这么多理论，现在来点实际的。我把这些年在生产环境踩过的坑和总结的经验分享一下。JVM参数调优这是最基础也是最重要的。我见过太多因为JVM参数配置不当导致的生产事故。export JAVA_OPTS="-server -Xms4g -Xmx4g -XX:NewRatio=1 -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:CMSFullGCsBeforeCompaction=0"这里几个关键点：-Xms和-Xmx设置成一样大，避免堆内存动态扩展的性能损耗NewRatio=1表示新生代和老年代1:1，对于Web应用比较合适使用CMS垃圾收集器，减少Full GC的停顿时间我之前遇到过一个案例，应用经常出现几秒钟的停顿，用户体验很差。后来发现是默认的Serial GC导致的，换成CMS后问题就解决了。Connector优化默认的Connector配置在高并发场景下肯定是不够用的：<Connector port="8080" protocol="org.apache.coyote.http11.Http11NioProtocol" connectionTimeout="30000" maxConnections="2000" maxThreads="500" minSpareThreads="50" acceptCount="200" enableLookups="false" compression="on" compressionMinSize="2048" compressableMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json"/>这些参数的含义：maxConnections: 最大连接数，超过这个数的连接会被放到队列里等待maxThreads: 最大工作线程数，处理请求的核心参数acceptCount: 当所有线程都忙的时候，队列中等待的连接数compression: 开启压缩，能显著减少网络传输量内存和线程池调优Thread Pool的配置直接影响并发处理能力：<Executor name="tomcatThreadPool" namePrefix="catalina-exec-" maxThreads="500" minSpareThreads="50" maxIdleTime="60000" prestartminSpareThreads="true"/>我一般会根据服务器的CPU核心数来设置线程数。经验值是CPU核心数的2-4倍，但具体还是要根据业务特点来调整。I/O密集型的应用可以设置更多线程，CPU密集型的就要控制一下。APR模式的使用如果你的服务器并发量特别大，建议使用APR模式。APR（Apache Portable Runtime）是Apache的可移植运行库，用C语言实现，性能比Java NIO还要好。安装APR：# CentOS yum install apr-devel openssl-devel # Ubuntu apt-get install libapr1-dev libssl-dev然后在Connector中配置：<Connector port="8080" protocol="org.apache.coyote.http11.Http11AprProtocol" />我在一个高并发的电商项目中使用APR，QPS从原来的3000提升到了8000+，效果还是很明显的。实际部署中的坑和解决方案多实例部署单机部署多个Tomcat实例是很常见的需求，但要注意端口冲突问题。除了HTTP端口（8080），还有shutdown端口（8005）和AJP端口（8009）都要修改。我写了个简单的脚本来批量创建多实例：#!/bin/bash BASE_PORT=8080 SHUTDOWN_PORT=8005 AJP_PORT=8009 for i in {1..3}; do INSTANCE_NAME="tomcat$i" HTTP_PORT=$((BASE_PORT + i - 1)) SHUTDOWN=$((SHUTDOWN_PORT + i - 1)) AJP=$((AJP_PORT + i - 1)) cp -r $CATALINA_HOME $INSTANCE_NAME sed -i "s/8080/$HTTP_PORT/g" $INSTANCE_NAME/conf/server.xml sed -i "s/8005/$SHUTDOWN/g" $INSTANCE_NAME/conf/server.xml sed -i "s/8009/$AJP/g" $INSTANCE_NAME/conf/server.xml done日志管理默认的日志配置在生产环境是不够用的。我一般会这样配置：# logging.properties handlers = 1catalina.org.apache.juli.AsyncFileHandler, 2localhost.org.apache.juli.AsyncFileHandler 1catalina.org.apache.juli.AsyncFileHandler.level = INFO 1catalina.org.apache.juli.AsyncFileHandler.directory = ${catalina.base}/logs 1catalina.org.apache.juli.AsyncFileHandler.prefix = catalina. 1catalina.org.apache.juli.AsyncFileHandler.maxDays = 30 1catalina.org.apache.juli.AsyncFileHandler.rotatable = true特别要注意的是日志轮转，不然日志文件会越来越大，最后把磁盘撑爆。我就遇到过这种情况，某天早上收到监控报警，磁盘使用率100%，查看发现catalina.out文件有20多G...监控和诊断生产环境必须要有完善的监控。我一般会监控这些指标：JVM指标：堆内存使用率、GC频率、线程数Tomcat指标：活跃线程数、请求处理时间、错误率系统指标：CPU、内存、磁盘I/O、网络I/O可以使用JConsole、VisualVM等工具，也可以集成Prometheus + Grafana做监控大盘。性能调优实战案例去年有个项目，用户反馈页面加载很慢。我们排查发现是数据库连接池配置有问题： <Resource name="jdbc/MyDB" type="javax.sql.DataSource" maxTotal="20" maxIdle="10" maxWaitMillis="10000"/> <Resource name="jdbc/MyDB" type="javax.sql.DataSource" maxTotal="100" maxIdle="50" minIdle="20" maxWaitMillis="30000" testOnBorrow="true" validationQuery="SELECT 1"/>调整后响应时间从平均2秒降到了500ms以内。容器化部署的注意事项现在很多公司都在用Docker部署应用，Tomcat的容器化也有一些需要注意的地方。Dockerfile优化FROM openjdk:8-jre-alpine # 安装APR支持 RUN apk add --no-cache apr-dev openssl-dev # 复制Tomcat COPY tomcat /opt/tomcat # 设置环境变量 ENV CATALINA_HOME=/opt/tomcat ENV CATALINA_BASE=/opt/tomcat ENV JAVA_OPTS="-server -Xms2g -Xmx2g -XX:+UseG1GC" # 暴露端口 EXPOSE 8080 CMD ["/opt/tomcat/bin/catalina.sh", "run"]资源限制在K8s中部署时要合理设置资源限制：resources: requests: memory: "2Gi" cpu: "1000m" limits: memory: "4Gi" cpu: "2000m"内存限制要和JVM堆内存设置匹配，不然容器可能会被OOMKilled。故障排查经验这些年遇到的Tomcat故障不少，分享几个典型的排查思路：内存溢出最常见的就是OutOfMemoryError。遇到这种问题，要先看是哪种类型的OOM：java.lang.OutOfMemoryError: Java heap space - 堆内存不够java.lang.OutOfMemoryError: PermGen space - 永久代不够（Java 8以前）java.lang.OutOfMemoryError: Metaspace - 元空间不够（Java 8及以后）可以通过添加JVM参数来生成堆转储文件：-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/然后用MAT或者VisualVM分析内存泄漏的原因。线程死锁有时候应用会突然卡住不响应，这时候要检查是否有死锁：# 获取进程PID jps # 生成线程转储 jstack <pid> > thread_dump.txt在转储文件中搜索"BLOCKED"关键字，通常能找到死锁的线程。响应时间慢这种问题比较复杂，需要从多个角度排查：检查GC日志，看是否有长时间的停顿查看数据库慢查询日志分析应用日志，找出耗时的操作使用APM工具做链路追踪写在最后Tomcat作为Java Web应用的重要基础设施，深入理解它的工作原理对我们的日常运维工作帮助很大。这篇文章总结了我这些年的实战经验，希望能帮到大家少踩一些坑。当然，技术是不断发展的，Tomcat也在持续更新。现在Tomcat 10已经支持Jakarta EE了，还有很多新特性值得关注。我们做运维的，就是要保持学习的心态，不断提升自己的技能。如果你在生产环境中遇到了Tomcat相关的问题，欢迎留言讨论。大家一起交流，共同进步！记得关注@运维躬行录，我会持续分享更多实战经验和技术干货。如果这篇文章对你有帮助，别忘了点赞转发哦~公众号：运维躬行录个人博客：躬行笔记
生产环境 Nginx 代理 WebSocket 总断连？这几个隐藏配置坑了多少人
前两天半夜正准备关机睡觉，微信突然响个不停。消息连着发了好几条，全是感叹号，说他们新上线的即时通讯服务炸了，客户端连两分钟都撑不住就掉线，重启服务好了一小会儿，马上又不行了。我看了一眼他发来的 Nginx 配置截图，再看了看报错日志，心里大概就有数了。这情况太典型了，很多兄弟在搞 WebSocket 代理的时候，都是照着网上的教程一顿抄，proxy_pass 一配，upstream 一挂，测试环境通了一下就觉得完事了。结果一上生产，并发上来，时间一长，各种稀奇古怪的问题全冒出来。WebSocket 这玩意儿，看着简单，不就是 HTTP 协议升个级嘛，但在 Nginx 这一层面，如果不把它的脾气摸透，真的能把人折腾死。今天咱们就借着这个实际案例，把 Nginx 代理 WebSocket 这件事彻底掰开了揉碎了讲讲，特别是那些没人告诉你、但生产环境一定会遇到的坑。咱们先得搞清楚，WebSocket 到底是个啥，为什么 Nginx 默认的 HTTP 代理配置搞不定它。平时咱们浏览网页，大多是 HTTP 协议。这玩意儿是个“哑巴”模式，客户端发个请求，服务器回个响应，这就完事了，连接断开。就像你去食堂打饭，你喊一句“阿姨来份红烧肉”，阿姨给你打一勺，交易结束，你俩就没关系了。下次想要米饭，得重新排队、重新喊。但是 WebSocket 不一样。它是一次握手，长久通话。就像你跟对象打电话，拨通那一刻（握手），双方的话筒（连接）就一直通着。你想说话就说，对方想回话就回，不用每次都重新拨号。这就是所谓的全双工通信。对于即时通讯、在线游戏、股票行情这种需要服务器主动推数据给客户端的场景，简直是神器。那问题来了，Nginx 作为一个反向代理，默认是处理 HTTP 请求的。它就像一个中间商，负责把客户的请求转给后端的服务器。对于普通的 HTTP 请求，Nginx 转发完就等着后端回包，然后发给客户，任务结束。但 WebSocket 来了，Nginx 就有点懵了。WebSocket 的建立过程，首先是发一个 HTTP 请求，但是这个请求头里带了点“私货”：Upgrade: websocket Connection: Upgrade这两句话的意思就是告诉服务器：“哥们，我不发普通网页请求了，咱们把协议升个级，换成 WebSocket 玩玩吧。”如果 Nginx 不做特殊配置，它看到这个请求，会把它当成普通 HTTP 请求转给后端。后端服务器（比如 Node.js、Go 或者 Java）收到请求，一看头信息，知道要升级协议，就会返回一个 101 Switching Protocols 的状态码。这时候，如果 Nginx 还傻傻地等着后端返回 HTTP 200 或者 404，那连接就断了。因为 101 状态码意味着协议切换，之后的数据流不再是标准的 HTTP 报文，而是 TCP 数据流了。所以，要让 Nginx 成功代理 WebSocket，核心就是得让它“睁一只眼闭一只眼”，允许协议升级，并且把这个升级的请求头原封不动地传给后端，同时还要保持这条连接长时间不断开。咱们来看看小张发给我的那个“有问题”的配置，估计很多人也是这么配的：upstream ws_backend { server 192.168.1.100:8080; } server { listen 80; server_name ws.example.com; location /ws { proxy_pass http://ws_backend; } }这配置看着挺干净，没毛病吧？测试环境测一下，发个消息，能收到。然后大家就以为搞定了。实际上，这配置在生产环境就是个“定时炸弹”。第一个坑，也是最直接的坑，就是握手失败。很多时候你会发现，浏览器控制台报 WebSocket connection failed，状态码 400 或者 502。这是因为 Nginx 默认转发请求的时候，可能会修改或者丢弃 Upgrade 和 Connection 这两个头。后端收不到这两个头，就不知道你要升级协议，自然就按普通 HTTP 请求处理了，结果就是握手失败。所以，咱们得明确告诉 Nginx，把这两个头给我传过去。标准配置得这么写：location /ws { proxy_pass http://ws_backend; # 关键配置：必须使用 HTTP 1.1 版本 proxy_http_version 1.1; # 透传 Upgrade 头 proxy_set_header Upgrade $http_upgrade; # 设置 Connection 头 proxy_set_header Connection "Upgrade"; }这里有个细节，proxy_http_version 1.1; 这一行千万别漏了。WebSocket 是依赖 HTTP/1.1 的特性的，HTTP/1.0 不支持。你要是不写，Nginx 可能会用 1.0 去连后端，那肯定不行。还有个细节，proxy_set_header Connection "Upgrade"; 这里直接写死了 Upgrade。其实网上有些教程会写 proxy_set_header Connection $connection_upgrade;，然后在 http 块里做个 map 映射。那种写法更严谨一点，可以根据客户端的请求头动态设置，但在大多数简单场景下，直接写死 Upgrade 也能用，咱们先以简单为主，能用再说。配置改完，重载 Nginx，以为这就完了？小张说，他们改了配置后，握手确实成功了，消息也能发了，但是过了一会儿，大概一两分钟没操作，连接就自动断了。这就是第二个大坑：超时设置。咱们刚才说了，WebSocket 是长连接。可能客户端和服务器建立连接后，很久都不说话，就那么挂着，等有消息了再推。比如你看股票软件，可能几分钟都不动一下。但是 Nginx 默认是个急性子。它有个参数叫 proxy_read_timeout，默认值是 60 秒。这玩意儿的逻辑是：如果 Nginx 在 60 秒内没有从后端服务器读到任何数据，它就认为后端挂了或者网络出问题了，为了释放资源，它会主动切断这个连接。这下你明白了吧？用户连上来，发两句消息，然后去倒杯水，回来一看，断了。因为 Nginx 等了 60 秒没见后端有数据过来，直接把连接掐了。解决办法很简单，把超时时间设长点。location /ws { # ... 之前的配置 ... # 延长超时时间，比如设成 1 小时，或者更长 proxy_read_timeout 3600s; proxy_send_timeout 3600s; }这里我设成了 3600 秒（1 小时）。具体设多少，得看你的业务场景。有些业务是 24 小时挂机的，那你就得设得更大，或者干脆在业务层做心跳机制。说到心跳，这其实是解决超时问题的最佳实践。光靠 Nginx 延长超时有时候不够稳妥，因为网络链路上不光有 Nginx，还有防火墙、负载均衡器（比如阿里云的 SLB、AWS 的 ELB），它们也有超时时间。最稳妥的办法是，客户端每隔 30 秒或者 45 秒，给服务器发一个心跳包，内容可以是空的，或者一个简单的字符串。服务器收到心跳包，回一个响应。这样，Nginx 就能检测到连接上有数据流过，就不会触发超时断连了。这就像是给连接“续费”一样。小张他们后来加了心跳，超时问题解决了，以为这就稳了。结果压力测试一来，又出幺蛾子了。用户反馈，有时候发消息，服务器收不到，或者服务器推的消息，用户收不到，而且这现象是随机的。一查日志，发现是因为他们后端做了集群，用了 Nginx 做负载均衡。这就引出了第三个坑：负载均衡的会话保持。WebSocket 是有状态的。客户端跟哪台后端服务器建立了连接，后续的通信就必须在这台服务器上进行。因为连接的上下文是保存在那台服务器的内存里的。如果 Nginx 的负载均衡策略配置不当，比如用了默认的轮询，就会出大问题。举个例子，客户端第一次请求握手，Nginx 把请求转发给了服务器 A，握手成功，连接建立。这时候，连接是存在于服务器 A 上的。过了一会儿，客户端发了一条消息，这个请求到了 Nginx。因为负载均衡是轮询，Nginx 把这个请求转发给了服务器 B。服务器 B 一看：“哥们，你是谁啊？我内存里没你的连接信息啊。” 于是服务器 B 就无法处理，或者直接报错，连接也就废了。所以，WebSocket 的负载均衡，必须得做会话保持，也就是“粘性会话”。最简单的办法是用 ip_hash。根据客户端的 IP 地址，算一个 hash 值，同一个 IP 的请求永远转发给同一台后端服务器。upstream ws_backend { ip_hash; server 192.168.1.100:8080; server 192.168.1.101:8080; }这样，同一个客户端的连接就会一直打在同一台机器上，问题就解决了。但是 ip_hash 有个缺点，如果某个 IP 的流量特别大，那台后端服务器压力就会很大，而且如果那台服务器挂了，hash 算法会重新计算，可能会导致一部分用户掉线。更高级一点的玩法是用 sticky cookie 模块，或者后端用 Redis 共享连接状态。不过对于大多数中小规模的应用，ip_hash 已经够用了。后来上了 ip_hash，随机掉线的问题立马就没了。还有个事儿，也是容易被忽略的，就是 HTTPS。现在谁还敢用 HTTP 啊，全是 HTTPS。WebSocket 对应的就是 WSS。如果你的前端页面是 HTTPS 的，那 WebSocket 连接也必须是 WSS，否则浏览器会直接拦截，报混合内容错误。Nginx 配置 WSS 其实也不难，就是普通的 HTTPS 配置加上刚才那些 WebSocket 的配置。server { listen 443 ssl; server_name ws.example.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; location /ws { proxy_pass http://ws_backend; # 这里依然是 http，因为 Nginx 到后端通常走内网 HTTP proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; proxy_set_header Host $host; # 别忘了超时 proxy_read_timeout 3600s; } }这里有个小细节，proxy_pass 后面写的是 http://ws_backend。这说明 Nginx 到后端服务之间，走的是 HTTP 协议（握手阶段）。有些人会问，我要不要在后面也配 SSL？一般来说没必要，因为 Nginx 已经在入口处把 SSL 卸载了，内网通信再加密反而增加开销，除非你的安全等级要求极高。另外，因为前端是 HTTPS，Nginx 这里监听 443 端口，所以前端连接的时候地址得写成 wss://ws.example.com/ws，别写成 ws:// 了，否则肯定连不上。最后再聊聊排错的事儿。如果你配完了，还是连不上，怎么查？第一招，看浏览器 F12 控制台。这是最快的方法。切到 Network 标签，点 WS 过滤器。看那个请求的状态码。如果是 101 Switching Protocols，恭喜你，握手成功了。后面如果断了，那就是连接保持的问题，查超时，查心跳。如果是 400 Bad Request，通常是 Upgrade 头没传过去，或者 Nginx 配置有语法错误。如果是 502 Bad Gateway，那是 Nginx 连不上后端服务，查后端服务起没起，端口对不对，防火墙有没有开。如果是 504 Gateway Timeout，那是后端处理太慢，或者直接没响应。第二招，看 Nginx 错误日志。tail -f /var/log/nginx/error.log。有时候 Nginx 会把具体的错误原因打出来，比如 upstream prematurely closed connection，这通常意味着后端服务崩了，或者后端主动断开了连接。第三招，用 tcpdump 抓包。这是大招。在 Nginx 服务器上抓包，看 TCP 握手有没有成功，看 HTTP 请求头里有没有 Upgrade: websocket。如果头没发出来，那是客户端的问题；如果头发了，Nginx 没转发，那是 Nginx 配置的问题。其实搞运维这么多年，WebSocket 代理这事儿，说难不难，说简单也不简单。难就难在它跨越了 HTTP 和 TCP 的界限，而且涉及到了长连接的维护。咱们平时习惯了 HTTP 这种“打一枪换个地方”的短连接思维，遇到长连接就容易按惯性思维去配，结果就踩坑。总结一下，Nginx 代理 WebSocket 的核心要点就这几条：协议升级头：Upgrade 和 Connection 必须透传，HTTP 版本必须是 1.1。超时时间：proxy_read_timeout 必须设长，配合心跳机制保活。负载均衡：必须配置会话保持，推荐 ip_hash 或 sticky。安全配置：前端 HTTPS 对应后端 WSS，注意混合内容限制。把这些点都记住了，以后遇到 WebSocket 连接问题，按图索骥，基本都能解决。小张的问题最后排查下来，其实就是超时没设，加上负载均衡没做会话保持，改完配置，压测一把过，服务稳得一批。运维这活儿，很多时候就是这样，原理通了，配置对了，问题自然就没了。希望今天这一通唠叨，能帮大家把 WebSocket 代理这点事儿彻底搞明白，以后再遇到类似需求，直接一套带走，再也不用半夜发微信求救了。行了，今天就先聊到这儿，我也得去补个觉了。如果觉得这篇文章对你有帮助，哪怕只是帮你避开了一个坑，也请点个赞或者在看支持一下。咱们下期见！公众号：运维躬行录个人博客：躬行笔记