背景
Trend Micro Cloud One Container Securityとは
Trend Micro Cloud One Container Securityとは、コンテナイメージのスキャンやポリシーベースのデプロイメントコントロール(例えば、イメージスキャンで脆弱性が見つかったイメージはデプロイできないなど)などの機能を兼ね備えたKubernetesに対するセキュリティソリューションです。
詳しくは公式サイトをご覧ください。
画像は公式ガイドより
AWS CDKとは
AWS CDKとは、TypeScriptやPythonなど使い慣れたプログラミング言語を使用してクラウドアプリケーションリソースを定義するためのオープンソースのソフトウェア開発フレームワークです。
いわゆるInfrastructure as Code(IaC)ツールの一種ですね。
こちらも詳しくは公式サイトをご覧ください。
画像は公式開発者ガイドより
なぜAWS CDKを使ってセットアップしたいのか
AWS CDKおよびInfrastructure as Codeのメリットは多岐に渡りますが、本記事ではセキュリティの向上に着目したいと思います。Cloud One Container Securityは確かに素晴らしい製品ですが、万能ではありません。せっかくCloud One Container SecurityでEKS Clusterをセキュアにしても、他のところが雑だとそこが脆弱性になってしまいます。
- EKS Clusterが乗っているVPCレベルのセキュリティ(例えば、セキュリティグループの設定)が雑になっている
- API KEYや認証情報(例えば、Clusterに乗っているコンテナが参照するRDSのID/Pass)の管理が雑になっている
AWS CDKを使うことでなぜ上記のような「雑な設定」を防ぐことができるのか、解説を交えながら手順を説明します。
Container SecurityにClusterを追加する
まず、Container Securityで管理する対象のEKS ClusterをAWS CDKで作成して、管理対象に追加します。
基本的には、公式ガイドのAdd a clusterに記載されている手順をAWS CDKベースで行なっていきます。
開発環境構築
まずは開発環境を作ります。ローカルPCに作ることもできますが、今回はAWS Cloud9を利用することにします。
ローカルPCからAWS CDKを実行する場合は、IAMのAccess Key IDやSecret Access Key IDの発行・管理がセキュリティ上の課題となります。
一方でCloud9の場合は、AWS Managed Temporary CredentialsもしくはEC2インスタンスに付与したIAMロールベースでの実行の利用が可能なので、Management Consoleへのログインをしっかり管理している前提(例えばMFAを必須にしているなど)であれば、セキュリティ上比較的安全に利用することができるかと思います。
Cloud9インスタンスの作成
Management ConsoleからCloud9のインスタンスを作成します。
スペックはサイフの中身と相談だとは思いますが、AWS CDKでEKSのプロジェクトを扱う(≒Cloud9の中でdocker buildなどを行う)前提で、快適に開発するための最低スペックは以下のような感じかなと思います。
- インスタンスタイプ
- m5.large (t3.smallでもいけなくないですがちょっと重いです。。)
- プラットフォーム
- Amazon Linux2 (ほかでも問題ないですが、まあこれが無難かと。)
- EBSボリュームサイズ
- 40GB (Cloud9のコンソールからは変更できないですが、EC2のコンソールから変更ください。デフォルトの10GBはすぐ枯渇します)
Cloud9へのミドルウェアのインストール
私はAWS CDKはTypeScriptで書きたいので、その前提でミドルウェアを整えていきます。何度も同じ作業をやってきたので、シェルスクリプト化しました。
#!/bin/bash
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" # nvmのロード
nvm install stable #nodeの最新Stableのインストール
nvm alias default stable #デフォルトで↑のStable版を使うよう設定
npm update -g npm #npmの最新化
npm install -g yarn #yarnのインストール
echo export PATH=\$PATH:`yarn global bin` >> ~/.bashrc # yarnのglobalディレクトリにpathを通す
source ~/.bashrc #上でやった設定の反映
yarn global add aws-cdk # CDK Cliのインストール
CDKプロジェクトの作成
インストールしたAWS CDKのCliを使ってAWS CDKのプロジェクトを作成します。
$ mkdir cdk-container-security-sample
$ cd cdk-container-security-sample
$ cdk init --language=typescript # TypeScriptのCDKプロジェクトを作成する
このようにプロジェクトが初期化されればOKです。
CDK Bootstrap (AWSアカウント/リージョンごとに初めてAWS CDKを使用する場合のみ)
初めてAWS CDKを利用する場合、CDK Bootstrapという初期設定が必要です。CDKの実行に必要なS3バケットや関連するIAMロールなどが作成されます。
# 初期化したcdk-container-security-sampleディレクトリ内で実行
$ yarn run cdk bootsrtap
Container Securityで管理するEKS Clusterを作成する
早速、lib/cdk-container-security-sample-stack.ts
を編集して、Container Securityの監視対象にするAWS EKSのコンテナを作成します。ただVPCを作成してそこにEKSクラスタを作成するだけでも良いのですが、ついでにEKSに展開するコンテナ上のアプリケーションが利用する想定で、PostgreSQLのAWS RDSを同じVPCに作成します。
関連ライブラリの追加
$ yarn add @aws-cdk/aws-ec2 @aws-cdk/aws-eks @aws-cdk/aws-rds
EKSを構築するスタックの作成
import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as eks from '@aws-cdk/aws-eks';
import * as rds from '@aws-cdk/aws-rds';
/**
* 使用するCDK Contextをマップするinterface
*/
interface CdkContainerSecuritySampleContext{
/** CIDRプレフィックス*/
cidrPrefix: string,
/** EKSクラスタのノード数*/
desiredCapacity: number,
/** EKSクラスタのノードのインスタンスタイプ*/
nodeInstanceType: string,
/** EKSクラスタ名*/
clusterName: string,
}
/**
* CdkContainerSecuritySampleContextのデフォルト値
*/
const DEFAULT_CONTEXT: CdkContainerSecuritySampleContext = {
cidrPrefix: '10.0',
desiredCapacity: 2,
nodeInstanceType: 'm5.large',
clusterName: 'container-security-sample',
}
export class CdkContainerSecuritySampleStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// CDK Contextのパース
const context = this.parseContext();
// EKSクラスタをデプロイするVPCの作成
const vpc = new ec2.Vpc(this, 'cloudone-poc-vpc', {
cidr: `${context.cidrPrefix}.0.0/16`,
natGateways:1,
subnetConfiguration: [
{
cidrMask: 24,
name: 'Public1',
subnetType: ec2.SubnetType.PUBLIC
},
{
cidrMask: 24,
name: 'Public2',
subnetType: ec2.SubnetType.PUBLIC
},
{
cidrMask: 24,
name: 'Private1',
subnetType: ec2.SubnetType.PRIVATE
},
{
cidrMask: 24,
name: 'Private2',
subnetType: ec2.SubnetType.PRIVATE
}
]
});
// EKSクラスタの作成
const cluster = new eks.Cluster(this, 'container-security-cluster', {
vpc,
clusterName: context.clusterName,
version: eks.KubernetesVersion.V1_20,
defaultCapacity: context.desiredCapacity,
defaultCapacityInstance: new ec2.InstanceType(context.nodeInstanceType)
});
/**
* kubernetes-external-secrets(AWS Secrets Managerで管理されているSecretをKubernatesのSecretに連携するController)のインストール
*/
// kubernetes-external-secretsで使用するサービスアカウントの作成
const externalSecretServiceAccount = cluster.addServiceAccount("external-secret-sa", {name: `${context.clusterName}-sa`});
// Helm経由でkubernetes-external-secretsのインストール
// @see https://github.com/external-secrets/kubernetes-external-secrets/tree/master/charts/kubernetes-external-secrets
const externalSecretHelm = new eks.HelmChart(this,'external-secret', {
cluster,
repository: "https://external-secrets.github.io/kubernetes-external-secrets",
chart: "kubernetes-external-secrets",
values: {
securityContext:{
fsGroup: 65534
},
serviceAccount:{
create:false,
name: externalSecretServiceAccount.serviceAccountName
},
env:{
AWS_REGION: this.region
}
}
});
// EKS上のコンテナが利用するRDS(PostgreSQL)をプライベートサブネットへ作成
const postgresRds = new rds.DatabaseInstance(this, 'postgres-container-security', {
engine: rds.DatabaseInstanceEngine.postgres({version: rds.PostgresEngineVersion.VER_13_3}),
vpc,
vpcSubnets: {
subnetType: ec2.SubnetType.PRIVATE
}
});
// EKSクラスタからRDSへのデフォルトポートでの疎通を許可する
postgresRds.connections.allowDefaultPortFrom(cluster);
/**
* kubernetes-external-secretsを使用してSecretManagerに格納されたRDSの接続情報を
* KubernetesのSecretに連携する
*/
// RDSの接続情報が格納されているSecret名
const postgresRdsSecretInfo = postgresRds.secret;
if(postgresRdsSecretInfo){
// kubernetes-external-secretsのサービスアカウントに接続情報の読み取りを許可
postgresRdsSecretInfo.grantRead(externalSecretServiceAccount);
// kubernetes-external-secretsを使用して接続情報を連携するマニフェストの追加
const postgresRdsSecretManifest = new eks.KubernetesManifest(this, 'rds-secret-manifest', {
cluster,
manifest:[{
apiVersion: 'kubernetes-client.io/v1',
kind: 'ExternalSecret',
metadata: {
name: 'postgres-rds-secret',
},
spec:{
backendType: "secretsManager",
data:[
{
key: postgresRdsSecretInfo.secretName,
name: "dbcredential"
}
]
}
}]
});
// このマニフェストがkubernetes-external-secretsのインストール後に実行されるよう依存関係を明示
postgresRdsSecretManifest.node.addDependency(externalSecretHelm);
}
}
/**
* CDK Contextをパースして返す
*/
private parseContext(): CdkContainerSecuritySampleContext{
return {
cidrPrefix: this.node.tryGetContext("CIDR_PREFIX") ?? DEFAULT_CONTEXT.cidrPrefix,
desiredCapacity: this.node.tryGetContext("DESIRED_CAPACITY") ?? DEFAULT_CONTEXT.desiredCapacity,
nodeInstanceType: this.node.tryGetContext("NODE_INSTANCE_TYPE") ?? DEFAULT_CONTEXT.nodeInstanceType,
clusterName: this.node.tryGetContext("CLUSTER_NAME") ?? DEFAULT_CONTEXT.clusterName,
}
}
}
上記のようにソースコードを作成して、cdk deploy
コマンドでリソースをデプロイします。
$ yarn run cdk deploy
また、いくつかContextを定義しているので、パラメータの上書きが可能です。例えば、VPCのCIDRプレフィックスを10.2
に変更するには、
$ yarn run cdk deploy -c CIDR_PREFIX=10.2
のようにして実行します。
AWS CDKによるセキュリティ向上ポイント
VPCセキュリテイ
VPC上にあるデータベースなどのリソースを保護するためには、ルートテーブルやセキュリティグループの設定・管理などの専門知識が必要です。例えば、本例で作成したRDSに関しては、
- 作成したEKSクラスタからはPostgreSQLのデフォルトポート(5432)でアクセスさせたい
- EKSクラスタからであっても5432以外からのアクセスは拒否したい
- EKSクラスタ以外からのアクセスはいかなるポートであっても拒否したい
という要件を満たす設定が必要です。
もちろん、きちんと勉強した専門家であれば手動でこのような環境を構築・管理することは可能ですが、専門家も人間である以上は人為ミスを想定せねばならず、その防止や発見のためにかなりのコストを費やすことになります。
一方で、AWS CDKにはHigh Level Construct
もしくはL2 Construct
という概念があります。
上記のソース例を見ていただくと分かる通り、ソースコード上でセキュリティグループやルートテーブルの設定を明示的に記述していません。ただ1行だけ、
// EKSクラスタからRDSへのデフォルトポートでの疎通を許可する
postgresRds.connections.allowDefaultPortFrom(cluster);
と記述されているのみです。
実はAWS CDKでは、上に挙げた3つの要件がこの1行だけで実現できます。
以下は、上記のソースによって作成されたセキュリティグループ(のうち1つ)のInbound ruleです。
このようにAWS CDKではセキュリティグループやルートテーブルなどの設定を抽象化し、allowDefaultPortFrom
のような目的別に応じたメソッドを提供してくれます。このような工夫により、
- 個別に手動設定することによる人為ミスの防止
- 担当者の理解不足・知識不足による雑な設定(全ポートを解放してしまうなど)の防止
- レビューの単純化(ルートテーブル/セキュリティグループの設定を1つ1つレビューするより
allowDefaultPortFrom
が使われていることを
チェックする方がはるかに単純で楽。)
などを図ることが可能です。
認証情報の管理
AWS CDKやKubernetesなど、いわゆるInfrastructure as Codeがセキュリティに与えるメリットは多くありますが、一方でよくある失敗例は認証情報の流出かと思います。具体的には、
DBへの接続情報(ID/Pass)をソースコードや設定ファイルにハードコードしたままGitHubにpushして流出させてしまった
のような事態を防止しなければいけません。
ソース例で使用している@aws-cdk/aws-rds
のDatabaseInstance
は、デフォルトで認証情報をAWS Secrets Managerに格納してくれます。
今回はこのようにAWS Secrets Managerに格納された認証情報をEKS上のコンテナから参照するため、EKSクラスタ上にKubernetes External Secretsというコントローラをインストールして、Secrets Manager上の情報をKubernetes上のSecretとして参照できるように設定します。
AWS CDKでは@aws-cdk/aws-eks
ライブラリを用いてクラスタへのHeml Chartのインストールやマニフェストの適用が可能なので、以下のようにインストールします。
// Helm経由でkubernetes-external-secretsのインストール
// @see https://github.com/external-secrets/kubernetes-external-secrets/tree/master/charts/kubernetes-external-secrets
const externalSecretHelm = new eks.HelmChart(this,'external-secret', {
cluster,
repository: "https://external-secrets.github.io/kubernetes-external-secrets",
chart: "kubernetes-external-secrets",
values: {
securityContext:{
fsGroup: 65534
},
serviceAccount:{
create:false,
name: externalSecretServiceAccount.serviceAccountName
},
env:{
AWS_REGION: this.region
}
}
});
インストールしたコントローラを用いて、Secrets Manager上に格納された認証情報を連携します。
// kubernetes-external-secretsのサービスアカウントに接続情報の読み取りを許可
postgresRdsSecretInfo.grantRead(externalSecretServiceAccount);
// kubernetes-external-secretsを使用して接続情報を連携するマニフェストの追加
const postgresRdsSecretManifest = new eks.KubernetesManifest(this, 'rds-secret-manifest', {
cluster,
manifest:[{
apiVersion: 'kubernetes-client.io/v1',
kind: 'ExternalSecret',
metadata: {
name: 'postgres-rds-secret',
},
spec:{
backendType: "secretsManager",
data:[
{
key: postgresRdsSecretInfo.secretName,
name: "dbcredential"
}
]
}
}]
});
// このマニフェストがkubernetes-external-secretsのインストール後に実行されるよう依存関係を明示
postgresRdsSecretManifest.node.addDependency(externalSecretHelm);
利用する側は以下の通り、Secretとしてマウントできます。
apiVersion: v1
kind: Pod
metadata:
name: mypod
spec:
containers:
- name: mypod
image: myapp
volumeMounts:
- name: rdsinfo
mountPath: "/etc/secret"
readOnly: true
volumes:
- name: rdsinfo
secret:
secretName: postgres-rds-secret
items:
- key: dbcredential
path: rds-info
このように、これからEKSに乗るであろうコンテナの定義も含め、発行されたRDSの認証情報を一切ソースコードに残すことなくRDSへの接続を定義することが可能です。
policy-based deployment controllerをインストールする
では、公式ガイドラインに則り、作成したクラスタをContainer Security配下に登録します。
クラスタ定義の追加
ガイドラインの通り、Cloud OneのコンソールからContainer Securityを選択し、クラスタを追加します。
すると以下のような指示が表示されます。
ここではhelm
コマンドを使ってクラスタにpolicy-based deployment controller
をインストールするよう指示されていますが、今回はここもAWS CDKで行います。
API KeyをAWS Secrets ManagerAWS Systems Managerのパラメータストアに登録する
上の画面ショットで黒塗りした箇所に書かれているAPI Keyは、先ほどのRDSの認証情報と同じく流出させてはいけないセキュリティ情報です。間違ってもソースコードや設定ファイルにハードコードしないよう、AWS Security Managerに登録しましょう。
と、書きたいところだったのですが、実は数時間ハマった末、今回のユースケースではAWS Security Managerでは上手くいかないことが分かりました(後述)
なので、今回はAWS Systems Managerのパラメータストアに登録して、そこからCloudFormationのパラメータとして読み込む形にしたいと思います。
以下のように登録しました。
policy-based deployment controllerをインストールするためのConstructを作成する
そのままStackに記述しても良かったのですが、少々ソースが長くなってきたのと、このコントローラのインストールは他のクラスタでも使い回すであろうことを鑑み、別のConstructに切り出しました。
基本的には、先にスクリーンショットを貼ったAdd Cluster
完了時の画面に書いてあるhelm install
のコマンドを@aws-cdk/aws-eks
のHelmChart
で定義するよう書き換えただけです。
import * as cdk from '@aws-cdk/core';
import * as eks from '@aws-cdk/aws-eks';
/**
* PolicyBasedDeploymentControllerのプロパティ
*/
export interface PolicyBasedDeploymentControllerProps{
/** インストール対象のEKSクラスタ*/
cluster: eks.ICluster,
/** Cloud Oneが発行したAPI Key*/
apiKey: cdk.SecretValue
}
/**
* Trend Micro Cloud One Container SecurityのPolicy-based Deployment ControllerをインストールするHelmを適用するConstruct
* @see https://cloudone.trendmicro.com/docs/container-security/cluster-add/#install-the-policy-based-deployment-controller
*/
export class PolicyBasedDeploymentController extends cdk.Construct{
constructor(scope: cdk.Construct, id:string, props: PolicyBasedDeploymentControllerProps){
super(scope, id);
const {cluster, apiKey} = props;
new eks.HelmChart(this, "policybased-deployment", {
cluster,
release: "trendmicro",
namespace: "trendmicro-system",
createNamespace: true,
chart: "https://github.com/trendmicro/cloudone-container-security-helm/archive/master.tar.gz",
values: {
cloudOne: {
apiKey
}
}
})
}
}
Stackにpolicy-based deployment controllerのインストールを追加する。
あとは、先ほど作成したlib/cdk-container-security-sample-stack.ts
から呼び出すだけです。
長くなるので主な変更箇所のみ示します
import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as eks from '@aws-cdk/aws-eks';
import * as rds from '@aws-cdk/aws-rds';
//追加
import {PolicyBasedDeploymentController} from './helm-policybased-deployment-controller';
//中略
export class CdkContainerSecuritySampleStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
//だいぶ中略
//Cloud One Container Securityの導入
new PolicyBasedDeploymentController(this, "cloudone", {
cluster,
apiKey: cdk.SecretValue.cfnParameter(new cdk.CfnParameter(this, 'CloudOneApiKey', {type: "AWS::SSM::Parameter::Value<String>", noEcho: true}))
});
}
//中略
}
先ほども少し触れましたが、今回はCloudFormationのパラメータを使用して、Systems ManagerのパラメータストアからAPI Keyを参照することにしました。ということで、デプロイ時のコマンドが少し変わります。
$ yarn cdk deploy --parameters CloudOneApiKey=/cloudone/apikey/samplecluster
では、早速事項して結果を確認しましょう。
結果確認
cdk deploy
コマンドを打ってしばらく待つと、先ほど作成したEKS Clusterにtrendmicro-system
というnamespace
が作成され、その中にいくつかのPodが作成されます。
全てのPodのステータスがRunning
となっているので正しく稼働していそうです。
また、CloudOneのコンソールを確認すると、
このようにLast Evaluation
の時間が更新され、クラスタが認識されたことが分かります。
まとめと今後について
ここまでで、
- AWS CDKを用いてセキュアにAWS EKSクラスタとそこから参照するRDSを作成し
- 同じくAWS CDKを用いて作成したEKSクラスタをCloud One Container Securityの監視対象にする
ことができました。
ガイドに記載の通り、ここからはCloud Oneのコンソールから「ポリシー」を定義して、クラスタに対して様々な監視やガードレールの敷設を行うことができます。VPCレベルでのガードレールの敷設はAWS Configなどからも可能ですが、Kubernetesのレイヤーに対してガードレールが設置できるのは非常にありがたいですね。
本来、AWS ECR上のイメージに対してイメージスキャンを実行して、Container Securityのポリシーと連動して脆弱性のあるイメージのデプロイを防ぐためのDeep Security SmartCheckのセットアップについてAWS CDKで行う予定だったのですが、記事が長くなりそうなので一度ここで区切らせて頂こうかと思います。
また数日後にそちらのやり方についても公開できればと思います。
また、ソースの全体像についてはSmartCheckのセットアップを完了したのち、GitHubで公開しようと思います。
Appendix
EKS Clusterの片づけについて
今回作成したEKS Clusterおよびその周りのVPC,RDSを稼働させ続けているとそれなりの利用料が発生します。
Infrastructure as Codeのメリットの1つは、
(後で同じ環境が再現できるので)一度作った環境を躊躇なく消せる
ことです。
クラウド破産しないためにも、検証が終わったら
$ yarn run cdk destory
で環境を削除しておくことを忘れないようにしましょう。
API Keyの管理にAWS SecretsManagerを使用するを断念した理由
本来はパラメータストアではなくSecretManagerにAPI Keyを登録して、
//Cloud One Container Securityの導入
//注意:失敗します
new PolicyBasedDeploymentController(this, "cloudone", {
cluster,
apiKey: cdk.SecretValue.secretsManager('cloudone-secret',{jsonField:"SAMPLE_API_KEY"})
});
のように呼び出した方がcdk deploy
のコマンドを変えることもなく、API Keyの管理自体も暗号化されていてスマートだと思ったのですが、うまく行きませんでした。
@aws-cdk/aws-eks
のHelmChart
コンストラクトを使用すると、Cloud Formationレベルではカスタムリソース
が作成されます。そして、cdk.SecretValue.secretsManager
を用いてSecretsManagerから値を参照すると、CloudFormationレベルでは動的参照が用いられます。
残念なことに、動的参照のリファレンスに記載の通り、
ssm-secure や secretsmanager などの安全な値の動的な参照は、現在カスタムリソースではサポートされていません。
ということで2021/07現在、HelmChart
のプロパティに対してSecretManagerの値は解決できないようです。
今回は苦肉の策として、
- ソースコードや設定ファイル上にAPI Keyをハードコードしなくて済む
-
cdk deploy
などのコマンド実行時にAPI Keyそのものを入力しなくて済む
を最低限満たす方法として、CloudFormationパラメータ+パラメータストアという手段を採用しました。