BotFlow

Entrance to AI

0%

逃离Vercel - Next.js应用自托管(Docker篇)

前言

自公众号开篇以来差不多也有一年的时间了,承蒙各位厚爱,本人更新比较佛系,不过每篇文章都是自己亲身实践过的例子,希望能把最好的东西呈现给大家。
最近半年,空闲时间一直在忙于AI出海应用,从0开始自学Next.js框架,虽然现在还是纯支出(0收入),回看整个过程还是觉得挺有意思,不敢想象一个人在过去半年独立开发了这么多网站。关于这半年做的一些项目,后续会另起新文展开。

好了,先回到今天的正题:Next.js自托管。

  • Next.js:在海外独立开发者社区中,Next.js是绝大多数独立开发者的选择,基于React生态,能够让一个初学者快速入门创建全栈Web应用程序。Next.js作为一个Web应用框架,配合Vercel平台,极大的降低了开发应用的门槛,很多著名的开源项目都是提供Vercel一键部署的,比如之前介绍过的ChatGPT NextWeb。
  • 自托管:顾名思义就是本地私有化部署,既然提到了自托管,那么可能有人要问了,Vercel平台不是提供一键部署吗,为什么还要自托管呢?

这里就不得不提Vercel的限制了,作为Next.js框架的开发者,Vercel对Next.js的无缝集成,为开发者提供了诸多便利

  • 免运维:Serverless,函数即服务,无需租用服务器,可以在初期省下一大笔服务器费用
  • 自动CI/CD:git push后实现自动部署,让网站“一键焕新”
  • HTTPS/SSL证书:现在HTTPS基本已经成标配,作为一个合格的开发者,应该为自己的网站加上HTTPS,让用户更放心

然而Vercel也存在诸多限制

  • 免费计划function call默认最大时长为10s,很多AI应用往往需要超过10s。好消息是最近Vercel放松了部分限制,通过额外配置放宽到900s,基本满足绝大多数场景使用
  • 免费计划(Hobby)仅限非商业个人使用。对于AI应用而言,计算开销是一个不小的开支(如GPU资源租用等),很多AI应用都是按次付费,为了平衡(zhuan)收支(qian),很多独立开发者会在网站上加上支付渠道,这种情况严格来说应该升级到Pro计划(每月20$)。另外值得注意的是,即使是免费网站,官网条款对于商业用途的描述也包含了Google AdSense等广告联盟

综上,短期内我选择将部分应用迁移至海外服务器独立部署,相比之下,每月固定费用从 $20 降至 $6

其实Vercel的平替也很多,比如CloudFlare也提供了类似的功能,大家可以自行选择,本文不再展开。

本专题将分成2篇分别介绍迁移步骤,分别对应于Vercel的【自动CI/CD】和【HTTPS证书管理】两大功能,本文先介绍基于Github Action Workflow的【自动CI/CD】。

以Dir2AI.com为例,这是最近做的一个AI导航站。只需输入AI相关网站的域名,便会自动收录,AI会自动生成网站简介(如标题、描述、关键词、截图等)。

准备工作

  • 一台服务器(可以在腾讯云、阿里云、DigitalOcean上购买),我这里买的是Digital Ocean的每月6$计划(1核1G)
  • nginx:用于反向代理,以CentOS为例,yum install nginx,其他系统可以自行google,不再赘述
  • Docker

Vercel自动帮我们自动做了CI/CD,那么自托管就需要我们自行编写CI/CD配置,这里采用免费的Github Action Workflow作为我们的CI/CD流水线工具。简单来说,分成如下几个步骤

  • 环境配置文件管理
  • Dockerfile编写
  • Workflow文件编写
  • 反向代理配置(以nginx为例)
  • DNS配置:方便后续通过域名直接访问

环境配置文件管理

项目代码和环境配置文件一般不要存放在同一个仓库(很多泄密项目都是由于不小心将秘钥文件引入项目从而导致秘钥信息暴露),这里有几种方式

编译环境变量

使用场景:适用于CI/CD工具(Github Workflow Action)的Docker镜像编译使用
直接在项目页面的secrets/variables页面新增即可

  • secrets和variables的区别:secrets不可查看明文,variables可查看明文

访问链接 https://github.com/你的用户名/项目名/settings/secrets/actions

配置文件

使用场景:常用于项目运行

由于Next.js项目往往需要配置不少环境变量,前述方法需要一个个配置比较麻烦,这里介绍下我自己的做法,也欢迎大家留言推荐最佳实践。
新起一个私有仓库专门存储秘钥(由于涉及秘钥千万记得设置为私有项目,否则结局可能很惨)。
由于独立开发者可能同时开发很多个项目,这里采用每个项目每个文件夹,方便日后管理。
一般来说,还可以按照不同环境名新增后缀,比如开发环境(dev)、生产环境(production),简单起见我这里统一命名为.env不再细分。

