☰
Docker Compose 容器化部署上线:从镜像构建到排错维护
2026/10/23 3:37:54 网站建设 项目流程

1. 先把"上线"这件事拆开看:Docker Compose 到底替我们省掉了什么

很多人第一次把本地项目往公网服务器上搬,脑子里想的是一条直线:买机器、装环境、传代码、跑起来。真动手才发现这条线上全是岔路——运行时装哪个版本、依赖装不装得全、端口开没开、数据库连不连得上、进程挂了谁来拉起、重启之后配置还在不在。Docker Compose 的价值,不是让你少敲几条命令,而是把这堆岔路收敛成一份可以版本管理的声明式文件。

我在带人做部署的时候有个习惯:先问对方"你的服务由几个部件组成"。如果答案是"一个 Web 应用加一个数据库",那 Compose 基本就是最优解;如果答案是"十几个微服务加消息队列再加网关",那要么上编排系统,要么老老实实按域拆成几份 Compose 文件分机部署。工具选型的第一步永远是先数清楚自己的部件数量,而不是先去找教程抄配置。

Compose 干的事情可以类比成一份"装修施工单"。以前你是拿着口头描述让工人(服务器)现场发挥,出了问题谁也说不清当初是怎么定的;现在你把每个房间用什么材料、走几路线、接口留在哪,全写在一张单子上,工人照着干,下次换一批工人照着同一张单子还能干出一样的结果。这份单子就是docker-compose.yml,它带来的核心收益是环境一致性和可复现性,而不是单纯图省事。

还有一层常被忽略的价值:Compose 天然定义了服务之间的网络拓扑。同一个项目里的容器默认落在同一个自定义网络上,可以直接用服务名互相访问,不需要记 IP、不需要写死地址。这一点在"本地能跑、服务器上连不上数据库"这类经典问题里,往往是关键差异所在——本地你可能用了localhost,而容器里的localhost指的是容器自己,不是宿主机。

我见过太多人卡在这一步:代码里数据库地址写的是127.0.0.1,本地跑得好好的,部署到服务器上容器一启动就报连接拒绝,然后开始怀疑防火墙、怀疑云厂商、怀疑人生。其实原因简单得可笑——容器内的回环地址指向容器自身,数据库在另一个容器里,当然连不上。理解 Compose 的网络模型,比背会十条命令更重要,这也是本篇后面会反复回到的一个点。

所以这份指南的定位很明确:给那些项目已经能在本地跑起来、但还没真正经历过公网环境考验的人。不管你用的是哪种语言栈、哪种数据库,只要你的服务可以被容器化,下面的路径基本通用。我尽量不写"复制粘贴就能跑"的空壳配置,而是把每一行为什么这么写讲清楚,因为线上出问题时,能救你的是理解,不是复制。

2. 上线前的容器化改造:镜像怎么打才不给未来埋雷

2.1 从"能跑"到"跑得住":Dockerfile 的三个关键决策

把项目塞进容器,第一步是写 Dockerfile。新手最常见的写法是把整个项目目录COPY进去、装依赖、启动命令一写了事,本地测试通过就以为万事大吉。实测下来,这种镜像往往有几个隐患:体积巨大、构建极慢、以 root 身份运行、没有健康检查。它们在演示环境里毫无症状,一到公网服务器上就开始暴露。

第一个决策是基础镜像的选择。以常见运行时为例,官方镜像通常提供完整版和精简版两条线,前者自带编译工具链,后者只保留运行所需的最小集合。我的建议是分阶段:构建阶段用完整版装依赖、编译产物,运行阶段用精简版只搬运最终产物。这样做的好处不只是体积小,更关键的是攻击面小——线上镜像里不该存在编译器、包管理器这类工具,它们对运行毫无帮助,只会成为隐患。

第二个决策是依赖安装与源码拷贝的顺序。Docker 的构建缓存是按层命中的,一旦某一层的内容变了,它之后的所有层都要重建。如果把COPY . .放在装依赖之前,那么你每改一行业务代码,依赖就要重装一遍,构建时间从几十秒涨到几分钟。正确顺序是先拷贝依赖清单文件、执行安装,再拷贝源码。这样只要依赖清单没变,装依赖那层就一直命中缓存。

