Kehittäjän näkökulma IAC-työkaluun

Kirjoittanut Jussi Ritamäki
Julkaistu

Pilviympäristön hallinta on monelle arkipäivää ja se voi olla joko mielekästä tekemistä tai vain pakollinen paha. Yhä useammat, varsinkin sovelluskehittäjätaustaiset ihmiset, vannovat jonkin Infrastructure As a Code (IAC) -työkalun nimeen. Oli sitten mielessä toistettavuus, itsestään dokumentoitavuus, tietoturva, automatisointi tai mikä tahansa muu IAC:n myyntivalteista, tässä blogissa ei ole tarkoitus erityisemmin pureutua niihin. Sen sijaan tarkastelen työkalupuolta ja kerron, mitä opin käytännön kautta tuoreemman CDKTF-työkalun hyvistä ja huonoista puolista. Ja myös yrityksestä tehdä IAC:n käytöstä vähemmän pakollista pahaa ja enemmän mielekästä tekemistä tietenkään olennaisia IAC:n hyötyjä unohtamatta. Samoja ajatuksia voidaan osittain soveltaa muihin vastaaviin työkaluihin, eli yhden ainoan työkalun armoilla ei olla. 

Isoissa projekteissa ja paljon pilvi-infraa rakentavissa yrityksissä on monesti asiaan vihkiytyineitä DevOps-ammattilaisia ja heillä on kovan luokan osaamista tietystä työkalusta ja sen vaihtaminen ei yleensä ole tarpeen taikka järkevää. Toisaalta joskus ollaan myös tilanteessa, jossa pilvi-infran rakentaminen jää kehittäjien vastuulle ja tällöin DevOps-ammattilaisten työkalut voivat tuntua oudoilta ja varsinkin poiketa siitä, mitä vaikka full-stack kehittäjä on tottunut käyttämään. Stack Overflow:n 2023 Developer surveyn vastanneista suurin osa (33,48 %) identifioi itsensä juuri Full Stack -kehittäjäksi, kun taas esimerkiksi DevOps-spesialistiksi lukeutui vain 1,8 %, joten juuri ajattelemasi peruste siitä, että Devops-ammattilaiset tämän kuitenkin hoitavat näyttää jo sangen epävarmalta eikö totta?

Uusi pyörä monien vanhojen tilalle?

Full Stack -kehittäjänä olen monet kerrat ihmetellyt sitä, miksi niin moneen kohteeseen on keksittävä jokin täysin uusi kieli, kun vanhoja ja hyväksi havaittuja on olemassa vaikka millä mitoin. Tai miksi lisätään ohjelmointikielille tyypillisiä rakenteita kieliin, mitkä eivät vain siihen ole alunperin tarkoitettu. Lisäksi, vaikka kehittäjät osaavat nykyään sujuvasti käyttää montaa eri ohjelmointikieltä, yhden projektin mittakaavassa niitä ei kannata viljellä sen enempää kuin on tarpeen, jotta saadaan homma hoidettua oikein. Kognitiivista kuormaa, kun tulee nykyprojekteissä ja maailmassa riittävästi siitäkin huolimatta, että tarvitsisi jatkuvasti vaihdella usean eri ohjelmointikielen välillä.

Erilaisissa pilvi-infraan liittyvissä asioissa ei voi olla törmäämättä edellisessä kappaleessa löyhästi viitattuihin kieliin, joita ei ole tarkoitettu ohjelmointiin, kuten JSON tai YAML. Yleisesti näitä käytetäänkin konfigurointiin ja yksinkertaisimmillaan ne ovat todella hyvin yksinkertaisia. Kuitenkin tarpeiden lisäännyttyä näihin tai näiden rinnalle on ilmestynyt piirteitä ja tapoja, jolla niitä voidaan ‘ohjelmoida’ esimerkiksi templatoimalla.

Terraform on yksi työkalu, joka on monessa yrityksessä noussut lähes standardityökaluksi ja tähän liittyvä kieli on Hashicorpin oman määrityksensä mukaan ‘configuration language’ HCL, joka menee edellä esitettyyn kategoriaan. Tämä on myös malliesimerkki konfiguraatiokielestä, johon on rakennettu perinteisille ohjelmointikielille tyypillisiä rakenteita yksinkertaisesti siitä syystä, että konfiguraatiot ovat monimutkaisia ja näitä tarvitaan.

