fengzifz.com

水则资车,旱则资舟

0%

之前写过一篇关于利用 gitlab 实现持续集成和部署的文章,见:在 gitlab.com 上配置持续集成和持续部署

然而,当我们的业务的测试环境和生产环境是分离的,并且生产环境也可以支持 CI/CD 时,我们也可以配置让其实现自动化,而不用每次手动去更新。

1. 在 gitlab.com 配置多个 gitlab-runner

实质上,就是配置多个 gitlab-runner,登录 gitlab.com 或者是你自己的自建 gitlab 服务器,然后打开项目的 settings > CI/CD > Runners,由于 gitlab 支持配置多个 gitlab-runner,所以我们只需要复制 registration token,到对应的服务器注册 gitlab-runner,然后指定不同的 tag 就可以了。

Read more »

最近几十天,像发了一场噩梦。

噩梦的开始

今年 2 月的时候,女儿出生,由于南方现在的天气反复无常,女儿在还没满月的时候,感冒了。去当地的三甲医院看了医生,开了两个周期的药,差不多痊愈了。

然而,在女儿满月之后,当时不知道为什么女儿老是呛奶,然而我们基于第一胎的喂养经验,并没有过多的重视这个问题。每次女儿呛奶的时候,老婆都竖起抱着她拍背(敲黑板:误区)。

呛奶的情况,从女儿满月的时候开始,呛的次数越来越多。而且我们听到女儿的呼吸有痰音,期间也从未停止过看医生,基本每 3 天就去复查。医院换了 3、4 家,医生也换了好几个,一直诊断是“感冒 + 支气管炎”,甚至还有诊断“消化不良”。

后来女儿开始连吃药也呛,她很抗拒吃药,而且吃药时,呛得特别厉害。

Read more »

在 PHP 领域,Laravel 的 migration 是挺好用的,通过命令去创建 migration、升级和回滚数据库、初始化等。

但在 golang 里面,我暂时没发现有哪个 migration 工具像 Laravel 那样,像 GORM 这个功能强大的 DB 库,其 migration 的功能也略显简单,不过 GORM 的重心是在 ORM 方面。

然而,用 golang 实现一个 migration 工具,其实也十分简单的,我们可以模拟 laravel 的 migration 来实现它。下面我将说说用 golang 来实现 migration 工具的思路和部分代码。

如果想直接查看源码的朋友,可以看这里:fengzifz/migration-go

思路

先了解 Laravel migration 的几个常用命令:

创建 migration:

1
2
3
4
5
6
7
8
9
10
11
12
# 创建 user 表
php artisan make:migration create_users_table

# 更新 user 表
php artisan make:migration add_age_column_for_users_table

# 更新数据库
php artisan migrate

# 回滚数据库
php artisan rollback
php artisan rollback --step=2

上面两个命令运行之后,对应的 migration 的内容是不太一样的,因为 Laravel 提供了两种模板文件,如果 migration 的名字包含 create_ 前缀和 _table 后缀,那么程序会自动把中间的当成表的名字,调用创建表的 migration 模板来生成 migration 文件。否则,就是用另一种模板来创建。

按照上面的套路,我们用 golang 这样去实现:

  1. 准备基本的 SQL 创建表的模板;
  2. 根据命令,选择对应的模板,去生成对应的 migration 文件;
  3. 编写升级和回滚的函数。

下面是用 golang 实现

1. 编写 SQL 模板
1
2
3
4
5
6

createTableSql := "CREATE TABLE DummyTable (\n" +
"id int(10) UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY, \n" +
"created_at timestamp NULL DEFAULT NULL, \n" +
"updated_at timestamp NULL DEFAULT NULL\n" +
");"

到时可以根据用户的命令行输入的 migration 的名字,来替换上述字符串中的 DummyTable

2. 创建 migration 文件

这里用一种比较基本的方式来创建 migration 管理文件。我们根据用户输入的 migration 文字,加上时间作为前缀,创建一个目录,在目录里面创建一个 up.sqldown.sql 文件,分别表示升级和回滚。

1
2
3
4
5
6
# 目录结构
database
|- migrations
|- 20190419084915_create_user_fields_table
|- up.sql
|- down.sql

