☰
Docker Compose 公网部署实战:网关、证书、备份与502排查
2026/10/23 3:38:20 网站建设 项目流程

本地docker compose up一把过,页面唰地打开,那一刻感觉项目已经做完了。然后你把代码传到服务器上,重新跑一遍,浏览器转了半天圈,返回一个 502。登上去一看容器起来了,日志里一行报错都没有,端口也开着,就是访问不到。这种"本地全绿、上线全红"的落差,几乎每个第一次把项目推到公网的人都会撞上一回,而且往往不是代码写错了,是容器外面的那层东西没搭对。

这篇东西讲的就是容器外面那层:用 Docker Compose 把一个项目从本地跑到一台能从外网访问的服务器上,中间涉及的系统初始化、编排文件怎么写、入口网关怎么配、证书怎么自动续、数据怎么备份、出问题从哪一层往下查。目标读者是已经把项目跑起来但还没真正上线过的人,也适合上过一两次线、每次都要凭记忆敲一堆命令、想把这套流程固定下来的开发者。我会把每一步的意图和取舍讲清楚,参数怎么估算、命令为什么这么写、坑一般埋在哪,都尽量摊开说,看完基本可以照着抄一遍。

1. 上线这件事,先想清楚要交付什么

1.1 从"能跑"到"敢让人访问"中间隔着什么

本地跑通只验证了一件事:代码逻辑没崩溃。真正上线要额外解决一堆本地根本不存在的问题。本地你连的是localhost:3306,上了服务器数据库在另一个容器里,得靠服务名互相找到;本地文件写在项目目录下,容器一重建,写进去的图片和上传附件全没了;本地没有防火墙,服务器默认只开了 22 端口,80 和 443 根本进不来;本地没人在意 HTTPS,浏览器现在对没有证书的站点直接标红警告,用户看一眼就关了。

我习惯把这些差距分成四层来看,从上往下依次是:接入层(域名解析、端口放行、证书、网关转发)、编排层(容器之间怎么组网、怎么启动、依赖顺序)、数据层(数据卷挂载、备份、迁移)、运维层(日志、健康检查、更新和回滚)。绝大多数上线失败,问题都卡在接入层和编排层,代码本身反而是最不容易出问题的一环。所以准备工作的重心应该放在外面两层,而不是反复检查业务代码。

这里有个心态上的转变很重要:本地开发你是"代码的作者",上线之后你是"这套服务的运维者"。运维者关心的是"它挂了怎么第一时间知道""它数据丢了能不能恢复""它更新失败了能不能退回去"这三个问题。这三个问题在本地开发时一个都不存在,但在公网上它们每天都会冒出来。

1.2 为什么用 Compose,而不是裸机直接跑或者上集群

裸机直接跑的意思是服务器上装个运行时,nohup起进程,再配个守护进程。这套方式我早年用过,能跑,但问题很集中:环境依赖漂移(服务器上的运行时版本和本地不一致,某个依赖编译不过),进程管不住(重启服务器之后忘了设开机自启,服务就没了),多服务协作麻烦(应用、数据库、缓存三个东西各自装、各自配、各自的启动顺序靠手工保证)。

整套容器集群(各种编排平台)当然更强,但它的复杂度是有代价的:你得先有一个能跑起来的集群,得懂它的资源对象模型,出了问题排查链路会拉得很长。对一个单体应用或者"前端 + 后端 + 数据库 + 缓存"这种四五个组件的项目来说,用集群属于拿大炮打蚊子,配置文件的体积会是你业务代码的好几倍。

Compose 恰好卡在中间:一个 YAML 文件描述整套服务,一条命令拉起全部,服务之间用服务名互相访问,数据卷、网络、环境变量、依赖顺序、健康检查全部声明式写在文件里。它的能力边界也很清楚——单机、非大规模、不需要跨机器调度。绝大多数中小项目、内部系统、演示环境、个人产品都落在这个边界内。

我的选型判断很简单:先问"这套东西会不会在半年内需要跨多台机器横向扩容",答案是否定的话,Compose 就是最优解。真要扩容了再迁移,那时候你对业务的实际负载特征已经有数据了,迁移方案会比现在拍脑袋定的靠谱得多。

1.3 动手之前先列一份清单

我每次上新项目,都会在笔记里先敲一份清单,一条条勾完再开始。这份清单不长,但漏掉任何一条,后面都要返工。

