拓冰建站拓冰建站
首页 / 资讯中心 / 正文

树莓派Docker全家桶:docker-compose编排与一键部署实战

前6篇我们把树莓派从烧录系统开始一路折腾到能用 Docker 跑起网页服务器。你可能和我一样最开始觉得 docker run 一条命令就能启动容器挺爽的。但容器一旦多起来——Nginx 做入口、后端 API 处理业务、MySQL 存数据、Redis 做缓存、Portainer 管面板——问题就接踵而至启动命令太长记不住配置文件散落各处重启一次树莓派就要手动敲半天命令停电一次直接 502 一整个上午。这一篇就是来解决这个问题的把树莓派网页服务器的全部容器整合成一套 Docker 全家桶用 docker-compose 编排管理一条命令一键部署新机器十分钟恢复环境。我不只给配置还会把每个字段为什么这么写、哪些坑我踩过、哪些优化是必须做的都讲清楚。1. 为什么要做全家桶整合手动 docker run 的路已经走不通了1.1 散落在终端历史里的长命令如果只用一两个容器docker run 确实可以一直这么写。可当你需要 5 个容器协作的时候问题就变得具体了。拿 Nginx 来说一条像样的启动命令大概是这样的docker run -d --name web-nginx \ --restartalways \ -p 80:80 -p 443:443 \ -v /opt/nginx/conf.d:/etc/nginx/conf.d:ro \ -v /opt/nginx/ssl:/etc/nginx/ssl:ro \ -v /opt/microweb/html:/var/www/html:ro \ --network host \ nginx:stable-alpine这条命令还不算最复杂的MySQL 和 Redis 加进来之后参数还能再翻一倍。问题是这种命令敲完很少有人会专门存成文件等到系统重装或者换机器你只能靠翻终端历史和自己的笔记去考古。更麻烦的是笔记里的版本经常和线上实际跑的不一致——某个端口没记、数据卷路径改过但笔记没同步。这种不一致比没有记录更危险。你照着笔记恢复服务结果容器起来了但数据连不上排查一圈才发现是挂载路径变了。我见过有人把 docker run 保存成 shell 脚本来解决这也是一个思路。但脚本里的容器网络、依赖、数据卷、日志策略仍然是分散的没有一个统一的视图告诉你当前这套服务到底由什么组成、彼此怎么关联。docker-compose.yml 之所以值得花时间写就是因为它把这套组装说明书变成了一个可提交、可回滚、可审查的文件。1.2 重启即翻车容器启动顺序的困境树莓派作为家里常开的服务器停电和意外重启是躲不掉的。假设你已经给每个容器都设置了--restartalwaysDocker 守护进程会在开机后按自己的顺序把所有容器拉起来。注意是按 Docker 自己的顺序不是按你期望的顺序。Nginx 和 API 可能瞬间就绪了MySQL 还在初始化数据目录API 第一次连数据库必然失败。如果应用代码里有重试机制可能过一会儿自己恢复了如果没写重试那这个失败就会变成一串连锁报错然后容器因为健康检查失败被反复重启看着docker ps里一堆 Restarting真的挺崩溃的。手动 docker run 时代解决这个问题的方法很不优雅要么在宿主机写 systemd 单元来控制启动顺序要么在应用代码里硬编码重连循环。这两种我都试过前者配置分散后者治标不治本。Compose 里的depends_on配合condition: service_healthy则把这个我要等到依赖服务真正可用的诉求直接写进配置。它让编排逻辑从靠运气重启变成按条件启动这是全家桶整合最核心的价值之一。后面第3节我会把具体写法展开。2. 规划打包方案哪些容器进全家桶2.1 一个典型的树莓派网页服务器全家桶做整合之前先把家庭成员确定下来。我的树莓派 4B 上跑的项目叫 microweb一个典型的轻量网页服务器包含 5 个容器nginx 负责接收 80/443 的请求、托管静态文件并反向代理到 APIapi 是一个 Python Flask 应用提供动态接口mysql 存业务数据redis 做缓存portainer 提供 Web 管理界面。你可以把 api 换成 PHP-FPM、Node.js 或 Go 应用结构完全一样。每个服务的内存占用大致如下表树莓派 4B 4GB 版实测参考值服务镜像标签闲置/轻负载内存说明nginxnginx:stable-alpine15-40 MB静态文件和反向代理apipython:3.11-slim 自建80-150 MBFlask Gunicorn 单进程mysqlmysql:8.0300-500 MB默认配置偏高需调优redisredis:7-alpine20-80 MB建议设置 maxmemoryportainerportainer/portainer-ce80-120 MB容器管理面板这么一算基础内存占用大概 600-900 MB4GB 版树莓派还剩 3GB 左右给操作系统和突发流量运行是没问题的。但如果你手里是 2GB 版我建议砍掉 portainer或者想办法把 MySQL 换掉——SQLite 在不追求并发写入的场景下完全能顶住。8GB 版最宽松几乎不用操心资源只要注意别让日志撑爆 SD 卡就行。这个账要在动手之前算清楚不然后面所有调优都是被动的。2.2 网络规划为什么把容器分到 frontend 和 backend全家桶最容易忽略的是网络规划。很多人图省事给所有容器都配上network_mode: host等于把所有端口全暴露在宿主机网络上任何容器都能访问任何端口指令之间的边界完全消失。万一某个容器被写入命令执行漏洞攻击者可以直接连数据库端口后果不用我多说。我采用的方案是自定义两个网络frontend 和 backend。nginx 和 portainer 连到 frontendmysql 和 redis 只连 backendapi 两个网络都连。这样一来nginx 能通过 api 这个容器名访问它的 5000 端口但完全不知道 mysql 和 redis 内部端口的存在portainer 只能在 9000 端口上管理容器连不了数据库。这种隔离成本几乎为零但价值很高。实际用下来它还带来一个额外好处——容器进程可以通过服务名直接互访不需要记住 IP 地址即使容器重建、IP 变化服务名解析依然有效。编排网络从第一天就该这么设计。3. 编写 docker-compose.yml 的完整过程与字段详解3.1 从 .env 开始把密码和配置抽出来写 compose 文件之前先把环境变量文件准备好。全家桶里有一堆敏感信息要处理MySQL 的 root 密码、业务数据库密码、Redis 连接密码、时区配置。直接写进 docker-compose.yml 有两个问题第一这个文件以后要提交到 Git 仓库密码跟着进去等于公开第二不同的环境本机、测试树莓派、正式树莓派参数不一样硬编码就废了。我在项目目录下建一个 .env 文件放真实配置MYSQL_ROOT_PASSWORDChangeMe_root_2026 MYSQL_DATABASEmicroweb MYSQL_USERmicroweb MYSQL_PASSWORDChangeMe_app_2026 REDIS_PASSWORDChangeMe_redis_2026 TZAsia/Shanghai同时维护一份 .env.example 作为模板值全用占位符提交进仓库。真正部署时先cp .env.example .env再编辑部署脚本会在启动前检查 .env 是否存在不存在就中止并提示。Compose 会自动读取同目录下的 .env 文件做变量替换docker-compose.yml 里只需要写${MYSQL_PASSWORD}这样的引用。另外还有个容易被忽略的点.gitignore 里一定要加入 .env否则哪天 git push 时手一抖密码就被推到远端仓库了这种事故我现实中见过不止一次。3.2 完整的 docker-compose.yml整个全家桶的 compose 文件我贴出来字段很长但每个都有明确用途文件后面我会逐类解释version: 3.8 services: nginx: image: nginx:stable-alpine container_name: microweb-nginx restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./nginx/ssl:/etc/nginx/ssl:ro - static_content:/var/www/html:ro networks: - frontend depends_on: - api logging: driver: json-file options: max-size: 10m max-file: 3 api: build: ./api image: microweb-api:latest container_name: microweb-api restart: unless-stopped env_file: - .env environment: - FLASK_ENVproduction volumes: - static_content:/app/uploads - ./logs:/var/log/microweb networks: - frontend - backend depends_on: mysql: condition: service_healthy redis: condition: service_healthy logging: driver: json-file options: max-size: 10m max-file: 3 mysql: image: mysql:8.0 container_name: microweb-mysql restart: unless-stopped command: - --default-authentication-pluginmysql_native_password - --innodb-buffer-pool-size256M - --max-connections100 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE} MYSQL_USER: ${MYSQL_USER} MYSQL_PASSWORD: ${MYSQL_PASSWORD} volumes: - mysql_data:/var/lib/mysql - ./mysql/init:/docker-entrypoint-initdb.d:ro networks: - backend healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -p${MYSQL_ROOT_PASSWORD}] interval: 10s timeout: 5s retries: 10 start_period: 40s logging: driver: json-file options: max-size: 10m max-file: 3 redis: image: redis:7-alpine container_name: microweb-redis restart: unless-stopped command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data networks: - backend healthcheck: test: [CMD, redis-cli, -a, ${REDIS_PASSWORD}, ping] interval: 10s timeout: 5s retries: 10 portainer: image: portainer/portainer-ce:latest container_name: microweb-portainer restart: unless-stopped ports: - 9000:9000 volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer_data:/data networks: - frontend logging: driver: json-file options: max-size: 10m max-file: 3 volumes: static_content: mysql_data: redis_data: portainer_data: networks: frontend: backend:3.3 关键字段的解释与选择理由先看restart: unless-stopped。这个策略表示容器异常退出后会自动重启但如果你手动执行docker stopDocker 不会再次拉起它。相比always好处是保留了手动暂停的能力——比如临时维护 MySQL你 stop 它之后它不会自己弹起来适合树莓派上长期运行的场景。volumes 部分有三类持久化数据要处理。MySQL 的业务数据放在mysql_data命名卷Redis 的 AOF 写在redis_data用户上传的文件放在static_content卷里供 nginx 直接读取。这三块必须持久化一旦容器销毁重建数据还在。像 nginx 配置、ssl 证书这类部署代码和密钥我用 bind mount 直接挂宿主机目录因为它们是随时要改的“活配置”放进命名卷里反而不方便查看和备份。这里单独说说static_content这个卷的用途。用户上传的文件写入 api 容器的/app/uploadsnginx 需要读到这部分静态资源否则图片和附件全部 404。如果让 nginx 挂载同一个宿主机目录就得关心目录权限和路径很啰嗦。用命名卷同时挂到 api 的/app/uploads和 nginx 的/var/www/html两个容器通过同一个卷共享文件路径还保持各自的习惯。这是 Docker 卷跨容器共享的标准做法写下来之后一劳永逸。最后是depends_on和healthcheck的配合。光写depends_on只能保证启动顺序不能保证“服务就绪”。比如 MySQL 容器虽然启动了但初始化可能要 30 秒这期间 API 连接还是失败。我给 MySQL 和 Redis 都定义了 healthcheck分别用mysqladmin ping和redis-cli ping做真实探测然后 API 声明condition: service_healthy。这样 Compose 会一直等到 MySQL 和 Redis 健康之后才启动 API。这就是第1节说的“重启即翻车”的解法。4. 一键部署脚本的设计思路4.1 把项目当成产品来组织目录全家桶整合到 compose 只是第一步如果每次部署还要去编辑器里翻一堆文件那和手动 docker run 也没多大区别。我按“产品目录”的方式整理了整个项目~/docker-server/ ├── .env # 真实环境变量不入库 ├── .env.example # 模板入库占位符 ├── docker-compose.yml ├── nginx/ │ ├── conf.d/ │ │ └── default.conf │ └── ssl/ ├── api/ │ ├── Dockerfile │ └── app.py ├── mysql/ │ └── init/ │ └── init.sql ├── logs/ # 应用日志目录 └── scripts/ ├── deploy.sh ├── backup.sh └── restore.sh这套目录放进一个 git 仓库里.env 通过 .gitignore 排除其他文件都做版本管理。这样做的价值在于可复现换新机器时git clone下来准备 .env执行 deploy.sh整套服务就能恢复假如哪次改配置改坏了git diff一看就知道动了哪里git checkout直接回滚。这种安全感是手动 docker run 时代从来没体会过的。4.2 deploy.sh 的内容与执行流程deploy.sh 写得很朴素核心是几个固定的阶段任务我贴出来并解释每一步为什么这么安排#!/bin/bash set -e cd $(dirname $0)/.. echo 检查 .env 是否存在 if [ ! -f .env ]; then echo 缺少 .env请先执行 cp .env.example .env 并填写密码 exit 1 fi echo 检查 Docker 是否可用 if ! command -v docker /dev/null 21; then echo Docker 未安装请先完成系统初始化 exit 1 fi echo 构建 / 拉取镜像 docker compose build --pull docker compose pull || true echo 启动全部服务 docker compose up -d --remove-orphans echo 等待网页就绪最多 120 秒 for i in $(seq 1 24); do if curl -s http://localhost /dev/null 21; then echo 网页服务已就绪 break fi echo 等待中... ${i}/24 sleep 5 done echo 所有服务状态 docker compose ps echo 本机验证 curl -sI http://localhost | head -n 1docker compose build --pull对本地构建镜像很有用它会在构建前把基础镜像拉到最新避免 build 出一个依赖过期基础镜像的 API 镜像。而docker compose pull || true是处理公共镜像的为什么加|| true因为 API 服务是本地 build 的pull 从远程仓库拉 microweb-api 注定失败我不希望这一行让脚本中断。如果你的 API 镜像已经发布到 GitHub Container Registry 或 Docker Hub可以去掉 build 相关部分单纯用 pull。等待就绪的循环我用的是 curl 探测而不是去检查每个容器的状态。因为真正对用户有意义的是“网页能不能访问”容器状态只是手段。等满 120 秒还不就绪脚本也会继续执行并打印docker compose ps这时候你一眼就能看到是哪个容器卡住了。这比在脚本里写一堆复杂的 docker inspect 判断更直观。5. 树莓派跑全家桶的硬性限制内存、Swap 与存储寿命5.1 arm64 架构与镜像选择树莓派 4B 的 CPU 是 ARM64和桌面 x86 不一样。多数主流镜像都有 multi-archdocker 会自动拉取正确架构所以你直接写nginx:stable-alpine就行不用刻意加 arm64 前缀。但有两类坑容易踩一是某些个人作者发布的镜像只编译了 amd64在树莓派上跑会直接报exec format error这种基本只能放弃或自己找源码构建二是在容器里执行 apt、npm 安装的场景要尽量选-alpine这类精简镜像因为它在 ARM 上运行更省资源构建也更快。我自己习惯把 Alpine 作为 nginx、redis 这类基础组件的首选APK 包管理器在 ARM 上的表现一直很稳定。5.2 内存不足时的调整策略树莓派 4B 4GB 版跑上面的全家桶如果不开 Swap内存占用经常会摸到 80% 以上。我建议先做两件事而不是一味加 Swap。第一给 Docker 日志加轮转限制。compose 文件里我已经为每个服务配了logging字段max-size: 10m和max-file: 3的意思是单份日志超过 10MB 就滚动最多保留 3 份。这个配置能防止某个容器疯狂打日志把 SD 卡写满非常要紧。第二把 MySQL 和 Redis 的参数往保守调。MySQL 的innodb_buffer_pool_size我设成 256Mmax_connections设成 100。如果你的数据表都很小buffer pool 可以再降到 128M。Redis 那边不加--maxmemory的话它会吞掉越来越多可用内存直到系统开始疯狂 Swap。设了--maxmemory 128mb --maxmemory-policy allkeys-lru之后Redis 会按 LRU 策略淘汰最久没用到的缓存 key对缓存场景影响几乎为零。这两条调整下来全家桶的内存占用能降 200MB 左右效果很明显。补充一个关于 Swap 的个人经验树莓派默认的 Swap 是 100MB 左右位在 SD 卡上频繁换页会让系统卡顿且加速 SD 卡磨损。如果内存实在太紧张我建议开 zram swap利用压缩把内存盘当 swap 用比读写 SD 卡快得多也不会磨损存储。但更好的方案还是先压低各服务的内存占用Swap 永远是最后一道防线不是第一选择。5.3 SD 卡寿命与数据安全问题树莓派所有 Docker 数据默认写在 SD 卡上MySQL 的频繁写入、容器日志的滚动、镜像层的读写都在消耗 SD 卡的写入寿命。我见过有人树莓派不到一年 SD 卡就报了 I/O error整盘数据差点没救回来。这个问题在 8GB 内存版上依然存在因为瓶颈不是内存是闪存写入寿命。我的做法是把 Docker 数据根目录迁移到外置 SSD 或移动硬盘。修改 /etc/docker/daemon.json{ data-root: /mnt/ssd/docker }然后重启 docker 服务再把原 /var/lib/docker 下的数据按需复制过去。这样镜像、容器、命名卷都落在 SSD 上SD 卡只承担系统本身的轻量读写。如果没有外置存储退而求其次至少把mysql_data卷用 bind mount 挂到移动硬盘并保证每天备份 SQL dump 到 U 盘或局域网 NAS。数据卷是全家桶里最脆弱也是最重要的部分宁愿部署慢一点也不能让数据裸奔。6. 实测中的翻车现场与排查链路6.1 容器一直重启一份 MySQL 日志救场第一次把全家桶完整跑起来时我遇到 api 容器无限重启。现象是docker compose ps显示 api 的 STATUS 是 Restartingnginx 能访问到静态页但动态接口全挂。排查链路我先说清楚先docker compose logs api看到连 MySQL 被拒绝再docker compose logs mysql发现 MySQL 在初始化阶段报了表空间错误。问题根源是之前手动 docker run 的旧 MySQL 数据目录被我直接挂进了新容器数据版本和存储引擎不一致MySQL 8 初始化失败。解决办法是清掉旧的 mysql_data 卷重新初始化实验环境无所谓但生产环境绝对不能这么干。这件事让我定下规矩动数据卷之前必须先 mysqldump 出来再改编排配置。后来我把这个流程写进了 backup.sh 的注释里每次恢复都提醒自己先看备份再看配置。6.2 容器时间漂移导致 TLS 握手失败全家桶跑了两周后某个早上 API 突然开始报 SSL 证书校验失败的错。我一度以为证书过期折腾了半小时后来docker exec microweb-api date一看容器时间慢了差不多 8 个小时。树莓派本身有 NTP 时间同步但部分 Debian 系的容器镜像没装 NTP 客户端容器会沿用宿主机的时钟长时间运行后漂移是常见现象。解决方案不复杂在 compose 里给各服务加TZAsia/Shanghai环境变量或者挂载/etc/timezone:/etc/timezone:ro。我两种都试过环境变量更省事因为 .env 里已经管理了这个参数。时间漂移对 HTTPS 最致命TLS 证书有效期验证依赖系统时间差几分钟都可能握手失败。所有容器统一配置时间是全家桶必须做的基础工作不要等出了故障再想起来。6.3 健康检查里的密码安全坑在给 MySQL 写 healthcheck 时我一开始用的是mysqladmin ping不带密码。这个命令在 MySQL 8 里默认能被匿名用户执行能 ping 通但后来我发现 root 密码里如果带了特殊字符比如$或healthcheck 命令会被 shell 或 compose 解析错健康检查永远失败。保险做法是 healthcheck 的 test 用列表格式比如[CMD, mysqladmin, ping, -h, localhost, -p${MYSQL_ROOT_PASSWORD}]并尽量把密码限制在字母数字和下划线范围内。如果你非要把密码设得花里胡哨可以考虑用环境变量文件单独管理或者干脆用 MySQL 镜像自带的healthcheck.sh脚本。健康检查这件事看着不起眼但一旦它失效depends_on: condition: service_healthy会等上一个永无止境的 timeout最后整条服务链全卡住。回到题目说的从纯净底座到一键部署。做完这次全家桶整合我最大的感受是前期手动 docker run 那些折腾没有白费正是因为在单容器时代把端口、数据卷、网络这些细节都摸明白了整合时才知道哪些配置必不可少、哪些字段可以简化。现在给树莓派换机器我只需要 git clone 项目、编辑 .env、跑 deploy.sh十分钟就能恢复服务。最后再分享一个小技巧backup.sh 里我把 mysqldump 的输出按日期标记配合 cron 每天早上执行一次备份文件扔到局域网 NAS 上再也不用担心 SD 卡报废连数据一起带走。这个系列后续如果做 HTTPS 证书自动化或者多站点共存思路也是一样的——先把底层编排逻辑沉淀下来再往上叠功能就顺手很多。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门