ZooKeeper是如何实现数据一致性的?

众所周知,ZooKeeper 是一个开源的分布式协调服务,很多分布式的应用都是基于 ZooKeeper 来实现分布式锁、服务管理、通知订阅等功能。
那么 ZooKeeper 自身是如何在分布式环境下实现数据的一致性的呢?

结构

既然 ZooKeeper 是在分布式环境下提供服务的,那么它必须要解决的问题就是单点问题,因此 ZooKeeper 是一个主备的结构。ZooKeeper 存在 leader、follower、observer三种角色,这三种角色在实际服务集群中都是服务节点。

  • leader:处理所有请求,为客户的提供读和写服务
  • follower:只提供读服务,有机会通过选举成为leader
  • observer:只提供读服务

由以上三种角色的介绍可知,ZooKeeper 中所有请求都是交给 leader 处理的,因此,如果leader挂了,ZooKeeper 就无法再提供服务,这就是单点问题。所幸在leader 挂了之后,followe r能够通过选举成为新的 leader。
那么问题来了,follower 是如何被选举成为新的 leader 的?新的 leader 又是如何保证数据的一致性的?这些问题的答案都在于 ZooKeeper 所用的分布式一致性协议ZAB。

ZAB协议

ZAB协议是为 ZooKeeper 专门设计的一种支持奔溃恢复的原子广播协议。虽然它不像 Paxos 算法那样通用通用,但是它却比 Paxos 算法易于理解。在我看来ZAB协议主要的作用在于三个方面:

  1. 选举出 leader
  2. 同步节点之间的状态达到数据一致
  3. 数据的广播

在了解ZAB协议具体过程之前不要先了解几个概念。

事务

ZooKeeper 作为一个分布式协调服务,需要 leader 节点去接受外部请求,转化为内部操作(比如创建,修改,删除节点),需要原子性执行的几个操作就组成了事务,这里用 T 代表。ZooKeeper 要求有序的处理事务,因此给每个事务一个编号,每进来一个事务编号加1,假设 leader 节点中进来 n 个事务,可以表示为 T1,T2,T3…Tn。为了防止单点问题导致事务数据丢失,leader 节点会把事务数据同步到 follower 节点中。

事务队列

leader 和 follower 都会保存一个事务队列,用 L 表示,L=T1,T2,T3…Tn,leader 的事务队列是接受请求保存下来的,follower 的事务队列是 leader 同步过来的,因此leader 的事务队列是最新,最全的。

任期

在 ZooKeeper 的工作过程中,leader 节点奔溃,重新选举出新的 leader 是很正常的事情,所以 ZooKeeper 的运行历史中会产生多个 leader,就好比一个国家的历史中会相继出现多为领导人。为了区分各个 leader,ZAB协议用一个整数来表示任期,我们假设用E表示任务。ZooKeeper 刚运行时选举出的第一个 leader 的任期为E=1;第一个 leader 奔溃后,下面选举出来的 leader,任期会加1,E=2;一次类推。加入任期概念的事务应该表示为 T(E,n)

协议过程

选举leader
  1. 每个 follower 广播自己事务队列中最大事务编号 maxId
  2. 获取集群中其他 follower 发出来的 maxId,选取出最大的 maxId 所属的 follower,投票给该 follower,选它为 leader
  3. 统计所有投票,获取投票数超过一半的 follower 被推选为 leader
同步数据
  1. 各个 follower 向 leader 发送自己保存的任期E
  2. leader,比较所有的任期,选取最大的E,加1后作为当前的任期E=E+1
  3. 将任务E广播给所有follower
  4. follower 将任期改为 leader 发过来的值,并且返回给 leader 事务队列 L
  5. leader 从队列集合中选取任期最大的队列,如果有多个队列任期都是最大,则选取事务编号 n 最大的队列 Lmax。将 Lmax 置为 leader 队列,并且广播给各个 follower。
  6. follower 接收队列替换自己的事务队列,并且执行提交队列中的事务。

至此各个节点的数据达成一致,ZooKeeper 恢复正常服务。

广播
  1. leader 节点接收到请求,将事务加入事务队列,并且将事务广播给各个 follower。
  2. follower 接收事务并加入都事务队列,然后给 leader 发送准备提交请求。
  3. leader 接收到半数以上的准备提交请求后,提交事务同时向 follower 发送提交事务请求
  4. follower 提交事务。

Python实战之520特别版:用微信每天和她说晚安

今天是传说中的520,不知你是否已经准备好要表白的话语。为了助你撩妹成功,云厉今天也学着某些人土土的教大家用Python每天给妹纸说晚安。
没错,每天!
用对了Python,每天都过520

这次真的是面向对象编程

大致思路是这样:

  1. 调接口获取每日心灵鸡汤或撩妹话术
  2. 基于wxpy模块,授权微信登录,搜索聊天对象,发送XX消息
  3. Timer每日发送
  4. 后台挂起执行脚本

安装wxpyrequests

pip3 install wxpy
pip3 install requests

其他如果没安装,也一并安装

下面发送信息脚本send_wx.py,不到40行的代码,注释不多,相信你懂!

# -*- coding: utf-8 -*-

from threading import Timer
from wxpy import *
import requests

bot = Bot(console_qr=2, cache_path='botoo.pkl') 

# 她的昵称和你的昵称。是微信昵称,不是备注哦
ta_name = '若';
my_name = '云厉';

# 从金山词霸获取每日鸡汤,英文和翻译,如果你有更好的鸡汤数据源,也可以更换
def get_message():
    url = "http://open.iciba.com/dsapi/"
    r = requests.get(url)
    contents = r.json()['content']
    translation= r.json()['translation']
    return contents, translation

# 给TA发送晚安信息
def send_message(): 
    try:
        message = get_message()
        # 搜索聊天对象,并ensure_one保证她的昵称在你的微信好友列表里只有一个
        my_love = ensure_one(bot.search(ta_name, sex=FEMALE))
        # 发送鸡汤
        my_love.send(message[0])
        my_love.send(message[1][5:])
        my_love.send(u'亲爱的,晚安!爱你~')
        # 每86400秒(1天),发送1次
        t = Timer(86400, send_message)
        t.start()
    except:
        my_self = ensure_one(bot.search(my_name, sex=MALE))
        my_self.send(u'今天撩妹消息发送失败了')

if __name__ == '__main__':
    send_message()
# 丢后台跑起来!
nohup python3 send_wx.py > send_wx.log 2>&1 &

首次运行,脚本会生成二维码,需要授权登录。这也是为什么在脚本内用Timer定时发送而不用Linux的cron,就是为了不要每次都授权登录,太麻烦!

测试一把,效果如下:

wxpy还能干啥?

  • 控制路由器、智能家居等具有开放接口的玩意儿
  • 运行脚本时自动把日志发送到你的微信
  • 加群主为好友,自动拉进群中
  • 跨号或跨群转发消息
  • 自动陪人聊天
  • 逗人玩
  • ...

总而言之,可用来实现各种微信个人号的自动化操作

如果你有兴趣,欢迎关注微信公众号:程序员到架构师,不定期更新各种各样的Python技术

Python中被忽略的else

else, 我们再熟悉不过了。对于一个Python程序员来说,else往往都是配合 if 来使用的,像这样:

a = '12'
if a == '123':
    print(a)
else:
    print('出错了!')

但是,Python中的else并不只能用在if之后,so,这次我们讨论一下Python流程控制中的else。

else子句不仅能在 if 语句中使用,还能在 for、while 和 try 语句中使用,这个语言特性不是什么秘密,但却没有得到重视。我们看一个例子:

my_list = ['apple', 'pear', 'orange', 'banana']
for item in my_list:
    if item == 'banana':
        print('Founded!')
        break
else:
    raise ValueError('No banana flavor found!')

本例当中,循环最后找到了'banana‘,输出'Founded!',并且跳出循环,所以else字句并没有被执行。但如果,将代码修改一下,去掉列表中的'banana':

my_list = ['apple', 'pear', 'orange']
for item in my_list:
    if item == 'banana':
    print('Founded!')
    break
else:
    raise ValueError('No banana flavor found!')

运行代码就会直接抛出错误!如果不使用else字句来完成上述功能,可能我们就需要设置控制标志了,像这样:

my_list = ['apple', 'pear', 'orange']
flag = True for item in my_list:
    if item == 'banana':
        print('Founded!')
        flag = False
        break
if flag:
    raise ValueError('No banana flavor found!')

很明显,这里使用了额外的变量 flag 和 if 语句。

while 和 for 相类似,简单举个例子:

a = 'apple'
while a == 'banana':
    pass
else:
    raise ValueError('No banana flavor found!')

下面看一下try:

try:
    dangerous_call()
except OSError:
    log('OSError...')
else:
    after_call()

很明确,try 块防守的是 dangerouscall() 可能出现的错误,而且很明显,只有 try 块不抛出异常,才会执行 aftercall()。

现在,总结一下 else 子句的行为如下:

for: 仅当 for 循环运行完毕时(即 for 循环没有被 break 语句中止)才运行 else 块。
while: 仅当 while 循环因为条件为假值而退出时(即 while 循环没有被break 语句中止)才运行 else 块。
try: 仅当 try 块中没有异常抛出时才运行 else 块。

即,如果异常或者 return、break 或 continue 语句导致控制权跳到了复合语句的主块之外,那么else 子句也会被跳过。

for/else、while/else 和 try/else 的语义关系紧密,不过与if/else 差别很大。主要是else 这个单词的意思阻碍了我们对这些特性的理解。 按正常的理解应该是“要么运行这个循环,要么做那件事”。可是,在循环中,else 的语义恰好相反:“运行这个循环,然后做那件事。”不过,相信多使用几次,你会熟悉的。

准确判断文件类型方法之二进制文件头

判断文件类型在开发中非常常见的需求,很多开发者的做法是简单判断文件的扩展名,很遗憾,这种方法非常不靠谱,因为文件扩展名可以伪造,并且在Linux系统中,没有扩展名的概念!

这里推荐一种准确判断文件类型的方法:通过二进制文件头判断文件类型
代码非常简单,只需从文件流中读取前两个字节,再解包,即可判断出文件类型,并且大部分语言都支持,Python和PHP版示例参考:

Python版

# -*- coding: utf-8 -*-

import struct 

file_type_map = {
    '7790'   : 'exe',
    '7784'   : 'midi',
    '8075'   : 'zip',
    '8297'   : 'rar',
    '7173'   : 'gif',
    '6677'   : 'bmp',
    '13780'  : 'png',
    '255216' : 'jpg',
}

def get_file_type(filename):
    fp = open(filename, 'rb')
    str_info = struct.unpack_from("BB", fp.read(2))
    str_code = '%d%d' % (str_info[0], str_info[1])
    fp.close()
    return 'unknown' if str_code not in file_type_map else file_type_map[str_code]

if __name__ == '__main__':
    file_type = get_file_type('./test.png')
    print(file_type)

PHP版

PHP有一个内置的专门用于判断图像类型的方法,exif_imagetype,默认未开启,需要编译时候指定 --enable-exif 开启,并且只支持图像类型文件,所以不推荐使用。PHP同样可以通过二进制文件头判断文件类型

<?php

$file_type_map = array(
    7790   => 'exe',
    7784   => 'midi',
    8075   => 'zip',
    8297   => 'rar',
    7173   => 'gif',
    6677   => 'bmp',
    13780  => 'png',
    255216 => 'jpg',
);

