GitHub ActionsからGCPにOpenID Connectでアクセスする

GitHub ActionsがOIDC (OpenID Connect)に対応したと公式から発表があったため、GCPへのアクセスを行ってみました。

github.blog

GCPのWebコンソールやgcloudコマンドを使ったやり方は公式ドキュメントで紹介されているため、ここではTerraformを使ったやり方を紹介します。

docs.github.com

github.com

上記の公式ドキュメントで紹介されているコマンドはデモ用のためセキュリティ的にはあまり良い状態ではありません。
今回は特定のリポジトリのみで使用できるように設定していきます。

Terraformで設定する

まずはGitHub Actionsで使用するサービスアカウントを作成します。

resource "google_service_account" "demo" {
  account_id   = "demo"
  display_name = "demo"
  description  = "demo"
}

サービスアカウントへの権限設定は今までと変わらないため省略します。

次にWorkload Identity Poolを作成します。
GCPによると環境によって分けることを推奨するようです。

Workload Identity プールは相互に分離されていますが、1 つのプールは任意の数のサービス アカウントになり代わることができます。一般に、開発、ステージング、本番環境など、環境ごとに新しいプールを作成することをおすすめします。

cloud.google.com

resource "google_iam_workload_identity_pool" "github_actions_demo" {
  provider = google-beta

  workload_identity_pool_id = "github-actions-demo"
  display_name              = "GitHub Actions demo"
  disabled                  = false
}

次は先程作成したPoolにWorkload Identity Providerを作成します。

ここで行う属性のマッピングがセキュリティを高めるために重要になってきます。

リポジトリで権限の制御を行うために "attribute.repository" = "assertion.repository" として属性にリポジトリ名を追加します。

リポジトリだけではなくブランチに対しても制御することができます。
例として、mainブランチのみにデプロイ用の権限を許可する、などがあるかと思います。

resource "google_iam_workload_identity_pool_provider" "demo" {
  provider = google-beta

  workload_identity_pool_id          = google_iam_workload_identity_pool.github_actions_demo.workload_identity_pool_id
  workload_identity_pool_provider_id = "demo-provider"
  display_name                       = "demo"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.actor"      = "assertion.actor"
    "attribute.aud"        = "assertion.aud"
    "attribute.repository" = "assertion.repository"
  }

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

属性のマッピングについての詳細は以下の公式ドキュメントが参考になります。

cloud.google.com

最後にサービスアカウントと紐付けます。

resource "google_service_account_iam_binding" "gha_demo" {
  service_account_id = google_service_account.demo.name
  role               = "roles/iam.workloadIdentityUser"

  members = [
    "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github_actions_demo.name}/attribute.repository/[your_github_name]/[your_repository_name]",
  ]
}

最後の [your_github_name]/[your_repository_name] はサービスアカウントの使用を許可するリポジトリになります。

GitHub Actions

GitHub Actionsでは以下のようにProviderとサービスアカウントの指定をすればアクセスできます。

jobs:
  demo:
    runs-on: ubuntu-latest
    permissions:
      contents: 'read'
      id-token: 'write'
    steps:
      - name: Authenticate to Google Cloud
        id: auth
        uses: google-github-actions/auth@v0.3.1
        with:
          create_credentials_file: 'true'
          workload_identity_provider: 'projects/xxxxxxxxxxx/locations/global/workloadIdentityPools/github-actions-demo/providers/demo-provider'
          service_account: 'demo@xxxxxxxxxxxx.iam.gserviceaccount.com'

      - name: gcloud
        run: |-
          gcloud auth login --brief --cred-file=${{ steps.auth.outputs.credentials_file_path }}

client-goでDynamic Clientを使用して複数のマニフェストを適用する

client-goで単体のリソースを作成する場合は以下のように明示的に対象のclientを作成することで行うことができます。

func main() {
    ...
    deployment := &appsv1.Deployment{}
    deploymentsClient := clientset.AppsV1().Deployments(apiv1.NamespaceDefault)
    result, err := deploymentsClient.Create(context.TODO(), deployment, metav1.CreateOptions{})
}

しかし、このやり方だと以下のような複数の種類リソースがまとまったyamlや、どのような種類を扱うか不明なときには利用するのが難しくなります。

apiVersion: v1
kind: Namespace
metadata:
  name: example
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: example

そこでDynamic Clientを使うと、このような状況でもclient-goでリソースの作成をすることができます。

Dynamic Client

Dynamic Clientを使用して上記のyamlを適用していきます。

Dynamic Clientの初期化