项目要求常见疏漏
服务器能从外网访问,系统为常见 Linux 发行版选了内存 1G 的入门规格,构建镜像时 OOM
域名一个可解析的域名,A 记录指向服务器只配了主域名,忘了 www 子域
端口22 用于管理,80/443 对外云控制台安全组和系统防火墙都开了但规则不一致
应用镜像能构建出可跑的最小镜像镜像里带了本地.env,覆盖了生产配置
数据持久化数据库、上传目录都挂到宿主机只在容器内建了目录,重建即丢
备份至少每天一次数据库导出备份文件放在同一块盘上,盘挂了全没
回滚方案保留最近若干版本镜像 tag只留一个 latest,出问题只能现场改代码

清单里最容易被跳过的是最后两条。大家上线时兴致勃勃,觉得"先跑起来再说",结果第一次更新就翻车,才发现既没有旧版本可退,也没有数据可恢复。这两件事的成本其实很低,加起来不到半小时,但它们决定的是你出事那天能不能睡个好觉。

2. 服务器与系统层,别急着装 Docker

2.1 规格怎么估:三个数字决定你要花多少钱

很多人选规格靠感觉,先买个最便宜的试试,跑不动再升配。升配本身不难,但迁移数据、重新配环境很烦,不如一开始估个八九不离十。

估算方法是把三类内存需求加起来。应用进程内存:一般 Web 服务空闲时占 100~300MB,带框架的会到 500MB 左右,跑过压测的话直接看你本地的峰值。数据库内存:这是大头。数据库会把常用数据缓存在内存里,缓存池给得太小会疯狂读盘,性能断崖式下跌。经验值是按数据总量的 1.5~2 倍给,但至少给 512MB,通常建议 1GB 起。系统与容器运行时:Docker 守护进程、日志、系统服务加起来预留 500MB。

假设你的项目是后端 300MB、数据库数据量 200MB、缓存服务 100MB,那么总需求约 300 + 1024(数据库按最小 1GB 给)+ 100 + 500 ≈ 1.9GB。这种情况下 2G 内存就是临界值,我会直接选 4G,因为构建镜像的时候会额外吃掉 1G 左右,2G 机器在服务器上直接构建镜像有相当概率被 OOM 杀掉。如果坚持要在服务器上构建,内存至少翻倍。

磁盘同理,分三块算:系统与镜像(镜像层叠起来很容易到 5~10G,加上系统本身预留 20G)、数据(数据库 + 上传文件)、日志(按每天 200MB 估,保留 7 天就是 1.4G)。20 + 数据量 × 3 + 10,是我给的最低盘量。听起来很宽裕,但磁盘满了是线上最恶性的故障之一,数据库直接拒绝写入,而且扩容往往需要停机。

CPU 反而不用太纠结,2 核基本够拉起上面这套东西。真正吃 CPU 的是构建镜像和跑数据库查询,前者可以挪到本地做,后者取决于你的业务复杂度。核心结论就一句:内存不要贴着需求线选,磁盘不要贴着数据量选,CPU 可以抠一点。

2.2 首次登录后立刻要做的五件事

装 Docker 之前先把系统基础打好,顺序错了后面要重来。

第一件是建普通用户并给 sudo 权限。长期用 root 操作是个坏习惯,一个手滑的rm -rf就是灾难。命令很简单,建用户、加组、验证切换。

adduser deploy usermod -aG sudo deploy su - deploy

第二件是把登录方式换成密钥。本地生成一对密钥,把公钥传上去,然后改 SSH 配置关掉密码登录。这一步做完,服务器被暴力破解密码的风险基本归零,日志里的扫描记录会少掉九成。

# 本机执行,一路回车即可 ssh-keygen -t ed25519 -C "deploy@project" ssh-copy-id -i ~/.ssh/id_ed25519.pub deploy@your-server-ip

改配置之前先确认密钥能登录、能 sudo,否则关掉密码登录你就把自己锁在外面了。配置里要改的三行:PasswordAuthentication no、PermitRootLogin no、PubkeyAuthentication yes。改完sshd -t检查语法,再systemctl reload sshd重载,不要 restart,重载失败会保留旧连接。

第三件是防火墙。云服务商的控制台里有一层安全组,系统里还有一层防火墙,两层都要放行同样的端口,否则会出现"我明明开了 80 但访问不通"这种查半天的问题。系统层的规则只留三个口。

sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

注意:ufw enable会立刻生效。如果当前是通过 SSH 连着,务必先确认 22 端口规则已经加进去了,否则执行完当场断线。稳妥做法是先在云控制台留一个网页终端作为后路。

第四件是时间同步。这件事听起来无关紧要,但证书校验、日志排序、数据库事务都依赖准确的系统时间。服务器时钟漂移个几分钟,证书校验就会失败,报错信息还特别绕。

sudo timedatectl set-ntp true timedatectl status

第五件是文件句柄上限。默认值对普通网站够用,但一旦有并发长连接(WebSocket、大文件下载),很容易撞到上限,表现是"偶发连接被拒"且日志里没有应用层错误。改法是在/etc/security/limits.conf里给部署用户加两行,把软硬限制都提到 65535,改完要重新登录才生效。

2.3 Docker 与 Compose 的安装方式

我的建议是走官方源安装,不要用系统发行版自带的包。自带包的版本往往落后一两年,新版 Compose 的语法特性用不了,还会遇到一些已经被修掉的奇怪 bug。

安装步骤大致是:卸载系统里可能残留的旧版本、配置官方软件源、安装引擎和 Compose 插件、把当前用户加进 docker 组。

# 加用户到 docker 组,避免每条命令都要 sudo sudo usermod -aG docker $USER newgrp docker # 验证,能出两行版本号就说明装好了 docker version docker compose version

加组之后必须重新登录或者newgrp才生效,这一步经常被忘,表现是执行 docker 命令报权限错误。

装完立刻要做一件事:限制容器日志的大小。Docker 默认把容器输出原样写进 JSON 文件,不做任何切割。一个报错刷屏的服务,一晚上就能写满几十 G,磁盘满了之后所有服务连锁崩溃。这个坑我踩过,凌晨被叫起来清磁盘的经历不想再来一次。改/etc/docker/daemon.json:

{ "log-driver": "json-file", "log-opts": { "max-size": "50m", "max-file": "3" } }

意思是单个日志文件最大 50MB,最多保留 3 个,总量封顶 150MB。改完重启 Docker 生效。已经存在的容器不会自动应用新配置,需要重建才会生效。

2.4 目录规划:把数据从容器里挪出来

容器是"用完即弃"的,这是设计初衷,也是最容易被误解的地方。你的应用代码在镜像里,镜像重建代码会更新,这没问题;但数据库文件、用户上传的图片、生成的报表,这些东西必须放在容器外面,否则容器一重建就全部蒸发。

我会在部署用户的家目录下建这样一套结构:

~/apps/myapp/ ├── docker-compose.yml ├── .env ├── nginx/ │ └── conf.d/ │ └── myapp.conf └── data/ ├── db/ # 数据库数据目录 ├── uploads/ # 应用上传目录 └── backup/ # 备份产物

这套结构的好处是所有权清晰。配置文件在myapp/下,可以直接版本管理;数据全在data/下,备份的时候一个命令打包整个目录就行;出了问题要重建环境,删掉容器和myapp/里除data/之外的东西,数据毫发无伤。

提示:data/db这个目录的权限有讲究。数据库容器里的进程通常不是 root,如果宿主机目录的 UID 对不上,容器启动会报"权限不足"然后直接退出。排查的时候用docker compose logs db看,如果报的是文件权限相关,把宿主机目录的属主改成容器内运行用户对应的 UID(常见是 999 或 1000)即可。不要图省事直接chmod 777,数据库文件目录全开权限,安全上不合适。

3. 把整套架构写进一个 YAML

3.1 一份接近可用的编排文件示例

先说结论:新版 Compose 规范里顶层的version:字段已经废弃,写了会有一条警告,删掉即可。很多人照抄老教程还在写version: "3.8",功能上没问题,但看到警告容易怀疑人生。

下面这份是四服务结构(入口网关 + 应用 + 数据库 + 缓存),拿掉业务相关字段之后可以直接套用:

