尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从本地到服务器:Docker Compose容器化与CI/CD流水线实战

从本地到服务器:Docker Compose容器化与CI/CD流水线实战 第九周项目功能不像第一个月那样捉襟见肘了真正让我头疼的反而是“怎么把东西交出去”。同样的代码在开发机上跑得很欢换一台机器就各种起不来数据库连不上、前端资源加载一半、时区不对、日志全堆在一个文件里翻不到头。这周的技术博客我不打算再写某个函数怎么实现而是完整记录一次容器化改造的过程——把后端服务、前端页面、MySQL、Nginx用Docker Compose编排起来再把构建和发布流程通过CI流水线固化掉。如果你也处在“代码能跑但不知道怎么稳定跑在服务器上”的阶段这篇应该能帮你省下不少排查时间。1. 第九周的核心矛盾功能写完了交付链路还是手工的1.1 “我本地能跑”是推进过程中最危险的信号这句话以前听别人说总觉得有点矫情。代码能跑不是好事吗这周被现实教育了一次。我们的后端服务在开发机上一切正常部署到一台内存只有2G的云服务器之后接口动不动就超时。查了半天发现服务器上的Node版本和本地差了三个大版本有些依赖编译出来的原生模块根本不兼容。这种问题在本地永远复现不出来因为本地没问题所以代码在潜意识里就被认为“没问题”结果问题全压在了部署那一刻。类似的情况还有不少本地用SQLite做开发服务器上要接MySQL本地Windows路径大小写不敏感服务器Linux严格区分本地时区是东八区服务器默认UTC日志时间直接错乱。每一件单独看都不是大问题但合在一起就是一场灾难。真正的危险在于“本地能跑”给所有人传递了一种虚假的安全感等到交付的时候才暴露出来排错成本已经翻了好几倍。更致命的是整个发布过程是完全手工的。打zip包、scp传上去、登录服务器找进程、kill掉再nohup启动全靠人肉记忆。一旦服务器上还有其他服务占着端口或者环境变量漏配了一个整个流程就得从头再来。这种流程本质上不可复现不可复现就意味着不可维护。1.2 锚定目标环境、发布、可观测性三条线同步推进所以这周我给自己定的话题很明确把部署链路彻底改造一遍让“环境不一致”和“手工发布”这两个最大的不稳定因素从根上消失。核心思路不是简单地“做一个Docker镜像”而是让任何一台机器拿到代码和配置之后都能复现出相同的行为。我给自己列了一个很具体的对照表维度现状目标运行环境开发机与服务器不一致靠人肉对齐用镜像固化运行时环境随代码走服务间通信手写IP和端口换机器就断Docker网络内用服务名访问发布方式scp ssh手工操作CI流水线一键构建、推送、部署日志处理日志散落在文件里出问题靠猜集中采集配置基础告警数据存储MySQL裸跑目录权限混乱命名卷持久化容器可重建不丢数据三条线是同步推的环境统一是基础自动化发布是效率可观测性是后手。没有第一条后面两条都没意义没有第三条服务即使上线了也是盲飞。这周记录的主要内容就是这三条线落地过程中踩进去的坑和最终定下来的方案。2. Compose编排踩过的三个坑服务名访问、启动顺序、卷权限2.1 服务名访问代替IP端口网络配置的第一步Compose刚上手的时候我第一个直觉是把每个服务的端口都映射到宿主机上后端映射个8080MySQL映射个3306前端映射个80然后让它们通过localhost互相访问。这在单机开发时没问题但服务一多就乱了端口占用冲突很难查而且把MySQL、Redis这类内部服务暴露到宿主机本身也是安全隐患。后来改成正确的姿势容器之间不要通过宿主机IP和映射端口访问而是直接在一个Compose网络中通过服务名访问。Docker Compose会默认创建一个网络服务名在这个网络中就是可解析的主机名。后端服务连数据库地址写mysql:3306而不是localhost:3306Compose的DNS会自动解析到对应的容器IP。服务名就是主机名这比记IP和端口靠谱得多。这是我当时一个多服务编排的基础文件新版Docker Compose已经不强制写version字段了我特意省略了它services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: myapp volumes: - mysql-data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1] interval: 5s timeout: 3s retries: 12 start_period: 30s backend: build: ./backend environment: DB_HOST: mysql DB_PORT: 3306 depends_on: mysql: condition: service_healthy frontend: build: ./frontend ports: - 80:80 depends_on: - backend volumes: mysql-data:用服务名通信之后容器无论被分配到哪个IP服务间的调用关系都不变。前端Nginx反代后端时proxy_pass http://backend:8080;里的backend同样走的是Compose网络内的解析。这个改动虽然简单但把“环境差异”这个最大的变量直接干掉了。2.2 启动顺序别靠sleephealthcheck condition才是正解Compose里depends_on这个字段很多人一开始都理解错了。它默认只控制容器的启动顺序也就是先启动谁后启动谁但完全不关心被依赖的服务是否真正“就绪”。MySQL容器起来了不代表MySQL已经能接受连接它还要走初始化流程。后端容器如果在这时候启动并尝试连数据库大概率会报连接拒绝。网上很多教程给的解法是加sleep 30让后端晚点启动。这是典型的治标不治本MySQL初始化快的时候30秒是白白浪费慢的时候30秒可能还不够。更麻烦的是如果MySQL真的启动失败了后端的逻辑还是按“等了30秒所以应该好了”去走错误成了一个不确定的时序问题排查起来很崩溃。正确做法是用健康检查。给依赖服务配置healthcheck然后在depends_on里声明condition: service_healthyCompose会等到被依赖服务的健康检查连续通过之后再启动下游服务。我上面对MySQL的配置就是标准的健康检查写法。几个参数值得说清楚interval间隔多少秒执行一次检查。timeout单次检查超时时间。retries连续几次成功才认为是healthy。start_period启动宽限期。MySQL这类服务首次初始化有可能要跑几十秒在start_period内检查失败不会计入重试次数。健康检查把“服务已启动”和“服务可用”区分开这不是锦上添花而是编排多服务必须跨过去的一道坎。2.3 卷权限容器里的root用户在宿主机上留下的烂摊子数据持久化这一块我用的是Docker命名卷避免了直接挂载宿主机目录遇到的一堆权限问题。但团队里有人习惯直接挂宿主机目录于是踩了一个很经典的坑容器内进程默认用root用户运行往挂载目录里写的文件在宿主机上看owner全是root。普通用户想删这些文件还要先sudo非常被动。这个问题在Nginx容器写日志、后端容器写上传文件时尤其明显。挂载的是一个宿主机目录宿主机用户是ubuntu容器用户是root两边uid对不上文件权限就乱了。解决思路有两个可以配合使用。第一在Compose里指定容器的用户让进程不要用root跑services: backend: build: ./backend user: 1000:1000但这个方案的前提是宿主机目录的所有者uid确实是1000而且镜像里的运行用户最好也是1000否则反而可能因为没权限报错。更稳妥的做法是先创建宿主机目录并chown再保证容器内进程的uid一致mkdir -p /opt/myapp/uploads chown -R 1000:1000 /opt/myapp/uploads另外如果只是想让数据持久化而不关心具体存在宿主机的哪个位置直接用命名卷最省心Docker会自动处理权限归属。命名卷做备份也很方便一条命令就能打tar包docker run --rm -v mysql-data:/data -v $(pwd):/backup alpine tar czf /backup/mysql-data.tar.gz -C /data .这条命令把名为mysql-data的命名卷打包成mysql-data.tar.gz输出到当前目录实测下来恢复和迁移都很顺手。3. 前端容器化和Nginx反向代理几个配置细节决定线上成败3.1 多阶段构建体积从1.2G压到120M前端容器化我一开始走了弯路。图省事直接在node:20-alpine镜像里把源码Copy进去装完依赖后跑npm run serve还以为自己是容器化最佳实践。结果镜像体积直接冲到1.2G因为整个node_modules、源码、构建缓存全被塞进去了。更糟的是npm run serve本身是开发服务器用来干生产环境的活性能和稳定性都不对。正确的做法是多阶段构建第一个阶段用Node镜像把前端代码构建成静态文件第二个阶段只拿构建产物放进Nginx镜像里跑。这样最终镜像只包含静态资源和一个轻量的Web服务器体积小、启动快、攻击面也小。FROM node:20-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:1.25-alpine COPY --frombuild /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80几个细节值得细说。这里用的是npm ci而不是npm install两者的区别是npm ci严格按package-lock.json里的版本安装并且会先清空node_modules保证两次构建装出来的依赖完全一致。这正是CI/CD环境需要的可复现性。如果项目里还没有lockfile建议先用npm install生成一份再提交到仓库。另一个细节是Dockerfile里先Copy依赖文件再Copy源码利用Docker的层缓存只要package.json和package-lock.json没变npm ci这一层就不会重新执行构建速度快很多。.dockerignore也不能漏至少要把这几项加进去node_modules dist .git *.log如果不忽略node_modulesCOPY . .会把本地几万个依赖文件一起打包进构建上下文Docker构建会慢到怀疑人生。改造前后对比也很直观项目改造前改造后基础镜像node:20-alpinenginx:1.25-alpine镜像体积约1.2G约120M运行进程node开发服务器Nginx静态文件缓存控制无可配置3.2 SPA刷新404与/api代理try_files和proxy_pass的细节前端项目用的是Vue的history路由模式。第一次部署完访问首页一切正常点击跳转也没问题但只要一刷新页面就直接404。排查了一下原因很典型Nginx收到/user/profile这个路径后去静态目录找这个文件找不到就返回404。但SPA是单页应用所有路由都应该回退到index.html由前端路由自己决定渲染什么。解法是Nginx配置里的try_filesserver { listen 80; server_name _; root /usr/share/nginx/html; location / { try_files $uri $uri/ /index.html; } }try_files的执行逻辑是先按$uri找文件找不到就按$uri/找目录再找不到就回退到/index.html。这个回退规则保证了history模式下任意路径刷新都能让前端路由接管。另一个高频问题出现在反向代理。前端要请求后端接口我在Nginx里这样转发location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }这里有一个细节新手特别容易踩proxy_pass后面的URL如果带路径比如http://backend:8080/转发时会把location匹配到的那段前缀替换掉。比如请求/api/user如果proxy_pass是http://backend:8080/后端收到的是/user如果proxy_pass是http://backend:8080不带斜杠那后端收到的就是完整的/api/user。到底用哪种取决于后端接口是不是自带/api前缀。这两个行为差别巨大配置前先确认一下后端路由设计能少走很多弯路。3.3 静态资源缓存与发版失效一个头字段没配对的经典事故前端构建工具会给静态文件生成带hash的文件名比如app.8f3a2b.js。这类文件名一变就说明内容变了可以放心地让浏览器长期缓存而index.html作为入口文件必须每次都向服务器确认有没有新版本。这两个文件的缓存策略完全不一样配反了就出问题。我一开始图省事给所有静态资源统一加了一个很长的缓存时间。结果就是发布新版本之后用户的浏览器还在用旧缓存的JS文件页面一直显示旧版本除非手动强制刷新。原因就是index.html也被缓存了浏览器压根不会去服务器确认有没有新版。调整后的配置是这样location /assets/ { expires 30d; add_header Cache-Control public, immutable; } location /index.html { add_header Cache-Control no-cache; }带hash的文件放/assets/下浏览器可以放心缓存30天文件名变了自然请求新文件index.html每次都不缓存保证发版后用户能在一次正常访问内拿到最新的入口文件。再配合一个gzip压缩页面的加载体验会好很多gzip on; gzip_comp_level 5; gzip_min_length 1024; gzip_types text/plain text/css application/javascript application/json image/svgxml;这里要提醒一句immutable不是所有文件都能加的只适合文件名带内容hash、永远不会发生原地更新的资源。误加到index.html上缓存更新问题就会变成线上事故。4. CI流水线固化部署从手动ssh到推到主干即发布4.1 流水线三段式设计测试、构建、部署各司其职手工发布卡在流程上的每一分钟都是可以省掉的。这周我基于GitLab CI把发布流程固化成了三段测试、构建、部署。最粗版本的配置文件长这样stages: - test - build - deploy variables: IMAGE_TAG: $CI_COMMIT_SHORT_SHA backend-test: stage: test image: node:20-alpine script: - npm ci - npm run lint - npm run test backend-build: stage: build image: docker:24.0.7 services: - docker:24.0.7-dind before_script: - echo $REGISTRY_PASSWORD | docker login $REGISTRY -u $REGISTRY_USER --password-stdin script: - docker build -t $REGISTRY/myapp-backend:$IMAGE_TAG ./backend - docker push $REGISTRY/myapp-backend:$IMAGE_TAG流水线把“构建”和“部署”这两个动作物理隔离了。测试没跑过代码不会被打成镜像镜像没推送成功部署环节根本不会触发。这个结构不复杂但它把以前所有靠人肉记忆的检查点变成了强制关卡。关于Runner的选择多说两句。用Docker executor跑流水线有一个前置条件Runner机器上要能跑Docker构建。一种方式是配置docker:dind服务让构建进程跑在DinD里另一种更省事的方式是注册Runner时用shell executor然后把Runner用户加到docker组里让它直接使用宿主的Docker socket。后者省掉了DinD带来的网络和数据卷问题小团队我建议直接这么干。4.2 镜像tag策略与清理为什么我不用latest镜像tag这个东西一开始很容易随手写个latest。本地无所谓一旦上了CI问题就出来了latest是不可追溯的你没法知道线上那个“latest”到底是哪次提交构建出来的。万一要回滚你甚至找不到上一个版本对应哪个镜像。我用的方案是直接用CI_COMMIT_SHORT_SHA当tag也就是每次提交产生的短commit号。这样一个tag唯一对应一次构建代码和镜像的关系清清楚楚。部署的时候compose文件里声明镜像名时用变量services: backend: image: registry.example.com/myapp-backend:${IMAGE_TAG:-latest}部署时只需要带上环境变量ssh deployer$SERVER_IP cd /opt/myapp IMAGE_TAG$IMAGE_TAG docker compose up -d backend镜像越积越多清理策略也得跟上。服务器上可以定期执行docker image prune -f --filter until24h这条命令会清掉24小时前创建且没被容器使用的镜像。但我建议慎用docker image prune -a这种删全部未使用镜像的命令规则太粗可能把还在用的镜像一并删掉。更稳的做法是保留最近N个版本的镜像其余手动清理。在仓库侧GitLab Container Registry自带清理策略可以按“保留最近N个tag”自动清理。配好之后服务器上基本就不用手动删镜像了。4.3 部署与回滚自动化里要留好退路部署脚本是这个流水线的最后一段也是事故率最高的地方。第一次写的时候我什么都不管直接ssh上去docker compose up -d结果服务起不来日志也看不到只能干瞪眼。后来我把部署脚本改成了这样一个流程先拉新镜像再启动然后等健康检查通过通过才算部署成功。完整一点的思路是这样ssh deployer$SERVER_IP cd /opt/myapp docker pull registry.example.com/myapp-backend:$IMAGE_TAG IMAGE_TAG$IMAGE_TAG docker compose up -d backend for i in \$(seq 1 30); do if curl -sf http://localhost:8080/health /dev/null; then echo deploy ok exit 0 fi sleep 2 done echo health check failed, rolling back 如果健康检查在预期时间内没通过就需要回滚。回滚的思路是再部署一次上一个已知正常的IMAGE_TAG。因为每次构建都有唯一的commit号回滚就变成了“把IMAGE_TAG指向上一个版本”这样一件事不需要重新构建代码。还有两个小坑。一是CI机器首次ssh部署服务器时会卡在host key确认上。解决方案是预先在CI变量里放好服务器的known_hosts或者在脚本里用-o StrictHostKeyCheckingno跳过确认。后者方便但你要清楚它牺牲的是对服务器的指纹校验建议只在内部CI环境里用。二是私钥的权限ssh会在私钥权限太宽松的时候直接拒绝使用所以脚本里要先chmod 600。5. 日志增长、集中采集、告警通知上线后最容易被忽略的环节5.1 Docker默认日志会在几天内撑满磁盘服务跑起来之后另一个隐藏的坑是Docker默认的日志驱动。默认情况下Docker把容器的标准输出写到/var/lib/docker/containers/id/目录下的json文件里而且这个文件没有大小上限不会自动轮转。如果应用里有一条错误日志在循环打印几个小时内就能看到磁盘占用疯狂上涨直到磁盘满了整个服务器上的服务都开始假死。这个我实测踩过。排查的时候用du -sh /var/lib/docker/containers/*一看一个日志文件就几十个G。要治本得改Docker守护进程的全局配置在/etc/docker/daemon.json里加上{ log-driver: json-file, log-opts: { max-size: 20m, max-file: 5 } }修改后需要重启Docker守护进程并且已经存在的容器要重建后配置才会生效。如果不想动全局配置也可以在compose文件里针对单个服务单独指定services: backend: build: ./backend logging: driver: json-file options: max-size: 20m max-file: 5每个日志文件20M保留最近5个这样单服务最多占用100M磁盘再也不用担心某个容器把服务器日志目录写爆。5.2 轻量集中日志中小团队从Loki开始不要一上来就ELK日志文件被轮转掉之后查询又有了新问题日志散落在每台服务器上查一次要登录服务器翻文件效率低到不忍直视。很多团队的方案是一上来就上ELK结果被Elasticsearch和Logstash的内存占用劝退。实际上中小团队用一个Grafana Loki加Promtail的组合就足够用了。这套组合的思路很简单Promtail作为采集中间件从容器的标准输出或者日志文件里抓取日志推送到Loki存储Loki本身不建索引只存原始日志和元数据资源占用比Elasticsearch小一个量级查询直接接Grafana的Explore页面支持LogQL语法用起来和查Elasticsearch完全两回事。如果是Docker编排环境Promtail可以直接通过Docker socket做容器发现自动给采集到的日志打上container_name这样的标签配置量很小。这样做的价值在于出问题的时候不需要登录服务器直接在一个Grafana面板里按服务名、按时间范围把日志捞出来效率提升非常明显。5.3 告警规则要“少而准”先通知到人再考虑自动处理日志能查了还差最后一步问题发生时能不能第一时间知道。告警规则这件事最怕贪多。规则配了一大堆最后每条都在频繁触发大家反而麻木了真正重要的告警也被淹没了。我的原则是“少而准”先覆盖四个高概率事故类型磁盘使用率超过85%容器健康检查连续失败unhealthy容器反复重启接口错误率异常上升对于还没有接入完整监控体系的小团队先用一个简单的cron脚本就能覆盖前两项。比如每隔一分钟检查一次当前有没有unhealthy的容器有就发一条Webhook到IM工具#!/bin/bash WEBHOOK_URLhttps://example.com/hook UNHEALTHY$(docker ps --filter healthunhealthy --format {{.Names}}) if [ -n $UNHEALTHY ]; then curl -s -X POST $WEBHOOK_URL \ -H Content-Type: application/json \ -d {\msgtype\:\text\,\text\:{\content\:\容器不健康: $UNHEALTHY\}} fi告警的级别也要分清楚。像容器不健康、接口错误率升高这种先通知到人去看就足够而自动重启这种操作一定要确认服务是幂等的再上否则可能把一个偶发故障变成反复重启的雪崩。我的顺序是先能在出事的时候通知到人再慢慢积累数据最后才谈自动化处理。6. 问题汇总、原因复盘与下一阶段方向6.1 回看本周踩坑清单哪些问题靠提前设计就能避开这周踩的坑不少把它们汇总成一张表供以后参照现象根因解决方式服务器上Node版本不一致模块编译失败运行时环境没固化多阶段构建镜像运行环境随代码走前端刷新页面404Nginx没有SPA路由回退try_files $uri $uri/ /index.html后端启动后连不上数据库depends_on只控制启动顺序不等就绪配healthcheck condition: service_healthy宿主机目录文件owner变成root容器默认root写文件指定user或先chown数据目录发版后用户看到旧版本index.html被缓存静态资源immutableindex.html设no-cacheDocker日志写满磁盘json-file日志无上限daemon.json配置max-size和max-file回看这些问题的根因大部分集中在两个假设上一是假设了环境一致二是假设了依赖已就绪。这两个假设在这次改造之后已经从源头上被拆掉了。6.2 后续优化配置管理、多环境、链路监控这周搭起来的链路解决的是从“代码”到“容器运行”的问题但还有很多下一步想做的事。首先是配置管理现在很多敏感信息还放在compose文件的环境变量里这不够安全。后面我打算把密钥类配置迁到专门的secret管理工具里CI流水线从变量库读取后注入而不是写死在仓库里。其次是多环境。目前的compose文件还是dev和prod混用后面要拆成按环境覆盖的方式dev和prod用同一套模板只通过.env文件覆盖差异项。这样才能保证开发、测试、生产三个环境的行为完全一致不然过一阵又会出现“测试环境好的生产环境不行”的老问题。最后是链路监控。日志和告警解决的是“服务挂了能不能知道”下一步要补的是“服务没挂但变慢了能不能看见”。我打算在接口层加一版p99延迟和错误率的监控配合Grafana面板实时看。这一块是长期活急不来但方向是明确的。这周最大的感受是容器化不是给服务套一层壳就算完它真正改变的是交付方式。以前我习惯本地启动正常就提交代码现在我写新服务时会先想清楚镜像边界是什么、配置怎么注入、日志往哪里输出、健康检查怎么定义。这些思考放在写代码的早期成本几乎为零等部署的时候再补代价就是一个个不眠夜。最后一个个人小习惯也分享出来每次在compose里新增服务先把healthcheck写好再写depends_on。顺序反了后面大概率要补一堆临时脚本擦屁股。
返回列表