一、背景
前阵子参加了一个 AI 创意比赛,项目是一个在线考试系统,有管理后台和考生答题端两个子系统。作品的评审环节需要提供一个可演示的测试环境。
问题来了,这个项目跑在生产环境上,不能随便动。而且之前的部署方式相当传统------站点通过Kestrel部署、还要装 SQL Server、配 Redis、搭 RabbitMQ....全套下来没个把小时搞不定。咱也不能为了演示个 Demo 把全套基础设施都从头搞一遍吧。
到这我很快就想到了docker,这简直就是容器大展身手的完美场景。预期的结果就是,编排好 docker 镜像后,拿到测试环境 docker-compose up -d 一键启动,其余什么都不用。
二、基础设施
可能有人会问,像关系数据库这种基础设施,放容器里不怕丢数据吗?是的,但注意,这是 Demo 环境,不是生产。目标就是让宿主机除了 docker 之外什么都不用装。数据库备份文件(
.bak)挂进去,启动后 restore 一下,数据就回来了。
dockerfile 用了多阶段构建:
plain
SDK 8.0 编译阶段 → 发布 → ASP.NET 8.0 运行时阶段
管理端的 Dockerfile 还多装了一个 mssql-tools18,方便在容器里直接执行 sqlcmd 恢复数据库。
事实上,生成 Dockerfile 的工作,IDE基本可以自动完成90%,剩下的就是我们自己根据情况调整,比如上面提到的需要在管理端做一次备份的操作,是需要手动在 Dockerfile 里编辑的。而且现在 AI 这么普及,不会的命令查一下就有了。注意用 AI 得到答案后,还是要自己消化的。
以下是管理端的Dockerfile基本结构。
dockerfile
# 管理端镜像文件
# ==========================================
# Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
# Copy项目文件,
# Copy项目文件,就是那些csproj中包含的内容,编译时用到
# 内容省略
# 重新生成
RUN dotnet restore "xxx.csproj"
# 拷贝所有代码
COPY . .
# 发布
WORKDIR "/src/xxxx"
RUN dotnet publish -c Release -o /app/publish --no-restore
# 准备运行时
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
# 安装MSSQL,这一步要是手动添加
RUN apt-get update && \
apt-get install -y curl apt-transport-https gnupg && \
curl https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/mssql-release.list && \
apt-get update && \
ACCEPT_EULA=Y apt-get install -y mssql-tools18 && \
apt-get clean && rm -rf /var/lib/apt/lists/*
ENV PATH="$PATH:/opt/mssql-tools18/bin"
# 拷贝发布好的文件
COPY --from=build /app/publish .
# 创建上传目录并授权,这个也是手动创建,和业务逻辑有关,无典型性
RUN mkdir -p /app/wwwroot/upfile && \
chmod 777 /app/wwwroot/upfile
# 指定端口
EXPOSE 8080
# 启动
ENTRYPOINT ["dotnet", "xxx.dll"]
端口规划:
| 服务 | 容器端口 | 映射到宿主机 |
|---|---|---|
| 管理后台 | 8080 | 5000 |
| 考生端 | 8080 | 5256 |
| SQL Server | 1433 | 1433 |
| Redis | 6379 | 6379 |
| RabbitMQ | 5672 / 15672 | 5672 / 15672 |
docker-compose.yml 写起来倒是不复杂,5 个 service,各自配好 image、ports、volumes、depends_on,再加一个 bridge 网络打通通信。RabbitMQ 带管理界面,15672 端口也映射出来方便排查。
yaml
services:
# ==================== 基础设施 ====================
# SQL Server 2022 Express (免费版本)
mssql:
image: ccr.ccs.tencentyun.com/{个人命名空间}/examimage:mssql-2022
container_name: exam-mssql
environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=${MSSQL_SA_PASSWORD}
- MSSQL_PID=Express
ports:
- "1433:1433"
volumes:
- mssql-data:/var/opt/mssql
# 挂载备份文件目录
- ./backups:/var/opt/mssql/backup
networks:
- exam-network
restart: unless-stopped
healthcheck:
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "${MSSQL_SA_PASSWORD}" -Q "SELECT 1" -C || exit 1
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# Redis 7 (单节点)
redis:
image: ccr.ccs.tencentyun.com/{个人命名空间}/examimage:redis-7
container_name: exam-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- exam-network
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
# RabbitMQ 3 (带管理界面)
rabbitmq:
image: ccr.ccs.tencentyun.com/{个人命名空间}/examimage:rabbitmq-3
container_name: exam-rabbitmq
environment:
- RABBITMQ_DEFAULT_USER=${RABBITMQ_DEFAULT_USER}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_DEFAULT_PASS}
- RABBITMQ_DEFAULT_VHOST=${RABBITMQ_DEFAULT_VHOST}
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq-data:/var/lib/rabbitmq
networks:
- exam-network
restart: unless-stopped
healthcheck:
test: rabbitmq-diagnostics -q ping
interval: 30s
timeout: 10s
retries: 5
# ==================== 应用服务 ====================
# 管理后台
exam-manage:
image: ccr.ccs.tencentyun.com/{个人命名空间}/examimage:exam-manage-latest
container_name: exam-manage
ports:
- "${EXAM_MANAGE_PORT}:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Docker
- ASPNETCORE_URLS=http://+:8080
# 生产环境主机地址配置(覆盖 appsettings.Docker.json)
- baseHost=${EXAM_MANAGE_HOST:-http://{公网ip+端口或者域名}}
- ExamClientHost=${EXAM_CLIENT_HOST:-http://{公网ip+端口或者域名}}
- corsHosts=${EXAM_CORS_HOSTS:-http://{公网ip+端口或者域名},http://{公网ip+端口或者域名}}
# 资源文件访问地址(覆盖 resoucesetting.json)
- resoucesetting__resourceHost=${EXAM_MANAGE_HOST:-http://{公网ip+端口或者域名}}
volumes:
- exam-uploads:/app/wwwroot/upfile
- exam-visualizations:/app/App_Data/visualizations
depends_on:
mssql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
networks:
- exam-network
restart: unless-stopped
# 考生答题端
exam-client:
image: ccr.ccs.tencentyun.com/{个人命名空间}/examimage:exam-client-latest
container_name: exam-client
ports:
- "${EXAM_CLIENT_PORT}:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Docker
- ASPNETCORE_URLS=http://+:8080
# 生产环境主机地址配置(覆盖 appsettings.Docker.json)
- baseHost=${EXAM_CLIENT_HOST:-http://{公网ip+端口或者域名}}
- corsHosts=${EXAM_CORS_HOSTS:-http://{公网ip+端口或者域名},http://{公网ip+端口或者域名}}
- payNotify=${EXAM_CLIENT_HOST:-http://{公网ip+端口或者域名}}/order/alipaynotify/pagepay
- payReturnUrl=${EXAM_CLIENT_HOST:-http://{公网ip+端口或者域名}}/order/alipaynotify/pagepay
# 资源文件访问地址(覆盖 resoucesetting.json)
- resoucesetting__resourceHost=${EXAM_CLIENT_HOST:-http://{公网ip+端口或者域名}}
volumes:
- exam-uploads:/app/wwwroot/upfile
- exam-visualizations:/app/App_Data/visualizations
depends_on:
mssql:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
networks:
- exam-network
restart: unless-stopped
# ==================== 网络和卷 ====================
networks:
exam-network:
driver: bridge
volumes:
mssql-data:
redis-data:
rabbitmq-data:
exam-uploads:
exam-visualizations:
三、配置分离
第一个关键决策来了,我新增了一个docker环境,前提是本地的开发环境不能受影响。
docker 环境里,数据库连接字符串是 server=mssql(容器名),Redis 是 server=redis,RabbitMQ 是 server=rabbitmq。而本地开发环境用的是 localhost\SQLEXPRESS、127.0.0.1:6379。
不能改本地配置,否则同事拉代码跑不起来,影响日常开发。
ASP.NET Core 的配置系统天然支持这个场景:WebApplication.CreateBuilder 会自动加载 appsettings.{ASPNETCORE_ENVIRONMENT}.json。只要在 docker-compose 里设置环境变量 ASPNETCORE_ENVIRONMENT=Docker,框架就会自动加载 appsettings.Docker.json,覆盖 appsettings.json 中的配置。
所以做法很简单:
- 项目根目录放
appsettings.Docker.json,里面写 docker 专用的连接字符串和其他配置 docker-compose.yml里设置ASPNETCORE_ENVIRONMENT=Docker- 本地
dotnet run不受影响,因为本地没有这个环境变量
配套的还有 cachesetting.Docker.json(Redis 配置)、mqsetting.Docker.json(RabbitMQ 配置),以及 .env 文件统一管理密码和端口。
配置文件的内容就不贴了,实际项目中应该有对应环境的独有配置文件即可,如下图

.env 文件是专门为 docker 环境生成的存储关键密码的文件,这个在生产环境里,我们习惯性的是放到环境变量里,但在 docker 环境,为了方便,我是创建了一个配置文件,这里也不贴了。
最后本地跑一下,看看效果


访问容器地址正常👇

四、发布部署
镜像在本地跑通了,接下来要部署到云服务器上给评审演示,这里最直接的方式就是推到 dockerhub,然后到目标服务器去拉取,但众所周知的网络环境,彼时我在本地往上推是没问题的,但在测试服务器往下拉就麻烦一点,所以放弃了 dockerhub 的方案,现在基本就剩以下2个方向。
4.1 方案一:tar 导出,手动传输
docker save命令把镜像导出成 tar 文件,再传到服务器,docker load 导入。
这个方案,最大的问题就是,中途我要修改代码重新打包的时候,每次都要重新导出,上传,在导入,虽然好用但过于原始,也过于消耗体力,所以放弃。
4.2 方案二:腾讯云 TCR 镜像仓库
既然 dockerhub 访问不了,刚好我有个个人的腾讯云账号,而且贴心的提供了一定额度的免费服务(TCR)

所以,现在思路就变成了:
本地构建 → 推送到 TCR → 服务器直接拉取
完整的流程如下。
构建镜像
bash
docker-compose build

这一步可能会花点时间,具体看你的服务大小。
打标签
bash
docker tag {镜像服务1} ccr.ccs.tencentyun.com/{TCR个人命名空间}/{容器名1}:{标签名1}
docker tag {镜像服务2} ccr.ccs.tencentyun.com/{TCR个人命名空间}/{容器名2}:{标签名2}
...

推送
bash
docker push ccr.ccs.tencentyun.com/{TCR个人命名空间}/examimage:exam-manage-v20260714
docker push ccr.ccs.tencentyun.com/{TCR个人命名空间}/examimage:exam-client-v20260714

注意,为了方便部署,可以滚动一个latest标签
bash
docker tag magicguangdongexam-exam-manage ccr.ccs.tencentyun.com/{TCR个人命名空间}/examimage:exam-manage-latest
docker push ccr.ccs.tencentyun.com/{TCR个人命名空间}/examimage:exam-manage-latest
标签规范也很简单:版本标签按日期 (如v20260714,可追溯),滚动标签用 latest(方便部署)。推送新版本的同时更新 latest。
4.3 基础镜像也要推
这里有个细节:连 mcr.microsoft.com/mssql/server:2022-latest、redis:7-alpine、rabbitmq:3-management 这些基础镜像,我也一并拉到本地、打标签、推到了 TCR,所有镜像都从同一个仓库拉取,全程流畅。
这些我之前已经推过了,如下

4.4 最终部署体验
最终TCR上会展示如下几个镜像

然后我们登录云服务,拉取这几个镜像,完成启动即可



启动完成后,再通过公网IP+端口,来访问一下

至此,服务容器化构建,推送,部署整个流程基本结束,之后每次更新代码,只需要本地重新 build、push,服务器上 pull,再重启应用容器即可。
注意
上面的截图演示,是因为我之前已经部署过,所以有一个关键步骤没有截图,就是第一次再云端拉去镜像后,需要先启动基础设施,恢复数据库,这是一次性操作,后续部署成功后,无需再反复执行。
bash
# 启动基础设施
docker compose -f docker-compose.prod.yml up -d mssql redis rabbitmq
# 等待 SQL Server 就绪(查看日志,等待 "Recovery is complete")
docker compose -f docker-compose.prod.yml logs -f mssql
# 查看备份文件的逻辑文件名
docker exec -it exam-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "<YOUR_SA_PASSWORD>" -Q "RESTORE FILELISTONLY FROM DISK='/var/opt/mssql/backup/Magic.Guangdong.Exam.bak'" -C
# 恢复数据库
docker exec -it exam-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "<YOUR_SA_PASSWORD>" -Q "
RESTORE DATABASE [Magic.Guangdong.Exam]
FROM DISK='/var/opt/mssql/backup/Magic.Guangdong.Exam.bak'
WITH MOVE 'Magic.Guangdong.Exam' TO '/var/opt/mssql/data/Magic.Guangdong.Exam.mdf',
MOVE 'Magic.Guangdong.Exam_log' TO '/var/opt/mssql/data/Magic.Guangdong.Exam_log.ldf'
" -C
# 创建消息队列数据库(这个是因为我使用了CAP,需要对队列消息做持久化存储,所以需要一对这样的数据表来存储发布订阅的消息记录)
docker exec -it exam-mssql /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "<YOUR_SA_PASSWORD>" -Q "CREATE DATABASE [Magic.Guangdong.Exam.MsgQuene]" -C
五、几个小但致命的坑
5.1 静态文件报错
我在部署到容器环境后,发现页面显示不正常,打开控制台一看一堆 error 输出,查了半天才发现,是文件大小写的问题。以前常在 Windows 环境开发,这个问题确实没有引起重视,这点大家还是要引以为戒,大小写敏感的问题要随时注意,Linux 文件系统对大小写是敏感的,而Windows不是。
5.2 文件共享问题
管理后台上传的图片(比如考试封面、用户头像),考生端页面上显示不出来。
这是因为,两个容器各自有独立的文件系统。管理端把文件写到了自己的容器里,考生端自然读不到需要使用Docker Volume 配置共享。
plain
volumes:
exam-uploads: # 共享上传文件
exam-visualizations: # 共享动画 JS 文件
两个容器都挂载同一个 volume,文件写进去双方都能读。同时 Dockerfile 里提前 mkdir -p 创建好目录,避免目录不存在导致启动失败。
六、龙虾乱入*
这里插一个章节。
其实到了云端部署环节,如果你的服务器是你自己的,也确定这是一个测试环境,没有任何数据安全问题。且你刚好有一只自己的 OpenClaw 助手,那事情就会变得简单且有趣起来。


再去服务器上检查一下,果然都关掉了。

同理,如果你需要让他帮你启动容器,再直接对话就行,非常方便。
再次提醒:使用 openclaw 助手之前,一定要确保这是你自己的服务器,或者你有绝对的管理权限,且确认没有安全风险的前提下。
七、一点感悟
好了,基本就是这些,前阵子的比赛初选已经结束了,很遗憾我没有进入复赛,但在将近3w名参赛选手中排到了200名左右,也非常欣慰了。上面这一套基于容器部署演示环境的流程,不是本次的比赛内容,但把一套繁琐的依赖基础设施通过 Docker 完成容器化编排、做到秒级拉起 Demo,本身就是一次非常过瘾的技术沉淀。顺手的工具链,才是开发者最坚实的后盾,我觉得这也挺值的记录一下,因此有了此篇。
相关资源: