Một trong các vấn đề cơ bản khi deployment và đảm bảo security cho ứng dụng đó là làm sao để quản lý config một cách tiện lợi, an toàn, dễ cập nhật.

Tuân theo một method là 12factor ta coi như việc dùng biến môi trường để lưu config là chuyện hiển nhiên không cần phải giải thích.

1. What the fuck are u doing now?

Lấy ví dụ là ứng dụng Rails, cách quản lý config hiện tại được mô tả như sau:

Sử dụng gem Figaro để quản lý config: Ví dụ khi sử dụng Pusher (một 3rd service để push notification in-app về cho mobile) như sau:

# config/application.yml
defaults: &defaults
  PUSHER_APP_ID: "2954"
  PUSHER_KEY: "7381a978f7dd7f9a1117"
  PUSHER_SECRET: "abdc3b896a0ffb85d373"
  PUSHER_CLUSTER: "mt1"

development:
  <: *defaults

production:
  <: *defaults
  PUSHER_APP_ID: "1234"
  PUSHER_KEY: "7381a978f7dd7f9a1117"
  PUSHER_SECRET: "abdc3b896a0ffb85d373"
  PUSHER_CLUSTER: "mt1"

Config pusher trong initializer như sau:

# initializers/pusher.rb

Pusher.app_id = ENV['PUSHER_APP_ID']
Pusher.key = ENV['PUSHER_KEY']
Pusher.secret = ENV['PUSHER_SECRET']
Pusher.cluster = ENV['PUSHER_CLUSTER']

Ta sẽ lưu thông tin cấu hình là một cặp key/value vào một tập tin yaml và load cấu hình thông qua initializers. Một initializer đơn giản là một tập tin Ruby được đặt tại config/initializers. Initializers thường được dùng để lưu cấu hình mà muốn load sau cùng, khi framework và tất cả các gem (thư viện trong ruby) được load.

Về cơ bản, vậy là xong. Tuy nhiên nếu ta muốn ràng buộc chặt chẽ hơn, yêu cầu là các thông tin cấu hình bắt buộc phải tồn tại, nếu thiếu sẽ không cho khởi tạo ứng dụng. Figaro hỗ trợ 2 phương thức là proactively và lazily.

Proactively nghĩa là ứng dụng sẽ raise một exception nếu có bất cứ giá trị config nào ràng buộc nhưng không được thiết lập ngay khi ứng dụng khởi tạo (initialization).

# config/initializers/figaro.rb

Figaro.require_keys("PUSHER_APP_ID", "PUSHER_KEY", "PUSHER_SECRET", "PUSHER_CLUSTER")

Trong khi lazily không raise exception khi khởi tạo, nên dẫn tới các lỗi không mong đợi trong runtime (khi nào cần dùng mới kiểm tra và nếu thiếu sẽ raise exception).

# config/initializers/pusher.rb

Pusher.app_id = Figaro.env.PUSHER_APP_ID!
Pusher.key    = Figaro.env.PUSHER_KEY!
Pusher.secret = Figaro.env.PUSHER_SECRET!
Pusher.cluster = Figaro.env.PUSHER_CLUSTER!

Ngoài Figaro, có một số gem khác tương tự, ví dụ dotenv. Về cơ bản, cách tiếp cận giữa Figaro và dotenv là như nhau, cùng được phát triển tại một thời điểm và giải quyết các vấn đề tương tự nhau:

Tương đồng:

  • Hỗ trợ tốt cho các ứng dụng Ruby.
  • Phổ biến và vẫn còn được bảo trì code tốt.
  • Đều lấy cảm hứng từ concept Twelve-Factor App.
  • Đều lưu giá trị config trong biến môi trường ENV.

Khác biệt:

  • Tập tin config:
    • Figaro lưu tất cả các biến config của các môi trường trong cùng một tập tin.
    • Dotenv hỗ trợ tách biến config của các môi trường ra các tập tin riêng biệt (.env.local, .env.development, .env).
  • Định dạng tập tin cấu hình
    • Figaro sử dụng yaml với một cặp key/value.
    • Dotenv là một tập hợp các giá trị KEY=VALUE.
  • Security vs. Convenience
    • Figaro quy ước là không bao giờ commit tập tin cấu hình.
    • Dotenv khuyến khích commit cấu hình của môi trường development.
  • Framework Focus
    • Figaro tập trung vào ứng dụng Rails.
    • Dotenv có thể sử dụng cho các ứng dụng Ruby.