function get_file_type($file) {
    $filepath = realpath($file);
    if (!($fp = @fopen($filepath, 'rb')))
        return false;
    $bin = fread($fp, 2);
    fclose($fp);
    $str_info = @unpack('C2chars', $bin);
    $str_code = intval($str_info['chars1'].$str_info['chars2']);
    global $file_type_map;
    return $file_type_map[$str_code] ?: 'unknown';
}

$file_type = get_file_type('./test.png');
print($file_type);

巧用Terraform完成腾讯云上自动运维

Terraform是一个IT基础架构自动化编排工具,主张基础架构即代码,你可以用代码集中管理你的云资源和基础架构。本文就腾讯云为例,讲述如何用Terraform完成云上自动化运维

1. 什么是资源

基础设施和服务统称为资源,如私有网络、子网、物理机、虚拟机、镜像、专线、NAT网关等等都可以称之为资源,也是开发和运维人员经常要打交道要维护的东西。
Terraform把资源大致分为两种:

1.1 resource
resource "资源类名" "映射到本地的唯一资源名" {
  参数 = 值
  ...
}

这类资源一般是抽象的真正的云服务资源,支持增删改,如私有网络、NAT网关、虚拟机实例

1.2 data source
data "资源类名" "映射到本地的唯一资源名" {
  参数 = 值
  ...
}

这类资源一般是固定的一些可读资源,如可用区列表、镜像列表。大部分情况下,resource资源也会封装一个data source方法,用于资源查询

2. 准备工作

2.1 安装Go和Terraform
  • Go 1.9 (to build the provider plugin)
  • Terraform 0.11.x
2.2 下载插件

下载腾讯云Terraform插件terraform-provider-tencentcloud,解压到指定目录,给二进制文件设置terraform-provider-tencentcloud可执行权限
(腾讯云正式入驻Terraform官方Providers后,不再需要手工下载插件,Terraform会自动识别资源商插件)

2.3 公共配置

Terraform实际是对上游API的抽象,你用Terraform的所有操作,最终都是通过API反应到服务商,因此我们需要配置几个公共配置,这里包括用户信息和地域信息,这些公共配置存储在环境变量供terraform-provider-tencentcloud读取

export TENCENTCLOUD_SECRET_ID=your-awesome-secret-id
export TENCENTCLOUD_SECRET_KEY=your-awesome-secret-key
export TENCENTCLOUD_REGION="ap-guangzhou"

获取云API秘钥:https://console.cloud.tencent.com/cam/capi

2.4 了解几个常用命令

Terraform有些命令,是我们常用的,也是后面我们实例会用到的

terraform init   # 初始化工作目录,也是我们第一个要执行的命令
terraform plan   # 生成计划
terraform appy   # 提交请求
terraform state  # 查看资源状态
terraform graph  # 生成执行计划图

接下来,我们就用Terraform构建一个常见的网络项目实例

3. 查询资源

我们把资源依赖的上游资源,先查询出来,便宜后面引用。

# 查询可用区信息
data "tencentcloud_availability_zones" "favorate_zones" {}
# 查询镜像
data "tencentcloud_image" "my_favorate_image" {
  filter {
    name = "image-type"
    values = ["PUBLIC_IMAGE"]
  }
}

后面我们创建的子网和虚拟机,需要用到可用区和镜像,所以这里先用data查询

4. 创建资源

4.1 创建一个私有网络,并命名为main,后面创建的其他资源,都会引用这个资源

resource "tencentcloud_vpc" "main" {
  name = "979137_test_vpc"
  cidr_block = "10.6.0.0/16"
}

解读:创建一个名为YunLi_test,CIDR为10.6.0.0/16的私有网络,在本地命名为main,这里会返回包括私有网络ID在内所有VPC属性

4.2 在私有网络main上再创建一个子网,因为虚拟机是挂在子网下的

resource "tencentcloud_subnet" "main_subnet" {
  vpc_id = "${tencentcloud_vpc.main.id}"
  name = "979137_test_subnet"
  cidr_block = "10.6.7.0/24"
  availability_zone = "${data.tencentcloud_availability_zones.favorate_zones.zones.0.name}"
}

解读:vpc_id引用了5.1我们创建的VPC ID,CIDR在私有网络范围内,可用区我们用前面查询到的,因为是示例,所以这里随机用查询到的第一个镜像

4.3 创建两个弹性IP,关联至NAT网关

resource "tencentcloud_eip" "eip_dev_dnat" {
  name = "979137_test_eip"
}
resource "tencentcloud_eip" "eip_test_dnat" {
  name = "979137_test_eip"
}

解读:所有依赖关系,被依赖的资源都需要先创建

4.4 创建NAT网关,用于给CVM提供外网能力

resource "tencentcloud_nat_gateway" "my_nat" {
  vpc_id = "${tencentcloud_vpc.main.id}"
  name = "979137_test_nat"
  max_concurrent = 3000000
  bandwidth = 500
  assigned_eip_set = [
    "${tencentcloud_eip.eip_dev_dnat.public_ip}",
    "${tencentcloud_eip.eip_test_dnat.public_ip}",
  ]
}

解读:引用了VPC ID;指定了最大并发连接数和带宽上限;关联了两个弹性IP,两个弹性IP引用的是前面创建好的弹性IP资源

4.5 创建一个安全组并配置安全组规则

resource "tencentcloud_security_group" "my_sg" {
  name = "979137_test_sg"
  description = "979137_test_sg"
}
# 放通80,443端口
resource "tencentcloud_security_group_rule" "web" {
  security_group_id = "${tencentcloud_security_group.my_sg.id}"
  type = "ingress"
  cidr_ip = "0.0.0.0/0"
  ip_protocol = "tcp"
  port_range = "80,443"
  policy = "accept"
}
# 放通常用web端口
resource "tencentcloud_security_group_rule" "sg_web" {
  security_group_id = "${tencentcloud_security_group.my_sg.id}"
  type = "ingress"
  cidr_ip = "0.0.0.0/0"
  ip_protocol = "tcp"
  port_range = "80,443,8080"
  policy = "accept"
}
# 放通内网ssh登录
resource "tencentcloud_security_group_rule" "sg_ssh" {
  security_group_id = "${tencentcloud_security_group.my_sg.id}"
  type = "ingress"
  cidr_ip = "10.65.0.0/16"
  ip_protocol = "tcp"
  port_range = "22"
  policy = "accept"
}
# 拒绝所有访问
resource "tencentcloud_security_group_rule" "sg_drop" {
  security_group_id = "${tencentcloud_security_group.my_sg.id}"
  type = "ingress"
  cidr_ip = "0.0.0.0/0"
  ip_protocol = "tcp"
  port_range = "ALL"
  policy = "drop"
}

解读:安全策略是云上安全必不可少的一环,这里除了允许内网ssh登录和开放web端口,其他全部拒绝

4.6 创建虚拟机

resource "tencentcloud_instance" "foo" {
  availability_zone = "${data.tencentcloud_availability_zones.favorate_zones.zones.0.name}"
  image_id = "${data.tencentcloud_image.my_favorate_image.image_id}"
  vpc_id = "${tencentcloud_vpc.main.id}"
  subnet_id = "${tencentcloud_subnet.main_subnet.id}"
  security_groups = [
    "${tencentcloud_security_group.my_sg.id}",
  ]
}

解读:可用区我们和子网用了同一个(而且必须是同一个),因为是示例我们使用了data查询到的第一个镜像,把虚拟机放在了指定的VPC子网内,关联了一个安全组

4.7 增加NAT网关端口转发规则

resource "tencentcloud_dnat" "dev_dnat" {
  vpc_id = "${tencentcloud_nat_gateway.my_nat.vpc_id}"
  nat_id = "${tencentcloud_nat_gateway.my_nat.id}"
  protocol = "tcp"
  elastic_ip = "${tencentcloud_eip.eip_dev_dnat.public_ip}"
  elastic_port = "80"
  private_ip = "${tencentcloud_instance.foo.private_ip}"
  private_port = "9001"
}
resource "tencentcloud_dnat" "test_dnat" {
  vpc_id = "${tencentcloud_nat_gateway.my_nat.vpc_id}"
  nat_id = "${tencentcloud_nat_gateway.my_nat.id}"
  protocol = "udp"
  elastic_ip = "${tencentcloud_eip.eip_test_dnat.public_ip}"
  elastic_port = "8080"
  private_ip = "${tencentcloud_instance.foo.private_ip}"
  private_port = "9002"
}

解读:这里引用了关系较多,端口转发的本质是将内网虚拟机IP/端口映射到外网弹性IP的端口。

全部配置写完后,执行plan

An execution plan has been generated and is shown below.
Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  + tencentcloud_dnat.dev_dnat
      id:                 <computed>
      elastic_ip:         "${tencentcloud_eip.eip_dev_dnat.public_ip}"
      elastic_port:       "80"
      nat_id:             "${tencentcloud_nat_gateway.my_nat.id}"
      private_ip:         "${tencentcloud_instance.foo.private_ip}"
      private_port:       "9001"
      protocol:           "tcp"
      vpc_id:             "${tencentcloud_nat_gateway.my_nat.vpc_id}"

  + tencentcloud_dnat.test_dnat
      id:                 <computed>
      elastic_ip:         "${tencentcloud_eip.eip_test_dnat.public_ip}"
      elastic_port:       "8080"
      nat_id:             "${tencentcloud_nat_gateway.my_nat.id}"
      private_ip:         "${tencentcloud_instance.foo.private_ip}"
      private_port:       "9002"
      protocol:           "udp"
      vpc_id:             "${tencentcloud_nat_gateway.my_nat.vpc_id}"

  + tencentcloud_eip.eip_dev_dnat
      id:                 <computed>
      name:               "979137_test_eip"
      public_ip:          <computed>
      status:             <computed>

  + tencentcloud_eip.eip_test_dnat
      id:                 <computed>
      name:               "979137_test_eip"
      public_ip:          <computed>
      status:             <computed>

  + tencentcloud_instance.foo
      id:                 <computed>
      allocate_public_ip: "false"
      availability_zone:  "ap-guangzhou-2"
      data_disks.#:       <computed>
      image_id:           "img-b4tgwxvn"
      instance_name:      "CVM-Instance"
      instance_status:    <computed>
      key_name:           <computed>
      private_ip:         <computed>
      public_ip:          <computed>
      security_groups.#:  <computed>
      subnet_id:          "${tencentcloud_subnet.main_subnet.id}"
      system_disk_size:   <computed>
      system_disk_type:   <computed>
      vpc_id:             "${tencentcloud_vpc.main.id}"

  + tencentcloud_nat_gateway.my_nat
      id:                 <computed>
      assigned_eip_set.#: <computed>
      bandwidth:          "500"
      max_concurrent:     "3000000"
      name:               "979137_test_nat"
      vpc_id:             "${tencentcloud_vpc.main.id}"

  + tencentcloud_security_group.my_sg
      id:                 <computed>
      description:        "979137_test_sg"
      name:               "979137_test_sg"

  + tencentcloud_security_group_rule.sg_drop
      id:                 <computed>
      cidr_ip:            "0.0.0.0/0"
      ip_protocol:        "tcp"
      policy:             "drop"
      security_group_id:  "${tencentcloud_security_group.my_sg.id}"
      type:               "ingress"

  + tencentcloud_security_group_rule.sg_ssh
      id:                 <computed>
      cidr_ip:            "10.65.0.0/16"
      ip_protocol:        "tcp"
      policy:             "accept"
      port_range:         "22"
      security_group_id:  "${tencentcloud_security_group.my_sg.id}"
      type:               "ingress"

  + tencentcloud_security_group_rule.sg_web
      id:                 <computed>
      cidr_ip:            "0.0.0.0/0"
      ip_protocol:        "tcp"
      policy:             "accept"
      port_range:         "80,443,8080"
      security_group_id:  "${tencentcloud_security_group.my_sg.id}"
      type:               "ingress"

  + tencentcloud_subnet.main_subnet
      id:                 <computed>
      availability_zone:  "ap-guangzhou-2"
      cidr_block:         "10.6.7.0/24"
      name:               "979137_test_subnet"
      route_table_id:     <computed>
      vpc_id:             "${tencentcloud_vpc.main.id}"

  + tencentcloud_vpc.main
      id:                 <computed>
      cidr_block:         "10.6.0.0/16"
      is_default:         <computed>
      is_multicast:       <computed>
      name:               "979137_test_vpc"

