一、OpenFaaS社区版源码修改使函数副本自动缩减到零

一、说明

OpenFaaS比其它Serverless框架简单,至少社区版组件少,可以拿来学习和自定义修改,但是有个问题就是它的社区版不支持函数数量自动缩减到0(Pro版可以但是得加钱,没办法)。为了方便之后的实验,只好深入源码看看能不能修改了,还好是可行的!

这里使用的是K8s部署的OpenFaaS,一切操作都基于K8s。

OpenFaaS的扩缩容主要是通过Prometheus采集数据指标,在里面配置告警规则,然后通过Alertmanager接收和处理这些告警信息,将对应的告警发送给OpenFaaS的Gateway进行处理。所以主要就是改Prometheus的配置文件以及Gateway的一点源码即可。

二、源码下载和部署

Prometheus和Alertmanager的配置文件在faas-nets的源码里。Gateway源码在faas源码里。所以需要下载两个源码文件夹。
https://github.com/openfaas/faas.git
https://github.com/openfaas/faas-netes.git

1、在K8s上部署OpenFaaS

使用faas-nets源码来部署OpenFaaS在K8s上,在faas-net源码下执行以下命令就完事了

kubectl apply -f namespaces.yml

# 这里创建的是OpenFaaS网页的账号密码,可以改成自己的
kubectl -n openfaas create secret generic basic-auth \
--from-literal=basic-auth-user=admin \
--from-literal=basic-auth-password=admin

# 所有组件的配置文件都在里面,包括Prometheus的,后续在这个文件夹修改规则。
kubectl apply -f ./yaml/

# 监听部署情况
kubectl get pods -n openfaas -w

网络不好的话,配置文件里的镜像可以改成自己的镜像,这样容器镜像下载就快多了。我里面的镜像全自己构建了一遍推送到了自己的私有镜像仓库。

部署成功后就是下面的样子(gateway:v0.23.2,faas-netes:v0.15.2)

# kubectl get pods -n openfaas 
NAME                                 READY   STATUS    RESTARTS   AGE
alertmanager-d498888f9-zt5h5         1/1     Running   0          22m
basic-auth-plugin-5b5587fcf4-nwxtf   1/1     Running   0          22m
gateway-7b469b749f-hqbxh             2/2     Running   0          22m
nats-647b476664-dhbbp                1/1     Running   0          22m
prometheus-dd4764755-kqmqt           1/1     Running   0          22m
queue-worker-84bfb5cd9b-7krwp        1/1     Running   0          22m

# kubectl get service -n openfaas
NAME                TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
alertmanager        ClusterIP   10.96.11.136     <none>        9093/TCP         23m
basic-auth-plugin   ClusterIP   10.109.200.69    <none>        8080/TCP         23m
gateway             ClusterIP   10.107.24.119    <none>        8080/TCP         23m
gateway-external    NodePort    10.103.253.100   <none>        8080:31112/TCP   23m
gateway-provider    ClusterIP   10.109.147.139   <none>        8081/TCP         23m
nats                ClusterIP   10.101.229.232   <none>        4222/TCP         23m
prometheus          ClusterIP   10.99.43.35      <none>        9090/TCP         23m

其中gateway-external暴露了31112端口,可以在网页通过服务器地址访问OpenFaaS界面,可以简单的部署官方函数和执行。

2023.5.16更新本文章。 新版本部署有区别。(gateway:v0.26.3,faas-netes:v0.16.7)

# kubectl get pods -n openfaas
NAME                            READY   STATUS    RESTARTS      AGE
nats-7c5dc767cd-nk48m           1/1     Running   0             89s
queue-worker-66b8b77ddf-rkcqh   1/1     Running   1 (83s ago)   89s
alertmanager-79c5c74bd7-56fgc   1/1     Running   0             90s
gateway-58cd467ccd-wxzks        2/2     Running   1 (81s ago)   90s
prometheus-55fdccc666-n5ltc     1/1     Running   0             89s

# kubectl get service -n openfaas
NAME                TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
alertmanager        ClusterIP   10.43.62.43     <none>        9093/TCP         2m33s
basic-auth-plugin   ClusterIP   10.43.106.199   <none>        8080/TCP         2m33s
gateway-external    NodePort    10.43.240.154   <none>        8080:31112/TCP   2m33s
gateway             ClusterIP   10.43.206.129   <none>        8080/TCP         2m32s
nats                ClusterIP   10.43.235.72    <none>        4222/TCP         2m32s
prometheus          ClusterIP   10.43.135.90    <none>        9090/TCP         2m32s

2024.5.30我又更新了本文章。 部署的东西没区别了,但有新的修改。(gateway:v0.27.6,faas-netes:v0.18.5)

2、下载命令行工具和部署函数

需要下载faas-cli命令行工具来操作OpenFaaS,执行以下命令即可:
curl -sL https://cli.openfaas.com | sudo sh

将OPENFAAS_URL写入环境变量中,执行如下命令:
echo export OPENFAAS_URL=127.0.0.1:31112 >> ~/.bashrc

配置立即生效,执行:
source ~/.bashrc

用账号密码登录,执行:
faas-cli login -u admin -p admin

简单部署一个官方函数,自定义函数的话可以去看官网文档。
faas-cli store deploy nodeinfo

# faas-cli store deploy nodeinfo
Deployed. 202 Accepted.
URL: http://127.0.0.1:31112/function/nodeinfo

# curl http://127.0.0.1:31112/function/nodeinfo
Hostname: nodeinfo-65dc4b757d-22rqf

Arch: x64
CPUs: 24
Total mem: 63597MB
Platform: linux
Uptime: 5259757.12

部署后它就一直在那,即使很久没调用也不会自己消失

# kubectl get pod -n openfaas-fn                                                      
NAME                        READY   STATUS    RESTARTS   AGE
nodeinfo-65dc4b757d-22rqf   1/1     Running   0          7m39s

三、改源码

1、prometheus-cfg.yml修改

主要修改里面的alert.rules.yml这个配置,这里我加了APINoInvocation这个告警。规则大概就是当函数副本数大于0,且新建的函数没调用过的话2分钟被灭,调用过一次的函数,如果5分钟内没有再调也得灭。

alert.rules.yml: |
  groups:
    - name: openfaas
      rules:
      - alert: service_down
        expr: up == 0
      - alert: APIHighInvocationRate
        expr: sum(rate(gateway_function_invocation_total{code="200"}[10s])) BY (function_name) > 3
        for: 5s
        labels:
          service: gateway
          severity: major
        annotations:
          description: High invocation total on "{{$labels.function_name}}"
          summary: High invocation total on "{{$labels.function_name}}"
      - alert: APINoInvocation # 告警规则名称
        expr: (sum(gateway_service_count) BY (function_name) > 0) and (sum(rate(gateway_function_invocation_total[3m])) BY (function_name) == 0) # promQL语句来匹配规则,当为true时触发
        for: 2m  # 当上面的语句触发后,2分钟内没有恢复,则告警进入pending状态,2分钟后触发报警
        labels:
          service: gateway
          severity: major
        annotations:
          description: No invocation on "{{$labels.function_name}}"
          summary: No invocation on "{{$labels.function_name}}"

gateway_function_invocation_total是函数调用计数,可以计算每个函数的调用次数。只有被调用过的函数才会有这个属性。新建的不调用默认就没有,它就不会统计到。
gateway_service_count是函数副本数,可以查询到新建的函数,如果一个函数实例都没有就是0。
function_name是每个函数名。这些是在Gateway源码里定义的。
这部分解释可以在faas\gateway\metrics\metrics.go代码里找到

告警设置

APIHighInvocationRate是一个默认的扩容告警,即10s内函数被多次的调用成功次数的增长率总和超过一定数就告警通知Gateway扩容。
prometheus设置的evaluation_interval是告警规则计算间隔时间,里面配置的是每15秒计算一次。

APINoInvocation是我设置的告警规则,主要通过gateway_function_invocation_totalgateway_service_count来设置表达式。这里设置的是当函数副本数大于0,并且3分钟内没有调用过就告警让Gateway把它灭掉。但是有个问题就是刚创建的函数没被调用过,因此gateway_function_invocation_total没值,就不会触发这个规则。这里我的方法是修改Gateway的代码,让它创建函数的时候就设置上这个值。

