如果你接过机房部署或者客户现场的活儿,一定体会过那种感觉:机器是全新的,网络却像被焊死了一样,能联网的只有你手头那台笔记本。这时候你会发现,docker离线安装几乎是唯一体面的出路——提前把所有东西打成包带进去,到现场解压启动,半个小时内把 MySQL、Redis、Nginx、RabbitMQ 这些中间件全部跑起来。这篇就写一写我在x86系统架构下的离线部署全流程,从 Docker 引擎本身,到各类中间件镜像的导入和运行,一条线完整走通,适合所有在隔离网、内网机房、客户现场做过部署的同学直接抄作业。
1. 离线部署的整体思路与方案选型
1.1 为什么非要在 x86 上单独强调架构
做离线部署时,很多人第一反应是“拷个安装包就行”,但实际干活时会发现坑全在后头。最大的坑就是架构不匹配:你在笔记本上下载的包,可能默认是 Apple Silicon 或者 ARM 架构的,拿到 x86 服务器上直接报exec format error。所以离线部署这件事,第一步就要确认目标机器的 CPU 架构。
x86 系统架构,准确说是 x86_64,也叫 AMD64,是目前服务器和台式机最主流的形态。进入正式操作之前,我建议先在目标机器上跑一下:
uname -m # 如果输出 x86_64 或者 amd64,说明我们的博文里的所有包都可以直接用为什么要专门说这个?因为 Docker Hub 上同一份镜像有多个架构的 manifest,你在联网机器上docker pull时,它会根据拉取端自动选择架构。但离线传输过程中,如果中间某个环节用错了架构的包,轻则启动失败,重则数据目录被初始化成错误的格式,后面排查起来特别费时间。
1.2 离线环境真正缺的是这三样东西
很多人以为离线部署 Docker 就是把 docker 这个二进制考过去,其实完整地跑起一个中间件环境,你至少得准备三样东西:
- Docker 引擎本体:包括
dockerd守护进程、docker客户端、containerd运行时组件,以及对应的 systemd 服务文件。 - 中间件镜像:MySQL、Redis、Nginx 这些镜像文件,必须以 tar 或者其他格式提前导出。
- 编排工具:如果你不想手动写一长串
docker run,还需要 docker compose 插件的离线包。
这三个东西在联网环境下都是一行命令的事,但在离线环境里,少一个你都跑不起来。我见过不止一个人只拷贝了docker二进制,然后对着Cannot connect to the Docker daemon发呆半小时,就是因为没带dockerd和 containerd 组件。
1.3 为什么选择 Docker 而不是直接装中间件源码
有些老手可能会问:离线环境下直接用 RPM 包或者编译源码装 MySQL、Redis、Nginx 不行吗?行,但维护成本会迅速失控。中间件的依赖关系是错综复杂的,MySQL 需要一堆系统库,Nginx 可能需要 PCRE、OpenSSL 的指定版本,Redis 新版本对编译工具链也有要求。在一个干净的内网机器上,这些依赖很可能全都需要从零开始收集。
Docker 的思路相当于把所有中间件连同它们的小型操作系统环境打包成镜像,底层依赖在镜像里已经固化,目标机器只需要一个能跑容器的引擎即可。再加上容器天然自带自启动策略、日志限制、数据卷挂载等机制,后期维护和升级都会轻松不少。尤其是客户现场经常要求“明天就要交付”,Docker 离线部署是短时间内把一堆中间件全部跑起来的最短路径。
2. 准备阶段:在联网机器上凑齐全部物料
2.1 获取 Docker 引擎静态安装包
Docker 官方长期提供 Linux 静态二进制包,你不需要在联网机器上安装 Docker,只需要把编译好的二进制文件下载下来,放到一个中转目录。以我常用的版本为例,直接到官方静态包下载页的linux/static/stable/x86_64/目录下找docker-24.0.7.tgz。
下载命令一般是:
wget https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz下载完成后,建议先算一下 sha256 校验值,避免传输过程中文件损坏。这一步别偷懒,离线环境里你没法在线重新下载,文件一旦损坏,到了现场才发现,这场交付就砸了。
2.2 获取 docker compose 插件
Docker Compose 本身也是一个独立二进制文件。在 Docker 的插件机制下,只要把docker-compose放到指定目录,并命名为docker-compose,docker compose子命令就会生效。
在联网机器上:
# 从官方 GitHub Releases 下载对应版本,比如 v2.24.0 wget https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-linux-x86_64下载完先改个名,后面安装的时候会用到。如果你觉得 GitHub 访问不稳定,也可以找国内的开源镜像仓库下载,但下载完一定要校验版本和文件的可用性。
2.3 把中间件镜像全部 pull 下来
镜像准备是最花时间的一步,因为中间件种类多,而且每个镜像的 tag 和大小都不一样。我的做法是先在联网机器上把需要用到的镜像全部 pull 到本地,再统一导出。比如这套组合:
docker pull mysql:8.0.32 docker pull redis:7.0.11 docker pull nginx:1.25.3 docker pull rabbitmq:3.12-management docker pull apache/kafka:3.5.0 docker pull zookeeper:3.8.1 docker pull postgres:15.4 docker pull elasticsearch:8.10.2 docker pull minio/minio这里有个小技巧:如果目标环境里有多种机器要用同一套镜像,我通常会在联网机器上写一个循环脚本,把镜像逐一 pull 下来并打成 tar 包:
#!/bin/bash images=( "mysql:8.0.32" "redis:7.0.11" "nginx:1.25.3" "rabbitmq:3.12-management" "apache/kafka:3.5.0" "zookeeper:3.8.1" "postgres:15.4" "elasticsearch:8.10.2" "minio/minio" ) mkdir -p /data/offline_images for img in "${images[@]}"; do echo "正在导出镜像: $img" name=$(echo "$img" | tr '/' '_') docker save "$img" | gzip > "/data/offline_images/${name}.tar.gz" done导出目录建议用一个单独的 U 盘或者移动硬盘来装,因为镜像加起来往往好几个 GB,用微信传文件那种做法是想都不用想的。
2.4 传输前的压缩与校验
离线环境下的物料传输,最常见的介质是 U 盘、移动硬盘、内网共享目录。建议传输前做两件小事:
- 压缩:
docker save输出的 tar 包虽然已经不小,但 gzip 压缩后体积通常能再省 20%~30%,比如 MySQL 8 的镜像大约 600MB,压缩后往往在 400MB 左右。 - 校验:把所有文件的 sha256 写到一个
sha256sum.txt文件里,到现场先校验再解压,这一步可以省去大量排查破损文件的时间。
我在实践里还会把 Docker 引擎包、compose 插件、镜像包放在同一个目录维护,并附一个 README 记录版本信息。别小看这个习惯,一个月后你再接到类似项目,翻 README 比翻聊天记录快得多。
3. Docker 引擎离线安装与验证
3.1 解压安装二进制
到了目标机器上,先把 Docker 静态包拷贝到/opt或者其他你习惯的软件目录。然后解压:
tar -zxvf docker-24.0.7.tgz解压后你会得到一个docker/目录,里面有docker、dockerd、containerd、containerd-shim-runc-v2、cri-dockerd、docker-init、docker-proxy等一堆文件。把这些二进制全部复制到/usr/bin/或者/usr/local/bin/:
cp docker/* /usr/bin/ chmod +x /usr/bin/docker*这里我建议统一放在/usr/bin/下,因为后面的 systemd 服务文件里写的就是这个路径,放在/usr/local/bin也可以,但要保持路径一致,别一会儿用/usr/bin一会儿用/usr/local/bin。
3.2 注册 systemd 服务
接下来手动写 systemd 服务文件。静态包不带自启动脚本,这一步是离线安装 Docker 引擎最容易出错的地方。直接在/etc/systemd/system/下新建docker.service:
[Unit] Description=Docker Application Container Engine Documentation=https://docs.docker.com After=network-online.target firewalld.service Wants=network-online.target [Service] Type=notify ExecStart=/usr/bin/dockerd ExecReload=/bin/kill -s HUP $MAINPID LimitNOFILE=infinity LimitNPROC=infinity LimitCORE=infinity TasksMax=infinity Restart=always RestartSec=2 [Install] WantedBy=multi-user.target再新建一个docker.socket文件,用于处理 socket 激活:
[Unit] Description=Docker Socket for the API [Socket] ListenStream=/var/run/docker.sock SocketMode=0660 SocketUser=root SocketGroup=root [Install] WantedBy=sockets.target然后重载 systemd 配置并设置开机自启:
systemctl daemon-reload systemctl enable docker.socket systemctl enable docker.service3.3 配置 daemon.json 和存储目录
启动 Docker 之前,我强烈建议先配置好 daemon.json。默认情况下 Docker 的数据目录在/var/lib/docker,如果系统的根分区不大,后面拉取或创建容器时很容易把磁盘撑爆。内网服务器常见的分区是/和/data分离,所以我会把 Docker 的数据目录指到大分区下。
创建/etc/docker/目录,并写入daemon.json:
{ "data-root": "/data/docker", "exec-opts": ["native.cgroupdriver=systemd"], "log-driver": "json-file", "log-opts": { "max-size": "100m", "max-file": "3" }, "storage-driver": "overlay2", "iptables": true, "ip-forward": true }几个配置项的作用可以这样理解:
>systemctl start docker systemctl status docker docker version docker infodocker version能同时看到客户端和服务器版本,说明 daemon 正常起来了。docker info里要重点看两个地方:一是Docker Root Dir是否指向你配置的/data/docker,二是Storage Driver是否显示overlay2。到这里,Docker 引擎本身已经算装好了。如果
systemctl status docker一直显示启动失败,不要慌,先看下面的常见问题章节,大概率是路径或权限问题,十分钟内能解决。4. 镜像文件与镜像仓库的离线导入
4.1 docker save / docker load 基本流程
在联网机器上用
docker save导出的镜像 tar 包,到了目标机器上用docker load导入即可。这是离线部署里最常用的一套组合。假设你已经把镜像包传到目标机器的
/data/offline_images目录,导入单个镜像:docker load -i mysql-8.0.32.tar.gz注意,如果你导出时用了 gzip 压缩,导入时
docker load会自动识别并解压,不需要手动先解压。多个镜像可以写一个循环脚本:#!/bin/bash cd /data/offline_images for f in *.tar.gz; do echo "导入镜像文件: $f" docker load -i "$f" done导入完成后用
docker images确认所有镜像都已就位。我通常会在这一步对比一下镜像的 IMAGE ID,因为在离线场景下,镜像 tag 可能因为反复导出导入产生细微差异,但 ID 是唯一的,对比 ID 最可靠。4.2 大镜像分卷压缩与传输
单个镜像文件超过 4GB 时,U 盘或者某些内网共享盘可能会受文件系统限制,不好直接拷贝。解决办法是用
split分卷,到达目标机器后再合并。以导出 Kafka 镜像为例:docker save apache/kafka:3.5.0 | gzip | split -b 2G - kafka.tar.gz.会生成
kafka.tar.gz.paa、kafka.tar.gz.pab这样的分卷文件。到目标机器上合并并导入:cat kafka.tar.gz.p* | gunzip | docker load需要提醒的是,
split分卷后所有分卷必须同时存在,缺失任何一个文件都无法恢复镜像。传输完第一件事就是检查分卷数量是否齐全。4.3 同一套镜像多台机器怎么处理
如果你有十台机器都要部署同一套环境,一台一台
docker load虽然可行,但效率偏低。更高效的做法是先在某一台机器上导入所有镜像,再启动一个内网 registry 容器,把镜像 push 到内网 registry,其他机器只需要配置daemon.json里的insecure-registries指向它,就能像在线环境一样直接 pull。举个例子:
docker run -d --name registry --restart=always \ -p 5000:5000 \ -v /data/registry:/var/lib/registry \ registry:2.8.2然后给镜像重新打上内网仓库的 tag 并 push:
docker tag mysql:8.0.32 内网IP:5000/mysql:8.0.32 docker push 内网IP:5000/mysql:8.0.32其他机器的
daemon.json里加上:{ "insecure-registries": ["内网IP:5000"] }这种做法在集群交付场景下非常实用,但对单机部署来说,直接用
docker load更简单直接,没有额外组件需要维护。两者适用场景不同,我这里只是给一个可选项。5. 中间件部署实操:一套命令直接抄
5.1 MySQL 8:配置、数据、日志全挂载
MySQL 是中间件里的老大哥,离线部署时要注意两个点:一是数据持久化,二是字符集。直接挂载宿主目录可以保证容器重启甚至容器删除后数据还在。
docker run -d \ --name mysql8 \ --restart=always \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=MyPass@123 \ -e TZ=Asia/Shanghai \ -v /data/mysql/conf:/etc/mysql/conf.d \ -v /data/mysql/data:/var/lib/mysql \ -v /data/mysql/logs:/var/log/mysql \ mysql:8.0.32 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_general_ci \ --lower_case_table_names=1--restart=always是每个中间件容器我都推荐加上的参数,只要 Docker 服务在,容器挂了会自动拉起。MySQL 8 默认字符集是 utf8mb4,但在启动参数里显式声明更保险。lower_case_table_names参数在 Linux 上默认是 0,如果你从 Windows 迁移应用过来,这参数不改成 1,表名大小写会出问题。5.2 Redis 7:持久化与访问密码
Redis 容器本身对宿主机目录的依赖不重,但如果你开了 AOF 或者 RDB 持久化,就必须把数据目录挂载出来。命令如下:
docker run -d \ --name redis7 \ --restart=always \ -p 6379:6379 \ -v /data/redis/conf/redis.conf:/etc/redis/redis.conf:ro \ -v /data/redis/data:/data \ redis:7.0.11 \ redis-server /etc/redis/redis.conf \ --requirepass Redis@123 \ --appendonly yes把自定义的
redis.conf挂载到容器里,并通过命令行参数追加requirepass和appendonly,这样做的好处是不需要把整个配置文件在镜像构建期就固化下来,换环境时直接改宿主目录的配置即可。appendonly yes开启 AOF 持久化,能在极端场景下把数据丢失窗口缩小到秒级。5.3 Nginx:静态站点与反向代理
Nginx 作为流量入口,挂载目录比较多,主要是配置、HTML 静态文件、日志和证书。命令如下:
docker run -d \ --name nginx \ --restart=always \ -p 80:80 \ -p 443:443 \ -v /data/nginx/conf.d:/etc/nginx/conf.d \ -v /data/nginx/html:/usr/share/nginx/html \ -v /data/nginx/logs:/var/log/nginx \ -v /data/nginx/ssl:/etc/nginx/ssl \ nginx:1.25.3这里的
conf.d目录可以放多个站点配置文件,每个文件对应一个 server 块。需要注意,容器里的 Nginx 默认会读取/etc/nginx/nginx.conf,而主配置文件里已经通过include /etc/nginx/conf.d/*.conf;加载了conf.d目录,所以我们的自定义站点配置放到宿主机的/data/nginx/conf.d/下即可。改完配置重载时用:docker exec nginx nginx -t docker exec nginx nginx -s reload5.4 RabbitMQ:管理后台与默认账号
RabbitMQ 镜像分两种,一种不带管理界面,一种是
-management后缀的,离线部署时我建议直接使用带管理界面的镜像,省得后面再启用插件。部署命令:docker run -d \ --name rabbitmq \ --restart=always \ -p 5672:5672 \ -p 15672:15672 \ -e RABBITMQ_DEFAULT_USER=admin \ -e RABBITMQ_DEFAULT_PASS=Admin@123 \ -v /data/rabbitmq:/var/lib/rabbitmq \ rabbitmq:3.12-management5672 是 AMQP 协议端口,15672 是管理后台 Web 端口。
RABBITMQ_DEFAULT_USER和RABBITMQ_DEFAULT_PASS是创建默认管理员账号的环境变量。这里有个比较容易踩的坑:如果容器数据目录已经有历史数据,环境变量不会生效,因为 RabbitMQ 会优先使用已存在的数据库。所以初始化账号最好在第一次启动时就通过环境变量设置好。5.5 Kafka + Zookeeper:多容器协作的正确姿势
Kafka 在容器化部署时比较特殊,它依赖 Zookeeper 来维护元数据,但 Zookeeper 最好不要和 Kafka 共用网络栈。我的做法是创建一个专用网络,让两者通过容器名互访。
docker network create kafka-net docker run -d \ --name zookeeper \ --network kafka-net \ --restart=always \ -e ZOOKEEPER_CLIENT_PORT=2181 \ -e ZOOKEEPER_TICK_TIME=2000 \ -p 2181:2181 \ zookeeper:3.8.1再启动 Kafka:
docker run -d \ --name kafka \ --network kafka-net \ --restart=always \ -e KAFKA_BROKER_ID=1 \ -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \ -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://192.168.1.10:9092 \ -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ -p 9092:9092 \ apache/kafka:3.5.0这里最坑的是
KAFKA_ADVERTISED_LISTENERS。如果这个地址写成了localhost:9092,那么客户端连接时会拿到localhost这个地址,如果客户端不在本机,连接就会失败。生产环境里要写客户端能访问到的宿主机实际 IP;如果只是本机测试,写宿主机 IP 也比写localhost更稳。5.6 PostgreSQL、Elasticsearch、MinIO 的快速补充
这三个中间件在不同项目里出现的频率也很高。PostgreSQL 很简单,注意挂载数据目录和设置密码:
docker run -d \ --name postgres \ --restart=always \ -e POSTGRES_PASSWORD=Postgres@123 \ -e TZ=Asia/Shanghai \ -p 5432:5432 \ -v /data/postgres:/var/lib/postgresql/data \ postgres:15.4Elasticsearch 需要额外注意内存和系统参数。容器运行前最好调整系统的
vm.max_map_count,否则会报max virtual memory areas vm.max_map_count [65530] is too low。可以临时执行:sysctl -w vm.max_map_count=262144然后启动单节点实例:
docker run -d \ --name es8 \ --restart=always \ -p 9200:9200 \ -p 9300:9300 \ -e discovery.type=single-node \ -e xpack.security.enabled=false \ -e ES_JAVA_OPTS="-Xms1g -Xmx1g" \ -v /data/es/data:/usr/share/elasticsearch/data \ -v /data/es/plugins:/usr/share/elasticsearch/plugins \ elasticsearch:8.10.2MinIO 是轻量对象存储,控制台和数据目录分别映射两个端口:
docker run -d \ --name minio \ --restart=always \ -p 9000:9000 \ -p 9001:9001 \ -e MINIO_ROOT_USER=admin \ -e MINIO_ROOT_PASSWORD=MiniO@123456 \ -v /data/minio/data:/data \ -v /data/minio/config:/root/.minio \ minio/minio server /data --console-address ":9001"MinIO 的 root 密码长度要求至少 8 位,太短会启动失败。这个坑我踩过一次,配置好密码后执行
docker logs minio能看到明确提示,但很多人不会去细看日志。6. 常见问题与排查技巧实录
6.1 Docker 服务启动失败怎么办
离线安装 Docker 后最常见的报错就是
Cannot connect to the Docker daemon。先别急着重新安装,按顺序排查:systemctl status docker看服务状态,如果显示failed,用journalctl -u docker --no-pager | tail -50看日志。- 检查
/usr/bin/dockerd是否存在且可执行,静态包解压后没复制全就会出现这种问题。 - 检查
/etc/docker/daemon.json的 JSON 格式是否符合规范,任何一个逗号或者引号错误都会导致 daemon 启动失败。 - 如果是权限问题,看日志里有没有
permission denied,常见于数据目录没有创建或者不具备写权限。
我遇到最多的场景就是数据目录权限不对。比如你指定
>mkdir -p /data/docker chown root:root /data/docker6.2 镜像导入慢或者貌似卡住
docker load大镜像时,终端可能长时间没有输出,这不一定是卡死,而是 gzip 解压和层写入都在后台进行。可以通过另一个终端执行docker images查看镜像是否已经出现,或者用du -sh /data/docker观察数据目录大小变化。如果确实发现 load 进程不动,也可以用
docker load -i加--quiet参数,减少输出干扰,但本质上导入大镜像就是需要几分钟到十几分钟不等,耐心等待即可。还有个更稳妥的做法,是把镜像分成多个小 tar 包逐个 load,比如按单个镜像导出,而不是把所有镜像打成一个巨型 tar 包,这样即使某个文件损坏,也不会影响其他镜像。6.3 容器时区与日志持续增长
容器默认时区是 UTC,你会发现日志时间和物理机时间差 8 个小时。解决办法有两个:一是在启动参数里加
-e TZ=Asia/Shanghai,适用于大多数官方镜像;二是挂载宿主机的时间文件:-v /etc/localtime:/etc/localtime:ro日志增长的问题前面已经提过,核心是在
daemon.json里限制日志大小。如果你已经跑了一段时间才发现日志特别大,可以清理:docker logs --tail 100 容器名 > /dev/null 2>&1或者直接删除容器重建,反正数据在挂载目录里,容器本身是无状态的。
6.4 数据目录权限与持久化目录归属
中间件容器启动时,很多会以非 root 用户运行,比如 MySQL、PostgreSQL、Elasticsearch。如果你挂载的宿主机目录权限不对,容器内用户没有写权限,启动就会失败。最快的排查方式:
docker logs 容器名 | tail -50如果看到
Permission denied,调整宿主机目录属主:chown -R 999:999 /data/mysql/data不同的镜像使用的 UID 不一样,MySQL 的官方镜像用的是
999,PostgreSQL 是999,Elasticsearch 用的是1000。具体以镜像内用户为准,可以通过docker run --rm 镜像名 id来查询。这里没有统一规律,关键是养成看日志的习惯。6.5 端口冲突与自启动检查
内网机器经常出现端口被占用的现象,比如 3306 已经被一个本地安装的 MySQL 占了。启动容器报错
bind: address already in use时,先找到占用端口的进程:netstat -tlnp | grep 3306 # 或者 ss -tlnp | grep 3306处理方式无非两种:杀掉旧进程,或者修改容器的端口映射。我一般优先改容器映射,因为杀进程可能影响机器上已有的服务。另外,容器设置了
--restart=always之后,机器重启时 Docker 会自动拉起容器,但要确认 Docker 服务本身开了自启:systemctl is-enabled docker如果输出
disabled,执行systemctl enable docker让重启后也能自动生效。7. 这套流程后续还可以怎么扩展
我个人在实际操作中的体会是,离线部署 Docker 和中间件这件事,真正的难点从来不是敲那几行命令,而是准备阶段的细致程度和对目标环境的耐心排查。
我自己的固定套路是:把所有安装物料做成一个带版本号的目录,里面同时放 sha256 校验文件、镜像清单、部署命令速查表。到了现场先校验再动手,启动完每个容器后立刻
docker ps确认状态,最后再用docker exec简单验证一下各中间件的可用性。比如 MySQL 就执行一条mysql -uroot -p密码 -e "select 1",Redis 就执行redis-cli -a 密码 ping,这几分钟的自检能避免后续应用接入时的各种“薛定谔的中间件问题”。另外,如果后面要交付的是多节点集群,建议认真研究一下内网 registry 的方案,把镜像推送到内网仓库后批量拉取,效率会高很多。如果只是单机或几台机器,老老实实用
docker save/docker load是最稳的,链路短、问题少。这套流程我已经跑了无数次,按部就班执行,基本上不会再出现离线部署翻车的局面。