Plan: 12 to add, 0 to change, 0 to destroy.

解读:12个资源将被创建,0个变更,0个销毁

确认无误,执行apply,可以看到执行结果
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.

我们可以通过graph命令结合graphviz工具生成资源执行计划图

terraform graph | dot -Tsvg > graph.svg

5. 更新资源

更新资源,就是更新前面我们写的配置的参数,这可能就是Terraform魅力之一吧!
如:我要更新端口转发规则test_dnat的协议和外部端口两个参数、安全组my_sg的名字和备注信息。修改tf文件后执行plan,出现了要更新的资源:

-/+ tencentcloud_dnat.test_dnat (new resource required)
      id:           "tcp://vpc-5ooh0ivd:nat-dn2bdr68@139.199.230.14:8080" => <computed> (forces new resource)
      elastic_ip:   "139.199.230.14" => "139.199.230.14"
      elastic_port: "8080" => "443" (forces new resource)
      nat_id:       "nat-dn2bdr68" => "nat-dn2bdr68"
      private_ip:   "10.6.7.15" => "10.6.7.15"
      private_port: "9002" => "9002"
      protocol:     "tcp" => "tcp"
      vpc_id:       "vpc-5ooh0ivd" => "vpc-5ooh0ivd"

  ~ tencentcloud_security_group.my_sg
      description:  "979137_test_sg" => "979137_dev_sg"
      name:         "979137_test_sg" => "979137_dev_sg"

Plan: 1 to add, 1 to change, 1 to destroy.

和创建一样,执行apply,提交修改

思考:我修改两个资源,为什么变成1个添加,1个修改,1个销毁?
解答:这是Terraform的ForceNew机制,端口转发规则的修改等价于删除+创建,我在《腾讯云支持Terraform开发实践》文中详细阐述过Terraform工作原理,欢迎阅读

6. 删除资源

Terraform删除资源,有两种方式

6.1 注释要删除的资源

Terraform注释不只对参数有效,还对整个资源配置有效,比如我注释一个DNAT资源

#resource "tencentcloud_dnat" "dev_dnat" {
#  vpc_id = "${tencentcloud_nat_gateway.my_nat.vpc_id}"
#  nat_id = "${tencentcloud_nat_gateway.my_nat.id}"
#  protocol = "tcp"
#  elastic_ip = "${tencentcloud_eip.eip_dev_dnat.public_ip}"
#  elastic_port = "80"
#  private_ip = "${tencentcloud_instance.foo.private_ip}"
#  private_port = "9001"
#}

执行plan可以看到,

Terraform will perform the following actions:

  - tencentcloud_dnat.dev_dnat

Plan: 0 to add, 0 to change, 1 to destroy.

Terraform认为dev_dnat是要删除的资源

6.2 terraform destory
Terraform will perform the following actions:

  - tencentcloud_dnat.dev_dnat
  - tencentcloud_dnat.test_dnat
  - tencentcloud_eip.eip_dev_dnat
  - tencentcloud_eip.eip_test_dnat
  - tencentcloud_instance.foo
  - tencentcloud_nat_gateway.my_nat
  - tencentcloud_security_group.my_sg
  - tencentcloud_security_group_rule.sg_drop
  - tencentcloud_security_group_rule.sg_ssh
  - tencentcloud_security_group_rule.sg_web
  - tencentcloud_subnet.main_subnet
  - tencentcloud_vpc.main

Plan: 0 to add, 0 to change, 12 to destroy.

这是一个全部资源销毁命令,执行后tf文件配置的所有资源都认为是要销毁的

写在最后:
自动化运维远不止于一个Terraform,在实际应用中还需结合更多工具降低我们的运维成本,让运维更高效更加自动化,欢迎关注微信公众号:程序员到架构师,回复Terraform获取更多内容,后续也将继续推送更多有关自动化运维的文章

腾讯云支持Terraform开发实践

Terraform是国际著名的开源的资源编排工具,据不完全统计,全球已有超过一百家云厂商及服务提供商支持Terraform。这篇文章从Terraform-Provider系统架构开始,到Terraform核心库讲解,到实践Terraform-Provider开发,再到单元测试,比较完整的描述了支持Terraform的开发全过程

1. Terraform是什么?

Terraform是一款基于Golang的开源的资源编排工具,可以让用户管理配置任何基础架构,可以管理公有云和私有云服务的基础架构,也可以管理外部服务。

如果你不知道什么叫资源编排,那 AWS控制台腾讯云控制台 你一定知道,你可以在这些控制台管理你的所有云资源,Terraform和控制台作用一样,本质都是管理你的云资源,只不过,控制台是界面化的操作,而Terraform是通过配置文件来实现

当你的基础架构很复杂时,当你在某云厂商采买了规模较大的云资源或云服务时,当你的基础架构是基于混合云时,...,控制台的界面化操作,也许并不是最佳的管理工具,这时候,Terraform可能就是上古神器了

2. 怎么使用Terraform管理基础架构?

在开始开发之前,我们先了解下用户是怎么玩的,这尤其重要,这有助于更好的理解我们后续的开发流程和开发思路
简单来说,用户就是维护一些类似 json 格式的 .tf 配置文件,通过对配置的增删改查,实现对基础架构资源的增删改查。

我在文章《巧用Terraform完成腾讯云上自动运维》是完全站在用户角度,讲述如何利用Terraform管理基础架构的,这里不再重复用户层的内容

3. 配置开发环境

Terraform支持插件模型,并且所有 provider 实际就是插件,插件以Go二进制文件的形式分发。虽然技术上可以用另一种语言编写插件,但几乎所有的Terraform插件都是用Golang编写的。

本文是在下列版本开发和测试的
- Terraform 0.11.x
- Go 1.9 (to build the provider plugin)

为了不使本文篇幅太长,环境相关请直接参考我们 Github 上的 README.md,这里就不重复写了,假设你已经准备好了开发环境

4. Provider架构

按照Go的开发习惯和Github路径,我把开发目录放在了

cd  $GOPATH/src/github.com/tencentyun/terraform-provider-tencentcloud

接下来,我们了解下 tencentcloud 的插件目录,以此了解 Provider 架构

├─terraform-provider-tencentcloud                    根目录
│  ├─main.go                                         程序入口文件
│  ├─AUTHORS                                         作者信息
│  ├─CHANGELOG.md                                    变更日志
│  ├─LICENSE                                         授权信息
│  ├─debug.tf.example                                调试配置文件示例
│  ├─examples                                        示例配置文件目录
│  │  ├─tencentcloud-eip                             EIP示例tf文件
│  │  ├─tencentcloud-instance                        CVM示例tf文件
│  │  ├─tencentcloud-nat                             NAT网关示例tf文件
│  │  ├─tencentcloud-vpc                             VPC示例tf文件
│  │  └─ ...                                         更多examples目录
│  ├─tencentcloud                                    Provider核心目录
│  │  ├─basic_test.go                                基础单元测试
│  │  ├─config.go                                    公共配置文件
│  │  ├─data_source_tc_availability_zones.go         可用区查询
│  │  ├─data_source_tc_availability_zones_test.go
│  │  ├─data_source_tc_nats.go                       NAT网关列表查询
│  │  ├─data_source_tc_nats_test.go
│  │  ├─data_source_tc_vpc.go                        VPC查询
│  │  ├─data_source_tc_vpc_test.go
│  │  ├─...                                          更多Data Source
│  │  ├─helper.go                                    一些公共函数
│  │  ├─provider.go                                  Provider核心文件
│  │  ├─provider_test.go
│  │  ├─resource_tc_eip.go                           EIP资源管理程序
│  │  ├─resource_tc_eip_test.go
│  │  ├─resource_tc_instance.go                      CVM实例资源管理程序
│  │  ├─resource_tc_instance_test.go
│  │  ├─resource_tc_nat_gateway.go                   NAT网关资源管理程序
│  │  ├─resource_tc_nat_gateway_test.go
│  │  ├─resource_tc_vpc.go                           VPC网关资源管理程序
│  │  ├─resource_tc_vpc_test.go
│  │  ├─...                                          更多资源管理程序
│  │  ├─service_eip.go                               封装的EIP相关Service
│  │  ├─service_instance.go                          封装的CVM实例相关Service
│  │  ├─service_vpc.go                               封装的VPC相关Service
│  │  ├─...
│  │  ├─validators.go                                公共的参数校验函数
│  ├─vendor                                          依赖的第三方库
│  ├─website                                         Web相关文件
│  │  ├─tencentcloud.erb                             文档左侧菜单栏
│  │  ├─docs                                         文档markdown源文件目录
│  │  │  ├─d                                         data相关文档(data_source_*)
│  │  │  │  ├─availability_zones.html.md
│  │  │  │  ├─nats.html.markdown
│  │  │  │  ├─vpc.html.markdown
│  │  │  │  ├─...
│  │  │  ├─index.html.markdown
│  │  │  ├─r                                         resource相关文档(resource_*)
│  │  │  │  ├─instance.html.markdown
│  │  │  │  ├─nat_gateway.html.markdown
│  │  │  │  ├─vpc.html.markdown
│  │  │  │  └─...

结构主要分五部分

  • main.go,插件入口
  • examples,示例目录,因为你的插件最终是给用户用的,一个比较理想的示例,是用户拉到代码后,可以直接跑起来
  • tencentcloud,最重要的目录,也就是我们的插件目录,里面都是Go文件,其中
    • provider.go 这是插件的根源,用于描述插件的属性,如:配置的秘钥,支持的资源列表,回调配置等
    • data_source_*.go 定义的一些用于读调用的资源,主要是查询接口
    • resource_*.go 定义的一些写调用的资源,包含资源增删改查接口
    • service_*.go 按资源大类划分的一些公共方法
  • vendor,依赖的第三方库
  • website,文档,重要性同examples

5. 生命周期

