拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RHEL 8上使用Docker Compose部署多容器应用实战指南

RHEL 8上使用Docker Compose部署多容器应用实战指南 1. 为什么是 Docker Compose一条命令拉起整个应用1.1 多容器应用的复杂性从哪来一个稍微像样的业务系统几乎不会是单容器前端 Nginx、后端 API、Redis 缓存、PostgreSQL 数据库、对象存储、消息队列……每个组件都有自己的镜像、端口、环境变量、数据目录和升级节奏。如果全部用docker run管理命令行会越来越长参数一多就容易漏而且不同环境之间很难保持一致。我在初期就吃过亏本地开发为了省事少挂了一个数据卷生产环境一启动发现数据存到容器可写层里容器一重建全丢。这个问题的根源不是docker run不好用而是它缺少“应用拓扑”的概念。你管理的应该是十几个容器的整体关系不是一条条孤立的命令。Docker Compose 的价值恰恰在于它把多个容器的关系写进一个 YAML 文件里用一个项目名把它们圈起来。你只需要执行docker compose up它就会按照依赖关系创建网络、启动容器、挂载卷、注入环境变量。对于复杂多容器应用来说这相当于把“部署动作”从一组容易出错的手工命令变成一个可评审、可版本化的配置文件。无论是本地开发、测试环境还是生产服务器只要你愿意遵守同一套 Compose 规则环境差异就能被大幅压缩。1.2 Compose 解决的是“应用拓扑”而非“单机调度”问题需要明确边界Docker Compose 不是 Kubernetes它不做跨节点调度也不提供自愈和弹性伸缩。它解决的是“在一台主机上把多个容器的关系理清楚”这件事。有人会问那生产环境是不是该用 K8s如果你的应用只需要在少数几台 RHEL 8 服务器上稳定运行数据量可控团队又不想引入额外的运维复杂度Compose 完全够用。我见过不少中小团队用 Compose 把几十个容器跑在一台高配机器上配合 systemd 做开机自启稳定运行半年以上。在 RHEL 8 上尤其适合先用 Compose原因有两个。第一RHEL 8 的默认容器工具链是 Podman但很多现成镜像和第三方组件还是按照 Docker 生态设计的用 Docker Compose 可以更顺畅地复用社区资源。第二RHEL 8 对 systemd、SELinux、firewalld 的管理方式和 Debian/Ubuntu 差异明显容器网络和存储会遇到更多边界条件用 Compose 固定一个明确的配置比每次手工调整docker run参数更可复现排查问题也更容易。1.3 开发与生产环境一致性Compose 文件为什么能两头通用环境不一致的经典现象是“我本地跑得好好的到服务器就不行了”。常见原因包括依赖版本不同、环境变量缺失、数据库连接串指向 localhost 而不是容器名、文件权限不同。Docker Compose 用一套 YAML 文件同时描述开发和生产环境正是压缩这种差异的最直接手段。但要注意完全“同一套文件”直接跑生产和开发也不是最优做法。Compose 官方设计了一个 override 机制默认的docker-compose.yml描述服务本身的镜像、端口、数据卷、依赖关系这是“应用真相”开发和生产再各自用配置文件覆盖特定差异。开发环境可以用挂载源码的热更新模式生产环境则用构建好的镜像和更严格的资源限制。这样就能做到“同源不同配置”从代码提交到部署的路径上环境差异被显式表达出来而不是藏在某个人手写的脚本里。我后面会给出具体配置方案。2. RHEL 8 上安装 Docker 和 Docker Compose三个关键坑2.1 不要用 RHEL 8 默认仓库里的“docker”RHEL 8 的 AppStream 仓库默认不提供 Docker只有与 Red Hat 深度集成的 Podman、Buildah、Skopeo。如果你直接yum install docker大概率会提示没有这个包。很多新手在这一步就被卡住。正确做法是配置 Docker 官方仓库。sudo dnf config-manager --add-repohttps://download.docker.com/linux/centos/docker-ce.repo sudo dnf install docker-ce docker-ce-cli containerd.io sudo systemctl enable --now docker这里要提醒的是RHEL 8 和 CentOS 8 同源Docker 官方仓库里以docker-ce.repo命名的文件在 RHEL 8 上也能用。装完后用docker version验证看到 Client 和 Server 的版本信息才算成功。我遇到过装完 docker-ce 后systemctl start docker失败的情况多半是之前装过 Podman 或容器运行时端口和存储目录冲突需要先清理。SELinux 是下一个大坑。RHEL 8 默认开启 SELinux处于 Enforcing 模式而 Docker 的数据目录默认在/var/lib/docker。如果后来你把数据目录改到了挂载盘比如/data/docker就必须检查挂载点的 SELinux 上下文否则容器启动时可能报 permission denied。简单处理是确保目录类型为container_var_lib_t或者用semanage fcontext修改后面我会再展开。2.2 Docker Compose v2 插件与独立二进制的选择现在官方推荐的是 Docker Compose v2 插件直接通过docker compose子命令调用。安装方式很简单sudo dnf install docker-compose-plugin装完后docker compose version会输出 v2 相关信息。RHEL 8 上的 docker-compose-plugin 包来自 Docker 官方仓库版本跟随 Docker Engine 一起更新。如果不想用插件也可以下载独立的 docker-compose 二进制但 v1 版本已经停止维护新写的 compose 文件里很多字段比如depends_on的 condition、name字段、version字段是否还要写差异很大我不建议新项目再用 v1。这里有个细节如果你在 RHEL 8 上通过pip install docker-compose装的是 Python 版 v1它和 Docker Compose v2 在解析 YAML 时有些行为不一致。比如 v2 默认会把 compose 文件里的version字段当作过时信息忽略但 v1 可能会因为这个字段报错。我们团队现在一律使用docker compose带空格插件形式写新文件时不写version字段避免新旧版本混用导致的理解偏差。2.3 firewalld 与容器端口先搞清楚流量去哪里RHEL 8 默认启用了 firewalld外部流量要进来光靠 compose 文件里的ports映射还不够。Docker 会在宿主机 iptables 里自动加规则但 RHEL 8 的 firewalld 使用 nftables 后端两者叠加时经常出现“容器端口中外部访问不到”的现象。我原来的经验是先把端口加到 firewalld 放行清单sudo firewall-cmd --permanent --add-port8080/tcp sudo firewall-cmd --reload但要注意如果 Docker 的 iptables 规则被 firewalld reload 刷掉容器网络可能瞬间中断。稍稳妥的做法是在 compose 文件里固定宿主机端口并且用docker compose down/up让 Docker 重新建立规则而不是单纯 reload firewall。实际生产里我更建议把服务和端口收敛到 Nginx 反向代理之后对外只暴露 80/443内部服务放在 Docker 自定义网络里。这样既减少防火墙的规则冲突也便于统一管理 TLS 证书。3. 复杂多容器应用的 compose 文件设计先建模再填参数3.1 目录结构和服务依赖把应用的“骨架”显式化我是这样规划项目目录的你可以直接抄myapp/ ├── docker-compose.yml ├── docker-compose.override.yml ├── docker-compose.prod.yml ├── .env ├── .env.example ├── nginx/ │ └── default.conf ├── backend/ │ ├── Dockerfile │ └── app/ ├── frontend/ │ ├── Dockerfile │ └── dist/ └── data/ ├── postgres/ └── redis/关键点是compose 文件只描述服务、网络、卷和应用级配置不要在里面写死某个环境的密码。密码、端口、域名这些变化项全部放到.env文件里通过环境变量引用。举个例子我的docker-compose.yml里会写services: postgres: image: postgres:16 environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}] interval: 5s timeout: 5s retries: 10服务依赖不要只靠随机 sleep 解决。backend 要等 postgres 准备好Compose v2 的depends_on支持condition: service_healthy配合健康检查才能真正实现“依赖就绪后再启动”比硬编码sleep 5可靠太多。我曾经用 sleep 的方式部署一个 Java 后端数据库偶尔启动慢后端进程直接退出Compose 会不断重启它最后数据库好了后端也进入正常状态但日志里全是无意义的报错排查问题时非常混淆。3.2 网络与服务发现为什么容器内不能再只用 localhost多容器应用里服务之间互相访问要用容器名而不是 localhost 或 127.0.0.1。Compose 默认创建一个项目名的网络每个服务都可以通过服务名作为 DNS 名称解析到对应的容器 IP。比如 backend 里要连接 postgres连接串应该写成jdbc:postgresql://postgres:5432/mydbRedis 地址写成redis://redis:6379。这个设计很多人第一次接触会不适应尤其是从单机部署转过来的开发者。原因很简单每个容器默认有自己的网络命名空间localhost 指的是容器自己不是宿主机的 127.0.0.1。如果 backend 里配置的是 localhost:5432它连接的是 backend 容器自己而 backend 容器里并没有数据库服务自然失败。把服务名当作主机名这是容器服务发现的基本功。对于需要外部访问的服务Compose 用ports做宿主机端口映射ports: - 8080:80左侧是宿主机端口右侧是容器内端口。生产环境里我通常只让 Nginx 对外暴露 80/443后端服务只暴露到 Docker 网络内部或者绑定到 127.0.0.1 再通过 Nginx 反代。这样一来compose 文件里的端口映射清晰且安全不会被轻易扫描到额外端口。3.3 数据卷与持久化哪些目录必须挂出来复杂应用的持久化点很多数据库、Redis、上传文件、日志都会写数据。如果没挂卷容器一重建数据就没了。我的原则是“凡是应用运行时产生的、需要跨容器生命周期保留的数据必须有卷声明”。Compose 里有两种常见写法一种是命名卷volumes: pgdata:然后服务里写pgdata:/var/lib/postgresql/data。命名卷由 Docker 管理位置在/var/lib/docker/volumes/适合数据库这种不关心具体路径、只关心持久性的数据。另一种是绑定挂载volumes: - ./data/postgres:/var/lib/postgresql/data绑定挂载直接把宿主机目录映射进去适合需要人工查看和备份的场景比如上传文件目录、Nginx 配置、证书文件。但它对权限更敏感RHEL 8 下尤其要注意宿主机目录的属主和 SELinux 上下文否则容器内用户写不进去。数据库这类数据我更推荐命名卷减少权限问题备份可以用一条命令完成docker run --rm -v myapp_pgdata:/data -v /backup:/backup alpine tar czf /backup/pgdata.tar.gz -C /data .。3.4 一个典型的复杂应用 compose 骨架组合起来一个包含 Nginx、后端、PostgreSQL、Redis 的典型 compose 文件长这样name: myapp services: nginx: image: nginx:1.26 ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/certs:/etc/nginx/certs:ro depends_on: - backend networks: - frontend backend: build: ./backend image: myapp/backend:1.0.0 environment: DB_URL: postgresql://postgres:5432/mydb REDIS_URL: redis://redis:6379 depends_on: postgres: condition: service_healthy redis: condition: service_healthy networks: - frontend - backend postgres: image: postgres:16 volumes: - pgdata:/var/lib/postgresql/data environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}] interval: 5s timeout: 5s retries: 10 networks: - backend redis: image: redis:7 command: [redis-server, --appendonly, yes] volumes: - redisdata:/data healthcheck: test: [CMD, redis-cli, ping] interval: 5s timeout: 3s retries: 10 networks: - backend networks: frontend: backend: volumes: pgdata: redisdata:后端服务可以访问 postgres 和 redisNginx 只接触 backendpostgres 和 redis 不对外暴露端口。如果你想部署 openkm 这类依赖多个后端组件的文档管理系统结构也类似openkm 容器连接数据库容器和索引服务容器再把 Web 端口暴露给 Nginx。核心思路不变具体的镜像和端口照官方文档填就行。4. 开发与生产环境的一致性方案override 与 .env 的配合4.1 用 docker-compose.override.yml 提升开发体验默认情况下docker compose会自动读取docker-compose.yml后面的docker-compose.override.yml。开发环境我会这样写services: backend: volumes: - ./backend:/app environment: DEBUG: true ports: - 8000:8000 postgres: ports: - 5432:5432这样开发时后端代码目录直接挂载进容器改完代码热重载数据库端口也映射到宿主机方便你用本地的数据库客户端直接连上去查数据。生产环境部署时只需要显式指定不加载 override 文件或者把生产配置写在docker-compose.prod.yml里。override 文件很实用但有一个容易搞混的问题如果docker-compose.yml里的服务没有portsoverride 文件里给同一个服务加了ports最终配置是合并的。Compose 对列表字段ports、volumes的合并规则不是替换而是追加。这导致在 override 里重复定义同一条端口映射时可能不会报错但会出现预期之外的映射。我踩过这个坑后形成的习惯是环境的特殊映射尽量只出现在 override 或 prod 文件里不要在基础文件里留一个默认端口再在 override 里改这样读起来很累也没必要。4.2 用 -f 参数合并生产配置生产环境不要用 override 文件来表达差异因为 override 是“默认自动加载”的容易误用。建议把生产配置单独写成docker-compose.prod.yml部署时用-f指定多个文件docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dprod 文件里可以覆盖镜像来源、资源限制、重启策略、日志轮转services: backend: image: registry.example.com/myapp/backend:1.0.0 restart: always deploy: resources: limits: cpus: 2.0 memory: 4g nginx: image: registry.example.com/myapp/nginx:1.0.0 restart: always ports: - 80:80 - 443:443更重要的是做好配置预检。Compose 支持把多个文件合并后的结果直接输出docker compose -f docker-compose.yml -f docker-compose.prod.yml config这个命令会把最终生效的配置以 YAML 形式打印出来。我每次部署前都会跑一遍重点检查环境变量是否被正确替换、端口映射是否符合预期、卷配置有没有遗漏。相比盯着原始文件想象看合并结果最直观。4.3 .env 文件同一份代码不同运行时参数应用代码里不应该出现环境相关的硬编码而 compose 文件也不应该硬编码密码、主机名。把这类参数放到.env文件在 compose 文件里用${VAR}引用是保证一致性的关键做法。项目里可以放一个.env.example作为模板提交到 Git真正的.env不进版本库由每个环境自行维护。开发环境.envPOSTGRES_DBmydb POSTGRES_USERdev POSTGRES_PASSWORDdevpass NGINX_PORT8080生产环境.envPOSTGRES_DBmydb POSTGRES_USERappuser POSTGRES_PASSWORDstrong-prod-pass NGINX_PORT80这样一来代码和 compose 文件都一致唯一不同的只是.env文件。配合 CI/CD 在部署时注入.env基本能做到“同一个 compose 文件在开发和生产跑出同样的应用行为”。需要注意docker compose自动读取项目根目录下的.env文件如果在子目录执行命令要确认路径。我习惯在 compose 文件同级的目录执行所有 docker compose 命令避免环境变量加载失败。4.4 镜像版本锁定让生产跑得和测试一模一样环境一致性不能只靠 compose 文件镜像本身也要做版本管理。开发环境 backend 用build: ./backend生产环境应该用registry.example.com/myapp/backend:1.0.0这种带明确 tag 的镜像。否则今天构建的 image 和上个月的 image 可能内容完全不同compose 文件没变实际行为却变了。我的建议是每次 CI 构建成功后就给镜像打上 git commit 对应的 tag生产部署文件里写死这个 tag。这样一旦线上有问题可以反查到对应的源码版本。配合docker compose up -d --pull always可以确保拉取最新镜像但调试时我更喜欢默认行为除非显式需要更新否则不要总是重新拉取覆盖容易让线上版本飘移。5. 实操记录在 RHEL 8 上从零部署一个多容器应用5.1 前置检查与目录初始化我在一台干净的 RHEL 8 服务器上做个完整演示。系统已装好 Docker 和 Docker Compose 插件先检查当前状态docker version docker compose version接着建立项目目录把 compose 文件放进去。准备一个.env文件先写入必要的变量。执行前最好确认磁盘空间多容器应用的镜像和解压后的层都比较大数据库卷也会持续增长至少预留 20GB 以上。我用df -h确认/var/lib/docker所在分区有足够余量。5.2 启动服务并观察依赖顺序在项目目录下执行docker compose up -dCompose 会先拉取或构建镜像然后按照依赖关系创建网络和容器。因为 postgres 和 redis 都有 healthcheck所以 backend 会等它们真正 ready 后再启动。这一点从docker compose ps的输出可以观察到docker compose ps正常情况应该看到所有服务状态为 running 或 healthy。如果 backend 显示 Restarting第一时间用docker compose logs backend看启动日志多半是数据库连接串写错、环境变量没加载、或者依赖服务的 healthcheck 一直没有通过。5.3 健康检查与访问验证部署完成后先验证外部入口。如果 Nginx 映射了 80 端口直接curl http://localhost看返回。再逐个检查服务内部docker compose exec backend curl -I http://nginx docker compose logs -f --tail50我习惯把健康检查做成 compose 文件的一部分而不是部署完再手工验证。postgres 和 redis 的 healthcheck 已经写在文件里Nginx 也可以用 nc 或 wget 做健康检查。这样docker compose ps的输出能直接告诉我们系统是否健康也能让编排层在容器异常时自动重启。不过 healthcheck 本身也会消耗一点资源间隔不要设太短5 秒起步比较合理。5.4 一次性部署后的环境一致性验证开发环境里用同一个 compose 文件启动后我做一致性验证的方法很简单在开发机器和生产服务器上分别执行docker compose exec backend env对比关键环境变量再执行docker compose ps看服务状态。只要 compose 文件、.env 和镜像版本一致两边的输出应该高度一致。这也意味着排障时只要你能在开发环境复现问题生产环境大概率也能复现这对定位 bug 的帮助是巨大的。6. 常见问题与排查技巧实录6.1 SELinux 与挂载目录权限RHEL 8 上最常遇到的是容器没有权限写挂载目录。报错通常是 Permission denied但服务本身没问题。排查时先看目录权限ls -ld /data/myapp sudo semanage fcontext -l | grep myapp如果目录没有合适的 SELinux 类型可以加一条sudo semanage fcontext -a -t container_file_t /data/myapp(/.*)? sudo restorecon -Rv /data/myapp如果只是临时验证也可以chcon但重启后可能失效fcontext 才是持久化方法。对于命名卷一般不需要手动干预Docker 会正确处理 SELinux 上下文这也是我推荐数据库数据用命名卷的原因之一。6.2 firewalld reload 导致容器网络异常生产环境执行firewall-cmd --reload后偶尔出现容器之间的网络不通。这是因为 firewalld 与 Docker 在 iptables 规则上存在相互覆盖的问题。我的处理顺序是先确认 Docker 服务正常再重启 docker 网络sudo systemctl restart docker docker compose up -d --force-recreate这种操作会影响在线容器尽量在维护窗口进行。更长效的做法是不要让 Docker 网络依赖 firewalld 的规则把对外暴露的端口与内部服务分开内部访问走 Docker 内置网络不经过宿主机防火墙。实在需要在宿主机防火墙上放行端口时规则尽量在 Docker 启动前配好减少 reload 冲突。6.3 Compose 版本差异导致字段失效网上很多旧教程还在使用 v1 的写法比如version: 3.8、depends_on带 condition 但使用旧语法。用 Docker Compose v2 时我建议直接看官方参考不写version字段depends_on的 condition 要写在服务名下面的对象形式里。我有一段代码就是这样被坑过depends_on: postgres: condition: service_healthy这个写法和旧版的depends_on: - postgres完全不同。如果你用的是独立二进制的 v1它可能不支持这种写法直接报错。部署前跑docker compose config能提前暴露很多字段解析问题。6.4 端口映射与容器内端口混淆有几次线上服务访问异常最后发现是ports映射写反了。比如 Nginx 容器内监听 80compose 里写成80:8080外部访问 80 端口实际容器里没有进程监听 8080当然失败。排查时先docker compose ps看端口映射再用 curl 分别测试宿主机端口和容器内端口。另外宿主机端口冲突也常见可以用ss -tlnp检查端口被谁占用尤其要留意系统自带的 httpd 或 nginx 已经把 80 占用了。7. 最后分享几个实战习惯如果只让我留三条建议第一条是所有的环境差异必须显式表达要么在.env里要么在专门的 compose 环境文件里不要靠“开发机器上手工改过”来维持。第二条是每次部署前跑docker compose config检查合并后的配置这个动作只需要几秒钟却能在故障发生前拦下一大半低级错误。第三条是尽量把服务间的依赖关系用 healthcheck 表达出来别再用 sleep 猜时间。我在实际使用中还有一个习惯把常用的 compose 操作封装成简短的 shell 脚本或者 Makefile比如 up、down、logs、config、clean。不是为了炫技而是防止自己手滑把命令写错。项目小的时候无所谓项目复杂起来每个命令参数都容易变成一颗雷。Compose 本身已经帮我们省掉了绝大多数手工操作剩下的就是固定一套执行模板让所有人都按同一套动作部署一致性自然就出来了。这套方法在 RHEL 8 上跑复杂多容器应用我已经反复验证过。给团队带来的直接好处是新同事从拉代码到本地起服务基本只需要两条命令cp .env.example .env和docker compose up -d。再也不用写三页部署文档也不用在不同环境之间来回猜。仅凭这一点就值得把 Compose 作为多容器应用的标配。
返回列表