Github Actions + Docker + Nestjs 实现自动化部署

833 阅读10分钟

前言

本篇文章将介绍我本人从 0 到 1 实现一个 nestjs 服务自动化部署的过程。

起步

我们做以下几项准备:

  1. 创建一个 nestjs 工程
  2. 集成 redismysql
  3. 安装 Docker Desktop 以及 mysqlredis
  4. 制作镜像、容器互联、以及使用 Docker Compose 快速编排
  5. 手动部署工程到服务器

有经验的可以直接跳过这一步~

准备创建一个 nestjs 的新工程:

nest new nest-demo
cd nest-test

注意,我这因为 3000 端口在使用,所以修改为了 4396,正常默认是 3000 端口

image-24.png

随后访问: http://localhost:4396 正常的服务 mysqlredis 是必不可少的,我们这里使用 docker 去安装创建

为什么要使用 Docker 部署?

  1. 可移植性:我们在部署服务的时候很多情况下需要考虑宿主机器环境差异,而 Docker 通过容器化技术,允许开发者将应用程序及其依赖环境打包在一起,形成一个轻量级、独立的容器,这就消除了因环境差异导致的问题
  2. 环境隔离:也就是说每一个容器都是独立的,可以独立部署,并且可以单独升级或删除,相当于是每个应用程序都自己住在独立的公寓互不影响,还会带来安全性升级
  3. 简化部署过程:Docker 容器化技术可以轻松实现应用的打包、分发、部署,简化了应用的部署过程,可以减少部署出错的概率,可以在 Docker 环境下快速打开、运行
  4. 可扩展性: Docker 容器可以快速复制、水平扩展
  5. 版本控制:Docker 容器每一个镜像都有自己的版本号,可以轻松实现回滚、发版
  6. 强大的生态系统支持:Docker Hub 社区内有成千上万的镜像,和丰富的资源可以快速使用

安装 Docker Desktop

这一步直接去 Docker 官网下载桌面端即可,桌面端可以帮助我们更简单的管理容器、管理镜像。www.docker.com/

安装 redis & mysql

打开安装好的 Docker 桌面端,搜索安装 redismysql,后并运行

tinywow_redis_61721169.gif

tinywow_mysql_61721353.gif

工程集成 redis 和 mysql

使用 npm 安装 redis 和 mysql以及相关依赖包,然后在工程中集成进行测试

npm install @nestjs/typeorm mysql2 typeorm redis

app.module.ts 集成 redistypeorm 模块,如下:

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { createClient } from 'redis';
import { TestEntity } from './test.entity';
import { EntityManager } from 'typeorm';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'mysql',
      host: 'localhost',
      port: 3306,
      username: 'root',
      password: <your password>,
      database: 'actions-test',
      synchronize: true,
      logging: true,
      autoLoadEntities: true,
      connectorPackage: 'mysql2',
      entities: [TestEntity],
      poolSize: 10,
    }),
  ],
  controllers: [AppController],
  providers: [
    {
      provide: 'REDIS_CLIENT',
      useFactory: async () => {
        const client = createClient({
          socket: {
            host: 'localhost',
            port: 6379,
          },
        });
        await client.connect();
        return client;
      },
    },
    AppService,
  ],
})
export class AppModule {
  constructor(entity: EntityManager) {
    // 初始化时插入一条数据
    entity.save(TestEntity, {
      name: '小明' + Date.now(),
    });
  }
}

在工程下创建一个mysql实体,test.entity.ts,如下:

import { Column, Entity,PrimaryGeneratedColumn } from 'typeorm';

@Entity()
export class TestEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: 'varchar',
    length: 255,
  })
  name: string;
}
  1. 初始化链接 mysqlredis
  2. 初始化时给 mysql 插入一条数据,用于后续的测试

修改app.controller.ts和 app.service.ts,如下:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('/redis')
  redis() {
    return this.appService.getRedisKey();
  }

  @Get('/mysql')
  mysql() {
    return this.appService.getMysqlData();
  }
}

app.service.ts

import { Inject, Injectable } from '@nestjs/common';
import { InjectEntityManager } from '@nestjs/typeorm';
import { RedisClientType } from 'redis';
import { TestEntity } from './test.entity';
import { EntityManager } from 'typeorm';

@Injectable()
export class AppService {
  @Inject('REDIS_CLIENT')
  private readonly redisClient: RedisClientType;

