🥩

Tailscaleに接続できるAWS LambdaをCDKで作成する

2023/04/01に公開

はじめに

前回の記事で紹介した通り、P2P型のVPNであるTailscaleは設定が簡単であり、非常に便利だ。

https://zenn.dev/watany/articles/088cdee8441511

そんなTailscale ネットワークにAWS Lambdaを接続できるらしい。が、公式ドキュメントを読んでも今一つピンとこなかった。

https://tailscale.com/kb/1113/aws-lambda/

比較的新しい機能ということもあって事例も見つからないため、試しに作って動かしてみた。

前提知識

AWS CDK

Tailscale on AWS Lambdaはコンテナで動かすことを前提にしている。

AWS上で管理するコンテナイメージは通常ECRを利用するが、検証目的で作成・削除を繰り返す際に、中のコンテナイメージを削除するのが少々面倒である。

であるが、先日AWS CDK v2.70.0で追加となったRepository deletion機能を使えば、自動で削除できて便利だ。

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ecr-readme.html#repository-deletion

cdk-docker-image-deployment

CDKでECRを構築するには工夫が必要である。

2023/4/1時点で公式が提供しているcdk-docker-image-deploymentが無難なので、こちらを利用する。
https://github.com/cdklabs/cdk-docker-image-deployment

※本題から逸れるため、選定理由は割愛した。詳細を知りたい場合はこのブログを読むと背景を含めてわかる。

https://zenn.dev/5t111111/articles/use-cdk-docker-image-deployment

環境構築

1. CDK環境の整備

適当なフォルダを作ってprojenでプロジェクトを作成する。

mkdir tailscaleFn
cd tailscaleFn/
npx projen new awscdk-app-ts

cdk-docker-image-deploymentを導入するため、depsを更新する。

.projenrc.js
const { awscdk } = require('projen');
const project = new awscdk.AwsCdkTypeScriptApp({
  cdkVersion: '2.1.0',
  defaultReleaseBranch: 'main',
  name: 'tailscaleFn',
  deps: ["cdk-docker-image-deployment"],                /* Runtime dependencies of this module. */
  // description: undefined,  /* The description is just a string that helps people understand the purpose of the package. */
  // devDeps: [],             /* Build dependencies for this module. */
  // packageName: undefined,  /* The "name" in package.json. */
});
project.synth();

設定値を反映

npx projen

2. Tailscale Imageの作成

プロジェクトの中にapp/ディレクトリを作成し、Imageに必要な資材を作成する。

bootstrap

bootstrapファイルは、前述のTailscaleのサンプルに、AWSのチュートリアルのサンプルを繋げて作っている。

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/runtimes-walkthrough.html#runtimes-walkthrough-function

tailscaleFn/app/bootstrap
#!/bin/sh

mkdir -p /tmp/tailscale
/var/runtime/tailscaled --tun=userspace-networking --socks5-server=localhost:1055 &
/var/runtime/tailscale up --authkey=${TAILSCALE_AUTHKEY} --hostname=aws-lambda-app
echo Tailscale started
ALL_PROXY=socks5://localhost:1055/ /var/runtime/my-app

set -euo pipefail

# Initialization - load function handler
source $LAMBDA_TASK_ROOT/"$(echo $_HANDLER | cut -d. -f1).sh"

# Processing
while true
do
  HEADERS="$(mktemp)"
  # Get an event. The HTTP request will block until one is received
  EVENT_DATA=$(curl -sS -LD "$HEADERS" -X GET "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/next")

  # Extract request ID by scraping response headers received above
  REQUEST_ID=$(grep -Fi Lambda-Runtime-Aws-Request-Id "$HEADERS" | tr -d '[:space:]' | cut -d: -f2)

  # Run the handler function from the script
  RESPONSE=$($(echo "$_HANDLER" | cut -d. -f2) "$EVENT_DATA")

  # Send the response
  curl -X POST "http://${AWS_LAMBDA_RUNTIME_API}/2018-06-01/runtime/invocation/$REQUEST_ID/response"  -d "$RESPONSE"
done

この組み合わせによって、Tailscaleの初期設定が済んでいるBashのカスタムランタイムが起動する、というわけだ。
ちなみに${TAILSCALE_AUTHKEY}は、後述するLambdaの環境変数経由で読み取るので、そのままでよい。

handler

Lambdaで動かすアプリケーションをbashで作成する。

こちらも前述のチュートリアルからの引用だが、差分としてsleep文を追加している。これしないとすぐ終了し、動作確認ができないからだ。今回は90秒間、起動する設定としている。

tailscaleFn/app/hello.sh
function handler () {
    EVENT_DATA=$1
 
    RESPONSE="{\"statusCode\": 200, \"body\": \"Hello from Lambda!\"}"
    echo $RESPONSE
    sleep 90
}

Dockerfile

tailscale公式の手順を参考に、Dockerfileを作成する。
差分は最後のENTRYPOINTをCMDに変更して、handlerを動作させている。

