Docker Compose 多容器编排实战

5171 字
26 分钟
Docker Compose 多容器编排实战

1. 为什么需要 Compose#

一个正经项目很少只有一个容器:前端要 Nginx 托管静态资源,后端跑 Node 或 Spring,数据存 PostgreSQL,缓存用 Redis。全靠 docker run 手动拉起,大概是这样:

Terminal window
docker network create shop-net
docker run -d --name shop-db --network shop-net \
-e POSTGRES_PASSWORD=secret -v shop-db-data:/var/lib/postgresql/data \
postgres:16-alpine
docker run -d --name shop-api --network shop-net \
-e DATABASE_URL=postgres://app:secret@shop-db:5432/shop \
-p 3000:3000 sakura/shop-api:1.0.0

这套写法的痛点:

  • 顺序靠人肉保证。数据库没起来,后端就连不上然后退出,得盯着日志等重试。
  • 参数散落在终端历史里。换机器、换同事就得重新拼,没法版本管理。
  • 状态不可复现。网络名、卷名随手起,删掉重来全散了。
  • 没有单一可信来源。改一个端口要在多处同步。

Compose 把这一堆命令抽象成声明式 YAML:你只描述想要什么(有哪些服务、用什么镜像、依赖谁、挂什么卷),创建网络、按序启动、注入变量这些”怎么做”交给 docker compose up。好处是这份 YAML 能进 Git、能 review、能被 CI 复用——新人 clone 后一条命令就得到与你一致的环境。

Compose 与 Kubernetes 的定位区别

二者不是替代关系,是不同层次的工具。

维度Docker ComposeKubernetes
定位单机多容器编排集群级容器编排
学习曲线低,一个 YAML 文件高,概念众多
部署目标本地开发、CI、单机小型生产多节点大规模生产
高可用单机,无跨节点自愈副本调度、自愈、滚动更新
伸缩--scale 有限,受单机约束HPA 自动伸缩
网络单主机 bridgeCNI 插件,跨节点组网
存储命名卷 / 绑定挂载PV / PVC / StorageClass
典型场景本地开发、边缘、中小项目云原生生产、微服务集群

结论:开发环境无脑用 Compose,生产看规模。单机够用、没有多节点诉求的项目,Compose 上生产完全可行;等需要多副本、滚动发布、跨节点容灾时再迁 K8s,服务划分思路还能直接复用。

2. 核心概念#

  • 项目(project):一组关联服务的集合,是 Compose 的操作单元。默认取当前目录名,可用顶层 name-p 指定。项目名决定容器、网络、卷的命名前缀。
  • 服务(service):一个抽象应用组件,描述用什么镜像、怎么配置、依赖谁。服务不是容器,而是容器的模板。
  • 容器(container):服务实例化后的运行体。一个服务默认跑一个容器,--scale 可拉起多个。
  • 网络(network):服务间通信的虚拟网络,同网服务可用服务名互访。
  • 卷(volume):持久化数据的载体,容器删了数据还在,数据库靠它。

compose.yaml 与 docker-compose.yml 的历史

早期 Compose 是独立 Python 工具,命令为 docker-compose(连字符),默认文件名 docker-compose.yml。Compose V2 用 Go 重写,以插件形式集成进 Docker CLI,命令变成 docker compose(空格)。

  • 新项目用 compose.yaml,这是官方当前首选。
  • docker-compose.yml 仍被支持,属向后兼容,旧项目不必急着改名。
  • Compose 按 compose.yamlcompose.ymldocker-compose.yamldocker-compose.yml 顺序查找,命中即用;同目录只保留一个。
  • 命令统一用 V2 的 docker compose,旧的 docker-compose 二进制已进入维护模式。

3. compose.yaml 完整结构#

name: my-project
services:
web:
image: nginx:1.27-alpine
networks:
frontend:
volumes:
db-data:
configs:
nginx-conf:
file: ./nginx/nginx.conf
secrets:
db-password:
file: ./secrets/db-password.txt

