AWS LambdaでSwiftを動かす
この記事は株式会社ガラパゴス(有志) 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にリネームする必要があります。
依存関係の設定
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ブランチを指定して使用します。
メイン部分の実装
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
struct Request: Decodable, Sendable {
let body: String
}
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
依存関係の追加
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
struct Account: Codable {
let accountName: String
let accountNumber: Int
}
特に難しいことはなく、リージョン、バケット名、ファイル名を指定して取得することが可能です。
ハンドラの修正
@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ライブラリに切り替える
- 依存関係の修正
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]
)
- レポジトリの修正
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