blogs.ASPItalia.comhttps://blogs.aspitalia.com/az/feed.ASPItalia.com 'Cortana' 2022.8.29blogs.ASPItalia.comhttps://blogs.aspitalia.com/az/2022-08-04T12:07:00+00:00https://gui.aspitalia.com/images/aspitalia/aspitalia_full.pngWebAssembly (Wasm) in C++ - 2a partehttps://blogs.aspitalia.com/az/post2924/WebAssembly-Wasm-C-2a-Parte.aspx2022-08-04T12:07:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2924" border="0" style="width:1px; height:1px;" /> <p>Proseguo da <a href="https://blogs.aspitalia.com/az/post2923/WebAssembly-Wasm-C-1a-Parte.aspx" title="Post precedente wasm c++ 1a parte">qui</a> dopo i primi test per la compilazione in Wasm di codice scritto in C++. Riassumendo: ogni compilazione crea un Modulo che può essere caricato nella pagina HTML e può rendere pubblici dei metodi che potranno poi essere invocati dal proprio codice Javascript. Un palese vantaggio a differenza di altre tecnologie è la dimensione dei file compilati creati che sono di pochi decine di KB per il file Javascript che fa da Proxy, e il file Wasm che con progetti anche più complessi può pesare poche centinaia di KB. Gli svantaggi? Presenti, alcuni li mostrerò in questo post.</p>
<p>E' il momento di completare quanto scritto nello scorso post e vedere come fare per permettere al codice Wasm di interagire con la pagina e il DOM.</p>
<h1>Da C++ a DOM (con truffa)</h1>
<p>Ora scrivo questo codice in C++ che ritorna la data e ora attuali:</p>
<code>std::string GetDateTime() {
auto now = std::chrono::system_clock::now();
auto in_time_t = std::chrono::system_clock::to_time_t(now);
std::stringstream s{};
s << std::put_time(std::localtime(&in_time_t), "%Y-%m-%d %H:%M:%S %z");
return s.str();
}
void GetDate() {
std::string w = GetDateTime();
const std::string finalText = "SetDate('" + w + "');";
emscripten_run_script(finalText.c_str());
}
EMSCRIPTEN_BINDINGS(simple_example) {
emscripten::function("GetDate", &GetDate);
}</code>
<p>La funzione <em>GetDate</em> la richiamo dal codice Javascript della pagina, e la registro nella funzione <em>EMSCRIPTEN_BINDINGS</em>. Nel codice di questa funzione sono presenti due righe di codice che, presa la stringa contenente le informazioni volute, le inserisce come parametro in una funzione <em>SetDate</em>, quindi questa stringa viene passata come parametro a <em>emscripten_run_script</em>. Questa funzione fa da tramite da il WebAssembly e la pagina HTML ma, come detto nello scorso post, non è possibile utilizzare direttamente gli oggetti della pagina, ed ecco quindi la <strong>truffa</strong>: viene richiamata la funziona Javascript SetDate che scriverà effettivamente l'informazione nel browser. In <strong>my-code.js</strong>:</p>
<code>const bt1 = document.getElementById('bt1');
const spanDate = document.getElementById('spanDate');
bt1.addEventListener('click', (e) => {
e.preventDefault();
Module.GetDate();
});
function SetDate(date) {
spanDate.innerText = date;
}</code>
<p>E infatti caricata nel browser questa pagina fa quanto promesso:</p>
<p><img src="/img/andrewz/wasm2/example-wasm-1.png" title="Example WASM in browser: c++ call function in Javascript" /></p>
<h1>Fetch in C++</h1>
<p>Altra pratica nelle applicazioni client side nel browser è il download di informazioni con Api Rest. E' possibile farlo anche con le WebAssembly anche se la procedura è un po' più complessa. Nella mia pagina creo due Button: il primo che esegue in download di un file JSON esistente, il secondo che proverà a richiedere un file JSON non presente sul server.</p>
<p>Primo passo è creare nella directory un file fittizio JSON: <strong>data.json</strong>:</p>
<code>{"name":"az"}</code>
<p>Ed ecco le funzioni in C++ che, richiamate da Javascript, eseguiranno il Fetch del file:</p>
<code>void downloadSucceeded(emscripten_fetch_t *fetch) {
printf("Finished downloading %llu bytes from URL %s.\n", fetch->numBytes, fetch->url);
std::string s(fetch->data, fetch->numBytes);
std::cout << s <<"\n";
const std::string finalText = "WriteFetchResult1('" + s + "');";
emscripten_run_script(finalText.c_str());
emscripten_fetch_close(fetch); // Free data associated with the fetch.
}
void downloadFailed(emscripten_fetch_t *fetch) {
printf("Downloading %s failed, HTTP failure status code: %d.\n", fetch->url, fetch->status);
std::string s = std::to_string(fetch->status);
const std::string finalText = "WriteFetchResult2('" + s + "');";
emscripten_run_script(finalText.c_str());
emscripten_fetch_close(fetch); // Also free data on failure.
}
void LoadFile1() {
emscripten_fetch_attr_t attr;
emscripten_fetch_attr_init(&attr);
strcpy(attr.requestMethod, "GET");
attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY | EMSCRIPTEN_FETCH_PERSIST_FILE;
attr.onsuccess = downloadSucceeded;
attr.onerror = downloadFailed;
emscripten_fetch(&attr, "data.json");
}</code>
<p><em>LoadFile1</em> crea l'oggetto <em>emscripten_fetch_attr_t</em> che eseguirà il download del file in modalità asincrona. Ad esso collego due eventi, il primo per gestire il download corretto della risorsa esterna - <em>onsuccess</em> - mentre la seconda permette la gestione in caso di errore - <em>onerror</em>. Nel browser il risultato dopo aver cliccato su entrambi i Button:</p>
<p><img src="/img/andrewz/wasm2/example-fetch.png" title="Example WASM in browser: fetch" /></p>
<p>Per poter compilare il codice C++, usando la funzione Fetch, è necessario aggiungere il parametro <strong>-s FETCH=1</strong> al compilatore:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js -O3 --std=c++20 --bind -s FETCH=1</code>
<h1>Main Loop</h1>
<p>Finora ho solo mostrato un WebAssembly passivo: esso veniva utilizzato solo come contenitore di classi o metodi da richiamare dalla pagina HTML e dal suo Javascript. Ma nelle applicazioni più complesse dove essa è colonna portante dell'applicazione che gira all'interno del browser dove è gestito da lui - dalla grafica, al sonoro, alla gestione degli eventi, etc... - si possono gestire eventi interni. Cuore è il Main Loop nel quale è possibile inserire il proprio codice per la gestione di effetti grafici più tranquilli di un'applicazione 2D o più impegnativi nel caso di applicazioni 3D gestiti con <a href="https://www.khronos.org/webgl/wiki/Main_Page" title="WebGL page">WebGL</a>.</p>
<p>Il suo <a href="https://emscripten.org/docs/api_reference/emscripten.h.html#c.emscripten_set_main_loop" title="main_loop in wasm con emscripten">utilizzo</a> è semplice e si basa nell'esecuzione di una sola riga di codice in cui viene passata la funzione che dovrà essere richiamata <em>n</em> volte al secondo. Nel mio codice C++ aggiungo:</p>
<code>void StartMainLoop() {
emscripten_set_main_loop(WriteMessageInBrowser,
0,
false);
}</code>
<p><em>WriteMessageInBrowser</em> è la funziona C++ che sarà richiamata. Il secondo parametro contenente il valore zero è il numero di volte al secondo che sarà chiamata la funzione, con valore zero si userà il valore di default del browser - sessanta - per diverse esigenze si può configurare il numero desiderato. L'ultimo parametro, impostato a <em>false</em>, viene utilizzato per simulare il loop: nel caso di valore <em>false</em> l'elaborazione del codice continua con il resto del codice presente dopo quel comando, altrimenti, se impostato a <em>true</em>, viene bloccata l'esecuzione.</p>
<p>Ecco il codice che utilizzerò nella mia demo:</p>
<code>int _counter = 0;
void WriteMessageInBrowser() {
std::string s = std::to_string(_counter);
const std::string finalText = "WriteInfoMainLoop('" + s + "');";
emscripten_run_script(finalText.c_str());
_counter += 1;
}
void StartMainLoop() {
emscripten_set_main_loop(WriteMessageInBrowser,
0,
false);
}
void StopMainLoop() {
emscripten_cancel_main_loop();
}</code>
<p>Così come nel primo esempio, utilizzo il trucco di richiamare una funziona Javascript della mia pagina che inserirà il valore della variabile <em>_counter</em> nel browser. Inoltre ho inserito due funzioni, uno per avviare il Main Loop e la seconda funzione per fermarlo. Ecco il risultato:</p>
<p><img src="/img/andrewz/wasm2/main_loop.gif" title="Example WASM in browser: main loop" /></p>
<p>
Ok basta, è ora di fare sul serio.</p>
<h1>
Benchmark: Javascript vs Wasm</h1>
<p>
Personalmente è la prima cosa che ho controllato quando ho iniziato a interessarmi al WebAssembly. E ovviamente non potevano mancare un paio di test riguardo. Nella demo che ho creato ho inserito due banali test per l'ordinamento con il semplice e con poche esigenze di prestazioni con il metodo Bubble Sort in Javascript e C++, quindi un secondo test con il più prestazionale Quick Sort.</p>
<p>
Partendo dall'esempio in Javascript, inizio a misurare il tempo alla creazione di un Array di 80.000 numeri random. Quindi questo Array viene passato ad una funzione per l'ordinamento e ritornato l'Array ordinato. Solo a questo punto viene calcolato il tempo di esecuzione. Il codice è il seguente:</p>
<code>...
const startTime = performance.now()
const arr = GetRandomNumbers(numbersElements, numbersElements);
const arrOrdered = OrderArrayBubbleSortJs(arr);
const endTime = performance.now()
const time = endTime - startTime;
spanBubbleSortJs.innerText = `${time.toFixed(2)} ms`;
...
function GetRandomNumbers(arrayLength, maxValue) {
const arr=[];
for (let i=0; i<arrayLength; i++) {
arr<i> = Math.floor(Math.random() * maxValue) + 1;
}
return arr;
}
function OrderArrayBubbleSortJs(arr) {
const length=arr.length;
for (let x = 0; x<length-1; x++) {
for (let y=x+1; y<length; y++) {
if (arr[x] > arr[y]) {
const tmp = arr[x];
arr[x] = arr[y];
arr[y]=tmp;
}
}
}
return arr;
}</code>
<p>
Stessa cosa con il codice in C++ anche se il calcolo del tempo di esecuzione viene fatto da Javascript:</p>
<code>...
const startTime = performance.now()
const arr = Module.GetBubbleSort(numbersElements);
const endTime = performance.now()
const time = endTime - startTime;
spanBubbleSortWasm.innerText = `${time.toFixed(2)} ms`;
...
template<typename T>
concept IsComparable = requires(T t)
{
std::is_arithmetic_v<T>;
t <=> t;
};
template<IsComparable T>
void BubbleSortOrder(std::vector<T>& data) {
auto size = data.size();
for (auto x = 0; x < size - 1; ++x) {
for (auto y = x + 1; y < size; ++y) {
if (data[x] > data[y]) {
std::swap(data[x], data[y]);
}
}
}
}
template<IsComparable T>
std::vector<T> GetBubbleSort(int size) {
auto arr = std::vector<T>(size);
srand (time(NULL));
for (auto i = 0; i < size; ++i) {
auto n = rand() % size + 1;
arr<i> = n;
}
BubbleSortOrder(arr);
return arr;
}</code>
<p>
A parte l'uso dei <a href="https://en.cppreference.com/w/cpp/language/constraints" title="Constraints and concepts">concept</a> nel Template per il controllo di tipo con l'utilizzo del <a href="https://blogs.aspitalia.com/az/post2919/Threeway-Comparison-Operator-C.-CSharp.aspx" title="Three-way comparison in C++">Three-way comparison</a>, qui c'è una ulteriore modifica nel codice. Non ho utilizzato l'Array classico presente nel linguaggio, spiegherò più avanti il motivo, ma un oggetto di tipo <a href="https://en.cppreference.com/w/cpp/container/vector" title="Vector in C++">Vector</a>. Per fare riconoscere quest'oggetto a Javascript ho dovuto aggiungere nella sezione <em>EMSCRIPTEN_BINDINGS</em> la sua definizione:</p>
<code>emscripten::register_vector<int>("vector<int>");</code>
<p>
E' il momento di controllare i dati oggettivi. I tempi qui presenti sono delle media eseguite su un computer con CPU Quad core su due tipi di browser: Firefox e Chrome.</p>
<table border="1">
<tr>
<th></th>
<th>Firefox</th>
<th>Chrome</th>
</tr>
<tr>
<td>Bubble Sort Javascript</td>
<td>20,13s</td>
<td>10,345s</td>
</tr>
<tr>
<td>Bubble Sort Wasm</td>
<td>9,875s</td>
<td>10,107s</td>
</tr>
</table>
<p>
La prima cosa che emerge è la bontà delle prestazioni del <a href="https://v8.dev/" title="Javascirpt v8 chrome">motore</a> Javascript di Chrome che equivale, con poca differenza, con la versione in WebAssembly - e leggendo come funziona il motore di Chrome si può capire il perché.</p>
<p>
Non rimane che testare il codice in versione per l'ordinamento Quick Sort sia in Javascript che WebAssembly. Ecco la versione Javascript con il link di riferimento dove ho preso il codice di esempio:</p>
<code>// https://www.guru99.com/quicksort-in-javascript.html
function swap(items, leftIndex, rightIndex){
var temp = items[leftIndex];
items[leftIndex] = items[rightIndex];
items[rightIndex] = temp;
}
function partition(items, left, right) {
var pivot = items[Math.floor((right + left) / 2)], //middle element
i = left, //left pointer
j = right; //right pointer
while (i <= j) {
while (items<i> < pivot) {
i++;
}
while (items[j] > pivot) {
j--;
}
if (i <= j) {
swap(items, i, j); //sawpping two elements
i++;
j--;
}
}
return i;
}
function quickSort(items, left, right) {
var index;
if (items.length > 1) {
index = partition(items, left, right); //index returned from partition
if (left < index - 1) { //more elements on the left side of the pivot
quickSort(items, left, index - 1);
}
if (index < right) { //more elements on the right side of the pivot
quickSort(items, index, right);
}
}
return items;
}</code>
<p>
Ed ecco la versione in C++:</p>
<code>template<IsComparable T>
int Partition(std::vector<T> &v, int start, int end) {
int pivot = end;
int j = start;
for(int i=start;i<end;++i){
if(v<i><v[pivot]){
std::swap(v<i>,v[j]);
++j;
}
}
std::swap(v[j],v[pivot]);
return j;
}
// https://slaystudy.com/c-vector-quicksort/
template<IsComparable T>
void Quicksort(std::vector<T> &v, int start, int end ) {
if(start<end){
int p = Partition(v,start,end);
Quicksort(v,start,p-1);
Quicksort(v,p+1,end);
}
}
template<IsComparable T>
std::vector<T> GetQuickSort(int size) {
auto arr = std::vector<int>(size);
srand (time(NULL));
for (auto i = 0; i < size; ++i) {
auto n = rand() % size + 1;
arr<i> = n;
}
Quicksort(arr, 0, size - 1);
return arr;
}</code>
<p>
Ed ecco la tabella riassuntiva delle prestazioni (notare che i tempi non sono in secondi ma in millisecondi):</p>
<table border="1">
<tr>
<th></th>
<th>Firefox</th>
<th>Chrome</th>
</tr>
<tr>
<td>Bubble Sort Javascript</td>
<td>18,20ms</td>
<td>11,70ms</td>
</tr>
<tr>
<td>Bubble Sort Wasm</td>
<td>7ms</td>
<td>6,30ms</td>
</tr>
</table>
<p>
La differenza in Firefox rimane quasi immutata, la versione in WebAssembly è sempre più veloce tra 2x e 2.5x. La versione in Wasm in Chrome si è ripresa un notevole vantaggio sulla versione in Javascript, e il vantaggio si avvicina al 2x.</p>
<p>
E' solo una supposizione, ma le differenze con l'ordinamento con Bubble Sort è alla fine molto semplice: sono solo due cicli <em>for</em> uno all'interno dell'altro con scambio del valore delle variabili, mentre la versione Quick Sort oltre ai cicli <em>for</em> fa un numero notevole di chiamate ad altre funzioni, ed è forse per questo motivo che la maggiore agilità di Wasm ha permesso di riprendere vantaggio nella versione per Chrome. Ma è solo una supposizione.</p>
<p>
La compilazione del codice scritto in C++ è stato fatto con l'ottimizzazione massima: <strong>-O3</strong>. Se rimuovessi questa opzione che prestazioni avrei? Eccole:</p>
<table border="1">
<tr>
<th></th>
<th>Firefox</th>
<th>Chrome</th>
</tr>
<tr>
<td>Bubble Sort Wasm</td>
<td>33ms</td>
<td>46ms</td>
</tr>
</table>
<h1>
Oggetti C++ in Javascript</h1>
<p>
Inizio a scrivere dei problemi che personalmente ho trovato. Innanzitutto il passaggio di Array da Javascript a C++ (e viceversa) è da incubo. Incubo perché di base non è possibile passare facilmente questo tipo di oggetto. Esempio banale:</p>
<code>const arr = [1, 2, 3];
Module.ReadThisArr(arr);</code>
<p>
E nella versione in C++:</p>
<code>void ReadThisArr(int *arr) {...}</code>
<p>
La compilazione andrà a buon fine, con una piccola modifica all'esportazione del metodo, ma al momento dell'utilizzo si avranno errori:</p>
<p><img src="/img/andrewz/wasm2/array_error_wasm.png" title="Errore in wasm con un array di javascript" /></p>
<p>Il modo più semplice per risolvere è utilizzando l'oggetto <a href="https://emscripten.org/docs/api_reference/val.h.html" title="emscripten val method">val</a>. La funzione in C++ scritta in questo modo funzionerebbe correttamente:</p>
<code>void ReadThisArr(emscripten::val const & arr) {
std::vector<int> data = emscripten::convertJSArrayToNumberVector<int>(arr);
...
}</code>
<p>La prima riga di codice nella funzione converte l'oggetto generico <em>val</em> in un <em>vector</em> di interi. Di contro questo trucco ha solo la lentezza nel processo di conversione.</p>
<p>La soluzione alternativa è creare un oggetto in C++ di tipo Vector da utilizzare poi in Javascript:</p>
<code>emscripten::register_vector<std::string>("VectorString");</code>
<p>Ora nel codice Javascript posso utilizzare questo oggetto proprio come fosse un <em>vector</em> in C++:</p>
<code>const myclass = new Module.VectorString();
myclass.push_back("stringa 1");
myclass.push_back("stringa 2");
myclass.push_back("stringa 3");
</code>
<p>Nella funzione in C++ sarà sufficiente utilizzare l'oggetto di tipo <em>std::vector<std::string></em> per ricevere correttamente il contenuto di quella collection.</p>
<h1>
Debug Wasm</h1>
<p>
Scrivere codice di una certa complessità senza Debug è follia. E nella scrittura di codice client side per il browser sono questi essenzialmente i punti che un buon Debugger dovrebbe permettere:</p>
<ul><li>Breakpoint</li>
<li>Variable inspection</li>
<li>DOM change detection</li>
<li>Control Flow</li>
<li>Interactive Console</li>
</ul>
<p>
E il compilatore Emcripten permette il Debug? Dei punti qui sopra sono solo disponibili il Control Flow e i Breakpoint. Ecco una piccola dimostrazione. Innanzitutto si deve aggiungere dei nuovi parametri al compilatore:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
-e EMCC_DEBUG=1 `
-e EMCC_AUTODEBUG=1 `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js --std=c++20 --bind -s FETCH=1 -gsource-map</code>
<p>
Ora aprendo il browser il Web Developer Tools, si potrà trovare anche il file <strong>main.cpp</strong>:</p>
<p><img src="/img/andrewz/wasm2/debug-wasm-1.png" title="Debug wasm in browser" /></p>
<p>
Ed è possibile aggiungere Breakpoint che effettivamente bloccheranno il codice al momento dell'esecuzione, e sarà poi possibile eseguire il Debug passo passo, ma non è possibile controllare il contenuto delle variabili né tanto meno, modificarne il valore, o utilizzare la Console. Ecco un esempio:</p>
<p><img src="/img/andrewz/wasm2/debug-wasm-2.png" title="Debug wasm passo passo in browser" /></p>
<p>
Il Debug si è correttamente fermato ma non sarà possibile interagire con gli oggetti presenti nel codice. Nel pannello di destra è presente la lista delle variabili locali, e tra queste è possibile trovare quelle utilizzate nel codice, ma solo trovare la variabile utilizzata ad una di queste <em>$var1...$var31</em> minerebbe la pazienza di chiunque.</p>
<p>
Sempre meglio di niente, ma non è sufficiente.</p>
<h1>
Conclusioni</h1>
<p>
Il bello dovrebbe cominciare ora parlando di argomenti più complessi come l'utilizzo di WebGL. Ma per le mie conoscenze in merito è meglio che mi fermo qui - a mia difesa questi argomenti li ho solo trattati per curiosità e non ho approfondito visto che il mio studio sul WebAssembly in C++ era scaturita da altre motivazione, come scritto nel primo post, e poi mostrare il
classico esempio di una schermata con figure geometriche di base colorate. In futuro? Forse.</p>
<p>
Tiro alcune conclusioni strettamente personali: qual è il reale vantaggio del WebAssembly? Le prestazioni. Qualcuno aggiunge anche la tipizzazioni data dal linguaggio come il C/C++, ma questa la trovo banale come motivazione anche perché <a href="https://www.typescriptlang.org/" type="Typescript site">TypeScript</a> risolve questo problema nello sviluppo tradizionale con Javascript. Di conseguenza, nella decisione di usare il WebAssembly o il classico Javascript per la creazione di una application che deve girare nel browser, qual è la molla che fa decidere di usare il più impegnativo WebAssembly? Si ritorna alla stessa risposta data alla prima domanda: le prestazioni. Per applicazioni veramente spinte che fanno uso di grafica 2D/3D diventa una scelta <strong>quasi</strong> obbligata, ma per la classica applicazione SPA con Form, inserimenti e visualizzazione di dati, <strong>ad oggi</strong>, è quanto mai sprecato l'uso del WebAssembly con Emscripten.</p>
<p>
A questo <a href="https://github.com/sbraer/wasmblog" title="link to source code (github)">link</a> il codice sorgente sia del post precedente che quello del codice mostrato in questo post.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2924/WebAssembly-Wasm-C-2a-Parte.aspx"><em>WebAssembly (Wasm) in C++ - 2a parte</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2924/WebAssembly-Wasm-C-2a-Parte.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2924.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2924WebAssembly (Wasm) in C++ - 1a partehttps://blogs.aspitalia.com/az/post2923/WebAssembly-Wasm-C-1a-Parte.aspx2022-07-31T18:20:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2923" border="0" style="width:1px; height:1px;" /> <p>Per motivazioni che non voglio spiegare mi sono ritrovato a interessarmi al mondo del WebAssembly. Accantonando qualsiasi riferimento in questo post a <a href="https://tags.aspitalia.com/Blazor/" title="tag blazor">Blazor</a> qui cercherò di scrivere qualche informazioni sulla realizzazione di WebAssembly con Emscripten in C++.</p>
<p>Il WebAssemply - o Wasm - come si intuisce dal nome è codice in formato binario. Non è puro linguaggio macchina che usa la macchina in cui gira al più basso livello possibile, ma è piuttosto un bytecode in grado di girare all'interno del browser, grazie ad una specie di sandbox, alla teorica massima velocità possibile ed è compatibile con tutti i browser che lo supportano, inoltre è indipendente dal sistema operativo in cui gira il browser - a scanso di equivoci ora, nel 2022, tutti i maggiori browser lo supportano.</p>
<p>Il Wasm alla fine è una sequenza di zero e uno in formato binario che si può riassumere nel formato esadecimale:</p>
<code>20 00
50
04 7E
42 01
05
20 00
20 00
42 01
7D
10 00
7E
0B</code>
<p>
Da cui è possibile avere questo codice in formato testuale - Wat - esempio preso da <a href="https://it.wikipedia.org/wiki/WebAssembly" title="Wasm in wikipedia">Wikipedia</a>:</p>
<code>get_local 0
i64.eqz
if (result i64)
i64.const 1
else
get_local 0
get_local 0
i64.const 1
i64.sub
call 0
i64.mul
end</code>
<p>
In qualche modo ricorda il linguaggio IL usato dal Framework .Net e da Net Core per decodificare il suo bytecode.</p>
<p>
Ovviamente si può scrivere il proprio codice in Wasm in Wat, ma perché complicarsi la vita quando si possono usare linguaggi più ad alto livello e conosciuti come, nel caso preso in considerazione per questo post, in C++?</p>
<h1>
Emscripten</h1>
<p>
Il tool di sviluppo che utilizzerò è <a href="https://emscripten.org/" title="Link to emscripten home page">Emscripten</a> che utilizza <a href="https://llvm.org/" type="LLVM home page">LLVM</a> per la compilazione e la trasformazione e compilazione del codice. Per il suo utilizzo è necessario installare dei tool appositi sulla macchina, sia che sia Windows, Linux, etc... coma da questa <a href="https://emscripten.org/docs/getting_started/downloads.html" title="Documentazione Emscript per l'installazione">documentazione</a>. In questo post eviterò questo passaggio e semplificherò il tutto usando Docker con la relativa <a href="https://hub.docker.com/r/emscripten/emsdk" title="Emscripten sdk container">immagine</a> contenente l'SDK dei tool di Emscripten. Per i test che farò sarà necessario quindi solo Docker e un web server per contenuto statico - nel mio caso ho installato con NPM il pacchetto <a href="https://www.npmjs.com/package/static-server" title="static server npm link">static-server</a> con questo comando:</p>
<code>npm -g install static-server</code>
<p>
Da console andando nella directory dove è presente il contenuto HTML e lanciare il comando <strong>static-server</strong> perché questo sia disponibile nel browser al link <strong>http://localhost:9080</strong>.</p>
<p>
Inizio scrivendo questo codice in C++:</p>
<code>#include <iostream>
int main() {
std::cout << "Hello World!\n";
}</code>
<p>
Che, se fosse compilato con un qualsiasi compilatore C++ e trasformato in eseguibile, visualizzerebbe a schermo il classico messaggio <em>Hello World!</em>. Ora lo voglio compilare in <em>emscripten</em> in modo che sia trasformato in Wasm e uso Docker per lanciare il comando <em>emcc</em>: </p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js</code>
<p>
Facendo questi test sotto Windows sto usando la Powershell. Se si volesse usare la Bash è sufficiente una modifica alle parentesi al comando pwd e al codice per il multilinea e il Path:</p>
<code>docker run \
--rm \
-v $(pwd):/src \
emscripten/emsdk:3.1.17 \
emcc main.cpp -o main.js</code>
<p>
Lanciato il comando, se non ci sono problemi, ritorna il prompt dei comandi. Nella directory:</p>
<code>$ dir
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 7/31/2022 10:30 AM 74 main.cpp
-a---- 7/31/2022 10:32 AM 199334 main.js
-a---- 7/31/2022 10:32 AM 154327 main.wasm</code>
<p>
Sono stati creati i due file, <strong>main.js</strong> e <strong>main.wasm</strong>: il primo è il codice Javascript che sarà utilizzato come chiamante e caricherà il file Wasm dove è presente il codice compilato. Per provarlo è sufficiente usare il comando <em>node</em>:</p>
<code>$ node Main.js
Hello World!</code>
<p>
Questo significa che il codice che finora si è scritto in nodejs utilizzato Javascript può essere scritto in C++? Sì... ma tanti auguri se si vuole provare questa strada. Se si volesse fare girare il codice qui sopra in una macchina dove non è possibile usare Wasm, si può dire al compilatore di creare solo codice Javascript con l'opzione <strong>-s WASM=0</strong>:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js -s WASM=0</code>
<p>
Risultato:</p>
<code>$ dir
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 7/31/2022 10:30 AM 74 main.cpp
-a---- 7/31/2022 10:37 AM 1635796 main.js
$ node Main.js
Hello World!</code>
<p>
Interessante? Può essere, ma io ero interessato ad altro.</p>
<h1>
Web Assembly nel browser</h1>
<p>
Il comando utilizzato finora in Docker, <em>emcc</em>, permette la compilazione del codice e la creazione anche della pagina HTML in cui girerà il codice Wasm. E' sufficiente utilizzare il nome di una pagina HTML nel comando visto prima:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.html</code>
<p>
Nella stessa directory ora lancio il comando <strong>static-server</strong> e apro il browser al link <a href="http://localhost:9080/Main.html">http://localhost:9080/Main.html</a>:</p>
<p><img src="/img/andrewz/wasm1/emscripten_browser.png" with="720" title="C++ in browser con Emscripten" /></p>
<p>
Nella directory:</p>
<code>$ dir
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a---- 7/31/2022 10:30 AM 74 main.cpp
-a---- 7/31/2022 10:39 AM 102503 main.html
-a---- 7/31/2022 10:39 AM 199334 main.js
-a---- 7/31/2022 10:39 AM 154327 main.wasm</code>
<p>
La pagina HTML carica il file <strong>Main.js</strong> che penserà poi al caricamento del file Wasm. Personalmente ho trovato il codice creato nella pagina per la maggior parte inutile quindi ho deciso di minimizzare il tutto.</p>
<h1>
Custom code</h1>
<p>
Inizio scrivendo una semplice funzione che mi ritorna una stringa:</p>
<code>#include <iostream>
#include <emscripten/emscripten.h>
#include <emscripten/bind.h>
std::string GetString() {
return "Hello World! from GetString";
}
EMSCRIPTEN_BINDINGS(simple_example) {
emscripten::function("GetString", &GetString);
}</code>
<p>
Gli <em>include</em> utilizzati sono utilizzati
le varie funzioni che saranno inserite in questo file. <strong>Bind.h</strong> viene utilizzato per poter esportare la funzione appena creata e renderla visibile all'interno del Browser a dal codice Javascript che utilizzerò per poterla chiamare, come definito nel blocco <em>EMSCRIPTEN_BINDINGS</em>. Provo a compilare:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js --bind</code>
<p>
Ho dovuto aggiungere anche l'opzione <em>--bind</em> avendo utilizzato la definizione del blocco degli oggetti da esportare. All'inizio di questo post avevo fatto riferimento al Wat, che è la visualizzazione testuale del codice compilato nel file Wasm. E' possibile ottenere il Wat dalla compilazione precedenete, e userò un'altra immagine Docker dove è presente il comando <strong>wasm2wat</strong>:</p>
<code>docker run `
--rm `
-v ${pwd}:/src `
polkasource/webassembly-wabt:v1.0.11 `
wasm2wat /src/main.wasm -o /src/main.wat</code>
<p>
Il risultato è un file testuale di oltre 8500 righe in cui copio solo una breve porzione:</p>
<code>(module
(type (;0;) (func (param i32) (result i32)))
(type (;1;) (func (param i32)))
(type (;2;) (func (param i32 i32)))
(type (;3;) (func (result i32)))
(type (;4;) (func))
(type (;5;) (func (param i32 i32 i32) (result i32)))
(type (;6;) (func (param i32 i32 i32)))
(type (;7;) (func (param i32 i32) (result i32)))
(type (;8;) (func (param i32 i32 i32 i32 i32)))
(type (;9;) (func (param i32 i32 i32 i32 i32 i32)))
(type (;10;) (func (param i32 i32 i32 i32)))
(type (;11;) (func (param i32 i64 i32) (result i64)))
(type (;12;) (func (param i32 i32 i32 i32) (result i32)))
(type (;13;) (func (param i32 i32 i32 i32 i32) (result i32)))
(type (;14;) (func (param i32 i32 i64 i32) (result i64)))
(type (;15;) (func (param i32 i32 i32 i64 i64)))
(type (;16;) (func (param i32 i32 i32 i32 i32 i32 i32)))
(type (;17;) (func (param i32 i64 i32 i32) (result i32)))
(import "env" "_embind_register_function" (func (;0;) (type 9)))
(import "env" "_embind_register_void" (func (;1;) (type 2)))
(import "env" "_embind_register_bool" (func (;2;) (type 8)))
(import "env" "_embind_register_integer" (func (;3;) (type 8)))
(import "env" "_embind_register_float" (func (;4;) (type 6)))
(import "env" "_embind_register_std_string" (func (;5;) (type 2)))
(import "env" "_embind_register_std_wstring" (func (;6;) (type 6)))
(import "env" "_embind_register_emval" (func (;7;) (type 2)))
(import "env" "_embind_register_memory_view" (func (;8;) (type 6)))
(import "env" "emscripten_memcpy_big" (func (;9;) (type 6)))
(import "env" "emscripten_resize_heap" (func (;10;) (type 0)))
(import "env" "abort" (func (;11;) (type 4)))
(import "wasi_snapshot_preview1" "fd_close" (func (;12;) (type 0)))
(import "wasi_snapshot_preview1" "fd_write" (func (;13;) (type 12)))
(import "env" "setTempRet0" (func (;14;) (type 1)))
(import "env" "_embind_register_bigint" (func (;15;) (type 16)))
(import "wasi_snapshot_preview1" "fd_seek" (func (;16;) (type 13)))
(func (;17;) (type 4)
call 153
call 49
call 51)
...</code>
<p>
Utilità? Nessuna.</p>
<p>
Ora creo il file - <strong>index.html</strong> - con il codice HTML dove voglio inserire questo WebAssembly:</p>
<code><!DOCTYPE html>
<html>
<head>
<title>Javascript Wasm Example</title>
<script type="text/javascript">
var Module = {
onRuntimeInitialized: function () {
console.log('Module loaded: ', Module);
GetString();
}
};
</script>
<script defer type="text/javascript" src="main.js"></script>
<script defer src="my-code.js" type="text/javascript"></script>
</head>
<body style="text-align:center">
<h1>Example WASM C++</h1>
<div id="output1"></div>
<div id="output2"></div>
<div id="output3"></div>
</body>
</html></code>
<p>
Con il comando di compilazione precedente sono stati creati due file: <strong>main.js</strong> e <strong>main.wasm</strong>. Il primo è incluso nel codice HTML (sarà compito suo caricare il secondo file), inoltre ho inserito un altro file Javascript - <strong>my-code.js</strong> - dove inserirò il mio codice che farà da tramite tra la pagina HTML e DOM e il Wasm. Questo è necessario perché il codice Wasm non può accedere direttamente agli oggetti della pagina e del browser (come modificare direttamente il DOM).</p>
<p>
Il codice Javascript incluso nella pagina carica il Modulo dal file <strong>main.js</strong> e solo quando il WebAssembly è stato caricato e istanziato è eseguito l'evento <em>onRuntimeInitialized</em> nel quale chiamo una mia funzione <em>GetString</em> presente nel file <strong>my-code.js</strong>:</p>
<code>function GetString() {
const msg = Module.GetString();
console.log(msg);
document.getElementById('output1').innerText = msg;
}</code>
<p>
Il metodo <em>GetString</em> in C++ prima compilato è presente ora nel Modulo e posso richiamarlo da Javascript (il cui risultato inserisco in un DIV all'interno della pagina. Ora avvio <strong>static-server </strong>e controllo da browser che tutto funzioni correttamente:</p>
<p><img src="/img/andrewz/wasm1/example-1.png" title="Wasm in browser" /></p>
<p>Incuriosito di come avviene la condivisione di oggetti più complessi come la classi, provo ad aggiungere nel file <strong>main.cpp</strong>:</p>
<code>class MyClass {
private:
int _x;
public:
MyClass(int x): _x{x} {}
int GetX() const {
return _x;
}
};</code>
<p>Questa classe ha un costruttore che accetta un valore numerico e un metodo che ritorna queso il valore numerico. Leggendo la <a href="https://emscripten.org/docs/porting/connecting_cpp_and_javascript/embind.html" title="Embind in C++ and Javascript">documentazione</a> scopro che devo aggiungere queste informazioni al blocco <em>EMSCRIPTEN_BINDINGS</em>:</p>
<code>EMSCRIPTEN_BINDINGS(simple_example) {
emscripten::function("GetString", &GetString);
emscripten::class_<MyClass>("MyClass")
.constructor<int>()
.function("GetX", &MyClass::GetX);
}</code>
<p>Provo la compilazione:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js --bind</code>
<p>Non ottengo errori. Ma ora devo poter istanziare questa classe da Jacascript. Nel file <strong>index.html</strong> aggiungo nel codice Javasciprt della pagina, dopo aver chiamato la funzione <em>GetString</em>, la chiamata alla funzine <em>CheckClass</em>, il cui codice inserirò in <strong>my-code.js</strong>:</p>
<code>function CheckClass() {
const myclass = new Module.MyClass(10);
try {
const x = myclass.GetX();
console.log(x);
document.getElementById('output2').innerText = x;
}
finally {
myclass.delete();
}
}</code>
<p>Come output vedrò nella pagina e nalla console il valore corretto (numero dieci). La creazione della classe si esegue nel classico modo ma ora dobbiamo essere noi da codice a chiamare di distruttore di classe perché non sarà chiamato in automatico all'uscita dello scope.</p>
<p>Leggendo la documentazione scopro che è possibile aggiungere i classici Flag dei compilatori C++ come la versione e le ottimizzazioni da fare al codice compilato. Provo immediatamente ad aggiungere alcune specifiche per il C++20 come i Concept:</p>
<code>template<typename T>
concept IsNumeric = std::is_arithmetic_v<T>;
IsNumeric auto GetSquare(IsNumeric auto num) {
return num * num;
}</code>
<p>Utilizzanto il Concept <em>IsNumeric</em> faccio in modo che al Template possano essere utilizzati solo tipi numerici (int, float, double, etc...). Aggiungo anche questa funzione in <em>EMSCRIPTEN_BINDINGS</em> specificando il tipo che utilizzerò come input:</p>
<code>emscripten::function("GetSquare", &GetSquare<int>);</code>
<p>Se volessi poter passare anche <em>double</em> come tipo di variabile:</p>
<code>emscripten::function("GetSquare", &GetSquare<double>);</code>
<p>Ora compilo aggiungendo le opzioni per compilazione con le specifiche del C++ 20 e con il parametro <strong>-O</strong> forzo il compilatore alla massima ottimizzazione del codice per le prestazioni:</p>
<code>docker run `
--rm `
-v ${PWD}:/src `
emscripten/emsdk:3.1.17 `
emcc main.cpp -o main.js -O3 --std=c++20 --bind</code>
<p>Altri parametri accettati di ottimizzazione:</p>
<ul><li><strong>-O0</strong> nessuna ottimizzazione</li>
<li><strong>-O1</strong> ottimizzazione minina</li>
<li><strong>-O2</strong> ...</li>
<li><strong>-O3</strong> ottimizzazione massima</li>
<li><strong>-Oz</strong> ottimizzazione per la dimensione finale del codice compilato.</li></ul>
<p>Aggiungo la funzione Javascript in <strong>my-code.js</strong>:</p>
<code>function GetSquare() {
const msg = Module.GetSquare(10);
console.log(msg);
document.getElementById('output3').innerText = msg;
}</code>
<p>Aggiungendo la chiamata a questa funzione all'evento <em>onRuntimeInitialized</em> in <strong>index.html</strong>:</p>
<code>onRuntimeInitialized: function() {
console.log('Module loaded: ', Module);
GetString();
CheckClass();
GetSquare();
}</code>
<p><img src="/img/andrewz/wasm1/example-2.png" title="Wasm in browser con class e concept" /></p>
<p>A proposito di opzioni per la compilazione, è presente anche una opzione per il compilatore <em>emcc</em> che permette di ridurre il file Javascript creato per la gestione del file Wasm nel caso questo file sia da eseguire solo da browser: <strong>-sENVIRONMENT=web</strong>, ma da alcuni test il risparmio sembra esiguo.</p>
<h1>
Conclusioni</h1>
<p>
Questa prima parte è stata leggera leggera. Non voglio mettere troppa roba in un singolo post e rimando alla seconda dove scriverò qualche altro esempio testando il C++, il passaggio di argomenti dal WebAssembly al browser, e non possono mancare dei test per misurare le prestazioni. Per ora basta: è estate pure per me.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2923/WebAssembly-Wasm-C-1a-Parte.aspx"><em>WebAssembly (Wasm) in C++ - 1a parte</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani1https://blogs.aspitalia.com/az/post2923/WebAssembly-Wasm-C-1a-Parte.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2923.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2923AWS, EKS e Fluent Bithttps://blogs.aspitalia.com/az/post2922/AWS-EKS-Fluent-Bit.aspx2022-07-22T18:02:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2922" border="0" style="width:1px; height:1px;" /> <p>Una persona che probabilmente mi odia mi ha chiesto: "Conosci Fluent Bit?".</p>
<p>Al che mi sono venute in mente le ore <del>sprecate</del> per cercare di capire come funzionasse <a href="https://www.fluentd.org/" title="Fluntd site">Fluentd</a>, il famoso data collector. Ricordai giustappunto il tempo utilizzato per capire i vari moduli per la lettura, la raccolta dei Log in Kubernetes, la loro trasformazione, e l'invio a destinazione.</p>
<p>Ingenuamente rispondo: "Fluent<strong>D</strong>?".</p>
<p>"No, no, Fluent <strong>BIT</strong>, diciamo che è il fratello minore di Fluentd ma ottimizzato per lavorare con Container e tutta quella roba lì che piace a te. Ma come fai a non conoscerlo?".</p>
<p>Ok, confesso l'ignoranza. prima di quel dialogo non sapevo neanche dell'esistenza di Fluent Bit eppure scopro che c'è da anni! E' forse il momento di provarlo sperando di non sprecare ore e ore per farlo funzionare?</p>
<h1>Fluent Bit</h1>
<p><a href="https://fluentbit.io/" title="Fluent Bit site">Fluent Bit</a> potrebbe definirsi il fratello minore di Fluentd. Ottimizzato per l'uso nei Containers ma soprattutto ha il pregio di avere un consumo molto inferiore di risorse. Dalla <a href="https://docs.fluentbit.io/manual/about/fluentd-and-fluent-bit" title="fluent bit vs fluentd link">documentazione ufficiale</a> sembra che il suo eseguibile consumi all'incirca 650KB invece dei 40MB di Fluentd. A parte questi dettagli come funzionano questi Tool? In modo molto semplice: leggono i Log delle applicazioni più importanti (sono presenti Plug-in per la lettura automatica dei Log dei più famosi database per esempio, e dopo eventuali filtri e trasformazioni, questi Log possono essere inviati a Tool che memorizzano queste informazioni, ad altri database e servizi come <a href="https://www.splunk.com/" title="link a Splunk">Splunk</a>. In questo schema il riassunto di quanto ho provato a spiegare (presa dal sito ufficiale):</p>
<p><img src="/img/andrewz/terraform8/fluentbit-diagram.png" width="720" title="Fluent bit" /></p>
<ul><li><a href="https://docs.fluentbit.io/manual/concepts/data-pipeline/input" title="Fluent Bit Input">Input</a>: di base Fluent Bit accetta più di venti Input come riportato da questo <a href="https://docs.fluentbit.io/manual/pipeline/inputs" title="fluent bit input list">link</a>. Per la demo che presenterà in questo post userò Docker, perché i Pod all'interno di Kubernetes, almeno nella versione ancora utilizzata, è compatibile. E' ci dovrebbe essere già il modulo per leggere in Input i Container in formato CRI, ottimo.</li>
<li><a href="https://docs.fluentbit.io/manual/concepts/data-pipeline/parser" title="Fluent Bit: Parser link">Parser</a>: questo modulo (non obbligatorio) permette il parsing delle informazioni da Input che possono essere in qualsiasi formato (testuale, json, etc...).</li>
<li><a href="https://docs.fluentbit.io/manual/concepts/data-pipeline/filter" title="Fluent Bit Filter">Filter</a>: questo modulo serve per filtrare il documento, per aggiungere e eliminare informazioni eventualmente ricevuti dai moduli precedenti.</li>
<li><a href="https://docs.fluentbit.io/manual/concepts/data-pipeline/buffer" title="Fluent Bit Buffer">Buffer</a>: questo modulo viene utilizzare solo per la memorizzazione delle informazioni elaborate dai moduli precedenti prima dell'invio al modulo per il Routing.</li>
<li><a href="https://docs.fluentbit.io/manual/concepts/data-pipeline/router" title="Fluent Bit: routing">Routing</a>: alla base delle trasmissioni di dati tra i vari moduli. Ogni dato elaborato dal modulo Input ha un suo Tag che permette il suo riconoscimento e la trattazione per i moduli successivi.</li>
<li><a href="https://docs.fluentbit.io/manual/concepts/data-pipeline/output" title="Fluent Bit Output">Output</a>: questo modulo invia quanto elaborato dai moduli precedenti al servizio prescelto per la memorizzazione. <a href="https://docs.fluentbit.io/manual/pipeline/outputs" title="Fluent bit plung in output list">Qui</a> l'elenco ufficiale dei Plug-in supportati.</li></ul>
<p>Ora non rimane che metterlo alla prova con qualcosa di semplice.</p>
<h1>Primo test in locale</h1>
<p>Prima di passare ad AWS provo un test il locale con la versione di Kubernetes presente nell'installazione di Docker per Windows 10. Dalla <a href="https://docs.fluentbit.io/manual/installation/kubernetes" title="Installazione di Fluent bit">documentazione</a> leggo che il modo più semplice per avviare Fluent Bit è con Helm:</p>
<code>helm install fluent-bit fluent/fluent-bit</code>
<p>Ma siccome voglio fare da subito alcune prove sovrascrivo il contenuto della configurazione prendendo i valori di default da questo <a href="https://github.com/fluent/helm-charts/blob/main/charts/fluent-bit/values.yaml" title="helm value default per fluent bit">file</a>:</p>
<code>## https://docs.fluentbit.io/manual/administration/configuring-fluent-bit/configuration-file
config:
service: |
[SERVICE]
Daemon Off
Flush {{ .Values.flush }}
Log_Level {{ .Values.logLevel }}
Parsers_File parsers.conf
Parsers_File custom_parsers.conf
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port {{ .Values.metricsPort }}
Health_Check On
inputs: |
[INPUT]
Name cpu
Tag my_cpu
filters: |
[FILTER]
Name record_modifier
Match my_cpu
Allowlist_key cpu_p
Allowlist_key user_p
outputs: |
[OUTPUT]
Name stdout
Match *
customParsers: |
</code>
<p>E' arrivato il momento di avviare Fluent Bit con Helm e questi parametri:</p>
<code>helm install fluent-bit -f value.yaml fluent/fluent-bit</code>
<p>Prima di vedere il risultato alcune parole sul mio test. Fluent Bit mette a disposizione nativamente dei Plug-in per la lettura delle informazioni sulla macchina in cui gira, tra cui l'utilizzo della memoria, della cpu etc... Qui sopra ho inserito in <em>[INPUT]</em> <strong>cpu</strong> in modo da avere i valori attuali della cpu. Nella sezione di <em>[FILTER]</em> ho inserito un filtro per avere solo due valori. Infine in <em>[OUTPUT]</em> ho messo la console. Punto importante è l'uso di Tag e Match per collegare i passi della Pipeline che Fluent Bit deve seguire per le spefiche informazioni. Avendo in <em>[INPUT]</em> utilizzato il tag <em>my_cpu</em>, il filtro e l'output successivo saranno collegati ad esso con il <em>Match</em>. Potrei creare più sezioni di Input per la CPU assegnando ad ognuna di esse un Tag specifico per elaborazioni e filtri particolari.</p>
<p>Ora controllando il Log del Pod di Fluent Bit trovo le informazioni volute:</p>
<code>[0] my_cpu: [1658425188.449414500, {"cpu_p"=>5.750000, "user_p"=>2.500000}]
[0] my_cpu: [1658425189.449495900, {"cpu_p"=>3.000000, "user_p"=>2.000000}]
[0] my_cpu: [1658425190.449405000, {"cpu_p"=>5.000000, "user_p"=>3.500000}]
[0] my_cpu: [1658425191.449414200, {"cpu_p"=>8.000000, "user_p"=>5.250000}]
...</code>
<p>Se non avessi inserito quel filtro avrei avuto questo contenuto su una CPU Quad-core:</p>
<code>[0] my_cpu: [1658425050.449336300, {"cpu_p"=>5.500000, "user_p"=>3.000000, "system_p"=>2.500000, "cpu0.p_cpu"=>3.000000, "cpu0.p_user"=>3.000000, "cpu0.p_system"=>0.000000, "cpu1.p_cpu"=>4.000000, "cpu1.p_user"=>2.000000, "cpu1.p_system"=>2.000000, "cpu2.p_cpu"=>7.000000, "cpu2.p_user"=>1.000000, "cpu2.p_system"=>6.000000, "cpu3.p_cpu"=>8.000000, "cpu3.p_user"=>6.000000, "cpu3.p_system"=>2.000000}]
[0] my_cpu: [1658425051.449415400, {"cpu_p"=>3.750000, "user_p"=>2.500000, "system_p"=>1.250000, "cpu0.p_cpu"=>3.000000, "cpu0.p_user"=>2.000000, "cpu0.p_system"=>1.000000, "cpu1.p_cpu"=>2.000000, "cpu1.p_user"=>1.000000, "cpu1.p_system"=>1.000000, "cpu2.p_cpu"=>5.000000, "cpu2.p_user"=>4.000000, "cpu2.p_system"=>1.000000, "cpu3.p_cpu"=>5.000000, "cpu3.p_user"=>3.000000, "cpu3.p_system"=>2.000000}]</code>
<h1>Kubernetes e Fluent Bit, primo tentativo</h1>
<p>
Avendo un cluster Kubernetes avviato in AWS trovo questi <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Container-Insights-setup-logs-FluentBit.html" title="Install Fluent Bit in EKS AWS cluster">link</a> in cui viene spiegata l'installazione di Fluent Bit che invia i Log raccolti a Cloud Watch. Perfetto! L'esempio che mi serviva. Cosa può andare storto? Il link è preso dalla documentazione ufficiale di AWS! Inizio a seguire la guida pedissequamente fino all'ultimo passo, fino a quando devo verificare il setup di Fluent Bit. Entro nella console di AWS e apro Cloud Watch. Subito l'amara sorpresa: nella documentazione descrive la presenza di tre Log Groups, ma nel mio caso non vedo nulla. Aspetto qualche minuto, forzo il refresh della pagina, ma non appare niente. Mi viene il dubbio di aver dimenticato qualcosa e, tornando all'inizio di quella pagina, controllo con maggiore scrupolo i passaggi fatti. La mia fiducia è quasi scomparsa, e i file YAML prima installati
senza nemmeno essere controllati, vengono aperti e controllati - gran brutte cose la pigrizia e la fiducia: insieme fanno solo danni.</p>
<p>
Il succo di tutto è un Pod avviato con all'interno l'immagine Docker di Fluent Bit. Inizio a preoccuparmi. Nella mia mente si fa largo un sospetto. Solo per il primi minuti cerco di evitarlo e lo metto da parte, ma più esamino il codice più questo mi sembra fondato. Trovo il codice per la definizione delle RBAC in Kubernetes. "E' una <a href="https://blogs.aspitalia.com/az/post2902/RBAC-Kubernetes-Operator.aspx" title="mio post precedente sull'argomento RBAC in Kubernetes">cosa normale</a>", penso, "perché se deve prelevare le informazioni dagli altri Pod e solo con i giusti permessi si può fare". Ma nella catena delle azioni che deve fare Fluent Bit mi soffermo un attivo sull'ultimo step: "Ma come invia le informazioni a Cloud Watch con un normale Service Account?" - questo argomento l'ho trattato nel <a href="https://blogs.aspitalia.com/az/post2921/AWS-EKS-OIDC-Accedere-Risorse-AWS-Kubernetes.aspx" title="post precedente">post
precedente</a>.</p>
<p>
Eccolo lì il problema. Per esserne certo controllo il Log di quel Pod:</p>
<code>[2022/06/11 17:50:29] [ info] [output:cloudwatch_logs:cloudwatch_logs.1] Creating log stream ip-10-0-58-61.eu-south-1.compute.internal-dataplane.systemd.kubelet.service in log group /aws/containerinsights/K8sDemo-blog-cloud-watch/dataplane
[2022/06/11 17:50:29] [error] [output:cloudwatch_logs:cloudwatch_logs.1] CreateLogStream API responded with error='AccessDeniedException'
[2022/06/11 17:50:29] [error] [output:cloudwatch_logs:cloudwatch_logs.1] Failed to create log stream</code>
<p>
Forse mi sono perso qualcosa in quel tutorial... non me ne importa niente. Vedo che l'User Account creato è <em>fluent-bit</em> e il Namespace utilizzato è <em>amazon-cloudwatch</em>. Mi ero segnato questo comando da terminale per la creazione dell'utente in AWS collegato ad un Service Account, lo provo con questo utente:</p>
<code>eksctl create iamserviceaccount --region eu-south-1 --name fluent-bit --namespace amazon-cloudwatch --cluster K8sDemo-blog-cloud-watch --attach-policy-arn arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy --override-existing-serviceaccounts --approve</code>
<p>
Ma preparo il comando per la sua cancellazione perché la mia fiducia è scesa in sciopero:</p>
<code>eksctl delete iamserviceaccount --region eu-south-1 --name fluent-bit --namespace amazon-cloudwatch --cluster K8sDemo-blog-cloud-watch</code>
<p>
Quindi forzo la cancellazione del Pod di Fluent Bit in modo che venga ricreato in automatico. Con pessimismo motivato aspetto qualche minuto e controllo Cloud Watch:</p>
<p><img src="/img/andrewz/terraform8/cloud-watch1.png" width="720" title="Cloud Watch Fluent bit" /></p>
<p>
Ha funzionato! Un po' di fortuna...</p>
<h1>
Figurarsi se Terraform poteva mancare</h1>
<p>
Prova a fare il Deploy di quanto ho fatto finora con Terraform. I file con gli script per la creazione di VPC, EKS e le altre risorse necessarie sono sempre quelli. Devo personalizzare la creazione del Service Account per Fluent Bit. Utilizzerò quanto spiegato nel <a href="https://blogs.aspitalia.com/az/post2921/AWS-EKS-OIDC-Accedere-Risorse-AWS-Kubernetes.aspx" title="post precedente">post precedente</a>:</p>
<code>data "aws_iam_policy_document" "oidc_assume_role_policy" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
effect = "Allow"
condition {
test = "StringEquals"
variable = "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub"
values = [
"system:serviceaccount:${kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name}:fluent-bit"
]
}
principals {
identifiers = [aws_iam_openid_connect_provider.eks.arn]
type = "Federated"
}
}
}
resource "aws_iam_role" "oidc_cw" {
assume_role_policy = data.aws_iam_policy_document.oidc_assume_role_policy.json
name = "oidc_cw"
}
resource "aws_iam_role_policy_attachment" "role_attach_cw" {
role = aws_iam_role.oidc_cw.name
policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}</code>
<p>
Non devo creare nessuna Policy ma utilizzerò quella già presente: <em>CloudWatchAgentServerPolicy</em>. Creo il Service Account:</p>
<code>resource "kubernetes_service_account" "aws-sa-cw" {
metadata {
name = "fluent-bit"
namespace = kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name
annotations = {
"eks.amazonaws.com/role-arn" = aws_iam_role.oidc_cw.arn
}
}
}</code>
<p>
Quindo, come spiegato in quel tutorial in AWS, creo un ConfigMap con le stesse chiavi/valori:</p>
<code>resource "kubernetes_config_map" "fluent-bit-cluster-info" {
metadata {
name = "fluent-bit-cluster-info"
namespace = kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name
}
data = {
"cluster.name" = var.cluster_name
"http.server" = "On"
"http.port" = 2020
"read.head" = "Off"
"read.tail" = "On"
"logs.region" = var.aws_region
}
}</code>
<p>
Sempre dalla documentazione veniva scaricato e avviata la creazione delle risorse definite in questo Yaml file:</p>
<code>kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/fluent-bit/fluent-bit.yaml</code>
<p>
Trasformo questo file nel formato di Terraform. Faccio una modifica solo nella sezione ConfigMap dove, in quel file, è definita la configurazione di Fluent Bit, che io sposto in file esterni per una più veloce e facile gestione:</p>
<code>resource "kubernetes_config_map" "fluent_bit_config" {
metadata {
name = "fluent-bit-config"
namespace = kubernetes_namespace.amazon-cloudwatch-ns.metadata[0].name
labels = {
k8s-app = "fluent-bit"
}
}
data = {
"application-log.conf" = file("${path.module}/fluent-bit-config/8.1-application-log.conf")
"dataplane-log.conf" = file("${path.module}/fluent-bit-config/8.2-dataplane-log.conf")
"host-log.conf" = file("${path.module}/fluent-bit-config/8.3-host-log.conf")
"parsers.conf" = file("${path.module}/fluent-bit-config/8.4-parsers.conf")
"application-custom.conf" = file("${path.module}/fluent-bit-config/8.5-application-custom.conf")
"fluent-bit.conf" = file("${path.module}/fluent-bit-config/8.6-fluent-bit.conf")
}
}</code>
<p>
Ho creato una directory apposita dove ho inserito i file già presenti e ne ho aggiunto uno mio - <strong>application-custom.conf</strong> - dove ho inserito la mia configurazione personalizzata. Sì, perché i file di default raccolgono un coacervo di informazioni di cui non me ne importa niente (per il momento o per sempre?), io voglio inserire in Cloud Watch le informazioni che voglio <strong>io</strong> dai Pod che voglio <strong>io</strong>!</p>
<h1>
Personalizzare Fluent Bit</h1>
<p>
Per i miei test ho bisogno di Pod. Nella mia demo ho avviato il classo Pod con Nginx quindi altri due Pod che inviano messaggi a intervalli regolari. Solo per curiosità ecco il codice usato per questi due ultimi Pod che, al limite del ridicolo, non fanno altro che scrivere un messaggio nella console ogni tre secondi:</p>
<code>apiVersion: v1
kind: Pod
metadata:
name: static-web1
spec:
containers:
- name: bash
image: nginx:1.14.2
command: ["/bin/bash", "-c", "--" ]
args: ["while :; do echo '12345,abcde,miao,cipcip,1234567890'; sleep 3; done"]
---
apiVersion: v1
kind: Pod
metadata:
name: static-web2
spec:
containers:
- name: bash
image: nginx:1.14.2
command: ["/bin/bash", "-c", "--" ]
args: ["while :; do echo 'qwerty'; sleep 3; done"]</code>
<p>
Ora devo configurare Fluent Bit perché prenda questi messaggi e li salvi in Cloud Front. Dal file utilizzato precedentemente per AWS faccio una sola aggiunta al file <strong>fluent-bit.conf</strong>:</p>
<code>[SERVICE]
Flush 5
Log_Level info
Daemon off
Parsers_File parsers.conf
HTTP_Server ${HTTP_SERVER}
HTTP_Listen 0.0.0.0
HTTP_Port ${HTTP_PORT}
storage.path /var/fluent-bit/state/flb-storage/
storage.sync normal
storage.checksum off
storage.backlog.mem_limit 5M
@INCLUDE application-log.conf
@INCLUDE dataplane-log.conf
@INCLUDE host-log.conf
@INCLUDE application-custom.conf # <- riga aggiunta</code>
<p>
Le tre righe precedenti creano i Log Group visti prima. Se si volesse evitare la loro creazione è sufficiente rimuovere queste righe da questo file. Ma ecco qualche dettaglio del mio file. Innanzitutto definisco i dati in input:</p>
<code>[INPUT]
Name tail
Path /var/log/containers/*.log
multiline.parser docker
Tag kube.*
Mem_Buf_Limit 5MB
Skip_Long_Lines On</code>
<p>
Uso come parser <a href="https://docs.fluentbit.io/manual/pipeline/inputs/tail" title="Tail input in Fluent Bit">Tail</a> e faccio e come Path inserisco il percorso dove sono salvati i Log file all'interno dei Pod di Kuberntes. Anche qui ho definito un Tag: <strong>kube.*</strong>, che sarà utilizzato come base per la creazione del Tag che potrà essere utilizzato dagli altri moduli di Fluent Bit. Ora definisco i filtri per i tre Pod creati precedentemente:</p>
<code>[FILTER]
Name kubernetes
Match kube.var.log.containers.static-web1_*
Merge_Log On
Keep_Log Off
K8S-Logging.Parser On
K8S-Logging.Exclude On</code>
<p>
I log vengono salvati nel Path visto prova e hanno come nome il nome stesso del Pod. Nell'esempio mostrato prima il primo Pod ha il nome <strong>static-web1</strong>. Ora utilizzo questo nome in Match per fare in modo che questo filtro prenda solo da Input il contenuto dei file con questo nome: <em>kube.var.log.containers.static-web1_*</em> (il Tag inizia con <strong>kube.</strong> come visto prima, e in automatico Kubernetes ha aggiunto il path completo del Log del Pod ed ha aggiunto un sequenza di caratteri casuali che risolvo con la Wildcard <strong>*</strong>).</p>
<p>
Se inserissi il contenuto da questo filtro otterrei questo output:</p>
<code>{
"log": "qwerty\n",
"stream": "stdout",
"kubernetes": {
"pod_name": "static-web2",
"namespace_name": "default",
"pod_id": "a9b2dacf-6cf5-44ea-bf34-600a30094122",
"host": "ip-10-0-150-58.eu-south-1.compute.internal",
"container_name": "bash",
"docker_id": "4558aa182399991deaf6afacc130e4f40f6d347f78ec6a171d2e6fa84987ae07",
"container_hash": "nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"container_image": "nginx:1.14.2"
}
}</code>
<p>
Per ora non mi interessano tutte queste informazioni, e soprattutto non voglio per ora il JSON come output. Devo utilizzare un altro filtro per <em>sistemare</em> questo output:</p>
<code>[FILTER]
Name nest
Match kube.var.log.containers.static-web1_*
Operation lift
Nested_under kubernetes
Add_prefix kubernetes_</code>
<p>
Con il filtro di tipo <a href="https://docs.fluentbit.io/manual/pipeline/filters/nest" title="Fluent bit filter Nest">Nest</a> con l'opzione <em>Lift</em> posso prendere il contenuto presente in altri Key e portarlo al primo livello. Nell'output precedente voglio prendere il contenuto della Key Kubernetes e portarli al primo livello. Con il filtro precedente ora avrò come output:</p>
<code>{
"log": "qwerty\n",
"stream": "stdout",
"kubernetes_pod_name": "static-web2",
"kubernetes_namespace_name": "default",
"kubernetes_pod_id": "a9b2dacf-6cf5-44ea-bf34-600a30094122",
"kubernetes_host": "ip-10-0-150-58.eu-south-1.compute.internal",
"kubernetes_container_name": "bash",
"kubernetes_docker_id": "4558aa182399991deaf6afacc130e4f40f6d347f78ec6a171d2e6fa84987ae07",
"kubernetes_container_hash": "nginx@sha256:f7988fb6c02e0ce69257d9bd9cf37ae20a60f1df7563c3a2a6abe24160306b8d",
"kubernetes_container_image": "nginx:1.14.2"
}</code>
<p>
Non mi servono tutte queste informazioni, quindi uso un altri Filtro per avere come output solo le Key che mi interessano:</p>
<code>[FILTER]
Name record_modifier
Match kube.var.log.containers.static-web1_*
Allowlist_key log
Allowlist_key time
Allowlist_key kubernetes_pod_name</code>
<p>
Ora avrò come output finale:</p>
<code>{
"log": "qwerty\n",
"time": "2022-07-19T18:31:31.531664336Z",
"kubernetes_pod_name": "static-web2"
}</code>
<p>
Non rimane che inviare il tutto a Cloud Watch:</p>
<code>[OUTPUT]
Name cloudwatch_logs
Match kube.var.log.containers.static-web1_*
region ${AWS_REGION}
log_group_name /aws/containerinsights/${CLUSTER_NAME}/web_all
log_stream_name web1
auto_create_group true
extra_user_agent container-insights</code>
<p>
Nel quale specifico, tra gli altri, la Region, il Log Group Name e il Log Stream Name. Anche se mi ripeto notare che in link di collegamento tra l'input, tutti i filtri utilizzati e l'output finale è dato dall'opzione Match.</p>
<p>
Ora farò lo stesso anche per il secondo Pod che invia il messaggio e Nginx. Nel modulo di output utilizzerò lo stesso Log Group Name in modo che questi tre Pod inviino i Log allo stesso gruppo. Solo il Log Stream name sarà differente. Alla fine otterrò questo risultato in Cloud Watch:</p>
<p><img src="/img/andrewz/terraform8/cloud-watch2.png" width="720" title="Cloud Watch Fluent bit" /></p>
<p>
Nel dettaglio in <strong>nginx</strong>:</p>
<p><a href="/img/andrewz/terraform8/cloud-watch3.png" title="Nginx Cloud Watch Fluent bit"><img src="/img/andrewz/terraform8/cloud-watch3.png" title="Nginx Cloud Watch Fluent bit" width="720" /></a></p>
<p>Che riporto qui in formato testuale:</p>
<code>{
"log": "127.0.0.1 - - [19/Jul/2022:18:31:26 +0000] \"GET / HTTP/1.1\" 200 612 \"-\" \"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0\" \"-\"\n",
"time": "2022-07-19T18:31:26.536651573Z",
"kubernetes_pod_name": "nginx-deployment-6947dd7485-wtn4q"
}</code>
<p>Ed ecco un esempio di Log per il Pod che invia del testo a intervalli regolari:</p>
<p><a href="/img/andrewz/terraform8/cloud-watch4.png" title="Nginx Cloud Watch Fluent bit"><img src="/img/andrewz/terraform8/cloud-watch4.png" title="Nginx Cloud Watch Fluent bit" width="720" /></a></p>
<h1>
Conclusioni</h1>
<p>
L'esempio qui riportato è molto semplice. Più interessate è l'utilizzo con moduli di output più interessanti come <a href="https://docs.fluentbit.io/manual/pipeline/outputs/elasticsearch" title="Elastic Search con Fluent Bit">Elastich Search</a> o <a href="https://docs.fluentbit.io/manual/pipeline/outputs/splunk" title="Splunk con Fluent Bit">Splunk</a> per un esame del log con strumenti più adatti che Cloud Watch e del suo <a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/AnalyzingLogData.html" title="AWS Logs Insights">Logs Insights</a>.</p>
<p>
Personalmente ho trovato il numero di Plug-in per i vari moduli di input, filter e output abbondante per le mie necessità. L'unico difetto che ho trovato in Fluent Bit è stata nella documentazione che ho trovato personalmente incompleta - per capire alcune opzioni ho dovuto cercare esempi funzionanti in Internet.</p>
<p>
Ecco il <a href="https://github.com/sbraer/kubernetes-blog/tree/main/k8s-4" title="Link codice demo">link</a> con il codice utilizzato in questa demo. Nella directory principale è presente il classico codice in Terraform da avviare con i classici tre comandi:</p>
<code>terraform init
terraform plan
terraform apply -auto-approve</code>
<p>
Dopo l'esecuzione di questo codice è possibile configurazione l'autenticazione sulla propria macchina EKS con questo comando:</p>
<code>aws eks --region eu-south-1 update-kubeconfig --name K8sDemo-blog-cloud-watch</code>
<p>
Nella stessa directory è presente il file <strong>nginx.yaml</strong> dove sono presente i tre Pod utilizzati per i test. Per avviarli:</p>
<code>kubectl apply -f nginx.yaml</code>
<p>
Alla fine si elimina il tutto con:</p>
<code>terraform destroy -auto-approve</code>
<p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2922/AWS-EKS-Fluent-Bit.aspx"><em>AWS, EKS e Fluent Bit</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2922/AWS-EKS-Fluent-Bit.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2922.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2922AWS, EKS, OIDC: accedere alla risorse di AWS da Kuberneteshttps://blogs.aspitalia.com/az/post2921/AWS-EKS-OIDC-Accedere-Risorse-AWS-Kubernetes.aspx2022-07-15T17:53:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2921" border="0" style="width:1px; height:1px;" /> <p>L'idea di questo post è nata dopo una critica sollevata un amico dopo il mio insistente proliferare di post dedicati a Kubernetes in AWS. Provo a riassumere la questione scaturita dalla critica: è possibile accedere alle numerose risorse di AWS dall'interno di Kubernetes? Se sì, come? Nel dettaglio: come posso leggere un Secret, per esempio, oppure leggere un Bucket in S3 dall'interno di un Pod?</p>
<h1>Identity federation</h1>
<p>Chi utilizza abitualmente AWS sa che per accedere, per esempio, ad un Bucket in S3 da una Lambda si devono assegnare i permessi con le IAM Role e Policy. La cosa si risolve giustappunto con l'utilizzo di una Role alla quale va assegnata una delle Policy esistenti, oppure creando ex novo una nuova Role con una nuova Policy ad hoc da assegnare alla Lambda.</p>
<p>Per esempio, senza fare alcuna fatica si può usare una di queste generiche:</p>
<p><img src="/img/andrewz/terraform7/s3policy.png" title="S3 Policy in AWS" /></p>
<p>Oppure, partendo da una di queste, creando una nuova più restrittiva e sicura:</p>
<code>{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicRead",
"Effect": "Allow",
"Principal": "*",
"Action": [
"s3:GetObject",
"s3:GetObjectVersion"
],
"Resource": [
"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"
]
}
]
}</code>
<p>L'utilizzo delle Policy direttamente dai Pod che girano all'interno del cluster EKS non è possibile perché questi non posseggono e non possono utilizzare direttamente nessuna autorizzazione per l'accesso alle risorse di AWS. Da parte sua, Kubernetes, mette a disposizione internamente le Role RBAC per l'utilizzo e l'accesso alle sue risorse interne collegabili ai Service Account. Proprio di questo voglio scrivere in questo post: come unire questi due mondi.</p>
<p>AWS mette a disposizione dalla versione 1.14 di Kubernetes l'<a href="https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html" title="IRSA in AWS">IAM Roles for Service Account (IRSA)</a>. La soluzione più semplice per implementare questa funzionalità e grazie all'OpenID che mette a disposizione EKS. In questo modo è possibile associare a uno, o più Service Account di Kubernetes, le Role IAM di AWS utilizzando l'AWS STS (<a href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html" title="link a AssumeRoleWithWebIdentity in AWS">AssumeRoleWithWebIdentity</a>).</p>
<p>La documentazione di AWS <a href="https://docs.aws.amazon.com/eks/latest/userguide/enable-iam-roles-for-service-accounts.html" title="AWS EKS oidc connection">spiega</a> tutti i passaggi per questa operazione che in questo post tratterò con Terraform... E sono al settimo post dedicato!</p>
<h1>Terraform</h1>
<p>Come in tutti i post precedenti dove ho creato una cluster Kubernetes EKS con Terraform, anche questa volta non farò eccezione. Userò sempre il codice che ho mostrato e incluso più volte nelle demo con piccole modifiche, ma aggiungerò inoltre la creazione dell'Identity Federation con OpenID.</p>
<p>Primo passaggio è prelevare le informazioni dal cluster appena avviato:</p>
<code>provider "kubernetes" {
host = aws_eks_cluster.demo.endpoint
token = data.aws_eks_cluster_auth.demo.token
cluster_ca_certificate = base64decode(aws_eks_cluster.demo.certificate_authority.0.data)
}
data "tls_certificate" "eks" {
url = aws_eks_cluster.demo.identity[0].oidc[0].issuer
}</code>
<p>Ora posso creare l'OpenID Connect Provider:</p>
<code>resource "aws_iam_openid_connect_provider" "eks" {
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint]
url = aws_eks_cluster.demo.identity[0].oidc[0].issuer
}</code>
<p>Posso aggiungere l'IAM Policy per collegare il mondo di AWS e ai miei Service Account che utilizzerò all'interno di Kubernetes:</p>
<code>data "aws_iam_policy_document" "oidc_assume_role_policy" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
effect = "Allow"
condition {
test = "StringEquals"
variable = "${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub"
values = [
"system:serviceaccount:default:aws-sa-s3",
"system:serviceaccount:default:aws-sa-vpc"
]
}
principals {
identifiers = [aws_iam_openid_connect_provider.eks.arn]
type = "Federated"
}
}
}</code>
<p>Notare l'action <strong>AssumeRoleWithWebIdentity</strong> e i due Service Account: <strong>aws-sa-s3</strong> e <strong>aws-sa-vpc</strong> ai quali darò i permessi di lettura a S3 per il primo e alle VPC per il secondo. Ora posso costruire le due Role in AWS con le rispettive Custom Policy:</p>
<code>resource "aws_iam_role" "oidc_s3" {
assume_role_policy = data.aws_iam_policy_document.oidc_assume_role_policy.json
name = "oidc_s3"
}
resource "aws_iam_policy" "policy_s3" {
name = "policy_s3"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListAllMyBuckets",
"s3:GetBucketLocation"
],
"Resource": "arn:aws:s3:::*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "role_attach_s3" {
role = aws_iam_role.oidc_s3.name
policy_arn = aws_iam_policy.policy_s3.arn
}
resource "aws_iam_role" "oidc_vpc" {
assume_role_policy = data.aws_iam_policy_document.oidc_assume_role_policy.json
name = "oidc_vpc"
}
resource "aws_iam_policy" "policy_vpc" {
name = "policy_vpc"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ec2:DescribeVpcs",
"Resource": "*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "role_attach_vpc" {
role = aws_iam_role.oidc_vpc.name
policy_arn = aws_iam_policy.policy_vpc.arn
}</code>
<p>Non rimane che collegare i due Service Account alle due Role appena create:</p>
<code>resource "kubernetes_service_account" "aws-sa-s3" {
metadata {
name = "aws-sa-s3"
namespace = "default"
annotations = {
"eks.amazonaws.com/role-arn" = aws_iam_role.oidc_s3.arn
}
}
}
resource "kubernetes_service_account" "aws-sa-vpc" {
metadata {
name = "aws-sa-vpc"
namespace = "default"
annotations = {
"eks.amazonaws.com/role-arn" = aws_iam_role.oidc_vpc.arn
}
}
}</code>
<p>In questo caso l'ARN delle Role è stato aggiunto in <em>annotations</em> (<em>eks.amazonaws.com/role-arn</em>) in modo che EKS colleghi correttamente i Service Account alle Role.</p>
<h1>Service Account</h1>
<p>Per controllare che tutto funzioni avvio due Pod con i due Service Account appena creati:</p>
<code>resource "kubernetes_pod" "tests3" {
metadata {
name = "tests3"
namespace = "default"
}
spec {
service_account_name = kubernetes_service_account.aws-sa-s3.metadata[0].name
container {
image = "amazon/aws-cli:2.7.12"
name = "aws-cli"
command = [ "/bin/bash", "-c", "--" ]
args = [ "sleep infinity" ]
}
}
}
resource "kubernetes_pod" "testvpc" {
metadata {
name = "testvpc"
namespace = "default"
}
spec {
service_account_name = kubernetes_service_account.aws-sa-vpc.metadata[0].name
container {
image = "amazon/aws-cli:2.7.12"
name = "aws-cli"
command = [ "/bin/bash", "-c", "--" ]
args = [ "sleep infinity" ]
}
}
}</code>
<p>Le due righe importanti in queste due file per Terraform sono quelle in cui viene definito il Service Account da utilizzare per quel Pod: <strong>aws-sa-s3</strong> per il primo Pod, e <strong>aws-sa-vpc</strong> per il secondo Pod. Ma per verificare che tutto funzioni è arrivato il momento di avviare il tutto in AWS in modo che venga creato un cluster EKS. La procedura, ripetuta in questi post per troppe volte, è la seguente:</p>
<code>terraform init
terraform plan
terraform apply -auto-approve</code>
<p>Dopo una decina di minuti di attesa, se tutto ha funzionato, saranno presenti i due Pod. Posso verificare, ma prima devo aggiornare il file <em>config</em> nella directory <em>.kube</em> dell'utente utilizzato sulla macchina da cui si è lanciato il tutto. Uso il comando:</p>
<code>aws eks --region eu-south-1 update-kubeconfig --name K8sDemo-blog-service-account</code> <p>Quindi verifico che i due Pod sono attivi:</p>
<code>$ kubectl get po
NAME READY STATUS RESTARTS AGE
tests3 1/1 Running 0 115s
testvpc 1/1 Running 0 115s</code>
<p>Al primo Service Account era stato assegnato la Role per poter accedere a S3. Controllo che funzioni:</p>
<code>$ kubectl exec tests3 -- aws s3api list-buckets
{
"Buckets": [
{
"Name": "MyBucket",
"CreationDate": "2022-07-03T11:49:33+00:00"
}
],
"Owner": {
"ID": "72e08b113af8bb54a4876a672d637a12451faf9134d50ae1933b48291ba4f31d"
}
}</code>
<p>Ottimo, ecco il mio unico Bucket in S3.</p>
<p>Il secondo Pod può richiedere la lista delle VPC nella zona dove è stato avviato il Cluster di Kubernetes:</p>
<code>$ kubectl exec testvpc -- aws ec2 describe-vpcs
{
"Vpcs": [
{
"CidrBlock": "10.0.0.0/16",
"DhcpOptionsId": "dopt-8f6780e6",
"State": "available",
"VpcId": "vpc-0dcd6cbf4e03d51dc",
"OwnerId": "838080890745",
"InstanceTenancy": "default",
"CidrBlockAssociationSet": [
{
"AssociationId": "vpc-cidr-assoc-0b29d742ced10ee16",
"CidrBlock": "10.0.0.0/16",
"CidrBlockState": {
"State": "associated"
}
}
],
"IsDefault": false,
"Tags": [
{
"Key": "Name",
"Value": "AZ-vpc-blog-service-account"
},
{
"Key": "kubernetes.io/cluster/K8sDemo-blog-service-account",
"Value": "shared"
}
]
},
{
"CidrBlock": "172.31.0.0/16",
"DhcpOptionsId": "dopt-8f6780e6",
"State": "available",
"VpcId": "vpc-876582ee",
"OwnerId": "838080890745",
"InstanceTenancy": "default",
"CidrBlockAssociationSet": [
{
"AssociationId": "vpc-cidr-assoc-421afd2b",
"CidrBlock": "172.31.0.0/16",
"CidrBlockState": {
"State": "associated"
}
}
],
"IsDefault": true
}
]
}</code>
<p>Le Role con i Service Account funzionano correttamente. Ma se invertissi i comandi nei due Pod?</p>
<code>$ kubectl exec testvpc -- aws s3api list-buckets
An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied
command terminated with exit code 254
$ kubectl exec tests3 -- aws ec2 describe-vpcs
An error occurred (UnauthorizedOperation) when calling the DescribeVpcs operation: You are not authorized to perform this operation.
command terminated with exit code 254</code>
<p>Mi ritengo moderatamente soddisfatto.</p>
<h1>Divagazione tecnica</h1>
<p>L'aggiunta di questo Service Account nel Pod provoca la creazione di una nuova directory e il Mount della stessa (come è visibile utilizzando il comando <em>describe</em> sul Pod). Entrando nel Pod con una Bash (non spiego come si fa, perché se si tratta questo argomento si deve conoscere almeno le basi di Kubernetes), controllo il contenuto di questa directory:</p>
<code>cd /var/run/secrets/eks.amazonaws.com/serviceaccount</code>
<p>Qui è presente un file con il nome <strong>token</strong> di cui controllo il contenuto:</p>
<code>$ cat token
eyJhbGciOiJSUzI1NiIsImtpZCI6ImE4YWQzMDAyZGFlZjlmYjEzZWNkZmRlYzljYjI0OGVkMzIwM2EyYmYifQ.eyJhdWQiOlsic3RzLmFtYXpvbmF3cy5jb20iXSwiZXhwIjoxNjU3OTA3NTg0LCJpYXQiOjE2NTc4MjExODQsImlzcyI6Imh0dHBzOi8vb2lkYy5la3MuZXUtc291dGgtMS5hbWF6b25hd3MuY29tL2lkL0Q1RDAzMkIwQzkyRTg3MzY3QThEREFFRDYxN0U3NUMwIiwia3ViZXJuZXRlcy5pbyI6eyJuYW1lc3BhY2UiOiJkZWZhdWx0IiwicG9kIjp7Im5hbWUiOiJ0ZXN0czMiLCJ1aWQiOiJmYTlmYmZmYi1kMGE0LTQxOWItYmU3OC03YWNkZDUwODA0NjMifSwic2VydmljZWFjY291bnQiOnsibmFtZSI6ImF3cy1zYS1zMyIsInVpZCI6IjVjZWM1MmQyLTMyNjItNDE5Mi1iZmEzLWM5N2FiYTI1ZGVmYSJ9fSwibmJmIjoxNjU3ODIxMTg0LCJzdWIiOiJzeXN0ZW06c2VydmljZWFjY291bnQ6ZGVmYXVsdDphd3Mtc2EtczMifQ.i-lBS-t_T4UGBE7kQjIQ536QwoToN9_j_YG0eNwKfIbzuFfs7bLUIyPTYYYObIbgkBLgBpXLnTwuDvG7x-hVufZjAJiXI1sZFfbGUnZ1cUso9Loq9hWdHleiBwRe_I385dTxJbwMUrUNadIfonLlHFsPZEb5QKfO3nYoy4vHIqW9pSPVyqvihjteQ_RxPmCfjkvMGVs-5EJH8hbDH6IyM5EmbpQAWij_W6OxViYHMMqRvoAlQpfZUagdJHbEFR5sfWeGJ2eUfc_0YmoLZRQxwMUtu9ziq8PPKR5_cwqddCuhBOgsRmhZ3DEaSBK8UbqaK8UkxrqJn-BcCCRFida_ug</code>
<p>Utilizzando il sito <a href="https://jwt.io" title="link to jwt.io">jwt.io</a> controllo il contenuto:</p>
<code>{
"aud": [
"sts.amazonaws.com"
],
"exp": 1657907584,
"iat": 1657821184,
"iss": "https://oidc.eks.eu-south-1.amazonaws.com/id/D5D032B0C92E87367A8DDAED617E75C0",
"kubernetes.io": {
"namespace": "default",
"pod": {
"name": "tests3",
"uid": "fa9fbffb-d0a4-419b-be78-7acdd5080463"
},
"serviceaccount": {
"name": "aws-sa-s3",
"uid": "5cec52d2-3262-4192-bfa3-c97aba25defa"
}
},
"nbf": 1657821184,
"sub": "system:serviceaccount:default:aws-sa-s3"
}</code>
<p>E sarà usato questo Token per le richieste API. La durata di questo Token è di ventiquattro ore. Ma ci penserà il servizio <a href="https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/" title="kubelet service link">kubelet</a> in Kubernetes a ricrearlo prima della sua scadenza.</p>
<h1>Conclusioni</h1>
<p>Ora se all'interno dei miei Pod avessi bisogno di un gestore di Queue come SNS/SQS, posso farlo. Devo accedere ai Bucket in S3 per la gestione dei file, posso farlo. Voglio accedere ai Secret in AWS dove ho salvato le credenziali in modo sicuro, posso farlo. Il cluster Kubernetes in EKS ora non è più ambiente chiuso dove tutto deve vivere al suo interno. Mi piace.</p>
<p><a href="https://github.com/sbraer/kubernetes-blog/tree/main/k8s-3" title="link codice demo">Link</a> per il codice della demo.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2921/AWS-EKS-OIDC-Accedere-Risorse-AWS-Kubernetes.aspx"><em>AWS, EKS, OIDC: accedere alla risorse di AWS da Kubernetes</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani1https://blogs.aspitalia.com/az/post2921/AWS-EKS-OIDC-Accedere-Risorse-AWS-Kubernetes.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2921.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2921AWS, EKS, gestione domini e TLS con Ingresshttps://blogs.aspitalia.com/az/post2920/AWS-EKS-Gestione-Domini-TLS-Ingress.aspx2022-07-08T18:00:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2920" border="0" style="width:1px; height:1px;" /> <p>In un <a href="https://blogs.aspitalia.com/az/post2918/Gestione-Domini-Certificati-AWS-Terraform.aspx" title="link al post precedente">post precedente</a> avevo trattato l'uso dei domini e dei certificati SSL in AWS. In un <a href="https://blogs.aspitalia.com/az/post2897/Storage-Persistente-Kubernetes-AWS.aspx" title="link al post dedicato a Ingress in AWS">post più antico</a> avevo scritto alcune informazioni riguardante l'uso di Ingress e Kubernetes. E arrivato il momento di unire il tutto?</p>
<h1>Avviare Kubernetes in AWS</h1>
<p>Per avere un cluster Kubernetes avviato in AWS ci sono tre possibili metodi:</p>
<ul><li>Aws Console dal browser</li>
<li>Da terminale con il comando <strong>eksctl</strong></li>
<li>Terraform</li></ul>
<p>Tralascio il primo metodo solo per gusto personale perché lo trovo scomodo e troppo spesso mi ha portato ad errori.</p>
<p>Con il secondo metodo, comando <strong>eksctl</strong>, si può creare un Cluster per Kubernetes in AWS con questo comando da terminale:</p>
<code>eksctl create cluster \
--name my-cluster \
--version 1.22 \
--region eu-south-1 \
--nodegroup-name linux-nodes-for-k8s \
--node-type t3.small \
--nodes 1</code>
<p>E per cancellarlo:</p>
<code>eksctl delete cluster --name my-cluster</code>
<p>Tempo medio per la creazione? (Tempo misurato con il comando <em>time</em> in Bash):</p>
<code>real 15m15.657s
user 0m0.000s
sys 0m0.000s</code>
<p>Per la cancellazione:</p>
<code>real 8m35.827s
user 0m0.015s
sys 0m0.031s</code>
<p>La comodità con questo comando è che si avrà già la configurazione attiva sul computer da cui è lanciata e si potrà iniziare a lavorare con Kubernetes immediatamente:</p>
<code>kubectl get nodes
...</code>
<h1>Terraform</h1>
<p>Ormai ho scritto parecchi post a riguardo ed ormai è il mio metodo preferito per la creazione di un cluster EKS in AWS. Anche per questo post ho creato un repository in Github con il codice sorgente, e nella directory apposita si può trovare i vari file che creeranno tutte le risorse necessarie.</p>
<p>
I passaggi, per chi già conosce Terraform sono i soliti:</p>
<code>terraform init
terraform plan
terraform apply -auto-approve</code>
<p>
Ovviamente prima di lanciare questi comandi si dev'essere certi di avere configurato l'accesso ad AWS correttamente sulla propria macchina con il comando:</p>
<code>aws configure</code>
<p>
Tempi di creazione nel mio caso:</p>
<code>real 10m49.006s
user 0m0.047s
sys 0m0.031s</code>
<p>
Per cancellare il cluster con il comando <strong>terraform destroy -auto-approve</strong>:</p>
<code>real 9m16.021s
user 0m0.000s
sys 0m0.046s</code>
<p>Come spiegato nel post precedente, per poter poi utilizzare il cluster Kubernetes creato in AWS con questo metodo si può usare questo comando:</p>
<code>aws eks --region eu-south-1 update-kubeconfig --name K8sDemo</code>
<p>Che mi permette di eseguire comandi come <em>kubectl</em>...</p>
<h1>
Domini e certificati</h1>
<p>
Qualsiasi scelta si è fatta per la creazione del cluster per Kubernetes, ora si deve controllare di avere un dominio e un certificato SSL. Le varie operazioni per come configurare questi due risorse in AWS ne ho scritto fin troppo nel post precedente, quindi non aggiungerò nulla, anche perché tutte le operazioni sono semplicissime. Per proseguire è necessario solo il nome del dominio - nel mio caso <strong>example.com</strong> - che, ricordo, è ovviamente fittizio (ne sto usando un altro sempre <em>.com</em>), che il certificato SSL abbia lo stesso nome del dominio e che sia creato nella Region dove è stato avviato il cluster di Kubernetes - nel mio caso <strong>eu-south-1</strong> (Milan).</p>
<h1>
Applicazioni web di esempio</h1>
<p>
Per questo esempio creerò due Pod in due Namespace, per semplicità <strong>a</strong> e <strong>b</strong>. Utilizzando ancora Terraform:</p>
<code>resource "kubernetes_namespace" "namespace-apache" {
metadata {
name = var.namespace_apache # <- a
}
}
resource "kubernetes_namespace" "namespace-nginx" {
metadata {
name = var.namespace_nginx # <- b
}
}</code>
<p>
Il primo contenente Nginx e la sua home page di default:</p>
<code>resource "kubernetes_deployment" "nginx-1" {
metadata {
name = "nginx-1"
namespace = kubernetes_namespace.namespace-nginx.metadata.0.name
}
spec {
replicas = 1
selector {
match_labels = {
app = "nginx-1"
}
}
template {
metadata {
labels = {
app = "nginx-1"
}
}
spec {
container {
image = "nginx:1.14.2"
name = "nginx-1"
}
}
}
}
}
resource "kubernetes_service" "nginx-1-ingress" {
metadata {
name = "nginx-1-ingress"
namespace = kubernetes_namespace.namespace-nginx.metadata.0.name
}
spec {
selector = {
app = "nginx-1"
}
port {
port = 80
}
cluster_ip = "None"
type ="ClusterIP"
}
depends_on = [kubernetes_deployment.nginx-1]
}</code>
<p>
Creo inoltre un Service di tipo <em>ClusterIp</em> che utilizzerò poi con Ingress. Queste risorse sono create all'interno del Namespace <strong>a</strong>.</p>
<p>
La seconda web application si basa su Apache. In questo caso ho personalizzato la home page perché la pagina di default - <em>It works</em> - è fin troppo banale. Semplificando il tutto ho creato il contenuto della home page in un oggetto ConfigMap:</p>
<code>locals {
html_home_page = <<EOF
<html>
<head>
<style>body {font-family:verdana;text-align:center}</style>
</head>
<body>
<h1>My Apache home page</h1>
<p>Simple example with content in config map</p>
</body>
</html>
EOF
}
resource "kubernetes_config_map" "html-apache-content" {
metadata {
name = "html-apache-content"
namespace = kubernetes_namespace.namespace-apache.metadata.0.name # <- B
}
binary_data = {
"HomePage" = base64encode(local.html_home_page)
}
}</code>
<p>
E il codice per il deploy di questa pagina con Apache:</p>
<code>resource "kubernetes_deployment" "apache-1" {
metadata {
name = "apache-1"
namespace = kubernetes_namespace.namespace-apache.metadata.0.name
}
spec {
replicas = 1
selector {
match_labels = {
app = "apache-1"
}
}
template {
metadata {
labels = {
app = "apache-1"
}
}
spec {
container {
image = "httpd:2.4.54-alpine3.16"
name = "apache-1"
volume_mount {
name = "config-volume-apache"
mount_path = "/usr/local/apache2/htdocs/"
read_only = true
}
}
volume {
name = "config-volume-apache"
config_map {
name = "html-apache-content"
items {
key = "HomePage"
path = "index.html"
}
}
}
}
}
}
depends_on = []
}
resource "kubernetes_service" "apache-1-ingress" {
metadata {
name = "apache-1-ingress"
namespace = kubernetes_namespace.namespace-apache.metadata.0.name
}
spec {
selector = {
app = "apache-1"
}
port {
port = 80
}
cluster_ip = "None"
type ="ClusterIP"
}
depends_on = [kubernetes_deployment.apache-1]
}</code>
<p>
Il tutto sarà creato nel Namespace <strong>b</strong>.</p>
<h1>
Ingress e Load Balancer in AWS</h1>
<p>
A questo <a href="https://kubernetes.github.io/ingress-nginx/" title="link a ingress per kubernetes">link</a> sono presenti informazioni e link su Ingress per Kubernetes. Tralasciando le informazioni di cui avevo già parlato in <a href="https://blogs.aspitalia.com/az/post2897/Storage-Persistente-Kubernetes-AWS.aspx" title="link post precedente per aws e ingress">passato</a>, c'è una <a href="https://kubernetes.github.io/ingress-nginx/deploy/#aws" title="k8s ingress con aws">sezione</a> di una pagina dal link precedente che spiega come installare Ingress con AWS. In modo semplice si deve solo avviare lo script per Kubernetes con una riga di comando:</p>
<code>kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.2.1/deploy/static/provider/aws/deploy.yaml</code>
<p>
Interessanti e utili le informazioni per la creazione di Ingress per la comunicazione con TLS e certificati, che è proprio quello che voglio:</p>
<p><img src="/img/andrewz/terraform6/aws_ingress_with_tls.png" title="Installare Ingress in AWS con TLS" /></p>
<p>
Volendo usare solo Terraform ora ho due soluzioni: utilizzare provider come <a href="https://registry.terraform.io/providers/gavinbunney/kubectl/latest" title="kubectl provider per terraform">kubectl</a> (non il comando) che permette di includere e installare i classici Manifest yaml di Kubernetes, oppure convertire il codice presente nel file Yaml nella versione per Terraform. Io ho scelto la seconda - più avanti farò qualche commento su questo argomento.</p>
<p>
Come spiegato nella documentazione per l'uso dei certificati TLS devo inserire l'AWS ARN del mio certificato e il CIDR della mia VPC. Ottengo queste informazioni con le Resource Data apposite:</p>
<code>data "aws_route53_zone" "myzone" {
name = var.domain_name
}
data "aws_acm_certificate" "cert" {
domain = var.domain_name
}
data "aws_eks_cluster" "k8sData" {
name = var.cluster_name
}
data "aws_vpc" "selected" {
id = data.aws_eks_cluster.k8sData.vpc_config[0].vpc_id
}</code>
<p>
Che utilizzerò nel file convertito:</p>
<code>...
data = {
allow-snippet-annotations = "true"
http-snippet = "server {\n listen 2443;\n return 308 https://$host$request_uri;\n}\n"
proxy-real-ip-cidr = data.aws_vpc.selected.cidr_block
use-forwarded-headers = "true"
}
...
annotations = {
"service.beta.kubernetes.io/aws-load-balancer-connection-idle-timeout" = "60"
"service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled" = "true"
"service.beta.kubernetes.io/aws-load-balancer-ssl-cert" = data.aws_acm_certificate.cert.arn
"service.beta.kubernetes.io/aws-load-balancer-ssl-ports" = "https"
"service.beta.kubernetes.io/aws-load-balancer-type" = "nlb"
}</code>
<p>
Se lanciassi ora la configurazione di Terraform dovrei trovare in AWS un nuovo Load Balancer con il certificato configurato correttamente:</p>
<p><a href="/img/andrewz/terraform6/load_balancer_active_ingress.png" title="Load balancer in AWS con SSL per ingress"><img src="/img/andrewz/terraform6/load_balancer_active_ingress.png" title="Load balancer in AWS con SSL per ingress" width="720" /></a></p>
<p>Notare che con il codice di quella documentazione viene creato un <a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/network/introduction.html" title="Link al Network load balancer nella documentazione di AWS">Network Load Balancer</a> e non un <a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html" title="link alla documentazione sull'Application Load Balancer in AWS">Application Load Balancer</a>. Per il risultato finale non cambia molto, ma è sempre meglio saperlo.</p>
<h1>
Ingress in Kubernetes</h1>
<p>
Accantono un attimo il Load Balancer in AWS e passo a scrivere il codice per la configurazione di Ingress:</p>
<code>resource "kubernetes_ingress_v1" "api-ingress-nginx" {
metadata {
name = "api-ingress-nginx"
annotations = {
"kubernetes.io/ingress.class" = "nginx-ingress"
"nginx.ingress.kubernetes.io/rewrite-target" = "/"
}
namespace = "default"
}
spec {
rule {
host = var.domain_name # <- example.com
http {
path {
path = "/"
backend {
service {
name = kubernetes_service.apache-service.metadata.0.name
port {
number = 80
}
}
}
}
}
}
rule {
host = "nginx.${var.domain_name}" # <- nginx.example.com
http {
path {
path = "/"
backend {
service {
name = kubernetes_service.nginx-service.metadata.0.name
port {
number = 80
}
}
}
}
}
}
}
depends_on = [
kubectl_manifest.ingressmanifest
]
}</code>
<p>
Ho creato due sezioni <em>rule</em> in <em>spec</em>, una per ogni servizio creato (apache/nginx). Nella prima <em>rule</em> ho inserito il dominio principale (<em>example.com</em>) in modo che punti al Pod dove gira Apache, mentre il dominio di terzo livello (<em>nginx</em>) punterà al Pod dove è in esecuzione Nginx e la sua pagina di default.</p>
<p>
Avendo creato le due web application in due Namespace diversi, così come i loro Service a cui devo fare riferimento, non posso inserire direttamente il loro nome nella configurazione perché Ingress può solo
vedere Service nel suo stesso Namespace. Un passaggio aggiuntivo è necessario. Nel Namespace Default creo altre due Service di tipo <a href="https://kubernetes.io/docs/concepts/services-networking/service/#externalname" title="ExternalType Service in Kubernetes">ExternalName</a>, nel quale creo un redirect al Service ClusterIp all'interno dei Namespace:</p>
<code>resource "kubernetes_service" "nginx-service" {
metadata {
name = "nginx-service"
}
spec {
type = "ExternalName"
external_name = "${kubernetes_service.nginx-1-ingress.metadata.0.name}.${kubernetes_namespace.namespace-nginx.metadata.0.name}.svc.cluster.local"
}
}
...
resource "kubernetes_service" "apache-service" {
metadata {
name = "apache-service"
}
spec {
type = "ExternalName"
external_name = "${kubernetes_service.apache-1-ingress.metadata.0.name}.${kubernetes_namespace.namespace-apache.metadata.0.name}.svc.cluster.local"
}
}</code>
<p>
In <em>external</em> ho inserito il dominio completo di Namespace:</p>
<code>{name service}.{namespace}.svc.cluster.localster.local</code>
<p>
In questo modo in Ingress posso puntare al nome di questo Service superando il limite prima descritto.</p>
<p>
Piccola parentesi su questo oggetto: l'uso dei Service di tipo Externalname è utile anche nel caso si volesse creare un Endpoint esterno da chiamare da tutti i Pod all'interno del Cluster in modo che sia facilmente modificabile senza dover mettere mano a tutte le configurazioni degli oggetti creati. Esempio:</p>
<code>apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: prod
spec:
type: ExternalName
externalName: www.google.com</code>
<p>Ora da qualsiasi Pod potrei richiamare questo comando per ottenere come risultato il contenuto della pagina di Google:</p>
<code>curl my-servicemy-service</code>
<p>
In futuro si potrebbe modificare il nome del dominio finale in <strong>www.altromotorediricerca.it</strong> in questo service, e si avrà la sicurezza che tutti i Pod automaticamente chiameranno il nuovo dominio quando utilizzeranno <em>my-service</em>. Chiudo la parentesi.</p>
<p>Prima di configurare i Domini in AWS con Terraform, ecco in questo grafico com'è configurato il tutto: </p>
<p><img src="/img/andrewz/terraform6/ingress_aws_eks_k8s.png" title="AWS EKS con Ingress" /></p>
<p>Il Service di tipo Load Balancer in Kubernetes è in grado di creare autonomamente un Load Balancer in AWS, da qui potrebbe nascere la domanda del perché complicarsi la vita con l'utilizzo di Ingress. La risposta è semplice è intuibile: per motivi economici. Ogni oggetto di tipo Load Balancer in AWS ha un <a href="https://aws.amazon.com/elasticloadbalancing/pricing/" title="Link alla pagina di AWS per i prezzi del Load Balancer">costo</a> anche per la sola attivazione: supponendo di avere decine di Service da esporre in Internet si può immagine non solo il costo, ma l'inutilità di avere un oggetto di questo tipo per ogni Service interno in Kubernetes. Con Ingress si risolve questo problema visto che tutte le richieste passeranno per un unico Load Balancer e, grazie ad un unico file di configurazione, si potrà avere l'esatta configurazione di tutti i Service in modo chiaro in un'unica risorsa.</p>
<h1>
Gestione dei domini</h1>
<p>
Niente di nuovo da quello che avevo già fatto nel post precedente. L'unica variante è che non utilizzerò oggetti creati in CloudFront ma il Load Balancer prima creato. Ora ho bisogno delle informazioni da questo oggetto. Anche in questo caso Terraform mi viene in aiuto:</p>
<code>data "aws_lb" "ingress-load-balancer" {
tags = {
"kubernetes.io/cluster/${var.cluster_name}" = "owned"
}
depends_on = [
kubectl_manifest.ingressmanifest
]
}</code>
<p>
Ora posso completare l'ultimo passo:</p>
<code>resource "aws_route53_record" "www-apache" {
zone_id = data.aws_route53_zone.myzone.zone_id
name = var.domain_name # <- example.com
type = "A"
alias {
name = data.aws_lb.ingress-load-balancer.dns_name
zone_id = data.aws_lb.ingress-load-balancer.zone_id
evaluate_target_health = false
}
}
resource "aws_route53_record" "www-nginx" {
zone_id = data.aws_route53_zone.myzone.zone_id
name = "nginx.${var.domain_name}" # <- nginx.example.com
type = "A"
alias {
name = data.aws_lb.ingress-load-balancer.dns_name
zone_id = data.aws_lb.ingress-load-balancer.zone_id
evaluate_target_health = false
}
}</code>
<p>
Ora non mi rimane che controllare che le pagine siano raggiungibili. Richiamo l'Url:</p>
<code>https://example.com</code>
<p>
E il browser:</p>
<p><img src="/img/andrewz/terraform6/apache-example.png" title="Apache, ingress e Kubernetes" /></p>
<p>
Ora controllo la pagina generata da Nginx:</p>
<p><img src="/img/andrewz/terraform6/nginx-example.png" title="Nginx, ingress e Kubernetes" /></p>
<p>
Entrambe le pagine sono protette in https. Ok, funziona.</p>
<h1>
Convertire il codice Yaml di Kubernetes in Terraform</h1>
<p>Alcuni suggerimenti dati dalla mia esperienza per la conversione dei file in Yaml al formato Terraform. Personalmente se la conversione riguarda pochi file la faccio a mano facendomi aiutare dall'Intellisense di VSCode.</p>
<p>
Per il file di Ingress preso in considerazione sopra, in cui sono presenti molte Resource e oltre cinquecento righe di codice, ho fatto affidamento per la prima conversione al tool <a href="https://github.com/sl1pm4t/k2tf" title="link to k2tf">k2tf</a>. C'è subito da dire che non è perfetto come Tool, perché spesso non converte certe configurazioni dimenticando alcune sezioni senza dare warning o errori a riguardo. Consiglio si deve fare una revisione completa del codice prodotto per essere certi che la conversione sia corretta.</p>
<p>
Il suo utilizzo è semplice e può gestire qualsiasi file Yaml per Kubernetes, ma può essere usato anche con le risorse già presenti nel Cluster. Per esempio, con questo comando prendo un Service esistente:</p>
<code>kubectl get svc/kube-dns -n kube-system -o yaml</code>
<p>
Avrò questo output:</p>
<code>apiVersion: v1
kind: Service
metadata:
annotations:
prometheus.io/port: "9153"
prometheus.io/scrape: "true"
creationTimestamp: "2022-05-11T19:15:39Z"
labels:
k8s-app: kube-dns
kubernetes.io/cluster-service: "true"
kubernetes.io/name: CoreDNS
name: kube-dns
namespace: kube-system
resourceVersion: "275"
uid: d6c08f56-6016-4101-814f-7e8210d4dfee
spec:
clusterIP: 10.96.0.10
clusterIPs:
- 10.96.0.10
internalTrafficPolicy: Cluster
ipFamilies:
- IPv4
ipFamilyPolicy: SingleStack
ports:
- name: dns
port: 53
protocol: UDP
targetPort: 53
- name: dns-tcp
port: 53
protocol: TCP
targetPort: 53
- name: metrics
port: 9153
protocol: TCP
targetPort: 9153
selector:
k8s-app: kube-dns
sessionAffinity: None
type: ClusterIP
status:
loadBalancer: {}</code>
<p>
Salvato in un file lo posso convertire per Terraform:</p>
<code>k2tf -f input.yaml -o output.tf</code>
<p>
Con questo risultato:</p>
<code>resource "kubernetes_service" "kube_dns" {
metadata {
name = "kube-dns"
namespace = "kube-system"
labels = {
k8s-app = "kube-dns"
"kubernetes.io/cluster-service" = "true"
"kubernetes.io/name" = "CoreDNS"
}
annotations = {
"prometheus.io/port" = "9153"
"prometheus.io/scrape" = "true"
}
}
spec {
port {
name = "dns"
protocol = "UDP"
port = 53
target_port = "53"
}
port {
name = "dns-tcp"
protocol = "TCP"
port = 53
target_port = "53"
}
port {
name = "metrics"
protocol = "TCP"
port = 9153
target_port = "9153"
}
selector = {
k8s-app = "kube-dns"
}
cluster_ip = "10.96.0.10"
cluster_i_ps = ["10.96.0.10"]
type = "ClusterIP"
session_affinity = "None"
ip_families = ["IPv4"]
}
}</code>
<p>E' già presente un errore se si guarda bene il codice: <strong>cluster_i_ps</strong> invece di <strong>cluster_ips</strong>.</p>
<p>
Consiglio di non prendere troppo alla leggera la conversione di configurazioni già esistenti da Yaml nel formato di Terraform perché si potrebbero trovare anomalie. Faccio un esempio: ecco un semplice file Yaml che crea un nuovo Namespace e al suo interno crea un Pod:</p>
<code>kind: Namespace
apiVersion: v1
metadata:
name: test
labels:
name: test
---
apiVersion: v1
kind: Pod
metadata:
name: mywebapp1
namespace: test
labels:
role: webserver-role
app: nginx
spec:
containers:
- name: webserver1
image: nginx:1.2</code>
<p>
<em>kubectl</em> da questo file creerà le risorse sequenzialmente: innanzitutto il Namespace, quindi al suo interno il Pod con Nginx. Ipotizzando lo stesso file in Terraform:</p>
<code>provider "kubernetes" {
config_path = "~/.kube/config"
}
resource "kubernetes_namespace" "test" {
metadata {
name = "test"
labels = {
name = "test"
}
}
}
resource "kubernetes_pod" "mywebapp_1" {
metadata {
name = "mywebapp1"
namespace = "test"
labels = {
app = "nginx"
role = "webserver-role"
}
}
spec {
container {
name = "webserver1"
image = "nginx:1.23.0"
}
}
}
</code>
<p>
Le due risorse saranno create in parallelo ma potrebbe succedere che il Pod venga creato in un Namespace che ancora non esiste con conseguente errore. Questo è solo un esempio perché la creazione del Namespace è molto veloce, ma per altre risorse potrebbe comportare errori e problemi casuali - lo dico perché ho avuto esperienza in merito. In questo caso è sempre meglio intervenire preventivamente con le dipendenze implicite o esplicite come spiegato nel <a href="https://blogs.aspitalia.com/az/post2912/Terraform-Kubernetes.aspx" title="link al primo post dedicato a Terraform">primo post</a> dedicato a Terraform. Ecco il codice qui sopra
scritto in modo più sicuro:</p>
<code>resource "kubernetes_pod" "mywebapp_1" {
metadata {
name = "mywebapp1"
namespace = kubernetes_namespace.test.metadata[0].name
labels = {
app = "nginx"
role = "webserver-role"
}
}
}</code>
<p>
Con questa dipendenza implicita il Pod sarà sempre gestito <strong>dopo</strong> la creazione del Namespace. La differenza è visibile anche dal log nella console di Terraform. Nel primo caso:</p>
<code>kubernetes_namespace.test: Creating...
kubernetes_pod.mywebapp_1: Creating...
kubernetes_namespace.test: Creation complete after 0s [id=test]
kubernetes_pod.mywebapp_1: Creation complete after 3s [id=test/mywebapp1]</code>
<p>
I due oggetti sono creati in parallelo. Con la dipendenza implicita invece la creazione è sequenziale:</p>
<code>kubernetes_namespace.test: Creating...
kubernetes_namespace.test: Creation complete after 0s [id=test]
kubernetes_pod.mywebapp_1: Creating...
kubernetes_pod.mywebapp_1: Creation complete after 3s [id=test/mywebapp1]</code>
<p>
Se si controlla il mio file convertito in Terraform incluso nella demo, ho dovuto fare moltissime modifiche a riguardo con l'aggiunta delle dipendenze in modo che tutte le Resource coinvolte venissero create nell'ordine corretto. Ma ne valeva la pena?</p>
<h1>
Conclusioni</h1>
<p>
Quello mostrato in questo post è la modalità semplificata per la configurazione di Ingress in AWS. C'è una procedura più complessa e personalizzabile, ma personalmente non padroneggiandola mi astengo nel divulgarla.</p>
<p>
Non era lo scopo di questo post, ma avrei potuto inoltre aggiungere CloudFront come layer aggiuntivo tra il dominio e il LoadBalancer in modo da poter distribuire velocemente, grazie alla CDN di AWS, il contenuto statico di queste due pagine. Utilità? Nessuna per questa demo.</p>
<p>
Non rimane che postare il <a href="https://github.com/sbraer/kubernetes-blog/tree/master/k8s-2" title="Link codice demo">link</a> dove trovare il codice qui utilizzato.</p>
<p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2920/AWS-EKS-Gestione-Domini-TLS-Ingress.aspx"><em>AWS, EKS, gestione domini e TLS con Ingress</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2920/AWS-EKS-Gestione-Domini-TLS-Ingress.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2920.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2920Three-way comparison operator in C++. E in C#?https://blogs.aspitalia.com/az/post2919/Threeway-Comparison-Operator-C.-CSharp.aspx2022-07-02T07:59:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2919" border="0" style="width:1px; height:1px;" /> <p>Capita (dal verbo <em>capitare</em>, non da <em>capire</em>), non è una regola fissa ma capita. In C# ho la mia bella classe e devo fare un <em>Compare</em>. Se uso l'operatore di uguaglianza (tralasciando <a href="https://docs.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/records" title="link to c# record">Record</a> che è possibile <em>as it is</em>), è facilmente implementabile anche con una <em>class</em> o <em>struct</em> facendo l'override due due operatori di uguaglianza (<em>==</em>) e diseguaglianza (<em>!=</em>):</p>
<code>public class Test1 //: IEquatable<Test1>
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public override int GetHashCode()
{
return Id.ToString().GetHashCode() ^ Name.GetHashCode();
}
public override bool Equals(object? obj)
{
if (obj is null)
{
return false;
}
if (Object.ReferenceEquals(this, obj))
{
return true;
}
if (obj is Test1 temp)
{
return temp.Id == Id && temp.Name == Name;
}
return false;
}
public int CompareTo(Test1? other)
{
if (other is null)
{
return 1;
}
if (other.Id != Id)
{
return other.Id.CompareTo(Id); // <- inverted
}
return Name.CompareTo(other.Name);
}
public static bool operator ==(Test1 e1, Test1 e2)
{
return e1.CompareTo(e2) == 0;
}
public static bool operator !=(Test1 e1, Test1 e2)
{
return e1.CompareTo(e2) != 0;
}
}</code>
<p>In questo caso la classe ha due property - <em>Id</em> un intero, e <em>Name</em> una stringa - che utilizzo per il <em>Compare</em>. Senza l'uso dell'override di questi operatori una comparazione di uguaglianza di due oggetti è fatta sulla reference all'oggetto e non sul suo contenuto.</p>
<p>Andando nel caso più particolare può nascere la necessità di fare il <em>Compare</em> tra due oggetti dello stesso tipo anche con altri operatori di confronto come "minore di" (<em><</em>), "minore o uguale a" (<em><=</em>), "maggiore di" (<em>></em>) e "maggiore o uguale a" (<em>>=</em>). Nel caso della mia semplice classe voglio che la property Id (numerica) abbia un ordinamento inverso, in modo che un oggetto che Id uguale a uno sia maggiore di una classe con Id uguale a due, tre, quattro... L'ordinamento di Name che è stringa deve seguire il classico ordinamento alfabetico.</p>
<p>Aggiungo quindi l'override di questi operatori:</p>
<code>public static bool operator <(Test1 e1, Test1 e2)
{
return e1.CompareTo(e2) < 0;
}
public static bool operator <=(Test1 e1, Test1 e2)
{
return e1.CompareTo(e2) <= 0;
}
public static bool operator >(Test1 e1, Test1 e2)
{
return e1.CompareTo(e2) > 0;
}
public static bool operator >=(Test1 e1, Test1 e2)
{
return e1.CompareTo(e2) >= 0;
}</code>
<p>Ora posso scrivere da codice:</p>
<code>var t21 = new Test1 { Id = 1, Name = "a" };
var t22 = new Test1 { Id = 1, Name = "a" };
var t23 = new Test1 { Id = 2, Name = "b" };
var t24 = new Test1 { Id = 2, Name = "a" };
Console.WriteLine($"t21 == t22 = {t21 == t22}");
Console.WriteLine($"t22 > t23 = {t22 > t23}");
Console.WriteLine($"t23 > t24 = {t23 > t24}");</code>
<p>
Che darà il risultato:</p>
<code>t21 == t22 = True
t22 > t23 = True
t23 > t24 = True</code>
<p>
Piccola critica: anche se non utilizzati, è necessario scrivere sempre tutti gli operatori. Se si scrive il primo operatore di uguaglianza (==) è necessario scrivere anche quello di diseguaglianza (!==) previo errore di compilatore. Così come scritto l'operatore "minore di" (<) è poi obbligatorio scrivere il suo inverso...</p>
<h1>Excursus in C++</h1>
<p>
Ecco la stessa classe Test1 scritta in C++. All'inizio solo con gli operatori di uguaglianza:</p>
<code>class Test1 final {
int Id;
std::string Name;
public:
Test1(const int& id, const std::string& name)
: Id{ id }, Name{ name } {}
bool operator==(const Test1& other) const {
return Id == other.Id && Name == other.Name;
}
};</code>
<p>
Almeno in C++ (dalla versione 20) capisce che non è necessario scrivere l'operatore di diseguaglianza ma sarà possibile utilizzare lo stesso da codice:</p>
<code>Test1 t1{1, "B"};
Test1 t2{2, "A"};
if (t1 != t2) {
std::cout << "t1 != t2\n";
}</code>
<p>
Per fare il <em>Compare</em> con gli altri operatori devo scrivere tutti gli override come in C#:</p>
<code>bool operator<(const Test1& other) const {
if (Id != other.Id) return Id < other.Id;
return Name < other.Name;
}
bool operator<=(const Test1& other) const{
if (Id != other.Id) return Id <= other.Id;
return Name <= other.Name;
}
bool operator>(const Test1& other) const{
return !operator<=(other);
}
bool operator>=(const Test1& other) const{
return !operator<(other);
}</code>
<p>
E potrò fare come sopra:</p>
<code>Test1 t1{1, "B"};
Test1 t2{2, "A"};
if (t1 < t2) {
std::cout << "t1 < t2\n";
}</code>
<p>
Dunque?</p>
<h1>
Three-way comparison, spaceship operator</h1>
<p>
Ricordo quando presentarono la versione 20 del linguaggio C++ che tra le novità la presenza di questo operatore: <strong><=></strong></p>
<p><a href="https://en.cppreference.com/w/cpp/language/operator_comparison" title="operator_comparison on cppreference"><img src="/img/andrewz/cplusplus1/threewaycomparison.png" title="Three way comparison" /></a></p>
<p>
La prima reazione fu: <em>Ma che fa? Ritorna true se <strong>a</strong> è minore, uguale e maggiore di <strong>b</strong>?</em></p>
<p>
Ovviamente no. In verità diventa utile anche per l'override degli operatori che ho mostrato prima. Innanzitutto un po' di teoria: il confronto tra due variabili non ritorna un valore booleano come gli operatori che tutti conoscono, ma tre possibili oggetti:</p>
<ul><li><strong>std::strong_ordering</strong> che può ritornare i valori: <em>less</em>, <em>equal</em>, <em>equivalent</em> e <em>greater</em>.</li>
<li><strong>std::weak_ordering</strong> che può ritornare i valori: <em>less</em>, <em>equal</em> e <em>greater</em>.</li>
<li><strong>std::partial_ordering</strong> che può ritornare i valori: <em>less</em>, <em>equivalent</em>, <em>greater</em>, <em>unordered</em>.</li>
</ul>
<p>
Due parole su <em>equal</em> e <em>equivalent</em>. In linea teorica sono la stessa cosa ma ci possono essere casistiche in cui non lo sono. Per esempio, come da documentazione, due stringhe contenenti lo stesso valore sono <em>equal</em> (contengono lo stesso valore e sono intercambiabili). Due oggetti si dicono <em>equivalent</em> quando non sono intercambiabili. Come da documentazione, due utenti con le stesse Role di autenticazione per l'accesso sono sì per il sistema equivalenti (hanno le stesse autorizzazioni) ma non sono uguali e intercambiabili.</p>
<p>
La scelta dell'uso di <em>strong_ordering</em> o <em>weak_ordering</em> si basa solo su questa differenza, mentre l'uso del <em>partial_ordering</em> è da utilizzare nelle routine di comparazione per l'ordinamento di oggetti (tra i valori restituiti è presente anche <em>unordered</em> per avvisare che i due oggetti non sono confrontabili).</p>
<p>
Parlando del mero codice si può scrivere:</p>
<code>if (a <=> b == std::strong_ordering::equal) { /* equal */}
if (a <=> b == std::strong_ordering::greater) { /* greater */}
...</code>
<p>
Oppure più semplicemente:</p>
<code>if (a <=> b == 0) { /* equal */}
if (a <=> b == 1) { /* greater */}
...</code>
<p>
Possibile perché se si controlla il codice di quegli oggetti si scopre che sono degli enum:</p>
<code>enum class _Compare_eq : _Compare_t { equal = 0, equivalent = equal };
enum class _Compare_ord : _Compare_t { less = -1, greater = 1 };
enum class _Compare_ncmp : _Compare_t { unordered = -128 };</code>
<p>
L'utilizzo diretto del tipo di oggetto restituito dal Three-way operator è utile quando viene usato come parametro in funzioni. Si può scrivere:</p>
<code>auto cmp = a <=> b;
ShowResult(cmp);
...
void ShowResult(std::strong_ordering result) {
if (result == 0) std::cout << "Equal\n";
if (result == -1) std::cout << "Less\n";
if (result == 1) std::cout << "Greater\n";
}</code>
<p>
Quando, con gli operatori standard, si ha solo come risultato un valore booleano. Arrivati a questo punto posso riscrivere la versione in C++ nel seguente modo molto più semplice:</p>
<code>#include <iostream>
class Test1 final {
friend std::ostream& operator<<(std::ostream& os, const Test1& p);
public:
int Id;
std::string Name;
Test1(const int& id, const std::string& name)
: Id{ id }, Name{ name } {}
bool operator==(const Test1& other) const {
return Id == other.Id && Name == other.Name;
}
std::strong_ordering operator<=>(const Test1& other) const {
if (auto cmp = other.Id <=> Id; cmp != 0) return cmp;
if (auto cmp = Name <=> other.Name; cmp != 0) return cmp;
return std::strong_ordering::equal;
}
};
std::ostream& operator<<(std::ostream& os, const Test1& dt) {
os << "{ _id='" << dt.Id << "', _name='" << dt.Name << "' }";
return os;
}
int main() {
Test1 t21{ 1, "A" };
Test1 t22{ 1, "A" };
Test1 t23{ 1, "B" };
std::cout << "t21 = " << t21 << "\n";
std::cout << "t22 = " << t22 << "\n";
std::cout << "t23 = " << t23 << "\n";
std::cout << "t21 == t22 = " << (t21 == t22) << "\n";
std::cout << "t21 != t22 = " << (t21 != t22) << "\n";
std::cout << "t21 < t22 = " << (t21 < t22) << "\n";
std::cout << "t21 <= t22 = " << (t21 <= t22) << "\n";
std::cout << "t21 > t22 = " << (t21 > t22) << "\n";
std::cout << "t21 >= t22 = " << (t21 >= t22) << "\n";
std::cout << "t21 >= t22 = " << (t21 >= t22) << "\n";
std::cout << "t21 > t23 = " << (t21 > t23) << "\n";
}</code>
<p>
E avere questo output:</p>
<code>t21 = { _id='1', _name='A' }
t22 = { _id='1', _name='A' }
t23 = { _id='1', _name='B' }
t21 == t22 = 1
t21 != t22 = 0
t21 < t22 = 0
t21 <= t22 = 1
t21 > t22 = 0
t21 >= t22 = 1
t21 >= t22 = 1
t21 > t23 = 0</code>
<p>
Il Three-way comparison NON esclude l'uso dell'<em>equal</em> operator che dev'essere sempre aggiunto come nel codice qui sopra:</p>
<code>bool operator==(const Test1& other) const {
return Id == other.Id && Name == other.Name;
}</code>
<p>
Che è inutile visto che in automatico questo linguaggio può creare il <em>Compare</em> tra tutti gli oggetti al suo interno. Se avessi scritto:</p>
<code>bool operator==(const Test1& other) const = default;</code>
<p>
Il tutto avrebbe funzionato correttamente. Anche con il Three-way comparison è possibile fare la stessa cosa:</p>
<code>auto operator<=>(const Test1& other) const = default;</code>
<p>
Ma nel mio caso mi avrebbe dato un risultato errato perché l'ordinamento di <strong>Id</strong> io lo volevo inverso. Se non avessi avuto necessità particolari avrei potuto scrivere:</p>
<code>#include <iostream>
struct S {
int X;
int Y;
bool operator==(const S& other) const = default;
auto operator<=>(const S& other) const = default;
};
int main() {
S s1{1,2};
S s2{1,3};
S s3{2,0};
S s4{1,2};
std::cout << "s1 < s2 = " << (s1 < s2) << "\n";
std::cout << "s1 < s3 = " << (s1 < s3) << "\n";
std::cout << "s3 > s2 = " << (s3 > s2) << "\n";
std::cout << "s1 == s4 = " << (s1 == s4) << "\n";
}</code>
<p>
E avere questo output:</p>
<code>s1 < s2 = 1
s1 < s3 = 1
s3 > s2 = 1
s1 == s4 = 1</code>
<h1>
E il Three-way comparison in C#?</h1>
<p>
La risposta è semplice: non c'è. Peccato, è presente anche in <a href="https://www.wikiwand.com/en/Three-way_comparison#/Spaceship_operator" title="link a wikipedia three ways comparison">altri linguaggi</a> ma in C# purtroppo non c'è. Spero in una prossima versione di C# che gli ingegneri Microsoft prendano in considerazione questo operatore che, in coppia con Record, permetterebbe di creare in modo veloce tutti gli operatori di confronto.</p>
<p>
E visto che sono in modalità richiesta, perché non aggiungere anche l'<a href="https://www.tutorialspoint.com/cplusplus17-if-statement-with-initializer" title="link a l'if statement initializer">if statement initializer</a> come è stato introdotto in C++ dalla versione 17? Come mostrato sopra nell'esempio ma esteso:</p>
<code>if (auto cmp = other.Id <=> Id; cmp != 0) {
return cmp;
}
else {
std::cout << (cmp == 0) << "\n";
return cmp;
}
</code>
<p>
<strong>cmp</strong> è un variabile creata dentro lo scope dell'<strong>if</strong> e sarà visibile sia nel blocco che sarà eseguito sia se la condizione sia vera, sia nel blocco dove la condizione è falsa. Quindi <strong>cmp</strong> sarà distrutta all'uscita della condizione. Pure questa aggiunta sarebbe comoda, no?</p>
<p>
Ok, non ho speranze.</p>
<p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2919/Threeway-Comparison-Operator-C.-CSharp.aspx"><em>Three-way comparison operator in C++. E in C#?</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani1https://blogs.aspitalia.com/az/post2919/Threeway-Comparison-Operator-C.-CSharp.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2919.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2919Gestione dei domini e certificati in AWS con Terraformhttps://blogs.aspitalia.com/az/post2918/Gestione-Domini-Certificati-AWS-Terraform.aspx2022-06-25T07:54:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2918" border="0" style="width:1px; height:1px;" /> <p>A riguardo questo <a href="https://blogs.aspitalia.com/az/post2915/Terraform-Vue.js-Lambda-Net6-Aws-CloudFront.aspx" title="link precedente">post</a> in cui, con Terraform, ho pubblicato una web application in Vue.js e una Lambda in Net6 in AWS, una persona - un amico - mi ha contestato che mi sono fermato sul più bello, e che il post è incompleto, perché non avevo spiegato come gestire eventualmente un dominio collegandolo automaticamente alle risorse web. Lo avevo scritto in quel post che non ero andato oltre perché non avevo sottomano un dominio utilizzabile per quel test. Ora l'ho, quindi è ora di completare quel post.</p>
<h1>Passo numero 1: di cosa si ha bisogno</h1>
<p>Per eseguire i passi di questo posto è necessario:</p>
<ul>
<li>Account AWS</li>
<li>Dominio</li>
<li>Terraform cli installato</li>
<li>NPM</li>
<li>Dotnet versione 6</li>
<li>AWS .NET Core CLI</li>
</ul>
<p>
Rimando a quel post dove avevo spiegato dove e come procurarsi i vari tool (per l'account di AWS e del dominio ci si deve arrangiare).
</p>
<h1>Passo numero 2: collegare un dominio</h1>
<p>Sono due alternative: utilizzare un dominio acquistato da altri siti oppure acquistarlo direttamente su <a href="https://aws.amazon.com/getting-started/hands-on/get-a-domain/" title="link esterno">AWS</a>. Nel mio caso utilizzerò un dominio acquistato su un altro servizio, l'importante è che sia configurabile (modifica dei DNS name). Non serve altro. Per tutto questo blog io farò riferimento al dominio <strong>example.com</strong> che ovviamente <strong>non</strong> è quello che ho usato per i test che farò da qui in avanti. L'utilizzo da AWS del dominio comporta questi semplici passaggi: nella console di AWS si entra nella pagina principale del servizio <a href="https://aws.amazon.com/route53/" title="link to aws route 53">Route 53</a> a questo link:</p>
<p><a href="https://us-east-1.console.aws.amazon.com/route53/v2/hostedzones" title="link to aws route 53 -> hosted zone">https://us-east-1.console.aws.amazon.com/route53/v2/hostedzones</a></p>
<p>Quindi cliccando su Create hosted zone si potrà inserire il nome del proprio dominio:</p>
<p>
<a href="/img/andrewz/terraform5/aws_hosted_zone.png" title="AWS hosted zone"><img src="/img/andrewz/terraform5/aws_hosted_zone.png" title="AWS hosted zone" width="720" /></a>
</p>
<p>Selezionato come tipo <strong>Public hosted zone</strong>. Accettando viene creata la nuova <em>Hosted zone</em> per il dominio:</p>
<p>
<a href="/img/andrewz/terraform5/aws_hosted_zone_dns.png" title="Aws hosted zone with dns"><img src="/img/andrewz/terraform5/aws_hosted_zone_dns.png" title="Aws hosted zone with dns" width="720" /></a>
</p>
<p>Ora è necessario configurare il dominio esterno in modo che punti ai server di AWS. E' sufficiente prendere i <em>value</em> della prima riga e inserire quei DNS nel pannello di configurazione che teoricamente il gestore del dominio mette a disposizione, per esempio:</p>
<p>
<img src="/img/andrewz/terraform5/domain_custom_dns.png" title="Domain custom dns" />
</p>
<p>Fine. Ora il dominio potrà essere gestito da AWS.</p>
<h1>Passo numero 3: SSL</h1>
<p>Facendo le cose per bene voglio che le risorse web che metterò online siano protette da un certificato SSL su protocollo TLS. Anche in questo caso AWS ha un pannello di configurazione apposito - <a href="https://aws.amazon.com/certificate-manager/" title="AWS Certificate manager link">AWS Certificate Manager</a> - che permette di fare il tutto con pochi click del mouse. La sezione è disponibile a questa pagina:</p>
<p><a href="https://us-east-1.console.aws.amazon.com/acm/home?region=us-east-1#/certificates/list" title="link ad AWS certificate manager list">https://us-east-1.console.aws.amazon.com/acm/home?region=us-east-1#/certificates/list</a></p>
<p>Per lo scopo di questo post è necessario creare il certificato nella Region <strong>us-east-1</strong> (<em>Virginia</em>) perché dovendolo utilizzare con CloudFront solo da questa Region i cartificati sono accettati e visibili - non ho mai capito perché ma non si può fare nulla.</p>
<p>Da qui è sufficiente cliccare su <em>Request</em>. Nel passo successivo selezionare <strong>Request a public certificate</strong>, e proseguendo si avrà questa schermata:</p>
<p>
<a href="/img/andrewz/terraform5/aws_request_public_certificate.png" title="Request certificate in AWS"><img src="/img/andrewz/terraform5/aws_request_public_certificate.png" title="Request certificate in AWS" width="720" /></a>
</p>
<p>Come Domain Name inserire sia entrambe le voci, <strong>example.com</strong> e <strong>*.example.com</strong> per permettere l'uso anche dei sotto domini. Lasciando come validazione la voce <strong>DNS validation</strong>, confermare il tutto - è quasi finita. Ora per validare il dominio sarà sufficiente aggiungere dei nuovi record di tipo <em>CNAME</em> nel dominio prima creato. Tornati alla sezione della lista dei certificati si vedrà che la richiesta è in <strong>Pending valitation</strong>. Cliccato sul <em>Certificate Id</em> appena creato nella schermata apparirà il record da inserire ma anche il pulsante <strong>Create records in Route 53</strong> che esegue l'operazione senza dover copiare/incollare a mano i codici:</p>
<p>
<a href="/img/andrewz/terraform5/dns_validation.png" title="DNS validation per il certificato"><img src="/img/andrewz/terraform5/dns_validation.png" title="DNS validation per il certificato" width="720" /></a>
</p>
<p>Ora rimane che aspettare qualche minuto per tornare nella lista dei certificati e vedere la colonna prima in <strong>Pengind Validation</strong> diventare <strong>Issued</strong>.</p>
<h1>Passo numero 4: impostare manualmente il tutto</h1>
<p>E' il momento di riprendere il <a href="https://github.com/sbraer/terraform_cloudfront_net6_blog" title="link github progetto precedente">progetto precedente</a> e rieseguire tutte le operazioni presenti anche nell'<a href="https://github.com/sbraer/terraform_cloudfront_net6_blog/blob/main/README.md" title="MD file progetto precedente">MD file</a>. Se si è fatto tutto correttamente alla fine verrà mostrato un link come questo:</p>
<code>https://d3lokx2nkx16nd.cloudfront.net/</code>
<p>Che utilizzato nel browser visualizzerà la web application in Vue.js. Ora voglio poter richiamare la stessa pagina ma con il mio dominio <strong>example.com</strong>. Il primo passo è andare in CloudFront ed eseguire una modifica nella configurazione:</p>
<p><a href="/img/andrewz/terraform5/cloudfront_certificate_domain.png" title="Certificato e dominio in AWS CloudFront"><img src="/img/andrewz/terraform5/cloudfront_certificate_domain.png" title="Certificato e dominio in AWS CloudFront" width="720" /></a></p>
<p>Ho aggiunto un <strong>Alternate domain name (CNAME)</strong> quindi in <strong>Custom SSL Certificate</strong> ho selezionato dal dropdownlist il certificato creato precedentemente.</p>
<p>Secondo passo: dalla pagina di <em>Route 53</em> in AWS è sufficiente entrare nella hosted zone creata nei passi precedenti e aggiungere un nuovo record:</p>
<p><a href="/img/andrewz/terraform5/newrecord_inroute53.png" title="New record in Route 53"><img src="/img/andrewz/terraform5/newrecord_inroute53.png" title="New record in Route 53" width="720" /></a></p>
<p>In record name lascio vuoto, come l'immagine, quindi selezionato il record type A, seleziono l'Alias che permetterà di selezionare dal dropdownlist l'<strong>Alias to CloudFront Distribution</strong>. Qui si può vedere perché è stato creato il certificato precedentemente in Virginia, quindi nella Textbox sottostante ho inserito l'Id della distribuzione in CloudFront della web application. Cliccato su <strong>Create Record</strong> posso provare che tutto funzioni dal browser richiamando il link:</p>
<code>https://example.com</code>
<p><img src="/img/andrewz/terraform5/httpsdomain.png" title="Vue.js in browser con dominio e SSL" /></p>
<p>Ho ovviamente nascosto il dominio originale, in ogni caso la pagina risulta protetta in HTTPS e il certificato risulta valido. Ma è giunto il momento di fare queste ultime operazione con Terraform.</p>
<h1>Passo numero 5: Terraform</h1>
<p>I passi per l'aggiunta del dominio e la creazione del certificato mostrati nei passi due e tre li ho fatti a mano e non con Terraform perché è un'operazione che si fa una sola volta e non si tocca più nulla. Altra storia configurare le risorse create perché utilizzino il dominio e il certificato. Innanzitutto dovrò fare in modo che Terraform possa accedervi. L'argomento lo avevo già trattato in uno dei post precedenti, ed è semplice. Per esempio, per avere le informazioni del dominio posso scrivere questo script in Terraform:</p>
<code>data "aws_route53_zone" "myzone" {
name = "example.com"
}
output "route53-1" {
description = "Route 53"
value = data.aws_route53_zone.myzone
}</code>
<p>Per avere queste informazioni in output:</p>
<code>route53-1 = {
"arn" = "arn:aws:route53:::hostedzone/Z0780463214RLPAW5X5JH"
"caller_reference" = "063fe79c-5bb5-49b3-bf42-9d9c630e4e59"
"comment" = "test with my domain"
"id" = "Z0953413214RLPAW5X5ZZ"
"linked_service_description" = tostring(null)
"linked_service_principal" = tostring(null)
"name" = "example.com"
"name_servers" = tolist([
"ns-1355.awsdns-41.org",
"ns-1007.awsdns-61.net",
"ns-1990.awsdns-56.co.uk",
"ns-181.awsdns-22.com",
])
"private_zone" = false
"resource_record_set_count" = 3
"tags" = tomap({})
"vpc_id" = tostring(null)
"zone_id" = "Z0953413214RLPAW5X5ZZ"
}</code>
<p>La stessa cosa per il certificato:</p>
<code>data "aws_acm_certificate" "cert" {
domain = "example.com"
provider = aws.us-east-1
}
output "certificate" {
description = "Certificate"
value = data.aws_acm_certificate.cert
}</code>
<p>Che darà come output:</p>
<code>certificate = {
"arn" = "arn:aws:acm:us-east-1:############:certificate/cb45d03e-####-####-####-43242302b735"
"domain" = "example.com"
"id" = "arn:aws:acm:us-east-1:############:certificate/cb45d03e-####-####-####-43242302b735"
"key_types" = toset(null) /* of string */
"most_recent" = false
"status" = "ISSUED"
"statuses" = tolist(null) /* of string */
"tags" = tomap({})
"types" = tolist(null) /* of string */
}</code>
<p>Ora ho tutte le informazioni per creare i nuovi record in Route 53 dalle Distribution di CloudFront come ho fatto prima, ma da codice. Riprendo ancora l'esempio del post precedente dedicato a Terraform e farò piccoli cambiamenti per l'aggiunta della gestione dei record del dominio e del certificato. Inoltre semplificherò il nome della API Rest che non sarà più dinamica (l'url lo prendevo da CloudFront) ma sarà statico visto che utilizzerò il Domain creato sopra. Alla fine delle nuove modifiche avrò:</p>
<ul><li><strong>https://example.com</strong> che mostrerà la pagina html con la web application in Vue.js.</li>
<li><strong>https://www.example.com</strong> che mostrerà la stessa pagina del punto precedente.</li>
<li><strong>https://api.example.com</strong> che sarà l'url per l'API Rest che eseguirà il calcolo.</li>
</ul>
<p>
Creo quindi un nuovo file nella directory dove sono presenti i file <strong>.tf</strong> creati precedentemente dal nome <strong>route53.tf</strong>. Innanzitutto devo fare un'altra modifica, perché, come scritto sopra, devo accedere al certificato nella Region <strong>us-east-1</strong> (Virginia), mentre i miei script in Terraform sono impostati per la Region <strong>eu-south-1</strong> (Milan). Al file <strong>providers.tf</strong> che contiene queste righe:</p>
<code>provider "aws" {
region = "eu-south-1"
}</code>
<p>
Aggiungo:</p>
<code>provider "aws" {
region = "us-east-1"
alias = "us-east-1"
}</code>
<p>
L'alias mi permetterà di scegliere quale Region utilizzare. Se una risorsa dovrà essere creata nella Region <strong>us-east-1</strong> dovrò scrivere:</p>
<code>resource "resource_type" "name" {
...
provider = aws.us-east-1
}</code>
<p>
Altrimenti sarà usata la Region di default. Ora posso aggiungere al file <strong>route53.tf</strong> il codice già visto prima per l'accesso alle informazioni del dominio e del certificato in AWS:</p>
<code>data "aws_route53_zone" "myzone" {
name = "example.com"
}
data "aws_acm_certificate" "cert" {
domain = "example.com"
provider = aws.us-east-1
}</code>
<p>
Ora creo il primo record in <em>Route 53</em> come ho mostrato sopra. Da codice:</p>
<code>resource "aws_route53_record" "www" {
zone_id = data.aws_route53_zone.myzone.zone_id
name = "example.com"
type = "A"
alias {
name = aws_cloudfront_distribution.s3_distribution.domain_name
zone_id = aws_cloudfront_distribution.s3_distribution.hosted_zone_id
evaluate_target_health = false
}
provider = aws.us-east-1
}</code>
<p>
Qui in <em>zone_id</em> ho preso i dati dall'oggetto <em>aws_route53_zone</em>, quindi nella sezione per l'alias ho inserito le informazioni create nella sezione per CloudFront.</p>
<p>
Ora creo un altro alias in modo che da browser si possa richiamare anche il dominio <strong>www.example.com</strong>:</p>
<code>resource "aws_route53_record" "www2" {
zone_id = data.aws_route53_zone.myzone.zone_id
name = "www.example.com"
type = "A"
alias {
name = aws_route53_record.www.fqdn
zone_id = aws_route53_record.www.zone_id
evaluate_target_health = false
}
provider = aws.us-east-1
}</code>
<p>
E questa volta i ferimenti all'alias li prendo dalla risorsa creata per il dominio principale. Mi rimane l'ultimo passo: aggiungere il sotto dominio <strong>api.example.com</strong> per l'API che esegue il calcolo:</p>
<code>resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.myzone.zone_id
name = "api.example.com"
type = "A"
alias {
name = aws_cloudfront_distribution.distribution.domain_name
zone_id = aws_cloudfront_distribution.distribution.hosted_zone_id
evaluate_target_health = false
}
provider = aws.us-east-1
}</code>
<p>
Così come per il dominio principale ho creato il riferimento al CloudFront creato per la Lambda. La sezione in Route 53 è sistemata, ora devo fare delle piccole modifiche alle due configurazioni per CloudFront per l'aggiunta del dominio e del certificato così come ho fatto prima dalla console di AWS. Nel file <strong>1-Lambda.tf</strong> ho aggiunto in coda al file:</p>
<code> aliases = ["${var.apisubdomain}.${var.domain}"] # <- api.example.com
viewer_certificate {
ssl_support_method = "sni-only"
acm_certificate_arn = data.aws_acm_certificate.cert.arn
}</code>
<p>
Come fatto precedentemente, ho inserito un nuovo <em>alias</em> con le informazioni sul dominio da usare e il certificato. La stessa cosa la faccio nel file <strong>2-site.tf</strong>;</p>
<code> aliases = [var.domain, "www.${var.domain}"] # <- example.com, www.example.com
viewer_certificate {
ssl_support_method = "sni-only"
acm_certificate_arn = data.aws_acm_certificate.cert.arn
}</code>
<p>
Manca l'ultimo passo: l'url inserito dinamicamente della API all'interno della web application in Vue.js. Ora inserirò direttamente il dominio in modo statico nel file <strong>url.json</strong> da Terraform.</p>
<p>
Ok ho finito. Richiamando i comandi per la creazione delle risorse in Terraform ora potrò visualizzare la web application con il dominio e con la protezione del protocollo HTTPS, così come per l'API. Alla fine avrò un risultato come il seguente:</p>
<code>cloudfront-site-url = "https://d1i3333wfmkh3h.cloudfront.net"
cloudfront_lambda_url = "https://d3lt9xfmkycdkh.cloudfront.net/api/calc/add?x=5&y=3"
site-domain-url = "https://example.com"
site-domain-url-alternative = "https://www.example.com"
site-domain-url-api = "https://api.example.com/api/calc/add?x=5&y=3"</code>
<h1>
Conclusioni</h1>
<p>
<a href="https://github.com/sbraer/terraform_cloudfront_net6_blog/tree/domain" title="link codice sorgente">Qui</a> il codice sorgente (è lo stesso repository del post precedente ma ho creato un altro Branch). Oltre al codice per la Lambda in Net6 (da compilare prima di usare terraform) è presente anche il codice della web application in Vue.js (anch'esso da buildare). Nel file <strong>README.md</strong> nel Repository sono presenti tutti i passi per queste operazioni. Nella directory <strong>Terraform</strong> sono presenti tutti i file per la costruzione delle risorse in AWS. Nel file <strong>variables.tf</strong> sono presenti le variabili da modificare dove inserire il dominio in proprio possesso e altre piccole personalizzazioni. Ora si può dire completo?</p>
<p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2918/Gestione-Domini-Certificati-AWS-Terraform.aspx"><em>Gestione dei domini e certificati in AWS con Terraform</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani1https://blogs.aspitalia.com/az/post2918/Gestione-Domini-Certificati-AWS-Terraform.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2918.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2918AWS Lambda Custom Rutime in C++https://blogs.aspitalia.com/az/post2917/AWS-Lambda-Custom-Rutime-C.aspx2022-06-18T07:58:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2917" border="0" style="width:1px; height:1px;" /> <p>Ogni tanto è bello sperimentare. Dopo il <a href="https://blogs.aspitalia.com/az/post2916/AWS-Lambda-Cold-Start-CSharp-E.aspx" title="link al post precedente">post precedente</a> dove avevo scritto qualche annotazione sull'uso dei Custom Runtine in AWS per le Lambda, in questo post mi dedicherò ad un test reale. Premetto che non sono un mago in C++ però non nego che come linguaggio mi è sempre piaciuto - in questo mio blog avevo scritto in passato alcuni post dedicati a questo linguaggio.</p>
<p>Verso la fine dello scorso post avevo mostrato l'esempio banale del funzionamento di una Lambda con uno script Bash, preso dalla documentazione di AWS e semplificato. Da quell'esempio si evince come il funzionamento interno delle Lambda non è nulla di complesso, perché espone alla microVM creata dove sarà eseguito il codice, un Endpoint HTTP per fornire alla stessa i dettagli della richiesta con una chiamata GET e la modalità POST dove inviare la risposta.</p>
<p>Le librerie fornite da AWS per le varie tecnologie semplificano queste transazioni richiedendo all'Endpoint informazioni sulla richiesta che saranno poi tradotte in un formato compatibile al linguaggio prescelto, quindi prederà la risposta in output del nostro codice e la tradurrà nel formato compatibile per inviarlo in modalità POST all'Endpoint apposito di AWS. Questo mostra come in verità si possa creare il tutto anche da soli: nulla vieta di scrivere da zero il codice della Lambda che esegue tutte queste operazioni. Il problema principale è che tale codice dovrà poi girare nella microVM che utilizza una versione di Linux custom - <strong>Custom runtime on Amazon Linux 2</strong> - con tutte le problematiche annesse: versioni delle librerie installate o la loro mancanza, versione del Kernel, etc... Ah, non è possibile farlo da Windows.</p>
<p>AWS mette a disposizione pubblicamente il codice sorgente delle librerie con cui poi poter interagire con il Cloud, inoltre mette a disposizione un altro repository con un esempio facile per la creazione di una Lambda in C++. Il problema ora è un altro per chi non è proprio a suo agio con C o C++: tutte queste librerie sono da compilare a mano.</p>
<h1>Compilare con quale distribuzione Linux?</h1>
<p>La prima versione che ho usato con continuità del mondo Linux fu la distribuzione Fedora di Redhat. Solo dopo la nascita della versione Ubuntu dalla Debian mi spostai su questa distribuzione (con varie escursioni verso altre distribuzioni come la OpenSuse e la Arch). Ad un certo punto, forse dovuto all'età, ho smesso di provare le distribuzioni che mi sembravano poter essere interessanti e mi sono legato alla sicurezza delle versioni LTS di Ubuntu (più precisamente la versione Mate), perché il mio tempo lo volevo usare per qualcos'altro che provare tutte le decine di versioni di Linux più o meno utili che si presentano come rivoluzionarie.</p>
<p>Solo ultimamente sono migrato alla versione LTS 22.04 e proprio poco dopo mi sono messo a fare le prime prove con l'SDK di AWS e le Lambda in C++. Che tempismo!!! Infatti questa versione di Ubuntu non è compatibile e non è possibile compilare il codice sorgente dell'SDK di AWS per degli aggiornamenti alle librerie di sistema. Personalmente ci ho perso poco tempo per capire se si poteva risolvere: in rete si trovavano suggerimenti che non funzionavano e così ho desistito. Quindi il primo suggerimento se si vuole fare degli esperimenti in merito è cercare una distribuzione Linux compatibile. Io ho risolto con la versione 20.04 LTS di Ubuntu virtualizzata - e pensare che l'avevo aggiornata poco prima!</p>
<h1>AWS SDK</h1>
<p>Il primo passo è compilare l'SDK di AWS. Partendo dalla macchina appena configurata è necessario installare il minimo indispensabile con:</p>
<code>sudo apt install build-essential</code>
<p>E in seguito:</p>
<code>sudo apt install cmake
sudo apt install zip
sudo apt install libcurl4-openssl-dev
sudo apt install libssl-dev
sudo apt install cmake
sudo apt-get install zlib1g-dev</code>
<p>Ora è il momenti di scaricare il codice sorgente, con questo comando:</p>
<code>git clone --recurse-submodules https://github.com/aws/aws-sdk-cpp</code>
<p>Ora sarà possibile compilare:</p>
<code>cd aws-sdk-cpp
mkdir build
cd build
cmake .. -DBUILD_ONLY="core" \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DENABLE_UNITY_BUILD=ON \
-DCUSTOM_MEMORY_MANAGEMENT=OFF \
-DENABLE_UNITY_BUILD=ON
make
make install</code>
<p>Il comando <strong>make</strong> sarà quello più impegnativo - sulla mia macchina virtuale ha concluso la compilazione in circa cinque minuti. In ogni caso verificare sempre eventuali errori emessi dai comandi qui sopra: la mancanza di qualche libreria è sempre fattibile, ma in ogni caso verificare sempre eventuali incompatibilità.</p>
<p>Con il comando <strong>make install</strong> si installeranno le librerie compilate in <strong>/usr/lib</strong>. Se tutto ha funzionato correttamente si passa al secondo passo. Info sono presenti nel <a href="https://github.com/aws/aws-sdk-cpp" title="link esterno">repository ufficiale</a>.</p>
<h1>AWS C++ Lambda Runtime</h1>
<p>AWS mette a disposizione un repository con la Lambda Runtime che utilizzerò per il mio esempio. Anche questo è da compilare. Come al solito si inizia con il codice sorgente (da scaricare in un'altra directory):</p>
<code>git clone https://github.com/awslabs/aws-lambda-cpp-runtime.git</code>
<p>Ora i comandi per la compilazione:</p>
<code>cd aws-lambda-cpp-runtime
mkdir build
cd build
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF
make
make install</code>
<p>Rimando a questo <a href="https://github.com/awslabs/aws-lambda-cpp" title="link esterno">url</a> per maggiori dettagli. Consiglio, per verificare che funzioni tutto la prima volta, di aggiungere una directory con il codice sorgente presente nel repository per la Lambda e seguire le istruzioni per la compilazione. Nel dettaglio è sufficiente creare questi due file. Il primo <strong>CMakeLists.txt</strong> usato per la compilazione con <em>CMake</em>:</p>
<code>cmake_minimum_required(VERSION 3.9)
set(CMAKE_CXX_STANDARD 11)
project(demo LANGUAGES CXX)
find_package(aws-lambda-runtime)
add_executable(${PROJECT_NAME} "main.cpp")
target_link_libraries(${PROJECT_NAME} PRIVATE AWS::aws-lambda-runtime)
target_compile_features(${PROJECT_NAME} PRIVATE "cxx_std_11")
target_compile_options(${PROJECT_NAME} PRIVATE "-Wall" "-Wextra")
# this line creates a target that packages your binary and zips it up
aws_lambda_package_target(${PROJECT_NAME})</code>
<p>E il file <strong>main.cpp</strong> con il codice sorgente vero e proprio:</p>
<code>#include <aws/lambda-runtime/runtime.h>
using namespace aws::lambda_runtime;
static invocation_response my_handler(invocation_request const& req)
{
if (req.payload.length() > 42) {
return invocation_response::failure("error message here"/*error_message*/,
"error type here" /*error_type*/);
}
return invocation_response::success("json payload here" /*payload*/,
"application/json" /*MIME type*/);
}
int main()
{
run_handler(my_handler);
return 0;
}</code>
<p>Passo successivo, creare in questa directory la directory <strong>build</strong> e lanciare, in sequenza, questi comandi:</p>
<code>$ mkdir build
$ cd build
$ cmake .. -DCMAKE_BUILD_TYPE=Release
$ make
$ make aws-lambda-package-demo</code>
<p>Se tutto ha funzionato si troverà nella directory un file ZIP contenente l'eseguibile da inviare ad AWS. Il codice è semplice: ricevuta la richiesta che non deve avere una dimensione in byte superiore a 42, invia un messaggio come risposta. Per provare questa Lambda in AWS è necessario creare una Role apposita così come le normali Lambda. Si può fare il tutto dalla Console di AWS oppure creando la role con un editor di testi con il nome <strong>trust-policy.json</strong>:</p>
<code>{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": ["lambda.amazonaws.com"]
},
"Action": "sts:AssumeRole"
}
]
}</code>
<p>Come da documentazione prima linkata, si deve configurare la Role per la Lambda con questi comandi:</p>
<code>$ aws iam create-role --role-name lambda-demo --assume-role-policy-document file://trust-policy.json
$ aws iam attach-role-policy --role-name lambda-demo --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
</code>
<p>E' possibile creare la Lambda sempre da Console oppure con questo comando da terminale:</p>
<code>$ aws lambda create-function --function-name demo \
--role arn:aws:iam::############:role/lambda-demo \
--runtime provided --timeout 15 --memory-size 128 \
--handler demo --zip-file fileb://demo.zip</code>
<p>Ora è possibile andare nella Console Web di AWS e testare che funzioni richiamando la Lambda in modalità Test. Ora mi tengo stretto il codice qui usato di esempio perché sarà usato come partenza per il mio codice.</p>
<h1>MongoDb in C++</h1>
<p>Raggiunto l'obbiettivo di vedere la Lambda scritta in C++ funzionare correttamente si è solo a metà strada. Il mio obbiettivo in questo post era ripetere quando avevo fatto con la Lambda scritta in C# e Net6: eseguire una query ad una triplice istanza di MongoDb in Replica set e visualizzare il risultato.</p>
<p>Quanto fatto sopra per l'SDK lo faccio ora con le librerie per l'accesso a MongoDb. In questo caso non è solo una la libreria da compilare, ma addirittura due. Ma niente di complicato, anzi, è stato più facile che l'SDK di AWS visto che ho potuto farlo con qualsiasi versione di Ubuntu. Il punto di partenza è questa <a href="https://www.mongodb.com/docs/drivers/cxx/" title="link esterno">pagina</a>. Seguendo pedissequamente quando spiegato <a href="http://mongocxx.org/mongocxx-v3/installation/linux/" title="link esterno">qui</a> per la compilazione della versione per Linux inizio compilando la libreria <strong>libmongoc</strong> che è usata come dipendenza della libreria principale che dovrò usare.</p>
<p>Controllo di avere il tutto con il comando consigliato:</p>
<code>$ sudo apt-get install cmake libssl-dev libsasl2-dev</code>
<p>Scarico il codice sorgente in una nuova directory:</p>
<code>$ wget https://github.com/mongodb/mongo-c-driver/releases/download/1.21.2/mongo-c-driver-1.21.2.tar.gz</code>
<p>Quindi eseguo le operazioni necessarie per preparare la compilazione:</p>
<code>$ tar xzf mongo-c-driver-1.21.2.tar.gz
$ cd mongo-c-driver-1.21.2
$ mkdir cmake-build
$ cd cmake-build
$ cmake -DCMAKE_BUILD_TYPE=Release \
-DENABLE_AUTOMATIC_INIT_AND_CLEANUP=OFF ..</code>
<p>Se non si ricevono errori, ecco i classici comandi per compilare e installare le librerie sulla macchina:</p>
<code>$ cmake --build .
$ sudo cmake --build . --target install</code>
<p>Primo passo completato. Ora è possibile compilare la libreria <strong>mongocxx</strong> (<strong>libmongoc</strong> è una dipendenza necessaria per la compilazione). In una nuova directory scarico il codice:</p>
<code>curl -OL https://github.com/mongodb/mongo-cxx-driver/releases/download/r3.6.7/mongo-cxx-driver-r3.6.7.tar.gz</code>
<p>E con i prossimi due comandi decomprimo l'archivio e vado nella directory corretta per poter compilare:</p>
<code>tar -xzf mongo-cxx-driver-r3.6.7.tar.gz
cd mongo-cxx-driver-r3.6.7/build</code>
<p>Inizio la configurazione con cmake:</p>
<code>cmake .. \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr/local</code>
<p>Quindi compilo e installo anche questa libreria con i suoi file per il supporto alla compilazione (anche questa operazione necessita di qualche minuto):</p>
<code>sudo cmake --build . --target EP_mnmlstc_core
cmake --build .
sudo cmake --build . --target install</code>
<p>Ora sono pronto per il mio esempio - era ora.</p>
<h1>La mia Lambda in C++</h1>
<p>Riprendendo l'esempio della Lambda, ecco il mio codice nel file main.cpp:</p>
<code>#include <iostream>
#include <cassert>
#include <aws/lambda-runtime/runtime.h>
#include <aws/core/utils/json/JsonSerializer.h>
#include <aws/core/utils/memory/stl/SimpleStringStream.h>
#include "mongoDbHelper.h"
using namespace aws::lambda_runtime;
using namespace Aws::Utils::Json;
static invocation_response my_handler(invocation_request const& request)
{
[[maybe_unused]] JsonValue json_request(request.payload);
try {
const auto message = MongoDbHelper::Instance().FindOne("MyCollection", "identifier", "x00001");
if (message) {
JsonValue response{*message};
return invocation_response::success(response.View().WriteCompact(), "application/json");
}
else {
return invocation_response::failure("Requesto to MongoDb", "NotFound");
}
}
catch(const std::exception& e) {
return invocation_response::failure("Generic error", e.what());
}
}
int main() {
run_handler(my_handler);
return 0;
}</code>
<p>Il codice modificato è nella funzione <strong>invocation_response</strong>. Questa volta del parametro di input - <em>json_request</em> -non ne faccio nulla e richiamo l'istanza di una mia classe che esegue effettivamente la richiesta a MongoDb. Questa funzione ritorna un oggetto di tipo <a href="https://en.cppreference.com/w/cpp/utility/optional" title="link esterno">std::optional</a> che permette di avere come contenuto sia la risposta (una stringa) o un valore nullo. In caso sia presente la stringa la utilizzo come risposta della Lambda in formato JSON, altrimenti ritorno un messaggio di errore. Tutto questo codice è all'interno del blocco <em>try..catch</em> per poter ritornare una risposta valida anche in caso di problemi di connessione al database.</p>
<p>Eviterò di postare anche l'header file della classe MongoDbHelper, che ha questo contenuto:</p>
<code>#include "mongoDbHelper.h"
MongoDbHelper& MongoDbHelper::Instance() {
static MongoDbHelper _instance{
Helper::GetEnvironmentVariable("MongoDbConnectionString", "mongodb+srv://#######:################@cluster0.#####.mongodb.net/?retryWrites=true&w=majority&tlsAllowInvalidCertificates=true"),
Helper::GetEnvironmentVariable("MongoDbDatabase", "MyDatabase")
};
return _instance;
}
MongoDbHelper::MongoDbHelper(const std::string& connectionString, const std::string& database)
: _database(database) {
_inst = std::make_unique<mongocxx::instance>();
_uri = std::make_unique<mongocxx::uri>(connectionString);
_pool = std::make_unique<mongocxx::pool>(*_uri.get());
}
std::optional<std::string> MongoDbHelper::FindOne(
const std::string& collection,
const std::string& key,
const std::string& value) const
{
auto conn = _pool.get()->acquire();
auto db = (*conn)[_database];
auto coll = db[collection];
auto cursor1 = coll.find_one({
bsoncxx::builder::stream::document{}
<< key << value
<< bsoncxx::builder::stream::finalize
});
if (cursor1) {
std::string returnString{bsoncxx::to_json(cursor1.value())};
return {returnString};
}
else {
return std::nullopt;
}
}</code>
<p>La funzione <em>FindOne</em> esegue effettivamente le richiesta utilizzando poi l'oggetto <em>std::optional</em> in caso sia presente la risposta o meno. La grande differenza nel codice, se confrontata con la versione in C#, è nella configurazione della <em>connection pool</em> per MongoDb che ho dovuto fare io da codice mentre nella versione in C# è attiva di default:</p>
<code>auto conn = _pool.get()->acquire();</code>
<p>Il vantaggio in termini di prestazioni è enorme, si passa dai 300ms a pochissimi millesimi di apertura per le successive richieste di connessione.</p>
<p>Un altro grande cambiamento è nel file <strong>CMakeLists.txt</strong>. Qui ho dovuto inserire le librerie aggiuntive per poter compilare correttamente il mio codice con i relativi path dei file da includere:</p>
<code>cmake_minimum_required(VERSION 3.12)
set(CMAKE_CXX_STANDARD 20)
project(demo LANGUAGES CXX)
find_package(aws-lambda-runtime)
find_package(AWSSDK COMPONENTS core)
find_package(mongocxx REQUIRED)
find_package(bsoncxx REQUIRED)
include_directories(${LIBMONGOCXX_INCLUDE_DIR})
include_directories(${LIBBSONCXX_INCLUDE_DIR})
add_executable(${PROJECT_NAME} "main.cpp" "helper.cpp" "mongoDbHelper.cpp")
target_link_libraries(${PROJECT_NAME} PRIVATE AWS::aws-lambda-runtime ${AWSSDK_LINK_LIBRARIES})
target_link_libraries(${PROJECT_NAME} PRIVATE mongo::bsoncxx_shared)
target_link_libraries(${PROJECT_NAME} PRIVATE mongo::mongocxx_shared)
target_compile_features(${PROJECT_NAME} PRIVATE "cxx_std_20")
target_compile_options(${PROJECT_NAME} PRIVATE "-Wall" "-Wextra" "-O3" "-mavx2")
aws_lambda_package_target(${PROJECT_NAME})</code>
<p>Ho modificato anche la versione di C++ da utilizzare e aggiunto qualche opzione per la compilazione. Arrivati a questo punto, è il momento di controllare che la compilazione funzioni. Nella directory dov'è presente il codice creo una nuova directory build da cui lancerò i comandi:</p>
<code>mkdir build
cd build</code>
<p>Quindi, come per la Lambda di esempio vista prima:</p>
<code>cmake .. -DCMAKE_BUILD_TYPE=Release
make
make aws-lambda-package-demo</code>
<p>Verificato che non ci siano errori si troverò infine un nuovo file zip con il nome <strong>demo.zip</strong>. Prima di caricarlo in AWS una veloce occhiata al suo contenuto:</p>
<p><img src="/img/andrewz/lambda2/demozip_content.png" title="zip demo content" /></p>
<p>Come scritto anche nel post precedente tutto parte dal file <strong>bootstrap</strong>. Dovrebbe essere un Bash file, e aperto con un text editor:</p>
<code>#!/bin/bash
set -euo pipefail
export AWS_EXECUTION_ENV=lambda-cpp
exec $LAMBDA_TASK_ROOT/lib/ld-linux-x86-64.so.2 --library-path $LAMBDA_TASK_ROOT/lib $LAMBDA_TASK_ROOT/bin/demo ${_HANDLER}</code>
<p>Tutta qua(!?), alla finfine AWS lancerà questa Lambda come se fosse un esebuibile (presente nella directory <strong>bin</strong>):</p>
<p><img src="/img/andrewz/lambda2/demozip_main.png" title="zip demo eseguibile nella directory bin" /></p>
<p>Il perché si usi <strong>ld-linux-x86-64.so.2</strong> per lanciare l'eseguibile è ben spiegato in questo <a href="https://www.youtube.com/watch?v=qdIa9vTlg9Q" title="link esterno">video</a> che consiglio se si è interessanti all'argomento. Nella directory <strong>lib</strong> si trovano tutte le librerie utilizzate dalla Lambda, tra cui anche quelle compilate da me:</p>
<p><img src="/img/andrewz/lambda2/lib_linux.png" title="Librerie incluse nella compilazione" /></p>
<h1>AWS, Lambda in C++ e Cold Start</h1>
<p>Ecco la prova del nove. E' il momento di caricare il codice compilato in AWS e verificare se ci sono vantaggi per il Cold Start. Taglio corto per non creare troppa aspettativa: no. Anzi, per meglio dire... deludente! Aspettative troppo alte? Prima di guardare il bicchiere mezzo vuoto un po' di ottimismo: le prestazioni sono ottime a regime (numero di istanze della Lambda avviate), invece per il Cold Start c'è delusione perché le prestazioni sono altalenanti - si va dai 200ms ai 600ms. Un esempio in cui il Cold Start è all'incirca di 467ms:</p>
<p><a href="/img/andrewz/lambda2/coldstartcplusplus1.png" title="Cold Start Lambda in C++ e custom runtime"><img src="/img/andrewz/lambda2/coldstartcplusplus1.png" title="Cold Start Lambda in C++ e custom runtime" width="720" /></a></p>
<p>E anche in questo caso sembra che con l'assegnazione di un quantitativo maggiore di RAM migliori i tempi di avvio anche se il consumo di memoria della Lambda è veramente esiguo se confrontato con la versione Net6. Con dei test soggettivi di centinaia di richieste con molteplici richieste contemporanee si è comportata meglio della versione in C#, ma con un vantaggio risicato.</p>
<h1>Tra il dire e il fare...</h1>
<p>Il codice presentato funziona. Va bene, ma la fase di sviluppo? A parte il problema di usare una versione di Linux compatibile con le librerie AWS - sempre che il problema sia stato già risolto - e l'impossibilità di lanciare direttamente il codice com'è possibile con lo stesso progetto in Net Core, in C++ si possono usare altri trucchi. Ho già premesso che non sono un guru i con questo linguaggio - o per meglio dire, non sono in guru in niente - e tra i vari trucchi semplici che ho utilizzato è il creare un nuovo file dove inserire la funzione <em>main</em> del codice.</p>
<p>Per esempio, ho creato il file main_local.cpp dove richiamo la funzione per la richiesta dei dati a MongoDb, e per compilare un eseguibile scrivo:</p>
<code>g++ -mavx2 -O3 --std=c++2a main_local.cpp mongoDbHelper.cpp helper.cpp -o a.out \
$(pkg-config --cflags --libs libmongocxx)</code>
<p>Quindi si può avviare con il comando:</p>
<code>./a.out</code>
<p>Oppure eseguire il debug da VsCode. E' inutile che aggiungo ancora che probabilmente ci sono tecniche migliori: per ora non le conosco.</p>
<h1>Conclusioni</h1>
<p>
Personalmente non ho trovato vantaggi così superiori della versione in C++ su quella in C# per proclamare che la soluzione di tutti i problemi della versione Net Core sono un ricordo del passato grazie alla versione in C++. Probabilmente l'utilizzo di altre tecnologie e linguaggi come Go per avere prestazioni superiori sarebbe la scelta migliore per le Lambda in AWS. Ma non posso sentenziare verità assolute.</p>
<p>
<a href="https://github.com/sbraer/lambda_cplusplus_blog" title="link codice sorgente lambda in c++">Qui</a> il codice di questo post.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2917/AWS-Lambda-Custom-Rutime-C.aspx"><em>AWS Lambda Custom Rutime in C++</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2917/AWS-Lambda-Custom-Rutime-C.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2917.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2917AWS Lambda Cold Start in C# e...https://blogs.aspitalia.com/az/post2916/AWS-Lambda-Cold-Start-CSharp-E.aspx2022-06-11T10:28:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2916" border="0" style="width:1px; height:1px;" /> <p>Visto che nel <a href="https://blogs.aspitalia.com/az/post2915/Terraform-Vue.js-Lambda-Net6-Aws-CloudFront.aspx" title="post precedente dedicato alle lambda, terraform e cloudfront">post precedente</a> avevo utilizzato per la demo una Lambda scritta in C#, in questo post mi dedicherò proprio a loro anche perché ne ho discusso dei pro e contro con qualche amico ultimamente. Non farò sviolinate a favore della Lambda in AWS perché sarebbero inutili, visto che i vantaggi che portano sono innegabili sia a livello prestazionale, sia per l'autoscaling di cui dispongono, sia per la loro economicità. Una volta configurate non c'è bisogno di altro: il cloud di AWS darà le risorse necessarie per il loro utilizzo al momento della richiesta senza dover configurare server o altro. Tutto bello e perfetto dunque?</p>
<h1>Lambda, qualche dettaglio</h1>
<p>Tralasciando il linguaggio di programmazione utilizzato o la tecnologia scelta, quando viene richiesta una Lambda, un servizio di AWS avvia una microVM specifica per la tecnologia utilizzata e idonea a fare girare il codice, quindi copia il codice della Lambda e lo esegue in modo che possa elaborare la richiesta. <strong>microVM</strong> sta per <em>virtual micro machine</em>, ed è una <a href="https://aws.amazon.com/blogs/aws/firecracker-lightweight-virtualization-for-serverless-computing/" title="link esterno ">tecnologia di virtualizzazione</a> utilizzata da AWS per la Lambda e per i servizi Fargate.</p>
<p>Ognuna di queste istanze è in grado di gestire una sola chiamata alla volta. AWS non invia altre richieste ad una istanza se è ancora in fase di elaborazione. Solo in caso di nessuna istanza libera, il servizio AWS avvia la creazione di un'altra microVM, installa il codice della Lambda e lo avvia assegnandogli la richiesta pendente. Quando non sono presenti richieste, dopo un tempo variabile (la media è dieci minuti), AWS spegne la microVM fino alla prossima richiesta. Questo dettaglio sentenzia una realtà: l'utilizzo del pattern async/await è totalmente inutile.</p>
<p>L'avvio di una microVM e del codice della Lambda non è immediato perché, anche se minimo, c'è sempre un minimo di latenza per il tempo necessario di creare l'istanza e avviarla. Questo lasso di tempo tra l'avvio e la capacità della Lambda di poter rispondere viene definito come Cold Start.</p>
<p>Nel grafico seguente una tentativo di mostrare quanto appena scritto:</p>
<p><img src="/img/andrewz/lambda1/chart_coldstart.png" title="Chart lambda" /></p>
<p>La prima chiamata - R1 - crea una nuova istanza per la Lambda (Istanza 1). Il rettangolo ha un blocco ha all'interno una sezione rossa che è il tempo utilizzato per il Cold Start. Quando ancora questa Lambda non ha risposta alla richiesta, viene fatta una seconda richiesta, di conseguenza AWS crea una nuova istanza - Istanza 2 - per R2, che a sua volta, essendo appena avviata ha il problema del Cold Start. La terza richiesta - R3 - ha la prima istanza disponibile perché ha già finito la prima elaborazione, e riesce ad elaborare velocemente la richiesta, cosa che non può la richiesta successiva - R4 - perché, non avendo istanze libere della Lambda, obbliga AWS ad avviare una nuova istanza. Le richieste successive - R5...R8 - trovano sempre un'istanza della Lambda libera e vengono elaborate molto velocemente.</p>
<p>Ora controllo se quanto detto è vero. Provo il prossimo codice in C# e Net 6 che simula una richiesta fittizia con una attesa che simula una elaborazione e ridà come risposta l'Id univoco dell'istanza:</p>
<code>using Amazon.Lambda.Core;
// Assembly attribute to enable the Lambda function's JSON input to be converted into a .NET class.
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace AWSLambdaMongoDbTest1;
public class Function
{
public string Id = Guid.NewGuid().ToString();
public string FunctionHandler(ILambdaContext context)
{
Thread.Sleep(400);
return Id;
}
}</code>
<p>Simulando dieci chiamate sequenziali si avrà il risultato aspettato: la stessa istanza ha elaborato tutte le richieste:</p>
<code>"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"</code>
<p>Ora sovrappongo le dieci chiamate e le eseguo in parallelo:</p>
<code>"59e9b0f1-063a-4ff0-9aed-bbcd2e38de23"
"b42a4be3-f3b7-4b56-b213-d741ec275c00"
"a0010a3e-261b-4546-aa5f-8e6bef9afbd3"
"fcdf78bb-eacc-4654-8608-1c92ec80b25e"
"0a12b6ac-b22c-4175-b25c-cc85f3c34d68"
"b1d02c07-f7b7-4544-9f6e-3f29144c730d"
"cc46fad8-77e0-4234-9b60-af8ed139d026"
"c56e4eba-83cd-4988-ad88-417aa4af396f"
"b604c916-3c79-4f46-8af4-8aad404b9dd8"
"ced76fc4-57cc-4587-9968-6e187c4059a2"</code>
<p>Com'è prevedibile, AWS ha creato altre nove istanze. Ora AWS ha dieci attive istanze che saranno chiuse in automatico in una decina di minuti.</p>
<h1>Cold Start</h1>
<p>Il tempo utilizzato dai servizi di AWS ad avviare quanto necessario ai servizi perché una istanza della Lambda possa elaborare una richiesta è definita Cold Start. E questo tempo di avvio varia e soprattutto con tecnologie come Net Core (Java per dirne un altro linguaggio) soffrono in modo marcato di questo problema. Fortunatamente dalla prima versione a cui ho messo mano nella Lambda con Net Core (era la versione 2.1) all'attuale (la 6) le cose sono migliorate notevolmente ma il problema è ancora presente anche se più limitato maggiore è la memoria assegnata.</p>
<p>Ora prenderò un esempio un po' più reale e utilizzabile anche nel mondo vero: nel codice utilizzato nella prossima Lambda farò una richiesta ad una tripla istanza di MongoDb in Replica Set e restituirò la sua risposta. Niente di complicato, ma almeno basta, per il momento, con 'sto <em>Hello World!</em></p>
<p>Il codice è tutto qua:</p>
<code>using Amazon.Lambda.Core;
using System.Diagnostics;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace AWSLambdaMongoDbTest1;
public class Function
{
public string FunctionHandler(ILambdaContext context)
{
var sw = Stopwatch.StartNew();
Console.WriteLine("Before MongoDb Request");
var result = MongoDbHelper.Instance.Value.GetDocument("MyCollection", "identifier", "x00001");
sw.Stop();
Console.WriteLine($"After MongoDb Request: {sw.ElapsedMilliseconds}ms");
return result;
}
}</code>
<p>Con la funzione apposita:</p>
<code>using MongoDB.Bson;
using MongoDB.Driver;
public class MongoDbHelper
{
private readonly string _connectionString;
private readonly string _databaseName;
public static Lazy<MongoDbHelper> Instance = new Lazy<MongoDbHelper>(() => new MongoDbHelper(
"mongodb+srv://#######:################@cluster0.#####.mongodb.net/?retryWrites=true&w=majority&tlsAllowInvalidCertificates=true",
"MyDatabase"
));
private MongoDbHelper(string connectionString, string databaseName)
{
_connectionString = connectionString;
_databaseName = databaseName;
}
public string GetDocument(string collection, string key, string value)
{
var dbClient = new MongoClient(_connectionString);
IMongoDatabase db = dbClient.GetDatabase(_databaseName);
var cars = db.GetCollection<BsonDocument>(collection);
var filter = Builders<BsonDocument>.Filter.Eq(key, value);
var doc = cars.Find(filter).First();
return doc.ToString();
}
}</code>
<p>Attivando nella configurazione della Lambda l'X-Ray per avere dettagli della richiesta, la Lambda qui sopra, configurata con 256MB, mostrerà questo risultato:</p>
<a href="/img/andrewz/lambda1/coldstart_net6.png" title="Cold start lambda net 6"><img src="/img/andrewz/lambda1/coldstart_net6.png" title="Cold start lambda net 6" width="720" /></a>
<p>Con la durata della chiamata totale, quindi i dettagli. Initialization è solo una parte del Cold Start. Qui un dettaglio di un'altra richiesta:</p>
<p><img src="/img/andrewz/lambda1/coldstart_detailed.png" title="Cold start lambda nel dettaglio" /></p>
<p>Il rettangolo rosso è il Cold Start da calcolare per l'avvio dell'istanza della Lambda. La parte iniziale è utilizzata per creare la microVM, e a seconda parte è il tempo dell'avvio del mio codice. Solo dopo questa zona viene elaborata realmente la richiesta. Notare come il tempo di avvio sia molto alto, e per migliorare la situazione è sufficiente assegnare alla Lambda una maggiore quantità di RAM. Ecco alcune personalissime misurazioni del Cold Start con diversi tagli di memoria:</p>
<table>
<tr>
<th></th>
<th>256</th>
<th>512</th>
<th>1024</th>
<th>1576</th>
<th>2048</th>
</tr>
<tr>
<td><span data-analytics="refreshEnabledV2" data-analytics-type="variant">x86_64</span></td>
<td>540ms</td>
<td>412ms</td>
<td>380ms</td>
<td>327ms</td>
<td>355ms</td>
</tr>
</table>
<p>In alcuni paesi è possibile anche selezionare la tipologia di microprocessore ARM - sfortunatamente nei data center in Italia non è possibile. Ecco lo stesso test fatto con questa opzione nei data center di Francoforte (anche le misurazione fatte sopra erano state fatte in Germania), da prendere in considerazione anche perché più economico come servizio:</p>
<table>
<tr>
<th></th>
<th>256</th>
<th>512</th>
<th>1024</th>
<th>1576</th>
<th>2048</th>
</tr>
<tr>
<td>ARM</td>
<td>391ms</td>
<td>399ms</td>
<td>380ms</td>
<td>325ms</td>
<td>287ms</td>
</tr>
</table>
<p>I dati qui sopra hanno poca importanza perché sono molto altalenanti, inoltre il Cold Start delle Lambda soffrono molto la complessità del codice e delle DLL incluse. Più la Lambda è complessa con moltissime Reference (incluse nello zip), e maggiore sarà il tempo di avvio. In un progetto reale in Net Core 6 abbastanza complesso, con 256MB di ram, ho registrato anche tempi superiori ai dieci secondi per il Cold Start, tempo che ritornava ad essere competitivo solo oltre il giga di memoria assegnato - per mia esperienza personale il quantitativo <strong>minimo</strong> di RAM da assegnare alle Lambda in Net 6 è 2048MB. In ogni caso è sempre meglio fare prove e test accurati con carichi progressivi di richieste prima di innalzare le Lambda di AWS come soluzione di tutti i problemi.</p>
<h1>Soluzione del problema Cold Start</h1>
<p>E' possibile risolvere questo problema? Da anni si una il trucco di fare una richiesta fittizia ogni <strong><em>x</em></strong> minuti in modo da tenere almeno una istanza sempre attiva. La cosa funziona, finché le richieste sono poche e il tempo di elaborazione molto veloce. Ma è sufficiente qualche richiesta in parallelo che il problema, ovviamente, si ripresenta.</p>
<p>Una opzione che risolve parzialmente il problema è <a href="https://docs.aws.amazon.com/lambda/latest/dg/provisioned-concurrency.html" title="link esterno">Provisioned concurrency configurations</a>. Dal pannello di <em>Configurazione</em> -> <em>Concurrency</em> si possono selezionare impostazioni interessanti sia per il Cold Start sia per mantenere l'utilizzo delle risorse dedicato alle Lambda controllato:</p>
<p><img src="/img/andrewz/lambda1/concurrency_lambda.png" title="Lambda Concurrency from aws" width="720" /></p>
<p>Nella parte superiore è possibile selezionare il numero massimo di istanze per la Lambda che AWS può avviare, ne parlerò anche in seguiro, ma interessante è la sezione <em>Provisioned concurrency configuration</em>. Qui è possibile selezionare il numero minimo di istanza della nostra Lambda da tenere sempre attivo (evitando così il Cold Start), e solo in caso il numero di richieste in parallelo dovesse essere superiore avvia nuove istanze. Il giro per attivarlo è un po' macchinoso ma nulla di complicato. Innanzitutto si deve creare andare nel tab <em>Versions</em> della Lambda e cliccando su <em>Publish new version</em>, assegnare un nome alla versione attuale della Lambda. Se tutto è stato fatto correttamente, ora dalla sezione <em>Provisioned concurrency configuration</em> sarà possibile aggiungere una nuova configurazione:</p>
<p><img src="/img/andrewz/lambda1/provisioned_concurrency.png" title="provisioned concurrency lambda aws" width="720" /></p>
<p>Non tralasciare mai il fattore costo che si può vedere sopra al numero delle istanze create. Cliccato su <strong>Save</strong>, AWS avvierà in poco meno di un minuto le istanze desiderate che saranno subito disponibili alla richieste.</p>
<p>Nello stesso pannello è possibile assegnare un numero massimo di istanze:</p>
<p><img src="/img/andrewz/lambda1/lambda_concurrency.png" title="Lambda Concurrency" width="720" /></p>
<p>Impostando, come nell'esempio, il valore cinque, AWS non permetterà che venga creato un numero superiore di istanze. E cosa succede se ora riprovassi il testo iniziale con dieci richieste contemporanee?</p>
<code>The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
The remote server returned an error: (500) Internal Server Error.
"d81fd267-9cfb-4066-a357-026b7c7e381e"
"0a12b6ac-b22c-4175-b25c-cc85f3c34d68"
"a0010a3e-261b-4546-aa5f-8e6bef9afbd3"
"cc46fad8-77e0-4234-9b60-af8ed139d026"
"b1d02c07-f7b7-4544-9f6e-3f29144c730d"</code>
<p>Come ci si poteva aspettare ecco il blocco delle richieste oltre a quelle prestabilite - AWS non accoda le richieste ma scarta immediatamente le richieste se non sono disponibili istanze per l'elaborazione.</p>
<h1>Un po' di dettagli tecnici</h1>
<p>Non è solo Net Core a soffrire del problema Cold Start. Anche altre tecnologie ne soffrono anche se in modo molto ridotto. NodeJs, Go e Python, per esempio, hanno un ridottissimo Cold Start (nei vari test che si possono trovare in rete) riescono a stare tranquillamente sotto i 300ms.</p>
<p>Personalmente ho trovato interessante il funzionamento dietro le quinte delle Lambda. Nella documentazione ufficiale di AWS si trova tutto. E' possibile utilizzare anche i <a href="https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html" title="link esterno">Custom Runtime</a> per fare girare qualsiasi linguaggio di programmazione, ed è interessante, per comprenderne il funzionamento, vedere l'esempio incluso nei tutorial in cui si mostra come vengono gestite le richieste e le risposte alle Lambda con uno script in Bash con il <strong>Custom runtime on Amazon Linux 2</strong>, che qui mostro semplificato in un unico file (che deve avere il nome <strong>bootstrap)</strong>:</p>
<code>#!/bin/sh
set -euo pipefail
# Processing
while true
do
HEADERS="$(mktemp)"
# Get an event. The HTTP request will block until one is received
EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")
# Extract request ID by scraping response headers received above
REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)
CUSTOM_RESPONSE="Hello World!"
# Send the response
curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response" -d "$CUSTOM_RESPONSE"
done</code>
<p>Nel ciclo <em>while true</em> viene fatta una richiesta HTTP GET ad un Endpoint interno di AWS che rimane in attesa fino alla risposta contenente l'<em>Event Data</em> della richiesta. Ora è possibile controllare il suo contenuto, ma io lo ignoro e preparo il messaggio di output "<em>Hello World!</em>" - non mi ero lamentato di questo esempio poco fa? - messaggio che invio in modalità POST all'Endpoint apposito, il quale sarà restituito al richiedente (il <em>REQUEST_ID</em> viene usato per permettere ad AWS di collegare la richiesta asincrona ricevuta con il client e la risposta da inviargli). Alla fine è banale, no?</p>
<p>Nella variabile <em>$HEADERS</em> è presente un path di un file da utilizzare per le richieste, per esempio <strong>/tmp/tmp.yCW4gp5MRe</strong>. Visualizzando il contenuto di questo file trovo le info della Lambda:</p>
<code>HTTP/1.1 200 OK
Content-Type: application/json
Lambda-Runtime-Aws-Request-Id: f947184d-d35c-4ce9-a98f-563d77f5cce9
Lambda-Runtime-Deadline-Ms: 1654784504908
Lambda-Runtime-Invoked-Function-Arn: arn:aws:lambda:eu-central-1:############:function:custombash
Lambda-Runtime-Trace-Id: Root=1-62a121f5-564bbaa91c4ad0b625124b58;Parent=c40a2fbf3c55c49b;Sampled=1
Date: Thu, 09 Jun 2022 14:21:41 GMT
Content-Length: 56</code>
<p>In <em>EVENT_DATA</em> trovo il contenuto della richiesta. Nel mio esempio ho lasciato l'esempio di default dalla Console di AWS:</p>
<code>{
"key1": "value1",
"key2": "value2",
"key3": "value3"
}</code>
<p>Oltre agli Endpoint per la gestione delle richieste sono pure disponibili i servizi per il Log e l'X-Ray. I runtime dedicati ai vari linguaggi di programmazione non fanno altro che mettere a disposizione in modo più semplice queste richieste HTTP. Fine.</p>
<h1>Conclusioni, per ora...</h1>
<p>Le banalità spiegate qui - chi conosce e ha lavorato con le Lambda conoscerà quanto scritto sopra - mi porteranno al prossimo post, dove proverò a utilizzare questi Custom Runtime in C++ prendendo come spunto il codice fornito da AWS per provare a replicare quando fatto dal codice in C# e Net 6 - forse.</p>
<p>Risolverò il problema del Cold Start? E' inutile dare false speranze. Il codice sorgente dell'esempio lo inserirò(!?) insieme al codice del prossimo post.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2916/AWS-Lambda-Cold-Start-CSharp-E.aspx"><em>AWS Lambda Cold Start in C# e...</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani1https://blogs.aspitalia.com/az/post2916/AWS-Lambda-Cold-Start-CSharp-E.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2916.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2916Terraform, Vue.js, Lambda in Net6 e Aws CloudFronthttps://blogs.aspitalia.com/az/post2915/Terraform-Vue.js-Lambda-Net6-Aws-CloudFront.aspx2022-06-03T08:56:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2915" border="0" style="width:1px; height:1px;" /> <p>Nel <a href="https://blogs.aspitalia.com/az/post2914/Terraform-Vue.js-Aws-CloudFront.aspx" title="post precedente">post precedente</a> ho mostrato come gestire una web application client side scritta in Vue.js da CloudFront. Grazie a questa CDN di AWS è possibile distribuire a livello mondiale quell'applicazione client side sicuri delle ottime prestazioni grazie alla distribuzione capillare dei nodi dell'infrastruttura di AWS - ovviamente qualsiasi contenuto web statico ne trae vantaggio. Con il post attuale, invece, farò gestire a CloudFront una Lambda scritta in C# e Net6 in modo di aggiungere un po' di server side alla semplice applicazione mostrata. Ovviamente la configurazione e la creazione di tutte le risorse in AWS sarà fatta con Terraform.</p>
<h1> Lambda in C# e Net6</h1>
<p> Innanzitutto si devono installare due tool nella macchina dove si vuole creare applicazioni Lambda per AWS. Il primo tool è dedicato a Visual Studio: <a href="https://aws.amazon.com/it/visualstudio/" title="link esterno">Aws Toolkit for Visual Studio</a>. Questo tool creerà nuovi tipi di applicazioni da creare in Visual Studio:</p>
<p><img src="/img/andrewz/terraform4/visual-studio-2020-aws-toolkit-lambda.png" title="Visual Studio 2020 e AWS Toolkit" width="720" /></p>
<p>Due sono i tipi di progetti utili:</p>
<ul><li>Aws Lambda Project</li>
<li>Aws ServerLess Application</li></ul>
<p>Le differenze a livello di applicazioni create in Visual Studio è che la prima creerà un unico entry point ad una function handler (come avviene anche per Lambda scritte in NodeJs e altri linguaggi) ed è più consigliato per la creazione di Lambda di utilizzo interno ad AWS (comunicazione tra servizi e altro, e non solo web). Esempio di codice creato in automatico da questo tipo di progetto:</p>
<code>using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
// The function handler that will be called for each Lambda event
var handler = (string input, ILambdaContext context) =>
{
return input.ToUpper();
};
// Build the Lambda runtime client passing in the handler to call for each
// event and the JSON serializer to use for translating Lambda JSON documents
// to .NET types.
await LambdaBootstrapBuilder.Create(handler, new DefaultLambdaJsonSerializer())
.Build()
.RunAsync();</code>
<p>L'applicazione di tipo Aws Serverless Application è più indicata per il Web e permette di creare web application più complesse con Controller e contenuto statico:</p>
<p><img src="/img/andrewz/terraform4/aws-toolkit-serverless-application.png" title="Visual Studio 2020 e AWS Toolkit: serverless application" width="720" /></p>
<p>Nel mio esempio userò proprio quest'ultimo tipo selezionando come Blueprint l'<strong>ASP.NET Core Minimal API</strong> che creerà lo scheletro della web application in cui inserirò la web api di cui ho bisogno.</p>
<p>Nel progetto si esempio creato faccio una piccola modifica al file principale - <strong>program.cs</strong> - per aggiungere il <a href="https://www.wikiwand.com/en/Cross-origin_resource_sharing" title="link esterno: CORS su wikipedia">CORS</a> al mio controller:</p>
<code>var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Add AWS Lambda support. When application is run in Lambda Kestrel is swapped out as the web server with Amazon.Lambda.AspNetCoreServer. This
// package will act as the webserver translating request and responses between the Lambda event source and ASP.NET Core.
builder.Services.AddAWSLambdaHosting(LambdaEventSource.RestApi);
builder.Services.AddCors(options =>
{
options.AddPolicy("corsapp", builder =>
{
builder.WithOrigins("*").AllowAnyMethod().AllowAnyHeader();
});
});
var app = builder.Build();
app.UseCors();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.MapGet("/", () => "Welcome to running ASP.NET Core Minimal API on AWS Lambda");
app.Run();</code>
<p>Quindi cancello il controller di base creato - <em>CaculatorController</em> - e ne creo un mio più semplice:</p>
<code>using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
namespace AWSServerless_test1.Controllers
{
[ApiController]
[EnableCors("corsapp")]
[Route("api/[controller]")]
public class CalcController : ControllerBase
{
private readonly ILogger<CalcController> _logger;
public CalcController(ILogger<CalcController> logger) => _logger = logger;
[HttpGet("add")]
public IActionResult Add(int x, int y)
{
_logger.LogInformation($"{x} plus {y} is {x + y}");
var json = new
{
time = DateTimeOffset.UtcNow,
result = x + y
};
return Ok(new { results = json });
}
}
}</code>
<p>In cui viene abilitato il CORS e viene creato un unico method GET che accetta due numeri in <em>Querystring</em> e ne restituisce la somma. E la Lambda in C# e Net6 è completa. Per potere farne il Deploy su AWS ora ho la possibilità di poterlo vare con Visual Studio cliccando con il tasto destro del mouse sul nome del progetto e selezionando: <strong>Publish to AWS Lambda</strong> che aprirà una nuova schermata in cui selezionare il Bucket S3 dove sarà inserito lo Stack di <a href="https://aws.amazon.com/it/cloudformation/" title="link esterno">CloudFormation</a> che sarà utilizzato per la creazione delle risorse in AWS.</p>
<p>Non è quello che serve a me visto che voglio usare Terraform. Io ho la necessità di un secondo tool da aggiungere: <a href="https://www.nuget.org/packages/Amazon.Lambda.Tools/" title="link esterno">Amazon-Lambda-Tool</a>. Installato, come spiegato nella pagina linkata, da terminale ora posso compilare e impacchettare il progetto pronto per AWS con un solo comando:</p>
<code>dotnet lambda packet</code>
<h1>Lambda in Terraform</h1>
<p>Ho tutto quello che mi serve per creare il tutto con Terraform. Innanzitutto i Provider (<strong>verions.tf</strong>):</p>
<code>terraform {
required_version = "~> 1.00"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}</code>
<p>La configurazione dei provider (<strong>providers.tf</strong>):</p>
<code>terraform {
required_version = "~> 1.00"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}</code>
<p>Per il Deploy della web application in S3 e CloudFront ho speso fin troppe parole nel post precedente. E' arrivato il momento della pubblicazione della Lambda. In questo caso ho creato il file <strong>1-lambda.tf</strong>. Per la pubblicazione della Lambda uso questo codice:</p>
<code>resource "aws_iam_role" "iam_for_lambda" {
name = "iam_for_lambda"
assume_role_policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Action": "sts:AssumeRole",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Effect": "Allow",
"Sid": ""
}
]
})
}
resource "aws_lambda_function" "test_lambda" {
filename = "../AWSServerless_test1/AWSServerless_test1/bin/Release/net6.0/AWSServerless_test1.zip"
function_name = var.lambda_function_name
role = aws_iam_role.iam_for_lambda.arn
handler = "AWSServerless_test1"
source_code_hash = filebase64sha256("../AWSServerless_test1/AWSServerless_test1/bin/Release/net6.0/AWSServerless_test1.zip")
runtime = "dotnet6"
memory_size = 512
environment {
variables = {
name = "LambdaNet6"
}
}
depends_on = [
aws_iam_role_policy_attachment.lambda_logs,
aws_cloudwatch_log_group.example,
]
}</code>
<p>Dove creo una Role per l'esecuzione della Lambda e una Resource di tipo <em>aws_lambda_function</em> dove inserisco il file zippato creato dal comando visto prima - <strong>dotnet lambda packet</strong>. Per poter gestire il Log in CloudWatch aggiungo queste Resource presenti anche tra le dipendenze della Lambda:</p>
<code>resource "aws_cloudwatch_log_group" "example" {
name = "/aws/lambda/${var.lambda_function_name}"
retention_in_days = 14
}
resource "aws_iam_policy" "lambda_logging" {
name = "lambda_logging"
path = "/"
description = "IAM policy for logging from a lambda"
policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*",
"Effect": "Allow"
}
]
})
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.iam_for_lambda.name
policy_arn = aws_iam_policy.lambda_logging.arn
}</code>
<p>Tutto qua: eseguendo il codice con il comando <strong>terraform</strong> saranno create tutte le risorse necessarie per la Lambda in AWS. Rimane il problema come renderla disponibile via HTTP. Per ottenere questo è necessario creare una <a href="https://aws.amazon.com/it/api-gateway/" title="link esterno">API Gateway</a>, ovviamente con Terraform. Qui il codice diventa un po' più complesso perché, come dalla Console Web di AWS, è necessario creare la Resource per la API con i vari collegamenti dei Method alla Lambda. Ecco il codice per futura memoria:</p>
<code>resource "aws_api_gateway_rest_api" "example" {
name = "ServerlessExample"
description = "Terraform Serverless Application Example"
}
resource "aws_api_gateway_resource" "proxy" {
rest_api_id = aws_api_gateway_rest_api.example.id
parent_id = aws_api_gateway_rest_api.example.root_resource_id
path_part = "{proxy+}"
}
resource "aws_api_gateway_method" "proxy" {
rest_api_id = aws_api_gateway_rest_api.example.id
resource_id = aws_api_gateway_resource.proxy.id
http_method = "ANY"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "lambda" {
rest_api_id = aws_api_gateway_rest_api.example.id
resource_id = aws_api_gateway_method.proxy.resource_id
http_method = aws_api_gateway_method.proxy.http_method
integration_http_method = "POST"
type = "AWS_PROXY"
uri = aws_lambda_function.test_lambda.invoke_arn
}
############
resource "aws_api_gateway_method" "proxy_root" {
rest_api_id = aws_api_gateway_rest_api.example.id
resource_id = aws_api_gateway_rest_api.example.root_resource_id
http_method = "ANY"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "lambda_root" {
rest_api_id = aws_api_gateway_rest_api.example.id
resource_id = aws_api_gateway_method.proxy_root.resource_id
http_method = aws_api_gateway_method.proxy_root.http_method
integration_http_method = "GET"
type = "AWS_PROXY"
uri = aws_lambda_function.test_lambda.invoke_arn
}
###############
resource "aws_api_gateway_deployment" "example" {
depends_on = [
aws_api_gateway_integration.lambda,
aws_api_gateway_integration.lambda_root,
]
rest_api_id = aws_api_gateway_rest_api.example.id
stage_name = "prod"
}
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.test_lambda.function_name
principal = "apigateway.amazonaws.com"
# The /*/* portion grants access from any method on any resource
# within the API Gateway "REST API".
source_arn = "${aws_api_gateway_rest_api.example.execution_arn}/*/*"
}</code>
<p>E' il momento di configurare CloudFront perché possa avere come source la Lambda appena creata. Siccome necessito del Domain e del Path per due parametri differenti e l'Api Gateway le ritorna come unico Url, uso una Regex per estrarre i due valori:</p>
<code>locals {
regobject1 = regex("^(?:(?P<scheme>[^:\\/?#]+):)?(?:\\/\\/(?P<domain>[^\\/?#]*))?\\/(?P<path>[\\w]+)$",aws_api_gateway_deployment.example.invoke_url)
}</code>
<p>Quindi come da documentazione creo la Resource con i parametri della Regex appena vista compresa l'<strong>origin</strong>:</p>
<code>resource "aws_cloudfront_distribution" "distribution" {
origin {
domain_name = local.regobject1.domain
origin_path = "/${local.regobject1.path}"
origin_id = "apigw_root"
custom_header {
name = "origin_id"
value = "apigw_root"
}
custom_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "https-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}</code>
<p>Definita di seguito la sezione per la cache globale con i valori di default, definisco la cache per la mia API:</p>
<code>ordered_cache_behavior {
path_pattern = "/api/*"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = "apigw_with_path"
default_ttl = 60
min_ttl = 10
max_ttl = 3600
forwarded_values {
query_string = true
query_string_cache_keys = [ "x", "y" ]
cookies {
forward = "all"
}
}
viewer_protocol_policy = "redirect-to-https"
}</code>
<p>A parte i valori di TTL qui ho definito anche che la cache dovrà essere utilizzata solo per i parametri Querystring, nel dettaglio solo per i parametri <strong>x</strong> e <strong>y</strong>. Solo la modifica di questi parametri farà eseguire a CloudFront una richiesta reale alla mia Lambda con il relativo salvataggio nella cache: l'aggiunta di altri parametri sarà completamente ignorato se il contenuto di <strong>x</strong> e <strong>y</strong> sono già presenti in cache.</p>
<p>Inoltre nei metodi accettati ho lasciato solo il GET (e l'HEAD) perché altre tipi di chiamate come il POST (PUT/DELETE...) comportano normalmente una modifica del contenuto, quindi l'uso della cache sarebbe errato.</p>
<h1>Passare l'URL della Lambda alla web application</h1>
<p>Nella web application mostrata nel post precedente avevo inserito l'evento <em>beforeMount</em> per richiedere il file JSON <strong>url.json</strong> presente nella directory <strong>public</strong>.</p>
<code>beforeMount() {
console.log('Requested url to remote service');
fetch("/extra/url.json")
.then(response => response.json())
.then(data => {
this.ExternalUrl = data.url;
console.log("Externl url = " + this.ExternalUrl);
});
}</code>
<p>In questo file avevo inserito la key url con un valore vuoto che mi permetteva di controllare la presenza di un URL per poter richiedere una API Rest remota per il calcolo. Ora ho la Lambda remota accessibile da CloudFront e quindi posso inserire qui l'url corretto. Con Terraform la cosa è banale, nel codice di Terraform della web application ho aggiunto questo codice:</p>
<code>resource "aws_s3_bucket_object" "url_content" {
bucket = aws_s3_bucket.site.id
key = "extra/url.json"
content = "{\"url\": \"https://${aws_cloudfront_distribution.distribution.domain_name}\"}"
content_type = "application/json"
}</code>
<p>Nel bucket S3 dove è presente il contenuto client side ora è l'url della Lambda senza altre modifiche.</p>
<h1>Conclusioni</h1>
<p>Una persona mi ha chiesto perché non mettere tutta una web application scritta in Net Core all'interno di una AWS Lambda invece di usare il doppio approccio static site S3 + Lambda. In effetti nulla mi proibirebbe di mettere nella Lambda di questo post dei controller che rispondano con pagine HTML e altro contenuto statico. Ma le Lambda sono fatte per altro, soprattutto con le loro limitazioni (cold start e impossibilità di installare componenti esterni direttamente sul server, etc...).</p>
<p>L'utilizzo di CloudFront nelle Lambda ne ottimizza l'uso soprattutto in caso di un grandissimo numero di richieste. La distribuzione in cache a livello planetario risolve l'altro problema della possibile latenza tra il paese da dove arriva la richiesta e il paese dove è installata la Lambda. Inoltre smorza il problema tipico delle Lambda - il cold start - in caso di scaling per un numero elevato di richieste in parallelo.</p>
<p>Fine di questo post. <a href="https://github.com/sbraer/terraform_cloudfront_net6_blog" title="link codice sorgente">Qui</a> il codice sorgente da installare con questi passaggi:</p>
<code>cd vuejs
npm run build
cd ..
cd AWSServerless_test1
cd AWSServerless_test1
dotnet lambda package
cd ..
cd ..
cd vuejs
npm install
npm run build
cd ..
cd terraform
terraform init
terraform plan
terrafrom apply -auto-approve</code>
<p>Se tutto funziona si avrà questo output:</p>
<code>Apply complete! Resources: 30 added, 0 changed, 0 destroyed.
lambda_base_url = "https://rggdqbob9d.execute-api.eu-south-1.amazonaws.com/prod"
lambda_cloudfront_url = "https://d16dtqjtodir87.cloudfront.net/api/calc/add?x=5&y=3"
lambda_domain = {
"domain" = "rggdqbob9d.execute-api.eu-south-1.amazonaws.com"
"path" = "prod"
"scheme" = "https"
}
site-url = "https://d3jd73vcrm3c9l.cloudfront.net"</code>
<p>E cliccando sull'ultimo URL si aprirà la pagina web dell'esempio. Infine, per eliminare tutto:</p>
<code>terraform destroy -auto-approve</code>
<p>
In questa serie di post dedicati a Terraform non ho mai approfondito il deployment CD/CI etc... - forse in futuro? - e come questo diventa più semplice anche con infrastruttute complesse. Terraform dunque promosso? Sì.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2915/Terraform-Vue.js-Lambda-Net6-Aws-CloudFront.aspx"><em>Terraform, Vue.js, Lambda in Net6 e Aws CloudFront</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani2https://blogs.aspitalia.com/az/post2915/Terraform-Vue.js-Lambda-Net6-Aws-CloudFront.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2915.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2915Terraform, Vue.js e Aws CloudFronthttps://blogs.aspitalia.com/az/post2914/Terraform-Vue.js-Aws-CloudFront.aspx2022-05-26T17:46:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2914" border="0" style="width:1px; height:1px;" /><p>Dopo il primo post dedicato a Kubernetes e il secondo a EKS, ecco il terzo post dedicato a <a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Introduction.html" title="link esterno">CloudFront</a>. CloudFront è un servizio CDN in AWS che consente la distribuzione di contenuto web statico e dinamico attraverso i suoi 310 <a href="https://aws.amazon.com/cloudfront/features/?whats-new-cloudfront.sort-by=item.additionalFields.postDateTime&whats-new-cloudfront.sort-order=desc" title="link esterno">Points of Presence</a> a livello mondiale. Tecnicamente >300 punti di accesso (edge locations) eseguono il redirect della richiesta a 13 regioni (Regioanal edge caches) che eseguono la reale richiesta alla risorsa originale e la salvano nella loro cache. Questo gran numero di punti accesso distribuiti a livello geografico permettono una richiesta molto veloce da pressocché qualsiasi paese. Prendendo lo schema dalla documentazione ufficile di AWS e CloudFront, ecco il funzionamento di base:</p>
<p><img src="/img/andrewz/terraform3/aws-cloudfront.png" title="AWS CloudFront cache" width="720" /></p>
<p>In CloudFront è sufficiente creare una <a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/distribution-working-with.html" title="link esterno">Distribution</a>, la quale accetta come input un Bucket S3, un LoadBalancer e altre risorse interne di AWS, oppure un HTTP Endpoint. In questo post mi dedicherò a S3.</p>
<h1>Vue.js</h1>
<p>Ora creerò una fin troppo semplice web appliction client side in Vue.js solo per scopo didattico (ma è possibile usare anche Angular, React, o semplice pagine HTML con o senza vanilla Javascript). Per comodità aggiungo anche Bootstrap e avrò questa semplice pagina di output:</p>
<p><img src="/img/andrewz/terraform3/vuejs1.png" title="Simple Vue.js application" /></p>
<p>Una volta inseriti dei valori numerici nelle due Textbox e cliccato <strong>Sum locally</strong>, sarà visualizzato il risultato:</p>
<p><img src="/img/andrewz/terraform3/vuejs2.png" title="Simple Vue.js application result" /></p>
<p>Per la creazione della web application in Vue.js ho eseguito i <a href="https://cli.vuejs.org/guide/installation.html" title="link esterno">classici passi</a> come da documentazione. Avendo già installato sulla macchine Nodejs e NPM, ho esguito questi comandi:</p>
<code>npm install -g @vue/cli</code>
<p>Quindi ho creato una nuova applicazione con il comando:</p>
<code>vue create name-application</code>
<p>Quindi ho selezionato la versione tre di Vue.js e alla fine nell'esempio creato ho cancellato e rimosso i riferimenti al componente di default creato in <strong>src/componentes</strong>, <strong>HelloWord.vue</strong>. Quindi ho creato un mio componente <strong>Calculator.vue</strong>. Utilizzando Bootstrap per l'output ho aggiunto con npm le dipendenze dedicate a Bootstrap.</p>
<p>Questo post non vuole essere un trattato su Vue.js - e poi io non sarei in grado di farlo. L'unico dettaglio che sarà utile in avanti è il pulsante in rosso <strong>Sum Remotely</strong>. Questo pulsante permette la chiamata di una API esterna in caso sia definito nel file <strong>url.json</strong> inserito in <strong>public/extra</strong>. In questo post inserisco una stringa vuota perché lo utilizzerò nel prossimo:</p>
<code>{"url": ""}</code>
<p>All'avvio della mia Vue.js application ho inserito l'evento <em>beforeMount</em> con questo codice:</p>
<code>beforeMount() {
console.log('Requested url to remote service');
fetch("/extra/url.json")
.then(response => response.json())
.then(data => {
this.ExternalUrl = data.url;
console.log("Externl url = " + this.ExternalUrl);
});
}</code>
<p>Che richiede il contenuto di quel file JSON e, se presente, salva il valore nella variabile <em>ExternalUrl</em> che sarà controllato dalla funzione chiamata dal button <strong>Sum Remotely</strong>.</p>
<p>Controllato che tutto funzioni (almeno localmente) con il comando <b>npm run serve</b> è il momento di farne il deploy locale:</p>
<code>npm run build</code>
<p>Che creerà il file necessari per il deploy che mi serviranno nei prossimi passi.</p>
<h1>Terraform</h1>
<p>Ovviamente per pubblicare il codice sopra prodotto userò Terraform. Scopo finale è rendere disponibile la web application creata in AWS CloudFront. Per ottenere questo innanzitutto devo inviare tutti i file su S3, quindi dovrò creare una Distribution in CloudFront in modo da renderla pubblica a livello mondiale.</p>
<p>Strumenti necessari:</p>
<ul>
<li><em>terraform</em> (tool) almeno la versione 1.0</li>
<li><em>aws cli</em> (dalla verisone 2)</li>
<li>Account di AWS attivo.</li>
</ul>
<p>Questa volta inserirò tutto in un unico file per mia pigrizia. Innanzitutto ecco la definizione dei provider necessari:</p>
<code>terraform {
required_version = "~> 1.00"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}
provider "aws" {
region = "eu-south-1"
}</code>
<p>Oltre le versioni dei provider ho impostato, come nei post precedenti, la zona dove pubblicare il tutto - <strong>eu-south-1</strong>, Milano. E' ora il momento di creare il Bucket in S3 dove inserirò tutti i file della mia webapplication in Vue.js:</p>
<code>variable "s3bucketname" {
description = "Bucket name"
type = string
default = "az-site2"
}
resource "aws_s3_bucket" "site" {
bucket = var.s3bucketname
force_destroy = true
}</code>
<h1>S3, Terraform e Content-type</h1>
<p>L'invio dei file su S3 è semplice con Terraform. C'è una Resource apposita che con poche righe di codice mi permette questa operazione in modo veloce, grazie alla funzione <em>for_each</em>:</p>
<code>resource "aws_s3_bucket_object" "myapplication" {
for_each = fileset("../output/dist/", "**/*.*")
bucket = aws_s3_bucket.site.id
key = each.value
source = "../output/dist/${each.value}"
etag = filemd5("../output/dist/${each.value}")
}</code>
<p>Ma una volta eseguito questo script con il comando Terraform e reso pubblico (mostrerò in seguito questa operazione), una volta richiesti i file dal browser questo non li mostrerà ma avvierà il download degli stessi. Questo <em>problema</em> è dovuto dal Content Type mancante. Se i file vengono inviarti dalla console web di AWS questo problema non sussiste perché il tool di upload di AWS è abbastanza intelligente da capire il tipo di file, ma da Terraform questo non è possibile e la soluzione, l'unica che ho trovato, è inviare gruppi di file per tipo con il corretto Content Type. Nel mio file Terraform quindi complico tutto inserendo per ogni tipo una resource per l'upload dei file:</p>
<code>resource "aws_s3_bucket_object" "html" {
for_each = fileset("../output/dist/", "**/*.html")
bucket = aws_s3_bucket.site.id
key = each.value
source = "../output/dist/${each.value}"
etag = filemd5("../output/dist/${each.value}")
content_type = "text/html"
}
resource "aws_s3_bucket_object" "svg" {
for_each = fileset("../output/dist/", "**/*.svg")
bucket = aws_s3_bucket.site.id
key = each.value
source = "../output/dist/${each.value}"
etag = filemd5("../output/dist/${each.value}")
content_type = "image/svg+xml"
}
...</code>
<p>Inizio inviando i file di tipo HTML con il corretto Content Type. Di seguito faccio lo stesso passaggio con i file SVG, quindi Javascript, etc... Questo risolve il problema. Ora che i file sono caricati in S3 si deve configurare il Bucket perché questa sia disponibile online:</p>
<code>resource "aws_s3_bucket_website_configuration" "site" {
bucket = aws_s3_bucket.site.id
index_document {
suffix = "index.html"
}
error_document {
key = "error.html"
}
}
resource "aws_s3_bucket_acl" "site" {
bucket = aws_s3_bucket.site.id
acl = "public-read"
}
resource "aws_s3_bucket_policy" "site" {
bucket = aws_s3_bucket.site.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "PublicReadGetObject"
Effect = "Allow"
Principal = "*"
Action = "s3:GetObject"
Resource = [
aws_s3_bucket.site.arn,
"${aws_s3_bucket.site.arn}/*",
]
},
]
})
}</code>
<p>In queste sezioni definisco il nome della pagina principale e quella di errore, quindi creo una Role di AWS per permettere l'accesso al Bucket. Se eseguissi questo script con Terraform avrei già risolto il problema perché l'url in S3 mostrerebbe la mia webapplication via browser.</p>
<h1>CloudFront</h1>
<p>E' arrivato il momento di mostrare al mondo interno la mia web application in Vue.js con CloudFront ottenendo il massimo delle prestazioni. Sempre con Terraform posso configurare il tutto. Inizio con:</p>
<code>locals {
s3_origin_id = "mysite"
}
resource "aws_cloudfront_origin_access_identity" "origin_access_identity" {
comment = "mysite"
}</code>
<p>Con cui credo un identity per CloudFront - posso inserire qualsiasi nome. Quindi posso creare la Resource per CloudFront:</p>
<code>resource "aws_cloudfront_distribution" "s3_distribution" {</code>
<p>In cui dovrò inserire alcune sezioni. Ecco la prima dove definisco l'<em>origin</em> che CloudFront utilizzerà come source:</p>
<code>origin {
domain_name = aws_s3_bucket.site.bucket_regional_domain_name
origin_id = local.s3_origin_id
s3_origin_config {
origin_access_identity = aws_cloudfront_origin_access_identity.origin_access_identity.cloudfront_access_identity_path
}
}
enabled = true
is_ipv6_enabled = true
comment = "my-cloudfront"
default_root_object = "index.html"</code>
<p>Importante il <em>domain_name</em> e l'<em>origin_id</em> che faranno riferimento al Bucket dove ho inserito la mia web application in Vue.js. Oltre ad abilitare l'Ipv6 definisco anche il file che sarà utilizzato utilizzato di default in caso nel path richiesto dal browser non fosse inserito.</p>
<code>default_cache_behavior {
allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.s3_origin_id
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
viewer_protocol_policy = "allow-all"
min_ttl = 0
default_ttl = 3600
max_ttl = 86400
}</code>
<p>Nella sezione precedente inserisco le informazioni per la cache in CloudFront. Innanzitutto inserisco i <em>Methods</em> che dovrà gestire per le richieste e quali saranno messo in cache (<em>GET</em> e <em>HEAD</em>). In <em>forward_values</em> posso impostare se CloudFront dovrà trattare nella cache anche i cookie e i parametri nella querystring (di questo spiegherò più in dettaglio nel prossimo post). Nelle impostazioni TTL potrò inserire quanto la cache in CloudFront dovrà durare (valore minimo, valore di default, e valore massimo, sempre in secondi) prima che CloudFront le cancelli dalla cache e rifaccia la richiesta a S3.</p>
<p>Se volessi inserire diverse sezioni di cache per diverse sezioni del sito web, potrei inserire più sezioni come la seguente:</p>
<code> ordered_cache_behavior {
path_pattern = "/content/*"
allowed_methods = ["GET", "HEAD", "OPTIONS"]
cached_methods = ["GET", "HEAD"]
target_origin_id = local.s3_origin_id
forwarded_values {
query_string = false
cookies {
forward = "none"
}
}
min_ttl = 0
default_ttl = 86400
max_ttl = 31536000
compress = true
viewer_protocol_policy = "redirect-to-https"
}</code>
<p>Dove specifico una durata di cache maggiore per le richieste a <strong>/content/</strong>. Aggiungo infine dei TAG e siccome non ho un dominio e un certificato da collegare a CloudFront dirò di gestire il tutto a lui:</p>
<code>tags = {
Environment = "development"
Name = "my-tag"
}
viewer_certificate {
cloudfront_default_certificate = true
}</code>
<p>CloudFront mi permette anche di bloccare o abilitare l'accesso solo da alcuni paesi:</p>
<code>restrictions {
geo_restriction {
restriction_type = "whitelist"
locations = ["US", "IT"]
}
}</code>
<p>In questo caso solo dagli Stati Uniti e dall'Italia sarà possibile accedere al contenuto di CloudFront. Fatto da un paese non presente nella lista il browser visualizzerà questo errore:</p>
<p><img src="/img/andrewz/terraform3/cloudfront_403.png" title="CloudFront error 403" /></p>
<p>
Inoltre è possibile definire la <strong>price_class</strong> che vogliamo sia utilizzata in CloudFront, per esempio:</p>
<code>price_class = "PriceClass_200"
</code>
<p>
Sono accettati tre parametri:</p>
<ul><li><em>PriceClass_All</em>: nessuna restrizione, il contenuto sarà distribuito a livello planetario.</li>
<li><em>PriceClass_200</em>: il contenuto non utilizzerà i servizi in Sud America e Australia/Nuova Zelanda</li>
<li><em>PriceClass_100</em>: restrizione massima, oltre ai paesi esclusi nella classe 200 saranno escluse anche il Sud Africa, Medio Oriente, Giappone e altri paesi Asiatici.</li>
</ul>
<p>
Avendo una restrizione di tipo 100 ovviamente non significa che la risorsa non sarà disponibile se richiesta dall'Australia: essa sarà raggiungibile e utilizzerà il paese più vicino (in linea teorica gli Stati Uniti) con una maggiore latenza dovuta alla maggiore distanza. A questa <a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/PriceClass.html" title="link esterno">pagina</a> maggiori informazioni.</p>
<p>
Alla fine dello script in Terraform visualizzo il dominio che utilizzerò per richiamare la mia web application in Vue.js:</p>
<code>output "cloudfront" {
description = "DNS output from cloudfront"
value = "https://${aws_cloudfront_distribution.s3_distribution.domain_name}"
}</code>
<h1>
Da tutto il mondo</h1>
<p>
Provo a richiamare la mia web application dal browser e verifico che tutto funzioni. Non mi basta. Controllo gli IP di risposta. Dalla mia connessione di casa:</p>
<code>nslookup ay7z457uy1.execute-api.eu-south-1.amazonaws.com
...
Non-authoritative answer:
Name: ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Addresses: 13.226.171.10
13.226.171.108
13.226.171.61
13.226.171.68</code>
<p>E verifico gli IP nei siti che visualizzano i paesi dove sono stati assegnati. Per esempio, utilizzando <a href="https://www.iplocation.net/" title="link esterno">iplocation</a> ottengo per il primo IP della lista:</p>
<p><img src="/img/andrewz/terraform3/cloudfront_ip_italy.png" title="CloudFront ip italiani" /></p>
<p>La stessa richiesta fatta da una macchina virtuale in Australia:</p>
<code>nslookup ay7z457uy1.execute-api.eu-south-1.amazonaws.com
...
Non-authoritative answer:
Name: ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.42
Name: ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.83
Name: ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.111
Name: ay7z457uy1.execute-api.eu-south-1.amazonaws.com
Address: 18.67.93.68</code>
<p><img src="/img/andrewz/terraform3/cloudfront_ip_australia.png" title="CloudFront ip australiani" /></p>
<h1>Troppa cache</h1>
<p>CloudFront per ottimizzare le prestazioni usa ovviamente una cache la cui scadenza viene definitiva nelle impostazioni viste prima (di default è ventiquattro ore). Per pagine in continuo aggiornamento questo comporta ovviamente dei problemi visto che, con un grande periodo di cache, si avrà disponibile l'aggiornamento dopo troppo tempo, ma con una cache troppo breve si perderebbero i vantaggi nel suo utilizzo.</p>
<p>La soluzione è <a href="https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/Invalidation.html" title="link esterno">invalidare</a> la cache. La cosa è possibile direttamente dalla console web di AWS oppure con il comando da terminale:</p>
<code>aws cloudfront create-invalidation --distribution-id {distribution_id} --paths /*</code>
<p>E da Terraform? Se si aggiornasse il contenuto della web application d'esempio potrei vederne le modifiche solo dopo il lungo periodo della cache. Per fortuna esiste un trucco per invalidare la cache direttamente da Terraform. Il trucco è in realtà semplice, il primo passo è creare un archivio zip del contenuto:</p>
<code>data "archive_file" "website" {
output_path = "../output/zip/website.zip"
source_dir = "../output/dist"
type = "zip"
}</code>
<p>Terraform in questo caso crea in <strong>output/zip</strong> un archivio ZIP con tutto il contenuto della web application in Vue.js. Esiste una resource - <a href="https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource" title="link esterno">null_resource</a> - che permette di collegare un <em>trigger</em> il cui la modifica del contenuto permette la creazione <em>ex novo</em> della resource. Inoltre questa resource permette l'esecuzione di comandi, quindi ecco la soluzione finale:</p>
<code>resource "null_resource" "website" {
provisioner "local-exec" {
command = "aws cloudfront create-invalidation --distribution-id ${aws_cloudfront_distribution.s3_distribution.id} --paths /*"
}
triggers = {
index = filebase64sha256(data.archive_file.website.output_path)
}
}</code>
<p>Nel trigger viene inserito l'<em>Hash256</em> del contenuto del file. Qualsiasi modifica del contenuto dello zip modificherebbe questo valore che avvierebbe il command in <em>local-exec</em>, nel mio caso l'invalidazione del contenuto di CloudFront.</p>
<p>Problema risolto - nel mio esempio io invalido tutto il contenuto della cache, ma con quel comando in AWS è possibile selezionare il Path che subirà la cancellazione del contenuto della cache. Questo permette la modifica della cache mirata per le risorse realmente modificate.</p>
<h1>Route 53 e Dominio</h1>
<p>Sempre con Terraform è possibile gestire i domini con il servizio <a href="https://aws.amazon.com/route53/" title="link esterno Route 53">Route 53</a> di AWS. Il risultato ottimale sarebbe collegare un dominio con certificato SSL alla distribuzione CloudFront creata in questo post. Ma al momento non mi è stato possibile per assenza di un dominio su cui poter fare questa demo. In futuro?</p>
<h1>Conclusioni</h1>
<p>A questo <a href="https://github.com/sbraer/terraform_cloudfront_blog" title="source code">link</a> il codice sorgente della semplice web application in Vue.js e lo script in Terraform per creare il tutto in AWS. La web application qui presentata è <em>limitata</em> dall'assenza di una API remota per la somma. Questa API la porterò nel prossimo post dove pubblicherò una Lambda in Net6 in AWS che sarà distribuita anch'essa a livello plenetario da CloudFront. In questo caso si deve gestire con più dettagli come dev'essere gestita la cache da parte di CloudFront. Quando avrò tempo.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2914/Terraform-Vue.js-Aws-CloudFront.aspx"><em>Terraform, Vue.js e Aws CloudFront</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani1https://blogs.aspitalia.com/az/post2914/Terraform-Vue.js-Aws-CloudFront.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2914.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2914Terraform, Kuberntes e AWS EKShttps://blogs.aspitalia.com/az/post2913/Terraform-Kuberntes-AWS-EKS.aspx2022-05-19T16:14:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2913" border="0" style="width:1px; height:1px;" /> <p>Ecco altre mie annotazioni personali. Dopo il <a href="https://blogs.aspitalia.com/az/post2912/Terraform-Kubernetes.aspx" title="post precedente">post</a> precedente dove ho solo introdotto Terraform ora provo ad alzare l'asticella. Obbiettivo che voglio ottenere in questo post: avviare le tre istanze di MongoDb e le due web application in Kubernetes con Terraform ma all'interno di un cluster Kubernetes in AWS (EKS) anch'esso creato con Terraform.</p>
<p>Questa volta ho bisogno di questi tool:</p>
<ul><li>Il tool terraform (versione >=1.0)</li>
<li>aws cli (versione >=2.0)</li>
<li>Account attivo in AWS</li>
</ul>
<p>Dal post precedente si dovrebbe già avere installato il tool <strong>terraform</strong> nella macchina locale. <a href="https://aws.amazon.com/it/cli/" title="link esterno">aws cli</a> lo si può scaricare da qui. Naturalmente è necessario avere un account attivo in AWS e configurato perché possa essere usato con il comando <strong>aws</strong> da terminale. Per completare questo <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/getting-started_create-admin-group.html" title="link esterno">passo</a> è sufficiente lanciare il comando:</p>
<code>aws configure</code>
<p>E inserire i codici dell'utente creato dalla console web di AWS. Alla fine, per verificare che tutto funzioni, da terminale lanciare il comando:</p>
<code>$ aws sts get-caller-identity
{
"UserId": "AEDAXXXXXXXXXXXXX228Z",
"Account": "xxxxxxxxxxxx",
"Arn": "arn:aws:iam::xxxxxxxxxxxx:user/xxxxxxxxxx"
}</code>
<p>Che dovrebbe dare in risultato simile all'esempio sopra. Premessa prima di continuare: anche se si sta utilizzando un account di prova in AWS che dà molti servizi gratuiti il primo anno, EKS non è tra questi.</p>
<h1>Si comincia... EKS</h1>
<p>
Innanzitutto il codice utilizzato in Terraform nel post precedente per la creazione delle risorse rimarrà quasi immutato. Le uniche differenze saranno nella modalità di autenticazione di Kubernetes e Helm per accedere al cluster in AWS. Completamente nuovo è il codice in Terraform per la creazione delle risorse in AWS di un cluster per Kubernetes. Utilizzando AWS, inoltre, non utilizzerò il tipo <em>NodePort</em> per i <em>service</em> di Kubernetes per le web application, ma il <em>Load Balancer</em>. EKS è il servizio che mette a disposizione AWS per la creazione di un cluster Kubernetes nel Cloud. Permette la creazione di macchine virtuale (EC2) da utilizzare nel cluster per l'esecuzione dei Pod e offre anche la possibilità di usare la modalità <a href="https://docs.aws.amazon.com/it_it/eks/latest/userguide/fargate.html" title="link esterno">Fargate</a> per poterli farli girare in un ambiante Serverless. Inoltre è in grado di collegare in modo autonomo la creazione dei Load Balancer di Kubernetes con l'omonimo servizio si AWS, così come i dischi EBS che saranno creati e gestiti da AWS come
<a href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/" title="link esterno">Persistent Volume</a> all'interno del cluster, con la possibilità anche di selezionare la tipologia di servizio per esigenze prestazionali o di capacità. Il suo utilizzo da console web all'inizio può appare problematico, perché necessita della creazione manuale di due Role per il suo utilizzo (<em>Cluster Role</em> e <em>Node Role</em>). Infine, si deve configurare il numero e il tipo di macchine da avviare e collegare al cluster in un <em>Node Group</em>. Per esigenze particolari si possono avviare più <em>Node Group</em> con ognuna la sua tipologia di macchine.</p>
<p>
Personalmente ho sempre trovato l'uso del <a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-eksctl.html" title="link esterno">comando</a> da terminale <strong>eksctl</strong> più comodo per la creazione (e distruzione) dei cluster Kubernetes in AWS/EKS - questione di gusti. Ora utilizzerà una nuova via: Terraform.</p>
<p>
Come nello scorso post cercherà di mantenere una struttura più pulita possibile con il nome dei file consigliati.</p>
<h1>
versions.tf</h1>
<p>
Come spiegato nello scorso post, qui inserisco i provider che utilizzerò:</p>
<code>terraform {
required_providers {
helm = {
source = "hashicorp/helm"
version = ">= 2.0.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.0.0"
}
random = {
source = "hashicorp/random"
version = "3.0.1"
}
aws = {
source = "hashicorp/aws"
version = ">= 4.0.0"
}
}
}</code>
<p>
Confrontato con lo stesso file precedente ho solo aggiunto il provider per AWS.</p>
<h1>
variables.tf</h1>
<p>
In questo file ho aggiunto parecchie variabili per personalizzare la creazione di tutte le risorse. Oltre al nome del <em>namespace</em> per Kubernetes, ho inserito anche il nome della nuova VPC (<em>vpc_name</em>) e la regione (<em>aws_region</em>) in cui sarà creato il tutto in AWS. Inoltre per il mio scopo creerò due <em>Node Group</em>. Nel primo creerà tre macchine nel quale avvierò le istanze di MongoDB, e nel secondo userò una sola macchina virtuale nella quale avvierò le due web application. Per specificarne il tipo e il numero:</p>
<code>variable "aws_region" {
description = "Aws region"
type = string
default = "eu-south-1"
}
...
variable "vpc_name" {
description = "VPC name"
type = string
default = "AZ-vpc"
}
variable "cluster_name" {
description = "K8s cluster name"
type = string
default = "K8sDemo"
}
variable "cluster_version" {
description = "Cluster version"
type = string
default = "1.21"
}
variable "worker_group_mongodb_instance_type" {
description = "Worker group mongodb instance type"
type = string
default = "t3.small"
}
variable "worker_group_mongodb_desidered_size" {
description = "Worker group mongodb desidered size"
type = number
default = 3
}
variable "worker_group_webapp_instance_type" {
description = "Worker group webapp instance type"
type = string
default = "t3.small"
}
variable "worker_group_webapp_desidered_size" {
description = "Worker group webapp desidered size"
type = number
default = 1
}</code>
<h1>
providers.tf</h1>
<code>provider "aws" {
region = var.aws_region
}
data "aws_availability_zones" "available" {}</code>
<p>
In questo caso configuro AWS comunicando la <em>region</em> che userà (<em>eu-south-</em>1, Milano) e con <em>data</em> chiedo a Terraform di poter richiedere le informazioni da AWS. Questo è necessario quando si devono gestire o fare riferimento a risorse già presenti. Per esempio, nel prossimo script (di uso solo didattico) per Terraform non creerò nulla ma richiederò solo informazioni sulla VPC in AWS nella region <em>eu-south-1</em>:</p>
<code>provider "aws" {
region = "eu-south-1"
}
data "aws_availability_zones" "available" {}
data "aws_vpc" "selected" {}
data "aws_subnets" "example" {
filter {
name = "vpc-id"
values = [data.aws_vpc.selected.id]
}
}
data "aws_subnet" "example" {
for_each = toset(data.aws_subnets.example.ids)
id = each.value
}
####################
output "aws-vailable-zones" {
description = "AWS Zones"
value = data.aws_availability_zones.available
}
output "vpc-default" {
description = "AWS Zones"
value = data.aws_vpc.selected
}
output "subnet_cidr_blocks" {
value = [for s in data.aws_subnet.example : s.id]
}</code>
<p>
Nelle sezioni <em>data</em> ho inserito le richieste delle informazioni che voglio da AWS: <em>VPC</em>, <em>zone</em>, <em>subnet</em>. L'output dopo il comando terraform <strong>apply</strong> sarà:</p>
<code>aws-vailable-zones = {
"all_availability_zones" = tobool(null)
"exclude_names" = toset(null) /* of string */
"exclude_zone_ids" = toset(null) /* of string */
"filter" = toset(null) /* of object */
"group_names" = toset([
"eu-south-1",
])
"id" = "eu-south-1"
"names" = tolist([
"eu-south-1a",
"eu-south-1b",
"eu-south-1c",
])
"state" = tostring(null)
"zone_ids" = tolist([
"eus1-az1",
"eus1-az2",
"eus1-az3",
])
}
subnet_cidr_blocks = [
"subnet-4e668127",
"subnet-7216170a",
"subnet-ee99b9a4",
]
vpc-default = {
"arn" = "arn:aws:ec2:eu-south-1:############:vpc/vpc-876582ee"
"cidr_block" = "172.31.0.0/16"
"cidr_block_associations" = tolist([
{
"association_id" = "vpc-cidr-assoc-421afd2b"
"cidr_block" = "172.31.0.0/16"
"state" = "associated"
},
])
"default" = true
"dhcp_options_id" = "dopt-8f6780e6"
"enable_dns_hostnames" = true
"enable_dns_support" = true
"filter" = toset(null) /* of object */
"id" = "vpc-876582ee"
"instance_tenancy" = "default"
"ipv6_association_id" = ""
"ipv6_cidr_block" = ""
"main_route_table_id" = "rtb-791cfb10"
"owner_id" = "############"
"state" = tostring(null)
"tags" = tomap({})
}</code>
<p>
E potrò utilizzare questi dati ovunque mi servano, come nel prossimo file.</p>
<h1>
1-vpc.tf</h1>
<p>
Con il codice seguente creo una nuova VPC:</p>
<code>module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "3.2.0"
name = var.vpc_name
cidr = "10.0.0.0/16"
azs = data.aws_availability_zones.available.names
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.4.0/24", "10.0.5.0/24", "10.0.6.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
enable_dns_hostnames = true
tags = {
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
}
public_subnet_tags = {
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
"kubernetes.io/role/elb" = "1"
}
private_subnet_tags = {
"kubernetes.io/cluster/${var.cluster_name}" = "shared"
"kubernetes.io/role/internal-elb" = "1"
}
}</code>
<p>
In questa VPC ho definito il CIDR che dovrà gestire come le Subnet sottostanti (notare l'uso del data per l'inserimento delle zone disponibile nell'oggetto <em>azs</em>). Creo tre Subnet private e tre pubbliche e un Gateway in modo che dalle Subnet private, dove farò girare le mie macchine per Kubernetes, si potrà accedere a Internet. Nelle tre sezioni di TAGS inserisco delle property necessarie perché possano essere utilizzate da EKS.</p>
<h1>
2-security-groups.tf</h1>
<p>
Per i due <em>Node Group</em> che creerò nel mio cluster devo specificare i rispettivi Security Group. Non avendo esigenze particolari li creo senza inserire nessun permesso:</p>
<code>resource "aws_security_group" "worker_group_mgmt_one" {
name_prefix = "worker_group_mgmt_one"
vpc_id = module.vpc.vpc_id
}
resource "aws_security_group" "worker_group_mgmt_two" {
name_prefix = "worker_group_mgmt_two"
vpc_id = module.vpc.vpc_id
}
resource "aws_security_group" "all_worker_mgmt" {
name_prefix = "all_worker_management"
vpc_id = module.vpc.vpc_id
}</code>
<h1>
3-eks.tf</h1>
<p>
All'interno di questo file vengono create le due Role necessarie per la creazione del cluster:</p>
<code>resource "aws_iam_role" "demo" {
name = "eks-cluster-${var.cluster_name}"
assume_role_policy = jsonencode({
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "eks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
})
}
resource "aws_iam_role" "nodes" {
name = "eks-node-group-nodes-${var.cluster_name}"
assume_role_policy = jsonencode({
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
Version = "2012-10-17"
})
}</code>
<p>
Quindi queste Role vengono assegnate al cluster creato in questa sezione:</p>
<code>resource "aws_eks_cluster" "demo" {
name = var.cluster_name
role_arn = aws_iam_role.demo.arn
version = var.cluster_version
vpc_config {
subnet_ids = module.vpc.private_subnets
}
depends_on = [aws_iam_role_policy_attachment.demo-AmazonEKSClusterPolicy]
}</code>
<p>
Un cluster senza macchine collegate è inutile. In EKS, come scritto sopra, è necessario creare almeno un <em>Node Group</em>. Nel mio esempio ne creo due, ecco solo la definizione per le macchine per MongoDb (la definizione per le due web application è simile):</p>
<code>resource "aws_eks_node_group" "mongodb-nodes" {
cluster_name = aws_eks_cluster.demo.name
node_group_name = "mongodb-nodes"
node_role_arn = aws_iam_role.nodes.arn
subnet_ids = module.vpc.private_subnets
capacity_type = "ON_DEMAND"
instance_types = [var.worker_group_mongodb_instance_type]
scaling_config {
desired_size = var.worker_group_mongodb_desidered_size
max_size = var.worker_group_mongodb_desidered_size
min_size = var.worker_group_mongodb_desidered_size
}
update_config {
max_unavailable = 1
}
labels = {
role = "mongodb"
name = "mongodb"
}
tags = {
name = "mongodb1"
}
depends_on = [
aws_iam_role_policy_attachment.nodes-AmazonEKSWorkerNodePolicy,
aws_iam_role_policy_attachment.nodes-AmazonEKS_CNI_Policy,
aws_iam_role_policy_attachment.nodes-AmazonEC2ContainerRegistryReadOnly,
]
}</code>
<p>
A parte l'assegnazione alla VPC creata, alla tipologia e numero di macchine da creare, ho inserito anche le Labels - <strong>role = "mongodb"</strong> - che utilizzerò per l'assegnazione dei Pod a queste macchine, come mostrerò in seguito.</p>
<p>
Nel file ho inserito anche questa sezione:</p>
<code>data "aws_eks_cluster_auth" "demo" {
name = var.cluster_name
}</code>
<p>
Che sarà usato nel prossimo file.</p>
<h1>4-kubernetes.tf</h1>
<p>
In questo file configuro i provider per Kubernetes e per Helm. Nel post precedente lo avevo fatto inserendo il path alla directory <strong>.kube</strong>, utilizzando AWS devo utilizzare:</p>
<code>provider "kubernetes" {
host = aws_eks_cluster.demo.endpoint
token = data.aws_eks_cluster_auth.demo.token
cluster_ca_certificate = base64decode(aws_eks_cluster.demo.certificate_authority.0.data)
}
provider "helm" {
kubernetes {
host = aws_eks_cluster.demo.endpoint
token = data.aws_eks_cluster_auth.demo.token
cluster_ca_certificate = base64decode(aws_eks_cluster.demo.certificate_authority.0.data)
}
}</code>
<p>
Ed ecco l'uso di <em>aws_eks_cluster_auth</em> grazie al quale riesco ad avere le informazioni necessarie per gestire da Terraform la connessione a Kubernetes.</p>
<p>
I file successivi sono gli stessi mostrati nel post precedente a parte piccole modifiche, come l'<a href="https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity" title="link esterno">assegnazione</a> dei Pod alle macchine volute. Nel caso di MongoDb per l'assegnazione dei Pod al <em>Node Group</em> con la Label <strong>role="mongodb"</strong> ho inserito questo codice:</p>
<code> set {
name = "nodeAffinityPreset.type"
value = "hard"
}
set {
name = "nodeAffinityPreset.key"
value = "role"
}
set {
name = "nodeAffinityPreset.values[0]"
value = "mongodb"
}</code>
<p>
Come in quella documentazione, in Kubernetes ho a disposizione due metodi per l'Affinity ai Node (per la preferenza su quale node creare il Pod). In <em>type</em>, avendo inserito <em>hard</em>, l'Affinity è obbligatoria, mentre con Soft essa è solo una preferenza. Quest'ultimo metodo è comodo in caso il Node dove gira il Pod debba andare offline per qualsiasi ragione. Con la modalità <em>hard</em> il Pod non potrà mai essere ricreato perché l'unico Node con l'Affinity desiderata dal Pod non è disponibile, mentre con <em>soft</em>, non trovando il Node con l'Affinity desiderata sarà scelto un altro con le risorse libere necessarie. Nella configurazione sopra ho inserito l'Affinity per i Node che hanno una Label con la key <strong>role</strong> e come valore <strong>mongodb</strong>. </p>
<p>
Nel caso delle web application, le macchine nel <em>Node Group</em> avranno la Label <strong>role="webapp"</strong>. Nel blocco Deployment ho aggiunto questo codice:</p>
<code>...
spec {
affinity {
node_affinity {
required_during_scheduling_ignored_during_execution {
node_selector_term {
match_expressions {
key = "role"
operator = "In"
values = ["webapp"]
}
}
}
}
}
volume {
...</code>
<p>Che equivale alla regola scritta sopra per il chart di Helm per MongoDb.</p>
<h1>outputs.tf</h1>
<p>
Siccome ho demandato ad AWS la creazione dei Load Balancer, con questo file posso mostrare gli URL che dovrò utilizzare per accedere alle due web application:</p>
<code>output "mongodb-express-dns-name" {
description = "MongoDb password"
value = "http://${kubernetes_service.mongoexpress-service.status[0].load_balancer[0].ingress[0].hostname}"
}
output "testdb-dns-name" {
description = "Api rest port"
value = "http://${kubernetes_service.webapp-service.status[0].load_balancer[0].ingress[0].hostname}/api/info"
}</code>
<h1>
Avviare il tutto</h1>
<p>
Arrivato a questo punto è il momento di avviare la creazione di tutte queste risorse in AWS. In sequenza:</p>
<code>terraform init</code>
<p>
Per il download dei provider necessari.</p>
<code>terraform plan</code>
<p>
Per verificare che la sintassi sia corretta. E solo infine:</p>
<code>terraform apply -auto-approve</code>
<p>
Questa volta la procedura sarà piuttosto lunga. Saranno necessari circa due minuti per la creazione della VPC, quindi dai cinque ai dieci minuti per la creazione del cluster EKS, e dai tre ai cinque minuti per la creazione dei due <em>Node Group</em>. Dopodiché inizierà la configurazione dei Pod e delle altre risorse necessarie all'interno di Kubernetes. E solo infine ecco l'output voluto:</p>
<code>Outputs:
mongodb-express-dns-name = "http://a56ec8e93f6c542ebbce34d149748ae2-93b4730aa8841fd1.elb.eu-south-1.amazonaws.com"
mongodb-password = <sensitive>
namespace = "test-blog-2"
testdb-dns-name = "http://a2a8417bd547347aa807c2af01b99b40-e8ef85a0505fb6f1.elb.eu-south-1.amazonaws.com/api/info"</code>
<p>
Non rimane che provare questi url. Per mongo-express:</p>
<p><img src="/img/andrewz/terraform2/mongo-express-1.png" title="Mongo express in AWS e EKS" width="720" /></p>
<p>
Per la web api:</p>
<p><img src="/img/andrewz/terraform2/api-rest-1.png" title="web api rest in AWS e EKS" width="720" /></p>
<p>
Se si riceve errore quando si richiedono le due web application non si deve avere fretta. Il collegamento dal Load Balancer e le due web application in Kubernetes non è mai immediato e l'attesa è quasi sempre di qualche minuto.</p>
<p>
Se volessi utilizzare Kubernetes in AWS da locale con il classico comando <strong>kubectl</strong>, dovrei usare questo comando da terminale:</p>
<code>aws eks --region eu-south-1 update-kubeconfig --name K8sDemo</code>
<p>
Dove <em>K8sDemo</em> è il nome del cluster EKS creato in AWS (e inserito nelle variabili nei file per Terraform). Ultimo controllo che i Pod siano stati inseriti sulle giuste macchine:</p>
<code>$ kubectl get po -n test-blog-2 -o wide
NAME READY STATUS RESTARTS AGE IP NODE
mongodb-easy-0 1/1 Running 0 22m 10.0.2.81 ip-10-0-2-234.eu-south-1.compute.internal
mongodb-easy-1 1/1 Running 0 22m 10.0.1.149 ip-10-0-1-233.eu-south-1.compute.internal
mongodb-easy-2 1/1 Running 0 21m 10.0.3.90 ip-10-0-3-143.eu-south-1.compute.internal
showdb-858c68d8b4-zld98 1/1 Running 0 9m21s 10.0.3.13 ip-10-0-3-251.eu-south-1.compute.internal
testdb-5454db7d9d-t6594 1/1 Running 0 9m21s 10.0.3.161 ip-10-0-3-251.eu-south-1.compute.internal</code>
<p>
I tre Pod di MongoDb sono avviati sulle tre macchine dedicate, così come le due web application sono avviate entrambe su una sola macchina.</p>
<p>
Controllato il buon funzionamento, distruggo il tutto con il comando:</p>
<code>terraform destroy -auto-approve</code>
<p>
E in una decina di minuti tutto dovrebbe essere cancellato.
In qualche caso può apparire questo errore:</p>
<code>Error: context deadline exceeded</code>
<p>
Sembra che sia dovuto ad un timeout del provider di AWS durante la cancellazione, e
il più delle volte basta rilanciare il comando qui sopra per completare la cancellazione. In altri casi, piuttosto rari, si deve intervenire manualmente cancellando le risorse direttamente dalla console web di AWS.</p>
<h1>
IAM Role ai Pod</h1>
<p>
Tra le feature del cluster di Kubernetes in AWS c'è la possibilità di assegnare delle IAM Role ai singoli Pod. Questo consente di poter accedere a risorse di AWS senza dover utilizzare direttamente delle credenziali che creerebbero poi il problema sulla sicurezza della loro gestione. Tale possibilità non la mostrerò in questo post perché ci sono ancora dei dettagli che non mi sono chiari - mancanza di tempo per approfondire la cosa, come scusa funziona sempre. Comunque maggiori info <a href="https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html" title="link esterno">qui</a> e nel link al video a fine post.</p>
<h1>
Usare altri Cloud</h1>
<p>
Se volessi usare un altro cloud le modifiche sono solo a livello di avvio e configurazione del cluster di Kubernetes. Nel mio caso,
non essendoci la VPC così come in AWS, potrei cancellare completamente i file 1, 2 e 3, e modificare parzialmente il 4 con la configurazione di <a href="https://www.linode.com/products/kubernetes/" title="link esterno">LKE</a> (il cluster di Kubernetes in Linode) con queste modifiche:</p>
<code>terraform {
required_providers {
linode = {
source = "linode/linode"
}
}
}
# Configure the Linode Provider
provider "linode" {
token = "################################################################"
}
resource "linode_lke_cluster" "mycluster" {
label = "my-cluster"
k8s_version = "1.23"
region = "eu-central"
tags = ["prod"]
control_plane {
high_availability = false
}
pool {
type = "g6-standard-1"
count = 4
}
}
resource "local_file" "config" {
content_base64 = linode_lke_cluster.mycluster.kubeconfig
filename = "${path.module}/config.txt"
}
provider "kubernetes" {
config_path = local_file.config.filename
}
resource "kubernetes_namespace" "test" {
metadata {
name = "my-test-123455"
}
}</code>
<p>
Qui ho dovuto salvare il file di configurazione per l'accesso al cluster in un file locale (grazie alla <em>resource</em> di Terraform <a href="https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file" title="link esterno">local_file</a>) il cui percorso completo l'ho inserito come parametro in <em>config_path</em> (lo stesso file lo potrò utilizzare poi per accedere al cluster da locale).</p>
<h1>
Sicurezza</h1>
<p>
Come mostrato nel post precedente l'inserimento di password all'interno di Terraform è sempre problematico. Mi è stato suggerito di utilizzare i vari servizi di Secret dei vari cloud, in modo che è possibile creare manualmente un <a href="https://aws.amazon.com/it/secrets-manager/" title="link esterno">Secret</a> contenente le credenziali poi da usare nella creazione di servizi che poi dovrebbero utilizzarle. Nel caso della mia demo la cosa non risolve il problema. Ipotizzando di avere un Secret in AWS dal nome <strong>example</strong>, è possibile leggerne il contenuto con questo codice in Terraform:</p>
<code>terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 3.0"
}
}
}
# Configure the AWS Provider
provider "aws" {
region = "eu-south-1"
}
data "aws_secretsmanager_secret" "byname" {
name = "example"
}
data "aws_secretsmanager_secret_version" "secretversion" {
secret_id = data.aws_secretsmanager_secret.byname.id
}
output "example1" {
description = "Secret Metadata"
value = data.aws_secretsmanager_secret.byname
}
output "example2" {
description = "Secret Content"
value = jsondecode(data.aws_secretsmanager_secret_version.secretversion.secret_string)
sensitive = true
}</code>
<p>
Il problema è che leggendo i file di stato creato da Terraform vedrei il contenuto in chiaro:</p>
<code>"instances": [
{
"schema_version": 0,
"attributes": {
"arn": "arn:aws:secretsmanager:eu-south-1:838080890745:secret:example-pcyzfF",
"id": "arn:aws:secretsmanager:eu-south-1:838080890745:secret:example-pcyzfF|AWSCURRENT",
"secret_binary": "",
"secret_id": "arn:aws:secretsmanager:eu-south-1:838080890745:secret:example-pcyzfF",
"secret_string": "{\n \"test1\": \"1234\",\n \"test2\": \"5678\"\n}",
"version_id": "35fb8d94-b96c-4419-92ef-fd7e324e3470",
"version_stage": "AWSCURRENT",
"version_stages": [
"AWSCURRENT"
]
},
"sensitive_attributes": []
}
]
</code>
<p>
La soluzione è utilizzare servizi appositi per la gestione dello State, visto che lo State Storage su S3 permette anche la crittografazione del contenuto:</p>
<p>
<a href="https://www.terraform.io/language/settings/backends/s3#encrypt" title="link esterno">https://www.terraform.io/language/settings/backends/s3#encrypt</a></p>
<h1>
Critiche e apprezzamenti di Terraform con il Cloud</h1>
<p>
In passato ho avuto a che fare con chi vendeva Terraform come passepartout per tutti i Cloud. Come se costruiti dei file per la configurazione di risorse da utilizzare in AWS bastasse modificare il provider per poterli utilizzare con altri Cloud (Azure, Google, etc...). Ovviamente non così - e sarebbe da pazzi crederlo, almeno attualmente. E' sufficiente vedere il solo esempio sopra, dove ho voluto utilizzare gli stessi file di Terraform con due Cloud differenti, in cui ho dovuto stravolgere quasi tutti i file - a parte Kubernetes, anche se avrei dovuto rimuovere le Affinity. Questo sconvolgimento sarebbe stato altresì completo anche se avessi usato la controparte di Azure per la costruzione di una rete interna <a href="https://docs.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview" title="link esterno">VNet</a>. Lasciando perdere questa facile critica, di contro, è da apprezzare la possibilità di avere uno strumento unico per la creazione di risorse completamente differenti su piattaforme agli antipodi (esagerando). Senza strumenti come Terraform, per la creazione di risorse su AWS e Azure, avrei dovuto utilizzare il comando da terminale <strong>aws</strong>
(lo stesso usato all'inizio del post - <strong>aws configure</strong>) e il comando <strong>az</strong> di Azure. Inoltre lo scambio di informazioni tra i due Cloud con quei comandi porterebbe ad una complicazione inutile che Terraform risolve facilmente. Ergo: Terraform promosso.</p>
<h1>
Fine</h1>
<p>
Ecco il repository con il <a href="https://github.com/sbraer/terraform_mongodb_example_aws" title="link esterno">codice</a>. <a href="https://www.youtube.com/watch?v=MZyrxzb7yAU" title="link esterno">Qui</a> un video illuminante su quanto esposto che mi ha aiutato a risolvere alcuni dubbi. Ci saranno altri post dedicati a Terraform? Perché no? Magari con qualcosa di più simpatico e utile - forse.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2913/Terraform-Kuberntes-AWS-EKS.aspx"><em>Terraform, Kuberntes e AWS EKS</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2913/Terraform-Kuberntes-AWS-EKS.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2913.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2913Terraform e Kuberneteshttps://blogs.aspitalia.com/az/post2912/Terraform-Kubernetes.aspx2022-05-13T17:30:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2912" border="0" style="width:1px; height:1px;" /> <p>
<a href="https://www.terraform.io/" title="link esterno">Terraform</a> è uno dei più popolari tool per l'IAC (Infrastructure As Code). Semplificando, Terraform è un unico eseguibile che, leggendo dei file di testo nel formato HCL (HashiCorp Configuration Language) si interfaccia con dei provider appositi per la costruzione di risorse per le più svariate piattaforme. Esistono provider per pressocché tutti i Cloud, ma anche per altre piattaforme come Kubernetes, Consul, Helm, Splunk, etc...</p>
<p>
Attualmente i <a href="https://registry.terraform.io/" title="link esterno">provider</a> ufficiali sono 35, quelli verificati 203, quelli della community 1832 (molti di essi abbandonati o inaffidabili). Scopo di questi provider è convertire le configurazioni scritte in HCL e inviarle al servizio apposito per la gestione di risorse. Nel caso del Cloud, avendo un provider (ufficiale) per AWS, è possibile definire in HCL l'infrastruttura di cui si ha necessità e sarà poi Terraform con il provider a pensare al resto, dall'aggiunta, alla modifica, alla cancellazione. E' possibile gestire il Multi Cloud con un unico strumento visto che si potrebbe creare un'infrastruttura in AWS e Azure contemporaneamente con un solo comando. Inoltre, potendo Terraform leggere le informazioni delle strutture create grazie ai provider, può inviare informazioni più Cloud - esempio banale, creato un database Sql di Azure è possibile inviare IP/DNS con le credenziali ad una Lambda creata contemporaneamente in AWS che necessita dell'accesso a quel database.</p>
<h1>
Pure Kubernetes?</h1>
<p>
Esiste un provider anche per Kubernetes. Confesso che l'ho iniziato a utilizzare da poco tempo perché ero purtroppo scettico dei vantaggi che avrebbe potuto darmi la gestione delle risorse in Kubernetes con Terraform. E posso dire di essermi quasi ricreduto. Solo perché questo post è pubblico, un po' di <strong>ABC</strong>.</p>
<p>
Innanzitutto per provare il tutto sono necessari questi strumenti:</p>
<ul><li><strong>terraform</strong> (tool scaricabile nelle varie versioni per tutti i sistemi operativi, almeno la versione 1.0) scaricabile da <a href="https://www.terraform.io/downloads" title="link esterno">qui</a>.</li>
<li><strong>Kubernetes</strong> avviato e funzionante (Docker Desktop per Windows, Minukube etc...)</li></ul>
<p>
Nel public registry di Terraform cerco il provider per Kubernetes:</p>
<p>
<a href="https://registry.terraform.io/providers/hashicorp/kubernetes/latest" title="link esterno">https://registry.terraform.io/providers/hashicorp/kubernetes/latest</a></p>
<p>
E' disponibile anche il <a href="https://github.com/hashicorp/terraform-provider-kubernetes" title="link esterno">link</a> al progetto in Github di questo provider con informazioni utili, tra cui anche esempi e documentazione sul suo utilizzo. Quindi creo la prima parte del file per terraform (<strong>main.tf</strong>):</p>
<code>terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.0.0"
}
}
}
provider "kubernetes" {
config_path = "~/.kube/config"
}</code>
<p>
La sintassi di HCL è simile al JSON ma ha caratteristiche tutte sue e discutibili. Tralasciando polemiche inutili nella prima parte - <em>required_providers</em> - specifico i provider che voglio utilizzare e la versione (opzionale). Quindi nella sezione provider posso inserire la configurazione che sarà utilizzata da questo provider; in questo caso ho inserito il il path dove solitamente Kubernetes inserisce i file (<em>config</em>, etc...) per poter essere poi utilizzato da tool come <em>kubectl</em>.</p>
<p>
Da console avvio il comando <strong>terraform init</strong> che dovrebbe dare in risultato come il seguente:</p>
<code>> terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/kubernetes versions matching ">= 2.0.0"...
- Installing hashicorp/kubernetes v2.11.0...
- Installed hashicorp/kubernetes v2.11.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
...</code>
<p>
Ora è il momento di creare qualcosa in Kubernetes con Terraform. Per fare questo utilizzo il blocco Resource (che aggiungo al file precedente):</p>
<code>resource "kubernetes_namespace" "test" {
metadata {
name = "test-ABC"
}
}</code>
<p>
Con queste poche righe di codice voglio creare un nuovo namespace in Kubernetes.
Ora da console controllo se tutto è corretto prima della creazione effettiva. Si utilizza il comando <strong>terraform plan</strong>:</p>
<code>> terraform plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# kubernetes_namespace.test will be created
+ resource "kubernetes_namespace" "test" {
+ id = (known after apply)
+ metadata {
+ generation = (known after apply)
+ name = "test-abc"
+ resource_version = (known after apply)
+ uid = (known after apply)
}
}
Plan: 1 to add, 0 to change, 0 to destroy.</code>
<p>
Se non ci sono messaggi di errore è il momento della creazione del nuovo namespace in Kubernetes con Terraform:</p>
<code>> terraform apply
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# kubernetes_namespace.test will be created
+ resource "kubernetes_namespace" "test" {
+ id = (known after apply)
+ metadata {
+ generation = (known after apply)
+ resource_version = (known after apply)
+ uid = (known after apply)
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
kubernetes_namespace.test: Creating...
kubernetes_namespace.test: Creation complete after 0s [id=test-abc]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
</code>
<p>Durante la creazione viene chiesta conferma (è possibile aggiungere al comando terraform l'opzione <em>-auto-approve</em> per scavalcare questa richiesta). Infine faccio la verifica con <em>kubectl</em>:</p>
<code>> kubectl get ns
NAME STATUS AGE
default Active 7d17h
kube-node-lease Active 7d17h
kube-public Active 7d17h
kube-system Active 7d17h
test-abc Active 5s</code>
<p>
Se volessi distruggere la risorsa appena creata:</p>
<code>terraform destroy</code>
<p>
Con il comando <strong>plan</strong> o prima della creazione effettiva si può notare come Terraform mostri informazioni sulle risorse che saranno gestite:</p>
<code> + resource "kubernetes_namespace" "test" {
+ id = (known after apply)
+ metadata {
+ generation = (known after apply)
+ resource_version = (known after apply)
+ uid = (known after apply)
}
}</code>
<p>
Queste informazioni possono essere utilizzate anche da altre Resource create o per essere utilizzate direttamente. Per esempio, se avessi voluto visualizzarle alla fine della creazione avrei dovuto usare in Terraform una o più sezioni <em>output</em> (da aggiungere al file):</p>
<code>output "namespace-id" {
description = "Id per test-abc"
value = kubernetes_namespace.test.id
}
output "namespace-uid" {
description = "uid per test-abc"
value = kubernetes_namespace.test.metadata[0].uid
}</code>
<p>
Ora posso richiamare ancora il comando <strong>apply</strong> (che non farà nessuna modifica) e visualizzerà le informazioni:</p>
<code>Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
Outputs:
namespace-id = "test-abc"
namespace-uid = "5f0cc05f-8a8a-43fc-82e1-5349a86b20cf"</code>
<p>
Il formalismo per prendere questi valori da una resource è <strong>[tipo_risorsa].[nome_risorsa].[property_risorsa]</strong>...</p>
<p>
Questo apre un'importante caratteristica di Terraform: le dipendenze. Queste possono essere implicite o esplicite. Complicando leggermente l'esempio qui sopra, aggiungo un altro provider:</p>
<code>terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.0.0"
}
random = {
source = "hashicorp/random"
version = ">= 3.0.0"
}
}
}</code>
<p>
Il provider random è utile perché permette di creare stringhe random (utili per password), sequenze casuali per array, etc... Ora voglio aggiungere una stringa casuale al nuovo namespace in Annotation (dove posso inserire qualsiasi stringa, utile per il mio test). All'interno del provider Random sono presenti più tipi di Resource, io utilizzerò quello per creare una password (<em>random_password</em>):</p>
<code>resource "random_password" "mypassword" {
count = 1
length = 16
special = true
}</code>
<p>
Una volta eseguito, <em>mypassword</em> conterrà una password di lunghezza 16 caratteri contenente anche caratteri speciali (info al provider).</p>
<p>
Ora modifico la creazione del namespace:</p>
<code>resource "kubernetes_namespace" "test" {
metadata {
name = "test-abc-2"
annotations = {
custom_text = "Stringa random: ${random_password.mypassword.0.result}"
}
}
}</code>
<p>
In annotations ho inserito la key <em>custom_text</em> con la mia stringa casuale. Ora lancio il comando terminal <strong>apply</strong> e verifico in Kubernetes il risultato:</p>
<code>> kubectl describe ns test-abc-2
Name: test-abc-2
Labels: kubernetes.io/metadata.name=test-abc-2
Annotations: custom_text: Stringa random: #FAGUPP9jlO1Xbi@
Status: Active</code>
<p>
Non ho inserito come output il risultato della stringa casuale perché essendo creato con l'oggetto password potrebbe contenere informazioni importanti e Terraform bloccherebbe immediatamente il tutto. Inserendo nel mio file:</p>
<code>output "namespace-annotation" {
description = "annotation text"
value = kubernetes_namespace.test.metadata[0].annotations.custom_text
}</code>
<p>
L'apply di Terraform visualizzerà questo errore:</p>
<code>Error: Output refers to sensitive values
│
│ on main.tf line 43:
│ 43: output "namespace-annotation" {
│
│ To reduce the risk of accidentally exporting sensitive data that was intended to be only internal, Terraform requires that any root module output containing sensitive data be explicitly
│ marked as sensitive, to confirm your intent.
│
│ If you do intend to export this data, annotate the output value as sensitive by adding the following argument:
│ sensitive = true</code>
<p>
Aggiungendo la property <em>sensitive = true</em> come suggerito nel messaggio di errore, <em>output</em> visualizzerà degli asterischi al posto del contenuto.</p>
<p>
Questo esempio mostra una dipendenza implicita. Nella Resource per il namespace, dovendo utilizzare il risultato di un altro Resource (password) ha atteso il risultato di quest'ultima prima della sua creazione. Altrimenti Terraform tenta di creare sempre tutte le risorse in parallelo per velocizzare il tutto. Se, invece, le risorse non sono direttamente dipendenti si può dichiarare esplicitamente una o più dipendenze:</p>
<code>resource "kubernetes_namespace" "test" {
metadata {
name = "test-abc-2"
annotations = {
custom_text = "Stringa random: ${random_password.mypassword.0.result}"
}
}
depends_on = [random_password.mypassword]
}</code>
<p>
Molto utile nel caso si debba avviare nel cloud un database e solo dopo la sua creazione avviare applicazioni che lo utilizzano.</p>
<h1>
Ritorno al passato</h1>
<p>
In questo vecchio mio <a href="https://blogs.aspitalia.com/az/post2895/Kubernetes-MongoDb-Replica-Set.aspx" title="link post precedente">post</a> avevo avviato con solo i file di configurazione di Kubernetes tre istanze in <em>replica set</em> per MongoDb e due web application che lo utilizzavano come datasource. Sempre in quel post avevo mostrato la discreta complessità della configurazione di MongoDb in cui avevo inserito anche degli script in bash per controllare e impostare correttamente il tutto. Un modo molto più semplice per ottenere lo stesso risultato con MongoDb è l'utilizzo di <a href="https://helm.sh/" title="link esterno">Helm</a>. Helm è un tool per il deployment per Kubernetes in cui sono presenti numerose configurazioni (charts) già pronte e funzionanti da utilizzare. E' presente anche un chart per MongoDb per la sua configurazione in <em>replica set</em> a questo <a href="https://bitnami.com/stack/mongodb/helm" title="link esterno">link</a>. Il tuo utilizzo semplifica quanto da me fatto in quel post e, volendo replicare di seguito quanto fatto allora con Terraform, perché non usare pure Helm visto che è tra i provider presenti?</p>
<h1>
Mettere ordine</h1>
<p>
Anche se è possibile creare un unico file in HCL per Terraform per creare il tutto, è consigliato la creazione di più file con specifici nomi per rendere più leggibile il tutto e per poter poi utilizzare la configurazione creata come modulo. In Terraform viene consigliata la creazione di almeno questi tre file:</p>
<ul><li>main.tf</li>
<li>variables.tf</li>
<li>outputs.tf</li>
</ul>
<p>
In aggiunta è consigliata la presenza anche di questi file:</p>
<ul>
<li>provider.tf</li>
<li>versions.tf</li>
<li>*.tfvars</li></ul>
<h1>
variables.tf</h1>
<p>
E' il file dove inserire tutte le variabili che si vogliono utilizzare nel proprio script. Ecco un esempio reale questo tipo di file:</p>
<code>variable "namespace_name" {
description = "Name for the namespace"
type = string
default = "test-blog"
}
variable "mongodb_username" {
description = "username used for mongodb authentication"
type = string
default = "myuser"
}
variable "mongodb-express-port" {
description = "port for mongoexpress web application"
type = number
default = 30165
}
variable "testdb-port" {
description = "port for testdb web application"
type = number
default = 30164
}</code>
<p>
<em>description</em>, <em>default</em> e <em>type</em> sono tutti opzionali. Nel caso <em>type</em> non fosse specificato sarà usato come tipo di variabile Any. <a href="https://www.terraform.io/language/values/variables" title="link esterno">Qui</a> dettagli sulle specifiche di ogni attributo. In <em>default</em> è possibile inserire i valori voluti ma è possibile utilizzare il file <strong>*.tfvars</strong> apposito per la specifica dei valori da utilizzare. Per esempio, creando il file <strong>testing.tfvars</strong> con questo valori:</p>
<code>namespace_name=new_application
mongodb_username=az</code>
<p>
Con il comando Terraform posso specificare l'utilizzo di quel file per l'assegnazione dei valori delle variabili:</p>
<code>terraform apply -var-file="testing.tfvars"</code>
<p>
Questo permette la creazione di più file di configurazione da utilizzare con lo stesso modulo, come per la creazione di ambienti di test etc...</p>
<h1>
versions.tf</h1>
<p>
In questo file è possibile inserire tutti i provider da utilizzare. Ecco il mio esempio in cui includo tre provider:</p>
<code>terraform {
required_providers {
helm = {
source = "hashicorp/helm"
version = ">= 2.0.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.0.0"
}
random = {
source = "hashicorp/random"
version = "3.0.1"
}
}
}</code>
<p>
Ho inserito i tre provider che mi servono: per <a href="https://registry.terraform.io/providers/hashicorp/helm/" title="link esterno">Helm</a>, per <a href="https://registry.terraform.io/providers/hashicorp/kubernetes/" title="link esterno">Kubernetes</a> e per i valori <a href="https://registry.terraform.io/providers/hashicorp/random/" title="link esterno">random</a> con le relative versioni volute.</p>
<h1>
providers.tf</h1>
<p>
Qui eventuali configurazioni dei singoli provider:</p>
<code>provider "kubernetes" {
config_path = "~/.kube/config"
}
provider "helm" {
kubernetes {
config_path = "~/.kube/config"
}
}</code>
<p>
Nel caso di Kuberrnetes e Helm ho dovuto inserire il <em>config_path</em> dove il provider troverà le informazioni per l'accesso al cluster. Per la configurazione ho letto la <a href="https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs/guides/getting-started" title="link esterno">documentazione</a> ufficiale del provider dove si trovano i vari esempi di utilizzo.</p>
<h1>
outputs.tf</h1>
<p>
Come spiegato prima, qui vengono specificate le variabili di output del modulo da visualizzare:</p>
<code>output "mongodb-password" {
description = "MongoDb password"
value = random_password.password[0].result
sensitive = true
}
output "mongodb-express-port" {
description = "MongoDb express port"
value = kubernetes_service.mongoexpress-service.spec[0].port[0].node_port
}
output "testdb-port" {
description = "Api rest port"
value = kubernetes_service.testdb-service.spec[0].port[0].node_port
}
output "namespace" {
description = "Namespace used"
value = kubernetes_namespace.test.metadata.0.name
}</code>
<p>
Le due web application saranno esposte pubblicamente il servizio NodePort di Kubernetes, quindi ho solo la necessità di visualizzare le porte che saranno utilizzate (anche se sono presenti nel file <strong>variables.tf</strong>).</p>
<h1>
main.tf</h1>
<p>
Ora si comincia a fare sul serio. Qui possiamo inserire il contenuto reale per la creazione di tutte le risorse necessarie. Io inserirò la creazione di tre Resource:</p>
<code>resource "kubernetes_namespace" "test" {
metadata {
name = var.namespace_name
}
}
resource "random_password" "password" {
count = 1
length = 32
special = false
}
resource "kubernetes_secret" "credentials1" {
metadata {
name = "credentials1"
namespace = kubernetes_namespace.test.metadata.0.name
}
data = {
"username" = var.mongodb_username
"mongodb-password" = random_password.password.0.result
"mongodb-root-password" = random_password.password.0.result
"mongodb-replica-set-key" = "ba436283462dcaada36367"
}
type = "Opaque"
depends_on = [random_password.password]
}</code>
<p>
Che farà in modo che Terraform crei un namespace nel cluster di Kubernetes dove inserirò tutte le risorse necessarie. Quindi con il provider random creo una password di 32 caratteri alfanumerici (senza caratteri speciali) che utilizzo per la creazione di un Secret in Kubernetes per l'inserimento delle credenziali per l'accesso a MongoDb. Tra queste due Resource c'è una dipendenza implicita, e il Secret non sarà creato fino a quando Random non creerà la password
richiesta.</p>
<p>
Come già scritto, l'uso di questi file è un semplice formalismo visto che, quando viene richiamato il comando Terraform, lui cerca in tutti i file *.tf della directory attuale. In progetti più corposi e reali qui si possono definire tutti i moduli che saranno utilizzati.</p>
<p>
Ipotizzando che la struttura qui sopra sia in una sottodirectory di un progetto più grosso, dal file <strong>main.tf</strong> della <em>root</em> avrei potuto include il mio modulo con questo codice:</p>
<code>module "azkubernetestest" {
source = "./mia_directory_module" # path del mio modulo relativo alla root
namespace_name = "mio-namespace"
mongodb_username = "mongodbuser"
mongodb-express-port = 30000
testdb-port = 30001
}</code>
<p>
E il mio modulo e tutte le risorse sarebbero state create con le variabili passate nella sezione Module.</p>
<h1>mongodb-helm.tf</h1>
<p>
In questo file ho inserito la configurazione di Helm per la creazione delle tre istanze di MongoDb in <em>replica set</em>. Come da <a href="https://github.com/bitnami/charts/tree/master/bitnami/mongodb" title="link esterno">documentazione</a> ho inserito i parametri necessari per la creazione del numero di istanze volute e per il passaggio delle variabili:</p>
<code>resource "helm_release" "mongodb1" {
name = "mongodb-easy"
repository = "https://charts.bitnami.com/bitnami"
chart = "mongodb"
version = "12.0.0"
set {
name = "global.namespaceOverride"
value = kubernetes_namespace.test.metadata.0.name
}
set {
name = "architecture"
value = "replicaset"
}
set {
name = "auth.rootUser"
value = var.mongodb_username
}
set {
name = "auth.existingSecret"
value = "credentials1"
}
set {
name = "auth.existingSecret"
value = "credentials1"
}
set {
name = "image.tag"
value = "4.4.13-debian-10-r51"
}
set {
name = "persistence.enabled"
value = false
}
set {
name = "arbiter.enabled"
value = false
}
set {
name = "replicaCount"
value = 3
}
depends_on = [kubernetes_secret.credentials1]
}</code>
<p>
Nella dichiarazione del chart è possibile inserire anche la versione desiderata. Helm permetta la configurazione degli oggetti in Kubernetes con le variabili, cosa che eseguo con la sintassi di Terraform nel quale inserisco anche il nome del namespace dove inserire le risorse create, il nome del Secret da cui prenderà le informazioni - <em>auth.existingSecret</em> - e infine ho dovuto inserire la dipendenza esplicita al Secret perché voglio che le istanze di MongoDb vengano create solo dopo la sua creazione.</p>
<h1>mongo-express.tf</h1>
<p>
In questo file definisco il Deployment della web application <em>mongo-express</em> così come avevo fatto in quel post. Tutta la configurazione scritta all'epoca in yaml compatibile con il comando <em>kubectl</em> l'ho dovuta riscrivere nel formato accettato da Terraform e dal provider di Kubernetes:</p>
<code>resource "kubernetes_deployment" "mongoexpress" {
metadata {
name = "mongoexpress"
namespace = kubernetes_namespace.test.metadata.0.name
}
spec {
replicas = 1
selector {
match_labels = {
app = "mongoexpress"
}
}
template {
metadata {
labels = {
app = "mongoexpress"
}
annotations = {
custom_text = "Mongoexpress web application"
}
}
spec {
volume {
name = "secretvolume"
secret {
secret_name = "credentials1"
}
}
container {
image = "mkucuk20/mongo-express"
name = "mongoexpress"
resources {
limits = {
memory = "100Mi"
#cpu =
}
}
env {
name= "ME_CONFIG_MONGODB_ADMINUSERNAME_FILE"
value="/etc/secretvolume/username"
}
env {
name= "ME_CONFIG_MONGODB_ADMINPASSWORD_FILE"
value = "/etc/secretvolume/mongodb-root-password"
}
env {
name= "ME_CONFIG_MONGODB_SERVER"
value="mongodb-easy-0.mongodb-easy-headless,mongodb-easy-1.mongodb-easy-headless,mongodb-easy-2.mongodb-easy-headless"
}
volume_mount {
name = "secretvolume"
read_only = true
mount_path = "/etc/secretvolume"
}
}
}
}
}
depends_on = [helm_release.mongodb1, kubernetes_secret.credentials1]
}
resource "kubernetes_service" "mongoexpress-service" {
metadata {
name = "mongoexpress-service"
namespace = kubernetes_namespace.test.metadata.0.name
}
spec {
selector = {
app = "mongoexpress"
}
port {
port = 80
target_port = 8081
node_port = var.mongodb-express-port
}
type ="NodePort"
}
depends_on = [kubernetes_deployment.mongoexpress]
}</code>
<p>
Non si discosta molto dalla versione originale, e anche in questo caso, anche se non strettamente necessario, ho inserito le dipendenze esplicite che mi permettono di creare questa web application SOLO quando le istanze di MongoDb sono effettivamente avviate.
Nella seconda parte dello script, la creazione del service di tipo NodePort.</p>
<h1>webapi.tf</h1>
<p>
Ecco l'ultima risorsa creata, come per mongo-express creo un deployment in Kubernetes per l'applicazione (web api rest) che avevo scritto qualche anno fa e che girava con Net Core 3.0:</p>
<code>resource "kubernetes_deployment" "testdb" {
metadata {
name = "testdb"
namespace = kubernetes_namespace.test.metadata.0.name
}
spec {
replicas = 1
selector {
match_labels = {
app = "testdb"
}
}
template {
metadata {
labels = {
app = "testdb"
}
annotations = {
custom_test = "Wep application rest to test MongoDb"
}
}
spec {
volume {
name = "secretvolume"
secret {
secret_name = "credentials1"
}
}
container {
image = "sbraer/mongodbtest:v1"
name = "testdb"
port {
protocol = "TCP"
container_port = 5000
}
resources {
limits = {
memory = "100Mi"
#cpu =
}
}
env {
name= "MONGODB_SERVER_USERNAME_FILE"
value = "/etc/secretvolume/username"
}
env {
name= "MONGODB_SERVER_PASSWORD_FILE"
value = "/etc/secretvolume/mongodb-root-password"
}
env {
name= "MONGODB_SERVER_LIST"
value="mongodb-easy-0.mongodb-easy-headless,mongodb-easy-1.mongodb-easy-headless,mongodb-easy-2.mongodb-easy-headless"
}
env {
name= "MONGODB_REPLICA_SET"
value="rs0"
}
env {
name= "MONGODB_DATABASE_NAME"
value="MyDatabase"
}
env {
name= "MONGODB_BOOKS_COLLECTION_NAME"
value="MyTest"
}
env {
name= "TMPDIR"
value="/tmp"
}
volume_mount {
name = "secretvolume"
read_only = true
mount_path = "/etc/secretvolume"
}
}
}
}
}
depends_on = [helm_release.mongodb1, kubernetes_secret.credentials1]
}
resource "kubernetes_service" "testdb-service" {
metadata {
name = "testdb-service"
namespace = kubernetes_namespace.test.metadata.0.name
}
spec {
selector = {
app = "testdb"
}
port {
port = 80
target_port = 5000
node_port = var.testdb-port
}
type = "NodePort"
}
depends_on = [kubernetes_deployment.testdb]
}</code>
<p>
Nulla di nuovo, anche in questo caso avvio una web application e il relativo servizio NodePort per rendere pubblica l'applicazione.</p>
<h1>
Avviare il tutto</h1>
<p>
E' il momento di controllare il funzionamento. Come spiegato nell'esempio iniziale, primo passo è con l'opzione <strong>init</strong>:</p>
<code>> terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/random versions matching "3.0.1"...
- Finding hashicorp/helm versions matching ">= 2.0.0"...
- Finding hashicorp/kubernetes versions matching ">= 2.0.0"...
- Installing hashicorp/kubernetes v2.11.0...
- Installed hashicorp/kubernetes v2.11.0 (signed by HashiCorp)
- Installing hashicorp/random v3.0.1...
- Installed hashicorp/random v3.0.1 (signed by HashiCorp)
- Installing hashicorp/helm v2.5.1...
- Installed hashicorp/helm v2.5.1 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
...</code>
<p>
E' possibile eseguire il plan, e se tutto è corretto lanciare il comando:</p>
<code>terraform apply -auto-approve</code>
<p>
Che creerà il tutto senza chiedere conferma (rimovuore l'opzione <strong>-auto-approve</strong> in caso si voglia proseguire solo dopo la conferma manuale). Alla fine delle operazioni, si dovrebbe avere un risultato come il seguente:</p>
<code>Apply complete! Resources: 8 added, 0 changed, 0 destroyed.
Outputs:
mongodb-express-port = 30165
mongodb-password = <sensitive>
namespace = "test-blog"
testdb-port = 30164</code>
<p>
Sconsiglio di eseguire la creazione di tutte queste risorse su una macchina con poca potenza e con pochi gigabyte di memoria: si otterrebbero errori nella creazioni dei Pod per l'insufficiente quantitativo di ram o errori e riavvii degli stessi per la bassa potenza della CPU.</p>
<p>
Se si è ottenuto il risultato come sopra, si può verificare da console interrogando direttamente Kubernetes:</p>
<code>> kubectl -n test-blog get all
NAME READY STATUS RESTARTS AGE
pod/mongodb-easy-0 1/1 Running 0 4m54s
pod/mongodb-easy-1 1/1 Running 0 4m41s
pod/mongodb-easy-2 1/1 Running 0 4m28s
pod/mongoexpress-bc6f47b7d-z6qk7 1/1 Running 0 4m13s
pod/testdb-c7465865d-h748w 1/1 Running 0 4m13s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/mongodb-easy-headless ClusterIP None <none> 27017/TCP 4m54s
service/mongoexpress-service NodePort 10.110.210.160 <none> 80:30165/TCP 4m6s
service/testdb-service NodePort 10.108.55.54 <none> 80:30164/TCP 4m10s
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/mongoexpress 1/1 1 1 4m14s
deployment.apps/testdb 1/1 1 1 4m14s
NAME DESIRED CURRENT READY AGE
replicaset.apps/mongoexpress-bc6f47b7d 1 1 1 4m14s
replicaset.apps/testdb-c7465865d 1 1 1 4m14s
NAME READY AGE
statefulset.apps/mongodb-easy 3/3 4m54s</code>
<p>
E prova finale, richiamare l'url: <a href="http://localhost:30624/api/info" title="link to test web api">http://localhost:30164/api/info</a></p>
<p><img src="/img/andrewz/terraform1/api_rest_1.png" title="web api from kuberntes and terraform"/></p>
<p>
Quindi è possibile provare anche mongo-express con il link: <a href="http://localhost:30165">http://localhost:30165</a></p>
<p>
<a title="Mongo Express" src="/img/andrewz/terraform1/mongo_express_1.png"><img width="620" title="Mongo Express" src="/img/andrewz/terraform1/mongo_express_1.png" /></a></p>
<p>
Per eseguire un ulteriore test, così come feci nel vecchio post, creo un database di nome <em>MyDatabase</em>, al suo interno creo la collection <em>MyTest</em> e inserisco questo Document:</p>
<code>{
"Name": "I promessi sposi",
"Author": "Alessandro Manzoni",
"Price":19.99,
"Category": "Classici italiani"
}</code>
<p>
Ora verifico dalla web api se posso leggere questi dati. Richiamo il link: <a href="http://localhost:30264/api/books" title="link to test webapi to mongodb">http://localhost:30264/api/books</a></p>
<p><img src="/img/andrewz/terraform1/api_rest_2.png" title="web api from kuberntes and terraform to Mongodb"/></p>
<p>Soddisfatto del buon funzionamento, distruggo tutto con il comando:</p>
<code>terraform destroy -auto-approve</code>
<h1>Conclusioni e critiche</h1>
<p>In questo breve post non ho trattato un altro importante argomento di Terraform: il mantenimento dello stato. Tutti i comandi visti finora creano, nella stessa directory dove sono lanciati, dei file interni usati da Terraform per mantenere lo stato attuale delle risorse create. Ad iniziare dal file <strong>terraform.tfstate</strong> che si può aprire con un qualsiasi text editor e in cui si può trovare un JSON con tutte le informazioni, comprese password create dinamicamente, come nel mio caso:</p>
<code>"data": {
"mongodb-password": "AB6bplfTd19N2ijzRqKIWdgQIjfXUblR",
"mongodb-replica-set-key": "ba436283462dcaada36367",
"mongodb-root-password": "AB6bplfTd19N2ijzRqKIWdgQIjfXUblR",
"username": "myuser"
}</code>
<p>Oltre a questo file si può trovare anche il file <strong>.terraform.lock.hcl</strong> e la directory <strong>.terraform</strong> contenente altre informazioni dello stato e per il lock delle risorse create. La creazione di questi file è ovviamente necessaria (senza di essi è impossibile eseguire modifiche o cancellazioni di quanto è stato creato con Terraform), e il loro mantenimento in locale è corretto solo per prove locali come quella che ho appena fatto. Soluzione alternativa è inserire questi file in un repository Git in modo che sia sempre utilizzabile, ma questo non blocca la possibilità che più utenti possano lavorare sullo stesso progetto ed entrambi inseriscano modifiche non condivise. Soluzioni più funzionali sono l'uso di servizi appositi come il <a href="https://www.hashicorp.com/products/terraform" title="link esterno">Terraform Cloud</a>, oppure, con AWS, è possibile <a href="https://www.terraform.io/language/settings/backends/s3" title="link esterno">utilizzare</a> S3 e DynamoDb per salvare tutte queste
informazioni.</p>
<p>Infine alcune critiche: tutto ciò che è creato da Terraform dev'essere gestito da Terraform. Di questo dettaglio ci si accorge abbastanza velocemente quando, creata una risorsa, ci si mette poi mano manualmente per una rifinitura, e quindi si riutilizza Terraform che, non riuscirà (spesso) a riconoscere quella risorsa (avendola noi modificata a mano) e vorrà ricrearla da capo. Unica soluzione e risistemare la risorsa a mano com'era nello stato precedente e poi continuare.</p>
<p>E' inutile girarci intorno, il linguaggio di scripting usato da Terraform (l'HCL) è limitato. Innanzitutto non è possibile fare veri e propri <strong>IF</strong> su variabili o oggetti creati per includere solo le risorse volute. Ci sono dei trucchi utilizzabili come quello con la property <em>count</em> condizionale:</p>
<code>resource "kubernetes_namespace" "test" {
count = var.create_namespace_a ? 1 : 0
metadata {
name = "namespace-a"
}
}</code>
<p>Ma non risolve tutti i problemi soprattutto in configurazioni complesse che vanno al di là di semplici demo come quella che ho mostrato qui. In futuro? Ormai ci ho perso speranza.</p>
<p>In un prossimo post si potrebbe aggiungere anche la creazione di un cluster Kubernetes in un Cloud e altre critiche? Al momento mi fermo qui con il <a href="https://github.com/sbraer/terraform_mongodb_example" title="link to github">link</a> al codice mostrato in questo post.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2912/Terraform-Kubernetes.aspx"><em>Terraform e Kubernetes</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani2https://blogs.aspitalia.com/az/post2912/Terraform-Kubernetes.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2912.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2912Fungible e Non Fungible Token in praticahttps://blogs.aspitalia.com/az/post2911/Fungible-Fungible-Token-Pratica.aspx2022-03-29T17:40:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2911" border="0" style="width:1px; height:1px;" /> <p>Ecco l'ultima parte della serie dedicata alla <a href="https://blogs.aspitalia.com/az/category196/Blockchain.aspx" title="Link esterno">Blockchain</a>. E' il momento di scrivere qualche nota riguardante i Token, anzi, per essere più precisi, sulle due categorie di Token utilizzabili nelle Blockchain: <em>Fungible Token</em> e <em>Non Fungible Token</em> (NFT). I Fungible Token sono equiparabili alla criptovaluta come il Bitcoin o l'Ethereum, i Non Fungible Token rappresentano invece un oggetto unico digitale (quindi una qualsiasi sequenza di byte) il cui riferimento è salvato nella Blockchain. Sequenza di byte è esagerata come definizione? No, perché un qualsiasi file che può essere un'immagine, un mp3, un file di testo, una sequenza di bytes casuale può essere salvato cone un NFT nella Blockchain.</p>
<p>Per poter salvare queste tipologie di Token è necessario ovviamente utilizzare reti/criptovalute che lo permettano. Per il momento la regina della criptovalute che lo permette è l'Ethereum - come già scritto più volte ci sono molte altre che lo permettono: Solana, l'italiana Algorand etc... E per il loro utilizzo sono necessari gli Smart Contract. Nessuno vieta di creare come Fungible Token la propria criptovaluta da utilizzare come moneta per un proprio servizio. Alcuni videogiochi hanno già cominciato a farne uso come valuta interna al gioco, così come i Non Fungible Token possono essere utilizzati per l'assegnazione di equipaggiamento/skin per il proprio personaggio come ricompensa.</p>
<p>Ethereum ha creato un suo standard interno per la creazione di applicativi - Smart Contract - per i Fungigle Token e i Non Fungile Token: Ethereum Request for Comment (ERC). A questo <a href="https://eips.ethereum.org/erc" title="link esterno">link</a> la lista degli attuali standard utilizzati di cui tratterò solo quelli di mio interesse:</p>
<ul><li><strong>ERC-20</strong>: questo standard definisce l'interfaccia per la creazione dei Fungible Token. Il proprio Smart Contract dovrà implementare tutti i metodi <a href="https://github.com/OpenZeppelin/openzeppelin-contracts/blob/9b3710465583284b8c4c5d2245749246bb2e0094/contracts/token/ERC20/IERC20.sol" title="link esterno">qui</a> definiti.</li>
<li><strong>ERC-721</strong>: questo standard definisce l'interfaccia per i Non Fungible Token. Qui sono i <a href="https://github.com/OpenZeppelin/openzeppelin-contracts/blob/9b3710465583284b8c4c5d2245749246bb2e0094/contracts/token/ERC721/IERC721.sol" title="link esterno">metodi</a> da implementare nel proprio Smart Contract.</li>
<li><strong>ERC-1155</strong>: questo <a href="https://eips.ethereum.org/EIPS/eip-1155" title="link esterno">standard</a> è nato come esigenza di unire di due standard qui sopra. Infatti utilizzando questa interfaccia si potranno utilizzare nel proprio Smart Contract sia i Fungigle Token sia i Non Fungible Token. Inoltre mette a disposizioni metodi appositi per trasferire anche più token a più destinatari allo stesso momento per risparmiare il costo di gas per la transazione. E sarà anche quello che utilizzerò per la demo che mostrerò più avanti.</li>
</ul>
<p>Ma qual è lo scopo finale di utilizzare questi standard? Inserire questi standard nei propri Smart Contract permette ad esso di essere utilizzato anche da piattaforme diverse. Creando uno Smart Contract che implementa l'interfaccia ERC-721 mi permetterà di inserire i miei Non Fungible Token in siti che permettono la vendita di questi oggetti. Ovviamente nessuno vieta la creazione di uno Smart Contract per la trattazione dei Token senza l'uso di queste interfacce se lo scopo finale è il loro utilizzo, come visto nel mio post precedente, in una custom web application.</p>
<p>Siccome lo scopo finale della demo che mostrerò è l'inserimento dei miei token in un portale pubblico, io utilizzerò questi standard, e volendo implementare entrambi le tipologie di Token utilizzerò l'ERC-1155.</p>
<h1>Tipologie di Token della demo</h1>
<p>La demo includerà le due tipologie di Token. Innanzitutto creerò il mio coin di esempio per il Fungible Token. Per rappresentarlo userò la seguente semplice immagine:</p>
<p><a href="/img/andrewz/blockchain4/azotoken.png" title="aztoken"><img src="/img/andrewz/blockchain4/aztoken.png" title="aztoken" width="256" /></a></p>
<p>Per i Non Fungible Token, utilizzerò delle immagini di esempio fatte al volo che non passeranno mai alla storia per l'arte espressa:</p>
<table>
<tr>
<td><img src="/img/andrewz/blockchain4/omino_bn.png" title="omino in bianco e nero" width="256" /></td>
<td><img src="/img/andrewz/blockchain4/omino_color.png" title="omino a colori" width="256" /></td>
</tr>
<tr>
<td>Omino in bianco e nero</td>
<td>Omino a colori</td>
</tr>
</table>
<p>Ma dove devono essere salvate queste immagini? Nella Blockchain? No. Anche perché l'inserimento di un quantitativo elevato di byte nella Blockchain porterebbe ad un salasso notevole (inoltre non si potrebbe avere un url da richiamare per poi rivedere queste immagini). Questi file devono essere salvati su qualche server sempre accessibile. Questo punto è più importante di quanto si potrebbe inizialmente pensare, perché il salvataggio di file poi inclusi in (N)FT dovranno essere poi per sempre essere disponibili soprattutto se sono già stati utilizzati per una transazione per una vendita o altro - lo so, con le immagini qui sopra non corro questi rischi. Conseguenza: vendere un NFT che un giorno ritornerà l'Http error 404 non è proprio una cosa piacevole per l'acquirente.</p>
<p>Come molte demo già presenti in rete ho deciso di seguire la regola della Blockchain riguardante la decentralizzazione, ed ho salvato queste immagini nel servizio <a href="https://ipfs.io/" title="link esterno">IPFS</a> che permette lo share di file. Senza dover collegare un server alla rete di IPFS per la condivisione dei file qui sopra per la demo, ho utilizzato <a href="https://app.pinata.cloud/" title="link esterno">servizi</a> appositi che permettono la condivisione dei file.</p>
<h1>Scrivere lo Smart Contract implementando l'ERC-1155</h1>
<p>Se l'aver visto l'interfaccia da implementare per gli standard può spaventare, in verità la loro implementazione è banale, perché in rete si trovano migliaia di esempi a riguardo, ed anzi, è possibile utilizzare wizard online che permettono di creare la struttura di base perfettamente funzionante. Per esempio, a questo <a href="https://docs.openzeppelin.com/contracts/4.x/wizard" title="link esterno">url</a>:</p>
<p><a href="/img/andrewz/blockchain4/openzeppelin_wizard.png" title="wizard in openzeppelin"><img src="/img/andrewz/blockchain4/openzeppelin_wizard.png" title="wizard in openzeppelin" width="720" /></a></p>
<p>E' possibile selezionare una delle interfacce prima esposte ed è possibile aggiungere alcune opzioni per la creazione dei metodi appositi, come <strong>Mintable</strong>, che permette la creazione di nuovi Token anche dopo la pubblicazione del Smart Contract. Qui si ha un'ottima base su cui lavorerò per la personalizzazione che mi serve.</p>
<p>Innanzitutto la gestione dell'url delle immagini contiene un bug e si deve riscrivere. Quindi volendo utilizzare tipologie di immagini diverse con url non consecutivi (id numerico) anche la gestione della funzione <strong>mint</strong> (per la creazione dei Token) dovrò riscriverla. Niente di complicato. Modifico la funzione di <strong>mint</strong>, perché nell'esempio creato dal wizard non sono inserite tutte le opzioni di cui ho bisogno. Ecco la mia versione con altre modifiche:</p>
<code>event mintEvent(address account, uint256 id, uint256 amount, string url);
constructor() ERC1155("") {}
mapping(uint256 => string) private _idUrls;
function setURI(uint256 id, string memory newuri) public onlyOwner {
require(bytes(_idUrls[id]).length != 0, "Id not present");
_idUrls[id] = newuri;
emit URI(newuri, id);
}</code>
<p><strong>mintEvent</strong> lo utilizzo solo per la gestione degli eventi per l'utilizzo della funzione <strong>mint</strong>, che include anche il parametro <em>url</em>. In questa funzione <em>Address</em> è il proprietario del Token creato, l'<em>id</em> è un numero che identifica il token, l'<em>amount</em> è la quantità che può essere tratta per quel token: questo diversifica un Fungible (<strong>amount = 0</strong>) da un Non Fungible (<strong>amount > 1</strong>). <em>data</em> può contenere qualsiasi dato e infine in <em>url</em> sarà inserito il link al file JSON in cui saranno inserite le informazioni sul Token (mostrerò poi). Ho creato anche un oggetto <em>mapping</em> (<em>dictionary</em>) per memorizzare gli url di ogni specifico Token.</p>
<p>L'interfaccia ERC1155 espone anche la funzione <strong>uri</strong> che, dato l'<em>id</em> di un Token, ne ritorna l'url. Avendo io modificato la gestione degli url, ecco l'override della funzione:</p>
<code>function uri(uint256 tokenId) override public view returns(string memory) {
require(bytes(_idUrls[tokenId]).length != 0, "Id not present");
return _idUrls[tokenId];
}</code>
<p>Ho inserito per dei miei test anche la funzione per modificare l'url di un Token, ma non è obbligatorio. Ecco il codice completo del mio Smart Contract:</p>
<code>// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
import "@openzeppelin/contracts/token/ERC1155/ERC1155.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyToken is ERC1155, Ownable {
event mintEvent(address account, uint256 id, uint256 amount, string url);
constructor() ERC1155("") {}
mapping(uint256 => string) private _idUrls;
function setURI(uint256 id, string memory newuri) public onlyOwner {
require(bytes(_idUrls[id]).length != 0, "Id not present");
_idUrls[id] = newuri;
emit URI(newuri, id);
}
function mint(address account, uint256 id, uint256 amount, bytes memory data, string memory url)
public
onlyOwner
{
_mint(account, id, amount, data);
_idUrls[id] = url;
emit mintEvent(account, id, amount, url);
}
function mintBatch(address to, uint256[] memory ids, uint256[] memory amounts, bytes memory data, string[] memory urls)
public
onlyOwner
{
_mintBatch(to, ids, amounts, data);
for(uint i = 0; i < urls.length; i++) {
_idUrls[ids<i>] = urls<i>;
emit mintEvent(to, ids<i>, amounts<i>, urls<i>);
}
}
function uri(uint256 tokenId) override public view returns(string memory) {
require(bytes(_idUrls[tokenId]).length != 0, "Id not present");
return _idUrls[tokenId];
}
}</code>
<h1>Usare una vera Blockchain</h1>
<p>Finora tutti i miei esempi utilizzavano Blockchain locali di test. Per questa demo dovrà uscire dalle sicure mura di casa e utilizzare una rete Ethernet vera. Fino ad un certo punto... perché continuerò a utilizzare una rete di test ma pubblica: <a href="https://www.rinkeby.io/" title="link esterno">Rinkeby</a>. Il primo passo è configurare questa rete in Metamask. Tralasciando il passaggio dell'installazione di Metamask e della creazione del primo Wallet, questa configurazione non è niente di complicato: dopo aver abilitato le reti di test come spiegato nello scorso post, è sufficiente selezionare questa rete:</p>
<p><img src="/img/andrewz/blockchain4/metamask_rinkeby.png" title="la rate rinkeby in metamask" /></p>
<p>Il problema successivo è che qualsiasi operazione nella rete necessita di Ethereum che dovrebbe essere a zero come da immagine. Essendo una rete di test è possibile aggiungere gratuitamente valuta andando in link appositi. Nel caso di Rinkeby al momento della scrittura di questo post un link funzionante è il seguente:</p>
<p><a href="https://faucets.chain.link/rinkeby" title="link esterno">https://faucets.chain.link/rinkeby</a></p>
<p>Inserito il proprio Address in pochi secondi saranno caricati 0.1 ETH, più che sufficienti per dei test. Se questo link non fosse più disponibile una ricerca in rete dovrebbe portare a nuove pagine dove è possibile richiedere valuta di test.</p>
<p>Ora lo Smart Contract devo inserirlo in questa Blockchain. Lo posso fare da Remix. Avviato questo IDE dallo stesso browser dove si è configurato Metamask dal tab <strong>Deploy & run transactions</strong>, seleziona dal dropdownlist dell'Environment la voce <strong>Inject Web3</strong>:</p>
<p><img src="/img/andrewz/blockchain4/use_metamask_with_remix.png" title="selezionare il provider metamask da Remix" /></p>
<p>Selezionata questa voce si aprirà la schermata di Metamask ed effettuato il collegamento si dovrà avere un risultato simile a questo:</p>
<p><img src="/img/andrewz/blockchain4/metamask_remix.png" title="metamask e Remix" /></p>
<p>A questo punto Remix sta usando la rete Rinkeby, e il deploy del Smart Contract dovrebbe andare proprio su quella rete. Cliccando su <strong>Deploy</strong> ora si aprirà la schermata di Metamask con la richiesta della conferma dell'operazione:</p>
<p><img src="/img/andrewz/blockchain4/metmask_confirm_smart_contract.png" title="metamask e conferma deploy" /></p>
<p>Cliccato su <strong>Confirm</strong> lo Smart Contract sarà inserito in imperitura memoria nella Blockchain - l'operazione non è immediata (in Rinkeby i tempi di attesa variano tra i cinque e in 15 secondi). Remix mostrerà anche in link sul sito <a href="https://rinkeby.etherscan.io" title="link esterno">Etherscan</a> dove sono visibili tutte le operazioni sia nella rete principale di Ethereum (mainnet) sia in quelle di test:</p>
<p><a href="https://rinkeby.etherscan.io/tx/0x0a5df8a1269fbae8c800249ce4d7850fff23595b14b56a4379887dc41a94d332" title="etherscan, smart contract info">https://rinkeby.etherscan.io/tx/0x0a5df8a1269fbae8c800249ce4d7850fff23595b14b56a4379887dc41a94d332</a></p>
<p><a href="/img/andrewz/blockchain4/etherscan_info_contract.png" title="etherscan, smart contract info"><img src="/img/andrewz/blockchain4/etherscan_info_contract.png" title="etherscan, smart contract info" width="720" /></a></p>
<p>Ed ecco l'Address del mio Smart Contract:</p>
<code>0x84f3745a9529bab3f6023c56eaa7dfd33da2f0ad</code>
<h1>Caricare il Token nella Blockchain</h1>
<p>Uno dei più importanti NFT marketplace è <a href="https://opensea.io/" title="link esterno">OpenSea</a> - famosa anche perché proprio nella sua piattaforma c'è stato un furto milionario di NFT causata da <a href="https://www.cybersecurity360.it/nuove-minacce/come-hanno-rubato-gli-nft-su-opensea-lastuzia-della-mail-phishing/" title="link esterno">email di phishing</a>. Senza scomodare Smart Contract e altro è possibile inserire le proprie immagini, ma non è lo scopo di questo post. OpenSea mette a disposizione anche l'interfaccia completa verso uno Smart Contract esterno e l'utilizzo di una versione di <a href="https://testnets.opensea.io" title="link esterno">test di OpenSea</a> dove fare tutti i propri test, e tra le reti supportate c'è proprio Rinkeby.</p>
<p>Nel servizio IPFS ho già caricato le immagini, ma per essere riconosciute devo creare dei file che ne definiscono il contenuto per poterle caricare su OpenSea. Ora creo i file JSON, questo è l'immagine del Token:</p>
<code>{
"name": "AzToken",
"description": "Coin AzToken",
"image": "https://gateway.pinata.cloud/ipfs/QmapzQnLrweNWr3mnqByvs3izAspf5jkNJWYKLa8ASnuRS"
}</code>
<p>Questa file è minimale perché sono disponibili molti altri <a href="https://docs.opensea.io/docs/metadata-standards" title="link esterno">attributi</a>. Quindi creo lo stesso tipo di file anche per le altre due immagini e carico il tutto ancora nul servizio IPFS. Alla fine ho:</p>
<ul>
<li>AzToken. Link <a href="https://gateway.pinata.cloud/ipfs/QmexZRLfu9a2eGnGw6ggnsMaFgnqAHP7e9CX4dXpDx74Ry" title="link json in IPFS">JSON</a>. <a href="https://gateway.pinata.cloud/ipfs/QmapzQnLrweNWr3mnqByvs3izAspf5jkNJWYKLa8ASnuRS" title="Link immagine in IPFS">Link</a> immagine.</li>
<li>Omino bianconero. Link <a href="https://gateway.pinata.cloud/ipfs/QmT3hKtgotgGi74WZtS4vv9okVQezH3GGzT5x9uLhJMCoP" title="link json in IPFS">JSON</a>. <a href="https://gateway.pinata.cloud/ipfs/Qmds8oqa63hJMzd1mByGRmmvkrdCsZjTBKH1j1HMVJfH5h" title="Link immagine in IPFS">Link</a> immagine.</li>
<li>Omino a colori. Link <a href="https://gateway.pinata.cloud/ipfs/Qmd5RGBN2pqHvMerrV68gFcmf7QrayoTiWnW76vQSngLqc" title="link json in IPFS">JSON</a>. <a href="https://gateway.pinata.cloud/ipfs/QmdmghettRX4W3RQUhFddvGLmnDVYXbvDuQ9UXVJ7x3SiR" title="Link immagine in IPFS">Link</a> immagine.</li>
</ul>
<p>Ritornando allo Smart Contract posso inserire l'AzCoint come Token. Seleziono il metodo <strong>mint</strong> e inserisco i parametri:</p>
<p><img src="/img/andrewz/blockchain4/erc1155_mint_1.png" title="remix, insert token in smart contract" /></p>
<p>in <strong>account</strong> ho inserito l'Address del mio Wallet (lo stesso usato per la creazione dello Smart Contract. <strong>id</strong> il valore numerico <strong>1</strong>. Volendo poter distribuire 1.000 Token di questo tipo inserisco questo valore in <strong>amount</strong>. In <strong>data</strong> lascio il valore <em>vuoto</em> <strong>"0x00"</strong>, e in <strong>url</strong> inserisco l'url del file JSON contenente le informazioni del mio Token. Cliccato su <strong>transact</strong> apparirà la richiesta di Metamask:</p>
<p><img src="/img/andrewz/blockchain4/remix_metamask_mint_confirm.png" title="metamask, conferma inserimento nuovo Token" /></p>
<p>Dopo qualche secondo apparirà la conferma e posso tornare in OpenSea per creare la mia prima Collection dove inserirò inizialmente l'AzToken grazie al mio Smart Contract.</p>
<p>Aperto nel browser la versione di <a href="https://testnets.opensea.io" title="link esterno">test di OpenSea</a> sarà possibile eseguire la login utilizzando Metamask. Cliccando sull'icona di Metamask nella toolbar del Browser (o premendo i tasti <strong>ALT+SHIFT+M</strong>) si potrà eseguire il Sign-In e si avrà a disposizione un proprio profilo e la possibilità di creare una propria collection (per qualsiasi operazione che dovrebbe passare per Metamask, fare sempre molta attenzione eventualmente alla sua icona nella toolbar del browser, che dovrebbe poter richiedere eventuali conferme per proseguire):</p>
<p><img src="/img/andrewz/blockchain4/opensea_metamask_create_new_profile.png" title="opensea creare un nuova collection" /></p>
<p>Nella schermata successiva selezionare <strong>Import existing smart contract</strong>:</p>
<p><img src="/img/andrewz/blockchain4/insert_smart_contract_in_opensea.png" title="inserimento smart contract in opensea" /></p>
<p>Quindi qui inserisco l'Address del mio Smart Contract:</p>
<p><img src="/img/andrewz/blockchain4/contract_address.png" title="address smart contract" /></p>
<p>Se tutto è stato fatto correttamente e i file prima creati sono visibili pubblicamente, dovrebbe apparire la nuova Collection con un nome casuale, nel mio caso: <strong>Unidentified contract - WEyY5weTFf</strong>. E sotto il mio token/coin che, cliccato sopra, mostrerà le info:</p>
<p><a href="/img/andrewz/blockchain4/az_coin_in_opensea.png" title="azcoin"><img src="/img/andrewz/blockchain4/az_coin_in_opensea.png" title="azcoin" width="720" /></a></p>
<p>Con la disponibilità di 1.000 pezzi. Ora i <em>Token</em> potranno essere ceduti dall'<em>owner</em> ma non acquistabili direttamente. Come da immagine, è possibile fare un'offerta - <strong>Make offer</strong> - che solo l'<em>owner</em> potrà accettare oppure no. Oppure sempre l'owner potrà impostare la possibilità di venderli cliccando su <strong>Sell</strong>:</p>
<p><a href="/img/andrewz/blockchain4/opensea_sell_token.png" title="vendita dei token in opensea"><img src="/img/andrewz/blockchain4/opensea_sell_token.png" title="vendita dei token in opensea" width="720" /></a></p>
<p>In Fees sono inserite le tasse per la vendita di questo Token. Ad OpenSea va il 2.5% mentre io ho impostato il 10% all'autore, percentuale che sarà sempre versata al creatore del Token anche in caso di vendita a terzi. Affidandosi a OpenSea, l'assegnazione dei Token passerà sempre per il mio Smart Contract, ma per la vendita effettiva è bene sapere che saranno i Smart Contract di OpenSea a fare il tutto (visto anche l'assegnazione dei Fee). Per modificare il Fee e personalizzare la pagina e il nome della collection, è sufficiente andare nelle opzioni dove è possibile inserire anche loghi e altre informazioni, compreso il Fee e l'Address su cui si sarà versata questa percentuale:</p>
<p><img src="/img/andrewz/blockchain4/creator_fee.png" title="fees smart contract" /></p>
<h1>Caricare il Non Fungible Token nella blockchain</h1>
<p>E' il momento di inserire le due opere d'arte nel Smart Contract come NFT. In Remix, sempre nel metodo <strong>mint</strong> <a href="https://rinkeby.etherscan.io/tx/0x9031cbaa4dd38c2d590bcf135fe33d2ca3a4059e04d24f71e1bd508a8727d0c1" title="link esterno">inserisco</a> i dati per l'<em>Omino in bianco e nero</em>:</p>
<p><img src="/img/andrewz/blockchain4/remix_nft_1.png" title="new nft in remix" /></p>
<p>In <strong>Account</strong> ho inserito ancora l'<em>Address</em> dell'<em>Owner</em>, in <strong>Id</strong> il numero consecutivo (2) ed essendo un NFT come <strong>Amount</strong> ho inserito il valore 1, in modo che il Token sia univoco. Quindi <a href="https://rinkeby.etherscan.io/tx/0x8380d0194495e6f245fc177e5eb63591796f434c47e4633ff9dc2d3db9a6ff24" title="link esterno">rifaccio</a> lo stesso per l'<em>Omino a colori</em>.</p>
<p>Ora riapro la mia collection in OpenSea, ed ecco sia il Token sia i due NFT (<a href="https://testnets.opensea.io/collection/sbraer-my-blog-collection" title="link esterno">link diretto</a>):</p>
<p><a href="/img/andrewz/blockchain4/my_blog_collection.png" title="my blog collection"><img src="/img/andrewz/blockchain4/my_blog_collection.png" title="my blog collection" width="720" /></a></p>
<p>Così come prima, io posso metterli in vendita o, con un account esterno, fare una proposta di acquisto. Sicuramente andranno a ruba.</p>
<h1>E se non volessi usare OpenSea?</h1>
<p>Forse era meglio farla come premessa. Io non ho alcun interesse a pubblicizzare <em>OpenSea</em>, ma è la piattaforma che permette dei test facilmente in un ambiente uguale a quello reale sulla <em>mainnet</em> di Ethereum. Innanzitutto, per chi non si fosse mai trovato di fronte al tipo di autenticazione con Wallet può farsi un'idea del funzionamento. Come ho cercato di spiegare nel <a href="https://blogs.aspitalia.com/az/post2910/Web3-Smart-Contract-Metamask-Web-Application.aspx" title="post precedente">post</a> precedente l'implementazione nelle proprie web application non presenta nulla di complicato. Inoltre come spiegato in quel post e in quello <a href="https://blogs.aspitalia.com/az/post2909/Smart-Contract-Ethereum-Dapp-CSharp.aspx" title="blockchain in C#">precedente</a>, interfacciandosi direttamente con la Blockchain e avendo creato dei propri Token è possibile assegnarli ai propri utenti con il proprio codice sia client che server side. Si pensi al mondo dei videogiochi dove si possono creare Skin (Token) esclusive per i
propri giocatori con l'assegnazione come premio o dopo la vendita. Inoltre con questa metodologia si può anche dimenticare la configurazione di un gateway per il pagamento con carte di credito e permettere ai propri utenti l'acquisto di determinati prodotti con cryptovaluta (ovviamente per un mirato tipo di mercato).</p>
<p>La comunicazione tra OpenSea e il mio Smart Contract funziona correttamente perché ho utilizzato l'interfaccia preposta. Nel mondo degli Smart Contract l'uso delle interfacce permette anche di fixare il problema noto sull'impossibilità di modificare il codice di uno Smart Contract una volta inserito nella Blockchain. Di seguito un semplice esempio a riguardo riprendendo quello presente in Remix. Innanzitutto definisco l'interfaccia, se si conosce altri linguaggi come il C# è di facile comprensione:</p>
<code>// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.7;
interface IStorage {
function store(uint num) external;
function retrieve() external view returns (uint);
}</code>
<p>E riscrivo il Contract Storage implementando questa <em>interface</em>:</p>
<code>// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.7;
import "./IStorage.sol";
contract Storage is IStorage {
uint private number;
function store(uint num) override external {
number = num;
}
function retrieve() override external view returns (uint){
return number;
}
}</code>
<p>Invece di richiamare direttamente questo Contract, creo una Contract Proxy che lo farà per me:</p>
<code>// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.7;
import "./IStorage.sol";
contract ProxyContract {
IStorage private _storage;
address private _owner = msg.sender;
function setContract(address from) external {
require(msg.sender == _owner, "Only Contract Owner!!!");
_storage = IStorage(from);
}
function retrieveFromContract() external view returns(uint) {
return _storage.retrieve();
}
function storeFromContract(uint num) external {
_storage.store(num);
}
}</code>
<p>E' presente il metodo <em>setContract</em> nel quale solo l'owner potrà inserire l'Address di un altro Contract. Nel mio esempio inserirò quello che sarà creato nella Blockchain per il Contract <em>Storage</em>. Ora utilizzando <em>ProxyContract</em> potrò interagire con il Contract <em>Storage</em> richiamadone le funzioni <em>External</em> (nelle interfacce è possibile inserire solo questo tipo di visibilità per i membri del contratto). Il vantaggio a questo punto è evidente nel caso di upgrade/fix di <em>Storage</em>. Una volta pubblicata una nuova versione del Smart Contract, potrò inserire qui il nuovo Address e senza modificare altro, potrò usare la nuova versione. Attenzione, perché gli oggetti memorizzati in Storage saranno ovviamente persi, qui subentra un altro trucco dove il salvataggio di oggetti può essere fatto da un terzo contratto accessibile anch'esso con interfaccia.</p>
<p>Con Etherscan è possibile vedere le chiamate da uno Smart Contract ad un altro nel tab <strong>Internal Txns</strong>, come visibile <a href="https://rinkeby.etherscan.io/address/0x84f3745a9529bab3f6023c56eaa7dfd33da2f0ad#internaltx" title="link esterno">qui</a> per la vendita simulata che ho testato del Token creato sopra (in questo caso OpenSea ha richiamato una funzione del mio Smart Contract per l'assegnazione del Token ad un altro utente).</p>
<h1>Pubblicare il codice sorgente del Smart Contract</h1>
<p>E' possibile inserire il codice sorgente in modo che sia visibile a tutti su Etherscan. Questa prassi è consigliata perché permette la visione del codice e delle operazioni fatte dallo Smart Contract. La procedura è abbastanza semplice, è sufficiente andare nella pagina sul sito di Etherscan dove è presente lo Smart Contract, nel mio caso a questo url:</p>
<p><a href="https://rinkeby.etherscan.io/address/0x84F3745a9529BAb3F6023C56EAa7dfd33da2f0Ad" title="link esterno">https://rinkeby.etherscan.io/address/0x84F3745a9529BAb3F6023C56EAa7dfd33da2f0Ad</a></p>
<p>E nella parte inferiore della pagina al tab <strong>Contract</strong>, sarà possibile inserirlo senza alcun tipo di autenticazione, ma inserendo poche informazioni e il codice sorgente:</p>
<p><img src="/img/andrewz/blockchain4/publish_code.png" title="publish code con etherscan" /></p>
<p>Cliccando su <strong>Verify and Publish</strong>, sarà aperto un form dove inserire le informazioni:</p>
<p><a href="/img/andrewz/blockchain4/verify_and_publish_smart_contract_code.png" title="verifica e deploy del codice sorgetne"><img src="/img/andrewz/blockchain4/verify_and_publish_smart_contract_code.png" title="verifica e deploy del codice sorgetne" width="720" /></a></p>
<p>Importante è utilizzare la corretta versione del compilatore utilizzato. Al passo successivo è necessario inserire il codice sorgente. Qui si deve fare attenzione nel caso il codice del Smart Contract includa con gli <strong>import</strong> altri file come per il mio esempio. In questo caso si deve inserire tutto il codice sorgente comprensivo di quei file. Il modo più semplice. sempre con l'editor Remix, è abilitare il plugin <strong>Flattener</strong> che visualizzerà nella barra a sinistra di Remix una nuova icona. Selezionata si potrà cliccare sul pulsante <strong>Flatten contracts/nome contract.sol</strong> per avere il codice sorgente da copiare in Etherscan:</p>
<p><img src="/img/andrewz/blockchain4/flattener.png" title="flattener in remix" /></p>
<p>Se tutto è stato inserito correttamente ora nel tab in Etherscan si potrà vedere il codice sorgente e ulteriori due tab permetteranno di richiamare le funzioni del Smart Contract:</p>
<p><a href="https://rinkeby.etherscan.io/address/0x84F3745a9529BAb3F6023C56EAa7dfd33da2f0Ad#code" title="link esterno">https://rinkeby.etherscan.io/address/0x84F3745a9529BAb3F6023C56EAa7dfd33da2f0Ad#code</a></p>
<h1>Per l'ultima volta: ma quanto mi costi?</h1>
<p>E' il momento di pagare il conto. Quando mi è costerebbe pubblicare questo Smart Contract se avessi utilizzato la <em>mainnet</em> di Ethereum?</p>
<ul><li>Gas usato per la pubblicazione: 3.159.361</li>
<li><a href="https://etherscan.io/gastracker" title="link esterno">Prezzo</a> per unità di Gas: 0.000000059 Ether</li>
<li>Prodotto Gas x unita di Gas: 0.186402299 Ether</li>
<li>Convertito in EUR: ¤ 575.00</li>
</ul>
<p>
Inserimento di un Token direttamente nel Smart Contract:</p>
<ul><li>Gas usato per la pubblicazione: 124.539</li>
<li>Prezzo per unità di Gas: 0.000000059 Ether</li>
<li>Prodotto Gas x unita di Gas: 0.007347801 Ether</li>
<li>Convertito in EUR: ¤ 22.67</li>
</ul>
<p>Il costo della vendita direttamente da OpenSea dipende dal prezzo di vendita ed è molto più economico. Tralascio commenti personali. Il range del prezzo delle unità di Gas è molto variabile, ed è meglio attendere che sia inferiore all'esempio qui sopra (59 Gwei).</p>
<h1>Conclusioni</h1>
<p>Alla fine di questo post, dunque, gli NFT sono <em>fuffa</em> oppure no? Allo stato attuale oltre al puro collezionismo per speculazione non sembra esserci molto. Possono essere utilizzati per scopi più <em>nobili</em> come in certi siti o giochi per l'assegnazione controllata di gadget e simili. Inoltre il possesso di un NFT non garantisce alcun diritto reale sui byte acquistati. Non c'è tuttora nessuna legge, o futuro piano, perché questi possano diventare un legale certificato di possesso. Attualmente la gestione dei diritti d'autore per contenuti digitali potrebbe avere una svolta importante se dovessero adottare gli NFT e diventassero quindi lo strumento principale per quel tipo di prodotto. Per ora sembra che non se ne parli nemmeno di questa cosa.</p>
<p>In questa serie di post dedicati alla Blockchain, al fantomatico Web3 e Smart Contract ho solo voluto scrivere mie annotazioni e pensieri del tutto personali. Ho tralasciato tematiche e altri aspetti interessanti di questo mondo come gli <a href="https://ethereum.org/en/developers/docs/oracles/" title="link esterno">Oracoli</a> (<em>Oracles</em>) per gli Smart Contract, che permettono la ricezione dei dati in tempo reale di qualsiasi tipo. Inoltre Opera, il noto browser, ha creato una propria versione di <a href="https://www.opera.com/crypto/next" title="link esterno">browser</a> per la Blockchain. Per rispondere alla domanda di una persona che mi ha chiesto se ne valeva la pena di imparare il linguaggio Solidity per gli Smart Contract quando a breve, a Giugno, la rete di Ethereum dovrebbe passare alla versione 2, rispondo che al momento non c'è alternativa: era stato promesso un nuovo linguaggio di programmazione più evoluto, <strong>Ewasm</strong> (<a href="https://ewasm.readthedocs.io/en/mkdocs/" title="link esterno">Ethereum WebAssembly</a>) che prometteva prestazioni molto più
elevate ed un maggior risparmio di Gas, ma dalle ultime news sembra che sia stato rinviato per varie problematiche non ancora risolte e tempi di sviluppo più lunghi del previsto.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2911/Fungible-Fungible-Token-Pratica.aspx"><em>Fungible e Non Fungible Token in pratica</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2911/Fungible-Fungible-Token-Pratica.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2911.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2911Web3, Smart Contract, Metamask e Web Applicationhttps://blogs.aspitalia.com/az/post2910/Web3-Smart-Contract-Metamask-Web-Application.aspx2022-03-20T18:33:00+00:00<img src="https://blogs.aspitalia.com/services/counter_rss.aspx?PostID=2910" border="0" style="width:1px; height:1px;" /> <h1>Smart contract e Web3 client side</h1>
<p>Dopo i primi due post della <a href="https://blogs.aspitalia.com/az/category196/Blockchain.aspx" title="serie post su blockchain">serie</a>, <a href="https://blogs.aspitalia.com/az/post2908/Blockchain-CSharp-Scimmiottare-Bitcoin.aspx" title="primo post della serie">uno</a> dedicato alla Blockchain e <a href="https://blogs.aspitalia.com/az/post2909/Smart-Contract-Ethereum-Dapp-CSharp.aspx" title="secondo post della serie">uno</a> agli Smart Contract con interoperabilità con il C#, ecco questo post dedicato ancora agli Smart Contract con application client side con i Wallet per la gestione dell'autenticazione. Però prima ancora un po' di teoria sugli Smart Contract.</p>
<h1>Funzioni Payable e problemi vari</h1>
<p>Nello scorso post ho scritto qualche dettaglio sulla programmazione degli Smart Contract in Ethereum grazie al linguaggio Solidity. Naturalmente non voleva essere una guida esaustiva, anche perché la documentazione ufficiale è ben scritta (inoltre è disponibile molto materiale scritto da appassionati e programmatori professionisti). Ho cercato di descrivere anche i vari attributi dei metodi di Solidity, come la visibilità (external, public, etc...) e il modo come dichiarare i parametri di input e output. E' il momento di utilizzare il parametro <strong>payable</strong>.</p>
<p>Uno Smart Contract è equiparabile ad un Wallet. In esso è possibile depositare cryptovaluta così come è possibile con esso versare cryptovaluta in un Wallet o in un altro Smart Contract. Utilizzando sempre come editor Remix di Ehetereum, nel tab <em>Deploy & run transaction</em>, nella sezione dove si specifica il Wallet di test da utilizzare e il Gas Limit, è presente anche una textbox <strong>Value</strong> e un dropdownlist dove sono presenti le varie unità di misura dell'Ethereum: Wei, Gwei, Finney e Ether (sempre nello scorso post maggiori info a riguardo):</p>
<p><img src="/img/andrewz/blockchain3/remix_value_1.png" title="Remix value example" /></p>
<p>In questa textbox <strong>Value</strong> è possibile inserire quanta cryptovaluta si vuole immettere dal Wallet selezionato (0x5b3...edd4c) nello Smart Contract quando viene richiamato uno dei suoi metodi. Provando a inserire un valore differente da zero e provando a richiamare un metodo a caso come il seguente (preso dagli esempi presenti in Remix):</p>
<code>function store(uint256 num) external {
number = num;
}</code>
<p>Al momento della creazione della transazione si ottiene un errore generico:</p>
<p><img src="/img/andrewz/blockchain3/remix_error_1.png" title="Remix error with payable" /></p>
<p>L'errore è dovuto dal metodo che non supporta la ricezione di cryptovaluta. L'attributo payable è utilizzato proprio per questo. Creo un metodo di questo tipo minimale:</p>
<code>function payme() payable external {}</code>
<p>Notare l'assenza di parametri di input o output che sono del tutto opzionali - un metodo di tipo payable è del tutto identico a qualsiasi altro metodo. Una volta fatto il deploy appare un nuovo pulsante - <strong>payme</strong> - dal colore differente da quelli visti finora: rosso.</p>
<p><img src="/img/andrewz/blockchain3/remix_payme.png" title="Remix new payme button" /></p>
<p>Ora inserendo nella textbox un quantitativo di cryptovaluta e cliccato su <strong>payme</strong>, dal Wallet selezionato sarà decurtato il quantitativo selezionato che sarà depositato nello Smart Contract. Sì, ma dove? E come lo gestisco? Creo un altra metodo:</p>
<code>function getMoney() external view returns(uint) {
return address(this).balance;
}</code>
<p>Rifatto il Deploy e depositato con <strong>payme</strong> altra cryptocurrency, cliccato su <strong>getMoney</strong> sarà visualizzato il quantitativo depositato nello Smart Contract. Se volessi controllare il quantitativo di valuta al momento dell'inserimento dovrei scrivere nel metodo <strong>payme</strong>:</p>
<code>function payme() payable external {
require(msg.value == 1 ether, "I accept only 1 ETH!");
}</code>
<p>msg è una <a href="https://docs.soliditylang.org/en/latest/units-and-global-variables.html" title="link esterno">global variable</a> che contiene queste informazioni:</p>
<p><img src="/img/andrewz/blockchain3/remix_msg_global.png" title="msg global variable" /></p>
<p>Grazie a <em>require</em> nella funzione qui sopra viene accettato solo un Ether, ogni qualsiasi altro quantitativo sarà scartato con un errore.</p>
<p>Ipotizzando di avere più metodi e di volere eseguire questo controllo (o altri) Solidity mette a disposizione i <strong>Modifier</strong>. Essi vengono definiti nel codice come se fossero funzioni:</p>
<code>modifier Only1Ether {
require(msg.value == 1 ether, "I accept only 1 ETH!");
_;
// Accept only 1 ETH
}</code>
<p>Ora il metodo payme:</p>
<code>function payme() payable Only1Ether external {}</code>
<p>E farà il controllo come prima. Il vantaggio è che ora posso utilizzare il Modifier in tutte le funzioni del mio contract. Ottimo, ma attenzione, i Modifier non eseguono nessuna ottimizzazione e funzionano al pari della macro in C++. La funzione <strong>payme</strong> al compilatore giungerà scritta così:</p>
<code>function payme() payable external {
require(msg.value == 1 ether, "I accept only 1 ETH!");
// Accept only 1 ETH
}</code>
<p>In modifier i caratteri <strong>_;</strong> sono usati come segnaposto dove sarà inserito il codice della funzione in cui è stato aggiunto l'attributo <strong>Only1Ether</strong>.</p>
<p>Per muovere cryptovaluta dallo Smart Contract ad un altro Wallet o Smart Contract si deve scrivere una funzione come la seguente:</p>
<code>function payto(address to) external {
(bool sent, ) = payable(to).call{value: address(this).balance}("");
require(sent, "Failed to send Ether to external address");
}</code>
<p>Non ho dovuto inserire l'attributo <em>payable</em> nella definizione della funzione, ma ho dovuto fare il boxing di Address al tipo <em>payable</em> altrimenti avrei avuto un errore.</p>
<p>Spesso ci si troverà nella situazione di dover abilitare alcune funzioni solo a determinati utenti, come al creatore dello Smart Contract. Il trucco è usare il <em>constructor</em> che, come detto nello scorso post, viene eseguito solo alla creazione del Contract e usare un Modifier per i metodi che voglio che siano accessibili solo dall'<em>owner</em> del Contract:</p>
<code>// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract TestContractOwner {
address private _owner;
string private _message;
modifier OnlyOwner {
require(msg.sender == _owner, "Only Owner!");
_;
}
constructor() {
_owner = msg.sender;
}
function setMessage(string calldata message) external OnlyOwner {
_message = message;
}
function getMessage() external view returns(string memory) {
return _message;
}
}</code>
<p>Nel <em>constructor</em> salvo l'Address dell'utente che ha creato il contratto, quindi con il Modifier verifico che il richiedente della funzione per la modifica del messaggio sia uguale a quell'utente. Notare che avrei potuto scrivere anche lo stesso codice senza costruttore:</p>
<code>contract TestContractOwner {
address private _owner = msg.sender;
string private _message;
...</code>
<p>Quanto spiegato ora torna utile quando uno Smart Contract raccoglie cryptovaluta e si vuole che, manualmente, qualcuno possa cederla a terzi. Un metodo accessibile a chiunque sarebbe ovviamente un disastro. Nell'esempio che inserirò più avanti creerò una semplicissima lotteria dove gli utenti possono acquistare con lo Smart Contract un numero e ad un certo punto il creatore assegnerà, a sua discrezione, la cryptovaluta raccolta ad un unico vincitore:</p>
<code>function pickWinner(uint8 winNumber) public onlyOwner {
address to = payable(take_address_from_mapping);
(bool sent, ) = to.call{value: _balance}("");
...
}</code>
<p>Su questo punto mi soffermo un attimo. Come ho scritto più volte uno Smart Contract è come un Wallet, e può raccogliere cryptovaluta. Ma cosa succede se la funzione qui sopra, nel momento di prendere l'Address a cui inviare la vincita, incontrasse un bug non visto in fase di sviluppo e non potesse inviare la cryptovaluta al legittimo futuro proprietario? La faccio semplice: quella cryptovaluta è persa per sempre. Anche se venisse trovato il bug è corretto, l'invio del codice non modificherebbe lo Smart Contract esistente (si ricorderà che in una Blockchain nulla può essere modificato) e ne sarà creato uno nuovo con i suoi oggetti non condivisi con la versione buggata. Per evitare questo problema è bene creare una funzione apposita che richiami il <a href="https://solidity-by-example.org/hacks/self-destruct/" title="link esterno">selfdestruct</a> dello Smart Contract:</p>
<code>function kill() external onlyOwner {
selfdestruct(payable(_owner));
}</code>
<p>Questa funziona sarà utilizzabile solo dall'owner del contratto e invierà tutta la cryptovaluta salvata all'interno dello Smart Contract al suo Wallet (e poi potrà agire di conseguenza: versamento della vincita, etc...).</p>
<h1>Wallet</h1>
<p>Finora si è solo nominato il Wallet. E' ora di installare un gestore dei Wallet sulla macchina, o per meglio dire, come addons nel browser (Chrome o Firefox) e vedere come collegarlo alla Blockchain i test locale creata con Ganache. Innanzitutto si deve avviare questo tool - io ho scelto <a href="https://metamask.io/" title="link esterno">Metamask</a> perché è il più famoso, ma ce ne sono molti altri. Dopo averlo installato apre nel browser la schermata:</p>
<p><img src="/img/andrewz/blockchain3/metamask1.png" title="metamask start" width="720" /></p>
<p>Se non dovesse apparire in automatico è sufficiente premere <strong>CTRL + Alt + M</strong>. Avendo già tutto in Ganache seleziono la prima voce perché voglio utilizzare uno dei Wallet creati da quel programma. Quando è richiesta la <em>Secret Recovery Phrase</em> inserisco la lista delle dodici parole visibili in Ganache nalla pagina degli Accounts. Per proteggere solo l'accesso all'installazione corrente di Metamask viene chiesta anche una password che però, è importante saperlo, non è legata in alcun modo all'account importato o creato (ogni istanza di Metamask su altri dispositivi possono avere altre password). Alla fine, riaprendo Metamask con la combinazione di tasti o cliccando sulla sua icona, apparirà la window:</p>
<p><img src="/img/andrewz/blockchain3/metamask_welcome.png" title="metamask welcome" /></p>
<p>Nel mio caso in alto avrò questo Address per la mia Wallet:</p>
<code>0xDe945A653609b311c5fCb264343DdA66858139B0</code>
<p>Che è lo stesso che trovo in Ganache:</p>
<p><a href="/img/andrewz/blockchain3/ganache_address.png" title="Ganache address"><img src="/img/andrewz/blockchain3/ganache_address.png" title="Ganache address" width="720" /></a></p>
<p>In Metamask, nella parte superiore della window, è presente la mainnet di Ethereum, che nel mio caso è errato. Ora è il momento di collegare Ganache. Cliccando sul nome di questa rete sarà visibile un'opzione che rimanda alle impostazioni dove si possono attivare le reti di test. Attivata l'opzione:</p>
<p><img src="/img/andrewz/blockchain3/metamask_networks.png" title="metamask networks" /></p>
<p>E' già presente una Localhost ma ha la porta sbagliata (Ganache di default, come si è visto anche nello scorso post usa la porta 7545 di default). In linea teorica si può modificare quella rete esistente, nella pratica è impossibile (nella versione attuale qualsiasi modifica alla porta visualizza un errore e rimette il valore della porta precedente). Poco male, cliccando su Add Network si aprirà una pagina nel browser dove inserire la nuova rete, ma prima di farlo, cliccando su Networks, è sufficiente cancellare la rete esistente e quindi creare la nuova:</p>
<p><a href="/img/andrewz/blockchain3/metamask_new_network.png" title="Metamask new network"><img src="/img/andrewz/blockchain3/metamask_new_network.png" title="Metamask new network" width="720" /></a></p>
<p>In Chain ID inserire il valore numerico 1337 e in Symbol qualsiasi stringa. Cliccato su Save e tornato alla window principale di Metamask, se tutto ha funzionato, vedrò la network di Ganache collegata con il mio Account e il valore corretto di Ether:</p>
<p><img src="/img/andrewz/blockchain3/metamask_my_ganache_account.png" title="metamask my networks" /></p>
<p>E' possibile inserire anche gli altri Address di Ganache (o di qualsiasi altro Address di cui si conosce la Private Key). Nel tab degli Accounts è sufficiente cliccare sul simbolo della piccola chiave sulla sinistra per l'account che si vuole importare in Metamask, e sarà visualizzata la Private Key. Copiata è possibile importarla in Metamask. Cliccando sul simbolo in alto a sinistra appare la lista degli account già presenti, quindi cliccando sulla voce <strong>Import Account</strong>, si può inserire la Private Key e ora l'account sarà gestibile da Metamask.</p>
<p>Vorrei sprecare ancora qualche parola sull'Address. Come scritto nel primo post di questa serie questa stringa alfanumerica è un prodotto della Public Key che è a sua volta creata da una Private Key casuale. L'Address qui utilizzato non è collegato in modo univoco al network di test di Ganache. Io posso usare lo stesso Address su qualsiasi rete, anche sulla <em>mainnet</em> di Ethereum. Non si deve registrare né renderne conto a nessuno della sua esistenza: chi ha la chiave privata ne é il proprietario. In Metamask ora posso selezionare anche la <em>mainnet</em>:</p>
<p><img src="/img/andrewz/blockchain3/metamask_mainnet.png" title="metamask mainnet" /></p>
<p>E potrei utilizzare lo stesso Address per ricevere cryptovaluta da altri Address o da qualsiasi Exchange preposto a questo scopo. Al primo avvio di Metamask si può creare anche un nuovo Wallet che, in automatico, creerà una nuova <strong>Private Key -> Public Key -> Address</strong> (e saranno fornite anche la lista di dodici parole da utilizzare per recupare la Private Key) che potrò utilizzare anch'esso in qualsiasi network sia quello principale <em>mainnet</em> che qualsiasi di test. Esagerando, posso creare decine di Wallet, ma anche centinaia, e solo io che posseggo la Private Key di ognuno le potrò utilizzare attivamente. C'è qualche vantaggio nell'avere decine di Wallet? No.</p>
<p>L'utilità dei tool come Metamask è solo nella visualizzazione delle informazioni sul Wallet? No, questi strumenti vengono utilizzati come parte attiva per autorizzare le transazioni e l'invio di Cryptovaluta, inoltre possono essere utilizzati anche per eseguire l'Authentication nelle web application. Sul primo punto tornerò a breve.</p>
<h1>Lotteria in Solidity</h1>
<p>Ora ho tutto l'ambiente pronto. Prima di passare all'applicazione di test che utilizzerà Metamask è il momento di scrivere lo Smart Contract dell'esempio. Scopiazzando dagli numerosi esempi presenti in rete, creerò una piccola lotteria dove gli utenti potranno scommettere sull'uscita di un numero pseudo casuale da 1 a 9. Ecco tutto il codice:</p>
<code>// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
contract LotteryBlog {
event EnterNumberEvent(uint8 number, address from, uint value);
event PickWinnerEvent(uint8 winNumber, address to, uint value);
event KillContractEvent();
struct Player {
uint8 Number;
bool Exist;
}
struct Number {
address Address;
bool Exist;
}
mapping(address => Player) private _players;
mapping(uint8 => Number) private _numbers;
address private _owner = msg.sender;
uint8[] private _onlyNumbers;
uint private _balance = 0;
bool private _isActive = true;
modifier onlyOwner {
require(msg.sender == _owner, "Only owner");
_;
}
modifier isActive {
require(_isActive, "Lottery is closed");
_;
}
function getBalance() external view onlyOwner isActive returns(uint) {
return _balance;
}
function enter(uint8 number) external isActive payable {
require(msg.value == 1 ether, "Accepted only 1 ETH");
require(number > 0 && number < 10, "Number can be from 1 to 9");
require(!_players[msg.sender].Exist, "Player already has a number");
require(!_numbers[number].Exist, "Number already assigned");
_players[msg.sender] = Player(number, true);
_numbers[number] = Number(msg.sender, true);
_onlyNumbers.push(number);
_balance += msg.value;
emit EnterNumberEvent(number, msg.sender, msg.value);
}
function getNumbersUsed() external view returns (uint8[] memory) {
return _onlyNumbers;
}
function pickWinner(uint8 winNumber) public onlyOwner isActive {
require(_numbers[winNumber].Exist, "winNumber do not exist in array");
address to = payable(_numbers[winNumber].Address);
(bool sent, ) = to.call{value: _balance}("");
require(sent, "Failed to send Ether to the winner");
emit PickWinnerEvent(winNumber, to, _balance);
_balance = 0;
_isActive = false;
}
function kill() external onlyOwner {
selfdestruct(payable(_owner));
emit KillContractEvent();
}
}</code>
<p>Ad inizio codice ho definito due struct dove memorizzerò gli Address e i numeri scelti dai partecipanti alla lotteria. Le regole sono semplici vedendo i <strong>require</strong> nella funzione <em>Enter</em>: un utente può scommettere solo su un numero, non si può scegliere un numero già selezionato da un altro utente, è accettata come scommessa solo il valore di un Ether. Superate questi <strong>require</strong>, le informazioni vengono salvate e solo l'<em>owner</em> dello Smart Contract potrà, quando vorrà, con la funzione <em>pickWinner</em>, inserire il numero vincente che assegnerà in automatico la vincita all'utente che ha inserito il numero corretto - anche qui c'è il <strong>require</strong> che obbliga la selezione di un numero che effettivamente è stato inserito nelle scommesse. Inoltre è presente la funzione <em>getBalance</em> - utilizzabile solo dall'<em>owner</em> - per avere il valore attualmente salvato come valuta all'interno del contratto, e <em>getNumbersUsed</em> che visualizza i numeri attualmente inseriti. Per sicurezza è presente anche la funzione
<em>kill</em> spiegata
precedentemente.</p>
<p>Questo Smart Contract è utilizzabile dall'IDE di Remix. Utile per eseguire i vari test, ma lo scopo finale è utilizzarlo da una applicazione.</p>
<h1>Accesso via web agli Smart Contract con il Wallet</h1>
<p>
Si ritorna a parlare di Web3 e il fantomatico futuro che propone per il futuro di Internet. Ora creo una web application che dovrà utilizzare lo Smart Contract mostrato. Il tutto sarà fatto con una semplice pagina Html e del codice Javascript, che si interfaccerà con lo Smart Contract e con il Wallet Metamask. Niente di complesso. Il tutto è abbastanza simile a quanto esposto nello scorso post riguardante il C#.</p>
<p>
Inizio creando un web server minimale in Net6 che, con tre righe di codice, fa quanto mi serve:</p>
<code>var app = WebApplication.CreateBuilder(args).Build();
app.UseFileServer();
app.Run();</code>
<p>
Ora nella directory <em>wwwroot</em> sarà possibile inserire il contenuto - pagine html e quant'altro - che potrò richiamare via browser. Ecco la pagina principale in html:</p>
<code><html>
<head>
<title>Lottery Blog</title>
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/web3@latest/dist/web3.min.js"></script>
<link rel="stylesheet" href="css/lottery.css">
</head>
<body>
<h1>Lottery Blog</h1>
<h4>
<span>Wallet Address: </span><span id="walletAddress">-</span>
</h4>
<div id="grid">
<div class="lottery-board" id="grid9">
</div>
</div>
<script type="text/javascript" src="js/lottery.js"></script>
</body>
</html></code>
<p>
Viene inclusa la libreria web3.js che farà tutto il lavoro e il mio file Javascript lottery.js:</p>
<code>var account = null;
var contract = null;
const ABI = [{...}];
const ADDRESS = '0x2890....63783D0';
(async () => {
try {
if (window.ethereum) {
window.web3 = new Web3(window.ethereum);
window.ethereum.on('accountsChanged', function (accounts) {
checkAndDraw();
});
window.ethereum.on('networkChanged', function (networkId) {
checkAndDraw();
});
await checkAndDraw();
}
else {
alert("Metamask is not active");
}
}
catch (err) {
alert("Generic error: " + err);
}
})();</code>
<p>
In ADDRESS ho inserito l'Address del contratto e in ABI il JSON contenente i metadata dello Smart Contract (dove sono inserite le informazioni sulle funzioni presenti così come i parametri di input e output utilizzati - informazione reperibile dall'IDE di Remix come spiegato nello scorso blog). Controllando l'oggetto <em>window.ethereum</em> verifico che sia installato Metamask (o altro Wallet), in caso negativo viene visualizzato l'errore, altrimenti viene aperta la window di questo Wallet Manager dove si potrà selezionare il Wallet desiderato nella rete di Ethereum da utilizzare.
Per questa demo controllo che il network connesso sia <em>private</em> (gestendo lo switch di Metamask grazie agli eventi <em>accountChanged</em> e <em>networkChanged</em>) altrimenti visualizzo un errore generico. Così come fanno altri siti che gestiscono i Wallet, questo mi consente di verificare se utilizzo la <em>mainnet</em> o una qualsiasi rete di test e informare l'utente. Inoltre questa modalità di accesso con Metamask apre un nuovo tipo di autenticazione basato proprio sul Wallet e network desiderato (così come viene fatto da altre web application come <a href="https://opensea.io/" title="link esterno">OpenSea</a>). La funzione che ho creato <em>getWalletAddress</em> prende proprio le informazioni riguardanti il Wallet selezionato dall'utente:</p>
<code>async function getWalletAddress() {
await window.ethereum.enable();
const accounts = await window.ethereum.request({method: 'eth_requestAccounts'});
account = accounts[0];
document.getElementById('walletAddress').textContent = account;
});
}</code>
<p>Questo Address potrebbe essere utilizzato per creare il cookie di autenticazione da usare nella web application. Interessante ma non approfondirò in questo post anche perché non mi sono ancora noti alcuni punti di questo tipo di autenticazione come la sicurezza...</p>
<p>
La funzione <em>getContractInfo</em> istanzia l'oggetto in Javascript per poter utilizzare lo Smart Contract:</p>
<code>function getContractInfo() {
contract = new web3.eth.Contract(ABI, ADDRESS);
console.log(contract);
}</code>
<p>E' giunto il momento di chiamare il metodo dello Smart Contract per avere la lista dei numeri già selezionati: <em>getNumebersInserted</em>. Questo è il codice:</p>
<code>async function makeGrid() {
if (contract) {
var numbers = await contract.methods.getNumbersUsed().call();
console.log(`Numbers from blockchain: ${numbers} ${numbers.length}`);
var grid = document.getElementById('grid9');
for (let i = 1; i < 10; i ++) {
// create grid
}
}
}</code>
<p>Con una riga la libreria Web3 fa tutto il lavoro. Se si ricorda il codice in C# avevo dovuto specificare anche la rete Ethereum da utilizzare, in questo caso ho potuto scavalcare questo passaggio perché sarà il collegamento creato da Metamask a passare tutti i parametri alla libreria Web3 in Javascript.</p>
<p><img src="/img/andrewz/blockchain3/lottery_blog_1.png" title="Lottery demo" /></p>
<p>Nell'immagine qui sopra ho già inserito l'acquisto di quattro numeri fatta dall'IDE di Remix. Ora acquisterò un numero dalla pagina web. Sempre nel precedente post, avevo usato molte parole sulla pericolosità di utilizzare funzioni in C# che avviavano nello Smart Contract delle transazioni, perché era necessario l'inclusione della Private Key. In questo caso il passagio è fattibile con il minimo dei rischi, perché il tutto sarà gestito da Metamask e nel nostro codice non saranno mai passate informazioni come la Private Key. Sempre da Javascript ora voglio fare in modo che l'utente possa cliccare nel Box con il numero su cui scommettere:</p>
<code>function buyTicket(number, event) {
console.log(`buyTicket: ${number}`);
(async ()=>{
try
{
const reply = await contract.methods.enter(number.toString()).send({from: account, value: 1000000000000000000});
console.log(reply);
}
catch(err) {
// show error message
}
})();
...
}</code>
<p>Questo codice richiama la funzione Enter passando il numero su cui l'utente ha cliccato e il valore di un Ether come value in Wei. Prima di eseguirlo si aprirà la window di Metamask dove appariranno tutte le informazioni della transazione che l'utente potrà accettare o meno:</p>
<p><a href="/img/andrewz/blockchain3/lottery_blog_2.png" title="Buy ticket in lottery demo"><img src="/img/andrewz/blockchain3/lottery_blog_2.png" title="Lottery demo buy ticket" width="720" /></a></p>
<p>In questo modo ho ovviato al problema citato nello scorso post. La chiave privata non è disponibile e utilizzabile dalla mia applicazione e il tutto avviene con molta più sicurezza con tutte le informazioni necessarie per la transazione.</p>
<h1>Ma pure tu quanto mi costi?</h1>
<p>E' tempo di controllare il costo di tutto questo con il prezzo del gas a 50 Gwei:</p>
<ul>
<li>Per l'inserimento dello Smart Contract nella Blockchain: 526.383 unità di gas (¤77.68).</li>
<li>Per l'inserimento dei primo numero da parte dell'owner: 136.835 (¤20.19)</li>
<li>Inserimento secondo numero: 85.635 (¤12.63).</li>
<li>Inserimento terzo numero: 85.635 (¤12.63).</li>
<li>Assegnazione della vincita: 37.912 (¤5.59).</li>
</ul>
<p>
Ovviamente il costo non dipende dal quantitativo di cryptovaluta coinvolta nella transazione, lo stesso costo lo avrei avuto anche se avessi potuto scommettere un <em>Gwei</em>).</p>
<p>
Nelle ottimizzazioni è consigliato l'uso degli oggetti base per la <em>lunghezza</em> delle variabili. Il tipo base è sempre a 256 bit e ogni altra dimensione dell'oggetto viene prima convertito. Io nel mio codice ho inserito quello che io ritenevo il tipo ideale per quell'utilizzo: per esempio per la memorizzazione dei numeri vincenti ho utilizzato il tipo <strong>uint8</strong> (8 bit). E se avessi utilizzato la dimensione consigliata? Ecco lo stesso codice utilizzando, dove è possibile, oggetti a 256bit:</p>
<code>// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
contract LotteryBlog {
event EnterNumberEvent(uint number, address from, uint value);
event PickWinnerEvent(uint winNumber, address to, uint value);
event KillContractEvent();
struct Player {
uint Number;
bool Exist;
}
struct Number {
address Address;
bool Exist;
}
mapping(address => Player) private _players;
mapping(uint => Number) private _numbers;
address private _owner = msg.sender;
uint[] private _onlyNumbers;
uint private _balance = 0;
bool private _isActive = true;
modifier onlyOwner {
require(msg.sender == _owner, "Only owner");
_;
}
modifier isActive {
require(_isActive, "Lottery is closed");
_;
}
function getBalance() external view onlyOwner isActive returns(uint) {
return _balance;
}
function enter(uint number) external isActive payable {
require(msg.value == 1 ether, "Accepted only 1 ETH");
require(number > 0 && number < 10, "Number can be from 1 to 9");
require(!_players[msg.sender].Exist, "Player already has a number");
require(!_numbers[number].Exist, "Number already assigned");
_players[msg.sender] = Player(number, true);
_numbers[number] = Number(msg.sender, true);
_onlyNumbers.push(number);
_balance += msg.value;
emit EnterNumberEvent(number, msg.sender, msg.value);
}
function getNumbersUsed() external view returns (uint[] memory) {
return _onlyNumbers;
}
function pickWinner(uint winNumber) public onlyOwner isActive {
require(_numbers[winNumber].Exist, "winNumber do not exist in array");
address to = payable(_numbers[winNumber].Address);
(bool sent, ) = to.call{value: _balance}("");
require(sent, "Failed to send Ether to the winner");
emit PickWinnerEvent(winNumber, to, _balance);
_balance = 0;
_isActive = false;
}
function kill() external onlyOwner {
selfdestruct(payable(_owner));
emit KillContractEvent();
}
}</code>
<p>
Il funzionamento è del tutto simile a quello precedente, ma quanto consuma?</p>
<ul><li>Creazione: 502.016 unità di Gas.</li>
<li>Inserimento primo numero: 158.802.</li>
<li>Inserimento secondo numero: 124.602.</li>
<li>Inserimento terzo numero: 124.602.</li>
<li>Assegnazione della vincita: 37.865.</li>
</ul>
<p>
Dalla versione otto di Solidity il problema del maggiore consumo di Gas per la conversione all'oggetto base per le variabili è stato ridotto ma non del tutto risolto come si nota nella creazione del contratto. Il resto delle operazioni sono addirittura più onerose (come ci si dovrebbe aspettare) usando il tipo di dato a 256 bit. E questo a cosa porta? L'ottimizzazione è certosina e deve sempre essere testata più e più volte per trovare il risparmio massimo. Sinceramente io non ho trovato nessuna regola fissa nell'ultima major release di Solidity.</p>
<h1>Conclusioni</h1>
<p>
E si conclude qua la terza parte dedicata alle Blockchain, Smart Contract e divertimenti simili. Manca solo un post: quello dedicato agli NFT. E' solo fuffa? O c'è realmente qualcosa di utile dietro a essi che non sia speculazione? Premetto: ovviamente non si troverà la risposta a questi quesiti e, soprattutto, nessun consiglio su come speculare su di essi. Sarà solo una mia disanima tecnica.</p>
<p>
<a href="https://github.com/sbraer/Web3Blog" title="link esterno">Qui</a> il codice sorgente.</p><p>Continua a leggere <a href="https://blogs.aspitalia.com/az/post2910/Web3-Smart-Contract-Metamask-Web-Application.aspx"><em>Web3, Smart Contract, Metamask e Web Application</em></a>.</p><hr /><p><a href="https://www.aspitalia.com/">(C) 2024 ASPItalia.com Network - All rights reserved</a></p>Andrea Zani0https://blogs.aspitalia.com/az/post2910/Web3-Smart-Contract-Metamask-Web-Application.aspx#feedbackhttps://blogs.aspitalia.com/az/CommentRSS2910.aspxhttps://blogs.aspitalia.com/services/trackback.aspx?PostID=2910