😽

AWS LambdaでSwiftを動かす

2024/12/23に公開

この記事は株式会社ガラパゴス(有志) Advent Calendar 2024 の25日目です。

この記事について

AWS LambdaでSwiftを使っている記事が少なくやりたいことをできるようになるまでとても苦労したのでまとめてみました。

なぜSwiftを使うのか

私がSwift以外のプログラミング言語を知らないからです。

開発環境

  • Swift 6
  • Xcode 16.2

本編

プロジェクトの作成

任意の場所で以下コマンドを実行し、プロジェクトを作成します。

$ swift package init --type executable --name LambdaExample

実行すると以下のように構成でプロジェクトが作成されます。

.
├── Package.resolved
├── Package.swift
└── Sources
    └── main.swift -> lambda.swiftにリネームする必要があります。

依存関係の設定

Package.swift
import PackageDescription

let package = Package(
    name: "LambdaExample",
    platforms: [
        .macOS(.v14)
    ],
    products: [
        .executable(name: "LambdaExample", targets: ["LambdaExample"])
    ],
    dependencies: [
        .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "v1")
    ],
    targets: [
        .executableTarget(
            name: "LambdaExample",
            dependencies: [
                .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
            ]
        ),
    ]
)

mainブランチは開発中のようで不安定そうなのでv1ブランチを指定して使用します。

メイン部分の実装

lambda.swift
import Foundation
import AWSLambdaRuntime

@main
struct S3ExampleLambda: SimpleLambdaHandler {
    func handle(_ event: Request, context: LambdaContext) async throws -> Response {
        return Response(body: "Hello Swift! \(event.body)", status: .ok)
    }
}
Request, Response
Model/Request.swift
struct Request: Decodable, Sendable {
    let body: String
}
Model/Response.swift
struct Response: Encodable, Sendable {
    let body: String
    let status: Status

    enum Status: Int, Codable {
        case ok = 200
        case internalServerError = 500
    }
}

まずは一旦単純にリクエストを受け取って、そのまま返す実装にしてます。

ローカルでデバッグ

ローカルでデバッグを行うさいに一癖あり、LOCAL_LAMBDA_SERVER_ENABLEDを設定しないといけません。設定なしで実行すると実行時にbadStatusCode(403 Forbidden)で終了してしまいます。

設定方法

Xcodeで設定する場合

Product -> Scheme -> Edit Scheme -> Run -> Arguments内のEnvironment VariablesでNameにLOCAL_LAMBDA_SERVER_ENABLED、Valueにtrueを設定します。

ターミナルから実行する場合

LOCAL_LAMBDA_SERVER_ENABLED=true swift run

設定し終えたら、Xcodeまたはターミナルから実行し、以下のようなcurlコマンドを使ってPOSTリクエストを送信してみます。

curl -v --header "Content-Type:\ application/json" \
--data '{"body": "test"}' \
http://127.0.0.1:7000/invoke

すると、以下のようなレスポンスが返ってきて正常に処理が行われていることがわかります。

*   Trying 127.0.0.1:7000...
* Connected to 127.0.0.1 (127.0.0.1) port 7000
> POST /invoke HTTP/1.1
> Host: 127.0.0.1:7000
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type:\ application/json
> Content-Length: 16
> 
* upload completely sent off: 16 bytes
< HTTP/1.1 200 OK
< content-length: 41
< 
* Connection #0 to host 127.0.0.1 left intact
{"body":"Hello Swift! test","status":200}%

Lambdaにデプロイしてみる

zipファイルの作成

AWS Lambdaにデプロイするにはzipファイルとしてパッケージ化してアップロードする必要があります。Dockerを介してビルドし、zipファイルを生成するのでDockerを起動した上で以下のコマンド実行します。

swift package --disable-sandbox archive

実行後成功するとLambdaExample/.build/plugins/AWSLambdaPackager/outputs/AWSLambdaPackager内に以下の通りにzipファイルが生成されています。

.
└── LambdaExample
    ├── LambdaExample
    ├── LambdaExample.zip
    └── bootstrap -> LambdaExample

注意点

macOSでswift package archiveは以下の理由により使用できないようです。

on macOS, the archiving plugin uses docker to build the Lambda for Amazon Linux 2, and as such requires to communicate with Docker over the localhost network. At the moment, SwiftPM does not allow plugin communication over network, and as such the invocation requires breaking from the SwiftPM plugin sandbox. This limitation would be removed in the future.

Lambdaに関数を作成してzipファイルをアップロードする

関数の作成の際、以下のことに注意し作成してください。
(現時点でロールとかは特に不要)

  • ランタイムはAmazon Linux 2を使用すること
  • アーキテクチャはご自身にあったものを使う(M1以降のMacを使用しているのであればarm64)
    作成後、先ほど作成したzipファイルをアップロードします。

Lambda上でテストを作成して実行してみる

テストタブに切り替えて、ローカルで実行した時と同じデータでテストします。

{
    "body": "test"
}

テスト実行後、すぐ上に実行結果が表示されるので確認します。

詳細を押すと、ローカルで実行した時同じレスポンスが表示されます。

