如何使用Semaphore持续集成和交付构建Node.js应用程序并将其部署到DigitalOceanKubernetes

来自菜鸟教程
跳转至:导航、​搜索

作为 Write for DOnations 计划的一部分,作者选择了 Open Internet / Free Speech fund 来接受捐赠。

介绍

Kubernetes 允许用户使用单个命令创建弹性和可扩展的服务。 就像任何听起来好得令人难以置信的事情一样,它有一个问题:您必须首先准备一个合适的 Docker 映像并彻底测试它。

持续集成 (CI) 是在每次更新时测试应用程序的做法。 手动执行此操作既乏味又容易出错,但 CI 平台会为您运行测试,及早发现错误并定位引入错误的点。 发布和部署过程通常很复杂、耗时,并且需要可靠的构建环境。 使用 Continuous Delivery (CD),您可以在每次更新时构建和部署您的应用程序,而无需人工干预。

为了使整个过程自动化,您将使用 Semaphore,一个持续集成和交付 (CI/CD) 平台。

在本教程中,您将使用 Node.js 构建地址簿 API 服务。 API 公开了一个简单的 RESTful API 接口,用于在数据库中创建、删除和查找人员。 您将使用 Git 将代码推送到 GitHub。 然后,您将使用 Semaphore 测试应用程序,构建 Docker 映像,并将其部署到 DigitalOcean Kubernetes 集群。 对于数据库,您将使用 DigitalOcean Managed Databases 创建一个 PostgreSQL 集群。

先决条件

在继续阅读之前,请确保您具备以下条件:

  • DigitalOcean 帐户和个人访问令牌。 按照创建个人访问令牌为您的帐户设置一个。
  • 一个 Docker Hub 帐户。
  • 一个 GitHub 帐户。
  • 一个 Semaphore 帐户; 您可以使用您的 GitHub 帐户注册。
  • 用于该项目的名为 addressbook 的新 GitHub 存储库。 创建存储库时,选中 Initialize this repository with a README 复选框并在 Add .gitignore 菜单中选择 Node。 关注 GitHub 的 Create a Repo 帮助页面了解更多详情。
  • Git 安装在您的本地计算机上并 设置 以使用您的 GitHub 帐户。 如果您不熟悉或需要复习,请考虑阅读 如何使用 Git 参考指南。
  • curl 安装在本地机器上。
  • Node.js 安装在您的本地计算机上。 在本教程中,您将使用 Node.js 版本 10.16.0

第 1 步——创建数据库和 Kubernetes 集群

首先配置将为应用程序提供动力的服务:DigitalOcean 数据库集群和 DigitalOcean Kubernetes 集群。

登录到您的 DigitalOcean 帐户并创建一个项目。 项目可让您组织构成应用程序的所有资源。 调用项目 addressbook

接下来,创建一个 PostgreSQL 集群。 PostgreSQL 数据库服务将保存应用程序的数据。 您可以选择可用的最新版本。 服务准备好之前应该需要几分钟。

一旦 PostgreSQL 服务准备就绪,创建一个数据库和一个用户。 将数据库名称设置为 addessbook_db,并将用户名设置为 addressbook_user。 记下为新用户生成的密码。 数据库是 PostgreSQL 组织数据的方式。 通常,每个应用程序都有自己的数据库,尽管对此没有硬性规定。 应用程序将使用用户名和密码来访问数据库,以便保存和检索其数据。

最后,创建一个 Kubernetes 集群。 选择运行数据库的同一区域。 将集群命名为 addressbook-server 并将节点数设置为 3

在配置节点时,您可以开始构建应用程序。

第 2 步 — 编写应用程序

让我们构建您要部署的地址簿应用程序。 首先,克隆您在先决条件中创建的 GitHub 存储库,以便您拥有 GitHub 为您创建的 .gitignore 文件的本地副本,您将能够快速提交应用程序代码,而无需手动创建存储库。 打开浏览器并转到新的 GitHub 存储库。 单击 克隆或下载 按钮并复制提供的 URL。 使用 Git 将空存储库克隆到您的计算机:

git clone https://github.com/your_github_username/addressbook

进入项目目录:

cd addressbook

克隆存储库后,您可以开始编写应用程序。 您将构建两个组件:一个与数据库交互的模块,以及一个提供 HTTP 服务的模块。 数据库模块将知道如何从地址簿数据库中保存和检索人员,HTTP 模块将接收请求并做出相应的响应。

虽然不是严格强制性的,但在编写代码时测试代码是一种很好的做法,因此您还将创建一个测试模块。 这是应用程序的计划布局:

  • database.js:数据库模块。 它处理数据库操作。
  • app.js:最终用户模块和主应用程序。 它为用户提供连接的 HTTP 服务。
  • database.test.js:测试数据库模块。

此外,您需要为您的项目创建一个 package.json 文件,该文件描述了项目及其所需的依赖项。 您可以使用编辑器手动创建它,也可以使用 npm 交互地创建它。 运行 npm init 命令以交互方式创建文件:

npm init

该命令将要求提供一些信息以开始使用。 如示例中所示填写值。 如果您没有看到列出的答案,请将答案留空,这将使用括号中的默认值:

npm outputpackage name: (addressbook) addressbook
version: (1.0.0) 1.0.0
description: Addressbook API and database
entry point: (index.js) app.js
test command: 
git repository: URL for your GitHub repository
keywords: 
author: Sammy the Shark <sammy@example.com>"
license: (ISC) 
About to write to package.json:

{
  "name": "addressbook",
  "version": "1.0.0",
  "description": "Addressbook API and database",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}


Is this OK? (yes) yes

现在您可以开始编写代码了。 数据库是您正在开发的服务的核心。 在编写任何其他组件之前,必须拥有一个设计良好的数据库模型。 因此,从数据库代码开始是有意义的。