下图是Terraform的整个执行过程:

  • ① ~ ④ 是在寻找 Providertencentcloud 插件就是这时候加载的
  • ⑤ 是读取用户的配置文件,通过配置文件,可以获得分别属于哪种资源,以及每个资源的状态
  • ⑥ 根据资源的状态,调用不同的函数,Create Update Delete 都属于写操作,而 Read 操作,只在 Update 的时候,作为前置操作

    何谓 Create ?
    当在 .tf 文件增加一个新的资源配置时,这时候 Terraform 认为是 Create

    何谓 Update ?
    当在 .tf 文件针对已经创建好的资源,修改其中一个或多个参数时,这时候 Terraform 认为是 Update

    何谓 Delete ?
    当把 .tf 文件中已经创建好的资源配置删掉后,或执行 terraform destroy 命令时,这时候 Terraform 认为是 Delete

    何谓 Read ?
    顾名思义,这是一个查询资源的操作,如前述 Read 只在 Update 的时候,作为前置操作,实际作用就是检查资源是否存在,以及更新资源属性到本地

细心的你一定注意到了 tencentcloud-sdk-go 这个 packagetencentcloud-sdk-go 是我们封装的一个独立于 Terraform 之外的基于 Tencent Cloud API 的Go版SDK

其作用就是负责调用 Tencent Cloud API

当然,你也可以不用它,直接在你的 terraform-provider 里组装参数、发送请求,但我们不建议这么做,使用SDK方式,可以让你的代码更加优雅,可以实现对出入参、HTTP请求的集中管理,可以让你的常用接口更好的复用,减少代码冗余

6. 定义资源

Terraform官网有个从 main.go 入口开始编写自定义Provider的指引 Writing Custom Providers,建议先浏览一遍。

成为Terraform提供商(开发Terraform插件),实际是对上游 API 的抽象,而所谓的资源就是我们的服务,比如云主机、私有网络、NAT网关。按惯例,我们要把每个资源放在自己的插件目录下,并以资源命名,前缀为 resource_data_source_,比如

tencentcloud/resource_tc_nat_gateway.go

package tencentcloud

import (
    "github.com/hashicorp/terraform/helper/schema"
)

func resourceTencentCloudNatGateway() *schema.Resource {
    return &schema.Resource{
        Create: resourceTencentCloudNatGatewayCreate,
        Read:   resourceTencentCloudNatGatewayRead,
        Update: resourceTencentCloudNatGatewayUpdate,
        Delete: resourceTencentCloudNatGatewayDelete,

        Schema: map[string]*schema.Schema{
            "vpc_id": &schema.Schema{
                Type:     schema.TypeString,
                Required: true,
                ForceNew: true,
            },
            "name": &schema.Schema{
                Type:         schema.TypeString,
                Required:     true,
                ValidateFunc: validateStringLengthInRange(1, 60),
            },
            "max_concurrent": &schema.Schema{
                Type:     schema.TypeInt,
                Required: true,
            },
            "bandwidth": &schema.Schema{
                Type:     schema.TypeInt,
                Required: true,
            },
            "assigned_eip_set": &schema.Schema{
                Type:     schema.TypeSet,
                Required: true,
                Elem: &schema.Schema{
                    Type: schema.TypeString,
                },
                MinItems: 1,
                MaxItems: 10,
            },
        },
    }
}

