Manjusaka

Manjusaka

簡單聊聊 IaC:基礎設施即代碼

實際上 IaC 這個概念的出現已經很久了,所以寫篇水文來簡單聊聊 IaC 的過去,現在,和將來

IaC 的過去#

實際上 IaC 的歷史其實足夠悠久。首先來看一下 IaC 的核心特徵

  1. 最終的產物是 machine readable 的產物。可能是一份代碼,也可能是一份配置文件
  2. 基於 machine readable 的產物,可以進一步依賴已有的 VCS 系統(SVN,Git)等做版本管理
  3. 基於 machine readable 的產物,可以進一步依賴已有的 CI/CD 系統(Jenkins,Travis CI)等做持續集成 / 持續交付
  4. 狀態的一致性,或者稱為幂等性。即理論上來講,基於同樣一份 Code,同一套參數構建出的產物,其最終的行為應該是一致的

實際上通過 IaC 這樣的一些核心特徵,我們現在能明白 IaC 興起的原因。IaC 實際上的興起,大背景是在千禧年之後,互聯網世界迭代的速度愈發的快速,這個時候傳統的手工式的維護面臨著幾個問題

  1. 交互式變更所引入的人的因素太大,導致了變更的不可控性
  2. 人工變更面對愈發快速的 Infra 迭代力有不逮
  3. 交互式的變更導致管控的難做,讓版本控制之類的手段變成空談

在這樣的時代背景下,大家都在追求用更技術,更優雅的手段來解決這些問題。於是,IaC 這個概念就出現了

如果說要將 IaC 分為幾個階段的話,那麼我覺得可以分為以下幾個階段

  1. 刀根火種階段
  2. 現代化的 IaC

如同前面所說,IaC 實際上是一個自發的驅動,在面對不確定的時候,我們選擇用代碼來盡可能的消滅掉不確定性(實際上這個原則一直貫穿到現在)

那麼在最早期,人們選擇用最基礎的代碼的形式,來完成 IaC 的工作。其特徵是對於之前的各種交互式的手段的精確化,程序化的描述。人們可能會選擇直接用 bash 來解決這一切(祖傳的來路不明的 bash 腳本.jpg),也可能會基於 Python Fabric 這樣的框架進行簡單的封裝來完成所需的程序化描述的工作。

但是我們回頭去看這一階段,我們能直觀的感受到一些缺陷

  1. 代碼復用性較差
  2. 各家都有一套祖傳的 IaC 基建,沒有統一的行業標準,導致新人入門門檻較高

所以在面對這樣一套問題的時候,更現代化的 IaC 設施應運而生。其中典型的一些產物是

  1. Ansible
  2. Chef
  3. Puppet

實際上這些工具,可能設計上各有所取捨(比如 Pull/Push 模型的取捨),但是其核心的特徵不會變化

  1. 框架內部提供了常見的比如 SSH 連接管理,多機並行執行,auto retry 等功能
  2. 基於上面描述的這一套基礎功能,提供了一套 DSL 封裝。讓開發者更專注於 IaC 的邏輯,而非基礎層面的細節
  3. 其開源開放,並形成了一套完善的插件機制。社區可以基於這一套提供更豐富的生態。比如 SDN 社區基於 ANSIBLE 提供了各種交換機的 playbook 等

那麼截至到現在,實際上 IaC 的發展其實到了一个相對完備的程度。其中不少工具,也依舊貫穿到了現在。

新生代的 IaC#

從 2006 年 8 月 25 日,Amazon 正式宣布提供了 EC2 服務開始。整個基礎設施開始快步向 Cloud 時代邁進。截止到目前,各家雲廠商提供了各種各樣的服務。通過十多年的演進,也誕生出了諸如 IaaS,PaaS,DaaS,FaaS 等等各種各樣的服務模式。這些服務模式,讓我們的基礎設施的構建,變得更加的簡單,更加的快速。但是這些服務模式,也帶來了一些新的問題。

可能寫到這裡,有同學已經能意識到問題的所在:在獲取算力,獲取資源越來越快捷的當下。我們怎麼樣去管理這樣一些資源?

