画像をグリッチさせるサービスのAWS LambdaをRustで作るところからCDKでデプロイするまで

こんにちは、Progateのキリル(@virtualkirill)です。

AWS LambdaのRustランタイムがリリースされてからずっと使ってみたいと思っていました。本記事では、LambdaをRustで書いてAWSにデプロイするまでのステップを解説していきます。

文章が長くなりすぎないように、ある程度のRust、DockerとNode.jsの知識を前提にします。また、読者の環境にRustツールチェーン、DockerとNode.jsがインストールされていることを前提にします。

今回は、少しでも作業を楽しくするためにHello World的なハンドラーではなく、送られた画像のグリッチ版を返すナノサービスを作ります。その画像を自分のプロファイルに設定していただいてもいいし好きなように使ってもOK。単独のサービスとしては用途が不明ですが、Hello Worldより楽しいし、チュートリアルに向いています。

新しいRustプロジェクトを作る

まずは、Rustの新しいプロジェクトを作るところから始めます。

cargo new glitch
cd glitch

ここで、APIの中心となる機能、グリッチを適用する二つの関数を作ります。ちなみに、僕はプロのグリッチアーティストではないし、グリッチアートは奥が深いが、今回は以下の二つのトリックで十分です。一つ目のトリックは、画像のランダムなバイトを一つ別のバイトに置き換えることです。もう一つのトリックは、ランダムなバイトのシーケンスをソートすることです。Rustのスタンダードライブラリには乱数を生成するものがないのでまず rand をインストールします。

[dependencies]
rand = "0.8.4"

バイトを置き換える関数は以下のようにかけます。それを src/lib.rs におきます。

use rand::{self, Rng};

pub fn glitch_replace(image: &mut [u8]) {
    let mut rng = rand::thread_rng();
    let size = image.len() - 1;
    let rand_idx: usize = rng.gen_range(0..=size);
    image[rand_idx] = rng.gen_range(0..=255);
}

特に難しいことはしていません。画像をバイトのスライスへのミュータブルな参照として処理します。1バイトは0..=255の値しか表現できないので、その中で乱数を生成します。次はソートをするグリッチです。

const CHUNK_LEN: usize = 19;

pub fn glitch_sort(image: &mut [u8]) {
    let mut rng = rand::thread_rng();
    let size = image.len() - 1;
    let split_idx: usize = rng.gen_range(0..=size - CHUNK_LEN);
    let (_left, right) = image.split_at_mut(split_idx);
    let (glitched, _rest) = right.split_at_mut(CHUNK_LEN);
    glitched.sort();
}

ここでも特に難しいことはしていません。ランダムなインデックスでスライスをスプリットし、一方をさらにあらかじめ決めた長さのスライスに split_at_mut という便利なメソッドを使って、スプリットします。 CHUNK_LEN はソートしたい領域を表します。ここでは勝手に19と決めているだけなので、好きな数にして、違うグリッチの仕方も楽しめます。

そして、より強いグリッチをさせるためには、それぞれを複数回適用します。

pub fn glitch(image: &mut [u8]) {
    glitch_replace(image);
    glitch_sort(image);
    glitch_replace(image);
    glitch_sort(image);
    glitch_replace(image);
    glitch_sort(image);
    glitch_sort(image);
}

次は実際のlambdaを作ります。

Cargo.toml: 必要な依存をダウンロードする

以下がこのプロジェクトのために必要な再現のクレート:

[dependencies]
lambda_http = "0.4.1"
lambda_runtime = "0.4.1"
tokio = "1.12.0"
rand = "0.8.4"
jemallocator = "0.3.2"

lambda_runtime は名前の通り、関数を実行するためのランタイムです。これはRustランタイムが公式のランタイムAPIを使って実装されているから必要です。ランタイムAPIを使えば、どの言語でもlambdaを実装できるし、Rustもその一例です。 lambda_http はlambdaのリクエストとレスポンスとコンテキストの便利な型などを提供してくれるライブラリです。 tokio はasyncランタイム。今回作るハンドラーはすごく簡単でasyncを必要としないが、 lambda_runtime は asyncの関数を期待しているので、ここでは合わせるために使います。もしasyncに馴染みがなければ、RustのFuture (JavaScriptでいうPromise)を動かすためのライブラリだと思ってください。ここでは、関数をasyncとして定義する以外にasyncの心配をすることがありません。そして最後に jemallocator 。これは後で説明します。

main.rs: ハンドラー

では glitch 関数は実際どうやってハンドラーとして使えばいいのか?リクエストのbodyから画像のバイトを抽出し、グリッチをかけたバージョンをレスポンスにコピーする apply_glitch というハンドラーを作りましょう。

use lambda_http::handler;
use lambda_http::Body;
use lambda_http::{IntoResponse, Request};