申请Personal Access Tokens,将其存储在上文提到的secrets的PAT变量。
访问 https://github.com/settings/tokens 创建即可

后续Github Workflow Action配置文件将使用如下命令同步环境变量配置文件

1
curl -H "Authorization: token ${{ secrets.PAT }}" -o .env https://raw.githubusercontent.com/你的用户名/你的环境配置项目名/main/${APP_NAME}/.env

编写Dockefile

Dockerfile参考Vercel官方指南
然后在next.config.jsmodule.exports里面加入一行output: "standalone"即可。
具体来说,比如model.exports的return在第58行,那么可以通过新增RUN sed -i "59i\output: 'standalone'," next.config.js实现对next.config.js的修改。
其他问题根据具体项目而定自行修改即可,不再展开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
FROM node:18-alpine AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi


# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1

# 根据自己的项目修改对应行数即可
# RUN sed -i "59i\output: 'standalone'," next.config.js

RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000

# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
CMD HOSTNAME="0.0.0.0" node server.js

本地调试命令

  • 编译镜像:docker build -t nextjs-docker
  • 运行镜像:docker run -p 3000:3000 nextjs-docker

编写Github Action workflows

在项目目录创建mkdir -p .github/workflows,编辑.github/workflows/deploy.yml
注意,还需要在https://github.com/你的用户名/你的环境配置项目名/settings/secrets/actions新增如下变量(根据前缀secrets还是vars区分存储于secrets或者variables)

  • Docker配置:Workflow的目的之一就是编译Docker镜像并推送至镜像源方便后续使用,如腾讯云阿里云都提供类似的配置
    • REGISTRY_MIRROR:Docker Registry,比如腾讯云香港的地址为hkccr.ccs.tencentyun.com,阿里云美国硅谷的地址为registry.us-west-1.aliyuncs.com,根据具体
    • REGISTRY_NAMESPACE:镜像仓库的命名空间
    • REGISTRY_USER:在镜像仓库的详情页都会介绍
    • REGISTRY_PASSWORD:在镜像仓库的详情页都会介绍

综上目前的配置变量列表

  • Variables
    • APP_NAME:应用名,应该和【环境配置项目名】下面的文件夹同名,否则在编译和部署过程中可能找不到对应的.env文件
    • HOST:远程服务器IP
    • USER:远程服务器用户名
    • REGISTRY_MIRROR
    • REGISTRY_NAMESPACE
    • REGISTRY_USER
  • Secret
    • KEY:ssh秘钥,cat ~/.ssh/id_rsa的输出
    • PAT:Github Personal Token
    • REGISTRY_PASSWORD
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      name: deployment
      on:
      push:
      # push到主分支启用CD,根据实际情况更改
      branches: [main]

      jobs:
      deploy-app:
      runs-on: ubuntu-latest
      # 后续step(非远程机器)可以通过${{env.xxx}}或者$xxx引用,否则通过 ${{vars.xx}} 或者 ${{secrets.xx}},可选
      env:
      REGISTRY_MIRROR: ${{ vars.REGISTRY_MIRROR }}
      REGISTRY_NAMESPACE: ${{ vars.REGISTRY_NAMESPACE }}
      REGISTRY_USER: ${{ vars.REGISTRY_USER }}
      REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
      APP_NAME: ${{ vars.APP_NAME }}
      steps:
      # 拉取main分支代码
      - name: Checkout
      uses: actions/checkout@v2
      - name: Set github_env
      run: echo "GITHUB_SHA_SHORT=$(echo $GITHUB_SHA | cut -c 1-6)" >> $GITHUB_ENV
      - name: Set env
      run: |
      # 将REGISTRY_REPO添加到GITHUB_ENV,后续通过$xxx引用
      echo "REGISTRY_REPO=${REGISTRY_MIRROR}/${REGISTRY_NAMESPACE}/${APP_NAME}" >> $GITHUB_ENV
      # 制作docker镜像并推送到镜像服务
      - name: Build and push docker image
      run: |
      ls -lth
      echo ${REGISTRY_PASSWORD} | docker login ${REGISTRY_MIRROR} --username ${REGISTRY_USER} --password-stdin
      # 部分项目编译时需要环境变量
      curl -H "Authorization: token ${{ secrets.PAT }}" -o .env https://raw.githubusercontent.com/你的用户名/你的环境配置项目名/main/${APP_NAME}/.env
      # 构建镜像
      docker image build -t ${APP_NAME} -t ${APP_NAME}:${{ env.GITHUB_SHA_SHORT }} -t $REGISTRY_REPO -t $REGISTRY_REPO:${{ env.GITHUB_SHA_SHORT }} .
      # 推送镜像
      docker push --all-tags $REGISTRY_REPO
      docker logout
      # https://github.com/marketplace/actions/ssh-remote-commands
      # 远程机器自动部署,因为是远程机器,所以这里环境变量需要重新设置
      - name: deploy
      uses: appleboy/[email protected]
      env:
      REGISTRY_MIRROR: ${{ vars.REGISTRY_MIRROR }}
      REGISTRY_USER: ${{ vars.REGISTRY_USER }}
      REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
      APP_NAME: ${{ vars.APP_NAME }}
      REGISTRY_REPO: ${{ vars.REGISTRY_MIRROR }}/${{ vars.REGISTRY_NAMESPACE }}/${{ vars.APP_NAME }}
      DIR_APP: /root/repo/${{ vars.APP_NAME }}
      with:
      host: ${{ vars.HOST }}
      username: ${{ vars.USER }}
      key: ${{ secrets.KEY }}
      # 通过在env声明+envs export,后续可以直接$xxx调用;后续亦可继续使用 ${{vars.xxx}} 和 ${{secrets.xxx}}
      envs: APP_NAME,REGISTRY_REPO,REGISTRY_MIRROR,REGISTRY_PASSWORD,REGISTRY_USER,DIR_APP
      script: |
      mkdir -p ${DIR_APP} && cd ${DIR_APP}
      echo -e "setup ${APP_NAME} from ${REGISTRY_REPO}"
      echo ${REGISTRY_PASSWORD} | docker login ${REGISTRY_MIRROR} --username ${REGISTRY_USER} --password-stdin
      # 清理已存在的容器和镜像
      docker rm -f ${APP_NAME} && docker rmi ${REGISTRY_REPO}:latest
      # 同步环境变量
      curl -H "Authorization: token ${{ secrets.PAT }}" -o .env https://raw.githubusercontent.com/你的用户名/你的环境配置项目名/main/${APP_NAME}/.env
      # 冒号前为暴露端口,注意和nginx配置匹配
      docker run -d --restart=always -p 3000:3000 --env-file .env --name ${APP_NAME} ${REGISTRY_REPO}
      docker logout