name: myapp services: gateway: image: nginx:1.27-alpine ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./data/certs:/etc/nginx/certs:ro - ./data/webroot:/var/www/cert:ro depends_on: app: condition: service_healthy restart: unless-stopped networks: - frontend logging: options: max-size: "20m" max-file: "3" app: image: registry.example.internal/myapp:2024-06-01-a1b2c3 environment: DATABASE_URL: postgres://app:${DB_PASSWORD}@db:5432/appdb REDIS_URL: redis://cache:6379/0 APP_ENV: production volumes: - ./data/uploads:/app/uploads depends_on: db: condition: service_healthy cache: condition: service_started healthcheck: test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8000/healthz"] interval: 30s timeout: 5s retries: 3 start_period: 40s restart: unless-stopped networks: - frontend - backend deploy: resources: limits: memory: 768M db: image: postgres:16-alpine environment: POSTGRES_USER: app POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: appdb volumes: - ./data/db:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U app -d appdb"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped networks: - backend deploy: resources: limits: memory: 1024M cache: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--maxmemory", "256mb", "--maxmemory-policy", "allkeys-lru"] volumes: - ./data/redis:/data restart: unless-stopped networks: - backend deploy: resources: limits: memory: 320M networks: frontend: backend:

这份文件里有几个决定值得单独说。

3.2 网络拓扑:为什么要分 frontend 和 backend

默认情况下 Compose 会给整个项目建一个网络,所有服务都在里面,互相都能通。图省事的话全塞一个网络也能跑,但有个隐患:入口网关同时连着应用和数据库,万一将来网关配置写错、或者网关本身出了漏洞,攻击者可以直接从网关摸到数据库端口。

分两个网络的逻辑很简单:frontend只放对外暴露的服务(网关、应用),backend只放内部服务(数据库、缓存)。应用同时在两个网络里,它是唯一能同时看到网关和数据库的服务;数据库只在backend,从网关那个网络里根本解析不到它的地址。

这个设计的实际收益是把"数据库端口暴露"这类低级错误从物理上堵死了。只要数据库的 networks 里没有frontend,你在网关里怎么配都转发不过去。

同一网络内,服务名就是主机名。应用连数据库写db:5432而不是127.0.0.1:5432,这是容器化最常见的新手错误之一——127.0.0.1在容器里指的是容器自己,不是宿主机,也不是隔壁容器。这一点用生活化的比喻说:每个容器是一间独立的房子,localhost是自己家的门牌,服务名是小区里其他房子的门牌,宿主机则是小区外面的马路。

3.3 环境变量与密码:别把凭据写进 YAML

上面那份文件里所有敏感值都用了${DB_PASSWORD}这种占位。Compose 会自动读取同目录下的.env文件填充。这样做的好处是 YAML 可以进版本库、可以分享、可以贴给同事看,密码单独放在.env里,加进.gitignore就完事。

.env的内容长这样:

DB_PASSWORD=a-long-random-string-here APP_SECRET_KEY=another-long-random-string

生成随机串别手敲,用openssl rand -base64 32或者head -c 48 /dev/urandom | base64,后者更保险一点。手敲的密码有个隐蔽问题:键盘上相邻的键位有物理规律,看着随机的串,实际熵可能很低。

生产环境的.env文件权限建议设成 600,只有属主可读。多人协作的团队里,更好的做法是用平台自带的密钥管理,把敏感值从文件里彻底挪出去,.env只留非敏感的配置项。这套东西说起来复杂,实际上配置成本不高,值得做。

注意:镜像里千万不要打包.env。构建时用.dockerignore把.env、.git、node_modules、本地数据目录全部排除。我见过镜像里带着数据库密码推到公共仓库的例子,虽然最后没出事,但那种后背发凉的感觉记忆犹新。

3.4 健康检查与启动顺序:depends_on不等于"等它准备好"

这是 Compose 里最容易误解的地方。老式的depends_on: [db]只保证启动顺序,不保证数据库真的能接受连接。数据库容器的启动分两个阶段:进程起来了(几秒钟),初始化完成、开始监听端口(可能几十秒)。老式depends_on只等第一阶段,应用这时候去连数据库,直接报连接拒绝然后退出。

新式的写法带上condition: service_healthy,配合被依赖服务的healthcheck,才是"等它真的能用了再启动我"。上面示例里网关等应用、应用等数据库,用的就是这个。

健康检查的test命令要选轻量且真实的探测点。数据库用pg_isready比curl一个端口靠谱得多,它真的会尝试握手而不是只看端口有没有监听。应用的健康检查端点要单独设计,只检查进程存活和必要依赖,不要把耗时的业务逻辑塞进去,否则高峰期健康检查超时,容器被判死重启,雪崩就是这么来的。