实现代码:

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
func CreateMigration(name string) (string, error) {

var (
err error
upFile *os.File
downFile *os.File
)

timestamp := time.Now().Format("20060102150405")
str := []string{migrationPath, timestamp, "_", name}
dirName := strings.Join(str, "")
createDir(dirName)

// Match table creation
// use CreateMigration.stub template for table creation
// use blank.stub template for others
reg := regexp.MustCompile(`^create_(\w+)_table$`)

upFile, err = os.Create(dirName + "/up.sql")
if err != nil {
return "", err
}

downFile, err = os.Create(dirName + "/down.sql")
if err != nil {
return "", err
}

defer upFile.Close()
defer downFile.Close()

upWriter := bufio.NewWriter(upFile)
downWriter := bufio.NewWriter(downFile)

if reg.MatchString(name) {
r := strings.NewReplacer("create_", "", "_table", "")
tableName := r.Replace(name)
_, err = upWriter.WriteString(strings.Replace(createTableSql, "DummyTable", tableName, -1))
if err != nil {
return "", err
}

upWriter.Flush()

_, err = downWriter.WriteString(strings.Replace(dropTableSql, "DummyTable", tableName, -1))
if err != nil {
return "", err
}

downWriter.Flush()
} else {
_, err = upWriter.WriteString("")
if err != nil {
return "", err
}

_, err = downWriter.WriteString("")
if err != nil {
return "", err
}
}

color.Green("Created: %v", name)
return dirName, nil
}
3. 升级和回滚
  • 升级:读取 /database/migrations 目录下面的所有子目录名字,然后从时间最近的一个目录开始,一个个地和 migration 表里面的最后一条记录对比,然后决定哪几个版本的 migration 需要升级。然后一个个地读取对应 migration 目录里面的 up.sql 的内容,进行升级;
  • 回滚:根据输入的命令的回滚的步数,如果回滚一步,那么就读取最后一条 migration 的记录,获取对应的 migration 目录名字,查找对应的 down.sql 进行回滚。回滚 n 步时同理。

实现代码:

升级 migration:

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
func Migrate() error {
var (
fSlices []string
arr []string
batch int
files []os.FileInfo
err error
rows *sql.Rows
lastBatch int
dbMigrate []string
toMigrate []string
m *Migration
insertStr string
symbol string
upSql []byte
)

// List migrations files
files, err = ioutil.ReadDir(migrationPath)
if err != nil {
return err
}

for _, f := range files {
arr = strings.Split(f.Name(), ".")
fSlices = append(fSlices, arr[0])
}

// Check migration version in database
rows, err = db.Query(queryAllMigrationSql)
if err != nil {
return err
}

lastRow := db.QueryRow(queryLastMigrationSql)
lastRow.Scan(&lastBatch)
batch = lastBatch + 1

defer rows.Close()

if lastBatch == 0 {
// No migration record in database, all migrations should to be Migrate
toMigrate = fSlices
} else {
// Get migrated files' name
for rows.Next() {
m, err = scanRow(rows)
if err != nil {
return err
}

dbMigrate = append(dbMigrate, m.Migration)
}

// Compare and get which migration not migrated yet
for _, v := range fSlices {
if !sliceContain(dbMigrate, v) {
toMigrate = append(toMigrate, v)
}
}
}

// Nothing to Migrate, stop and log fatal
toMigrateLen := len(toMigrate)
if toMigrateLen == 0 {
color.Blue("Nothing migrated")
os.Exit(2)
}

// Migrate
for i, v := range toMigrate {

// Read up.sql
upSql, err = ioutil.ReadFile(migrationPath + v + "/up.sql")
if err != nil {
return err
}

_, err = db.Exec(string(upSql))
if err != nil {
return err
}

color.Green("Migrated: %v", v)

// Calculate the batch number, which is need to Migrate
if i+1 == toMigrateLen {
symbol = ""
} else {
symbol = ","
}

insertStr += "('" + v + "', " + strconv.Itoa(batch) + ")" + symbol
}

// Connect sql update statement
updateMigrationSql = strings.Replace(updateMigrationSql, "DummyString", insertStr, -1)

_, err = db.Exec(updateMigrationSql)
if err != nil {
return err
}

return nil
}

回滚 migration:

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
func Rollback(step string) error {

var (
lastBatch int
toBatch int
err error
rows *sql.Rows
rollBackMig []string
m *Migration
downSql []byte
)

lastRow := db.QueryRow(queryLastMigrationSql)
lastRow.Scan(&lastBatch)

if i, err := strconv.Atoi(step); err == nil {
if lastBatch >= i {
toBatch = lastBatch - (i - 1)
} else {
color.Red("Can not Rollback %d steps", i)
return err
}
}

// Which migrations need to be Rollback
rows, err = db.Query("SELECT * FROM migrations WHERE `batch`>=" + strconv.Itoa(toBatch))
if err != nil {
return err
}

// Rollback slice
for rows.Next() {
m, err = scanRow(rows)
if err != nil {
return err
}

rollBackMig = append(rollBackMig, m.Migration)
}

// Rolling back
for _, v := range rollBackMig {

downSql, err = ioutil.ReadFile(migrationPath + v + "/down.sql")
if err != nil {
return err
}

_, err = db.Exec(string(downSql))
if err != nil {
return err
}

color.Green("Rollback: %s", v)
}

// Delete migrations record
_, err = db.Exec("DELETE FROM migrations WHERE `batch`>=" + strconv.Itoa(toBatch))
if err != nil {
return err
}

return nil
}
4. 命令支持

