迷ったらカレー

技術系の雑多なブログ

Google Kubernetes EngineにNAT付きでConcourse CIをたてる

(この記事は、Recruit Engineers Advent Calendar 2018 16日目の記事です)

こんにちは。リクルートでエンジニアをしている加藤です。 フロントエンドをやったり、バックエンドをやったり、プロダクティビティ的なことをやったりと、いろんなことをやっています。最近は、KubernetesGCPを触る機会が増えてきたのと、あとJenkinsがもういい加減辛くなってきてConcourse CIを触り始めました。つまりこの記事は、 最近やったこと全部盛り な内容になっております。すこし長いですがお付き合いください。

この記事でやること

この記事では、GKE(Google Kubernetes Engine)に、NAT付きでConcourseを立てる方法をご紹介します。今回想定している状況は、

  • ファイヤウォールの内側にいる、プライベートのサービス(GitlabやGithubEnterperiseなど)につなぎに行きたい
  • Concourseには特定のIPからのみ接続を許可したい
  • Concourseへの接続はHTTPSにしたい

というものです。エンタープライズの開発環境では、よくある状況かと思います。 全体構成は以下のようになります。

全体構成
全体構成

この構成を作るため、以下の順で進めていきます。

  1. NAT Gateway付きのGKEクラスタを作る
  2. Let's EncryptのTLS証明書をGKEクラスタで管理する
  3. ConcourseをGKEクラスタ上で動かす
  4. Cloud Armorで接続元IPを制限する

この記事で例示されるコードは、後ほどリポジトリにアップして、記事に追記します。

Concourse CI?

Concourse CIは、pivotalによって開発されている、オープンソースのCIツールです。ググるとJenkins vs Concourseみたいな記事が出るくらいには人気があります。特徴的な点としては、パイプラインの状態可視化と、設定ファイルによる明示的なタスク定義があります。ConcourseCIについては、数ある紹介記事・比較記事にお任せします。個人的にはMediumにある公式の紹介記事が丁寧に書いてあっておすすめです。

参考 - JenkinsとConcourse CIを比較 - Concourse official articles on Medium

0. 前提

⚠️ これ以降の手順は、以下の項目を前提として進みます。ご注意ください ⚠️

  • GCPプロジェクトを持っていること
  • ローカルマシンにGoogle Cloud SDKがインストールされていて、gcloud コマンドが利用可能であること
  • gcloud コマンドでログインしていて、GCPプロジェクトを設定していること
  • ローカルマシンにkubectlがインストールされていて、kubectl コマンドが利用可能であること

1. NAT Gateway付きのGKEクラスタを作る

GKEクラスタからのリクエストの送信元IPを固定するには、NAT Gatewayが必要です。今回、NAT Gatewayの役割を果たしてくれるのは、GCE(Google Compute Engine)のVMインスタンスです。このVMインスタンスNATインスタンスと呼びます。GKEクラスタのアウトバウンドのトラフィックは、全てNATインスタンスを経由するようにします。これによって、GKEクラスタからのリクエストの送信元IPは、NATインスタンスのものになります。プライベートサービスのファイヤウォールには、NATインスタンスのIPを許可する設定を追加してあげれば、GKEクラスタからの接続を許可することができます。

本章では、リソースのデプロイにCloud Deployment Managerを利用します。各リソース定義のフォーマットは、yamlをベースとしてjinja2で書かれています。サポートされているリソース定義については、Cloud Deployment Managerドキュメントのサポートされるリソースタイプから全て辿ることができます。

1.1. ネットワークリソースの定義

ネットワークリソースを定義します。必要なのは、以下の3つです。

構成のベースとなるVPCネットワークは、構成全体を囲むいわゆるグループのようなものです。networkというリソースで表現されます。GKEクラスタのサブネットと、NATインスタンスのサブネットは、ネットワークに属するグループです。subnetworkというリソースで表現されます。

resources:
######## Network ############
- name: devops-nat-network
  type: compute.v1.network
  properties: 
    autoCreateSubnetworks: false
######### Subnets ##########
######### For Cluster #########
- name: devops-subnet 
  type: compute.v1.subnetwork
  properties:
    network: $(ref.devops-nat-network.selfLink)
    ipCidrRange: 172.16.0.0/12
    region: asia-northeast1
########## For NAT Instance ##########
- name: devops-nat-subnet
  type: compute.v1.subnetwork
  properties: 
    network: $(ref.devops-nat-network.selfLink)
    ipCidrRange: 10.1.1.0/24
    region: asia-northeast1

GCPでは、ネットワークはCIDRとリージョンを持たず、サブネットがCIDRとリージョンを持っています。同じネットワークに属するサブネット同士は、CIDRの範囲が被ってはいけないので注意してください。この辺りは少し混乱するかもしれませんが、一旦はグルーピングがしたいんだな、くらいの理解で大丈夫です。さらに詳しくはVirtual Private Cloud(VPC)ネットワークの概要を読んでください。AWSと比較して違いが知りたいという方は【AWSしかやったことない人向け】AWSとGCPのネットワークの違いを理解してみようが非常に丁寧でおすすめです。

この設定で、以下のように枠だけの状態ができました。

枠だけの状態
枠だけの状態

各リソースのnameは適当につけました。今回はConcourseのための構成なので、全てにdevopsというprefixをつけています。後からどんな用途のリソースかわかればいいと思いますので、状況に応じて好きなものをつけてください。

1.2. NATインスタンスとGKEクラスタの定義