几个参数的含义值得记住:interval是探测间隔,timeout是单次超时,retries是连续失败几次算不健康,start_period是启动宽限期——这段时间内的失败不计入 retries。start_period设小了,应用启动慢一点就会被判死,进入反复重启的循环,日志里看起来像是"启动就崩",其实是健康检查误杀。

3.5 端口暴露:只留一个口朝外

ports和expose的区别值得单独讲。ports是把容器端口映射到宿主机,写"8080:8080"意味着宿主机上这个端口能被外部访问;expose只是声明容器端口对其他容器可见,不映射到宿主机。

原则就一条:只有入口网关写ports,其他服务一律不写。数据库写"5432:5432"是把数据库直接怼到公网上,扫描器几分钟内就会找上门,配合弱密码就是一场灾难。应用服务也不该直接暴露,所有外部请求都从网关进来,这样限流、访问日志、证书处理都只有一处需要维护。

内部服务之间通过服务名访问,根本不需要映射到宿主机。如果你在本地调试时确实需要直连数据库,可以用 SSH 隧道把端口转发过来,用完就关,不要把端口暴露当成常规操作。

4. 入口层:域名、网关与证书

4.1 域名解析与生效

域名指向服务器就是一条 A 记录的事,但有两个细节容易忽略。一是只配主域名忘了 www,用户习惯性敲 www 打不开,体验直接扣分。稳妥做法是两条 A 记录都指向同一个 IP,网关里再统一处理。二是TTL 设得太长。上线初期你可能会换 IP,TTL 设成 3600 意味着改完之后一小时才全球生效,急的时候特别难受。前期设 300 秒,等一切尘埃落定再调回 3600。

配完记录别急着访问,先验证解析是否生效:

dig +short your-domain.com # 应该返回你服务器的 IP

如果返回空或者是旧 IP,等一会儿再试,或者换个 DNS 服务器查。dig @8.8.8.8 your-domain.com可以强制走某个公共 DNS,快速判断是"还没传播完"还是"记录本身配错了"。这一步验证完,再去浏览器访问,能省掉大量"到底是 DNS 问题还是服务问题"的纠结。

4.2 网关配置的四个关键项

网关是整套架构里唯一对外的大门,配置文件不需要很长,但四类配置必须有。

第一类是转发目标。用服务名加容器内端口,proxy_pass http://app:8000;。注意这里不能有尾部斜杠,多了斜杠会改变路径拼接行为,导致接口 404,这个细节坑过很多人。

第二类是超时。网关默认超时是 60 秒,如果你的接口有耗时任务(导出报表、调用外部服务),60 秒必然 504。设成 300 秒并配合后端的异步任务处理,是比较稳妥的折中。

proxy_connect_timeout 5s; proxy_send_timeout 300s; proxy_read_timeout 300s;

第三类是上传大小。网关默认允许 1MB,用户传张高清图就 413,报错信息还是 HTML 页面,前端拿到的不是 JSON,提示逻辑全乱。按业务需求设,一般 20MB 起步。

client_max_body_size 20m;

第四类是真实 IP 透传。不配这几行,应用日志里所有请求的来源 IP 都是网关容器的地址,属于内网 IP,排查问题和做访问统计时完全没用。

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_set_header X-Forwarded-Proto $scheme;

最后一行管协议,应用如果用它生成绝对链接(比如邮件里的回调地址),不配就会生成http://开头的链接,用户点进去被浏览器拦。

改完配置不用重启网关,docker compose exec gateway nginx -t校验语法,通过后nginx -s reload热加载,已有连接不受影响。

4.3 证书:把续期做成自动的

HTTPS 现在是标配,浏览器对没有证书的站点会明确标注不安全,用户流失是必然的。免费证书的签发已经标准化,走 ACME 协议即可,几乎所有的证书服务方都支持。

常见的申请方式分两种:HTTP-01 验证要求你的服务器 80 端口能从外网访问,在指定路径放一个校验文件,签发方来读;DNS-01 验证是在 DNS 里加一条 TXT 记录,好处是可以签发通配符证书,坏处是需要在 DNS 服务方那配置 API 凭据。

小项目用 HTTP-01 就够,配置简单。关键点在于续期要自动化。证书有效期通常是 90 天,手工续期意味着你每年要记住四次这件事,忘一次站点就挂了。自动化方案一般是一个定时任务,每天检查一次,快到期就自动续并重载网关。

