1. 项目概述:为什么.NET Core 3.1 + Docker这个组合至今仍值得深挖
我第一次在生产环境把一个订单核验服务从Windows Server IIS迁到Linux容器里,用的就是.NET Core 3.1 + Docker。那会儿是2020年中,团队里还有人质疑“微软的东西跑Linux靠谱吗”,结果上线后CPU占用降了37%,部署耗时从平均12分钟压到47秒,最关键是——再也没人半夜被IIS应用池自动回收的告警叫醒。现在回头看,.NET Core 3.1虽已停止官方支持,但它仍是大量存量系统的技术锚点:某金融后台的风控引擎、某高校教务系统的课表调度模块、某医疗设备厂商的数据采集中台,至今仍在稳定运行。它们不升级不是因为不想,而是核心业务逻辑耦合了3.1特有的WPF兼容层、Windows Forms渲染路径,或者依赖某个只在3.1上验证过的加密算法实现。这时候硬切到6.0或8.0,光是第三方NuGet包兼容性测试就得干掉两周。所以“部署到Docker”这件事,本质不是技术炫技,而是给老系统续命的手术刀——用容器化隔离运行时环境,用镜像版本固化依赖,用编排脚本替代人工运维。你不需要懂Kubernetes原理,但必须清楚dotnet publish -r linux-x64 --self-contained false和--self-contained true的区别在哪;你不必手写Dockerfile每一行,但得明白mcr.microsoft.com/dotnet/core/aspnet:3.1这个基础镜像里到底塞了什么;你可能连docker-compose.yml的healthcheck字段都没配过,但得知道当容器启动后ASP.NET Core进程卡在ApplicationStarted事件里没响应时,该去查哪个日志文件。这篇内容就是为那些正在维护这类系统的开发者写的:不讲大道理,只拆解真实部署中踩过的坑、调过的参数、改过的配置。如果你正对着CI/CD流水线里失败的构建日志发呆,或者刚收到运维同事“容器启动后502”的截图,那接下来的内容,每一段都是能直接抄作业的解决方案。
2. 核心设计思路:为什么必须放弃“一键生成”的幻觉
很多人以为.NET Core部署Docker就是VS右键“发布到容器注册表”点几下鼠标的事。我试过三次——第一次在本地Docker Desktop跑通,推到公司私有Harbor后容器起不来;第二次用Azure DevOps Pipeline构建,镜像体积比预期大2.3GB;第三次在K8s集群里部署,Pod状态卡在ContainerCreating长达18分钟。问题全出在设计思路上:把Docker当成IIS的平替,而不是重新思考应用生命周期。真正的核心设计必须回答三个问题:运行时环境怎么选?镜像分层怎么切?健康检查怎么设?先说运行时。.NET Core 3.1官方提供三类基础镜像:sdk(含编译工具链)、aspnet(仅运行时,含ASP.NET Core库)、runtime(最小化运行时)。很多教程直接教用mcr.microsoft.com/dotnet/core/sdk:3.1,这就像修车时把整个4S店搬进车库——构建阶段确实省事,但最终镜像里塞了dotnet build、dotnet test这些永远用不到的二进制文件,还混着/usr/share/dotnet/sdk/3.1.426/这种占空间的目录。实测下来,用sdk镜像构建的ASP.NET Core WebAPI镜像,基础层就比aspnet镜像大1.2GB。更致命的是安全风险:sdk镜像默认以root用户运行,而生产环境要求非root用户启动容器。所以我的方案是双阶段构建——构建阶段用sdk镜像编译代码,运行阶段用aspnet镜像加载编译好的DLL。这样既保证构建能力,又让最终镜像干净轻量。再看镜像分层。Docker镜像的每一层都是只读的,修改文件会触发新层写入。如果把dotnet publish输出目录整个COPY进去,哪怕只改一行代码,整个bin/Release/netcoreapp3.1/publish/目录都会重建层。正确做法是分层COPY:先COPY*.csproj和*.sln文件,RUNdotnet restore,再COPY源码,RUNdotnet publish。这样只要没改依赖,dotnet restore层就能复用缓存,构建速度提升40%以上。最后是健康检查。ASP.NET Core 3.1默认不暴露/healthz端点,很多人直接用curl http://localhost:5000/做HEALTHCHECK,结果容器明明启动了,但应用还没完成数据库连接池初始化,健康检查就失败导致反复重启。正确的姿势是启用Microsoft.AspNetCore.Diagnostics.HealthChecks中间件,配置AddCheck时指定超时和重试策略,比如数据库检查加3秒超时,避免因网络抖动误判。这三个设计选择背后,全是血泪教训:某次上线前夜,因为用了sdk镜像,私有仓库磁盘爆满,运维同事手动删镜像删到凌晨三点;另一次因健康检查太激进,订单服务在流量高峰时被K8s连续杀掉7个Pod,监控曲线像心电图一样上下跳。所以别信“一键生成”,每个选择都要问自己:它在生产环境里扛得住压力吗?
3. 实操细节解析:Dockerfile里每一行代码的生存意义
写Dockerfile不是拼凑命令,而是给容器世界立规矩。下面这段代码是我在线上跑了三年的模板,逐行拆解它为什么长这样:
# 构建阶段:只负责编译,不参与运行 FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build WORKDIR /src # 先拷依赖文件,利用Docker缓存机制 COPY *.csproj ./ RUN dotnet restore -r linux-x64 # 再拷源码,此时restore层已缓存 COPY . . # 关键参数:-r指定运行时标识符,--self-contained false表示依赖宿主机dotnet runtime RUN dotnet publish -c Release -o /app/publish -r linux-x64 --self-contained false # 运行阶段:极简主义,只装必要组件 FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim WORKDIR /app # 从构建阶段复制publish目录,不带任何开发工具 COPY --from=build /app/publish . # 创建非root用户,UID固定为1001避免权限混乱 RUN groupadd -g 1001 -r appuser && useradd -r -u 1001 -g appuser appuser # 切换到非root用户,这是K8s PodSecurityPolicy强制要求 USER appuser # 暴露端口,注意这里只是声明,实际端口由ASPNETCORE_URLS控制 EXPOSE 80 # 启动命令,显式指定运行时和URL,避免环境变量污染 ENTRYPOINT ["dotnet", "OrderService.dll"]第一行FROM ... AS build的AS build不是可有可无的标签,它是多阶段构建的锚点。没有它,COPY --from=build就找不到源。第二行WORKDIR /src必须存在,否则dotnet restore会报错“无法解析项目文件”,因为SDK镜像的默认工作目录是/,而dotnet restore需要.csproj在当前目录下。第三行COPY *.csproj ./看似简单,但藏着玄机:如果项目有多个.csproj(比如主程序+类库+测试项目),这条命令会把所有都拷进来,导致dotnet restore还原所有依赖,浪费时间。线上方案是精确指定:COPY OrderService.csproj ./。第四行RUN dotnet restore -r linux-x64里的-r参数至关重要。.NET Core 3.1的-r(runtime identifier)决定了还原哪些平台特定的NuGet包。不加-r的话,dotnet restore会按宿主机环境(比如你的Windows开发机)还原win-x64包,等到了Linux容器里运行就报System.DllNotFoundException。第五行COPY . .之后的RUN dotnet publish,--self-contained false是核心。很多人为了“免依赖”选true,结果镜像体积暴涨到280MB,而false模式下只打包DLL,靠基础镜像里的libhostfxr.so加载,体积压到85MB。第六行FROM ... aspnet:3.1-buster-slim的buster-slim后缀代表Debian 10精简版,比stretch(Debian 9)少37个预装包,启动快1.8秒。第七行RUN groupadd ...创建用户不是形式主义——某次安全审计发现,所有容器都用root运行,被开出高危漏洞单。第八行USER appuser后,如果应用代码里有File.WriteAllText("/tmp/log.txt", "test"),就会因权限不足抛异常,必须改成/app/logs/这种用户有权限的路径。第九行EXPOSE 80常被误解为“开放端口”,其实它只是文档化声明,真正生效的是ASPNETCORE_URLS=http://+:80环境变量。最后一行ENTRYPOINT不用CMD,是因为CMD会被docker run后面的参数覆盖,而ENTRYPOINT更稳定。这些细节,少一个都可能让容器在生产环境里“假死”——看着docker ps显示up,但curl返回connection refused,查日志发现Failed to bind to address http://[::]:80: address already in use,根源就是ASPNETCORE_URLS没设对。所以写Dockerfile时,我习惯在每行后面加注释,不是为了好看,而是下次改的时候能秒懂当初为啥这么写。
4. 完整部署流程:从本地调试到K8s集群的七步通关
部署不是终点,而是新问题的起点。我把完整流程拆成七个不可跳过的步骤,每个步骤都配了真实场景的避坑指南:
4.1 步骤一:本地Docker Desktop验证(必做!)
在推送镜像前,先用docker build -t orderservice:local .构建本地镜像,然后docker run -p 5000:80 orderservice:local启动。关键检查点有三个:第一,打开浏览器访问http://localhost:5000/healthz,必须返回{"status":"Healthy"},如果返回500,说明健康检查中间件没注册;第二,在容器内执行docker exec -it <container_id> sh,进入后运行ls -la /app/,确认OrderService.dll存在且大小正常(小于5MB才合理);第三,用docker logs <container_id>看启动日志,重点找Now listening on: http://[::]:80这行,如果看到Unable to start Kestrel,八成是ASPNETCORE_URLS环境变量没传进去。我见过最离谱的案例:开发同学本地用WSL2,Docker Desktop设置里勾选了“Use the WSL 2 based engine”,结果构建时dotnet publish用的是WSL2的Linux内核,但运行时Docker Desktop又切回Windows Hyper-V,导致linux-x64运行时加载失败。解决方案是统一用WSL2后端,并在Docker Desktop设置里关掉Windows容器支持。
4.2 步骤二:私有镜像仓库推送(Harbor/Vault)
docker tag orderservice:local harbor.example.com/prod/orderservice:3.1.22打标签后,docker push推送。这里有两个隐形陷阱:一是Harbor仓库的项目权限,必须给推送用户分配developer角色,否则denied: requested access to the resource is denied;二是镜像命名规范,prod/orderservice里的prod是Harbor的项目名,不能写成production,否则CI/CD脚本里硬编码的路径会失效。我们曾因项目名大小写不一致,导致自动化部署脚本在测试环境成功,生产环境失败,排查了6小时才发现是Harbor里项目名是Prod而脚本里写的是prod。
4.3 步骤三:K8s Deployment编写(YAML不是填空题)
Deployment文件不能照抄网上模板。这是我们的生产级配置:
apiVersion: apps/v1 kind: Deployment metadata: name: orderservice spec: replicas: 3 selector: matchLabels: app: orderservice template: metadata: labels: app: orderservice spec: # 强制非root用户,否则PodSecurityPolicy拒绝创建 securityContext: runAsNonRoot: true runAsUser: 1001 containers: - name: orderservice image: harbor.example.com/prod/orderservice:3.1.22 ports: - containerPort: 80 protocol: TCP # 健康检查必须配,否则K8s认为Pod没就绪 livenessProbe: httpGet: path: /healthz port: 80 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 readinessProbe: httpGet: path: /healthz port: 80 initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 3 # 资源限制防雪崩 resources: requests: memory: "256Mi" cpu: "100m" limits: memory: "512Mi" cpu: "200m"重点看livenessProbe的initialDelaySeconds: 30——这是留给ASP.NET Core初始化的时间。3.1版本的EF Core DbContext初始化、Redis连接池建立、Consul服务注册,全在这30秒里发生。如果设成10秒,Pod会因健康检查失败被K8s反复重启。readinessProbe的periodSeconds: 5则更激进,确保流量只打到真正就绪的Pod。资源限制里的memory: "256Mi"不是拍脑袋:我们用kubectl top pods监控发现,空载时内存占用180Mi,峰值220Mi,所以requests设256Mi留出缓冲,limits设512Mi防内存泄漏。
4.4 步骤四:ConfigMap与Secret注入(别把密码写进代码)
数据库连接字符串绝不能硬编码在appsettings.json里。我们用ConfigMap管理配置,Secret管理敏感信息:
# 创建ConfigMap kubectl create configmap orderservice-config \ --from-file=appsettings.Production.json # 创建Secret(base64编码) echo -n 'mydbpassword' | base64 # 输出:bXlkYnBhc3N3b3Jk kubectl create secret generic orderservice-secret \ --from-literal=ConnectionStrings__DefaultConnection='Server=db;Database=orders;Password=bXlkYnBhc3N3b3Jk'Deployment里通过envFrom注入:
envFrom: - configMapRef: name: orderservice-config - secretRef: name: orderservice-secret注意Secret的值必须base64编码,但K8s会自动解码注入到环境变量。某次事故:运维同事手动编辑Secret YAML,把bXlkYnBhc3N3b3Jk写成了bXlkYnBhc3N3b3Jk\n(多了换行符),导致解码后密码末尾带\n,数据库连不上。后来我们改用kubectl create secret generic命令生成,杜绝手工编辑。
4.5 步骤五:Ingress路由配置(HTTPS不是可选项)
用kubectl apply -f ingress.yaml暴露服务:
apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: orderservice-ingress annotations: nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/force-ssl-redirect: "true" spec: tls: - hosts: - api.orders.example.com secretName: orderservice-tls rules: - host: api.orders.example.com http: paths: - path: / pathType: Prefix backend: service: name: orderservice port: number: 80关键在annotations里两个SSL重定向。不加的话,HTTP请求不会自动跳转HTTPS,前端调用会因混合内容被浏览器拦截。secretName: orderservice-tls对应的TLS证书,必须用kubectl create secret tls命令创建,不能用generic类型,否则Nginx Ingress Controller无法识别。
4.6 步骤六:日志与监控接入(别等出事才查)
K8s里查日志不能docker logs,要用kubectl logs -l app=orderservice。但我们加了ELK栈:在Deployment里挂载日志卷:
volumeMounts: - name: log-volume mountPath: /app/logs volumes: - name: log-volume emptyDir: {}然后用Filebeat DaemonSet收集/app/logs/*.log。监控用Prometheus,需在Startup.cs里加:
public void ConfigureServices(IServiceCollection services) { services.AddHealthChecks() .AddSqlServer(Configuration.GetConnectionString("DefaultConnection")); // 暴露/metrics端点 services.AddPrometheusExporter(); }不暴露指标端点,Prometheus就抓不到数据。某次性能下降,我们靠process_cpu_seconds_total{job="orderservice"}指标发现某个Pod CPU飙升,登录后发现是日志级别设成了Debug,疯狂刷磁盘IO。
4.7 步骤七:滚动更新与回滚(上线不是结束)
更新用kubectl set image deployment/orderservice orderservice=harbor.example.com/prod/orderservice:3.1.23。K8s会自动启新Pod、停旧Pod。但必须配maxSurge和maxUnavailable:
strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0maxUnavailable: 0确保更新时至少有一个Pod在线,避免服务中断。回滚用kubectl rollout undo deployment/orderservice。我们曾因没配maxUnavailable,更新时3个Pod全停,订单接口503持续47秒,损失了23笔交易。现在每次上线前,运维同事必须确认这行配置存在。
5. 常见问题与排查技巧:那些让你凌晨三点爬起来的日志
5.1 问题一:容器启动后立即退出,docker ps看不到记录
现象:docker run -d orderservice:3.1.22后,docker ps -a显示状态Exited (139) 2 seconds ago
排查思路:139是SIGSEGV信号,通常内存访问违规。先docker run -it orderservice:3.1.22前台运行,看实时日志。大概率是dotnet进程启动失败。
根因:ENTRYPOINT ["dotnet", "OrderService.dll"]里DLL名错了。3.1项目默认输出名是<ProjectName>.dll,但若<PropertyGroup>里设了<AssemblyName>CustomName</AssemblyName>,实际DLL名就是CustomName.dll。
解决:进容器docker run -it orderservice:3.1.22 sh,执行ls /app/,确认DLL名,然后改Dockerfile。
5.2 问题二:K8s Pod状态CrashLoopBackOff,日志只有standard_init_linux.go:211: exec user process caused "no such file or directory"
现象:kubectl logs orderservice-xxxxx报错如上,kubectl describe pod显示Back-off restarting failed container
排查思路:这不是文件不存在,而是动态链接库缺失。dotnet二进制依赖libicu、libssl等系统库。
根因:用了mcr.microsoft.com/dotnet/core/aspnet:3.1基础镜像,但某些精简版Linux发行版(如Alpine)没预装这些库。
解决:换镜像为mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim(Debian 10),或在Dockerfile里加RUN apt-get update && apt-get install -y libicu-dev libssl-dev -y。
5.3 问题三:健康检查失败,但手动curl/healthz返回200
现象:K8s里Pod状态Running但NotReady,kubectl get pods显示1/2(2个容器,1个就绪)
排查思路:readinessProbe和livenessProbe是独立的。用kubectl describe pod <pod_name>看Events,找Readiness probe failed详情。
根因:Probe配置了initialDelaySeconds: 10,但应用启动要15秒。第10秒健康检查就发起,此时应用还没初始化完DbContext。
解决:调大initialDelaySeconds,或在Startup.cs里优化ConfigureServices,把耗时操作(如数据库迁移)移到Configure方法里异步执行。
5.4 问题四:容器内时间比宿主机快8小时
现象:日志时间戳全是未来时间,监控图表时间轴错乱
根因:Docker容器默认用UTC时区,而应用代码里DateTime.Now取的是本地时区。
解决:在Dockerfile里加ENV TZ=Asia/Shanghai,再RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone。
5.5 问题五:dotnet publish后镜像体积异常大(>300MB)
现象:docker images显示镜像大小298MB,而同功能Java Spring Boot镜像才85MB
排查思路:docker history <image_id>看各层大小。
根因:dotnet publish用了--self-contained true,把整个.NET Runtime打包进来了。
解决:改用--self-contained false,并确保基础镜像含对应Runtime。用docker run -it mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim dotnet --list-runtimes确认已安装Microsoft.AspNetCore.App 3.1.32。
5.6 问题六:K8s里服务间调用超时,curl http://orderservice:80/healthz返回Connection refused
现象:A服务调B服务,B服务Pod日志正常,但A服务报HttpRequestException: Connection refused
根因:K8s Service DNS解析失败。orderservice是Service名,但Pod里/etc/resolv.conf的search域没配对。
解决:检查Service YAML,确认metadata.name是orderservice,且spec.selector匹配Pod标签。用kubectl exec -it <a_pod> -- nslookup orderservice验证DNS是否解析到ClusterIP。
5.7 问题七:dotnet restore在CI/CD里失败,报Unable to load the service index for source https://api.nuget.org/v3/index.json
现象:Jenkins流水线里dotnet restore卡住,最后超时
根因:公司内网防火墙拦截了NuGet.org的HTTPS请求,或代理配置错误。
解决:在Dockerfile构建阶段前加代理配置:
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build # 配置NuGet源为公司内网镜像 RUN mkdir -p /root/.nuget/NuGet && \ echo '{"resources": [{"type": "packageSource", "url": "https://nuget.internal.example.com/v3/index.json"}]}' > /root/.nuget/NuGet/NuGet.Config6. 进阶技巧与经验沉淀:让部署从“能用”到“稳如磐石”
6.1 技巧一:用Docker BuildKit加速构建(提速2.3倍)
默认Docker构建不启用BuildKit,导致dotnet restore无法并行。在CI/CD脚本开头加:
export DOCKER_BUILDKIT=1 docker build --progress=plain -t orderservice:3.1.22 .BuildKit会自动并行执行RUN指令,dotnet restore和dotnet publish不再串行。实测Jenkins流水线构建时间从6分12秒降到2分38秒。注意--progress=plain参数,否则Jenkins控制台看不到实时日志。
6.2 技巧二:镜像扫描防漏洞(别让Log4j重演)
用Trivy扫描镜像:
trivy image harbor.example.com/prod/orderservice:3.1.22某次扫描发现mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim含CVE-2021-33574(glibc堆溢出),紧急升级到3.1.32-buster-slim。现在所有镜像推送Harbor前,都加Trivy扫描步骤,高危漏洞自动阻断发布。
6.3 技巧三:多架构镜像支持(ARM64不是未来,是现在)
公司新采购的服务器是ARM64芯片,但现有镜像只支持AMD64。用docker buildx构建多架构:
docker buildx build --platform linux/amd64,linux/arm64 \ -t harbor.example.com/prod/orderservice:3.1.22 \ --push .--platform参数指定目标架构,--push自动推送到Harbor。K8s会根据Node架构自动拉取对应镜像。
6.4 技巧四:构建时注入Git版本信息(追溯到代码行)
在Dockerfile里加:
ARG GIT_COMMIT ENV GIT_COMMIT=$GIT_COMMITCI/CD里用:
docker build --build-arg GIT_COMMIT=$(git rev-parse HEAD) -t orderservice:3.1.22 .然后在ASP.NET Core里读取:
var commit = Environment.GetEnvironmentVariable("GIT_COMMIT"); Console.WriteLine($"Build from commit: {commit}");上线后查问题,直接kubectl logs就能看到是哪个Git提交导致的,不用翻Jenkins构建记录。
6.5 技巧五:优雅关闭防订单丢失(K8s SIGTERM处理)
K8s删除Pod时发SIGTERM信号,.NET Core 3.1默认不处理,导致正在处理的订单被中断。在Program.cs里加:
public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); }) .ConfigureServices(services => { services.AddSingleton<IHostedService, GracefulShutdownService>(); });GracefulShutdownService监听IApplicationLifetime.ApplicationStopping,等待正在处理的订单完成后再退出。实测上线后,订单丢失率从0.02%降到0。
6.6 技巧六:用Docker Compose做本地集成测试(绕过K8s复杂度)
写docker-compose.integration.yml:
version: '3.8' services: orderservice: image: harbor.example.com/prod/orderservice:3.1.22 environment: - ASPNETCORE_ENVIRONMENT=Integration - ConnectionStrings__DefaultConnection=Server=db;Database=orders; depends_on: - db db: image: mcr.microsoft.com/mssql/server:2019-latest environment: - SA_PASSWORD=MyPass@123 - ACCEPT_EULA=Ydocker-compose -f docker-compose.integration.yml up一键启动完整环境,前端调用http://localhost:5000/api/orders测试,比搭K8s Minikube快10倍。
6.7 技巧七:镜像签名防篡改(合规刚需)
用Cosign签名镜像:
cosign sign --key cosign.key harbor.example.com/prod/orderservice:3.1.22K8s Admission Controller配置ImagePolicyWebhook,只允许签名有效的镜像运行。某次安全审计,这项直接帮我们过了等保三级。
7. 最后的实战体会:技术选择没有银弹,只有权衡
我在某次跨部门分享会上被问:“既然.NET Core 3.1都EOL了,为什么不升级?”当时我放了一张图:横轴是升级成本(人天),纵轴是业务收益(TPS提升百分比),.NET Core 3.1到6.0的点坐标是(120, 8.3),而3.1容器化改造的点是(18, 37.2)。数字不会骗人——把老系统装进容器,比升级框架更能快速见效。但这不意味着可以躺平。去年我们做了个实验:用dotnet monitor诊断容器内.NET Core 3.1进程,发现GC暂停时间高达1.2秒,根源是ConcurrentDictionary在高并发下锁竞争。解决方案不是升级框架,而是把热点字典换成MemoryCache,配合PostEvictionCallbacks清理。所以技术决策的本质,是算清账:这笔投入花在哪里,能换来最实在的回报?容器化不是终点,而是把系统从“黑盒”变成“透明盒”的开始。当你能随时docker exec进容器看内存、用kubectl top看CPU、用trivy扫漏洞,你就掌握了系统的主动权。至于那些还在纠结“该不该升级”的团队,我的建议是:先让3.1在容器里稳稳跑着,等业务流量涨上来,等监控数据攒够,等下一个技术债爆发时,你自然就知道答案了。毕竟,运维的终极目标不是用最新技术,而是让业务不掉链子。