【NTT Communications Advent Calendar 2023】Nix と home-manager で dotfiles を管理する話
この記事は、 NTT Communications Advent Calendar 2023 9日目の記事です。
みなさんは dotfiles をどのように管理されているでしょうか? Bash や Zsh といったスクリプトによってインストールを管理したり、 git でホームディレクトリを管理する、 Chef や Ansible のような構成管理ツールを用いて管理する方法があると思います。 自分もシェルスクリプトによる管理をおこなったり、 Ansible 等の構成管理ツールの利用や 自前の構成管理ツール をつくって管理していましたが、現在は Nix と home-manager というものを使って管理しています。 今回はこの Nix と home-manager による dotfiles の管理をどのように行えばよいのか、それらによって管理する Pros/Cons にはどのようなものがあるのかについて解説します。
Nix
まずそもそも Nix とはどういったものなのかについて解説します。
Nix とは NixOS という Linux ディストリビューションに搭載された apt や yum/dnf のようなパッケージ管理システムです。 Nix はドキュメントにも記載してありますが、 純粋関数型パッケージマネージャー です。
Nix is a purely functional package manager.
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 Manual の Nix 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.nix
の home.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
ボタンをクリックするとパッケージが提供されているかを検索してくれます。
パッケージを更新する場合は nix-channel
の --update
オプションを利用し、 home-manager
の switch
コマンドを利用します。
$ 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
してもArchitecture
がamd64
のものしか落ちてこない
なのでこのイメージはそのままでは使えない。 かといってローカルでも走らせられるように 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 アプリを生成する必要がある。
これらは GOOS
と GOARCH
で指定できる。
また、 Docker にはいくつか 自動で設定されるグローバル ARG 変数 が存在し、これらに OS やアーキテクチャ、プラットフォームの情報などが格納されている。
この情報を利用してこれらを go
コンパインラに伝えて動かしたいアークテキチャ向けに golang アプリをビルドしている。
CGO_ENABLED
は cgo
を使って 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 上にこれをデプロイすれば終わりだ。 やることは
DockerImageCode::fromImageAsset
のプロパティにplatform
を指定するDockerImageFunction
のarchitecture
プロパティを設定
の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 が終了することをいう。
--ephemeral
は v2.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); // ...
さて、ここまでは簡単なのだが、問題は SNS と API 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 runners
の Read & 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); } }
そして、 RunnerGroup
を lib/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, }, }, );