########## NAT Instance ##########
- name: devops-nat-vm
  type: compute.v1.instance 
  properties:
    zone: asia-northeast1-a
    canIpForward: true
    tags:
      items:
      - nat-to-internet
    machineType: https://www.googleapis.com/compute/v1/projects/{{ env["project"] }}/zones/asia-northeast1-a/machineTypes/f1-micro
    disks:
      - deviceName: boot
        type: PERSISTENT
        boot: true
        autoDelete: true
        initializeParams:
          sourceImage: https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-9-stretch-v20181113
    networkInterfaces:
    - network: projects/{{ env["project"] }}/global/networks/devops-nat-network
      subnetwork: $(ref.devops-nat-subnet.selfLink)
      accessConfigs:
      - name: External NAT
        type: ONE_TO_ONE_NAT
        ##asia-northeast1リージョンに外部IPアドレスを予約してから入れる
        natIP: 
    metadata:
      items:
      - key: startup-script
        value: |
          #!/bin/sh
          # --
          # ---------------------------
          # Install TCP DUMP
          # Start nat; start dump
          # ---------------------------
          apt-get update
          apt-get install -y tcpdump
          apt-get install -y tcpick 
          iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
          nohup tcpdump -e -l -i eth0 -w /tmp/nat.pcap &
          nohup tcpdump -e -l -i eth0 > /tmp/nat.txt &
          echo 1 | tee /proc/sys/net/ipv4/ip_forward
########## FIREWALL RULES FOR NAT Instance ##########
## アウトバウンドトラフィック
- name: devops-nat-vm-firewall 
  type: compute.v1.firewall
  properties:
    allowed:
    - IPProtocol : tcp
    - IPProtocol : udp
    - IPProtocol : icmp
    - IPProtocol : esp
    - IPProtocol : ah
    - IPProtocol : sctp
    sourceTags: 
    - route-through-nat
    network: $(ref.devops-nat-network.selfLink)
## インバウンドトラフィック
- name: devops-nat-vm-ssh
  type: compute.v1.firewall
  properties: 
    allowed:
    - IPProtocol : tcp
      ports: [22]
    sourceRanges: 
    ## デバッグのため、特定のネットワーク内からのみNATへのSSHを許可
    - YYY.YYY.YYY.YYY/ZZ
    network: $(ref.devops-nat-network.selfLink)
########## GKE CLUSTER CREATION ##########
- name: devops
  type: container.v1.cluster
  metadata: 
   dependsOn:
   - devops-nat-network 
   - devops-subnet
  properties: 
    cluster: 
      name: devops 
      network: devops-nat-network
      subnetwork: devops-subnet
      nodePools:
      - name: default-pool
        initialNodeCount: 1
        config:
          tags:
          - route-through-nat
          # マシンタイプについてはhttps://cloud.google.com/compute/docs/machine-types?hl=ja
          machineType: n1-highmem-2 
          imageType: ubuntu
        autoscaling:
          enabled: true
          minNodeCount: 1
          maxNodeCount: 3
    zone: asia-northeast1-a

この設定で、以下のように枠に箱が入った状態ができました。

枠に箱が入った状態
枠に箱が入った状態

1.3. ネットワークルーティングの定義

########## GKE MASTER ROUTE ##########
- name: devops-master-route
  type: compute.v1.route
  properties:
    destRange: $(ref.devops.endpoint)
    network: $(ref.devops-nat-network.selfLink)
    nextHopGateway: projects/{{ env["project"] }}/global/gateways/default-internet-gateway
    priority: 100
    tags:
    - route-through-nat

########## NAT ROUTE ##########
- name: devops-route-through-nat
  metadata: 
    dependsOn:
    - devops  
    - devops-nat-network
  type: compute.v1.route
  properties: 
    network: $(ref.devops-nat-network.selfLink)
    destRange: 0.0.0.0/0
    description: "route all other traffic through nat"
    nextHopInstance: $(ref.devops-nat-vm.selfLink)
    tags:
    - route-through-nat
    priority: 800

この設定で、以下のように箱の間に道を通すことができました。

箱の間に道が通った状態
箱の間に道が通った状態

1.4. 静的IPアドレスを取得する

静的なIPアドレスを取得します。GCEのVMにデフォルトで割り振られるIPアドレスは動的なもので、インスタンスを再起動したりすると切り替わってしまいます。NATインスタンスの場合、これではあまり嬉しくありません。

静的なIPアドレスを取得する詳しい方法は公式のドキュメントを参照してください。アドレスを取得する際には、先ほど定義したGKEクラスタと同じリージョンを選択します。静的なIPアドレスが取得できたら、1.2 で空欄にしていた、NATインスタンス設定のnatIPに追記します。

取得したIPを許可するよう、プライベートのサービスのファイヤウォールに設定します。これで、クラスタからプライベートのサービスに繋がるようになりました。

プライベートサービスに繋がる状態
プライベートサービスに繋がる状態

1.5. 設定をデプロイする

ここまでで、基本的な構成の設定が完了しました。あとは、これをデプロイするだけです。デプロイには、gcloud コマンドを使います。cluster-with-nat-route.yamlというファイルに上記の設定がひとまとめにされていて、このデプロイにdevops-with-natという名前をつける場合、以下のようなコマンドになります。

$ gcloud deployment-manager deployments create devops-with-nat --config cluster-with-nat-route.yaml

処理には少し時間がかかります。処理が完了したらgcloudコマンドで、ローカルマシンのkubectlをGKEクラスタに繋げます。

$ gcloud container clusters devops get-credentials

1.6. Helmをインストールする

この章の仕上げとして、GKEクラスタHelmが使えるようにセットアップします。 Helmは、Kubernetes用のパッケージマネージャです。これ以降の2章、3章では、GKEクラスタへのデプロイに、Helmを利用します。