那麼要解決這樣的問題,我們似乎又需要去考慮怎麼樣用代碼或者可聲明式的配置來管理這些資源。是不是有點眼熟,歷史始終就是一個圈圈.jpg

在起初的時候,我們各自會選擇基於各家雲廠商提供的 API 與 SDK 自行封裝一套 IaC 工具,如同前面所說的一樣。這樣會帶來一些額外的問題:

  1. 代碼復用性較差
  2. 各家都有一套祖傳的 IaC 基建,沒有統一的行業標準,導致新人入門門檻較高

那麼這個時候,雲時代的,面向雲資源管理的新型 IaC 工具的需求也愈發的迫切。這個時候,Terraform 這樣的新型工具應運而生

在 Terraform 裡,可能一台 EC2 Instance 的開啟可能就是這樣的一段簡短的定義

resource "aws_vpc" "my_vpc" {
  cidr_block = "172.16.0.0/16"

  tags = {
    Name = "tf-example"
  }
}

resource "aws_subnet" "my_subnet" {
  vpc_id            = aws_vpc.my_vpc.id
  cidr_block        = "172.16.10.0/24"
  availability_zone = "us-west-2a"

  tags = {
    Name = "tf-example"
  }
}

resource "aws_network_interface" "foo" {
  subnet_id   = aws_subnet.my_subnet.id
  private_ips = ["172.16.10.100"]

  tags = {
    Name = "primary_network_interface"
  }
}

resource "aws_instance" "foo" {
  ami           = "ami-005e54dee72cc1d00" # us-west-2
  instance_type = "t2.micro"

  network_interface {
    network_interface_id = aws_network_interface.foo.id
    device_index         = 0
  }

  credit_specification {
    cpu_credits = "unlimited"
  }
}

這個基礎上,我們可以繼續將我們諸如 Database,Redis,MQ 等基礎設施都進行代碼化 / 描述式配置化,進而提升我們對資源維護的有效性。

同時,隨著各家 SaaS 的發展,研發人員也嘗試著將這些 SaaS 服務也進行代碼化 / 描述式配置化。以 Terraform 為例,我們可以通過 Terraform 的 Provider 來進行對接。比如 newrelic 提供的 ProviderBytebase 提供的 Provider 等等

同時,在 IaC 工具幫助我們完成基礎設施描述的標準化之後,我們在此基礎上能做更多有趣的事情。比如我們可以基於 Infracost 來計算每次資源變更所帶來的資源花費變更。基於 atlantis 來完成集中式的資源變更等等進階的工作。

那麼到現在為止,我們已有的 IaC 產品的選擇足夠多,能滿足我們大部分需求。那么是不是 IaC 整個產品的發展實際上就已經到了一个相對完備的程度呢?答案很明顯是否定的

未来的 IaC#

所以這張主要來聊聊當下 IaC 產品所面臨的一些問題,以及我對未來的一些思考吧

缺陷一:現有基於 DSL 的語法體系的缺陷#

先給大家看一個例子

locals {
  dns_records = {
    # "demo0" : 0,
    "demo1" : 1,
    "demo2" : 2
    "demo3" : 3,
  }
  lb_listener_port  = 80
  instance_rpc_port = 9545

  default_target_group_attr = {
    backend_protocol     = "HTTP"
    backend_port         = 9545
    target_type          = "instance"
    deregistration_delay = 10
    protocol_version     = "HTTP1"
    health_check = {
      enabled             = true
      interval            = 15
      path                = "/status"
      port                = 9545
      healthy_threshold   = 3
      unhealthy_threshold = 3
      timeout             = 5
      protocol            = "HTTP"
      matcher             = "200-499"
    }
  }
}

module "alb" {
  source  = "terraform-aws-modules/alb/aws"
  version = "~> 6.0"

  name                       = "alb-demo-internal-rpc"
  load_balancer_type         = "application"
  internal                   = true
  enable_deletion_protection = true


