バージョン管理された人

subversionで管理されてます

【NTT Communications Advent Calendar 2023】Nix と home-manager で dotfiles を管理する話

この記事は、 NTT Communications Advent Calendar 2023 9日目の記事です。

みなさんは dotfiles をどのように管理されているでしょうか? Bash や Zsh といったスクリプトによってインストールを管理したり、 git でホームディレクトリを管理する、 ChefAnsible のような構成管理ツールを用いて管理する方法があると思います。 自分もシェルスクリプトによる管理をおこなったり、 Ansible 等の構成管理ツールの利用や 自前の構成管理ツール をつくって管理していましたが、現在は Nixhome-manager というものを使って管理しています。 今回はこの Nix と home-manager による dotfiles の管理をどのように行えばよいのか、それらによって管理する Pros/Cons にはどのようなものがあるのかについて解説します。

Nix

まずそもそも Nix とはどういったものなのかについて解説します。

Nix とは NixOS という Linux ディストリビューションに搭載された aptyum/dnf のようなパッケージ管理システムです。 Nix はドキュメントにも記載してありますが、 純粋関数型パッケージマネージャー です。

Nix is a purely functional package manager.

Introduction - Nix Reference Manual

purely functional なパッケージマネージャーとはどういったものなのかというとビルドされたパッケージは副作用のないビルド方法を記載した関数によってビルドされ、そのビルドされたものは変更されることがないというものです。 Haskell の値をパッケージマネージャーに見立ててもらうとわかりやすいと思いますが、 Haskell においては値は副作用のない関数によって計算され、その値はその後に変更されることがありません。 これをパッケージ管理で実現したものが Nix になります。

ビルド方法を記載する関数が purely functional であるため、再度ビルドしても同じ生成物が作成されるようになっています。 このため、一度ビルドができてしまえば常に同じ環境を実現できるようになっています。

Nix Language

Nix では Nix Language という言語でパッケージのビルド方法等を記載します。

Nix Language は ドキュメント によると

  • ドメイン特化
  • 宣言的
  • 純粋
  • 関数型
  • 遅延性
  • 動的型付け

という特徴を持つ言語です。