您不必编写应用程序的所有位; Node.js 有一个庞大的可重用模块库。 例如,如果项目中有 Sequelize ORM 模块,则无需编写任何 SQL 查询。 该模块提供了一个将数据库作为 JavaScript 对象和方法处理的接口。 它还可以在您的数据库中创建表。 Sequelize 需要 pg 模块才能与 PostgreSQL 一起使用。

使用带有 --save 选项的 npm install 命令安装模块,该选项告诉 npm 将模块保存在 package.json 中。 执行此命令以安装 sequelizepg

npm install --save sequelize pg

创建一个新的 JavaScript 文件来保存数据库代码:

nano database.js

通过将此行添加到文件中来导入 sequelize 模块:

数据库.js

const Sequelize = require('sequelize');

. . .

然后,在该行下方,使用您将从系统环境中检索的数据库连接参数初始化一个 sequelize 对象。 这样可以将凭据排除在您的代码之外,这样您就不会在将代码推送到 GitHub 时意外共享您的凭据。 您可以使用 process.env 访问环境变量,并使用 JavaScripts 的 || 运算符为未定义的变量设置默认值:

数据库.js

. . .

const sequelize = new Sequelize(process.env.DB_SCHEMA || 'postgres',
                                process.env.DB_USER || 'postgres',
                                process.env.DB_PASSWORD || '',
                                {
                                    host: process.env.DB_HOST || 'localhost',
                                    port: process.env.DB_PORT || 5432,
                                    dialect: 'postgres',
                                    dialectOptions: {
                                        ssl: process.env.DB_SSL == "true"
                                    }
                                });

. . .

现在定义 Person 模型。 为避免示例变得过于复杂,您将只创建两个字段:firstNamelastName,它们都存储字符串值。 添加以下代码来定义模型:

数据库.js

. . .

const Person = sequelize.define('Person', {
    firstName: {
        type: Sequelize.STRING,
        allowNull: false
    },
    lastName: {
        type: Sequelize.STRING,
        allowNull: true
    },
});

. . .

这定义了两个字段,使 firstNameallowNull: false 成为强制性的。 Sequelize 的 模型定义文档 显示了可用的数据类型和选项。

最后,导出 sequelize 对象和 Person 模型,以便其他模块可以使用它们:

数据库.js

. . .

module.exports = {
    sequelize: sequelize,
    Person: Person
};

在一个单独的文件中拥有一个表创建脚本很方便,您可以在开发过程中随时调用该脚本。 这些类型的文件称为 migrations。 创建一个新文件来保存此代码:

nano migrate.js

将这些行添加到文件中以导入您定义的数据库模型,并调用 sync() 函数来初始化数据库,从而为您的模型创建表:

迁移.js

var db = require('./database.js');
db.sequelize.sync();

应用程序正在系统环境变量中查找数据库连接信息。 创建一个名为 .env 的文件来保存这些值,您将在开发期间将其加载到环境中:

nano .env

将以下变量声明添加到文件中。 确保将 DB_HOSTDB_PORTDB_PASSWORD 设置为与您的 DigitalOcean PostgreSQL 集群关联的那些:

.env

export DB_SCHEMA=addressbook_db
export DB_USER=addressbook_user
export DB_PASSWORD=your_db_user_password
export DB_HOST=your_db_cluster_host
export DB_PORT=your_db_cluster_port
export DB_SSL=true
export PORT=3000

保存文件。

警告:永远不要将环境文件检入源代码管理。 他们通常有敏感信息。

由于您在创建存储库时定义了默认的 .gitignore 文件,因此该文件已被忽略。


您已准备好初始化数据库。 导入环境文件并运行migrate.js

source ./.env
node migrate.js

这将创建数据库表:

Output
Executing (default): CREATE TABLE IF NOT EXISTS "People" ("id"   SERIAL , "firstName" VARCHAR(255) NOT NULL, "lastName" VARCHAR(255), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));
Executing (default): SELECT i.relname AS name, ix.indisprimary AS primary, ix.indisunique AS unique, ix.indkey AS indkey, array_agg(a.attnum) as column_indexes, array_agg(a.attname) AS column_names, pg_get_indexdef(ix.indexrelid) AS definition FROM pg_class t, pg_class i, pg_index ix, pg_attribute a WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid AND a.attrelid = t.oid AND t.relkind = 'r' and t.relname = 'People' GROUP BY i.relname, ix.indexrelid, ix.indisprimary, ix.indisunique, ix.indkey ORDER BY i.relname;

输出显示两个命令。 第一个根据您的定义创建 People 表。 第二个命令通过在 PostgreSQL 目录中查找来检查该表是否确实被创建。

为您的代码创建测试是一种很好的做法。 通过测试,您可以验证代码的行为。 您可以为系统的每个功能、方法或任何其他部分编写检查,并验证它是否按您期望的方式工作,而无需手动测试。

jest 测试框架非常适合编写针对 Node.js 应用程序的测试。 Jest 扫描项目中的文件以查找测试文件,并一次执行一个。 使用 --save-dev 选项安装 Jest,它告诉 npm 该模块不需要运行程序,但它是开发应用程序的依赖项:

npm install --save-dev jest

您将编写测试来验证您是否可以从数据库中插入、读取和删除记录。 这些测试将验证您的数据库连接和权限配置是否正确,并且还将提供一些您以后可以在 CI/CD 管道中使用的测试。

创建 database.test.js 文件:

nano database.test.js

添加以下内容。 首先导入数据库代码:

数据库.test.js

const db = require('./database');

. . .

要确保数据库可以使用,请在 beforeAll 函数内调用 sync()

数据库.test.js

. . .

beforeAll(async () => {
    await db.sequelize.sync();
});

. . .

第一个测试在数据库中创建一个人员记录。 sequelize 库异步执行所有查询,这意味着它不等待查询结果。 要使测试等待结果以便您可以验证它们,您必须使用 asyncawait 关键字。 此测试调用 create() 方法在数据库中插入新行。 使用 expectperson.id 列与 1 进行比较。 如果您获得不同的值,则测试将失败:

数据库.test.js

. . .