これで、Lambda上でSwiftが実行できることが確認できました。

S3からデータを取得する

送信したリクエストをレスポンスとして返すだけではなんの意味もないのでS3からデータを取得してみましょう。

公開範囲がプライベートな場合

公開範囲がプライベート(バケット所有者のみ)の場合、ローカルでデバッグするときにアクセスキーの設定が必要になります。以下の情報をEnvironment Variablesに追加します。

AWS_ACCESS_KEY_ID=YOUR_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=YOUR_AWS_SECRET_ACCESS_KEY

依存関係の追加

Package.swift
let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "v1"),
+       .package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.0.60")
    ],
    targets: [
        .executableTarget(
            name: "LambdaExample",
            dependencies: [
                .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
+               .product(name: "AWSS3", package: "aws-sdk-swift")
            ]
        ),
    ]
)

aws-sdk-swiftを追加してAWSS3をimportできるようにします

S3とやりとりするレポジトリの作成

import Foundation
import AWSS3

enum S3Repository {
    static func fetchAccount() async throws -> Account {
        return try await fetchObject(key: "test.json")
    }

    private static func fetchObject<T: Decodable>(
        key: String,
        region: String = "ap-northeast-1",
        bucketName: String = "your-bucket-name"
    ) async throws -> T {
        let s3Client = try S3Client(region: region)

        let input = GetObjectInput(bucket: bucketName, key: key)
        let output = try await s3Client.getObject(input: input)

        guard let data = try await output.body?.readData() else {
            throw AWSS3Error.cannotReadData
        }

        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return try jsonDecoder.decode(T.self, from: data)
    }
}

enum AWSS3Error: Error {
    case cannotReadData
}
Account.swift
Account.swift
struct Account: Codable {
    let accountName: String
    let accountNumber: Int
}

特に難しいことはなく、リージョン、バケット名、ファイル名を指定して取得することが可能です。

ハンドラの修正

lambda.swift
@main
struct S3ExampleLambda: SimpleLambdaHandler {
    func handle(_ event: Request, context: LambdaContext) async throws -> Response {
+       let account = try await S3Repository.fetchAccount()
+       context.logger.info("\(account)")
-       return Response(body: "Hello Swift! \(event.body)", status: .ok)
+       return Response(body: "accountName: \(account.accountName), accountNumber: \(account.accountNumber)", status: .ok)
    }
}

ハンドラに読み取り処理を追加し、取得できた情報をレスポンスとして返してみます。

実行してみる

また、Xcodeの実行ボタンを押してローカルサーバを起動し、curlコマンドを実行します

コマンド
curl -v --header "Content-Type:\ application/json" \
--data '{"body": "test"}' \
http://127.0.0.1:7000/invoke

下記の通り、test.jsonに設定した情報がレスポンスして返ってきたことがわかります。

{"status":200,"body":"accountName: guest, accountNumber: 1"}%

デプロイしてテストしてみる

swift package --disable-sandbox archiveコマンド実行してzipファイルを作成してアップロードします。アップロード完了後、テスト実行してみます。

あれ?実行できない...?あれ...

私の環境だけでしょうか、実行すると以下のエラーが出てしまいます。