关于 version 字段

老配置顶部常写着 version: "3.8"。这个字段已经废弃(obsolete),Compose V2 会忽略它,执行 docker compose config 时甚至打印一条警告。它的历史作用是区分文件格式版本,而 V2 CLI 已统一格式,所以新项目不要写。

字段作用常用度
name指定项目名,影响资源命名前缀常用
services定义所有服务,唯一必填字段必填
networks自定义网络,做隔离与分组常用
volumes命名卷,做数据持久化常用
configs注入非敏感配置文件按需
secrets注入敏感信息,不进环境变量按需
include引入其他 compose 文件做模块化进阶
x-*自定义扩展字段,配合 YAML 锚点进阶

省略 networks 时,Compose 自动创建 项目名_default 网络,把所有服务接进去。

4. 服务定义详解#

image 与 build

services:
api:
build:
context: ./backend # 构建上下文
dockerfile: Dockerfile # 默认即为 Dockerfile
args: # 构建期变量,对应 Dockerfile 的 ARG
NODE_ENV: production
target: runtime # 多阶段构建指定阶段
image: sakura/shop-api:1.0.0

context 是构建上下文,Dockerfile 的 COPY 只能引用其内部文件;args构建期变量,不进容器运行时;target 指定多阶段构建停在哪个阶段(生产用 runtime、开发用 development)。imagebuild 同现时,image 给构建产物打标签,方便 docker compose push

container_name

指定容器名便于 docker exec -it shop-web sh,但代价是不能 --scale(会命名冲突),多副本场景不要设它。

ports 与 expose

services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80" # 宿主机 8080 → 容器 80
- "127.0.0.1:9000:9000" # 只绑本机回环
api:
image: sakura/shop-api:1.0.0
expose:
- "3000" # 仅内部网络可见,不映射宿主机

ports 映射到宿主机;expose 只声明端口供同网服务访问。只该被内部调用的后端用 expose 更安全。

environment 与 env_file

services:
api:
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:-secret}@db:5432/shop
env_file:
- .env

environment 直接写键值对并支持插值;env_file 从文件批量读取。列表形式 - KEY=VALUE 与映射形式 KEY: VALUE 等价。

command 与 entrypoint

command 覆盖镜像 CMD(默认参数),entrypoint 覆盖 ENTRYPOINT(可执行入口),最终命令由二者拼接。例如:

services:
cache:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]

推荐列表(exec form)而非字符串,避免 shell 解析带来的信号处理问题。

restart 策略对比

策略行为适用
no不自动重启(默认)一次性任务、调试
always无论退出码都重启长驻服务,但手动 stop 也会被拉起
on-failure仅非 0 退出时重启偶发失败的任务
on-failure:3非 0 退出最多重启 3 次限制重启,避免死循环
unless-stopped始终重启,除非被手动 stop生产最常用

5. 依赖与启动顺序#

depends_on 的两种语法

短语法只表达启动顺序:

services:
api:
depends_on:
- db
- cache

长语法可带条件:

services:
api:
depends_on:
db:
condition: service_healthy
restart: true
cache:
condition: service_started
condition含义
service_started容器已启动(默认行为,不等就绪)
service_healthy通过健康检查,真正就绪
service_completed_successfully容器成功退出(退出码 0),适合迁移任务

为什么 depends_on 不等于就绪等待

这是最常见的坑。默认 depends_on 只保证启动顺序:先 docker run db,再轮到 api。但”已启动”和”服务可用”是两回事——PostgreSQL 启动后还要初始化数据目录、启动进程、监听 5432,可能耗时数秒。这期间 api 去连库会直接 connection refused 退出,于是出现经典现象:第一次 up 挂,再 up 一次就好。正解是 depends_on + healthcheck 组合:

services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: shop
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d shop"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
api:
image: sakura/shop-api:1.0.0
depends_on:
db:
condition: service_healthy

api 会等到 db 健康检查通过才启动,从根上消除竞态。