test('create person', async () => {
    expect.assertions(1);
    const person = await db.Person.create({
        id: 1,
        firstName: 'Sammy',
        lastName: 'Davis Jr.',
        email: 'sammy@example.com'
    });
    expect(person.id).toEqual(1);
});

. . .

在下一个测试中,使用 findByPk() 方法检索具有 id=1 的行。 然后,验证 firstNamelastName 值。 再次使用 asyncawait

数据库.test.js

. . .

test('get person', async () => {
    expect.assertions(2);
    const person = await db.Person.findByPk(1);
    expect(person.firstName).toEqual('Sammy');
    expect(person.lastName).toEqual('Davis Jr.');
});

. . .

最后,测试从数据库中删除一个人。 destroy() 方法删除具有 id=1 的人。 为确保它正常工作,请尝试再次检索此人并检查返回的值是否为 null

数据库.test.js

. . .

test('delete person', async () => {
    expect.assertions(1);
    await db.Person.destroy({
        where: {
            id: 1
        }
    });
    const person = await db.Person.findByPk(1);
    expect(person).toBeNull();
});

. . .

最后,添加此代码以在所有测试完成后使用 close() 关闭与数据库的连接:

应用程序.js

. . .

afterAll(async () => {
    await db.sequelize.close();
});

保存文件。

jest 命令为您的程序运行测试套件,但您也可以将命令存储在 package.json 中。 在编辑器中打开此文件:

nano package.json

找到 scripts 关键字并替换现有的 test 行(这只是一个占位符)。 测试命令为jest

. . .

  "scripts": {
    "test": "jest"
  },

. . .

现在您可以调用 npm run test 来调用测试套件。 这可能是一个较长的命令,但是如果您以后需要修改jest命令,外部服务就不必更改; 他们可以继续呼叫 npm run test

运行测试:

npm run test

然后,检查结果:

Output  console.log node_modules/sequelize/lib/sequelize.js:1176
    Executing (default): CREATE TABLE IF NOT EXISTS "People" ("id"   SERIAL , "firstName" VARCHAR(255) NOT NULL, "lastName" VARCHAR(255), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY ("id"));

  console.log node_modules/sequelize/lib/sequelize.js:1176
    Executing (default): SELECT i.relname AS name, ix.indisprimary AS primary, ix.indisunique AS unique, ix.indkey AS indkey, array_agg(a.attnum) as column_indexes, array_agg(a.attname) AS column_names, pg_get_indexdef(ix.indexrelid) AS definition FROM pg_class t, pg_class i, pg_index ix, pg_attribute a WHERE t.oid = ix.indrelid AND i.oid = ix.indexrelid AND a.attrelid = t.oid AND t.relkind = 'r' and t.relname = 'People' GROUP BY i.relname, ix.indexrelid, ix.indisprimary, ix.indisunique, ix.indkey ORDER BY i.relname;

  console.log node_modules/sequelize/lib/sequelize.js:1176
    Executing (default): INSERT INTO "People" ("id","firstName","lastName","createdAt","updatedAt") VALUES ($1,$2,$3,$4,$5) RETURNING *;

  console.log node_modules/sequelize/lib/sequelize.js:1176
    Executing (default): SELECT "id", "firstName", "lastName", "createdAt", "updatedAt" FROM "People" AS "Person" WHERE "Person"."id" = 1;

  console.log node_modules/sequelize/lib/sequelize.js:1176
    Executing (default): DELETE FROM "People" WHERE "id" = 1

  console.log node_modules/sequelize/lib/sequelize.js:1176
    Executing (default): SELECT "id", "firstName", "lastName", "createdAt", "updatedAt" FROM "People" AS "Person" WHERE "Person"."id" = 1;

 PASS  ./database.test.js
  ✓ create person (344ms)
  ✓ get person (173ms)
  ✓ delete person (323ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        5.315s
Ran all test suites.

通过测试数据库代码,您可以构建 API 服务来管理通讯录中的人员。

要处理 HTTP 请求,您将使用 Express Web 框架。 安装 Express 并使用 npm install 将其保存为依赖项:

npm install --save express

您还需要 body-parser 模块,您将使用它来访问 HTTP 请求正文。 也将其安装为依赖项:

npm install --save body-parser 

创建主应用程序文件app.js

nano app.js

导入 expressbody-parserdatabase 模块。 然后创建一个名为 appexpress 模块实例来控制和配置服务。 您使用 app.use() 添加中间件等功能。 使用它来添加 body-parser 模块,以便应用程序可以读取 url-encoded 字符串:

应用程序.js

var express = require('express');
var bodyParser = require('body-parser');
var db = require('./database');
var app = express();
app.use(bodyParser.urlencoded({ extended: true }));

. . .

接下来,将路由添加到应用程序。 路由类似于应用程序或网站中的按钮; 它们会在您的应用程序中触发一些操作。 路由将唯一的 URL 链接到应用程序中的操作。 每条路线都将服务于特定的路径并支持不同的操作。

您将定义的第一个路由处理 /person/$ID 路径的 GET 请求,它将显示具有指定 ID 的人员的数据库记录。 Express 自动在 req.params.id 变量中设置请求的 $ID 的值。

应用程序必须回复编码为 JSON 字符串的人员数据。 正如您在数据库测试中所做的那样,使用 findByPk() 方法通过 id 检索人员并使用 HTTP 状态 200 (OK) 回复请求并将人员记录作为 JSON 发送。 添加以下代码:

应用程序.js

. . .

app.get("/person/:id", function(req, res) {
    db.Person.findByPk(req.params.id)
        .then( person => {
            res.status(200).send(JSON.stringify(person));
        })
        .catch( err => {
            res.status(500).send(JSON.stringify(err));
        });
});

. . .

错误会导致 catch() 中的代码被执行。 例如,如果数据库已关闭,则连接将失败,而这将改为执行。 如果出现问题,请将 HTTP 状态设置为 500(内部服务器错误)并将错误消息发送回用户:

添加另一条路线以在数据库中创建一个人。 该路由将处理 PUT 请求并从 req.body 访问人员的数据。 使用 create() 方法在数据库中插入一行:

应用程序.js

. . .

app.put("/person", function(req, res) {
    db.Person.create({
        firstName: req.body.firstName,
        lastName: req.body.lastName,
        id: req.body.id
    })
        .then( person => {
            res.status(200).send(JSON.stringify(person));
        })
        .catch( err => {
            res.status(500).send(JSON.stringify(err));
        });
});

. . .

添加另一个路由来处理 DELETE 请求,这将从通讯录中删除记录。 首先使用ID定位记录,然后使用destroy方法将其移除:

应用程序.js

. . .

app.delete("/person/:id", function(req, res) {
    db.Person.destroy({
        where: {
            id: req.params.id
        }
    })
        .then( () => {
            res.status(200).send();
        })
        .catch( err => {
            res.status(500).send(JSON.stringify(err));
        });
});

. . .

为方便起见,添加一个使用 /all 路径检索数据库中所有人的路由:

应用程序.js

. . .

app.get("/all", function(req, res) {
    db.Person.findAll()
        .then( persons => {
            res.status(200).send(JSON.stringify(persons));
        })
        .catch( err => {
            res.status(500).send(JSON.stringify(err));
        });
});

. . .

还剩最后一条路线。 如果请求与之前的任何路由都不匹配,则发送状态码 404(未找到):

应用程序.js

. . .

app.use(function(req, res) {
    res.status(404).send("404 - Not Found");
});

. . .

最后,添加 listen() 方法,启动服务。 如果定义了环境变量 PORT,则服务在该端口进行侦听; 否则,默认为端口 3000

应用程序.js

. . .

var server = app.listen(process.env.PORT || 3000, function() {
    console.log("app is running on port", server.address().port);
});

正如您所了解的,package.json 文件允许您定义各种命令来运行测试、启动应用程序和其他任务,这通常可以让您以更少的输入运行常用命令。 在 package.json 上添加新命令以启动应用程序。 编辑文件:

nano package.json

添加 start 命令,如下所示:

包.json

. . .

  "scripts": {
    "test": "jest",
    "start": "node app.js"
  },

. . .

不要忘记在上一行添加一个逗号,因为 scripts 部分需要用逗号分隔其条目。

保存文件并首次启动应用程序。 首先,用source加载环境文件; 这会将变量导入到会话中,并使它们可用于应用程序。 然后,使用 npm run start 启动应用程序:

source ./.env
npm run start

该应用程序在端口 3000 上启动:

Outputapp is running on port 3000

打开浏览器并导航到 http://localhost:3000/all。 您将看到一个显示 [] 的页面。

切换回您的终端并按 CTRL-C 停止应用程序。

现在是添加代码质量测试的好时机。 代码质量工具(也称为 linter)扫描项目中的代码问题。 诸如留下未使用的变量、不以分号结束语句或缺少花括号等不良编码实践可能会导致难以发现的错误。

安装 jshint 工具,一个 JavaScript linter,作为开发依赖:

npm install --save-dev jshint

多年来,JavaScript 收到了更新、功能和语法更改。 该语言已被 ECMA International 以“ECMAScript”的名义标准化。 大约每年一次,ECMA 发布具有新功能的新版本 ECMAScript。

默认情况下,jshint 假定您的代码与 ES6(ECMAScript 版本 6)兼容,如果发现该版本不支持的任何关键字,则会抛出错误。 您需要找到与您的代码兼容的版本。 如果您查看所有最新版本的 功能表 ,您会发现 async/await 关键字直到 ES8 才引入。 您在数据库测试代码中使用了这两个关键字,因此将最低兼容版本设置为 ES8。

要告诉 jshint 您正在使用的版本,请创建一个名为 .jshintrc 的文件:

nano .jshintrc

在文件中,指定 esversionjshintrc 文件使用 JSON,因此在文件中创建一个新的 JSON 对象:

.jshintrc

{ "esversion": 8 }

保存文件并退出编辑器。

添加命令以运行 jshint。 编辑package.json

nano package.json

package.jsonscripts 部分中将 lint 命令添加到您的项目中。 该命令针对您目前创建的所有 JavaScript 文件调用 lint 工具:

包.json

. . .

  "scripts": {
    "test": "jest",
    "start": "node app.js",
    "lint": "jshint app.js database*.js migrate.js"
  },

. . .

现在您可以运行 linter 来查找任何问题:

npm run lint

不应有任何错误消息:

Output> jshint app.js database*.js migrate.js

如果有任何错误,jshint 将显示有问题的行。

您已完成该项目并确保其正常工作。 将文件添加到存储库,提交并推送更改:

git add *.js
git add package*.json
git add .jshintrc
git commit -m 'initial commit'
git push origin master

现在您可以配置 Semaphore 以测试、构建和部署应用程序,首先使用您的 DigitalOcean 个人访问令牌和数据库凭据配置 Semaphore。

第 3 步 — 在 Semaphore 中创建 Secret

有些信息不属于 GitHub 存储库。 密码和 API 令牌就是很好的例子。 您已将此敏感数据存储在单独的文件中并将其加载到您的环境中,使用 Semaphore 时,您可以使用 Secrets 来存储敏感数据。

项目中存在三种秘密:

  • Docker Hub:您的 Docker Hub 帐户的用户名和密码。
  • DigitalOcean 个人访问令牌:将应用程序部署到您的 Kubernetes 集群。
  • 环境变量:用于数据库用户名和密码连接参数。

要创建第一个密钥,请打开浏览器并登录 Semaphore 网站。 在左侧导航菜单中,单击 CONFIGURATION 标题下的 Secrets。 单击 创建新密钥 按钮。

对于 密钥名称 ,输入 dockerhub。 然后在Environment Variables下,创建两个环境变量:

  • DOCKER_USERNAME:你的 DockerHub 用户名。
  • DOCKER_PASSWORD:你的 DockerHub 密码。

单击保存更改

为您的 DigitalOcean 个人访问令牌创建第二个秘密。 再次单击左侧导航菜单上的 Secrets,然后单击 Create New Secret。 将此密钥称为 do-access-token 并创建一个名为 DO_ACCESS_TOKEN 的环境值,并将值设置为您的个人访问令牌:

保存秘密。

对于下一个秘密,您将从项目的根目录上传 .env 文件,而不是直接设置环境变量。

创建一个名为 env-production 的新密钥。 在 Files 部分下,按 Upload file 链接找到并上传您的 .env 文件,并告诉 Semaphore 将其放置在 /home/semaphore/env-production

注意:因为文件是隐藏的,你可能无法在你的电脑上找到它。 通常有一个菜单项或组合键可以查看隐藏文件,例如CTRL+H。 如果一切都失败了,您可以尝试使用非隐藏名称复制文件:

cp .env env

然后上传文件并重命名:

cp env .env

环境变量都配置好了。 现在您可以开始持续集成设置。

第 4 步 — 将您的项目添加到 Semaphore

在这一步中,您将把项目添加到 Semaphore 并启动 持续集成 (CI) 管道

首先,将您的 GitHub 存储库与 Semaphore 链接:

  1. 登录到您的 Semaphore 帐户。
  2. 单击 PROJECTS 旁边的 + 图标。
  3. 单击存储库旁边的 Add Repository 按钮。

现在 Semaphore 已连接,它将自动获取存储库中的任何更改。

您现在已准备好为应用程序创建持续集成管道。 管道定义了您的代码必须经过的路径才能构建、测试和部署。 每次 GitHub 存储库发生更改时,管道都会自动运行。

首先,您应该确保 Semaphore 使用您在开发期间一直使用的相同版本的 Node。 您可以检查您的机器上正在运行的版本:

node -v
Outputv10.16.0

您可以通过在存储库中创建一个名为 .nvmrc 的文件来告诉 Semaphore 使用哪个版本的 Node.js。 在内部,Semaphore 使用 节点版本管理器 在 Node.js 版本之间切换。 创建 .nvmrc 文件并将版本设置为 10.16.0

echo '10.16.0' > .nvmrc

信号量管道进入 .semaphore 目录。 创建目录:

mkdir .semaphore

创建一个新的管道文件。 初始管道始终称为 semaphore.yml。 在此文件中,您将定义构建和测试应用程序所需的所有步骤。

nano .semaphore/semaphore.yml

注意:您正在创建YAML格式的文件。 您必须保留前导空格,如教程中所示。


第一行必须设置 Semaphore 文件版本; 当前稳定为 v1.0。 此外,管道需要一个名称。 将这些行添加到您的文件中:

.semaphore/semaphore.yml

version: v1.0
name: Addressbook

. . .

信号量自动配置虚拟机来运行任务。 有【X10X】多款机器可供选择【X45X】。 对于集成作业,请使用 e1-standard-2(2 个 CPU 4 GB RAM)和 Ubuntu 18.04 操作系统。 将这些行添加到文件中:

.semaphore/semaphore.yml

. . .

agent:
  machine:
    type: e1-standard-2
    os_image: ubuntu1804

. . .

Semaphore 使用 blocks 来组织任务。 每个块可以有一个或多个作业。 块中的所有作业并行运行,每个作业都在独立的机器中。 Semaphore 等待一个块中的所有作业都通过,然后再开始下一个作业。

首先定义第一个块,它会安装所有 JavaScript 依赖项以测试和运行应用程序:

.semaphore/semaphore.yml

. . .

blocks:
  - name: Install dependencies
    task:

. . .

您可以定义所有作业通用的环境变量,例如将 NODE_ENV 设置为 test,以便 Node.js 知道这是一个测试环境。 在 task 之后添加此代码:

.semaphore/semaphore.yml

. . .
    task:
      env_vars:
        - name: NODE_ENV
          value: test

. . .

prologue 部分中的命令在块中的每个作业之前执行。 这是定义设置任务的方便位置。 您可以使用 checkout 克隆 GitHub 存储库。 然后,nvm use 激活您在 .nvmrc 中指定的相应 Node.js 版本。 添加 prologue 部分:

.semaphore/semaphore.yml

    task:
. . .

      prologue:
        commands:
          - checkout
          - nvm use

. . .

接下来添加此代码以安装项目的依赖项。 为了加快作业速度,Semaphore 提供了 cache 工具。 您可以运行 cache storenode_modules 目录保存在 Semaphore 的缓存中。 cache 自动确定应该存储哪些文件和目录。 第二次执行作业时,cache restore 恢复目录。

.semaphore/semaphore.yml

. . .

      jobs:
        - name: npm install and cache
          commands:
            - cache restore
            - npm install
            - cache store 

. . .

添加另一个将运行两个作业的块。 一个运行 lint 测试,另一个运行应用程序的测试套件。

.semaphore/semaphore.yml

. . .

  - name: Tests
    task:
      env_vars:
        - name: NODE_ENV
          value: test
      prologue:
        commands:
          - checkout
          - nvm use
          - cache restore 

. . .

prologue 重复与前一个块中相同的命令,并从缓存中恢复 node_module。 由于此块将运行测试,因此您将 NODE_ENV 环境变量设置为 test

现在添加作业。 第一个作业使用 jshint 执行代码质量检查:

.semaphore/semaphore.yml

. . .

      jobs:
        - name: Static test
          commands:
            - npm run lint

. . .

下一个作业执行单元测试。 您需要一个数据库来运行它们,因为您不想使用生产数据库。 Semaphore的sem-service可以在完全隔离的测试环境中启动本地PostgreSQL数据库。 作业结束时数据库被销毁。 启动此服务并运行测试:

.semaphore/semaphore.yml

. . .

        - name: Unit test
          commands:
            - sem-service start postgres
            - npm run test

保存 .semaphore/semaphore.yml 文件。

现在添加并提交更改到 GitHub 存储库:

git add .nvmrc
git add .semaphore/semaphore.yml
git commit -m "continuous integration pipeline"
git push origin master

将代码推送到 GitHub 后,Semaphore 就会启动 CI 管道:

您可以单击管道以显示块和作业及其输出。

接下来,您将创建一个为应用程序构建 Docker 映像的新管道。

第 5 步 — 为应用程序构建 Docker 映像

Docker 镜像是 Kubernetes 部署的基本单元。 映像应包含运行应用程序所需的所有二进制文件、库和代码。 Docker 容器不是一个轻量级的虚拟机,但它的行为就像一个。 Docker Hub 注册表包含数百个现成的镜像,但我们将构建自己的镜像。

在此步骤中,您将添加一个新管道来为您的应用构建自定义 Docker 映像并将其推送到 Docker Hub。

要构建自定义图像,请创建 Dockerfile

nano Dockerfile

Dockerfile 是创建图像的配方。 您可以使用 official Node.js 发行版作为起点,而不是从头开始。 将此添加到您的 Dockerfile

Dockerfile

FROM node:10.16.0-alpine

. . .

然后添加复制package.jsonpackage-lock.json的命令,然后在镜像中安装节点模块:

Dockerfile

. . .

COPY package*.json ./
RUN npm install

. . .

首先安装依赖项将加速后续构建,因为 Docker 将缓存此步骤。

现在添加这个命令,它将项目根目录中的所有应用程序文件复制到映像中:

Dockerfile

. . .

COPY *.js ./

. . .

最后,EXPOSE 指定容器侦听应用程序正在侦听的端口 3000 上的连接,并且 CMD 设置在容器启动时应该运行的命令。 将这些行添加到您的文件中:

Dockerfile

. . .

EXPOSE 3000
CMD [ "npm", "run", "start" ]

保存文件。

完成 Dockerfile 后,您可以创建一个新管道,以便 Semaphore 可以在您将代码推送到 GitHub 时为您构建映像。 创建一个名为 docker-build.yml 的新文件:

nano .semaphore/docker-build.yml

使用与 CI 管道相同的样板启动管道,但名称为 Docker build

.semaphore/docker-build.yml

version: v1.0
name: Docker build
agent:
  machine:
    type: e1-standard-2
    os_image: ubuntu1804

. . .

该管道将只有一个块和一个作业。 在第 3 步中,您使用 Docker Hub 用户名和密码创建了一个名为 dockerhub 的密钥。 在这里,您将使用 secrets 关键字导入这些值。 添加此代码:

.semaphore/docker-build.yml

. . .

blocks:
  - name: Build
    task:
      secrets:
        - name: dockerhub

. . .

Docker 映像存储在存储库中。 我们将使用官方的 Docker Hub,它允许无限数量的公共镜像。 添加这些行以从 GitHub 签出代码并使用 docker login 命令向 Docker Hub 进行身份验证。

.semaphore/docker-build.yml

    task:
. . .

      prologue:
        commands:
          - checkout
          - echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin

. . .

每个 Docker 镜像都由名称和标签的组合完全标识。 名称通常对应于产品或软件,标签对应于软件的特定版本。 例如,node.10.16.0。 当没有提供标签时,Docker 默认使用特殊的 latest 标签。 因此,使用 latest 标签来引用最新图像被认为是一种很好的做法。

添加以下代码来构建镜像并将其推送到 Docker Hub:

.semaphore/docker-build.yml

. . .

      jobs:
      - name: Docker build
        commands:
          - docker pull "${DOCKER_USERNAME}/addressbook:latest" || true
          - docker build --cache-from "${DOCKER_USERNAME}/addressbook:latest" -t "${DOCKER_USERNAME}/addressbook:$SEMAPHORE_WORKFLOW_ID" .
          - docker push "${DOCKER_USERNAME}/addressbook:$SEMAPHORE_WORKFLOW_ID"

当 Docker 构建镜像时,它会重用现有镜像的一部分来加快进程。 第一个命令尝试从 Docker Hub 中提取 latest 映像,以便可以重用它。 如果任何命令返回的状态代码不为零,信号量将停止管道。 例如,如果存储库没有任何 latest 图像,因为它不会在第一次尝试时,管道将停止。 您可以通过将 || true 附加到命令来强制 Semaphore 忽略失败的命令。

第二个命令构建映像。 要稍后引用此特定图像,您可以使用唯一字符串对其进行标记。 Semaphore 为作业提供了几个 环境变量 。 其中之一,$SEMAPHORE_WORKFLOW_ID 是唯一的,并且在工作流中的所有管道之间共享。 稍后在部署中引用此图像很方便。

第三个命令将镜像推送到 Docker Hub。

构建管道已准备就绪,但除非您将其连接到主 CI 管道,否则 Semaphore 不会启动它。 您可以使用 promotions 链接多个管道以创建复杂的多分支工作流。

编辑主管道文件.semaphore/semaphore.yml

nano .semaphore/semaphore.yml

在文件末尾添加以下行:

.semaphore/semaphore.yml

. . .

promotions:
  - name: Dockerize
    pipeline_file: docker-build.yml
    auto_promote_on:
      - result: passed

auto_promote_on 定义了启动 docker build 管道的条件。 在这种情况下,它会在 semaphore.yml 文件中定义的所有作业都通过时运行。

要测试新管道,您需要添加、提交并将所有修改后的文件推送到 GitHub:

git add Dockerfile
git add .semaphore/docker-build.yml
git add .semaphore/semaphore.yml
git commit -m "docker build pipeline"
git push origin master

CI 管道完成后,Docker 构建管道启动。

完成后,您将在 Docker Hub 存储库 中看到新映像。

您已经完成了构建过程测试并创建了映像。 现在,您将创建最终管道以将应用程序部署到您的 Kubernetes 集群。

第 6 步 — 设置到 Kubernetes 的持续部署

Kubernetes 部署的构建块是 pod。 Pod 是一组容器,它们作为一个单元进行管理。 pod 内的容器同步启动和停止,并且始终在同一台机器上运行,共享其资源。 每个 pod 都有一个 IP 地址。 在这种情况下,Pod 将只有一个容器。

豆荚是短暂的; 它们经常被创建和销毁。 在每个 pod 启动之前,您无法确定将分配给每个 pod 的 IP 地址。 为了解决这个问题,您将使用 services,它具有固定的公共 IP 地址,因此传入的连接可以负载平衡并转发到 pod。

您可以直接管理 pod,但最好让 Kubernetes 使用 部署 来处理。 在本节中,您将创建一个说明性清单来描述您的集群的最终所需状态。 清单有两个资源:

  • 部署:根据需要启动集群节点中的 Pod,并跟踪它们的状态。 由于在本教程中我们使用的是 3 节点集群,因此我们将部署 3 个 Pod。
  • 服务:作为我们用户的入口点。 监听端口 80 (HTTP) 上的流量并将连接转发到 Pod。

创建一个名为 deployment.yml 的清单文件:

nano deployment.yml

使用 Deployment 资源启动清单。 将以下内容添加到新文件以定义部署:

部署.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: addressbook
spec:
  replicas: 3
  selector:
    matchLabels:
      app: addressbook
  template:
    metadata:
      labels:
        app: addressbook
    spec:
      containers:
        - name: addressbook
          image: ${DOCKER_USERNAME}/addressbook:${SEMAPHORE_WORKFLOW_ID}
          env:
            - name: NODE_ENV
              value: "production"
            - name: PORT
              value: "$PORT"
            - name: DB_SCHEMA
              value: "$DB_SCHEMA"
            - name: DB_USER
              value: "$DB_USER"
            - name: DB_PASSWORD
              value: "$DB_PASSWORD"
            - name: DB_HOST
              value: "$DB_HOST"
            - name: DB_PORT
              value: "$DB_PORT"
            - name: DB_SSL
              value: "$DB_SSL"


. . .

对于清单中的每个资源,您需要设置一个 apiVersion。 对于部署,请使用稳定版本 apiVersion: apps/v1。 然后,告诉 Kubernetes 这个资源是一个带有 kind: Deployment 的 Deployment。 每个定义都应该有一个在 metadata.name 中定义的名称。

spec 部分中,您告诉 Kubernetes 所需的最终状态是什么。 此定义要求 Kubernetes 应使用 replicas: 3 创建 3 个 pod。

Labels 是用于组织和交叉引用 Kubernetes 资源的键值对。 您可以使用 metadata.labels 定义标签,并且可以使用 selector.matchLabels 查找匹配的标签。 这就是您将元素连接在一起的方式。

spec.template 定义了 Kubernetes 将用来创建每个 pod 的模型。 在 spec.template.metadata.labels 中,您为 pod 设置了一个标签:app: addressbook

使用 spec.selector.matchLabels,您可以让部署管理带有标签 app: addressbook 的任何 pod。 在这种情况下,您要让这个部署负责所有的 pod。

最后,您定义在 pod 中运行的映像。 在 spec.template.spec.containers 中设置图像名称。 Kubernetes 会根据需要从注册表中拉取镜像。 在这种情况下,它将从 Docker Hub 中提取)。 您还可以为容器设置环境变量,这很幸运,因为您需要为数据库连接提供多个值。