  @InjectEntityManager()
  private readonly testEntity: EntityManager;

  async getRedisKey() {
    await this.redisClient.set('key', 'redis测试');
    return await this.redisClient.get('key');
  }

  async getMysqlData() {
    const test = await this.testEntity.getRepository(TestEntity);
    return test.find();
  }
}

运行项目访问: http://localhost:4396/redis http://localhost:4396/mysql

image-1.png

image-2.png

后续可以把代码提交到 github 上,方便后续部署。

创建 Docker 镜像和容器

在根目录下创建 Docerfile 文件,如下:

# 使用node镜像
FROM node:latest

# 工作目录
WORKDIR /app

# 复制package.json文件到当前目录,方便我们安装依赖包
COPY package.json .

# 设置npm镜像
RUN npm config set registry https://registry.npmmirror.com/

# 安装依赖包
RUN npm install

# 复制整个项目
COPY . .

# 构建项目
RUN npm run build

# 暴露端口
EXPOSE 4396

CMD [ "node", "./dist/main.js" ]

然后我们打包我们的镜像

docker build . -t actions-test:0.1

这个时候会自动根据我们目录下的 Dockerfile 去构建出我们的镜像,一般我们安装依赖时最好设置下 npm 国内镜像,这样构建速度会更快 最终我们可以在 Docker 桌面端中查看到我们的镜像:

image-3.png 我们直接点击运行按钮运行我们的服务:

😱额,果然出意外了!!! 这是因为我们的服务运行在了一个独立的容器内,改容器内没有 redismysql,这个时候需要用到 Docker network帮助我们实现容器互联

使用 docker network 容器互联
# 创建网络 actions-test-net
docker network create -d bridge actions-test-net

随后我们需要在 Docker 桌面端中把在运行中的 redismysql 删除,因为他们没有加入我们创建的这个网络

随后我们通过命令行去运行 redismysql 容器,并把他们加入网络

# 运行 redis 容器
docker run -d -it --name redis --network actions-test-net redis

# 运行 mysql 容器
docker run -d -e MYSQL_ROOT_PASSWORD=<your password> -e MYSQL_DATABASE=actions-test -it --name mysql --network actions-test-net mysql

修改app.module.tsredismysqlhost 为容器的名称 mysql

image-4.png image-5.png redis

image-6.png 重新打包镜像

docker build . -t actions-test:0.2

docker run -d -p 4396:4396 --name actions-test --network actions-test-net actions-test:0.2

我们再去访问这两个地址就会发现已经正常了 http://localhost:4396/mysql http://localhost:4396/redis

每次都这样进行容器互联太麻烦了,而且真实情况我们每次都要在服务器上去这样运行。有没有一种更好的方式呢🤔🤔?欢迎Docker Compose登场!!!👏👏

Docker Compose 容器编排

Docker Compose 是一个用于定义和运行多容器 Docker 应用程序的工具。通过 docker-compose.yml 文件,我们可以定义一个应用程序的所有服务,并指定每个服务的容器镜像、环境变量、端口映射等配置信息。 它可以帮助我们解决以下问题:

  1. 多容器互联
  2. 多个容器之间编排

我们创建一个 dokcer-compose.yml 文件,如下:

version: '3.8'
services:
  action-test:
    build:
      context: ./
      dockerfile: ./Dockerfile
    # 依赖项
    depends_on:
      - mysql
      - redis
    ports:
      - '4396:4396'
    # 容器重启策略
    restart: always
    networks:
      - common-network
  mysql:
    image: mysql
    ports:
      - '3306:3306'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci # 设置默认字符集
    environment:
      MYSQL_DATABASE: actions-test
      MYSQL_ROOT_PASSWORD: <your password>
    restart: always
    networks:
      - common-network
  redis:
    image: redis
    ports:
      - '6379:6379'
    restart: always
    networks:
      - common-network
networks:
  common-network:
    driver: bridge

我们把之前的容器都给删除一下,然后重新运行 docker-compose.yml 文件

docker-compose up -d

这样就是容器运行成功了,我们同样按照上面那样去访问测试下

image-7.png

手动部署服务器

使用 ssh 登入服务器:

ssh <your user>@<your server ip>
mkdir /var/www/service && cd /var/www/service
git clone <your github repo>
cd <your project name>
docker-compose up -d

