飞牛NAS实战:Docker Compose搭建PHP 8.2开发环境
说实话把一台飞牛NAS变成能跑PHP项目的开发机这件事听起来有点折腾但做起来并不难。很多人买NAS回去主要就是存电影、备份照片时间久了总觉得有点浪费——毕竟飞牛系统底层是DebianDocker环境完整处理器和内存也够用。与其让它在角落里吃灰不如直接在上面搭一套Nginx PHP 8.2Alpine Redis MySQL的轻量级开发环境跑点个人项目、WordPress、ThinkPHP、Laravel这类PHP应用完全够用。这篇内容适合谁看一类是已经有飞牛NAS、想拿它当开发机用的朋友另一类是单纯想在自己机器上不管Windows、macOS还是Linux用Docker Compose把PHP环境串起来的入门玩家。我会把从镜像选型、目录规划、compose文件编写、Nginx配置、PHP扩展安装到MySQL初始化和Redis持久化这些全部过一遍按我实际踩坑后的最终方案来写。你照着做半小时内能跑起来。1. 整体设计与思路拆解1.1 组件选型的底层逻辑先说Nginx和PHP为什么是分离的。Nginx本身不解析PHP代码它只负责接收HTTP请求、处理静态文件然后把需要动态处理的请求交给PHP-FPM处理。所以镜像选择上用nginx:alpine和php:8.2-fpm-alpine两个独立容器而不是用php:8.2-apache那样的合体镜像。好处是职责清晰Nginx挂了不影响PHP进程PHP出了致命错误Nginx还能继续输出静态页面这在排查问题时候体验好得多。为什么选Alpine版本Alpine基于musl libc系统自带体积非常小。php:8.2-fpm-alpine镜像解压后大概80MBnginx:alpine更是只有50MB左右。在NAS这种存储敏感型设备上镜像体积小意味着拉取快、占用少、启动也快。当然Alpine也有副作用后面我会讲到需要额外装哪些PHP扩展依赖因为musl和glibc的兼容性差异会导致某些编译好的扩展不能直接用。PHP版本为什么卡在8.2而不是最新8.3或8.4从稳定性和生态兼容来看8.2是目前生产环境的主流版本绝大多数PHP开源项目都已经适配好了Composer依赖也不太会遇到冲突。8.3、8.4虽然新但在一些老项目的框架里还会出现弃用警告甚至兼容问题。对于开发环境稳定压倒一切8.2足够。MySQL和Redis的选型相对常规。MySQL我用mysql:8.0注意如果你手里的老项目是拿5.7跑的那就用mysql:5.7比如热词里提到的5.7.44版本。语法兼容性上5.7和8.0有差异主要是认证插件和窗口函数老项目直接上8.0会报认证错误这点要提前确认。Redis用redis:7-alpine7.x的稳定性和性能都很好而且支持ACL权限控制比单纯设一个requirepass安全。1.2 目录规划与数据持久化这套环境里最核心的一条原则是所有有状态的数据都必须通过数据卷挂载出来容器本身可以被随意销毁重建。我在飞牛NAS上把目录结构规划成了这样你也可以完全照搬/vol1/1000/docker/dev-env/ ├── compose/ │ └── docker-compose.yml ├── nginx/ │ ├── conf.d/ │ └── logs/ ├── php/ │ └── php.ini ├── mysql/ │ ├── data/ │ └── init/ ├── redis/ │ ├── data/ │ └── redis.conf └── www/ └── default/ └── index.php解释几个关键点。nginx/conf.d用来放站点配置文件每个站点一个server块这样多站点管理非常清晰。mysql/data是MySQL的数据目录容器销毁再重建数据依然都在。mysql/init放初始化SQL脚本MySQL容器首次启动时会自动执行目录下的.sql文件这个机制后面单独讲。redis/data存RDB快照和AOF日志。www是站点根目录的上级目录所有PHP项目都放这里。选择这套目录结构原因只有一个出了问题你能在宿主机上直接看到容器内部到底写了什么。很多人在NAS上装完容器数据在容器里堆积NAS一重启或者镜像一更新容器重建数据全没了哭都来不及。凡是服务类容器持久化目录必须规划在第一步这比选什么镜像重要得多。1.3 网络方案与端口规划网络模式我选的是bridge桥接这是Docker默认方案也是我推荐大多数场景使用的方案。bridge模式下每个容器有自己的虚拟IP通过端口映射对外提供服务。好处是隔离性好MySQL和Redis不需要对宿主机暴露端口只在容器内部网络里互通降低了被局域网其他设备扫描到的风险。端口规划思路是这样Nginx对外映射8080端口因为NAS上80端口可能被系统或已有服务占用用8080避免冲突MySQL的3306不对外映射只在docker-compose内部网络中使用Redis的6379同样不对外映射仅内部访问如果你需要通过宿主机之外的机器比如你日常用的Windows电脑连接MySQL或Redis做调试那就需要映射端口。我自己的习惯是开发期内网可信映射出去也没问题但至少要设密码并且不要用root账号给外部工具连接。等部署套件跑稳定了再把端口映射去掉只留Nginx对外的8080。2. 核心配置解析2.1 docker-compose.yml全量解读直接给出最终的compose文件这是整套方案的基准配置。version: 3.8 services: nginx: image: nginx:alpine container_name: dev-nginx restart: unless-stopped ports: - 8080:80 volumes: - ../nginx/conf.d:/etc/nginx/conf.d:ro - ../nginx/logs:/var/log/nginx - ../www:/var/www/html depends_on: - php networks: - dev-net php: image: php:8.2-fpm-alpine container_name: dev-php restart: unless-stopped volumes: - ../www:/var/www/html - ../php/php.ini:/usr/local/etc/php/php.ini:ro environment: - TZAsia/Shanghai networks: - dev-net mysql: image: mysql:8.0 container_name: dev-mysql restart: unless-stopped command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci environment: - MYSQL_ROOT_PASSWORDroot_password - MYSQL_DATABASEapp_db - MYSQL_USERapp_user - MYSQL_PASSWORDapp_password - TZAsia/Shanghai volumes: - ../mysql/data:/var/lib/mysql - ../mysql/init:/docker-entrypoint-initdb.d networks: - dev-net redis: image: redis:7-alpine container_name: dev-redis restart: unless-stopped command: redis-server /usr/local/etc/redis/redis.conf volumes: - ../redis/data:/data - ../redis/redis.conf:/usr/local/etc/redis/redis.conf:ro networks: - dev-net networks: dev-net: driver: bridge逐步讲几个关键配置。restart: unless-stopped的意思是容器异常退出时自动重启除非你手动停止它。这个策略在NAS上非常实用因为NAS可能会自动更新重启如果容器没有自动重启策略重启后所有服务挂掉你还得手动一个一个拉起来。实测这个策略配合飞牛NAS的重启机制系统起来后容器会自己恢复几乎不需要人工干预。depends_on只保证Nginx容器在PHP之后启动但它不等待PHP真正可用。对于Nginx和PHP这对组合来说先后顺序基本无所谓因为Nginx配置里连的是PHP容器名容器启动后网络层面的连接会自动建立。但如果你遇到Nginx启动后报host not found in upstream的错误那大概率是因为Nginx先启动、PHP还没注册到Docker内部DNS。解决办法是在nginx.conf的upstream里改用一个固定的IP或增加重试机制但这属于衍生问题一般出现频率不高。MySQL容器的command参数指定了默认字符集和排序规则这非常关键。MySQL 8.0默认字符集是utf8mb4但排序规则可能是utf8mb4_0900_ai_ci兼容性在某些老项目上会出问题。我显式指定为utf8mb4_unicode_ci兼容性和支持范围都很成熟避免后面出现乱码和排序异常。2.2 Nginx配置与location工作流机制先说一句很多人直接把Nginx配置文件贴进去就完事却忽略了Nginx处理location的匹配顺序。如果你不理解Nginx的location匹配规则后面配置多站点、配置伪静态、配置反向代理的时候会一头雾水。Nginx的location匹配分优先级别从上到下依次是location /uri精确匹配命中后立即停止搜索location ^~ /path前缀匹配命中后不再检查正则location ~或location ~*正则匹配按文件中的顺序匹配第一个命中的生效location /path普通前缀匹配按最长前缀匹配规则开发环境里最常见的坑是你写了一个location /的普通匹配来转发PHP请求又写了location ~ \.php$的正则匹配但请求一个不存在的PHP文件时Nginx可能进了location /而没进PHP正则匹配于是返回404而不是进入PHP-FPM处理。这个坑我在多站点配置上踩过不止一次。下面是我推荐用的站点配置模板放在nginx/conf.d/default.confserver { listen 80; server_name localhost; root /var/www/html/default; index index.php index.html; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass php:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } location ~ /\.(?!well-known).* { deny all; } }重点说说location ~ \.php$这个块。fastcgi_pass指向的是php:9000这是docker-compose服务名和PHP-FPM默认端口。容器之间通过内部网络可以用服务名互相访问所以在compose文件的网络里php:9000会被自动解析到PHP容器的IP和9000端口你不需要去查容器的具体IP。fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;这一行决定了PHP-FPM去磁盘上找哪个文件来执行。由于Nginx和PHP容器挂载了同一个../www目录到各自的/var/www/html两边看到的是同一份文件系统所以这个路径必须两边一致否则PHP会报File not found。这个坑我见过太多次了——根目录在Nginx里配置成了/var/www/html但PHP-FPM看到的文件路径因为挂载差异对不上最终就是404或502。解决办法是统一root和fastcgi_param SCRIPT_FILENAME的基准目录。try_files $uri $uri/ /index.php?$query_string的作用是如果请求的URI不是真实文件也不是真实目录就重写到index.php去处理。这是绝大多数PHP框架Laravel、ThinkPHP伪静态的基本写法这样你的URL才能是/user/profile而不是/index.php?routeuser/profile。还有一个生产环境不太需要、开发环境最好加上的配置就是隐藏敏感文件。location ~ /\.(?!well-known).* { deny all; }的意思是拒绝访问所有点开头的隐藏文件但不影响.well-knownSSL证书验证目录。这个正则里的负向零宽断言比较绕但很好用直接抄就行。2.3 PHP-FPM与php.ini关键优化PHP容器用的是php:8.2-fpm-alpine它默认已经编译好了很多常用扩展比如pdo_mysql、mysqli、opcache、curl等。但有几个扩展在Alpine版本里是默认没有的需要你自行安装。我们常用的安装方式是在Docker容器里通过docker-php-ext-install命令编译安装。比如Redis扩展就需要先用pecl装docker exec -it dev-php sh -c pecl install redis docker-php-ext-enable redis注意docker-php-ext-install是PHP官方镜像提供的辅助脚本它只负责编译和启用扩展。Alpine系列的缺点是有些扩展依赖系统库比如pdo_mysql本身不需要额外依赖但gd库需要libpng-dev、jpeg-dev这些。所以如果后面你要装GD库做图片处理需要先装依赖再编译docker exec -it dev-php sh -c apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev docker-php-ext-configure gd --with-freetype --with-jpeg docker-php-ext-install gd但你用轻量环境部署不建议装太多扩展够用就好。每多一个扩展容器体积变大且Alpine下某些扩展编译容易踩坑。我一般在开发机上只额外装Redis扩展和zip扩展因为PHP项目经常用到Composer而Composer需要zip和unzip。装法如下docker exec -it dev-php sh -c apk add --no-cache zip unzip libzip-dev docker-php-ext-install zipphp.ini映射到容器里之后有几个参数需要特别关注我摘几个开发环境必备的配置贴出来upload_max_filesize 20M post_max_size 20M memory_limit 256M max_execution_time 120 date.timezone Asia/Shanghai error_reporting E_ALL display_errors Ondisplay_errors在开发环境设置为On非常关键。很多人部署完环境后发现页面白屏第一反应是代码有问题但真实情况往往是PHP报错被关掉了你什么都看不到。开发阶段把display_errors打开所有警告和致命错误直接输出到页面排查效率提升一大截。等真正上生产环境再把它设为Off并把log_errors打开重定向到日志文件。max_execution_time在开发环境也建议调大一点。PHP默认值30秒如果你在跑一个耗时较长的脚本比如批量导入数据或OCR识别验证码30秒到了进程会被强制中断。注意这个参数在某些场景下不生效比如用exec()调用外部程序时超时控制还得配合set_time_limit()来用。不过容器里跑一个php -r命令行脚本时max_execution_time默认是0无限因为CLI模式默认不限制这和FPM模式的默认值不一样别搞混了。PHP-FPM下除了php.ini里设置也可以改/usr/local/etc/php-fpm.d/www.conf这个文件来调整进程管理参数。默认pm dynamicpm.max_children默认5这对于开发环境完全够用。如果你并发请求多可以把pm.max_children调到10同时把pm.start_servers改成2pm.min_spare_servers和pm.max_spare_servers都适当调高。但这些参数在www.conf里的调整开发环境不建议过度优化等有了明确的并发指标再动。2.4 MySQL与Redis的容器化配置要点MySQL容器第一次启动时会依次做几件事初始化数据目录、读取/docker-entrypoint-initdb.d目录下的SQL脚本并执行、根据环境变量创建用户和数据库。理解这个初始化机制很重要因为容器重启后这些脚本不会再次执行所以不要在init目录里放幂等性差的脚本。我建议这样初始化MySQL简单又清晰。首先在mysql/init/下创建一个脚本比如01_init.sqlCREATE DATABASE IF NOT EXISTS app_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE app_db; -- 创建一个测试表 CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, email VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO users (username, email) VALUES (admin, adminexample.com);这个脚本只在MySQL数据目录为空时执行。如果mysql/data里已经有数据容器不会重新初始化脚本也就不会执行。所以如果你改了初始化脚本发现没生效先别奇怪多半是因为数据目录非空了。想重新执行脚本就先把mysql/data备份后清空再重启容器。MySQL的密码管理分两种情况。一种是通过环境变量MYSQL_ROOT_PASSWORD指定的root密码另一种是MYSQL_USER和MYSQL_PASSWORD创建的普通应用账号。开发时直接用root连也不是不行但不建议让业务代码拿root连接数据库。用app_user配合app_password连接app_db权限尽量窄这样将来项目换到生产环境时不用改代码里的连接信息结构只改密码就好。Redis的配置我用的是一个自定义的redis.conf文件核心配置如下bind 0.0.0.0 port 6379 protected-mode yes requirepass redis_password appendonly yes appendfsync everysec maxmemory 256mb maxmemory-policy allkeys-lrubind 0.0.0.0加上protected-mode yes的组合意思是允许所有来源连接但如果没有配置密码或ACL则拒绝非本机访问。这正好配合requirepass设置密码后从容器外部连接Redis就一定要输入密码。appendonly yes开启AOF持久化这是开发环境相对稳妥的选择。Redis默认开启RDB快照但RDB是定时做快照时间窗口内数据可能丢失。AOF每次写操作追加日志最多丢1秒的数据appendfsync everysec对开发环境来说足够安全。不过AOF文件会持续增长在maxmemory 256mb限制下Redis会淘汰旧key来保持内存空间AOF文件虽然记录的是操作历史但实际有效期内的数据不会无限膨胀。这里需要理解一下AOF文件大小和内存数据量不是一回事AOF会积累所有写操作所以Redis提供了BGREWRITEAOF来执行重写机制压缩文件。生产环境可以定时执行redis-cli bgrewriteaof来重写AOF开发环境不用管。maxmemory-policy allkeys-lru的意思是当内存达到256MB上限时按LRU策略淘汰所有键包括设置了过期时间的和没设置过期时间的。这对缓存场景非常适用因为缓存本身就是可以牺牲的数据用LRU淘汰最久未使用的key不会影响业务逻辑。3. 实操过程与验证3.1 部署前的检查清单部署之前先确认几件事否则后面排查麻烦。第一飞牛NAS的Docker环境是否正常。进入飞牛的Docker管理界面看是否有容器在运行。如果Docker本身状态异常优先先重启Docker服务。第二检查端口冲突。8080端口是否被其他容器或服务占用在飞牛NAS的Docker界面里能看到端口占用情况也可以在命令行里用netstat -tlnp | grep 8080确认。如果8080被占用了换一个端口比如8081。第三确认目录权限。www目录如果想让容器里的PHP-FPM写入文件比如上传文件、生成缓存宿主机目录的权限要设置正确。飞牛NAS上我一般把www目录设为777给开发环境用因为容器里PHP-FPM是以www-data用户运行的宿主机普通用户写的文件容器可能读不了反之亦然。这是个开发环境特有的矛盾用777最省心但如果你在意安全可以精确匹配用户和组ID。先看看PHP容器里www-data的UID是多少docker exec -it dev-php id www-data一般输出是uid82(www-data)。那你就可以把宿主机www目录的属主改为82chown -R 82:82 /vol1/1000/docker/dev-env/www这样PHP容器内的www-data用户就能正常读写这个目录了。第四确认mysql/init目录里有没有你要执行的初始化SQL。如果有确认SQL格式正确且文件名保持顺序比如01_init.sql、02_data.sql。3.2 部署流程与日志观察一切就绪后进入compose文件所在目录执行cd /vol1/1000/docker/dev-env/compose docker compose up -d第一次启动会拉取四个镜像耗时取决于网速。飞牛NAS的Docker镜像源如果是默认官方源在国内拉取会比较慢建议在飞牛Docker设置里把镜像源配置为国内镜像加速地址。镜像拉取完成后compose会依次创建网络、启动容器。启动后立刻查看容器状态docker compose ps正常情况应该是四个容器全部Up状态。如果有容器反复重启或退出马上看日志docker compose logs mysql docker compose logs php docker compose logs nginx docker compose logs redisMySQL容器启动慢是正常的因为它要做数据目录初始化。第一次启动可能需要30秒到1分钟如果看到ready for connections日志说明MySQL已经可用了。如果MySQL容器一直重启大概率是数据目录权限问题或端口冲突参考后面的排查章节。3.3 站点代码与验证在www/default目录下创建index.php内容这样写?php phpinfo();然后通过浏览器访问http://NAS_IP:8080如果一切正常你会看到PHP 8.2的信息页。如果看到的是Nginx默认页检查你的Nginx配置里root指向的目录和index设置是否正确。如果看到502 Bad Gateway说明Nginx连不上PHP-FPM参考常见问题里的排查方法。接下来验证MySQL和Redis的连接。在www/default下再写一个测试脚本test_db.php?php $pdo new PDO(mysql:hostmysql;dbnameapp_db;charsetutf8mb4, app_user, app_password); $stmt $pdo-query(SELECT * FROM users); print_r($stmt-fetchAll(PDO::FETCH_ASSOC)); $redis new Redis(); $redis-connect(redis, 6379); $redis-auth(redis_password); $redis-set(test_key, hello from redis); echo $redis-get(test_key);注意这里用的是hostmysql和hostredis这是Docker内部网络的服务名。由于PHP容器和MySQL/Redis容器都在同一个dev-net网络里PHP通过服务名访问它们不需要知道具体IP。这个设计的好处是你不需要记住容器的IP容器重启导致IP变化也不影响代码。访问http://NAS_IP:8080/test_db.php页面输出users表的数据和hello from redis就说明MySQL、Redis、PHP三者之间全部联通。我自己的这套环境跑Laravel和ThinkPHP项目基本不用再改任何配置直接复制项目到www目录然后把数据库配置指向mysql容器服务名、Redis配置指向redis容器服务名就能跑起来。3.4 常用运维命令速查我把日常要用到的命令整理成一个速查表存下来备查操作命令说明启动所有服务docker compose up -d后台运行全部容器停止所有服务docker compose down停止并删除容器数据卷保留重启单个服务docker compose restart php改完配置后重启生效查看所有容器状态docker compose ps最常用的状态检查查看某个容器日志docker compose logs -f mysql-f是持续跟踪输出进入PHP容器docker exec -it dev-php shAlpine容器没有bash用sh进入MySQLdocker exec -it dev-mysql mysql -uroot -p进入MySQL命令行执行Redis命令docker exec -it dev-redis redis-cli -a redis_password带密码连接Redis其中docker compose down不会删除volumes里定义的数据卷这是关键。我们的数据全部挂载在宿主机目录上即使down之后再up -d数据和配置都还在容器等于全新创建但数据延续。这也是我坚持用bind mounts而不是named volumes的原因之一bind mounts的路径更直观备份时直接打包目录即可。4. 常见问题与排查技巧实录4.1 容器启动失败类问题一端口被占用导致Nginx启动失败症状docker compose ps显示nginx容器Exited日志里报Address already in use。排查用netstat -tlnp | grep 8080看谁占用了8080端口。如果是其他容器可以改compose里的端口映射为8081:80或者停掉占用端口的服务。改了compose文件后要执行docker compose up -d重新创建容器不是restart因为compose文件的改动只有重新创建容器才会生效。问题二MySQL容器反复启动失败症状MySQL容器不断重启日志里出现[ERROR] [MY-010457] [InnoDB] ...或者Permission denied。排查最常见的两个原因一是宿主机mysql/data目录权限不对。先查看数据目录权限ls -l /vol1/1000/docker/dev-env/mysql/data如果属主不是MySQL容器内的用户MySQL官方镜像里mysql用户的UID是999就执行chown -R 999:999 /vol1/1000/docker/dev-env/mysql还有一个原因宿主机mysql/data目录之前被非空初始化过现在换了一个版本的MySQL镜像启动。比如你之前用5.7初始化过数据现在改成了8.08.0的容器读到5.7格式的数据文件会报版本不兼容。解决办法只有一个备份数据后清空mysql/data目录重新初始化。这就是我前面反复强调为什么要用bind mounts的原因数据在宿主机上备份和清理都方便。问题三PHP容器启动后一直处于“Restarting”状态症状docker compose ps里php容器状态显示Restarting (1) ...日志报语法错误或配置错误。排查多半是php.ini文件的格式有问题比如映射的宿主机路径不对、文件权限不对、或者php.ini里有未知指令。先检查compose文件里php服务的volumes段是否正确- ../php/php.ini:/usr/local/etc/php/php.ini:ro确认宿主机上php/php.ini确实存在。php.ini里如果有配置错误PHP-FPM启动时会报Fatal error并退出。可以先用不带php.ini的配置测试一下容器能不能正常启动如果能启动那就是php.ini的问题逐行排查克制一下。4.2 PHP访问类问题四访问PHP页面出现502 Bad Gateway症状浏览器访问http://NAS_IP:8080Nginx正常响应但PHP页面返回502。排查502意味着Nginx无法连接PHP-FPM。先检查PHP容器是否正常运行docker compose ps php如果PHP容器正常那问题多半在fastcgi_pass配置。确认Nginx配置里是fastcgi_pass php:9000;这个php是服务名。如果Nginx配置写的是127.0.0.1:9000那就大错特错了——Nginx和PHP不在同一个网络命名空间里127.0.0.1指向的是Nginx容器自己而不是PHP容器。这是新手最容易犯的错误我在群里被问过无数次。再检查Nginx容器内部是否能解析到PHP容器docker exec -it dev-nginx ping phpping命令可能没安装那就用getent hosts php看解析结果。能解析出IP说明Docker内部DNS正常不能解析则检查compose网络配置是否正确。问题五访问PHP页面返回File not found症状Nginx返回404但错误日志里写的是Primary script unknown或者File not found。排查这是SCRIPT_FILENAME配置不一致导致的。Nginx和PHP容器虽然都挂载了../www到/var/www/html但如果某个容器的路径写错了PHP-FPM就会在错误的目录里找文件。确认Nginx的root指令和fastcgi_param SCRIPT_FILENAME的值都统一为/var/www/html基准。我自己的排查顺序是先看浏览器访问的URL对应的文件路径再看Nginx的root配置最后检查PHP容器的挂载路径是否一致。问题六PHP页面中文乱码症状页面输出中文变成一堆问号或方框。排查分两种情况。数据库乱码大概率是MySQL连接字符集不对。确认PDO连接串里加charsetutf8mb4new PDO(mysql:hostmysql;dbnameapp_db;charsetutf8mb4, user, pass);也要确认MySQL服务端字符集配置正确。如果以上都没问题检查PHP源码文件本身的编码格式是不是UTF-8无BOM。Windows下用记事本编辑的PHP文件可能会带上BOM头输出到页面时会导致头部输出空白或乱码。推荐开发工具一律用VS Code或PhpStorm编码统一UTF-8。跨域问题也是开发中经常遇到的尤其是在前后端分离项目里。PHP接口给前端调用时需要在PHP入口文件加跨域头header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization);如果你对接的是老项目用JSONP前端那边用callback参数包一层jsonp_callback($data)输出就行。这个和Docker环境本身没啥关系但既然是我们做PHP开发这个顺手贴在这里供参考。4.3 MySQL与Redis连接类问题七PHP连接MySQL提示Connection refused症状SQLSTATE[HY000] [2002] Connection refused。排查如果代码里连接的是mysql服务名确认PHP和MySQL在同一个Docker网络里。docker compose默认会为所有服务创建一个网络如果你手动创建了别的网络或者服务定义里加了不同的networks它们之间可能不互通。最简单的验证方式是把compose文件里所有服务放到同一个网络我在示例里就是全部放在dev-net下重启后测试。还有一种情况是MySQL还没完全启动好。MySQL容器的启动流程较长如果你在compose启动后立刻访问数据库可能会报连接失败。解决办法是在依赖处理上增加等待机制比如使用dockerize工具或者像我在测试脚本里那样连接失败后隔几秒重试几次。开发环境最简单的方式就是等日志里出现ready for connections再访问。问题八Redis连接报NOAUTH Authentication required症状Error: NOAUTH Authentication required。排查说明Redis设置了requirepass但你连接时没带密码。PHP连接时记得调用auth方法$redis-auth(redis_password);命令行连接时也要带-a参数docker exec -it dev-redis redis-cli -a redis_password如果你用Redis Desktop Manager这类GUI工具连接也要在设置里填好密码。另外注意Docker容器里的Redis即便设置了requirepass它在容器内部网络通信时也受密码保护所以PHP脚本里必须正确认证。问题九Redis缓存一直不生效症状设置了Redis缓存但每次请求都重新查数据库。排查先确认Redis容器是否正常、数据能否写入最直接的测试是进入容器用SET/GET命令走一遍。如果SET/GET正常再看项目里Redis连接的配置是否指向了正确的服务名、端口和密码。很多时候问题出在项目用了某个第三方缓存包但它默认连接的是localhost:6379而不是容器服务名所以永远连不上Redis。找到这个配置文件把连接地址改成redis:6379即可。不过这里要说一句我们在开发环境里搞Redis很多时候只是在验证功能。如果你准备在生产环境用Redis做分布式锁那还需要考虑锁的超时时间、可重入性、以及Redisson客户端的具体实现。Redis是这套环境里最容易被低估的组件——刚开始只当缓存用后面做队列、做分布式锁、做限流都是同一套基础设施这也是我坚持在开发环境就把它配置正确的原因。4.4 维护类问题十修改Nginx配置后不生效症状改完nginx/conf.d下的配置访问页面还是旧效果。排查Nginx的配置文件是启动时加载的修改后必须重新加载。执行docker exec -it dev-nginx nginx -s reload如果reload报语法错误先检查配置语法docker exec -it dev-nginx nginx -t出现test is successful就说明语法没问题。注意因为我把nginx/conf.d目录以ro只读方式挂载进容器宿主机上的修改会同步到容器里但Nginx不会自动感知必须手动reload。这个和nginx -s reload负责平滑重载不中断现有连接放心用。同样的逻辑也适用于PHP配置。改完php.ini后PHP-FPM不会自动重载需要重启容器docker compose restart php问题十一更换SSL证书后不生效症状配置了HTTPS站点替换了证书文件浏览器还是提示证书错误。排查证书不生效绝大多数是路径和文件名问题。确认你要换的证书文件确实放在了宿主机挂载目录里容器里能看到。用docker exec -it dev-nginx ls -l /etc/nginx/conf.d/ssl/确认容器内能看到新证书。Nginx配置里指定的证书路径要和实际文件名完全一致一个字符都不能差。最后再次nginx -s reload强制浏览器清缓存或隐身窗口测试。注意一个细节更换证书时Nginx可能还持有旧证书的句柄reload能解决大多数情况。如果reload后还是旧证书执行docker compose restart nginx强制重启容器基本都能解决。问题十二容器重启后IP变化症状容器重启后之前记录下来的容器IP变了某个项目连不上了。排查这正是我建议全部用服务名而不是IP连接的原因。Docker bridge网络默认会为每个容器分配IP容器重启后IP可能变化但服务名不变。只要代码里用的是mysql、redis、php这种服务名Docker内置DNS会自动解析到新IP。如果你脚本或代码里有硬编码容器IP的地方全部改成服务名这是治本的办法。问题十三磁盘空间不断增大症状NAS的Docker数据目录越来越大但看不出哪个容器占的空间。排查先看哪个容器贡献了最大的数据量docker system df然后看具体容器的日志大小。Nginx访问日志在nginx/logs里会持续增长Redis的AOF文件在redis/data里也会累积。对于开发环境日志和缓存定期清理就行。Nginx日志文件可以直接在宿主机上通过truncate命令清空truncate -s 0 /vol1/1000/docker/dev-env/nginx/logs/access.logRedis的AOF重写可以在Redis里执行docker exec -it dev-redis redis-cli -a redis_password bgrewriteaof如果你发现某台容器占用的空间是你的数据卷之外的空间在容器层里那就要检查是不是容器运行期间产生了大量临时文件。比如PHP-FPM的/tmp目录里堆积了Session文件这些文件如果没挂载出来会随容器而生、随容器而灭不用太担心。但如果你用docker commit把容器保存成了新镜像镜像会变得非常大那是另一个问题。5. 一些长期使用的经验这套环境我在飞牛NAS上跑了快半年说几个真实体会。第一Docker Compose文件本身要纳入版本管理。你可能会觉得一个compose文件而已没必要搞那么正式。但当你改了三次配置、忘了之前哪一次改动是什么原因之后你就明白把compose文件和配套的Nginx配置、php.ini、redis.conf都放进Git仓库有多舒服。我用的是飞牛NAS自带的Git功能每次改动提交一次出问题可以直接git diff看改动历史比反复试错高效太多。第二备份策略要早定。MySQL的数据目录可以直接在宿主机上打包备份tar -czf mysql_backup_$(date %Y%m%d).tar.gz /vol1/1000/docker/dev-env/mysql/dataRedis的数据目录也是一样的方式打包。如果你想要逻辑备份用mysqldump更稳可以定期用docker exec执行docker exec -it dev-mysql mysqldump -uroot -proot_password --all-databases /vol1/1000/docker/dev-env/backup/all_databases.sql备份文件不要放在容器数据目录里放在NAS的另一个存储池或者挂载的移动硬盘上这样即使整个docker目录损坏备份还在。第三在这套环境之上扩展其他服务非常顺滑。比如你想加一个Node.js服务只需要在compose文件里加一个node服务丢到同一个dev-netNginx配置里加一个location /api反向代理过去就完事了。这比在NAS上单独再装一套LAMP环境要清爽得多因为你永远只需要维护一份compose文件所有服务的启动、停止、重启逻辑完全一致。最后说一个习惯问题碰到页面报错先去查日志不要急着改代码。Nginx的错误日志在nginx/logs/error.logPHP的报错如果display_errors是开着的就直接看页面MySQL的日志直接看容器输出。把这几个日志位置在头脑里记住大部分问题都能在两分钟内定位。这套环境搭完之后飞牛NAS就不再只是个存储设备了它是一台能随时写代码、跑项目、测功能的小服务器。后续我会再写一篇在这个基础上加装Node服务、配置HTTPS证书、实现定时备份的文章到时候把这几个组件串起来讲一讲。

