资源编排ROS之自定制资源(多云部署Terraform篇)

简介: 资源编排服务(Resource Orchestration Service, 简称ROS)是阿里云提供的一项简化云计算资源管理的服务。您可以遵循ROS定义的模板规范编写资源栈模板,在模板中定义所需的云计算资源(例如ECS实例、RDS数据库实例)、资源间的依赖关系等。

1.背景

资源编排服务(Resource Orchestration Service, 简称ROS)是阿里云提供的一项简化云计算资源管理的服务。您可以遵循ROS定义的模板规范编写资源栈模板,在模板中定义所需的云计算资源(例如ECS实例、RDS数据库实例)、资源间的依赖关系等。ROS的编排引擎将根据模板自动完成所有资源的创建和配置,实现自动化部署及运维。

ROS资源编排接入了大量的阿里云资源,目前涉及38个服务,近200个资源,而且还在持续增长中。对于尚未提供的资源,或无法提供的资源或功能,ROS提供了自定义资源 (ALIYUN::ROS::CustomResource)作为解决方案。如果您还不了解自定义资源,可以参考资源编排ROS之自定制资源(基础篇)

本篇为多云部署Terraform篇。

其他进阶篇:多云部署AWS篇

2.目标

成功使用ROS调用Terraform部署资源。推而广之,使用类似的方法,可以使用所有的三方资源编排工具。可以对所有其他云厂商的云资源进行部署,从而达到多云部署的目标。

3.准备工作

ROS模板

为了简化,我们使用如下模板在ROS中进行部署。这个模板创建了7个资源:

  • Vpc:专有网络,用于创建ECS实例。
  • VSwitch:虚拟交换机,用于创建ECS实例。
  • SecurityGroup:安全组,用于创建ECS实例。这里配置了入方向规则,放通8080端口,用于提供HTTP服务。
  • WaitConditionHandle:用于接收ECS实例cloudinit脚本完成信号。
  • WebServers:ECS实例,通过cloudinit在8080端口提供HTTP服务,能够使用Terraform模板创建AWS资源。
  • WaitWebServersReady:等待ECS实例cloudinit的完成信号,用于流程同步。确保TestTerraform资源创建时,HTTP服务可用。
  • TestTerraform:自定义资源,用于传递参数和接收输出。
    • 传递参数:包括AWS的AK信息,地域信息,Terrraform信息(包括模板和参数)。用于测试的Terraform模板由于较为简单,已内嵌到ROS模板当中。为了方便示例,这个模板只创建了一个aws_s3_bucket资源。
    • 接收输出:为了简单,内嵌的Terraform模板的输出与自定义资源的输出一致。为了方便示例,输出只有一个,为aws_s3_bucket资源的arn。

ROSTemplateFormatVersion: '2015-09-01'
Parameters:
  Password:
    Type: String
    Default: Test12345
    NoEcho: true
  Timeout:
    Type: Number
    Default: 600
  AwsAccessKeyId:
    Type: String
    NoEcho: true
  AwsAccessKeySecret:
    Type: String
    NoEcho: true
  AwsRegionId:
    Type: String
    Default: us-east-1