这样直接在服务器上使用 docker compose 编排容器就可以了

image-8.png 这样就运行成功了,访问ip:port就行了,我这边是 http://39.106.130.29:4396/mysql http://39.106.130.29:4396/redis

# 销毁容器
docker-compose down

# 查看容器状态
docker-compose ps

# 查看容器日志
docker-compose logs

集成自动化部署

上面我们已经实现了手动部署,大致流程也已经了解了,那么结合Github Actions,我们就可以实现自动化部署了,大致流程如下:

  1. 修改代码并 pushGithub
  2. 触发 Github Actions 的工作流程
    • 构建镜像
    • 推送到镜像仓库
    • ssh 登录到服务器,更新镜像重启容器

配置 Github Actions

我们在项目根目录下创建 .github/workflows/nest-test.yml 文件,如下:

注意:我这里是该仓库有多个工程,所以指定工程名称,分别在 docker/build-push-action@v5 中指定 on push 处指定触发条件

.github/workflows/nest-test.yml

name: 服务部署
on:
  push:
    branches:
      - master
    # 设置那些文件发生改变才触发,我这里是因为有多个仓库,所以需要设置
    paths:
      - "nest-test/**"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Get Metadata
        uses: docker/metadata-action@v5
        id: metadata
        with:
          images: thankslyh/nest-test
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{secrets.DOCKER_USER}}
          password: ${{secrets.DOCKER_PASSWORD}}
      - name: Build and push to Docker Hub
        uses: docker/build-push-action@v5
        with:
          # 注意:我这里是该仓库有多个工程,所以指定工程名称
          context: ./nest-test
          # 构建出来的镜像的 tag,因为我们 on push 处指定了 master,所以这里指定 master
          tags: ${{ steps.metadata.outputs.tags }}
          labels: ${{ steps.metadata.outputs.labels }}
          push: true
      - name: Update
        uses: appleboy/ssh-action@v1.0.3
        with:
          password: ${{ secrets.ESC_PASSWORD }}
          # 部署目标主机
          host: ${{ secrets.ESC_HOST }}
          # 登录用户
          username: ${{ secrets.ESC_USER }}
          script: docker login --username=${{secrets.DOCKER_USER}} --password=${{secrets.DOCKER_PASSWORD}} && cd /var/www/actions-test/nest-test && git pull && docker-compose down && docker-compose pull && docker-compose up -d

接下来我们需要在 github 上创建以下这些 secrets

  1. DOCKER_USER - Docker Hub 用户名
  2. DOCKER_PASSWORD - Docker Hub 密码
  3. ESC_HOST - 部署目标主机
  4. ESC_PASSWORD - 部署目标主机密码

image-9.png

注意事项:

  1. 使用secrets来保护敏感信息,比如密码,token等。这样不会在构建的时候被 log 出来同时在配置里也看不见
  2. 因为我们上面使用的metadata-action这个包,它会从 Git RefrenceDocker 的事件中提取信息,然后我们指定的是提取出来的信息中的 tag 作为我们镜像的 tag,所以这里的 tagmaster

设置 ESC 服务器DockerHub 镜像加速 Docker 国内镜像加速

同时需要修改docker-compose.yml 文件,nest-test 容器的 image 改为 我们自己的镜像名称,如下:

image-11.png 可以登录 Docker Hub 进行查看自己的镜像,我这里是thankslyh/nest-test

image-10.png

这样我们修改下工程代码,在 app.controler.ts里增加一个路由,用来测试部署结果

...
@Get('/deploy')
  deploy() {
    return 'deploy success';
  }
...

然后 pushGithub,然后就可以看到 Github Actions 的工作流执行完成

访问 http://39.106.130.29:4396/deploy

到这里我们完成了自动化部署,但是还有问题,每次部署都太慢了要好几分钟,有什么办法加速下?

优化自动化部署

我们要优化构建,首先我们从流程节点中去分析慢的节点都有哪些,从上面流程我们大概能知道完整的流程节点如下:

  1. Checkout 检出代码
  2. 获取元信息
  3. 登录到 Docker Hub
  4. 打包镜像并上传 Docker Hub
    1. 历时 2m51s
  5. ssh 登录服务器,更新代码、更新镜像、重新启动
    1. 50s

image-13.png

