Do Zero a “Salve Mundo” com AWS CDK

🇧🇷  escrito em português brasileiro

~‏‏‎ ‎‏‏‎ ‎10m 9s ‎‏‏‎ ‎‏‏‎⌛

CDKCloud Development Kit é atualmente a minha ferramenta predileta para Infrastruture as Code (IaC). Para se ter uma API na AWS com toda a infraestrutura provisionada e o próprio código em TypeScript, CDK permite a criação e manutenção de aplicações isomórficas em outro nível já que se trata até mesmo da infra, não apenas front-end e back-end.

O Cloud Development Kit (CDK) foi lançado em 2019. No momento suporta TypeScript, JavaScript, Python, Java, C#/.Net, e em modo de preview, Go. Diferente do SAM (Serverless Application Model) que é focado em Serverless portanto seu escopo é facilitar a manipulação de serviços serverless e é um extensão, ou melhor ainda, um subset do CloudFormation. Já o CDK é uma biblioteca para manipular e provisionar todas as propriedades da AWS, em seus mais de 200 serviços[1].

Este código foi apresentado nesta sexta-feira 13, de agosto 2021 no DevOps Extreme, com 24 horas de conteúdo gratuito.

# O que você precisa saber

# Arquitetura

Arquitetura básica
API Gatewy REST API ANY {proxy+} e Lambda

# Passo a passo

Primeiro, no diretório escolhido para o projeto vamos criar um package.json básico.

{
"name": "deveops-extreme",
"version": "0.0.1",
"private": true,
}

Agora já podemos instalar nossas dependências de desenvolvimento e também nossas dependências da aplicação.

npm install -D typescript ts-node esbuild aws-sdk @types/aws-lambda @types/node

npm install -S @aws-cdk/aws-apigateway @aws-cdk/aws-lambda @aws-cdk/core aws-cdk

Isto deverá popular nosso package.json com todas as dependências de nossa aplicação e deverá ficar parecido com o arquivo a seguir. Como o comando npm install sem determinar uma versão em particular instalará a versão mais recente, dependenndo de quando estiver rodando, suas versões podem ser diferentes das exibidas. Caso queira exatamente as mesmas em que foi realizado esta demonstração basta deixar apenas as minhas versões, sem o sinal ^ na frente, que indica que aquela versão é a menor desejada mas pode ser instalado versões superiores. Definindo uma versão direta, o npm deverá instalar exatamente a versão definida.


{
"name": "deveops-extreme",
"version": "0.0.1",
"private": true,
"devDependencies": {
"@types/aws-lambda": "^8.10.82",
"@types/node": "^16.6.1",
"aws-sdk": "^2.967.0",
"esbuild": "^0.12.20",
"ts-node": "^10.2.0",
"typescript": "^4.3.5"
},
"dependencies": {
"@aws-cdk/aws-apigateway": "^1.118.0",
"@aws-cdk/aws-lambda": "^1.118.0",
"@aws-cdk/core": "^1.118.0",
"aws-cdk": "^1.118.0"
}
}

Uma vez com as dependências no lugar vamos definir nosso tsconfig.json, que é o arquivo que vai definir nosso projeto TypeScript. Destaco que podemos usar a lib mais recente pois vamos escrever nossa Lambda na especificação ECMAScript mais recente. Também está no modo mais fortemente tipado permitido pelo TypeScript com strict e vários outros de sua família assim como algumas definições que poderiam estar em um linter mas para economia e concisão, resolvi não adotar nesse exemplo mínimo como noImplicitThis, noFallthroughCasesInSwitch e noImplicitReturns por exemplo. A documentação do tsconfig é bem completa e vale a leitura para quem deseja entender mais as decisões do compilador.

{
"compilerOptions": {
"alwaysStrict": true,
"downlevelIteration": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSourceMap": true,
"lib": [
"es2020"
],
"moduleResolution": "node",
"noEmitOnError": true,
"noFallthroughCasesInSwitch": true,
"noImplicitAny": true,
"noImplicitThis": true,
"noImplicitReturns": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true,
"strict": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"strictPropertyInitialization": true,
"stripInternal": true,
"target": "ES2020",
"typeRoots": [
"node_modules/@types"
],
"useDefineForClassFields": true
},
"exclude": [
"node_modules"
],
"include": [
"."
]
}

