.NET Core容器化部署实战:Docker集成MySQL与Nginx全攻略
去年年中帮一个朋友把他手上的 .NET Core Web API 项目做容器化改造时他问了我一个特别典型的问题为什么非得用 Docker 把 MySQL 和 Nginx 也一起装进去单独装个 MySQL、单独装个 Nginx再用 .NET 自带的 Kestrel 跑起来不也挺好吗这个问题其实代表了很多 .NET 开发者的真实困惑。今天这篇内容我就围绕“从0到1打造高性能 .NET Core 应用容器化部署MySQL 和 Nginx 无缝集成”这个实战项目把架构思路、镜像选型、三服务编排、常见排错、性能优化一整套流程完整拆开来讲。无论你是刚接触容器的 .NET 新手还是已经在生产环境摸爬滚打的老手这篇东西应该都能给你一些可落地的参考。这套方案的适用人群很明确手上有一个 .NET Core 应用Web API、MVC 都行希望用 Docker 部署到 Linux 服务器并且让 MySQL 数据库和 Nginx 反向代理跟应用本身形成一套可复制的标准环境。核心价值在于环境一致性、一键启动、水平扩展方便以及后续做 CI/CD 时的部署标准化。我下面说的每一步都是我实际验证过的方案不是纸上谈兵。1. 容器化部署的整体设计思路与架构拆解1.1 为什么选择容器化从一台裸机到三个容器的转变很多 .NET 开发者习惯了在 Windows Server 上装 IIS、装 SQL Server 的玩法切到 Linux Docker 之后最大的不习惯在于“应用和环境的关系”变了。以前部署是“准备一台机器装好运行时把文件发布上去”换机器就要重来一遍容器化之后应用连同它依赖的运行时、配置、环境变量、端口定义全部打包进镜像换任何一台装了 Docker 的机器都能跑出一模一样的结果。我们这个项目里最典型的依赖关系是这样的应用程序容器跑 .NET Core 的 Web API内部用 Kestrel 监听 8080不直接对宿主机暴露仅内部网络可见。MySQL 容器提供数据库服务数据目录必须挂载到宿主机卷否则容器删除数据就没了。Nginx 容器作为唯一对外的入口监听宿主机的 80/443 端口把请求反向代理到应用容器的 8080。这个架构的优势在于对外只有 Nginx 一个口子应用容器不直接暴露端口攻击面小了很多MySQL 的端口是否对外暴露由你决定生产环境通常不暴露只让应用容器通过 Docker 内部网络访问。这比我以前用裸机部署时“防火墙开一堆端口”的做法要干净得多。1.2 架构选型Docker Compose 还是 Kubernetes很多人一上来就问“要不要直接上 Kubernetes”我的建议是单机场景先老老实实用 Docker Compose。Kubernetes 解决的是多节点、自动伸缩、故障自愈的问题但它的学习成本、运维成本是实打实的。对一个单体 .NET Core 应用加 MySQL 加 Nginx 的组合Docker Compose 用一份 YAML 就能把三个服务定义清楚、一键拉起足够应付 90% 的初期需求。Docker Compose 的核心价值在于“编排”——它帮你管理三个容器之间的启动顺序、网络互通、卷挂载、环境变量传递。举个例子如果没有 Compose你要先启动 MySQL、等它初始化完成、再启动应用、再手动配 Nginx每一步都要人工确认有了 Compose用depends_on加健康检查就能把这一串动作自动化。等技术栈往后长到微服务规模多个应用互相调用、需要自动扩容的时候再考虑迁移到 Kubernetes 也不迟。实际上你为 Compose 写的 Dockerfile 在 Kubernetes 里是直接可用的只是从 Compose 编排变成了 Deployment/Service 编排迁移成本并没有想象中那么大。1.3 网络模型服务名通信与端口映射的取舍这里我要重点讲一下 Docker 网络因为“无缝集成”的底层逻辑就在这。Docker Compose 默认会创建一个 bridge 网络所有通过 Compose 启动的服务都加入这个网络彼此之间可以用服务名作为主机名互相访问。这个设计非常关键。意味着你连接 MySQL 时连接字符串不用写localhost或服务器的 IP而直接写Servermysql;Port3306;...其中mysql就是 Compose 里定义的服务名。同理Nginx 反代 .NET 应用时proxy_pass http://app:8080;里的app也是服务名。这套机制让三个容器之间的地址关系变成了“名称解析”配置写死也不怕内网 IP 变化。但要注意容器内部网络和宿主机网络是隔离的。只有显式声明了ports宿主机端口才会映射到容器端口。这个机制对多环境部署特别友好同一个 Compose 文件开发环境映射一串端口、生产环境只映射 80/443不用改代码。2. 环境准备与镜像选型实战2.1 .NET 镜像选择SDK 镜像与 Runtime 镜像的区别容器化 .NET Core 应用第一步就是选镜像。很多新手直接拉mcr.microsoft.com/dotnet/sdk来跑生产环境这是不对的。SDK 镜像是给构建用的包含编译器、开发工具体积大生产环境只需要aspnet运行时镜像里面已经带了 ASP.NET Core 运行时和 Kestrel 依赖。所以 .NET 的 Dockerfile 通常采用多阶段构建第一阶段用 SDK 镜像dotnet publish第二阶段把发布产物复制到aspnet运行时镜像。这样最终镜像只包含运行所需的文件通常比 SDK 镜像小一两百兆。选具体版本时我个人的原则是生产环境尽量用8.0或9.0的 LTS/STS 版本别追预览版。如果项目还在 .NET 6也不用急着升但要注意 .NET 6 已停止支持安全补丁不再更新建议尽早规划迁移。镜像 tag 别用latest要锁定具体版本号比如mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim避免某天拉下来一个莫名其妙的版本。2.2 MySQL 镜像版本选择5.7.44、8.0 还是 8.4 LTSMySQL 的版本选择是这次项目里争议最多的点。从热词里也能看出来很多人还在搜mysql 5.7.44 安装过程、mysql 5.7.26下载也有人问“5.7.44 之后为什么官方直接跳到 5.7.43 没有新版本了”。这个问题其实很简单MySQL 5.7 系列在 2023 年 10 月 EOL停止维护了5.7.44 就是 5.7 系列的最终版本。官方不会再给 5.7 出修 bug 的版本所以你在官方下载页看到 5.7 停在了 5.7.44。生产环境我通常建议直接上 MySQL 8.0因为 5.7 已经 EOL出安全漏洞没人管。如果你特别在意 LTS 版本可以选择 MySQL 8.4 LTS这是官方定义的长期支持版本支持周期到 2032 年。Docker 拉取就用mysql:8.0或者mysql:8.4直接指定大版本即可。但这里有个很现实的坑从 MySQL 5.7 升级到 8.0默认认证插件变了。8.0 默认用caching_sha2_password而很多老客户端和旧连接字符串用的是mysql_native_password会导致连接报错。我们的 .NET 应用一般用最新的 MySqlConnector支持新插件没问题但如果你的项目里还引用了旧版驱动可能需要在 MySQL 启动命令里加--default-authentication-pluginmysql_native_password来兼容。2.3 Nginx 镜像的选择与 alpine 版本的坑Nginx 镜像的官方版本主要有nginx基于 Debian和nginx:alpine基于 Alpine Linux。Alpine 体积小但是坑也多。很多人在热词里搜“nginx alpine 挂载 conf.d 报错”其实就是没搞懂 Alpine 的特殊性。Alpine 镜像里没有 bash用的是sh默认不具备完整的中文字体、glibc 环境如果你在配置里用了daemon off;以外的特殊启动方式或者需要用到某些需要 glibc 的编译模块就会出问题。我的建议是求稳就用 Debian 版nginx:stable想省体积再考虑 alpine。生产环境求稳偏差一点点体积的代价换稳定性值。另外Nginx 容器有个重要的行为默认以daemon off;前台方式运行这是 Docker 容器能够保持存活的前提。你在自己机器上习惯的nginx命令直接启动在容器里是不行的必须前台运行否则容器启动之后立刻退出。3. 核心配置Dockerfile、docker-compose.yml 与三服务联调3.1 .NET 应用 Dockerfile 多阶段构建直接给一个我实测可用的 Dockerfile 模板基于 .NET 8# 第一阶段构建 FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build WORKDIR /src # 先单独复制 csproj利用 Docker 层缓存避免每次改动代码都重新 restore COPY [MyApp.Api/MyApp.Api.csproj, MyApp.Api/] RUN dotnet restore MyApp.Api/MyApp.Api.csproj # 复制全部源码并发布 COPY . . WORKDIR /src/MyApp.Api RUN dotnet publish MyApp.Api.csproj -c Release -o /app/publish /p:UseAppHostfalse # 第二阶段运行 FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim AS final WORKDIR /app EXPOSE 8080 # 非 root 用户运行安全加固 USER $APP_UID COPY --frombuild /app/publish . ENTRYPOINT [dotnet, MyApp.Api.dll]这里有两个特别容易被忽略的细节/p:UseAppHostfalse是用于生成纯 DLL 而不是原生可执行文件在容器里跑更省资源。USER $APP_UID是 .NET 8 镜像内置的非 root 用户直接用它跑应用比默认 root 安全得多。老版本的镜像没有这个环境变量可以自行创建用户。构建命令很简单在项目根目录包含 .sln 的地方执行docker build -t myapp-api .。3.2 MySQL 容器初始化与数据持久化MySQL 容器最核心的是两件事初始化脚本和数据卷。第一次启动时MySQL 会自动执行/docker-entrypoint-initdb.d/目录下的.sql、.sh、.sql.gz文件。这个机制特别适合做“首次建库建表”。你把init.sql放到一个宿主机目录然后通过卷挂载进去volumes: - ./mysql/init:/docker-entrypoint-initdb.d - mysql_data:/var/lib/mysql第一行是初始化脚本第二行是数据持久化。数据卷mysql_data是 Docker 管理的卷容器删了数据还在。这里有个重要提醒初始化脚本只在数据卷为空时执行。如果你改动了init.sql但数据卷里已经有了数据这些改动不会生效。想重跑初始化必须把数据卷删掉再重新创建容器。MySQL 版本选择的时候还要注意时区问题。不少应用往数据库里存时间会出现 8 小时偏差就是因为 MySQL 容器默认使用了 UTC 时区。解决方案很简单在 Compose 环境变量里加TZ: Asia/Shanghai或启动参数加--default-time-zone08:00。3.3 Nginx 反向代理与配置文件挂载Nginx 在这套架构里的职责很纯粹接收外部 HTTP/HTTPS 请求转发给 .NET 应用容器。实际配置模板如下server { listen 80; server_name api.example.com; location / { proxy_pass http://app:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 60s; proxy_read_timeout 60s; proxy_send_timeout 60s; } # 大文件上传场景需要调大 body 大小限制 client_max_body_size 20m; }proxy_pass http://app:8080;里的app是应用服务在 Compose 网络中的名称Nginx 会自动解析这个主机名。注意Host、X-Real-IP、X-Forwarded-For这几个头部一定要转发过去否则 .NET 应用拿不到用户的真实 IP日志排查会很痛苦。有 HTTPS 需求时证书文件直接挂载进容器。我看到有人搜“nginx 替换 ssl 证书不生效”大概率是没重启容器或者只删了配置没删容器内的旧证书。Nginx 配置文件更新后必须执行docker compose exec nginx nginx -s reload或者docker compose restart nginx只改宿主机文件不重载配置是没用的。3.4 docker-compose.yml 完整编排与启动顺序控制下面是一份完整的docker-compose.yml这是整个项目的核心配置文件services: app: build: context: . dockerfile: MyApp.Api/Dockerfile container_name: myapp-api restart: unless-stopped environment: - ASPNETCORE_ENVIRONMENTProduction - ConnectionStrings__DefaultServermysql;Port3306;Databasemyapp;Userroot;PasswordYourPassword123;SslModeRequired; depends_on: mysql: condition: service_healthy networks: - app-net mysql: image: mysql:8.0 container_name: myapp-mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORDYourPassword123 - MYSQL_DATABASEmyapp - TZAsia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci volumes: - ./mysql/init:/docker-entrypoint-initdb.d - mysql_data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, localhost, -proot] interval: 10s timeout: 5s retries: 5 networks: - app-net nginx: image: nginx:stable container_name: myapp-nginx restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/ssl:/etc/nginx/ssl depends_on: - app networks: - app-net volumes: mysql_data: networks: app-net: driver: bridge启动顺序的坑重点看depends_on在这里的用法。仅仅用裸的depends_on只能保证容器启动顺序不保证 mysql 已经能接受连接。MySQL 容器起来后还要初始化数据库这个初始化可能要几十秒。所以我给 mysql 加了一个healthcheck然后应用层的depends_on写的是condition: service_healthy意思就是“等 MySQL 健康了再启动应用”。这个细节对“无缝集成”非常重要不加的话应用启动时极大概率连不上数据库报Unable to connect to any of the specified MySQL hosts。4. 无缝集成的关键细节连接字符串、环境变量与健康检查4.1 环境变量传递与连接字符串的坑.NET Core 的配置系统支持环境变量覆盖而且有一套非常特殊的命名规则环境变量中的双下划线__对应配置层级中的冒号:。所以ConnectionStrings__Default这个环境变量在 .NET 里读到的就是ConnectionStrings:Default也就是连接字符串。这个机制很多人第一次接触时都会懵因为 Linux 环境变量名里不允许有冒号而 .NET 配置键里普遍用冒号分层所以微软设计了双下划线的替代写法。理解了这一点你就知道为什么 Compose 里写ConnectionStrings__DefaultServermysql;Port3306;...而不用ConnectionStrings:Default。连接字符串本身还有个坑需要在 Server 处写明容器服务名不能写 localhost。因为应用跑在容器里local 指向的是它自己的容器而不是 MySQL 容器。这是我在实际项目里见过频率最高的集成错误。另外MySQL 8.0 默认启用了 SSL所以在连接字符串里通常要带SslModeRequired或Preferred。如果你看到报错SSL connection error优先检查这里。热词里也有“mysql ssl 连接错误”等下我在排错章节专门展开。4.2 健康检查配置与容器启动顺序容器编排里健康检查是个容易被忽视但极其重要的功能。Compose 的healthcheck定义了一个命令Docker 会定期去执行它根据返回码判断容器是否健康。状态从starting变到healthy之后其他服务才能安全地依赖它。MySQL 的健康检查最常用mysqladmin ping这个命令虽然没有真正做登录验证-p后的密码不对有时候也能返回 ping 成功但至少能证明 mysqld 进程活着并接受连接。对生产环境更严格的做法是用真实 SQL 探测healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1, -u$$MYSQL_USER, -p$$MYSQL_PASSWORD]注意这里$$是 Compose 的转义写法意思是把变量传给容器内执行而不是由 Compose 在宿主机先解析。.NET 应用的健康检查我建议也加上如果你在 Program.cs 里引入了 ASP.NET Core 自带的健康检查中间件可以用curl探测/health接口。但要注意容器里不一定有 curlDebian 的 .NET 镜像通常也没有预装所以要么在 Dockerfile 里装要么用 .NET 自带的/health加一个 TCP 探针。4.3 Nginx 与 .NET 应用配合的请求转发细节Nginx 反代 Kestrel 时最容易出的问题是HTTP/1.1 协议适配。Kestrel 默认启用了 HTTP/1.1如果 Nginx 转发时没设置proxy_http_version 1.1就可能出现upstream prematurely closed connection或 502 报错。这行配置我在模板里专门写了非常重要。另一个细节是 WebSocket 支持。如果 .NET 应用里有 SignalR 或原生 WebSocket 功能Nginx 必须额外配置升级请求头location /hubs/ { proxy_pass http://app:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; }不配这个SignalR 会连不上或频繁断开。这个场景我以前踩过印象很深前端页面能加载但所有推送消息都收不到一查日志全是 WebSocket 握手失败。还有响应大小限制。默认 Nginx 缓冲机制有时候会让 .NET 的 SSEServer-Sent Events流式响应变卡如果应用里有流式输出场景建议在 location 里加上关闭缓冲的配置避免 Nginx 因为缓冲导致 SSE 事件延迟推送。5. 常见问题与排错实录5.1 docker 安装 MySQL 失败的几个高频原因热词里有一项很扎眼“docker 安装 mysql 失败”。我在实际帮人排查时见过最多的三类原因端口冲突宿主机 3306 已经被本机装过的 MySQL 占了。报错信息通常是Error starting userland proxy: listen tcp4 0.0.0.0:3306: bind: address already in use。解决办法是把容器端口映射改成3307:3306或者把宿主机原 MySQL 先停掉。数据卷残留之前初始化失败留下了脏数据卷新容器启动时把 MySQL 放到旧数据目录上进行不了初始化。报错里会出现[ERROR] --initialize specified but the data directory has files in it。解决方法是删掉旧卷docker volume rm 项目名_mysql_data再重新启动。权限问题如果直接把数据目录挂载到宿主机某个普通目录而不是用 Docker 命名卷很容易遇到 MySQL 无法写入数据文件的权限问题。因为容器内 MySQL 默认以mysql用户运行但宿主机目录的 owner 是 root。解决办法是给目录授权chown -R 999:999 ./mysql/data或者干脆用 Docker 命名卷省事。5.2 Nginx 证书与 HTTPS 排错Nginx 这块的高频问题热词里也很明显“nginx 替换 ssl 证书不生效”、“nginx 转发https 反向代理 net::err_cert_common_name_invalid”。先说证书替换不生效。我排查过好几个案例结论几乎一致宿主机文件更新了但容器里没更新。因为 Nginx 容器是通过卷挂载读证书的所以宿主机文件更新后容器里其实已经是新证书了但 Nginx 进程还保持着旧证书的缓存。你必须在宿主机修改证书后执行重载docker compose exec nginx nginx -t docker compose exec nginx nginx -s reload第二个问题net::err_cert_common_name_invalid这个错误是证书域名不匹配。常见原因有两个一是证书是给abc.example.com签的但你用 IP 地址或localhost访问二是证书只包含主域名不包含www子域。解决办法是确保访问域名和证书的 Common Name / SAN 完全一致。如果只是本地测试可以配置 Nginx 时把server_name写成访问时用的 IP但浏览器依然会报警因为 IP 地址证书本身几乎不会用于生产。热词里还有“nginx mirror 超时时间”这个是指mirror模块或者镜像流量复制时的上游超时。如果你开了mirror上游镜像接口响应慢会影响主请求吗实际上 Nginx 的mirror是异步的但mirror_request_body的发送和响应读取有自己的超时配置。一般排查思路是在 mirror 的 location 里单独设置proxy_read_timeout、proxy_connect_timeout避免镜像请求拖垮主链路。5.3 MySQL 连接报错与性能调优连接报错这块大家在热词里搜得最多的就是“mysql ssl 连接错误”。这个错误在 .NET 场景下有几种来源连接字符串没带SslMode而 MySQL 8.0 默认要求 SSL。报错像The host localhost does not support SSL connections。解决方法是连接字符串里显式写SslModeRequired或Preferred。证书校验失败。某些内网环境 MySQL 的 SSL 证书是自签名的.NET 的 SSL 校验会失败。这种情况可以把SslMode设为Required不校验证书但加密传输不要设成VerifyFull。老驱动不支持新认证插件。如果用的还是旧版MySql.Data4.x 或更早升级到 8.0 服务端时会报Authentication method caching_sha2_password not supported。解决办法是升级驱动到 MySqlConnector 或新版MySql.Data不建议改服务端认证插件因为那会降低安全性。MySQL 性能调优很多人问锁分类。锁这个东西说实话是个大话题但容器化部署中你先要关心的不是行锁、间隙锁的细节而是InnoDB 缓冲池大小。默认 MySQL 容器的innodb_buffer_pool_size是 128M对生产应用来说太小了。调整方式是在 Compose 的command里加参数command: - --innodb_buffer_pool_size1G - --max_connections500这个参数决定了 InnoDB 在内存里缓存多少数据页和索引页。命中率高了磁盘 IO 就少了。我习惯用SHOW GLOBAL STATUS LIKE Innodb_buffer_pool_read_requests;和..._reads对比计算命中率低于 99% 就该加 buffer pool 了。5.4 其他现场问题速查表下面整理一下这个项目里大家最容易遇到的一批高频问题和直接处理办法都是我在实际操作中总结出来的现象可能原因解决方案应用启动报Unable to connect to any of the specified MySQL hostsMySQL 还没初始化完成应用就开始连接用depends_on: condition: service_healthy访问站点返回 502 Bad GatewayNginx 解析不到app:8080或应用崩溃在 Compose 网络内docker compose exec nginx ping app测试连通性再看应用日志MySQL 容器启动后立刻退出数据目录权限问题或旧数据残留删卷重来或chown -R 999:999上传大文件提示 413Nginx 默认client_max_body_size 1m在http/server/location配大一点修改代码后镜像还是旧版本没重新构建直接用了旧的 tagdocker compose build --no-cache或确认 tag 变化容器内时间差了 8 小时镜像默认 UTC 时区Compose 里加TZ: Asia/ShanghaiNginx 配置文件改完没生效只改了宿主机文件没重载docker compose exec nginx nginx -s reload应用日志看不到用户真实 IPNginx 没转发X-Forwarded-For按前面模板配好 proxy_set_header最后补一个很多人在搜的开发环境问题“本地虚拟机多端口 nginx 开发环境多站点自定义域名配置”。这个场景的实质是你在一台虚拟机里跑了多个站点想用site1.dev.com、site2.dev.com这样的域名访问不同端口。做法是给每个站点写一个 server 块各自server_name不同listen 80一样Nginx 根据域名路由到不同的proxy_pass。宿主机上再把域名映射到虚拟机 IP修改C:\Windows\System32\drivers\etc\hosts或 Linux 下的/etc/hosts。这套玩法对本地联调非常实用配合容器化也是完全可行的只要把每个站点映射到不同宿主机端口即可。6. 性能优化与安全加固6.1 镜像瘦身与启动加速.NET 应用容器化之后很多人发现镜像比较大可能 200MB启动也慢。这里有两个方向可以优化。第一是Dockerfile 层缓存。我平时写 Dockerfile 时会把COPY csproj和RUN dotnet restore放在复制全部源码之前这样只要依赖没变dotnet restore层就会被缓存改代码后重新构建基本秒级完成。这个习惯在本地开发调试时效率提升非常明显。第二是裁剪发布。.NET 8 支持PublishTrimmed可以把未使用的代码从托管程序集中移除减小体积dotnet publish -r linux-x64 -c Release -p:PublishTrimmedtrue -p:SelfContainedfalse但要注意PublishTrimmed可能引发反射相关的运行时错误因为裁剪器无法静态分析所有反射调用。有使用反射、动态加载程序集的场景需要先用测试集充分验证。生产环境求稳我建议保守一点用 ReadyToRun 镜像编译优化启动速度而不是贸然做很激进的裁剪。6.2 MySQL 参数调整与 Nginx 性能配置MySQL 容器化后性能瓶颈常常在默认配置和宿主机资源之间不匹配。除了前面说的innodb_buffer_pool_size还有两个经常要调的max_connections默认 151稍微有点并发就报Too many connections。但注意这个参数不是越大越好每个连接都有内存开销设太大反而会导致内存暴涨。innodb_log_file_size默认 48MB写入日志频繁时性能会受限可以试试 256MB 或 512MB。Nginx 那边性能优化的核心是worker_processes和worker_connections。默认配置只启动了 1 个 worker 进程。生产环境通常设置为worker_processes auto;让它根据 CPU 核心数自动生成 worker然后worker_connections调大以支持更高并发。同时开启gzip压缩对 .NET 应用返回的 JSON 数据有立竿见影的减负效果gzip on; gzip_types application/json application/javascript text/css text/xml; gzip_min_length 1k;6.3 最小权限与安全配置安全这个环节很多人不重视但容器化架构里一旦出错影响范围是全局的。三个容器分别说应用容器用非 root 用户运行前面 Dockerfile 里的USER $APP_UID不要跑 root。环境变量里别写死生产密码用 Docker secret 或外部配置源管理。MySQL 容器不要用MYSQL_ROOT_PASSWORD作为日常连接账号。正确做法是启动时只用 root 建库建用户然后在初始化脚本里创建专用账号并授予最小权限。类似CREATE USER app_user% IDENTIFIED BY SafePassword123; GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO app_user%; FLUSH PRIVILEGES;另外生产环境不要把 MySQL 3306 端口映射到宿主机 0.0.0.0只在 Compose 内部网络里给应用访问即可。如果确实需要外部连用 SSH 隧道而不是直接暴露端口。Nginx 容器注意 TLS 版本配置最低 TLSv1.2禁用 TLSv1.0/1.1。安全相关的响应头也要加比如X-Content-Type-Options: nosniff、X-Frame-Options: SAMEORIGIN、Strict-Transport-Security如果全 HTTPS。关于热词里提到的“nginx 代理 ollama 设置 apikey”这个玩法本质上是把 Nginx 当作 API 网关在反代层统一注入鉴权头。原理跟反代 .NET 应用完全一样只是proxy_set_header Authorization Bearer xxx或proxy_set_header X-API-Key xxx然后转发给上游服务。这套模式适合给内部 AI 服务做统一出入口但要注意 key 放在 Nginx 配置里属于明文存储生产环境还是建议用更正规的密钥管理方式。最后再分享一个真实感受这套“三容器”架构跑稳定之后最大的红利是部署动作变成了一条命令。本地开发时一条docker compose up -d生产发布时把镜像推到仓库、服务器上拉镜像再重启整个生命周期干净利落。我在实际运维中遇到的大部分事故仔细回想都是从“手工改服务器环境”开始的——而容器化会把这类不确定性降到很低。如果团队正在做 CI/CD 改造我强烈建议把应用发布的 Dockerfile 作为标准化第一步后面的流水线都建立在这层基础上。