Node JS 런타임에서 안전하고 편리하게 환경 변수 관리하기

Urim·2023년 5월 27일
1
post-thumbnail

소스 코드에서 제외해야 할 값들이 있다.

웹 서비스를 개발하다 보면 소스 코드에 포함시킬 수 없는 민감한 값들이 있습니다. 예를 들어 AWS의 Access key와 Secret Key처럼 시스템을 통제할 수 있는 권한을 제공하는 키가 그렇습니다. REST API로 통신하기 위해 외부 서버의 URL이나 host, port 정보가 필요한 경우도 그렇습니다. 시스템 외부의 변수로 인해 언제 어떻게 바뀔지 모르는 이 값들을 소스 코드에 그대로 작성한다면 (흔히 말해 '하드 코딩'한다면) 해당 값들이 변경될 때마다 소스 코드를 다시 작성해서, 재배포하는 귀찮은 일들을 겪어야 합니다. 또 보안에 치명적인 영향을 주는 값들이 그대로 담겨있는 채로 소스코드가 외부로 유출된다면 시스템 운영에 치명적인 위험을 주게 됩니다.

이 문제를 해결하기 위한 가장 쉬운 방법은 환경 변수에 이러한 값들은 정의하는 것입니다. .env 라는 파일에 환경 변수들을 저장해서 프로세스가 시작할 때 이 값들을 가져오고 Node JS 에서 전역변수로 정의되어 있는 process를 사용해 process.env.SOME_SECRET_VALUE 등의 방식으로 이 환경 변수 값을 가져올 수 있습니다. 이 때, dot-env 패키지를 사용할 수 있습니다.

환경 변수 관리는 어렵다.

환경 변수를 사용함으로써 소스 코드에서 제외하고 싶은 개발자들은 여러 가지 문제를 경험하게 됩니다. 우선, 개발자가 환경 변수를 실수로 유출시킬 수 있는 가능성이 있습니다. '일단 되는지 확인해보고, 이따 commit 하기 전에 꼭 환경 변수로 빼놔야지' 라는 생각으로 남겨둔 환경 변수를 그대로 commit하는 순간 git의 history에 영영 그 secret 값이 남게 됩니다. 이 레포지토리가 실수로 외부에 유출되는 순간, 해당 키 또한 유출되게 됩니다. .env파일을 .gitignore 에 포함하는 걸 깜빡하는 신입 개발자의 실수도 이러한 경우에 포함됩니다. 신입 개발자가 이러한 실수를 만들지 않도록 주의를 주고, 관리하는 데에도 많은 리소스가 들어갑니다.

또한, 각각의 개발 환경(로컬, 개발 서버, 테스트 서버, 프로덕션 서버 등)마다 환경 변수 값을 일일히 설정하고 관리하는 작업은 번거롭고 실수가 발생할 수 있습니다. 이로 인해 개발, 테스트, 배포 과정에서 예상치 못한 문제가 발생할 수 있으며, 환경 변수 값을 수정하는 작업으로 인해 작업이 지연될 수 있습니다. 더욱이 관리의 어려움으로 인해 작업의 효율성이 저하될 수도 있습니다.

개발자가 환경 변수를 일일히 관리할 필요가 없다면 어떨까?

이러한 문제들의 근본적인 원인은 환경 변수를 선언하고 할당하는 업무가 어플리케이션 개발자에게 할당되어 있다는 점입니다. 만약 개발자는 환경 변수의 key에 대한 정보만 알고 있고, value에 대한 정보는 전혀 알 수 없다면 어떨까요? 개발자는 키의 값을 알지 못하기 때문에 보안에 위협이 되는 실수를 저지를 염려가 전혀 없게 될 것입니다. 또 운영 단에서 바로 환경 변수 목록을 모듈 갈아끼듯이 갈아끼면서 손쉽게 환경 변수를 관리할 수 있게 될 것입니다.

