
上个月我把一个正式运行的 .NET Core 8 项目从传统的 Windows 服务 IIS 部署方式整体迁移到了 Docker Compose MySQL Nginx 的容器化方案。迁移过程不算复杂但细节非常磨人尤其是容器网络里 MySQL 主机名怎么填、Nginx 怎么准确转发到 .NET Core 进程、三个容器之间怎么按顺序健康地启动任何一个环节没弄对部署出来就是连环的 502、连不上数据库、证书不生效。这篇我来把自己从 0 到 1 的完整过程写下来包括最终能直接复制的配置文件、每一步选型背后的理由以及我实际踩过并解决的坑。先把方案说清楚。这套结构就是.NET Core 提供业务 APIMySQL 负责持久化Nginx 在前面做反向代理。三件事各司其职容器化之后环境完全隔离代码从开发机到测试服务器再到生产服务器不用再担心服务器上装了不同的 .NET SDK、MySQL 版本、配置文件位置不一样。如果你是第一次做 .NET Core 容器化这篇文章完全可以照着操作一遍。1. 整体方案设计为什么非要用容器化以及三个组件的分工1.1 容器化到底解决了什么问题以前部署 .NET Core 应用最痛苦的地方不是代码写不出来而是每台服务器都是一个“独一无二”的环境。这台机器装了 .NET 6那台机器装了 8.0还有一台既有 MySQL 5.7 又有 8.0 的残留端口被占系统 PATH 找不到 dotnet 命令。每次上线运维那边都要对着 checklist 挨个确认环境时间全耗在“环境打架”上。容器化相当于给每个服务都装进一个集装箱应用代码、运行环境、依赖库、配置文件全部打成镜像启动时就是一个独立进程。Docker 负责调度端口和资源容器内部用固定端口外部映射随意彻底绕开了服务器上“到底装了什么版本”的诅咒。更关键的是可重复性同一个镜像在任何装了 Docker 的机器上跑出来结果都一样这比在每台机器上手搓环境可靠得多。对我这个项目来说还有一个实际收益以前 Nginx、MySQL、.NET Core 进程是分散部署的排查问题要在三个不同目录里翻日志。容器化之后一条 docker compose logs 命令就把三个服务的日志全部收拢用 docker compose ps 一眼看出谁活着谁挂了排障效率完全不是一个量级。1.2 三个核心组件的职责边界.NET Core 应用容器负责业务逻辑和 API 响应对外只暴露一个内部端口我用的是 8080。注意容器内部不要直接用 80因为非 root 用户很可能没有权限监听 80而且容器内 80 通常会被 Nginx 占用语义混淆。MySQL 容器负责所有关系型数据存储初始化建库、建表、账号都通过挂载 SQL 脚本自动完成。数据目录必须挂到具名 volume否则容器一删数据全丢。Nginx 容器负责客户端入口统一接收 HTTP/HTTPS 请求然后按规则转发到 .NET Core 容器。它还承担静态文件服务、请求头改写、连接超时、缓存压缩这些“网关型”工作。三者通过 Docker Compose 自动创建的 bridge 网络app-net互联。在同一个网络里服务名就是域名例如 .NET Core 访问数据库时主机名直接写mysql而不是127.0.0.1这一点刚接触容器的人非常容易掉坑。1.3 为什么用 Docker Compose而不是直接上 Kubernetes有些朋友看到“生产部署”就下意识想到 Kubernetes但说实话对于中小型项目、一台服务器、三四个后台服务的规模Kubernetes 带来的复杂度远超收益。Kubernetes 解决的是大规模、多节点、弹性伸缩的问题代价是引入 etcd、kubelet、控制器等一系列新层。单机环境下用 Docker Compose 编排这三个容器启动快、心智负担低、日志和监控都能简单处理已经能把 90% 的运维痛点解决掉。如果将来流量涨到需要多机部署从 Docker Compose 平滑迁移到 Kubernetes 也不是推倒重来因为 Dockerfile、应用配置、Nginx 配置文件这些核心资产都还在只是把编排格式从 compose 改成 Kubernetes 清单而已。所以我的建议是先 Compose 跑透不要一开始就上重型调度系统。2. 环境准备与镜像版本选型最容易翻车的地方全在里面2.1 Docker 安装与基础配置机器上先装 Docker。Linux 环境可以直接用官方脚本安装或者通过系统包管理器安装 docker-ce。安装完成后有几个细节我建议立刻处理把当前用户加入docker组否则每次执行 docker 命令都要加 sudo。运行sudo usermod -aG docker $USER重新登录生效。配置 Docker 数据目录到独立的数据盘或者有充足空间的目录默认装在某些系统盘上跑几个月容器日志可能会把盘撑满。设置镜像加速器。国内环境直接拉取mcr.microsoft.com、docker.io有时候会非常慢配置registry-mirrors之后明显改善。不过生产环境建议用信任的公共镜像源或者局域网内自建镜像仓库。装好之后验证命令docker info和docker compose version确保 Compose 插件可正常调用。这里多说一句很多人把 Docker 安装好就立刻开始拉镜像忽略了 Docker 的存储驱动和日志驱动配置。我用的是默认overlay2一般没问题但建议在/etc/docker/daemon.json里加上日志截断{ log-driver: json-file, log-opts: { max-size: 20m, max-file: 5 } }不限制容器日志大小的话一个写日志较猛的服务几天就能把磁盘写满这是容器运维里非常经典的隐蔽事故。2.2 .NET Core 镜像选择SDK 和 Runtime 必须分开写 Dockerfile 时我强烈建议用多阶段构建先用 SDK 镜像编译再用 Runtime 镜像运行。理由很简单SDK 镜像体积通常是 Runtime 的好几倍还包含编译器、NuGet 缓存这些运行时完全不需要的垃圾。如果直接把 SDK 镜像当作运行容器镜像体积会非常大安全漏洞面也更大。我项目用的是 .NET 8所以选用编译阶段mcr.microsoft.com/dotnet/sdk:8.0-alpine运行阶段mcr.microsoft.com/dotnet/aspnet:8.0-alpine注意必须选aspnet而不是runtime。aspnet镜像里面额外包含了 ASP.NET Core 运行时、托管组件和常见 native 依赖你的 Web API 项目一定要用aspnet。项目如果还有其他 native 依赖比如某些加密库、图形库可能还得选带特定包的镜像这里不展开。2.3 MySQL 镜像版本与初始化方案MySQL 我选了官方mysql:8.0.37。为什么不选 5.7一方面 .NET Core 的连接器MySqlConnector/Pomelo对 8.0 支持更完整另一方面 MySQL 5.7 已经 EOL安全风险不可控。还有人问我为什么不用 MariaDB右边也没问题但我项目依赖了 MySQL 8.0 的一些 JSON 特性和窗口函数兼容起见保持官方 MySQL。使用官方 MySQL 容器有几个关键环境变量MYSQL_ROOT_PASSWORD设置 root 账号密码。MYSQL_DATABASE首次启动自动创建的数据库。MYSQL_USER/MYSQL_PASSWORD创建一个业务专用账号避免应用用 root 连库。初始化脚本放在/docker-entrypoint-initdb.d目录下容器首次启动时自动按文件名字母顺序执行。利用这一点我可以在第一次启动时就建好表结构、预置基础数据省去部署后手动跑 SQL 的麻烦。需要注意初始化脚本只在数据目录为空时执行。如果你的 MySQL 容器已经跑过一次即使改了 init 脚本重新启动也不会自动执行因为数据目录已经初始化过了。想要重新初始化要么删除 volume会丢数据要么手动进去执行 SQL。2.4 Nginx 镜像与配置挂载方式Nginx 我选择nginx:1.27-alpine。alpine 版本体积小、构建快需要的依赖少对生产环境足够。默认官方镜像已经把 Nginx 的可执行文件和默认配置打包好我们的核心工作是把它读配置的目录挂进去/etc/nginx/conf.d挂载站点配置。/etc/nginx/nginx.conf挂载主配置如果你需要自定义 worker 进程数、gzip 级别。证书目录挂载 SSL 证书注意权限要正确。挂载配置文件时我用:ro只读模式防止容器内被意外修改也避免宿主机和容器对同一文件的写权限冲突。Nginx 配置文件一旦变更执行docker exec nginx nginx -t校验语法再docker exec nginx nginx -s reload平滑重载。不需要重启容器因为容器只是跑 Nginx 主进程reload 足够让新配置生效。3. 核心配置文件编写每条参数是什么为什么这么设3.1 Dockerfile多阶段构建的完整写法下面是我的 .NET Core API 项目的 Dockerfile直接放在 API 项目目录下或者放在解决方案根目录用上下文路径控制# 编译阶段 FROM mcr.microsoft.com/dotnet/sdk:8.0-alpine AS build WORKDIR /src # 先只复制 csproj利用 Docker 缓存避免每次修改源码都重新 restore COPY [src/MyApp.Api/MyApp.Api.csproj, src/MyApp.Api/] RUN dotnet restore src/MyApp.Api/MyApp.Api.csproj # 再复制全部源码编译发布 COPY . . WORKDIR /src/src/MyApp.Api RUN dotnet publish MyApp.Api.csproj -c Release -o /app/publish /p:UseAppHostfalse # 运行阶段 FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine AS final WORKDIR /app EXPOSE 8080 # 非 root 用户运行提升安全性 USER app # 时区设为上海否则容器默认 UTC日志时间会差 8 小时 ENV TZAsia/Shanghai COPY --frombuild /app/publish . ENTRYPOINT [dotnet, MyApp.Api.dll]有几个细节值得展开UseAppHostfalse的作用是禁止生成 native apphost 可执行文件直接生成 DLL。这样在容器里用dotnet MyApp.Api.dll启动更稳也能避免个别 Alpine 环境下 apphost 缺少 glibc 的可执行异常。先复制 csproj 再 restore之后再复制所有源码是充分利用 Docker 层缓存。如果你的依赖没变化后续构建可以跳过 restore 过程构建速度提升明显。USER app这一步非常重要。官方 aspnet 镜像默认已经创建了无权限的 app 用户用它运行应用攻击者即使拿到代码执行权限也很难直接改系统和篡改容器文件。3.2 docker-compose.yml三容器编排与健康检查Compose 文件是全家桶的核心。里面每一项配置都有意义name: myapp-stack services: nginx: image: nginx:1.27-alpine container_name: nginx ports: - 80:80 - 443:443 volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/conf.d:/etc/nginx/conf.d:ro - ./cert:/etc/nginx/cert:ro - ./wwwroot:/usr/share/nginx/html:ro depends_on: - api networks: - app-net restart: unless-stopped api: build: context: . dockerfile: src/MyApp.Api/Dockerfile image: myapp-api:1.0.0 container_name: api environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://:8080 TZ: Asia/Shanghai expose: - 8080 depends_on: mysql: condition: service_healthy networks: - app-net restart: unless-stopped deploy: resources: limits: cpus: 1.0 memory: 512M mysql: image: mysql:8.0.37 container_name: mysql ports: - 3306:3306 environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: ${MYSQL_DATABASE:-myapp_db} MYSQL_USER: ${MYSQL_USER:-myapp_user} MYSQL_PASSWORD: ${MYSQL_PASSWORD} TZ: Asia/Shanghai command: - --character-set-serverutf8mb4 - --collation-serverutf8mb4_unicode_ci volumes: - ./mysql/init:/docker-entrypoint-initdb.d:ro - mysql_data:/var/lib/mysql healthcheck: test: [CMD, mysqladmin, ping, -h, 127.0.0.1, -u, root, -p$$MYSQL_ROOT_PASSWORD] interval: 10s timeout: 5s retries: 5 start_period: 30s networks: - app-net restart: unless-stopped networks: app-net: driver: bridge volumes: mysql_data:重点说三个原因depends_on不是简单的“排序”在 Compose 2.x 中depends_on只控制容器的启动顺序但不会等待容器真正“就绪”。我以前看过很多配置里写了depends_on: mysql就直接连接数据库然后 MySQL 还在初始化API 启动就连不上。解决方案就是给 MySQL 加healthcheck然后在 API 的depends_on里指定condition: service_healthy让 API 等 MySQL 真正能接受连接后再启动。MySQL 的healthcheck利用容器内自带的mysqladmin ping。注意配置文件里写了-p$$MYSQL_ROOT_PASSWORD这里的$$是 Compose 转义符让它把字符串传给容器后由容器的 shell 解释成环境变量。如果只写一个$MYSQL_ROOT_PASSWORDCompose 会在宿主机上先找这个变量通常为空导致探测失败。API 容器不对外暴露8080端口到宿主机只暴露3306。生产环境里 API 完全不需要被外部直连所有外部流量都走 Nginx这样攻击面更小。用expose而不是ports只让同一网络中的服务访问。3.3 Nginx 反向代理配置从端口转发到站点隔离我把主配置和站点配置分开。nginx.conf里做全局调优user nginx; worker_processes auto; worker_rlimit_nofile 65535; events { worker_connections 4096; } http { log_format main $remote_addr - $remote_user [$time_local] $request $status $body_bytes_sent $http_referer $http_user_agent $http_x_forwarded_for; access_log /var/log/nginx/access.log main; error_log /var/log/nginx/error.log warn; include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on; tcp_nopush on; keepalive_timeout 65; gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svgxml; gzip_vary on; include /etc/nginx/conf.d/*.conf; }站点配置放在conf.d/api.conf里upstream myapp_api { server api:8080; keepalive 32; } server { listen 80; server_name api.example.com; location / { proxy_pass http://myapp_api; 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 120s; } # 静态资源缓存 location ~* \.(js|css|png|jpg|svg|ico)$ { root /usr/share/nginx/html; expires 7d; add_header Cache-Control public, max-age604800; } }注意到upstream里写的是server api:8080这里的关键点是 API 容器在同一个 Docker 网络中的服务名就是api。你不需要去查容器 IP只要容器名或服务名在同一个 network 里Nginx 会自动解析这个名称。keepalive 32是给 Nginx 和 .NET Core 之间的后端连接做复用避免每个请求都新建 TCP 连接。.NET Core 的 Kestrel 默认支持 HTTP/1.1 keep-alive这样可以减少大量 TIME_WAIT对高并发场景性能提升很明显。3.4 MySQL 初始化脚本与 .NET 连接字符串初始化脚本我只放一个init.sql内容如下CREATE DATABASE IF NOT EXISTS myapp_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE myapp_db; CREATE TABLE IF NOT EXISTS users ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(64) NOT NULL, email VARCHAR(128) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_email (email) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;MySQL 8.0 默认字符集虽然是 utf8mb4但为了和表、字段、连接全部统一我仍然显式指定。原因很简单如果连接字符串或者表定义里出现utf8那么 emoji 和中文特殊字符在某些场景下会变成乱码或报 “Incorrect string value”。utf8mb4才是真正的四字节 UTF-8这个坑踩一次就记住了。.NET Core 里的appsettings.Production.json连接字符串{ ConnectionStrings: { Default: Servermysql;Port3306;Databasemyapp_db;User Idmyapp_user;Passwordmyapp_pass_2024;CharSetutf8mb4;SslModePreferred;Connection Timeout30;Maximum Pool Size200; } }这里的Servermysql同样依赖容器网络。如果你在宿主机本地调试这里要改成127.0.0.1因为你的 .NET 进程不在 Docker 网络内解析不了mysql这个名称。这也是很多人本地跑得通、容器里跑不通的第一个坑。4. 一键部署实操过程与验证4.1 镜像构建与容器启动流程配置文件准备完毕部署就一条命令docker compose up -d --build-d表示后台运行--build会让 Compose 重新构建那些指定了build的镜像本项目就是 API。命令执行时Compose 会按依赖顺序先启动 MySQL再等待健康检查通过然后启动 API最后启动 Nginx。整个过程可以在另一个终端观察docker compose logs -f看到 mysql 容器出现类似mysqld: ready for connections日志时说明数据库已经就绪。API 日志里出现Now listening on: http://[::]:8080说明 Kestrel 已启动。Nginx 日志里没有报错整个栈就基本起来了。查看状态docker compose psfriendly name列会显示每个容器的状态Up代表正在运行。如果发现有容器反复重启多半是健康检查没过需要用docker compose logs 容器名看具体输出。4.2 验证三层服务的连通性部署完成不等于服务可用我建议按底层到上层的顺序逐步验证验证 MySQL 是否可以从宿主机连通mysql -h 127.0.0.1 -P 3306 -u myapp_user -p myapp_db -e SELECT VERSION();这里输入的是用MYSQL_USER创建的业务账号密码不是 root。如果连不上先看宿主机防火墙是否开放 3306再看 MySQL 容器是否真的启动了。验证 API 是否可以从容器网络内部访问。最简单的方法是直接进入 Nginx 容器测试解析docker exec nginx curl http://api:8080/health如果返回 200说明 Nginx 能正确解析api主机名并访问到 .NET Core 应用。验证 Nginx 对外入口curl -H Host: api.example.com http://127.0.0.1/api/ping也可以先用/etc/hosts把api.example.com指向本机再直接用域名访问效果等同。4.3 开发环境与生产环境的多站点扩展实际开发中一台服务器可能要同时跑多个 .NET Core 站点每个站点不同域名。这个方案天然支持只要在conf.d目录下多加一个配置文件新增一个server块然后给新的 API 服务也添加一个 Compose service或者在同一个upstream里按路由分流即可。例如新增第二个站点api2.example.comserver { listen 80; server_name api2.example.com; location / { proxy_pass http://api2:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }然后在docker-compose.yml里增加api2服务加入同一个app-net网络即可。只要端口不冲突多站点随意扩展。本地开发时我还会把多个域名映射到127.0.0.1127.0.0.1 api.local 127.0.0.1 admin.local这样本地 Nginx 容器可以直接按server_name分发到不同站点不需要每次切换端口。5. 常见问题与排查技巧实录5.1 数据库连接失败主机名写错、认证插件不兼容这个坑我见得太多了。症状是 API 启动日志直接报MySqlConnector: Authentication method caching_sha2_password failed.原因通常是 MySQL 8.0 默认的caching_sha2_password认证插件与某些旧版本客户端不兼容。解决方案有两个一是检查你的 .NET 连接器版本Pomelo.EntityFrameworkCore.MySql 6.0 或 MySqlConnector 2.0 已经支持该插件升级包即可解决二是在 MySQL 里把用户改成mysql_native_password但不建议长期这样做因为这个插件本身已经废弃。还有更常见的连接字符串写成Server127.0.0.1。在容器内 127.0.0.1 指向 MySQL 容器自身而不是宿主机所以 API 容器内根本连不上。要写服务名mysql或者通过 Compose 定义的网络别名。5.2 Nginx 返回 502 Bad Gateway 的排查路径Nginx 502 的意思是它连不上上游的 API 服务。排查顺序先看 API 容器是否在运行docker compose ps api。进入 Nginx 容器手动探测docker exec nginx curl http://api:8080/health。如果返回Could not resolve host api说明两个容器不在同一个网络或者upstream里的服务名拼写错了。看 Nginx 错误日志docker logs nginx。有时候日志会显示connect() failed (111: Connection refused)说明 API 端口没监听。检查 API 容器内 ASPNETCORE_URLS 是否监听了http://:8080或者 API 项目有没有设置urls。看 API 容器日志docker compose logs api。如果 API 容器一直崩溃Nginx 当然 502。5.3 Nginx 替换 SSL 证书后不生效的典型原因生产环境用 HTTPS 时换证书是家常便饭。经常有人改了服务器上的证书文件reload Nginx 之后却还是旧证书。我遇到的场景是证书文件没有挂载进容器reload 的是容器里旧文件。证书文件权限不对Nginx 主进程可能以nginx用户运行证书目录如果权限是 700 或者属主不是 nginx就会读不到新证书。证书链不完整很多人只替换了域名证书没有把中间证书一并合入 fullchain.pem导致浏览器报证书链错误。reload 之前忘了执行nginx -t配置里有语法错误Nginx 不加载新配置。正确操作顺序是# 在宿主机更新证书后 docker exec nginx nginx -t docker exec nginx nginx -s reload如果用的是挂载目录./cert:/etc/nginx/cert:ro宿主机更新文件后容器内马上能看到不需要重启容器。SSL 配置里也要注意浏览器常见的net::ERR_CERT_COMMON_NAME_INVALID往往是因为证书签发的域名和你访问的域名不一致或者证书的 SAN 列表里没有当前域名。换证书之前先确认server_name和证书域名完全匹配别在配置层面白忙活。5.4 MySQL 字符集、时区和数据持久化表现不一致容器跑起来之后数据库数据存进去了但程序查出来中文乱码第一反应是 MySQL 字符集不对。检查当前连接和环境SHOW VARIABLES LIKE character_set_server; SHOW VARIABLES LIKE collation_server;如果结果不是 utf8mb4 和 utf8mb4_unicode_ci说明容器启动命令里的--character-set-server没有生效大概率是 Compose 里的command配置被覆盖了或者在初始化脚本里重新指定了数据库字符集。时区问题也需要注意。默认 MySQL 用的是 UTC查询NOW()会跟北京时间差 8 小时。解决方案是在environment里设置TZ: Asia/Shanghai同时建议在连接字符串里不要依赖 MySQL 服务器时间程序里统一用DateTimeOffset存储时间这样无论服务器时区怎么变业务逻辑都不会出错。数据持久化方面如果使用 bind mount 而不是具名 volume可能会遇到宿主机目录权限导致 MySQL 启动失败。尤其 SELinux 开启的系统上挂载目录可能会被容器视为不可写。使用具名 volume 可以省掉很多权限问题但备份就相对麻烦一些需要额外做docker run --rm -v some_volume:/source -v $(pwd):/backup alpine tar之类的操作。5.5 Docker 安装 MySQL 失败专项排查热词里反复出现docker安装mysql失败单独拿出来说一说。常见的失败原因有失败现象可能原因快速解决方案no matching manifest for linux/arm64/v8 in the manifest list entries架构不匹配例如 Apple M 系列或 ARM 服务器拉了 x86 镜像改用mysql:8.0.37官方多架构镜像或使用--platform linux/arm64Error response from daemon: driver failed programming external connectivity宿主机 3306 端口被已有 MySQL 或服务占用ss -lntp查看占用然后改映射端口或停掉旧服务mysqld: Cant create/write to file ... Permission denied数据目录没有写入权限确认 volume 目录权限、SELinux 上下文容器反复重启环境变量缺失或者初始化脚本异常看docker compose logs mysql重点关注密码策略和 init 脚本 SQL 错误最容易被忽略的是MYSQL_ROOT_PASSWORD和MYSQL_PASSWORD是否为空。如果 root 密码为空Docker 容器会在日志中拒绝启动因为官方镜像明确禁止使用空 root 密码。这是 MySQL 官方镜像的一个安全设计。6. 生产级优化与安全加固建议6.1 镜像瘦身与最小权限用 Alpine 镜像能明显减少体积但要注意 Alpine 使用 musl个别 .NET 原生依赖可能不兼容。如果应用里引用了需要 libstdc 或 glibc 的 native 库建议还是用 Ubuntu 镜像例如mcr.microsoft.com/dotnet/aspnet:8.0-jammy宁可体积大一点保证稳定性。不管用哪个基础镜像一定改成非 root 运行。.NET 官方的 aspnet 镜像已经提供app用户直接在 Dockerfile 里USER app即可。如果用自定义基础镜像可以自己创建用户RUN addgroup -S app adduser -S app -G app USER app同时给最终镜像设置只读文件系统。在 compose 里可以给容器的 rootfs 加只读标记read_only: true tmpfs: - /tmp这个配置在生产上能有效阻止攻击者在容器内写入持久化文件但需要确认应用没有在容器内写文件。.NET Core 写日志时如果写到/app/logs就要改成挂载卷或者输出到 stdout。6.2 性能参数建议Nginxworker_processes auto自动匹配 CPU 核数worker_connections 4096是每个 worker 的最大连接数。通常worker_connections * worker_processes只要大于预估峰值并发就可以了不需要盲目调大。MySQLinnodb_buffer_pool_size建议设置为核心内存的 60% 左右。16G 内存的服务器可以给到 10G4G 内存就给 2G 左右。不要超过物理内存的 70%否则操作系统会频繁换页反而更慢。.NET CoreKestrel 已经非常强不需要额外调线程池参数但建议在 API 容器里设置DOTNET_gcServer1启用服务器模式 GC对高并发 Web 请求更友好。也可以参考environment: DOTNET_gcServer: 1 DOTNET_GCHeapHardLimit: 0x20000000 # 500MB 限制不过这部分要结合你的实际负载来压测不能套一个想当然的值。6.3 备份、日志与健康检查生产环境必须有备份策略。MySQL 容器千万不要只依赖mysql_data这个 volume最好定时用 crontab 执行备份docker exec mysql sh -c exec mysqldump --single-transaction -umyapp_user -p$MYSQL_PASSWORD myapp_db | gzip ./backup/myapp_$(date %F).sql.gz--single-transaction参数可以让 InnoDB 表在备份过程中保持一致性而不会锁表影响业务。日志方面建议通过docker logs直接采集到集中日志平台不要把日志写到容器内部文件再想着怎么拖出来。用 JSON 文件日志驱动加大小限制同时应用内使用 Serilog 直接输出结构化日志到 console这样docker logs就能看到完整字段也可以挂载日志插件把日志转发到 Elasticsearch 或其他日志存储。健康检查不只有 MySQL.NET Core 和 Nginx 也应该有。.NET API 里做一个/health接口返回 200然后在 compose 里配置healthcheck: test: [CMD, curl, -f, http://localhost:8080/health] interval: 30s timeout: 5s retries: 3 start_period: 10s这里注意curl是否存在于 aspnet alpine 镜像中。如果镜像里没有 curl可以用wget或者安装curl或者在镜像构建时额外加入。没有健康检查编排层面就无法感知应用是否真正对外提供服务容器状态看起来是 Up实际上可能早就因为OutOfMemory卡死了。7. 一点亲测后的体会这套 Docker Compose 方案运行到现在最大的感受是“部署变成了一件可以预期的事”。以前每次上线前要检查 .NET 版本、MySQL 配置、Nginx 路径现在一条命令构建、一条命令启动连数据库初始化都是自动的。排障路径也变得干净应用层看 Kestrel 日志代理层看 Nginx 日志数据层看 MySQL 日志互相之间不会串扰。最后分享一个我常用的操作习惯每次改配置文件之前先把当前生效的版本备份一份不管是 nginx 配置还是 compose 文件。容器化虽然让环境一致了但配置文件本身还是人的产物改前备份能让你快速回退到“刚才还是好的”那个状态。任何自动化方案都替代不了这个基础习惯它往往才是生产环境最有效的保险。这套架构后续如果流量涨上来我下一步会考虑引入 Kubernetes 做多副本自动伸缩但在此之前Docker Compose 这套已经足够把业务跑得很稳了。