healthcheck 参数与写法

参数含义默认值
test检查命令无(不写即无检查)
interval检查间隔30s
timeout单次超时30s
retries连续失败多少次判 unhealthy3
start_period启动宽限期,期间失败不计入 retries0s

test 有两种写法:["CMD", "curl", "-f", "..."] 直接执行命令,不经 shell;["CMD-SHELL", "pg_isready -U app && ..."] 交给 shell,支持管道与 &&start_period 很关键,慢启动的 Java 应用通常给 30s 到 60s,避免没起来就被判 unhealthy 反复重启。

6. 网络#

不定义 networks 时,Compose 自动建一个 项目名_default bridge 网络,全部服务接入,服务间用服务名互访:

services:
api:
environment:
DATABASE_URL: postgres://app:secret@db:5432/shop
REDIS_URL: redis://cache:6379

这就是 Compose 最省心之处——服务名即 DNS 名,内置 DNS 把服务名解析到对应容器 IP,重启换 IP 也不影响。

默认网络是扁平的,所有服务互相可见。生产里通常分层隔离:

services:
web:
networks: [frontend]
api:
networks: [frontend, backend]
db:
networks: [backend]
cache:
networks: [backend]
networks:
frontend:
backend:

这样 web 只能访问 api,碰不到 db 和 cache;db 与 cache 完全不可从外部网络访问;api 是唯一桥接点,这是最小权限原则在网络层的落地。容器访问宿主机则通过 extra_hosts 加上 host.docker.internal:host-gateway

7. 数据持久化#

容器删除后可写层数据即丢,要 survive 重建必须落到容器之外:

services:
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data # named volume,Docker 管理
- ./init:/docker-entrypoint-initdb.d:ro # bind mount,宿主机路径,只读
- /var/lib/postgresql/wal # 匿名卷,自动命名
volumes:
db-data:
类型写法数据位置适用
named volumedb-data:/pathDocker 管理目录数据库、需 Docker 管理的数据
bind mount./data:/path宿主机指定路径代码热重载、配置、日志
匿名卷/pathDocker 自动命名临时屏蔽镜像内目录

生产里数据库一律用 named volume:性能更好,跨平台一致,备份有标准工具。bind mount 更适合挂源码和宿主机已有目录。数据库持久化写法如下,其中 ${POSTGRES_PASSWORD:?...} 表示变量必须提供,否则直接报错退出,防止默认密码上线:

services:
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:

卷的备份与迁移

Terminal window
# 备份:把 db-data 内容打包到当前目录
docker run --rm -v shop_db-data:/data -v "$PWD":/backup \
alpine tar czf /backup/db-data-backup.tar.gz -C /data .
# 恢复:解包到一个新卷
docker volume create shop_db-data-restore
docker run --rm -v shop_db-data-restore:/data -v "$PWD":/backup \
alpine tar xzf /backup/db-data-backup.tar.gz -C /data

生产更推荐数据库自带的逻辑备份(pg_dumpmysqldump),格式更稳、可跨版本迁移;卷级物理备份只适合整机迁移或快速回滚。

8. 环境变量与配置管理#

Compose 默认读取项目根目录的 .env 做变量插值。它和服务的 env_file 是两回事:.env 给 Compose 自己用、参与 ${VAR} 插值;env_file 把变量注入到某个服务的容器里。

.env
POSTGRES_PASSWORD=change-me-in-production
APP_PORT=8080
语法含义
${VAR}直接取值,未定义则替换为空
${VAR:-default}未定义或为空时用默认值
${VAR-default}未定义时用默认值(空串也算已定义)
${VAR:?err}未定义或为空时报错退出,适合必填项
${VAR:+alt}已定义时用 alt
$$VAR转义,输出字面量 $VAR

多环境与 -f 叠加

不同环境的差异不必复制整份文件,Compose 默认会自动读取 compose.override.yaml 叠加到 compose.yaml 上:

# compose.override.yaml —— 开发专用覆盖
services:
api:
build:
target: development
command: npm run dev
environment:
NODE_ENV: development

也可显式叠加任意文件:docker compose -f compose.yaml -f compose.prod.yaml up -d。规则是后者覆盖前者的同名标量字段,列表字段合并,所以生产覆盖文件只写差异项。验证合并结果用 docker compose -f ... config

不要把密钥写进仓库

  • .env 只放非敏感默认值,真实密钥由 CI 变量或部署环境注入。
  • .gitignore 加上 .env*.localsecrets/,并提供 .env.example 入库作模板。
  • 极敏感凭据用顶层 secrets 以文件挂载方式注入,而非环境变量——环境变量会出现在 docker inspect 和子进程环境里,容易泄漏。

9. 完整实战:前后端全栈一键启动#

一个可直接运行的全栈项目:React 前端(构建为静态资源交给 Nginx)+ Node 后端 + PostgreSQL + Redis。

shop/
├── compose.yaml
├── .env
├── frontend/ # React + Dockerfile
├── backend/ # Node + Dockerfile
└── init/
└── 01-schema.sql
name: shop
services:
web:
build:
context: ./frontend
args:
NODE_ENV: production
image: sakura/shop-web:1.0.0
ports:
- "${APP_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
networks: [frontend]
restart: unless-stopped
api:
build:
context: ./backend
target: runtime
image: sakura/shop-api:1.0.0
environment:
NODE_ENV: production
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD:?required}@db:5432/shop
REDIS_URL: redis://cache:6379
expose:
- "3000"
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 10s
timeout: 3s
retries: 5
start_period: 20s
networks: [frontend, backend]
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
POSTGRES_DB: shop
volumes:
- db-data:/var/lib/postgresql/data
- ./init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d shop"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
networks: [backend]
restart: unless-stopped
cache:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes:
- cache-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
networks: [backend]
restart: unless-stopped
networks:
frontend:
backend:
volumes:
db-data:
cache-data:

配套的 .env 只需 POSTGRES_PASSWORD=change-me-in-productionAPP_PORT=8080。逐个服务解释:

  • web:从 frontend/Dockerfile 多阶段构建,产物由容器内 Nginx 托管,映射到宿主机 APP_PORT。只接入 frontend 网络,碰不到数据库;依赖 api 健康检查通过才启动,避免一打开就 502。
  • apitarget: runtime 使用生产阶段。DATABASE_URLREDIS_URL 直接写服务名 dbcache,靠内置 DNS 解析。用 expose 而非 ports,因为不该被公网直连;健康检查命中 /health
  • db:数据落在 named volume db-data,容器重建不丢;init/ 挂进初始化目录,首次启动自动执行 01-schema.sql 建表;pg_isready 保证真正就绪。
  • cache:Redis 开 AOF 持久化,数据落 cache-data 卷,redis-cli ping 返回 PONG 即健康。
  • networksfrontend 面向外部,backend 是数据层,通过 api 桥接,最小暴露。
  • volumes:两个命名卷承载有状态数据,独立于容器生命周期。

启动与验证:

Terminal window
docker compose up -d --build # 构建并后台启动
docker compose ps # 确认均为 healthy
curl -I http://localhost:8080 # 验证前端
docker compose exec api curl -f http://localhost:3000/health

10. 常用命令#

命令作用
docker compose up -d后台启动全部服务
docker compose up -d --build强制重建镜像后启动
docker compose down停止并删除容器、网络
docker compose down -v同时删除命名卷(数据一并清空)
docker compose ps列出服务状态
docker compose logs -f api实时跟踪某服务日志
docker compose logs --tail=100 web只看最近 100 行
docker compose exec api sh在运行中的容器开会话
docker compose run --rm api npm test一次性容器跑命令,跑完即删
docker compose build --no-cache不用缓存重建
docker compose pull拉取最新镜像
docker compose restart api重启指定服务
docker compose config输出合并后的最终配置
docker compose top查看服务内进程
docker compose stats实时资源占用