配置反向代理

经过上述步骤就可以实现在远程服务器自动部署了,不过这里只能本地访问,如果需要通过域名访问该服务,还需要进行反向代理配置。
以nginx为例

新增配置文件

nginx会自动包含文件夹conf.d的所有conf

1
vi /etc/nginx/conf.d/dir2ai.com.conf

配置文件示例(记得替换自己的域名),这里我将dir2ai.com 301强转至www.dir2ai.com

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
server {
listen 80;
server_name dir2ai.com;
return 301 http://www.dir2ai.com$request_uri;
}

server {
listen 80;
server_name www.dir2ai.com;

location / {
# 和Workflow配置文件的docker run暴露的端口同步修改
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

测试配置nginx -t,出现如下提示说明配置文件一切正常

1
2
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

重载配置nginx -s reload

配置DNS

在域名服务商配置DNS,新增两条A记录到服务器IP,如果是CloudFlare可额外设置是否启用CloudFlare CDN。

至此,就可以通过 http://www.dir2ai.com 访问啦!

实测1核1G的服务器(加4G虚拟内存)运行4个Next.js应用,资源尚有剩余,目测单机能支持5-6个服务,如果你的网站数不是很多(<5个),不妨考虑一下 :)

常见问题

Next.js无法通过request.nextUrl.origin获取真实Origin

之前通过request.nextUrl.origin获取Origin地址,但是采用nginx反向代理后会得到http://0.0.0.0:3000
这里需要改成如下方法才能获取正确的地址

1
2
3
4
5
export function getOrigin(req) {
const host = req.headers.get('host')
const scheme = req.headers.get('x-forwarded-proto')
return `${scheme}://${host}`
}

curl localhost:3000正常,但curl 主域名有问题

  • 确认防火墙端口(如3000)已放行
  • 解除selinux限制,可采用setenforce 0临时屏蔽

小结

本文介绍了如何在自己的服务器实现Next.js应用自托管,后面会再开一篇介绍【如何部署HTTPS证书】,这样就基本实现了Vercel最基本的两大功能。
本文旨在为与我情况类似的独立开发者提供了一条经济的路线选择,当然随着业务的增长,机器横向扩展、运维等带来的隐形成本不可忽视,因此大家根据自己的实际情况自行选择。或许等每月MRR能够cover服务器成本时,又会回归Vercel,这就是另外一码事了:)