为了保持部署清单的灵活性,您将依赖变量。 但是,YAML 格式不允许使用变量,因此该文件还无效。 当您为 Semaphore 定义部署管道时,您将解决该问题。

这就是部署。 但这仅定义了 pod。 您仍然需要允许流量流向您的 pod 的服务。 只要使用三个连字符 (---) 作为分隔符,就可以在同一个文件中添加另一个 Kubernetes 资源。

添加以下代码以定义连接到带有 addressbook 标签的 Pod 的负载均衡器服务:

部署.yml

. . .

---

apiVersion: v1
kind: Service
metadata:
  name: addressbook-lb
spec:
  selector:
    app: addressbook
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 3000

负载均衡器将接收端口 80 上的连接,并将它们转发到应用程序正在侦听的 Pod 端口 3000

保存文件。

现在,为 Semaphore 创建一个部署管道,它将使用清单部署应用程序。 在.semaphore目录下新建文件:

nano .semaphore/deploy-k8s.yml

像往常一样开始管道,指定版本、名称和图像:

.semaphore/deploy-k8s.yml

version: v1.0
name: Deploy to Kubernetes
agent:
  machine:
    type: e1-standard-2
    os_image: ubuntu1804

. . .

该管道将有两个块。 第一个块将应用程序部署到 Kubernetes 集群。