我们在 main 函数里面,读取用户输入的命令,然后再决定执行哪个函数。

在 go 里面,可以用 os.Args 来获取脚本的命令和参数。我们现在对命令做如下约束:

  • 创建 migration:<脚本> make:migration <名字>
  • 升级 migration:<脚本> up
  • 回滚 migration:<脚本> down <步数?>,步数是可选,不填是默认是 1 步

实现代码:

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
func main() {
command := os.Args[1]

if strings.Compare(command, "make:migration") == 0 {

// ***********************
// CreateMigration a migration file
// ./migrate make xxx
// ***********************

fileName := os.Args[2]

if len(fileName) < 0 {
color.Red("Please enter a migration file name")
os.Exit(2)
}

_, err := CreateMigration(fileName)
checkErr(err)

color.Green("Create migration successfully!")

} else if strings.Compare(command, "up") == 0 {

// ****************
// Migrate database
// ./migrate up
// ****************
err := Migrate()
checkErr(err)

color.Green("Migrate successfully!")

} else if strings.Compare(command, "down") == 0 {

// ********
// Rollback
// ./migrate down OR ./migrate down 3
// ********
var step string
if len(os.Args) < 3 {
// Default step is 1
step = "1"
} else {
step = os.Args[2]
}

err := Rollback(step)
checkErr(err)

color.Green("Rollback successfully!")

}
}

现在,我们把 migration.go 编译成可执行文件 migration,然后直接运行如下命令:

创建 migration:

1
./migrate make:migration create_users_table

结果:

升级 migration:

1
./migrate up

结果:

回滚:

1
./migrate down

结果:

代码仓库

可以查看我的 github 仓库,查看详细代码:fengzifz/migration-go

封装了一个函数,用来判断某个 IP 是否在指定的 IP 段。例如:

$range 支持 3 种写法:

Wildcard

1
2
3
$ip = '192.168.1.203';
$range = '192.168.*.*';
ipInRange($ip, $range); // true

CIRD

1
2
3
$ip = '192.168.1.203';
$range = '192.168.1/24';
ipInRange($ip, $range); // true

开始-结束

1
2
3
$ip = '192.168.1.203';
$range = '192.168.1.1-192.168.1.255';
ipInRange($ip, $range); // true

以下是封装好的函数:

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
/**
* $range 支持多种写法
* - Wildcard: 1.2.3.*
* - CIRD:1.2.3/24 或者 1.2.3.4/255.255.255.0
* - Start-End: 1.2.3.0-1.2.3.255
* @param $ip
* @param $range
* @return bool
*/
public function ipInRange($ip, $range)
{
if (strpos($range, '/') !== false) {
// $range is in IP/NETMASK format
list($range, $netmask) = explode('/', $range, 2);
if (strpos($netmask, '.') !== false) {
// $netmask is a 255.255.0.0 format
$netmask = str_replace('*', '0', $netmask);
$netmask_dec = ip2long($netmask);
return ((ip2long($ip) & $netmask_dec) == (ip2long($range) & $netmask_dec));
} else {
// $netmask is a CIDR size block
// fix the range argument
$x = explode('.', $range);
while (count($x) < 4) {
$x[] = '0';
}
list($a, $b, $c, $d) = $x;
$range = sprintf("%u.%u.%u.%u", empty($a) ? '0' : $a, empty($b) ? '0' : $b, empty($c) ? '0' : $c,
empty($d) ? '0' : $d);
$range_dec = ip2long($range);
$ip_dec = ip2long($ip);
# Strategy 1 - Create the netmask with 'netmask' 1s and then fill it to 32 with 0s
#$netmask_dec = bindec(str_pad('', $netmask, '1') . str_pad('', 32-$netmask, '0'));
# Strategy 2 - Use math to create it
$wildcard_dec = pow(2, (32 - $netmask)) - 1;
$netmask_dec = ~$wildcard_dec;
return (($ip_dec & $netmask_dec) == ($range_dec & $netmask_dec));
}
} else {
// range might be 255.255.*.* or 1.2.3.0-1.2.3.255
if (strpos($range, '*') !== false) { // a.b.*.* format
// Just convert to A-B format by setting * to 0 for A and 255 for B
$lower = str_replace('*', '0', $range);
$upper = str_replace('*', '255', $range);
$range = "$lower-$upper";
}
if (strpos($range, '-') !== false) { // A-B format
list($lower, $upper) = explode('-', $range, 2);
$lower_dec = (float)sprintf("%u", ip2long($lower));
$upper_dec = (float)sprintf("%u", ip2long($upper));
$ip_dec = (float)sprintf("%u", ip2long($ip));
return (($ip_dec >= $lower_dec) && ($ip_dec <= $upper_dec));
}
return false;
}
}