Viết dài dài cho vui chứ thực ra không có nhiều sự khác biệt lắm giữa hai thư viện này, dùng cái nào cũng được.

Lưu và tải config khi ứng dụng khởi tạo thì như vậy, nhưng quản lý cấu hình và deployment thì cần thêm các yêu cầu như sau:

  • Giá trị cấu hình trên môi trường production KHÔNG chia sẻ cho developer.
  • Giá trị cấu hình phải khác biệt giữa các môi trường, ví dụ PUSHER_SECRET_KEY phải khác biệt giữa development và production. Bảo vệ key của môi trường production an toàn nhất có thể. Cho phép apply các rule khác nhau lên từng cấu hình (ví dụ key của development chỉ dùng để test nên limit notification …).
  • Cần có một cách tiện lợi để chia sẻ tập tin cấu hình ở development khi có một người mới join team nhưng không được commit vào git.
  • Cần có một cách để quản lý version của cấu hình trên production. Dễ dàng thêm mới, delivery lên production.

Nguyên tắc số 1: Quản lý tất cả cấu hình qua biến môi trường (ENV), thông qua thư viện, không hard-code bất cứ chỗ nào trong ứng dụng. Nghĩa là không chơi như vầy:

# initializers/pusher.rb

Pusher.secret = "bX5kuRUuVJlP1AD"

Nguyên tắc số 2: Không commit bất kỳ tập tin cấu hình nào vào git, không lưu trữ các credentials info vào git. Nghĩa là không được tồn tại các tập tin như sau:

> tree config/
├── config
│   ├── application.yml
│   ├── database.yml
│   ├── mongoid.yml
│   ├── newrelic.yml
│   ├── redis.yml
│   ├── secrets.yml
│   └── sidekiq.yml

Nguyên tắc số 3: Để đảm bảo consistensy của giá trị cấu hình giữa các môi trường (có ở production mà thiếu ở development) và để dễ dàng cho developer mới khi tham gia dự án, commit vào git một tập tin example của từng tập tin cấu hình. Ví dụ:

> tree config/
├── config
│   ├── application.example.yml
│   ├── database.example.yml
│   ├── mongoid.example.yml
│   ├── newrelic.example.yml
│   ├── redis.example.yml
│   ├── secrets.example.yml
│   └── sidekiq.example.yml

Lưu ý:

  • Giữ nguyên extention là yaml để sử dụng highlight syntax của editor (không nên dùng application.yml.example).
  • Khi cần sử dụng thêm một biến môi trường mới NÊN thêm vào file example và commit lên git, trên production cũng tra cứu ngược vào tập tin example để đảm bảo không thiếu/dư thừa bất kỳ giá trị nào.

=> Tuy nhiên tập tin example chỉ giải quyết được các credentials info mà developer có thể tự tạo (như database, redis …) chứ credentials info từ 3rd service thì vẫn cần phải chia sẻ thủ công (người cũ gửi cho người mới).

Nguyên tắc số 4: Quản lý credentials độc lập cho từng môi trường <=> pick các service cho phép generate, phân quyền và giới hạn độc lập trên từng credential. Ví dụ:

  • Tạo thông tin pusher cho cùng một ứng dụng nhưng 2 môi trường development (coding + debug) và production.
  • Giới hạn số push notification cho môi trường development (vì debug không cần nhiều, tránh bug/lộ secret_key dẫn tới tốn chi phí).
  • Cho phép truy cập web admin của development key, hỗ trợ debugging.

Tách độc lập cho từng môi trường, từng ứng dụng giúp dễ dàng revoker khi thông tin bị lộ ra ngoài, hoặc nếu lộ, giảm thiểu ảnh hưởng chung đến toàn bộ ứng dụng.

Cũng cần lưu ý là một số dịch vụ như IAM AWS, secret_key chỉ hiện thị đúng một lần lúc tạo, sau này sẽ không thể xem lại được nữa, nên nếu quên secret_key thì chỉ có cách tạo secret_key khác.

Vấn đề là lưu thông tin cấu hình trên production ở đâu để:

  • Lỡ tay xoá mất hoặc server có sự cố mất thông tin cấu hình.
  • Dễ dàng sync khi deployment.
  • Có mã hoá nhưng phải quản lý được version tương tự như quản lý code với git.

