Реализация функции AWS Lambda в Rust, которая записывает данные в DynamoDB. Все развернуто через Terraform, а также с HTTP-клиентом Rust!

В рамках моего путешествия, чтобы узнать больше о разработке Rust, я разработал сервис Lambda, размещенный на AWS, который записывает в базу данных DynamoDB и связанный HTTP-клиент Rust. Наряду с Rust я использовал Terraform для управления развертыванием ресурсов AWS. Эта статья — четвертая, которую я написал о своем приложении для беспроводного термостата, работающем на Raspberry Pi. Вы можете найти другие здесь: Беспроводной термостат Raspberry Pi — в Rust, Простая кросс-компиляция Rust и Реализация многопоточной общей памяти в Rust. Весь исходный код доступен в моем репозитории GitHub.

В этой статье мы рассмотрим следующее:

  1. Определите JSON API как отдельный ящик, совместно используемый двумя связанными проектами.
  2. Напишите функцию AWS Lambda на Rust, используя SDK AWS Rust, которая принимает HTTP POST с полезными данными JSON и записывает данные в базу данных DynamoDB.
  3. Используйте Terraform для определения и создания базы данных, лямбда-функции и связующего звена разрешений, необходимого для того, чтобы все части соответствовали друг другу.
  4. Используйте AWS CLI для развертывания исполняемых обновлений приложения Lambda.
  5. Напишите HTTP-клиент Rust, который отправляет данные в нашу функцию Lambda.

Я предполагаю, что в вашей системе установлены AWS CLI, Terraform и Rust, а ваша учетная запись AWS настроена и подключена к CLI. Это немного работы, но ее легко выполнить в документации каждой системы.

Вариант использования моего приложения — отслеживать состояние моего приложения термостата Raspberry Pi и записывать историю. Приложение Rust, работающее на Raspberry Pi, будет передавать информацию в облачную базу данных. Имея данные об активности в облачной базе данных, можно отслеживать работоспособность приложения, изучая данные и избегая необходимости открывать брандмауэры для доступа внешних наблюдателей. Я также получаю источник данных для истории, который позже я могу отобразить в пользовательском интерфейсе.

Я выбрал DynamoDB на AWS в качестве платформы базы данных. Мои данные легко вписываются в уровень бесплатного пользования DynamoDB, а DynamoDB — эффективное место для передачи данных временных рядов IoT. Вместо прямого подключения приложения Pi к базе данных Dynamo я выбрал сервисный уровень на основе HTTP для интерфейса между Raspberry PI и AWS. Я обнаружил, что службы HTTP более устойчивы, чем прямые подключения к БД — природа HTTP без сохранения состояния делает его самокорректирующимся при сбоях в работе сети. Передача данных в БД — отличная работа для функции Lambda, и, поскольку AWS недавно опубликовала Rust SDK, я воспользовался возможностью создать функцию Lambda как приложение Rust. Вот картина того, как части сочетаются друг с другом, которые мы собираемся изучить:

Приложение состоит из трех основных частей. Во-первых, основным приложением является термостат_pi, клиент, который создает данные, которые мы перемещаем в базу данных. В рамках этого проекта находится проект функции Lambda с именем push_temp. Наконец, проект temp_data содержит определение API передачи данных. Все три проекта находятся на GitHub под приложением thermostat_pi.

В temp_data я начал со структуры Rust, которая содержит фрагменты данных для приложения термостата, и включил serde для представления JSON:

//temp-data/src/lib.rs
use serde::Deserialize;
use serde::Serialize;

#[derive(Debug, Serialize, Deserialize)]
pub struct TempData {
 pub record_date: String,
 pub thermostat_on: bool,
 pub temperature: f32,
 pub thermostat_value: u16,
}

Я создал это в отдельном ящике Rust, чтобы его можно было использовать как в приложении Pi, так и в проектах лямбда-функций, гарантируя, что они всегда будут синхронизированы. temp-data Cargo.toml выглядит так:

[package]
name = "temp-data"
version = "0.1.0"
edition = "2021"
license = "MIT"

[dependencies]
serde = {version = "1", features = ["derive"]}