第三个决策是运行用户。容器默认以 root 运行,这在单机玩具项目里无所谓,但只要端口暴露在公网,就属于不必要的风险放大。创建一个普通用户、切换过去再启动进程,成本极低,收益是把潜在的越权影响限制在一个低权限身份内。这一条我强烈建议所有要上公网的项目都做。

FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . FROM node:20-alpine WORKDIR /app RUN addgroup -S app && adduser -S app -G app COPY --from=builder /app /app USER app EXPOSE 3000 CMD ["node", "server.js"]

上面这份示例体现的就是前面说的三点:分阶段构建、依赖先行、非 root 运行。注意npm ci而不是npm install,前者严格按锁文件安装,保证每次构建的依赖树一致,这是环境可复现的基本要求。

2.2 别把密钥烤进镜像:配置与代码的分离

比镜像体积更容易出事的是配置管理。我见过有人把数据库密码直接写在代码里,然后把代码打进镜像推到公共仓库,等到发现时已经晚了。镜像一旦构建完成,里面的内容就是可以被任何人拉取解包的,把密钥写进镜像等于公开发布。

正确做法是通过环境变量注入,Compose 里用env_file指向一个服务器本地文件,或者用environment声明。这个文件不进版本库,只存在于服务器上,权限设为仅属主可读。项目里留一份.env.example说明需要哪些变量、格式是什么,新人照着填即可。这条规矩看起来是老生常谈,但每次复盘线上事故,总能看到它的影子。

services: app: image: myapp:latest env_file: - .env.production environment: TZ: Asia/Shanghai

TZ这一行值得单独说。容器默认时区通常是 UTC,你的日志时间、定时任务触发时间、数据库写入的时间戳全都偏了八小时。排查问题时对不上日志时间,能把人逼疯。显式设一次时区,一劳永逸。

2.3 健康检查:让"容器在跑"和"服务可用"变成两件事

docker ps显示Up,不代表你的服务真的能响应请求。进程可能已经死循环、可能卡在初始化、可能连不上数据库但还没退出。Compose 支持healthcheck,定期执行一条探测命令,根据结果标记容器健康状态。

healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"] interval: 30s timeout: 5s retries: 3 start_period: 40s

start_period这个参数很多人不写,导致应用启动慢的时候被误判为不健康。它的含义是"给容器一段宽限期,这段时间内探测失败不计入重试次数"。有初始化逻辑的服务一定要设,否则你会看到容器在启动阶段被反复重启。

健康检查真正的价值在于依赖编排。数据库容器如果不是健康的,应用容器就不该急着启动。用depends_on配合condition: service_healthy可以让启动顺序有实际意义,而不是"先启动但连不上"。

3. 服务器侧的准备工作:比配置文件更容易出错的地方

3.1 系统层面的三件小事:时区、防火墙、磁盘

拿到一台干净的服务器,别急着装 Docker。先把三件事理清楚。

时区。宿主机时区和容器时区最好一致,否则日志交叉比对时会怀疑自己。系统级设一次,容器里再设一次,双重保险。

防火墙。云服务器的安全组和系统防火墙是两层,很多人只改了其中一层。安全组的规则在控制台配置,系统防火墙用系统的工具管理。我的习惯是:只对外开放必要端口,通常是 SSH 端口加 Web 服务端口;数据库端口绝对不要对公网开放,容器之间在内部网络通信就够了。如果确实需要本地连接数据库调试,走 SSH 隧道,别图省事直接开端口。

磁盘。容器日志默认会无限增长,跑上几个月把磁盘写满,然后整台机器上的服务一起挂掉。这是极其常见的线上事故。在 Docker 的守护进程配置里给日志设个上限,是部署前就该做的动作。

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

这段配置写进 Docker 的配置文件后重启服务生效,含义是单个日志文件最大 50MB,最多保留 3 个。总量可控,排查时也够用。

3.2 装 Docker 时最容易忽略的一步

装 Docker 本身没什么难度,按官方文档走即可。真正容易忽略的是把当前用户加入 docker 用户组,否则每条命令都要加sudo,用着别扭,脚本里也容易出权限问题。