まず、ローカルマシンにHelmクライアントをインストールします。手順はリンク先を参照してください。完了すると、helmコマンドが使えるようになります。

GKEクラスタでHelmを利用するには、適切な権限が付与されたサービスアカウントが必要です。Helmのデフォルト設定に従い、tillerという名前でサービスアカウントを作成します。

apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system

上記設定がhelm.yamlという名前の場合、GKEクラスタへの適用は次のように行います。

$ kubectl apply -f helm.yaml

サービスアカウントが作成できたら、ローカルマシンのhelmコマンドを初期化します。

$ helm init --upgrade --service-account tiller

2. Let's encryptのTLS証明書をGKEクラスタで管理する

GKEクラスタができたので、TLS証明書の準備に移ります。TLS証明書は、Let's Encryptで取得します。GKEクラスタで証明書管理を行う場合、cert-manager という超便利ツールを使います。

2.1. cert-managerをインストールする

cert-managerは、すでにhelmのパッケージが公開されており、コマンド一つでデプロイすることができます。今回は、cert-manager用に新しくnamespaceを切ります。

$ kubectl apply -f k8s/namespace.yaml
$ helm install --name cert-manager --namespace cert-manager stable/cert-manager

2.2. Let's EncryptへTLS証明書作成依頼を発行する

今回、ドメインAWSのRoute53で取得しているという想定です。その他のドメインプロバイダの場合、Issuerspec.acme.dns01の書き方が変わります。dns01でのチャレンジに使えるドメインプロバイダと設定の書き方は、cert-managerのドキュメントに書いてあります。

apiVersion: v1
kind: Secret
metadata:
  name: prod-route53-credentials-secret
  namespace: cert-manager
type: Opaque
data:
  secret-access-key: YOUR_AWS_SECRET_ACCESS_KEY(base64 encoded) 
---
apiVersion: certmanager.k8s.io/v1alpha1
kind: Issuer
metadata:
  name: letsencrypt-prod
  namespace: cert-manager
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your_email_here@example.com
    privateKeySecretRef:
      name: letsencrypt-prod
    dns01:
      providers:
        - name: prod-dns
          route53:
            region: ap-northeast-1
            accessKeyID: YOUR_AWS_ACCESS_KEY_ID
            secretAccessKeySecretRef:
              name: prod-route53-credentials-secret
              key: secret-access-key
---
apiVersion: certmanager.k8s.io/v1alpha1
kind: Certificate
metadata:
  name: mycert
  namespace: cert-manager
spec:
  secretName: mydomain-net-tls
  issuerRef:
    name: letsencrypt-prod
  commonName: '*.mydomain.net'
  acme:
    config:
      - dns01:
          provider: prod-dns
        domains:
          - '*.mydomain.net'

上記設定がcert.yamlという名前の場合、GKEクラスタへの適用は次のように行います。

$ kubectl apply -f cert.yaml

証明書の発行には少し時間がかかります。申請状況の経過は次のコマンドで確認できます。

$ kubectl describe cert mycert -n cert-manager

Failed等の表示が途中でみられるかもしれませんが焦らず待ってみてください。完了するとmydomain-net-tlsというSecretが作成されます。作成されたかどうかは、以下のコマンドで確認できます。

$ kubectl get secret -n cert-manager

2.3. namespaceを跨いでワイルドカード証明書を使い回す

ワイルドカード証明書を、クラスタ内のnamespaceを跨いで使いまわしたい、というのは非常にわかりやすい要求です。Let's EncryptのAPIコールには件数制限があるということと、namespaceごとに証明書を取る手間を考えると、誰もがそうしたいと思うでしょう。しかし、cert-managerで取得した証明書をnamespaceを跨いで使うには、少し工夫が必要です。

cert-managerにはClusterIssuerというのがあり、これを使うとクラスタ内の異なるnamespaceで証明書を使いまわせそうに見えるのですが、悲しいことにそうはいきません。このIssueにもある通り、ClusterIssuer自体は使いまわせても、取得した証明書は使いまわせないという悲しい仕様になっています。

そこで、kubedを使います。一度Issuerで取得した証明書の変更を監視しておいて、対象となるnamespaceに都度連携する方式です。

kubedは、helmでインストールすることができます。

$ helm repo add appscode https://charts.appscode.com/stable/
$ helm install appscode/kubed --name kubed --namespace kube-system \
  --set apiserver.enabled=false \
  --set config.clusterName=devops

kubedのインストールが完了したら、先ほど作成されたmydomain-net-tlsに、kubedが監視するためのアノテーションを貼ります。

$ kubectl annotate secret mydomain-net-tls -n cert-manager kubed.appscode.com/sync="app=kubed"

この章の仕上げとして、Concourseをデプロイするnamespace を作り、そこにkubedで証明書を連携します。

apiVersion: v1
kind: Namespace
metadata:
  name: concourse
  labels:
    app: kubed

kubedが連携先として認識するため、labelにapp=kubedを設定しています。kubectlで設定を反映します。

$ kubectl apply -f concourse-namespace.yaml

証明書が連携されているかどうか確認して完了です。

$ kubectl get secret mydomain-net-tls -n concourse

ここまでで、構成は以下のようになっています。だいぶできてきました。

cert-managerで証明書を管理
cert-managerで証明書を管理

3. ConcourseをGKEクラスタにデプロイする

3.1. 静的なIPアドレスを取得してドメインに紐づける

