├── .gitignore ├── app ├── javascript │ ├── utils │ │ ├── helpers.js │ │ └── api.js │ └── .DS_Store ├── assets │ ├── javascripts │ │ ├── application.js │ │ └── active_admin.js │ └── stylesheets │ │ └── active_admin.scss ├── .DS_Store ├── controllers │ ├── api │ │ ├── api_controller.rb │ │ └── home_controller.rb │ ├── errors_controller.rb │ └── home_controller.rb ├── jobs │ └── http_post_job.rb ├── views │ ├── errors │ │ ├── 404.html.erb │ │ ├── 422.html.erb │ │ └── 500.html.erb │ └── home │ │ └── index.html.erb ├── template.rb └── lib │ └── http_helper.rb ├── lib ├── generators │ └── stimulus │ │ ├── USAGE │ │ ├── templates │ │ └── controller.js.erb.tt │ │ └── stimulus_generator.rb ├── template.rb └── tasks │ └── deploy.rake.tt ├── ruby-version.tt ├── public └── robots.txt ├── dockerignore ├── spec ├── factories.rb ├── template.rb ├── support │ └── factory_bot.rb └── rails_helper.rb ├── config ├── sidekiq.yml ├── template.rb ├── initializers │ └── sidekiq.rb ├── routes.rb ├── environments │ ├── production.rb │ └── development.rb └── application.rb ├── .DS_Store ├── Procfile ├── k8s ├── sidekiq_quite.sh ├── service.yaml.tt ├── template.rb ├── cluster │ ├── lets_encrypt_issuer.yaml.tt │ └── load_balancer.yaml.tt ├── ingress.yaml.tt ├── migration.yaml.tt ├── redis.yaml.tt ├── sidekiq.yaml.tt ├── project │ └── application-nginx-conf.yaml.tt ├── web.yaml.tt ├── demo.yaml.tt └── README.md.tt ├── README.md.tt ├── gitignore ├── Dockerfile.tt ├── LICENSE ├── README.md ├── .circleci └── config.yml.tt └── template.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /app/javascript/utils/helpers.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/generators/stimulus/USAGE: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ruby-version.tt: -------------------------------------------------------------------------------- 1 | <%= RUBY_VERSION %> -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /dockerignore: -------------------------------------------------------------------------------- 1 | /log 2 | /public/assets 3 | /public/packs -------------------------------------------------------------------------------- /spec/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | end 3 | -------------------------------------------------------------------------------- /config/sidekiq.yml: -------------------------------------------------------------------------------- 1 | :queues: 2 | - default 3 | - [mailers, 2] -------------------------------------------------------------------------------- /app/assets/javascripts/active_admin.js: -------------------------------------------------------------------------------- 1 | //= require arctic_admin/base 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrocket/rails-template/HEAD/.DS_Store -------------------------------------------------------------------------------- /app/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrocket/rails-template/HEAD/app/.DS_Store -------------------------------------------------------------------------------- /app/controllers/api/api_controller.rb: -------------------------------------------------------------------------------- 1 | class Api::ApiController < ActionController::API 2 | end 3 | -------------------------------------------------------------------------------- /app/javascript/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astrocket/rails-template/HEAD/app/javascript/.DS_Store -------------------------------------------------------------------------------- /spec/template.rb: -------------------------------------------------------------------------------- 1 | copy_file "spec/support/factory_bot.rb" 2 | copy_file "spec/factories.rb" 3 | apply "spec/rails_helper.rb" 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/active_admin.scss: -------------------------------------------------------------------------------- 1 | $primary-color: #fc0; 2 | @import 'activeadmin_addons/all'; 3 | @import "arctic_admin/base"; 4 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | require "factory_bot_rails" 2 | 3 | RSpec.configure do |config| 4 | config.include FactoryBot::Syntax::Methods 5 | end 6 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | rails: bundle exec rails s -p 3000 -b 0.0.0.0 2 | tailwind: rails tailwindcss:watch 3 | sidekiq: bundle exec sidekiq -e development -C config/sidekiq.yml -------------------------------------------------------------------------------- /k8s/sidekiq_quite.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Find Pid 4 | SIDEKIQ_PID=$(ps aux | grep sidekiq | grep busy | awk '{ print $2 }') 5 | # Send TSTP signal 6 | kill -SIGTSTP $SIDEKIQ_PID -------------------------------------------------------------------------------- /app/jobs/http_post_job.rb: -------------------------------------------------------------------------------- 1 | class HttpPostJob < ApplicationJob 2 | queue_as :default 3 | include HttpHelper 4 | 5 | def perform(url, options = {}) 6 | post(url, options) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/controllers/errors_controller.rb: -------------------------------------------------------------------------------- 1 | class ErrorsController < ApplicationController 2 | def show 3 | status_code = params[:code] || 500 4 | render status_code.to_s, status: status_code 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/errors/404.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 404 Not found. 3 | Please design this page @ views/errors/404.html.erb 4 |
-------------------------------------------------------------------------------- /app/views/errors/422.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 422 Forbidden. 3 | Please design this page @ views/errors/422.html.erb 4 |
-------------------------------------------------------------------------------- /app/views/errors/500.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 500 Unknown Error. 3 | Please design this page @ views/errors/500.html.erb 4 |
-------------------------------------------------------------------------------- /k8s/service.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: <%= k8s_name %>-web-svc 5 | spec: 6 | ports: 7 | - port: 80 8 | targetPort: 80 9 | selector: 10 | app: <%= k8s_name %>-web -------------------------------------------------------------------------------- /lib/template.rb: -------------------------------------------------------------------------------- 1 | template "lib/tasks/deploy.rake.tt" 2 | template "lib/generators/stimulus/templates/controller.js.erb.tt" 3 | copy_file "lib/generators/stimulus/stimulus_generator.rb" 4 | copy_file "lib/generators/stimulus/USAGE" 5 | -------------------------------------------------------------------------------- /config/template.rb: -------------------------------------------------------------------------------- 1 | apply "config/application.rb" 2 | apply "config/environments/development.rb" 3 | apply "config/environments/production.rb" 4 | apply "config/routes.rb" 5 | 6 | copy_file "config/initializers/sidekiq.rb" 7 | copy_file "config/sidekiq.yml" 8 | -------------------------------------------------------------------------------- /app/controllers/api/home_controller.rb: -------------------------------------------------------------------------------- 1 | module Api 2 | class HomeController < Api::ApiController 3 | # sample api 4 | def index 5 | render json: { 6 | hello: "#{Rails.version} (#{Rails.env})" 7 | } 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /app/controllers/home_controller.rb: -------------------------------------------------------------------------------- 1 | class HomeController < ApplicationController 2 | def index 3 | end 4 | 5 | # for k8s health check 6 | def health_check 7 | render json: { 8 | rails_version: Rails.version, 9 | deploy_version: ENV.fetch("DEPLOY_VERSION") 10 | }, status: :ok 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /README.md.tt: -------------------------------------------------------------------------------- 1 | ## Start dev 2 | 3 | ```bash 4 | rails db:create 5 | rails db:migrate 6 | bundle && yarn && rails dev 7 | ``` 8 | 9 | ## Deploy 10 | 11 | [.circleci/config.yml](https://circleci.com/) 12 | 13 | ### Set credentials 14 | 15 | open credential and paste db, redis urls 16 | 17 | ```bash 18 | EDITOR="nano" rails credentials:edit 19 | 20 | # like this 21 | production: 22 | database_url: DATABASE_URL 23 | redis_url: REDIS_URL 24 | ``` 25 | 26 | ### Kubernetes 27 | 28 | [k8s/README.md](k8s/README.md) -------------------------------------------------------------------------------- /k8s/template.rb: -------------------------------------------------------------------------------- 1 | template "k8s/README.md.tt" 2 | template "k8s/demo.yaml.tt" 3 | 4 | template "k8s/ingress.yaml.tt" 5 | template "k8s/service.yaml.tt" 6 | template "k8s/web.yaml.tt" 7 | template "k8s/sidekiq.yaml.tt" 8 | template "k8s/migration.yaml.tt" 9 | template "k8s/redis.yaml.tt" 10 | 11 | template "k8s/project/application-nginx-conf.yaml.tt", "k8s/project/#{k8s_name}-nginx-conf.yaml" 12 | template "k8s/cluster/lets_encrypt_issuer.yaml.tt" 13 | template "k8s/cluster/load_balancer.yaml.tt" 14 | copy_file "k8s/sidekiq_quite.sh" 15 | 16 | template "Dockerfile.tt" 17 | -------------------------------------------------------------------------------- /app/template.rb: -------------------------------------------------------------------------------- 1 | copy_file "app/controllers/api/api_controller.rb" 2 | copy_file "app/controllers/api/home_controller.rb" 3 | copy_file "app/controllers/home_controller.rb" 4 | copy_file "app/controllers/errors_controller.rb" 5 | 6 | copy_file "app/views/errors/404.html.erb" 7 | copy_file "app/views/errors/422.html.erb" 8 | copy_file "app/views/errors/500.html.erb" 9 | copy_file "app/views/home/index.html.erb" 10 | 11 | copy_file "app/javascript/utils/api.js" 12 | copy_file "app/javascript/utils/helpers.js" 13 | 14 | run "bin/importmap add axios" 15 | copy_file "app/lib/http_helper.rb" 16 | copy_file "app/jobs/http_post_job.rb" 17 | -------------------------------------------------------------------------------- /k8s/cluster/lets_encrypt_issuer.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: cert-manager.io/v1alpha2 2 | kind: ClusterIssuer 3 | metadata: 4 | name: letsencrypt-prod 5 | namespace: cert-manager 6 | spec: 7 | acme: 8 | # The ACME server URL 9 | server: https://acme-v02.api.letsencrypt.org/directory 10 | # Email address used for ACME registration 11 | email: <%= admin_email %> 12 | # Name of a secret used to store the ACME account private key 13 | privateKeySecretRef: 14 | name: letsencrypt-prod 15 | # Enable the HTTP-01 challenge provider 16 | solvers: 17 | - http01: 18 | ingress: 19 | class: nginx -------------------------------------------------------------------------------- /lib/generators/stimulus/templates/controller.js.erb.tt: -------------------------------------------------------------------------------- 1 | //
2 | // 3 | //
4 | import { Controller } from "@hotwired/stimulus"; 5 | import api from "utils/api"; 6 | import helpers from 'utils/helpers'; 7 | 8 | // https://stimulus.hotwired.dev/reference 9 | export default class extends Controller { 10 | static targets = [ "" ]; 11 | static outlets = [ "" ]; 12 | static classes = [ "" ]; 13 | static values = {}; 14 | 15 | initialize() { 16 | 17 | } 18 | 19 | connect() { 20 | console.log("Stimulus connected from <%%= stimulus_path %>"); 21 | } 22 | 23 | disconnect() { 24 | 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /k8s/ingress.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: <%= k8s_name %>-ingress 5 | annotations: 6 | kubernetes.io/ingress.class: "nginx" 7 | cert-manager.io/cluster-issuer: "letsencrypt-prod" 8 | nginx.ingress.kubernetes.io/proxy-body-size: "20m" 9 | spec: 10 | tls: 11 | - hosts: 12 | - <%= app_domain %> 13 | secretName: <%= k8s_name %>-tls 14 | rules: 15 | - host: <%= app_domain %> 16 | http: 17 | paths: 18 | - path: / 19 | pathType: Prefix 20 | backend: 21 | service: 22 | name: <%= k8s_name %>-web-svc 23 | port: 24 | number: 80 -------------------------------------------------------------------------------- /k8s/migration.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: <%= k8s_name %>-migration 5 | spec: 6 | ttlSecondsAfterFinished: 15 7 | template: 8 | spec: 9 | imagePullSecrets: 10 | - name: digitalocean-access-token 11 | containers: 12 | - name: migration-app 13 | image: <%= container_registry_path %>:$IMAGE_TAG 14 | imagePullPolicy: Always 15 | command: [ "/bin/sh","-c" ] 16 | args: [ "bin/rails db:migrate" ] 17 | env: 18 | - name: RAILS_ENV 19 | value: "production" 20 | - name: RAILS_MASTER_KEY 21 | valueFrom: 22 | secretKeyRef: 23 | name: <%= k8s_name %>-secrets 24 | key: <%= k8s_name %>-master-key 25 | restartPolicy: Never -------------------------------------------------------------------------------- /gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore uploaded files in development. 17 | /storage/* 18 | !/storage/.keep 19 | 20 | /public/assets 21 | .byebug_history 22 | 23 | # Ignore master key for decrypting credentials and more. 24 | /config/master.key 25 | /public/packs 26 | /public/packs-test 27 | /node_modules 28 | /yarn-error.log 29 | yarn-debug.log* 30 | .yarn-integrity 31 | .idea 32 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | insert_into_file "spec/rails_helper.rb", after: /require 'rspec\/rails'\n/ do 2 | <<~'RUBY' 3 | require 'support/factory_bot' 4 | require "sidekiq/testing" 5 | require "mock_redis" 6 | RUBY 7 | end 8 | 9 | insert_into_file "spec/rails_helper.rb", after: "RSpec.configure do |config|\n" do 10 | <<~'RUBY' 11 | config.use_transactional_fixtures = true 12 | config.include Devise::Test::IntegrationHelpers 13 | config.include ActiveSupport::Testing::TimeHelpers 14 | config.include ActiveJob::TestHelper 15 | config.before(:suite) do 16 | DatabaseCleaner.strategy = :transaction 17 | DatabaseCleaner.clean_with(:truncation) 18 | end 19 | config.before(:all) do 20 | Sidekiq::Testing.fake! 21 | end 22 | config.before(:each) do 23 | Sidekiq::Worker.clear_all 24 | allow(Redis).to receive(:new) { MockRedis.new } 25 | end 26 | RUBY 27 | end 28 | -------------------------------------------------------------------------------- /config/initializers/sidekiq.rb: -------------------------------------------------------------------------------- 1 | redis_url = if Rails.env.production? 2 | redis_url = Rails.application.credentials.dig(:production, :redis_url) 3 | "#{redis_url || ENV["REDIS_URL"]}/1" 4 | elsif Rails.env.test? 5 | "#{ENV.fetch("REDIS_URL") { "redis://localhost:6379" }}/1" 6 | else 7 | "redis://localhost:6379/1" 8 | end 9 | 10 | Sidekiq.configure_server do |config| 11 | config.redis = { url: redis_url } 12 | end 13 | 14 | Sidekiq.configure_client do |config| 15 | config.redis = { url: redis_url } 16 | end 17 | 18 | # if Rails.env.development? 19 | # require 'sidekiq/testing' 20 | # Sidekiq::Testing.inline! 21 | # end 22 | 23 | Sidekiq.default_job_options = { retry: 0, backtrace: true } 24 | 25 | schedule_file = "config/schedule.yml" 26 | if Rails.env.production? && File.exist?(schedule_file) && Sidekiq.server? 27 | Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) 28 | end -------------------------------------------------------------------------------- /k8s/cluster/load_balancer.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | annotations: 5 | service.beta.kubernetes.io/do-loadbalancer-enable-proxy-protocol: 'true' 6 | service.beta.kubernetes.io/do-loadbalancer-hostname: <%= app_domain %> 7 | labels: 8 | helm.sh/chart: ingress-nginx-4.0.6 9 | app.kubernetes.io/name: ingress-nginx 10 | app.kubernetes.io/instance: ingress-nginx 11 | app.kubernetes.io/version: 1.1.1 12 | app.kubernetes.io/managed-by: Helm 13 | app.kubernetes.io/component: controller 14 | name: ingress-nginx-controller 15 | namespace: ingress-nginx 16 | spec: 17 | type: LoadBalancer 18 | externalTrafficPolicy: Local 19 | ports: 20 | - name: http 21 | port: 80 22 | protocol: TCP 23 | targetPort: http 24 | - name: https 25 | port: 443 26 | protocol: TCP 27 | targetPort: https 28 | selector: 29 | app.kubernetes.io/name: ingress-nginx 30 | app.kubernetes.io/instance: ingress-nginx 31 | app.kubernetes.io/component: controller -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | insert_into_file "config/routes.rb", before: /^end/ do 2 | if use_active_admin 3 | <<-'RUBY' 4 | 5 | authenticate :admin_user do 6 | require 'sidekiq/web' 7 | require 'sidekiq/cron/web' 8 | mount Sidekiq::Web => '/admin/sidekiq' 9 | end 10 | RUBY 11 | else 12 | <<-'RUBY' 13 | 14 | require 'sidekiq/web' 15 | require 'sidekiq/cron/web' 16 | mount Sidekiq::Web => '/admin/sidekiq' 17 | RUBY 18 | end 19 | end 20 | 21 | insert_into_file "config/routes.rb", before: /^end/ do 22 | <<-'RUBY' 23 | 24 | namespace :api do 25 | get '/home/index' => 'home#index' 26 | end 27 | RUBY 28 | end 29 | 30 | insert_into_file "config/routes.rb", before: /^end/ do 31 | <<-'RUBY' 32 | root 'home#index' 33 | RUBY 34 | end 35 | 36 | insert_into_file "config/routes.rb", before: /^end/ do 37 | <<-'RUBY' 38 | 39 | %w( 404 422 500 ).each do |code| 40 | get code, :to => "errors#show", :code => code 41 | end 42 | 43 | get 'health_check', to: 'home#health_check' 44 | RUBY 45 | end 46 | -------------------------------------------------------------------------------- /Dockerfile.tt: -------------------------------------------------------------------------------- 1 | FROM ruby:<%= RUBY_VERSION %>-alpine 2 | 3 | RUN apk add \ 4 | bash git openssh \ 5 | nano \ 6 | curl-dev \ 7 | ca-certificates \ 8 | build-base \ 9 | libxml2-dev \ 10 | tzdata \ 11 | postgresql-dev \ 12 | yarn \ 13 | imagemagick \ 14 | vips-dev \ 15 | libc6-compat \ 16 | gettext 17 | 18 | ARG master_key 19 | ENV MASTER_KEY=$master_key 20 | 21 | ARG deploy_version 22 | ENV DEPLOY_VERSION=$deploy_version 23 | 24 | ARG secret_key_base 25 | ENV SECRET_KEY_BASE=$secret_key_base 26 | 27 | ARG rails_env 28 | ENV RAILS_ENV=$rails_env 29 | 30 | ARG bundle_dir 31 | ENV BUNDLE_DIR=$bundle_dir 32 | 33 | ENV RAILS_ROOT /app 34 | 35 | RUN mkdir -p $RAILS_ROOT 36 | WORKDIR $RAILS_ROOT 37 | 38 | COPY Gemfile Gemfile.lock ./ 39 | RUN gem install bundler:2.3.26 40 | RUN bundle config build.google-protobuf --with-cflags=-D__va_copy=va_copy 41 | RUN BUNDLE_FORCE_RUBY_PLATFORM=1 bundle install --path $BUNDLE_DIR --jobs 20 --retry 5 --without development test 42 | 43 | COPY . . 44 | 45 | RUN bundle exec rake assets:precompile -------------------------------------------------------------------------------- /lib/generators/stimulus/stimulus_generator.rb: -------------------------------------------------------------------------------- 1 | class StimulusGenerator < Rails::Generators::Base 2 | source_root File.expand_path("templates", __dir__) 3 | argument :controller_name, type: :string, required: true 4 | argument :action_name, type: :string, default: false 5 | attr_accessor :stimulus_path, :controller_pattern 6 | 7 | def set_up 8 | if action_name.present? 9 | @stimulus_path = "app/javascript/controllers/#{controller_name.underscore}/#{action_name.underscore}_controller.js" 10 | @controller_pattern = "#{ruby_to_stimulus(controller_name)}--#{ruby_to_stimulus(action_name)}" 11 | else 12 | @stimulus_path = "app/javascript/controllers/#{controller_name.underscore}_controller.js" 13 | @controller_pattern = ruby_to_stimulus(controller_name) 14 | end 15 | end 16 | 17 | def generate_stimulus 18 | template "controller.js.erb", stimulus_path 19 | end 20 | 21 | private 22 | 23 | def ruby_to_stimulus(string) 24 | string.underscore 25 | .tr("_", "-") 26 | .split("/") 27 | .join("--") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Astrocket 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | gsub_file "config/environments/production.rb", "config.assets.compile = false", "config.assets.compile = true" 2 | 3 | insert_into_file "config/environments/production.rb", before: /^end/ do 4 | <<-'RUBY' 5 | config.exceptions_app = self.routes 6 | 7 | config.cache_store = :redis_cache_store, { 8 | url: "#{Rails.application.credentials.dig(:production, :redis_url) || ENV["REDIS_URL"]}/2", 9 | pool_size: 5, # https://guides.rubyonrails.org/caching_with_rails.html#connection-pool-options 10 | pool_timeout: 3, 11 | connect_timeout: 10, # Defaults to 20 seconds 12 | read_timeout: 1, # Defaults to 1 second 13 | write_timeout: 1, # Defaults to 1 second 14 | reconnect_attempts: 3, # Defaults to 0 15 | reconnect_delay: 0.1, 16 | reconnect_delay_max: 0.2, 17 | error_handler: ->(method:, returning:, exception:) { 18 | Sentry.capture_exception exception, level: "warning", 19 | tags: {method: method, returning: returning} 20 | } 21 | } 22 | 23 | config.kredis.connector = ->(config) { ConnectionPool::Wrapper.new(size: 5, timeout: 3) { Redis.new(config) } } # redis from shared.yml 24 | RUBY 25 | end 26 | -------------------------------------------------------------------------------- /k8s/redis.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: <%= k8s_name %>-redis-svc 5 | spec: 6 | ports: 7 | - port: 6379 8 | targetPort: 6379 9 | selector: 10 | app: <%= k8s_name %>-redis 11 | --- 12 | apiVersion: apps/v1 13 | kind: Deployment 14 | metadata: 15 | name: <%= k8s_name %>-redis 16 | spec: 17 | selector: 18 | matchLabels: 19 | app: <%= k8s_name %>-redis 20 | replicas: 1 21 | strategy: 22 | type: RollingUpdate 23 | rollingUpdate: 24 | maxUnavailable: 0 25 | template: 26 | metadata: 27 | labels: 28 | app: <%= k8s_name %>-redis 29 | spec: 30 | containers: 31 | - name: redis 32 | image: redis:5.0-alpine 33 | ports: 34 | - containerPort: 6379 35 | resources: 36 | requests: 37 | cpu: 100m 38 | memory: 200Mi 39 | limits: 40 | cpu: 300m 41 | memory: 500Mi 42 | volumeMounts: 43 | - mountPath: /data 44 | name: <%= k8s_name %>-redis-data 45 | volumes: 46 | - name: <%= k8s_name %>-redis-data 47 | emptyDir: {} -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | insert_into_file "config/application.rb", before: /^ end/ do 2 | <<-'RUBY' 3 | # Use sidekiq to process Active Jobs (e.g. ActionMailer's deliver_later) 4 | config.active_job.queue_adapter = :sidekiq 5 | config.middleware.use ::I18n::Middleware 6 | config.generators do |g| 7 | g.assets false 8 | g.stylesheets false 9 | end 10 | 11 | config.action_view.field_error_proc = Proc.new do |html_tag, instance| 12 | html = '' 13 | 14 | form_fields = ['textarea', 'input', 'select'] 15 | 16 | elements = Nokogiri::HTML::DocumentFragment.parse(html_tag).css "label, " + form_fields.join(', ') 17 | 18 | elements.each do |e| 19 | if e.node_name.eql? 'label' 20 | e['class'] = %(#{e['class']} invalid_field_label) 21 | html = %(#{e}).html_safe 22 | elsif form_fields.include? e.node_name 23 | if instance.error_message.kind_of?(Array) 24 | html = %(#{e}