在 2016 年写过一篇在 ubuntu 上面配置 let’s encrypt 证书的文章,见:配置免费 HTTPS - letsencrypt ssl

然而,在 Ubuntu 18 下面,当运行 ./certbot-auto certonly xxx 时,可能出现的错误是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Creating virtual environment...
Traceback (most recent call last):
File "/usr/lib/python3/dist-packages/virtualenv.py", line 2363, in <module>
main()
File "/usr/lib/python3/dist-packages/virtualenv.py", line 719, in main
symlink=options.symlink)
File "/usr/lib/python3/dist-packages/virtualenv.py", line 988, in create_environment
download=download,
File "/usr/lib/python3/dist-packages/virtualenv.py", line 918, in install_wheel
call_subprocess(cmd, show_stdout=False, extra_env=env, stdin=SCRIPT)
File "/usr/lib/python3/dist-packages/virtualenv.py", line 812, in call_subprocess
% (cmd_desc, proc.returncode))

OSError: Command /opt/eff.org/certbot/venv/bin/python2.7 - setuptools pkg_resources pip wheel failed with error code 2
# 或者是(注意两个 error code 是不一样的)
OSError: Command /opt/eff.org/certbot/venv/bin/python2.7 - setuptools pkg_resources pip wheel failed with error code 1

这个主要是由于系统里面安装了多个 python 版本导致的。

解决办法

如果以上两种方法,也无法解决你的问题,请使用如下命令:

1
2
apt update
apt install certbot

安装完 certbot 之后,创建证书:

1
2
# 运行之前,请确保你的域名是可访问的
certbot certonly --agree-tos --email admin@example.com --webroot -w /data/www/xxx -d example.com -d www.example.com

成功之后,你会看到类似的提示:

1
2
3
4
5
6
7
8
9
10
11
12
13
IMPORTANT NOTES:
- Congratulations! Your certificate and chain have been saved at:
/etc/letsencrypt/live/example.com/fullchain.pem
Your key file has been saved at:
/etc/letsencrypt/live/example.com/privkey.pem
Your cert will expire on 2019-02-13. To obtain a new or tweaked
version of this certificate in the future, simply run certbot
again. To non-interactively renew *all* of your certificates, run
"certbot renew"
- If you like Certbot, please consider supporting our work by:

Donating to ISRG / Let's Encrypt: https://letsencrypt.org/donate
Donating to EFF: https://eff.org/donate-le

剩下的配置,仍然可以参考我 2016 年写的文章:配置免费 HTTPS - letsencrypt ssl

完。

参考

在 Google 宣布 Kotlin 成为 Android 的开发语言之后,Kotlin 才开始慢慢进入人民的视野。到现在为止,已经 1 年多过去了,网络上已经逐渐出现各种 Kotlin 的教程。而且,Kotlin 其灵活和简单的语法,也受到很多开发者的青睐。

然而,我个人认为,Kotlin 要完全取代 Java,未来 5 年也是不行的。Java 经过这么长时间的发展,其生态圈聚集了各行各业的精英,工具也十分完善。

但我们仍然可以学习一下这门新兴的语言,具备 Java 基础的朋友,上手 Kotlin 也是十分容易,毕竟触类旁通嘛。

今天,本文章只是简单介绍一下如何使用 Kotlin 创建一个简单的 Adapter。

环境

  • MacOS 10.14
  • Android Studio v3.2
  • Kotlin 1.2

仓库

如想直接运行 demo 的,请前往:Base Adapter Demo 克隆,欢迎 star。

什么是 Adapter

在开发 App 时,经常要使用列表,在实际应用中,我们的列表 (ListView/GridView) 的数据不可能是静态的,往往是从某些数据源 (Data Source) 获取而来。而适配器 (Adapter),则是列表和数据源之间的纽带,它可以把数据映射到列表上面。

下图简单直观地显示出它们的关系。

Android Adapter 的类型有很多,详见:Android Adapter (需要科学上网),本文只简单介绍如何利用 BaseAdapter 来创建列表,其他类型的 Adapter 这里不一一介绍。

用 Kotlin 创建 Android BaseAdapter

本文要求你:

  • 对 Android Studio 的使用有初步的了解;
  • 对 Kotlin 的基础语法有一定的了解;
  • 对 Android 开发有一定的了解。

在你新建了一个基于 Kotlin 的项目之后,它会默认包含一个 MainActivity

现在,我们通过 Android Studio 另外创建两个类:

  • AnimalsListActivity
  • AnimalsAdapter