2、Gateway源码修改(gateway和faas-netes)

首先是修改faas\gateway\scaling\ranges.go默认最小副本为0,它源码里是1。

// DefaultMinReplicas is the minimal amount of replicas for a service.
DefaultMinReplicas = 0

然后在faas\gateway\handlers\alerthandler.go里面,这个代码主要是接收Alertmanager的告警消息,scaleService函数就是具体的扩缩容操作了。观察CalculateReplicas函数发现只要status = "resolved"就能把副本直接设置为最小实例数,那就直接在它上面加个判断条件就行。这个resolved状态是Alertmanager发送的告警是否解决的标志,源码里通过判断告警是否解决来决定是否增加还是灭掉多余的函数容器,所以这样设置就是假定告警解决了,让它直接恢复最低函数数量。修改的代码如下:

func scaleService(alert requests.PrometheusInnerAlert, service scaling.ServiceQuery, defaultNamespace string) error {
	var err error

	serviceName, namespace := middleware.GetNamespace(defaultNamespace, alert.Labels.FunctionName)

	if len(serviceName) > 0 {
		// 获取现在的副本数
		queryResponse, getErr := service.GetReplicas(serviceName, namespace)
		if getErr == nil {
			status := alert.Status

			// 添加这个判断,如果告警名字是APINoInvocation,就直接给我缩到0
			if alert.Labels.AlertName == "APINoInvocation" {
				status = "resolved"
			}

			// 计算新的副本数
			newReplicas := CalculateReplicas(status, queryResponse.Replicas, uint64(queryResponse.MaxReplicas), queryResponse.MinReplicas, queryResponse.ScalingFactor)

			log.Printf("[Scale] function=%s %d => %d.\n", serviceName, queryResponse.Replicas, newReplicas)
			if newReplicas == queryResponse.Replicas {
				return nil
			}

			updateErr := service.SetReplicas(serviceName, namespace, newReplicas)
			if updateErr != nil {
				err = updateErr
			}
		}
	}
	return err
}

前面说过如果新建函数如果不调用的话,gateway_function_invocation_total就没有值,所以最后还得在faas\gateway\metrics\exporter.go文件修改。Collect函数就是给Prometheus收集数据,这里面的ServiceReplicasGauge是计算的每个函数副本数,它所在的循环会遍历所有存在的函数,所以新建的函数也能统计到。Prometheus提供了一个通过值获取指标的函数,即用GetMetricWithLabelValues获取一下每个函数的指标,如果不存在就会自己新建一个初始的,这样给GatewayFunctionInvocation获取一下,就能把新建的函数的调用数给设置为0。这些指标可以在faas\gateway\metrics\metrics.go找到。修改的代码如下:

// Collect collects data to be consumed by prometheus
func (e *Exporter) Collect(ch chan<- prometheus.Metric) {
	e.metricOptions.ServiceReplicasGauge.Collect(ch)
	e.metricOptions.GatewayFunctionInvocation.Collect(ch)
	e.metricOptions.GatewayFunctionsHistogram.Collect(ch)
	e.metricOptions.GatewayFunctionInvocationStarted.Collect(ch)
	e.metricOptions.ServiceReplicasGauge.Reset()

	for _, service := range e.services {
		var serviceName string
		if len(service.Namespace) > 0 {
			serviceName = fmt.Sprintf("%s.%s", service.Name, service.Namespace)
		} else {
			serviceName = service.Name
		}

		// Set current replica count
		e.metricOptions.ServiceReplicasGauge.
			WithLabelValues(serviceName).
			Set(float64(service.Replicas))

		// 添加这一句,获取一下函数调用数,如果没有,会自动设置为0
		e.metricOptions.GatewayFunctionInvocation.GetMetricWithLabelValues(serviceName, "200")
	}

	e.metricOptions.ServiceMetrics.Counter.Collect(ch)
	e.metricOptions.ServiceMetrics.Histogram.Collect(ch)
}

2023.5.16更新本文章。 新版还得改faas-netes(v0.16.7)的一点代码。
首先是修改faas-netes\pkg\handlers\replica_updater.go,注释掉缩为0的错误。