sudo usermod -aG docker $USER

执行完要重新登录才生效。这一步做完,后面的操作都不需要提权,Compose 文件里的相对路径、挂载目录的权限问题也会少很多。

还有一个细节:Docker 的数据目录默认在系统盘。镜像、容器层、数据卷都堆在那里,项目跑久了系统盘告警是常态。如果服务器有独立的数据盘,把 Docker 的根目录迁移过去。迁移步骤不复杂:停服务、改配置里的>services: app: build: . image: myapp:latest restart: unless-stopped ports: - "127.0.0.1:3000:3000" env_file: - .env.production depends_on: db: condition: service_healthy db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - db_data:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s retries: 5 volumes: db_data:

逐行看几个关键点。

restart: unless-stopped表示除非人为停止,否则容器退出就自动拉起。这个策略适合长期运行的服务。为什么要用unless-stopped而不是always?因为你在调试时手动stop了一个容器,always会在 Docker 服务重启后又把它拉起来,而unless-stopped尊重你的手动操作。调试体验差别很大。

ports写成127.0.0.1:3000:3000而不是3000:3000,这是个非常重要的安全习惯。前者表示只监听本机回环地址,外网访问不到;后者表示监听所有网卡,等于直接对公网开放。通常我们会在这台机器上再放一个反向代理,由它对外提供 80/443,应用只在内网被代理访问。这样应用端口不直接暴露,安全边界清晰。

4.2 数据卷:容器可以删,数据不能丢

volumes这一块是新手最容易轻视、出事后最肉疼的部分。容器本身是临时的,删了重建,里面写入的文件全部消失。数据库的数据目录必须挂到卷上,卷的生命周期独立于容器。

docker compose down # 容器删除,卷保留 docker compose down -v # 容器和卷一起删除,数据没了

这两个命令的区别,我建议你在测试环境亲手试一次,感受一下-v的威力。生产环境执行down -v之前,务必确认有可用备份。我见过有人在生产机上顺手敲了down -v,数据库直接归零,恢复靠的是几小时前的备份,丢了一段业务数据。

除了数据库,用户上传的文件、应用生成的缓存,只要是需要持久化的,都要挂卷或者挂宿主机目录。判断标准很简单:这个目录里的东西,容器重建后还需要吗?需要就挂出去。

挂宿主机目录时要注意权限。容器内进程以非 root 用户运行,宿主机目录如果属于 root 且权限严格,容器写入会报权限拒绝。解决方案是提前把宿主机目录的属主改成对应的用户 ID,或者在 Compose 里显式指定user。这个问题在数据库容器上尤其常见,因为官方数据库镜像会检查数据目录的属主。

4.3 反向代理:把端口收敛到一个入口

前面提到应用端口只监听回环地址,那外部流量怎么进来?答案是反向代理。常见选择有 Nginx、Caddy、Traefik,选哪个看你的需求。Caddy 的优势是自动申请和续期证书,配置极简;Nginx 胜在生态成熟、资料多、可控性强。

我倾向于把反向代理也放进 Compose,作为一个服务统一管理。

proxy: image: nginx:alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/certs:/etc/nginx/certs:ro depends_on: - app

代理转发到应用时,目标地址写服务名加容器端口,比如proxy_pass http://app:3000;。因为它们在同一个 Compose 网络里,服务名会被解析成容器地址。这就是前面说的"服务名互访"的实际用处,配置文件里永远不出现 IP。

代理配置里还有几个转发头要带上:Host、X-Real-IP、X-Forwarded-For、X-Forwarded-Proto。不带头信息,应用拿到的客户端 IP 全是代理的 IP,限流、审计、日志全都失真;不传X-Forwarded-Proto,应用不知道自己被 HTTPS 访问,可能生成错误的跳转地址,导致重定向循环。

4.4 多环境配置的复用思路

项目往往有测试环境和生产环境,两份 Compose 文件如果完全复制,改一处要改两遍,迟早不一致。Compose 支持多文件叠加,基础文件写公共部分,环境文件写差异部分。

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