Ví dụ về một quy trình deployment thủ công như sau:

  • Cấu trúc thư mục của node deployment như sau:

    > tree /fk
    /fk
    └── predeploy
        └── fk-service-hs
            ├── current -> releases/20200104193548
            ├── fk-service-hs_master # git-repo_branch
            ├── override-latest/config
            └── releases/20200104193548
    
  • Với predeploy là nơi code sẽ được chuẩn bị để deployment.

  • override-latest/config là thông tin cấu hình của production, lưu trữ các tập tin cần chép đè sử dụng cho production.

  • fk-service-hs_master thư mục chứa source code lấy từ github về.

  • current là symbol link tới releases/20200104193548 đây cũng chính là code chạy production. current = fk-service-hs_master + override-latest/* .

  • Ví dụ ta cần dùng một tập thông tin khác cho Pusher trên production, ta sẽ override lại toàn bộ tập tin application.yml, tương tự với các tập tin yaml khác.

  • Cuối cùng chỉ cần sync current, releases/20200104193548 tới các node production là xong.

  • Ý tưởng là của capistrano.

Như ta thấy, thông tin cấu hình trên production có thể lưu trữ ở 3 nơi:

  • Trên node deployment, lưu ở override-latest/config. Mỗi lần cần update gì thì ssh vào node này cập nhật.
  • Hơi mất công khi cần update đều cần ssh vào server.
  • Chỉ lưu trên server production, nên ai vào production mới xem được nên không nhất thiết phải mã hoá.
  • Không quản lý được version, nhưng không cần backup vì lưu trên nhiều node.
  • Lưu ở một bên thứ 3 như S3 bucket.
    • Khi deployment, nếu cần cập nhật thông tin cấu hình thì cho phép tick để chọn sync mới cấu hình từ từ S3 bucket -> override-latest/config. Node deployment phải có quyền access vào S3 bucket.
    • Multi-version (tính năng của S3) nhưng không có commit log.
    • Có sẵn mã hoá từ KMS (tính năng của S3).
    • Có thể lưu local + git sau đó sync qua aws s3 sync nhưng hơi mất công vì vừa phải commit vừa sync. (git add . && git ci -m'Update pusher infos' && aws s3 sync . s3://fk-service-hs/ --profile lazy-ops).
  • Cuối cùng là lưu thẳng vào github, đây là cách khoẻ nhất.
    • Quản lý được version, commit log, dễ dàng update tương tự code (commit, push, PR, review).
    • Native với deployment vì chỉ cần cấp thêm quyền vào một repo khác, dễ dàng sync mới về node deployment.
    • Không mã hoá 🤨🤨🤨. (mọi thứ đều ổn cho tới chỗ này).

Với cách cuối, có thể dùng một secret_key (mã hoá bất đối xứng) để mã hoá và validate trước khi push lên github nhưng lại mất đi tính năng quản lý version (vim và git diff) nên cơ bản cũng không xài được.

Đại khái bạn sẽ muốn quản lý version của config như vầy:

- PUSHER_KEY: "7381a978f7dd7f9a1117"
+ PUSHER_KEY: "7381a9sjd7dd7f9a1117"
  PUSHER_SECRET: "abdc3b896a0ffb85d373"

2. What next?

Có bạn sẽ hỏi sao không xài Vault? Về cơ bản cũng gặp các vấn đề trên, chỉ khác là lưu vào file thì giờ thêm con server nữa, lưu trữ biến môi trường dùng cho cấu hình trên con server Vault. Nhà thì bao việc, thêm con server nữa, lại phải HA con server này, gắn monitoring các kiểu, nghĩ thôi cũng đã hết ngày.

Tuân theo một method là 12factor ta coi như việc dùng biến môi trường để lưu config là chuyện hiển nhiên không cần phải giải thích.

Có rất nhiều vấn đề nếu sử dụng biến môi trường để quản lý config và đặc biệt nếu sử dụng dotenv, lưu trữ trong một tập tin plain text. Ví dụ:

  • Chỉ hỗ trợ string, kiểu dữ liệu quá đơn giản cho các nhu cầu phức tạp (cả figaro và dotenv đều gặp vấn đề).

  • Tổ chức config, chia sẻ config giữa các namespace (yaml hỗ trợ nên figaro có thể tổ chức ra nhiều file, nhiều namespace, share giữa các namespace, dotenv không hỗ trợ).