En ole kovin kokenut AWS-puolella (enintään kovia kokenut), mutta tästä maailmasta ponnistanut vaihtoehtoinen paradigma AWS Cloud Development Kit (CDK) on saavuttanut jo jonkinlaista jalansijaa. Ja ainakin henkilökohtaisesti tähän malliin on helppo ihastua. Kaikki nämä vuodet jotka olen harjoitellut ohjelmointia, miten tehdä se siististi niin, että se on toimivaa, testattavaa, luettavaa, uudelleenkäytettävää ja abstrahoitua ei olekaan hukkaan heitettyä vaan voin hyödyntää oppimaani myös infran tekemisessä valmiiksi tutulla ns. oikealla ohjelmointikielellä. Kuin kirsikkana kakun päälle pääsee verestämään muistojaan olio-ohjelmoinnin osittain unohdettuun, mutta jollain tapaa kauniiseen maailmaan funktionaalisuuden vallattua alaa muualla. Funktionaalisen ohjelmoinnin kannattajana voin todeta, että stackkien rakentelu oliomallina toimii oikein hyvin tähän tarkoitukseen, kunhan ei lähdetä liian puristisesti miettimään olioiden vastuita, tekemään monimutkaisia periytymishierarkioita.

Nyt CDKTF-projektin myötä tämä ei ole vain AWS-kehittäjien ulottuvissa, vaan sama paradigma on lainattu myös Hashicorpin uuteen työkaluun. Kirjaston kehitys vaikuttaa olevan aktiivista ja toistaiseksi versionumero 0.x.x ei lupaa stabiilisuutta rajapinnassa, vaan rikkovia muutoksia saattaa versiopäivitysten välillä olla luvassa. Tämä saattaa karkottaa jotkut pois työkalun parista, mutta jo pitkään tätä käytettyäni en ole kokenut päivityksiä ongelmallisiksi ja tyypit avustavat mahdollisessa refaktoroinnin tarpeessa.

Tyypit kunniaan

Jos et vielä ole valmis luopumaan vanhoista tavoista ja vakuuttunut CDK-mallin ylivoimaisuudesta, yritetään avata syitä miksi itse koen sen mielekkääksi. Ennen tätä on syytä tarkastella CDKTF:n kielivaihtoehtoja: Typescript, Python, Java, C# ja Go. Siis lähestulkoon jokaiselle jotain, mutta itse valitsen (ja myös Hashicorp suosittelee) Typescriptin. Kuitenkin, jos kaikki muu koodipesässä on vaikka Javaa tai C#:ia, niin nämä olisivat hyviä vaihtoehtoja. Taas esimerkiksi pythonin ollessa moneen hommaan hyvä kieli, menetetään tässä heti yksi ja mielestäni se tärkeín vahvuus: staattinen tyypitys. Tämä tai pikemminkin sen puute on mielestäni myös yksi alkuperäisen HCL kielen huonoja puolia. Unohdetaan siis ainakin tämän kirjoituksen kontekstissa muut kuin staattisesti tyypitetyt kielet ja esimerkeissä käytän Typescriptiä.

Vaikka IAC-hommissa lopputulos eli tuotettava konfiguraatio ei ole toimiva ja pyörivä ohjelma, jossa staattisista tyypeistä saataisiin turvaa eliminoimaan ajon aikaisia virheitä, on tyypeistä suuri dokumentoiva hyöty. IDE:n kautta edestakaisin navigointi toimii monin verroin paremmin ja kehityssykli on nopeampi, kun koko joukko tyhmiä virheitä jää kiinni jo tyyppitarkistuksessa samalla kun niitä kirjoittaa. Myöskään vaadittuja propertyjä ei tarvitse kaivaa dokumentaatiosta vaan IDE auttaa siinä kun propertyjen pakollisuus näkyy tyypeistä.

Kuva: Käännösvirhe

Varsinkin isoon ja monimutkaiseen Terraform-koodipesään sukeltaminen ilman tyyppejä ja sitä kautta selkeitä riippuvuussuhteita on yleensä aikamoinen string–search-harjoitus. Kirjastossa on jo paljon piirteitä mikä kannustaa kielen ominaisuuksien käyttöä. Ja ettei menisi pelkäksi kehumiseksi on todettava, että vaikka tyypitettyjä viitteitä voidaan monessa paikkaa hyödyntää ollaan siitä huolimatta useasti raakojen stringienkin varassa. Usein näin ei tarvitsisi olla, sillä resurssin property on määrätty olemaan pilvialustan puolesta jokin tietyistä string-arvoista tai sallitut arvot voivat riippua jostain toisesta määräävästä propertystä. Nämä olisi yleensä täysin mahdollista tyypittää. 