差异通常包括:镜像标签、端口映射、环境变量文件、资源限制。这样公共结构只维护一份,环境差异集中在一处,复盘时一目了然。这是工程化上很划算的一笔投入。

5. 首次部署的完整链路:一步一步来,别跳步

5.1 从拉代码到服务可访问的实操顺序

把前面的准备串起来,一次完整的首次部署大致是这样一条链路。我按实际操作顺序写,并且标注每步的验证方法,因为部署最怕的就是"执行了但不知道成没成"。

第一步,登录服务器,进入项目目录,从版本库拉取代码。验证方式:确认目录结构和本地一致,关键配置文件都在。

第二步,准备环境变量文件。从示例文件复制一份,填入真实值,设置权限。

cp .env.example .env.production chmod 600 .env.production

第三步,构建镜像。这一步在服务器上执行,耗时取决于项目大小和网络。验证方式:看构建日志末尾是否成功,然后docker images确认镜像存在且标签正确。

docker compose build

第四步,启动服务。

docker compose up -d

第五步,观察状态。docker compose ps看所有服务是否处于 running 且 healthy。如果某个服务反复重启,直接看日志。

docker compose logs -f --tail=100 app

第六步,本机验证。在服务器上直接请求应用端口。

curl -I http://127.0.0.1:3000/health

第七步,外部验证。从本地浏览器或命令行访问公网地址,确认代理链路通畅。

这个顺序的关键在于分层验证:先确认容器起来了,再确认应用在本机可达,最后确认外网可达。三层分开查,出问题时能立刻定位到是哪一层的问题。一上来就从外网访问,失败了根本不知道是应用没起、端口没开、还是代理配错。

5.2 第一次启动数据库的初始化坑

数据库容器第一次启动时会执行初始化,创建一个空的数据目录,设置初始用户和密码。这一步有几个坑值得提前说。

第一,初始化只在数据目录为空时执行。如果你改了密码环境变量然后重启容器,新密码不会生效,因为数据目录已经存在,初始化逻辑被跳过。这时候要么进容器手动改密码,要么清空数据目录重新初始化(仅限开发环境)。生产环境遇到这种情况,正确做法是用数据库自身的工具改密码。

第二,初始化需要时间,尤其是有初始化脚本的时候。应用如果启动太快,会在数据库还没准备好时尝试连接然后失败退出。这就是前面强调健康检查和depends_on的原因。但要注意,depends_on只保证启动顺序,不保证应用自身的重连逻辑。应用侧最好也加上连接重试,双保险。

第三,编码和排序规则。初始化时如果不指定,可能拿到默认的字符集,后续存中文、emoji 出问题。建库时显式指定 UTF-8 相关配置,省得以后迁移数据。

5.3 上线后立刻要做的几项加固

服务能访问了,不等于可以收工。下面这几件事,我建议在首次上线当天就做完。

改掉默认凭据。数据库的超级用户密码、应用的初始管理员账号,全部换成强密码。用密码管理器生成,别用能猜到的组合。

关闭不必要的端口。回头检查安全组和系统防火墙,确认只有 80、443 和运维端口对外开放。数据库端口、应用端口都应该只在内网可见。

验证备份可用。备份这事,没验证过等于没有。手动导一次数据库,恢复到另一个库里,确认数据完整。这个过程可能只要十分钟,但能在真正出事时救命。

配置日志轮转。前面在 Docker 层面设了日志上限,应用自身的日志文件也要管。应用写到挂载目录里的日志,同样需要轮转策略,否则一样会把磁盘写满。

6. 上线之后:更新、排错与长期维护

6.1 一次平滑更新的标准动作

项目上线只是开始,后面会有无数次更新。我用的更新流程固定为四步,减少手滑空间。

# 1. 拉取最新代码 git pull # 2. 重新构建镜像 docker compose build app # 3. 只重建变更的服务,其他不动 docker compose up -d app # 4. 观察日志和健康状态 docker compose logs -f --tail=50 app

第 3 步只指定app,意味着数据库和代理不受影响,重建范围最小。Compose 会对比配置和镜像,只重建有变化的容器,这是它的内置能力,不需要你手动停机。