async fn apply_glitch(mut req: Request, _c: Context) -> Result<impl IntoResponse, Error> {
    let payload = req.body_mut();
    match payload {
        Body::Binary(image) => {
            glitch(image);
            Ok(image.to_owned())
        }
        // Ideally you want to handle Body::Text and Body::Empty cases too.
        // We use a special macro unimplemented!() that prevents the compiler from failing without all cases handled.
        _ => unimplemented!(),
    }
}

IntoResponse という便利なトレートに気づいてほしい。このトレートがあるおかげで、ハンドラーから StringVec<u8> といった型を、HTTPヘッダーをあまり意識せずに返すことができます。

main.rs: Main

最後に、ハンドラーをただ lambda_http::handler でラップして完了です。実際に、lambdaランライムが動かすことができる関数が作られます。全てをつなぐには2行だけで済みます。#[tokio::main] を忘れないように注意してください。これは tokio の いわゆる attribute macromain 関数をasyncにしてくれます。 #[global_allocator] の部分も必須ですが、その説明は後でします。

use lambda_runtime::{self, Error};
use lambda_http::handler;
use jemallocator;

#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;

#[tokio::main]
async fn main() -> Result<(), Error> {
    let func = handler(apply_glitch);
    lambda_runtime::run(func).await?;
    Ok(())
}

AWSへのデプロイ

AWSにデプロイする方法はいくつあります。その一つはAWSコンソールですが、簡単な操作でも個人的に混乱することがあるので、CDKというIaC (Infrastructure as code)ツールがあって助かります。CDKは、AWSの必要なリソースを慣れているプログラミング言語で宣言的に定義できるライブラリです。今回はCDKのNode.jsバージョンを使います。CDKはTypeScriptの定義も含まれているので、AWSのドキュメントを全く見ずにデプロイできることも一つの強みです。

CDKプロジェクト

CDKの唯一のデメリットは、ローカル環境で必要なツールをあらかじめインストールしておく必要があることぐらいです。今回は aws CLI と Node.jsを用意します。AWSにログインしている必要があるので、aws CLIは自分のクレデンシャルが設定されていることを確認してください。CDKをインストールします:

npm install -g aws-cdk
cdk --version

CDKは、デプロイの前にアカウントに必要なリソースが存在していないと動かないです。そのリソースというのは例えば実質CloudFormationのスタックであるCDKのアウトプットとかlambdaそのものを保管するためのS3バケットです。めんどくさく聞こえるが、これは簡単な bootstrap コマンドだけでできます。

cdk bootstrap aws://ACCOUNT-NUMBER/REGION

では私たちが作ったlambdaをクラウドにデプロイするCDKプロジェクトを作りましょう。Rustプロジェクトのルートに lambda というフォルダーを作って、その中から以下のコマンドを実行してください:

cdk init app --language=typescript

このコマンドで、デプロイに必要なファイルが全て生成されます。 lambda/lib/lambda-stack.ts を開けると次のコードがあるはずです。

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

export class LambdaStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // The code that defines your stack goes here
  }
}

とりあえず問題ないことを確認するために、 cdk synth というコマンドを実行してドライランの結果としてアウトプットされた CloudFormation のコードをみましょう。エラーがなければOKです。今は追加のコンストラクト(それぞれのAWSのリソースを定義するための再利用可能なコンポーネント)をインストールせずにできることが少ない。なので必要なものをインストールします:

npm install @aws-cdk/aws-lambda @aws-cdk/aws-apigatewayv2-integrations @aws-cdk/aws-apigatewayv2 @aws-cdk/aws-apigatewayv2

これらを lambda/lib/lambda-stack.ts でimportします。

import * as apigw from "@aws-cdk/aws-apigatewayv2";
import * as intg from "@aws-cdk/aws-apigatewayv2-integrations";
import * as lambda from "@aws-cdk/aws-lambda";
import * as cdk from "@aws-cdk/core";

やっとリソースとしてのlambdaを上記のコンストラクター内で定義できます。

const glitchHandler = new lambda.Function(this, "GlitchHandler", {
  code: lambda.Code.fromAsset("../artifacts"),
  handler: "unrelated",
  runtime: lambda.Runtime.PROVIDED_AL2,
});

code はバイナリが置いてある場所です。 handler は、実際に実行される関数の名前ですが、今回のようにカスタムなランタイムを使う場合は、全く関係ないようです。そして runtime は PROVIDED_AL2 です。これは私たちが自分で(以前にRustの依存としてインストールした)Amazon Linux 2で動くランライムを用意するということを表します。lambdaを定義するだけでは足りません。lambdaは基本的に外の世界からはアクセスできないようになっていて、外からリクエストできるためには API Gatewayを使う必要があります。なので、API GatewayもCDKで定義します。