通常のClientを作成するときと同じようにDynamic Clientに必要な変数を初期化します。

import (
    "k8s.io/apimachinery/pkg/api/meta"
    "k8s.io/client-go/discovery"
    "k8s.io/client-go/dynamic"
    "k8s.io/client-go/kubernetes"
    "k8s.io/client-go/rest"
    "k8s.io/client-go/restmapper"
    "k8s.io/client-go/tools/clientcmd"
)

var (
    client          kubernetes.Interface
    discoveryClient discovery.DiscoveryInterface
    mapper          meta.RESTMapper
    dynamicClient   dynamic.Interface
    config          *rest.Config
)

type Dynamic struct {
    Client    dynamic.Interface
    Discovery discovery.DiscoveryInterface
    Mapper    meta.RESTMapper
}

func InitK8s() error {
    conf, err := clientcmd.BuildConfigFromFlags("", clientcmd.RecommendedHomeFile)
    if err != nil {
        return err
    }
    config = conf

    clientset, err := kubernetes.NewForConfig(conf)
    if err != nil {
        return err
    }
    client = clientset

    discoveryClient = clientset.Discovery()

    groupResources, err := restmapper.GetAPIGroupResources(discoveryClient)
    if err != nil {
        return err
    }
    mapper = restmapper.NewDiscoveryRESTMapper(groupResources)

    dyn, err := dynamic.NewForConfig(config)
    if err != nil {
        return err
    }
    dynamicClient = dyn

    return nil
}

ポイントとしては RESTMapper を作成するときにDynamic Clientを構築するために必要な情報をAPI Serverから取得するため、毎回実行してしまうとサーバーに負荷をかけてしまうので予め作成しておく必要があります。

全体の流れ

k8s.io/apimachinery に付属する関数を使用することでリソース単位のオブジェクトに分割できます。

分割したリソースごとにClientを構築してマニフェストを適用していきます。

import (
    "k8s.io/apimachinery/pkg/runtime"
    "k8s.io/apimachinery/pkg/util/yaml"
)

func main() {
    ...
    dynamic := Dynamic{}
    manifest := "..."
    decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(manifest), 100)
    for {
        var rawObj runtime.RawExtension
        if err := decoder.Decode(&rawObj); err != nil {
            break
        }

        obj := &unstructured.Unstructured{}
        client, err := dynamic.NewClient(rawObj.Raw, obj) 
        res, err := dynamic.Apply(client, obj)
        ...
   }
}

Dynamic Clientの構築

まずは分割したリソースからclientが適切なAPIにアクセスするための情報をパースして、Dynamic Clientを構築します。

func (d Dynamic) NewClient(data []byte, obj *unstructured.Unstructured) (dynamic.ResourceInterface, error) {
    dec := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme)
    _, gvk, err := dec.Decode(data, nil, obj)
    if err != nil {
        return nil, err
    }

    mapping, err := d.Mapper.RESTMapping(gvk.GroupKind(), gvk.Version)
    if err != nil {
        return nil, err
    }

    if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
        if obj.GetNamespace() == "" {
            obj.SetNamespace(metav1.NamespaceDefault)
        }
        return d.Client.Resource(mapping.Resource).Namespace(obj.GetNamespace()), nil
    } else {
        return d.Client.Resource(mapping.Resource), nil
    }
}

上記コード gvkgvk.String() を出力すると apps/v1, Kind=DaemonSet のような情報が出力されます。
これがDynamic Clientを構築するために必要な情報です。
この情報をもとに先程作成した RESTMapper を経由してAPI情報を取得してDynamic Clientを構築します。

Create

Dynamic Clientの構築が完了したので、リソースを作成します。

kubectl create 相当のものはこのようになります。

func (d Dynamic) Create(ctx context.Context, client dynamic.ResourceInterface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
    return client.Create(ctx, obj, metaV1.CreateOptions{
        FieldManager: "example",
    })
}

また、 kubectl apply 相当のものはこのようになります。

func (d Dynamic) Apply(ctx context.Context, client dynamic.ResourceInterface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) {
    data, err := json.Marshal(obj)
    if err != nil {
        return nil, err
    }

    return client.Patch(ctx, obj.GetName(), types.ApplyPatchType, data, metaV1.PatchOptions{
        FieldManager: "example",
    })
}

このようにしてDynamic Clientを構築することで、複数のリソースがまとまったマニフェストをclient-goを使用して適用することができます。

KubernetesのJobを管理するカスタムコントローラーを作成した