func MakeReplicaUpdater(defaultNamespace string, clientset *kubernetes.Clientset) http.HandlerFunc {
	// ...

	// 注释或者删除即可
	// if req.Replicas == 0 {
	// 	http.Error(w, "replicas cannot be set to 0 in OpenFaaS CE",
	// 		http.StatusBadRequest)
	// 	return
	// }
	
	// ...
}

然后是修改faas-netes\pkg\handlers\replica_reader.go,它原本设置的最大实例数为10,可以改大点。

const MaxReplicas = 50

最后修改faas-netes\pkg\controller\informers.go,它逻辑是当实例数为0时自动改成1,必须得改为0。

func applyValidation(deployment *appsv1.Deployment, kubeClient *kubernetes.Clientset) error {
	if deployment.Spec.Replicas == nil {
		return nil
	}

	if _, ok := deployment.Spec.Template.Labels["faas_function"]; !ok {
		return nil
	}

	current := *deployment.Spec.Replicas
	var target int
	if current == 0 {
		// 改为0
		target = 0
	} else if current > handlers.MaxReplicas {
		target = handlers.MaxReplicas
	} else {
		return nil
	}
	
	// ...
}

2024.5.30更新本文章。
这一版gateway(v0.27.6) 也限制了函数最大实例数量。
依旧是修改faas\gateway\scaling\ranges.go,将实例数量改大点。

// DefaultMaxReplicas is the amount of replicas a service will auto-scale up to.
const (
	// ...
	DefaultMaxReplicas = 50
	// ...
)

func MakeHorizontalScalingHandler(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// ...
		// 这里改为0
		if scaleRequest.Replicas < 1 {
			scaleRequest.Replicas = 0
		}

		if scaleRequest.Replicas > DefaultMaxReplicas {
			scaleRequest.Replicas = DefaultMaxReplicas
		}
		// ...
	}
}

这一版faas-netes(v0.18.5)又多了限制,最大只能部署15个函数,还有60天商用限制,继续改源码:
修改faas-netes\pkg\handlers\replica_reader.go,将函数数量改大点,也可以找到使用该参数的地方注释掉,这样就无限制。

const MaxFunctions = 100

修改faas-netes\main.go,这里有个上网检测,如果机器不能上网就注释掉。

if err := config.ConnectivityCheck(); err != nil {
	log.Fatalf("Error checking connectivity, OpenFaaS CE cannot be run in an offline environment: %s", err.Error())
}

四、构建镜像和重新部署

构建镜像

首先可以自己改一下GatewayDockerfile文件,我主要删了5处,如下:

# 删,用不着license-check
# FROM --platform=${BUILDPLATFORM:-linux/amd64} ghcr.io/openfaas/license-check:0.4.0 as license-check

FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.22-alpine as build

ENV GO111MODULE=on
ENV CGO_ENABLED=0

ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETOS
ARG TARGETARCH

ARG GIT_COMMIT
ARG VERSION

# 删,用不着license-check
# COPY --from=license-check /license-check /usr/bin/

WORKDIR /gateway

COPY vendor         vendor
COPY go.mod         go.mod
COPY go.sum         go.sum

COPY handlers       handlers
COPY metrics        metrics
COPY requests       requests

COPY types          types
COPY plugin         plugin
COPY version        version
COPY scaling        scaling
COPY probing        probing
COPY pkg            pkg
COPY main.go        .

# 删,用不着license-check
# RUN license-check -path ./ --verbose=false "Alex Ellis" "OpenFaaS Authors" "OpenFaaS Author(s)"

# 删,测试浪费时间
# Run a gofmt and exclude all vendored code.
# RUN test -z "$(gofmt -l $(find . -type f -name '*.go' -not -path "./vendor/*"))"
# RUN go test $(go list ./... | grep -v integration | grep -v /vendor/ | grep -v /template/) -cover

