介紹完 Pulumi 的基本概念後,就可以開始進行一些實戰練習了。預計會透過 4 至 5 篇文章介紹如何建立在 AWS 上建立一個 Kubernetes Cluster,並在上執行應用程式。
第一篇就從建立基礎架構開始,在 AWS 中,基礎架構莫過於 VPC、Subnet 了。本篇文章希望借鑒 AWS 建立 VPC 與其相關資源的介面,來使用 Pulumi 撰寫一個一樣功能的 Component Resource。
AWS 建立 VPC 與其他資源的介面:
這個介面供我們建立 VPC、Public Subnet、Private Subnet。
並可決定要使用幾個 Availability Zone、幾個 NAT Gateway 等。
本篇文章會先完成前半段簡單的部分,建立 VPC、Internet Gateway、Public Subnet、Route Table、Route Table Association。
Private Subnet、NAT Gateway、S3 Endpoint 等留待下一篇文章繼續。
第一步必須先定義 Arguments,將所有讓使用 ComponentResource 的使用者需要調整的欄位定義成 args。
例如以下就是將所有可以讓使用者輸入的選項定義為 VpcArguments interface。在 TypeScript 中,?
的欄位為選填的欄位。以下 Interface 中,所有欄位都是選填,如果使用者沒有特別指定,程式可以指定預設值。
為了要通用,因此所有的 VpcArguments 都會使用 Input 型別,這樣才能讓其他 Output 型別做為 Component Resource 的 Input 使用
interface VpcArguments {
// VPC 名稱
vpcName?: Input<string>;
// VPC IPv4 CIDR Block
vpcCidrBlocks?: Input<string>;
// 是否啟用 IPv6
enableIpv6Block?: Input<boolean>;
// VPC Tenancy
tenancy?: Input<'Default' | 'Dedicated'>;
// 使用的 AZ 數量
numOfAzs?: Input<number>;
// 是否要自訂使用的 AZ
customizeAzs?: Input<Input<string>[]>;
// 建立的 Public Subnet 數量,必須是使用 AZ 數量的倍數
numOfPublicSubnet?: Input<number>;
customPublicSubnetCidrs?: Input<Input<string>[]>;
// 建立的 Private Subnet 數量,必須是使用 AZ 數量的倍數
numOfPrivateSubnet?: Input<number>;
customPrivateSubnetCidrs?: Input<Input<string>[]>;
// NAT Gateway 數量
natGateway?: Input<'One' | 'OnePerAz'>;
// 是否使用 S3 Gateway VPC Endpoint
vpcEndpoints?: Input<'S3Gateway'>;
// DNS Options
dnsOptions?: Input<VpcDnsOptions>;
// 額外的 Tag
additionalTags?: Input<{
[key: string]: Input<string>;
}>;
}
接著我們就可以撰寫 ComponentResource 了。這邊多了一個昨天沒提到的 initialize 方法,這個方法是 override ComponentResource 中的 initialize 方法,可以看到這個方法有個 async 關鍵字。這就是為了在建立資源期間需要用到 async/await 所準備的方法。在 constructor 執行父類別的建構子時,最後就會呼叫 initialize 方法。因此我們要將所有建立資源的程式碼都放在 initialize 內。
export class Vpc extends pulumi.ComponentResource {
constructor(name: string, args: VpcArguments = {}, opts: pulumi.ComponentResourceOptions = {}) {
super("pulumi-practice:vpc:Vpc", name, args, opts);
}
protected async initialize({name, args, opts}: {
name: string,
args: VpcArguments,
opts: pulumi.ComponentResourceOptions
}) {
}
}
我們類別的使用者就可以使用以下程式碼建立整個 AWS 的網路基礎架構。
const vpc = new Vpc('project-vpc', {
vpcName: pulumi.getProject() + '-' + pulumi.getStack(),
vpcCidrBlocks: "10.100.0.0/16",
enableIpv6Block: true,
numOfAzs: 2,
numOfPublicSubnet: 2,
numOfPrivateSubnet: 2,
natGateway: 'One',
});
首先建立 VPC,由於所有的 args 參數都有可能沒有填寫,因此需要特別注意處理預設值。
const vpcCidrBlocks = args.vpcCidrBlocks || '172.16.0.0/16';
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: vpcCidrBlocks,
assignGeneratedIpv6CidrBlock: args.enableIpv6Block || false,
enableDnsHostnames: args.dnsOptions?.dnsHostname || true,
enableDnsSupport: args.dnsOptions?.dnsResolution || true,
instanceTenancy: (args.tenancy || 'Default') === 'Default' ? 'default' : 'dedicated',
tags: Object.assign({
Name: args.vpcName ? `${args.vpcName}-vpc` : undefined,
}, args.additionalTags)
});
接著就是建立 Internet Gateway,並設定 Default Route Table 為所有 Public Subnet 的 Route Table
const igw = new aws.ec2.InternetGateway(`${name}-igw`, {
vpcId: vpc.id,
tags: Object.assign({
Name: args.vpcName ? `${args.vpcName}-igw` : undefined,
}, args.additionalTags)
});
const defaultRouteTable = new aws.ec2.DefaultRouteTable(`${name}-default-rtb`, {
defaultRouteTableId: vpc.defaultRouteTableId,
routes: [
{
cidrBlock: "0.0.0.0/0",
gatewayId: igw.id
}
],
tags: Object.assign({
Name: args.vpcName ? `${args.vpcName}-public-rtb` : undefined,
}, args.additionalTags),
});
建立 subnet 前,我們需要先處理一下使用的 AZ 的數量與要使用哪些 AZ 的問題。
因為這兩個欄位是設計為選填,因此我們必須要查詢 region 中所有的 AZ 數量做為預設值。
// 取得 region 中所有的 AZ (排除需要 opt-in 的 AZ)
const azs = await aws.getAvailabilityZones({
filters: [
{
name: "opt-in-status",
values: ["opt-in-not-required"]
}
]
});
// 預設使用的 AZ 數量為所有查詢到的 AZ 數量
const numOfAzs = args.numOfAzs || azs.names.length;
// 如有設定 customizeAzs,則使用自訂的 AZ,如果沒有設定,則使用前 numOfAzs 個 AZ
const usedAzs = args.customizeAzs ||
pulumi.output(numOfAzs).apply(numAz => azs.names.slice(0, numAz));
const numOfPublicSubnet = args.numOfPublicSubnet || numOfAzs;
const numOfPrivateSubnet = args.numOfPrivateSubnet || numOfAzs;
接著需要檢查建立的 subnet 數量是否為 AZ 的倍數:
pulumi.all([numOfAzs, usedAzs, numOfPublicSubnet, numOfPrivateSubnet]).apply(
([_numOfAzs, _usedAzs, numPublic, numPrivate]) => {
if (_numOfAzs !== _usedAzs.length) {
throw new Error(`customizeAzs length (${args.customizeAzs}) not match numOfAzs (${_numOfAzs})`);
}
if (numPublic % _numOfAzs !== 0) {
throw new Error(`numOfPublicSubnet (${numPublic}) must be multiple of numOfAzs (${_numOfAzs})`);
}
if (numPrivate % _numOfAzs !== 0) {
throw new Error(`numOfPrivateSubnet (${numPrivate}) must be multiple of numOfAzs (${_numOfAzs})`);
}
});
最後我們需要將 VPC Cidr 分網段,並把網段的數量對半切:
const subnets = pulumi.output(vpcCidrBlocks).apply(cidrBlocks => {
return splitSubnets(cidrBlocks, new Netmask(cidrBlocks).bitmask + 4);
});
const divSubnets = subnets.apply(subnets => {
return {
publicSubnet: subnets.slice(0, subnets.length / 2),
privateSubnet: subnets.slice(subnets.length / 2)
};
});
這樣就萬事俱備可以開始建立子網路了
建立 Public Subnet 需要建立 Subnet,並將 Subnet 與 Default Route Table 相關聯。之後建立 Private Subnet 也是要做類似的事,因此我們可以將 Subnet、Route Table 的管理包裝成一個 Subnet
Component Resource。
以下先來看包裝完後,建立 Public Subnet 的方式
const publicSubnets = pulumi.all([numOfPublicSubnet, numOfAzs]).apply(([numOfPublicSubnet, numOfAzs]) => {
const publicSubnets = [];
for (let subnetNum = 0; subnetNum < numOfPublicSubnet; subnetNum++) {
let azName = azs.names[subnetNum % numOfAzs];
publicSubnets.push(new Subnet(`${name}-subnet-public-${azName}-${(subnetNum % numOfAzs) + 1}`, {
vpcId: vpc.id,
cidrBlock: divSubnets.publicSubnet[subnetNum],
availabilityZone: azName,
mapPublicIpOnLaunch: true,
routeTableId: defaultRouteTable.id,
tags: Object.assign({
Name: args.vpcName ? `${name}-subnet-public${subnetNum + 1}-${azName}` : undefined,
}, args.additionalTags),
}));
}
return publicSubnets;
});
以下是建立 Subnet Component Resource 的程式碼,可以看到就是簡單的將建立 Subnet 與 RouteTableAssociation 兩個資源包裝起來。如果使用者沒有傳入 RouteTableId 的話,就建立一個 RouteTable。
interface SubnetArguments {
vpcId: Input<string>;
cidrBlock: Input<string>;
availabilityZone: Input<string>;
mapPublicIpOnLaunch?: Input<boolean>;
tags?: Input<{ [key: string]: Input<string> }>;
routeTableId?: Input<string>;
}
class Subnet extends ComponentResource {
private name!: string;
public subnet!: aws.ec2.Subnet;
public routeTable?: aws.ec2.RouteTable;
constructor(name: string, args: SubnetArguments, opts: pulumi.ComponentResourceOptions = {}) {
super('pulumi-practice:vpc:Subnet', name, args, opts);
}
protected async initialize({name, args, opts}: {
name: string,
args: SubnetArguments,
opts: pulumi.ComponentResourceOptions
}): Promise<any> {
this.name = name;
this.subnet = new aws.ec2.Subnet(name, {
vpcId: args.vpcId,
cidrBlock: args.cidrBlock,
availabilityZone: args.availabilityZone,
mapPublicIpOnLaunch: args.mapPublicIpOnLaunch || false,
tags: args.tags
}, {parent: this});
let rbtId = args.routeTableId;
if (!args.routeTableId) {
this.routeTable = new aws.ec2.RouteTable(name, {
vpcId: args.vpcId,
});
}
new aws.ec2.RouteTableAssociation(name, {
subnetId: this.subnet.id,
routeTableId: rbtId!,
}, {parent: this});
}
}