相关新闻

用通义灵码#gitCommit上下文,让AI自动生成规范Git提交信息

用通义灵码#gitCommit上下文,让AI自动生成规范Git提交信息

如果你也是那种每天被"写一句像样的提交说明"逼到抓耳挠腮的人,这篇文章就是为你准备的。我用通义灵码写了快一年的提交信息,真正改变我效率的,不是那些被传烂的万能提示词,而是一个极容易被忽略的内置上下文&#xff1…

2026/10/7 10:32:10 阅读更多 →
Windows服务高效管理:命令行操作与自动化实战指南

Windows服务高效管理:命令行操作与自动化实战指南

1. 为什么命令行使服务管理更高效1.1 图形界面之外的刚需场景Windows服务这玩意儿,几乎所有搞运维、搞开发的人都绕不开。平时你可以在services.msc里慢慢点,图形界面点到手疼也就管一台机器。可一旦到了服务器上,或者涉及几十台机器批量操作…

2026/10/7 10:32:10 阅读更多 →
基于Java与SQL Server的房屋中介管理系统设计与实现

基于Java与SQL Server的房屋中介管理系统设计与实现

简介:这是一套基于Java与SQL Server实现的房屋中介公司管理系统课程设计源码包,面向Java初学开发者、数据库课程设计及毕业设计学生。系统在Eclipse环境中开发,界面采用Swing库设计,通过JDBC接口访问SQL Server数据库,…