RUN CGO_ENABLED=${CGO_ENABLED} GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build --ldflags "-s -w \
    -X \"github.com/openfaas/faas/gateway/version.GitCommitSHA=${GIT_COMMIT}\" \
    -X \"github.com/openfaas/faas/gateway/version.Version=${VERSION}\" \
    -X github.com/openfaas/faas/gateway/types.Arch=${TARGETARCH}" \
    -a -installsuffix cgo -o gateway .

FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.19.1 as ship

# 删
# LABEL org.label-schema.license="Non-commercial use only" \
#       org.label-schema.vcs-url="https://github.com/openfaas/faas-netes" \
#       org.label-schema.vcs-type="Git" \
#       org.label-schema.name="openfaas/faas-netes" \
#       org.label-schema.vendor="openfaas" \
#       org.label-schema.docker.schema-version="1.0"

# 加一个这个,快一点
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk update

RUN addgroup -S app \
    && adduser -S -g app app \
    && apk add --no-cache ca-certificates

WORKDIR /home/app

EXPOSE 8080
EXPOSE 8082
ENV http_proxy      ""
ENV https_proxy     ""

# 注意本地代码的路径
COPY --from=build /gateway .
COPY assets     assets

ARG TARGETPLATFORM

RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ] ; then sed -ie s/x86_64/armhf/g assets/script/funcstore.js ; elif [ "$TARGETPLATFORM" = "linux/arm64" ] ; then sed -ie s/x86_64/arm64/g assets/script/funcstore.js; fi

RUN chown -R app:app ./

USER app

CMD ["./gateway"]

然后是faas-netsDockerfile文件,如下:

FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:1.22-alpine as build

ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETOS
ARG TARGETARCH

ARG VERSION
ARG GIT_COMMIT

ENV CGO_ENABLED=0
ENV GO111MODULE=on
ENV GOFLAGS=-mod=vendor

WORKDIR /faas-netes

COPY . .

RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \
        --ldflags "-s -w \
        -X github.com/openfaas/faas-netes/version.GitCommit=${GIT_COMMIT}\
        -X github.com/openfaas/faas-netes/version.Version=${VERSION}" \
        -o faas-netes .

FROM --platform=${TARGETPLATFORM:-linux/amd64} alpine:3.19.1 as ship

# 加一个这个,快一点
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk update

RUN apk --no-cache add \
    ca-certificates

RUN addgroup -S app \
    && adduser -S -g app app

WORKDIR /home/app

EXPOSE 8080

ENV http_proxy      ""
ENV https_proxy     ""

COPY --from=build /faas-netes    .

RUN chown -R app:app ./

USER app

CMD ["./faas-netes"]

构建镜像和推送到自己仓库

# 这里我用了自己的私有镜像仓库,方便下载和囤镜像,别忘了后面的点(.)
docker build -t 192.168.1.51:5000/gateway:v0.27.6.1 .

# 推到自己仓库
docker push 192.168.1.51:5000/gateway:v0.18.5.1

或者写个Makefile

SERVER?=192.168.1.51:5000
G_IMG_NAME?=gateway
F_IMG_NAME?=faas-netes
G_TAG?=v0.27.6.1
F_TAG?=v0.18.5.1

.PHONY: g
g:
	docker build -t $(SERVER)/$(G_IMG_NAME):$(G_TAG) .
	docker push $(SERVER)/$(G_IMG_NAME):$(G_TAG)

.PHONY: f
f:
	docker build -t $(SERVER)/$(F_IMG_NAME):$(F_TAG) .
	docker push $(SERVER)/$(F_IMG_NAME):$(F_TAG)

然后运行make g 或 make g G_TAG=v1.1

重新部署

# 修改prometheus-cfg.yml后,重新apply配置以及重启pod
kubectl apply -f prometheus-cfg.yml
kubectl replace --force -f prometheus-dep.yml

# 修改gateway-dep.yml对应的镜像,直接重新apply配置
kubectl apply -f gateway-dep.yml

五、验证

新建的函数

下面整合了faas-netesgateway的日志,可以看到nodeinfo函数创建和被灭掉大约花了2分15秒。

因为刚创建函数调用数为0,会直接触发APINoInvocation这个规则,但是需要等2分钟才激活告警。而Prometheus配置的告警规则计算时间是15秒,所以刚创建的话,函数被灭的时间大致就在2分15秒内。