更新前有个习惯动作:先备份数据库。涉及数据库结构变更的更新,备份是底线。另外,如果新版本有问题需要回滚,镜像标签要能对应到版本号,别全用latest。用latest的后果是回滚时你根本不知道该回哪个,因为标签永远指向最新。给镜像打上版本号或提交短哈希,回滚就是改一行标签重新up。

6.2 常见故障的排查链路

线上出问题,最忌讳的是瞎改。我习惯按固定链路排查,从外到内逐层排除。

第一步,确认现象范围。是全部不可访问,还是某个接口报错?是刚部署完就出问题,还是运行一段时间后出问题?范围决定了排查方向。

第二步,看代理层。代理容器是否健康,配置是否加载成功,上游地址是否可达。代理日志里通常能看到明确的错误,比如上游超时、连接拒绝。

第三步,看应用容器状态。docker compose ps看是否在反复重启。反复重启基本等于启动即崩溃,直接看日志找报错。

第四步,看应用日志。从启动日志开始看,找第一个报错,而不是最后一个。很多人习惯看最新的报错,但根因往往在前面。

第五步,看依赖服务。数据库是否健康,连接数是否打满,慢查询是否堆积。连接池耗尽是个高频问题,表现为应用卡住但不报错。

第六步,看宿主机资源。CPU、内存、磁盘。磁盘满、内存耗尽触发 OOM,都会让服务突然不可用,而且日志里可能什么都没有。

下面这张表是我整理的常见现象与对应方向的对照,实战中能省不少时间。

现象优先排查方向典型原因
外网完全打不开,本机 curl 正常代理、防火墙、安全组端口未放行、代理配置未生效
容器反复重启应用启动日志配置缺失、依赖连不上、端口冲突
接口偶发超时数据库连接池、资源连接泄漏、慢查询、内存不足
日志时间对不上时区配置容器未设时区
磁盘突然告警日志、临时文件日志未轮转、临时文件未清理
更新后功能异常镜像版本、环境变量标签不对、配置遗漏

6.3 几个我用血换来的经验

不要在服务器上直接调试业务逻辑。有人喜欢在服务器上改代码试错,改完忘了同步回本地,下次部署全丢。所有代码改动在本地完成,走构建流程。

环境变量的名字要统一。代码里读DB_HOST,Compose 里写DATABASE_HOST,这种低级不一致排查起来极其折磨。项目初期就定好命名规范,写进文档。

容器内不要存重要文件。任何写在容器文件系统里的东西,容器一重建就没了。养成习惯:写之前先问一句"这个目录挂出去了吗"。

时区、编码、权限这三样,出问题时都表现得极其诡异。时间错八小时、中文变乱码、莫名其妙权限拒绝,症状看起来都和根因不沾边。部署前一次性配好,比事后救火划算得多。

保留一份"从零重建"的文档。假设服务器明天挂了,换一台新机器,你需要多久能恢复?把完整步骤写下来,每隔一段时间照着走一遍验证。这份文档的价值,在你真正需要它的那天会体现得淋漓尽致。

6.4 用起来之后才会想到的扩展方向

跑顺了之后,有些东西可以逐步加上。比如资源限制,给每个服务设 CPU 和内存上限,防止某个服务失控拖垮整台机器。比如定时备份,用系统定时任务定期导出数据库并清理过期备份。比如基础的监控,哪怕只是定期探测健康接口、异常时发个通知,也比出事后靠用户反馈强。

这些都不是上线的前提,但它们是"从能用到好用"的分界线。我的建议是不要一上来全加上,先让核心链路稳定跑起来,再按实际遇到的问题逐个补。部署这件事,过度设计的成本往往比欠设计还高——你没遇到过的问题,提前防也防不到点子上。

回到最开始那个问题:Docker Compose 到底替我们省掉了什么?我的答案是,它把"这台服务器上现在跑着什么、怎么配的、怎么重建"从口头记忆变成了一份可审查的文件。真正的门槛从来不在命令本身,而在于你是否想清楚了每个服务要什么、依赖谁、数据放哪、出问题怎么查。把这些想明白,写配置只是几分钟的事;想不明白,配置抄得再像,线上照样会给你上课。

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

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

立即咨询