  http_tcp_listeners = [
    {
      protocol           = "HTTP"
      port               = local.lb_listener_port
      target_group_index = 0
      action_type        = "forward"
    }
  ]

  http_tcp_listener_rules = concat([
    for rec, pos in local.dns_records : {
      http_tcp_listener_index = 0
      priority                = 105 + tonumber(pos)
      actions = [
        {
          type               = "forward"
          target_group_index = tonumber(pos)
        }
      ]
      conditions = [
        {
          host_headers = ["${rec}.manjusaka.me"]
        }
      ]

    }
    ], [{
      http_tcp_listener_index = 0
      priority                = 120
      actions = [
        {
          type = "weighted-forward"
          target_groups = [
            {
              target_group_index = 0
              weight             = 95
            },
            {
              target_group_index = 5
              weight             = 4
            },
          ]
        }
      ]
      conditions = [
        {
          host_headers = ["demo0.manjusaka.me"]
        }
      ]
  }])

  target_groups = [
    merge(
      {
        name_prefix = "demo0"
        targets = {
          "demo0-${module.ec2_instance_demo[0].tags_all["Name"]}" = {
            target_id = module.ec2_instance_demo[0].id
            port      = local.instance_rpc_port
          }
        }
      },
      local.default_target_group_attr,
    ),
    merge(
      {
        name_prefix = "demo1"
        targets = {
          "demo1-${module.ec2_instance_demo[0].tags_all["Name"]}" = {
            target_id = module.ec2_instance_demo[0].id
            port      = local.instance_rpc_port
          }
        }
      },
      local.default_target_group_attr,
    ),
    merge(
      {
        name_prefix = "demo2"
        targets = {
          "demo2-${module.ec2_family_c[0].tags_all["Name"]}" = {
            target_id = module.ec2_family_c[0].id
            port      = local.instance_rpc_port
          },
        }
      },
      local.default_target_group_attr,
    ),

    merge(
      {
        name_prefix = "demo3"
        targets = {
          "demo3-${module.ec2_family_d[0].tags_all["Name"]}" = {
            target_id = module.ec2_family_d[0].id
            port      = local.instance_rpc_port
          },
        }
      },
      local.default_target_group_attr,
    ), # target_group_index_3
    merge(
      {
        name_prefix = "demonew"
        targets = {
          "demo0-${module.ec2_instance_reader[0].tags_all["Name"]}" = {
            target_id = module.ec2_instance_reader[0].id
            port      = local.instance_rpc_port
          }
        }
      },
      local.default_target_group_attr,
    ),
  ]
}

這段 TF 配置描述雖然看起來長,但是實際上做的事很簡單,根據不同的域名 *.manjusaka.me 將流量轉發到不同的 instance 上。然後對於 demo0.manjusaka.me 這個域名,進行單獨的流量灰度處理。

我們能發現,Terrafrom 這種 DSL 的解決方案所需要面臨的問題就是在對於這種動態靈活的場景下,其表達能力將會有很大的局限性。

社區也充分意識到了這個問題。所以類似 Pulumi 這種基於 Python/Lua/Go/TS 等完整的編程語言的 IaC 產品就應運而生了。比如我們用 Pulumi + Python 改寫上面的例子 (此處由 ChatGPT 提供技術支持)

from pulumi_aws import alb

dns_records = {
    # "demo0" : 0,
    "demo1": 1,
    "demo2": 2,
    "demo3": 3,
}
lb_listener_port = 80
instance_rpc_port = 9545

default_target_group_attr = {
    "backend_protocol": "HTTP",
    "backend_port": 9545,
    "target_type": "instance",
    "deregistration_delay": 10,
    "protocol_version": "HTTP1",
    "health_check": {
        "enabled": True,
        "interval": 15,
        "path": "/status",
        "port": 9545,
        "healthy_threshold": 3,
        "unhealthy_threshold": 3,
        "timeout": 5,
        "protocol": "HTTP",
        "matcher": "200-499",
    },
}