从文件名字便可知道,AnimalsListActivity 是列表,AnimalsAdapter 是 Adapter。

首先,先完成 Adapter 的部分。
这里需要注意,继承系统的 BaseAdapter,需要在自定义的适配器里面重写下面几个方法:

  • getView
  • getItem
  • getItemId
  • getCount
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
package com.fengzifz.animalsound

import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.TextView

// items 的类型,应当根据实际情况而定,由于这只是一个简单的 demo,故意设定成 String
class AnimalsAdapter(private var activity: Activity, private var items: List<String>) : BaseAdapter() {

private class ViewHolder(view: View?) {
var name: TextView? = null

init {
this.name = view?.findViewById(R.id.name)
}
}

@SuppressLint("InflateParams")
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {

val view: View?
val viewHolder: ViewHolder

if (convertView == null) {
val inflater = activity.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater

// 需要创建视图 animals_list
view = inflater.inflate(R.layout.animals_list, null)
viewHolder = ViewHolder(view)
view?.tag = viewHolder
} else {
view = convertView
viewHolder = view.tag as ViewHolder
}

viewHolder.name?.text = items[position]

return view as View
}

override fun getItem(position: Int): String {
return items[position]
}

override fun getItemId(position: Int): Long {
return position.toLong()
}

override fun getCount(): Int {
return items.size
}

}

然后,是 AnimalsListActivity

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
package com.fengzifz.animalsound

import android.os.Bundle
import android.support.v7.app.AppCompatActivity;
import kotlinx.android.synthetic.main.activity_animals.*
import kotlinx.android.synthetic.main.content_animals.*

class AnimalsListActivity : AppCompatActivity() {

// 测试数据,实际应用中,应当从数据源获取数据
private var listData = listOf(
"Dog", "Cat", "Pig", "Hen", "Fish"
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_animals)
setSupportActionBar(toolbar)

animal_list.adapter = AnimalsAdapter(this, listData)

}

}

最后,创建视图文件。
在主视图里面,加入:

1
2
3
4
5
6
<!-- 当然,你使用 ListView 也可以 -->
<GridView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:numColumns="3"
android:id="@+id/animal_list"/>

animals_list 视图:

1
2
3
4
5
6
<?xml version="1.0" encoding="utf-8"?>
<!-- 你可以设置成你需要的标签,这里随便设置一个 button -->
<Button
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="match_parent"
android:layout_width="match_parent" android:id="@+id/name"/>

运行效果:
demobaseadapter

参考

运行环境

  • Ubuntu 16
  • gitlab.com
  • gitlab-runner v11

问题描述

上一篇 在 gitlab.com 上配置持续集成和持续部署 介绍过如何在 gitlab.com 上面部署 CI,但你马上就可以发现另一个问题:gitlab-runner 和 www-data 用户的权限问题。

按照官网的文档安装 gitlab-runner,它演示了创建一个 gitlab-runner 用户来运行 gitlab-runner 服务,但因为官网的文档并没有具体教你如何用脚本进行部署,所以它到这里为止,也并没有涉及权限的问题。

一般来说,Web 目录的所有者是 www-data,而我们在创建部署 gitlab-runner 时,它是由 gitlab-runner 用户来运行的。因此,进行部署时,如果 web 目录不是属于 gitlab-runner 用户,那么它是没有权限把更新内容 fetch 或 clone 到 web 目录里面。


解决办法

如果你还没有创建和运行 gitlab-runner,那么,按照 gitlab 安装 gitlab-runner 教程,在第 4 个步骤时,把这条命令跳过:

1
sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash

然后把第 5 个步骤的命令改成:

1
2
# www-data 用户默认的 home 目录是 /var/www
sudo gitlab-runner install --user=www-data --working-directory=/var/www

但如果你已经运行了 gitlab-runner,那么:

  1. 先 uninstall gitlab-runner;
  2. 重新 install;
  3. 重启 gitlab-runner。
1
2
3
4
5
6
7
8
# 1. uninstall gitlab-runner
gitlab-runner uninstall

# 2. 重新 install
gitlab-runner install --user=www-data --working-directory=/var/www

# 3. 重启 gitlab-runner
gitlab-runner restart

然后,别忘记 double check 一次,你的 web 目录的所有权是不是属于 www-data 用户,如果不是,则需要运行:

1
chown 33.33 -R /xxx/xxx/www

最后,把之前部署在 /home/gitlab-runner 下面的脚本和 ssh key 等,移到 /var/www 并修改 owner 即可。

这篇文章,拖了很久了。