Concourse用に静的なIPアドレスを取得します。1.4.の手順と同じ方法で取得し、concourseという名前をつけます。この名前はConcourseの設定で使います。取得したIPアドレスを、AWS Route53でドメインに紐づけます。今回は、concourse.mydomain.netという名前を紐づけた想定で進めます。

3.2. ソースをfetchして設定を書く

Helmで提供されているConcourseのパッケージに、設定を追加してデプロイします。まずソースをfetchしてきます。

$ helm fetch --untar stable/concourse

新しくconcourseというディレクトリが作られます。この中にある、values.yamlがデフォルトの設定ファイルになります。コピーして持ってきましょう。

$ cp ./concourse/values.yaml ./myvalues.yaml

この中に、変更する箇所がいくつかあります。設定ファイル全体をここで見るのは長すぎるので、ピックアップして見ていきます。設定ファイルには大きく分けてconcourseという項目と、webという項目があります。concourseにはアプリケーション内の設定を書き、webにはkubernetesリソース周りの設定を書くようになっています。

concourse:
  web:
    logLevel: error
    bindPort: 8080
    # IPを紐づけたドメイン
    externalUrl: https://concourse.mydomain.net 
    tsa:
      logLevel: info
      bindPort: 2222
      peerIp: #ここはあとで追加する
web:
  service:
    type: NodePort
  ingress:
    enabled: true
    annotations:
      #取得した静的IPの名前
      kubernetes.io/ingress.global-static-ip-name: concourse
    hosts:
      #IPを紐づけたドメイン
      - concourse.mydomain.net
    tls:
      #cert-managerで取得してkubedで連携した証明書
      - secretName: mydomain-net-tls
      #IPを紐づけたドメイン
        hosts:
          - concourse.mydomain.net

3.2. デプロイして設定をアップデートする

$ helm install --name concourse --namespace concourse -f concourse.yaml ./concourse

concourseのworkerはwebのpeerIpがないとエラーを吐き続けるのですが、これはデプロイ後でないとわかりません。

$ kubectl get svc concourse-web -n concourse
NAME                   TYPE        CLUSTER-IP        EXTERNAL-IP   PORT(S)                         AGE
concourse-web          NodePort    XXX.XXX.XXX.XXX   <none>        8080:30979/TCP,2222:31674/TCP   18s

XXX.XXX.XXX.XXXを、myvalues.yamlpeerIpに設定して再度デプロイします

$ helm upgrade -f myvalues.yaml concourse ./concourse

ここまでで、以下のような状態まできました。あと一息です。

LB経由でConcourseにアクセスできる
LB経由でConcourseにアクセスできる

3.3. 動作確認

Concourse自体はすぐに立ち上がるのですが、LBが立ち上がるまで少し時間がかかります。GCPのコンソール上で状態を確認しつつ、全てのリソースが立ち上がったら、接続できるかどうか確認します。

今回、ユーザ名とパスワードは変更していないので、デフォルト設定のままです。ユーザ名:test、パスワード:testでログインしてみましょう。

webから

https://concourse.mydomain.netにアクセスして、右上にあるloginボタンからフォームを開きます。

Concourse Web ログイン
Concourse Web ログイン

flyから

Concourseでは、flyというCLIを使って設定の操作を行います。ローカルマシンにflyをインストールして、以下のコマンドでログインします。

$ fly -t myci login -c https://concourse.mydomain.net -u test -p test

4. Cloud Armorで接続元IPを制限する

最後に、Cloud Armorを使って、接続元のIPを制限します。Cloud Armorは現在Beta版として提供されているセキュリティサービスです。LBに組み込むことで、GCP上のリソースをDDoS攻撃から守ってくれる他、ファイヤウォールの設定を書くこともできます。本来、kubernetesnetwork policyを書くのが王道だと思いますが、今後設定を使いまわしたいというのと、あと単純に使ってみたかったという理由なので許してください。

設定は、GCPのコンソール上から行うことができます。メニューから、ネットワークセキュリティ > Cloud Armorを選択します。

メニューからCloud Armorを選択
メニューからCloud Armorを選択

ポリシーの作成ボタンを押すと、設定編集画面が開きます。デフォルトアクションを拒否にして、許可ルールを追加していきます。ターゲットへのポリシーの適用で、先ほど作られたLBを選択したら完了です。

Cloud Armor ポリシーの作成
Cloud Armor ポリシーの作成

実際に、許可されたアドレス外からアクセスしようとすると、403 Forbiddenが表示されるので試してみてください。

さて、ここまでで全ての構成が完成しました! 長かった...。お付き合いいただきありがとうございました。

完成
完成

まとめ

この記事では、エンタープライズの開発環境によくある状況を想定して、GKEにConcourse CIを立てる方法をご紹介しました。GCPのネットワーク設定や、cert-managerとkubedの合わせ技による証明書の管理などは、かなりややこしい部分だと思います。かなり駆け足になってしまいましたので、随時記述を補足していきたいと思います。

この記事が何かのお役に立てば幸いです。

※残課題※

以下残課題は、対応完了次第追記します。

  • 公開リポジトリへのソースコードのアップ。この記事で例示したコードをアップします。
  • この問題の対処。今はクライアント証明書に頼りきりなので、ちゃんとIP制限の設定をします。

Lighthouseのバッチ実行と結果レポートをいい感じでやるためのパッケージを作りました

nightharborというパッケージを作りました。対象ページのリストを読み込み、ひたすらlighthouseを実行して、それらの結果をレポーティングためのものです。名前はlighthouse(灯台)が稼働してそうなとこ、という安直な感じでつけました。

github.com

lighthouseって???