Käytettävien terraform-providerien (esim. Azure tai AWS -resurssien API) CDKTF-vastineet ovat suoria käännöksiä (ja generoitavissa) alkuperäisistä ja se on monessa mielessä hyvä juttu. Esimerkiksi uusia provider-versioita ja ominaisuuksia ei erikseen tarvitse implementoida. Tietysti myös heikkouksia on kuten se, että näitä ei alunperin ole suunniteltu staattisesti tyypitettyyn kieleen joten tyyppi-inferenssi ei tule kovin helposti olemaan täydellinen. Tämä voi olla eri tavalla työkalussa, joka on toteutettu alusta alkaen staattisten tyyppien ehdoilla kuten vaikka Pulumi, mutta näihin täytyy perehtyä tarkemmin ennen, kuin osaan tarkemmin analysoida eroja. Jätetään se siis seuraavaan tai vaikka sitä seuraavaan kertaan.

Perusrakenne ja yksinkertainen konfiguraatio

Ennen kuin enempää viittaan CDK:n spesifeihin termeihin, avataan lyhyesti keskeisimpiä. App-instanssi on erityinen objekti, joita on vähintään yksi ja mihin infra sisältö kasataan. 

import { App } from 'cdktf';
const app = new App();
Koodiesimerkki: App object

Tähän lisätään stackkejä jotka ovat yksittäisiä kokonaisuuksia, joilla on oma tilatiedostonsa. Resources taas vastaavat infrastruktuuri-objekteja ja constructit ovat tapa muodostaa paremmin abstrahoituja kokonaisuuksia sisältäen useita resursseja. Eli kaikessa yksinkertaisuudessaan homma toimii niin, että stack-olioihin liimaillaan erilaisia resursseja ja tästä saadaan itse konfiguraation pyöräyttämällä se haluttujen parametrien läpi.

const app = new App();
new ExampleStack(app);
app.synth();
Koodiesimerkki:  Stackin lisäys

Näiden rakennuspalikoiden ja proseduraalisen staattisesti tyypitetyn kielen avulla päästään rakentamaan konfiguraatiota varsin elegantisti. Siinä missä HCL-puolella copy & paste -koodaus on enemmän sääntö kuin poikkeus, CDKTF:n kanssa se ei oikeastaan käy edes mielessä. Sen verran helppoa asioiden paketointi vaikka omaksi constructiksi on ja miksi ei myös omiin funktioihin. Niin ikään saman konfiguraation sovittaminen eri ympäristöihin on jälleen kerran helpompaa kuin perinteisillä menetelmillä. Eli konfiguraation konfigurointi 🙂 sujuu helposti. Voithan käyttää apuna myös monia lempikirjastojasi kuten Envalidia ja hyödyntää monia samoja tapoja kuin sovelluksiesi puolella.

/** Using pre-build provider */
import { AzurermProvider } from '@cdktf/provider-azurerm/lib/provider';
import { ResourceGroup } from '@cdktf/provider-azurerm/lib/resource-group';
import { App, TerraformStack } from 'cdktf';
import { Construct } from 'constructs';
class ExampleStack extends TerraformStack {
 /** Add resources in the constructor */
 constructor(scope: Construct) {
   /** Call base Constructor with id */
   super(scope, 'example_1');
   /** Add required providers */
   new AzurermProvider(this, 'AzureRm', {
     features: {},
     subscriptionId: process.env.TF_ARM_SUBSCRIPTION_ID,
     tenantId: process.env.TF_ARM_TENANT_ID,
   });
   /** Add resources to be created */
   new ResourceGroup(this, 'rg_example', {
     location: 'North Europe',
     name: 'rg-cdktf-example',
   });
 }
}
const app = new App();
new ExampleStack(app);
app.synth();
Koodiesimerkki: Yksinkertaistettu esimerkki