定义块并导入所有秘密:

.semaphore/deploy-k8s.yml

. . .

blocks:
  - name: Deploy to Kubernetes
    task:
      secrets:
        - name: dockerhub
        - name: do-access-token
        - name: env-production

. . .

将您的 DigitalOcean Kubernetes 集群名称存储在环境变量中,以便您以后可以引用它:

.semaphore/deploy-k8s.yml

. . .

      env_vars:
        - name: CLUSTER_NAME
          value: addressbook-server

. . .

DigitalOcean Kubernetes 集群通过两个程序的组合进行管理:kubectldoctl。 前者已经包含在 Semaphore 的镜像中,而后者没有,所以你需要安装它。 您可以使用 prologue 部分来执行此操作。

添加此序言部分:

.semaphore/deploy-k8s.yml

. . .

      prologue:
        commands:
          - wget https://github.com/digitalocean/doctl/releases/download/v1.20.0/doctl-1.20.0-linux-amd64.tar.gz
          - tar xf doctl-1.20.0-linux-amd64.tar.gz 
          - sudo cp doctl /usr/local/bin
          - doctl auth init --access-token $DO_ACCESS_TOKEN
          - doctl kubernetes cluster kubeconfig save "${CLUSTER_NAME}"
          - checkout

. . .

第一条命令下载doctl官方releasewget。 第二个命令用 tar 解压并复制到本地路径。 安装 doctl 后,它可用于向 DigitalOcean API 进行身份验证,并为我们的集群请求 Kubernetes 配置文件。 查看我们的代码后,我们完成了 prologue