KubernetesのJobは完了しても登録したJobは削除されず kubectl get job などを実行したときの視認性がよくありません。
これを解消するための設定として .spec.ttlSecondsAfterFinished がありますが、長いことalpha版でGKEでは使用することができません。

単純に消すだけであればclient-goを使って書いたスクリプトをCronJobで定期実行すればよさそうですが、カスタムコントローラーを作ってみたかったため akaimo/job-observer というコントローラーを作成してみました。

akaimo/job-observer

カスタムコントローラーを作るにあたっていくつかの方法がありますが、Kubernetes本体のことも知りたかったため、本家のコントローラーと同様のアプローチである k8s.io/code-generator を使って作成しました。

インストール方法

デフォルト設定のコントローラーをインストールする場合は以下の一行を実行するだけです。

$ kubectl apply -f https://raw.githubusercontent.com/akaimo/job-observer/master/bundle.yaml

これはhelmのtemplateコマンドで出力したものなのでカスタマイズができます。
現状はあまり多くのことはできませんが...

カスタムリソース

Cleaner リソースでPodの停止から任意の時間が経過したJobを削除することができます。

apiVersion: job-observer.akaimo.com/v1alpha1
kind: Cleaner
metadata:
  name: my-cleaner
spec:
  ttlAfterFinished: 1m
  cleaningJobStatus: Complete
  selector:
    matchLabels:
      app: my
      version: mikan

削除対象を絞り込むことができ、ServiceでPodを指定するときと同じ方法でLabelを使って選択できます。
また、Jobの終了ステータスで指定することもできます。

Cleaner リソースを複数個登録することで、対象の条件ごとに削除までの期間を指定できます。
例えば、成功したJobは1h、失敗したJobは24hといったような指定ができます。

今後について

今の所、わざわざコントローラーを入れるほどの内容ではないというのが正直なところです。

コントローラーを作成することにもなれてきたので、Jobの終了時にSlackなどに通知する機能を追加してみようかと考えています。

Ubuntuで開発環境を整える

MacのDocker周りの遅さに嫌気が差したのでUbuntuに移行してみることにしました。
自分用のメモも兼ねて構築の流れを残しておきます。

WSL2にしなかった理由

最初はWSL2で構築する予定でしたが、検証していると問題が発生したため辞めました。

中でも致命的だったのがGoのModulesがWindows側のIDEから実行できないことです。

github.com

小さなプロジェクトや大部分を把握している場合なんかはvimで開発していますが、Kubernetesなどの大きなもののコードを読むときはIDEがあると便利です。
コードを読むだけならWindows側に置いておけばいいかなとも思いましたが、Windowsのことをあまり理解したいと思えなかったので辞めました。

気が向いたら再チャレンジするかもしれません。

環境

自作PCに直接Ubuntuをインストールするとドライバやらが面倒なので仮想化しています。

また、仮想化していると簡単にバックアップとリストアができるので気軽にアップデートなどを行えて便利です。

構築の流れ

OS周り

開発環境

構築完了

VMやOS周りの設定さえ終えればあとの大部分はAnsibleがやってくれます。

以前と比べるとUbuntuでも動くソフトウェアが増えていて(Toolbox Appや1Passwordなど)だいぶ扱いやすくなった印象です。

vimの補完プラグインasyncomplete-around.vimを作りました

asyncomplete-around.vimとは

asyncomplete-around.vimprabirshrestha/asyncomplete.vimのSourceプラグインです。

github.com

現在のカーソル位置を中心として上下20行(デフォルト値)に存在する単語を補完対象として表示します。

asyncomplete.vimは補完ポップアップを表示するプラグインで、これに適した補完対象(Source)を渡すプラグインと組み合わせることでポップアップに単語が表示され補完できるようになります。

asyncomplete.vimのソースプラグインとして代表的なものとして、LSPプラグインprabirshrestha/vim-lspprabirshrestha/asyncomplete-lsp.vimがあります。

作った経緯

もともと補完プラグインとしてShougo/deoplete.nvimを使っていました。
使用感に不満はありませんでしたが、Python環境を用意するのが面倒だと感じていました。

LSPとしてprabirshrestha/vim-lspを使っていたため同じ作者のprabirshrestha/asyncomplete.vimに乗り換えたところ、Buffer関連の補完が少し弱く、LSPで補完できないちょっとした単語を繰り返し入力することを面倒に感じていました。
少し調べると独自の補完ソースを追加できるとの記載があったため、asyncomplete-around.vimを作成することにしました。

オススメ設定

ここで私の設定を紹介します。
コードの詳細はGitHubにて公開しているため、適宜そちらを参照してください。

