先说一个真实场景:上个月在某内网机房的部署现场,基础平台需要同时上 MySQL、Redis、Nginx、RabbitMQ,四台服务器全部没有外网,能联网的只有我自己的工作机。最开始我还抱着侥幸心理想直接yum install docker-ce,结果连 yum 源都配不通,只能老老实实走离线路线。如果你也遇到类似的场景——内网环境、x86 系统架构、需要 docker 离线安装并部署各类中间件——这篇就是给你准备的完整实施路线。
这篇内容不是简单丢几个命令,而是把 Linux x86 架构下从 Docker 引擎离线安装、镜像离线搬运、到中间件 compose 编排落地的整个链条讲清楚。适合实施工程师、运维同学,以及所有需要给隔离网络环境交付基础服务的开发者。后面每个环节我都会说明为什么要这么做、有哪些实测踩过的坑,照着操作基本能一次跑通。
1. 离线环境勘察:架构、系统版本与存储规划,动工前必须确认的三件事
离线部署最怕的不是命令记不住,而是物料拿错。一旦进了内网,网络是断的,发现缺包或者架构不对,来回折腾一次的成本非常高。所以我现在的习惯是:动手拷任何文件之前,先把目标服务器的基本信息全部摸一遍。
1.1 架构确认:uname 输出决定了你后面所有镜像选型
x86 系统架构在 Docker 生态里对应的平台标识是linux/amd64,这个结论看着简单,但实操里经常有人栽跟头。我在现场见过有人把 ARM 服务器当 x86 处理,也见过在苹果电脑上提前拉好镜像、结果发现拉到的是 arm64 版本,进内网 load 之后完全跑不起来。
建议每台目标机器都执行一遍:
uname -m # 期望输出:x86_64 lscpu | grep Architecture # 期望输出:Architecture: x86_64如果输出是x86_64,那么后续所有 Docker 镜像、二进制包、RPM 包都要按amd64平台来准备。如果出现aarch64,整条方案就要换一套,本文不展开。顺便说一句,千万别只信服务器型号,同型号设备不同批次可能换了 CPU,一切以uname -m实际输出为准。
1.2 操作系统发行版与内核版本:决定你用 RPM 还是静态包
有了架构信息,下一步确认操作系统版本:
cat /etc/os-release uname -r/etc/os-release会告诉我这是哪个发行版、什么大版本。比如 CentOS 7 和 Rocky Linux 9,对应的 Docker RPM 包前缀不同,分别是el7和el9;如果是 Debian 系,那就要换成.deb包或者直接走静态压缩包路线。
内核版本主要影响存储驱动。Docker 默认推荐overlay2,这个驱动在内核 4.0 之后表现稳定;CentOS 7 默认内核是 3.10,虽然也能用 overlay2,但前提是文件系统支持 d_type。如果/var/lib/docker所在分区是 XFS,格式化时必须带ftype=1,否则 Docker 启动会报错。这块后面我单独讲。
1.3 磁盘与数据目录规划:别把数据卷放在系统根分区
离线部署的中间件都是要长期跑业务的,数据目录和 Docker 的># 先安装 yum-utils 并添加 docker 官方仓库(在有网机器上执行) yum install -y yum-utils yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo # 只下载不安装,把依赖包也拉下来 yum install --downloadonly --downloaddir=/root/docker-offline \ docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-buildx-plugin
执行完之后,/root/docker-offline目录下会有一堆.rpm文件。把这些文件打包拷进内网,然后在目标机器上执行:
cd /root/docker-offline rpm -Uvh *.rpm为什么不一个个rpm -ivh而是一次rpm -Uvh *.rpm?因为 Docker 的这几个包存在依赖关系,一次丢给 RPM 让它在内部自动解决依赖顺序,成功率更高。如果机器上有旧的 Docker 残留,-Uvh也能更平滑地升级替换。
装完之后顺手验证一下插件是否就位:
docker compose version # 期望输出类似:Docker Compose version v2.x.x这个docker-compose-plugin非常关键,内网部署中间件基本都要靠 compose 编排。如果用独立二进制,后面还得单独处理权限和全局命令问题,插件方式装完即用。
2.2 静态二进制方式:跨发行版通用,适合官方源不方便的情况
有些场景下你拿不到 RPM 包,比如目标机器是精简系统,或者根本没配置 yum 源,又或者你的联网机器和目标机器发行版不一致。这时候静态二进制包就是最优解。
先在工作机上从 Docker 官网下载docker-<version>.tgz和对应的docker-compose-linux-x86_64独立二进制,解压后把可执行文件放进去:
tar xzvf docker-20.10.21.tgz cd docker/ cp docker /usr/local/bin/ # 其余组件如 containerd、runc、ctr 也一并复制如果把文件放到/usr/local/bin,后面编写 systemd 单元时 ExecStart 路径要对应好。我自己习惯统一放/usr/local/bin,因为/usr/bin在某些系统上会被包管理器管理,升级时可能被覆盖。
接着编写 systemd 服务单元/etc/systemd/system/docker.service:
[Unit] Description=Docker Service After=network-online.target Wants=network-online.target [Service] Type=notify ExecStart=/usr/local/bin/dockerd ExecReload=/bin/kill -s HUP $MAINPID LimitNOFILE=1048576 LimitNPROC=infinity LimitCORE=infinity TasksMax=infinity [Install] WantedBy=multi-user.target然后是核心的/etc/docker/daemon.json。离线环境下这个文件要重点配几个参数:
{ "data-root": "/data/docker", "storage-driver": "overlay2", "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" }, "insecure-registries": ["10.10.0.10:5000"], "exec-opts": ["native.cgroupdriver=systemd"] }>systemctl daemon-reload systemctl enable --now docker docker version docker info
2.3 启动自检:version 只是第一步,要确认存储驱动与 SELinux
docker version能显示 Client 和 Server 说明守护进程起来了,但还不够。建议执行docker info,重点看两块:
docker info | grep -E "Storage Driver|Cgroup Driver|Docker Root Dir"正常应该看到:
Storage Driver: overlay2 Cgroup Driver: systemd Docker Root Dir: /data/docker如果 Storage Driver 显示的是vfs,说明 overlay2 没有启用,通常是分区格式不支持 d_type。这种情况下要么格式化分区时加ftype=1,要么就改用 volume 挂载方式缓解,但性能和磁盘占用都不理想。
另外一个非常容易踩的坑是 SELinux。很多内网系统安全基线是开启 SELinux 的,而离线环境往往没有对应的 selinux-policy 包。Docker 启动时如果与 SELinux 策略冲突,容器起来也会被拒绝访问或直接失败。临时处理方式是检查状态:
getenforce # 如果输出 Enforcing,且不方便装策略包,可以先临时改为 Permissive setenforce 0不建议直接关闭 SELinux,但内网交付时间紧的情况下,临时 Permissive 比卡在容器拉不起快得多。等后续有安全整改要求再补策略。
3. 镜像进内网的两种姿势:save/load 直接搬运与私有仓库中转
Docker 引擎装好了,下一步就是让内网里的 Docker 能拿到中间件镜像。这一步我试过两种主流方案:一种是docker save打包再用docker load导入,适合镜像数量少、目标机器少的情况;另一种是搭一个内网私有仓库中转,适合多台机器、多套环境反复拉取的情况。下面分别展开。
3.1 docker save 与 docker load:最直接的搬运方案
在最简单的场景——几台目标机器、几个镜像——直接用 save/load 就够了。我在有网的工作机上把需要的镜像全部拉好:
docker pull mysql:8.0.36 docker pull redis:7.2.4 docker pull nginx:1.26.2 docker pull rabbitmq:3.13-management这里有个关键细节:tag 一定要定死。不要用mysql:latest这种写法,因为你在有网机器上拉取的 latest 和内网部署时想要的实际版本可能已经变了,离线环境一旦打包就无法再更新,复现性全靠 tag 固定。
导出镜像时,我习惯一个镜像打一个 tar 包,而不是全部合在一起:
docker save -o mysql-8.0.36.tar mysql:8.0.36 docker save -o redis-7.2.4.tar redis:7.2.4如果镜像比较大,可以先压缩再传输:
pigz -k mysql-8.0.36.tar # 或者 gzip mysql-8.0.36.tar为什么推荐 pigz?因为它是并行压缩,多核 CPU 下比 gzip 快很多。但要注意,pigz压缩后的文件在接收端要有对应解压工具,一般内网机器上gzip -d也能解压 pigz 生成的文件,兼容性没问题。传输之前生成一下校验值,避免大文件拷一半损坏:
md5sum mysql-8.0.36.tar.gz > checksums.txt到了内网目标机器,解压后导入:
gzip -d mysql-8.0.36.tar.gz docker load -i mysql-8.0.36.tar导入后立即确认:
docker images # 确认 REPOSITORY 和 TAG 都在,且 IMAGE ID 与非导出端一致这里要提醒一个很多人踩过的坑:如果你在导出前docker tag成了别的名字,load 后也会保持那个名字。比如我把镜像 tag 成registry.example.com/mysql:8.0.36再 save,内网 load 后镜像名也是这个,直接docker compose up可能会因为镜像名不匹配而重复拉取。所以要么导出前保持干净的 tag,要么导入后立刻docker tag修正。
3.2 内网私有仓库:多机多环境下的正规军方案
如果目标环境有十台机器都要用同一批镜像,靠一个个 save/load 就有点粗暴了。更优雅的做法是先在一台上网机器上搭一个 registry,把镜像推进去,然后把整个 registry 数据目录拷进内网,启动一个内网私有仓库,所有机器从内网仓库拉取。
在联网机器上:
docker run -d --name registry \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ --restart=always \ registry:2然后把需要的镜像重新打 tag 并推送:
docker tag mysql:8.0.36 10.10.0.10:5000/mysql:8.0.36 docker push 10.10.0.10:5000/mysql:8.0.36这里的10.10.0.10是将来内网仓库的地址。推完之后,把/data/registry整个目录拷贝到内网一台机器上,再以同样方式启动 registry 容器,内网其他机器的 daemon.json 里配置好insecure-registries,然后就可以直接docker pull 10.10.0.10:5000/mysql:8.0.36了。
这个方案的好处是:所有镜像以一个标准目录的形式集中管理,后续补镜像、更新版本、多机器分发都很快。坏处是前期多搭一个服务,而且 registry 数据目录的完整性很重要,拷贝前最好先把容器停掉,避免写入中途文件不一致。
3.3 平台架构的坑:--platform 只对有网机器拉取时有用
前面说过,x86 架构对应linux/amd64。如果工作机本身不是 amd64 架构,拉取镜像时一定要显式指定平台:
docker pull --platform linux/amd64 mysql:8.0.36不加这个参数,Docker 会默认拉取当前机器架构的镜像。特别是很多 tag 都是多架构 manifest 的,你在 Mac 或 ARM 服务器上执行docker pull mysql:8.0.36,拿到的是linux/arm64版本,save 之后带到内网 x86 机器上,load 能成功,启动就直接报exec format error。这个报错别慌,基本就是平台不匹配。
另外,docker save保存的是某个具体架构的镜像层,不是多架构 manifest。所以在有网机器上拉取、导出、校验平台成了三层确认动作。我一般会在导出前看一眼:
docker inspect mysql:8.0.36 --format '{{.Architecture}}' # 期望输出:amd64这一步确认 OK,后面传输和导入的心态才稳。
4. 中间件批量落地:MySQL、Redis、Nginx、RabbitMQ 四套 compose 模板拆解
镜像到了内网,最后一步就是编排部署。我用 docker compose 统一管理,原因很简单:离线环境里每套中间件都要配挂载、端口、环境变量、资源限额,把这些写进一个docker-compose.yml,之后升级、迁移、复盘都有据可查。
4.1 MySQL 8.0:字符集、表名大小写、数据卷与备份思路
MySQL 是基础中的基础,compose 配置里要关心的不只是"能起容器",还有字符集、排序规则、表名策略。这个如果你等集群初始化完再改,后面就是删库重来的节奏。
services: mysql: image: mysql:8.0.36 container_name: mysql restart: always ports: - "3306:3306" environment: MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD}" TZ: "Asia/Shanghai" command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_general_ci - --lower_case_table_names=1 - --default-authentication-plugin=mysql_native_password volumes: - /data/mysql/data:/var/lib/mysql - /etc/localtime:/etc/localtime:ro deploy: resources: limits: memory: 2g几个关键点说一下。lower_case_table_names=1意味着所有表名小写存储,这个参数只在数据库初始化阶段生效,初始化之后再改会报错,所以第一次拉起前就要想清楚业务对大小写的依赖。TZ=Asia/Shanghai解决容器默认 UTC 时间和本地差 8 小时的问题,如果日志和业务对时间敏感,这个一定要配。
数据卷挂载到宿主机/data/mysql/data之后,备份方案就简单了。我常用的策略是:业务低峰期用mysqldump逻辑备份,每天一次;另外每周找时间对/data/mysql/data目录做一次文件级快照或直接tar打包。容器重建时,只要数据目录不动,业务数据就不会丢。
4.2 Redis 7.x:持久化、密码、内存上限一个不能少
Redis 看起来简单,但内网部署时最容易漏的就是持久化和内存约束。默认镜像不带 redis.conf,容器起起来跑的是匿名内存存储,一旦容器重建全部数据清空。所以我的做法是先把 redis.conf 放到宿主机,再挂载进容器覆盖默认配置。
宿主机先创建/data/redis/conf/redis.conf:
daemonize no bind 0.0.0.0 protected-mode yes port 6379 requirepass ${REDIS_PASSWORD} dir /data appendonly yes appendfsync everysec save 3600 1 save 300 100 save 60 10000 maxmemory 512mb maxmemory-policy allkeys-lru然后 compose 编排:
services: redis: image: redis:7.2.4 container_name: redis restart: always command: ["redis-server", "/usr/local/etc/redis/redis.conf"] ports: - "6379:6379" volumes: - /data/redis/data:/data - /data/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf deploy: resources: limits: memory: 1gappendonly yes是开启 AOF 持久化,appendfsync everysec平衡了性能和数据安全,最坏丢一秒数据。maxmemory一定要设置,否则某个业务把内存打爆,系统 OOM 起来会影响同一台宿主机上的其他容器。我踩过一次:Redis 没配 maxmemory,缓存键根本不清,最后宿主机内存告警,MySQL 也跟着被 OOM。自那以后,每个中间件我都强制配内存上限。
4.3 Nginx:静态服务、反向代理与证书挂载
Nginx 内网部署主要是两类用途:一是托管前端静态页面,二是做反向代理把请求转发到内网其他服务。compose 配置里把宿主机的配置目录、站点目录、日志目录都挂载进去,这样改配置不用进容器,改完 reload 一下即可。
services: nginx: image: nginx:1.26.2 container_name: nginx restart: always ports: - "80:80" - "443:443" volumes: - /data/nginx/conf.d:/etc/nginx/conf.d:ro - /data/nginx/html:/usr/share/nginx/html - /data/nginx/certs:/etc/nginx/certs:ro - /data/nginx/logs:/var/log/nginx deploy: resources: limits: memory: 256m对应的/data/nginx/conf.d/default.conf示例:
server { listen 80; server_name _; location / { root /usr/share/nginx/html; index index.html; } } server { listen 443 ssl; server_name internal.example.local; ssl_certificate /etc/nginx/certs/internal.crt; ssl_certificate_key /etc/nginx/certs/internal.key; location /api/ { proxy_pass http://mysql-web-backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }conf.d挂载成只读,防止容器被黑后改 nginx 配置。证书路径也挂载为只读,内网自签证书更新时只需要替换文件加 reload。注意 Nginx 容器里的进程以 nginx 用户运行,挂载的目录如果权限过严可能导致读不到文件,我的习惯是chmod -R 755配合chown -R nginx:nginx处理。
4.4 RabbitMQ 3.x:管理插件、账号、数据目录与集群注意点
RabbitMQ 的镜像 tag 分带 management 和不带 management 两种。内网部署几乎都会用管理界面排查问题,所以直接拉rabbitmq:3.13-management这样带插件版本的镜像最省事。
services: rabbitmq: image: rabbitmq:3.13-management container_name: rabbitmq restart: always hostname: rabbitmq-node1 ports: - "5672:5672" - "15672:15672" environment: RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER}" RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASSWORD}" volumes: - /data/rabbitmq/data:/var/lib/rabbitmq - /data/rabbitmq/log:/var/log/rabbitmq deploy: resources: limits: memory: 2ghostname这里特别重要。RabbitMQ 节点名依赖系统 hostname,容器重建后 hostname 变了,节点会重新创建数据库,业务侧的队列绑定会乱。固定hostname之后,即使容器重建,节点名和数据也能对上。
生产环境建议再做两个动作:内存阈值和磁盘阈值。默认 RabbitMQ 在内存水位达到 40% 时会阻塞生产者,磁盘剩余低于 50MB 也会阻塞。内网机器内存小的时候这个默认值容易误触发。启动后可以执行:
docker exec -it rabbitmq rabbitmqctl set_vm_memory_high_watermark 0.6如果后续要搭多节点集群,还要把所有节点的数据目录中的.erlang.cookie统一成同一个值,并确保节点 hostname 能互相解析。单节点部署的话上面这套配置足够。
4.5 统一编排目录:密码管好、镜像 tag 锁定、网络互通
我习惯把每个服务的独立docker-compose.yml放在各自的目录里,比如:
/data/compose/ ├── .env # 统一管理密码等敏感变量 ├── mysql/ │ └── docker-compose.yml ├── redis/ │ └── docker-compose.yml ├── nginx/ │ └── docker-compose.yml └── rabbitmq/ └── docker-compose.yml.env文件写一遍密码,compose 里用${MYSQL_ROOT_PASSWORD}引用,避免密码散落各处。启动时每个目录下执行:
cd /data/compose/mysql docker compose up -d如果中间件之间有网络依赖,比如 Nginx 反代后端服务,建议给所有服务加同一个自定义 network:
networks: internal-base: driver: bridge再把每个服务都挂到这个网络里。这样容器之间通过服务名互相访问,IP 变动也无感。
5. 上线只是开始:日常自检清单与内网环境特有的隐藏坑
中间件全量启动那一刻确实很有成就感,但离线部署的真正难点其实在运维期。内网环境没有公共镜像仓库兜底,也没有现成的监控体系,日常巡检更多依赖 Docker 层的自检手段。这里分享一套我实际用的检查流,以及几个内网环境下特别容易踩的坑。
5.1 自检三板斧:容器状态、启动日志、服务探活
每次加完服务或者怀疑服务异常,我不会一上来就翻各种系统文件,而是先执行三条命令:
# 第一板斧:看容器状态 docker ps -a # 第二板斧:看最近日志 docker logs --tail 50 <容器名> # 第三板斧:从宿主机直接探活 curl -I http://127.0.0.1:80/ 2>&1 redis-cli -h 127.0.0.1 -a '<密码>' ping 2>/dev/null mysql -h127.0.0.1 -uroot -p'<密码>' -e "select version();" rabbitmqctl -n rabbitmq@rabbitmq-node1 statusdocker ps -a和docker ps的区别是能看到退出的容器,如果容器一直在重启,status列会显示Restarting,这时候第二条命令的日志就是第一现场。服务探活命令则比看日志更直接,能确认端口、认证、协议层是否正常。
实际执行时我会把这几条探测做成一个简单的 shell 脚本,输出成功或失败,巡检时执行一次就能覆盖所有服务。离线环境没有现成监控的情况下,这个脚本就是最轻量的兜底手段。
5.2 容器重启与升级:restart 策略没问题,但要防数据与配置漂移
restart: always保证容器挂了能自动拉起,这是好事,但它很容易掩盖另一个问题:如果容器启动失败,系统会无限重启,你以为服务在跑,实际一直在崩溃循环。所以不能只看Restarting状态,还要配合日志确认是启动正常还是循环失败。
升级中间件时,我的步骤是:
# 1. 先备份数据卷或关键配置目录 tar czf backup-mysql-data.tar.gz /data/mysql/data # 2. 拉取新版本镜像(在线环境)或 load 新版本 tar 包 docker load -i mysql-8.0.37.tar # 3. 修改 compose 文件里的 image tag # 4. 重建容器,数据卷和配置卷不动 docker compose down docker compose up -d这个流程的关键是:数据卷不动、配置卷不动,只换镜像层。很多内网服务出问题都是因为升级时有人把整个/data/mysql目录也删了重来,数据全没了。永远先备份,再操作。
5.3 日志增长与内存水位:OOM 不会提前通知你
中间件服务日志和内存是内网环境最容易爆的两个点。daemon.json 里我配了max-size: 100m和max-file: 3,这能防止容器日志无限堆积把磁盘打满。但要注意这个配置只对新建容器生效,已经在跑的容器不会自动应用,需要重新创建容器才生效。
内存的话,除了在 compose 里配置deploy.resources.limits.memory,还要关注宿主机是否开启 swap。如果 swap 没配,Redis 的 maxmemory 又没设,内存打爆后内核 OOM Killer 可能杀掉同一宿主机上的其他容器。我遇到过 MySQL 容器被杀,排查半天发现是同一个宿主机的 Nginx 缓存日志太大造成的。所以内存上限一定要逐服务配置,不要心存侥幸。
查看 OOM 记录:
dmesg | grep -i "killed process" | tail -20 journalctl -k --since "1 hour ago" | grep -i oom5.4 时区、DNS 与时间同步:内网环境的三大隐形杀手
这是内网部署最容易被忽略、又最喜欢出问题的三类问题。
时区问题表现为容器日志时间和业务时间差 8 小时。我在每个 compose 里都显式加了TZ: "Asia/Shanghai"环境变量,同时把/etc/localtime以只读方式挂载进容器。这样排查日志时不需要在脑子里做时区换算。
DNS 问题出现在容器内访问内网其他服务时。Docker 默认把宿主机的/etc/resolv.conf拷贝进容器,但内网环境经常有自定义 DNS 服务器,宿主机配置正确但容器里解析不了。稳妥做法是在每个服务的 compose 里显式声明:
dns: - 10.10.0.2时间同步问题更有隐蔽性:内网没有 NTP 服务器,宿主机时间一旦漂移,中间件的日志时间戳、RabbitMQ 的集群心跳、甚至 TLS 证书校验都会受影响。进入内网后第一时间确认是否存在可用的 NTP 源,没有的话至少要确保宿主机时间基本一致。这个没有容器层面的快捷解法,属于部署前的环境检查项。
结尾
离线部署搞过几次之后,我最大的体会是:真正决定项目顺利程度的不是后端命令写得多熟,而是"物料准备是否完整、镜像平台是否匹配、数据卷是否隔离"。我每次进内网前都会反复过一遍清单上的每一项,宁可在有网环境多花半小时检查,也不愿在内网里来回折腾。最后再分享一个小技巧:把docker images的导出结果在宿主机上留一个文本备份,方便后续排查"哪个镜像在哪个机器上"——在隔离网络里,这份记录往往比什么都管用。