Затем я определил соответствующую базу данных DynamoDB для хранения этой информации. Я выбрал ключ раздела «день» для данных временных рядов, что позволяет извлекать данные за день без сканирования всей таблицы. Я также создал ключ сортировки для даты/времени. Эта ключевая структура обеспечит эффективный доступ для чтения к данным, когда я хочу настроить сигнал тревоги или построить график исторических данных. У меня нет большого опыта работы с DynamoDB, поэтому мог бы быть более эффективный способ решить эту проблему — но то, что у меня есть, работает для меня. Вот как будет выглядеть таблица DynamoDB, когда мы закончим:

Ключи Record_Day и Record_Date представляют собой строки для DynamoDB. Формат Record_Date — это RFC3339, который поддерживает стандартный пакет времени Rust. Он создает строку, которая может правильно сортировать значения времени по алфавиту.

Затем мы создаем лямбда-функцию, которая принимает наш входящий запрос и сохраняет его в таблице DynamoDB. Это находится в каталоге push-temp моего основного проекта (ссылка на GitHub). Push-temp Cargo.toml содержит следующие записи:

[package]
name = "push_temp"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"

[dependencies]
aws-config = "0.51.0"
aws-sdk-dynamodb = "0.21.0"
log = "0.4.14"
serde = {version = "1", features = ["derive"]}
tokio = "1.16.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
lambda_http = "0.7"
serde_json = "1.0.78"

# Our package that defines the struct of the incoming request
temp-data = { path="../temp-data" }

Мы используем AWS SDK для Rust. Я поместил весь код Rust в файл main.rs для нашей лямбда-функции. Во-первых, есть некоторый шаблон для импорта нашей структуры сообщений, определения наших типов ответов и настройки среды Lambda:

//push-temp/src/main.rs
use aws_sdk_dynamodb::model::AttributeValue;
use aws_sdk_dynamodb::Client;
use lambda_http::{lambda_runtime::Error, service_fn, IntoResponse, Request};

extern crate temp_data;
use temp_data::TempData;

use log::{debug, error};
use serde::Serialize;

#[derive(Debug, Serialize)]
struct SuccessResponse {
 pub body: String,
}

#[derive(Debug, Serialize)]
struct FailureResponse {
 pub body: String,
}

// Implement Display for the Failure response so that we can then implement Error.
impl std::fmt::Display for FailureResponse {
 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 write!(f, "{}", self.body)
 }
}

impl std::error::Error for FailureResponse {}

Функция main() регистрирует обработчик входящего события; наша функция-обработчик называется my_handler:

//push-temp/src/main.rs (continued)
#[tokio::main]
async fn main() -> Result<(), Error> {
 tracing_subscriber::fmt::init();
 debug!("logger has been set up");
 lambda_http::run(service_fn(my_handler)).await?;
 Ok(())
}

Наша функция my_handler() запустится при поступлении входящего запроса. Наша функция my_handler() должна сделать пару вещей. Во-первых, он получает входящий JSON из запроса и анализирует его в нашу структуру request_struct. Обратите внимание, что если синтаксический анализ JSON завершается сбоем, в этот момент возвращается значение ошибки.

//push-temp/src/main.rs (continued)
async fn my_handler(request: Request) -> Result<impl IntoResponse, Error> {
  debug!("handling a request, Request is: {:?}", request);
  let request_json = match request.body() {
    lambda_http::Body::Text(json_string) => json_string,
    _ => "",
  };
  debug!("Request JSON is : {:?}", request_json);
  let request_struct: TempData = serde_json::from_str(request_json)?;

Далее нам нужно поместить эту структуру в нашу таблицу DynamoDB. Я решил разделить каждый элемент данных на отдельный атрибут DynamoDB вместо того, чтобы хранить JSON напрямую. Мы делаем небольшое форматирование данных, чтобы выделить день как отдельный атрибут для использования в качестве нашего ключа раздела. Остальные значения структуры преобразуются в AttributeValues ​​для Dynamo DB API. Наша обработка ошибок скрывает сообщения об ошибках DynamoDB от конечного пользователя как детали реализации.

//push-temp/src/main.rs (continued)

// set up as a DynamoDB client
let config = aws_config::load_from_env().await;
let client = Client::new(&config);

// build the values that are stored in the DB
let record_date_av = AttributeValue::S(request_struct.record_date.clone());
let thermostat_on_av = AttributeValue::S(request_struct.thermostat_on.to_string());
let temperature_av = AttributeValue::N(request_struct.temperature.to_string());
let thermostat_value_av = AttributeValue::N(request_struct.thermostat_value.to_string());
let record_day_av: AttributeValue = AttributeValue::S(request_struct.record_date[..10].to_string());

// Store our data in the DB
let _resp = client
  .put_item()
  .table_name("Shop_Thermostat")
  .item("Record_Day", record_day_av)
  .item("Record_Date", record_date_av)
  .item("Thermostat_On", thermostat_on_av)
  .item("Temperature", temperature_av)
  .item("Thermostat_Value", thermostat_value_av)
  .send()
  .await
  .map_err(|err| {
    error!("failed to put item in Shop_Thermostat, error: {}", err);
    FailureResponse {
      body: "The lambda encountered an error and your message was not saved".to_owned(),
    }
  })?;
debug! {
 "Successfully stored item {:?}", &request_struct
 }
Ok("the lambda was successful".to_string())
}