2026/10/7 10:32:10 阅读更多 →

最新新闻

嘉立创EDA的AI功能实测:智能生成、查错与自动布线效率提升指南

嘉立创EDA的AI功能实测:智能生成、查错与自动布线效率提升指南

1. 从一次画板子说起:嘉立创EDA的AI功能到底能干什么画PCB这件事,十年前我刚入行的时候,基本就是“手搓”两个字。原理图一笔一笔连,封装一个一个对,布线全靠经验和直觉,一块双层板磨两三天是常态。后来国产…

2026/10/7 11:14:00 阅读更多 →
phpstudy下MySQL服务启动失败?从日志到端口全排查指南

phpstudy下MySQL服务启动失败?从日志到端口全排查指南

phpstudy 里 MySQL 服务启动失败,这应该是国内 PHP 开发者本地环境里出现频率最高的问题之一。“MySQL服务无法启动”的提示,几乎每个用过 phpstudy 的人都被弹过,网上一搜同款问题能出来几十页。可真轮到自己处理时,很多人第一反…

2026/10/7 11:14:00 阅读更多 →
免费商务演示PPT视频素材全攻略:从选材到AI生成与高清导出

免费商务演示PPT视频素材全攻略:从选材到AI生成与高清导出

商务方案、技术培训、行业分享,这几年我经手的PPT少说也有几百份。最常遇到的状态不是“不会做”,而是“没素材”:方案写得很扎实,但打开第一页就透着一股敷衍感——背景是默认白色,排版是送风模板,整个电脑…