const glitchApi = new apigw.HttpApi(this, "GlitchAPI", {
  description: "Image glitching API",
  defaultIntegration: new intg.LambdaProxyIntegration({
    handler: glitchHandler,
  }),
});

これは大体自明です。以上で作ったlambda, glitchHandler という関数をリクエストのハンドラーとしてトリガーしてくれる HTTP API Gatewayが作られます。ちなみに、CDKではリソースで別のリソースを参照するには、実際のプログラミング言語の参照を使うことに注目してほしいです。これは個人的にすごく便利だと感じる機能です。

バイナリを作る

ほぼ完了ですが、CDKがちゃんとバイナリにアクセスしてアップロードできることを確認しましょう。Rustが原則としてビルドのアウトプットを target/ フォルダーに置き、パッケージ名と同じファイル名をつけます。AWSのRustのlambdaの変わっているところは、バイナリのファイル名を bootstrapにしないといけないところです。それを Cargo.toml で調整できます。

[package]
autobins = false

[[bin]]
name = "bootstrap"
path = "src/main.rs"

ついでにアウトプットフォルダーも artifacts にして 直接 cargo build でビルドできるようにしてもいいが、仮にこのプロジェクトはWindowsでもLinuxでもMacOSでも作業したいとしましょう。 bootstrap バイナリは実は x86_64-unknown-linux-gnu ターゲット向けにビルドしないといけないです。これは簡単には Windowsではできないので、 Dockerを使います!

RustをDockerで使ったことがあるなら、コンパイルが非常に遅くなることを経験しているはず。これは、依存だけビルドし、Dockerのキャッシュを利用するオプションが cargo には現時点ないからです。ありがたいことに、cargo-chefという、ワークアラウンドを提供してくれるクレートがあります。これを次のようにDockerfileで使います(ほとんと公式READMEのコピペ)。

FROM lukemathwalker/cargo-chef:latest-rust-1.53.0 AS chef
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Build dependencies - this is the caching Docker layer!
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release

FROM scratch AS export
COPY --from=builder /app/target/release/bootstrap /

これで、以下を実行すると:

docker build -o artifacts .

Dockerが x86_64-unknown-linux-gnu のバイナリをビルドし artifacts フォルダーに置きます。そしてcargo-chefのおかげで、再ビルドがすごく速くなります。あとは、CDKでビルドをデプロイするだけです!( lambda フォルダーの中にいることを確認してください)

cdk deploy

理想的には、デプロイ直後に、API GatewayがどのURLにデプロイされたかを知りたいですが、これもCDKに少しコードを追加することでできます。

new cdk.CfnOutput(this, "glitchApi", {
  value: glitchApi.url!,
});

これで、 cdk コマンドに --outputs-file オプションをつけると

cdk deploy --outputs-file cdk-outputs.json

lambda/cdk-outputs.json として作られたファイルの中にURLがみられます。

{
  "LambdaStack": {
    "glitchApi": "https://your-gateway-api-url.amazonaws.com/"
  }
}

Glitch!

色々大変だったと思いますが、やっとグリッチAPIが使えます!ここで僕が試せるURLを貼らなければ不親切なので、今すぐ試せるコマンドを下に用意しました。

curl -X POST https://ifzc7embya.execute-api.ap-northeast-1.amazonaws.com --data-binary "@pic.jpg" -o "$(date +%s).jpg"

このサービスが常にオンラインであることはもちろん保証できないですが。

基本的にここで作ったAPIを使うには、画像を用意して、以下のように呼び出します。

curl -X POST https://your-gateway-api-url.amazonaws.com --data-binary "@pic.jpg" -o glitched.jpg

glitched.jpg ファイルにはグリッチしたバージョンの画像があるはずです。それも美的に見て楽しいものだったら大成功!次のステップとして、グリッチの数や順番というパラメタをいじって違う結果を楽しむことができます。

いくつかの例

これがAPIで遊びながら作ったいくつかの気に入りのグリッチです。

f:id:jlkiri:20211116174144p:plain

f:id:jlkiri:20211116174150p:plain

f:id:jlkiri:20211116174140p:plain

f:id:jlkiri:20211116174134p:plain

ちょっと待って。結局 jemallocator の話は?ああ忘れるところでした。約束していたので説明します。AWSのlambdaは長い間 x86_64-unknown-linux-musl ターゲット向けにビルドしないといけなかったようです。これをするにはデフォルトとしてインストールされていない musl ツールチェーンが必要になって非常にめんどくさかったです。しかし現在は x86_64-unknown-linux-gnu のビルドでも問題ないようですが Rustのデフォルト アロケータである malloc の代わりに jemallocator を使う必要があります。jemallocator はクレートとして存在するので、インストールして、コードに1行を追加するだけで終わります。この条件は将来なくなるかはわかりません。

jlkiri/glitch-lambda (github.com)