# Stack

Agora vamos ao código de nossa infra do Stack! Vamos criar na raíz do projeto um arquivo chamado cdk.json:

{
"app": "npx ts-node index.ts"
}

E o index.ts a que este se refere:

import * as cdk from "@aws-cdk/core";
import { buildSync } from "esbuild";
import path from "path";

buildSync({
bundle: true,
external: ["aws-sdk"],
format: "cjs",
platform: "node",
sourcemap: true,
target: "node14.2",
});

Ainda sem os valores de entrada e saída.

# API

Agora vamos trabalhar em nova pasta, crie uma chamada api. Eu sempre crio um arquivo JSON de configuração, config.json, que posso depois, manipular e criar versões para ambientes diferentes e outros detalhes de implementação:

{
"apiName": "DevOpsExtreme",
"apiDescription": "General purpose Lambda to get request from API Gateway with CDK",
"api": {
"handler": "ApiLambda"
},
"headers": {
"Content-Type": "text/plain;charset=utf-8",
"X-Clacks-Overhead": "GNU Terry Pratchett"
},
"tags": [
{"key": "Key", "value": "Value"},
{"key": "Project", "value": "ServerlessLand"}
]
}

Se quiser saber mais sobre a X-Clacks-Overhead header, siga esse fio.

Agora a infra da API, em api/index.ts:

import * as apigw from "@aws-cdk/aws-apigateway";
import * as lambda from "@aws-cdk/aws-lambda";
import * as cdk from "@aws-cdk/core";
import { Tags } from "@aws-cdk/core";
import path from "path";
import config from "./config.json";


export class ApiStack extends cdk.Stack {
constructor(scope: cdk.App, id: string) {
super(scope, id);

const handler = new lambda.Function(this, "handler", {
code: new lambda.AssetCode(path.resolve(__dirname, "dist")),
handler: `index.${config.api.handler}`,
runtime: lambda.Runtime.NODEJS_14_X,
});

new apigw.LambdaRestApi(this, config.apiName, {
handler,
description: config.apiDescription
});


const tags = config.tags

tags.forEach(tag => {
Tags.of(this).add(tag.key, tag.value)
Tags.of(handler).add(tag.key, tag.value)
})

}
}

Podem ver que uso um forEach para manipular as tags e podemos usar qualquer controle de fluxo ou connstruto de nossas linguagens, nesse caso, TypeScript em nossa infra! Esse é o grande valor que o CDK trouxe para mim. Algumas vezes eu acho JSON ou YAML uma carga cognitiva a mais depois de estar lidando com toda a base de código e poder me expressar na linguagem que programo — e por extensão — penso e abstraio o dia todo, para mim é libertador e consigo ter mais autonomia e senso de controle! Mas essa é minha experiência, totalmente subjetiva, mas eu vivo a partir dela e compartilho com vocês. Quem sabe alguém se identifica!

# Notas

import * as cdk from "@aws-cdk/core";
import { Tags } from "@aws-cdk/core";

// Esse { Tags } eu estou usando o _desconstructing_ do ES.
// Assim como eu uso cdk.Stack, eu poderia simplesmente mais tarde ter usado cdk.
// Tags lá no final, mas acredito que fica mais explicíto.
// Da mesma forma poderia ir além e fazer algo como:

import { App, Stack, Tags } from "@aws-cdk/core";

// e nem usar o cdk.App e cdk.Stack!
// Mas estes em especial meu mapa mental é este
// Programar tem muito de estilo. Esse é o meu :)

Esta é a definição da Lambda:

    const handler = new lambda.Function(this, "handler", {
code: new lambda.AssetCode(path.resolve(__dirname, "dist")),
handler: `index.${config.api.handler}`,
runtime: lambda.Runtime.NODEJS_14_X,
});

O AssetCode tem que ser resolvido para o código compilado. Lembrando que TypeScript é uma linguagem que necessita ser compilada para JavaScript, o que vamos fazer lá no index.ts da raíz do projeto, aqui estou definindo que esse código gerado vai ficar em uma pasta dist que o esbuild vai criar para nós. A Lambda tem uma série de configurações possíveis, apenas determinei que fosse no Node.js LTS atual, o 14.x.