Чтобы развернуть нашу пользовательскую функцию Lambda в AWS, нам нужно создать исполняемый файл с именем bootstrap. Нам нужен Rust для сборки нашего исполняемого файла путем кросс-компиляции в цель x86_64-unknown-linux-musl — это то, что требуется для времени выполнения Lambda. Мне нравится использовать just в качестве средства запуска команд, и я создал простой justfile для сборки, который запускает две команды, необходимые для создания исполняемого файла bootstrap в нашем локальном каталоге. Я использую кросс-инструмент (cargo install cross), который извлекает контейнер Docker для среды кросс-компиляции. AWS SDK документирует альтернативы cross, если вы не хотите использовать локальный контейнер Docker. Наконец, мы копируем созданный исполняемый файл в файл с магическим именем bootstrap и сохраняем его в корне нашего проекта.

#push-temp/justfile
build: 
 cross build - release - target x86_64-unknown-linux-musl
 cp target/x86_64-unknown-linux-musl/release/push_temp bootstrap

Мы могли бы вручную развернуть нашу функцию Lambda, заархивировав загрузочный файл и загрузив его через веб-интерфейс AWS. Но другие части AWS должны обходить функцию Lambda, чтобы все работало. Нам нужно настроить разрешения для функции Lambda для вставки данных в наши таблицы DynamoDB и разрешения для выполнения самой функции Lambda.

Недавно AWS опубликовала средство для создания URL-адреса функции Lambda — конечной точки HTTPS, напрямую подключенной к функции Lambda. Для простых случаев использования, таких как наш, URL-адрес функции Lambda упрощает настройку и позволяет избежать необходимости создавать конечную точку шлюза API. Если конечная точка шлюза API важна для вас, я бы посоветовал прочитать эту статью, которая включает в себя необходимые дополнительные шаги. Мой подход представляет собой упрощенную версию описанного.

Мы могли бы использовать консоль AWS для создания нашей лямбда-функции, URL-адреса функции и DynamoDB, но это не очень удобно для повторения. Вместо этого давайте воспользуемся Terraform для определения частей, которые нам нужны для повторяемости процесса. Это также дает нам чистый способ удалить все, когда мы хотим это сделать. Я разделил конфигурацию Terraform на набор файлов для каждой части нашего развертывания, и все они расположены в корне ящика push_temp. Во-первых, файл variables.tf будет определять пару общих значений, которые нам понадобятся:

#push-temp/variables.tf

# Input variable definitions, adjust for your needs
variable "aws_region" {
  description = "AWS region for all resources."
  type = string
  default = "us-east-2"
}

variable "push_temp_bin_path" {
  description = "The binary path for the lambda."
  type = string
  default = "./bootstrap"
}

Затем файл main.tf устанавливает нашу среду:

#push-temp/main.tf

terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 4.0"
    }
    archive = {
      source = "hashicorp/archive"
      version = "~> 2.2.0"
    }
  }

  required_version = "~> 1.0"
}

provider "aws" {
  region = var.aws_region
}

data "aws_caller_identity" "current" {}