이미 잘 구축된 기존의 솔루션을 여럿 찾아봤지만, 다소 부담되는 금액으로 시스템에 쉽게 적용하지 못했습니다. 그래서 오픈 소스의 도움을 받아 직접 이러한 시스템을 구축해보면 어떨까 하는 생각이 들었습니다. 시스템의 주요 목적은 다음과 같습니다:

  1. 중앙화된 관리: 개발자가 환경 변수를 직접 설정하고 관리하는 것을 방지하고, 중앙 데이터베이스에서 통합적으로 관리합니다. 이를 통해 환경 변수 설정의 일관성과 정확성을 유지할 수 있습니다.
  2. 환경별 설정: 개발, 테스트, 배포 등 다양한 환경에 대해 환경 변수를 일일히 설정하는 번거로움을 줄입니다. 각각의 환경에 대한 설정을 중앙에서 관리하고 필요한 환경에 쉽게 적용할 수 있습니다.
  3. 보안과 권한 관리: 중요한 환경 변수와 관련된 보안 요구 사항을 충족하기 위해 시스템은 적절한 접근 제어 및 사용자 권한 관리 기능을 제공합니다. 이를 통해 환경 변수의 무단 수정이나 노출을 방지할 수 있습니다.
  4. 작업 효율성 향상: 환경 변수 설정과 관리 작업을 단순화함으로써 개발자들의 작업 효율성을 향상시킵니다. 개발자는 중앙화된 인터페이스를 통해 간편하게 필요한 환경 변수를 조회하고 설정할 수 있습니다.

Infisical을 통한 환경 변수 관리 중앙화

Infisical은 오픈 소스 플랫폼으로, 애플리케이션과 인프라에서 API 키, 데이터베이스 자격 증명, 환경 변수와 같은 비밀 정보를 쉽게 저장, 관리 및 동기화할 수 있도록 도와줍니다. 이 플랫폼은 사용자 인터페이스와 중앙 데이터베이스를 통해 구성되며, 개발자가 환경 변수를 직접 관리하는 불편함을 해소하고 여러 환경 간의 일관성을 유지할 수 있습니다.

Infisical의 주요 기능으로는 프로젝트, 시크릿 개요, 시크릿 대시보드, 통합, 액세스 제어, 조직 설정 등이 있습니다. 프로젝트는 애플리케이션에 대한 시크릿을 보유하며, 개발, 테스트, 프로덕션 등과 같은 환경으로 구성됩니다. 시크릿 개요는 모든 시크릿을 한눈에 볼 수 있는 기능으로, 누락된 시크릿을 식별하는 데 유용합니다. 시크릿 대시보드에서는 특정 환경에서 시크릿을 관리하고, 시크릿의 재정의, 버전 관리, 롤백 등의 작업을 수행할 수 있습니다.

Infisical은 통합 기능을 제공하여 프로젝트 환경의 시크릿을 다양한 외부 통합 시스템과 동기화할 수 있습니다. 액세스 제어를 통해 프로젝트 멤버의 권한을 관리할 수 있고, 조직을 통해 프로젝트와 멤버를 구조화할 수 있습니다.

또한, Infisical은 암호화를 사용하여 시크릿 값을 안전하게 저장하고 공유합니다. 사용자 인증에는 Secure Remote Password (SRP)를 사용하며, 공개 키 암호화를 활용하여 시크릿 공유와 동기화를 수행합니다.

이 플랫폼은 셀프 호스팅이 가능하기 때문에, 보안이 보장된 네트워크 안에서 호스팅하여 사용하면 안전하게 환경 변수를 중앙에서 관리할 수 있습니다. 관리자는 READ 권한으로 어플리케이션을 플랫폼에 추가할 수 있고, 각 환경 별로 값을 구별하여 정의할 수 있습니다. 어플리케이션 개발자는 프로세스를 시작하기 전에 Infisical에 로그인하기만 하면 자동으로 환경 변수를 다운로드하여 프로세스를 시작합니다. 개발자는 환경 변수의 값이 무엇인지 알 필요도 없이 프로세스를 시작하여 로컬에서 서비스가 정상 동작하는지 확인할 수 있습니다.

Add User and Auth

관리용 웹 페이지에서 email로 유저를 초대할 수 있습니다.

$ infisical login --domain="http://{self-hosted-host-and-port}/api"
# id, password 입력을 통해 로그인
$ infisical init 

Run with Env

$ infisical run [options] -- [your application start command]

#example
$ infisical run --env=prod -- npm run start

Push Env

  1. 관리자용 웹 페이지에서 각 환경 추가, 환경 별 변수 추가/수정/삭제 작업을 할 수 있습니다.
  2. 권한이 주어진 사용자의 경우 CLI로 환경 변수 수정이 가능합니다.
## Example 
$ infisical secrets set --env=dev STRIPE_API_KEY=sjdgwkeudyjwe DOMAIN=example.com HASH=jebhfbwe

추가 자세한 기능은 관련 공식 문서에 정리되어 있습니다.

Configuration Object로 변환

ENV_VAULT 라는 객체에 소스코드에서 사용하는 환경변수를 모두 선언하여 관리합니다. parseEnvVariable 라는 함수를 사용하여 환경변수 값이 존재하는 지 여부를 확인하고, 타입을 강제해줄 수 있습니다.