但是从上图其中可以看出来,主要耗时在下面两个阶段:

  1. 构建镜像并上传,耗时 2 分 51 秒
  2. ssh 登录服务器,更新代码、更新镜像、重新启动 50s

所以主要的优化就针对于这两个大的阶段了。

构建优化

构建优化主要针对于打包镜像的优化,该阶段影响部署速度主要分为以下三个方面:

  1. 构建镜像的耗时
  2. 镜像上传到镜像仓库的耗时
    • 镜像大小
    • 带宽(无法控制,可以氪金)

现状镜像大小 1.5G,构建时长 200s 左右,主要是安装依赖阶段

image-14.png

image-15.png 我们可以通过以下方式优化构建镜像的耗时:

  1. 使用alpine的基础镜像,减少镜像体积
  2. 多阶段构建,降低复杂性、减少依赖、减小文件大小、节约构建时间
  3. 使用使用 .dockerignore 忽略文件,减少不必要的文件打包到镜像里,减少镜像体积

创建 .dockeringore 文件:

*.md
!README.md
node_modules/
[a-c].txt
.git/
.DS_Store
.vscode/
.dockerignore
.eslintignore
.eslintrc
.prettierrc
.prettierignore

我们修改 Dockerfile 如下:

# 使用alpine基础镜像
FROM node:alpine AS builder

WORKDIR /app

COPY package.json .

RUN npm config set registry https://registry.npmmirror.com/

RUN npm install

COPY . .

RUN npm run build

FROM node:alpine AS runner

COPY --from=builder /app/dist /app
COPY --from=builder /app/package.json /app/package.json

WORKDIR /app

RUN npm config set registry https://registry.npmmirror.com/

RUN npm install --omit=dev

EXPOSE 4396

CMD [ "node", "./dist/main.js" ]

构建镜像后,镜像体积减小了 1.5G -> 500M以下,构建时长 200s -> 150s 左右

image-16.png

我们修改业务代码部署下看看效果如何:

image-17.png

时间缩短到了1 分 55s 和 27s 左右,比之前快了 50%左右,但是比较奇怪的是依赖安装速度还是比较慢,两次阶段的依赖安装时间分别是 80s 和 15s,很奇怪

image-18.png

去掉 npm 的镜像

大胆假设小心推理,既然依赖安装的过程是在 Github Actions 的过程中(理论上本身就是在外网),那是不是就不需要设置 npm 镜像了?理论上是可以的,试试看看 我们把 Dockerfile 中的镜像配置去掉,修改 app.controller.ts,修改返回结果为"deploy success2"

image-19.png

部署代码看看,奈斯!现在构建时间已经优化到不到 2 分钟了

image-20.png image-21.png

镜像上传阿里云

因为 Docker Hub 的地址是在国外,虽然我们增加了 Docker Hub 的镜像加速,但还是可能存在不稳定情况,我们修改为使用阿里云的镜像服务,直接把镜像部署到阿里云

阿里云镜像服务 创建命名空间

image-22.png 获取访问凭证

image-23.png

随后我们修改 docker-compose.yml 文件,把镜像地址改为阿里云的镜像地址,如下:

version: '3.8'
services:
  action-test:
    # 这里是${镜像地址}/${命名空间}/${镜像名称}:${镜像标签}
    image: registry.cn-beijing.aliyuncs.com/thankslyh/nest-test:master
    ...

同时我们修改 .github/workflows/nest-test.yml 如下:

name: 服务部署
...
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      ...
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          # 设置登录的仓库
          registry: registry.cn-beijing.aliyuncs.com
          username: ${{secrets.ALI_DOCKER_USER}}
          password: ${{secrets.ALI_DOCKER_PASS}}
      ...
      - name: Update
        uses: appleboy/ssh-action@v1.0.3
        with:
          password: ${{ secrets.ESC_PASSWORD }}
          # 部署目标主机
          host: ${{ secrets.ESC_HOST }}
          # 登录用户
          username: ${{ secrets.ESC_USER }}
          script: docker login --username=${{secrets.ALI_DOCKER_USER}} --password=${{secrets.ALI_DOCKER_PASS}} registry.cn-beijing.aliyuncs.com && cd /var/www/actions-test/nest-test && docker-compose down && docker-compose pull && docker-compose up -d