Resources:
  Vpc:
    Type: ALIYUN::ECS::VPC
    Properties:
      CidrBlock: 192.168.0.0/16
      VpcName: vpc-ros-tf-test
  VSwitch:
    Type: ALIYUN::ECS::VSwitch
    Properties:
      CidrBlock: 192.168.0.0/24
      ZoneId:
        Fn::Select:
        - 0
        - 
          Fn::GetAZs:
            Ref: ALIYUN::Region
      VpcId:
        Fn::GetAtt: [Vpc, VpcId]
  SecurityGroup:
    Type: ALIYUN::ECS::SecurityGroup
    Properties:
      SecurityGroupName: sg-ros-tf-test
      VpcId:
        Ref: Vpc
      SecurityGroupIngress:
      -
        IpProtocol: tcp
        Policy: accept
        PortRange: 8080/8080
        SourceCidrIp: 0.0.0.0/0
      - 
        IpProtocol: tcp
        Policy: accept
        PortRange: 22/22
        SourceCidrIp: 0.0.0.0/0
  WaitConditionHandle:
    Type: ALIYUN::ROS::WaitConditionHandle
    Properties:
      Mode: Full
      Count: 1
  WebServers:
    Type: ALIYUN::ECS::InstanceGroup
    Properties:
      MaxAmount: 1
      MinAmount: 1
      ImageId: centos_7_7_64_20G_alibase_20191008.vhd
      InstanceType: ecs.c6.large
      Password:
        Ref: Password
      InstanceChargeType: PostPaid
      InternetMaxBandwidthIn: 1
      SecurityGroupId: 
        Ref: SecurityGroup
      VpcId:
        Fn::GetAtt: [Vpc, VpcId]
      VSwitchId:
        Ref: VSwitch
      UserData:
        Fn::Replace:
        - ros-notify:
            Fn::GetAtt: [WaitConditionHandle, CurlCli]
        - |
          #!/bin/sh

          cd ~

          cat > install_helper.sh&1
          if [ "$?" -eq "0" ]; then
            ros-notify --data-binary '{"status": "SUCCESS"}'
          else
            ros-notify --data-binary '{"status": "FAILURE"}'
          fi

  WaitWebServersReady:
    Type: ALIYUN::ROS::WaitCondition
    Properties:
      Handle:
        Ref: WaitConditionHandle
      Timeout: 600
      Count: 1
  TestTerraform:
    Type: Custom::AwsCloudFormationStack
    DependsOn: WaitWebServersReady
    Properties:
      ServiceToken:
        Fn::Join:
        - ''
        - - web:http://
          - 
            Fn::Select:
            - 0
            - Fn::GetAtt: [WebServers, PublicIps]
          - :8080
      Parameters:
        AccessKeyId:
          Ref: AwsAccessKeyId
        AccessKeySecret:
          Ref: AwsAccessKeySecret
        RegionId:
          Ref: AwsRegionId
        Stack:
          Template: |
            provider "aws" {}

            variable "bucket_name" {
              type = string
            }

            resource "aws_s3_bucket" "my_bucket" {
              bucket = var.bucket_name
            }

            output "my_bucket_arn" {
              value = aws_s3_bucket.my_bucket.arn
            }
          Parameters:
            bucket_name: bucket-by-ros
      Timeout:
        Ref: Timeout
Outputs:
  InstanceIds:
    Value:
      Fn::GetAtt: [WebServers, InstanceIds]
  PublicIps:
    Value:
      Fn::GetAtt: [WebServers, PublicIps]
  AwsMyBucketArn:
    Value:
      Fn::GetAtt: [TestTerraform, my_bucket_arn]

HTTP服务代码

HTTP服务的实现代码如下,主要提供了如下功能:

  • 提供HTTP服务接口,按照ALIYUN::ROS::CustomResource的规范接受输入,返回输出。
  • 按照在ROS模板中自定义的资源属性,通过Terraform管理AWS资源。这段代码使用Terraform模板和命令行,对AWS的资源进行操作(创建、更新、删除)。这只是一个简单的示例实现,省略了很多细节,如安全、异常处理等。

代码可以通过这个地址进行下载。


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

import eventlet
eventlet.monkey_patch()

import os
import sys
import json
import time
import shutil
import logging
import urllib2
import platform
import subprocess

import six
from eventlet import wsgi
from webob.dec import wsgify
from webob import Response


LOG = logging.getLogger(__name__)


class TerraformError(Exception):

    pass