Теперь мы можем настроить каждый из ресурсов, которые нам нужно развернуть. Сначала мы создаем таблицу DynamoDB. Обратите внимание, что мы определяем два столбца, которые используем в качестве ключей, а остальные динамически создаются по мере вставки данных. Наши ключи — это строки, поэтому для их определения мы используем тип = «S». Мы инициализируем таблицу с минимально возможным использованием ресурсов, так как у нас есть один маленький Raspberry Pi, отправляющий нам данные.

#push-temp/dynamo.tf

# aws_dynamodb_table.shop-thermostat-table:
resource "aws_dynamodb_table" "shop-thermostat-table" {
  hash_key = "Record_Day"
  name = "Shop_Thermostat"
  range_key = "Record_Date"
  billing_mode = "PAY_PER_REQUEST"
  read_capacity = 0
  write_capacity = 0
  
  attribute {
    name = "Record_Day"
    type = "S"
  }

  attribute {
    name = "Record_Date"
    type = "S"
  }
}

Далее мы можем определить нашу лямбда-функцию. Нам нужно предоставить .zip-файл нашего исполняемого файла в Terraform для первоначального развертывания, чтобы настроить функцию Lambda. Я не хочу использовать Terraform для развертывания нашего исполняемого файла при каждом изменении приложения — Terraform не является инструментом CI/CD. Но нам нужно что-то для создания функции. Поэтому после того, как все ресурсы будут успешно созданы, мы будем использовать другой метод для развертывания обновлений приложений.

Мы также настроили URL-адрес лямбда-функции в качестве общедоступной конечной точки.

#push-temp/lambdas.tf

# Here we grab the compiled executable and use the archive_file package
# to convert it into the .zip file we need.
data "archive_file" "push_temp_lambda_archive" {
  type = "zip"
  source_file = var.push_temp_bin_path
  output_path = "bootstrap.zip"
}

# Here we set up an IAM role for our Lambda function
resource "aws_iam_role" "push_temp_lambda_execution_role" {
  assume_role_policy = <<EOF
  {
    "Version": "2012–10–17",
    "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow",
      "Sid": ""
    }
  ]
}
EOF
}