tailscaleFn/app/Dockerfile
FROM public.ecr.aws/lambda/provided:al2 as builder
WORKDIR /app
COPY . ./
# This is where one could build the application code as well.


FROM alpine:latest as tailscale
WORKDIR /app
ENV TSFILE=tailscale_1.38.3_amd64.tgz
RUN wget https://pkgs.tailscale.com/stable/${TSFILE} && \
  tar xzf ${TSFILE} --strip-components=1


FROM public.ecr.aws/lambda/provided:al2
# Copy binary to production image
COPY --from=builder /app/bootstrap /var/runtime/bootstrap
COPY --from=builder /app/hello.sh /var/task/hello.sh
COPY --from=tailscale /app/tailscaled /var/runtime/tailscaled
COPY --from=tailscale /app/tailscale /var/runtime/tailscale
RUN mkdir -p /var/run && ln -s /tmp/tailscale /var/run/tailscale && \
    mkdir -p /var/cache && ln -s /tmp/tailscale /var/cache/tailscale && \
    mkdir -p /var/lib && ln -s /tmp/tailscale /var/lib/tailscale && \
    mkdir -p /var/task && ln -s /tmp/tailscale /var/task/tailscale

# Run on container startup.
# ENTRYPOINT ["/var/runtime/bootstrap"]
CMD ["hello.handler"]

注意:ファイルの権限設定

初回で失敗したのだがLamdbaをコンテナで動かす場合、実行権限を付与しないと失敗する。

chmod +x app/*

3 CDKによるデプロイ

準備

Tailscaleのセットアップ用するために必要なAuth Keyは、以下を参考に取得しておく。
https://tailscale.com/kb/1085/auth-keys/

Code

Auth keyは、今回はLambdaの環境変数で簡単に設定している。<AUTH_KEY>を各自のキーで置き換えて欲しい。

tailscaleFn/src/main.ts
import { App, Duration, Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as imagedeploy from 'cdk-docker-image-deployment';

const imageTag = "tailscale-init"
const repoName = "example-tailscale"

export class RepoStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps = {}) {
    super(scope, id, props);

    // 使い捨て用のECR Repositoryを作成
    const repo = new ecr.Repository(this, 'MyTempRepo', {
      repositoryName: repoName,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteImages: true,
    });
    
    // `app/`配下をDocker Buildし、ECR RepositoryへPush
    new imagedeploy.DockerImageDeployment(this, "ExampleTailscale",
      {
        source: imagedeploy.Source.directory("./app"),
        destination: imagedeploy.Destination.ecr(repo, {
          tag: imageTag,
        }),
      }
    );
  }
}

export class FnStack extends Stack {
  constructor(scope: Construct, id: string, props: StackProps = {}) {
    super(scope, id, props);

    // `RepoStack`で作成済みのRepositoryを名前指定で参照
    const repo = ecr.Repository.fromRepositoryName(this, 'ExistRepo', "example-tailscale");

    // `RepositoryのイメージからLambdaを作成`
    // 環境変数`TAILSCALE_AUTHKEY`にキーを指定
    new lambda.DockerImageFunction(this, 'ECRFunction', {
        code: lambda.DockerImageCode.fromEcr(repo,  {
          tagOrDigest: imageTag,
        }),
        timeout: Duration.minutes(15),
        environment: {
          TAILSCALE_AUTHKEY: "<AUTH_KEY>"
        }
    });

  }
}


// for development, use account/region from cdk cli
const devEnv = {
  account: process.env.CDK_DEFAULT_ACCOUNT,
  region: process.env.CDK_DEFAULT_REGION,
};

const app = new App();

new RepoStack(app, 'repo', { env: devEnv });
new FnStack(app, 'fn', { env: devEnv });

app.synth();

Deploy

以下のコマンドで、自動的にコンテナイメージのビルドも含めた一連のデプロイが完了する。

cdk deploy repo
cdk deploy fn

ちなみにスタックを分割している理由は、ECRへのDeployを待たずにLambdaがデプロイされてしまうのを避けるため、明示的に分割したかったからだ。

fn.addDependsOn(deployment)のように書けば動く可能性はある。

4. 動作確認

Lambdaをテスト実行し、90秒ほど動作し続けている間に、tailscaleネットワーク上の他のマシンからpingを打つと疎通することがわかる。

ping aws-lambda-app
PING aws-lambda-app.tail5c971.ts.net (100.85.119.28) 56(84) bytes of data.
64 bytes from aws-lambda-app.tail5c971.ts.net (100.85.119.28): icmp_seq=1 ttl=64 time=17.0 ms
64 bytes from aws-lambda-app.tail5c971.ts.net (100.85.119.28): icmp_seq=2 ttl=64 time=3.93 m
...

当然だが、Lambdaの実行が終了すると返事がなくなる。想定通りの動作だ。

まとめ

細かい情報を集めるのに苦労したが、Tailscaleに接続できるAWS LambdaをCDKで作成することに成功した。

実際のワークロードにのせる場合は、複数台実行した際のライセンス周りが気になるところである。

Discussion