class TerraformStack(object):

    WORK_DIR = os.path.expanduser('~/terraform-ros')
    PLUGIN_DIR = '{}/init_providers/.terraform/plugins/{}'.format(
        WORK_DIR, 'darwin_amd64' if platform.system() == 'Darwin' else 'linux_amd64')

    def __init__(self, event):
        self._event = event
        res_props = event['ResourceProperties']
        self._cwd = '{}/{}/{}'.format(self.WORK_DIR, event['StackId'], event['LogicalResourceId'])
        self._env = dict(
            AWS_ACCESS_KEY_ID=res_props['AccessKeyId'],
            AWS_SECRET_ACCESS_KEY=res_props['AccessKeySecret'],
            AWS_DEFAULT_REGION=res_props['RegionId']
        )

    def _get_apply_vars(self):
        result = []
        for k, v in six.iteritems(self._event['ResourceProperties']['Stack']['Parameters']):
            if isinstance(v, (list, dict)):
                v = json.dumps(v)
            elif isinstance(v, bool):
                v = six.text_type(v).lower()
            else:
                v = six.text_type(v)
            result.extend(['-var', '{}={}'.format(k, v)])
        return result

    def _modify_template(self):
        tf_name = '{}/main.tf'.format(self._cwd)
        with open(tf_name, 'w') as f:
            f.write(self._event['ResourceProperties']['Stack']['Template'])

    def _get_output(self):
        resp = self._do_terraform_command('output', '-json')
        return {k: v['value'] for k, v in six.iteritems(json.loads(resp))}

    def _do_terraform_command(self, *args):
        proc_args = ['terraform']
        proc_args.extend(args)
        orig_cmd_line = subprocess.list2cmdline(proc_args)
        cmd_line = '{ %s; } 2>&1' % orig_cmd_line
        try:
            LOG.debug('begin to execute cmd(%s)', orig_cmd_line)
            r = subprocess.check_output(cmd_line, cwd=self._cwd, env=self._env, shell=True)
            LOG.debug('succeed to execute cmd(%s) result(%s)', orig_cmd_line, r)
            return r
        except subprocess.CalledProcessError as ex:
            LOG.error('fail to execute cmd(%s) return_code(%s) output(%s)',
                      orig_cmd_line, ex.returncode, ex.output)
            raise

    def _clear_cwd(self):
        shutil.rmtree(self._cwd, ignore_errors=True)
        path = os.path.dirname(self._cwd)
        if not os.listdir(path):
            os.rmdir(path)

    def create(self):
        LOG.info('create resource: %s', self._cwd)

        if os.path.exists(self._cwd):
            raise TerraformError('fail to create resource, already exists: {}.'.format(self._cwd))

        try:
            os.makedirs(self._cwd)

            self._modify_template()

            self._do_terraform_command('init', '-plugin-dir', self.PLUGIN_DIR)
            self._do_terraform_command('apply', '-auto-approve', *self._get_apply_vars())

            return self._get_output()
        except Exception:
            self._clear_cwd()
            raise

    def update(self):
        LOG.info('update resource: %s', self._cwd)

        if not os.path.exists(self._cwd):
            raise TerraformError('fail to update resource, not found: {}.'.format(self._cwd))

        self._modify_template()

        self._do_terraform_command('apply', '-auto-approve', *self._get_apply_vars())

        return self._get_output()

    def delete(self):
        LOG.info('delete resource: %s', self._cwd)

        if not os.path.exists(self._cwd):
            return

        self._do_terraform_command('destroy', '-auto-approve', *self._get_apply_vars())
        self._clear_cwd()


class App(object):

    def __init__(self):
        self.pool = eventlet.GreenPool()

    @staticmethod
    def handle_impl(event):
        req_type = event['RequestType']
        terraform_stack = TerraformStack(event)

        result = dict(
            RequestId=event['RequestId'],
            LogicalResourceId=event['LogicalResourceId'],
            StackId=event['StackId'],
        )
        try:
            if req_type == 'Create':
                terraform_stack_outputs = terraform_stack.create()
            elif req_type == 'Update':
                terraform_stack_outputs = terraform_stack.update()
            else:
                terraform_stack.delete()
                terraform_stack_outputs = None

            result['Status'] = 'SUCCESS'
            result['PhysicalResourceId'] = 'DUMMY'
            if terraform_stack_outputs:
                result['Data'] = terraform_stack_outputs
        except Exception as ex:
            result['Status'] = 'FAILED'
            result['Reason'] = str(ex)

        headers = {
            'Content-type': 'application/json',
            'Accept': 'application/json',
            'Date': time.strftime('%a, %d %b %Y %X GMT', time.gmtime())
        }
        req = urllib2.Request(event['ResponseURL'], data=json.dumps(result), headers=headers)
        resp = urllib2.urlopen(req)
        resp_content = resp.read()
        LOG.info('response: %s %s', result, resp_content)

    @wsgify
    def handle(self, request):
        event = json.loads(request.body)
        self.pool.spawn_n(self.handle_impl, event)
        return Response(json=dict(Result='OK'))