几个必须提醒的点:

  • down -v 会删数据卷,本地清环境很方便,生产上等于毁库,执行前务必确认。
  • up -d --buildup -d 的区别是是否重建镜像。改了 Dockerfile 或构建上下文代码却没带 --build,跑的还是旧镜像。
  • logs -f--tail 更好用:--tail=200 -f 既看历史又跟新日志。
  • runexec 的区别:run 启动新容器执行(默认不映射端口),exec已运行的容器里执行;一次性任务用 run --rm

11. 开发与生产的差异#

开发要改代码立刻生效,用绑定挂载 + 开发模式:

compose.override.yaml
services:
api:
build:
target: development
command: npm run dev
volumes:
- ./backend:/app
- /app/node_modules # 匿名卷遮蔽,防止宿主机空目录覆盖容器内依赖
ports:
- "3000:3000"
environment:
NODE_ENV: development

- /app/node_modules 这一行别漏:它用匿名卷遮蔽容器内目录,防止宿主机空目录或不同平台安装的二进制覆盖容器依赖,否则经常报模块找不到。

生产则用构建好的不可变镜像,把源码挂进去是反模式;资源限制、日志轮转等差异项写进 compose.prod.yaml,用 -f 叠加。

调试工具、一次性任务不该在正常启动时被拉起,用 profiles 打标签,默认 up 时不会启动:

services:
adminer:
image: adminer:4
profiles: ["debug"]
ports:
- "8081:8080"

激活用 docker compose --profile debug up -ddocker compose --profile tools run --rm migrate。一份文件同时服务开发、调试、运维多种场景,比维护多份文件清爽。

12. 生产注意事项#

资源限制:Compose V2 中 deploy.resources 下的 limits直接生效(不需要 swarm 模式)。

services:
api:
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
reservations:
memory: 256M

老写法 mem_limitcpus 仍可用,但 deploy.resources 是推荐格式,也便于日后迁移 K8s。单机下 reservations 意义有限,主要靠 limits 防内存泄漏拖垮整机。

日志驱动与大小限制:默认 json-file 驱动不会自动清理,日志会一直涨到撑爆磁盘,生产必须加限制。

services:
api:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"

含义是单文件最大 10MB、最多保留 3 个,滚动覆盖。也可用默认就压缩轮转的 local 驱动,或把日志发往 ELK、Loki 等集中式系统。

重启策略与镜像版本固定:生产统一 restart: unless-stopped绝不使用 latest 标签——postgres:latest 某天 pull 下来一个不兼容新版本就可能炸库,固定到 postgres:16.3-alpine 这样具体版本;自建镜像用语义化 tag(sakura/shop-api:1.0.0)。

健康检查必配:每个长驻服务都该有 healthcheck,它是 depends_on 的条件来源,也是外部监控判断存活的依据。没有健康检查的服务崩溃可能悄无声息地持续几小时。

13. 从 Compose 到 Kubernetes#

什么时候该升级

  • 单机资源撑不住,需要多节点横向扩展。
  • 需要多副本 + 负载均衡,而不只是单实例。
  • 需要滚动更新、灰度发布、自动回滚
  • 需要自动扩缩容(按 CPU/内存/QPS 增减副本)。
  • 需要跨节点容灾,单机挂了业务还能跑。
  • 团队规模上来,需要命名空间隔离与 RBAC

如果只是想要一个能跑、够用的部署,Compose 完全没必要换,别为时髦给自己加运维负担。

Docker ComposeKubernetes说明
serviceDeployment服务定义 → 副本控制器
service 单实例Pod最小调度单元
portsService / Ingress端口暴露与路由
networksService / NetworkPolicy服务发现与网络隔离
named volumePersistentVolumeClaim持久化存储声明
bind mounthostPath / ConfigMap宿主挂载或配置注入
environment / env_fileConfigMap / Secret配置与密钥
healthchecklivenessProbe / readinessProbe存活与就绪探针
depends_oninitContainers启动依赖
profilesnamespace / Helm values环境与场景隔离
restartrestartPolicy(Pod 级)重启策略
deploy.resourcesresources.requests/limits资源配额
--scalereplicas / HPA副本数