Gitlab 和 Github 一样,是一个代码托管平台,而 Gitlab 还提供了部署到私有云的安装包。不管是直接使用 gitlab.com,还是自行部署 Gitlab,免费版的 Gitlab 都够我们大部分人使用了。而且免费的 Gitlab 也提供了 CI/CD 服务,免费版的 gitlab.com 提供每个分组每个月有 2000 个 pipeline,对于独立开发者或小团队,基本足够。

看这里可以了解 gitlab.com 的会员等级以及他们的特权:gitlab.com pricing

名词解析

  • **Continuous Integration (CI)**:这里说的 CI,是指持续集成,并不是 PHP 框架 CodeIgniter;
  • **Continuous Development (CD)**:持续部署;
  • gitlab-runner:运行在生产环境服务器上的一个服务;
  • Pipeline:每一次需要进行 CI 的提交,Gitlab 都会创建一个 pipeline,并根据用户自己指定的策略来进行测试/编译/部署;
  • .gitlab-ci.yml:运行 gitlab ci 的配置文件;
  • stage:阶段,一个 pipeline 可以指定几个阶段;
  • Jobs:任务,每一个 stage 都会生成一个任务。

CI/CD 流程

如下图所示,在 CI 阶段,会进行编译,测试等操作

而在 CD 阶段,进行代码 review,发布等。在 gitlab.com 上面,购买 Starter ($4/月),还提供了代码质量检查的服务。

下图通过流水线的方式,更加直观地展示出 gitlab 的 CI/CD 的作用。

配置 Gitlab CI/CD

(本文所有的配置均在 gitlab.com 上面进行,理论上是和自行部署的 gitlab 是一样的。)

1. 注册帐号

首先,你得要有一个 gitlab.com 的帐号,没有的请先打开 gitlab.com 进行注册。

2. 创建项目

点击顶部菜单的“新建”按钮,然后点击 “New project”,按照提示填入项目信息。

然后把仓库拉取到本地。这里我们以 Laravel 5.5 为例,我们创建一个新的 Laravel 项目(参考:Laravel installation)。

3. 创建 Gitlab CI 配置文件

新建 .gitlab-ci.yml 文件,然后填入如下信息:

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
stages:
- test
- deploy

# Variables
variables:
MYSQL_ROOT_PASSWORD: root
MYSQL_USER: homestead
MYSQL_PASSWORD: secret
MYSQL_DATABASE: homestead
DB_HOST: mysql

# Speed up builds
cache:
key: $CI_BUILD_REF_NAME # changed to $CI_COMMIT_REF_NAME in Gitlab 9.x
paths:
- vendor
- node_modules
- public
- .yarn

test:
stage: test
services:
- mysql:5.7
image: edbizarro/gitlab-ci-pipeline-php:7.2-alpine
script:
- sudo yarn config set cache-folder .yarn
- yarn install --pure-lockfile
- composer install --prefer-dist --no-ansi --no-interaction --no-progress
- cp .env.example .env
- php artisan key:generate
- php artisan migrate:refresh --seed
- ./vendor/phpunit/phpunit/phpunit -v --coverage-text --colors=never --stderr
artifacts:
paths:
- ./storage/logs # for debugging
expire_in: 1 days
when: always
only:
- develop
- master

deploy_production:
stage: deploy
script:
- <在这里执行部署命令,这里后面会详细说>
environment:
name: production
url: https://fengzifz.com
only:
- master
tags:
- production

  • stages:定义有哪些阶段,我们定义了 testdeploy 两个阶段;
  • variables:数据库配置信息,这里我们默认跟 .env.example 保持一致,因为下面测试时,使用了 homestead 来集成测试环境;
  • cache:缓存,像一些公共类库,可以直接使用缓存;
  • test:这个 test 对应我们之前在 stages 里面定义的 test
  • deploy_production:在测试通过之后,我们就直接部署到生产环境。

Gitlab 会根据 stages 定义阶段的顺序来执行,它会先创建一个 Job 来执行 test stage,当 test 通过之后,再创建一个新的 Job 来执行 depoly。如果 test 不通过,那么 pipeline 就会显示 fail 信息。

上面是一个简单的例子,我们在 pipeline 里面只定义了简单的 testdeploy,流程是当测试通过之后,就部署到生产环境。

而在一些更加复杂的工程里面,我们需要做一些灰度发布时,可以在 deploy 阶段指定多个 Job,例如:

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
stages:
- test
- deploy

(略...)

deploy_staging:
stage: deploy
script:
- <在这里执行部署命令,这里后面会详细说>
environment:
name: staging
url: https://staging.fengzifz.com
only:
- master
tags:
- staging


deploy_production:
stage: deploy
script:
- <在这里执行部署命令,这里后面会详细说>
environment:
name: production
url: https://fengzifz.com
when: manual
only:
- master
tags:
- production