Nix Language は基本的は文字列型や数値型、真理値型等が存在します。 その他にも絶対パスや相対パスも表現できます(先頭が # である行はコメント行です)。

# integer
1
# floating
3.14
# string
"single line string"
''
  multi line string
''
# string interpolation
"toString ${toString 3}"
# boolean
true
# null
null
# absolute path
/etc
# relative path
./foo.json

その他にもリストや集合といった複合型もあります。

# list
[ 1 "123" ]
# set
{ x = 1; y = 2; }

また重要なコンポーネントである関数は次のように記述します。

# f(x) = x + 1
x: x + 1
# f(x, y) = x + y
x: y: x + y
# function call: f(100) = 100 + 1
(x: x + 1) 100

他にも値を束縛するための let ~ in ~if ~ then ~ else ~といった制御構文や関数の引数におけるパターンマッチ等が存在します。 より詳細に知りたい方は Nix Reference ManualNix Language セクションを読んでいただければと思います。

home-manager

そんな Nix を利用してユーザー環境を管理できるようにした仕組みが home-manager です。 home-manager を利用するとホームディレクトリーに dotfiles を設置したり、必要なパッケージを事前にインストールしたりといったことが可能になります。

ユーザー環境の管理自体は簡単で、次のコマンドを実施すると構成を管理する ~/.config/home-manager/home.nix が生成されます。

nix-shell --run sh '<home-manager>' -A install

~/.config/home-manager/home.nix には次のような内容を記載します。

{ config, pkgs, ... }:
{
  home.username = "<ユーザー名>";
  home.homeDirectory = "<home.username ユーザーのホームディレクトリーのパス>";
  # ここは ~/.config/home-manager/home.nix を生成したときにつくコメントを参考にして設定する。
  # デフォルトだとコマンド実行時にバージョンが記載されている
  home.stateVersion = "<home-manager のバージョン>";
  home.packages = [
    # 使用したいパッケージをここに記載する。
    # 以下は vim と kubectl をインストールしている例
    pkgs.vim
    pkgs.kubectl
  ];
  home.file = {
    # ~/.ssh/config の内容をここに記載している。
    # ここに `<ファイルのパス>.text = "<ファイルの内容>"` のように記載しておくと `<ファイルの内容>` を持つ
    # ファイルが `<ファイルのパス>` で指定したパスに作成される。
    # ただし、実体は作成されるファイルはシンボリックリンクになっており、実際は nix によって管理される
    # readonly なファイルになる点に注意。
    ".ssh/config".text = ''
      Host github
        HostName github.com
        User git
    '';
  };
}

home-manager の制御は home-manager コマンドを通して行います。 基本的には switch サブコマンドを実行すれば環境が用意されます。

$ kubectl version
kubectl: command not found
# 環境を準備する
$ home-manager switch
$ kubectl version
Client Version: version.Info{Major:"1", Minor:"23", GitVersion:"v1.23.9", GitCommit:"c1de2d70269039fe55efb98e737d9a29f9155246", GitTreeState:"clean", BuildDate:"2022-07-13T14:26:51Z", GoVersion:"go1.17.11", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"23", GitVersion:"v1.23.9", GitCommit:"c1de2d70269039fe55efb98e737d9a29f9155246", GitTreeState:"clean", BuildDate:"2022-07-13T14:19:57Z", GoVersion:"go1.17.11", Compiler:"gc", Platform:"linux/amd64"}
# ~/.ssh/config をみてみる
$ cat ~/.ssh/config
Host github
  HostName github.com
  User git

home-manager で switch サブコマンドによって生成されるファイルはシンボリックリンクになっています。 実態は Nix が管理しているファイルになっています。

$ ls ~/.ssh/config
lrwxr-xr-x   1 user  usergroup    84 Dec 10 02:50 config -> /nix/store/pr31jgjnwnpmaf58w35jyrajilfzcail-home-manager-files/.ssh/config

~/.config/home-manager/home.nixhome.file に設定した内容を書き換えて swtich サブコマンドを実行するとこの内容が書き変わります。

{ ... }:
{
  home.file = {
    ".ssh/config".text = ''
      Host gitlab
        HostName gitlab.com
        User git
    '';
  };
}
# dotfiles を再生成
$ home-manager switch
trace: warning: optionsDocBook is deprecated since 23.11 and will be removed in 24.05
trace: warning: optionsDocBook is deprecated since 23.11 and will be removed in 24.05
trace: warning: optionsDocBook is deprecated since 23.11 and will be removed in 24.05
/nix/store/7871wampdlaqva7mpa616gkmjn4c38qj-home-manager-generation
Starting Home Manager activation
Activating checkFilesChanged
Activating checkLaunchAgents
Activating checkLinkTargets
Activating writeBoundary
Activating linkGeneration
Cleaning up orphan links from /home/user
No change so reusing latest profile generation 139
Creating home file links in /home/user
Activating batCache
No themes were found in '/home/user/.config/bat/themes', using the default set
No syntaxes were found in '/home/user/.config/bat/syntaxes', using the default set.
Writing theme set to /home/user/.cache/bat/themes.bin ... okay
Writing syntax set to /home/user/.cache/bat/syntaxes.bin ... okay
Writing metadata to folder /home/user/.cache/bat ... okay
Activating copyFonts
Activating installPackages
replacing old 'home-manager-path'
installing 'home-manager-path'
Activating onFilesChange
Activating setupLaunchAgents

There are 75 unread and relevant news items.
Read them by running the command "home-manager news".
# ~/.ssh/config を見てみる
$ cat ~/.ssh/config
Host gitlab
  HostName gitlab.com
  User git

そのため、 Nix の環境さえ用意できれば dotfiles を IaC で管理できます。 書き換えたければ ~/.config/home-manager/home.nix を書き換え、 home-manager switch を実行すれば環境がそのまま用意されます。 その他にも ~/.config/home-manager/home.nix にある home.packages に必要なパッケージを記載すればパッケージのインストールもしてもらえます。 もしインストールされたものを削除したい場合は nix-gabage-collect コマンドを実行すると不必要な環境が Nix から綺麗さっぱり削除されます。

# インストールされたコマンドは Nix の環境にインストールされ、 ~/.nix-profile/bin に
# シンボリックリンクがはられます。
# インストールされたパッケージや依存物は ~/.config/home-manager/home.nix の home.packages を書き換えただけだと
# Nix の環境にあるキャッシュは削除されません。
# このコマンドは home-manager の機能というよりは Nix が提供する Nix 環境の GC を実施するコマンドです
nix-gabage-collect

また、 Nix の導入が済んでしまえば一時的にパッケージを利用する場合は nix-shell コマンドを利用すると一時的にパッケージをインストールして利用できます。

# Node.js と pnpm を一時的にインストールした環境に入る
$ nix-shell -p nodejs nodePckages.pnpm
these 6 derivations will be built:
  /nix/store/0i23yycgl4g3ysh919zrhqw6s7mv0clq-reconstructpackagelock.js.drv
  /nix/store/10sbwpqks6vpixp3najqz2zr6bfbz2av-linkbins.js.drv
  /nix/store/cay26jcn3vgnhsimhrpbqjmv39w5syg7-addintegrityfields.js.drv
  /nix/store/dxwm67zm4j9rlprgy7qj7j10iywbharh-install-package.drv
  /nix/store/vgc54qkgm1h9haaw95gkpahzss8hb1n3-pinpointDependencies.js.drv
  /nix/store/win93b0d7agghd066d888bjbh5brrri0-pnpm-8.10.2.drv
these 5 paths will be fetched (46.54 MiB download, 408.80 MiB unpacked):
  /nix/store/1ipdb6jghbj1zbnxwydghq8gkgf6v3ca-cctools-llvm-11.1.0-973.0.1-dev
  /nix/store/gf6d8v8qj8rbpnyydyrv2m0vjcfw3bh6-cctools-port-973.0.1-dev
  /nix/store/8sgv1kbxwx641j6v4sv63zh5p01w473k-node-sources
  /nix/store/x551k64la7wmg4xj07yxfdd60wq2rd4y-pnpm-8.10.2.tgz
  /nix/store/4ag0qy6w79b67xslwgi26ax3i91a19zc-tarWrapper
copying path '/nix/store/8sgv1kbxwx641j6v4sv63zh5p01w473k-node-sources' from 'https://cache.nixos.org'...
copying path '/nix/store/x551k64la7wmg4xj07yxfdd60wq2rd4y-pnpm-8.10.2.tgz' from 'https://cache.nixos.org'...
copying path '/nix/store/4ag0qy6w79b67xslwgi26ax3i91a19zc-tarWrapper' from 'https://cache.nixos.org'...
building '/nix/store/cay26jcn3vgnhsimhrpbqjmv39w5syg7-addintegrityfields.js.drv'...
building '/nix/store/dxwm67zm4j9rlprgy7qj7j10iywbharh-install-package.drv'...
building '/nix/store/10sbwpqks6vpixp3najqz2zr6bfbz2av-linkbins.js.drv'...
building '/nix/store/vgc54qkgm1h9haaw95gkpahzss8hb1n3-pinpointDependencies.js.drv'...
building '/nix/store/0i23yycgl4g3ysh919zrhqw6s7mv0clq-reconstructpackagelock.js.drv'...
copying path '/nix/store/gf6d8v8qj8rbpnyydyrv2m0vjcfw3bh6-cctools-port-973.0.1-dev' from 'https://cache.nixos.org'...
copying path '/nix/store/1ipdb6jghbj1zbnxwydghq8gkgf6v3ca-cctools-llvm-11.1.0-973.0.1-dev' from 'https://cache.nixos.org'...
building '/nix/store/win93b0d7agghd066d888bjbh5brrri0-pnpm-8.10.2.drv'...
unpacking sources
patching sources
updateAutotoolsGnuConfigScriptsPhase
configuring
no configure script, doing nothing
building
installing
unpacking source archive /nix/store/x551k64la7wmg4xj07yxfdd60wq2rd4y-pnpm-8.10.2.tgz
pinpointing versions of dependencies...
patching script interpreter paths in .
./pnpm/bin/pnpx.cjs: interpreter directive changed from "#!/usr/bin/env node" to "/nix/store/fwgfw6i5q1hv49bgfl96bmzv72l98khy-nodejs-18.18.2/bin/node"
./pnpm/bin/pnpm.cjs: interpreter directive changed from "#!/usr/bin/env node" to "/nix/store/fwgfw6i5q1hv49bgfl96bmzv72l98khy-nodejs-18.18.2/bin/node"
./pnpm/dist/node_modules/node-gyp/bin/node-gyp.js: interpreter directive changed from "#!/usr/bin/env node" to "/nix/store/fwgfw6i5q1hv49bgfl96bmzv72l98khy-nodejs-18.18.2/bin/node"
./pnpm/dist/node-gyp-bin/node-gyp: interpreter directive changed from "#!/usr/bin/env sh" to "/nix/store/zzpm4317hn2y29rm46krsasaww9wxb1k-bash-5.2-p15/bin/sh"
No package-lock.json file found, reconstructing...
npm WARN config production Use `--omit=dev` instead.
rebuilt dependencies successfully
npm WARN config production Use `--omit=dev` instead.

up to date, audited 1 package in 183ms

found 0 vulnerabilities
linking bin 'pnpm'
linking bin 'pnpx'
post-installation fixup
checking for references to /private/tmp/nix-build-pnpm-8.10.2.drv-0/ in /nix/store/kyprrxixg263c82lzy9gmfpj97kkl32p-pnpm-8.10.2...
patching script interpreter paths in /nix/store/kyprrxixg263c82lzy9gmfpj97kkl32p-pnpm-8.10.2
rewriting symlink /nix/store/kyprrxixg263c82lzy9gmfpj97kkl32p-pnpm-8.10.2/bin to be relative to /nix/store/kyprrxixg263c82lzy9gmfpj97kkl32p-pnpm-8.10.2

[nix-shell:~]$ pnpm --version
8.10.2
[nix-shell:~]$ node --version
v18.18.2

また、 shell.nix というものをディレクトリに用意しておけば、 nix-shell コマンドだけ実行すればそのまま一時的な環境に入ることもできます。

$ ls
shell.nix
$ cat shell.nix
{ pkgs ? import <nixpkgs> {}, ... }:
pkgs.mkShell {
  nativeBuildInputs = [
    pkgs.nodejs
    pkgs.pnpm
  ];
}
$ nix-shell
[nix-shell:~/Test/Directory]$ pnpm --version
8.10.2
[nix-shell:~/Test/Directory]$ node --version
v18.18.2

Nix が提供するパッケージを探す場合は NixOS Search を利用します。 こちらに必要なパッケージ名をサーチボックスに入れて Search ボタンをクリックするとパッケージが提供されているかを検索してくれます。

NixOS Search で nodejs パッケージを探している

NixOS Search における nodejs の検索結果

パッケージを更新する場合は nix-channel--update オプションを利用し、 home-managerswitch コマンドを利用します。

$ nix-channel --update
this derivation will be built:
  /nix/store/ikk0zi83gc4x263imdgdx7d721wh33il-darwin.drv
building '/nix/store/ikk0zi83gc4x263imdgdx7d721wh33il-darwin.drv'...
this derivation will be built:
  /nix/store/csfs0vgc39fwjsz6bx22a9iyx5zx3gwh-home-manager.drv
building '/nix/store/csfs0vgc39fwjsz6bx22a9iyx5zx3gwh-home-manager.drv'...
unpacking channels...
$ home-manager switch
# ここからは長い出力がある

まとめ

ここまでで Nix + home-manager で dotfiles を管理する方法をここまでに記載してきました。 Nix は慣れるまで少し扱いが難しく、また日本語の情報は少ないので扱いにこなれるまで時間はかかります。 しかし、導入が済んでしまえば dotfiles を用意しつつしかも環境を綺麗に保ちながらパッケージの導入ができます。 是非とも今回の記事を活用してよりよい dotfiles 環境を構築してみてください。

もし例が気になる場合は自分が環境をつくってリポジトリに公開している ので、そちらを確認してください。

次回の NTT Communications Advent Calendar 2023 の記事もお楽しみに。

golang で書かれた Lambda Docker を ARM で動かす CDK

AWS Lambda も ARM で動かす方が若干料金が安くなる。 また、 AWS Lambda はミリ秒課金になってから料金を安くするために実行速度の早い言語である golang 書きたくなるだろう。

今回はこの golang アプリを動かす Docker を ARM な AWS Lambda で動かすインフラを定義する CDK コードについて解説する。

Dockerfile

何はともあれ Lambda Docker を動かすには Docker 環境を用意しなければならないため、 Lambda Docker で動かすイメージを作る Dockerfile を作る。 実は、 x86_64 環境であれば、何も考えずに golang アプリを生成し、 ECR Public Garelly にある lambda/go の Usage 通りに利用するような Dockerfile を書くだけでいい。 しかし、 ARM はこれでは 動かない 。 lambda/go には OS/Arch の部分に ARM 64 と立派に記載されているが、このイメージは ARM 環境だと 利用できずx86_64 環境のみが提供されている。 証拠とは言えないが、傍証としては このような Issue に対する 回答 が存在する。

docker image pull --platform linux/arm64 public.ecr.aws/lambda/go:1 をして docker inspect しても Architectureamd64 のものしか落ちてこない

なのでこのイメージはそのままでは使えない。 かといってローカルでも走らせられるように RIE を自前で組込もうとするとダウンロード URL が arm64 と x86_64 で異なるため、自前でダウンロード先を動的に変更するようにしたりする必要がある等マルチアーキテクチャで動かすには手間が増えてしまう。 もし、 AWS が新しいアーキテクチャを増やしてきた場合はそこのメンテナンスも自前で必要になるなど Docker でアーキテクチャ関係なく動かしたいのにアーキテクチャのことを意識して Dockerfile をメンテナンスしなければならなくなる。 したがって、これとは違うイメージを利用しなければならない。

実は ECR Public Garelly には lambda/provided というのが存在し、こちらのイメージを使えば lambda に対応したバイナリであればなんでも動かせる。

先程の Issue の回答にも lambda/provided を使えと書いてある。

ゆえに、 golang アプリを ARM64 な Lambda Docker として動かしたい場合はこの lambda/provided を使うことになる。 ここではこちらが試して動いた lambda/provided:al2 を使って Lambda Docker を構築する Dockerfile を示す。

FROM --platform=$TARGETPLATFORM golang:1 as build
WORKDIR /usr/src/app
ARG TARGETOS
ARG TARGETARCH
COPY go.mod go.sum ./
RUN go mod download && go mod tidy
COPY . .
RUN env CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -v -o /main ./...

FROM --platform=$TARGETPLATFORM public.ecr.aws/lambda/provided:al2
COPY --from=build /main ${LAMBDA_RUNTIME_DIR}/bootstrap
CMD [ "main" ]

この Dockerfile ではマルチステージビルドを利用して golang アプリの生成をやってからそれを実際に動かすイメージへコピーして Lambda Docker 用のコンテナをつくるということをやっている。

少々だけ解説すると、 go コマンドでビルドする際に

を指定してあげて、しっかり動かしたい環境向けの golang アプリを生成する必要がある。 これらは GOOSGOARCH で指定できる。 また、 Docker にはいくつか 自動で設定されるグローバル ARG 変数 が存在し、これらに OS やアーキテクチャ、プラットフォームの情報などが格納されている。 この情報を利用してこれらを go コンパインラに伝えて動かしたいアークテキチャ向けに golang アプリをビルドしている。 CGO_ENABLEDcgo を使って C ライブラリをビルドしないようにする同じみの設定。 たったこれだけにはなるが、 lambda/go が ARM で動かないことを知らないと Lambda Docker を動かしたときにアーキテクチャが違うことによって動かないというエラーに悩まされることになる。

lambda/go を使っても ARM 向けのビルドだけは通るが、実態は自分がビルドした golang アプリ以外 x86_64 向けにビルドされたものしか入っていないため、 docker inspect すると Arcitecture は ARM なのに Lambda で動かそうとしてもアーキテクチャが噛み合わずに動くことはない。 しかし、ローカルだと qemu が挟まって正常に動いてしまうため、なぜか Lambda でだけ動かない Docker イメージができあがる。

最後にビルドした golang アプリをコピーしなければならないわけだが、これは ${LAMBDA_RUNTIME_DIR}/bootstrap というディレクトリに設定すれば OK。 この bootstrap から Lambda 関数を起動するようになっているので、ここにプログラムをコピーし、それを CMD に指定して走らせるという手順になっている。

他の言語でも同じ

CDK

Docker イメージは用意できたので、あとは AWS 上にこれをデプロイすれば終わりだ。 やることは

  1. DockerImageCode::fromImageAsset のプロパティに platform を指定する
  2. DockerImageFunctionarchitecture プロパティを設定

の2つになる。 つまり、まとめて書けば

import { Construct } from "constructs";
import * as cdk from "aws-cdk-lib";

export class Component extends cdk.Resource {
  constructor(scope: Construct, id: string) {
    // ...
    new cdk.aws_lambda.DockerImageFunction(this, "Function", {
      code: cdk.aws_lambda.DockerImageCode.fromImageAsset(
        "<lambda コードへのパス>",
        { platform: cdk.aws_ecr_assets.Platform.ARM_64 },
      ),
      architecture: cdk.aws_lambda.Architecture.ARM_64,
    });
  }
}

になる。

まとめ

lambda/go という Lambda Docker を golang で動かすイメージが提供されているものの、こちらは使えないため、 lambda/provided を利用する。 最後にデプロイ先のアーキテクチャを指定すれば ARM 上で Lambda 関数を動かせる。

CDK カスタムリソースで tagging

AWS のリソースではタグをつけられる。 CloudFormation はそれにならってタグをつける仕組みが存在し、 CDK もそれにならう形でタグをつけられるようになっている。 タグをつけることで作成者等の情報をリソースに入れ込むことができ、誰がどのような目的でどれくらいの期限までリソースを管理しているのかといった情報をリソースにメタ情報として載せることができる。 CDK でもプリミティブなリソースに対してはタグをつけることが可能となっているが、カスタムリソースを利用する場合、このタグをつける機能を自前で提供しなければならない。 今回はこの自前でタグをつけるシステムについて解説する。

ITaggable

CDK にはタグ付けを支援してくれる ITaggable というインターフェースが提供されている。 この ITaggable は tags というプロパティを持つことを強制するが、この tags で ITaggable をインプリメントしたコンポーネントをタグ管理することができるようになる。 ただし、これだけでは明示的に tags へタグへの追加/削除を行うメソッドを生やすなりする必要があり、 aws-cdk-lib.Tags.of(component).add 等を利用してカスタムリソースへタグを追加/削除することができない。 なぜならば、タグの追加タイミングはわからないが、カスタムリソースへタグを追加する場合は付けられたタグの情報を上手いことをカスタムリソースのイベントハンドラーへと渡してあげる必要があるからだ。

タグの追加/削除は tags に対して行われるので、ここへ登録されたタグの情報をカスタムリソースに渡すことが必要となる。

Aspect

ITaggable ではタグの管理のみが行えるようになるが、これを適切に tagging されたタイミングでタグをつけるようなロジックを実装する必要がある。 そんなときに IAspect というものを利用する。

これ自体はタグが作成/削除されたときに呼ばれるコンポーネントのライフサイクルを実装するためのインターフェースである。 これを利用することで、明示的にタグを追加しないでも、 aws-cdk-lib.Tags.of(component).add 等でタグの追加がなされたときにタグの情報を更新することが可能になる。

CfnResource

aws-cdk-lib でカスタムリソースを実装する際には core ライブラリの CustomResource を利用するのが一般的だと思うが、実はタグを実装するときにはある条件では使えない。

タグを上手くカスタムリソースのイベントハンドラーに渡すにはハンドラーへ渡すプロパティを利用するか、イベントハンドラーの環境変数を利用することになる。 いずれにせよ、 visit メソッドが呼ばれたときにこれら情報を更新することになる。 もし、プロパティを更新する方法をとる場合、 core ライブラリの CustomResource にはこのリソースを更新する方法がない。 したがって、この状況では CustomResource を使えないため、変わりに同じ core ライブラリにある CfnResource を利用する。

この CfnResource には addPropertyOverride というメソッドがあって、これに上書きしたいプロパティ名と値を指定することで、既に登録済みのプロパティの値を更新することができるようになっている。 したがって、タグの情報をプロパティとして指定しつつ、都度タグの情報が更新されるたびにこの addPropertyOverride メソッドを呼び出すことでタグ情報を更新するという形でいつでも追加/削除されたタグの最新情報をイベントハンドラーが知ることができる。

実装

以上のものを利用してカスタムリソースにタグをつけるロジックを実装していく。

まずは ITaggable を実装することを明示する。

import { Construct } "construct";
import * as cdk from "aws-cdk-lib";

export class MyConstruct
  extends Construct
  implements cdk.ITaggable
{
  // ...
}

tags 自体は先程も書いたがプロパティは単純に実装だけすればよい。

import { Construct } "construct";
import * as cdk from "aws-cdk-lib";

export class MyConstruct
  extends Construct
  implements cdk.ITaggable
{
  tags: cdk.TagManager;

  constructor(scope: Construct, id: string) {
    // ...
    this.tags = new cdk.TagManager(
      cdk.TagType.KEY_VALYE,
      // ドキュメント的にはタグ管理したい CloudFormation のリソースタイプ
      // (たとえば AWS::EC2::Instance)を指定するのが一般的と思われる。
      "What::You:Want"
    );
  }
}

次に、作りたいカスタムリソースを作る。

import { Construct } "construct";
import * as cdk from "aws-cdk-lib";

export class MyConstruct
  extends Construct
  implements cdk.ITaggable
{
  // ...
  private readonly resource: cdk.CfnResource;

  constructor(scope: Construct, id: string) {
    // ...
    this.resource = new cdk.CfnResource(
      this,
      "Resource",
      {
        type: "AWS::CloudFormation::CustomResource,
        properties: {
          // この ServiceToken には aws-cdk-lib.custom-resources.Provider の
          // serviceToken プロパティを指定する。
          ServiceToken: provider.serviceToken,
          // 以下にリソースで利用したいプロパティを指定する。
        }
      }
    );
  }
}

カスタムリソースができたら cdk.Aspect.of(scope).add でタグを都度更新するようにする。

import { Construct } "construct";
import * as cdk from "aws-cdk-lib";

export class MyConstruct
  extends Construct
  implements cdk.ITaggable
{
  // ...
  private readonly resource: cdk.CfnResource;

  constructor(scope: Construct, id: string) {
    // ...
    cdk.Aspect.of(this).add({
      visit: () => {
        this.resource.addProperty("tags", this.tags.renderTags());
      }
    });
  }
}

あとはカスタムリソースのイベントハンドラー側に渡ってくるイベントにある ResourceProperties から最新のタグ情報をプロパティで取得することが可能となる。

全体

import { Construct } "construct";
import * as cdk from "aws-cdk-lib";

export class MyConstruct
  extends Construct
  implements cdk.ITaggable
{
  tags: cdk.TagManager;
  private readonly resource: cdk.CfnResource;

  constructor(scope: Construct, id: string) {
    // ...
    this.tags = new cdk.TagManager(
      cdk.TagType.KEY_VALYE,
      // ドキュメント的にはタグ管理したい CloudFormation のリソースタイプ
      // (たとえば AWS::EC2::Instance)を指定するのが一般的と思われる。
      "What::You:Want"
    );
    this.resource = new cdk.CfnResource(
      this,
      "Resource",
      {
        type: "AWS::CloudFormation::CustomResource,
        properties: {
          // この ServiceToken には aws-cdk-lib.custom-resources.Provider の
          // serviceToken プロパティを指定する。
          ServiceToken: provider.serviceToken,
          // 以下にリソースで利用したいプロパティを指定する。
        }
      }
    );
    cdk.Aspect.of(this).add({
      visit: () => {
        this.resource.addProperty("tags", this.tags.renderTags());
      }
    });
  }
}

まとめ

カスタムリソースにタグをつけるには ITaggable を実装して Aspect で visit するように仕込めばできる。

参考

GitHub Actions の Self hosted runner で --ephemeral オプションでの罠

GitHub Actions の Self hosted runner で設定するときに、 config.sh--ephemeral オプションを指定することで、 Self hosted runner を短命にできる。

短命である とは1つのジョブを走らせたあとに self hosted runner が終了することをいう。 --ephemeralv2.282.0 で導入されたが、これ以前は self hosted runner は1つのジョブを走らせた後も終了することなく再度ジョブが来たらそのままジョブを走らせることしかできなかった。 このため、内部でシステムの状態を変更させるようなタスク(apt-get でパッケージをインストールするなど)を走らせると、その後のジョブにもその影響を与えることができてしまっていた(たとえば、あるジョブで apt-get install -y python3 としてしまうと、その後に同じ self hosted runner で走るジョブでは python3 をインストールしていないのに python3 を使えてしまえていた。

self hosted runner は自動アップデートが走るのだが、その際、 self hosted runner を走らせているプロセス(run.sh)を終了させて再度 self hosted runner を起動するというようになっているがこれと --ephemeral との相性が悪い。 これは --ephemeral がついてないときはこれらの処理は正常に働き、アップデートが完了したら再度 self hosted runner が走るようになっていた。 しかし、 --ephemeral を設定して self hosted runner を起動していると、自動アップデートした際に self hosted runner を走らせているプロセス(run.sh) が自動で終了するまではいいのだが、その後に self hosted runner が起動してこなくなる。 自動でアップデートしてくれないので、 self hosted runner を走らせているインスタンスを終了して最新バージョンをインストールしたインスタンスを起動しなおすなどしてもいいと思う。

ちなみに、 linux かつ x64 な self hosted runner のコードを取得するには次のコマンドを実行すればよい:

$ curl -s https://api.github.com/repos/actions/runner/releases/latest \
    | jq -r '.assets[]|select(.name|contains("linux-x64"))' \
    | jq -r .browser_download_url

linux-x64 の部分を好きなように書き換えれば、他のアーキテクチャに置き換えられる。 サポートされているものについては actions/runner のリリースを見て欲しい。

GitHub Actions の self hosted runner を短命にできるようになった

v2.282.0--ephemeral というオプションがサポートされた。

これは1つのジョブを回したら self hosted runner を終了させるというように動作を変更するためのフラグで、今までは起動したらしっぱなしだった self hosted runner を短命な動作に変更させることができる。 これによって、例えば AWS の Auto Scaling Group で self hosted runner を起動し、ジョブを走らせたあとにすぐにインスタンスを終了させて新しいインスタンスを立ち上げる、みたいなことができるようになった。 つまり、 Self hosted runner でべき等性を確保できるようになった。

しかも、走り終わったら自動で self hosted runner の登録の解除もやってくれる。 そのため、 Runner の設定ページに大量の削除待ちのオフライン runner を見なくてもいい。

今まではそのようなことはできず、 Self hosted runner を立ち上げたら立ち上げっぱなしにするしかなく、各ジョブによるパッケージなども Docker などを利用しなければ共有せざるをえなかった。

例えば、ワークフロー W のジョブ J 内で yum install -y git なぞしようものなら、 W 内の J 以外のジョブや、果ては同じ runner で走ってしまった W 以外のワークフロー内の全てのジョブにも git がインストールされた状態で走ってしまっていた。 これによって、ある時点ではパッケージが使えるが、インスタンスが再起動されて新しい環境が立ち上がると、 git がインストールされてないことでジョブが動かなくなる、なんてことも起こる可能性があった。

しかし、このオプションのサポートによって、適切にインスタンスを終了し新環境を立ち上げるようにすれば、各ジョブにおけるそのような不適切な共有を避けることができるようになるだろう。

CDK による API Gateway と SNS の連携

API Gateway から SNS への連携は Lambda + SQS を利用しなくても直接 API Gateway から SNS へリクエストを流せる。

まずは、 API Gateway と通知先 SNS topic を用意する:

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

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

    const topic = new sns.Topic(this, "topic");
    const gateway = new apigateway.RestApi(this, "gateway");

    // ...

API Gateway から SNS topic へ publish するためには IAM ロールを用意し、 SNS topic にそのロールへ publish する許可を与え、 API Gateway が publish するときに、その権限を利用するようにする必要がある:

// ...

const role = new iam.Role(this, "role", {
  // API Gateway がこのロールに Assume するため、 apigateway.amazonaws.com
  // プリンシパルを指定する。
  assumedBy: new iam.ServicePrincipal("apigateway.amazonaws.com"),
});
// role に publish する権限を与える
topic.grantPublish(role);

// ...

さて、ここまでは簡単なのだが、問題は SNSAPI Gateway を連携する部分。 ここがドキュメントがなくて最も難解な箇所となる。

連携させるには apigateway.AwsIntegration を利用する。 これは API Gateway と任意の AWS サービスを連携させる際に利用するものなのだが、任意のサービスと連携させる分、設定項目が多く、抽象的で、また、 AWS 側の API の仕様を調べなければならないため、設定内容を調べる難易度が高くなる。

今回は SNS と連携して API Gateway から SNS へリクエストを流すため、 SNS の Publish を行うまでの設定について記載する:

// ...

// ステータスコードの詳細については次を参考に
// https://docs.aws.amazon.com/sns/latest/api/API_Publish.html#API_Publish_Errors
const statusCodes = ["200", "400", "403", "404", "500"];
const integration = new apigateway.AwsIntegration({
  // ここに連携するサービス名を記載する。
  // 今回は SNS なので、 `"sns"` を指定する。
  service: "sns",
  // API 発行先のルートを指定する。
  // 用は `https://example.com/${path}` の `${path}` の部分をここに指定する。
  // SNS に Publish する場合は `"/"` を 指定すればよい(なお、空文字列 `""` を指定したら `path` を指定しろとエラーになった。おそらく JavaScript の `falsy` な値に引っかかっているからだと思われる)。
  path: "/",
  // proxy は通してないので false に設定する
  proxy: false,
  // 今回は API Gateway に POST されたものを SNS topic に流すように設定する。
  // 他のメソッドを利用するなら、別途インテグレーションしてほしい
  integrationHttpMethod: "POST",
  options: {
    // ここに、先程用意したロールを指定する。
    // このロールに API Gateway が AssumeRole して Publish する
    credentialsRole: role
    // AWS API のリクエストパラメータを設定する
    requestParameters: {
      // API Gateway から SNS にリクエストを流すとき、リクエストをエンコードしているので、API Gateway から SNS へ出すリクエストの HTTP ヘッダに Content-Type を生やす
      "integration.request.header.Content-Type": "'application/x-www-form-urlencoded'",
    },
    // API Gateway から連携先に出すリクエストのテンプレートを指定する。
    // API Gateway にくるリクエストの Content-Type 毎に設定できる。
    requestTemplates: {
      // 今回は最も一般的であろうと思われる "application/json" のみを設定する。
      // SNS のリクエストパラメータについての詳細は次のドキュメントを見て欲しい。
      // https://docs.aws.amazon.com/sns/latest/api/API_Publish.html
      "application/json": [
        "Action=Publish",
        `TopicArn=$util.urlEncode('${topic.topicArn}')`,
        "Message=$util.urlEncode($input.body)",
        // FIFO topic を利用するなら MessageGroupId を指定してやる必要がある。
        // "MessageGroupId=<グループID>",
      ].join("&")
    },
    // 上の requestTemplates で指定してない Content-Type なデータが送られてきたら 415 を返すようにする
    passthroughBehavior: apigateway.PassthroughBehavior.WHEN_NO_TEMPLATES,
    // 最終的にクライアントに返る統合レスポンスの設定を行う。
    // 設定項目については次を参照して欲しい
    // https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-apigateway.IntegrationResponse.html
    integrationResponses: statusCodes.map(statusCode => ({ statusCode })),
  },
});

// ...

最後に、ルートを生やす:

// ...

// バックエンドから返ってくる中で取り扱いたいレスポンスを指定する:
const methodResponses: apigateway.MethodResponses[] = statusCodes.map(
  (statusCode) => ({ statusCode })
);
gateway.root.addMethod("POST", intergration, { methodResponses });

// ...

Github Actions の self hosted runner を Github Apps + AWS で動かす

Github Actions の self hosted runner の登録は Github API を叩くことで行うことができるので、これを利用して AWS 上に、 Github の Organization 単位な self hoster runner の実行環境を用意して、 runner を登録して走らせる、ということを行う。

なお、 AWS 側の構築は CDK で行う。

前提

Github Apps は個人に紐付かないアクセストークンを発行できるもので、これの作りかたは Github Apps のドキュメント を読んでもらって作成していることを前提としている。

Github Apps を利用してアクセストークンを発行するのに必要な情報は次のとおり:

  • App ID
  • Client Secrets

App ID は Github Apps の設定画面から閲覧できるので、事前にメモしておいた方がいい。

また、 self hosted runner の登録を Github Apps として行うので、 Github Apps に Self-hosted runnersRead & Write 権限はつけておく必要がある。

runner の登録用トークンの発行

runner の登録用トークンの発行は Github API を通して行うことができる。 しかし、runner 登録用トークンの発行を行う Github API を叩くにはアクセストークンの発行が必要となる。

アクセストークンの発行

アクセストークンの発行は少々複雑で、 JWT を発行する必要がある。 公式ページには Ruby による発行例 が載っているので、 Ruby があり、 JWT のライブラリを利用できる環境ならばそれをそのまま利用しても良いだろう。

しかし、 Amazon Linux 2 などには標準で Ruby などは入っていないので、 Ruby の構築からでは少々手間になってしまう。 実は JWT の発行アルゴリズム 自体は非常に単純なので、 jq コマンドと openssl コマンドさえあれば bash シェルスクリプトでもできる(例では RS256 固定となっているが、先程の Ruby による発行例のページに RS256エンコードするように記載されているのにならった)。

JWT の発行

まず、ヘッダーやペイロードなどは URL 向け base64 エンコード を行うため、これを行う関数を作成する:

# URL 向け base64 エンコードを施す
# ## 引数
# stdin :byte[]: エンコードしたいもの
# ## 返り値
# stdout :string: エンコード結果を標準出力へ出力する
#
# ## 使用例
# ```bash
# $ printf "%s\n" "$(echo "test" | base64url)"
# dGVzdAo
# $
# ```
function base64url() {
  # base64 エンコードして
  base64 | \
    # `+` は `-` に、 `/` は `_` に置き換える
    tr '+/' '-_' | \
    # パディングは削除
    tr -d '=' | \
    # 一応改行も削除しておく
    tr -d '\n'
 }

JWT のヘッダーとペイロードは、この関数を用いてエンコードするだけなので、実に単純

HEADER="$(jq -ncj '{alg: "RS256", typ: "JWT"}' | base64url)"
PAYLOAD="$(echo "<payload>" | base64url)"

シグネチャはヘッダーとペイロードをそれぞれ URL 向け base64 エンコードした上で、 encodedHeader.encodedPayload のように . で接続したものを、 openssl コマンドを用いて署名して得られる:

# 署名する
#
# ## 引数
# $1 :string: client secret の値
# stdin :byte[]: 署名したい値
# ## 返り値
# stdout :string: URL 向け base64 エンコードされた文字列
#
# ## 使用例
# ```bash
# $ echo "test" | sign "$SECRET"
# A0sd...
# $
function sign() {
  local secret="$1"
  openssl dgst -binary -sha256 -sign <(echo "$secret")
}
SECRET="<署名に利用するシークレット値>"
SIGNATURE="$(printf "%s.%s" "$HEADER" "$PAYLOAD" | sign "$SECRET" | base64url)"

最後は、以上で得られた URL 向け base64 エンコードされたヘッダー・ペイロードシグネチャ. を用いて encodedHeader.encodedPayload.encodedSignature のように結合することで JWT トークンを得ることができる。

アクセストークンの発行

ペイロードを作成する:

# アクセストークンの発行要求時刻
IAT="$(date "+%s")"
# トークンの失効時刻
EXP="$((IAT + 10 * 60))"
# Github Apps の App ID
ISS="<App ID>"
PAYLOAD="$(
  jq -ncj \
    --argjson iat "$IAT" \
    --argjson exp "$EXP" \
    --arg iss iss "$ISS" \
    '{iat: $iat, exp: $exp, iss: $iss}'
)"

このペイロードを用いて、上の JWT の発行手順を同じように踏んで JWT トークンを発行する。

発行した JWT トークンをもとに、アクセストークンを取得するための URL を Github に生成させる:

ACCESS_TOKEN_URL="$(
  curl -s \
      -H "Authorization: Bearer <JWTトークン>" \
      -H "Accept: application/vnd.github.v3+json" \
      https://api.github.com/app/installations | \
  jq -r --argjson app_id "$ISS" '.[] | select(.app_id == $app_id) | .access_tokens_url'
)"