注意修改项:

  1. 增加了登录仓库地址 registry.cn-beijing.aliyuncs.com,需要改成你自己的
  2. 在 appleboy/ssh-action 里的 script 中增加了指定登录镜像地址的命令 docker login --username=${{secrets.ALI_DOCKER_USER}} --password=${{secrets.ALI_DOCKER_PASS}} registry.cn-beijing.aliyuncs.com

同时我们这里因为新增了两个secrets变量,所以需要到 github 的设置里添加两个变量:

ALI_DOCKER_USER - 阿里云登录账号 ALI_DOCKER_PASS - 阿里云登录密码

修改工程代码 push master 试试,成功了

虽然速度慢了点,但是好处是稳定,到这里我们整体的流程就完成啦

完整版

Dockerfile

FROM node:alpine AS builder

WORKDIR /app

COPY package.json .

# RUN npm config set registry https://registry.npmmirror.com/

# 需要打包所以需要完整的依赖
RUN npm install

COPY . .

RUN npm run build

FROM node:alpine AS runner

COPY --from=builder /app/dist /app
COPY --from=builder /app/package.json /app/package.json

# RUN npm config set registry https://registry.npmmirror.com/

WORKDIR /app

# 移除开发依赖
RUN npm install --omit=dev

EXPOSE 4396

CMD [ "node", "/app/main.js" ]

.github/workflows/nest-test.yml

name: 服务部署
on:
  push:
    branches:
      - master
    # 设置那些文件发生改变才触发,我这里是因为有多个仓库,所以需要设置
    paths:
      - "nest-test/src/**"
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Get Metadata
        uses: docker/metadata-action@v5
        id: metadata
        with:
          images: registry.cn-beijing.aliyuncs.com/thankslyh/nest-test
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          # 设置登录的仓库
          registry: registry.cn-beijing.aliyuncs.com
          username: ${{secrets.ALI_DOCKER_USER}}
          password: ${{secrets.ALI_DOCKER_PASS}}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          # 注意:我这里是该仓库下有多个工程,所以需要指定路径
          context: ./nest-test
          tags: ${{ steps.metadata.outputs.tags }}
          labels: ${{ steps.metadata.outputs.labels }}
          push: true
      - name: Update
        uses: appleboy/ssh-action@v1.0.3
        with:
          password: ${{ secrets.ESC_PASSWORD }}
          # 部署目标主机
          host: ${{ secrets.ESC_HOST }}
          # 登录用户
          username: ${{ secrets.ESC_USER }}
          script: docker login --username=${{secrets.ALI_DOCKER_USER}} --password=${{secrets.ALI_DOCKER_PASS}} registry.cn-beijing.aliyuncs.com && cd /var/www/actions-test/nest-test && docker-compose down && docker-compose pull && docker-compose up -d

docker-compose.yml

version: '3.8'
services:
  action-test:
    image: registry.cn-beijing.aliyuncs.com/thankslyh/nest-test:master
    depends_on:
      - mysql
      - redis
    ports:
      - '4396:4396'
    restart: always
    networks:
      - common-network
  mysql:
    image: mysql
    ports:
      - '3306:3306'
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci # 设置默认字符集
    environment:
      MYSQL_DATABASE: actions-test
      MYSQL_ROOT_PASSWORD: <your password>
    restart: always
    networks:
      - common-network
  redis:
    image: redis
    ports:
      - '6379:6379'
    restart: always
    networks:
      - common-network
networks:
  common-network:
    driver: bridge

总结

  1. 我们通过 Github Actions实现了 Docker 镜像的自动打包、上传和部署
  2. 我们通过分析构建部署过程优化了构建镜像的耗时和上传镜像的耗时
    • 使用alpine的基础镜像,减少镜像体积
    • 多阶段构建,降低复杂性、减少依赖、减小文件大小、节约构建时间
    • 使用使用 .dockerignore 忽略文件,减少不必要的文件打包到镜像里,减少镜像体积
    • 去掉 npm 的镜像,加快依赖安装速度
  3. 我们把镜像部署到阿里云,提升了部署的稳定性

源码地址

套个盾:非专业人员,文中有错误的地方欢迎指出交流,谢谢~~ 看到这里了如果觉得有帮助,麻烦点个赞~~

参考

yeasy.gitbook.io/docker_prac… github.com/docker/meta… github.com/docker/buil… github.com/appleboy/ss…