TerraformでAWS Lambda(Python+C拡張)をデプロイする

terraform-provider-lambdazipを修正してPythonのデプロイにもある程度対応できるようになったのでメモ。

github.com

ディレクトリ構成

pylambda/
├── .gitignore
├── main.tf
└── src/
    ├── lambda_function.py
    ├── requirements.txt
    └── ruff.toml

Pythonソース

lambda_function.py

#!/usr/bin/env python
from cffi import FFI


def lambda_handler(_event, _context):
    ffi = FFI()
    ffi.cdef("int abs(int);")
    clib = ffi.dlopen(None)
    print(clib.abs(-123))


if __name__ == "__main__":
    lambda_handler(None, None)

requirements.txt

cffi==1.17.1
pycparser==2.22

Terraformソース

main.tf

terraform {
  required_providers {
    lambdazip = {
      source  = "winebarrel/lambdazip"
      version = ">= 0.9.2"
    }
  }
}

data "lambdazip_files_sha256" "pylambda" {
  files = [
    "src/lambda_function.py",
    "src/requirements.txt",
  ]
}

resource "lambdazip_file" "pylambda" {
  base_dir = "src"
  sources  = ["**"]
  excludes = [
    # サイズ削減のためpycacheをzipファイルから除外
    "**/__pycache__/**",
    "venv/**",
    "ruff.toml",
    ".ruff_cache/**",
    "requirements.txt",
  ]
  output   = "pylambda.zip"
  triggers = data.lambdazip_files_sha256.pylambda.map
  # ディレクトリ直下にパッケージをインストールするのでtemp dirで作業
  use_temp_dir = true
  # サイズ削減のため最高圧縮レベルでzipファイルを作成
  compression_level = 9

  before_create = <<-EOT
    pip install -t .
      --platform manylinux2014_x86_64
      --implementation cp
      --python-version 3.13
      --only-binary=:all:
      -r requirements.txt
  EOT
}

resource "aws_lambda_function" "pylambda" {
  filename         = lambdazip_file.pylambda.output
  function_name    = "pylambda"
  role             = aws_iam_role.pylambda.arn
  handler          = "lambda_function.lambda_handler"
  source_code_hash = lambdazip_file.pylambda.base64sha256
  runtime          = "python3.13"
}

