GCPにOpenVPNサーバーを構築したのでコードを解説してみる

Google Cloud Platform（GCP）上にOpenVPNサーバーを構築してみました。個人利用目的で作ったのですが、作り込んでいった所、結構面白い内容になったのでコードを解説しようと思います。第一章は構築編です。コードはどうでもいいからOpenVPNサーバーだけ使いたいという人はどうぞ。第二章はコードの解説です。メインの内容は「GCP上に構築」なのでDDClientやOpenVPNの設定の説明は少なめです。

ソースコードは https://github.com/ko1nksm/vpn-server

**解説に含まれる内容**

  * デプロイメントマネージャー
    * 構成ファイル、jinja2テンプレート、起動スクリプト
    * GCEインスタンス、ネットワーク、ファイアウォール
    * インスタンスグループ、インスタンステンプレート、ヘルスチェック
  * 起動スクリプト
    * Stackdriver Logging（重要度表示対応）
    * Stackdriver Monitoring（collectd exec pluginによるカスタム指標）
    * Stackdriver Error Reporting
    * rsyslogd
    * ログローテート
    * インスタンスメタデータの取得＋変更監視
    * Google Cloud Storageバケットからのデータ取得 (gsutil / gcsfuse)
    * DDclinet
    * OpenVPN
    * BusyBox httpdによる簡易HTTPサーバー
    * systemdユニットファイルの作成と上書き
    * Uptime Check

## 方針

GCPにはウェブインターフェースが搭載された「OpenVPN Access Server」がMarketplaceにあるので使うだけならそれが簡単なのだと思いますが、無料だと使用できるユーザー数が少ないので一般的（？）なオープンソース版のOpenVPNサーバーを使用します。