def main():
    try:
        pid = os.fork()
        if pid > 0:
            sys.exit(0)
    except OSError, e:
        sys.stderr.write("fork #1 failed (%d) %s\n" % (e.errno, e.strerror))
        sys.exit(0)

    os.setsid()
    os.chdir('.')
    os.umask(0)

    try:
        pid = os.fork()
        if pid > 0:
            sys.exit(0)
    except OSError, e:
        sys.stderr.write("fork #2 failed (%d) %s\n" % (e.errno, e.strerror))
        sys.exit(0)

    si = file('/dev/null', "r")
    so = file('/dev/null', "w+")
    se = file('/dev/null', "a+")

    sys.stderr.flush()
    sys.stdout.flush()
    sys.stderr.flush()
    os.dup2(si.fileno(), sys.stdin.fileno())
    os.dup2(so.fileno(), sys.stdout.fileno())
    os.dup2(se.fileno(), sys.stderr.fileno())

    logging.basicConfig(level=logging.DEBUG,
                        format='%(asctime)s.%(msecs)03d [%(levelname)s] %(message)s',
                        filename=os.path.expanduser('~/terraform_server.log'))

    app = App()
    wsgi.server(eventlet.listen(('', 8080)), app.handle)


if __name__ == "__main__":
    main()

ECS的cloudinit脚本

由于后端使用了Terraform,并且对外提供了HTTP服务,所以我们需要通过ECS的cloudinit功能,在创建ECS后初始化ECS的配置。cloudinit脚本大致如下,主要提供了如下功能:

  • 安装Terraform及AWS插件。
  • 安装Python依赖库,下载代码,启动HTTP服务。
  • 发信号通知ROS,cloudinit成功或失败。

#!/bin/sh

cd ~

cat > install_helper.sh&1
if [ "$?" -eq "0" ]; then
  curl -i -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'x-acs-region-id: ap-southeast-1' http://100.100.100.110/waitcondition?stackname=test-ros-custom-resource-tf\&stackid=0c713e95-19db-4bf3-abf3-aa3d3cec6a22\&resource=WaitConditionHandle\&expire=1576016133\&signature=c4721b3bcdd11bcb49d40fa2a8f14ff8972b4208 --data-binary '{"status": "SUCCESS"}'
else
  curl -i -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'x-acs-region-id: ap-southeast-1' http://100.100.100.110/waitcondition?stackname=test-ros-custom-resource-tf\&stackid=0c713e95-19db-4bf3-abf3-aa3d3cec6a22\&resource=WaitConditionHandle\&expire=1576016133\&signature=c4721b3bcdd11bcb49d40fa2a8f14ff8972b4208 --data-binary '{"status": "FAILURE"}'
fi

4.测试验证

我们在ROS对上述模板和代码进行测试验证。

创建资源栈。推荐选择国外Region(如新加坡 ap-southeast-1),国内访问Terraform较慢,安装Terraform可能会失败。

在模板中,Password,AwsAccessKeyId和AwsAccessKeySecret都被设置成了加密参数。

观察资源列表,ALIYUN::ROS::CustomResource资源已成功创建。

观察输出列表,获取到aws_s3_bucket的arn。

观察AWS S3控制台,可以看到bucket-by-ros存储桶。

5.总结

通过自定义资源 (ALIYUN::ROS::CustomResource)可以实现多云部署,轻松实现一个入口,一键部署。与三方服务或工具结合,可以极大的扩展功能,满足几乎所有的资源编排需求。

作者介绍
目录