2026/10/7 11:14:00 阅读更多 →
AI Agent技能系统实战:从散装函数到可编排、稳定执行的技术路径

AI Agent技能系统实战:从散装函数到可编排、稳定执行的技术路径

做AI Agent开发的人,这两年应该都有一个共同的体感:模型能力越来越强,但真正让Agent“干活”的部分,反而越来越成为瓶颈。你给模型一堆工具函数,它调用起来总是差那么点意思——要么参数传错,要么流程走偏&…

2026/10/7 11:14:00 阅读更多 →
Agent-Reach:让智能体真正“够得着”真实世界的工程实践

Agent-Reach:让智能体真正“够得着”真实世界的工程实践

去年年底我在内部跑完一轮 Agent 项目复盘后,把整套方案单独抽出来,起名叫Agent-Reach。名字里的 Agent 不用解释,Reach 才是我想表达的东西——英文里 reach 是“触达、够得着”,但放到 AI Agent 的开发语境里,它其实…

2026/10/7 11:14:00 阅读更多 →
AWS 文档示例库中的 Amazon SES v2 优惠券周刊邮件场景:从联系人列表到模板化群发的完整实践指南

AWS 文档示例库中的 Amazon SES v2 优惠券周刊邮件场景:从联系人列表到模板化群发的完整实践指南

示例工程教程后端 【免费下载链接】aws-doc-sdk-examples Welcome to the AWS Code Examples Repository. This repo contains code examples used in the AWS documentation, AWS SDK Developer Guides, and more. For more information, see the Readme.md file below. 项目地…

2026/10/7 11:12:59 阅读更多 →

日新闻

ROS2机械臂仿真与运动控制:从URDF建模到Gazebo实战全解析

ROS2机械臂仿真与运动控制:从URDF建模到Gazebo实战全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/7 1:01:58 阅读更多 →
用浏览器直接改ESP32的WiFi密码:NVS键值配置工具设计与实现

用浏览器直接改ESP32的WiFi密码:NVS键值配置工具设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/7 1:02:00 阅读更多 →
芯片封装缺陷检测:扫描声学显微镜(SAT)原理与实操指南

芯片封装缺陷检测:扫描声学显微镜(SAT)原理与实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/7 1:02:00 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/6 7:15:40 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/6 5:29:09 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/7 9:29:10 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/6 8:21:32 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/6 4:21:51 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/6 1:18:13 阅读更多 →