// env.server.ts
const number = (value: string) => {
  const result = Number(value);
  if (!Number.isNaN(result)) {
    return result;
  }
};

const boolean = (value: string) => {
  switch (value) {
    case "true": {
      return true;
    }
    case "false": {
      return false;
    }
  }
};

const string = (value: string) => value;

const typeConverter = { boolean, number, string };
type SupportedTypes = keyof typeof typeConverter;

const parseEnvVariable = (
  key: string,
  type: SupportedTypes,
  defaultValue?: any
) => {
  const value = process.env[key];
  if (value !== undefined) {
    const result = typeConverter[type](value);
    if (result !== undefined) {
      return result;
    }
    throw new Error(`process.env.${key}에 적절한 값을 설정하지 않았습니다`);
  }
  if (defaultValue !== undefined) {
    return defaultValue;
  }
  throw new Error(`process.env.${key}에 할당할 값이 없습니다`);
};

export const ENV_VAULT = {
  sessionSecret: parseEnvVariable("SESSION_SECRET", "string") as string,
  ...
};

export const isProduction = () => {
  try {
    return process.env.NODE_ENV === "production";
  } catch (e) {
    return false;
  }
};

사용 예시

const sessionSecret = ENV_VAULT.sessionSecret; // type is string

Custom Lint로 개발자의 실수 원천 차단하기

env.server.ts 과 서버에서 접근하지 않는 client 코드 (components directory 내 react function component, storybook component 등) 을 제외한 파일에서는 환경변수를 직접 접근하는 것을 막습니다. custom lint를 정의해서 ‘process.env’ 가 작성되었을 시 error를 표시하게 합니다. 아래는 process.env의 사용을 제한하는 eslint custome rule 예시입니다.

module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "Prevent the use of process.env except in specified files.",
      category: "Best Practices",
      recommended: true,
    },
    schema: [],
  },
  create(context) {
    const restrictedFiles = new Set(context.options[0] || []);

    return {
      Identifier(node) {
        if (node.name === "process" && node.parent.property?.name === "env") {
          const currentFilePath = context.getFilename();

          if (!restrictedFiles.has(currentFilePath)) {
            context.report({
              node,
              message: "Do not use process.env outside of specified files.",
            });
          }
        }
      },
    };
  },
};

eslint config를 조절해 lint를 설정하고, 검사를 제외할 파일을 명시합니다.

#eslintrc.js
/** @type {import('eslint').Linter.Config} */
module.exports = {
  plugins: ["{package-name}"],
	...
  rules: {
    "{package-name}/no-use-of-process-env": "error",
  },
  overrides: [
    {
      files: [
        "app/lib/env.server.ts",
        "app/components/**/*.{ts,tsx}",
        "stories/**/*.{ts,tsx}",
      ],
      rules: {
        "{package-name}/no-use-of-process-env": "off",
      },
    },
  ],
};

CI/CD 파이프라인에 적용하기

CLI로 user authentication을 진행할 수 있지만, CLI interaction 은 CI/CD 자동화 파이프라인을 구축하는 데 불필요한 어려움을 줍니다. Infisical 은 servie token 발급도 가능하기 때문에 해당 값만 따로 환경 변수로 관리하고, CI/CD 파이프라인에서는 해당 값을 사용해 나머지 환경 변수들을 가져올 수 있도록 자동화할 수 있습니다.

결국 환경 변수를 따로 사용해야 하지 않냐고 할 수 있지만, 언제든 무효화시킬 수 있는 값 하나를 관리하는 데 드는 비용과, 여러가지 key와 그때그때 바뀌는 value 여러 개를 한꺼번에 관리하는 비용에는 큰 차이가 있기 때문에, 여전히 의미 있는 효율화를 이룰 수 있습니다. 아래는 배포에 사용하는 Dockerfile 예시입니다.

FROM node

...

ARG INFISICAL_TOKEN
ARG INFISICAL_API_URL

RUN curl -1sLf \
'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash
RUN apt-get update && apt-get install -y infisical

...

ENV INFISICAL_TOKEN $INFISICAL_TOKEN
ENV INFISICAL_API_URL $INFISICAL_API_URL
RUN infisical run npm run build --token $INFISICAL_TOKEN --domain $INFISICAL_API_URL

...

ENTRYPOINT [ "infisical", "run", "npm", "run" ]
CMD [ "start" ]

참고 자료

Introduction - Infisical

The death of the .env file***

Node.js 기반에서 환경변수 사용하기 (dotenv, cross-env)

ESLint 조금 더 잘 활용하기

private npm registry 구축 :: 마이구미

profile
컴퓨터공학을 배웁니다.

0개의 댓글