49.实战篇ReactNotesDocker部署
前言
本篇我们讲解使用 Docker 部署我们的 Next.js 项目。
Docker 直接部署
如果只是把项目部署上去,其实并不复杂。我给大家一个简单的、用于理解学习、演示核心要点的示例:
下载代码
我们以 Day11 分支的代码为例,此时我们的代码中使用了 Prisma + MySQL。
下载我们的 day11 分支代码:
git clone -b day11 git@github.com:mqyqingfeng/next-react-notes-demo.git
先本地运行一下,验证代码无问题:
# 注意本地开启 MySQL 后运行:
npm i && npm run dev
构建镜像
项目根目录新建 .dockerignore
文件,代码如下:
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
docker
.git
项目根目录新建 Dockerfile
,代码如下:
FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install --registry=https://registry.npmmirror.com
RUN npx prisma generate
RUN chmod +x /startup.sh
EXPOSE 3000
ENTRYPOINT ["/startup.sh"]
注意:这里的镜像构建指令并不复杂,相信大家有《实战篇 | React Notes | Docker 快速入门》的基础,都很容易理解。关于最后一句指令,我们并没有使用 CMD
,而是 ENTRYPOINT
,两者作用类似。使用 ENTRYPOINT
,我们将执行内容放到了脚本文件 startup.sh
中。
项目根目录新建 startup.sh
文件,代码如下:
#!/bin/sh
MIGRATION_STATUS=$(npx prisma migrate status)
if echo "$MIGRATION_STATUS" | grep -q "Database schema is up to date"; then
echo "No migrations needed."
else
echo "Running migrations..."
npx prisma migrate deploy
fi
npm run build && npm run start
执行的内容并不复杂,主要是因为我们使用了 Prisma,所以需要运行 prisma generate
和prisma migrate deploy
。prisma generate
我们已经放到了构建指令中,prisma migrate deploy
我们放到了运行脚本中。
项目根目录新建 docker-compose.yml
文件,代码如下:
version: "3.9"
networks:
react-notes:
driver: bridge
services:
mysql:
image: mysql:8.0
container_name: mysql
command: --default-authentication-plugin=caching_sha2_password
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=admin
- MYSQL_DATABASE=notes
- MYSQL_USER=notes
- MYSQL_PASSWORD=cpZfriEBbmJjWeiR
ports:
- '3306:3306'
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h127.0.0.1', '-P3306']
interval: 5s
timeout: 2s
retries: 20
networks:
- react-notes
next-app:
container_name: next-app
build:
context: .
restart: always
ports:
- 3000:3000
networks:
- react-notes
depends_on:
mysql:
condition: service_healthy
在这段代码中,这里我们声明了 mysql
的容器名为 mysql
,并建立了一个名为 notes
的 MySQL 用户,对应修改根目录的 env
文件为:
DATABASE_URL="mysql://notes:cpZfriEBbmJjWeiR@mysql:3306/notes"
稍微复杂一点的是 healthcheck
,这是为了保证 mysql 完全运行后才运行 next-app
。我们可以看到 next-app
的 depends_on
里有 mysql
,依赖的条件是 service_healthy
,也就是说 mysql
的 healthcheck
要先通过才会运行 next-app
。
服务器运行
服务器安装 Docker 和 Docker Compose,我是直接用宝塔安装了:
注:安装的速度不算快,大概等待了 20 分钟。服务器直接安装可以参考:《安装Docker并使用(Linux)》。
然后把代码推送到服务器,注意如果服务器已经开启了 MySQL,先关闭 MySQL。最后命令行进入服务器项目目录,运行 docker compose up
,我们就成功的将项目运行在了服务器的 3000
端口。
如果参照过《实战篇 | React Notes | 服务器部署》,因为已经设置过 Nginx,所以会将域名 notes.yayujs.com
代理到 3000 端口,所以直接访问:https://notes.yayujs.com/ 就会生效:
如果你没有设置过,宝塔里也支持容器快捷的设置反向代理、绑定到具体的域名上:
Docker 本地开发
现在我们非常随意的就用 Docker 把代码部署到了服务器上。但是这样的方法只能说——又不是不能用……
问题依然很多,比如用了数据库却没有做数据持久化、Next.js 项目没有开启 standalone
输出模式,Docker 的镜像构建太过随意导致构建包很大等等……
实际上,选择用 Docker 选择的是一种开发方式。也就是说,不是在本地开发完了项目,最后用 Docker 部署一下,而是在本地开发项目的时候,就已经开始使用 Docker 了。
所以学习本篇不要着急,我们从头开始说起。
下载代码
先说说日常开发项目怎么使用 Docker。我们依然以 Day11 分支的代码为例,此时我们的代码中用了 Prisma + MySQL。
下载我们的 day11 分支代码:
git clone -b day11 git@github.com:mqyqingfeng/next-react-notes-demo.git
老规矩,先本地运行一下,验证代码无问题:
# 注意要在本地开启 MySQL 后运行:
npm i && npm run dev
.dockerignore
项目根目录新建 .dockerignore
文件,代码如下:
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
docker
.git
dev.Dockerfile
项目根目录新建 dev.Dockerfile
,代码如下:
FROM node:18-alpine
# RUN apt-get update -y
# RUN apt-get install -y openssl
WORKDIR /app
COPY . .
RUN npm i --registry=https://registry.npmmirror.com;
RUN chmod +x /app/dev.startup.sh
ENTRYPOINT ["sh", "/app/dev.startup.sh"]
在这段代码中:
我们新建的是 dev.Dockerfile
,但跟 Next.js 的 .env.development
不同的是,.env.development
Next.js 是会自动读取的,但 Docker 并不会自动读取 dev.Dockerfile
,所以名字其实可以乱取,我们只是为了方便区分不同环境的 Dockerfile。(PS:Docker 其实也支持开发环境,不过目前处于开发停滞状态)
再解释下代码的含义。RUN apt-get
这两句是为了避免出现类似于下面这样的错误提示(如果你遇到了这个问题的话,参考此 GitHub Issue,没有遇到就不用写了):
Prisma Client could not locate the Query Engine for runtime “debian-openssl-1.1.x”.
This happened because Prisma Client was generated for “debian-openssl-3.0.x”, but the actual deployment required “debian-openssl-1.1.x”.
Add “debian-openssl-1.1.x” to
binaryTargets
in the “schema.prisma” file and runprisma generate
after saving it:
然后指定工作目录,拷贝所有文件、安装依赖包、给脚本文件添加运行权限。最后用 ENTRYPOINT 指定了容器启动脚本。因为我们用了 Prisma,脚本内容比较多,所以单独使用了一个脚本文件。
dev.startup.sh
项目根目录新建 dev.startup.sh
文件,代码如下:
#!/bin/sh
MIGRATION_STATUS=$(npx prisma migrate status)
if echo "$MIGRATION_STATUS" | grep -q "Database schema is up to date"; then
echo "No migrations needed."
else
echo "Running migrations..."
npx prisma migrate deploy
fi
npx prisma generate
if [ -f yarn.lock ]; then
yarn dev;
elif [ -f package-lock.json ]; then
npm run dev;
elif [ -f pnpm-lock.yaml ]; then
pnpm dev;
else
npm run dev;
fi
这段代码并不复杂,一是 Prisma 相关处理,二是运行 npm run dev
开启开发模式。你可能会问,prisma generate
怎么又放到了脚本里执行?之前不是放在镜像构建指令里吗?其实这里放哪里都可以的。(但是线上部署的时候因为开启了 standalone 输出模式只能放在构建指令中)
dev.docker-compose.yml
项目根目录新建 dev.docker-compose.yml
文件,代码如下:
version: "3.8"
networks:
react-notes-dev:
driver: bridge
services:
next-app-dev:
container_name: next-app
build:
context: .
dockerfile: dev.Dockerfile
env_file:
- .env
- .env.development
volumes:
- .:/app
restart: always
ports:
- 3000:3000
networks:
- react-notes-dev
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8.0
container_name: next-app-mysql
command: --default-authentication-plugin=caching_sha2_password
restart: unless-stopped
# volumes:
# - ./docker/data/mysql/:/var/lib/mysql/
environment:
- MYSQL_ROOT_PASSWORD=admin
- MYSQL_DATABASE=notes
- MYSQL_USER=notes
- MYSQL_PASSWORD=cpZfriEBbmJjWeiR
ports:
- '3306:3306'
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h127.0.0.1', '-P3306']
interval: 5s
timeout: 2s
retries: 20
networks:
- react-notes-dev
这段代码也很好理解,注意 next-app
的数据卷,因为我们将项目的所有内容都挂载到工作目录 /app
下,当我们修改项目文件时,Docker 里运行的项目也会正常发生修改,所以即便使用了 Docker 也继续支持热更新。
根目录的 .env.development
文件对应修改为:
DATABASE_URL="mysql://notes:cpZfriEBbmJjWeiR@next-app-mysql:3306/notes"
此时运行 docker compose -f dev.docker-compose.yml up
,本地访问 http://localhost:3000/
应该已经可以正常运行:
host
使用 next-auth
的时候,如果部署到线上环境,可能需要额外设置一下 AUTH_URL
。对于初学者而言,又有可能忘记这件事情,于是明明在本地运行没有问题,但发布到线上后就出现了问题……干脆我们本地开发的时候就直接使用线上域名进行开发!
我们设想的开发方式是本地浏览器访问 https://notes.yayujs.com
就会进入我们本地启用的页面。为此你需要修改主机的 host
:
# 修改 host 文件
vim /etc/hosts
# 添加如下:
127.0.0.1 notes.yayujs.com
其实修改 host 并不算复杂,但是发布到线上又需要注释掉对应的域名才能查看效果,开发本地又需要解开注释才能查看效果。一来二去也就麻烦了,为此你可以使用 SwitchHosts 这个软件:
借助 SwitchHosts 这个软件,你可以轻松切换 host。
注意:如果切换 host 后,浏览器没有成功解析,你可以进行这样一些尝试:
有可能是浏览器做了 DNS 缓存或其他处理。浏览器打开 chrome://net-internals/#sockets
,点击 Flush Socket Pools
。注意 Flush 的是当前窗口里的所有页面。
有可能是使用了代理导致。这个时候就需要将你的域名放到代理的排除名单里。我们以 ClashX 为例,打开 ~/.config/clash
,添加一个名为 proxyIgnoreList.plist
的文件,代码如下:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
<string>192.168.0.0/16</string>
<string>10.0.0.0/8</string>
<string>172.16.0.0/12</string>
<string>127.0.0.1</string>
<string>localhost</string>
<string>*.local</string>
<string>http://notes.yayujs.com</string>
<string>https://notes.yayujs.com</string>
</array>
</plist>
在 <string>
标签中添加要排除的域名,然后重启 ClashX 即可。
设置 host 后,此时访问 http://notes.yayujs.com:3000/:
注:此时也只是能正常访问首页,功能比如登录还有问题
nginx
我们的目标是本地访问 https://notes.yayujs.com
的时候可以进入我们的开发页面,通过 switchHosts 关闭相关 host 的时候,访问 https://notes.yayujs.com
则会进入我们的线上部署页面。为此我们需要用到 nginx 镜像。
修改 dev.docker-compose.yml
文件,完整代码如下:
version: "3.8"
networks:
react-notes-dev:
driver: bridge
services:
next-app-dev:
container_name: next-app
build:
context: .
dockerfile: dev.Dockerfile
env_file:
- .env
- .env.development
volumes:
- .:/app
restart: always
ports:
- 3000:3000
networks:
- react-notes-dev
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8.0
container_name: next-app-mysql
command: --default-authentication-plugin=caching_sha2_password
restart: unless-stopped
# volumes:
# - ./docker/data/mysql/:/var/lib/mysql/
environment:
- MYSQL_ROOT_PASSWORD=admin
- MYSQL_DATABASE=notes
- MYSQL_USER=notes
- MYSQL_PASSWORD=cpZfriEBbmJjWeiR
ports:
- '3306:3306'
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h127.0.0.1', '-P3306']
interval: 5s
timeout: 2s
retries: 20
networks:
- react-notes-dev
nginx:
image: nginx
container_name: next-app-nginx
volumes:
- ./docker/nginx.conf:/etc/nginx/nginx.conf
- ./docker/ssl/notes.yayujs.com.key:/etc/nginx/ssl/notes.yayujs.com.key
- ./docker/ssl/notes.yayujs.com.pem:/etc/nginx/ssl/notes.yayujs.com.pem
ports:
- 80:80
- 443:443
restart: always
networks:
- react-notes-dev
depends_on:
- next-app-dev
项目根目录新建 docker
文件夹,然后新建 nginx.conf
,代码如下:
events {
worker_connections 1000;
}
http {
include /etc/nginx/mime.types;
upstream nextjs_upstream {
server next-app:3000;
}
server {
listen 80;
listen 443 ssl;
server_name notes.yayujs.com;
ssl_certificate /etc/nginx/ssl/notes.yayujs.com.pem;
ssl_certificate_key /etc/nginx/ssl/notes.yayujs.com.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
gzip on;
gzip_proxied any;
gzip_comp_level 4;
gzip_types text/css application/javascript image/svg+xml;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
location / {
proxy_pass http://nextjs_upstream;
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 REMOTE-HOST $remote_addr;
add_header X-Cache $upstream_cache_status;
proxy_set_header X-Host $host;
proxy_set_header X-Scheme $scheme;
proxy_connect_timeout 30s;
proxy_read_timeout 86400s;
proxy_send_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}
同时在 docker
目录下新建 ssl
文件夹,然后放入开启 https 会用到的 key 和 pem 文件。
注:按理说这些再加上设置 AUTH_URL 就够了,但是因为用的是 next-auth v5 beta 版本,目前还不算稳定,如果不使用 AUTH_URL,点击 signIn
的时候跳转地址的 callbackUrl 会设置为 localhost:3000
,如果添加 AUTH_URL
,会出现报错:
TypeError: next_dist_server_web_exports_next_request__WEBPACK_IMPORTED_MODULE_0__ is not a constructor
根据这个 GitHub Issue 的描述,此问题会由 Next.js 修复,但目前还没有发布到稳定版。对此我的处理方式是在 next-auth
的 redirects
回调函数中做一层处理,修改 auth.js
:
import NextAuth from "next-auth"
// import GitHub from "next-auth/providers/github"
import CredentialsProvider from "next-auth/providers/credentials";
import { addUser, getUser } from "@/lib/prisma";
export const { handlers, auth, signIn, signOut } = NextAuth({
providers:[CredentialsProvider({
// 显示按钮文案 (e.g. "Sign in with...")
name: "密码登录",
// `credentials` 用于渲染登录页面表单
credentials: {
username: { label: "账号", type: "text", placeholder: "输入您的账号" },
password: { label: "密码", type: "password", placeholder: "输入您的密码" }
},
// 处理从用户收到的认证信息
async authorize(credentials, req) {
// 默认情况下不对用户输入进行验证,确保使用 Zod 这样的库进行验证
let user = null
// 登陆信息验证
user = await getUser(credentials.username, credentials.password)
// 密码错误
if (user === 1) return null
// 用户注册
if (user === 0) {
user = await addUser(credentials.username, credentials.password)
}
if (!user) {
throw new Error("User was not found and could not be created.")
}
return user
}
})],
pages: {
signIn: '/auth/signin'
},
callbacks: {
authorized({ request, auth }) {
const { pathname } = request.nextUrl
if (pathname.startsWith("/note/edit")) return !!auth
return true
},
async jwt({ token, user, account }) {
if (account && account.type === "credentials" && user) {
token.userId = user.userId;
}
return token;
},
async session({ session, token }) {
if (token.userId) {
session.user.userId = token.userId
}
return session;
},
async redirect({ url, baseUrl }) {
if (url.startsWith("/")) return `${baseUrl}${url}`
else if (new URL(url).origin === baseUrl) return url
if (baseUrl.indexOf(":3000") > -1) return url
return baseUrl
}
},
trustHost: true
})
运行:
# 删除掉之前的容器
docker compose -f dev.docker-compose.yml down
# 重新构建镜像
docker compose -f dev.docker-compose.yml up --build
本地访问 https://notes.yayujs.com,此时应该可以正常运行:
那么问题来了,如果我切换了 host,我如何知道浏览器打开的地址访问的是线上还是本地呢?
方法有很多种,一种简单的方式是查看页面请求头,如果是本地:
如果是线上:
如果切换后没有生效,就参照上节讲的两种方式试试。
至此我们就用 Docker 搭建了一个开发环境,我们的目标是:
- 提供一个一致的开发环境,不用在每台主机上都配置一遍
- 模拟复制一个线上环境,不用担心漏掉某些配置
- 正常本地开发 Next.js 项目的功能如热更新继续支持,能够实时查看效果
Docker 生产部署
接下来我们看看生产部署的时候要做些什么。
其实主要就两件事情,一是编译和运行生产版本,二是减少镜像大小,毕竟我们开发时构建的镜像包都快有 1G 了:
这谁能忍?(其实我能忍,但为了显得我精益求精、既要又要还要,我们还是努力优化一下……)
减少镜像的大小,主要是开启 Next.js 项目的 standalone 模式,然后优化镜像构建。
注:关于 standalone 输出模式的介绍可以查看《API 篇 | next.config.js(下)》
那就让我们开始吧。
修改 next.config.js
,开启 standalone 输出模式,代码如下:
const nextConfig = {
output: 'standalone'
}
module.exports = nextConfig
项目根目录新建 prod.Dockerfile
,代码如下:
FROM node:18-alpine AS base
FROM base AS builder
WORKDIR /app
COPY . .
RUN npm i --registry=https://registry.npmmirror.com;
RUN npx prisma generate
RUN npm run build;
FROM base AS runner
WORKDIR /app
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
ENV NEXT_TELEMETRY_DISABLED 1
COPY prisma ./prisma/
COPY prod.startup.sh ./prod.startup.sh
RUN chmod +x /app/prod.startup.sh
ENTRYPOINT ["sh", "/app/prod.startup.sh"]
项目根目录新建 prod.startup.sh
,代码如下:
#!/bin/sh
MIGRATION_STATUS=$(npx prisma migrate status)
if echo "$MIGRATION_STATUS" | grep -q "Database schema is up to date"; then
echo "No migrations needed."
else
echo "Running migrations..."
npx prisma migrate deploy
fi
node server.js
项目根目录新建 prod.docker-compose.yml
,代码如下:
version: "3.8"
networks:
react-notes-prod:
driver: bridge
services:
next-app-prod:
container_name: next-app
build:
context: .
dockerfile: prod.Dockerfile
env_file:
- .env
- .env.production
restart: always
ports:
- 3000:3000
networks:
- react-notes-prod
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8.0
container_name: next-app-mysql
command: --default-authentication-plugin=caching_sha2_password
restart: unless-stopped
# volumes:
# - ./docker/data/mysql/:/var/lib/mysql/
environment:
- MYSQL_ROOT_PASSWORD=admin
- MYSQL_DATABASE=notes
- MYSQL_USER=notes
- MYSQL_PASSWORD=cpZfriEBbmJjWeiR
ports:
- '3306:3306'
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h127.0.0.1', '-P3306']
interval: 5s
timeout: 2s
retries: 20
networks:
- react-notes-prod
nginx:
image: nginx
container_name: next-app-nginx
volumes:
- ./docker/nginx.conf:/etc/nginx/nginx.conf
- ./docker/ssl/notes.yayujs.com.key:/etc/nginx/ssl/notes.yayujs.com.key
- ./docker/ssl/notes.yayujs.com.pem:/etc/nginx/ssl/notes.yayujs.com.pem
ports:
- 80:80
- 443:443
restart: always
networks:
- react-notes-prod
depends_on:
- next-app-prod
修改 .env
:
DATABASE_URL="mysql://notes:cpZfriEBbmJjWeiR@next-app-mysql:3306/notes"
修改 .env.production
:
# 注释掉 AUTH_URL,因为会导致报错,预计会在 Next.js v14.1.1 版本修复
# AUTH_URL=https://notes.yayujs.com
因为容器名不能重复,我们删除掉之前用于开发模式的容器:
docker compose -f dev.docker-compose.yml down
然后构建镜像并运行生产模式的容器:
docker compose -f prod.docker-compose.yml up
此时本地应该可以正常访问:
查看镜像大小:
镜像从之前的 830M 优化到了现在的 169M,可喜可贺!(懒得继续优化了……)
部署到线上的方式也很简单:
将代码推送到服务器上,然后登陆服务器,进入到项目根目录,运行相同的命令:
docker compose -f prod.docker-compose.yml up
如果参照《实战篇 | React Notes | 服务器部署》在宝塔设置过 Node 项目,记得删除 Node 项目或者修改对应的 Nginx 配置,否则可能会因为端口占用影响容器运行。
现在我们又一次用 Docker 把代码部署到了服务器上,还做了不少优化,但是这样的方法只能说——勉强能用……
大问题基本解决,小问题依然很多:比如还是没有解决数据库持久化问题,不过这个比较简单,参考上篇 《实战篇 | React Notes | Docker 快速入门》即可自己创建数据卷。如果大家在实际的开发中用到 Redis,比如用 Redis 做数据缓存,也可以参考《实战篇 | React Notes | Docker 快速入门》中的 Docker Compose 配置。
比如目前我们虽然构建了两个镜像,但因为容器名不能重复,所以我们运行本地或者线上的时候,都要删除掉之前的容器再重新开一个容器,如果分别建立两个容器,Nginx 配置需要做两份或者改为读取环境变量的方式,这又要做一点配置工作。此外流程上还可以借助 Jenkins 实现 CI/CD,优化开发流程……
总之任重而道远。Docker 会用就好用,不会用的时候要踩一堆坑,我们慢慢讲解。后面的项目我们会继续用 Docker 作为基本的开发方式。
本篇项目源码:https://github.com/mqyqingfeng/next-react-notes-demo/tree/day12
参考链接
- https://stackoverflow.com/questions/76698529/how-to-update-the-prisma-in-a-production-docker-instance
- https://github.com/vercel/next.js/blob/canary/examples/with-docker-compose/next-app/dev.Dockerfile
- https://github.com/prisma/prisma/blob/main/docker/docker-compose.yml
- https://github.com/prisma/prisma-examples/blob/latest/databases/mongodb/docker-compose.yml
- https://github.com/nextauthjs/next-auth/issues/9922
- https://gist.github.com/malteneuss/a7fafae22ea81e778654f72c16fe58d3
- https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/generating-prisma-client