도입 배경
타이어픽 서비스는 현재 .env 파일을 통해 환경변수를 관리하고 있다.
문제는 DB 정보와 연동 서비스의 API Key들도 .env를 통해 관리를 하고 있다는 점으로 Private Repository라고 해도 유출이 걱정되는 부분이 존재한다. 최근 독일 및 러시아에서 불법적인 접근이 시도 된 것을 확인하였고 DDOS를 당해 서비스가 잠시 마비가 된 사례가 존재했다.
이러한 가운데 .env 파일의 민감정보가 유출되어 고객정보까지 새어나간다면 굉장히 문제가 될 수 있다.
따라서 유출 걱정 없이 환경변수를 문제없이 관리 할 수 있도록 AWS에서는 Secret Manager 서비스를 운영하고 있다.
aws-cli 설치
가장 먼저 해야할 것은 Secret Manger 연동을 위해 PC에 aws-cli 설치 후 아래 커맨드와 함께 aws-cli 설정을 진행한다.
% npm install -g aws-cli
% aws-cli configure
Bash
복사
설정이 완료되면 아래 커맨드를 통해 현재 Secret Manager에 설정된 보안 암호들을 가져올 수 있게 된다.
% aws secretsmanager get-secret-value --secret-id tirepick-dev
{
"ARN": "arn:aws:secretsmanager:ap-northeast-2:109460092530:secret:tirepick-dev-8hKZJl",
"Name": "tirepick-dev",
"VersionId": "590befd1-3eb0-4078-9632-3a61b4f8febc",
"SecretString": {{SecretKeys}}
}
Shell
복사
loadSecrets.ts
ASM 연동을 위해서는 아래 라이브러리를 먼저 설치해준다.
npm install @aws-sdk/client-secrets-manager
Bash
복사
이후 aws-cli를 통해 환경변수를 가져와서 사용할 수 있도록 함수를 작성해준다.
import {
GetSecretValueCommand,
GetSecretValueResponse,
SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager';
import { Logger, HttpException, HttpStatus } from '@nestjs/common';
interface SecretsStringList {
key: string;
value: string;
}
const logger = new Logger('Load Secrets Variable');
export const loadSecrets = async () => {
// secretsManager 객체를 가져올 수 있도록 객체 생성
const secretsManager = new SecretsManagerClient({
region: `${process.env.SECRETS_REGION}`,
credentials: {
accessKeyId: `${process.env.SECRETS_ACCESS_KEY_ID}`,
secretAccessKey: `${process.env.SECRETS_ACCESS_KEY}`,
},
});
const command = new GetSecretValueCommand({
SecretId: `${process.env.SECRETS_ARN}`,
});
try {
const secretResult: GetSecretValueResponse = await secretsManager.send(
command,
);
const secretList: SecretsStringList = JSON.parse(secretResult.SecretString);
// Secrets Manager SecretString
// key-value 값을 읽어와서 'process.env'에 넣어주는 작업 수행
for (const [key, value] of Object.entries(secretList)) {
process.env[key] = value;
}
} catch (e) {
logger.error(e.message);
throw new HttpException(
'AWS Secrets Manager 환경변수를 읽어오는데 문제가 발생했습니다.',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
};
TypeScript
복사
main.ts
async function bootstrap() {
await loadSecrets();
const app = await NestFactory.create<NestExpressApplication>(AppModule);
...
TypeScript
복사
1차 문제발생
AppModule 선언하기 전에 미리 환경변수를 선언하려고 하였으나, 비동기적으로 호출이 되며, Nest 애플리케이션 인스턴스가 먼저 생성되며 에러 발생
Error: Config validation error: "PORT" is required. "DB_HOST" is required. "DB_PORT" is required. "DB_USERNAME" is required. "DB_PASSWORD" is required. "DB_NAME" is required. "JWT_SECRET" is required
at Function.forRoot (/Users/dominic/Workspace/tirepick-server/node_modules/@nestjs/config/dist/config.module.js:66:23)
at Object.<anonymous> (/Users/dominic/Workspace/tirepick-server/dist/src/app.module.js:241:35)
at Module._compile (node:internal/modules/cjs/loader:1108:14)
at Object.Module._extensions..js (node:internal/modules/cjs/loader:1137:10)
at Module.load (node:internal/modules/cjs/loader:988:32)
at Function.Module._load (node:internal/modules/cjs/loader:828:14)
at Module.require (node:internal/modules/cjs/loader:1012:19)
at require (node:internal/modules/cjs/helpers:93:18)
at Object.<anonymous> (/Users/dominic/Workspace/tirepick-server/dist/src/main.js:29:22)
at Module._compile (node:internal/modules/cjs/loader:1108:14)
Bash
복사
혹시 AWS 정보를 하지 못해 앞단에서 로직이 동작하지 않았는지 확인해보니 아래와 같이 정상
그렇다면 bootstrap() 함수 내에 await가 정상적으로 동작하지 않는 것일까?
다음과 같이 then을 활용하여 진행을 해보았지만 실패, 디버깅을 해도 에러 전에 loadSecrets()를 호출하지 않는다. 이로서 확신 할 수 있는 것은 loadSecrets() 함수가 실행되기 전에 인스턴스 생성이 된다는 점.
그렇다면 SecretManager를 사용한다는 전략은 불가능해진다.
왜?
이 시점에서 확인해봐야 할 것은 왜 이런 일이 발생하느냐는 것이다.
main.js가 가장 먼저 호출되며 함수는 비동기처리가 되어 순서대로 실행되어야 한다.
문제는 의외로 가까운 곳에 있다.
main.ts
import { loadSecrets } from './common/utils/loadSecrets';
import { NestExpressApplication } from '@nestjs/platform-express';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
await loadSecrets();
const app = await NestFactory.create<NestExpressApplication>(AppModule);
}
Bash
복사
app.module.ts
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
schema: process.env.DB_SCHEMA_NAME,
synchronize: false,
}),
}
TypeScript
복사
Nest의 create() 함수가 진행되어 인스턴스가 생성되기 이전에 이미 main.ts에는 AppModule을 참조하기 위해 Import 되어 있다. 가장 먼저 import가 실행되고 그 시점에 app.module.ts 내의 선언된 환경변수를 불러들이면서 에러가 발생한다.
라이프사이클
이 시점에서 NestJS 라이프사이클을 확인해보았고 5가지 Hook을 제공하고 있지만 제공하고 있는 onModuleInit, onApplicationBootstrap 또한 import 보다 먼저 실행되는 Hook이 아니다.
Bootstrapping starts는 Hook으로 제공되지 않는다.
결론은 NestJS 내에서는 해결할 방법이 없다는 것.
문제해결
NestJS 애플리케이션 단에서 aws-sdk를 사용하여 환경변수를 불러오는데에 실패했다면,
애초에 build 되기 전에 미리 환경변수를 넣을 수 있다면 가능하다고 판단되었다.
aws-cli를 이용해 ACM의 보안암호를 조회 할 수 있기 때문에, 조회된 값을 build 단계 전에 생성해버리는 방법이다.
그렇게 하기 위해서는 먼저 aws-cli를 통해 보안암호를 조회해온다.
aws 계정 엑세스 키 생성 및 aws-cli 설정을 완료했다는 가정하에 진행한다.
보안암호 조회
aws secretsmanager get-secret-value --secret-id tirepick-dev --query SecretString --output text
{"TZ": "Asia/Seoul", "PORT": "80", "CLIENT_URL": "https://webdev.tire-pick.com", "API_VER": "0.0.1", "DB_HOST": "tirepick-dev-new...}
TypeScript
복사
위와 같이 조회하게 되면 JSON 형태로 값이 조회되나 우리가 원하는 환경변수의 형태가 아니다.
TZ=Asia/Seoul
PORT=80
CLIENT_URL=https://webdev.tire-pick.com
API_VER=0.0.1
DB_HOST=tirepick-dev-newbiz-db.chd...
Shell
복사
다음과 같이 {key}={value} 형태로 변환하기 위해서는 jq 라이브러리를 이용해주어야한다.
brew install jq
Shell
복사
jq 라이브러리를 사용하면 결과값을 원하는 형태로 변환 할 수 있고 최종적으로는 아래와 같은 코드가 작성되었다.
코드 작성 시 jqplay가 도움이 되어 공유한다.
aws secretsmanager get-secret-value --secret-id tirepick-dev --query SecretString --output text | jq 'to_entries[] | \"\\(.key)=\\(.value | @text)\"' | sed 's/\"//g' > envs/.env.dev
TZ=Asia/Seoul
PORT=80
CLIENT_URL=https://webdev.tire-pick.com
API_VER=0.0.1
DB_HOST=tirepick-dev-newbiz-db.ch....
Shell
복사
package.json
작성된 코드는 pacakage.json script를 작성하여 서버 실행 시 자동으로 환경변수를 받아와 파일을 생성하도록 설정한다.
운영서버의 경우 .env를 사용하지 않고 arn을 통해 값을 조회하므로 별도로 설정해줄 필요가 없다.
개발환경에서는 .env를 통해 개발을 진행해야 하므로 start:dev에 script를 추가해준다.
"scripts": {
"start:dev": "npm run get-secrets:dev && cross-env TZ=Asia/Seoul NODE_ENV=dev nest start --debug --watch",
"get-secrets:dev": "aws secretsmanager get-secret-value --secret-id tirepick-dev --query SecretString --output text | jq 'to_entries[] | \"\\(.key)=\\(.value | @text)\"' | sed 's/\"//g' > envs/.env.dev"
},
JSON
복사
설정이 완료되었다면 npm run start:dev 또는 npm run get-secrets:dev 를 통해 .env.dev 파일이 정상적으로 생성되는지 확인해본다.
script 실행 후 파일이 정상생성되었으며 Nest 애플리케이션이 정상실행 되었다는 반가운 메시지를 만나 볼 수 있다.
task-definition 수정 및 배포
개발환경 내에서 SecretManager를 통해 환경변수를 가져올 수 있도록 되었으니 이제 ECS 내에서도 정상적으로 환경변수를 가져올 수 있도록 설정을 해주어야한다.
아래와 같이 ECS 배포 설정을 수정하기 위해 task-definition에서 민감한 정보를 arn을 통해 가져올 수 있도록 변경한다.
"secrets": [
{
"name": "DB_HOST",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:109460XXXXXX:secret:tirepick-dev-8hKZJl::DB_HOST::"
},
{
"name": "DB_PORT",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:109460XXXXXX:secret:tirepick-dev-8hKZJl::DB_PORT::"
},
{
"name": "DB_USERNAME",
"valueFrom": "arn:aws:secretsmanager:ap-northeast-2:109460XXXXXX:secret:tirepick-dev-8hKZJl::DB_USERNAME::"
},
]
JSON
복사
배포 실패 Why?
ECS로 배포가 되지 않고 무한루프가 돌고 있다.
ECS를 확인해보니 역시나 수정한 task-definition에 의해 발생 중
이 경우 ECS 배포 계정의 Role이 문제가 되는 것으로 보인다.
마치며
누군가는 오픈소스 복붙을 통해 10분만에 끝날 수 있는 설정이 이틀에 걸려 삽질을 했고 결국 오픈소스 적용은 하지못한 채 다른 방법을 사용하게 되어 공유한다.
당장 급하게 해야하는 태스크는 아니었지만 결국에 해야되는 보안사항이라면 빠르게 적용해야 된다는 생각에 계속 붙잡고 있었던 것 같다. 개발이란 참 어려운 일이지만, 막상 모든 설정을 마치고 나니 후련한 마음이 든다.
이로서 끝난 것은 아니고 script 실행을 위해서는 몇가지 선행조치가 필요하고 새롭게 추가된 script 및 aws-cli 인증 방법에 대해 팀원들에게 공유를 해야 아름다운 마무리가 가능하다.