本題の前に、lighthouseについて軽くお話しします。ご存知の方も多いかとおもいますが、Googleが提供しているサイトパフォーマンス計測ツールです。サイトパフォーマンスをスコア化して表示してくれるだけでなく、読み込むまでにどれくらい時間がかかったのか、ページ速度を遅くしている要因が何なのか、どうすれば改善できるのか、などいろいろ丁寧に教えてくれるステキツールです。Chromeの開発者ツールにある Audits から使うことができます。ブラウザからの使い方は こちらなどをご覧いただければと思います。

本題

このlighthouseというツールですが、node.js上でも動きます。 今回作ったパッケージは、node.js上でlighthouseをバッチ実行するためのものです。

それなりの規模のWebサイトになってくると、パフォーマンスを調べたいページについて、手動でぽちぽちlighthouseを実行するのは結構な手間です。 加えて、「定期的にモニタリングしたい」とか「競合の計測もしたい」なんて要望もでてきたりしたら大変です。手動実行なんてしたくないですし、結果のファイルをどこかに手動アップロードしまくるなんてのも絶対にやりたくありません。

なので、やりたいこととしては、ざっくり以下のような感じです。

  1. 好きな場所から対象ページリストを読み込む
  2. 順番にlighthouseを実行する
  3. 好きな場所に好きな形式で出力する

1, 3を自由にカスタマイズできて、2は設定(lighthouse, puppeteer)が渡せるように作りました。図にするとこんな感じです。

f:id:yoshiyuki-kato:20180917003247p:plain

基本的な使い方

基本的な使い方については、サンプルプロジェクトをcloneして実行していただくのが一番手っ取り早いかと思います。

$ git clone https://github.com/YoshiyukiKato/nightharbor-example.git
$ cd nightharbor-example
$ npm install
$ npm start
https://google.com
    first-contentful-paint: 0.98
    first-meaningful-paint: 0.98
    speed-index: 0.99
    screenshot-thumbnails: null
    final-screenshot: null
    estimated-input-latency: 0.82
    time-to-first-byte: 1
    first-cpu-idle: 0.87
    interactive: 0.88
    user-timings: null
    critical-request-chains: null
    redirects: 1
    mainthread-work-breakdown: 0.75
    bootup-time: 0.97
    uses-rel-preload: 1
    uses-rel-preconnect: 0.74
    font-display: 1
    network-requests: null
    metrics: null
    uses-long-cache-ttl: 1
    total-byte-weight: 1
    offscreen-images: 1
    render-blocking-resources: 1
    unminified-css: 1
    unminified-javascript: 1
    unused-css-rules: 1
    uses-webp-images: 1
    uses-optimized-images: 1
    uses-text-compression: 1
    uses-responsive-images: 1
    efficient-animated-content: 1
    dom-size: 1

こんな感じの実行結果がコンソールに出力されたでしょうか。 nhb.config.jsには、このプロジェクトの全ての設定が書いてあります。

const {SimpleLoader} = require("nightharbor/loader");
const {SimpleReporter} = require("nightharbor/reporter");

module.exports = {
  loaders: [
    new SimpleLoader([
      { url: "https://google.com" }
    ])
  ],
  reporters: [
    new SimpleReporter()
  ],
  
  //following params are optional
  //default params are shown as comment 
  /*
  chromeNum: 1,
  puppeteerConfig: {
    headless: true
  },
  lighthouseConfig: {
    extends: 'lighthouse:default',
    settings: {
      onlyCategories: ['performance'],
    }
  }
  */
}

nightharborでは、CLIからの実行とプログラム上での実行の二つを用意していますが、どちらもこのconfigを渡すだけでOKです。 CLIの場合はconfigファイルへのpathを

$ npm i -g nightharbor
$ nhb --config ./path/to/config.js

プログラムからの場合は直接configオブジェクトをexecメソッドに渡します。

import nhb from "nightharbor";
import config from "./path/to/config";

nhb.exec(config)
  .then(() => console.log("done"));
  .catch(console.error);

ここからは、nhb.config.jsの内容について説明をしていきます。

loaders

loadersは、lighthouse実行対象リストを読み込むLoaderの配列です。SimpleLoaderは、組み込みのもっとも簡単なLoaderで、生の配列でlighthouseの実行対象を指定するためのものです。Loaderが扱う実行対象の情報には、urlというパラメータが含まれている必要があります。

{ "url": "https://google.com" }

いまのところ、組み込みのSimpleLoaderに加えて、僕自身が使うために作ったLoaderは以下の2つです。

loadersには、Loaderのインタフェースを実装したクラスのインスタンスであれば、含めることができます。以下のような感じで独自のLoaderを作ることもできます。

class CustomLoader {
  /**
   * @return {Promise<{ url: string, [key: string]: any }[]>}
   */
  load(){
    //some asynchronous fetch tasks such as read file and api request.
    return Promise.resolve([
      { url: "https://google.com" }
    ]);
  }
}

load メソッドがあればいいのでサクッと作れます。 なお、ここで読み込む対象ページの情報は、最終的にlighthouseの実行結果と結合されReporterに渡されます。なので、url以外にもレポートに含めたい情報を含めておくと便利かもしれません。

reporters

reportersは、lighthouseの実行結果(audits)を任意の形で出力するReporterの配列です。SimpleReporterは、組み込みのもっとも簡単なReporterで、コンソールにlighthouseの結果を出力します。

いまのところ、組み込みのSimpleReporterに加えて、僕自身が使うために作ったReporterは以下の三つです。

reportersには、Reporterのインタフェースを実装したクラスのインスタンスであれば、含めることができます。独自のReporterを作ることもできます。