Lifecycle(id: 1803393718321, maxTimes: 0, stopSignal: TERM)
RuntimeEngine(ip: 127.0.0.1, port: 9001, requestTimeout: nil
START RequestId: 4d8f2fb0-97c5-4f40-96f0-ac7229a73e34 Version: $LATEST
AWSClientRuntime/resource_bundle_accessor.swift:12: Fatal error: could not load resource bundle: from /var/task/aws-sdk-swift_AWSClientRuntime.resources or /workspace/.build/aarch64-unknown-linux-gnu/release/aws-sdk-swift_AWSClientRuntime.resources
2024-12-17T03:05:50.664Z 4d8f2fb0-97c5-4f40-96f0-ac7229a73e34 Task timed out after 3.05 seconds

しばらくこの謎のエラーで作業が止まってしまいました。結局色々試してみましたが解決できず原因不明で終わってしまっています。(解決方法わかる方いましたら教えていただきたいです。)
このままだとSwiftで実装することができなくなってしまいます。色々調べていると、aws-sdk-swiftの他に、sotoというライブラリを見つけました。こちらを使って先ほどの実装を修正します。

sotoライブラリに切り替える

  • 依存関係の修正
package.swift
let package = Package(
    ...
    dependencies: [
        .package(url: "https://github.com/swift-server/swift-aws-lambda-runtime.git", branch: "v1"),
-       .package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.0.60")
+       .package(url: "https://github.com/soto-project/soto.git", from: "7.2.0")
    ],
    targets: [
        .executableTarget(
            name: "LambdaExample",
            dependencies: [
                .product(name: "AWSLambdaRuntime", package: "swift-aws-lambda-runtime"),
-               .product(name: "AWSS3", package: "aws-sdk-swift")
+               .product(name: "SotoS3", package: "soto")
            ]
        ),
    ],
    swiftLanguageModes: [.v6]
)

  • レポジトリの修正
S3Repository.swift
import Foundation
- import AWSS3
+ import SotoS3

enum S3Repository {
    static func fetchAccount() async throws -> Account {
        return try await fetchObject(key: "test.json")
    }

    private static func fetchObject<T: Decodable>(
        key: String,
-       region: String = "ap-northeast-1",
+       region: Region = .apnortheast1,
        bucketName: String = "blog-sample-bucket-name"
    ) async throws -> T {
-       let s3Client = try S3Client(region: region)
-       let input = GetObjectInput(bucket: bucketName, key: key)
-       let output = try await s3Client.getObject(input: input)

+       let client = AWSClient()
+       let s3 = S3(client: client, region: region)
+       let input = S3.GetObjectRequest(bucket: bucketName, key: key)
+       let output = try await s3.getObject(input)

-       guard let data = try await output.body?.readData() else {
-           throw AWSS3Error.cannotReadData
-       }
+       let data = try await output.body.collect(upTo: 1_000_000)
+       try await client.shutdown()

        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return try jsonDecoder.decode(T.self, from: data)
    }
}

さほど大きな変更点はありません、若干書き方が変わった程度です。
また、ローカルで実行して、正常にレスポンスが返ってくることを確認し、Lambdaにデプロイします。

Lambdaでテストを実行

デプロイ完了後、テストを実行して、Lambda上で以下のレスポンスが返されることを確認します

{
  "body": "accountName: guest, accountNumber: 1",
  "status": 200
}

めでたしめでたし。これでsotoライブラリを使用して問題なくS3からデータを取得可能なことを確認できました。

まとめ

aws-sdk-swiftライブラリで躓きましたが、sotoライブラリに切り替えることで無事S3からのデータ取得を実現しました。Swiftを使ったサーバーレスアプリケーション開発の可能性と、実装上の注意点を示すことができました。

番外編

Lambdaで外部通信を行う

通常のアプリ開発ではURLSessionや、Alamofireを使えばなんの問題もなく実装可能ですが、Linux環境だと一筋縄ではいきません。

まず何も考えずに実装してみる

import Foundation

enum HTTPClient {
    static func execute<T: HTTPRequest>(_ request: T) async throws -> T.Response {
        var urlRequest = URLRequest(url: request.url)
        urlRequest.httpMethod = request.method.rawValue
        request.headers.forEach { key, value in
            urlRequest.setValue(value, forHTTPHeaderField: key)
        }
        urlRequest.httpBody = request.body

        let (data, response) = try await URLSession.shared.data(for: urlRequest)

        if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
            throw HTTPError.invalidResponse(statusCode: httpResponse.statusCode)
        }
        return try JSONDecoder().decode(T.Response.self, from: data)
    }
}

enum HTTPError: Error {
    case invalidResponse(statusCode: Int)
}

enum HTTPMethod: String {
    case POST
    case GET
}

protocol HTTPRequest {
    associatedtype Response: Decodable

    var url: URL { get }
    var method: HTTPMethod { get }
    var headers: [String: String] { get }
    var body: Data { get }
}

protocol HTTPResponse {}

extension HTTPRequest {
    @discardableResult
    func execute() async throws -> Response {
        try await HTTPClient.execute(self)
    }
}

上記の実装でXcode上でビルドができることが確認できると思います。
では、swift package --disable-sandbox archiveでアーカイブしましょう
...あれ?アーカイブできない?

/workspace/Sources/S3AccountRepository.swift:37:26: error: cannot find 'URLRequest' in scope
35 | enum HTTPClient {
36 |     static func execute<T: HTTPRequest>(_ request: T) async throws -> T.Response {
37 |         var urlRequest = URLRequest(url: request.url)
   |                          `- error: cannot find 'URLRequest' in scope
38 |         urlRequest.httpMethod = request.method.rawValue
39 |         request.headers.forEach { key, value in

/workspace/Sources/S3AccountRepository.swift:44:53: error: type 'URLSession' (aka 'AnyObject') has no member 'shared'
42 |         urlRequest.httpBody = request.body
43 | 
44 |         let (data, response) = try await URLSession.shared.data(for: urlRequest)
   |                                                     `- error: type 'URLSession' (aka 'AnyObject') has no member 'shared'

どういうことでしょうか?
大きな理由としては、少し前に、URLRequestやURLSessionがFoundationNetworkingというモジュールに移動されたことが原因だそうです。Linux環境だとFoundationNetworkingを手動でimportしてあげないと使用できません。

修正

import Foundation
+ #if canImport(FoundationNetworking)
+ import FoundationNetworking
+ #endif

enum HTTPClient {
    static func execute<T: HTTPRequest>(_ request: T) async throws -> T.Response {
        ...

import文を追加してあげることでURLRequestやURLSessionが参照できるようになり、アーカイブできるようになります。また、他のソリューションとしては、ライブラリを使うと良いです。async-http-clientというライブラリを使用することでも外部通信の実装が行えますので好きな方を使うと良いと思います。

参考

Create AWS Lambda functions using the AWS SDK for Swift
swift-aws-lambda-runtime-getting-started

株式会社ガラパゴス(有志)

Discussion