# Here we attach a permission to execute a lambda function to our role
resource "aws_iam_role_policy_attachment" "push_temp_lambda_execution_policy" {
  role = aws_iam_role.push_temp_lambda_execution_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Here is the definition of our lambda function 
resource "aws_lambda_function" "push_temp_lambda" {
  function_name = "PushTemp"
  source_code_hash = data.archive_file.push_temp_lambda_archive.output_base64sha256
  filename = data.archive_file.push_temp_lambda_archive.output_path
  handler = "func"
  runtime = "provided"
  
  # here we enable debug logging for our Rust run-time environment. We would change
  # this to something less verbose for production.
 environment {
   variables = {
     "RUST_LOG" = "debug"
   }
 }
 
 #This attaches the role defined above to this lambda function
 role = aws_iam_role.push_temp_lambda_execution_role.arn
}

// Add lambda -> DynamoDB policies to the lambda execution role
resource "aws_iam_role_policy" "write_db_policy" {
  name = "lambda_write_db_policy"
  role = aws_iam_role.push_temp_lambda_execution_role.name
  policy = <<EOF
{
  "Version": "2012–10–17",
  "Statement": [
   {
     "Sid": "",
     "Action": [
       "dynamodb:PutItem"
     ],
     "Effect": "Allow",
     "Resource": "arn:aws:dynamodb: :${var.aws_region}::${data.aws_caller_identity.current.account_id}:table/Shop_Thermostat"
   }
 ]
}
EOF
}

// The Lambda Function URL that allows direct access to our function
resource "aws_lambda_function_url" "push_temp_function" {
   function_name = aws_lambda_function.push_temp_lambda.function_name
   authorization_type = "NONE"
}

Наконец, мы создаем выходной файл, чтобы получить конечную точку API для вызова нашей функции:

#push-temp/output.tf

# Output value definitions
output "invoke_url" {
  value = aws_lambda_function_url.push_temp_function.function_url
}

Ура, все сделано! `terraform init & terraform apply` создаст все необходимое, загрузит нашу недавно скомпилированную функцию и подготовит ее к тестированию!

Мы можем вызвать внешнюю конечную точку через curl, заменив «конечную точку» ниже на значение, которое terraform выводит при применении.

curl -X POST https://<endpoint>.lambda-url.us-east-2.on.aws/ \
-H 'Content-Type: application/json' \
-d '{"record_date":"2022–02–03T13:22:22","thermostat_on":true,"temperature":"65","thermostat_value":"64"}'

Вы можете использовать консоль DynamoDB, чтобы увидеть новую запись в базе данных:

Чтобы обновить код приложения после первоначального развертывания, я создал цель развертывания в своем файле justfile для команд, необходимых для развертывания обновленного приложения. Эти команды полагаются на то, что интерфейс командной строки AWS должен быть установлен и настроен для того же региона, что и функция Lambda.

#push-temp/justfile (continued)
deploy: build
  cp target/x86_64-unknown-linux-musl/release/push_temp bootstrap
  zip bootstrap.zip bootstrap
  aws lambda update-function-code - function-name PushTemp - zip-file fileb://./bootstrap.zip

Теперь, когда у нас есть работающая серверная часть, которая может принимать сообщения HTTP с нашими данными JSON и сохранять их в DynamoDB, мы можем создать внешний интерфейс Rust, который отправляет этот запрос. Наш Cargo.toml в основном приложении снова имеет ссылку на наш общий контейнер TempData, чтобы мы могли использовать общую структуру.

[dependencies]
temp-data = { path="temp-data" }

Я создал функцию store_temp_data() для использования всякий раз, когда новые данные доступны в приложении Rust. Я передаю данные и URL-адрес конечной точки, который находится в конфигурации времени выполнения в другом месте. Я использую крейт reqwest для базового HTTP-клиента. Наша функция начинается с инициализации клиента и создания структуры запроса TempData, которую мы видели ранее. Мы также получаем текущее время и конвертируем его в формат RFC3339.

//thermostat-pi/src/send_temp.rs

use reqwest;
use reqwest::Error;
use time::format_description::well_known::Rfc3339;
use time::macros::offset;
use time::OffsetDateTime;

extern crate temp_data;
use temp_data::TempData;

pub async fn store_temp_data(
  thermostat_on: bool, 
  current_temp: f32,
  thermostat_value: i16,
  aws_url: &str,
) -> Result<(), Error> {
  let client = reqwest::Client::new();
  
  // Get the current time, offset to my timezone
  let now = OffsetDateTime::now_utc().to_offset(offset!(-6));
  let now = now.format(&Rfc3339).unwrap();

  let body = TempData {
    record_date: now,
    thermostat_on: thermostat_on,
    temperature: current_temp
    thermostat_value: thermostat_value
  };

Затем мы отправляем запрос на нашу конечную точку, попутно сериализуя его в JSON, и обрабатываем ответ. Я выбираю регистрировать ошибку и возвращать OK при ошибке, так как это некритическая функция для нашего приложения.

//thermostat-pi/src/send_temp.rs (continued) 

  let response = client
    .post(aws_url)
    .json(&body)
    .send()
    .await;

  match response {
    Ok(r) => {
      tracing::debug!("response: {:?}", r);
    }
    Err(e) => {
      tracing::error!("Error sending to AWS, {}", e);
    }
  }

  Ok(())
}

Вот и все! Что касается пяти вещей, которые мы намеревались выполнить, вот наши ключевые выводы:

  1. Мы определяем структуру TempData в отдельном контейнере (каталоге) с собственным Cargo.toml, что дает нам общую структуру для ссылок для API между нашими клиентскими и серверными приложениями. Использование структуры для определения интерфейса на основе JSON между клиентом и сервером и использование serde для сериализации и десериализации нашей структуры TempData на обоих концах позволяет легко настроить и синхронизировать наши проекты.
  2. AWS Rust SDK предоставляет простые в использовании интерфейсы для определения Rust for Lambda и доступа к DynamoDB. Rust представляет собой отличную среду выполнения Lambda благодаря своей скорости и малому объему памяти.
  3. Terraform отлично подходит для создания всех необходимых нам компонентов AWS и настройки разрешений, необходимых для склеивания всего вместе.
  4. Использование интерфейса командной строки AWS — это простой способ обновить исполняемый файл Lambda по требованию.
  5. Крейт reqwest дает нам простые средства для отправки HTTP-запросов для нашего клиентского приложения.

Надеюсь, вы найдете это полезным в своем путешествии по Rust! Если у вас есть идеи по улучшению, пишите в комментариях.