# 放到 crontab,每天凌晨跑一次 0 3 * * * cd ~/apps/myapp && ./scripts/renew-cert.sh >> ./data/backup/cert.log 2>&1

续期脚本里除了申请证书,还有一个必须做的动作:续期成功后重载网关。不重载的话,新证书躺在磁盘上,网关还在用内存里的旧证书,到期照样报错。这个坑非常隐蔽,因为续期日志全绿,你根本看不出问题。

提示:配置完之后一定要做一次"演练续期",强制跑一次续期流程,确认证书文件更新了、网关重载了、浏览器里看到的证书日期变了。不要等三个月后真到期那天才发现脚本某一行写错了。

4.4 502 和 504 的排查路线

这两个错误码加起来占上线初期故障的一大半,但含义完全不同,混淆了就会查错方向。

502 Bad Gateway意思是网关连不上或连上了但对端不响应。排查顺序自上而下:

  1. docker compose ps看应用容器状态,是不是Restarting或者Exited。
  2. docker compose logs --tail=100 app看应用日志,九成情况这里会有明确报错。
  3. 如果应用活着,进网关容器验证连通性:docker compose exec gateway wget -qO- http://app:8000/healthz。这里返回连接拒绝,说明服务名解析或端口不对。
  4. 检查应用是不是只监听了127.0.0.1。这是个高频坑:应用在本地跑习惯绑127.0.0.1,容器里的其他服务连不进来,必须绑0.0.0.0。

504 Gateway Timeout意思是连上了,但等响应等超时了。这时候应用是活的,只是处理慢。方向是查应用内部:数据库慢查询、外部接口卡住、死锁、连接池耗尽。先看应用日志有没有慢请求记录,再看数据库有没有长时间运行的查询,最后看网关的超时配置是不是设得太短。

我自己的习惯是先在网关上开访问日志,把$upstream_response_time和$request_time都记下来。响应时间异常长的请求会立刻暴露出来,比逐个服务翻日志快得多。

5. 上线与迭代:把流程做成可重复的

5.1 镜像在哪里构建

一个很实际的问题:镜像在本地构建好推上去,还是在服务器上直接构建。

在服务器上构建的问题:服务器要装完整的构建工具链,镜像体积会包含构建缓存和中间层,内存占用瞬间翻倍,构建期间服务性能下降。更麻烦的是,构建依赖服务器上的代码状态,如果你直接在服务器上改文件调试,构建产物就变成了"只有这台机器能复现"的东西,版本管理彻底失效。

在本地或流水线构建的优势:构建环境隔离,产物是确定性的镜像,推到私有镜像仓库,服务器只负责拉取。这样服务器上不需要任何语言运行时、编译工具,它唯一的工作就是跑容器。整个服务器干净得像一台专用的运行设备。

我的做法是用带分支名和提交短哈希的 tag,比如myapp:main-a1b2c3d,同时打一个myapp:latest。部署时用具体 tag,不用latest。原因很直接:latest会漂移,今天拉的和明天拉的不是同一个东西,出了问题你没法确定线上跑的到底是哪个版本。具体 tag 让"当前线上是什么版本"这个问题有确定答案。

镜像仓库选私有的,即使代码是开源的,镜像里也可能带着构建时的中间产物。推送前确认镜像大小,多阶段构建能把几百兆压到几十兆,拉取速度和磁盘占用都有明显改善。

5.2 一份可以直接抄的更新脚本

手工更新的流程是:登服务器、改 compose 文件里的 tag、docker compose pull、docker compose up -d、看日志。这个流程本身不难,但每次都要重复,容易漏步骤。写成脚本之后就变成一条命令。

#!/usr/bin/env bash set -euo pipefail NEW_TAG="${1:?用法: ./deploy.sh <tag>}" cd "$(dirname "$0")/.." echo "==> 备份数据库" ./scripts/backup.sh echo "==> 更新镜像 tag 为 ${NEW_TAG}" sed -i "s|image: registry.example.internal/myapp:.*|image: registry.example.internal/myapp:${NEW_TAG}|" docker-compose.yml echo "==> 拉取新镜像" docker compose pull app echo "==> 滚动更新(只重建应用容器)" docker compose up -d --no-deps app echo "==> 等待健康检查通过" for i in $(seq 1 30); do STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q app)") [ "$STATUS" = "healthy" ] && echo "健康检查通过" && exit 0 sleep 2 done echo "!! 健康检查未通过,开始回滚" docker compose logs --tail=200 app exit 1