この URL に JWT トークンを用いて接続すればアクセストークンを得られる:

curl -s \
    -X POST
    -H "Authorization: Bearer <JWTトークン> \
    -H "Accept: application/vnd.github.v3+json" \
    "$ACCESS_TOKEN_URL" | \
  jq -r .token

runner の登録用トークンの取得

上記で得られたアクセストークンを用いることで runner を登録することが可能になる。 登録自体はアクセストークンさえ得られれば この API をたたくだけで実現できる:

curl -sSL \
    -X POST \
    -H "Accept: application/vnd.github.v3+json" \
    -H "Authorization: token <アクセストークン>" \
    https://api.github.com/orgs/<Organization 名>/actions/runners/registration-token | \
  jq -r .token

API のドキュメントを読んでもらうとわかるが、トークンには有効期限が設定されているので、扱う時間については注意して欲しい。

CDK

Github API のアクセストークンを発行し、 runner を登録するトークンさえ得られれば、あとはそのトークンを持ってして runner を起動すれば自動で runner 登録処理が走る。 なので、あと我々としてやることは少なく、 runner を走らせるスタックを用意できさえすればよい。

ここでは Docker を Github Actions で利用できるように EC2 インスタンス上に runner の環境を作成し、ついでに常に CI 環境が1台存在するような Auto Healing 機能が付いたスタックを用意する。 ECS などでも EC2 インスタンスをバックエンドで利用するようにしてやれば Docker outside of Docker でできる ようではある、が自分は未検証。