func resourceTencentCloudNatGatewayCreate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceTencentCloudNatGatewayRead(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceTencentCloudNatGatewayUpdate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceTencentCloudNatGatewayDelete(d *schema.ResourceData, meta interface{}) error {
    return nil
}

这里实际就是返回了一个 schema.Resource 类型的结构体,结构体中我们定义了资源参数和CRUD操作

  • Create
  • Read
  • Update
  • Delete
  • Schema

其中 Schema 就是定义的资源参数,是 map[string]*schema.Schema 类型的嵌套数组,这是一个非常重要的数组,在Terraform里,你也理解为这些就是一个资源的属性
在我们本次的示例中,就是一个NAT网关的所有属性(这些属性,我们可以在NAT网关的云API中看到)

每个属性,它的值都是一个结构体,包含了若干属性,这些属性,都是围绕资源属性值的,下面逐一介绍

Type schema.ValueType

定义这个属性的值的数据类型,可选值及对应的数据类型

  • TypeBool - bool
  • TypeInt - int
  • TypeFloat - float64
  • TypeString - string
  • TypeList - []interface{}
  • TypeMap - map[string]interface{}
  • TypeSet - *schema.Set

Required bool

也就我们经常在 API 里说的 参数是否必填,默认 false,当设置为 true 后,用户对资源增删改操作时,都需要配置该参数

Optional bool

是否可选的,和 Required 互斥的,不能同时配置 RequiredOptional,即一个属性(参数)要么必填,要么可选

ForceNew bool

如果设置为 true,当资源属性值发生变化时,不会触发修改动作,而是删除该资源,再创建新的资源,即:

修改 = 删除 + 创建

这是一个非常有用的属性,我们很多云资源的很多属性都不支持修改,比如

  • 一个CVM实例创建时指定的子网,创建后,是不支持修改的
  • 一个NAT网关创建时指定的VPC,创建后,是无法修改的

在控制台可以通过前端技术实现这样的限制,Terraform 同样可以做到这样的限制,但 ForceNew 实现了更高级的用法,给用户提供了更多选择,

一个有趣的事情,如果某种云资源的所有属性,都是Required,并且属性联合起来,具有唯一性,比如路由表的路由策略、DNAT规则、KeyPair、...,都是这类特性,这时候你修改一个属性,实际就等价于删除旧资源,创建新资源
这时候,你就可以把所有属性的ForceNew 设为 true,然后不用实现 Update 函数了,因为无论用户修改哪个属性,都是走 Delete - Create 的流程,根本不会走到 Update 的流程里,但实现的效果,都是一样的,用户是无感知的

ValidateFunc SchemaValidateFunc

属性值的扩展验证函数,验证IP合法性示例:

func validateIp(v interface{}, k string) (ws []string, errors []error) {
    value := v.(string)
    ip := net.ParseIP(value)
    if ip == nil {
        errors = append(errors, fmt.Errorf("%q must contain a valid IP", k)) 
    }   
    return
}

MinItems、MaxItems int

TypeTypeSetTypeList 类型时,可以给 MinItemsMaxItems 赋值,限定属性值元素的最小个数和最大个数,上述代码中,我们限定了NAT网关的关联EIP个数范围是1~10个

CRUD操作

这4个操作 Create Read Update Delete,指向的是4个函数,也是我们重点要实现的。
在"生命周期"一节中,我们知道了Terraform是根据资源的模式和状态,来决定是否需要创建新资源,更新现有资源或销毁资源的,而最终就是调用这4个函数来实现的

7. CRUD实现

了解了用户行为、Terraform执行流程、资源管理逻辑,现在就是实现这些功能的时候了
因为这块内容较多,这里继续用NAT网关作为示例,详述一个资源CURD的实现

开始之前,我们需要引入更多的包,都是我们后面要用到的

import (
    "encoding/json"
    "errors"
    "fmt"
    "log"

    "github.com/hashicorp/terraform/helper/schema"
    "github.com/zqfan/tencentcloud-sdk-go/common"
    vpc "github.com/zqfan/tencentcloud-sdk-go/services/vpc/unversioned"
)

//...

func resourceTencentCloudNatGatewayCreate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceTencentCloudNatGatewayRead(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceTencentCloudNatGatewayUpdate(d *schema.ResourceData, meta interface{}) error {
    return nil
}

func resourceTencentCloudNatGatewayDelete(d *schema.ResourceData, meta interface{}) error {
    return nil
}

上述代码中,我们看到,我们要实现的资源管理函数,出参都是 error 类型,说明Terraform都是根据 error 来判断成功与否的,返回 nil 时表示操作成功,否则就报错

入参都是 *schema.ResourceData 类型的参数 d,和 interface{} 类型的参数 meta,具体这两个参数有什么用呢?
这是我们这节的关键!
参数 d 是我们开发过程中用的最多的参数,它的数据类型是个对象,包含了非常的方法,下面我们介绍几个常用的方法

func (*ResourceData) Get

func (d *ResourceData) Get(key string) interface{}

用来获取给定 Key 的数据,如果给定的 Key 不存在,会返回 nil
通过 Set 方法设置的数据,以及用户配置的参数,都可以通过这个方法获得
一般,我们在 Create 资源的时候,用的比较多

func (*ResourceData) GetOk

func (d *ResourceData) GetOk(key string) (interface{}, bool)

检查给定的 Key 是否设置为一个非0的值,一般我们在获取 Optional 类型的属性值的时候,会用到

func (*ResourceData) SetId

func (d *ResourceData) SetId(v string)

Terraform对资源的管理都是围绕ID实现的,每个资源都有一个唯一ID,一个ID代表一个资源,因此,当创建资源后,需要调用这个方法写入资源ID,一般服务端都会返回资源唯一ID,比如我们的示例中,这个ID就是NAT网关的ID.
eg: nat-79r5e43i

这时候,你是不是有一个疑惑?我们的资源没有唯一 ID 怎么办?

对于没有唯一ID的资源,比如路由策略、安全组规则的增删改查,我们就需要自己构造ID了。
可以用某个参数作为ID;也可以多个参数联合起来;也可以自己实现一个算法生成ID。
前提条件就是一定要唯一 ,然后我们在用到ID的时候,再反解出来,这就间接实现了我们所需要的唯一 ID

func (*ResourceData) Id

func (d *ResourceData) Id() string

获取当前的资源ID,也就是 SetId 方法写入的值,比如我们在 Read Update Delete 的时候,都需要用到ID,映射到对应的资源,从而完成对某个资源的读取,修改,删除

func (*ResourceData) Set

func (d *ResourceData) Set(key string, value interface{}) error

给某个 Key 设置值,设置后,可以用 Get 方法获取,一般用于 Read 操作,从服务端 Read 完数据后,会将资源的属性 Set 到本地,用于后续的其他资源管理操作

func (*ResourceData) HasChange

func (d *ResourceData) HasChange(key string) bool

想象一下,当用户修改了他的配置文件(也就是修改资源的属性),我们的程序是怎么知道的?
这时候,就需要用到 HasChange 了,检查给定的 Key 是否发生变化,一个非常有用而且经常会用到的方法,一般在 Update 操作的时候,我们需要监控用户的配置文件,发生变化时,我们就触发变更操作

func (*ResourceData) GetChange

func (d *ResourceData) GetChange(key string) (interface{}, interface{})

这个方法就是当我们在使用 HasChange 方法知道数据发生变化时,用这个方法可以获取到变化前后的数据,即旧数据和新数据
比如用户修改了NAT网关的关联弹性IP,这时候,我们就需要将对比新旧数据,将用户删减的弹性IP,从服务端解绑,用户增加的弹性IP,绑定到NAT网关

func (*ResourceData) Partial

func (d *ResourceData) Partial(on bool)

一般我们的资源属性,有非常多属性是支持修改的,比如我们这次示例中NAT网关,其中NAT网关的名称 name、最大并发连接数 max_concurrent、带宽上限 bandwidth、关联弹性IP assigned_eip_set 都是支持修改的。

对用户来说,这些都是NAT网关的属性值而已,但对我们开发人员来说,涉及到的后端接口却是不一样的,这时候,如果用户修改了多个属性值,按照文档流的执行方式,如果前面执行的修改成功了,后面执行的失败了,这时候如果退出程序,给用户报错,就不合理了,因为实际我们的后端,已经修改了其中部分属性值。
这时候,服务端的数据和用户本地的数据,也不一致了,后续的其他操作,也会出现比较严重的问题

所以,我们应该不难理解这个方法的用途,就是用来设置是否 允许修改部分属性 的方法,默认false,当开启 允许修改部分属性 后,使用了 SetPartial 方法设置的属性,即便 Update 出现错误,已经修改成功的属性,也会将状态同步到本地,程序下次执行时,就不会认为是要更新的了

总结三个字就是 “非事务”

func (*ResourceData) SetPartial

func (d *ResourceData) SetPartial(k string)

这个方法就是配合 Partial 方法使用的,经过这个方法设置的属性,允许修改部分属性 的逻辑才有效

7.1 创建资源

这里就是创建NAT网关

func resourceTencentCloudNatGatewayCreate(d *schema.ResourceData, meta interface{}) error {

    // 创建请求对象
    args := vpc.NewCreateNatGatewayRequest()

    // 给对象属性赋值,这里要注意,因为
    args.VpcId = common.StringPtr(d.Get("vpc_id").(string))
    args.NatName = common.StringPtr(d.Get("name").(string))

    // 因为 max_concurrent 和 bandwidth 是可选值,所以我们用 GetOk 判断用户是否配置
    if v, ok := d.GetOk("max_concurrent"); ok {
        args.MaxConcurrent = common.IntPtr(v.(int))
    }
    if v, ok := d.GetOk("bandwidth"); ok {
        args.Bandwidth = common.IntPtr(v.(int))
    }

    // assigned_eip_set 是个数组,取值方法和整型、字符串有点不一样,需要用 List 方法
    eips := d.Get("assigned_eip_set").(*schema.Set).List()
    args.AssignedEipSet = common.StringPtrs(expandStringList(eips))

    // 这里就是发送请求了
    client := meta.(*TencentCloudClient)
    conn := client.vpcConn
    response, err := conn.CreateNatGateway(args)
    b, _ := json.Marshal(response)
    log.Printf("[DEBUG] conn.CreateNatGateway response: %s", b)
    if _, ok := err.(*common.APIError); ok {
        return fmt.Errorf("conn.CreateNatGateway error: %v", err)
    }

    // 因为NAT网关的创建是异步的,到这里,我们只拿到了一个BillId,所以需要用到轮询逻辑了
    if _, err := client.PollingVpcBillResult(response.BillId); err != nil {
        return err
    }

    // 为了方便调试,我们把NAT网关ID记录到日志
    log.Printf("[DEBUG] conn.CreateNatGateway NatGatewayId: %s", *response.NatGatewayId)

    // 调用 SetId 写入资源ID(这里就是NAT网关ID),关于 SetId 方法的作用,参考前面说的
    d.SetId(*response.NatGatewayId)
    return nil
}

上述代码中 PollingVpcBillResult,我们说到了轮询,其实在Terraform开发中,轮询这个操作,是用的很频繁的,主要适用于异步的服务端接口,比如当前示例的NAT网关创建,还有后面会讲到的修改带宽,又如一些资源删除也都是异步的。
服务端只返回一个任务ID,这时候需要我们在客户端轮询任务,直到结果返回,我们才能直到这个资源的真正的状态!

这个方法位于 service_vpc.go,并且是作为 *TencentCloudClient 对象的一个方法,核心是用到了Terraform官方的 resource 库,直接来看下这个方法吧,

func (client *TencentCloudClient) PollingVpcBillResult(billId *string) (status bool, err error) {
    queryReq := vpc.NewQueryNatGatewayProductionStatusRequest()
    queryReq.BillId = billId
    status = false
    // 设置超时时间为3分钟
    err = resource.Retry(3*time.Minute, func() *resource.RetryError {
        queryResp, err := client.vpcConn.QueryNatGatewayProductionStatus(queryReq)
        b, _ := json.Marshal(queryResp)
        log.Printf("[DEBUG] client.vpcConn.QueryNatGatewayProductionStatus response: %s", b)
        if _, ok := err.(*common.APIError); ok {
            // 返回 NonRetryableError 错误,resource 会退出重试,并返回错误信息
            return resource.NonRetryableError(fmt.Errorf("client.vpcConn.QueryNatGatewayProductionStatus error: %v", err))
        }   
        // 返回 nil 之后,表示操作成功,resource 就会退出重试
        if *queryResp.Data.Status == vpc.BillStatusSuccess {
            return nil 
        }  
        // 返回一个 RetryableError 错误,resource 将持续重试
        return resource.RetryableError(fmt.Errorf("billId %v, not ready, status: %v", billId, *queryResp.Data.Status))
    })  
    return
}

7.2 读取资源

Create 的代码末尾,我们看到了 SetId,而 Read 操作,我们就是要根据资源ID,查询资源,然后调用 Set 方法回写本地

func resourceTencentCloudNatGatewayRead(d *schema.ResourceData, meta interface{}) error {

    conn := meta.(*TencentCloudClient).vpcConn

    descReq := vpc.NewDescribeNatGatewayRequest()
    descReq.NatId = common.StringPtr(d.Id())

    descResp, err := conn.DescribeNatGateway(descReq)
    b, _ := json.Marshal(descResp)
    log.Printf("[DEBUG] conn.DescribeNatGateway response: %s", b)
    if _, ok := err.(*common.APIError); ok {
        return fmt.Errorf("conn.DescribeNatGateway error: %v", err)
    }

    // 未找到资源时,为什么不报错?SetId("") 又是什么意思?
    if *descResp.TotalCount == 0 || len(descResp.Data) == 0 {
        d.SetId("")
        return nil 
    } else if err != nil {
        return err
    }

    nat := descResp.Data[0]

    d.Set("name", *nat.NatName)
    d.Set("max_concurrent", *nat.MaxConcurrent)
    d.Set("bandwidth", *nat.Bandwidth)
    d.Set("assigned_eip_set", nat.EipSet)
    return nil
}

我们在代码15行,留了个疑问,这也是很多开发,初次开发Terraform时,不太理解的地方!

当从服务端查询没有数据时,我们并不直接报错,而是把ID置空,并且返回 nil,这样做的目的是因为我们的云资源管理行为,不只在Terraform,还有控制台,也可能基于云API的其他工具,倘若不是因为你的代码Bug导致查询失败而未找到数据,那就是在其他工具删除了该资源导致资源为找到,这时候

  • 返回 nil,是为了不让程序退出,让程序不认为这是错误
  • 把ID置空,是为了改变资源状态,前面我们提到Terraform,对于资源的管理,是完全基于ID的,当我们把ID置空,Terraform未找到资源ID,就会认为这是一个新资源,这也是我们所预期的

7.3 修改资源

我们在生命周期那一节,讲到了 Update 操作前,Terraform实际会先调用 Read,为什么呢?
因为Terraform判断一个资源状态,是依据本地的 terraform.tfstate 文件,这里记录所有配置(即资源)的状态,但是状态并非实时的,所以 Terraform 在做 Update 操作之前,会先从服务器 Read 数据,用最新的数据和本地做对比,获取最新的资源状态

func resourceTencentCloudNatGatewayUpdate(d *schema.ResourceData, meta interface{}) error {

    client := meta.(*TencentCloudClient)
    conn := client.vpcConn

    // 开启 允许部分属性修改 功能
    d.Partial(true)

    // 标识是否有修改
    attributeUpdate := false

    updateReq := vpc.NewModifyNatGatewayRequest()
    updateReq.VpcId = common.StringPtr(d.Get("vpc_id").(string))
    updateReq.NatId = common.StringPtr(d.Id())

    // 修改NAT网关名称
    if d.HasChange("name") {
        d.SetPartial("name")
        var name string
        if v, ok := d.GetOk("name"); ok {
            name = v.(string)
        } else {
            return fmt.Errorf("cann't change name to empty string")
        }
        updateReq.NatName = common.StringPtr(name)

        attributeUpdate = true
    }

    // 修改带宽上限
    if d.HasChange("bandwidth") {
        d.SetPartial("bandwidth")
        var bandwidth int
        if v, ok := d.GetOk("bandwidth"); ok {
            bandwidth = v.(int)
        } else {
            return fmt.Errorf("cann't change bandwidth to empty string")
        }
        updateReq.Bandwidth = common.IntPtr(bandwidth)

        attributeUpdate = true
    }

    // 修改名称和带宽上限,用的同一个接口,如果有修改,就提交
    if attributeUpdate {
        updateResp, err := conn.ModifyNatGateway(updateReq)
        b, _ := json.Marshal(updateResp)
        log.Printf("[DEBUG] conn.ModifyNatGateway response: %s", b)
        if _, ok := err.(*common.APIError); ok {
            return fmt.Errorf("conn.ModifyNatGateway error: %v", err)
        }
    }

    // 修改并发连接数上限,这里用到了 GetChange,对比新旧数据
    // 因为我们的NAT网关的并发连接数上限,只能升不能降
    if d.HasChange("max_concurrent") {
        d.SetPartial("max_concurrent")
        old_mc, new_mc := d.GetChange("max_concurrent")
        old_max_concurrent := old_mc.(int)
        new_max_concurrent := new_mc.(int)
        if new_max_concurrent <= old_max_concurrent {
            return fmt.Errorf("max_concurrent only supports upgrade")
        }
        upgradeReq := vpc.NewUpgradeNatGatewayRequest()
        upgradeReq.VpcId = updateReq.VpcId
        upgradeReq.NatId = updateReq.NatId
        upgradeReq.MaxConcurrent = common.IntPtr(new_max_concurrent)

        upgradeResp, err := conn.UpgradeNatGateway(upgradeReq)
        b, _ := json.Marshal(upgradeResp)
        log.Printf("[DEBUG] conn.UpgradeNatGateway response: %s", b)
        if _, ok := err.(*common.APIError); ok {
            return fmt.Errorf("conn.UpgradeNatGateway error: %v", err)
        }

        if _, err := client.PollingVpcBillResult(upgradeResp.BillId); err != nil {
            return err
        }
    }

    // 修改关联弹性EIP,这块逻辑稍微复杂点,因为 `assigned_eip_set` 是个数组
    // 我们需要对比新旧数据,拿到用户删除的数组元素和增加的数组元素
    // 然后调用解绑接口,解绑用户删除的数组元素;再调用绑定接口,绑定用户增加的数组元素
    if d.HasChange("assigned_eip_set") {
        o, n := d.GetChange("assigned_eip_set")
        os := o.(*schema.Set)
        ns := n.(*schema.Set)

        old_eip_set := os.List()
        new_eip_set := ns.List()

        if len(old_eip_set) > 0 && len(new_eip_set) > 0 {

            // Unassign old EIP
            unassignIps := os.Difference(ns)
            if unassignIps.Len() != 0 {
                unbindReq := vpc.NewEipUnBindNatGatewayRequest()
                unbindReq.VpcId = updateReq.VpcId
                unbindReq.NatId = updateReq.NatId
                unbindReq.AssignedEipSet = common.StringPtrs(expandStringList(unassignIps.List()))
                unbindResp, err := conn.EipUnBindNatGateway(unbindReq)
                b, _ := json.Marshal(unbindResp)
                log.Printf("[DEBUG] conn.EipUnBindNatGateway response: %s", b)
                if _, ok := err.(*common.APIError); ok {
                    return fmt.Errorf("conn.EipUnBindNatGateway error: %v", err)
                }

                if _, err := client.PollingVpcTaskResult(unbindResp.TaskId); err != nil {
                    return err
                }
            }

            // Assign new EIP
            assignIps := ns.Difference(os)
            if assignIps.Len() != 0 {
                bindReq := vpc.NewEipBindNatGatewayRequest()
                bindReq.VpcId = updateReq.VpcId
                bindReq.NatId = updateReq.NatId
                bindReq.AssignedEipSet = common.StringPtrs(expandStringList(assignIps.List()))
                bindResp, err := conn.EipBindNatGateway(bindReq)
                b, _ := json.Marshal(bindResp)
                log.Printf("[DEBUG] conn.EipBindNatGateway response: %s", b)
                if _, ok := err.(*common.APIError); ok {
                    return fmt.Errorf("conn.EipBindNatGateway error: %v", err)
                }

                if _, err := client.PollingVpcTaskResult(bindResp.TaskId); err != nil {
                    return err
                }
            }

        } else {
            return errEipUnassigned
        }

        d.SetPartial("assigned_eip_set")
    }

    // 关闭 允许部分属性修改 功能
    d.Partial(false)

    return nil
}

主要思路,概括下就是:
1. 调用 Partial 方法开启 允许部分属性修改 功能
2. 调用 HasChange 方法检查是否变化,
3. 调用 SetPartial 方法把该属性加入到部分属性修改的集合里
4. 调用 GetChange 方法获取新旧数据(也可以直接 Get 最新数据)
5. 提交修改
6. 调用 Partial 方法关闭 允许部分属性修改 功能

7.4 删除资源

删除资源就是根据资源ID,从服务端将对应的资源删除

func resourceTencentCloudNatGatewayDelete(d *schema.ResourceData, meta interface{}) error {

    client := meta.(*TencentCloudClient)

    deleteReq := vpc.NewDeleteNatGatewayRequest()
    deleteReq.VpcId = common.StringPtr(d.Get("vpc_id").(string))
    deleteReq.NatId = common.StringPtr(d.Id())

    deleteResp, err := client.vpcConn.DeleteNatGateway(deleteReq)
    b, _ := json.Marshal(deleteResp)
    log.Printf("[DEBUG] client.vpcConn.DeleteNatGateway response: %s", b)
    if _, ok := err.(*common.APIError); ok {
        return fmt.Errorf("[ERROR] client.vpcConn.DeleteNatGateway error: %v", err)
    }

    _, err = client.PollingVpcTaskResult(deleteResp.TaskId)
    return err
}

示例是一个最简单的删除操作,在实际应用中,如果你的资源删除是异步的,或者删除操作,还依赖其他资源删除,比如当删除一个私有网络资源时,如果网络内还有其他资源,比如子网、VPN等,调用删除接口时,会报错,导致删除失败!
遇到这些场景,我们还需要用到前面提到的重试操作,
就是当删除失败,特定原因下(一般就是有依赖关系)我们要执行重试,因为Terraform在删除资源时,是有次序的,直接删除有可能删不掉,而重试,当依赖关系都删完后,就能删除最顶层的被依赖的资源了

至此,一个基本的资源管理程序就算写完了!最后你还需要将资源管理函数配置到 provider.goResourcesMap 映射关系列表中,才能真正被使用

8. 编写单元测试用例

到了测试环节,你可以自己编写 tf 文件,编译插件

go build -o terraform-provider-tencentcloud

然后测试你的程序

terrform plan
terrform apply

但我们非常不鼓励你这么做,我们强烈建议你自己编写单元测试用例,测试你的程序,在前面的 Provider架构 章节中,你可以看到许多的 *_test.go 这就是我们的单元测试用例
如果要成为Terraform官方认证的provider,单元测试用例,也是必不可少的

我们先来看下Terraform的单元测试系统流程图

下面是NAT网关资源管理程序的单元测试用例:

package tencentcloud

import (
    "encoding/json"
    "fmt"
    "log"
    "testing"

    "github.com/hashicorp/terraform/helper/resource"
    "github.com/hashicorp/terraform/terraform"
    "github.com/zqfan/tencentcloud-sdk-go/common"
    vpc "github.com/zqfan/tencentcloud-sdk-go/services/vpc/unversioned"
)

func TestAccTencentCloudNatGateway_basic(t *testing.T) {
    resource.Test(t, resource.TestCase{
        PreCheck:     func() { testAccPreCheck(t) },
        Providers:    testAccProviders,
        // 配置 资源销毁结果检查函数
        CheckDestroy: testAccCheckNatGatewayDestroy,
        // 配置 测试步骤
        Steps: []resource.TestStep{
            {
                // 配置 配置内容
                Config: testAccNatGatewayConfig,
                // 配置 验证函数
                Check: resource.ComposeTestCheckFunc(
                    // 验证资源ID
                    testAccCheckTencentCloudDataSourceID("tencentcloud_nat_gateway.my_nat"),
                    // 验证资源属性,能匹配到,肯定就是创建成功了
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "name", "terraform_test"),
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "max_concurrent", "3000000"),
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "bandwidth", "500"),
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "assigned_eip_set.#", "2"),
                ),
            },
            {
                // 配置 配置内容
                Config: testAccNatGatewayConfigUpdate,
                Check: resource.ComposeTestCheckFunc(
                    testAccCheckTencentCloudDataSourceID("tencentcloud_nat_gateway.my_nat"),
                    // 验证修改后的属性值,如果能匹配到,肯定就是修改成功了
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "name", "new_name"),
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "max_concurrent", "10000000"),
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "bandwidth", "1000"),
                    resource.TestCheckResourceAttr("tencentcloud_nat_gateway.my_nat", "assigned_eip_set.#", "2"),
                ),
            },
        },
    })
}