映射相当直观:服务怎么切分、网络怎么隔离、哪些有状态,这些设计在 Compose 阶段想清楚,迁移时基本照搬。所以用 Compose 认真组织项目,本身就是给未来上 K8s 铺路。

14. 常见问题排查#

容器起来了但连不上数据库:症状是后端启动后立即退出,日志里 ECONNREFUSEDcould not connect to server

  • 只写了 depends_on: [db] 却没配 condition: service_healthy,api 在 db 就绪前就去连,加上健康检查条件即可。
  • 连接地址写错。容器里不能用 localhost 连数据库——那是 api 容器自己,必须用服务名 db:5432
  • 两个服务不在同一网络,检查各自 networks 是否有交集。

端口冲突:报错 bind: address already in use,说明宿主机端口被占用,用 docker ps --format '{{.Names}} {{.Ports}}' | grep 8080 定位。解决方式是换宿主机端口("8081:80")或停掉占用进程。冲突只看宿主机侧端口,容器内端口可以重复。

卷权限问题:PostgreSQL 等应用报 Permission denied,通常是宿主路径(bind mount)属主与容器内用户不匹配。named volume 一般没这问题,是首选;bind mount 场景可用 user: 对齐 UID/GID,或调整宿主机目录权限。

容器内 DNS 解析失败:报 bad addressName or service not known。检查服务是否真的在同一自定义网络;检查服务名拼写与 services: 下的键是否完全一致(区分大小写);手动加了自定义网络却漏掉对方服务也会解析不到。

配置不生效:改了 compose 文件但行为没变,先用 docker compose config 输出插值、合并后的最终配置,看到的内容与预期不符,问题就在这里。另外改了代码却没加 --build,跑的还是旧镜像,也会造成”配置没生效”的错觉。

15. 命令与配置速查表#

常用命令

命令作用
docker compose up -d后台启动全部服务
docker compose up -d --build重建镜像后启动
docker compose down停止并删除容器与网络
docker compose down -v同时删除命名卷(清数据)
docker compose ps查看服务状态
docker compose logs -f api实时跟踪日志
docker compose exec api sh进入运行中的容器
docker compose run --rm api cmd一次性容器执行命令
docker compose config验证并输出最终配置
docker compose restart api重启服务

服务常用字段

字段作用
image / build镜像来源
container_name指定容器名(不可与 scale 共用)
ports / expose对外映射 / 仅内部暴露
environment / env_file环境变量注入
command / entrypoint覆盖启动命令
depends_on启动依赖与条件
healthcheck健康检查
volumes / networks数据持久化 / 网络接入
restart重启策略
deploy.resources资源限制
logging日志驱动与轮转
profiles按需启动分组

插值语法与可选值

语法 / 项含义或可选值
${VAR}直接取值,未定义则为空
${VAR:-default}未定义或空时用默认值
${VAR:?err}未定义时直接报错
${VAR:+alt}已定义时替换为 alt
$$VAR转义为字面量
depends_on.conditionservice_started / service_healthy / service_completed_successfully
restartno / always / on-failure[:N] / unless-stopped
healthcheck.test["CMD", ...] / ["CMD-SHELL", "..."]
日志驱动json-file / local / syslog / fluentd / gelf

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Docker Compose 多容器编排实战
https://sakura-hu.top/posts/devops/dockercompose多容器编排/
作者
Sakura
发布于
2026-09-11
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Sakura
Hello, I'm Sakura.
公告
欢迎来到我的博客! 我是Sakura,祝你拥有美好的一天~
分类
标签
最新动态
站点统计
文章
21
分类
7
标签
35
总字数
85,101
运行时长
0
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录