alb_module = alb.ApplicationLoadBalancer(
    "alb",
    name="alb-demo-internal-rpc",
    load_balancer_type="application",
    internal=True,
    enable_deletion_protection=True,
    http_tcp_listeners=[
        {
            "protocol": "HTTP",
            "port": lb_listener_port,
            "target_group_index": 0,
            "action_type": "forward",
        }
    ],
    http_tcp_listener_rules=[
        {
            "http_tcp_listener_index": 0,
            "priority": 105 + pos,
            "actions": [
                {
                    "type": "forward",
                    "target_group_index": pos,
                }
            ],
            "conditions": [
                {
                    "host_headers": [f"{rec}.manjusaka.me"],
                }
            ],
        }
        for rec, pos in dns_records.items()
    ]
    + [
        {
            "http_tcp_listener_index": 0,
            "priority": 120,
            "actions": [
                {
                    "type": "weighted-forward",
                    "target_groups": [
                        {"target_group_index": 0, "weight": 95},
                        {"target_group_index": 5, "weight": 4},
                    ],
                }
            ],
            "conditions": [{"host_headers": ["demo0.manjusaka.me"]}],
        }
    ],
    target_groups=[
        alb.TargetGroup(
            f"demo0-{module.ec2_instance_demo[0].tags_all['Name'].apply(lambda x: x)}",
            name_prefix="demo0",
            targets=[
                {
                    "target_id": module.ec2_instance_demo[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        ),
        alb.TargetGroup(
            f"demo1-{module.ec2_instance_demo[0].tags_all['Name'].apply(lambda x: x)}",
            name_prefix="demo1",
            targets=[
                {
                    "target_id": module.ec2_instance_demo[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        ),
        alb.TargetGroup(
            f"demo2-{module.ec2_family_c[0].tags_all['Name'].apply(lambda x: x)}",
            name_prefix="demo2",
            targets=[
                {
                    "target_id": module.ec2_family_c[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        ),
        alb.TargetGroup(
            f"demo3-{module.ec2_family_d[0].tags_all['Name'].apply(lambda x: x)}",
            name_prefix="demo3",
            targets=[
                {
                    "target_id": module.ec2_family_d[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        ),
        alb.TargetGroup(
            f"demo0-{module.ec2_instance_reader[0].tags_all['Name'].apply(lambda x: x)}",
            name_prefix="demonew",
            targets=[
                {
                    "target_id": module.ec2_instance_reader[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        ),
    ],
)

你看,整體的用法是不是更貼近於我們的使用習慣,其表達力也更好

缺陷二:和業務需求之間的 Gap#

實際上在雲時代的 IaC 工具,更多的去解決的是基礎設施的存在性問題。而對於已有基礎設施的編排與更合理的利用實際上是存在比較大的 Gap 的。我們怎麼樣將應用部署到這些基礎資源上。怎麼樣去調度這些資源。實際上是一個很值得玩味的問題。

實際上可能出乎人們的意料,實際上 Kubernetes/Nomad 實際上就在嘗試解決這樣的問題。可能有人在思考,什麼?這個也算是 IaC 工具?毫無疑問的是嘛,不信你對照一下我們前面列的 IaC 的幾個核心特徵

  1. 最終的產物是 machine readable 的產物。可能是一份代碼,也可能是一份配置文件(YAML 工程師表示認可)
  2. 基於 machine readable 的產物,可以進一步依賴已有的 VCS 系統(SVN,Git)等做版本管理(manifest 隨著倉庫走)
  3. 基於 machine readable 的產物,可以進一步依賴已有的 CI/CD 系統(Jenkins,Travis CI)等做持續集成 / 持續交付(argocd 等平台提供了進一步的支持)

同時我們在對應的配置文件裡,可以聲明我們所需要 CPU/Mem,需要的磁碟 / 遠程盤,需要的網關等。同時這一套框架實際上將計算 Infra 進行了一個相對通用性的抽象,讓業務百分之八十的場景下並不需要去考慮底層 Infra 的細節。

但是實際上這套已經存在的方案又會存在一些問題。比如其複雜度的飆升,self-hosted 的運維成本,以及一些抽象泄漏帶來的問題。

缺陷三:質量性的偏差#

雲時代新生的 IaC,其 scope 相較於傳統的諸如 ansible 之類的 IaC 工具範圍更大,野心也更大。所帶來的副作用就是其質量的偏差。這個話題可以分為兩方面說

第一點來看,諸如 Terraform 這樣的 IaC 工具,通過官方提供的 Provider 實現了對 AWS/Azure/GCP 等平台的支持。但是即便是官方支持,其 Provider 裡設計的一些邏輯,和平台側在交互式界面裡的設計邏輯並不一致。比如我之前吐槽過 “比如 Aurora DB Instance 的 delete protection 在 Console 創建時默認打開,而 TF 裡是默認關閉”。這實際上會在使用的時候,給開發者帶來額外的心智負擔

第二點來看,IaC 工具極度依賴社區(此處的社區饱含開源社區和各類商業公司)。不同於 Ansible 等老前輩,其周邊設施的質量相對穩定。Terraform 等新生代的 IaC 周邊的質量一言難盡。比如國內諸如福報雲,華為雲,騰訊雲等廠商提供的 Provider 一直被人詬病。而不少大型的面向研發者的 SaaS 平台沒有官方提供的 Provider 等(比如 Newrelic)

同時,雲廠商所提供的一些功能實際上是和通用性 IaC 工具所衝突的。比如 AWS 的 WAF 工具,其中有一個功能是基於 IPSet 進行攔截,這個時候如果 IPSet 非常大,那麼使用通用性的 IaC 工具進行描述將會是一個災難性的存在。這個時候對於類似的場景,只能基於雲廠商自己的 SDK 進行封裝,雲廠商提供的 SDK 質量合格還好。如果像福報雲這樣的神奇的 SDK 設計的話,那就只能自求多福了。。

缺陷四:面對開發者體驗的不足#

開發者體驗實際上現在是一個比較熱門的話題。畢竟沒有人願意將自己寶貴的生命來做重複的工作。就目前而言,主要的 IaC 工具都是 For Production Server 的,而不是 For Developer Experience 的,導致我們用的時候,其體驗就很一般。

比如我們現在有一個場景,我們需要在 AWS 上給研發的同學批量開一批 EC2 Instance 作為開發機。怎麼樣保證研發同學在這些機器上開箱即用,就是很大的問題。

雖然我們可以通過預製鏡像等方式提供相對統一的環境。不過我們可能會需要更進一步的去細調環境的話,那麼就會比較蛋疼。

針對於類似的場景,老一點的有 Nix,新一點的有 envd 來解決這些問題。但是目前來講,還是和已有的 IaC 產品有一些 gap。後續怎麼樣進行對接可能會是個很有趣的話題。

缺陷五:面對新型技術棧的一些不足#

最典型的是 Serverless 的場景。比如我舉個例子,我現在有個簡單的需求,就是用 Lambda 來實現一個簡單的 SSR 的渲染

export default function BlogPosts({ posts }) {
  return posts.map(post => <BlogPost key={post.id} post={post} />)
}

export async function getServerSideProps() {
  const posts = await getBlogPosts();
  return {
    props: { posts }
  }
}

函數本身非常簡單,但是如果我們要將這個函數部署到 Production 環境裡將會是一個比較麻煩的事。比如我們來思考下我們現在需要為這個簡單的函數準備什麼樣的 infra

  1. 一個 lambda 實例
  2. 一個 S3 bucket
  3. 一個 APIGateway 及路由規則
  4. 接入 CDN (可選)
  5. DNS 準備

那麼在 IaC Manifest + 業務代碼彼此分離的情況下,我們的變更以及資源的管理將會是一個很大的問題。Vercel 在最近的 Blog Framework-defined infrastructure 也描述了這樣的問題。我們怎麼樣能進一步發展為 Domain Code as Infrastructure 將會是未來的一個挑戰

總結#

這篇文章寫了兩天,差不多作為自己對 IaC 這個事物的一些碎碎念(而不是 Terraform Tutorial!(逃。祝大家讀的開心

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。