// testAccProviders 在测试前会根据 Config 建立测试资源,测试结束后又会全部销毁
// 这个函数就是检查资源是否销毁用的,代码逻辑比较好理解,就是根据ID查询资源是否存在
func testAccCheckNatGatewayDestroy(s *terraform.State) error {

    conn := testAccProvider.Meta().(*TencentCloudClient).vpcConn

    // 这用到了 s.RootModule().Resources 数组
    // 这个数组的属性反应的就是资源状态文件 terraform.tfstate
    for _, rs := range s.RootModule().Resources {
        if rs.Type != "tencentcloud_nat_gateway" {
            continue
        }

        descReq := vpc.NewDescribeNatGatewayRequest()
        descReq.NatId = common.StringPtr(rs.Primary.ID)
        descResp, err := conn.DescribeNatGateway(descReq)

        b, _ := json.Marshal(descResp)

        log.Printf("[DEBUG] conn.DescribeNatGateway response: %s", b)

        if _, ok := err.(*common.APIError); ok {
            return fmt.Errorf("conn.DescribeNatGateway error: %v", err)
        } else if *descResp.TotalCount != 0 {
            return fmt.Errorf("NAT Gateway still exists.")
        }
    }
    return nil
}

// 基本用法配置文件,机智的你,一定发现了,这不是和debug用的tf文件一样一样的么
const testAccNatGatewayConfig = `
resource "tencentcloud_vpc" "main" {
  name       = "terraform test"
  cidr_block = "10.6.0.0/16"
}
resource "tencentcloud_eip" "eip_dev_dnat" {
  name = "terraform_test"
}
resource "tencentcloud_eip" "eip_test_dnat" {
  name = "terraform_test"
}

resource "tencentcloud_nat_gateway" "my_nat" {
  vpc_id           = "${tencentcloud_vpc.main.id}"
  name             = "terraform_test"
  max_concurrent   = 3000000
  bandwidth        = 500 
  assigned_eip_set = [ 
    "${tencentcloud_eip.eip_dev_dnat.public_ip}",
    "${tencentcloud_eip.eip_test_dnat.public_ip}",
  ]
}
`

// 修改用法配置文件,机智的你一定发现了,这不就是和debug修改后的tf文件一样一样的么
const testAccNatGatewayConfigUpdate = `
resource "tencentcloud_vpc" "main" {
  name       = "terraform test"
  cidr_block = "10.6.0.0/16"
}
resource "tencentcloud_eip" "eip_dev_dnat" {
  name = "terraform_test"
}
resource "tencentcloud_eip" "eip_test_dnat" {
  name = "terraform_test"
}
resource "tencentcloud_eip" "new_eip" {
  name = "terraform_test"
}

resource "tencentcloud_nat_gateway" "my_nat" {
  vpc_id           = "${tencentcloud_vpc.main.id}"
  name             = "new_name"
  max_concurrent   = 10000000
  bandwidth        = 1000 
  assigned_eip_set = [ 
    "${tencentcloud_eip.eip_dev_dnat.public_ip}",
    "${tencentcloud_eip.new_eip.public_ip}",
  ]
}
`

开始测试

export TF_ACC=true
cd tencentcloud
go test -i; go test -test.run TestAccTencentCloudNatGateway_basic -v

我们可以看到,用官方的 testAccProviders,除了自动编译,测试流程也更加标准化,全面覆盖 Create Update Delete,针对同一个资源管理程序,你还可以编写很多更复杂的场景,加入到 Steps,或者分成多个测试用例,这样的测试会更加全面!

写在最后:
目前已经越来越多的云厂商和服务商支持Terraform,为用户提供服务,Terraform也日渐普遍。欢迎关注微信公众号:程序员到架构师,回复Terraform获取更多内容,后续也将继续推送更多有关云计算的文章

MySQL位运算的应用

我们知道,PHP当中的错误级别常量,是根据二进制位特性而确定的一个个整数,可以简单的通过位运算定制PHP的错误报告。我们也可以将其应用到 MySQL 当中!

你是否遇到过下面这样的情况?

一、用户可能有若干不同属性或不同状态,然后你可能会在数据表中通过一个个字段去标记和实现,比如:

id int(11) 用户ID(自增)
username char(50) 用户名
password char(50) 密码
... ... ...
confirm_info tinyint(1) 是否确认资料 [ 0/1 ]
confirm_contract tinyint(1) 是否确认协议 [ 0/1 ]
freeze tinyint(1) 冻结状态 [ 0/1 ]
payer tinyint(1) 笔记是否付费用户 [ 0/1 ]
high_quality tinyint(1) 标记是否优质用户 [ 0/1 ]

二、用户拥有多个用户组(角色),常见的实现方法有:

1、增加一个字段,专门存它的用户组,用逗号隔开

id int(11) 用户ID(自增)
username char(50) 用户名
password char(50) 密码
... ... ...
confirm_info tinyint(1) 是否确认资料 [ 0/1 ]
confirm_contract tinyint(1) 是否确认协议 [ 0/1 ]
freeze tinyint(1) 冻结状态 [ 0/1 ]
payer tinyint(1) 笔记是否付费用户 [ 0/1 ]
high_quality tinyint(1) 标记是否优质用户 [ 0/1 ]
role_id varchar(255) 如:1,3,11

使用 FIND_IN_SET() 或 LIKE 进行数据查询。这种存储方式,虽然比较直观,结构也比较接单,但是,但是查询和维护数据都不方便

2、增加一个关联表

user_id int(11) 用户ID
role_id int(11) 角色ID
... ... ...

通过JOIN,连表查询。这种存储方式,似乎很好扩展,无论是增加了用户组,还是重新给用户划分用户组,直接操作这张关联表就行。但是,连表查询终究是需要操作多张表的,效率肯定不如单表查询,而且如果用户属性多了(不仅限于分组),那就得关联更多的表或者查多次,使查询变得更加复杂!