class CustomReporter{
  /**
   * will be called when a lighthouse execution completed
   * @param {any} result
   * @return {void}
   */
  write(result){
    //do something
  }

  /**
   * will be called after all executions
   * @return {Promise}
   */
  close(){
    //do something
  }
}

Reporterには、一回のlighthouse実行後に結果が渡されるwriteメソッドと、全ての対象ページへのlighthouse実行が終了した後に呼ばれるcloseメソッドを実装します。なお、現状writeメソッドに渡ってくる結果情報は、データサイズを抑えるため、各audit(例えば、speed-indexなど)ごとscoreのみにしています(auditのインタフェースはこちらを見てください)。detailsなど他の情報も取りたいという要望ありましたらIssueください

chromeNum

puppeteerで起動するchromeの数です。対象ページが多い場合、一つのchromeで処理するのでは流石に時間がかかるので、何個も立ち上げて同時に処理を走らせることができます。ただし、数字を大きく設定する場合、マシンスペックを十分に確保した上で行う必要があります。

puppeteerConfig

puppeteerの設定です。詳しくはこちらをご覧ください。

lighthouseConfig

lighthouseの設定です。詳しくはこちらをご覧ください。

実際の使い方の例

僕自身は、S3から対象リストのCSVファイルを読み込ませるようにして、CloudWatch + AWS Batchで定期実行し、結果をS3とBigQueryに出力しています。 nightharborのアプリケーションはdocker imageにして、AWS ECRに上げています。

f:id:yoshiyuki-kato:20180917025457p:plain

アプリケーションのimageはビルド後のサイズが気になるのでnodeのalpineベースで作ったのですが、lighthouseの公式docに従って進めても動かないという罠があって結構大変でした。AWS上での設定や実際に定期計測してみた話はまた別途記事にまとめようかと思いますが、とりあえずalpineベースのサンプルプロジェクトを作ったので、よかったらご活用ください。

github.com

まとめ

lighthouseをバッチ実行していい感じでレポーティングするためのパッケージ、nightharborをご紹介しました。 何かのお役に立てば幸いです。IssueもPRもお待ちしております(日本語でも英語でも大丈夫です)。

ログ収集基盤sentryのオンプレ版をローカルに立てて遊ぶ

アプリケーションログ収集基盤のsentryを触ってみたらすごい便利そうだったので、仕事でも使いたいなーと思ったのですが、ホスティング版をEnterprise向けに大々的に使おうとすると結構お金がかかりそうなので、まずは小さく試せるオンプレ版で使用感をみてみようという試み。 dockerベースでサクッと入れられるのでとても便利です。

github.com

手順は↑のREADMEに書いてある通りですが、一応。

0. クローンしてくる

$ git clone https://github.com/getsentry/onpremise.git
$ cd onpremise

1. ディレクトリつくる

$ mkdir -p data/{sentry,postgres}

2. シークレットキーを生成してdocker-compose.ymlに貼り付ける

$ docker-compose run --rm web config generate-secret-key

一瞬どこにキーが出力されてるのかわかりづらいですが、一番最後、WARNINGの後に出力されてるのがキーです。

...
WARNING: Image for service web was built because it did not already exist. To rebuild this image you must use `docker-compose build` or `docker-compose up --build`.
[このへんにキーが出力されてる]

こいつをコピーして、docker-compose.ymlに貼り付けます

...
version: '2'
services:
  base:
    restart: unless-stopped
    build: .
    environment:
      # Run `docker-compose run web config generate-secret-key`
      # to get the SENTRY_SECRET_KEY value.
      SENTRY_SECRET_KEY: [ここにキーを貼り付ける]
      SENTRY_MEMCACHED_HOST: memcached
...

3. データベースを構築する

$ docker-compose run --rm web upgrade

このときに、初期アカウントも作ります。ダーっとログが出力されたあと、プロンプトが起動するので順番に答えていきます。

Would you like to create a user account now? [Y/n]: Y
Email: [自分のメアドを入力]
Password: [好きなパスワードを入力]
Repeat for confirmation: [パスワード確認]
Should this user be a superuser? [y/N]: Y

最後に「superuserにする?」って聞かれるので、Yにしときました。adminはいた方がいいよね。

4. アプリケーションを起動する

$ docker-compose up -d

5. アプリケーションにアクセスする

http://localhost:9000でアクセスできるようになってます。

f:id:yoshiyuki-kato:20180203154141p:plain

さっき設定したアカウントのメアドとパスワードでログインします。

f:id:yoshiyuki-kato:20180203155218p:plain

  • Root URL: sentryサーバにアクセスするためのURL。ローカルで試すだけなのでhttp://localhost:9000にしました。外向きに公開するならば、そのURLを入力します。
  • Admin Email: sentryサーバの管理者アドレス。自分のメールアドレスを入れました。
  • Allow Registration: 誰でもアカウント作れるようにするかどうか。今回は自分だけですしデフォルトのままオフで。
  • Anonymize Beacon: sentryの開発元に送られるデータを匿名化するかどうか。管理者アドレスとかその辺の情報が全部除外されるようです。ただし、セキュリティアップデートとかの連絡はできなくなっちゃうよ、とのこと。実サービス向けでの運用始めたらオフにした方がいいのかもしれないですが、今回はお試しなのでデフォのままオンで。

もろもろ設定してcontinueを押すと、ダッシュボードに遷移します。

f:id:yoshiyuki-kato:20180203160603p:plain

超簡単でした。ここまで10分かかってない... せっかくなのでこの土日で遊び倒してみようと思います。

Serverless FrameworkとExpressでお手軽にAPIサーバを作ろう