#{instance.error_message.uniq.join(', ')}

).html_safe 25 | else 26 | html = %(#{e}

#{instance.error_message}

).html_safe 27 | end 28 | end 29 | end 30 | html 31 | end 32 | RUBY 33 | end 34 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | gsub_file "config/environments/development.rb", "config.cache_store = :memory_store", <<-'RUBY' 2 | config.cache_store = :redis_cache_store, { 3 | url: "redis://localhost:6379/2", 4 | pool_size: 5, # https://guides.rubyonrails.org/caching_with_rails.html#connection-pool-options 5 | pool_timeout: 3, 6 | connect_timeout: 10, # Defaults to 20 seconds 7 | read_timeout: 1, # Defaults to 1 second 8 | write_timeout: 1, # Defaults to 1 second 9 | reconnect_attempts: 3, # Defaults to 0 10 | reconnect_delay: 0.1, 11 | reconnect_delay_max: 0.2, 12 | error_handler: ->(method:, returning:, exception:) { 13 | Sentry.capture_exception exception, level: "warning", 14 | tags: {method: method, returning: returning} 15 | } 16 | } 17 | RUBY 18 | 19 | insert_into_file "config/environments/development.rb", before: /^end/ do 20 | <<-'RUBY' 21 | config.kredis.connector = ->(config) { ConnectionPool::Wrapper.new(size: 5, timeout: 3) { Redis.new(config) } } # redis from shared.yml 22 | 23 | config.action_mailer.default_url_options = {host: "http://localhost:3000"} 24 | config.action_mailer.asset_host = 'http://localhost:3000' 25 | config.action_mailer.delivery_method = :letter_opener 26 | config.action_mailer.perform_deliveries = true 27 | RUBY 28 | end 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rails-template 2 | 3 | ## Description 4 | rails template for kubernetes deployment 5 | 6 | [see generated sample](https://github.com/astrocket/rails-template-stimulus) 7 | 8 | ## Requirements 9 | * Rails 7.x (w/tailwind) 10 | * Ruby 3.x 11 | 12 | ## Installation 13 | 14 | To generate a Rails application using this template, pass the `-m` option to `rails new`, like this: 15 | 16 | ```bash 17 | $ rails new project -T -d postgresql --css tailwind \ 18 | -m https://raw.githubusercontent.com/astrocket/rails-template/master/template.rb 19 | ``` 20 | 21 | ## What's included? 22 | 23 | * Kubernetes & Docker for production deploy 24 | * Tailwind & Stimulus by importmaps 25 | * ActiveJob, Sidekiq, Redis setting for background job 26 | * ActiveAdmin + ArcticAdmin for application admin 27 | * Rspec + FactoryBot setting for test code 28 | 29 | ## Foreman start task 30 | 31 | Procfile based applications 32 | 33 | with `foreman start` 34 | 35 | It runs 36 | 37 | * rails 38 | * tailwind 39 | * sidekiq 40 | 41 | ## Stimulus.js generator 42 | 43 | Stimulus specific generator task. 44 | 45 | with `rails g stimulus posts index` 46 | 47 | It generates 48 | 49 | * `app/javascript/posts/index_controller.js` with sample html markup containing stimulus path helper. 50 | 51 | ## Kubernetes & Docker 52 | 53 | With [kubernetes](https://kubernetes.io/) you can manage multiple containers with simple `yaml` files. 54 | 55 | Template contains 56 | 57 | * deployment [guide](k8s/README.md.tt) for DigitalOcean's cluster from scratch 58 | * Let's Encrypt issuer and Ingress configuration 59 | * demo deployment yaml to instantly run sample hashicorp/http-echo + nginx app 60 | * basic puma, nginx and sidekiq deployment setup 61 | 62 | ## Testing 63 | 64 | [rspec-rails](https://github.com/rspec/rspec-rails) 65 | 66 | [factory-bot](https://github.com/thoughtbot/factory_bot/wiki) 67 | 68 | > Run test specs 69 | ```bash 70 | bundle exec rspec 71 | ``` -------------------------------------------------------------------------------- /app/lib/http_helper.rb: -------------------------------------------------------------------------------- 1 | require "uri" 2 | require "net/http" 3 | require "net/https" 4 | require "json" 5 | require "time" 6 | 7 | module HttpHelper 8 | def self.included(base) 9 | base.extend(Methods) 10 | base.send(:include, Methods) 11 | end 12 | 13 | module Methods 14 | def get_json(url, options = {}) 15 | JSON.parse(get(url, options).body) 16 | end 17 | 18 | def post_json(url, options = {}) 19 | JSON.parse(post(url, options).body) 20 | end 21 | 22 | def delete_json(url, options = {}) 23 | JSON.parse(delete(url, options).body) 24 | end 25 | 26 | def get(url, options = {}) 27 | options = { 28 | headers: false, 29 | timeout: 10 30 | }.merge!(options) 31 | 32 | uri = URI.parse(url) 33 | https = Net::HTTP.new(uri.host, uri.port) 34 | https.read_timeout = options[:timeout] || 10 35 | https.use_ssl = url.include?("https") 36 | req = Net::HTTP::Get.new(uri) 37 | options[:headers].map { |k, v| req[k] = v } if options[:headers] 38 | https.request(req) 39 | end 40 | 41 | def post(url, options = {}) 42 | options = { 43 | body: {}, 44 | headers: { 45 | "Content-Type" => "application/json" 46 | }, 47 | timeout: 10 48 | }.merge!(options) 49 | 50 | uri = URI.parse(url) 51 | https = Net::HTTP.new(uri.host, uri.port) 52 | https.read_timeout = options[:timeout] || 10 53 | https.use_ssl = url.include?("https") 54 | req = Net::HTTP::Post.new(uri.path, initheader = options[:headers]) 55 | req.body = options[:body].to_json 56 | https.request(req) 57 | end 58 | 59 | def delete(url, options = {}) 60 | options = { 61 | body: {}, 62 | headers: { 63 | "Content-Type" => "application/json" 64 | }, 65 | timeout: 10 66 | }.merge!(options) 67 | 68 | uri = URI.parse(url) 69 | https = Net::HTTP.new(uri.host, uri.port) 70 | https.read_timeout = options[:timeout] || 10 71 | https.use_ssl = url.include?("https") 72 | req = Net::HTTP::Delete.new(uri.path, initheader = options[:headers]) 73 | req.body = options[:body].to_json 74 | https.request(req) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/tasks/deploy.rake.tt: -------------------------------------------------------------------------------- 1 | require "io/console" 2 | require "bundler/vendor/thor/lib/thor/shell" 3 | extend Bundler::Thor::Shell 4 | 5 | def terminal_length 6 | require "io/console" 7 | IO.console.winsize[1] 8 | rescue LoadError 9 | Integer(`tput co`) 10 | end 11 | 12 | def full_liner(string) 13 | remaining_length = terminal_length - string.length - 1 14 | "#{string} " + ("-" * remaining_length).to_s 15 | end 16 | 17 | def log_containers(app) 18 | puts set_color("Please type one of your #{app}'s container names", :blue, :bold) 19 | 20 | names = `kubectl get pods -l app=#{app} -o jsonpath='{.items[0].spec.containers[*].name}'`.split(" ") 21 | names_table = {} 22 | names.each_with_index do |name, _i| 23 | names_table[_i] = name 24 | puts set_color("[#{_i}] #{name}", :green) 25 | end 26 | 27 | names_i = STDIN.gets.strip.to_i 28 | sh("kubectl logs -f --tail=5 --selector app=#{app} -c #{names_table[names_i]}") 29 | end 30 | 31 | namespace :deploy do 32 | namespace :demo do 33 | task up: :environment do 34 | sh("kubectl apply -f k8s/demo.yaml") 35 | end 36 | 37 | task down: :environment do 38 | sh("kubectl delete -f k8s/demo.yaml") 39 | end 40 | end 41 | 42 | namespace :production do 43 | task set_master_key: :environment do 44 | sh("kubectl create secret generic <%= k8s_name %>-secrets --from-file=<%= k8s_name %>-master-key=config/master.key") 45 | end 46 | 47 | namespace :ingress do 48 | task up: :environment do 49 | sh("kubectl apply -f k8s/project/<%= k8s_name %>-nginx-conf.yaml") 50 | sh("kubectl apply -f k8s/service.yaml") 51 | sh("kubectl apply -f k8s/ingress.yaml") 52 | end 53 | 54 | task down: :environment do 55 | sh("kubectl delete secret <%= k8s_name %>-secrets") 56 | sh("kubectl delete -f k8s/project/<%= k8s_name %>-nginx-conf.yaml") 57 | sh("kubectl delete -f k8s/service.yaml") 58 | sh("kubectl delete -f k8s/ingress.yaml") 59 | end 60 | end 61 | end 62 | 63 | namespace :logs do 64 | task demo: :environment do 65 | log_containers("<%= k8s_name %>-demo-web") 66 | end 67 | 68 | task web: :environment do 69 | log_containers("<%= k8s_name %>-web") 70 | end 71 | 72 | task sidekiq: :environment do 73 | log_containers("<%= k8s_name %>-sidekiq") 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /app/javascript/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | function getQueryString(params) { 4 | return Object.keys(params) 5 | .map(k => encodeURIComponent(k) + '=' + encodeURIComponent(params[k])) 6 | .join('&'); 7 | } 8 | 9 | const get = (path, params) => { 10 | const qs = '?' + getQueryString(params || {}); 11 | const headers = { 12 | 'Cache-Control': 'no-cache', 13 | 'Pragma': 'no-cache', 14 | 'Expires': '0' 15 | } 16 | 17 | return axios({ 18 | method: 'get', 19 | headers: headers, 20 | url: path + qs 21 | }) 22 | }; 23 | 24 | const post = (path, params, headers = null) => { 25 | headers = headers || { 26 | 'Accept': 'application/json', 27 | 'Content-Type': 'application/json', 28 | 'Cache-Control': 'no-cache', 29 | 'Pragma': 'no-cache', 30 | 'Expires': '0' 31 | }; 32 | headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); 33 | 34 | return axios({ 35 | method: 'post', 36 | url: path, 37 | headers: headers, 38 | data: params 39 | }) 40 | }; 41 | 42 | const put = (path, params, headers = null) => { 43 | headers = headers || { 44 | 'Accept': 'application/json', 45 | 'Content-Type': 'application/json', 46 | 'Cache-Control': 'no-cache', 47 | 'Pragma': 'no-cache', 48 | 'Expires': '0' 49 | }; 50 | headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); 51 | 52 | return axios({ 53 | method: 'put', 54 | url: path, 55 | headers: headers, 56 | data: params 57 | }) 58 | }; 59 | 60 | const patch = (path, params, headers = null) => { 61 | headers = headers || { 62 | 'Accept': 'application/json', 63 | 'Content-Type': 'application/json', 64 | 'Cache-Control': 'no-cache', 65 | 'Pragma': 'no-cache', 66 | 'Expires': '0' 67 | }; 68 | headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); 69 | 70 | return axios({ 71 | method: 'patch', 72 | url: path, 73 | headers: headers, 74 | data: params 75 | }) 76 | }; 77 | 78 | export default { 79 | get: get, 80 | post: post, 81 | put: put, 82 | patch: patch 83 | } 84 | -------------------------------------------------------------------------------- /k8s/sidekiq.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: <%= k8s_name %>-sidekiq 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: <%= k8s_name %>-sidekiq 9 | replicas: 1 10 | strategy: 11 | type: RollingUpdate 12 | rollingUpdate: 13 | maxSurge: 1 14 | maxUnavailable: 0% 15 | template: 16 | metadata: 17 | labels: 18 | app: <%= k8s_name %>-sidekiq 19 | spec: 20 | imagePullSecrets: 21 | - name: digitalocean-access-token 22 | containers: 23 | - name: sidekiq 24 | image: <%= container_registry_path %>:$IMAGE_TAG 25 | imagePullPolicy: Always 26 | command: ["/bin/sh","-c"] 27 | args: ["bundle exec sidekiq -C config/sidekiq.yml"] 28 | env: 29 | - name: RAILS_ENV 30 | value: "production" 31 | - name: RAILS_LOG_TO_STDOUT 32 | value: "true" 33 | - name: REDIS_URL 34 | value: "redis://<%= k8s_name %>-redis-svc:6379" 35 | - name: DEPLOY_VERSION 36 | value: $IMAGE_TAG 37 | - name: RAILS_MASTER_KEY 38 | valueFrom: 39 | secretKeyRef: 40 | name: <%= k8s_name %>-secrets 41 | key: <%= k8s_name %>-master-key 42 | resources: 43 | requests: 44 | cpu: 600m 45 | memory: 800Mi 46 | limits: 47 | cpu: 700m 48 | memory: 900Mi 49 | ports: 50 | - containerPort: 7433 51 | livenessProbe: 52 | httpGet: 53 | path: / 54 | port: 7433 55 | initialDelaySeconds: 15 56 | timeoutSeconds: 5 57 | readinessProbe: 58 | httpGet: 59 | path: / 60 | port: 7433 61 | initialDelaySeconds: 15 62 | periodSeconds: 5 63 | successThreshold: 2 64 | failureThreshold: 2 65 | timeoutSeconds: 5 66 | lifecycle: 67 | preStop: 68 | exec: 69 | command: ["k8s/sidekiq_quiet"] 70 | terminationGracePeriodSeconds: 300 71 | --- 72 | apiVersion: autoscaling/v2beta1 73 | kind: HorizontalPodAutoscaler 74 | metadata: 75 | name: <%= k8s_name %>-sidekiq 76 | spec: 77 | scaleTargetRef: 78 | apiVersion: apps/v1 79 | kind: Deployment 80 | name: <%= k8s_name %>-sidekiq 81 | minReplicas: 1 82 | maxReplicas: 2 83 | metrics: 84 | - type: Resource 85 | resource: 86 | name: cpu 87 | targetAverageUtilization: 60 -------------------------------------------------------------------------------- /k8s/project/application-nginx-conf.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: <%= k8s_name %>-nginx-conf 5 | data: 6 | nginx.conf: | 7 | user nginx; 8 | worker_processes auto; 9 | 10 | error_log /var/log/nginx/error.log warn; 11 | pid /var/run/nginx.pid; 12 | 13 | events { 14 | worker_connections 16384; 15 | } 16 | 17 | http { 18 | include /etc/nginx/mime.types; 19 | default_type application/octet-stream; 20 | 21 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 22 | '$status $body_bytes_sent "$http_referer" ' 23 | '"$http_user_agent" "$http_x_forwarded_for"'; 24 | 25 | access_log /dev/stdout main; 26 | # access_log off; 27 | 28 | sendfile on; 29 | tcp_nopush on; 30 | tcp_nodelay on; 31 | 32 | gzip on; 33 | gzip_static on; 34 | gzip_http_version 1.0; 35 | gzip_comp_level 2; 36 | gzip_min_length 1000; 37 | gzip_proxied any; 38 | gzip_types application/x-javascript text/css text/javascript text/plain text/xml image/x-icon image/png; 39 | gzip_vary on; 40 | gzip_disable "MSIE [1-6].(?!.*SV1)"; 41 | 42 | keepalive_timeout 45; 43 | client_max_body_size 20m; 44 | 45 | upstream app { 46 | server 127.0.0.1:3000 fail_timeout=0; 47 | } 48 | 49 | server { 50 | listen 80; 51 | 52 | root /assets; 53 | 54 | location /nginx_status { 55 | stub_status on; 56 | access_log off; 57 | allow 127.0.0.1; 58 | deny all; 59 | } 60 | 61 | location ~ ^/assets/ { 62 | expires 60d; 63 | add_header Cache-Control public; 64 | add_header ETag ""; 65 | break; 66 | } 67 | 68 | location /admin { 69 | client_max_body_size 100m; 70 | try_files $uri/index.html $uri/index.htm @app; 71 | } 72 | 73 | location / { 74 | try_files $uri/index.html $uri/index.htm @app; 75 | } 76 | 77 | location @app { 78 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 79 | proxy_set_header Host $http_host; 80 | proxy_http_version 1.1; 81 | proxy_redirect off; 82 | 83 | proxy_read_timeout 30; 84 | proxy_send_timeout 30; 85 | 86 | # If you don't find the filename in the static files 87 | # Then request it from the app server 88 | if (!-f $request_filename) { 89 | proxy_pass http://app; 90 | break; 91 | } 92 | } 93 | } 94 | } -------------------------------------------------------------------------------- /app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 | Lorem ipsum dolor 4 |

"Nullam ut vulputate orci. Duis eleifend urna nec."

5 |
6 | <% 3.times do %> 7 |

8 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ut vulputate orci. Duis eleifend urna nec lacus 9 | faucibus ultricies. Aliquam in mattis justo. Nunc sollicitudin blandit venenatis. Nam rutrum, urna at hendrerit 10 | aliquam, nisl nibh pretium lorem, vel fringilla risus dolor id tortor. Vestibulum massa massa, scelerisque vitae 11 | cursus a, ultricies id orci. Pellentesque vel efficitur felis, in aliquam neque. Sed semper sed sem vel fermentum. 12 | Curabitur eget tortor ut nisi hendrerit blandit. Suspendisse semper feugiat neque nec imperdiet. Maecenas 13 | vulputate fringilla lectus ut dictum. Orci varius natoque penatibus et magnis dis parturient montes, nascetur 14 | ridiculus mus. Mauris accumsan tortor a lacus consectetur, et dignissim ante venenatis. Vivamus sit amet aliquam 15 | tortor, vel vehicula turpis. Proin posuere nibh in lorem rutrum, a elementum leo dignissim. Vestibulum massa 16 | ipsum, venenatis id leo nec, luctus efficitur mi. 17 |

18 |

19 | Integer porta pretium turpis ut gravida. Suspendisse efficitur lorem accumsan ante tincidunt maximus et vel quam. 20 | Morbi nec mattis metus. Nullam aliquet enim et ultricies rhoncus. Phasellus eleifend tempor erat at tempor. Donec 21 | dapibus facilisis orci, nec suscipit metus sollicitudin sit amet. In elementum ligula a fermentum accumsan. Aenean 22 | metus diam, accumsan sit amet hendrerit id, rutrum nec diam. Suspendisse ligula turpis, vulputate in malesuada 23 | aliquet, interdum quis ipsum. Mauris hendrerit mauris vel tincidunt volutpat. 24 |

25 |

26 | Nam imperdiet maximus ultricies. Curabitur rhoncus sollicitudin posuere. Sed semper sagittis placerat. Mauris vel 27 | leo mauris. Ut urna justo, pulvinar sed elit non, imperdiet vehicula augue. Aenean et porttitor nisl, ac mollis 28 | tellus. Morbi cursus enim non scelerisque interdum. Sed luctus justo eu dapibus congue. Sed euismod interdum ante 29 | eu blandit. Duis tortor arcu, molestie eget egestas sed, tempus ac urna. 30 |

31 | <% end %> 32 |
33 |
-------------------------------------------------------------------------------- /k8s/web.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: <%= k8s_name %>-web 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: <%= k8s_name %>-web 9 | replicas: 2 10 | strategy: 11 | type: RollingUpdate 12 | rollingUpdate: 13 | maxSurge: 1 14 | maxUnavailable: 0% 15 | template: 16 | metadata: 17 | labels: 18 | app: <%= k8s_name %>-web 19 | spec: 20 | imagePullSecrets: 21 | - name: digitalocean-access-token 22 | containers: 23 | - name: app 24 | image: <%= container_registry_path %>:$IMAGE_TAG 25 | imagePullPolicy: Always 26 | command: ["/bin/sh","-c"] 27 | args: ["bin/rails s -b 0.0.0.0"] 28 | env: 29 | - name: RAILS_ENV 30 | value: "production" 31 | - name: RAILS_LOG_TO_STDOUT 32 | value: "true" 33 | - name: REDIS_URL 34 | value: "redis://<%= k8s_name %>-redis-svc:6379" 35 | - name: RAILS_SERVE_STATIC_FILES 36 | value: "true" 37 | - name: DEPLOY_VERSION 38 | value: $IMAGE_TAG 39 | - name: RAILS_MASTER_KEY 40 | valueFrom: 41 | secretKeyRef: 42 | name: <%= k8s_name %>-secrets 43 | key: <%= k8s_name %>-master-key 44 | volumeMounts: 45 | - mountPath: /assets 46 | name: assets 47 | ports: 48 | - containerPort: 3000 49 | resources: 50 | requests: 51 | cpu: 800m 52 | memory: 1200Mi 53 | limits: 54 | cpu: 1000m 55 | memory: 1400Mi 56 | readinessProbe: 57 | httpGet: 58 | path: /health_check 59 | port: 3000 60 | periodSeconds: 5 61 | successThreshold: 2 62 | failureThreshold: 2 63 | timeoutSeconds: 5 64 | lifecycle: 65 | postStart: 66 | exec: 67 | command: 68 | - sh 69 | - -c 70 | - "cp -r /app/public/* /assets" 71 | - name: nginx 72 | image: nginx:1.17-alpine 73 | ports: 74 | - containerPort: 80 75 | volumeMounts: 76 | - mountPath: /assets 77 | name: assets 78 | readOnly: true 79 | - mountPath: /etc/nginx/nginx.conf 80 | name: nginx-conf 81 | subPath: nginx.conf 82 | readOnly: true 83 | readinessProbe: 84 | httpGet: 85 | path: /health_check 86 | port: 80 87 | periodSeconds: 5 88 | successThreshold: 2 89 | failureThreshold: 2 90 | timeoutSeconds: 5 91 | volumes: 92 | - name: nginx-conf 93 | configMap: 94 | name: <%= k8s_name %>-nginx-conf 95 | items: 96 | - key: nginx.conf 97 | path: nginx.conf 98 | - name: assets 99 | emptyDir: {} 100 | --- 101 | apiVersion: autoscaling/v2beta1 102 | kind: HorizontalPodAutoscaler 103 | metadata: 104 | name: <%= k8s_name %>-web 105 | spec: 106 | scaleTargetRef: 107 | apiVersion: apps/v1 108 | kind: Deployment 109 | name: <%= k8s_name %>-web 110 | minReplicas: 2 111 | maxReplicas: 3 112 | metrics: 113 | - type: Resource 114 | resource: 115 | name: cpu 116 | targetAverageUtilization: 60 -------------------------------------------------------------------------------- /k8s/demo.yaml.tt: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: <%= k8s_name %>-demo-ingress 5 | annotations: 6 | kubernetes.io/ingress.class: "nginx" 7 | cert-manager.io/cluster-issuer: "letsencrypt-prod" 8 | nginx.ingress.kubernetes.io/proxy-body-size: "20m" 9 | spec: 10 | tls: 11 | - hosts: 12 | - <%= app_domain %> 13 | secretName: <%= k8s_name %>-demo-tls 14 | rules: 15 | - host: <%= app_domain %> 16 | http: 17 | paths: 18 | - backend: 19 | serviceName: <%= k8s_name %>-demo-web-svc 20 | servicePort: 80 21 | --- 22 | apiVersion: v1 23 | kind: Service 24 | metadata: 25 | name: <%= k8s_name %>-demo-web-svc 26 | spec: 27 | ports: 28 | - port: 80 29 | targetPort: 80 30 | selector: 31 | app: <%= k8s_name %>-demo-web 32 | --- 33 | apiVersion: v1 34 | kind: ConfigMap 35 | metadata: 36 | name: <%= k8s_name %>-demo-nginx-conf 37 | data: 38 | nginx.conf: | 39 | user nginx; 40 | worker_processes auto; 41 | 42 | error_log /var/log/nginx/error.log warn; 43 | pid /var/run/nginx.pid; 44 | 45 | events { 46 | worker_connections 16384; 47 | } 48 | 49 | http { 50 | include /etc/nginx/mime.types; 51 | default_type application/octet-stream; 52 | 53 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 54 | '$status $body_bytes_sent "$http_referer" ' 55 | '"$http_user_agent" "$http_x_forwarded_for"'; 56 | 57 | access_log /dev/stdout main; 58 | # access_log off; 59 | 60 | sendfile on; 61 | tcp_nopush on; 62 | tcp_nodelay on; 63 | 64 | keepalive_timeout 45; 65 | 66 | gzip on; 67 | gzip_static on; 68 | gzip_http_version 1.0; 69 | gzip_comp_level 2; 70 | gzip_min_length 1000; 71 | gzip_proxied any; 72 | gzip_types application/x-javascript text/css text/javascript text/plain text/xml image/x-icon image/png; 73 | gzip_vary on; 74 | gzip_disable "MSIE [1-6].(?!.*SV1)"; 75 | 76 | client_max_body_size 20m; 77 | 78 | upstream app { 79 | server localhost:3000 fail_timeout=0; 80 | } 81 | 82 | server { 83 | listen 80; 84 | 85 | root /; 86 | 87 | keepalive_timeout 30; 88 | client_max_body_size 20m; 89 | 90 | location / { 91 | try_files $uri/index.html $uri/index.htm @app; 92 | } 93 | 94 | location /nginx_status { 95 | stub_status on; 96 | access_log off; 97 | allow 127.0.0.1; 98 | deny all; 99 | } 100 | 101 | location @app { 102 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 103 | proxy_set_header Host $http_host; 104 | proxy_http_version 1.1; 105 | proxy_redirect off; 106 | 107 | proxy_read_timeout 60; 108 | proxy_send_timeout 60; 109 | 110 | # If you don't find the filename in the static files 111 | # Then request it from the app server 112 | if (!-f $request_filename) { 113 | proxy_pass http://app; 114 | break; 115 | } 116 | } 117 | } 118 | } 119 | --- 120 | apiVersion: apps/v1 121 | kind: Deployment 122 | metadata: 123 | name: <%= k8s_name %>-demo-web 124 | spec: 125 | selector: 126 | matchLabels: 127 | app: <%= k8s_name %>-demo-web 128 | replicas: 2 129 | strategy: 130 | type: RollingUpdate 131 | rollingUpdate: 132 | maxSurge: 1 133 | maxUnavailable: 25% 134 | template: 135 | metadata: 136 | labels: 137 | app: <%= k8s_name %>-demo-web 138 | spec: 139 | containers: 140 | - name: app 141 | image: hashicorp/http-echo 142 | args: 143 | - "-listen=:3000" 144 | - "-text=Hello <%= k8s_name %>" 145 | ports: 146 | - containerPort: 3000 147 | readinessProbe: 148 | httpGet: 149 | path: / 150 | port: 3000 151 | periodSeconds: 5 152 | successThreshold: 2 153 | failureThreshold: 2 154 | timeoutSeconds: 5 155 | - name: nginx 156 | image: nginx:1.17-alpine 157 | ports: 158 | - containerPort: 80 159 | readinessProbe: 160 | httpGet: 161 | path: / 162 | port: 80 163 | periodSeconds: 5 164 | successThreshold: 2 165 | failureThreshold: 2 166 | timeoutSeconds: 5 167 | volumeMounts: 168 | - mountPath: /etc/nginx/nginx.conf 169 | name: nginx-conf 170 | subPath: nginx.conf 171 | readOnly: true 172 | volumes: 173 | - name: nginx-conf 174 | configMap: 175 | name: <%= k8s_name %>-demo-nginx-conf 176 | items: 177 | - key: nginx.conf 178 | path: nginx.conf -------------------------------------------------------------------------------- /.circleci/config.yml.tt: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | ruby: circleci/ruby@1.4.0 5 | doctl: digitalocean/cli@0.1.1 6 | k8s: circleci/kubernetes@0.1.0 7 | docker: circleci/docker@2.0.2 8 | executors: 9 | ruby: 10 | parameters: 11 | rails_env: 12 | type: string 13 | default: test 14 | docker: 15 | - image: circleci/ruby:<%= RUBY_VERSION %>-node 16 | environment: 17 | RAILS_ENV: << parameters.rails_env >> 18 | rspec: 19 | parameters: 20 | rails_env: 21 | type: string 22 | default: test 23 | docker: 24 | - image: circleci/ruby:<%= RUBY_VERSION %>-node 25 | environment: 26 | DATABASE_HOST: 127.0.0.1 27 | DATABASE_PORT: 5432 28 | DATABASE_USER: postgres 29 | REDIS_URL: redis://127.0.0.1:6379 30 | - image: cimg/postgres:13.5 31 | environment: 32 | POSTGRES_USER: postgres 33 | - image: cimg/redis:6.2.6 34 | environment: 35 | RAILS_ENV: << parameters.rails_env >> 36 | 37 | jobs: 38 | rspec-test: 39 | executor: 40 | name: rspec 41 | rails_env: test 42 | steps: 43 | - checkout 44 | - run: 45 | name: Wait for db 46 | command: | 47 | dockerize -wait tcp://localhost:5432 -timeout 1m 48 | dockerize -wait tcp://localhost:6379 -timeout 1m 49 | - restore_cache: 50 | keys: 51 | - bundle-cache-<%= k8s_name %>-{{ checksum "Gemfile.lock" }} 52 | - run: 53 | name: bundle install via cache 54 | command: | 55 | gem install bundler:2.3.26 56 | bundle config build.google-protobuf --with-cflags=-D__va_copy=va_copy 57 | bundle config set path /home/circleci/project/vendor/bundle 58 | bundle config set without development 59 | BUNDLE_FORCE_RUBY_PLATFORM=1 bundle install --jobs 5 --retry 3 60 | - run: sudo apt install -y postgresql-client || true 61 | - run: 62 | name: set test database 63 | command: | 64 | bundle exec rake db:create 65 | bundle exec rake db:migrate 66 | - run: 67 | name: bundle exec rspec 68 | command: | 69 | bundle exec rspec 70 | - save_cache: 71 | key: bundle-cache-<%= k8s_name %>-{{ checksum "Gemfile.lock" }} 72 | paths: 73 | - /home/circleci/project/vendor/bundle 74 | build-image-apply: 75 | executor: 76 | name: ruby 77 | rails_env: production 78 | parameters: 79 | digitalocean-access-token: 80 | type: env_var_name 81 | default: DIGITALOCEAN_ACCESS_TOKEN 82 | description: The access token to connect DigitalOcean. (@circleci config) 83 | cluster: 84 | type: string 85 | default: <%= k8s_cluster_name %> 86 | container-registry-path: 87 | type: string 88 | default: <%= container_registry_path %> 89 | steps: 90 | - setup_remote_docker: 91 | version: 20.10.7 92 | docker_layer_caching: true 93 | - checkout 94 | - k8s/install 95 | - doctl/install 96 | - doctl/initialize: 97 | digitalocean-access-token: <> 98 | - run: | 99 | doctl kubernetes cluster kubeconfig save <> --expiry-seconds=3600 100 | - run: 101 | name: install base 102 | command: | 103 | sudo apt-get update 104 | sudo apt-get install gettext 105 | - restore_cache: 106 | keys: 107 | - bundle-cache-<%= k8s_name %>-{{ checksum "Gemfile.lock" }} 108 | - run: 109 | name: image build 110 | command: | 111 | docker build \ 112 | --build-arg rails_env=$RAILS_ENV \ 113 | --build-arg secret_key_base=dummy \ 114 | --build-arg bundle_dir=/home/circleci/project/vendor/bundle \ 115 | -t <>:$CIRCLE_SHA1 \ 116 | -t <>:latest . 117 | - save_cache: 118 | key: bundle-cache-<%= k8s_name %>-{{ checksum "Gemfile.lock" }} 119 | paths: 120 | - /home/circleci/project/vendor/bundle 121 | - run: 122 | name: image push 123 | command: | 124 | doctl registry login --expiry-seconds=3600 125 | docker push <> 126 | - run: 127 | name: database migration 128 | command: | 129 | IMAGE_TAG=$CIRCLE_SHA1 envsubst < k8s/migration.yaml | kubectl apply -f - 130 | kubectl wait --timeout=20m --for=condition=complete job/<%= k8s_name %>-migration 131 | - run: 132 | name: apply k8s deployments 133 | command: | 134 | IMAGE_TAG=$CIRCLE_SHA1 envsubst < k8s/sidekiq.yaml | kubectl apply -f - 135 | IMAGE_TAG=$CIRCLE_SHA1 envsubst < k8s/web.yaml | kubectl apply -f - 136 | 137 | workflows: 138 | production: 139 | jobs: 140 | - rspec-test: 141 | filters: 142 | tags: 143 | only: /^v[0-9]+.[0-9]+.[0-9]+/ 144 | branches: 145 | ignore: master 146 | - build-image-apply: 147 | filters: 148 | tags: 149 | only: /^v[0-9]+.[0-9]+.[0-9]+/ 150 | branches: 151 | ignore: /.*/ 152 | requires: 153 | - rspec-test -------------------------------------------------------------------------------- /template.rb: -------------------------------------------------------------------------------- 1 | require "fileutils" 2 | require "shellwords" 3 | require "tmpdir" 4 | 5 | RAILS_REQUIREMENT = ">= 6".freeze 6 | 7 | def assert_minimum_rails_version 8 | requirement = Gem::Requirement.new(RAILS_REQUIREMENT) 9 | rails_version = Gem::Version.new(Rails::VERSION::STRING) 10 | return if requirement.satisfied_by?(rails_version) 11 | 12 | prompt = "This template requires Rails #{RAILS_REQUIREMENT}. "\ 13 | "You are using #{rails_version}. Continue anyway?" 14 | exit 1 if no?(prompt) 15 | end 16 | 17 | def add_template_repository_to_source_path 18 | if __FILE__.match?(%r{\Ahttps?://}) 19 | source_paths.unshift(tempdir = Dir.mktmpdir("rails-template-")) 20 | at_exit { FileUtils.remove_entry(tempdir) } 21 | git clone: [ 22 | "--quiet", 23 | "https://github.com/astrocket/rails-template", 24 | tempdir 25 | ].map(&:shellescape).join(" ") 26 | else 27 | source_paths.unshift(File.dirname(__FILE__)) 28 | end 29 | end 30 | 31 | def gemfile_requirement(name) 32 | @original_gemfile ||= IO.read("Gemfile") 33 | req = @original_gemfile[/gem\s+['"]#{name}['"]\s*(,[><~= \t\d.\w'"]*)?.*$/, 1] 34 | req && req.tr("'", %(")).strip.sub(/^,\s*"/, ', "') 35 | end 36 | 37 | def terminal_length 38 | require "io/console" 39 | IO.console.winsize[1] 40 | rescue LoadError 41 | Integer(`tput co`) 42 | end 43 | 44 | def full_liner(string) 45 | remaining_length = terminal_length - string.length - 2 46 | string.to_s + ("-" * remaining_length).to_s 47 | end 48 | 49 | def ask_questions 50 | use_active_admin 51 | app_domain 52 | admin_email 53 | git_repo_url 54 | container_registry_path 55 | use_k8s 56 | if use_k8s 57 | k8s_cluster_name 58 | end 59 | end 60 | 61 | def k8s_name 62 | app_name.downcase.tr("_", "-") 63 | end 64 | 65 | def git_repo_path 66 | git_repo_url[/github\.com(\/|:)(.+).git/, 2] || "username/#{app_name}" 67 | end 68 | 69 | def git_repo_url 70 | @git_repo_url ||= ask_with_default("What is the git remote URL for this project?", :green, "skip") 71 | end 72 | 73 | def app_domain 74 | @app_domain ||= ask_with_default("What is the app domain for this project?", :green, "example.com") 75 | end 76 | 77 | def admin_email 78 | @admin_email ||= ask_with_default("What is the admin's email address? (for SSL Certificate)", :green, "admin@example.com") 79 | end 80 | 81 | def use_active_admin 82 | @use_active_admin ||= ask_with_default("Would you like to use ActiveAdmin as admin?", :green, "yes") 83 | @use_active_admin == "yes" 84 | end 85 | 86 | def use_k8s 87 | @use_k8s ||= ask_with_default("Would you like to use k8s as default deployment stack?", :green, "yes") 88 | @use_k8s == "yes" 89 | end 90 | 91 | def container_registry_path 92 | @container_registry_path ||= ask_with_default("What is your container registry path?", :green, "registry.digitalocean.com/#{git_repo_path}") 93 | end 94 | 95 | def k8s_cluster_name 96 | @k8s_cluster_name ||= ask_with_default("What is digital ocean k8s cluster name?", :green, "example-cluster") 97 | end 98 | 99 | def ask_with_default(question, color, default) 100 | return default unless $stdin.tty? 101 | question = (question.split("?") << " [#{default}]?").join 102 | answer = ask(question, color) 103 | answer.to_s.strip.empty? ? default : answer 104 | end 105 | 106 | def preexisting_git_repo? 107 | @preexisting_git_repo ||= (File.exist?(".git") || :nope) 108 | end 109 | 110 | def git_repo_specified? 111 | git_repo_url != "skip" && !git_repo_url.strip.empty? 112 | end 113 | 114 | def commit_count 115 | @commit_count ||= 1 116 | end 117 | 118 | def git_commit(msg) 119 | git :init unless preexisting_git_repo? 120 | 121 | git add: "-A ." 122 | git commit: "-n -m '#{msg}'" 123 | puts set_color full_liner("🏃 #{msg}"), :green 124 | @commit_count = +1 125 | end 126 | 127 | def apply_and_commit(applying) 128 | apply applying 129 | git_commit("generated #{applying}") 130 | end 131 | 132 | def after_spring_stop 133 | run "bin/spring stop" 134 | yield if block_given? 135 | end 136 | 137 | assert_minimum_rails_version 138 | add_template_repository_to_source_path 139 | ask_questions 140 | 141 | copy_file "gitignore", ".gitignore", force: true 142 | copy_file "dockerignore", ".dockerignore", force: true 143 | copy_file "Procfile", "Procfile", force: true 144 | template "ruby-version.tt", ".ruby-version", force: true 145 | 146 | run("gem install bundler --no-document --conservative") 147 | run("bundle config set --local force_ruby_platform false") 148 | 149 | after_bundle do 150 | run("bundle add rails-i18n image_processing sidekiq connection_pool kredis") 151 | rails_command("kredis:install") 152 | run("bundle add letter_opener --group development") 153 | run("bundle add rspec-rails factory_bot_rails mock_redis database_cleaner-active_record --group test") 154 | run("bundle add sidekiq-cron sidekiq_alive --group production") 155 | run("touch config/schedule.yml") 156 | 157 | apply_and_commit("app/template.rb") 158 | apply_and_commit "lib/template.rb" 159 | rails_command("generate rspec:install") 160 | apply_and_commit "spec/template.rb" 161 | if use_k8s 162 | apply_and_commit("k8s/template.rb") 163 | end 164 | 165 | rails_command("db:create") 166 | rails_command("db:migrate") 167 | 168 | if use_active_admin 169 | run("bundle add activeadmin") 170 | run("bundle add devise devise-i18n sass-rails activeadmin_addons arctic_admin") 171 | run "rails generate devise:install" 172 | rails_command("db:migrate") 173 | run "rails generate active_admin:install AdminUser" 174 | rails_command("db:migrate") 175 | copy_file "app/assets/javascripts/active_admin.js", force: true 176 | copy_file "app/assets/stylesheets/active_admin.scss", force: true 177 | git_commit("active_admin installed") 178 | end 179 | 180 | rails_command("db:seed") 181 | 182 | apply_and_commit "config/template.rb" 183 | template ".circleci/config.yml.tt" 184 | 185 | copy_file "public/robots.txt", force: true 186 | template "README.md.tt", "README.md", force: true 187 | git_commit("project ready") 188 | puts set_color full_liner("Start by running 'cd #{@app_path} && foreman start'"), :green 189 | puts set_color full_liner(""), :green 190 | end 191 | -------------------------------------------------------------------------------- /k8s/README.md.tt: -------------------------------------------------------------------------------- 1 | ## Prepare Kubernetes Cluster 2 | 3 | ### Create New Kubernetes cluster from DigitalOcean 4 | 5 | https://cloud.digitalocean.com/kubernetes 6 | 7 | ### Install Kubectl 8 | 9 | The Kubernetes command-line tool, kubectl, allows you to run commands against Kubernetes clusters. ([link](https://kubernetes.io/docs/tasks/tools/install-kubectl/)) 10 | ```bash 11 | brew install kubectl 12 | # kubectl version 13 | ``` 14 | 15 | ### Connect your cluster 16 | 17 | Use the name of your cluster instead of example-cluster-01 in the following command. 18 | 19 | Generate API Token & Copy it ([link](https://cloud.digitalocean.com/account/api/tokens)) 20 | 21 | Authenticate through doctl command (doctl is a DigitalOcean's own cli tool [link](https://github.com/digitalocean/doctl)) 22 | ```bash 23 | brew install doctl 24 | doctl auth init 25 | # paste API Token 26 | ``` 27 | 28 | Add your cluster to local config (you can get your cluster's name from DO's dashboard) 29 | ```bash 30 | doctl kubernetes cluster kubeconfig save example-cluster-01 31 | # kubectl config current-context 32 | ``` 33 | 34 | ## Cluster set up 35 | 36 | https://www.digitalocean.com/community/tutorials/how-to-set-up-an-nginx-ingress-with-cert-manager-on-digitalocean-kubernetes 37 | 38 | ### Install the DigitalOcean Kubernetes metrics server tool 39 | 40 | https://marketplace.digitalocean.com/apps/kubernetes-monitoring-stack 41 | 42 | ## Deploy 43 | 44 | ### Deploying Demo App 45 | 46 | [read](https://webcloudpower.com/helm-rails-static-files-path/) 47 | 48 | Create Demo Ingress, Service, Pod. (to test configuration) 49 | 50 | [k8s/demo.yaml](k8s/demo.yaml) 51 | 52 | ```bash 53 | kubectl apply -f k8s/demo.yaml 54 | ``` 55 | 56 | Logging deployed demo app 57 | 58 | ```bash 59 | rails deploy:logs:demo 60 | ``` 61 | 62 | Check Let's Encrypt progress 63 | 64 | ```bash 65 | kubectl describe certificate <%= k8s_name %>-demo-tls 66 | 67 | # Below is an example success message 68 | Events: 69 | Type Reason Age From Message 70 | ---- ------ ---- ---- ------- 71 | Normal GeneratedKey 5m cert-manager Generated a new private key 72 | Normal Requested 5m cert-manager Created new CertificateRequest resource "<%= k8s_name %>-demo-tls-1514794236" 73 | Normal Issued 5m cert-manager Certificate issued successfully 74 | ``` 75 | 76 | Problem with SSL? => debug cert-manager [read](https://cert-manager.io/docs/faq/acme/). 77 | 78 | To delete all demo resources 79 | 80 | ```bash 81 | kubectl delete -f k8s/demo.yaml 82 | ``` 83 | 84 | ### Deploying Production App 85 | 86 | It's recommended to manage resources seperately to prevent downtime while updating your app. 87 | 88 | * `k8s/project/<%= k8s_name %>-nginx-conf.yaml` creates application level nginx's(* is different from ingress-nginx*) config-map, where we serve static files, redirect app traffic to puma. (It will be used when deploying k8s/app.yaml) 89 | 90 | ```bash 91 | kubectl create secret generic <%= k8s_name %>-secrets --from-file=<%= k8s_name %>-master-key=config/master.key # push master.key to k8s secret 92 | kubectl apply -f k8s/ingress.yaml # from load-balancer to web service 93 | kubectl apply -f k8s/service.yaml # web service for web deployment 94 | kubectl apply -f k8s/project/<%= k8s_name %>-nginx-conf.yaml # for web deployment's nginx 95 | kubectl apply -f k8s/redis.yaml # redis 96 | kubectl apply -f k8s/web.yaml # puma & nginx 97 | kubectl apply -f k8s/sidekiq.yaml # sidekiq 98 | ... etc 99 | ``` 100 | 101 | ## Extra 102 | 103 | ### Monitoring Commands 104 | 105 | #### Pod 106 | 107 | ```bash 108 | kubectl get pods 109 | # NAME READY STATUS RESTARTS AGE 110 | # <%= k8s_name %>-demo-web-f45fbdf9-7wsqn 2/2 Running 0 18s 111 | # <%= k8s_name %>-demo-web-f45fbdf9-fsxz8 2/2 Running 0 18s 112 | # <%= k8s_name %>-demo-web-f45fbdf9-qhms6 2/2 Running 0 18s 113 | # <%= k8s_name %>-demo-web-f45fbdf9-vqfmj 2/2 Running 0 18s 114 | 115 | # for single pod (tail 5 lines) 116 | kubectl logs -f --tail=5 <%= k8s_name %>-demo-web-f45fbdf9-7wsqn -c app 117 | kubectl logs -f --tail=5 <%= k8s_name %>-demo-web-f45fbdf9-7wsqn -c nginx 118 | 119 | # for all pods 120 | kubectl logs -f --tail=5 --selector app=<%= k8s_name %>-demo-web -c app 121 | kubectl logs -f --tail=5 --selector app=<%= k8s_name %>-demo-web -c nginx 122 | ``` 123 | 124 | #### Container 125 | 126 | log containers using [lib/tasks/deploy.rake](lib/tasks/deploy.rake) 127 | 128 | ```bash 129 | rails deploy:logs:web 130 | rails deploy:logs:sidekiq 131 | ``` 132 | 133 | ### Rails Tasks 134 | 135 | Execute multiple kubectl commands with rails task. 136 | 137 | ```bash 138 | # apply demo service, ingress, config, deployments 139 | rails deploy:demo:up 140 | rails deploy:demo:down # rollback 141 | 142 | # push master.key to kubernetes secret 143 | rails deploy:production:set_master_key 144 | 145 | # apply production application nginx config 146 | # apply production ingress 147 | rails deploy:production:ingress:up 148 | rails deploy:production:ingress:down # rollback and delete master_key 149 | 150 | # migrate database with production image 151 | rails deploy:production:migrate 152 | 153 | # apply production deployments 154 | rails deploy:production:all 155 | ``` 156 | 157 | ### Managing Secrets 158 | 159 | [How to Read Kubernetes Secrets](https://howchoo.com/g/ywvlmgnmode/read-kubernetes-secrets) 160 | 161 | push master.key to cluster secrets from local file 162 | 163 | ```bash 164 | kubectl create secret generic <%= k8s_name %>-secrets --from-file=<%= k8s_name %>-master-key=config/master.key 165 | ``` 166 | 167 | reference pushed key from pod 168 | 169 | ```yaml 170 | ... 171 | env: 172 | - name: RAILS_MASTER_KEY 173 | valueFrom: 174 | secretKeyRef: 175 | name: <%= k8s_name %>-secrets 176 | key: <%= k8s_name %>-master-key 177 | ... 178 | ``` 179 | 180 | read decoded key 181 | 182 | ```yaml 183 | kubectl get secret <%= k8s_name %>-secrets -o jsonpath="{.data.<%= k8s_name %>-master-key}" | base64 --decode 184 | ``` 185 | 186 | delete secrets 187 | 188 | ```bash 189 | kb delete secret <%= k8s_name %>-secrets 190 | ``` 191 | 192 | ### Tuning 193 | 194 | #### Puma 195 | 196 | `process * thread * pod replicas < db connection` 197 | 198 | [heroku blog](https://devcenter.heroku.com/articles/deploying-rails-applications-with-the-puma-web-server#process-count-value) 199 | 200 | ### Application Nginx 201 | 202 | [read](https://www.digitalocean.com/community/tutorials/how-to-optimize-nginx-configuration) 203 | 204 | You can customize application nginx through [config-map](k8s/project/<%= k8s_name %>-nginx-conf.yaml) as usual. 205 | 206 | #### Ingress Nginx 207 | 208 | To make your service scalable, you should consider tuning your [ingress-nginx](https://kubernetes.github.io/ingress-nginx) as your needs. 209 | 210 | As you can read out from the guide linked above, you have 3 ways to tune ingress-nginx. 211 | 212 | **ConfigMap** : Global options for every ingress (like worker_process, worker_connection, proxy-body-size ..) 213 | 214 | **Annotation** : Per ingress options (like ssl, proxy-body-size..) 215 | 216 | **Custom Template** : Using file 217 | 218 | If you configure the same option using Annotation and ConfigMap, Annotation will override ConfigMap. (ex. proxy-body-size) 219 | 220 | https://github.com/nginxinc/kubernetes-ingress/tree/master/examples 221 | 222 | > example of scalable websocket server architecture 223 | [read](https://github.com/nginxinc/kubernetes-ingress/tree/master/examples/websocket) 224 | ```text 225 | ws.example.com -> 226 | LoadBalancer -> 227 | Websocket supportive Nginx Ingress with SSL (with enough worker_connections) -> 228 | Anycable Go server -> 229 | Anycable RPC rails server 230 | ``` 231 | 232 | #### Auto Scaling 233 | 234 | [read](https://www.digitalocean.com/docs/kubernetes/resources/autoscaling-with-hpa-ca/) 235 | 236 | 1. Cluster Autoscaling (from DigitalOcean's dashboard) 237 | 2. Horizontal Pod Autoscaling (from HorizontalPodAutoscaler resource) 238 | --------------------------------------------------------------------------------