我们知道,PHP当中的错误级别常量,是根据二进制位特性而确定的一个个整数,可以简单的通过位运算定制PHP的错误报告。我们也可以将其应用到 MySQL 当中,还是以上面的用户表为例,我们只用一字段 status,专门记录用户状态,其结构如下:

id int(11) 用户ID(自增)
username char(50) 用户名
password char(50) 密码
... ... ...
status int(11) 用户状态位

然后我们给每一种状态设定一个对应的值,这个值需要使用2的N次方来表示,比如:

1 已确认资料
2 已确认协议
4 已冻结
8 付费用户
16 优质用户

查询技巧示例:

1、查询所有已冻结的用户:

SELECT * FROM `user` WHERE `status` & 4 = 4

2、查询所有未确认协议的用户

SELECT * FROM `user` WHERE `status` & 2 = 0

3、设置ID等于186的用户为优质用户

UPDATE `user` SET `status` = `status` | 16 WHERE `id` = 186

4、取消ID等于186的优质用户身份

UPDATE `user` SET `status` = `status` ^ 16 WHERE `id` = 186

5、查询已冻结的优质付费用户(4 + 8 + 16 = 28)

SELECT * FROM `user` WHERE `status` & 28 = 28

同理,上述应用场景二中的分组问题,也可以使用同样的方法,优化表结构!然后使用位运算,实现各种条件的查询!

秒懂系列|Apache用户认证配置之Basic认证

很多时候我们可能需要对服务器资源进行保护,通常的做法是在应用层通过鉴权来实现,如果你嫌自己去实现鉴权太麻烦,那就直接让Apache去帮你实现吧!
Apache常见的用户认证可以分为下面三种:
- 基于IP,子网的访问控制(ACL)
- 基本用户验证(Basic Authentication)
- 消息摘要式身份验证(Digest Authentication)

基于IP的访问控制可以通过配置 Allow From实现!这里不多讲。
一般的,我们还会在IP的基础上,再增加一层 Basic Authentication,实现一个基本的服务器用户认证!

1、生成用户名密码文件

/usr/local/apache2/bin/htpasswd -bc users.pwd test hehe1234

Adding password for user test

/usr/local/apache2/bin/htpasswd -b users.pwd test2 hehe4321

Adding password for user test2

cat users.pwd

test:$apr1$4R3foyQ5$1KGHVA5HQL8M9b0K/2UWO0
test2:$apr1$pKLy86CD$W9hFUvs4F06OBXtQhCbPV/

可以看到用户名密码文件已经生成了,一行一个!

2、配置 VirtualHost,如:

<VirtualHost *:80>
    DocumentRoot /usr/local/www/pma/
    DirectoryIndex index.php index.html index.shtml
    ServerName pma.979137.com
    CustomLog "logs/pma.979137.com-access_log" common
    ErrorLog "logs/pma.979137.com-error_log"
    <Directory /usr/local/www/pma/>
        Options Includes FollowSymLinks
        AllowOverride AuthConfig
        AuthName "PMA Contents." 
        AuthType basic
        AuthUserFile /usr/local/apache/conf/users.pwd 
        Require valid-user
    </Directory>
</VirtualHost>
  • AllowOverride 表示通过配置文件进行身份验证
  • AuthName 发送给客户端报文头内容:WWW-Authenticate
  • AuthType 认证类型
  • AuthUserFile 这个就是刚刚生成的用户名密码文件
  • Require 指定哪些用户或组才能被授权访问。如:
    • require user test test2(只有用户 test 和 test2 可以访问)
    • requires groups managers (只有组 managers 中成员可以访问)
    • require valid-user (在 AuthUserFile 指定的文件中任何用户都可以访问)

我们来看一下效果:

在浏览器访问:

cURL请求:

curl -v http://pma.979137.com/test.php

* Trying 10.223.28.1...
* Connected to pma.979137.com (10.223.28.1) port 80 (#0)
> GET /test.php HTTP/1.1
> Host: pma.979137.com
> User-Agent: curl/7.43.0
> Accept: */*>
< HTTP/1.1 401 Authorization Required < Date: Fri, 06 Jan 2017 07:02:15 GMT < Server: Apache/2.2.27 (Unix) PHP/5.3.29 < WWW-Authenticate: Basic realm=" PMA Contents." < Content-Length: 490 < Content-Type: text/html; charset=iso-8859-1 < >401 Authorization Required
>Authorization Required
This server could not verify that you
are authorized to access the document
requested. Either you supplied the wrong
credentials (e.g., bad password), or your
browser doesn't understand how to supply
the credentials required.

在没有携带用户名和密码时,HTTP Code 返回了 401,并输出了 Authorization Required!
表示需要该请求需要进行认证!

我们再来看下,携带密码请求:

curl -v -u 'test:hehe1234' 'http://pma.979137.com/test.php'

* Trying 10.223.28.1...
* Connected to pma.979137.com (10.223.28.1) port 80 (#0)
* Server auth using Basic with user 'test'
> GET /test.php HTTP/1.1
> Host: pma.979137.com
> Authorization: Basic c2hpbGlhbmd4aWU6YWl5aTEzMTQ=
> User-Agent: curl/7.43.0
> Accept: */*
>
< HTTP/1.1 200 OK < Date: Fri, 06 Jan 2017 07:14:14 GMT < Server: Apache/2.2.27 (Unix) PHP/5.3.29 < X-Powered-By: PHP/5.3.29 < Content-Length: 25 < Content-Type: text/html < string(11) "hello word!"

HTTP Code 已经是 200 了,并且返回了正确的内容!
至此,一个简单的 Basic 认证就OK了!这种认证一般可用于浏览器访问,也可以用于 API 认证!

秒懂系列|Apache和NGINX如何按天分割日志

默认情况下ApacheNGINX等web服务器,是没有帮我们按天分日志的,而是把所有日志放到一个文件,当流量大而日志多的时候,管理起来非常方便,一方面是内容多不易查找定位问题,另一方面文件也会越来越大,无效内容越来越多。
常规做法是把日志按天分割,网上很多通过 写脚本+定时任务 来做的,这其实有点多余,而且不好维护,Linux本身自带了很多日志处理程序,比如:logrotate ,它可以实现日志的自动滚动,日志归档等功能。
下面我们介绍使用 logrotate 实现
- Apache按天分割日志
- Nginx按天分割日志

Apache

Apache自身即成了 logrotate,一般在 /usr/local/apache2/bin/rotatelogs
先找到你的 rotatelogs 路径

locate rotatelogs

/usr/local/apache2/bin/rotatelogs

在你的 VirtualHost 模块添加或替换

<VirtualHost *:80>
    ......
    ErrorLog "| /usr/local/apache2/bin/rotatelogs /data/logs/apache/979137.com-error_log-%Y%m%d 86400 480"
    CustomLog "| /usr/local/apache2/bin/rotatelogs /data/logs/apache/979137.com-access_log-%Y%m%d 86400 480" common
</VirtualHost>

指定分割时间:86400 默认单位为s。也就是24小时
指定分区时差:480 默认单位m,也就是8小时

只保留30天日志,自动清理脚本:

#!/bin/bash

LOG_PATH_APACHE="/data/logs/apache/"
DAYS_AGO=date -d "-30 day" +%Y%m%d
\rm -rf ${LOG_PATH_APACHE}*log-${DAYS_AGO}

加入到crontab,每天执行一次即可

0 4 * * * /bin/bash /data/cron/clear_logs.sh > /dev/null 2&>1

NGINX

假设:
- 日志在 /data/logs/nginx/ 下面
- Nginx 安装在 /usr/local/nginx/

1、在 /etc/logrotate.d 目录下创建一个 Nginx 的配置文件 Nginx 配置内容如下

/data/logs/nginx/xxx.qq.com-access_log /data/logs/nginx/xxx.qq.com-error_log {
    su nobody nobody
    daily
    rotate 30
    notifempty
    sharedscripts
    postrotate
    if [ -f /usr/local/nginx/logs/nginx.pid ]; then
        /bin/kill -USR1 /bin/cat /usr/local/nginx/logs/nginx.pid
    fi
    endscript
}

配置解释:
1)配置需要分割的日志文件,也可以用 * 代替
2)su nobody nobody :一般我们的日志目录是允许所有用户进行写操作的,logrotate 认为这不是不安全的,这时 logrotate 会报错如下:

error: skipping "/data/logs/nginx/cafe.qq.com-error_log" because parent directory has insecure permissions (It's world writable or writable by group which is not "root") Set "su" directive in config file to tell logrotate which user/group should be used for rotation.

所以我们需要在配置里使用 su 切换身份执行
3)daily :日志文件每天进行滚动
4)rotate 30 :保留30天的日志
5)notifempty:日志文件为空不进行滚动
6)sharedscripts :运行postrotate脚本
9)/bin/kill -USR1 \/bin/cat /usr/local/nginx/logs/nginx.pid\ :Nginx平滑重启,也即让Nginx重新生成文件

2、执行logrotate

/usr/sbin/logrotate -f /etc/logrotate.d/nginx

如此,Nginx就会自动的按天产生日志了!

简单实用的PHPMonitor:运行错误监控

所有运行在线网环境的程序应该都被监控,对线网而言,无论是 Fatal error、E_NOTICE 还是 E_STRICT,都应该被消灭。对开发者而言,线网发生任何异常或潜在bug时,应该第一时间修复、优化,而不是等反馈之后才知道,然后临时去排查问题!

这篇文章要讲啥?

  1. 了解PHP错误机制
  2. 注册PHP的异常处理函数、错误处理函数、脚本退出函数
  3. 配置PHP预加载(重要)
  4. 搭建日志中心(ELK,ElasticSearch + Logstash + Kibana)
  5. 基于 ElasticSearch RESTful 实现告警

先看一张系统全局图

要达到的目的?

所有PHP程序里的错误或潜在错误(支持自定义)都全部写入日志并告警,包含错误的所在文件名、行号、函数(如果有)、错误信息等等
PHP程序不需要做任何修改或接入!

一、了解PHP错误机制

截止PHP7.1,PHP共有16个错误级别:
https://php.net/manual/zh/errorfunc.constants.php

如果你对PHP错误机制还不太了解,网上有很多关于PHP错误机制的总结,因为这个不是文章的重点,这里不再详细介绍!

二、注册PHP的异常处理函数、错误处理函数、脚本退出函数

PHP有三个很重要的注册回调函数的函数
- register_shutdown_function 注册PHP退出时的回调函数
- set_error_handler 注册错误处理函数
- set_exception_handler 注册异常处理函数

我们先定义一个日志处理类,专门用于写日志(也可以用于用户日志哦)

<?php
/**
 * 日志接口
 * @filename Loger.php
 * @since 2016-12-08 12:13:50
 * @author 979137.com
 * @version $Id$
 */
class Loger {

    // 日志目录,建议独立于Web目录,这个目录将作用于 Logstash 日志收集
    protected static $log_path = '/data/logs/';

    // 日志类型
    protected static $type = array('ERROR', 'INFO', 'WARN', 'CRITICAL');