几个细节值得说明。set -euo pipefail让脚本在任一步失败时立刻停下,而不是带着错误继续往下跑。备份放在最前面,是给回滚留退路。--no-deps保证只重建应用,数据库和缓存不动,避免误重启导致数据服务中断。

sed改文件这一步略显粗暴,更优雅的做法是把 tag 抽成.env里的变量,compose 文件里写${APP_TAG}。这样脚本只需要改.env,YAML 完全不动,可读性和安全性都更好。我一开始用sed,后来改成变量,是因为有一次正则没匹配上,脚本静默跑完了,部署的还是老版本,查了半天才发现。

注意:docker compose up -d重建容器时,如果 volume 挂载的路径写错,可能会在宿主机上新建一个空目录,看起来一切正常,实际数据全在新目录里,老数据还在旧路径。这种情况非常难发现,因为服务确实跑起来了。上线前把docker compose config的输出打出来,逐项核对 volume 的宿主机路径,能避免这个坑。

5.3 备份与回滚:出事那天你会庆幸做过

备份的核心原则只有一条:备份不能和原数据放在同一块盘上。磁盘坏了,数据和备份一起没,这不叫备份,叫同步。

数据库备份用官方客户端导出,比直接复制数据目录安全得多——直接拷贝一个正在运行中的数据库目录,得到的文件可能处于不一致状态,恢复时会报错。导出命令加上-T参数(不分配伪终端),才能正确通过管道重定向:

docker compose exec -T db pg_dump -U app -d appdb \ | gzip > "./data/backup/db-$(date +%F-%H%M).sql.gz"

用 cron 每天跑一次,保留最近 14 天,超期的自动删掉。上传目录用tar打包,体积大的话可以先rsync到另一台机器或者对象存储,再做打包。

回滚要提前演练。回滚就是./deploy.sh 上一个tag,两分钟的事。但前提是旧镜像还在仓库里,而且数据库结构没有做不兼容的变更。如果新版本改了表结构,回滚代码是没用的,因为老代码跑在新表结构上会报错。所以数据库变更加上"向后兼容"的约束:加字段可以,删字段要分两个版本走(先停止使用,下个版本再删),改字段类型要特别小心。这条规则看着麻烦,但它是唯一能让"随时回滚"成立的前提。

5.4 日志与最小监控

日志分散在四个容器里,出问题时要一个个docker compose logs翻,效率很低。至少要做两件事:统一日志查看命令,和关键指标的可视化。

统一查看就是docker compose logs -f --tail=50,一条命令看全部。加--since 10m只看最近十分钟,排查时噪音小很多。

监控不用上复杂的系统,用宿主机的定时脚本加一个简单的告警就够。重点看三个指标:磁盘使用率、容器健康状态、接口响应时间。磁盘超过 80% 就该清理了,容器不健康要立刻知道,接口响应时间突然翻倍说明有性能问题在酝酿。

# 每分钟检查一次,容器不健康就告警 UNHEALTHY=$(docker ps --filter health=unhealthy --format '{{.Names}}') [ -n "$UNHEALTHY" ] && echo "不健康容器: $UNHEALTHY" | mail -s "告警" ops@your-domain.com

邮件告警容易被忽略,更实用的是推送到团队用的即时通讯工具里。不管用哪种渠道,告警必须有人看到,否则等于没有。我见过配了告警但没人看的情况,出事之后才发现邮件积了几十封。

6. 踩过的坑和一份速查表

6.1 五个印象最深的故障复盘

第一个是容器反复重启。现象是docker compose ps显示状态一直在Restarting,日志刷得飞快。原因是应用监听在127.0.0.1,健康检查从容器外部探测,永远探不到,被判死重启。改成0.0.0.0立刻好了。这个问题的隐蔽之处在于,应用日志里一切正常,甚至能看到"服务已启动"的提示,你会以为它在好好跑。

第二个是磁盘一夜写满。某次更新之后加了一个调试日志,输出量特别大,第二天早上服务全挂,数据库报"设备空间不足"。根因就是没限制 Docker 日志大小。教训是容器日志上限必须成为默认配置,新建任何项目第一件事就是配它。