個人利用ということで性能よりもコスト重視で（他にGCEインスタンス等がなければ）[無料枠](https://cloud.google.com/free/docs/gcp-free-tier?hl=ja#always-free)内に収まるようにしています。ただしネットワーク使用量は下り1GB/月を超えると費用がかかるので注意して下さい。リモートでのメンテナンスやサポートを用途として想定してます。（データが多い人は転送量無制限のVPSを使用したほうが安くなるでしょう。）

静的外部IPアドレスは未使用時（GCEインスタンス停止時）に予約していると[課金される](https://cloud.google.com/compute/all-pricing#ipaddress)ので動的IPアドレスを使用します。GCEインスタンスを停止すると外部IPアドレスが変わってしまうので（Google Cloud Platformのサービスではない）DDNSを併用します。更新にはDDclientを使用します。私は個人所有ドメインのレジストラがDDNSを提供してるのでそれを使用していますが、無料で提供している所がいくつもあるのでお金をかけたくない人はそれらを使用すると良いと思います。

## 構成

* Google Cloud Platform
  * Google Cloud Engine
    * GCEインスタンス （f1-micro、us-central1リージョン）
    * ネットワーク （プレミアム階層）※[スタンダード階層の方が安いですが無料枠がありません](https://cloud.google.com/network-tiers/pricing?hl=ja)
  * Google Cloud Storage （設定ファイル、証明書等の保存場所）
    * us-central1リージョン
* DDNSサーバー
  * 個人所有ドメインのDDNSサーバー

**VPNネットワーク**

```
+-------- OpenVPN Server on GCP (ddns.example.com, 172.16.0.1) --------+
| VPN Network: 172.16.0.0/24                                           |
|                                                                      |
|    +-- network1 (172.16.0.A) --+    +-- network2 (172.16.0.B) --+    |
|    | 192.168.1.0/24            |    | 192.168.2.0/24            |    |
|    |                           |    |                           |    |
|    +---------------------------+    +---------------------------+    |
|                                                                      |
|    macbook (172.16.0.C)    iphone (172.16.0.D)                       |
|                                                                      |
+----------------------------------------------------------------------+
```

自宅LAN（network1）と実家LAN（network2）のルータにはOpenVPNクライアント機能が搭載されていて、OpenVPNサーバー（ddns.example.com）を介してVPNネットワークを構築しています。また外からiPhoneやテザリング経由でMacBookをVPNネットワークに参加できるようにしています。（個人利用だとだいたいみんなこんな使い方になるんじゃないかな？）

※これらの構成は定義ファイルや設定ファイルで変更可能です。

## 第一章 構築編

細かい構築手順をごちゃごちゃ書くのは嫌なので、主にデプロイメントマネージャーを使って構築しています。コマンド一つで関連するリソースをまとめて作成したり削除できたりします。

### 0. 準備

GCP一般的な話なので特に説明はしません。作業は主にgcloudコマンドを使用してCLIで行いますのでGoogle Cloud SDKが必要です。

* Google Cloud Platformアカウント取得
* [Google Cloud Console](https://console.cloud.google.com/?hl=ja)からVPNサーバー用のプロジェクトを作成（必須ではないですが、他のリソースと混ざるとややこしくなるので推奨します。）
* [Google Cloud SDK](https://cloud.google.com/sdk/docs/?hl=ja)のインストール
  * `gcloud auth login`でGoogle Cloud Platformにログイン
  * `gcloud config`でデフォルトのプロジェクトとゾーンを設定（必須ではないですが以降の説明では設定されてるのを前提にコマンド引数の`--project`と`--zone`を省略しています。）

### 1. Google Cloud Storageバケット作成

DDclientとOpenVPNの設定ファイルの保存場所としてGoogle Cloud Storage (GCS)にバケットを作成します。設定ファイル等をGCEインスタンスに直接置かずGCEインスタンス起動時にGCSから取得しているため、VPNサーバーが不要になったときに気軽に削除でき、必要になったときに簡単に再作成できます。設定ファイルのサイズは小さく[GCSの使用料金](https://cloud.google.com/storage/pricing?hl=ja)も安いため、不要なときにVPNサーバーを削除することでコスト（無料枠）を節約できます。（そのうちスケジュールで自動的に削除と再作成する機能をつけようと思ってます。）

GCSバケット作成コマンド

```
gsutil mb -c standard -l us-central1 gs://バケット名
```

バケット名は自由につけられますが、一般公開されてるので[Cloud Storage のベスト プラクティス](https://cloud.google.com/storage/docs/best-practices?hl=ja)などを参考に個人を特定できる情報等を含めないようにしてください。また、バケットにはDDNS更新のパスワード、OpenVPNサーバーの秘密鍵、TLS認証鍵等の秘密情報を置くのでバケットにアクセスできるユーザーに注意し、公開アクセスを「一般公開」に変更したりしないように注意して下さい。

GCSバケットのフォルダ構成とファイル（一例）は以下のとおりです。これらのファイルはGCEインスタンスの`/etc/ddclient/`と`/etc/openvpn/`以下にマッピングされます。設定ファイルは後述する編集モード（`editMode: true`）で起動しVPNサーバー`gcloud compute ssh`コマンドでGCEインスタンスに接続して作成してもいいですし、ブラウザまたは`gsutil`コマンドでGCSバケットにアップロードしても良いです。ただしOpenVPNの証明書や秘密鍵はセキュリティ上の理由からVPNサーバー上で作成するのではなく、別の（安全な）マシンで作成してアップロードすることを推奨します。（実はOpenVPNの証明書をローカルマシンで簡単に安全に管理するツールを作っていたりします。まだ完成してないですができたら公開する予定です。→ ベータ版ですが[公開](https://github.com/ko1nksm/easy-rsa-shell)しました。）

```
gs://バケット名
├── ddclient/
│     └── ddclient.conf
└── openvpn/
       ├── server.conf, ca.crt, dh.pem, openvpn.crt, openvpn.key, ta.key
       └── ccd/
              └── network1, network2, macbook, iphone
```

これらのファイルの作成方法の詳細は省略しますが、`ca.crt`, `dh.pem`, `openvpn.crt`, `openvpn.key`はEasyRSAで作成するのが簡単です。EasyRSAは2系と3系があって生成ファイルは基本的に同じなのですがコマンド体系とディレクトリ構造がわかりやすく整理されてるので3系をおすすめします。（[参考](https://wiki.archlinux.jp/index.php/Easy-RSA)）ちなみに今回作成したOpenVPNサーバーのDebian 9 (stretch) は2系で、Debian 10 (buster)は3系です。

`server.conf`と`ccd`ディレクトリ以下はネットワーク構成に応じて作成します。以下は設定例です。詳細は省略します。

<details>
<summary>/etc/openvpn/server/server.conf</summary><div>

```conf
# Service
verb 4
passtos

# Networking
dev tun
persist-key
persist-tun
topology subnet
keepalive 10 30
route 192.168.1.0 255.255.255.0 # network1
route 192.168.2.0 255.255.255.0 # network2

# VPN
proto udp4
server 172.16.0.0 255.255.255.0
compress lz4-v2
push "compress lz4-v2"
client-to-client
client-config-dir /etc/openvpn/server/ccd

# Cryptography
auth SHA512
cipher AES-256-GCM
ncp-ciphers AES-256-GCM
tls-server
tls-version-min 1.2
tls-auth /etc/openvpn/server/ta.key 0
ca /etc/openvpn/server/ca.crt
dh /etc/openvpn/server/dh.pem
cert /etc/openvpn/server/openvpn.crt
key /etc/openvpn/server/openvpn.key

# For OpenVPN status page
script-security 2
up /usr/local/bin/openvpn-up.sh
down /usr/local/bin/openvpn-down.sh
```
</div></details>

<details>
<summary>/etc/openvpn/server/ccd/network1</summary><div>

```conf
iroute 192.168.1.0 255.255.255.0
push "route 192.168.2.0 255.255.255.0"
```
</div></details>


<details>
<summary>/etc/openvpn/server/ccd/network2</summary><div>

```conf
iroute 192.168.2.0 255.255.255.0
push "route 192.168.1.0 255.255.255.0"
```
</div></details>

<details>
<summary>/etc/openvpn/server/ccd/macbook, /etc/openvpn/server/ccd/iphone</summary><div>

```conf
push "route 192.168.1.0 255.255.255.0"
push "route 192.168.2.0 255.255.255.0"
push "dhcp-option DNS 192.168.1.0" # network1のDNSサーバーを使用する
push "dhcp-option DOMAIN home" # network1のローカルドメイン
```
</div></details>


`ddclient.conf` は以下のような感じですが使用するDDNSサーバーによって異なるので https://github.com/ddclient/ddclient を参照したほうが良いです。なおDebian 9に含まれるバージョンは古かったので3.9.0をインストールしています。

<details>
<summary>/etc/ddclient/ddclient.conf</summary><div>

```
ssl=yes
use=web, web=checkip.dyndns.com/, web-skip='IP Address'
protocol=dyndns2
custom=yes, server=members.dyndns.org
login=<username>
password=<password>
ddns.example.com
```
</div></details>

### 2. デプロイ用ファイルの作成

#### 各ファイルについて

ヘルスチェックなしとありの2つのバージョンがあります。ヘルスチェックありはOpenVPNサーバーが正常に動作してないときにGCEインスタンスを破棄して再作成を行います。

https://github.com/ko1nksm/vpn-server からソースコードをダウンロードして下さい。使用するのは以下のファイルのみです。

* テンプレート（以下のどちらか）
  *  `vpn-server.yaml.jinja` （ヘルスチェックなし）
  *  `vpn-server-hc.yaml.jinja` （ヘルスチェックあり）
* 起動スクリプト
  *  `startup-script.sh`

以下を参考に構成ファイル `vpn-server.yaml` を作成します。

```yaml
imports:
- path: startup-script.sh
- path: vpn-server.yaml.jinja # ヘルスチェックありの場合は vpn-server-hc.yaml.jinja
  name: vpn-server.jinja

resources:
- name: vpn-server
  type: vpn-server.jinja
  properties:
    machineType: f1-micro
    zone: us-central1-f
    networkTier: PREMIUM

    configurationBucket: バケット名 # 設定ファイルを保存するバケット名
    editMode: true or false # DDclientとOpenVPNの設定ファイル変更を行うかどうか
    allowSSH: true or fasle # GCEインスタンスにSSH接続できるかどうか
    allowStatusPage: true or false # OpenVPNステータスページを表示するかどうか
    antiRobotAuth: user:password # OpenVPNステータスページのBASIC認証
    ddclient: # 使用するddclientのアーカイブのURL
    ddns-update-interval: # DDNSの更新間隔。1week など
```

#### machineType, zone, networkTier

それぞれGCEで使用するマシンタイプ、ゾーン、ネットワーク階層です。無料枠で使用可能な環境は制限があるので設定には注意して下さい。

#### configurationBucket

「1. Google Cloud Storageバケット作成」で作成したバケット名です。（頭の `gs://` は不要です。）DDclientとOpenVPNの設定ファイル及び証明書を保存します。

#### editMode

trueにするとGCSバケットを[Google Cloud Storage FUSE](https://cloud.google.com/storage/docs/gcs-fuse?hl=ja)を使用して`/etc`以下にマウントします。これによりGCEインスタンス上からGCSバケットの設定ファイルを直接変更することができます。falseの場合はGCSバケットからファイルをコピーします。trueでもGCSバケットに過剰なアクセスは発生したり遅くなったりはしないと思いますが、念の為、設定を変更するときだけtrueにし通常はfalseにするのをおすすめします。

#### allowSSH

SSH接続できるかどうかです。trueのままでも問題ないと思いますが、気になる人は必要なければfalseに変更して下さい。

#### allowStatusPage, antiRobotAuth

OpenVPNには現在接続してるクライアント情報を見ることができるステータスファイルがあります。`allowStatusPage`はこのステータスファイルをブラウザで見るための設定です。URLはVPNサーバーと同じアドレス（例 `http://ddns.example.com`）です。

アクセス時にはBASIC認証（`antiRobotAuth`）がかけてあります・・・と見せかけて、これはただのロボット（クローラー）避けです。そもそもHTTP通信なのでBASIC認証をした所で安全性には問題が残ります。（さすがにこれだけのためにHTTPS導入はやろうとは思いません。）だからといって危険なわけではなくインターネット上の公開ページ（`http://dns.example.com`)にアクセスしていると思いきや、iframeで`http://172.16.0.1`を表示しています。これはVPNネットワークのアドレスなのでステータスファイルはOpenVPNサーバーに接続している人しか見れないので安全です。かと言って公開ページをクロールされてアクセスがあっても迷惑なのでそのためのBASIC認証です。

`antiRobotAuth`は「ユーザー名:パスワード」という書式で設定しますが、パスワードはハッシュ化した文字列を使用することも可能です。ハッシュ化は以下のコマンド等で行うことができます。

```
$ openssl passwd -1 'password'
```

#### ddclient

DDclientのアーカイブファイルのURLです。Debian 9に含まれてるパッケージが古く個人的理由で最新バージョンが使用したかったのでインストールしています。また人によっては独自でパッチを当てたバージョンを使いたいことがありそうなので変更できるようにしています。デフォルトのURLは `https://github.com/ddclient/ddclient/archive/v3.9.0.tar.gz` ですが、DDclientは実行ファイルが`ddclient`の1ファイルでできてるのでそのファイルが含まれてるtar.gzファイルへのURLであればどれでも使えるはずです。

#### ddns-update-interval

GCEインスタンスは起動時に割り当てられたIPアドレスから変わることはないので、IPアドレスの変更を監視するためにDDclientをサービスとして起動しておく必要はないのですが、無料のDDNSサーバーでは定期的に更新を行わないとアカウントが削除されることがあります。そのためのDDNSの更新間隔です。未指定の場合は定期的な更新は行いません。定期更新はDDclientの機能ではなくsystemdを使用しています。設定値には`systemd-analyze timespan`で解釈可能な値が使用可能です。（例 `86400`, `24h`, `2weeks`, `1month` など）

### 3. デプロイ

#### 作成

デプロイメントマネージャーを使用するので、VPNサーバーのデプロイはコマンド一つ叩くだけです。

```
gcloud deployment-manager deployments create vpn-server --config vpn-server.yaml
```

ちなみにデプロイメントの作成が完了したからと言ってサービスが稼働してるわけではないことに注意して下さい。OSの起動処理（起動スクリプトの実行等）はこれから行われるので、GCPの状態にもよりますがサービスが稼働するまであと2分程度かかります。（合計で3～5分ぐらい？）

DDclientとOpenVPNは設定ファイルがない場合はサービスは起動しません。GCEインスタンスに接続し設定を行い、`sudo systemctl start ddclient`, `sudo systemctl start openvpn-server@server`でサービスを起動して下さい。設定ファイルが正しく作成できていればGCEインスタンスを再起動するとサービスは自動起動します。（※`systemctl enable`は行わないで下さい。実装の都合上サービスは起動スクリプトから起動します。）

#### 更新

構成を変更したい場合はupdateを実行します。削除してから作り直すよりも早いですが、すべての変更が可能なわけでなくGCEインスタンスの停止や再起動、再作成が必要になる場合があります。（ヘルスチェックありの場合はインスタンステンプレートを使用している関係でほとんど変更できないです。）

```
gcloud deployment-manager deployments update vpn-server --config vpn-server.yaml
```

#### 削除

VPNサーバーを削除する場合は以下のコマンドです。

```
gcloud deployment-manager deployments delete vpn-server
```

## 第二章 解説編

デプロイメントマネージャー（`vpn-server.yaml.jinja`, `vpn-server-hc.yaml.jinja`）、起動スクリプト（`startup-script.sh`）の順に、ソースコードの上の方から解説していきます。ソースコードを参照しつつ読んで下さい。

### 1. デプロイメントマネージャー

GCEインスタンスの作成は、慣れてない場合はブラウザから行うのが分かりやすいと思いますが、デプロイメントマネージャーを使うと複数のリソースからなる構成をまとめて管理ができるので、削除したり作り直なおしたりする作業が楽になります。なるべく早いうちに使えるようになったほうが良いと思います。具体的な書き方を調べるのに苦労しますが、だいたい以下のページから辿れると思います。サンプルを見るのが一番早いかもしれません。

- [Google Cloud Deployment Manager のドキュメント](https://cloud.google.com/deployment-manager/docs/?hl=ja)
- [サポートされるリソースタイプ](https://cloud.google.com/deployment-manager/docs/configuration/supported-resource-types?hl=ja)
- [サンプル](https://github.com/GoogleCloudPlatform/deploymentmanager-samples)

#### 各ファイルについて

今回はコードが長くなったので、構成ファイル (`vpn-server.yaml`)、テンプレート (`vpn-server.yaml.jinja`, `vpn-server-hc.yaml.jinja`)、起動スクリプト (`startup-script.sh`) の3つに分けましたが、単純なものなら1つの構成ファイルまたはテンプレートにすることも可能です。例えばテンプレートだけの場合、構成ファイルのプロパティに相当する値は`gcloud`コマンドの引数で渡すことができます。起動スクリプトは構成ファイルまたはテンプレートに埋め込まれてることが多いですが、さすがに長いので分離しました。単体のシェルスクリプトファイルになったのでテキストエディタのシンタックスハイライト機能も使えるのも便利です。

#### テンプレート

今回はjinja2を使いましたがPythonを記述することも可能です。柔軟な処理が行えるので（？）[「Python テンプレートの使用をおすすめします。」](https://cloud.google.com/deployment-manager/docs/step-by-step-guide/create-a-template?hl=ja) と書かれているのですが、コードで定義内容を生成するのはわかりづらく感じるので個人的にはjinja2の方が好みです。

なおjinja2テンプレートの拡張子は、一般的には`.jinja`が使われてると思いますが、シンタックスハイライトのために`.yaml.jinja`にしています。

##### Pythonテンプレート

Pythonテンプレートを使ってる例としては、[このようなもの](https://github.com/GoogleCloudPlatform/deploymentmanager-samples/blob/master/examples/v2/cloud_functions/python/cloud_function.py)を見つけました。Cloud FunctionsのコードをGCSから読み取るために、定義ファイルでインポートしたファイルをPythonコードでメモリ内にzip圧縮+base64し、zipファイルを生成する"シェルコマンド"を組み立てて、Cloud Buildで実行しGCSにアップロードするという無茶（？）をしています。

##### テンプレート実行環境

ちなみにPythonおよびjinja2はローカルPCで実行されてるのではなく、ファイルとして転送されGCP上で実行されるので注意して下さい。ローカルPCにインストールされてるPythonやjinjaのバージョンとは無関係にGCP上のバージョンが使用されます。（現時点では）日本語ページには[Jinja 2.8 または Python 2.7が使われている](https://cloud.google.com/deployment-manager/docs/configuration/templates/create-basic-template?hl=ja) と書かれていますが、英語ページでは[Jinja 2.8 or Python 3.x](https://cloud.google.com/deployment-manager/docs/configuration/templates/create-basic-template)となっています。Python2系は2020年1月1日にサポートが終了し、2020年4月にはGCPからPython2は完全に削除されるとのことなので注意して下さい。

#### vpn-server.yaml.jinja

##### 構成内容

以下の内容から構成されています。

- GCEインスタンス (compute.v1.instance)
  - vpn-server
- ネットワーク (compute.v1.network)
  - vpn-server-network
- ファイアウォール (compute.v1.firewall)
  - vpn-server-icmp-fw
  - vpn-server-openvpn-fw
  - vpn-server-ssh-fw (allowSSH: trueの場合のみ)
  - vpn-server-http-fw (allowStatusPage: trueの場合のみ)

##### {% set _ = dict(env, **properties) %}

毎回 `properties["machineType"]` と書くのが長ったらしいからこうしてるだけです。ついでに`env`の内容もマージして`properties`で上書きできるようにしてます。ちょっとしたハックなので別に真似しなくていいです。

##### &network $(ref.{{ _.name }}-network.selfLink)

テンプレート（及び構成ファイル）はYAMLでもあるので、YAMLのアンカー、エイリアスが使用できます。`$(ref.{{ _.name }}-network.selfLink)` が少し長い上に何度も出てきてるのでちょっとした省力化です。

##### {{ imports["startup-script.sh"] | tojson }}

外部ファイルにした起動スクリプトを埋め込んでいます。同様のことをするのにGCPのサンプルでは`tojson`フィルタではなく`indent`フィルタが使われていますが、インデントの深さに依存してしまうので`tojson`の方が良いと思います。

#### vpn-server-hc.yaml.jinja (ヘルスチェック)

内容はヘルスチェックなしと似ていますが、作成するインスタンスを直接定義する代わりに、「インスタンステンプレート」とそのテンプレートを用いてインスタンスの作成や削除を行う「インスタンスグループマネージャー」の2つを定義しています。そしてインスタンスグループマネージャーのもつインスタンス管理機能の一つとして「ヘルスチェック」を利用するという形です。

##### 構成内容

- インスタンスグループ (compute.v1.instanceGroupManager)
  - vpn-server
- インスタンステンプレート (compute.v1.instanceTemplate)
  - vpn-server-instance-template
- ヘルスチェック (compute.v1.healthChecks)
  - vpn-server-hc
- ネットワーク (compute.v1.network)
  - vpn-server-network
- ファイアウォール (compute.v1.firewall)
  - vpn-server-icmp-fw
  - vpn-server-openvpn-fw
  - vpn-server-healthcheck-fw
  - vpn-server-ssh-fw (allowSSH: trueの場合のみ)
  - vpn-server-http-fw (allowStatusPage: trueの場合のみ)

##### vpn-server-healthcheck-fw

後述しますが、ヘルスチェック用には専用のHTTPサーバーをTCPポート8080で起動しています。このHTTPサーバーにはヘルスチェックからのみアクセスできるように制限をかけています。（`sourceRanges: [ "130.211.0.0/22", "35.191.0.0/16" ]`）

### 2. 起動スクリプト（startup-script）

起動スクリプトはインスタンスを作成したときだけではなく（再）起動時にも実行されます。（こちらの[注意点](https://qiita.com/ko1nksm/items/f6707f4e08145af25985)も参考にして下さい。）そのため冪等性をもたせておく必要があります。それを実現するためにAnsibleやChefがありますが、シェルスクリプトでも少し書き方を工夫するだけで実現可能です。今回作成した起動スクリプトはそのことに注意して記述してあります。シェルスクリプトで冪等性をもたせるのは難しいと思われるかもしれませんが、構成管理で必要なのはパッケージのインストール、設定ファイルの配置、サービスの起動程度なのでさほど難しいことでもありません。

この起動スクリプトは個人利用ということもあって安定性よりメンテナンスの手間を省くという方針で作成しており、再起動時に現在の設定に応じて構成を変更したりパッケージをアップグレードしています。定期的に自動再起動するようにしてるわけではないので常に更新され続けるわけではないですが、デフォルトでunattended-upgradesがインストールされており、セキュリティパッチは自動的に適用されるので一応は安全です。

#### 冒頭部分

##### #!/bin/bash -eux

起動スクリプトはシバン行 (`#!`) を見て実行されるので[シェルスクリプト以外も使用できます](https://cloud.google.com/compute/docs/startupscript)。省略した場合にはbashが使用されますが、[shellcheck](https://github.com/koalaman/shellcheck)でbashコードと判断されるようにシバンを書いています。またエラーが有ったときにすぐに終了するように`-e`オプションと`-u`オプションを指定しています。`-x`オプションは実行するコマンドを標準エラー出力に出力するデバッグ機能ですが、起動スクリプトの実行ログとして便利なため指定しています。

##### chmod() { command chmod -v "$@"; }

コマンドのデフォルトのオプションを追加しています。同様のことは`alias`を使用してもできますが、bashではデフォルトではスクリプトで`alias`は使用できず、また`alias`はインタラクティブシェルで使用するための機能だと思っているのでシェル関数を使用しています。

このように外部コマンドを同名のシェル関数で再定義して`command`コマンドで元の外部コマンドを呼び出すことで、オプションの追加や処理の拡張を行うことが可能なのですが、あまり見かけないテクニックな気がします。呼び出し先が外部コマンドではなくビルトインコマンドの場合は`builtin`コマンドが使えます。（ただし`builtin`はPOSIXシェル準拠ではなくdashなどでは使用できません。）

追加しているオプションはログ出力として便利なverboseと冪等性確保（何度も実行できて同じ結果になる）のために必要なオプションという観点で選んでいます。

#### Report errors to Stackdriver Error Reporting

起動スクリプトで発生したエラーをsyslogとStackdriver Error Reportingに出力しています。Error Reportingは本来アプリケーションのエラーを通知するためのものだと思うのですが、起動スクリプトのエラーを通知するのにも便利だと思うので対応してみました。

Error Reportingは各言語のスタックトレースを解釈します。しかしbashの標準的なスタックトレース形式というのはありませんしError Reportingも対応していません。そのためbashのスタックトレース情報をマッピングしやすかったRubyのスタックトレース形式に整えています。Error Reportingへの出力は正確には次のerror-reportingスクリプトで行っています。

ちなみに私はよくPOSIX準拠シェルでコードを書くのですが、POSIX準拠シェルにはエラーをトラップする機能はありません。そのため起動スクリプトはbashを使用しています。

##### '(set +x -- error: exit status $?; error $4)'

この変な書き方は（`set -x`による）ログ出力内容が`set +x`だけだと何の意味もなく味気ないので、`set +x -- error: exit status 127`のように終了ステータスを表すメッセージにもなっていれば便利だなというだけのためにそうしています。なお`set +x`でデバッグ出力を無効にしているため`error`関数の中の実行コマンドはログに出力されません。

`()`でサブシェルを使用しているのはトラップ処理終了時に`set -x`状態に戻るようにするためです。（`error`関数内の変数のローカル化という意味もあります。）

#### Create error-reporting script

指定したメッセージをError Reportingに出力するためのスクリプトです。まず注意点としてError Reportingへの出力は（本来アプリケーションのエラー通知を対象としているため？）エラーが発生した場所（関数名、ファイル名、行番号）もしくはスタックトレースが必須です。それがないとError Reportingに記録されません。

またError Reportingには発生したエラー情報を通知していますが、詳細なログはsyslogに記録されます。このエラー情報と詳細なログを対応付ける方法がないのでエラーメッセージにユニークなIDを追加しており、このIDで検索することで発生したエラー前後のログを見つけることができるようにしています。

Error Reportingへの出力はJSONで行う必要があるため、地味に面倒なJSONエスケープも行っています。Error Reporingへの出力は、`gcloud beta error-reporting events report`でも行えて、こちらであればJSONエスケープは不要なのですが、betaであるのに加えスタックトレースが必須なので使用していません。`gcloud logging write`であればスタックトレースではなく関数名（及びファイル名、行番号）だけでも出力できます。

##### install /dev/stdin

`install` コマンドはファイルの作成とディレクトリの作成とパーミッションの指定を同時に行える事ができるコマンドです。これを使うことで`mkdir -p`や`chmod +x`を減らすことができます。コピー元の`/dev/stdin`は標準入力からコピーするという意味でヒアドキュメントを組み合わせることが可能になります。

似たようなことを行うのに、`cat <<HERE > ファイル名` が使われることがありますが、`cat`だと`set -x`オプションによるログ出力で `cat`しか出力されません。（リダイレクト先のファイル名が出力されません。）`install`コマンドであればファイル名は引数なのでログに出力されます。

#### Setup for rsyslog

Debian 9にはデフォルトでrsyslogの設定がされていますが、重要度（severity）が出力されていないので設定を追加しています。このseverityは後述するStackdriver Loggingの設定と合わせることでLoggingのログビューワーのアイコンとして表示できます。またopenvpn、ddclient、httpdは見やすいようにそれぞれ別のファイルに出力しています。

##### 50-ddclient.conf (mask password)

DDclientは、`-verbose`オプションを付けたときにログにDDNS更新のパスワードが出力されるのが気になったので隠してみました。パスワードっぽいパラメータ名の値を書き換えてるだけなので、もしかしたら漏れがあるかもしれません。

#### Install Stackdriver Logging

syslogの内容をStackdriver Loggingに送るためにStackdriver Logging agentをインストールしています。

Stackdriver Logging agent (google-frulentd)と同時にインストールされるgoogle-fluentd-catch-all-configには、syslogやよく使われるものは設定ファイルが用意されていますが、今回インストールするDDclientとOpenVPNとhttpdは用意されておらず、不要な設定ファイルの読み込みで時間がかかるため`DO_NOT_INSTALL_CATCH_ALL_CONFIG=1`を指定してgoogle-fluentd-catch-all-configをインストールしないようにしています。

`--structured`は`DO_NOT_INSTALL_CATCH_ALL_CONFIG=1`を指定しているため意味はないのですが、構造化ログを使用することを明確にするために指定しています。（現時点では）`--structured`を省略（= `--unstructured`)すると、**google-fluentd-catch-all-config v0.7**がインストールされ、指定すると**google-fluentd-catch-all-config-structured v1.0**がインストールされます。[install-logging-agent.sh](https://dl.google.com/cloudagents/install-logging-agent.sh)を読むと将来デフォルトを変更するかもしれないと書いてあるので、バージョン番号からしても`--structured`を指定した方が良さそうです。構造化ログを使用するとLoggingのログビューワーが見やすく少し便利になります。（重複してる日付表示が消えたりカスタムフィールドが使用可能になったり）

ちなみに、今回作成するマシンのメモリ使用量は全部でわずか230MB程度なのですが、Stackdriver Logging agentはそのうち130MB位使用しています（笑）

#### Setup for Stackdriver Logging

Stackdriver Logging（google-frulentd)の設定ファイルです。前述の通り`DO_NOT_INSTALL_CATCH_ALL_CONFIG=1`を指定しているので、**google-fluentd-catch-all-config-structured**を参考にしつつ`syslog.conf`も含めてすべて作成しています。

`syslog.conf` （ならびに`openvpn.conf`, `ddclient.conf`, `httpd.conf`）はLoggingのログビューワーで[重要度](https://cloud.google.com/logging/docs/reference/v2/rest/v2/LogEntry?hl=ja#logseverity)が表示されるように変更しています。

#### Setup for logrotate

GCEインスタンスは10GBのブートディスクうち空きは8GB以上あるので、ほぼ不要だとは思うのですが、何かの設定ミスで大量にログが溜まったりしたら嫌なので念の為にOpenVPNとhttpdのログローテートの設定をしています。DDclientはログの量は少ないと思われるため省略しています。

#### Install Stackdriver Monitoring

モニタリング情報をStackdriver Monitoringに送信するために、Stackdriver Monitoring agent (collectd)をインストールしています。Stackdriver Monitoring agentは現時点では「[Debian 10 "Buster"に対応していません](https://cloud.google.com/monitoring/agent/?hl=ja#supported_operating_systems)」ということなので、今回のOpenVPNサーバーではDebian 9 "Stretch" を使用しています。以下のコマンドでunstable 版をインストールできましたがまだうまく動いていないようでした。Debian 10 "Buster"に正式に対応したら、GCEインスタンスをDebian 10ベースに変更する予定です。

```
curl -sS https://dl.google.com/cloudagents/install-monitoring-agent.sh | sudo REPO_SUFFIX=unstable bash
```

#### Setup for Stackdriver Monitoring

##### カスタム指標

Stackdriver Monitoring agent経由でOpenVPNのクライアント数と送受信量を[カスタム指標](https://cloud.google.com/monitoring/agent/custom-metrics-agent?hl=ja)として送信しています。

シェルスクリプトからcollectdのexec pluginでStackdriver Monitoringにカスタム指標を送る方法は情報が少なく少し大変でした。詳細はソースコード参照ですが、シェルスクリプトでメトリクス値を取得して、そのモニタリングデータの保存先をcollect.dの設定ファイルでMetaDataとして指定してあげる必要があるようです。

なおexec pluginはroot権限では起動できません。OpenVPNのステータスログはデフォルトではrootからしか見れないのでパーミッションを変更しています。

#### Create metadata script

インスタンスメタデータを取得するスクリプトです。よく使う割に長いのでスクリプトにしています。

#### Retrive configuration

editModeの値によって[Cloud Storage FUSE](https://cloud.google.com/storage/docs/gcs-fuse?hl=ja)でGCSバケットをマウントするか、`gsutil`コマンドでコピーするか分岐しています。GCSバケットにあるのは設定ファイルだけなので、Cloud Storage FUSEでもGCSバケットへのアクセスが過剰に増えたりはしないと思うのですが、いつアクセスが発生するのか明確ではないので、変更の必要があるときだけeditModeをtrueにして通常はfalseを指定するようにしました。falseであればGCEインスタンスの起動時に一度だけコピーを行います。

##### (umask 0077; ～)

一部のファイルはパーミッションを600にする必要があるためumaskを使用しています。`()`で囲んでいるのはumaskの変更を一時的にするためです。サブシェル内でumaskを変更しているので、サブシェルを抜けると元に戻ります。

#### Unit File for notify-failure.service

OpenVPNやDDclientサービスの起動が失敗したり停止したときに、Error Reportingにエラーを通知するためのサービスです。`systemctl start notify-failure-ddclient@ddclient.service.service`のような形式（`notify-failure-syslogタグ名@サービス名.service`）で呼び出します。（実際はユニットファイルの`OnFailure`から実行します。）

新しいバージョンのsystemdであればsyslogタグ名の部分を`%j`で取得できるのですが、Debian 9のsystemdでは非対応だったため、シェルスクリプトでワークアラウンドを書いています。

注意点として`systemctl start notify-failure-ddclient@ddclient.service.service`という呼び出しが行えるようにするためには、`/etc/systemd/system/notify-failure-ddclient@.service`というファイルが存在していなければいけません。`/etc/systemd/system/notify-failure@.service`というファイルへのシンボリックリンクではダメで**ハードリンク**（またはコピー）でなければなりません。

#### Install DDclient

DDclinetはDebian 9のパッケージに含まれているのですが、バージョンが古かったので独自でインストールしています。また標準パッケージのDDclinetはIPアドレスの変更を監視するためにインストールするとデフォルトでサービスとして起動するのですがそれを避ける意味もあります。（GCEの場合はIPアドレスはGCEインスタンスを再作成させない限り変わらないので常時起動している必要はありません。）

##### DDNS update

ただし無料のDDNSサービスでは定期的に更新しないとアカウントが削除されてしまうので、`ddnsUpdateInterval`の設定で定期更新もできるようにしました。定期的な更新はDDclinetの機能ではなくsystemdの機能を行って実行しています。（`ddclient.timer`）`ddnsUpdateInterval`が未指定の場合は起動時に一回のみ実行します。

#### Unit File for busybox httpd

OpenVPNのステータスログをブラウザから見れるようにしています。そのためにHTTPサーバーが必要ですがなるべく軽くて簡単に使えるHTTPサーバーにしたいところです。軽量をうたうHTTPサーバーはいくつかありますが今回は多くの機能やパフォーマンスは必要ないのでbusybox内蔵のHTTPサーバーを使用することにしました。

busyboxはメモリやストレージ容量が限られてる組み込みなどで使われるもので、一つのバイナリにシェル（ash）や`ls`や`cp`など基本的なコマンドのサブセットが詰め込まれています。この中に簡易的なHTTPサーバーも含まれています。HTTPSには対応していませんが今回必要なのはHTTPだけなので十分です。BASIC認証やCGIにも対応しています。組み込み用だけあってメモリ使用量は起動時で1～2MB程度しか使用しません。簡易な用途であれば十分使えると思います。

そこでインストールしようと思ったのですが、Debian 9にはすでにインストール済みでした。正確には`busybox`パッケージはインストールされていなかったのですが、`busybox`バイナリは存在していました。

busybox httpdはVPN外部接続用とVPN内部接続用の2つ（ヘルスチェックあり版では更にもう1つ）起動しますがユニットファイルは共通化しています。

#### OpenVPN Status

OpenVPNのステータスページを表示するには、HTTPサーバーを2つ使用します。一つはVPN外部から接続するための外部IPアドレスにバインドしたHTTPサーバーで、もう一つはVPN内部から接続するためのHTTPサーバーです。バインドするIPアドレスを変えているのでどちらも同じTCPポート80が使用できます。

外部IPアドレスはGCEインスタンス起動時にすでに存在しているため、VPN外部用HTTPサーバー (`httpd@public.service`) は起動スクリプトから起動できますが、VPNのIPアドレスはOpenVPNが起動するまで存在しません。そのためOpenVPNサーバーの起動と停止時にVPN内部用HTTPサーバー (`httpd@private.service`)の起動と停止を行います。そのためのスクリプトが、`/usr/local/share/openvpn/up.sh`と`/usr/local/share/openvpn/down.sh`でOpenVPNの設定ファイル (`/etc/openvpn/server/server.conf`)に記述します。

```conf
script-security 2
up /usr/local/bin/openvpn-up.sh
down /usr/local/bin/openvpn-down.sh
```

OpenVPNのステータスページは`allowStatusPage`を`false`にすると無効になります。VPN外部からの接続はファイアウォールの設定でHTTPポート80を防ぐことで実現できますが、VPN内部からはこれだけでは見れてしまいます。そのため`/var/www/private/cgi-bin/index.cgi`ではインスタンスメタデータを参照し`false`の場合に403エラーを返しています。なおステータスページのURLは、`http://VPN内部IPアドレス/cgi-bin/index.cgi`だけではなく`http://VPN内部IPアドレス/`でも参照できます。これは`/var/www/private/index.html`がなければ、`/var/www/private/cgi-bin/index.cgi`を参照するというbusybox httpdの仕様によるものです。

##### インスタンスメタデータの更新の待機

またVPN外部から接続するHTTPサーバーにはロボットよけとしてBASIC認証がかけてあります。このユーザー名とパスワードをGCEインスタンスを停止することなく設定を反映させることができるようにしました。ユーザー名とパスワードはインスタンスメタデータに記録されていますが、変更されたときにすぐに反映させるために[更新の待機](https://cloud.google.com/compute/docs/storing-retrieving-metadata?hl=ja#waitforchange)を利用しています。インスタンスメタデータを取得するときに`?wait-for-change=true`を加えるとインスタンスメタデータが変更するまで待機する機能です。これを利用することでBASIC認証のユーザー名とパスワードが変更されたら即座にHTTPの設定ファイルを再読み込み（`kill -HUP`）させることが可能です。

インスタンスメタデータの取得はこれまで`curl`を使用してきましたが、`update-anti-robot-auth`スクリプトではbusybox wgetを使用しています。インスタンスメタデータの変更を検出するために`curl`は起動した状態で待機するのですが、その時のメモリ使用量が8MBを超えており、OpenVPNが7MB程度という状況でユーザー名とパスワードの変更の監視のためだけにそれだけのメモリを使用するのが気に入らなかったからです。（まあメモリは十分空いているのですが。）busybox wgetだと2MBしか使用しません。ただし何故か60分でConnection reset by peerが発生し切断されるのでインスタンスメタデータに変更がなくても1800秒で結果を返すようにしています。

##### Uptime Check

おまけですが、VPN外部用のHTTPサーバーのステータスページはUptime Checkに利用できます。これはGCEのヘルスチェックとは別の機能で、GCPの外部から見たときに（ウェブサイトが）正しく稼働しているかを確認するためのStackdriver Monitoringの機能です。OpenVPNが正しく動いているかまではわかりませんが、DDNSの更新が正しく行われているかのチェックに利用することができます。

#### Install OpenVPN

OpenVPNのパッケージは、stretch-backportsにより新しいものがあったのでそちらを利用しています。`-no-install-recommends`を指定しているのは、`easy-rsa`（と合わせて入る`opensc`, `pcscd`）をインストールしないようにするためです。`easy-rsa`はともかく`pcscd`はサービスとして起動するのでリソースの無駄です。

ユニットファイルはリトライの設定を上書きしています。デフォルト設定では10秒間の間に5回リトライが発生したらサービスは停止するのですが、リトライする前に5秒のウェイトが設定されているため10秒間に5回リトライをすることがありえないため、設定ファイルにミスがあっても無限にリトライしてしまいサービスが停止しないためです。

##### chmod 755 /run/openvpn-server

collectdのexec pluginから参照できるようにパーミッションを変更しています。

#### ヘルスチェック

OpenVPNそのものにはヘルスチェック機能はなくTLS暗号化されたUDPプロトコルを使用してるので外部から直接OpenVPNに対してヘルスチェックを行うのは大変です。そこでOpenVPNのステータスファイルが1分以上更新されなければOpenVPNは正しく動いていないと判断することにしました。そのためのCGIをbusybox httpdから呼び出しています。

VPN外部用のHTTPサーバーはBASIC認証をかけておりヘルスチェックには利用できないため、ヘルスチェック用に新たなHTTPサーバーを起動しています。このHTTPサーバーへはファイアウォールの設定でヘルスチェックのアクセスだけを許可するようにしています。 (`sourceRanges: [ "130.211.0.0/22", "35.191.0.0/16" ]`)

ヘルスチェック用のHTTPサーバーは、ヘルスチェックありのときだけ起動するようにしています。現在の設定がヘルスチェックありかどうかは、インスタンスメタデータに`instance-template` が存在してるかどうかで判断しています。

##### busybox httpdのパフォーマンスチェック

おまけでどれくらいのパフォーマンスがでるかを`ab`コマンドで軽く調べてみました。

```
ab -n リスエスト数 -c 同時接続数 http://localhost:8080/
```

同時接続数1の場合（ヘルスチェック想定）`Time per request: 1.613 [ms]`でした。（10並列だと`Time per request: 18.358 [ms]`）この程度なら十分でしょう。

ちなみに`busybox`ではなく`busybox-static`を使用するともう少しパフォーマンスが上がります。`busybox-static`では（`/bin/sh`の代わりにbusybox ashを使用した場合に）`busybox`に実装されているコマンドを外部コマンド呼び出しではなくビルトインコマンドのような扱いで実行します。そのためヘルスチェックスクリプトで使用している`find`コマンドの実行が速くなり、`Time per request: 0.713 [ms] (mean)` ぐらいの値がでました。

## さいごに

システムとしてはDDclientとOpenVPNを組合わせただけの単純なものですが、いろんな機能を詰め込んだのでなかなかいいサンプルになったのではないでしょうか？そのせいで起動スクリプトは随分と長くなりましたが、基本的に各設定ファイルを配置しているだけなので長さの割に複雑な感じはしないと思います。（それぞれのファイルに分離することも可能です）

Stackdriver Error Reportingとの連携やStackdriver Monitoringへのカスタム指標など他であまり見かけないこともやってみました。コードはサンプルコード扱いなので自由に再利用してもらって構いません。

あと定期的なスケジュールでGCEインスタンスを作成・削除する機能を追加したいと思ってるので、そのうちCloud SchedulerやCloud Functionsを利用したコードを追加すると思います。