接下来是我们管道的最后一部分:部署到集群。

请记住,deployment.yml 中有一些环境变量,而 YAML 不允许这样做。 因此,当前状态下的 deployment.yml 将不起作用。 为了解决这个问题,source 环境文件加载变量,然后使用 envsubst 命令用实际值就地扩展变量。 结果是一个名为 deploy.yml 的文件,它是插入了值的完全有效的 YAML。 准备好文件后,您可以使用 kubectl apply 开始部署:

.semaphore/deploy-k8s.yml

. . .

      jobs:
      - name: Deploy
        commands:
          - source $HOME/env-production
          - envsubst < deployment.yml | tee deploy.yml
          - kubectl apply -f deploy.yml

. . .

第二个块将 latest 标签添加到 Docker Hub 上的映像,以表示这是部署的最新版本。 重复 Docker 登录步骤,然后拉取、重新标记并推送到 Docker Hub:

.semaphore/deploy-k8s.yml

. . .

  - name: Tag latest release
    task:
      secrets:
        - name: dockerhub
      prologue:
        commands:
          - checkout
          - echo "${DOCKER_PASSWORD}" | docker login -u "${DOCKER_USERNAME}" --password-stdin
          - checkout
      jobs:
      - name: docker tag latest
        commands:
          - docker pull "${DOCKER_USERNAME}/addressbook:$SEMAPHORE_WORKFLOW_ID" 
          - docker tag "${DOCKER_USERNAME}/addressbook:$SEMAPHORE_WORKFLOW_ID" "${DOCKER_USERNAME}/addressbook:latest"
          - docker push "${DOCKER_USERNAME}/addressbook:latest"