resource "aws_iam_role" "pylambda" {
  name = "pylambda"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect    = "Allow"
        Principal = { Service = "lambda.amazonaws.com" }
        Action    = "sts:AssumeRole"
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "pylambda" {
  role       = aws_iam_role.pylambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

AWSにデプロイ・実行

$ terraform apply
...

$ aws lambda invoke --function-name pylambda /dev/null
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}

$ aws logs tail /aws/lambda/pylambda
...
START RequestId: ac1cdf4e-7ac5-42e4-aeeb-408a4b279160 Version: $LATEST
123
END RequestId: ac1cdf4e-7ac5-42e4-aeeb-408a4b279160
REPORT RequestId: ac1cdf4e-7ac5-42e4-aeeb-408a4b279160  Duration: 2842.61 ms  Billed Duration: 2843 ms   Memory Size: 128 MB    Max Memory Used: 76 MB

ローカルで実行

$ cd src
$ python -m venv venv
$ . ./venv/bin/activate

(venv) $ pip install -r requirements.txt
...
(venv) $ pip install boto3 # boto3はrequirements.txtに含めない
...

(venv) $ ./lambda_function.py
123

その他

  • そこそこの頻度で aws lambda invoke"FunctionError": "Unhandled" でコケる
    • コールドスタート時のCライブラリのロード失敗?
  • Lambda上だとC言語経由での標準出力が潰されている?
    • そんなことはないか…
    • printfが出力されなかった

terraform-provider-firebaseremoteconfigを作った

一身上の都合により terraform-provider-firebaseremoteconfig を作った。

github.com

こういう感じでFirebaseのRemote Configのパラメータを管理できる。

resource "firebaseremoteconfig_parameter" "foo" {
  key        = "foo"
  value_type = "JSON"

  default_value = {
    value = jsonencode({
      foo = "bar"
      zoo = 100
    })
  }
}

conditional_valuesも一応、管理できる。

resource "firebaseremoteconfig_parameter" "bar" {
  key         = "bar"
  description = "blah blah blah"
  value_type  = "STRING"

  conditional_values = {
    android = {
      value = "ZOO"
    }
    ios = {
      value = "BAZ"
    }
  }
  default_value = {
    value = "hoge"
  }
}

APIに沿っている、と思う。

firebase.google.com

google-api-go-clientの状況

github.com

firebaseremoteconfig/v1は3年前から更新されていない。firebaseremoteconfig-api.jsonに至っては8年前から更新されていない。go run ./google-api-go-generator/ -gendir .を実行してもコードが更新されない。

今のこところ更新される予定はない模様。

github.com

The Go firebaseremoteconfig has not been generated from a new discovery doc in many years it seems. We only regenerate libraries for things that show up in the public listing of APIs, which this one no longer does it seems: https://www.googleapis.com/discovery/v1/apis.

https://www.googleapis.com/discovery/v1/apis を見ると確かにfirebaseremoteconfigが載っていない。グエー。 おかげでvalueTypeとかがコードにない状態になっている。

なのでterraform-provider-firebaseremoteconfigではgoogle-api-go-clientのフォーク版を使うということをやっている。 やりたくはなかった…

Node.jsのFirebase Admin SDKではサポートされているが、GoのFirebase Admin SDKではサポートされていない

これからどうしたものか。

追記

tech blogに書いた

zenn.dev

qube: Zstandard Seekable Format対応

自作のDB負荷テストツール qube を Zstandard Seekable Format に対応させた。

Zstandard Seekable Format is 何?

github.com

名前の通りシーク可能なZstandardのフォーマット。 ファイル末尾にシークテーブルをつけて解凍しなくても任意のオフセットからデコードしたデータを読み取れる。 シークテーブルは通常のzstdでは単純に無視されるので、zstdコマンドでも解凍可能。

こちらの記事が詳しい。

qiita.com

本家実装済みだが、CLIは提供されていない模様。

GoとRustの実装にはCLIもある。

Goのサンプルコードは以下の通り。

seekable-zstd-sample.go

qubeで何がうれしい?

qubeはクエリログをテストデータとしてDBの負荷テストを行うために開発したツールだが、テストデータは5〜10GBと大きくなりがちで取り回しが非常に悪い。(テストサーバへの転送に時間がかかる、jqでテストデータをフィルタリングしているとディスクが埋まる…etc)

なので圧縮ファイルを使えると便利なのだが、シーク可能である必要があったので、gzipやbz2が使えなかった。

なぜシークが必要かというと、データをメモリ上でシャッフルする代わりにランダムな行からデータの読み込みを行う機能をつけているので、ファイルの任意のオフセットにシークできる必要があった。

あと、ループ終了時の巻き戻しに io.Seeker など os.File と共通のインターフェースを使えた方がうれしい(seekable zstd では io.ReadSeekCloser でtxtファイルとzstファイルを抽象化できたので、だいぶ実装が楽になった)


テストデータをzstdseekで圧縮すると1/10になったが、qubeの使い勝手は変わらない。便利。 (クライアント側のCPU消費は増えると思う)

$ go install github.com/SaveTheRbtz/zstd-seekable-format-go/cmd/zstdseek@latest
$ zstdseek -f data.jsonl -o data.jsonl.zst
$ ls -lh data.jsonl*
-rw-r--r--@ 1 sugawara  staff   235M  7 13 09:28 data.jsonl
-rw-r--r--@ 1 sugawara  staff    23M  7 13 09:36 data.jsonl.zst
$ qube -d 'root@tcp(127.0.0.1:13306)/employees' -f data.jsonl.zst -n 5 -t 10s --random
00:05 | 5 agents / exec 89821 queries, 0 errors (22555 qps)
...
{
  "Options": {
    "DataFiles": [
      "salaries.jsonl.zst"

結構便利なフォーマットなので今後も使う機会がありそう。

terraform-provider-multireplaceを作った(が、すでにあった)

terraformのjsonencode()には<, >, &\u003c, \u003e, \u0026 に変換するよく知られた仕様がある。

developer.hashicorp.com

When encoding strings, this function escapes some characters using Unicode escape sequences: replacing <, >, &, U+2028, and U+2029 with \u003c, \u003e, \u0026, \u2028, and \u2029. This is to preserve compatibility with Terraform 0.11 behavior.

github.com

replace()を使うのが今のところの回避策ではあるが、複数の文字列を置換するのがめんどいのでmultireplace() jsonunescape()というユーザー定義関数を作った。

github.com

output "london_bridge" {
  value = provider::multireplace::multireplace(
    "London Bridge Is Falling Down, Falling down, falling down",
    { Falling = "Winding", falling = "jumping" }
  )
  #=> "London Bridge Is Winding Down, Winding down, jumping down"

  # value = provider::multireplace::multireplace(
  #   "London Bridge Is Falling Down, Falling down, falling down",
  #     { Falling = "Winding" },
  #     { falling = "jumping" }
  # )
}
locals {
  # see https://developer.hashicorp.com/terraform/language/functions/jsonencode
  jsonencode_html = jsonencode({
    link = "<a href=\"https://example.com?foo=bar&zoo=baz\">Open</a>"
  })

  jsonunescape_html = provider::multireplace::jsonunescape(local.jsonencode_html)
}

output "html" {
  value = <<-EOT
    ${local.jsonencode_html}
    ---
    ${local.jsonunescape_html}
  EOT
  #=> <<-EOT
  #       {"link":"\u003ca href=\"https://example.com?foo=bar\u0026zoo=baz\"\u003eOpen\u003c/a\u003e"}
  #       ---
  #       {"link":"<a href=\"https://example.com?foo=bar&zoo=baz\">Open</a>"}
  #   EOT
}

その後、よく調べたらmultireplace()については既存の実装があった。

github.com

locals {
  # 実際の関数呼び出しは `provider::string-functions::multi_replace(...)`
  replaced = multi_replace("a,b,c,d,e", {
    "," = "|",
    "a" = "z",
  })
}

output "replaced" {
  value = local.replaced
}

# replaced = "z|b|c|d|e"

こちらの方はmulti_replace()だけではなく、Case conversionやEscapingなど文字列まわりの便利関数が一通りそろっている。


jsonencode()に特化している訳ではないので、自作のterraform-provider-multireplaceプロバイダは使っていくと思うが、terraform-provider-string-functionsもなかなか便利そうなので今後使っていきたい。

GitHub Actionsのmatrixのconclusionを後続のジョブで取得する

needs.job_id.result がいずれかのmatrixの結果しか返さない、っぽい。

いくつかやり方はありそうだったがAPI使ったチェック用ジョブを設定するのが簡単だった。

  finish:
    needs: test
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Check test conclusion
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api /repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs \
          | jq -e '.jobs | map(select(.name | test("^test ")).conclusion) | any(. == "failure") | not'

ssowrapというツールを作ったが特に必要なかった

ssowrapというaws2-wrapGolang製シングルバイナリ版を作った。

github.com

Golang版のaws2-wrapは頑張って探せばありそうだったけれど

ということで、自作した。


使い方はaws2-wrapとほぼ同じでAWS_PROFILEを設定してからssowrapでラップしてコマンドを実行する。

export AWS_PROFILE=my-profile
ssowrap terraform plan

ただ、これを作ったのはどちらかといえば、自分のSSOクレデンシャルを使ってDockerコンテナを動かしたかったのが大きい。

以下のようにDockerコンテナを実行すると、自分のSSOクレデンシャルをコンテナ内のサーバに渡すことができる。

docker run \
  -e AWS_PROFILE=my-profile \
  -v "${HOME}/.ssh":/root/.ssh:ro \
  -v "${HOME}/.aws":/root/.aws:ro \
  ghcr.io/winebarrel/ssowrap:v0.1.2 \
  ssowrap -- my-server-cmd

…と思っていたら、そもそも最近のAWS CLIにはexport-credentialsというコマンドがあるので、わざわさ自作する必要はなかった。

awscli.amazonaws.com

aws configure export-credentials --format env-no-export を実行すると AWS_ACCESS_KEY_ID=... \n AWS_SECRET_ACCESS_KEY=... \n という形式で出力されるので

env $(aws configure export-credentials --format env-no-export) my-server-cmd

と実行するとssowrapと同様のことができる。

まあ、awscliとsession-manager-pluginを含むシンプルな(信頼できる)Dockerイメージはなかったので、わざわざイメージをビルドしたくない場合にはよいかも。 (aws-cliのイメージに対してIssueは投げられている模様)

おまけ

Atlantisで使われているのを見てghcr.ioを使ってみたが、GitHubリポジトリとDockerリポジトリの管理を一体化できるのがなかなかよかった。 x86_64ランナーだとarm64のビルドがめちゃくちゃ遅かったが、ubuntu-24.04-armをつかったら普通に速くなった。

x86_64とarm64で別々にpushしても大丈夫だろうか、と思ったが特に問題なくマルチプラットフォームで使うことができている。