github.com

補完範囲の設定

私は画面に表示されている単語が補完できれば良いかなと思っているため50行にしています。

let g:asyncomplete_around_range = 50

Priorityの設定

asyncomplete.vimpreprocessorという仕組みがあり、補完の表示内容を編集することができます。
今回はそれを利用して補完ソースの表示順を使いやすいように制御します。

各ソースにPriorityを設定し、その値をもとにソートする関数をg:asyncomplete_preprocessorに設定します。

function! s:sort_by_priority_preprocessor(options, matches) abort
    ...
endfunction

let g:asyncomplete_preprocessor = [function('s:sort_by_priority_preprocessor')]

call asyncomplete#register_source(asyncomplete#sources#around#get_source_options({
      \ 'name': 'around',
      \ 'allowlist': ['*'],
      \ 'priority': 10,
      \ 'completor': function('asyncomplete#sources#around#completor'),
      \ }))

私はasyncomplete-lsp.vim, asyncomplete-file.vim, asyncomplete-around.vimという順番で表示させています。
asyncomplete-around.vimは指定範囲内のマッチした単語が全て候補として表示されるため、LSPで候補となっている関数や変数よりも上に表示されてしまうと、とても使いづらくなってしまうためこの順番にしています。

asyncomplete-around.vimの今後

しばらく使用してみて私の要望を満たすものだと確信できたので、上位互換のプラグインが登場しない限り今後も使用、メンテナンスをしていきます。

asyncomplete-around.vimは非同期化されていないため場合によってはカクつくことがあるかもしれません。
そのため、まずは非同期化を進めていきたいと思います。

Pull RequestのコメントからGitHub Actionsを実行する

GitHubのPull Requestのコメントから任意のGitHub Actionsを実行する必要があり、やり方に少々癖があったので紹介します。

issue_comment

ドキュメントを眺めているとPull Requestに関連するイベントはいくつかありますが、Pull Requestのコメントをトリガーとしたイベントが無いように見えます。

どうにかPull Requestのコメントをトリガーに起動できないか調べていたらCommunity Forumで以下の投稿を見つけました。

github.community

この投稿によるとissue_commentイベントでPull Requestのコメントもトリガーとなるようです。
Issueへのコメントもトリガーの対象となるので、適切にフィルタリングする必要があると書かれています。

実装

今回は/planとコメントに入力することでterraform planが実行されるGitHub Actionsを作ってみたいと思います。

stepsまでのコードが以下になります。

name: "/plan"
on:
  issue_comment:
    types: [created, edited]

jobs:
  manual_plan:
    name: "manual plan"
    if: contains(github.event.comment.html_url, '/pull/') && startsWith(github.event.comment.body, '/plan')
    runs-on: [ubuntu-latest]
    steps:

ポイントはジョブ全体でifを設定して起動する条件を設定しているところです。

contains()でPull Requestのコメントのみに反応するようにし、startsWith()/planとコメントに入力された場合のみにしています。

実行されるブランチ

上記の設定のままstepsで処理を書いていけば意図した通りに動作しそうですが、1つ問題があります。
issue_commentをトリガーとした場合、actions/checkoutでpullできるブランチがmasterブランチになってしまいます。(正確にはリポジトリのデフォルトブランチ)

Pull Requestのコメントで実行するので、そのPull Requestのブランチでジョブを実行したいです。

actions/checkout@v2からはブランチを指定してpullできるようになりました。
Actionsをトリガーした内容が入った変数、つまり${{ github }}をもとにGitHubAPIを使ってPull Requestのブランチ名を取得します。
それをactions/checkout@v2にセットし、Pull Requestのブランチでジョブを実行できるようにします。

    steps:
      - name: get upstream branch
        id: upstreambranch
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          echo "::set-output name=branchname::$(curl -H "Authorization: token ${GITHUB_TOKEN}" ${{ github.event.issue.pull_request.url }} | jq '.head.ref' | sed 's/\"//g')"

      - name: Checkout upstream repo
        uses: actions/checkout@v2
        with:
          ref: ${{ steps.upstreambranch.outputs.branchname }}

Pull Requestに結果を書き込み

せっかくなのでplanの結果をPull Requestのコメントに書き込みたいと思います。

GitHubに関連する操作はactions/github-scriptを使えば大体の事ができます。

github.com

      - id: plan
        run: terraform plan -no-color

      - name: plan success message
        uses: actions/github-script@0.9.0
        if: steps.plan.outputs.exitcode == 0
        env:
          STDOUT: "```${{ steps.plan.outputs.stdout }}```"
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            github.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: "#### `terraform plan` Success\n" + process.env.STDOUT
            })