スタックの構築

まず、スタックを用意する:

import * as core from "@aws-cdk/core";
// RunnerGroup はここで構築する
import { RunnerGroup, Parameter } from "./runner-group";

export class SelfHostedRunnerStack extends core.Stack {
  constructor(scope: core.Construct, id: string, param: Parameter, props?: core.StackProps) {
    super(scope, id, props);

    // このコンポーネントをつくる
    new RunnerGroup(this, "RunnerGroup", param);
  }
}

そして、 RunnerGrouplib/runner-group.ts に記載する:

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

type Parameter = {
  /**
   * EC2 インスタンスの設置先 VPC
   */
  vpc: ec2.IVpc;
  /**
   * 起動する runner のバージョン
   */
  runnerVersion: string;
  /**
   * スタックの設置先リージョン
   */
  region: string;
};
/**
 * Runner を走らせる AutoScalingGroup
 */
export class RunnerGroup extends ec2.AutoScalingGroup {
  constructor(scope: core.Construct, id: string, param: Parameter) {
    // デプロイされる EC2 インスタンスのロール
    // SSM を利用するために作っているだけなので、いらないかも
    const executionRole = new iam.Role(scope, "ExecutionRole", {
      assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
      ],
    });

    const securityGroup = new ec2.SecurityGroup(scope, "SecurityGroup", {
      vpc: param.vpc,
    });
    // runner は Github と 443 を通してジョブのやりとりをしているので、
    // 443 番ポートはあけておく
    securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443));

    super(scope, id, {
      // 常に1台が起きているようにする
      maxCapacity: 1,
      minCapacity: 1,
      role: executionRole,
      vpc: param.vpc,
      machineImage: new ec2.AmazonLinuxImage(),
      // CPU をそこそこ占有するので、コンピューティング最適化されたインスタンスタイプを選択し、
      // 余裕を持って、 vCPU コアもそこそこ大きめに確保しておく
      instanceType: ec2.InstanceType.of(InstanceClass.C5, InstanceSize.LARGE),
      // public なサブネットに設置するならば、 `associatePublicIpAddress` プロパティを `true` に設定しておく必要がある。
      // ここでは Public Subnet への NAT Gateway が設置されている Private Subnet に配置してしてしまう設定にする。
      vpcSubnets: {
        subnets: vpc.privateSubnets,
      },
      securityGroup,
      // ここで runner を起動するスクリプトを指定することで、インスタンス起動時に runner の自動登録・起動がなされるようにする
      userData: userData(param.runnerVersion, param.region),
    });
  }
}