按照上面的配置文件,当 master 分支有代码更新时,Gitlab 就会自动把更新内容发布到 staging 环境。注意在 deploy_production 里面,指定了 when:manual,即当我们在必须手动触发部署时,更新内容才会发布到生产环境。
更多配置请参考官方文档:Introduction to environments and deployments

4. 上传代码

为了方便测试,我们直接在 master 上面上传代码。上传后,打开 gitlab.com 上面,对应的项目页面,点击左菜单的 CI/CD > Pipelines,然后会看到它会自动生成一个 pipeline,并显示 running

打开 CI/CD > Jobs,会看到,gitlab 也根据在 .gitlab-ci.yml 所定义的任务,自动创建对应的 Job。

打开 Operations > Environments,gitlab 会根据在 deploy 阶段定义的 environment,自动创建对应的环境,在这里,可以进行一些重新发布或回滚等操作。

5. 配置自动部署

根据上面 1 - 4 配置好之后,虽然 pipeline 显示 pass,但最终是没有自动部署的,因为我们还没配置好自动部署。

Gitlab 的 CD,主要是使用 gitlab-runner 服务。

安装 gitlab-runner

通过 ssh 登录到你的服务器,然后安装 gitlab-runner。

这里不做搬运工了,安装方法请直接请参考官方文档:Install Gitlab Runner

注册 gitlab-runner