この記事は、Recruit Engineers Advent Calendar 20176日目の記事です。

こんにちは。最近マルチロールな働き方をしていて自分が何者なのかわからなくなってきた加藤です。現在リクルートジョブズというところでエンジニアをしています。

日頃、社内用のちょっとしたツールとか、試験的なAPIとかを作るのにAWS LambdaやDynamoDBを使ってサーバレスな開発をしているのですが、この記事ではその際によくやるServerless FrameworkとExpressを活用した開発手法の紹介をしたいと思います。


Expressといえば、Node.jsのWebアプリケーションフレームワークとして有名ですが、最近ではaws-serverless-expressというのを使うと、AWS LambdaでもExpressを使って開発ができます。サラッと書きましたが、正直こいつが出てきた時僕は100万starくらいしたい気持ちでした。そのくらいlambdaを便利にしてくれたと思っています。

github.com

一方、Serverless Frameworkというと「?」となる方も多いかと思います。ざっくり説明すると、API GatewayAWS Lambdaなどのフルマネージドなサービスを組み合わせる、 サーバレス・アーキテクチャ による開発を簡単にしてくれるツールです。AWSには標準でCloud Formationというオーケストレーション機能がありますが、イメージとしては、Cloud FormationにわかりやすいCUIと拡張性のある開発環境がくっついたような感じです。プラグインを使うとローカルにAPI GatewayやLambda、S3、DynamoDBなどの開発環境を立ててることができるようになっています。

serverless.com

これら二つを組み合わせて使うと、簡単にサーバレスなAPIの開発ができるというお話です。

つくるもの

要件

よくありそうなTODOアプリのAPIをつくります。

機能 メソッド パス
一覧 GET /items
追加 POST /item
取得 GET /item/:id
更新 PUT /item/:id
削除 DELETE /item/:id

構成

API Gateway + Lambda + DynamoDBというベーシックな構成です。

ソースコード

github.com

前提条件

  • AWS CLIの設定が完了していること
  • node.jsがインストールされていてnpmコマンドが使えること
  • https://serverless.com/に登録していること

手順

1. 準備

serverlessのセットアップ

まず、npmでserverlessをグローバルに入れます。

$ npm install -g serverless

これで、serverless,slss,slsコマンドが使えるようになります。

$ sls

サブコマンドを指定しないで呼ぶと、ヘルプメニューとして使えるコマンド一覧が表示されます。この記事でもこの先何回もslsコマンドを使っていくことになります。 このタイミングで、Serverless.comにログインしておきます。

$ sls login

ブラウザが開くはずなので、よしなにログイン処理をすすめてください。

プロジェクトの初期化

$ mkdir sls-todo && cd sls-todo
$ npm init

必要に応じて、.gitignoreもよしなに書いてください。 さて、この手順の仕上げとして、今回必要になるファイルとディレクトリをあらかじめ設置しておきます。以下のような構造にしました。

.
├── package.json
├── seeds
│   └── todo-items.json
├── serverless.yml
└── src
    ├── app.js
    └── routes
        └── index.js

ここで、serverless.ymlというファイルを作成しました。これは、Serverless Frameworkの設定ファイルです。Serverless Frameworkはこのファイルに書かれている内容を実行していきますので、アプリケーションの本体といっても過言ではないかもしれません。3. serverless.ymlを書くに、完成形のserverless.ymlを載せてあります。

2. 使うもののインストール

Lambdaで使うやつ

  • aws
  • aws-serverless-express
  • express
  • body-parser
  • cors
  • compression
$ npm install --save express aws-sdk aws-serverless-express body-parser cors compression

Serverlessプラグイン

  • serverless
  • serverless-offline
  • serverless-dynamodb-local
  • serverless-plugin-include-dependencies
$ npm install --save-dev serverless serverless-offline serverless-dynamodb-local serverless-plugin-include-dependencies

3. serverless.ymlを書く

serverless.ymlを書くのは、最初はちょっと骨が折れるかもしれません。ボトムアップに作っていくのは結構きついものがあるので、ほぼ全部入りのUser GuideのServerless.ymlから、自分が使いたい部分を抜き出してきて使うのがオススメです。各リソースのイベントについて細かい設定がしたい場合、Eventsの各ページが参考になります。

今回は以下のように設定を書きました。各項目の内容はコメントに書いてある通りです。

service: sls-todos

# 実行環境に関する設定
provider:
  name: aws
  runtime: nodejs6.10
  region: ap-northeast-1

# 利用するプラグインについて
plugins:
  - serverless-offline
  - serverless-dynamodb-local
  - serverless-plugin-include-dependencies