A configuração do nosso endpoint do API Gateway também foi mínima:

    new apigw.LambdaRestApi(this, config.apiName, {
handler,
description: config.apiDescription
});

Caso nosso handler tivesse outro nome, teríamos que passá-lo, handler: seuHandler, aproveitando a característica do ES de que a chave e valor tem o mesmo nome. E apenas uma descrição que ajuda a visualizar melhor no console, caso precise acessar. Com isso por padrão já temos uma API que vai responder a todos os métodos (GET, POST, PUT, DELETE, OPTIONS, etc) e a qualquer caminho! Assim como na Lambda há muitas formas de se configurar e você pode determinar caminhos específicos assim como métodos, CORS entre outros. Para APIs que sejam focadas inclusive recomendo as HTTP API, que podem ser até 70% mais baratas, basta seguir a documentação. Este projeto utiliza REST API.

# Lambda

E por fim o código de nossa aplicação, que ficará em novo diretório, api/lambda, que para todos os efeitos, é bem simples:

import { Handler } from "aws-lambda";
import config from "../config.json";

export const handler: Handler = async event => {
return {
body: `Salve mundo! O caminho é: "${event.path}"`,
headers: config.headers,
statusCode: 200,
};
};

Aqui nossa Lambda responde 200 para qualquer método, e respondemos com nossos cabeçalhos customizados e também printamos qualquer evento. E é claro o objeto event vai te trazer várias infoormações como IP, método, query strings o body caso tenha, entre outros para elaborar uma API mais completa. Poderíamos chamar esse arquivo de api/lambda/index.ts e parar por aqui. Mas, como uma decisão de arquitetura e empírica, quase sempre, em aplicações distribuídas e serverless nós raramente temos uma lambda apenas. Aliás usar uma Lambda para várias tarefas diferentes eu não recomendaria. Fazer uma coisa e fazer esta coisa bem feita é bem melhor. Então eu coloco este código em api/lambda/api.ts ou algum nome que faça sentido ao domínio da aplicação e no que seria o api/lambda/index.ts colocaremos a importação de todas as lambdas que nosso projeto terá, iniciando, é claro, com apenas esta:

export { handler as ApiLambda } from "./api";

Notem que este ApiLambda é o nome que eu defini em config.api.handler e referenciei em api/index.ts.

Agora, para colocar no ar só precisamos voltar ao index.ts na raíz, definir o entryPoints, os códigos que iremos ter como input e então outfile, onde colocaremos o output do esbuild:

import * as cdk from "@aws-cdk/core";
import { buildSync } from "esbuild";
import { ApiStack } from "./api/index";
import path from "path";
import config from "./api/config.json";

buildSync({
bundle: true,
entryPoints: [path.resolve(__dirname, "api", "lambda", "index.ts")],
external: ["aws-sdk"],
format: "cjs",
outfile: path.join(__dirname, "api", "dist", "index.js"),
platform: "node",
sourcemap: true,
target: "node14.2",
});

const app = new cdk.App();
const idStack = config.apiName;
new ApiStack(app, `${idStack}Api`);

E é claro, importar nossas definições e instanciar nossa infra!

cdk deploy --profile pessoal

Eu preciso usar --profile pessoal pois eu tenho mais de uma credencial AWS na minha máquina, se tiver apenas uma, isto não será necessário! O console vai exibir todas as criações, basta confirmar com y e após poucos minutos, ele já devolverá uma URL para você! Você também poderá buscar no CloudFormation ou nos respectivos serviços API Gateway e Lambda.

E aí o que acharam? Aqui vai o repositório do código feito na apresentação ao vivo.

💡 Antes de commitar, edite seu .gitignore o valor, além dos desejados para aplicações Node.js, caso contrário, você vai enviar todos os arquivos locais de deploy e cache do CDK, que não é necessário ou mesmo desejado o versionamento:

cdk.out

  1. Fonte: Overview of Amazon Web Services de Agosto, 2021. Em 2020 eram mais de 175. Temos sempre novos serviços e produtos além de melhorias entrando ao longo do ano, mas muitos são revelados na coferência re:Invent. ↩︎