在服务器上面运行 gitlab-runner register 来注册 gitlab-runner,期间需要输入如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Please enter the gitlab-ci coordinator URL (e.g. https://gitlab.com )
# 使用 gitlab.com 的朋友,直接填写 https://gitlab.com;
# 如果是部署私有云的朋友请填写自己的 gitlab 服务器,如果是内容,还需要在路由器做端口转发

Please enter the gitlab-ci token for this runner
# 打开并登录 gitlab.com,点击左菜单的 settings > CI/CD > Runners,
# 在 Specific Runners 下面,找到 token,并复制粘贴到 ternimal。

Please enter the gitlab-ci tags for this runner (comma separated):
# 这里填入标签,gitlab-runner 是根据标签来区分的,这里可以按照自己的需求,
# 来确定填入标签的形式,例如可以按照项目来区分,如:fz-oa,这样,
# 在 .gitlab-ci.yml 里面,deploy 阶段的 tags,也需要填入 fz-oa 来匹配
# 对应的 gitlab-runner

Please enter the executor: ssh, docker+machine, docker-ssh+machine, kubernetes, docker, parallels, virtualbox, docker-ssh, shell:
# 选择执行 script 的方式,大家可以根据自己服务器的环境来选择
# 下面会以 shell 为例

gitlab-runner 运行成功之后,在 settings > CI/CD > Runners > Specific Runners,会看到对应的 runner。

编写 deploy 脚本

因为在上面创建 gitlab-runner 时,执行 script 的方式,我们选择了 shell,所以,我们需要在服务器上面,编写一个脚本来进行部署。

(1) 切换到 gitlab-runner 用户

1
su gitlab-runner

(2) 创建 ~/.local/bin 目录

1
2
cd ~/.local # 如没有 .local,请自行创建
mkdir bin

(3) 编写 deploy 脚本

1
vi deploy

deploy 输入下面内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/bin/bash
if [ $# -ne 2 ]
then
echo "arguments error!"
exit 1
else
deploy_path="/var/www/$1/$2"
if [ ! -d "$deploy_path" ]
then
project_path="git@gitlab.com:"$1/$2".git"
git clone $project_path $deploy_path
else
cd $deploy_path
git pull
fi
fi

(4) 加上执行权限

1
chmod +x deploy

(5) 把 deploy 添加到环境变量

1
vi ~/.profile

.profile 末尾加上:

1
export PATH="$HOME/.local/bin:$PATH"

reload 一下 .profile

1
source ~/.profile

现在,就可以直接运行 deploy xx xx 命令了。

(7) 配置 SSH 登录
因为脚本是通过 ssh 去克隆 gitlab.com 的仓库,所以还需要配置 gitlab-runner 的密钥对。

创建 ssh 密钥对的过程,请自行 Google 吧。创建完成之后,复制公钥到 gitlab.com 里面任意用户下面 (或者创建一个对应的叫 gitlab-runner 的用户,把公钥复制到 SSH 管理里面)。

注意:因为 ssh 访问 gitlab 时,需要把 gitlab.com 添加到你服务器的 know_hosts,所以,先在服务器上面,手动执行一次 deploy 命令。

至此,大功告成。

再次在项目提交代码时,当 gitlab.com 里面的 pipeline 运行完成之后,服务器的代码就会自动更新了。

说起日志分析工具,我们通常想起 ELK 日志分析工具,但不要为了 ELK 而使用 ELK,应该要看实际环境和业务需求而定,如果你的业务量是海量 + 分布式,日志分散难以查找,查询速度慢,并希望实时显示日志,那么才要考虑使用 ELK,否则,一般的小型的日志分析工具,甚至直接登录服务器看 log 也跟方便。

本文将介绍如何在 Ubuntu 下安装和使用 GoAccess。

1. 安装

GoAccess 是用 C 语言写的一个日志分析工具,Github 地址是:allinurl/goaccess,官网网站是:GoAccess 官网

注意:

  • 更加详细的安装方式请参考 github。本文以 Ubuntu 为例;
  • Ubuntu 下使用 apt-get install 安装,只能安装旧版本(v0.6),不建议使用这种方式直接安装;
  • 安装时,可能会提示没有 GeoIP 这个库 Missing development files for the GeoIP library,可以检查一下,提前安装好,可以参考 Install GeoIP

我们直接从 Debian & Ubuntu 的仓库获取最新的 GoAccess 的安装包来安装:

1
2
3
4
$ echo "deb https://deb.goaccess.io/ $(lsb_release -cs) main" | sudo tee -a /etc/apt/sources.list.d/goaccess.list
$ wget -O - https://deb.goaccess.io/gnugpg.key | sudo apt-key add -
$ sudo apt-get update
$ sudo apt-get install goaccess

安装完成之后,运行:

1
goaccess --version

查看版本,我写这篇文章时,最新版是 1.2。

2. 使用

GoAccess 的使用也是非常简单,并且支持输入多种格式。

输出 html:

1
goaccess /var/log/nginx/access.log -a > report.html

注意:输出报告时,请记得看终端提示,是否提示有 log-formattime-format 等格式错误,如果有,到 /etc/goaccess.conf 里面修改。

这样,我们把 report.html 放在可访问的 web 目录下面,就可以通过浏览器访问,可以查看这个 Live Demo

从 404 这一栏可以看出,服务器经常都会遭受到一些机器的嗅探,他们经常会使用一些开源框架/系统的一些常用路径,来嗅探你的服务器的数据库/管理员的路径等,如果服务器返回的是 200,那么攻击者可能就会采取进一步的行动。

除了 html 报告,像 cvs/json 这些常用的格式也可以输出,同样,GoAccess 也支持实时日志访问:

1
goaccess /xxx/xxx/access.log -o /www/xxx/real_time_report.html --real-time-html

这样,你就可以访问 real_time_report.html 来查看实时的日志情况了。

3. 小结

对于日志分析工具,个人建议适合自己的业务和需求就好,在不同的阶段,做不同的事情,ELK 是十分强大,但在业务刚刚起步时,并不一定适合我们。围绕自己的业务,去解决事情,不要为了使用工具而使用工具。

前文也说过,在日志量相对较小时,直接登录服务器查看,或者写个脚本定期抓取分类也是可以的,这里就不在过多地说明了。

另外,有一款用 Rubu 写的超级 Geek 的日志分析工具:glTail.rb 官网glTail.rb Github,也许以后有机会再尝试一下。

也许因为各种原因,我们有时候会忘记了 MySQL 的 root 的密码,网上很多教程,都教你直接 mysql -u root -p 登陆 mysql 后修改,但前提是,我忘记了 root 的密码,如何登陆?

密码错误的提示:

1
ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: YES)

debian-sys-maint 用户

MySQL 安装之后,会默认生成几个用户,其中一个就是 debian-sys-maint 了,我们可以使用这个用户登陆来修改 root 的密码。

debian-sys-maint 中 Debian 系统对 MySQL 维护用的,可以理解为通过系统的某个“非常规”程序对 MySQL 进行备份恢复等行为时,改程序所使用的登录 MySQL 的账户。

那么,debian-sys-maint 的用户密码是多少呢?

在 Ubuntu 下面,可以打开这个文件查看:

1
vi /etc/mysql/debian.cnf

然后会看到这个配置文件里面,有个 password 的字段:

1
2
3
4
5
6
7
8
9
10
11
# Automatically generated for Debian scripts. DO NOT TOUCH!
[client]
host = localhost
user = debian-sys-maint
password = xxxxxxxxxx
socket = /var/run/mysqld/mysqld.sock
[mysql_upgrade]
host = localhost
user = debian-sys-maint
password = xxxxxxxxxx
socket = /var/run/mysqld/mysqld.sock

password 记下来,然后在终端使用 debian-sys-maint 来登陆:

1
2
mysql -u debian-sys-maint -p
# 输入密码之后就可以登陆成功

登陆到 mysql 之后,用下面命令来重置 root 的密码:

1
update mysql.user set authentication_string=PASSWORD('xxxxxx') where user='root';

最后刷新一下权限即可:

1
flush privileges;