Nyt yksinkertainen CDKTF-konfiguraatio on kasassa ja voidaan ajaa suunnittelu- eli plan-vaihe. `yarn cdktf plan` ja saadaan ulostulo samassa muodossa kuin perinteisen HCL Terraformin kanssa

Kuva: Plan output

Paljon koodia yhtä resurssia varten? No ehkä, mutta tarvitaan vielä realistisempia käyttötapauksia, että tyypityksen ja kielen hyötyjä saadaan esille. Koodiakin alkaa säästymään, kun saadaan uudelleen käytettäviä osia.

Useamman stackin konfiguraatio

Stackkien avulla resursseja voidaan jaotella omiksi kokonaisuuksiksi, mutta usein näiden välillä on riippuvuuksia. Tämän voi hoitaa monin keinoin, mutta Terraformissa monesti tarjotaan erillinen output niihin tietoihin, joihin halutaan toisesta konfiguraatiosta viitata. CDKTF:ssä stackkien väliset riippuvuudet hoituvat kuin ajatus, kun output:eja ei erikseen tarvitse julistaa. Riittää, että julkaisee stackista memberinä resurssin, mihin riippuvuuksia on tulossa ja käyttää suoraan tätä objektia toisessa stackissa, kuten muussakin luokkia käyttävässä ohjelmoinnissa voisi ajatella tekevänsä. Tätä voisi verrata vaikka sovelluksessa käytettävän datamalliin riippuvuuksien mallintamiseen. Lisätään seuraavaksi aiempaan esimerkkiin toinen stack asian havainnollistamiseksi. 

Joka stackille on erikseen instantioitava tarvittavat providerit riippuen siitä mitä resursseja halutaan käyttää. Monesti yhden projektin sisällä useampi stack tekee resursseja samoja providereita käyttäen, joten tehdään ensin pieni refaktorointi helpottamaan tätä.

/** Base class for stacks using Azurerm provider */
abstract class ProviderStack extends TerraformStack {
 constructor(scope: Construct, name: string) {
   super(scope, name);
   new AzurermProvider(this, 'AzureRm', {
     features: {},
     subscriptionId: process.env.TF_ARM_SUBSCRIPTION_ID,
     tenantId: process.env.TF_ARM_TENANT_ID,
   });
 }
}
Koodiesimerkki: Abstrakti kantaluokka stackeille

Lisäämällä abstraktin kantaluokan, voidaan providerien alustukset hoitaa yhdessä paikassa ja konkreettiset resurssi-stackit voivat keskittyä lisäämään resursseja.

/** Using this in most resources */
const location = 'North Europe';
class ExampleStack extends ProviderStack {
 public rg: ResourceGroup;
 constructor(scope: Construct) {
   super(scope, 'example_1');
   /** Add resources to the scope of this stack using this keyword */
   this.rg = new ResourceGroup(this, 'rg_example', {
     location,
     name: 'rg-cdktf-example',
   });
 }
}
class ExampleStack2 extends ProviderStack {
 constructor(scope: Construct, props : { rg: ResourceGroup }) {
   super(scope, 'example_2');
   new StorageAccount(this, 'sa_example', {
     location,
     name: 'cdktfdemost1',
     accountReplicationType: 'LRS',
     accountTier: 'Standard',
     resourceGroupName: props.rg.name,
   });
 }
}
const app = new App();
/** This stack needs to be created first */
const { rg } = new ExampleStack(app);
/** Depend on the output of the first stack */
new ExampleStack2(app, { rg })
/** Create the configuration */
app.synth();
Koodiesimerkki: Kahden stackin esimerkki

Konfiguraation konfigurointia

Yksi ajatus IaC-mallissa on se, että se on helppo asentaa moneen eri ympäristöön. Yleensä eri ympäristöt ovat eri tarkoitukseen: yksi on testaukseen ja toinen vastaava tuotantoon, kun taas kolmas voi olla tyystin eri asiakasasennukseen. Tällöin konfiguraatiota täytyy pystyä varioimaan tietyiltä osin. 

Tehdään muutama konfiguraatioon liittyvä asia ja ensimmäisenä määritetään kaikki ympäristömme, vaikka aina ympäristöt eivät ole näin hyvin staattisesti määritettävissä.

/** All possible azure environments */
export const ALL_ENVS = ['prod', 'test'] as const;
export type Env = (typeof ALL_ENVS)[number];
Koodiesimerkki: Ympäristöjen määrittely