/**
 * EC2 インスタンスの起動スクリプトを作成する
 *
 * なお、ここでは起動される OS は Amazon Linux 2 であると仮定している。
 */
const userData = (runnerVersion: string, region: string): ec2.UserData => {
  // Amazon Linux 2 なら `ec2-user` というユーザーが最初からいるので、それを利用しても良いだろう
  const user = "<runner を走らせるユーザー名>";
  const commands = [
    // デバッグ用
    "set -xe",
    // Docker をインストールし、 runner が docker を走らせられるようにする(docker グループに加えるのはよろしくない気もするが、面倒なので...)。
    "yum install -y docker",
    `gpasswd -a ${user} docker`,
    // self hosted runner で最低限 git は必要(コードのチェックアウトはかならず行うため)
    "yum install -y git",
    // ログを /var/log/user-data.log に残しておきつつ、 journalctl にも残しておく
    "exec > >(tee /var/log/user-data.log | logger -t user-data -s 2>/dev/console) 2>&1",
    // runner 登録用トークンを発行する一連の処理をここに記載するのだが、
    // 一々書いていると大きくなるので、ここでは `access-token.bash` という
    // スクリプトにそれらの一連の処理が記載されているとする。
    'export RUNNER_TOKEN="$(/opt/bin/access-token.bash)"',
    // runner をダウンロードする
    `su "${user}" -c 'curl -sSL "https://github.com/actions/runner/releases/download/v${runnerVersion}/actions-runner-linux-x64-${runnerVersion}.tar.gz" | tar zxf - -C "$HOME/runner"'`,
    // 登録する runner 名をランダムに生成する。
    // ただし、登録できるのは64文字となるため、 sha256 で64文字化する
    'export NAME="$(dd bs=<好きなバイト数> count=1 if=/dev/urandom status=none | sha256sum | cut -f 1 -d " ")"',
    // runner を起動する
    // `--replace` で起動時に名前が被っているときは新しく登録する runner で置き換えるようにする。
    // `--unattended` でインタラクティブな操作を無効化している。
    `su "${user}" -c 'cd "$HOME/runner"; ./config.sh --unattended --url "https://github.com/<Organization 名>" --replace --token "$RUNNER_TOKEN" --name "$NAME"'`,
  ];

  const ud = ec2.UserData.forLinux({ shebang: "#!/usr/bin/env bash" });
  ud.addCommands(...commands);
  return ud;
}

あとは、 bin ディレクトリにある起動スクリプトに次のようにスタックを追加してやればデプロイできるようになる:

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

const app = new core.App();
const region = "<設置先リージョン>";
new SelfHostedRunnerStack(
  app,
  "SelfHostedRunnerStack",
  {
    // 執筆時点で、最新版は `2.277.1`
    runnerVersion: "2.277.1",
    region,
  },
  {
    env: {
      region,
      account: process.env.CDK_DEFAULT_ACCOUNT,
    },
  },
);