一台机器装 Docker 与 Harbor,把 nginx、php-fpm、mysql 三个官方镜像推进自建仓库,再用一份 Dockerfile 把 PHP 扩展补齐,最后用自定义网桥把三个容器连起来跑起一套电商站。这篇按七步走完,重点在「配置文件与业务数据全部落在宿主机」这条主线------容器随便删,站不会挂。
本文概要:
| 项目 | 内容 |
|---|---|
| 文章主题 | 用 Docker + Harbor 部署一套 ECShop 电商站:私有仓库、自定义 PHP 镜像、LNMP 三容器协同 |
| 所属类别 | 云计算 → 容器技术 → Docker 综合实践 |
| 知识关键字 | Harbor · insecure-registries · docker tag / push · Dockerfile · docker-php-ext-install · 自定义 bridge 网络 · fastcgi_pass · absolute_redirect |
主线一句:容器化部署的难点从来不是 docker run,而是想清楚「哪些东西必须离开容器」 ------代码、配置、数据、日志四类全部挂到宿主机,镜像只留运行时。七步走完,只要 /ecshop 目录还在,整套站随时可以重建。
一、环境与目录规划
| 项 | 值 |
|---|---|
| 系统 | CentOS 7 |
| 仓库主机 | harbor,10.1.8.102,域名 registry.zhb.cloud |
| 应用主机 | docker,10.1.8.10 |
| 资源 | CPU ≥ 2 核、内存 ≥ 4 G、磁盘 ≥ 20 G |
| 仓库 | Harbor v2.10.2(http 模式) |
| 镜像 | nginx:1.24、mysql:5.7、php:7.2-fpm → 自建 php:fpm-ecshop |
text
/ecshop
├── harbor-data # Harbor 的持久化目录(装在仓库主机上)
├── mysql
│ └── data # MySQL 数据文件
├── html # ECShop 源码(代码)
├── conf
│ ├── nginx/conf.d # 站点配置(配置)
│ └── php-fpm # php.ini 与 fpm 池(配置)
└── log # nginx 访问/错误日志(日志)
四类东西各回各家:代码、配置、数据、日志全在宿主机,镜像里只有程序和扩展。这是后面所有步骤的前提。
二、部署 Docker 与私有仓库
2.1 装 Docker:防火墙不能关
bash
# 防火墙必须开启:容器的端口映射靠 firewalld 的 NAT 完成,关了反而连不上
[root@docker ~]# systemctl enable firewalld.service --now
[root@docker ~]# firewall-cmd --set-default-zone=trusted
# 配置 docker-ce 源(国内镜像)
[root@docker ~]# cat > /etc/yum.repos.d/docker-ce.repo <<'EOF'
[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://mirrors.huaweicloud.com/docker-ce/linux/centos/7/$basearch/stable
enabled=1
gpgcheck=0
EOF
[root@docker ~]# yum install -y docker-ce
[root@docker ~]# systemctl enable docker.service --now
[root@docker ~]# docker --version
Docker version 26.1.4, build 5650f9b
⚠️ 注意 :
firewall-cmd --set-default-zone=trusted是为了让 docker 自动下发的规则不被默认 zone 拦掉。把 firewalld 直接stop掉也能连上,但一旦重启就丢------容器网络「时好时坏」多半是这个原因。
2.2 Harbor 部署:四个文件改四处
bash
# 仓库主机上:解包、复制模板、编辑
[root@harbor ~]# tar -xf harbor-offline-installer-v2.10.2.tgz
[root@harbor ~]# cd harbor/
[root@harbor harbor]# cp harbor.yml.tmpl harbor.yml
[root@harbor harbor]# vim harbor.yml
只改这四项,其余保持默认:
| 配置项 | 改成 | 说明 |
|---|---|---|
hostname |
registry.zhb.cloud |
客户端 docker login/push 用的地址,必须能解析 |
http.port |
80 |
保留 http 段;https 段整段注释掉(否则缺证书起不来) |
harbor_admin_password |
zhb@123# |
默认 Harbor12345,改掉 |
data_volume |
/ecshop/harbor-data |
镜像层与数据库都落这里,别留在 /data |
bash
[root@harbor harbor]# ./install.sh
...
✔ ----Harbor has been installed and started successfully.----
2.3 交给 systemd 托管
Harbor 自己是一套 docker compose,机器重启不会自动拉起。补一个 oneshot 服务:
ini
[Unit]
Description=Harbor Docker Registry
After=docker.service network-online.target
Requires=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/etc/harbor
ExecStart=/usr/libexec/docker/cli-plugins/docker-compose up -d
ExecStop=/usr/libexec/docker/cli-plugins/docker-compose down
TimeoutStartSec=0
[Install]
WantedBy=multi-user.target
bash
[root@harbor ~]# cp -a harbor /etc
[root@harbor ~]# vim /etc/systemd/system/harbor.service # 写入上面内容
[root@harbor ~]# systemctl daemon-reload
[root@harbor ~]# systemctl enable --now harbor
[root@harbor ~]# systemctl status harbor
Active: active (exited) since ... CST
Type=oneshot + RemainAfterExit=yes 是这类「跑一次就退出的脚本型服务」的标准写法:up -d 本身会立刻返回,不这么写 systemd 会把它当成失败。
2.4 控制台三件事:建用户、建项目、加成员
Harbor 里「谁能推镜像」由项目成员决定,顺序不能颠倒------先有用户,才能把用户加进项目。

四档角色的分界值得记一下:受限访客只能拉,开发者能推拉,维护者能删镜像和改标签,项目管理员还能管成员和项目设置。日常推送用「开发者」就够。
2.5 客户端接入:让 docker 认识这个 http 仓库
bash
# 1) 域名解析
[root@docker ~]# echo "10.1.8.102 registry.zhb.cloud registry" >> /etc/hosts
[root@docker ~]# ping -c 2 registry.zhb.cloud
# 2) 声明仓库地址(http 必须进 insecure-registries)
[root@docker ~]# cat > /etc/docker/daemon.json <<'EOF'
{
"registry-mirrors": ["http://registry.zhb.cloud"],
"insecure-registries": ["registry.zhb.cloud"]
}
EOF
# 3) 重启生效
[root@docker ~]# systemctl restart docker
[root@docker ~]# docker login registry.zhb.cloud
Username: zhb
Password:
Login Succeeded
| 字段 | 作用 | 漏了会怎样 |
|---|---|---|
insecure-registries |
允许走 http 或自签证书访问该仓库 | 报 http: server gave HTTP response to HTTPS client |
registry-mirrors |
拉公共镜像时先问这里,加速 | 不会报错,只是从公网慢慢拉 |
2.6 把三个基础镜像搬进私有仓库
bash
# 一条链路:拉 → 打标签 → 推
[root@docker ~]# docker pull php:7.2-fpm
[root@docker ~]# docker tag php:7.2-fpm registry.zhb.cloud/library/php:7.2-fpm
[root@docker ~]# docker push registry.zhb.cloud/library/php:7.2-fpm
# nginx、mysql 同理,换掉名字与 tag 即可
[root@docker ~]# docker tag nginx:1.24 registry.zhb.cloud/library/nginx:1.24
[root@docker ~]# docker push registry.zhb.cloud/library/nginx:1.24
[root@docker ~]# docker tag mysql:5.7 registry.zhb.cloud/library/mysql:5.7
[root@docker ~]# docker push registry.zhb.cloud/library/mysql:5.7
docker tag 不是复制文件,只是给同一个镜像 ID 加个别名;push 时才真正按仓库路径上传。所以后面 Dockerfile 里 FROM registry.zhb.cloud/library/php:7.2-fpm 能直接命中本地已有的层,构建几乎不下载。
三、构建 ECShop 专用 PHP 镜像
官方 php:7.2-fpm 缺 ECShop 要用的 GD、mysqli、zip 等扩展,必须自己装。
3.1 Dockerfile 分四段看
dockerfile
FROM registry.zhb.cloud/library/php:7.2-fpm
LABEL version="1.0" description="ECShop php 7.2 fpm"
USER root
# 1) buster 已 EOL,三个源全部指向归档站
RUN echo 'Acquire::Check-Valid-Until "false";' > /etc/apt/apt.conf.d/99ignore-expired \
&& echo 'deb http://archive.debian.org/debian/ buster main contrib non-free' > /etc/apt/sources.list \
&& echo 'deb http://archive.debian.org/debian-security/ buster/updates main contrib non-free' >> /etc/apt/sources.list \
&& echo 'deb http://archive.debian.org/debian/ buster-updates main contrib non-free' >> /etc/apt/sources.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
libfreetype6-dev libjpeg62-turbo-dev libpng-dev libzip-dev \
&& rm -rf /var/lib/apt/lists/*
# 2) GD 必须先 configure 再 install,否则不带 jpeg/freetype
# --with-jpeg-dir / --with-freetype-dir 指向头文件目录,缺了只是编出个不能处理图片的 gd
RUN docker-php-ext-configure gd \
--with-freetype-dir=/usr/include/ \
--with-jpeg-dir=/usr/include/
# 3) 一次装齐业务扩展
RUN docker-php-ext-install -j"$(nproc)" gd mysqli pdo_mysql zip bcmath mbstring
# -j"$(nproc)":按 CPU 核数并行编译,多核机器上能快一半
# 4) 工作目录与 nginx 传过来的 SCRIPT_FILENAME 保持一致
WORKDIR /var/www/html
CMD ["php-fpm"]
| 段 | 在解决什么 | 关键点 |
|---|---|---|
| 换源 | Debian 10 官方源已下架,apt update 直接 404 |
Acquire::Check-Valid-Until "false" 必须加,否则归档源的 Release 过期会让 apt 拒绝 |
| 装依赖库 | GD 编译要用 freetype/jpeg/png 的头文件 | 是 -dev 包,不是运行库;--no-install-recommends 表示不装推荐包,镜像更小 |
| 编译扩展 | docker-php-ext-install 是官方镜像自带的封装 |
pdo_mysql 而非 mysql,PHP 7 已移除旧驱动 |
| 定路径 | nginx 会把 SCRIPT_FILENAME 传给 fpm |
容器里没有这个路径就会报 File not found |
⚠️ 注意 :GD 的
--with-freetype-dir是 PHP 7.x 的写法,PHP 8 之后改成--with-freetype,照抄会编译报错。这条差异在跨版本复用 Dockerfile 时最容易踩。
3.2 构建、运行、验证
bash
[root@docker ecshop]# docker build -t php:fpm-ecshop -f ecshop-php.dockerfile .
[+] Building 43.9s (9/9) FINISHED
=> => naming to docker.io/library/php:fpm-ecshop
[root@docker ecshop]# docker image ls | grep php
php fpm-ecshop 466f50cfb1eb 4 minutes ago 536MB
php 7.2-fpm 28f52b60203d 5 years ago 398MB
镜像从 398 MB 涨到 536 MB,多出来的就是编译工具链与扩展。验证扩展装没装上:
bash
[root@docker ~]# docker run --rm php:fpm-ecshop php -m | grep -E 'gd|mysqli|zip|mbstring'
gd
mbstring
mysqli
zip
3.3 php.ini 放宿主机,不写死在镜像里
镜像里只放「业务无关的默认值」,时区、上传大小这类会变的参数挂到宿主机:
bash
[root@docker ~]# docker run -d --name ecshop-php --restart unless-stopped \
--network ecshop-net \
-v /ecshop/html:/var/www/html \
-v /ecshop/conf/php-fpm:/ecshop/conf/php-fpm \
php:fpm-ecshop
容器启动时若无 php.ini 就从 php.ini-production 生成一份到 /ecshop/conf/php-fpm/,之后改宿主机上的文件、docker restart 即生效:
bash
[root@docker ~]# docker exec ecshop-php php --ini | grep php.ini
Loaded Configuration File: /ecshop/conf/php-fpm/php.ini
# 改时区后验证
[root@docker ~]# docker exec ecshop-php php-fpm -i | grep -E '^date.timezone|^memory_limit'
date.timezone => Asia/Shanghai => Asia/Shanghai
⚠️ 注意 :
php-fpm -i显示的是 fpm 进程的配置,php --ini显示的是 CLI 的加载路径。两者都指向同一个挂载目录,才说明外挂配置真的生效了------只看其中一个容易自欺欺人。
四、网络与三个容器
4.1 自建 bridge 网络
bash
[root@docker ~]# docker network create ecshop-net \
--driver=bridge \
--subnet=172.66.1.0/24 \
--ip-range=172.66.1.0/25 \
--gateway=172.66.1.254 \
-o com.docker.network.bridge.name=docker-ecshop
| 参数 | 含义 | 为什么要改 |
|---|---|---|
--driver=bridge |
用网桥模式 | 单机部署不需要 overlay |
--subnet |
网段 | 避开默认 172.17.0.0/16,防止和别的网桥撞 |
--ip-range |
从网段里再划一段分配用 | 只写 subnet 时整段都可分,写上便于和静态地址共存 |
--gateway |
网关地址 | 默认取网段第一个地址,显式写更好记 |
-o com.docker.network.bridge.name |
宿主机上的网桥名 | 不写就是 br-<哈希>,ip a 里根本认不出是谁 |
自定义网桥最大的好处是「容器名即域名」 :只要在同一个自定义网络里,fastcgi_pass ecshop-php:9000 就能直接解析,不需要 --link,也不怕容器重建换 IP。默认 bridge 网络没有这个内建 DNS。
4.2 建目录,写站点配置
bash
[root@docker ~]# mkdir -p /ecshop/{html,conf/nginx/conf.d,conf/php-fpm,mysql/data,log}
nginx
server {
listen 80;
server_name shop.zhb.cloud;
root /usr/share/nginx/html;
index index.php index.html;
# 端口映射场景必加,否则 301 补全会把 8080 换成 80
absolute_redirect off;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass ecshop-php:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
include fastcgi_params;
fastcgi_read_timeout 300;
}
location ~ /\.ht {
deny all;
}
}
两处细节决定这套站能不能进后台:
absolute_redirect off;:容器内监听 80、外部映射 8080,nginx 自己拼Location时会用getsockname()的 80,访客被送到错误的端口。关掉它,让浏览器自己补。SCRIPT_FILENAME /var/www/html...:这个路径必须是 php 容器里的真实路径 ,与 nginx 容器的root无关。后面挂载 php 容器时就按/var/www/html挂。
⚠️ 注意 :
include fastcgi_params里已经有fastcgi_param HTTPS $https if_not_empty;,不要再手动追加fastcgi_param HTTPS on;。写死on会让 PHP 以为自己在 https 上,ECShop 后台的 Referer 校验(前缀https://.../admin对不上真实的http://...)会把每个已登录请求都踢回登录页,表现就是「密码明明对,登录完又回到登录页」。
4.3 三个容器,挂载各不相同
bash
# 1) 数据库:只挂数据目录,不发布端口
[root@docker ~]# docker run -d --name ecshop-mysql --restart unless-stopped \
--network ecshop-net \
-v /ecshop/mysql/data:/var/lib/mysql \
-v /ecshop/mysql/conf:/etc/mysql/conf.d:ro \
-e MYSQL_ROOT_PASSWORD='zhb@123#' \
-e MYSQL_DATABASE=ecshop \
-e MYSQL_USER=ecshop \
-e MYSQL_PASSWORD='zhb@123#' \
mysql:5.7
# 2) PHP:代码挂 /var/www/html,配置挂自己的路径
[root@docker ~]# docker run -d --name ecshop-php --restart unless-stopped \
--network ecshop-net \
-v /ecshop/html:/var/www/html \
-v /ecshop/conf/php-fpm:/ecshop/conf/php-fpm \
php:fpm-ecshop
# 3) nginx:代码只读挂载 + 配置目录 + 日志目录 + 对外发布 8080
[root@docker ~]# docker run -d --name ecshop-nginx --restart unless-stopped \
--network ecshop-net \
-p 8080:80 \
-v /ecshop/html:/usr/share/nginx/html:ro \
-v /ecshop/conf/nginx/conf.d:/etc/nginx/conf.d:ro \
-v /ecshop/log:/var/log/nginx \
nginx:1.24
| 容器 | 挂载什么 | 为什么 |
|---|---|---|
| mysql | /ecshop/mysql/data |
数据目录丢了等于库没了,必须持久化 |
| php | /ecshop/html → /var/www/html |
与 SCRIPT_FILENAME 完全一致;配置目录挂出来便于改 php.ini |
| nginx | html 挂 :ro、配置目录挂 :ro、日志挂可写 |
容器只负责转发,不该能改代码与配置;日志要留 |
--network ecshop-net 让容器接入前面建的自定义网桥,三个容器因此能互相用名字访问;三个容器都加了 --restart unless-stopped,少了它宿主机重启后站点不会自己起来。
bash
[root@docker ~]# docker ps --format '{{.Names}}\t{{.Status}}\t{{.Ports}}'
ecshop-nginx Up 2 minutes 0.0.0.0:8080->80/tcp
ecshop-php Up 2 minutes 9000/tcp
ecshop-mysql Up 3 minutes 3306/tcp
五、站点初始化
浏览器打开 http://10.1.8.10:8080(带端口,nginx 只发布了 8080),进入 ECShop 安装向导:

数据库主机一定要写容器名 ecshop-mysql ,不能写 localhost 或 127.0.0.1------后者在 php 容器里指的是 php 容器自己,那里没有数据库。
装完进后台:

六、四个必须记住的坑
| 现象 | 原因 | 处理 |
|---|---|---|
前台 502 / File not found |
php 容器里没有 SCRIPT_FILENAME 指向的路径 |
让 -v 的容器端路径与 nginx 里的 SCRIPT_FILENAME 一字不差 |
访问 IP 打不开,要加 :8080 |
只发布了 8080 | 直接访问 http://IP:8080,或改 -p 80:80 |
| 改 nginx.conf 不生效 | 单文件挂载绑定 inode,vim 写临时文件再改名会换 inode |
改成挂目录 /etc/nginx/conf.d,改完 nginx -s reload |
| 安装时提示目录不可写 | 容器内 uid 与宿主机属主不一致 | chown -R 33:33 /ecshop/html/{data,temp,upload},别图省事 chmod 777 |
七、四个为什么
1. 为什么配置文件要外挂,不打包进镜像? 镜像是只读的构建产物,打进镜像意味着「改一个参数就要重新 build + 重新 push + 重新拉」。外挂之后改配置只需 vim + restart,而且同一份镜像能在测试、生产套不同配置。更要紧的是镜像里不该有环境相关的信息------时区、上传上限、数据库地址,换环境就变。
2. PHP 时区写在外部 php.ini 里有什么好处? 时区跟着部署地走。写死进镜像,跨机房部署就得重新构建;写进外挂 ini,date.timezone = Asia/Shanghai 一行改完重启即生效,日志时间、订单时间立刻对齐。
3. 自定义 bridge 比默认 bridge 强在哪?
| 能力 | 默认 bridge |
自定义网络 |
|---|---|---|
| 容器名解析 | 不支持,只能用 --link(已废弃) |
内建 DNS,ping 容器名 通 |
| 网段 | 固定 172.17.0.0/16 |
自己指定,可避开冲突 |
| 隔离 | 所有容器同一张网 | 按业务分网,互不可见 |
| 动态加入 | 只能重建容器 | docker network connect 随时加 |
4. MySQL 不映射 3306 有什么安全意义? 容器的端口只要不 -p 发布,外部就完全访问不到,只有同网段的容器能连。数据库暴露在公网是被爆破的头号目标,少一次映射就少一个入口。真要在宿主机上连,用 docker exec -it ecshop-mysql mysql -u ... 走容器内部即可。
排错速查
bash
# 三个容器是否都在同一张网里(能看到 ecshop-net 且有 IP 才对)
docker inspect ecshop-{nginx,php,mysql} --format '{{.Name}} {{range $k,$v := .NetworkSettings.Networks}}{{$k}}={{$v.IPAddress}}{{end}}'
# 从 nginx 容器里测 php 的 9000 通不通(最直接的链路验证)
docker exec ecshop-nginx sh -c 'nc -z ecshop-php 9000 && echo OK || echo FAIL'
# php 里能不能连上数据库
docker exec ecshop-php php -r 'var_dump((new mysqli("ecshop-mysql","ecshop","zhb@123#","ecshop"))->connect_errno);'
# nginx 展开后的真实配置(-T 会打印 include 展开后的全部内容,改没改对看这个不看文件)
docker exec ecshop-nginx nginx -T | grep -nE 'fastcgi_pass|SCRIPT_FILENAME|absolute_redirect'
# 看谁在拒绝:日志比浏览器的报错有用得多
docker logs ecshop-nginx --tail 20 # --tail 20:只取最后 20 行,别刷屏
docker logs ecshop-php --tail 20
📚 参考资料(官方文档 · 中英双语)
- Docker 镜像打标签与推送(
docker tag/docker push):docs.docker.com - Docker 自定义 bridge 网络与内建 DNS:docs.docker.com
- Docker daemon 配置项
insecure-registries/registry-mirrors:docs.docker.com - Harbor 官方安装与配置说明(
harbor.yml):goharbor.io - nginx
fastcgi_pass与fastcgi_param:nginx.org
附:命令英文全称速查表
| 命令 / 参数 | 英文全称 | 一句话说明 |
|---|---|---|
docker tag |
tag | 给镜像加一个「仓库地址/项目/名字:标签」别名 |
docker push / pull |
push / pull | 推送到仓库 / 从仓库拉取 |
docker login |
login | 认证并写 ~/.docker/config.json |
insecure-registries |
insecure registries | 允许 http 或自签证书仓库 |
registry-mirrors |
registry mirrors | 拉公共镜像的加速地址列表 |
--restart unless-stopped |
restart policy | 除手动停止外,异常退出与重启都自动拉起 |
-v ...:ro |
volume, read only | 挂载并设为容器内只读 |
fastcgi_pass |
FastCGI pass | 把 PHP 请求转给哪个 fpm 地址 |
absolute_redirect |
absolute redirect | 3xx 的 Location 是否补全成绝对 URL |
容器化部署的验收标准只有一条:把三个容器全删掉,用同样的
docker run再跑一遍,站点不用重新安装就能用。做到这一步,代码、配置、数据、日志四类才真的落在了该落的地方。