2022/09/24 16:18:58 Deployment created: nodeinfo.openfaas-fn
2022/09/24 16:18:58 Service created: nodeinfo.openfaas-fn
...
2022/09/24 16:21:13 [Scale] function=nodeinfo 1 => 0.
2022/09/24 16:21:13 SetReplicas [nodeinfo.openfaas-fn] took: 0.0144s

调用一次函数

下面整合了faas-netesgateway的日志,可以看到nodeinfo函数经过调用一次重启后和被灭掉大约花了5分9秒。

因为APINoInvocation配置的告警规则是3分钟内没调用过,所以调用后至少得等3分钟再触发告警,然后加上激活等待时间2分钟,以及Prometheus每一次的规则计算时间15秒,时间在5分15秒内,差不多符合。

# curl  http://127.0.0.1:31112/function/nodeinfo
2022/09/24 17:36:26 [Scale 0/20] function=nodeinfo 0 => 1 requested
2022/09/24 17:36:26 SetReplicas [nodeinfo.openfaas-fn] took: 0.0201s
2022/09/24 17:36:34 [Ready] function=nodeinfo waited for - 8.0282s
2022/09/24 17:36:34 Forwarded [GET] to /function/nodeinfo - [200] - 0.0284s
2022/09/24 17:36:34 nodeinfo took 0.027710 seconds
...
2022/09/24 17:41:43 [Scale] function=nodeinfo 1 => 0.
2022/09/24 17:41:43 SetReplicas [nodeinfo.openfaas-fn] took: 0.0135s
  • 27
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 8
    评论
### 回答1: OpenProject是一款开源的项目管理软件,它是一个基于Web的应用程序,旨在帮助团队协作和协调他们的项目。 OpenProject社区源码可以从项目的官方网站上获得。通过下载和安装源代码,您可以自己部署和定制OpenProject。 社区本的源码包含了OpenProject的核心功能和基本模块。这些模块包括但不限于项目创建、任务分配、进度追踪、文档管理、讨论论坛、时间追踪和报告等。 通过使用OpenProject的源码,您可以根据自己的需求进行定制或扩展。您可以修改和添加新的功能、调整用户界面、集成其他软件或服务,并将其与组织的现有工具集成。 为了使用OpenProject社区源码,请确保您的系统满足软件的最低要求,并安装必要的依赖项。下载源码后,您可以遵循官方文档中的指南进行安装和配置。在安装过程中,您可能需要一些技术知识和经验,以确保正确的部署和配置。 部署成功后,您可以根据需要创建项目团队和项目。通过OpenProject的用户界面和功能,您可以轻松地分配任务、跟踪进度、管理文档、与团队成员进行讨论,并生成各种报告和统计数据。 总之,OpenProject社区源码为您提供了自主部署和定制OpenProject的机会,使您能够根据自己的项目管理需求进行定制,并与团队成员协同工作。 ### 回答2: OpenProject是一个开源的项目管理软件。它允许团队在一个中央位置协作和管理项目。OpenProject社区本的源码是公开提供的,任何人都可以获取和使用它。 OpenProject社区源码是用Ruby on Rails编写的。它使用一系列开源技术和库,如PostgreSQL数据库、Passenger服务器和Bootstrap前端框架。这使得OpenProject社区本具有了稳定性、可扩展性和易于维护的特性。 使用OpenProject社区本的源码,可以自定义和扩展软件的功能。你可以根据自己的需要添加新的模块、功能和插件。你还可以修改现有功能和界面,以符合自己的品牌和设计标准。 由于OpenProject社区本是开源的,你可以与全球的开发者社区一起合作,共享和交流最佳实践。你可以从其他人的经验中学习,甚至向他们寻求帮助和建议。 并且,使用OpenProject社区源码可以帮助你节约软件开发成本。你不需要购买昂贵的授权,只需花费一些时间和资源来定制你的OpenProject实例。 总而言之,OpenProject社区源码是一个强大的工具,可以帮助你构建一个高效的项目管理系统。无论你是一个个人用户还是一个企业,都可以从中受益,并根据自己的需求进行定制。
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

唉真难起名字

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值