LFSをやってみたので振り返る

長期休暇を利用してLFSをやってみたので振り返ってみたいと思います。

きっかけ

日頃から業務でLinuxを使ってサービスを運用していますが、最近はクラウドやコンテナなどを使うことがほとんどでLinuxのほんの一部だけを知っていれば運用できています。
しかし、何らかの障害が発生したときの調査は平常時に使う一部の知識だけでは難しいことが多いです。
そんな状態から脱出すべく、以下の書籍を読みました。

gihyo.jp

www.sbcr.jp

これらを読んでいるとLinuxの仕組みがわかってきますが、ただテキストを読んでいるだけだとどうしても印象が薄くなってしまい忘れてしまうことが目立ってきました。
そこで何か手を動かしてできることがないか調べてみるとLFSLinux From Scratch)を見つけたので、やってみることにしました。

期待したこと

LFSをやることで以下の内容を取得できることを期待して手を動かしてみました。

  • Linuxの基盤となる部分を知る
  • サービスを運用するときの障害対応力を上げる

構築中に感じたこと

Linuxで使われるファイルやディレクトリを手作業で作成する

LinuxをインストールするとFHSに即したディレクトリが構築され、/etc/passwd/etc/groupには初期のユーザー情報が記述されています。
LFSで1から構築する場合はこれらのファイルやディレクトリも自分で作成することになります。
当たり前だと言えばそうかもしれませんが、なかなか新鮮な感覚でした。

ソフトウェアのバージョンを合わせることの重要性

LFSは構築に使うパッケージを全て自前でビルドします。
その関係上、まずはホストシステムでLFSの構築に使う環境の構築を行います。この環境の構築はホストにインストールされているソフトウェアを使うため、このバージョンが異なっていると予期せぬエラーに遭遇することもあります。

Linux KernelやGlibcなど、変更するのが大変なものもありますが、ホストのOSを変えるなどして、できる限り合わせる必要があります。
最低でもマイナーバージョンまでは揃えるべきです。

ホスト環境をチェックするスクリプトが載っているので、よくチェックすることをオススメします。

lfsbookja.osdn.jp

クラウドでは使用頻度が少ないコマンドを実行する

LFSを始めると最初に専用のパーティションを作成することになります。
パーティションを作成するためにfdiskコマンドを実行したり、それをマウントするためのmountコマンド、/ets/fstabへの記述、そして構築が進んでいくと作り途中のLFS環境に入るためのchrootコマンド、デバイスノードを作成するmknodコマンドなど、クラウドでサービスを運用しているとあまり使わないコマンドを実行します。

クラウド以外で運用する場合やLinuxに関連する開発を行う場合、これらのコマンドは定期的に使うことになるので久しぶりに実行して思い出すことができたのは良い機会だったと思います。

知らないこと、忘れていることはその場で調べる

これが今回の構築で一番良かったところです。

LFSのドキュメントはとてもよくできており、ほぼ全ての作業をコピペするだけで構築できます。
そのコピペするコマンドの中には上記の本で触れられているがあまり使わないコマンドや、そのそも出てこないものなどもあります。
それらのコマンドを知らないままでもコピペで実行すれば構築できてしまいますが、コマンドのオプションも含め、知らないものは全て調べます。
時間はかかりますが勉強になります。

また、LFSでは全てのパッケージをビルドするため、インストールする全てのライブラリに触れます。
その中には知らないパッケージや生成されるコマンドがあると思います。
LFSのドキュメントに簡単には説明が書いてありますが、これも調べるととても勉強になります。

期待したものは得られたか

構築が完了した時点で期待した成果が得られたのか振り返ります。

Linuxの基盤となる部分を知る

これに関しては十分に達成できたと思います。
ただ、ライブラリやコマンドはビルドしただけなので、ふつうのLinuxプログラミングで書かれている内容をもう一度やってみるとさらなる理解が得られそうな気がしています。

サービスを運用するときの障害対応力を上げる

障害対応力は構築が完了した段階だとまだ上がった実感はありません。
少なからず関連はしていると思うので、実際に対応を行ってみて変化を感じるかどうか様子を見てみたいと思います。

今後にむけて

サービスをLinuxの上で運用していくことはまだしばらく続くと思うので、さらなる理解と障害対応力を磨いて行きたいと思います。
そのために次はこれらの書籍をよんでみようかと思っています。

www.seshop.com

www.oreilly.co.jp