Toiseksi lisätään useille resursseille yhteiset asiat, kuten tagit, yhteen paikkaan.

/** Common configuration for resources, eg tags */
export const resourceDefaults = {
 location: 'North Europe',
 tags: {
   configuredBy: 'cdktf',
   project: 'demo'
 }
}
Koodiesimerkki: Yleistä konfiguraatiota

Viimeisenä määritellään asiat, jotka eri ympäristöjen välillä voivat muuttua. Lisätään config-objekti, jossa vaaditaan ottamaan kantaa kaikkiin eri ympäristöihin. Tässä kohtaa voitaisiin hyvin käyttää vaikkapa ympäristömuuttujia, jos asioita olisi tarpeen määrittää vieläkin dynaamisemmin.

/** Things we may want to change between different environments */
export interface InfraConfig {
 saTier: 'Standard' | 'Premium';
 containers: { name: string, isPrivate?: boolean }[];
}
/** Configuration variations for all environments */
export const config: {
 [key in Env]: InfraConfig;
} = {
 prod: {
   saTier: 'Premium',
   containers: [
     { name: 'container1' },
     { name: 'container2', isPrivate: true }],
 },
 test: {
   saTier: 'Standard',
   containers: [{ name: 'container1' }],
 },
};
Koodiesimerkki: Ympäristökohtaista konfiguraatiota

Muutetaan hieman kantaluokkaamme pakottamalla ympäristö parametriksi ja näin saadaan nimeys yhdenmukaiseksi.

abstract class ProviderStack extends TerraformStack {
 constructor(scope: Construct, name: string, env: Env) {
   super(scope, `${name}-${env}`);
Koodiesimerkki: Kantaluokan refaktorointia

Ja lopuksi havainnollistetaan konfiguraation käyttöä esimerkin avulla. Stack vaatii nyt dynaamisen resource group -referenssin lisäksi myös juuri luomamme konfiguraation.

interface ExampleStack2Props {
 rg: ResourceGroup;
}
class ExampleStack2 extends ProviderStack {
 constructor(scope: Construct, env: Env, props : ExampleStack2Props & InfraConfig) {
   super(scope, `example2`, env);
   const sa = new StorageAccount(this, 'sa_example', {
     ...resourceDefaults, // spread defaults like tags
     name: `cdktfdemost${env}`,
     accountReplicationType: 'LRS',
     accountTier: props.saTier, // from config
     resourceGroupName: props.rg.name, // from another stack
   });
   //loop through
   props.containers.forEach(({name, isPrivate}) => {
     new StorageContainer(this, `container_${name}`, {
       name,
       storageAccountName: sa.name,
       containerAccessType: isPrivate ? 'private' : 'container',
     });
   });
 }
}
const app = new App();
/** Add all environments
*   now we need to mention which stack we want run with cdktf */
ALL_ENVS.forEach((env) => {
 const { rg } = new ExampleStack(app, env);
 new ExampleStack2(app, env, {
         ...config[env],
         rg
       }
   );
});
app.synth();
Koodiesimerkki: Ympäristökohtaisen konfiguraation hyödyntäminen

Yhteenveto

Paljon hienoja asioita jäi kertomatta ja esimerkiksi omien construct-luokkien käyttöä olisi voinut havainnollistaa. Esimerkkien kautta välittyy toivottavasti kuitenkin ajatus siitä, että tyyppejä ei kirjoiteta sen takia, että saadaan lisää koodia tai että asiat muuttuisivat kankeiksi. Vaan siksi, että saadaan koodiin ryhtiä, itsestään dokumentoivuutta, turvallisuutta ja ennen kaikkea saadaan tyyppitarkistuksen kautta välitön hyöty kehitykseen ja refaktorointiin. Ohjelmoitavuutta olisi myös voinut korostaa enemmän. Kun kielen perusrakenteet hallitsee, ei tarvitse jokaisen loopin tai if-lausekkeen kohdalla lähteä dokumentaatiosta tarkistamaan, miten se tehtiin vaan voi pääsääntöisesti tehdä, kuten on tottunut sen muuallakin tekemään.

CDKTF ei sovi kaikille, mutta uskon, että se kuitenkin sopisi monille. Itse olen käyttänyt molempia tapoja tehdä Terraformia. Perinteistä HCL:ää ja CDKTF:ää ja aika usein valitsisin jälkimmäisen.