# デプロイ時のパッケージの内容について
package:
  include:
    - src/**

# lambdaを実行するトリガの設定。今回はAPI Gatewayのルーティング設定
functions:
  sls-todo:
    role: ${DYNAMO_ACCESS_ROLE} # dynamoにアクセスできるロール
    description: TODOリストAPIのルーティング
    handler: src/routes/index.handler
    events:
      - http:
          path: /todos
          method: get
          cors: true
      - http:
          path: /todo
          method: post
          cors: true
      - http:
          path: /todo/{id}
          method: get
          cors: true
          request:
            parameters:
              paths:
                id: true
      - http:
          path: /todo/{id}
          method: put
          cors: true
          request:
            parameters:
              paths:
                id: true
      - http:
          path: /todo/{id}
          method: delete
          cors: true
          request:
            parameters:
              paths:
                id: true

# ローカルのdynamodbの起動設定
custom:
  dynamodb:
    start:
      port: 8888
      inMemory: true
      migrate: true
      seed: true
    seed:
      test:
        sources:
          - table: sls-todos
            sources: [./seeds/todo-items.json]

# CloudFormationの形式でのリソース定義
resources:
  Resources:
    slsTodos:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: sls-todos
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

4. DynamoDBのローカルへのインストールと起動

serverless.ymlpluginsを書いたことで、プラグインが提供するコマンドが使えるようになっています。

slsと打つと、以下のようなコマンドが一覧に追加されて表示されるはずです。

dynamodb ...................... undefined
dynamodb migrate .............. Creates local DynamoDB tables from the current Serverless configuration
dynamodb seed ................. Seeds local DynamoDB tables with data
dynamodb start ................ Starts local DynamoDB
dynamodb remove ............... Removes local DynamoDB
dynamodb install .............. Installs local DynamoDB
$ sls dynamodb install

これで、ローカルにDynamoDBがインストールされます。 DBの準備ができたので、今度はseedファイルの準備をします。

seeds/todo-items.json

[
  {
    "id" : "test",
    "todo" : "Hello World!"
  }
]

seedファイルの準備ができたら、dynamodbを起動します。

$ sls dynamodb start

先ほど、serverless.ymlのdynamodbパートでの起動時オプションにseed: truemigrate: trueをつけたので、起動と同時にseedファイルの内容がDBに反映されます。

5. ExpressでAPIを書く

使っているミドルウェアは少し違いますが、基本的には普通にExpressを利用するときと同じ書き方ができます。ここでは、ルーティングに関わらない共通の処理をapp.jsにまとめています。

src/app.js

const express = require('express');
const bodyParser = require('body-parser');
const cors = require('cors');
const compression = require('compression');
const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware');
const app = express();

app.set('view engine', 'pug');
app.use(compression());
app.use(cors());
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(awsServerlessExpressMiddleware.eventContext());

module.exports = app;

一つ完全に違うのはapp.jsroutes以下のルーターを読むのではなく、ルーターapp.jsを読み込んでいるという点です。これは、app.jsを核にして一つのサービスが立つのではなく、各ルーターが独立したサービスとして立ちapp.jsという共通設定を利用する形になっているためです。

src/routes/index.js

const express = require("express");
const awsServerlessExpress = require('aws-serverless-express');
const AWS = require("aws-sdk");
const app = require("../app");
const router = express.Router();

//ローカルかリモートかで、dynamodbのconfigを切り替える
const dynamoConfig = process.env.NODE_ENV === "development" ? {
  region: "localhost",
  endpoint: "http://localhost:8888", 
} : {};
const ddc = new AWS.DynamoDB.DocumentClient(dynamoConfig);

//一覧
router.get('/todos', (req, res, next) => {
  ddc.scan({
    TableName: "sls-todos"
  }).promise()
  .then((result) => res.send(result))
  .catch(next);
});

//追加
router.post('/todo', (req, res, next) => {
  ddc.put({
    TableName: "sls-todos", 
    Item: req.body.Item
  }).promise()
  .then((result) => res.send(result))
  .catch(next);
});

//取得
router.get('/todo/:id', (req, res, next) => {
  ddc.get({
    TableName: "sls-todos",
    Key:{
      id: req.params.id,
    }
  }).promise()
  .then((result) => res.send(result))
  .catch(next);
});

//更新
router.put('/todo/:id', (req, res, next) => {
  req.body.Item.id = req.params.id;
  ddc.get({
    TableName: "sls-todos",
    Item: req.body.Item
  }).promise()
  .then((result) => res.send({ message: "update success!" }))
  .catch(next);
});

//削除
router.delete('/todo/:id', (req, res, next) => {
  ddc.delete({
    TableName: "sls-todos",
    Key:{
      id: req.params.id,
    }
  }).promise()
  .then((result) => res.send({ message: "delete success!" }))
  .catch(next);
});

router.use((err, req, res, next) => {
  res.status(500).send(err);
});

app.use("/", router);

const server = awsServerlessExpress.createServer(app);
exports.handler = (event, context) => awsServerlessExpress.proxy(server, event, context);

6. ローカルで動かす

ここまでで、アプリケーションの設定とコードの準備が全て整いました。offlineコマンドで、ローカルにサーバを立ち上げてみましょう。

$ sls offline
Serverless: Starting Offline: dev/ap-northeast-1.

Serverless: Routes for sls-todo:
Serverless: GET /todos
Serverless: POST /todo
Serverless: GET /todo/{id}
Serverless: PUT /todo/{id}
Serverless: DELETE /todo/{id}

Serverless: Offline listening on http://localhost:3000

localhost:3000でサーバが立ち上がります。serverless.ymlに設定したルーティングに対して、リクエストを投げてみましょう。curlやブラウザでやるのもいいですが、個人的にはpostmanがオススメです。

7. デプロイする

$ sls deploy

これだけです。ローカルの時と同じく、デプロイされた先のAPIを叩いてみましょう。

$ sls remove

挙動の確認ができて、必要がなくなったら畳んでしまいましょう。リモートにデプロイしたまま放置していると、知らないうちにお金がかかっていることがあるので...。

まとめ

  • Serverless frameworkを使うと嬉しいことがたくさん
    • フルマネージドなサービスを使ってサクッとAPIを作れる
    • APIをつくるときにExpressを使うとコードも書きやすくてなおよい
    • プラグインを使えばローカルに検証環境を作るのも簡単
    • デプロイするのも、逆に落とすのもコマンド一つでOK
    • ただしserverless.ymlを書くのだけはがんばってください

それではみなさま、よきサーバレスライフを!!