第三个是证书续期成功但站点仍报过期。续期脚本每天跑,日志全绿,浏览器还是提示证书过期。查了半天发现网关用的是挂载进去的证书文件,续期写的是另一个路径下的文件,两边根本不是同一个。修正之后还加了"续期后强制 reload"这一步。这个坑让我明白,自动化看着跑通不算数,得验证结果。

第四个是数据库数据"消失"。一次清理操作中删掉了容器,重新up之后数据库是空的。原因是 compose 文件里数据库的 volume 写成了命名卷,而清理时带了-v参数,把命名卷一起删了。命名卷虽然比匿名卷好管理,但删除时很容易误伤。换成宿主机目录绑定之后,数据在肉眼可见的位置,操作时心里有数。

第五个是跨域请求全被拦。前端和接口部署在同一个域名下,但网关把请求转发给后端时没带Host头,后端生成的跨域响应头里域名对不上,浏览器判定跨域失败。补上proxy_set_header Host $host;之后解决。这类问题的排查方法是打开浏览器开发者工具的网络面板,看真实响应的头信息,不要靠猜。

6.2 故障速查表

现象最可能的原因第一步查什么
浏览器连不上,超时防火墙或安全组没放行端口从外部telnet 域名 80试连通性
502 Bad Gateway应用容器没起来或端口不对docker compose ps看状态
504 超时后端处理慢或网关超时太短看访问日志里的响应时间分布
静态资源 404网关路径拼接错误检查proxy_pass有没有多余的尾部斜杠
上传文件 413网关体积限制太小调client_max_body_size
容器一直重启健康检查误判或启动即崩溃docker compose logs --tail=100 服务名
数据重建后丢失volume 路径没配对docker compose config核对挂载路径
证书提示过期续期后没重载网关对比磁盘证书日期和浏览器显示的日期
应用连不上数据库用了127.0.0.1而非服务名检查连接的 host 部分
时区错乱八小时容器没设时区加TZ=Asia/Shanghai环境变量
日志里客户端 IP 全是内网没透传真实 IP检查网关注入的头
构建时报内存不足服务器内存不够构建移到本地构建或加 swap

表格最后一行提到时区,这个坑值得单独说。容器默认用 UTC,应用里所有时间都会比本地时间少八小时,用户看到订单时间对不上,客服就开始接到投诉。解法是在服务的环境变量里加TZ=Asia/Shanghai,同时确认数据库的时区设置和连接串里的一致。三个地方任意一处不一致,就会出现"存进去是下午三点、查出来是早上七点"这种灵异现象。

6.3 几个我一直在用的习惯

第一,任何改动都先docker compose config过一次。这条命令会解析 compose 文件,把变量替换后的最终结果打出来。YAML 缩进错一个空格、环境变量没读到、volume 路径写错,在这一步全都能看出来。比up之后出问题再回头查要快得多。

第二,新项目第一次上线,先在本地模拟一遍完整流程。用同样的 compose 文件在本地起一遍,包括网关、证书(自签一个)、健康检查,全部跑通再上服务器。这样能把"配置问题"和"网络问题"分开,排查范围直接减半。

第三,保留一份"复活脚本"。内容很简单:从零开始,到服务能跑起来,需要执行的所有命令,按顺序写在一个文件里。服务器真挂了、要换机器的时候,这个脚本能让你在一小时内重建环境。写的时候会觉得"我肯定记得住",但真出事那天,人是慌的,记忆是不可靠的。

第四,每次更新前后各看一眼磁盘和内存。df -h和free -m两条命令,花不了五秒钟,但能提前发现资源在慢慢被吃光。多数线上故障不是突然发生的,是资源一点点耗尽,最后一次小请求触发了雪崩。养成这个习惯,等于给自己装了一只提前预警的眼睛。

第五,不要在生产服务器上做实验。想试新配置、新版本,在本地或者另起一台测试机折腾。生产环境的价值在于它是"可靠的那一份",一旦你开始在上面随手改东西,这个可靠性就没了。这个道理谁都懂,但赶时间的时候最容易破例,而破例的代价往往比省下来的时间大得多。

这套流程跑顺之后,一次完整的部署从提交代码到线上生效,大概就是十几分钟的事,其中有十分钟在等镜像构建和数据库备份。真正需要人盯着的部分,只有健康检查那几十秒。到了这个状态,你会发现自己敢在下午五点发版了——不是因为胆子大了,是因为知道就算出事,两分钟就能退回去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询