保存文件。

此管道执行部署,但只有在成功生成 Docker 映像并推送到 Docker Hub 时才能启动。 因此,您必须将构建和部署管道与提升联系起来。 编辑 Docker 构建管道以添加它:

nano .semaphore/docker-build.yml

将促销添加到文件末尾:

.semaphore/docker-build.yml

. . .

promotions:
  - name: Deploy to Kubernetes
    pipeline_file: deploy-k8s.yml
    auto_promote_on:
      - result: passed

您已完成 CI/CD 工作流程的设置。

剩下的就是推送修改后的文件并让 Semaphore 完成工作。 添加、提交和推送存储库的更改:

git add .semaphore/deploy-k8s.yml
git add .semaphore/docker-build.yml
git add deployment.yml
git commit -m "kubernetes deploy pipeline"
git push origin master

部署需要几分钟才能完成。

接下来让我们测试应用程序。

第 7 步 — 测试应用程序

此时,应用程序已启动并运行。 在此步骤中,您将使用 curl 来测试 API 端点。

您需要知道 DigitalOcean 为您的集群提供的公共 IP。 请按照以下步骤找到它:

  1. 登录到您的 DigitalOcean 帐户。
  2. 选择通讯录项目
  3. 转到网络
  4. 单击 负载平衡器
  5. 显示 IP 地址。 复制 IP 地址。

让我们使用 curl 检查 /all 路线:

curl -w "\n" YOUR_CLUSTER_IP/all 

您可以使用 -w "\n" 选项来确保 curl 打印所有行:

由于数据库中还没有记录,因此结果是一个空的 JSON 数组:

Output[]

通过向 /person 端点发出 PUT 请求来创建新的人员记录:

curl -w "\n" -X PUT \
  -d "firstName=Sammy&lastName=the Shark" YOUR_CLUSTER_IP/person

API 返回人员的 JSON 对象:

Output{
    "id": 1,
    "firstName": "Sammy",
    "lastName": "the Shark",
    "updatedAt": "2019-07-04T23:51:00.548Z",
    "createdAt": "2019-07-04T23:51:00.548Z"
}

创建第二个人:

curl -w "\n" -X PUT \
  -d "firstName=Tommy&lastName=the Octopus" YOUR_CLUSTER_IP/person

输出表明创建了第二个人:

Output{
    "id": 2,
    "firstName": "Tommy",
    "lastName": "the Octopus",
    "updatedAt": "2019-07-04T23:52:08.724Z",
    "createdAt": "2019-07-04T23:52:08.724Z"
}

现在发出 GET 请求以获取具有 2id 的人:

curl -w "\n" YOUR_CLUSTER_IP/person/2

服务器回复您请求的数据:

Output{
    "id": 2,
    "firstName": "Tommy",
    "lastName": "the Octopus",
    "createdAt": "2019-07-04T23:52:08.724Z",
    "updatedAt": "2019-07-04T23:52:08.724Z"
}

要删除此人,请发送 DELETE 请求:

curl -w "\n" -X DELETE YOUR_CLUSTER_IP/person/2 

此命令不返回任何输出。

您的数据库中应该只有一个人,即具有 1id 的人。 再次尝试获取 /all

curl -w "\n" YOUR_CLUSTER_IP/all 

服务器回复一组仅包含一条记录的人员:

Output[
    {
        "id": 1,
        "firstName": "Sammy",
        "lastName": "the Shark",
        "createdAt": "2019-07-04T23:51:00.548Z",
        "updatedAt": "2019-07-04T23:51:00.548Z"
    }
]

此时,数据库中只剩下一个人。

这完成了我们应用程序中所有端点的测试并标志着教程的结束。

结论

在本教程中,您从头开始编写了一个完整的 Node.js 应用程序,该应用程序使用了 DigitalOcean 的托管 PostgreSQL 数据库服务。 然后,您使用 Semaphore 的 CI/CD 管道完全自动化了测试和构建容器映像、将其上传到 Docker Hub 并将其部署到 DigitalOcean Kubernetes 的工作流。

要了解有关 Kubernetes 的更多信息,您可以阅读 Kubernetes 简介 和 DigitalOcean 的 Kubernetes 教程 的其余部分。

现在您的应用程序已部署完毕,您可以考虑添加域名保护您的数据库集群,或为您的数据库设置警报