    /**
     * 写入日志信息
     * @param mixed  $_msg  调试信息
     * @param string $_type 信息类型
     * @param string $file_prefix 日志文件名默认取当前日期,可以通过文件名前缀区分不同的业务
     * @param array  $trace TRACE信息,如果为空,则会从debug_backtrace里获取
     * @return bool
     */
    public static function write($_msg, $_type = 'info', $file_prefix = '', $trace = array()) {
        $_type = strtoupper($_type);
        $_msg  = is_string($_msg) ? $_msg : var_export($_msg, true);
        if (!in_array($_type, self::$type)) {
            return false;
        }
        $server = isset($_SERVER['SERVER_ADDR']) ? $_SERVER['SERVER_ADDR'] : '0.0.0.0';
        $remote = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
        $method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'CLI';
        if (!is_array($trace) || empty($trace)) {
            $dtrace = debug_backtrace();
            $trace  = $dtrace[0];
            if (count($dtrace) == 1) {
                //不是在类或函数内调用
                $trace['function'] = '';
            } else {
                if ($dtrace[1]['function'] == '__callStatic') {
                    $trace['file'] = $dtrace[2]['file'];
                    $trace['line'] = $dtrace[2]['line'];
                    $trace['function'] = empty($dtrace[3]['function']) ? '' : $dtrace[3]['function'];
                } else {
                    $trace['function'] = $dtrace[1]['function'];
                }
            }
        }
        $ace = $trace;
        $now = date('Y-m-d H:i:s');
        $pre = "[{$now}][%s][{$ace['file']}][{$ace['line']}][{$ace['function']}][{$remote}][{$method}][{$server}]%s";
        $msg = sprintf($pre, $_type, $_msg);

        $filename = 'phplog_' . ($file_prefix ?: 'netbar') . '_' . date('Ymd') . '.log';
        $destination = self::$log_path . $filename;
        is_dir(self::$log_path) || mkdir(self::$log_path, 0777, true);
        //文件不存在,则创建文件并加入可写权限
        if (!file_exists($destination)) {
            touch($destination);
            chmod($destination, 0777);
        }
        return error_log($msg.PHP_EOL, 3, $destination) ?: false;
    }

    /**
     * 静态魔术调用
     * @param $method
     * @param $args
     * @return mixed
     *
     * @method void error($msg) static
     * @method void info($msg) static
     * @method void warn($msg) static
     */
    public static function __callStatic($method, $args) {
        $method = strtoupper($method);
        if (in_array($method, self::$type)) {
            $_msg = array_shift($args); 
            return self::write($_msg, $method);
        }
        return false;
    }
}

接下来,再写一个系统处理类,定义回调函数和注册回调函数

<?php
/**
 * 注册系统处理函数
 * @filename Handler.php
 * @since 2016-12-08 12:13:50
 * @author 979137.com
 * @version $Id$
 */
class Handler {

    const LOG_FILE_PREFIX = 'handler';
    const LOG_TYPE = 'CRITICAL';

    /**
     * 函数注册
     * @return none
     */
    static public function set() {
        //注册致命错误处理方法
        register_shutdown_function(array(__CLASS__, 'fatalError'));
        //注册自定义错误处理方法
        set_error_handler(array(__CLASS__, 'appError'));
        //注册异常处理方法
        set_exception_handler(array(__CLASS__, 'appException'));
    }

    /**
     * 致命错误捕获,PHP错误级别预定义常量参考:
     * http://php.net/manual/zh/errorfunc.constants.php
     * @return none
     */
    static public function fatalError() {
        $error = error_get_last() ?: null;
        if (!is_null($error) && in_array($error['type'], array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR))) {
            $error['class'] = $error['function'] = '';
            Loger::write($error['message'], self::LOG_TYPE, self::LOG_FILE_PREFIX, $error);
            self::halt($error);
        }
    }
    /**
     * 自定义错误处理
     * @param int $errno 错误类型
     * @param string $errstr 错误信息
     * @param string $errfile 错误文件
     * @param int $errline 错误行数
     * @return void
     */
    static public function appError($errno, $errstr, $errfile, $errline) {
        $error['message'] = "[$errno] $errstr";
        $error['file'] = $errfile;
        $error['line'] = $errline;
        $error['class'] = $error['function'] = '';
        if (!in_array($errno, array(E_STRICT, E_DEPRECATED))) {
            Loger::write($error['message'], self::LOG_TYPE, self::LOG_FILE_PREFIX, $error);
            if (in_array($errno, array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR))) {
                self::halt($error);
            }
        }
    }

    /**
     * 自定义异常处理
     * @param mixed $e 异常对象
     * @return void
     */
    static public function appException($e) {
        $error = array();
        $error['message']  = $e->getMessage();
        $error['file'] = $e->getFile();
        $error['line'] = $e->getLine();
        $trace = $e->getTrace();
        if(empty($trace[0]['function']) && $trace[0]['function'] == 'exception') {
            $error['file'] = $trace[0]['file'];
            $error['line'] = $trace[0]['line'];
        }
        //$error['trace']  = $e->getTraceAsString();
        $error['function'] = $error['class'] = '';
        Loger::write($error['message'], self::LOG_TYPE, self::LOG_FILE_PREFIX, $error);
        self::halt($error);
    }

    /**
     * 错误输出
     * @param mixed $error 错误
     * @return void
     */
    static public function halt($error) {
        ob_get_contents() && ob_end_clean();
        $e = array();
        if (IS_DEBUG || IS_CLI) {
            //调试模式下输出错误信息
            $e = $error;
            if(IS_CLI){
                $e_message  = $e['message'].' in '.$e['file'].' on line '.$e['line'].PHP_EOL;
                if (isset($e['treace']) ) {
                    $e_message .= $e['trace'];
                }
                exit($e_message);
            }
        } else {
            //线网不显示错误信息,显示固定字符串,保护系统安全
            //TODO:比较友好的做法是重定向到一个漂亮的错误页面
            exit('Sorry, the system error');
        }
        // 包含异常页面模板
        $exceptionFile = __DIR__ . '/exception.tpl';
        include $exceptionFile;
        exit(0);
    }
}

二、自动加载脚本(auto_append_file)

以上两个脚本准备就绪以后,我们可以把它们合并到一个文件,并增加两个重要常量:

<?php
//是否CLI模式
define('IS_CLI', PHP_SAPI == 'cli' ? true : false);
//当前是否开发模式,用于区分线网和开发模式,默认false
//开发模式下,所有错误会打印出来。非开发模式下,不会打印到页面,但会记录日志
define('IS_DEBUG', isset($_SERVER['DEV_ENV']) ? true : false);
//注册系统处理函数
Handler::set();

假设合并后的文件名叫:auto_prepend_file.php
我们把这个文件进行预加载(即自动包含进所有PHP脚本)
这时候到了很重要的一步就是,就是配置 php.ini

auto_prepend_file = /your_path/auto_prepend_file.php

重启你的Web服务器如 ApacheNginx
写一个测试脚本 test.php

<?php
var_dump($tencent);

因为 $tencent 未定义,所以这时候就会回调我们注册的函数,可以看到已经有错误日志了

php -f test.php
tail -f phplog_handler_20161209.log 

[2016-12-09 16:01:05][CRITICAL][/data/logs/test.php][2][][0.0.0.0][CLI][0.0.0.0][8] Undefined variable: tencent

三、搭建日志中心(ELK,ElasticSearch + Logstash + Kibana)

因为我们的Web服务器一般都是分布式的,线网可能有N台服务器,我们的日志又是写本地,所以这时候就需要一个日志中心了,
日志系统目前已经有很多成熟的解决方案了,这里推荐 ELK 部署,
ElasticSearch,一个基于 Lucene 的搜索服务器,它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 架构。
Logstash,分布式日志收集、分析、存储工具
Kibana,基于 ElasticSearch 的前端展示工具
因为网上已经很多 ELK 的搭建教程了,这里就不重复写了!

下面是一个基于ELK搭建好的日志中心,好不好用,用了就知道了!^_^

四、利用 ElasticSearch RESTful 实现告警功能

前面我们提到 ElasticSearch 是基于 RESTful 架构!
ElasticSearch 提供了非常多的接口,包括索引的增删改查,文档的增删改查,各种搜索

ElasticSearch官方提供了一个PHP版本的SDK:Elasticsearch-PHP
官方文档(全英文,目前没有中文版,看起来可能吃力,英文就英文吧,认真啃):
https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/index.html

我们要做告警要用到的就是搜索接口
说白了,就是定时去把前面写的那些日志,全扫出来,然后告警给关系人!

Elasticsearch-PHP 安装很简单,通过 Composer 来安装,我这里以 1.0 为例子
1、编写一个 composer.json 文件

{
    "require": {
        "elasticsearch/elasticsearch": "~1.0"
    }
}

2、cd到你的项目里,下载安装包

curl -s http://getcomposer.org/installer | php

3、安装 Elasticsearch-PHP 及其依赖

php composer.phar install --no-dev

完事之后,就可以写告警脚本了!下面是我写的一个示例

<?php
//ElasticSearch Server,这是你的ELK服务器和端口
$es_server = array(
    '127.0.0.1:9200',
);

//接受告警人员
$alert_staff = array(
    'shiliangxie' => 18666665940,
    'xieshiliang' => 18600005940,
);

//需要告警的错误级别
$alert_level = array('ERROR', 'WARN', 'CRITICAL');

//告警周期,单位:分钟,这个需要和 cron 设置成一样的时间
$alert_cycle = 10;

###################################################################

//搜索最近 $alert_cycle 的错误日志
$q['index'] = 'logstash-'.date('Y.m.d');
$q['ignore_unavailable'] = true;
$q['type'] = 'php';
$q['size'] = 500;
$q['sort'] = array('@timestamp:desc');
$range_time['lte'] = sprintf('%.3f', microtime(true)) * 1000;
$range_time['gte'] = $range_time['lte'] - $alert_cycle * 60 * 1000;
$range = array('@timestamp' => $range_time);
$match = array('level' => implode(' OR ', $alert_level));
$q['body']['query']['filtered'] = array(
    'filter' => array('range' => $range),
    'query'  => array('match' => $match),
);
$params['hosts'] = $es_server;
require __DIR__.'/vendor/autoload.php';
$client = new Elasticsearch\Client($params);
$ret = $client->search($q);
$hit = $ret['hits']['hits'];
$hit = is_array($hit) && count($hit) ? $hit : array();

//组织告警内容
$alerts = array();
foreach($hit as $h) {
    $source = $h['_source'];
    $tag = md5($source['level'] . $source['file'] . $source['line']. $source['msg']);
    if (isset($alerts[$tag])) {
        $alerts[$tag]['num']++;
    } else {
        $alerts[$tag]['num'] = 1;
        $content = '[%s][%s][%s][%s]%s';
        $alerts[$tag]['content'] = sprintf($content, date('Y-m-d H:i:s', strtotime($source['@timestamp'])),
            $source['level'], $source['file'], $source['line'], $source['msg']
        );
    }
}
//var_dump($alerts);exit;
//发送告警
if (is_array($alerts) && count($alerts)) {
    array_walk($alerts, function($alert, $tag) use($alert_staff) {
        $msg = sprintf('[Repeat:%d]%s', $alert['num'], $alert['content']);
        array_map(function($mobile) use($msg) {
            return send_sms($mobile, $msg);
        }, $alert_staff);
        return ;
    });
}

然后我们把告警脚本加到 crontab 即可

*/10 * * * * /usr/local/bin/php /data/cron/PHPMonitor.php

坐等告警吧!

对于报警方案,目前市场上也有很多实现方案,如 Yelp 公司的 ElastAlert,用 Python 写的一个基于 ELKElasticsearch RESTful 报警框架

微信公众号:程序员到架构师

最新文章

Return Top